/** * 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 { 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 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 })); 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] ?? ''); } /** 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); return { notes: asset.notes .filter((n) => n.tick < length) .map((n) => ({ tick: n.tick, length: Math.min(n.length, length - n.tick), 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(); const notes: PatternNote[] = []; for (const item of raw as unknown[]) { if (typeof item !== 'object' || item === null) continue; const n = item as Record; 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); }