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>
184 lines
6.7 KiB
TypeScript
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);
|
|
}
|