Files
feuerwerk/src/core/music/pattern.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

184 lines
6.7 KiB
TypeScript

/**
* A pattern (#333): notes for one instrument, edited in a piano roll and played by the scheduler
* (#332). The notes are counted in ticks; the meter only says where the bars of the ruler are, so a
* pattern changed from 4/4 to 6/8 keeps every note where it was.
*/
import { newId } from '../ids';
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 {
id: string;
/** Where it starts and how long it is held, in ticks. */
tick: number;
length: number;
/** As MIDI counts it: 60 is C4. */
note: number;
/** 0 to 1. */
velocity: number;
}
export const PATTERN_INSTRUMENT_KEY = 'instrument';
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;
export const DEFAULT_GRID = '1/16';
/** What a new note is struck with until another velocity has been set. */
export const DEFAULT_VELOCITY = 0.8;
/** The grids a note is drawn on, in ticks: with 480 to the quarter every triplet is a whole number. */
const GRIDS: readonly (readonly [string, number])[] = [
['1/1', 1920],
['1/2', 960],
['1/4', 480],
['1/8', 240],
['1/16', 120],
['1/32', 60],
['1/4T', 320],
['1/8T', 160],
['1/16T', 80],
['1/32T', 40],
];
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];
}
function wholeNumber(
value: PropertyBag[string] | undefined,
fallback: number,
min: number,
max: number,
): number {
const n = typeof value === 'number' ? Math.round(value) : NaN;
return Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : fallback;
}
export function patternMeter(props: PropertyBag): Meter {
return signMeter(textOf(props[PATTERN_SIGN_KEY] ?? DEFAULT_SIGN));
}
export function patternTempo(props: PropertyBag): number {
const tempo = props[PATTERN_TEMPO_KEY];
return heldTempo(typeof tempo === 'number' ? tempo : NaN);
}
function patternBars(props: PropertyBag): number {
return wholeNumber(props[PATTERN_BARS_KEY], DEFAULT_BARS, 1, MAX_BARS);
}
export function patternGrid(props: PropertyBag): number {
return gridTicks(textOf(props[PATTERN_GRID_KEY] ?? DEFAULT_GRID));
}
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));
}
/** The loop the playhead runs round: as it was set, cut at the end of the pattern; else all of it. */
export function patternLoop(asset: PatternAsset): LoopRange {
const length = patternLength(asset);
const held = heldLoop(asset.loop ?? null);
if (!held || held.start >= length) return { start: 0, end: length };
return { start: held.start, end: Math.min(held.end, length) };
}
/** What the scheduler plays of it: the notes before its end, with its instrument; none without one. */
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,
...playedNote(n.tick, Math.min(n.length, length - n.tick), swing),
note: n.note,
velocity: n.velocity,
instrument,
})),
};
}
/** Notes in the order they are played, the lower one first where two start together. */
function byStart(a: PatternNote, b: PatternNote): number {
return a.tick - b.tick || a.note - b.note;
}
/**
* Held to what a note may be: a start in whole ticks from 0, at least a tick long, a note of the
* keyboard, a velocity from 0 to 1, an id no other note has. Anything else in the list is dropped.
*/
export function heldPatternNotes(raw: unknown): PatternNote[] {
if (!Array.isArray(raw)) return [];
const ids = new Set<string>();
const notes: PatternNote[] = [];
for (const item of raw as unknown[]) {
if (typeof item !== 'object' || item === null) continue;
const n = item as Record<string, unknown>;
const num = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : NaN);
const tick = Math.round(num(n.tick));
const length = num(n.length) > 0 ? Math.max(1, Math.round(num(n.length))) : NaN;
const note = Math.round(num(n.note));
const velocity = num(n.velocity);
if (!(tick >= 0) || !(length >= 1) || !(note >= LOWEST_NOTE && note <= HIGHEST_NOTE)) continue;
let id = typeof n.id === 'string' && n.id !== '' ? n.id : newId();
while (ids.has(id)) id = newId();
ids.add(id);
notes.push({
id,
tick,
length,
note,
velocity: Number.isFinite(velocity) ? Math.max(0, Math.min(1, velocity)) : DEFAULT_VELOCITY,
});
}
return notes.sort(byStart);
}