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>
316 lines
13 KiB
TypeScript
316 lines
13 KiB
TypeScript
/**
|
||
* A song (#334): sounds and patterns laid out on tracks along one time axis, at one tempo and meter.
|
||
* An item is counted in ticks like a note; a pattern item repeats its pattern for as long as it is,
|
||
* a sound item its sound, which keeps its own speed whatever the tempo. A track's volume, pan, mute
|
||
* and solo are heard on a bus of its own, so a change reaches what is sounding already; where it
|
||
* goes from there is the song's routing (#350).
|
||
*/
|
||
import { newId } from '../ids';
|
||
import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes';
|
||
import type { PatternAsset, SongAsset, SoundAsset } from '../model/Asset';
|
||
import { curveLookup } from '../model/curve';
|
||
import { type PropertyBag, textOf } from '../model/Properties';
|
||
import { findAssetOf, type Project } from '../model/Project';
|
||
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,
|
||
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';
|
||
|
||
export interface SongTrack {
|
||
id: string;
|
||
name: string;
|
||
/** In decibels, from `TRACK_MIN_DB` to `TRACK_MAX_DB`. */
|
||
volume: number;
|
||
/** -1 all left, 1 all right. */
|
||
pan: number;
|
||
mute: boolean;
|
||
solo: boolean;
|
||
/** The sound FX everything on the track goes through, before its volume and pan (#346); `''` for none. */
|
||
fx: string;
|
||
/** Its volume, pan and parameters over the song (#337); left out without a lane. */
|
||
automation?: AutomationLane[];
|
||
}
|
||
|
||
export type SongItemKind = 'pattern' | 'sound';
|
||
|
||
export interface SongItem {
|
||
id: string;
|
||
track: string;
|
||
kind: SongItemKind;
|
||
/** The pattern or sound asset it plays. */
|
||
asset: string;
|
||
/** Where it starts and how long it lasts, in ticks. */
|
||
tick: number;
|
||
length: number;
|
||
/** Where in what it plays it starts: ticks into a pattern, seconds into a sound. */
|
||
offset: number;
|
||
/** In decibels, from `ITEM_MIN_DB` to `itemMaxDb` of its kind. */
|
||
volume: number;
|
||
/** Semitones a pattern's notes are moved by. */
|
||
transpose: number;
|
||
/** The instrument a pattern is played with instead of its own, `''` for its own. */
|
||
instrument: string;
|
||
/**
|
||
* The sound FX it goes through: a sound by itself, a pattern all its notes together on a bus of
|
||
* their own (#346). `''` is the one its asset names, `NO_FX` none (#352).
|
||
*/
|
||
fx: string;
|
||
}
|
||
|
||
export const SONG_TEMPO_KEY = 'tempo';
|
||
export const SONG_SIGN_KEY = 'sign';
|
||
export const SONG_GRID_KEY = 'grid';
|
||
|
||
export const DEFAULT_SONG_GRID = '1/16';
|
||
export const SONG_GRID_OPTIONS = [{ value: 'bar', label: 'Bar' }, ...GRID_OPTIONS];
|
||
/** A new song has this many tracks. */
|
||
const DEFAULT_TRACKS = 1;
|
||
/** A song is never shorter than this many bars, on the ruler or when it plays. */
|
||
const MIN_BARS = 1;
|
||
|
||
export { dbToGain, TRACK_MAX_DB, TRACK_MIN_DB };
|
||
export const ITEM_MIN_DB = -60;
|
||
/** A sound item can be turned up; a pattern item's notes are held to unity, as a note always is. */
|
||
export const SOUND_ITEM_MAX_DB = 12;
|
||
export const MAX_TRANSPOSE = 48;
|
||
|
||
export function itemMaxDb(kind: SongItemKind): number {
|
||
return kind === 'sound' ? SOUND_ITEM_MAX_DB : 0;
|
||
}
|
||
|
||
export function songMeter(props: PropertyBag): Meter {
|
||
return signMeter(textOf(props[SONG_SIGN_KEY] ?? DEFAULT_SIGN));
|
||
}
|
||
|
||
export function songTempo(props: PropertyBag): number {
|
||
const tempo = props[SONG_TEMPO_KEY];
|
||
return heldTempo(typeof tempo === 'number' ? tempo : NaN);
|
||
}
|
||
|
||
/** The grid items snap to, in ticks: a bar, or one of the grids of a pattern. */
|
||
export function songGrid(props: PropertyBag): number {
|
||
const grid = textOf(props[SONG_GRID_KEY] ?? DEFAULT_SONG_GRID);
|
||
return grid === 'bar' ? ticksPerBar(songMeter(props)) : gridTicks(grid);
|
||
}
|
||
|
||
export function itemEnd(item: SongItem): number {
|
||
return item.tick + item.length;
|
||
}
|
||
|
||
/** Where the song ends: after its last item, on a bar line, and never before `MIN_BARS`. */
|
||
export function songLength(song: SongAsset): number {
|
||
const bar = ticksPerBar(songMeter(song.props));
|
||
const last = Math.max(0, ...song.items.map(itemEnd));
|
||
return Math.max(MIN_BARS * bar, Math.ceil(last / bar) * bar);
|
||
}
|
||
|
||
/** The loop the playhead runs round: as it was set, else the whole song. */
|
||
export function songLoop(song: SongAsset): LoopRange {
|
||
return heldLoop(song.loop ?? null) ?? { start: 0, end: songLength(song) };
|
||
}
|
||
|
||
export function newTrack(index: number): SongTrack {
|
||
return { id: newId(), name: `Track ${index}`, volume: 0, pan: 0, mute: false, solo: false, fx: '' };
|
||
}
|
||
|
||
export function defaultTracks(): SongTrack[] {
|
||
return Array.from({ length: DEFAULT_TRACKS }, (_, i) => newTrack(i + 1));
|
||
}
|
||
|
||
export function newItem(fields: Pick<SongItem, 'track' | 'kind' | 'asset' | 'tick' | 'length'>): SongItem {
|
||
return { id: newId(), offset: 0, volume: 0, transpose: 0, instrument: '', fx: '', ...fields };
|
||
}
|
||
|
||
/** The bus a track is heard on; ids are a song's own, so the song's id goes in front. */
|
||
export function trackBus(songId: string, trackId: string): string {
|
||
return `song:${songId}:${trackId}`;
|
||
}
|
||
|
||
/**
|
||
* The bus a track's own items go in by, into the bus of the track (#350): what solo silences on a
|
||
* track that only passes a soloed one on. `#` is in no id, so no item's bus is called that.
|
||
*/
|
||
export function trackItemsBus(songId: string, trackId: string): string {
|
||
return `${trackBus(songId, trackId)}:#items`;
|
||
}
|
||
|
||
/** The bus a pattern item with a sound FX is heard on (#346), into the bus of its track's items. */
|
||
export function itemBus(songId: string, item: Pick<SongItem, 'id' | 'track'>): string {
|
||
return `${trackBus(songId, item.track)}:${item.id}`;
|
||
}
|
||
|
||
/** The sound FX an item goes through (#352): its own pick, else the one of what it plays; '' for none. */
|
||
export function itemFx(project: Project, item: SongItem): string {
|
||
return pickedFx(item.fx, itemAsset(project, item)?.props ?? {});
|
||
}
|
||
|
||
/** The asset an item plays: its pattern, or its sound – also when it plays a clip of it (#357). */
|
||
export function itemAsset(project: Project, item: SongItem): PatternAsset | SoundAsset | undefined {
|
||
return item.kind === 'sound'
|
||
? findSound(project, item.asset)?.asset
|
||
: findAssetOf(project, item.asset, 'pattern');
|
||
}
|
||
|
||
/** Whether a pattern item is heard on a bus of its own (#346): it goes through a sound FX that is there. */
|
||
export function hasItemBus(project: Project, item: SongItem): boolean {
|
||
if (item.kind !== 'pattern') return false;
|
||
const fx = itemFx(project, item);
|
||
return fx !== '' && findAssetOf(project, fx, 'soundfx') !== undefined;
|
||
}
|
||
|
||
/**
|
||
* The part of an item from `tick` on, which is where it is cut by a split or by its left edge: what
|
||
* it plays starts as far in as the cut is from its start. Null where `tick` is not inside it.
|
||
*/
|
||
export function itemFrom(item: SongItem, tick: number, tempo: number): SongItem | null {
|
||
const by = Math.round(tick) - item.tick;
|
||
if (by <= 0 || by >= item.length) return null;
|
||
const offset = item.kind === 'sound' ? item.offset + secondsOfTicks(by, tempo) : item.offset + by;
|
||
return { ...item, tick: item.tick + by, length: item.length - by, offset };
|
||
}
|
||
|
||
/** Where the first round of a pattern item starts in the song: before the item, by its offset. */
|
||
export function firstRound(item: SongItem, span: number): number {
|
||
return item.tick - (((item.offset % span) + span) % span);
|
||
}
|
||
|
||
/**
|
||
* 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. `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,
|
||
swung = false,
|
||
): { tick: number; length: number; late?: number; note: number; velocity: number }[] {
|
||
const span = patternLength(pattern);
|
||
const end = itemEnd(item);
|
||
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;
|
||
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;
|
||
}
|
||
|
||
/** What the scheduler plays of a song: every item that has its asset, on the bus of its track's items. */
|
||
export function songSequence(project: Project, song: SongAsset): Sequence {
|
||
const notes: SequenceNote[] = [];
|
||
const clips: SequenceClip[] = [];
|
||
const tracks = new Set(song.tracks.map((t) => t.id));
|
||
for (const item of song.items) {
|
||
if (!tracks.has(item.track)) continue;
|
||
const volume = dbToGain(item.volume);
|
||
const bus = hasItemBus(project, item) ? itemBus(song.id, item) : trackItemsBus(song.id, item.track);
|
||
if (item.kind === 'sound') {
|
||
if (!findSound(project, item.asset)?.asset.file) continue;
|
||
clips.push({
|
||
tick: item.tick,
|
||
length: item.length,
|
||
sound: item.asset,
|
||
fx: itemFx(project, item),
|
||
offset: item.offset,
|
||
volume,
|
||
bus,
|
||
});
|
||
continue;
|
||
}
|
||
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, 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);
|
||
const curves = curveLookup(project);
|
||
const lanes = song.tracks.flatMap((track) =>
|
||
(track.automation ?? [])
|
||
.filter((lane) => lane.points.length > 0)
|
||
.map((lane) => sequenceLaneOf(lane, trackBus(song.id, track.id), curves)),
|
||
);
|
||
return { notes, clips, ...(lanes.length > 0 && { lanes }) };
|
||
}
|
||
|
||
const finite = (value: unknown, fallback: number) =>
|
||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||
const clampTo = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
||
const text = (value: unknown) => (typeof value === 'string' ? value : '');
|
||
|
||
/** Held to what a track may be; at least one track, ids no other has. */
|
||
export function heldSongTracks(raw: unknown): SongTrack[] {
|
||
const ids = new Set<string>();
|
||
const tracks: SongTrack[] = [];
|
||
for (const item of Array.isArray(raw) ? (raw as unknown[]) : []) {
|
||
if (typeof item !== 'object' || item === null) continue;
|
||
const t = item as Record<string, unknown>;
|
||
let id = text(t.id) || newId();
|
||
while (ids.has(id)) id = newId();
|
||
ids.add(id);
|
||
tracks.push({
|
||
id,
|
||
name: text(t.name) || `Track ${tracks.length + 1}`,
|
||
volume: clampTo(finite(t.volume, 0), TRACK_MIN_DB, TRACK_MAX_DB),
|
||
pan: clampTo(finite(t.pan, 0), -1, 1),
|
||
mute: t.mute === true,
|
||
solo: t.solo === true,
|
||
fx: text(t.fx),
|
||
});
|
||
const automation = heldLanes(t.automation);
|
||
if (automation.length > 0) tracks.at(-1)!.automation = automation;
|
||
}
|
||
return tracks.length > 0 ? tracks : [newTrack(1)];
|
||
}
|
||
|
||
/**
|
||
* Held to what an item may be: on a track of the song, a kind it knows, a start in whole ticks from
|
||
* 0, at least a tick long, an offset from 0. Anything else in the list is dropped.
|
||
*/
|
||
export function heldSongItems(raw: unknown, tracks: readonly SongTrack[]): SongItem[] {
|
||
const known = new Set(tracks.map((t) => t.id));
|
||
const ids = new Set<string>();
|
||
const items: SongItem[] = [];
|
||
for (const entry of Array.isArray(raw) ? (raw as unknown[]) : []) {
|
||
if (typeof entry !== 'object' || entry === null) continue;
|
||
const i = entry as Record<string, unknown>;
|
||
const kind = i.kind === 'sound' || i.kind === 'pattern' ? i.kind : null;
|
||
const tick = Math.round(finite(i.tick, NaN));
|
||
const length = Math.max(1, Math.round(finite(i.length, NaN)));
|
||
if (!kind || !known.has(text(i.track)) || !(tick >= 0) || !(length >= 1)) continue;
|
||
let id = text(i.id) || newId();
|
||
while (ids.has(id)) id = newId();
|
||
ids.add(id);
|
||
const offset = Math.max(0, finite(i.offset, 0));
|
||
items.push({
|
||
id,
|
||
track: text(i.track),
|
||
kind,
|
||
asset: text(i.asset),
|
||
tick,
|
||
length,
|
||
offset: kind === 'pattern' ? Math.round(offset) : offset,
|
||
volume: clampTo(finite(i.volume, 0), ITEM_MIN_DB, itemMaxDb(kind)),
|
||
transpose: Math.round(clampTo(finite(i.transpose, 0), -MAX_TRANSPOSE, MAX_TRANSPOSE)),
|
||
instrument: text(i.instrument),
|
||
fx: text(i.fx),
|
||
});
|
||
}
|
||
return items.sort((a, b) => a.tick - b.tick);
|
||
}
|