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

316 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}