Lay patterns and sounds out on tracks and play them as a song #334
Patterns could only be heard one at a time, in their own tab or from a behavior. A song lays patterns and sounds out on tracks along one time axis and plays them through the scheduler of #332, the third piece of the music tools of #330. - New asset type song under the heading Songs, with four empty tracks, 120 BPM in 4/4 and a grid of a bar. Tempo, meter and grid are rows in the inspector. Tracks, items and the loop are the asset's data, held to their rules on load and in every command. - A track has a name, volume, pan, mute and solo. Each is heard on a bus of its own, so a change reaches what is sounding at once. - A pattern item repeats its pattern for as long as it is, with a transpose and another instrument if wanted. A sound item plays its sound as a clip at its own speed, round and round, through a sound FX if wanted. Both have a volume on top of their track's. - Cut back at the left end or split, an item starts as far into what it plays as the cut is: ticks into a pattern, seconds into a sound. - The tab shows the heads with M, S, volume and pan, the ruler of the Pattern tab and the items, a pattern with its notes, a sound with its waveform, a missing asset red. Patterns and sounds are dragged in from the Assets panel. Body, right and left end move, lengthen and cut back, Alt leaves the grid. Ctrl+C/V/D/A, Ctrl+E splits where playing starts, Delete, arrows. A double-click opens a pattern item in its tab. - What is picked is a new selection of its own, so the inspector shows the item or the track, with a button to delete it. - The scheduler plans clips beside notes. Where the playhead starts or jumps back inside a clip, it is taken up that far into its sound. A clip fades out at the end of its item or of the loop. - The editor plays one piece at a time: a pattern started stops the song and the other way round. - The rules are pure functions in core/music/song, editor/song/ songView and songEdit. Findings: - The suites of4ea752fand2a3b90bwere red at e2e 54: the Pattern pushed Struct below the fold of the + menu. The module now scrolls to it, as e2e 47 does for Vector art. It also left the menu open, which made e2e 55 fail behind it. - The Assets panel only dragged assets a scene can place. Patterns and sounds are dragged now as well, and a scene says why it does not take one instead of calling every asset a scene. Tests: - song.test.ts (new, 14) and song-edit.test.ts (new, 9): model, round trip, sequence of patterns and clips, mute and solo, cuts, clips on the scheduler, moving, sizing, trimming, splitting, duplicating, hits, head parts. sound-library.test.ts +5: clip offset, end fade, pitched clip, track bus, peaks. - e2e 65 (new) on melektaus-atx: ALL PASSED, 18 checks. e2e 54 and 55 with the fix: ALL PASSED, 33 checks. e2e 4 with 30 asset types: ALL PASSED, 62 checks. - Gate on melektaus-atx: CHECK EXIT 0, 250 test files, 3884 tests. - Counter-checks: no taking up of a clip, no pattern offset, no clip end each fail 3, 1 and 1 tests. - Probe on stein2: the tab with four tracks, before and while playing, looked at in two screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2a3b90b017
commit
8f9c250eb6
43 files changed
+3863
-36
No files matched your search
@@ -11,6 +11,7 @@ import type {
|
||||
VariablesAsset,
|
||||
} from '../model/Asset';
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
import { defaultTracks } from '../music/song';
|
||||
import { linearCurve } from '../model/curve';
|
||||
import { isFontFile } from '../model/font';
|
||||
import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
|
||||
@@ -108,6 +109,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
return { ...base, type: 'instrument', graph: starterInstrumentGraph() };
|
||||
case 'pattern':
|
||||
return { ...base, type: 'pattern', notes: [] };
|
||||
case 'song':
|
||||
return { ...base, type: 'song', tracks: defaultTracks(), items: [] };
|
||||
case 'particles':
|
||||
return { ...base, type: 'particles', graph: createEmptyGraph() };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
Asset,
|
||||
CurveAsset,
|
||||
PatternAsset,
|
||||
SongAsset,
|
||||
TileAsset,
|
||||
BehaviorAsset,
|
||||
StateMachineAsset,
|
||||
@@ -148,6 +149,12 @@ export function requirePattern(project: Project, assetId: string): PatternAsset
|
||||
return asset;
|
||||
}
|
||||
|
||||
export function requireSong(project: Project, assetId: string): SongAsset {
|
||||
const asset = findAssetOf(project, assetId, 'song');
|
||||
if (!asset) throw new CommandError(`Song ${assetId} not found`);
|
||||
return asset;
|
||||
}
|
||||
|
||||
export function requireVariablesAsset(project: Project, assetId: string): VariablesAsset {
|
||||
const asset = findAssetOf(project, assetId, 'variables');
|
||||
if (!asset) throw new CommandError(`Variables ${assetId} not found`);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Editing a song (#334). As with the notes of a pattern, one gesture is one command: the tracks or
|
||||
* items are written whole once the pointer lets go, so an undo takes the drag back. What is written
|
||||
* is held to the rules of a track and an item first; an item whose track goes goes with it.
|
||||
*/
|
||||
import type { SongAsset } from '../model/Asset';
|
||||
import type { Project } from '../model/Project';
|
||||
import { heldSongItems, heldSongTracks, type SongItem, type SongTrack } from '../music/song';
|
||||
import { heldLoop, type LoopRange } from '../music/sequence';
|
||||
import type { Command } from './Command';
|
||||
import { requireSong } from './require';
|
||||
|
||||
export interface SongParts {
|
||||
tracks?: readonly SongTrack[];
|
||||
items?: readonly SongItem[];
|
||||
}
|
||||
|
||||
export class SetSongCommand implements Command {
|
||||
private previous: { tracks: SongTrack[]; items: SongItem[] } | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly parts: SongParts,
|
||||
readonly label = 'Edit song',
|
||||
) {}
|
||||
|
||||
/** Nothing to do when the song already reads exactly like this – no empty undo step. */
|
||||
static changes(asset: SongAsset, parts: SongParts): boolean {
|
||||
const tracks = heldSongTracks(parts.tracks ?? asset.tracks);
|
||||
const items = heldSongItems(parts.items ?? asset.items, tracks);
|
||||
return JSON.stringify([asset.tracks, asset.items]) !== JSON.stringify([tracks, items]);
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireSong(project, this.assetId);
|
||||
this.previous = { tracks: asset.tracks, items: asset.items };
|
||||
asset.tracks = heldSongTracks(this.parts.tracks ?? asset.tracks);
|
||||
asset.items = heldSongItems(this.parts.items ?? asset.items, asset.tracks);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.previous) return;
|
||||
const asset = requireSong(project, this.assetId);
|
||||
asset.tracks = this.previous.tracks;
|
||||
asset.items = this.previous.items;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets the loop of the ruler, or takes it away (`null`): then the whole song is the loop. */
|
||||
export class SetSongLoopCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous: LoopRange | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly loop: LoopRange | null,
|
||||
) {
|
||||
this.label = loop ? 'Set loop' : 'Loop the whole song';
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireSong(project, this.assetId);
|
||||
this.previous = asset.loop;
|
||||
const held = heldLoop(this.loop);
|
||||
if (held) asset.loop = { start: Math.round(held.start), end: Math.round(held.end) };
|
||||
else delete asset.loop;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const asset = requireSong(project, this.assetId);
|
||||
if (this.previous) asset.loop = this.previous;
|
||||
else delete asset.loop;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Graph } from '../graph/Graph';
|
||||
import type { PatternNote } from '../music/pattern';
|
||||
import type { SongItem, SongTrack } from '../music/song';
|
||||
import type { LoopRange } from '../music/sequence';
|
||||
import type { PropertyDef } from '../properties/PropertySchema';
|
||||
import type { AssetType } from './AssetTypes';
|
||||
@@ -341,6 +342,18 @@ export interface PatternAsset extends AssetBase {
|
||||
loop?: LoopRange;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sounds and patterns on tracks along one time axis (#334). The tempo, the meter and the grid are
|
||||
* properties; the tracks, their items and the loop are the asset's own data, the whole song while
|
||||
* no loop is set.
|
||||
*/
|
||||
export interface SongAsset extends AssetBase {
|
||||
type: 'song';
|
||||
tracks: SongTrack[];
|
||||
items: SongItem[];
|
||||
loop?: LoopRange;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile a tilemap paints with: `pictures` names one block per animation step (a texture id for a
|
||||
* sheet that holds nothing else, a frame id for one block cut out of a sheet), and `props` say
|
||||
@@ -596,6 +609,7 @@ export type Asset =
|
||||
| SoundFxAsset
|
||||
| InstrumentAsset
|
||||
| PatternAsset
|
||||
| SongAsset
|
||||
| ParticlesAsset;
|
||||
|
||||
/** The assets whose `variables` are declared by the user – see `requireVariableOwner`. */
|
||||
|
||||
@@ -27,6 +27,7 @@ export const ASSET_TYPES = [
|
||||
'soundfx',
|
||||
'instrument',
|
||||
'pattern',
|
||||
'song',
|
||||
'particles',
|
||||
] as const;
|
||||
|
||||
@@ -61,6 +62,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
soundfx: 'Sound FX',
|
||||
instrument: 'Instrument',
|
||||
pattern: 'Pattern',
|
||||
song: 'Song',
|
||||
particles: 'Particles',
|
||||
};
|
||||
|
||||
@@ -74,7 +76,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
* A struct is a shape that variables and fields take on (#253), nothing that exists on its own.
|
||||
* A shader function is part of other shaders (#303), worn by nothing itself. A sound FX is what a
|
||||
* sound is played through (#311), heard but never seen, an instrument what a note is played
|
||||
* with (#331) and a pattern the notes it plays (#333).
|
||||
* with (#331), a pattern the notes it plays (#333) and a song what lays them out (#334).
|
||||
*/
|
||||
export type PlaceableAssetType = Exclude<
|
||||
AssetType,
|
||||
@@ -94,6 +96,7 @@ export type PlaceableAssetType = Exclude<
|
||||
| 'soundfx'
|
||||
| 'instrument'
|
||||
| 'pattern'
|
||||
| 'song'
|
||||
| 'variables'
|
||||
| 'struct'
|
||||
>;
|
||||
@@ -115,6 +118,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
'soundfx',
|
||||
'instrument',
|
||||
'pattern',
|
||||
'song',
|
||||
'variables',
|
||||
'struct',
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Asset } from './Asset';
|
||||
import { isPlaceableType } from './AssetTypes';
|
||||
import { ASSET_TYPE_LABELS, isPlaceableType } from './AssetTypes';
|
||||
import { containerReaches } from './container';
|
||||
import type { ObjectHolder, Project } from './Project';
|
||||
|
||||
@@ -9,7 +9,9 @@ import type { ObjectHolder, Project } from './Project';
|
||||
* promise a placement the command then refuses.
|
||||
*/
|
||||
export function placementRejection(project: Project, holder: ObjectHolder, asset: Asset): string {
|
||||
if (!isPlaceableType(asset.type)) return 'A scene cannot be placed into a scene';
|
||||
// A pattern or a sound is dragged out of the panel for a song (#334), and may pass over a scene.
|
||||
if (!isPlaceableType(asset.type))
|
||||
return `A ${ASSET_TYPE_LABELS[asset.type].toLowerCase()} cannot be placed into a scene`;
|
||||
if (asset.type === 'container' && containerReaches(project, asset, holder.id))
|
||||
return `${asset.name} cannot contain ${holder.name}: that nests forever`;
|
||||
return '';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { heldLoop, type LoopRange, notesIn, playheadAt, type Sequence } from './sequence';
|
||||
import { clipsIn, heldLoop, type LoopRange, notesIn, playheadAt, type Sequence } from './sequence';
|
||||
import {
|
||||
COMMON_TIME,
|
||||
DEFAULT_TEMPO,
|
||||
@@ -26,6 +26,22 @@ export interface ScheduledNote {
|
||||
instrument: string;
|
||||
/** What a Delay synced to beats counts by. */
|
||||
tempo: number;
|
||||
/** A factor on its loudness, and the track of a song it is heard on (#334). */
|
||||
volume?: number;
|
||||
bus?: string;
|
||||
}
|
||||
|
||||
/** One clip handed to the player (#334): when it starts, how long it plays and from where in the sound. */
|
||||
export interface ScheduledClip {
|
||||
at: number;
|
||||
duration: number;
|
||||
/** Seconds into the sound, however often it has gone round by then. */
|
||||
offset: number;
|
||||
sound: string;
|
||||
fx: string;
|
||||
volume: number;
|
||||
bus: string;
|
||||
tempo: number;
|
||||
}
|
||||
|
||||
/** What the scheduler needs of the audio side; a test hands in a clock of its own. */
|
||||
@@ -36,6 +52,8 @@ export interface SchedulerHost {
|
||||
latency(): number;
|
||||
/** Starts the note, gives back the number of its voice; 0 where nothing plays. */
|
||||
play(note: ScheduledNote): number;
|
||||
/** Starts a clip the same way; a host that plays no clips leaves it out. */
|
||||
playClip?(clip: ScheduledClip): number;
|
||||
playing(voice: number): boolean;
|
||||
/** Ends these voices; with `letRing` a note falls away in the release of its Envelope. */
|
||||
stop(voices: readonly number[], letRing?: boolean): void;
|
||||
@@ -70,6 +88,8 @@ export class Scheduler {
|
||||
private anchor: number | null = null;
|
||||
/** Playing time planned so far, in whole ticks. */
|
||||
private until = 0;
|
||||
/** Started and not planned yet: a clip the playhead starts in the middle of is taken up. */
|
||||
private fresh = false;
|
||||
/** How it played before the last change of tempo or loop, for the time until that is reached. */
|
||||
private before: { anchor: number; from: number; tempo: number; loop: LoopRange | null } | null = null;
|
||||
private voices: number[] = [];
|
||||
@@ -103,6 +123,7 @@ export class Scheduler {
|
||||
this.until = 0;
|
||||
this.before = null;
|
||||
this.ended = false;
|
||||
this.fresh = true;
|
||||
this.sleep = this.host.wake(() => this.pump());
|
||||
this.pump();
|
||||
}
|
||||
@@ -154,7 +175,8 @@ export class Scheduler {
|
||||
const to = Math.floor(ticksOfSeconds(this.host.now() + LOOKAHEAD - this.anchor, this.tempo));
|
||||
if (to <= this.until) return;
|
||||
this.voices = this.voices.filter((voice) => this.host.playing(voice));
|
||||
for (const due of notesIn(this.sequence(), this.from, this.loop, this.until, to)) {
|
||||
const sequence = this.sequence();
|
||||
for (const due of notesIn(sequence, this.from, this.loop, this.until, to)) {
|
||||
const voice = this.host.play({
|
||||
at: this.anchor + secondsOfTicks(due.at, this.tempo),
|
||||
duration: secondsOfTicks(due.length, this.tempo),
|
||||
@@ -162,9 +184,26 @@ export class Scheduler {
|
||||
velocity: due.velocity,
|
||||
instrument: due.instrument,
|
||||
tempo: this.tempo,
|
||||
...(due.volume !== undefined && { volume: due.volume }),
|
||||
...(due.bus !== undefined && { bus: due.bus }),
|
||||
});
|
||||
if (voice !== 0) this.voices.push(voice);
|
||||
}
|
||||
if (this.host.playClip)
|
||||
for (const due of clipsIn(sequence, this.from, this.loop, this.until, to, this.fresh)) {
|
||||
const voice = this.host.playClip({
|
||||
at: this.anchor + secondsOfTicks(due.at, this.tempo),
|
||||
duration: secondsOfTicks(due.length, this.tempo),
|
||||
offset: due.offset + secondsOfTicks(due.skip, this.tempo),
|
||||
sound: due.sound,
|
||||
fx: due.fx,
|
||||
volume: due.volume,
|
||||
bus: due.bus,
|
||||
tempo: this.tempo,
|
||||
});
|
||||
if (voice !== 0) this.voices.push(voice);
|
||||
}
|
||||
this.fresh = false;
|
||||
this.until = to;
|
||||
if (!this.loop && playheadAt(to, this.from, null) >= this.end) {
|
||||
this.ended = true;
|
||||
|
||||
@@ -15,10 +15,30 @@ export interface SequenceNote {
|
||||
velocity: number;
|
||||
/** The instrument asset that plays it. */
|
||||
instrument: string;
|
||||
/** A factor on its loudness, 1 where left out: the volume of a song's item (#334). */
|
||||
volume?: number;
|
||||
/** The track of a song it is heard on (#334); straight out where left out. */
|
||||
bus?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sound played as a clip of a song (#334): from `offset` seconds into it, round and round until
|
||||
* `length` ticks have passed. It keeps its own speed whatever the tempo.
|
||||
*/
|
||||
export interface SequenceClip {
|
||||
tick: number;
|
||||
length: number;
|
||||
sound: string;
|
||||
/** The sound FX it goes through, `''` for none. */
|
||||
fx: string;
|
||||
offset: number;
|
||||
volume: number;
|
||||
bus: string;
|
||||
}
|
||||
|
||||
export interface Sequence {
|
||||
notes: readonly SequenceNote[];
|
||||
clips?: readonly SequenceClip[];
|
||||
}
|
||||
|
||||
/** From `start` up to `end`, in ticks of the sequence; `end` is where the playhead jumps back. */
|
||||
@@ -82,3 +102,46 @@ export function notesIn(
|
||||
}
|
||||
return due.sort((a, b) => a.at - b.at || a.note - b.note);
|
||||
}
|
||||
|
||||
/** A clip of the sequence where it falls in playing time: `skip` ticks of it lie behind the playhead. */
|
||||
export interface DueClip extends SequenceClip {
|
||||
at: number;
|
||||
skip: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The clips starting in the playing time from `start` up to `end`, as `notesIn` finds notes. Where
|
||||
* the playhead lands – at the start with `fresh`, and after every jump back – a clip it lands in the
|
||||
* middle of is taken up there, `skip` ticks into it. A clip ends where its item or the loop does.
|
||||
*/
|
||||
export function clipsIn(
|
||||
sequence: Sequence,
|
||||
from: number,
|
||||
loop: LoopRange | null,
|
||||
start: number,
|
||||
end: number,
|
||||
fresh: boolean,
|
||||
): DueClip[] {
|
||||
const clips = sequence.clips ?? [];
|
||||
const due: DueClip[] = [];
|
||||
if (clips.length === 0) return due;
|
||||
const stretchEnd = loop !== null && from < loop.end ? loop.end : Infinity;
|
||||
let elapsed = start;
|
||||
let head = playheadAt(start, from, loop);
|
||||
let landed = fresh;
|
||||
while (elapsed < end) {
|
||||
const span = Math.min(end - elapsed, stretchEnd - head);
|
||||
if (!(span > 0)) break;
|
||||
for (const clip of clips) {
|
||||
const clipEnd = Math.min(clip.tick + clip.length, stretchEnd);
|
||||
if (clip.tick >= head && clip.tick < head + span)
|
||||
due.push({ ...clip, at: elapsed + (clip.tick - head), length: clipEnd - clip.tick, skip: 0 });
|
||||
else if (landed && clip.tick < head && clipEnd > head)
|
||||
due.push({ ...clip, at: elapsed, length: clipEnd - head, skip: head - clip.tick });
|
||||
}
|
||||
elapsed += span;
|
||||
head = head + span >= stretchEnd ? loop!.start : head + span;
|
||||
landed = true;
|
||||
}
|
||||
return due.sort((a, b) => a.at - b.at);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { newId } from '../ids';
|
||||
import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes';
|
||||
import type { SongAsset } from '../model/Asset';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { gridTicks, GRID_OPTIONS, patternInstrument, patternLength } from './pattern';
|
||||
import { heldLoop, type LoopRange, type Sequence, type SequenceClip, type SequenceNote } from './sequence';
|
||||
import { heldMeter, heldTempo, type Meter, secondsOfTicks, 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;
|
||||
}
|
||||
|
||||
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 0. */
|
||||
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 a sound goes through, `''` for none. */
|
||||
fx: string;
|
||||
}
|
||||
|
||||
export const SONG_TEMPO_KEY = 'tempo';
|
||||
export const SONG_BEATS_KEY = 'beats';
|
||||
export const SONG_BEAT_KEY = 'beat';
|
||||
export const SONG_GRID_KEY = 'grid';
|
||||
|
||||
export const DEFAULT_SONG_GRID = 'bar';
|
||||
export const SONG_GRID_OPTIONS = [{ value: 'bar', label: 'Bar' }, ...GRID_OPTIONS];
|
||||
/** A new song has this many tracks, and its ruler shows at least this many bars. */
|
||||
export const DEFAULT_TRACKS = 4;
|
||||
export const MIN_BARS = 4;
|
||||
|
||||
export const TRACK_MIN_DB = -60;
|
||||
export const TRACK_MAX_DB = 6;
|
||||
export const ITEM_MIN_DB = -60;
|
||||
export const MAX_TRANSPOSE = 48;
|
||||
|
||||
export function songMeter(props: PropertyBag): Meter {
|
||||
const beats = props[SONG_BEATS_KEY];
|
||||
return heldMeter({
|
||||
beats: typeof beats === 'number' ? Math.round(beats) : 4,
|
||||
unit: Number(textOf(props[SONG_BEAT_KEY] ?? '4')),
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
export function dbToGain(db: number): number {
|
||||
return db <= TRACK_MIN_DB ? 0 : 10 ** (db / 20);
|
||||
}
|
||||
|
||||
/** What a track's bus is set to: silent while muted, or while others are soloed and it is not. */
|
||||
export function trackLevel(track: SongTrack, tracks: readonly SongTrack[]): { gain: number; pan: number } {
|
||||
const soloed = tracks.some((t) => t.solo);
|
||||
const heard = !track.mute && (!soloed || track.solo);
|
||||
return { gain: heard ? dbToGain(track.volume) : 0, pan: track.pan };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
/** What the scheduler plays of a song: every item that has its asset, on the bus of its track. */
|
||||
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 bus = trackBus(song.id, item.track);
|
||||
const volume = dbToGain(item.volume);
|
||||
if (item.kind === 'sound') {
|
||||
if (!findAssetOf(project, item.asset, 'sound')?.file) continue;
|
||||
clips.push({
|
||||
tick: item.tick,
|
||||
length: item.length,
|
||||
sound: item.asset,
|
||||
fx: item.fx,
|
||||
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;
|
||||
const span = patternLength(pattern);
|
||||
const end = itemEnd(item);
|
||||
const into = ((item.offset % span) + span) % span;
|
||||
for (let round = item.tick - into; 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);
|
||||
notes.push({ tick, length, note, velocity: n.velocity, instrument, volume, bus });
|
||||
}
|
||||
}
|
||||
notes.sort((a, b) => a.tick - b.tick || a.note - b.note);
|
||||
clips.sort((a, b) => a.tick - b.tick);
|
||||
return { notes, clips };
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
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, 0),
|
||||
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);
|
||||
}
|
||||
@@ -117,6 +117,14 @@ import {
|
||||
PATTERN_INSTRUMENT_KEY,
|
||||
PATTERN_TEMPO_KEY,
|
||||
} from '../music/pattern';
|
||||
import {
|
||||
DEFAULT_SONG_GRID,
|
||||
SONG_BEAT_KEY,
|
||||
SONG_BEATS_KEY,
|
||||
SONG_GRID_KEY,
|
||||
SONG_GRID_OPTIONS,
|
||||
SONG_TEMPO_KEY,
|
||||
} from '../music/song';
|
||||
import { DEFAULT_TEMPO, MAX_TEMPO, MIN_TEMPO } from '../music/time';
|
||||
import { Theme } from '../../ui';
|
||||
|
||||
@@ -715,6 +723,36 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
step: 1,
|
||||
},
|
||||
],
|
||||
// The tracks, items and loop are intrinsic data on the asset (see SongAsset).
|
||||
song: [
|
||||
{
|
||||
key: SONG_TEMPO_KEY,
|
||||
label: 'Tempo (BPM)',
|
||||
kind: 'number',
|
||||
default: DEFAULT_TEMPO,
|
||||
min: MIN_TEMPO,
|
||||
max: MAX_TEMPO,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: SONG_BEATS_KEY,
|
||||
label: 'Beats per bar',
|
||||
kind: 'number',
|
||||
default: 4,
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1,
|
||||
decimals: 0,
|
||||
},
|
||||
{ key: SONG_BEAT_KEY, label: 'Beat', kind: 'enum', default: '4', options: BEAT_OPTIONS },
|
||||
{
|
||||
key: SONG_GRID_KEY,
|
||||
label: 'Grid',
|
||||
kind: 'enum',
|
||||
default: DEFAULT_SONG_GRID,
|
||||
options: SONG_GRID_OPTIONS,
|
||||
},
|
||||
],
|
||||
// How many particles and for how long; what one of them does is the asset's graph, whose Property
|
||||
// nodes add their rows after these – see `schemaForBag`.
|
||||
particles: [
|
||||
|
||||
@@ -69,12 +69,14 @@ export class PitchPlayer {
|
||||
* `channels` are the samples of the sound, `step` how many of them pass for one sample of the
|
||||
* output (the sound's rate over the output's) and `rate` the output's rate, which sets the frame.
|
||||
* `loop` repeats the whole sound, or the samples from `start` up to `end` (#331) once it gets there.
|
||||
* `skip` is how many samples of the sound lie before where it starts (#334).
|
||||
*/
|
||||
constructor(
|
||||
private readonly channels: readonly Float32Array[],
|
||||
private readonly step: number,
|
||||
rate: number,
|
||||
loop: boolean | { start: number; end: number },
|
||||
skip = 0,
|
||||
) {
|
||||
// About 43 ms: a frame holds a few periods of a low note.
|
||||
this.size = 2 ** Math.round(Math.log2(rate * 0.043));
|
||||
@@ -96,7 +98,7 @@ export class PitchPlayer {
|
||||
this.runPhase = channels.map(() => new Float64Array(this.bins));
|
||||
this.sum = channels.map(() => new Float32Array(this.size));
|
||||
this.given = this.hop;
|
||||
this.frameAt = -this.size;
|
||||
this.frameAt = Math.max(0, Math.round(skip / step)) - this.size;
|
||||
const length = channels[0]?.length ?? 0;
|
||||
this.end = loop ? Infinity : Math.ceil(length / step);
|
||||
const region = loop === true ? { start: 0, end: length } : loop || null;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { isShaderNodeDef } from '../shader/nodes';
|
||||
import { isSoundFxNodeDef } from '../soundfx/nodes';
|
||||
import { INSTRUMENT_SAMPLE, isInstrumentNodeDef, withSamplePlay } from '../instrument/nodes';
|
||||
import { heldPatternNotes } from '../music/pattern';
|
||||
import { heldSongItems, heldSongTracks } from '../music/song';
|
||||
import { heldLoop, type LoopRange } from '../music/sequence';
|
||||
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
|
||||
import type {
|
||||
@@ -276,6 +277,18 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
} else if (type === 'pattern') {
|
||||
const loop = readLoop(a.loop);
|
||||
assets.push({ id, type, name, props, notes: heldPatternNotes(a.notes), ...(loop && { loop }) });
|
||||
} else if (type === 'song') {
|
||||
const loop = readLoop(a.loop);
|
||||
const tracks = heldSongTracks(a.tracks);
|
||||
assets.push({
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
props,
|
||||
tracks,
|
||||
items: heldSongItems(a.items, tracks),
|
||||
...(loop && { loop }),
|
||||
});
|
||||
} else if (type === 'curve') {
|
||||
assets.push({ id, type, name, props, points: sanitizedCurve(normalizePoints(a.points)) });
|
||||
} else if (type === 'animation') {
|
||||
@@ -655,7 +668,7 @@ function normalizePixelArt(
|
||||
return { width: canvasSide(raw.width), height: canvasSide(raw.height), layers, frames };
|
||||
}
|
||||
|
||||
/** A loop of a pattern (#333): two numbers of ticks with room between them, else none. */
|
||||
/** A loop of a pattern (#333) or song (#334): two numbers of ticks with room between them, else none. */
|
||||
function readLoop(raw: unknown): LoopRange | null {
|
||||
const r = asRecord(raw);
|
||||
const start = asFinite(r.start);
|
||||
|
||||
Reference in new issue
Block a user