From 8f9c250eb6d1345b48f27f571ff6caf8b2df4cd1 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 6 Oct 2026 16:28:25 +0200 Subject: [PATCH] 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 of 4ea752f and 2a3b90b were 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 --- src/core/commands/assetCommands.ts | 3 + src/core/commands/require.ts | 7 + src/core/commands/songCommands.ts | 74 ++ src/core/model/Asset.ts | 14 + src/core/model/AssetTypes.ts | 6 +- src/core/model/placement.ts | 6 +- src/core/music/Scheduler.ts | 43 +- src/core/music/sequence.ts | 63 ++ src/core/music/song.ts | 244 +++++ src/core/properties/PropertySchema.ts | 38 + src/core/soundfx/pitchPlayer.ts | 4 +- src/core/store/serialize.ts | 15 +- src/editor/Editor.ts | 19 +- src/editor/actions.ts | 6 + src/editor/assetTabs.ts | 1 + src/editor/debug/songHooks.ts | 46 + src/editor/debugHook.ts | 2 + src/editor/inspector/Inspector.ts | 7 +- src/editor/inspector/songSection.ts | 210 ++++ src/editor/music/transport.ts | 15 + src/editor/panels/AssetPanel.ts | 8 +- src/editor/panels/assetGroups.ts | 1 + src/editor/panels/assetIcons.ts | 2 + src/editor/panels/icons/song.svg | 9 + src/editor/pattern/PatternEditor.ts | 4 + src/editor/song/SongEditor.ts | 181 ++++ src/editor/song/SongTimeline.ts | 1275 +++++++++++++++++++++++++ src/editor/song/songActions.ts | 36 + src/editor/song/songEdit.ts | 197 ++++ src/editor/song/songView.ts | 146 +++ src/editor/state.ts | 25 + src/runtime/systems/Audio.ts | 2 + src/viewport/SoundLibrary.ts | 136 ++- src/viewport/music.ts | 95 +- src/viewport/pitchWorklet.ts | 6 +- tests/core/song.test.ts | 317 ++++++ tests/editor/song-edit.test.ts | 149 +++ tests/viewport/sound-library.test.ts | 104 +- tools/e2e/04-assets.mjs | 2 +- tools/e2e/54-struct-types.mjs | 15 +- tools/e2e/65-song.mjs | 363 +++++++ tools/e2e/index.mjs | 2 + tools/lib/harness.mjs | 1 + 43 files changed, 3863 insertions(+), 36 deletions(-) create mode 100644 src/core/commands/songCommands.ts create mode 100644 src/core/music/song.ts create mode 100644 src/editor/debug/songHooks.ts create mode 100644 src/editor/inspector/songSection.ts create mode 100644 src/editor/music/transport.ts create mode 100644 src/editor/panels/icons/song.svg create mode 100644 src/editor/song/SongEditor.ts create mode 100644 src/editor/song/SongTimeline.ts create mode 100644 src/editor/song/songActions.ts create mode 100644 src/editor/song/songEdit.ts create mode 100644 src/editor/song/songView.ts create mode 100644 tests/core/song.test.ts create mode 100644 tests/editor/song-edit.test.ts create mode 100644 tools/e2e/65-song.mjs diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index acd753af..565ed66a 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -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() }; } diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index 6ff83636..d2bdacef 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -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`); diff --git a/src/core/commands/songCommands.ts b/src/core/commands/songCommands.ts new file mode 100644 index 00000000..afdc6691 --- /dev/null +++ b/src/core/commands/songCommands.ts @@ -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; + } +} diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 76997905..69b1a7d5 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -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`. */ diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index 9cc43475..f8ecb1a1 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -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 = { soundfx: 'Sound FX', instrument: 'Instrument', pattern: 'Pattern', + song: 'Song', particles: 'Particles', }; @@ -74,7 +76,7 @@ export const ASSET_TYPE_LABELS: Record = { * 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 = new Set([ 'soundfx', 'instrument', 'pattern', + 'song', 'variables', 'struct', ]); diff --git a/src/core/model/placement.ts b/src/core/model/placement.ts index 2406bf98..53572d15 100644 --- a/src/core/model/placement.ts +++ b/src/core/model/placement.ts @@ -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 ''; diff --git a/src/core/music/Scheduler.ts b/src/core/music/Scheduler.ts index 17b104ec..774743d2 100644 --- a/src/core/music/Scheduler.ts +++ b/src/core/music/Scheduler.ts @@ -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; diff --git a/src/core/music/sequence.ts b/src/core/music/sequence.ts index 12f0b8bd..7c117c65 100644 --- a/src/core/music/sequence.ts +++ b/src/core/music/sequence.ts @@ -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); +} diff --git a/src/core/music/song.ts b/src/core/music/song.ts new file mode 100644 index 00000000..81d8a7a1 --- /dev/null +++ b/src/core/music/song.ts @@ -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 { + 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(); + 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; + 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(); + 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; + 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); +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 4ce50393..ea199305 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 = { 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: [ diff --git a/src/core/soundfx/pitchPlayer.ts b/src/core/soundfx/pitchPlayer.ts index 29e234f3..b4b23c2e 100644 --- a/src/core/soundfx/pitchPlayer.ts +++ b/src/core/soundfx/pitchPlayer.ts @@ -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; diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index de026a21..e0ba49bc 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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); diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index ac57aa7f..f7903811 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -75,6 +75,7 @@ import { ShaderEditor } from './shader/ShaderEditor'; import { SoundFxEditor } from './soundfx/SoundFxEditor'; import { InstrumentEditor } from './instrument/InstrumentEditor'; import { PatternEditor } from './pattern/PatternEditor'; +import { SongEditor } from './song/SongEditor'; import { StateMachineEditor } from './statemachine/StateMachineEditor'; import { installShortcuts } from './shortcuts'; import type { PlayFrom } from '../core/model/playSettings'; @@ -99,6 +100,7 @@ export type CentreTab = | 'soundfx' | 'instrument' | 'pattern' + | 'song' | 'game' | 'texture' | 'vectorart' @@ -151,6 +153,7 @@ const PANE_ORDER = [ 'soundfx', 'instrument', 'pattern', + 'song', 'texture', 'vectorart', 'pixelart', @@ -173,6 +176,7 @@ const TAB_LABELS: Record = { soundfx: 'Sound FX', instrument: 'Instrument', pattern: 'Pattern', + song: 'Song', texture: 'Texture', vectorart: 'Vector art', pixelart: 'Pixel art', @@ -224,6 +228,7 @@ export class Editor implements PlayHost { readonly soundFxEditor: SoundFxEditor; readonly instrumentEditor: InstrumentEditor; readonly patternEditor: PatternEditor; + readonly songEditor: SongEditor; readonly textureEditor: TextureEditor; readonly vectorArtEditor: VectorArtEditor; readonly pixelArtEditor: PixelArtEditor; @@ -312,6 +317,7 @@ export class Editor implements PlayHost { this.soundFxEditor = new SoundFxEditor(state); this.instrumentEditor = new InstrumentEditor(state); this.patternEditor = new PatternEditor(state); + this.songEditor = new SongEditor(state); this.textureEditor = new TextureEditor(state); this.vectorArtEditor = new VectorArtEditor(state); this.pixelArtEditor = new PixelArtEditor(state); @@ -423,6 +429,7 @@ export class Editor implements PlayHost { soundfx: this.soundFxEditor, instrument: this.instrumentEditor, pattern: this.patternEditor, + song: this.songEditor, texture: this.textureEditor, vectorart: this.vectorArtEditor, pixelart: this.pixelArtEditor, @@ -495,6 +502,7 @@ export class Editor implements PlayHost { copy: () => (this.tab === 'animation' && this.animationEditor.canvas.copyKeys()) || (this.tab === 'pattern' && this.patternEditor.canvas.copy()) || + (this.tab === 'song' && this.songEditor.canvas.copy()) || copySelection(state), paste: () => this.paste(), removePoint: () => this.removePoint(), @@ -514,7 +522,8 @@ export class Editor implements PlayHost { // creating it) is for – see the `open` event below. state.on('selection', () => { const s = state.selection; - if (s?.kind === 'node' || s?.kind === 'frame') this.openDocument(`asset:${s.assetId}`); + if (s?.kind === 'node' || s?.kind === 'frame' || s?.kind === 'song') + this.openDocument(`asset:${s.assetId}`); if (s?.kind === 'object' && findAsset(state.project, s.holderId)?.type === 'container') this.openDocument(`asset:${s.holderId}`); }); @@ -594,6 +603,8 @@ export class Editor implements PlayHost { return this.curveEditor.canvas.removePickedPoint(); case 'pattern': return this.patternEditor.canvas.deleteSelection(); + case 'song': + return this.songEditor.canvas.deleteSelection(); default: return false; } @@ -622,6 +633,8 @@ export class Editor implements PlayHost { return this.instrumentEditor.canvas.paste(); case 'pattern': return this.patternEditor.canvas.paste(); + case 'song': + return this.songEditor.canvas.paste(); default: return false; } @@ -1009,6 +1022,7 @@ export class Editor implements PlayHost { // The preview is the editor's own, so it runs on while the game is paused. if (this.tab === 'curve') this.curveEditor.tick(dt); if (this.tab === 'pattern') this.patternEditor.tick(); + if (this.tab === 'song') this.songEditor.tick(); // Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session // to do it, and its states move just the same. if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw(); @@ -1042,7 +1056,8 @@ export class Editor implements PlayHost { (this.tab === 'animation' && this.animationEditor.isPlaying) || (this.tab === 'tile' && this.tileEditor.isPlaying) || (this.tab === 'curve' && this.curveEditor.isPlaying) || - (this.tab === 'pattern' && this.patternEditor.isPlaying) + (this.tab === 'pattern' && this.patternEditor.isPlaying) || + (this.tab === 'song' && this.songEditor.isPlaying) ); } diff --git a/src/editor/actions.ts b/src/editor/actions.ts index f54f8d52..0a8d45aa 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -38,6 +38,7 @@ import { findUsages, } from '../core/model/usages'; import { Dialog } from '../ui'; +import { removeSongItems, removeTrack } from './song/songActions'; import { type EditorState, graphSelection, objectSelection } from './state'; /** @@ -216,6 +217,11 @@ export function removeSelection(state: EditorState): boolean { : new RemoveAnimationTracksCommand(s.assetId, [s.trackId]), ); return true; + case 'song': + // The items when some are picked, else the track itself and what lies on it. + if (s.itemIds.length > 0) removeSongItems(state, s.assetId, s.itemIds); + else removeTrack(state, s.assetId, s.trackId); + return true; } } diff --git a/src/editor/assetTabs.ts b/src/editor/assetTabs.ts index cbde84f3..8cf96e58 100644 --- a/src/editor/assetTabs.ts +++ b/src/editor/assetTabs.ts @@ -12,6 +12,7 @@ export const TAB_OF_ASSET: Partial> = { soundfx: 'soundfx', instrument: 'instrument', pattern: 'pattern', + song: 'song', texture: 'texture', vectorart: 'vectorart', pixelart: 'pixelart', diff --git a/src/editor/debug/songHooks.ts b/src/editor/debug/songHooks.ts new file mode 100644 index 00000000..4cb0b5f6 --- /dev/null +++ b/src/editor/debug/songHooks.ts @@ -0,0 +1,46 @@ +import { findAssetOf } from '../../core/model/Project'; +import type { HeadPart } from '../song/songView'; +import { soundLibrary } from '../../viewport/SoundLibrary'; +import { centreOf, type HookContext } from './hookHelpers'; + +/** The Song tab and its tracks (#334). */ +export function songHooks({ state, editor }: HookContext) { + const { songEditor } = editor; + const timeline = songEditor.canvas; + return { + song: { + current: () => songEditor.currentAssetId, + info: () => songEditor.shownInfo, + rect: () => songEditor.canvasRect(), + /** The tracks, items and loop as the model holds them. */ + model: (assetId: string) => { + const asset = findAssetOf(state.project, assetId, 'song'); + return asset + ? (JSON.parse( + JSON.stringify({ tracks: asset.tracks, items: asset.items, loop: asset.loop ?? null }), + ) as unknown) + : null; + }, + picked: () => timeline.pickedIds, + pickedTrack: () => timeline.pickedTrack, + /** Where a tick of a track's lane, a part of an item, a part of a head and a tick of the ruler are on screen. */ + laneAt: (tick: number, track: number) => timeline.lanePoint(tick, track), + itemAt: (itemId: string, part?: 'start' | 'body' | 'end') => timeline.itemPoint(itemId, part), + headAt: (track: number, part: HeadPart, share?: number) => timeline.headPoint(track, part, share), + addTrackAt: () => timeline.addTrackPoint(), + rulerAt: (tick: number) => timeline.rulerPoint(tick), + view: () => timeline.viewInfo(), + items: () => timeline.itemsDrawn(), + playAt: () => ({ ...centreOf(songEditor.playButton), text: songEditor.playButton.text }), + fitAt: () => centreOf(songEditor.fitButton), + playing: () => songEditor.isPlaying, + /** The notes and clips the scheduler handed to the player since Play, and where it stands. */ + planned: () => [...songEditor.music.planned], + clips: () => [...songEditor.music.clips], + position: () => songEditor.music.current?.position() ?? null, + clock: () => soundLibrary.clock(), + stats: () => soundLibrary.stats(), + buses: () => soundLibrary.busLevels(), + }, + }; +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 2752429a..a32b2c75 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -14,6 +14,7 @@ import { } from '../viewport/SoundLibrary'; import { curveHooks } from './debug/curveHooks'; import { patternHooks } from './debug/patternHooks'; +import { songHooks } from './debug/songHooks'; import { documentHooks } from './debug/documentHooks'; import { graphHooks } from './debug/graphHooks'; import { centreOf, type HookContext } from './debug/hookHelpers'; @@ -46,6 +47,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E ...vectorArtHooks(context), ...curveHooks(context), ...patternHooks(context), + ...songHooks(context), ...renderHooks(context), /** * Voices started, stopped and in flight. CI runs the browser with `--mute-audio`, so nothing diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index dedcae33..cfeb6baf 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -16,6 +16,7 @@ import { objectSignature, refreshObjectSection, } from './objectSection'; +import { buildSongSection, refreshSongSection, songSignature } from './songSection'; import { buildTrackSection, refreshTrackSection, trackSignature } from './trackSection'; import { INLINE_LIMIT } from '../variables/target'; import { inlineListsIn } from '../variables/VariablesEditor'; @@ -131,6 +132,7 @@ export class Inspector extends Panel { else if (s.kind === 'annotation') buildAnnotationSection(this.host, s.assetId, s.annotationId); else if (s.kind === 'track') buildTrackSection(this.host, s.assetId, s.trackId, s.keys); else if (s.kind === 'edge') buildEdgeSection(this.host, s.assetId, s.edgeId, s.edgeIds); + else if (s.kind === 'song') buildSongSection(this.host, s.assetId, s.trackId, s.itemIds); else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds); else buildObjectSection(this.host, s.holderId, s.objectId); this.scroll.setContent(this.host.body); @@ -146,6 +148,7 @@ export class Inspector extends Panel { if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId); if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys); if (s.kind === 'edge') return edgeSignature(this.state.project, s.assetId, s.edgeId, s.edgeIds); + if (s.kind === 'song') return songSignature(this.state.project, s.assetId, s.trackId, s.itemIds); return objectSignature(this.state, s.holderId, s.objectIds, s.objectId); } @@ -179,7 +182,9 @@ export class Inspector extends Panel { ? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys) : s.kind === 'edge' ? refreshEdgeSection(this.host, s.assetId, s.edgeId) - : s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId); + : s.kind === 'song' + ? refreshSongSection(this.host, s.assetId, s.trackId, s.itemIds) + : s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId); if (!ok) this.rebuild(); } } diff --git a/src/editor/inspector/songSection.ts b/src/editor/inspector/songSection.ts new file mode 100644 index 00000000..801f78f3 --- /dev/null +++ b/src/editor/inspector/songSection.ts @@ -0,0 +1,210 @@ +import { SetSongCommand } from '../../core/commands/songCommands'; +import type { SongAsset } from '../../core/model/Asset'; +import { ASSET_TYPE_LABELS } from '../../core/model/AssetTypes'; +import { findAsset, findAssetOf, type Project } from '../../core/model/Project'; +import type { PropertyDef } from '../../core/properties/PropertySchema'; +import { + ITEM_MIN_DB, + MAX_TRANSPOSE, + type SongItem, + type SongTrack, + TRACK_MAX_DB, + TRACK_MIN_DB, +} from '../../core/music/song'; +import { Button } from '../../ui'; +import { removeSongItems, removeTrack } from '../song/songActions'; +import { createFieldRow } from './fieldFactory'; +import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host'; +import { previewingHandlers } from './previewHandlers'; + +const TRACK_ROWS: readonly PropertyDef[] = [ + { + key: 'volume', + label: 'Volume (dB)', + kind: 'number', + default: 0, + min: TRACK_MIN_DB, + max: TRACK_MAX_DB, + step: 0.5, + }, + { key: 'pan', label: 'Pan', kind: 'number', default: 0, min: -1, max: 1, step: 0.05 }, + { key: 'mute', label: 'Mute', kind: 'boolean', default: false }, + { key: 'solo', label: 'Solo', kind: 'boolean', default: false }, +]; + +/** The rows of an item: what it plays, how loud, and per kind how. */ +function itemRows(item: SongItem): PropertyDef[] { + const rows: PropertyDef[] = [ + { + key: 'asset', + label: ASSET_TYPE_LABELS[item.kind], + kind: 'asset', + default: '', + assetTypes: [item.kind], + }, + { key: 'volume', label: 'Volume (dB)', kind: 'number', default: 0, min: ITEM_MIN_DB, max: 0, step: 0.5 }, + ]; + if (item.kind === 'pattern') + rows.push( + { + key: 'transpose', + label: 'Transpose', + kind: 'number', + default: 0, + min: -MAX_TRANSPOSE, + max: MAX_TRANSPOSE, + step: 1, + decimals: 0, + }, + { key: 'instrument', label: 'Instrument', kind: 'asset', default: '', assetTypes: ['instrument'] }, + ); + else rows.push({ key: 'fx', label: 'Sound FX', kind: 'asset', default: '', assetTypes: ['soundfx'] }); + return rows; +} + +/** What decides the rows: whether it is the track or an item, and of what kind. */ +export function songSignature( + project: Project, + assetId: string, + trackId: string, + itemIds: readonly string[], +): string { + const asset = findAssetOf(project, assetId, 'song'); + const item = itemOf(asset, itemIds); + if (item) return `item:${item.id}:${item.kind}`; + return asset?.tracks.some((t) => t.id === trackId) ? `track:${trackId}` : 'missing'; +} + +/** The item picked last (#334), else the track: its rows and the way to delete it. */ +export function buildSongSection( + host: InspectorHost, + assetId: string, + trackId: string, + itemIds: readonly string[], +): void { + const asset = findAssetOf(host.state.project, assetId, 'song'); + if (!asset) return; + const item = itemOf(asset, itemIds); + if (item) return buildItem(host, asset, item, itemIds.length); + const track = asset.tracks.find((t) => t.id === trackId); + if (track) buildTrack(host, asset, track); +} + +export function refreshSongSection( + host: InspectorHost, + assetId: string, + trackId: string, + itemIds: readonly string[], +): boolean { + const asset = findAssetOf(host.state.project, assetId, 'song'); + const item = itemOf(asset, itemIds); + if (item) { + for (const def of itemRows(item)) host.fields.get(def.key)?.setValue(item[def.key as keyof SongItem]); + return true; + } + const track = asset?.tracks.find((t) => t.id === trackId); + if (!track) return false; + host.nameField?.setValue(track.name); + for (const def of TRACK_ROWS) host.fields.get(def.key)?.setValue(track[def.key as keyof SongTrack]); + return true; +} + +function buildTrack(host: InspectorHost, asset: SongAsset, track: SongTrack): void { + const { state } = host; + addTitle(host, 'SONG TRACK'); + addNameField(host, track.name, { + onChange: (name) => { + if (name.trim()) + state.execute( + new SetSongCommand( + asset.id, + { tracks: withTrack(state.project, asset.id, track.id, { name: name.trim() }) }, + 'Rename track', + ), + ); + else host.nameField?.setValue(track.name); + }, + }); + const count = asset.items.filter((i) => i.track === track.id).length; + addNote(host, `Track of the song "${asset.name}", ${count} ${count === 1 ? 'item' : 'items'}.`); + for (const def of TRACK_ROWS) { + const handlers = previewingHandlers(host, { + target: () => findAssetOf(state.project, asset.id, 'song')?.tracks.find((t) => t.id === track.id), + read: (target) => target[def.key as keyof SongTrack], + write: (target, value) => Object.assign(target, { [def.key]: value }), + commit: (value) => + new SetSongCommand( + asset.id, + { tracks: withTrack(state.project, asset.id, track.id, { [def.key]: value }) }, + `Set track ${def.key}`, + ), + }); + const row = createFieldRow(host, def, track[def.key as keyof SongTrack], handlers); + host.fields.set(def.key, row); + host.body.add(row.row); + } + addGap(host); + host.body.add( + new Button('Delete track', { + variant: 'danger', + onClick: () => removeTrack(state, asset.id, track.id), + }), + ); +} + +function buildItem(host: InspectorHost, asset: SongAsset, item: SongItem, picked: number): void { + const { state } = host; + addTitle(host, 'SONG ITEM'); + const track = asset.tracks.find((t) => t.id === item.track); + const what = findAsset(state.project, item.asset)?.name ?? 'missing'; + const others = picked > 1 ? ` ${picked - 1} more picked.` : ''; + addNote(host, `${ASSET_TYPE_LABELS[item.kind]} "${what}" on ${track?.name ?? 'a track'}.${others}`); + for (const def of itemRows(item)) { + const handlers = previewingHandlers(host, { + target: () => findAssetOf(state.project, asset.id, 'song')?.items.find((i) => i.id === item.id), + read: (target) => target[def.key as keyof SongItem], + write: (target, value) => Object.assign(target, { [def.key]: value }), + commit: (value) => + new SetSongCommand( + asset.id, + { items: withItem(state.project, asset.id, item.id, { [def.key]: value }) }, + `Set item ${def.key}`, + ), + }); + const row = createFieldRow(host, def, item[def.key as keyof SongItem], handlers); + host.fields.set(def.key, row); + host.body.add(row.row); + } + if (item.kind === 'pattern') addNote(host, 'An empty Instrument plays the pattern with its own.', 'small'); + addGap(host); + host.body.add( + new Button(picked > 1 ? 'Delete items' : 'Delete item', { + variant: 'danger', + onClick: () => { + const s = state.selection; + removeSongItems(state, asset.id, s?.kind === 'song' ? s.itemIds : [item.id]); + }, + }), + ); +} + +function itemOf(asset: SongAsset | undefined, itemIds: readonly string[]): SongItem | undefined { + const id = itemIds.at(-1); + return id === undefined ? undefined : asset?.items.find((i) => i.id === id); +} + +/** The tracks with one of them changed, read at that moment so another edit in between stays. */ +function withTrack( + project: Project, + assetId: string, + trackId: string, + patch: Partial, +): SongTrack[] { + const asset = findAssetOf(project, assetId, 'song'); + return (asset?.tracks ?? []).map((t) => (t.id === trackId ? { ...t, ...patch } : t)); +} + +function withItem(project: Project, assetId: string, itemId: string, patch: Partial): SongItem[] { + const asset = findAssetOf(project, assetId, 'song'); + return (asset?.items ?? []).map((i) => (i.id === itemId ? { ...i, ...patch } : i)); +} diff --git a/src/editor/music/transport.ts b/src/editor/music/transport.ts new file mode 100644 index 00000000..4b631415 --- /dev/null +++ b/src/editor/music/transport.ts @@ -0,0 +1,15 @@ +/** + * The editor plays one piece at a time (#334): a song or a pattern. Whoever starts playing claims the + * transport with its own stop, and whoever had it before is stopped. + */ +let holder: (() => void) | null = null; + +export function claimTransport(stop: () => void): void { + const before = holder; + holder = stop; + if (before && before !== stop) before(); +} + +export function releaseTransport(stop: () => void): void { + if (holder === stop) holder = null; +} diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index c5067e10..2d18a61d 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -328,10 +328,14 @@ export class AssetPanel extends Panel { return this.list.rowColor(assetId); } - /** Hands the asset to the viewport under the pointer; a scene has nowhere to be placed into. */ + /** + * Hands the asset to the view under the pointer: a scene places it, a song lays out a pattern or a + * sound (#334). A scene has nowhere to go. + */ private startPlacementDrag(assetId: string): void { const asset = findAsset(this.state.project, assetId); - if (!asset || !isPlaceableType(asset.type)) return; + if (!asset || !(isPlaceableType(asset.type) || asset.type === 'pattern' || asset.type === 'sound')) + return; startDrag({ kind: 'asset', id: assetId }, asset.name); } diff --git a/src/editor/panels/assetGroups.ts b/src/editor/panels/assetGroups.ts index 75757bca..49a2a1f0 100644 --- a/src/editor/panels/assetGroups.ts +++ b/src/editor/panels/assetGroups.ts @@ -38,6 +38,7 @@ export const ASSET_GROUP_LABELS: Record = { soundfx: 'Sound effects', instrument: 'Instruments', pattern: 'Patterns', + song: 'Songs', particles: 'Particles', }; diff --git a/src/editor/panels/assetIcons.ts b/src/editor/panels/assetIcons.ts index 8ae9541a..e850e527 100644 --- a/src/editor/panels/assetIcons.ts +++ b/src/editor/panels/assetIcons.ts @@ -25,6 +25,7 @@ import rigidbody from './icons/rigidbody.svg?raw'; import scene from './icons/scene.svg?raw'; import shader from './icons/shader.svg?raw'; import shaderfunction from './icons/shaderfunction.svg?raw'; +import song from './icons/song.svg?raw'; import sound from './icons/sound.svg?raw'; import soundfx from './icons/soundfx.svg?raw'; import sprite from './icons/sprite.svg?raw'; @@ -57,6 +58,7 @@ export const ASSET_ICON_SVG: Record = { scene, shader, shaderfunction, + song, sound, soundfx, sprite, diff --git a/src/editor/panels/icons/song.svg b/src/editor/panels/icons/song.svg new file mode 100644 index 00000000..3c0cda55 --- /dev/null +++ b/src/editor/panels/icons/song.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/editor/pattern/PatternEditor.ts b/src/editor/pattern/PatternEditor.ts index c9b9c53a..35102188 100644 --- a/src/editor/pattern/PatternEditor.ts +++ b/src/editor/pattern/PatternEditor.ts @@ -22,6 +22,7 @@ import { Button, Dropdown, Label, Menu } from '../../ui'; import { Music } from '../../viewport/music'; import { soundLibrary } from '../../viewport/SoundLibrary'; import { AssetTabEditor } from '../AssetTabEditor'; +import { claimTransport, releaseTransport } from '../music/transport'; import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons'; import type { EditorState } from '../state'; import { PianoRoll } from './PianoRoll'; @@ -47,6 +48,7 @@ export class PatternEditor extends AssetTabEditor { private playingId = ''; /** What the scheduler was last told, so only a change is passed on. */ private told = ''; + private readonly halt = () => this.stop(); constructor(state: EditorState) { super(state, { infoWidth: INFO_WIDTH }); @@ -89,6 +91,7 @@ export class PatternEditor extends AssetTabEditor { play(): void { const asset = this.asset(); if (!asset) return; + claimTransport(this.halt); this.playingId = asset.id; this.told = this.settingsOf(asset); void this.music.play(patternSequence(asset), { @@ -101,6 +104,7 @@ export class PatternEditor extends AssetTabEditor { } stop(): void { + releaseTransport(this.halt); this.music.stop(); this.playingId = ''; this.canvas.playhead = null; diff --git a/src/editor/song/SongEditor.ts b/src/editor/song/SongEditor.ts new file mode 100644 index 00000000..2b38186c --- /dev/null +++ b/src/editor/song/SongEditor.ts @@ -0,0 +1,181 @@ +import { SetAssetPropertyCommand } from '../../core/commands/assetCommands'; +import { + SONG_GRID_KEY, + SONG_GRID_OPTIONS, + songLoop, + songMeter, + songSequence, + songTempo, + type SongTrack, + trackBus, + trackLevel, +} from '../../core/music/song'; +import type { Asset, SongAsset } from '../../core/model/Asset'; +import { textOf } from '../../core/model/Properties'; +import { Button, Dropdown, Label, Menu } from '../../ui'; +import { type BusLevel, Music } from '../../viewport/music'; +import { activateAsset } from '../actions'; +import { AssetTabEditor } from '../AssetTabEditor'; +import { claimTransport, releaseTransport } from '../music/transport'; +import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons'; +import type { EditorState } from '../state'; +import { SongTimeline } from './SongTimeline'; + +const INFO_WIDTH = 200; +const GRID_WIDTH = 64; + +/** + * The Song tab (#334): the tracks and their items, and in the header Play – the Space bar does the + * same – and the grid items snap to. The song plays round its loop through the scheduler (#332); an + * edit is heard from its next window on, a track's level and pan at once. + */ +export class SongEditor extends AssetTabEditor { + /** Public for the check scripts. */ + readonly playButton: Button; + readonly gridButton = new Dropdown('Bar', { width: GRID_WIDTH }); + readonly music = new Music(() => this.state.project); + /** The song playing, `''` while none is. */ + private playingId = ''; + /** What the scheduler was last told, so only a change is passed on. */ + private told = ''; + private readonly halt = () => this.stop(); + + constructor(state: EditorState) { + super(state, { infoWidth: INFO_WIDTH }); + this.playButton = new Button('Play', { + icon: TOOLBAR_ICON_SVG.play, + minWidth: 72, + tooltip: 'Play the song round its loop (Space)', + onClick: () => this.togglePlay(), + }); + this.gridButton.onClick = () => this.openGridMenu(); + const timeline = new SongTimeline(state); + timeline.onTogglePlay = () => this.togglePlay(); + timeline.onStart = (tick) => { + if (this.isPlaying) this.music.current?.seek(tick); + }; + timeline.onTracksPreview = (tracks) => { + const asset = this.asset(); + if (asset && this.isPlaying) this.music.setBuses(busesOf(asset.id, tracks)); + }; + timeline.onOpenPattern = (patternId) => activateAsset(state, patternId); + this.mount(timeline, [this.playButton, new Label('Grid', { style: 'muted' }), this.gridButton]); + state.on('load', () => this.stop()); + } + + override selectedId(): string { + return this.state.selectedSongId ?? ''; + } + + override isMine(asset: Asset): asset is SongAsset { + return asset.type === 'song'; + } + + get isPlaying(): boolean { + return this.playingId !== ''; + } + + togglePlay(): void { + if (this.isPlaying) this.stop(); + else this.play(); + } + + /** Plays the song shown from where the ruler says, round its loop. */ + play(): void { + const asset = this.asset(); + if (!asset) return; + claimTransport(this.halt); + this.playingId = asset.id; + this.told = this.settingsOf(asset); + this.music.setBuses(busesOf(asset.id, asset.tracks)); + void this.music.play(songSequence(this.state.project, asset), { + tempo: songTempo(asset.props), + meter: songMeter(asset.props), + loop: songLoop(asset), + from: this.canvas.start, + }); + this.syncPlayButton(); + } + + stop(): void { + releaseTransport(this.halt); + this.music.stop(); + this.playingId = ''; + this.canvas.playhead = null; + this.canvas.invalidate(); + this.syncPlayButton(); + } + + /** Only called while the tab is visible: the playhead follows what is heard. */ + tick(): void { + if (!this.isPlaying) return; + this.canvas.playhead = this.music.current?.position().ticks ?? null; + this.canvas.invalidate(); + } + + override destroy(options?: Parameters['destroy']>[0]): void { + this.music.stop(); + super.destroy(options); + } + + private openGridMenu(): void { + const asset = this.asset(); + if (!asset) return; + const current = textOf(asset.props[SONG_GRID_KEY] ?? 'bar'); + new Menu( + SONG_GRID_OPTIONS.map((option) => ({ + label: option.label, + current: option.value === current, + onSelect: () => + this.state.execute(new SetAssetPropertyCommand(asset.id, SONG_GRID_KEY, option.value)), + })), + ).openBelow(this.gridButton); + } + + private settingsOf(asset: SongAsset): string { + return JSON.stringify([songTempo(asset.props), songMeter(asset.props), songLoop(asset)]); + } + + /** What changed while playing goes to the scheduler and the tracks; another song shown stops it. */ + private follow(asset: SongAsset | null): void { + if (!this.isPlaying) return; + if (!asset || asset.id !== this.playingId) { + this.stop(); + return; + } + this.music.edit(songSequence(this.state.project, asset)); + this.music.setBuses(busesOf(asset.id, asset.tracks)); + const settings = this.settingsOf(asset); + if (settings === this.told) return; + this.told = settings; + const scheduler = this.music.current; + scheduler?.setTempo(songTempo(asset.props)); + scheduler?.setMeter(songMeter(asset.props)); + scheduler?.setLoop(songLoop(asset)); + } + + private syncPlayButton(): void { + this.playButton.setIcon(this.isPlaying ? TOOLBAR_ICON_SVG.stop : TOOLBAR_ICON_SVG.play); + this.playButton.setText(this.isPlaying ? 'Stop' : 'Play'); + } + + protected override infoText(asset: SongAsset): string { + const tracks = asset.tracks.length; + const items = asset.items.length; + return `${tracks} ${tracks === 1 ? 'track' : 'tracks'} · ${items} ${items === 1 ? 'item' : 'items'}`; + } + + protected override syncExtras(asset: SongAsset | null): void { + this.fitButton.setEnabled(asset !== null); + this.playButton.setEnabled(asset !== null); + this.gridButton.setEnabled(asset !== null); + const grid = asset ? textOf(asset.props[SONG_GRID_KEY] ?? 'bar') : 'bar'; + this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? 'Bar'); + this.follow(asset); + } +} + +/** The level and pan each track's bus is set to. */ +export function busesOf(songId: string, tracks: readonly SongTrack[]): BusLevel[] { + return tracks.map((track) => ({ name: trackBus(songId, track.id), ...trackLevel(track, tracks) })); +} diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts new file mode 100644 index 00000000..f172f66d --- /dev/null +++ b/src/editor/song/SongTimeline.ts @@ -0,0 +1,1275 @@ +import { + Container, + type FederatedPointerEvent, + type FederatedWheelEvent, + Graphics, + type Text, +} from 'pixi.js'; +import { SetSongCommand, SetSongLoopCommand } from '../../core/commands/songCommands'; +import { patternLength } from '../../core/music/pattern'; +import type { LoopRange } from '../../core/music/sequence'; +import { + itemEnd, + newItem, + newTrack, + type SongItem, + type SongItemKind, + songGrid, + songLength, + songLoop, + songMeter, + songTempo, + type SongTrack, + TRACK_MAX_DB, + TRACK_MIN_DB, +} from '../../core/music/song'; +import { secondsOfTicks, ticksPerBar } from '../../core/music/time'; +import type { SongAsset } from '../../core/model/Asset'; +import type { Vec2 } from '../../core/model/Properties'; +import { findAsset, findAssetOf } from '../../core/model/Project'; +import { + activeDrag, + crispText, + drawBand, + endDrag, + Menu, + onDragChange, + pressCount, + type Size, + textStyle, + Theme, + ui, + Widget, + zoomFactor, +} from '../../ui'; +import { soundLibrary } from '../../viewport/SoundLibrary'; +import { TimeRuler } from '../music/TimeRuler'; +import { + clampTimePan, + fitTime, + RULER_HEIGHT, + tickToX, + timeLines, + xToTick, + zoomTime, +} from '../music/timeView'; +import { snapDown, snapNear } from '../pattern/rollEdit'; +import type { EditorState } from '../state'; +import { removeTrack } from './songActions'; +import { + clampMove, + draggedLength, + droppedLength, + duplicateOffset, + moveDelta, + moved, + placedCopies, + resized, + split, + trimDelta, + trimmed, +} from './songEdit'; +import { + ADD_ROW_HEIGHT, + type Box, + clampTracks, + HEAD_BOXES, + HEAD_WIDTH, + type HeadPart, + headPartAt, + indexOf, + itemAt, + itemBox, + itemsWithin, + laneTick, + nearestTrack, + sliderShare, + sliderValue, + type SongView, + TRACK_HEIGHT, + trackAtY, + trackTop, +} from './songView'; + +const DRAG_THRESHOLD = 3; +/** How close to an end of the loop a press on the ruler takes that end. */ +const LOOP_EDGE_REACH = 4; +const PATTERN_COLOR = 0x35598f; +const SOUND_COLOR = 0x2f6f52; +const MISSING_COLOR = 0x6b3a3a; +const MUTE_COLOR = 0xd08a2c; +const SOLO_COLOR = 0xc9c23a; +/** Height of an item's name strip; what it plays is drawn below. */ +const LABEL_STRIP = 15; +/** The letter on a lit mute or solo button. */ +const DARK_LABEL = textStyle('small').clone(); +DARK_LABEL.fill = 0x30343c; + +interface Placed { + text: string; + x: number; + y: number; + w: number; + dark?: boolean; +} + +type Area = 'corner' | 'ruler' | 'heads' | 'lanes'; + +type Gesture = + | { kind: 'pan'; pointer: Vec2; panX: number; panY: number } + | { + kind: 'ruler'; + from: number; + pointerX: number; + active: boolean; + loop: LoopRange | null; + free: boolean; + edge: 'start' | 'end' | null; + } + | { kind: 'empty'; start: Vec2; current: Vec2; active: boolean; add: boolean; track: string } + | { + kind: 'move'; + anchor: SongItem; + grab: number; + ticks: number; + tracks: number; + moved: boolean; + pointer: Vec2; + } + | { kind: 'resize'; anchor: SongItem; by: number } + | { kind: 'trim'; anchor: SongItem; by: number } + | { kind: 'erase'; erased: Set } + | { kind: 'slider'; track: string; part: 'volume' | 'pan'; value: number }; + +/** An asset dragged in from the panel, where it would land. */ +interface Drop { + kind: SongItemKind; + asset: string; + track: string; + tick: number; + length: number; +} + +/** + * The Song tab's canvas (#334): the heads of the tracks down the left, a ruler along the top and the + * items on the lanes between. An asset dragged in from the panel lands as an item; an item is moved + * by its body, lengthened by its right end and cut back by its left one, and goes with a right-click + * or Delete. A head sets its track's mute, solo, volume and pan. What is picked lives in the editor's + * selection, so the inspector shows it. + */ +export class SongTimeline extends Widget { + private readonly bg = new Graphics(); + private readonly lanesLayer = new Container(); + private readonly lanesG = new Graphics(); + private readonly itemsG = new Graphics(); + private readonly overlay = new Graphics(); + private readonly itemLabels = new Container(); + private readonly itemTexts: Text[] = []; + private readonly headsLayer = new Container(); + private readonly headsG = new Graphics(); + private readonly headLabels = new Container(); + private readonly headTexts: Text[] = []; + private readonly ruler = new TimeRuler(); + private readonly clips = { lanes: new Graphics(), heads: new Graphics(), ruler: new Graphics() }; + private readonly hint = crispText({ text: '', style: textStyle('muted') }); + private readonly addLabel = crispText({ text: '+ Track', style: textStyle('muted') }); + private view: SongView = { panX: 0, pxPerTick: 0.02, panY: 0 }; + private needsFit = true; + private assetId = ''; + private gesture: Gesture | null = null; + private drop: Drop | null = null; + /** Where playing starts and a paste lands, in ticks. */ + private startTick = 0; + /** What Ctrl+C took, for any song of the project. */ + private clipboard: SongItem[] = []; + /** Sounds asked for, so a waveform is drawn once its bytes are there. */ + private readonly loading = new Set(); + /** Where the playhead stands while the song plays, in ticks; null while it does not. */ + playhead: number | null = null; + onTogglePlay: () => void = () => {}; + /** Where playing starts was moved: a playing song jumps there. */ + onStart: (tick: number) => void = () => {}; + /** The tracks as a slider in a head would leave them: heard at once, written when it lets go. */ + onTracksPreview: (tracks: readonly SongTrack[]) => void = () => {}; + /** A pattern item was double-clicked. */ + onOpenPattern: (patternId: string) => void = () => {}; + + constructor(private readonly state: EditorState) { + super(); + this.focusable = true; + this.lanesLayer.addChild(this.lanesG, this.itemsG, this.itemLabels, this.overlay); + this.headsLayer.addChild(this.headsG, this.headLabels, this.addLabel); + this.addChild(this.bg, this.lanesLayer, this.headsLayer, this.ruler, this.hint); + for (const clip of Object.values(this.clips)) this.addChild(clip); + this.lanesLayer.mask = this.clips.lanes; + this.headsLayer.mask = this.clips.heads; + this.ruler.mask = this.clips.ruler; + this.hint.eventMode = 'none'; + this.itemLabels.eventMode = 'none'; + this.headLabels.eventMode = 'none'; + this.addLabel.eventMode = 'none'; + + this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e)); + this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); + this.onPointerRelease(() => this.onPointerUp()); + this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); + state.on('project', () => this.invalidate()); + state.on('selection', () => this.invalidate()); + // A drag can end anywhere, and this view never hears about it: the ghost would stay without this. + onDragChange(() => { + if (!activeDrag() && this.drop) { + this.drop = null; + this.invalidate(); + } + }); + } + + override measure(): Size { + return { w: Math.max(this.minWidth, 360), h: Math.max(this.minHeight, 200) }; + } + + get currentAssetId(): string { + return this.assetId; + } + + /** Another song starts playing from its start, framed. */ + show(assetId: string): void { + if (this.assetId === assetId) return; + this.gesture = null; + this.drop = null; + this.assetId = assetId; + this.startTick = 0; + this.needsFit = true; + this.redraw(); + } + + saveView(): SongView { + return { ...this.view }; + } + + restoreView(view: unknown): void { + this.view = { ...(view as SongView) }; + this.needsFit = false; + this.redraw(); + } + + /** The whole song across, from its first track down. */ + fit(): void { + const asset = this.asset(); + if (!asset || this.lanesWidth <= 0 || this.lanesHeight <= 0) { + this.needsFit = true; + return; + } + this.needsFit = false; + this.view = { ...fitTime(songLength(asset), this.lanesWidth), panY: 0 }; + this.redraw(); + } + + get start(): number { + return this.startTick; + } + + /** The items picked, by id, when the selection is about this song. */ + get pickedIds(): string[] { + const s = this.state.selection; + return s?.kind === 'song' && s.assetId === this.assetId ? s.itemIds : []; + } + + /** The track the selection is on, `''` when it is about something else. */ + get pickedTrack(): string { + const s = this.state.selection; + return s?.kind === 'song' && s.assetId === this.assetId ? s.trackId : ''; + } + + pick(itemIds: readonly string[], trackId?: string): void { + const asset = this.asset(); + if (!asset) return; + const last = asset.items.find((i) => i.id === itemIds.at(-1)); + const track = trackId ?? last?.track ?? (this.pickedTrack || asset.tracks[0]?.id || ''); + this.state.select({ kind: 'song', assetId: asset.id, trackId: track, itemIds: [...itemIds] }); + } + + // --- what the keys and the menus do ---------------------------------------------------------- + + selectAll(): boolean { + const asset = this.asset(); + if (!asset) return false; + this.pick(asset.items.map((i) => i.id)); + return true; + } + + deleteSelection(): boolean { + const asset = this.asset(); + const picked = new Set(this.pickedIds); + if (!asset || picked.size === 0) return false; + this.commit( + asset, + { items: asset.items.filter((i) => !picked.has(i.id)) }, + picked.size === 1 ? 'Delete item' : 'Delete items', + ); + this.pick([]); + return true; + } + + copy(): boolean { + const asset = this.asset(); + const picked = new Set(this.pickedIds); + if (!asset || picked.size === 0) return false; + this.clipboard = asset.items.filter((i) => picked.has(i.id)).map((i) => ({ ...i })); + const n = this.clipboard.length; + this.state.setNotice(`Copied ${n} ${n === 1 ? 'item' : 'items'}`, 'quiet'); + return true; + } + + /** Lands at the start of playing, on the tracks it came from; false with nothing copied. */ + paste(): boolean { + const asset = this.asset(); + if (!asset || this.clipboard.length === 0) return false; + const copies = placedCopies( + this.clipboard, + this.startTick, + asset.tracks.map((t) => t.id), + ); + this.commit( + asset, + { items: [...asset.items, ...copies] }, + copies.length === 1 ? 'Paste item' : 'Paste items', + ); + this.pick(copies.map((i) => i.id)); + return true; + } + + /** A copy of the picked items right behind them, picked in their place. */ + duplicate(): boolean { + const asset = this.asset(); + const picked = new Set(this.pickedIds); + if (!asset || picked.size === 0) return false; + const chosen = asset.items.filter((i) => picked.has(i.id)); + const by = duplicateOffset(asset.items, picked, songGrid(asset.props)); + const copies = placedCopies( + chosen, + Math.min(...chosen.map((i) => i.tick)) + by, + asset.tracks.map((t) => t.id), + ); + this.commit(asset, { items: [...asset.items, ...copies] }, 'Duplicate items'); + this.pick(copies.map((i) => i.id)); + return true; + } + + /** Cuts the picked items where playing starts; the right-hand parts are picked afterwards. */ + splitAtStart(): boolean { + const asset = this.asset(); + const picked = new Set(this.pickedIds); + if (!asset || picked.size === 0) return false; + const cut = split(asset.items, picked, this.startTick, songTempo(asset.props)); + if (cut.right.length === 0) { + this.state.setNotice('Where playing starts is not inside a picked item'); + return true; + } + this.commit(asset, { items: cut.items }, cut.right.length === 1 ? 'Split item' : 'Split items'); + this.pick(cut.right); + return true; + } + + /** Moves the picked items by ticks and tracks, as far as they can go. */ + nudge(ticks: number, tracks: number): boolean { + const asset = this.asset(); + const picked = new Set(this.pickedIds); + if (!asset || picked.size === 0) return false; + const by = clampMove(asset.items, picked, indexOf(asset.tracks), asset.tracks.length, ticks, tracks); + if (by.ticks !== 0 || by.tracks !== 0) + this.commit( + asset, + { + items: moved( + asset.items, + picked, + asset.tracks.map((t) => t.id), + by.ticks, + by.tracks, + ), + }, + picked.size === 1 ? 'Move item' : 'Move items', + ); + return true; + } + + /** A new track under the others, or at `index`. */ + addTrack(index?: number): boolean { + const asset = this.asset(); + if (!asset) return false; + const track = newTrack(asset.tracks.length + 1); + const tracks = [...asset.tracks]; + tracks.splice(index ?? tracks.length, 0, track); + this.commit(asset, { tracks }, 'Add track'); + this.pick([], track.id); + return true; + } + + override onKeyDown(event: KeyboardEvent): boolean { + const asset = this.asset(); + if (!asset) return false; + const mod = event.ctrlKey || event.metaKey; + const key = event.key.toLowerCase(); + if (event.code === 'Space' && !mod) { + this.onTogglePlay(); + return true; + } + if (mod && key === 'c') return this.copy(); + if (mod && key === 'v') return this.paste(); + if (mod && key === 'd') return this.duplicate() || true; + if (mod && key === 'e') return this.splitAtStart() || true; + if (mod && key === 'a') return this.selectAll(); + if (mod) return false; + // Taken even with nothing picked: a Delete must not go on to ask about the song itself. + if (event.key === 'Delete' || event.key === 'Backspace') { + if (!this.deleteSelection() && this.pickedTrack) removeTrack(this.state, asset.id, this.pickedTrack); + return true; + } + if (event.key === 'Escape' && this.pickedIds.length > 0) { + this.pick([]); + return true; + } + const grid = songGrid(asset.props); + const bar = ticksPerBar(songMeter(asset.props)); + if (event.key === 'ArrowUp') return this.nudge(0, -1); + if (event.key === 'ArrowDown') return this.nudge(0, 1); + if (event.key === 'ArrowRight') return this.nudge(event.shiftKey ? bar : grid, 0); + if (event.key === 'ArrowLeft') return this.nudge(event.shiftKey ? -bar : -grid, 0); + return false; + } + + // --- for the check scripts ------------------------------------------------------------------- + + /** The middle of a track's lane at a tick, on screen. */ + lanePoint(tick: number, track: number): Vec2 { + const at = this.getGlobalPosition(); + return { + x: at.x + HEAD_WIDTH + tickToX(this.view, tick), + y: at.y + RULER_HEIGHT + trackTop(this.view, track) + TRACK_HEIGHT / 2, + }; + } + + /** A point of an item: the middle of its body or of one of its ends. */ + itemPoint(itemId: string, part: 'start' | 'body' | 'end' = 'body'): Vec2 | null { + const asset = this.asset(); + const item = asset?.items.find((i) => i.id === itemId); + const row = item && indexOf(asset!.tracks).get(item.track); + if (!item || row === undefined) return null; + const box = itemBox(this.view, row, item); + const at = this.getGlobalPosition(); + const x = part === 'start' ? box.x + 2 : part === 'end' ? box.x + box.w - 2 : box.x + box.w / 2; + return { x: at.x + HEAD_WIDTH + x, y: at.y + RULER_HEIGHT + box.y + box.h / 2 }; + } + + /** The middle of a part of a track's head, or a share across its slider. */ + headPoint(track: number, part: HeadPart, share = 0.5): Vec2 { + const at = this.getGlobalPosition(); + const box = HEAD_BOXES[part]; + const slider = part === 'volume' || part === 'pan'; + return { + x: at.x + box.x + (slider ? box.w * share : box.w / 2), + y: at.y + RULER_HEIGHT + trackTop(this.view, track) + box.y + box.h / 2, + }; + } + + addTrackPoint(): Vec2 { + const asset = this.asset(); + const at = this.getGlobalPosition(); + const top = trackTop(this.view, asset?.tracks.length ?? 0); + return { x: at.x + HEAD_WIDTH / 2, y: at.y + RULER_HEIGHT + top + ADD_ROW_HEIGHT / 2 }; + } + + rulerPoint(tick: number): Vec2 { + const at = this.getGlobalPosition(); + return { x: at.x + HEAD_WIDTH + tickToX(this.view, tick), y: at.y + RULER_HEIGHT - 6 }; + } + + viewInfo(): SongView & { + start: number; + playhead: number | null; + loop: LoopRange | null; + drop: Drop | null; + } { + return { + ...this.view, + start: this.startTick, + playhead: this.playhead, + loop: this.shownLoop(), + drop: this.drop, + }; + } + + /** The items as drawn: their name, whether they hold something to show, and their box on screen. */ + itemsDrawn(): { id: string; label: string; content: 'notes' | 'wave' | 'none'; box: Box }[] { + const asset = this.asset(); + if (!asset) return []; + const rows = indexOf(asset.tracks); + const at = this.getGlobalPosition(); + return this.shownItems(asset).flatMap((item) => { + const row = rows.get(item.track); + if (row === undefined) return []; + const box = itemBox(this.view, row, item); + return [ + { + id: item.id, + label: this.labelOf(item), + content: this.contentOf(item), + box: { x: at.x + HEAD_WIDTH + box.x, y: at.y + RULER_HEIGHT + box.y, w: box.w, h: box.h }, + }, + ]; + }); + } + + // --- pointer --------------------------------------------------------------------------------- + + private get lanesWidth(): number { + return Math.max(0, this.w - HEAD_WIDTH); + } + + private get lanesHeight(): number { + return Math.max(0, this.h - RULER_HEIGHT); + } + + private asset(): SongAsset | null { + return this.assetId ? (findAssetOf(this.state.project, this.assetId, 'song') ?? null) : null; + } + + private areaAt(p: Vec2): Area { + const left = p.x < HEAD_WIDTH; + if (p.y < RULER_HEIGHT) return left ? 'corner' : 'ruler'; + return left ? 'heads' : 'lanes'; + } + + /** A point of the widget in the lanes' own pixels. */ + private inLanes(p: Vec2): Vec2 { + return { x: p.x - HEAD_WIDTH, y: p.y - RULER_HEIGHT }; + } + + private onPointerDown(e: FederatedPointerEvent): void { + const asset = this.asset(); + if (!asset) return; + const local = this.toLocal(e.global); + const p = { x: local.x, y: local.y }; + const area = this.areaAt(p); + e.stopPropagation(); + ui().focus.focus(this); + if (e.button === 1) { + this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY }; + return; + } + const g = this.inLanes(p); + const tick = xToTick(this.view, g.x); + if (area === 'heads') return this.pressHead(asset, e, g.y, p.x); + if (e.button === 2) { + if (area === 'ruler') this.openRulerMenu(asset, Math.max(0, tick), e.global); + if (area === 'lanes') { + this.gesture = { kind: 'erase', erased: new Set() }; + this.erase(asset, g); + } + this.invalidate(); + return; + } + if (e.button !== 0) return; + if (area === 'ruler') { + const loop = songLoop(asset); + const near = (t: number) => Math.abs(tickToX(this.view, t) - g.x) <= LOOP_EDGE_REACH; + const edge = near(loop.end) ? 'end' : near(loop.start) ? 'start' : null; + this.gesture = { + kind: 'ruler', + from: Math.max(0, tick), + pointerX: g.x, + active: false, + loop: null, + free: e.altKey, + edge, + }; + } else if (area === 'lanes') this.pressLanes(asset, e, g); + this.invalidate(); + } + + private pressHead(asset: SongAsset, e: FederatedPointerEvent, y: number, x: number): void { + const row = trackAtY(this.view, asset.tracks.length, y); + if (row === null) { + const below = y - trackTop(this.view, asset.tracks.length); + if (e.button === 0 && below >= 0 && below < ADD_ROW_HEIGHT) this.addTrack(); + return; + } + const track = asset.tracks[row]!; + if (e.button === 2) { + this.openTrackMenu(asset, row, e.global); + return; + } + if (e.button !== 0) return; + const part = headPartAt(x, y - trackTop(this.view, row)); + if (part === 'mute' || part === 'solo') { + const label = + part === 'mute' + ? track.mute + ? 'Unmute track' + : 'Mute track' + : track.solo + ? 'Unsolo track' + : 'Solo track'; + this.commit( + asset, + { tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, [part]: !t[part] } : t)) }, + label, + ); + } else if (part === 'volume' || part === 'pan') { + const value = this.sliderAt(part, x); + this.gesture = { kind: 'slider', track: track.id, part, value }; + this.onTracksPreview(this.shownTracks(asset)); + } + if (this.pickedTrack !== track.id || this.pickedIds.length > 0) this.pick([], track.id); + this.invalidate(); + } + + private sliderAt(part: 'volume' | 'pan', x: number): number { + return part === 'volume' + ? sliderValue(x, HEAD_BOXES.volume, TRACK_MIN_DB, TRACK_MAX_DB, 0.5) + : sliderValue(x, HEAD_BOXES.pan, -1, 1, 0.05); + } + + private pressLanes(asset: SongAsset, e: FederatedPointerEvent, g: Vec2): void { + const hit = itemAt(this.view, asset.items, asset.tracks, g); + const anchor = hit ? asset.items.find((i) => i.id === hit.id) : undefined; + const row = nearestTrack(this.view, asset.tracks.length, g.y); + if (!hit || !anchor) { + this.gesture = { + kind: 'empty', + start: g, + current: g, + active: false, + add: e.shiftKey, + track: asset.tracks[row]?.id ?? '', + }; + return; + } + if (pressCount(e, `song-item:${anchor.id}`) === 2 && anchor.kind === 'pattern') { + this.onOpenPattern(anchor.asset); + return; + } + const picked = this.pickedIds; + if (e.shiftKey) { + this.pick( + picked.includes(anchor.id) ? picked.filter((id) => id !== anchor.id) : [...picked, anchor.id], + ); + return; + } + if (!picked.includes(anchor.id)) this.pick([anchor.id]); + if (hit.part === 'end') this.gesture = { kind: 'resize', anchor, by: 0 }; + else if (hit.part === 'start') this.gesture = { kind: 'trim', anchor, by: 0 }; + else { + const grab = xToTick(this.view, g.x) - anchor.tick; + this.gesture = { kind: 'move', anchor, grab, ticks: 0, tracks: 0, moved: false, pointer: g }; + } + } + + private onPointerMove(e: FederatedPointerEvent): void { + const local = this.toLocal(e.global); + const g = this.inLanes({ x: local.x, y: local.y }); + const asset = this.asset(); + const dragged = activeDrag(); + if (dragged?.kind === 'asset' && asset) { + this.trackDrop(asset, dragged.id, local, g); + return; + } + const gesture = this.gesture; + if (!gesture || !asset) return; + const tick = xToTick(this.view, g.x); + const grid = songGrid(asset.props); + const free = e.altKey; + const picked = new Set(this.pickedIds); + switch (gesture.kind) { + case 'pan': + this.view = clampTracks( + clampTimePan({ + ...this.view, + panX: gesture.panX + (local.x - gesture.pointer.x), + panY: gesture.panY + (local.y - gesture.pointer.y), + }), + asset.tracks.length, + this.lanesHeight, + ); + break; + case 'ruler': { + gesture.free = free; + if (!gesture.active && Math.abs(g.x - gesture.pointerX) < DRAG_THRESHOLD) return; + gesture.active = true; + const snap = (t: number) => Math.max(0, free ? Math.round(t) : snapNear(t, grid)); + const at = snap(tick); + const { start, end } = songLoop(asset); + if (gesture.edge === 'start') gesture.loop = { start: Math.min(at, end - 1), end }; + else if (gesture.edge === 'end') gesture.loop = { start, end: Math.max(at, start + 1) }; + else { + const from = snap(gesture.from); + gesture.loop = at === from ? null : { start: Math.min(at, from), end: Math.max(at, from) }; + } + break; + } + case 'empty': { + gesture.current = g; + const span = Math.max(Math.abs(g.x - gesture.start.x), Math.abs(g.y - gesture.start.y)); + if (!gesture.active && span < DRAG_THRESHOLD) return; + gesture.active = true; + break; + } + case 'move': { + if (!gesture.moved && Math.hypot(g.x - gesture.pointer.x, g.y - gesture.pointer.y) < DRAG_THRESHOLD) + return; + gesture.moved = true; + const row = nearestTrack(this.view, asset.tracks.length, g.y); + const by = moveDelta( + asset.items, + picked, + indexOf(asset.tracks), + asset.tracks.length, + gesture.anchor, + tick - gesture.grab, + row, + grid, + free, + ); + gesture.ticks = by.ticks; + gesture.tracks = by.tracks; + break; + } + case 'resize': + gesture.by = draggedLength(gesture.anchor, tick, grid, free) - gesture.anchor.length; + break; + case 'trim': + gesture.by = trimDelta(asset.items, picked, gesture.anchor, tick, grid, free, songTempo(asset.props)); + break; + case 'erase': + this.erase(asset, g); + break; + case 'slider': + gesture.value = this.sliderAt(gesture.part, local.x); + this.onTracksPreview(this.shownTracks(asset)); + break; + } + this.invalidate(); + } + + private onPointerUp(): void { + const drop = this.drop; + const asset = this.asset(); + if (drop && asset) { + this.drop = null; + endDrag(); + const item = newItem(drop); + this.commit(asset, { items: [...asset.items, item] }, 'Add item'); + this.pick([item.id]); + return; + } + const gesture = this.gesture; + this.gesture = null; + if (!gesture || !asset) return; + const picked = new Set(this.pickedIds); + const grid = songGrid(asset.props); + switch (gesture.kind) { + case 'ruler': + if (gesture.active) { + const same = JSON.stringify(gesture.loop) === JSON.stringify(songLoop(asset)); + if (gesture.loop && !same) this.state.execute(new SetSongLoopCommand(asset.id, gesture.loop)); + } else this.setStart(gesture.free ? Math.round(gesture.from) : snapDown(gesture.from, grid)); + break; + case 'empty': + if (gesture.active) { + const caught = itemsWithin(this.view, asset.items, asset.tracks, gesture.start, gesture.current); + this.pick([...new Set([...(gesture.add ? this.pickedIds : []), ...caught])]); + } else if (gesture.track) this.pick([], gesture.track); + break; + case 'move': + if (gesture.moved && (gesture.ticks !== 0 || gesture.tracks !== 0)) + this.commit( + asset, + { + items: moved( + asset.items, + picked, + asset.tracks.map((t) => t.id), + gesture.ticks, + gesture.tracks, + ), + }, + picked.size === 1 ? 'Move item' : 'Move items', + ); + else if (!gesture.moved) this.pick([gesture.anchor.id]); + break; + case 'resize': + if (gesture.by !== 0) + this.commit( + asset, + { items: resized(asset.items, picked, gesture.by) }, + picked.size === 1 ? 'Resize item' : 'Resize items', + ); + break; + case 'trim': + if (gesture.by !== 0) + this.commit( + asset, + { items: trimmed(asset.items, picked, gesture.by, songTempo(asset.props)) }, + picked.size === 1 ? 'Trim item' : 'Trim items', + ); + break; + case 'erase': + if (gesture.erased.size > 0) { + this.commit( + asset, + { items: asset.items.filter((i) => !gesture.erased.has(i.id)) }, + gesture.erased.size === 1 ? 'Delete item' : 'Delete items', + ); + this.pick(this.pickedIds.filter((id) => !gesture.erased.has(id))); + } + break; + case 'slider': { + const tracks = this.shownTracksWith(asset, gesture); + this.commit(asset, { tracks }, gesture.part === 'volume' ? 'Set track volume' : 'Set track pan'); + this.onTracksPreview(this.asset()?.tracks ?? tracks); + break; + } + } + this.invalidate(); + } + + /** Follows an asset dragged in from the panel: a pattern or a sound lands on the lane under the pointer. */ + private trackDrop(asset: SongAsset, assetId: string, local: Vec2, g: Vec2): void { + const dragged = findAsset(this.state.project, assetId); + const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h; + const row = trackAtY(this.view, asset.tracks.length, g.y); + if (!inside || row === null || !dragged || (dragged.type !== 'pattern' && dragged.type !== 'sound')) { + if (this.drop) { + this.drop = null; + this.invalidate(); + } + return; + } + const grid = songGrid(asset.props); + const tick = Math.max(0, snapDown(laneTick(this.view, g.x), grid)); + const bar = ticksPerBar(songMeter(asset.props)); + let length: number; + if (dragged.type === 'pattern') + length = droppedLength(null, patternLength(dragged), songTempo(asset.props), bar); + else { + this.ensureLoaded(dragged.file); + length = droppedLength(soundLibrary.duration(dragged.file), null, songTempo(asset.props), bar); + } + this.drop = { kind: dragged.type, asset: dragged.id, track: asset.tracks[row]!.id, tick, length }; + this.invalidate(); + } + + private erase(asset: SongAsset, g: Vec2): void { + if (this.gesture?.kind !== 'erase') return; + const erased = this.gesture.erased; + const hit = itemAt( + this.view, + asset.items.filter((i) => !erased.has(i.id)), + asset.tracks, + g, + ); + if (hit) erased.add(hit.id); + } + + private setStart(tick: number): void { + this.startTick = Math.max(0, tick); + this.onStart(this.startTick); + this.invalidate(); + } + + private commit(asset: SongAsset, parts: { tracks?: SongTrack[]; items?: SongItem[] }, label: string): void { + if (SetSongCommand.changes(asset, parts)) this.state.execute(new SetSongCommand(asset.id, parts, label)); + } + + private openRulerMenu(asset: SongAsset, tick: number, global: Vec2): void { + const bar = ticksPerBar(songMeter(asset.props)); + const first = snapDown(tick, bar); + new Menu([ + { + label: `Loop bar ${first / bar + 1}`, + onSelect: () => + this.state.execute(new SetSongLoopCommand(asset.id, { start: first, end: first + bar })), + }, + { + label: 'Loop the whole song', + disabled: !asset.loop, + onSelect: () => this.state.execute(new SetSongLoopCommand(asset.id, null)), + }, + ]).open(global.x, global.y); + } + + private openTrackMenu(asset: SongAsset, row: number, global: Vec2): void { + const track = asset.tracks[row]!; + const swap = (other: number) => { + const tracks = [...asset.tracks]; + [tracks[row], tracks[other]] = [tracks[other]!, tracks[row]!]; + this.commit(asset, { tracks }, 'Move track'); + }; + new Menu([ + { label: 'Add track above', onSelect: () => this.addTrack(row) }, + { label: 'Add track below', onSelect: () => this.addTrack(row + 1) }, + { label: 'Move up', disabled: row === 0, onSelect: () => swap(row - 1) }, + { label: 'Move down', disabled: row === asset.tracks.length - 1, onSelect: () => swap(row + 1) }, + { + label: 'Delete track', + disabled: asset.tracks.length <= 1, + onSelect: () => { + this.pick([], track.id); + this.state.execute( + new SetSongCommand( + asset.id, + { + tracks: asset.tracks.filter((t) => t.id !== track.id), + items: asset.items.filter((i) => i.track !== track.id), + }, + 'Delete track', + ), + ); + }, + }, + ]).open(global.x, global.y); + } + + private onWheel(e: FederatedWheelEvent): void { + const asset = this.asset(); + if (!asset) return; + const local = this.toLocal(e.global); + const area = this.areaAt({ x: local.x, y: local.y }); + const g = this.inLanes({ x: local.x, y: local.y }); + let view: SongView = this.view; + if (area === 'ruler' || e.altKey) view = { ...view, ...zoomTime(view, zoomFactor(e.deltaY), g.x) }; + else if (e.shiftKey) view = { ...view, panX: view.panX - e.deltaY }; + else view = { ...view, panX: view.panX - e.deltaX, panY: view.panY - e.deltaY }; + this.view = clampTracks(clampTimePan(view), asset.tracks.length, this.lanesHeight); + this.needsFit = false; + this.redraw(); + e.preventDefault(); + } + + // --- drawing --------------------------------------------------------------------------------- + + /** The items as the running gesture would leave them. */ + private shownItems(asset: SongAsset): readonly SongItem[] { + const g = this.gesture; + const picked = new Set(this.pickedIds); + if (g?.kind === 'move' && g.moved) + return moved( + asset.items, + picked, + asset.tracks.map((t) => t.id), + g.ticks, + g.tracks, + ); + if (g?.kind === 'resize') return resized(asset.items, picked, g.by); + if (g?.kind === 'trim') return trimmed(asset.items, picked, g.by, songTempo(asset.props)); + if (g?.kind === 'erase') return asset.items.filter((i) => !g.erased.has(i.id)); + return asset.items; + } + + private shownTracks(asset: SongAsset): SongTrack[] { + const g = this.gesture; + return g?.kind === 'slider' ? this.shownTracksWith(asset, g) : asset.tracks; + } + + private shownTracksWith(asset: SongAsset, g: Extract): SongTrack[] { + return asset.tracks.map((t) => (t.id === g.track ? { ...t, [g.part]: g.value } : t)); + } + + private shownLoop(): LoopRange | null { + const asset = this.asset(); + if (!asset) return null; + const g = this.gesture; + if (g?.kind === 'ruler' && g.active) return g.loop; + return songLoop(asset); + } + + private labelOf(item: SongItem): string { + return findAsset(this.state.project, item.asset)?.name ?? 'missing'; + } + + private contentOf(item: SongItem): 'notes' | 'wave' | 'none' { + if (item.kind === 'pattern') { + const pattern = findAssetOf(this.state.project, item.asset, 'pattern'); + return pattern && pattern.notes.length > 0 ? 'notes' : 'none'; + } + const sound = findAssetOf(this.state.project, item.asset, 'sound'); + return sound?.file && soundLibrary.peaks(sound.file) ? 'wave' : 'none'; + } + + private ensureLoaded(file: string): void { + if (!file || this.loading.has(file) || soundLibrary.duration(file) !== null) return; + this.loading.add(file); + void soundLibrary.load(file).then(() => this.invalidate()); + } + + override layout(): void { + if (this.needsFit) this.fit(); + } + + override redraw(): void { + if (this.needsFit && this.lanesWidth > 0 && this.lanesHeight > 0 && this.asset()) { + this.fit(); + return; + } + const c = Theme.colors; + const w = this.w; + const h = this.h; + const lw = this.lanesWidth; + const lh = this.lanesHeight; + this.bg.clear().rect(0, 0, w, h).fill(c.viewport); + this.bg.rect(0, 0, HEAD_WIDTH, h).fill(c.panel); + this.bg.rect(0, 0, w, RULER_HEIGHT).fill(c.panelHeader); + this.bg.rect(HEAD_WIDTH - 1, 0, 1, h).fill(c.border); + this.clips.lanes.clear().rect(HEAD_WIDTH, RULER_HEIGHT, lw, lh).fill(0xffffff); + this.clips.heads + .clear() + .rect(0, RULER_HEIGHT, HEAD_WIDTH - 1, lh) + .fill(0xffffff); + this.clips.ruler.clear().rect(HEAD_WIDTH, 0, lw, RULER_HEIGHT).fill(0xffffff); + this.lanesLayer.position.set(HEAD_WIDTH, RULER_HEIGHT); + this.headsLayer.position.set(0, RULER_HEIGHT); + this.ruler.position.set(HEAD_WIDTH, 0); + + const asset = this.asset(); + this.lanesG.clear(); + this.itemsG.clear(); + this.overlay.clear(); + this.headsG.clear(); + if (!asset) { + this.syncTexts(this.itemTexts, this.itemLabels, []); + this.syncTexts(this.headTexts, this.headLabels, []); + this.addLabel.visible = false; + this.ruler.visible = false; + this.hint.text = 'No song selected – pick one in the Assets panel or create one.'; + this.hint.position.set(HEAD_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2); + this.hint.visible = true; + return; + } + this.ruler.visible = true; + const length = songLength(asset); + const tracks = this.shownTracks(asset); + this.drawLanes(asset, tracks, lw, lh, length); + this.drawItems(asset, lw, lh); + this.drawHeads(tracks); + this.ruler.draw(this.view, songMeter(asset.props), lw, { + length, + loop: this.shownLoop(), + start: this.startTick, + playhead: this.playhead, + }); + const g = this.gesture; + if (g?.kind === 'empty' && g.active) drawBand(this.overlay, g.start, g.current); + if (this.drop) { + const row = indexOf(asset.tracks).get(this.drop.track); + if (row !== undefined) { + const box = itemBox(this.view, row, this.drop); + this.overlay + .rect(box.x, box.y, box.w, box.h) + .fill({ color: c.accent, alpha: 0.25 }) + .stroke({ width: 1, color: c.accent }); + } + } + const start = tickToX(this.view, this.startTick); + if (start >= 0 && start <= lw) this.overlay.rect(start, 0, 1, lh).fill({ color: c.text, alpha: 0.3 }); + if (this.playhead !== null) { + const x = tickToX(this.view, this.playhead); + if (x >= 0 && x <= lw) this.overlay.rect(x - 0.5, 0, 1, lh).fill(c.hot); + } + this.hint.text = + asset.items.length === 0 + ? 'Drag a pattern or a sound from the Assets panel onto a track. Space plays.' + : ''; + this.hint.visible = this.hint.text !== ''; + this.hint.position.set( + HEAD_WIDTH + Theme.padding * 2, + RULER_HEIGHT + trackTop(this.view, tracks.length) + Theme.padding * 2, + ); + } + + /** A band per track, the picked one lit, and the bar, beat and grid lines across them. */ + private drawLanes( + asset: SongAsset, + tracks: readonly SongTrack[], + lw: number, + lh: number, + length: number, + ): void { + const c = Theme.colors; + tracks.forEach((track, i) => { + const y = trackTop(this.view, i); + if (y > lh || y + TRACK_HEIGHT < 0) return; + const lit = track.id === this.pickedTrack && this.pickedIds.length === 0; + this.lanesG + .rect(0, y, lw, TRACK_HEIGHT) + .fill(lit ? { color: c.accent, alpha: 0.08 } : { color: 0xffffff, alpha: i % 2 ? 0.015 : 0.035 }); + this.lanesG.rect(0, y + TRACK_HEIGHT - 1, lw, 1).fill({ color: c.border, alpha: 0.8 }); + }); + const bottom = Math.min(lh, trackTop(this.view, tracks.length)); + const alpha = [0.9, 0.45, 0.2]; + for (const line of timeLines(this.view, songMeter(asset.props), songGrid(asset.props), lw)) + this.lanesG.rect(line.x, 0, 1, bottom).fill({ color: c.border, alpha: alpha[line.level] }); + const end = tickToX(this.view, length); + if (end < lw) + this.lanesG + .rect(Math.max(0, end), 0, lw - Math.max(0, end), bottom) + .fill({ color: 0x000000, alpha: 0.2 }); + } + + private drawItems(asset: SongAsset, lw: number, lh: number): void { + const c = Theme.colors; + const rows = indexOf(asset.tracks); + const picked = new Set(this.pickedIds); + const labels: Placed[] = []; + for (const item of this.shownItems(asset)) { + const row = rows.get(item.track); + if (row === undefined) continue; + const box = itemBox(this.view, row, item); + if (box.x > lw || box.x + box.w < 0 || box.y > lh || box.y + box.h < 0) continue; + const missing = !findAsset(this.state.project, item.asset); + const color = missing ? MISSING_COLOR : item.kind === 'pattern' ? PATTERN_COLOR : SOUND_COLOR; + const lit = picked.has(item.id); + this.itemsG + .roundRect(box.x, box.y, box.w, box.h, 3) + .fill({ color, alpha: lit ? 1 : 0.85 }) + .stroke({ + width: lit ? 2 : 1, + color: lit ? c.accentText : 0x000000, + alpha: lit ? 1 : 0.45, + alignment: 1, + }); + if (item.kind === 'pattern') this.drawNotes(item, box, lw); + else this.drawWave(item, box, lw); + if (box.w > 18) labels.push({ text: this.labelOf(item), x: box.x + 4, y: box.y + 1, w: box.w - 8 }); + } + this.syncTexts(this.itemTexts, this.itemLabels, labels); + } + + /** The notes of a pattern item, small, across every round it plays. */ + private drawNotes(item: SongItem, box: Box, lw: number): void { + const pattern = findAssetOf(this.state.project, item.asset, 'pattern'); + if (!pattern || pattern.notes.length === 0) return; + const span = patternLength(pattern); + const notes = pattern.notes.filter((n) => n.tick < span); + if (notes.length === 0) return; + const low = Math.min(...notes.map((n) => n.note)); + const high = Math.max(...notes.map((n) => n.note)); + const top = box.y + LABEL_STRIP; + const room = Math.max(4, box.h - LABEL_STRIP - 3); + const step = room / Math.max(1, high - low + 1); + const into = ((item.offset % span) + span) % span; + const end = itemEnd(item); + for (let round = item.tick - into; round < end; round += span) + for (const n of notes) { + const from = Math.max(item.tick, round + n.tick); + const to = Math.min(end, round + Math.min(n.tick + n.length, span)); + if (to <= from) continue; + const x0 = tickToX(this.view, from); + const x1 = tickToX(this.view, to); + if (x1 < 0 || x0 > lw) continue; + const y = top + (high - n.note) * step; + this.itemsG + .rect(x0, y, Math.max(1, x1 - x0 - 0.5), Math.max(1, Math.min(3, step - 0.5))) + .fill({ color: 0xffffff, alpha: 0.8 }); + } + } + + /** The waveform of a sound item, round and round as it plays, from where it starts in it. */ + private drawWave(item: SongItem, box: Box, lw: number): void { + const sound = findAssetOf(this.state.project, item.asset, 'sound'); + if (!sound?.file) return; + const peaks = soundLibrary.peaks(sound.file); + const seconds = soundLibrary.duration(sound.file); + if (!peaks || !seconds) { + this.ensureLoaded(sound.file); + return; + } + const asset = this.asset()!; + const tempo = songTempo(asset.props); + const mid = box.y + LABEL_STRIP + (box.h - LABEL_STRIP) / 2; + const half = (box.h - LABEL_STRIP - 4) / 2; + const from = Math.max(box.x, 0); + const to = Math.min(box.x + box.w, lw); + for (let x = from; x < to; x += 2) { + const tick = xToTick(this.view, x) - item.tick; + const at = (item.offset + secondsOfTicks(tick, tempo)) % seconds; + const peak = peaks[Math.min(peaks.length - 1, Math.floor((at / seconds) * peaks.length))] ?? 0; + const reach = Math.max(0.5, peak * half); + this.itemsG.rect(x, mid - reach, 1.5, reach * 2).fill({ color: 0xffffff, alpha: 0.7 }); + } + } + + /** The heads: name, mute and solo, the volume and pan sliders, and the row that adds a track. */ + private drawHeads(tracks: readonly SongTrack[]): void { + const c = Theme.colors; + const labels: Placed[] = []; + tracks.forEach((track, i) => { + const y = trackTop(this.view, i); + const lit = track.id === this.pickedTrack; + this.headsG + .rect(0, y, HEAD_WIDTH - 1, TRACK_HEIGHT) + .fill(lit ? { color: c.accent, alpha: 0.18 } : c.panel); + this.headsG.rect(0, y + TRACK_HEIGHT - 1, HEAD_WIDTH - 1, 1).fill(c.border); + const { name, mute, solo, volume, pan } = HEAD_BOXES; + labels.push({ text: track.name, x: name.x, y: y + name.y, w: name.w }); + this.toggle(mute, y, track.mute, MUTE_COLOR); + this.toggle(solo, y, track.solo, SOLO_COLOR); + labels.push( + { text: 'M', x: mute.x + 5, y: y + mute.y, w: mute.w, dark: track.mute }, + { text: 'S', x: solo.x + 5, y: y + solo.y, w: solo.w, dark: track.solo }, + ); + const share = sliderShare(track.volume, TRACK_MIN_DB, TRACK_MAX_DB); + this.headsG + .rect(volume.x, y + volume.y, volume.w, volume.h) + .fill(c.viewport) + .stroke({ width: 1, color: c.border, alignment: 1 }); + this.headsG + .rect(volume.x + 1, y + volume.y + 1, Math.max(0, (volume.w - 2) * share), volume.h - 2) + .fill({ color: c.accent, alpha: track.mute ? 0.35 : 0.8 }); + const unity = volume.x + volume.w * sliderShare(0, TRACK_MIN_DB, TRACK_MAX_DB); + this.headsG.rect(unity, y + volume.y - 2, 1, volume.h + 4).fill({ color: c.text, alpha: 0.5 }); + this.headsG + .rect(pan.x, y + pan.y, pan.w, pan.h) + .fill(c.viewport) + .stroke({ width: 1, color: c.border, alignment: 1 }); + const centre = pan.x + pan.w / 2; + const knob = pan.x + pan.w * sliderShare(track.pan, -1, 1); + this.headsG + .rect(Math.min(centre, knob), y + pan.y + 2, Math.max(1, Math.abs(knob - centre)), pan.h - 4) + .fill({ color: c.accent, alpha: 0.8 }); + this.headsG.rect(centre, y + pan.y - 2, 1, pan.h + 4).fill({ color: c.text, alpha: 0.5 }); + }); + this.syncTexts(this.headTexts, this.headLabels, labels); + const addTop = trackTop(this.view, tracks.length); + this.headsG.rect(0, addTop, HEAD_WIDTH - 1, ADD_ROW_HEIGHT).fill({ color: 0xffffff, alpha: 0.02 }); + this.addLabel.visible = true; + this.addLabel.position.set(Theme.padding, addTop + (ADD_ROW_HEIGHT - this.addLabel.height) / 2); + } + + private toggle(box: Box, y: number, on: boolean, color: number): void { + const c = Theme.colors; + this.headsG + .roundRect(box.x, y + box.y, box.w, box.h, 3) + .fill(on ? color : c.viewport) + .stroke({ width: 1, color: c.border, alignment: 1 }); + } + + /** Puts texts at their places, cut short with "…" where they would run past their width. */ + private syncTexts(pool: Text[], parent: Container, labels: readonly Placed[]): void { + labels.forEach((label, i) => { + let text = pool[i]; + if (!text) { + text = crispText({ style: textStyle('small') }); + pool.push(text); + parent.addChild(text); + } + text.style = label.dark ? DARK_LABEL : textStyle('small'); + if (text.text !== label.text) text.text = label.text; + for (let cut = label.text.length; text.width > label.w && cut > 1; cut--) + text.text = `${label.text.slice(0, cut - 1)}…`; + text.visible = true; + text.position.set(label.x, label.y); + }); + for (let i = labels.length; i < pool.length; i++) pool[i]!.visible = false; + } +} diff --git a/src/editor/song/songActions.ts b/src/editor/song/songActions.ts new file mode 100644 index 00000000..c04595ff --- /dev/null +++ b/src/editor/song/songActions.ts @@ -0,0 +1,36 @@ +import { SetSongCommand } from '../../core/commands/songCommands'; +import { findAssetOf } from '../../core/model/Project'; +import type { EditorState } from '../state'; + +/** Deletes a track and its items (#334); the last track stays, a song has at least one. */ +export function removeTrack(state: EditorState, assetId: string, trackId: string): void { + const asset = findAssetOf(state.project, assetId, 'song'); + if (!asset) return; + if (asset.tracks.length <= 1) { + state.setNotice('A song keeps at least one track'); + return; + } + state.execute( + new SetSongCommand( + assetId, + { + tracks: asset.tracks.filter((t) => t.id !== trackId), + items: asset.items.filter((i) => i.track !== trackId), + }, + 'Delete track', + ), + ); +} + +export function removeSongItems(state: EditorState, assetId: string, itemIds: readonly string[]): void { + const asset = findAssetOf(state.project, assetId, 'song'); + if (!asset || itemIds.length === 0) return; + const going = new Set(itemIds); + state.execute( + new SetSongCommand( + assetId, + { items: asset.items.filter((i) => !going.has(i.id)) }, + going.size === 1 ? 'Delete item' : 'Delete items', + ), + ); +} diff --git a/src/editor/song/songEdit.ts b/src/editor/song/songEdit.ts new file mode 100644 index 00000000..409203d7 --- /dev/null +++ b/src/editor/song/songEdit.ts @@ -0,0 +1,197 @@ +import { newId } from '../../core/ids'; +import { itemEnd, itemFrom, type SongItem } from '../../core/music/song'; +import { secondsOfTicks, ticksOfSeconds } from '../../core/music/time'; +import { snapDown, snapNear } from '../pattern/rollEdit'; + +/** + * What the Song tab does to items (#334), as plain functions on lists so a test can check them. + * Everything snaps to the grid unless `free` – Alt held –, which puts it on the tick. + */ + +/** + * How far a drag moves the picked items: the grabbed one's start lands on the grid (or the tick), + * across as many tracks as the pointer went, and none goes before tick 0 or off the tracks. + */ +export function moveDelta( + items: readonly SongItem[], + picked: ReadonlySet, + rows: ReadonlyMap, + count: number, + anchor: SongItem, + wantedStart: number, + wantedRow: number, + grid: number, + free: boolean, +): { ticks: number; tracks: number } { + const start = free ? Math.round(wantedStart) : snapNear(wantedStart, grid); + return clampMove( + items, + picked, + rows, + count, + start - anchor.tick, + wantedRow - (rows.get(anchor.track) ?? 0), + ); +} + +export function clampMove( + items: readonly SongItem[], + picked: ReadonlySet, + rows: ReadonlyMap, + count: number, + ticks: number, + tracks: number, +): { ticks: number; tracks: number } { + const chosen = items.filter((i) => picked.has(i.id)); + if (chosen.length === 0) return { ticks: 0, tracks: 0 }; + const first = Math.min(...chosen.map((i) => i.tick)); + const at = chosen.map((i) => rows.get(i.track) ?? 0); + const top = Math.min(...at); + const bottom = Math.max(...at); + return { + // `|| 0`: no -0 for an item at tick 0. + ticks: Math.max(ticks, -first) || 0, + tracks: Math.max(-top, Math.min(count - 1 - bottom, tracks)) || 0, + }; +} + +export function moved( + items: readonly SongItem[], + picked: ReadonlySet, + trackIds: readonly string[], + ticks: number, + tracks: number, +): SongItem[] { + const rows = new Map(trackIds.map((id, i) => [id, i])); + return items.map((i) => { + if (!picked.has(i.id)) return i; + const row = (rows.get(i.track) ?? 0) + tracks; + return { ...i, tick: i.tick + ticks, track: trackIds[row] ?? i.track }; + }); +} + +/** + * The length the grabbed item takes when its end is dragged to a tick: to the grid line nearest the + * pointer, but never back to its own start – then to the first grid line after it. + */ +export function draggedLength(anchor: SongItem, pointerTick: number, grid: number, free: boolean): number { + if (free) return Math.max(1, Math.round(pointerTick) - anchor.tick); + const end = snapNear(pointerTick, grid); + return end > anchor.tick ? end - anchor.tick : snapDown(anchor.tick, grid) + grid - anchor.tick; +} + +/** Every picked item lengthened by what the grabbed one was, none shorter than a tick. */ +export function resized(items: readonly SongItem[], picked: ReadonlySet, by: number): SongItem[] { + return items.map((i) => (picked.has(i.id) ? { ...i, length: Math.max(1, i.length + by) } : i)); +} + +/** How far into what it plays an item can be pulled back: its offset, in ticks. */ +function offsetTicks(item: SongItem, tempo: number): number { + return item.kind === 'sound' ? ticksOfSeconds(item.offset, tempo) : item.offset; +} + +/** + * How far the start edge of the grabbed item moves when it is dragged to a tick, held so that no + * picked item gets shorter than a tick, starts before 0 or before the beginning of what it plays. + */ +export function trimDelta( + items: readonly SongItem[], + picked: ReadonlySet, + anchor: SongItem, + pointerTick: number, + grid: number, + free: boolean, + tempo: number, +): number { + const start = free ? Math.round(pointerTick) : snapNear(pointerTick, grid); + let by = start - anchor.tick; + for (const item of items) { + if (!picked.has(item.id)) continue; + by = Math.min(by, item.length - 1); + by = Math.max(by, -item.tick, -Math.floor(offsetTicks(item, tempo) + 1e-6)); + } + return by || 0; +} + +/** The picked items with their start edge moved by `by` ticks, what they play staying where it was. */ +export function trimmed( + items: readonly SongItem[], + picked: ReadonlySet, + by: number, + tempo: number, +): SongItem[] { + if (by === 0) return [...items]; + return items.map((i) => { + if (!picked.has(i.id)) return i; + const offset = + i.kind === 'sound' ? Math.max(0, i.offset + secondsOfTicks(by, tempo)) : Math.max(0, i.offset + by); + return { ...i, tick: i.tick + by, length: Math.max(1, i.length - by), offset }; + }); +} + +/** + * The picked items that `at` falls inside cut in two there. Gives the new list and the ids of the + * right-hand parts, which are what is picked afterwards. + */ +export function split( + items: readonly SongItem[], + picked: ReadonlySet, + at: number, + tempo: number, +): { items: SongItem[]; right: string[] } { + const out: SongItem[] = []; + const right: string[] = []; + for (const item of items) { + const rest = picked.has(item.id) ? itemFrom(item, at, tempo) : null; + if (!rest) { + out.push(item); + continue; + } + const part = { ...rest, id: newId() }; + out.push({ ...item, length: part.tick - item.tick }, part); + right.push(part.id); + } + return { items: out, right }; +} + +/** How far right a copy of the picked items goes so it stands right behind them: whole grid steps. */ +export function duplicateOffset( + items: readonly SongItem[], + picked: ReadonlySet, + grid: number, +): number { + const chosen = items.filter((i) => picked.has(i.id)); + if (chosen.length === 0) return 0; + const first = Math.min(...chosen.map((i) => i.tick)); + const end = Math.max(...chosen.map(itemEnd)); + return Math.max(grid, Math.ceil((end - first) / grid) * grid); +} + +/** Copies of items with ids of their own, on their tracks, moved so the earliest starts at `at`. */ +export function placedCopies( + items: readonly SongItem[], + at: number, + trackIds: readonly string[], +): SongItem[] { + if (items.length === 0) return []; + const first = Math.min(...items.map((i) => i.tick)); + const known = new Set(trackIds); + return items.map((i) => ({ + ...i, + id: newId(), + tick: Math.max(0, at) + (i.tick - first), + track: known.has(i.track) ? i.track : (trackIds[0] ?? i.track), + })); +} + +/** How long an item dropped in is: its pattern, or its sound at the song's tempo, else a bar. */ +export function droppedLength( + seconds: number | null, + patternTicks: number | null, + tempo: number, + bar: number, +): number { + if (patternTicks !== null) return Math.max(1, patternTicks); + if (seconds !== null && seconds > 0) return Math.max(1, Math.round(ticksOfSeconds(seconds, tempo))); + return bar; +} diff --git a/src/editor/song/songView.ts b/src/editor/song/songView.ts new file mode 100644 index 00000000..b677e837 --- /dev/null +++ b/src/editor/song/songView.ts @@ -0,0 +1,146 @@ +import type { SongItem, SongTrack } from '../../core/music/song'; +import type { Vec2 } from '../../core/model/Properties'; +import { clamp } from '../../ui'; +import { type TimeView, tickToX, xToTick } from '../music/timeView'; + +/** + * The geometry of the Song tab (#334), apart from Pixi so a test can drive it: the tracks stacked + * down the lanes, `y` measured from the top of the lanes, `x` from their left edge as on the ruler. + */ +export interface SongView extends TimeView { + /** Pixels from the lanes' top edge to the first track. */ + panY: number; +} + +export const HEAD_WIDTH = 176; +export const TRACK_HEIGHT = 52; +/** The row under the last track that adds one. */ +export const ADD_ROW_HEIGHT = 28; +/** How close to an end of an item a press takes that end. */ +export const EDGE_REACH = 6; +const INSET = 2; + +export interface Box { + x: number; + y: number; + w: number; + h: number; +} + +export function trackTop(view: SongView, index: number): number { + return view.panY + index * TRACK_HEIGHT; +} + +/** The index of the track a height falls on, null below the last one. */ +export function trackAtY(view: SongView, count: number, y: number): number | null { + const index = Math.floor((y - view.panY) / TRACK_HEIGHT); + return index >= 0 && index < count ? index : null; +} + +/** The track a height is nearest to, held to the tracks there are: where a dragged item goes. */ +export function nearestTrack(view: SongView, count: number, y: number): number { + return clamp(Math.floor((y - view.panY) / TRACK_HEIGHT), 0, Math.max(0, count - 1)); +} + +export function itemBox(view: SongView, index: number, item: Pick): Box { + const x = tickToX(view, item.tick); + return { + x, + y: trackTop(view, index) + INSET, + w: Math.max(1, tickToX(view, item.tick + item.length) - x), + h: TRACK_HEIGHT - INSET * 2, + }; +} + +export type ItemPart = 'start' | 'body' | 'end'; + +/** The item under a point, the last drawn first, and which part of it: an end within reach, else the body. */ +export function itemAt( + view: SongView, + items: readonly SongItem[], + tracks: readonly SongTrack[], + p: Vec2, +): { id: string; part: ItemPart } | null { + const index = indexOf(tracks); + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]!; + const row = index.get(item.track); + if (row === undefined) continue; + const box = itemBox(view, row, item); + if (p.y < box.y || p.y > box.y + box.h || p.x < box.x || p.x > box.x + box.w) continue; + const reach = Math.min(EDGE_REACH, box.w / 3); + if (p.x >= box.x + box.w - reach) return { id: item.id, part: 'end' }; + if (p.x <= box.x + reach) return { id: item.id, part: 'start' }; + return { id: item.id, part: 'body' }; + } + return null; +} + +/** The items a band from `a` to `b` touches. */ +export function itemsWithin( + view: SongView, + items: readonly SongItem[], + tracks: readonly SongTrack[], + a: Vec2, + b: Vec2, +): string[] { + const index = indexOf(tracks); + const x0 = Math.min(a.x, b.x); + const x1 = Math.max(a.x, b.x); + const y0 = Math.min(a.y, b.y); + const y1 = Math.max(a.y, b.y); + return items + .filter((item) => { + const row = index.get(item.track); + if (row === undefined) return false; + const box = itemBox(view, row, item); + return box.x <= x1 && box.x + box.w >= x0 && box.y <= y1 && box.y + box.h >= y0; + }) + .map((item) => item.id); +} + +/** Keeps the tracks and the row that adds one in reach: nothing to scroll to above or far below. */ +export function clampTracks(view: V, count: number, height: number): V { + const content = count * TRACK_HEIGHT + ADD_ROW_HEIGHT; + const panY = clamp(view.panY, Math.min(0, height - content), 0); + return panY === view.panY ? view : { ...view, panY }; +} + +export function indexOf(tracks: readonly SongTrack[]): Map { + return new Map(tracks.map((t, i) => [t.id, i])); +} + +/** The parts of a track's head, in its own pixels: name, mute, solo, volume and pan. */ +export type HeadPart = 'name' | 'mute' | 'solo' | 'volume' | 'pan'; + +export const HEAD_BOXES: Readonly> = { + name: { x: 8, y: 4, w: 112, h: 18 }, + mute: { x: 128, y: 5, w: 18, h: 16 }, + solo: { x: 150, y: 5, w: 18, h: 16 }, + volume: { x: 8, y: 30, w: 100, h: 12 }, + pan: { x: 116, y: 30, w: 52, h: 12 }, +}; + +export function headPartAt(x: number, yInTrack: number): HeadPart { + for (const part of ['mute', 'solo', 'volume', 'pan'] as const) { + const box = HEAD_BOXES[part]; + if (x >= box.x - 2 && x <= box.x + box.w + 2 && yInTrack >= box.y - 3 && yInTrack <= box.y + box.h + 3) + return part; + } + return 'name'; +} + +/** A share of a slider's width for a value from `min` to `max`, and back. */ +export function sliderShare(value: number, min: number, max: number): number { + return clamp((value - min) / (max - min), 0, 1); +} + +export function sliderValue(x: number, box: Box, min: number, max: number, step: number): number { + const share = clamp((x - box.x) / box.w, 0, 1); + return clamp(Math.round((min + share * (max - min)) / step) * step, min, max); +} + +/** The tick under a point of the lanes, never before 0. */ +export function laneTick(view: SongView, x: number): number { + return Math.max(0, xToTick(view, x)); +} diff --git a/src/editor/state.ts b/src/editor/state.ts index c78e3e46..59cd273a 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -66,6 +66,11 @@ export type Selection = * describes – of several keys, the one picked last. */ | { kind: 'track'; assetId: string; trackId: string; keys: KeyRef[] } + /** + * A track of a song (the Song tab, #334) and the items picked on it or on others: `itemIds` is + * empty when the track itself was clicked. The inspector shows the item picked last, else the track. + */ + | { kind: 'song'; assetId: string; trackId: string; itemIds: string[] } | null; /** @@ -742,6 +747,24 @@ export class EditorState { ); } } + if (s.kind === 'song') { + const asset = findAssetOf(this.project, s.assetId, 'song'); + if (!asset) this.select(null); + else { + // An item that died leaves the selection; a track that died hands it to the song. + const items = s.itemIds.filter((id) => asset.items.some((item) => item.id === id)); + const trackAlive = asset.tracks.some((track) => track.id === s.trackId); + if (!trackAlive) this.select({ kind: 'asset', assetId: s.assetId }); + else if (items.length !== s.itemIds.length) this.select({ ...s, itemIds: items }); + } + } + } + + /** The song the selection refers to (#334): the asset, or a track or items of it. */ + get selectedSongId(): string | null { + const s = this.selection; + if (s?.kind !== 'asset' && s?.kind !== 'song') return null; + return findAssetOf(this.project, s.assetId, 'song')?.id ?? null; } /** The texture asset the current selection refers to (the asset itself or one of its frames). */ @@ -926,6 +949,8 @@ export function sameSelection(a: Selection, b: Selection): boolean { ); if (a.kind === 'edge' && b.kind === 'edge') return a.assetId === b.assetId && a.edgeId === b.edgeId && sameIds(a.edgeIds, b.edgeIds); + if (a.kind === 'song' && b.kind === 'song') + return a.assetId === b.assetId && a.trackId === b.trackId && sameIds(a.itemIds, b.itemIds); return false; } diff --git a/src/runtime/systems/Audio.ts b/src/runtime/systems/Audio.ts index 3d00f054..655f9117 100644 --- a/src/runtime/systems/Audio.ts +++ b/src/runtime/systems/Audio.ts @@ -86,6 +86,8 @@ export interface AudioSink { export interface NoteTiming { at?: number; tempo?: number; + /** The track of a song it is heard on (#334); straight out where left out. */ + bus?: string; } export const SILENT_AUDIO: AudioSink = { diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 883697a7..e9f36793 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -33,6 +33,8 @@ const DECODE_RATE = 48000; /** Seconds a stopped voice takes to fall silent, so it does not click (#324). */ const FADE = 0.01; +/** How finely `peaks` cuts a sound up. */ +const PEAK_BUCKETS = 1024; /** * One number of a voice that can still change while it plays (#315): a level, a share of a Mix, the @@ -53,8 +55,11 @@ interface Knob { * fall silent without a click while the echoes carry on (#324). */ interface Source { - /** At that second of the audio clock (#332); a worklet was told when it was made. */ - start(at: number): void; + /** + * At that second of the audio clock (#332), `offset` seconds into the sound (#334); a worklet was + * told both when it was made. + */ + start(at: number, offset: number): void; stop(): void; ended(then: () => void): void; fade: GainNode | null; @@ -139,6 +144,9 @@ interface Wiring { file: string; loop: boolean; rings: boolean; + /** Seconds into the sound its ways start, and whether it is a clip of a song, which fades out at its end (#334). */ + offset: number; + clip: boolean; sources: Source[]; drivers: ConstantSourceNode[]; envelopes: Map; @@ -161,6 +169,13 @@ interface Start { duration: number; /** When it starts on the audio clock (#332); one already past starts now. */ at: number; + /** + * A clip of a song (#334): seconds into the sound it starts, and the second of the audio clock it + * fades out at. + */ + clip?: { offset: number; end: number }; + /** The track of a song it is heard on (#334), `''` straight out. */ + bus?: string; } /** @@ -229,6 +244,9 @@ export class SoundLibrary implements AudioSink { /** The same counter per owner, for the same reason – a behavior destroyed while bytes were in * flight must not be given its voice afterwards. */ private readonly ownerStops = new Map(); + /** The tracks of the songs played so far (#334), by name. */ + private readonly buses = new Map(); + private readonly peaked = new Map(); /** The patterns a game plays, each on a scheduler of its own. */ private readonly sequences = new Playbacks({ now: () => this.time(), @@ -277,7 +295,64 @@ export class SoundLibrary implements AudioSink { if (files.length === 0) return 0; const fx = { layers, params, tempo: timing.tempo ?? DEFAULT_TEMPO }; const at = timing.at ?? 0; - return this.begin({ id: 0, file: name, files, volume, loop: false, owner, fx, duration, at }); + const bus = timing.bus ?? ''; + return this.begin({ id: 0, file: name, files, volume, loop: false, owner, fx, duration, at, bus }); + } + + /** + * A sound as a clip of a song (#334): from `at` on the audio clock, `offset` seconds into it, round + * and round until `duration` seconds have passed, then faded out – its echoes ring on. One that + * gets its bytes late starts as far into the sound as it would be by then. + */ + playClip(file: string, clip: ClipTiming, fx: FxVoice = DRY_VOICE): number { + if (file === '' || this.failed.has(file)) return 0; + const layered = { layers: [fx.plan], params: fx.params, tempo: fx.tempo ?? DEFAULT_TEMPO }; + const { at, offset, duration, volume, bus } = clip; + return this.begin({ + id: 0, + file, + files: [file], + volume, + loop: true, + owner: '', + fx: layered, + duration: 0, + at, + clip: { offset, end: at + Math.max(0, duration) }, + bus, + }); + } + + /** + * Sets the level and the pan of a track of a song (#334) in a quick glide, so a change reaches + * what is sounding on it already. A bus is made on its first use and kept. + */ + setBus(name: string, gain: number, pan: number): void { + const bus = this.bus(name); + const now = this.audio().currentTime; + bus.set = { gain: Math.max(0, gain), pan: Math.max(-1, Math.min(1, pan)) }; + bus.gain.gain.setTargetAtTime(Math.max(0, gain), now, GLIDE / 3); + bus.pan.pan.setTargetAtTime(Math.max(-1, Math.min(1, pan)), now, GLIDE / 3); + } + + /** What each track's bus was last set to (test hook). */ + busLevels(): { name: string; gain: number; pan: number }[] { + return [...this.buses].map(([name, bus]) => ({ name, ...bus.set })); + } + + /** Where the voices of a track go in; straight out for `''`. */ + private busInput(name: string): AudioNode { + return name === '' ? this.audio().destination : this.bus(name).gain; + } + + private bus(name: string): Bus { + const known = this.buses.get(name); + if (known) return known; + const context = this.audio(); + const made = { gain: context.createGain(), pan: context.createStereoPanner(), set: { gain: 1, pan: 0 } }; + made.gain.connect(made.pan).connect(context.destination); + this.buses.set(name, made); + return made; } /** A sequence without notes plays nothing, and is given no number. */ @@ -563,6 +638,28 @@ export class SoundLibrary implements AudioSink { return this.duration(file) ?? 0; } + /** + * The loudest sample of each of `PEAK_BUCKETS` equal stretches of a loaded sound, from 0 to 1 – + * what a clip of a song draws as its waveform (#334); null while it has not been loaded. + */ + peaks(file: string): Float32Array | null { + const known = this.peaked.get(file); + if (known) return known; + const buffer = this.buffers.get(file); + if (!buffer) return null; + const peaks = new Float32Array(PEAK_BUCKETS); + for (let c = 0; c < buffer.numberOfChannels; c++) { + const data = buffer.getChannelData(c); + for (let i = 0; i < data.length; i++) { + const bucket = Math.floor((i / data.length) * PEAK_BUCKETS); + peaks[bucket] = Math.max(peaks[bucket]!, Math.abs(data[i]!)); + } + } + for (let b = 0; b < PEAK_BUCKETS; b++) peaks[b] = Math.min(1, peaks[b]!); + this.peaked.set(file, peaks); + return peaks; + } + /** * Starts loading every file not held yet. Waiting for the first play left that play late: its * start was not heard, and a graph tuning it in the meantime had moved on by the time it started. @@ -708,12 +805,14 @@ export class SoundLibrary implements AudioSink { }; } - private start({ id, file, files, volume, loop, owner, fx, duration, at }: Start): void { + private start({ id, file, files, volume, loop, owner, fx, duration, at, clip, bus = '' }: Start): void { const context = this.audio(); // A context starts suspended where the page has not been interacted with; without this the // voice would run silently and end as if it had played. if (context.state === 'suspended') void context.resume(); const when = Math.max(at, context.currentTime); + // A clip whose bytes came too late is over already, or starts as far in as it would be by now. + if (clip && clip.end <= when) return; const gain = context.createGain(); gain.gain.value = Math.max(0, Math.min(1, volume)); const rings = fx.layers.some(fxRings); @@ -724,6 +823,8 @@ export class SoundLibrary implements AudioSink { file, loop, rings, + offset: clip ? clip.offset + (when - at) : 0, + clip: clip !== undefined, sources: [], drivers: [], envelopes: new Map(), @@ -732,7 +833,7 @@ export class SoundLibrary implements AudioSink { stages: 0, }; for (const plan of fx.layers) this.wire(wiring, fxChainOf(plan, fx.params, fx.tempo)).connect(gain); - gain.connect(context.destination); + gain.connect(this.busInput(bus)); const { sources } = wiring; this.last = { file, @@ -770,8 +871,9 @@ export class SoundLibrary implements AudioSink { this.voices.push(voice); this.started += 1; for (const driver of wiring.drivers) driver.start(); - for (const source of sources) source.start(when); + for (const source of sources) source.start(when, wiring.offset); if (duration > 0) this.release(id, when + duration); + if (clip) this.endAt(voice, sources, clip.end); } /** @@ -847,7 +949,7 @@ export class SoundLibrary implements AudioSink { private source(wiring: Wiring, chain: Way): AudioNode { const sound = this.sound(wiring, chain); const own = chain.source; - if (!wiring.rings && !own) return sound; + if (!wiring.rings && !own && !wiring.clip) return sound; const fade = gainNode(wiring.context, own?.volume ?? 1); wiring.sources.at(-1)!.fade = fade; return sound.connect(fade); @@ -868,6 +970,7 @@ export class SoundLibrary implements AudioSink { loop, startAt: wiring.at, }; + if (wiring.offset > 0) options.skip = Math.round((wiring.offset % buffer.duration) * buffer.sampleRate); if (own && loop) Object.assign(options, loopIn(buffer, own.loopStart, own.loopEnd)); if (!this.sent.has(file)) { options.channels = Array.from({ length: buffer.numberOfChannels }, (_, c) => @@ -911,7 +1014,8 @@ export class SoundLibrary implements AudioSink { source.loopEnd = own.loopEnd; } wiring.sources.push({ - start: (at) => source.start(at), + // Web Audio holds an offset past the end of a looping buffer to its end, not round it. + start: (at, offset) => source.start(at, offset > 0 ? offset % buffer.duration : 0), stop: () => source.stop(), ended: (then) => { source.onended = then; @@ -978,6 +1082,22 @@ export class SoundLibrary implements AudioSink { } } +/** A track of a song (#334): its level, then its pan, then out; `set` is what they glide to. */ +interface Bus { + gain: GainNode; + pan: StereoPannerNode; + set: { gain: number; pan: number }; +} + +/** When and how a clip of a song plays (#334); see `playClip`. */ +export interface ClipTiming { + at: number; + offset: number; + duration: number; + volume: number; + bus: string; +} + /** What `stats` says about the last voice: enough for a check to tell which effects it went through. */ interface LastVoice { file: string; diff --git a/src/viewport/music.ts b/src/viewport/music.ts index f29c75ae..8da22f31 100644 --- a/src/viewport/music.ts +++ b/src/viewport/music.ts @@ -1,7 +1,16 @@ -import { type ScheduledNote, Scheduler, type SchedulerOptions } from '../core/music/Scheduler'; +import { + type ScheduledClip, + type ScheduledNote, + Scheduler, + type SchedulerOptions, +} from '../core/music/Scheduler'; import type { Sequence } from '../core/music/sequence'; import { instrumentBuildFor, instrumentSourcesOf } from '../core/instrument/compile'; import { findAssetOf, type Project } from '../core/model/Project'; +import { resolveAssetProps } from '../core/properties/resolve'; +import { volumeOf } from '../core/properties/sounds'; +import { soundFxBuildFor } from '../core/soundfx/compile'; +import { DRY_VOICE, type FxPlan, type FxVoice } from '../core/soundfx/nodes'; import { playNote } from '../runtime/systems/behaviorContext'; import { soundLibrary } from './SoundLibrary'; import { wake } from './wake'; @@ -12,33 +21,50 @@ export interface PlannedNote extends ScheduledNote { planned: number; } +/** One planned clip of a song (#334), the same way (test hook). */ +export interface PlannedClip extends ScheduledClip { + voice: number; + planned: number; +} + +/** What a track of a song is set to: its bus, its level and its pan (#334). */ +export interface BusLevel { + name: string; + gain: number; + pan: number; +} + /** * The scheduler on the sound library's clock (#332), its notes played by the instruments of the - * project: what the Pattern tab plays (#333), and what the check scripts drive through `__gm.music`. + * project: what the Pattern tab (#333) and the Song tab (#334) play, and what the check scripts drive + * through `__gm.music`. */ export class Music { private scheduler: Scheduler | null = null; private sequence: Sequence = { notes: [] }; /** Counts starts and stops: a start still loading its files gives up once another came after it. */ private run = 0; - /** The last notes planned, newest last, for a check to hold against the clock. */ + /** The last notes and clips planned, newest last, for a check to hold against the clock. */ readonly planned: PlannedNote[] = []; + readonly clips: PlannedClip[] = []; constructor(private readonly project: () => Project) {} - /** Loads what the notes need, then plays from `from` ticks. */ + /** Loads what the notes and clips need, then plays from `from` ticks. */ async play(sequence: Sequence, options: SchedulerOptions & { from?: number } = {}): Promise { this.stop(); const run = this.run; this.sequence = sequence; this.planned.length = 0; - await soundLibrary.prepare(this.layersOf(sequence)); + this.clips.length = 0; + await this.prepare(sequence); if (run !== this.run) return; this.scheduler = new Scheduler( { now: () => soundLibrary.time(), latency: () => soundLibrary.latency(), play: (note) => this.playOne(note), + playClip: (clip) => this.playClip(clip), playing: (voice) => soundLibrary.playing(voice), stop: (voices) => { for (const voice of voices) soundLibrary.stopVoice(voice); @@ -54,7 +80,12 @@ export class Music { /** A change heard from the next window on; a note new to it loads what it needs meanwhile. */ edit(sequence: Sequence): void { this.sequence = sequence; - void soundLibrary.prepare(this.layersOf(sequence)); + void this.prepare(sequence); + } + + /** The tracks of a song set to their level and pan, heard at once (#334). */ + setBuses(levels: readonly BusLevel[]): void { + for (const { name, gain, pan } of levels) soundLibrary.setBus(name, gain, pan); } stop(): void { @@ -68,7 +99,12 @@ export class Music { } private playOne(note: ScheduledNote): number { - const struck = { note: note.note, velocity: note.velocity, volume: 1, duration: note.duration }; + const struck = { + note: note.note, + velocity: note.velocity, + volume: note.volume ?? 1, + duration: note.duration, + }; const voice = playNote( { project: this.project(), audio: soundLibrary }, note.instrument, @@ -78,6 +114,7 @@ export class Music { { at: note.at, tempo: note.tempo, + ...(note.bus && { bus: note.bus }), }, ); this.planned.push({ ...note, voice, planned: soundLibrary.clock() }); @@ -85,8 +122,48 @@ export class Music { return voice; } - /** The layers of every note the sequence has, so their files are there before the first is due. */ - private layersOf(sequence: Sequence) { + /** A sound at its own volume times the item's, through its sound FX where it has one. */ + private playClip(clip: ScheduledClip): number { + const project = this.project(); + const asset = findAssetOf(project, clip.sound, 'sound'); + const volume = asset + ? Math.max(0, Math.min(1, volumeOf(resolveAssetProps(project, asset)) * clip.volume)) + : 0; + const voice = asset?.file + ? soundLibrary.playClip( + asset.file, + { at: clip.at, offset: clip.offset, duration: clip.duration, volume, bus: clip.bus }, + this.fxOf(clip.fx, clip.tempo), + ) + : 0; + this.clips.push({ ...clip, voice, planned: soundLibrary.clock() }); + if (this.clips.length > 256) this.clips.shift(); + return voice; + } + + /** A sound FX that does not compile plays the sound dry, as `Play sound` does. */ + private fxOf(fxId: string, tempo: number): FxVoice { + const fx = fxId ? findAssetOf(this.project(), fxId, 'soundfx') : undefined; + const plan = fx && soundFxBuildFor(fx).compiled; + return plan ? { plan, params: {}, tempo } : DRY_VOICE; + } + + /** Every file and worklet the sequence needs, so they are there before the first note is due. */ + private async prepare(sequence: Sequence): Promise { + const project = this.project(); + const plans: FxPlan[] = [...this.layersOf(sequence)]; + const files = new Set(); + for (const clip of sequence.clips ?? []) { + const file = findAssetOf(project, clip.sound, 'sound')?.file; + if (file) files.add(file); + const fx = this.fxOf(clip.fx, 120); + if (fx !== DRY_VOICE) plans.push(fx.plan); + } + await Promise.all([soundLibrary.prepare(plans), ...[...files].map((file) => soundLibrary.load(file))]); + } + + /** The layers of every note the sequence has. */ + private layersOf(sequence: Sequence): FxPlan[] { const project = this.project(); const sources = instrumentSourcesOf(project); const seen = new Set(); diff --git a/src/viewport/pitchWorklet.ts b/src/viewport/pitchWorklet.ts index 9de32ade..7973f571 100644 --- a/src/viewport/pitchWorklet.ts +++ b/src/viewport/pitchWorklet.ts @@ -28,6 +28,8 @@ export interface PitchOptions { loopEnd?: number; /** The second of the audio clock its first sample is heard at (#332); before it, silence. */ startAt?: number; + /** Samples of the sound before where it starts, for a clip of a song taken up mid-way (#334). */ + skip?: number; } /** Every sound a voice has been played from, for the next voice of it – processors share this scope. */ @@ -40,11 +42,11 @@ class PitchProcessor extends AudioWorkletProcessor { constructor({ processorOptions }: { processorOptions: PitchOptions }) { super(); - const { file, channels, step, loop, loopStart, loopEnd, startAt } = processorOptions; + const { file, channels, step, loop, loopStart, loopEnd, startAt, skip } = processorOptions; this.startAt = startAt ?? 0; if (channels) sounds.set(file, channels); const region = loop && loopEnd !== undefined ? { start: loopStart ?? 0, end: loopEnd } : loop; - this.player = new PitchPlayer(sounds.get(file) ?? [], step, sampleRate, region); + this.player = new PitchPlayer(sounds.get(file) ?? [], step, sampleRate, region, skip); this.port.onmessage = (event: MessageEvent) => { if (event.data === 'leave') this.player.leaveLoop(); else this.stopped = true; diff --git a/tests/core/song.test.ts b/tests/core/song.test.ts new file mode 100644 index 00000000..27e90726 --- /dev/null +++ b/tests/core/song.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; +import { SetSongCommand, SetSongLoopCommand } from '../../src/core/commands/songCommands'; +import type { PatternAsset, SongAsset } from '../../src/core/model/Asset'; +import { + heldSongItems, + heldSongTracks, + itemFrom, + newItem, + type SongItem, + songGrid, + songLength, + songLoop, + songSequence, + trackBus, + trackLevel, +} from '../../src/core/music/song'; +import { type ScheduledClip, Scheduler } from '../../src/core/music/Scheduler'; +import { clipsIn, type Sequence } from '../../src/core/music/sequence'; +import { secondsOfTicks } from '../../src/core/music/time'; +import { schemaForAsset } from '../../src/core/properties/PropertySchema'; +import { makeProject, roundTrip } from '../helpers'; + +const BAR = 1920; + +/** A song with its four tracks, a one-bar pattern of two notes played by "Piano", and a sound. */ +function makeSong() { + const f = makeProject(); + const create = new CreateAssetCommand('song', 'Tune'); + const pattern = new CreateAssetCommand('pattern', 'Riff'); + const piano = new CreateAssetCommand('instrument', 'Piano'); + const organ = new CreateAssetCommand('instrument', 'Organ'); + for (const command of [create, pattern, piano, organ]) f.stack.execute(command); + f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1)); + f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', piano.asset.id)); + f.stack.execute( + new SetPatternNotesCommand(pattern.asset.id, [ + { id: 'n1', tick: 0, length: 480, note: 60, velocity: 1 }, + { id: 'n2', tick: 960, length: 1200, note: 64, velocity: 0.5 }, + ]), + ); + f.project.assets.push({ id: 'snd', type: 'sound', name: 'Drum loop', props: {}, file: 'loop.wav' }); + const song = () => f.project.assets.find((a) => a.id === create.asset.id) as SongAsset; + const track = (i: number) => song().tracks[i]!.id; + const item = (fields: Partial & Pick): SongItem => ({ + ...newItem({ track: track(0), kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR }), + ...fields, + }); + const lay = (...items: SongItem[]) => f.stack.execute(new SetSongCommand(song().id, { items })); + return { + ...f, + song, + track, + item, + lay, + patternId: pattern.asset.id, + piano: piano.asset.id, + organ: organ.asset.id, + }; +} + +describe('the song asset (#334)', () => { + it('starts with four tracks, nothing on them, at 120 BPM in 4/4 on a grid of a bar', () => { + const f = makeSong(); + expect(f.song().tracks.map((t) => t.name)).toEqual(['Track 1', 'Track 2', 'Track 3', 'Track 4']); + expect(f.song().items).toEqual([]); + expect(schemaForAsset(f.project, f.song()).map((d) => d.key)).toEqual(['tempo', 'beats', 'beat', 'grid']); + expect(songGrid(f.song().props)).toBe(BAR); + f.stack.execute(new SetAssetPropertyCommand(f.song().id, 'grid', '1/8T')); + expect(songGrid(f.song().props)).toBe(160); + }); + + it('is at least four bars long, else up to the bar line after its last item, and loops all of it', () => { + const f = makeSong(); + expect(songLength(f.song())).toBe(4 * BAR); + f.lay(f.item({ tick: 4 * BAR, length: 100 })); + expect(songLength(f.song())).toBe(5 * BAR); + expect(songLoop(f.song())).toEqual({ start: 0, end: 5 * BAR }); + f.stack.execute(new SetSongLoopCommand(f.song().id, { start: BAR, end: 2 * BAR })); + expect(songLoop(f.song())).toEqual({ start: BAR, end: 2 * BAR }); + f.stack.undo(); + expect(f.song().loop).toBeUndefined(); + }); + + it('keeps tracks and items to their rules on load, and a song survives the round trip', () => { + const tracks = heldSongTracks([{ id: 'a', name: '', volume: 99, pan: -4, mute: 1 }, 'x', { id: 'a' }]); + expect(tracks.map((t) => [t.name, t.volume, t.pan, t.mute])).toEqual([ + ['Track 1', 6, -1, false], + ['Track 2', 0, 0, false], + ]); + expect(tracks[1]!.id).not.toBe('a'); + expect(heldSongTracks([])).toHaveLength(1); + const items = heldSongItems( + [ + { + id: 'i', + track: 'a', + kind: 'pattern', + asset: 'p', + tick: 10.4, + length: 0.2, + offset: 3.7, + volume: 5, + transpose: 99, + }, + { track: 'gone', kind: 'pattern', tick: 0, length: 10 }, + { track: 'a', kind: 'video', tick: 0, length: 10 }, + { track: 'a', kind: 'sound', tick: -1, length: 10 }, + { track: 'a', kind: 'sound', tick: 0, length: 10, offset: 1.25 }, + ], + tracks, + ); + expect(items.map((i) => [i.tick, i.length, i.offset, i.volume, i.transpose])).toEqual([ + [0, 10, 1.25, 0, 0], + [10, 1, 4, 0, 48], + ]); + + const f = makeSong(); + f.lay(f.item({ tick: BAR, length: BAR, transpose: -2, volume: -6 })); + f.stack.execute(new SetSongLoopCommand(f.song().id, { start: 0, end: BAR })); + const back = roundTrip(f.project).assets.find((a) => a.id === f.song().id) as SongAsset; + expect([back.tracks, back.items, back.loop]).toEqual([f.song().tracks, f.song().items, f.song().loop]); + }); + + it('takes a track with its items, and an undo brings both back', () => { + const f = makeSong(); + f.lay(f.item({ tick: 0, length: BAR }), f.item({ tick: 0, length: BAR, track: f.track(1) })); + const before = JSON.stringify(f.song()); + const tracks = f.song().tracks.filter((t) => t.id !== f.track(0)); + f.stack.execute(new SetSongCommand(f.song().id, { tracks })); + expect(f.song().items.map((i) => i.track)).toEqual([tracks[0]!.id]); + f.stack.undo(); + expect(JSON.stringify(f.song())).toBe(before); + expect(SetSongCommand.changes(f.song(), { items: f.song().items })).toBe(false); + }); +}); + +describe('what a song plays (#334)', () => { + it('repeats a pattern for as long as its item is, each note held to the item and the pattern', () => { + const f = makeSong(); + f.lay(f.item({ tick: BAR, length: 2 * BAR + 480 })); + const { notes } = songSequence(f.project, f.song()); + expect(notes.map((n) => [n.tick, n.length, n.note])).toEqual([ + [BAR, 480, 60], + [BAR + 960, 960, 64], + [2 * BAR, 480, 60], + [2 * BAR + 960, 960, 64], + [3 * BAR, 480, 60], + ]); + expect(notes.every((n) => n.instrument === f.piano && n.bus === trackBus(f.song().id, f.track(0)))).toBe( + true, + ); + }); + + it('starts a pattern as far in as its offset says, moved by its transpose, with another instrument', () => { + const f = makeSong(); + f.lay(f.item({ tick: 0, length: BAR, offset: 960, transpose: 12, instrument: f.organ, volume: -6 })); + const { notes } = songSequence(f.project, f.song()); + expect(notes.map((n) => [n.tick, n.length, n.note, n.instrument])).toEqual([ + [0, 960, 76, f.organ], + [960, 480, 72, f.organ], + ]); + expect(notes[0]!.volume).toBeCloseTo(0.501, 3); + }); + + it('plays a sound as a clip with its offset and FX, and leaves out what has nothing to play', () => { + const f = makeSong(); + f.lay( + f.item({ + kind: 'sound', + asset: 'snd', + tick: 480, + length: BAR, + offset: 0.25, + fx: 'fx1', + track: f.track(2), + }), + f.item({ kind: 'sound', asset: 'gone', tick: 0, length: BAR }), + f.item({ asset: 'gone', tick: 0, length: BAR }), + ); + expect(songSequence(f.project, f.song())).toEqual({ + notes: [], + clips: [ + { + tick: 480, + length: BAR, + sound: 'snd', + fx: 'fx1', + offset: 0.25, + volume: 1, + bus: trackBus(f.song().id, f.track(2)), + }, + ], + }); + }); + + it('silences a muted track, and while any track is soloed every one that is not', () => { + const f = makeSong(); + const [a, b] = f.song().tracks; + const level = trackLevel({ ...a!, volume: -6, pan: 0.5 }, f.song().tracks); + expect(level.gain).toBeCloseTo(0.501, 3); + expect(level.pan).toBe(0.5); + expect(trackLevel({ ...a!, mute: true }, f.song().tracks).gain).toBe(0); + const soloed = [{ ...a!, solo: true }, b!]; + expect([trackLevel(soloed[0]!, soloed).gain, trackLevel(b!, soloed).gain]).toEqual([1, 0]); + expect(trackLevel({ ...a!, volume: -60 }, f.song().tracks).gain).toBe(0); + }); + + it('cuts an item in two where its rest plays on: ticks into a pattern, seconds into a sound', () => { + const f = makeSong(); + const pattern = f.item({ tick: BAR, length: BAR, offset: 120 }); + expect(itemFrom(pattern, BAR + 480, 120)).toMatchObject({ + tick: BAR + 480, + length: BAR - 480, + offset: 600, + }); + const sound = f.item({ kind: 'sound', asset: 'snd', tick: BAR, length: BAR, offset: 0.5 }); + expect(itemFrom(sound, BAR + 480, 120)).toMatchObject({ tick: BAR + 480, offset: 1 }); + expect(itemFrom(sound, BAR, 120)).toBeNull(); + expect(itemFrom(sound, 2 * BAR, 120)).toBeNull(); + }); +}); + +describe('clips on the scheduler (#334)', () => { + const clip = (tick: number, length: number) => ({ + tick, + length, + sound: 's', + fx: '', + offset: 0, + volume: 1, + bus: '', + }); + const sequence: Sequence = { notes: [], clips: [clip(0, 4 * BAR), clip(2 * BAR, BAR)] }; + + it('takes a clip up mid-way where the playhead starts, and only there', () => { + expect(clipsIn(sequence, BAR, null, 0, 10, true).map((c) => [c.at, c.skip, c.length])).toEqual([ + [0, BAR, 3 * BAR], + ]); + expect(clipsIn(sequence, BAR, null, 0, 10, false)).toEqual([]); + expect(clipsIn(sequence, BAR, null, BAR, BAR + 10, false).map((c) => [c.at, c.skip])).toEqual([[BAR, 0]]); + }); + + it('ends a clip at the end of the loop and takes it up again after the jump back', () => { + const loop = { start: BAR, end: 2 * BAR + 960 }; + const due = clipsIn(sequence, BAR, loop, 0, 2 * BAR, true); + expect(due.map((c) => [c.at, c.skip, c.length])).toEqual([ + [0, BAR, BAR + 960], + [BAR, 0, 960], + [BAR + 960, BAR, BAR + 960], + ]); + }); + + it('hands a clip to the player at its second, as far into the sound as the playhead is', () => { + const played: ScheduledClip[] = []; + let now = 10; + const scheduler = new Scheduler( + { + now: () => now, + latency: () => 0, + play: () => 0, + playClip: (c) => played.push(c), + playing: () => true, + stop: () => {}, + wake: () => () => {}, + }, + () => ({ notes: [], clips: [{ ...clip(0, 4 * BAR), offset: 0.25, volume: 0.5, bus: 'b' }] }), + { tempo: 120 }, + ); + scheduler.start(BAR + 480); + expect(played).toHaveLength(1); + expect(played[0]!.at).toBeCloseTo(10.05, 9); + expect(played[0]!.offset).toBeCloseTo(0.25 + secondsOfTicks(BAR + 480, 120), 9); + expect(played[0]!.duration).toBeCloseTo(secondsOfTicks(4 * BAR - BAR - 480, 120), 9); + expect([played[0]!.volume, played[0]!.bus, played[0]!.tempo]).toEqual([0.5, 'b', 120]); + now += 0.5; + scheduler.pump(); + expect(played).toHaveLength(1); + }); + + it('gives the notes of a song their volume and track on the way to the player', () => { + const f = makeSong(); + f.lay(f.item({ tick: 0, length: 480, volume: -6 })); + const notes: unknown[] = []; + const scheduler = new Scheduler( + { + now: () => 0, + latency: () => 0, + play: (n) => notes.push(n), + playing: () => true, + stop: () => {}, + wake: () => () => {}, + }, + () => songSequence(f.project, f.song()), + ); + scheduler.start(); + expect(notes).toHaveLength(1); + expect(notes[0]).toMatchObject({ note: 60, bus: trackBus(f.song().id, f.track(0)) }); + expect((notes[0] as { volume: number }).volume).toBeCloseTo(0.501, 3); + }); +}); + +describe('a pattern laid out in a song', () => { + it('is found by its id: an edit to the pattern is heard everywhere it lies', () => { + const f = makeSong(); + f.lay(f.item({ tick: 0, length: BAR }), f.item({ tick: BAR, length: BAR, track: f.track(1) })); + f.stack.execute( + new SetPatternNotesCommand(f.patternId, [{ id: 'n', tick: 240, length: 10, note: 70, velocity: 1 }]), + ); + expect(songSequence(f.project, f.song()).notes.map((n) => [n.tick, n.note])).toEqual([ + [240, 70], + [BAR + 240, 70], + ]); + expect((f.project.assets.find((a) => a.id === f.patternId) as PatternAsset).notes).toHaveLength(1); + }); +}); diff --git a/tests/editor/song-edit.test.ts b/tests/editor/song-edit.test.ts new file mode 100644 index 00000000..54936020 --- /dev/null +++ b/tests/editor/song-edit.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { newItem, type SongItem } from '../../src/core/music/song'; +import { + clampMove, + draggedLength, + droppedLength, + duplicateOffset, + moveDelta, + moved, + placedCopies, + split, + trimDelta, + trimmed, +} from '../../src/editor/song/songEdit'; +import { + clampTracks, + HEAD_BOXES, + headPartAt, + itemAt, + itemsWithin, + sliderValue, + type SongView, + TRACK_HEIGHT, +} from '../../src/editor/song/songView'; + +const BAR = 1920; +const TRACKS = ['t1', 't2', 't3']; +const rows = new Map(TRACKS.map((id, i) => [id, i])); +const tracks = TRACKS.map((id) => ({ id, name: id, volume: 0, pan: 0, mute: false, solo: false })); + +function item( + id: string, + track: string, + tick: number, + length: number, + extra: Partial = {}, +): SongItem { + return { ...newItem({ track, kind: 'pattern', asset: 'p', tick, length }), id, ...extra }; +} + +describe('moving and sizing items (#334)', () => { + const items = [item('a', 't1', BAR, BAR), item('b', 't2', 3 * BAR, BAR)]; + const both = new Set(['a', 'b']); + + it('moves the picked items on the grid, across tracks, never before 0 or off the tracks', () => { + expect(moveDelta(items, both, rows, 3, items[0]!, BAR + 700, 1, BAR, false)).toEqual({ + ticks: 0, + tracks: 1, + }); + expect(moveDelta(items, both, rows, 3, items[0]!, BAR + 1000, 2, BAR, false)).toEqual({ + ticks: BAR, + tracks: 1, + }); + expect(moveDelta(items, both, rows, 3, items[0]!, -5000, 0, BAR, false)).toEqual({ + ticks: -BAR, + tracks: 0, + }); + expect(moveDelta(items, both, rows, 3, items[0]!, BAR + 7, 0, BAR, true)).toEqual({ + ticks: 7, + tracks: 0, + }); + expect(clampMove(items, both, rows, 3, 0, -1)).toEqual({ ticks: 0, tracks: 0 }); + expect(moved(items, both, TRACKS, 480, 1).map((i) => [i.tick, i.track])).toEqual([ + [BAR + 480, 't2'], + [3 * BAR + 480, 't3'], + ]); + }); + + it('lengthens by the right end to the grid, and never back past its own start', () => { + expect(draggedLength(items[0]!, 3 * BAR + 100, BAR, false)).toBe(2 * BAR); + expect(draggedLength(items[0]!, 0, BAR, false)).toBe(BAR); + expect(draggedLength(items[0]!, BAR + 5, BAR, true)).toBe(5); + }); + + it('cuts back by the left end what it plays with it, and pulls it out no further than its start', () => { + const sound = item('s', 't1', BAR, BAR, { kind: 'sound', offset: 0.25 }); + const picked = new Set(['s']); + // 0.25 s at 120 BPM is 240 ticks: that is as far back as the left end goes. + expect(trimDelta([sound], picked, sound, 0, 120, false, 120)).toBe(-240); + expect(trimDelta([sound], picked, sound, 5 * BAR, 120, false, 120)).toBe(BAR - 1); + const [cut] = trimmed([sound], picked, 480, 120); + expect([cut!.tick, cut!.length, cut!.offset]).toEqual([BAR + 480, BAR - 480, 0.75]); + const pattern = item('p', 't1', BAR, BAR, { offset: 120 }); + expect(trimDelta([pattern], new Set(['p']), pattern, 0, 60, false, 120)).toBe(-120); + expect(trimmed([pattern], new Set(['p']), -120, 120)[0]).toMatchObject({ tick: BAR - 120, offset: 0 }); + }); + + it('splits the picked items the point falls inside, and gives the right-hand parts', () => { + const cut = split(items, new Set(['a', 'b']), BAR + 480, 120); + expect(cut.items.map((i) => [i.track, i.tick, i.length, i.offset])).toEqual([ + ['t1', BAR, 480, 0], + ['t1', BAR + 480, BAR - 480, 480], + ['t2', 3 * BAR, BAR, 0], + ]); + expect(cut.right).toEqual([cut.items[1]!.id]); + }); + + it('puts a duplicate right behind the picked items, and a paste on their tracks', () => { + expect(duplicateOffset(items, both, BAR)).toBe(3 * BAR); + const copies = placedCopies(items, 480, ['t2', 't3']); + expect(copies.map((i) => [i.tick, i.track])).toEqual([ + [480, 't2'], + [480 + 2 * BAR, 't2'], + ]); + expect(copies.every((c) => c.id !== 'a' && c.id !== 'b')).toBe(true); + }); + + it('makes a dropped pattern as long as it is, a sound as long as it plays at the tempo, else a bar', () => { + expect(droppedLength(null, 3 * BAR, 120, BAR)).toBe(3 * BAR); + expect(droppedLength(1.25, null, 120, BAR)).toBe(1200); + expect(droppedLength(null, null, 120, BAR)).toBe(BAR); + }); +}); + +describe('where things are in the Song tab (#334)', () => { + const view: SongView = { panX: 0, pxPerTick: 0.1, panY: 0 }; + const items = [item('a', 't1', 0, BAR), item('b', 't2', BAR, BAR)]; + + it('finds an item and which end of it is under the pointer', () => { + expect(itemAt(view, items, tracks, { x: 96, y: TRACK_HEIGHT / 2 })).toEqual({ id: 'a', part: 'body' }); + expect(itemAt(view, items, tracks, { x: 190, y: TRACK_HEIGHT / 2 })).toEqual({ id: 'a', part: 'end' }); + expect(itemAt(view, items, tracks, { x: 194, y: TRACK_HEIGHT * 1.5 })).toEqual({ + id: 'b', + part: 'start', + }); + expect(itemAt(view, items, tracks, { x: 96, y: TRACK_HEIGHT * 1.5 })).toBeNull(); + expect(itemsWithin(view, items, tracks, { x: 150, y: 10 }, { x: 250, y: TRACK_HEIGHT + 10 })).toEqual([ + 'a', + 'b', + ]); + }); + + it('scrolls no further than the tracks and the row that adds one', () => { + expect(clampTracks({ ...view, panY: 40 }, 3, 400).panY).toBe(0); + expect(clampTracks({ ...view, panY: -500 }, 10, 400).panY).toBe(400 - 10 * TRACK_HEIGHT - 28); + }); + + it('tells the parts of a head apart and reads a slider from left to right', () => { + const { mute, solo, volume, pan } = HEAD_BOXES; + expect(headPartAt(mute.x + 4, mute.y + 4)).toBe('mute'); + expect(headPartAt(solo.x + 4, solo.y + 4)).toBe('solo'); + expect(headPartAt(volume.x + 10, volume.y + 4)).toBe('volume'); + expect(headPartAt(pan.x + 10, pan.y + 4)).toBe('pan'); + expect(headPartAt(20, 8)).toBe('name'); + expect(sliderValue(volume.x, volume, -60, 6, 0.5)).toBe(-60); + expect(sliderValue(volume.x + volume.w, volume, -60, 6, 0.5)).toBe(6); + expect(sliderValue(pan.x + pan.w / 2, pan, -1, 1, 0.05)).toBe(0); + }); +}); diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index dd707298..eed28ea6 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -60,11 +60,12 @@ interface FakeSource { playbackRate: FakeParam; onended: (() => void) | null; started: boolean; - /** The second of the audio clock it was started at. */ + /** The second of the audio clock it was started at, and how far into its sound (#334). */ startedAt: number; + startedOffset: number; stopped: boolean; connect: (target: unknown) => unknown; - start: (at?: number) => void; + start: (at?: number, offset?: number) => void; stop: () => void; } @@ -112,6 +113,8 @@ const clocks: { stopped: boolean; onended: (() => void) | null; }[] = []; +/** The pan of every track's panner made (#334), in order. */ +const panners: FakeParam[] = []; /** The curve of every WaveShaper made, in order. */ const shapers: { curve: Float32Array | null }[] = []; let contextState = 'running'; @@ -183,10 +186,12 @@ function fakeContext() { onended: null, started: false, startedAt: -1, + startedOffset: 0, stopped: false, - start: (at?: number) => { + start: (at?: number, offset?: number) => { source.started = true; source.startedAt = at ?? 0; + source.startedOffset = offset ?? 0; }, stop: () => { source.stopped = true; @@ -216,6 +221,11 @@ function fakeContext() { return delay; }, createChannelMerger: () => wired('merger', {}), + createStereoPanner: () => { + const panner = wired('panner', { pan: param(0) }); + panners.push(panner.pan); + return panner; + }, createChannelSplitter: () => wired('splitter', {}), createConstantSource: () => { const clock = wired('clock', { @@ -259,6 +269,7 @@ beforeEach(() => { disconnects.length = 0; clocks.length = 0; shapers.length = 0; + panners.length = 0; contextState = 'running'; decodes = []; vi.stubGlobal( @@ -1315,3 +1326,90 @@ describe('notes with an Envelope (#335)', () => { expect([shapers[0]!.curve![512], shapers[0]!.curve![1024]]).toEqual([-12, 0]); }); }); + +describe('clips and tracks of a song (#334)', () => { + it('starts a clip at its second, as far into its sound as it says, round and round', async () => { + const library = new SoundLibrary(); + // The sound is 1.5 s long: 2 s in is half a second into its second round. + library.playClip(FILE, { at: 2, offset: 2, duration: 3, volume: 0.5, bus: '' }); + await settle(); + await arrive(); + expect(sources[0]).toMatchObject({ startedAt: 2, startedOffset: 0.5, loop: true }); + expect(library.stats().last).toMatchObject({ at: 2, volume: 0.5, loop: true }); + }); + + it('fades a clip out at its end and lets it go, a source only once', async () => { + const library = new SoundLibrary(); + library.playClip(FILE, { at: 2, offset: 0, duration: 3, volume: 1, bus: '' }); + await settle(); + await arrive(); + // The voice's gain first, then the fade of its source. + expect(gains[1]!.ramps).toEqual([ + ['set', 1, 5], + ['linear', 0, 5.01], + ]); + expect(clocks.map((clock) => clock.stopAt)).toEqual([5.01]); + expect(sources[0]!.stopped).toBe(false); + clockTo(); + expect(sources[0]!.stopped).toBe(true); + expect(library.stats().playing).toBe(0); + }); + + it('starts a pitched clip in its worklet as many samples in as the offset is', async () => { + const library = new SoundLibrary(); + const pitched = { pitch: [{ value: 5 }], stages: [] } as FxPlan; + library.playClip( + FILE, + { at: 1, offset: 1.75, duration: 1, volume: 1, bus: '' }, + { plan: pitched, params: {} }, + ); + await settle(); + await arrive(); + // 0.25 s into the second round, at the sound's own 800 samples a second. + expect(worklets[0]!.options.processorOptions).toMatchObject({ startAt: 1, loop: true, skip: 200 }); + }); + + it("sends a song's voices through the bus of their track: its level, then its pan, then out", async () => { + const library = new SoundLibrary(); + library.setBus('song:s:t', 0.5, -0.25); + library.playNote( + 'instrument:i', + [ + { + pitch: [], + stages: [], + source: { file: FILE, volume: 1, loop: false, loopStart: 0, loopEnd: 0, gate: false }, + }, + ], + {}, + 1, + '', + 0, + { bus: 'song:s:t' }, + ); + await settle(); + await arrive(); + expect(wires).toEqual(expect.arrayContaining(['gain > panner', 'panner > destination'])); + expect(wires.filter((wire) => wire === 'gain > gain').length).toBeGreaterThan(0); + expect(wires).not.toContain('gain > destination'); + expect(gains[0]!.glides).toEqual([0.5]); + expect(panners[0]!.glides).toEqual([-0.25]); + library.setBus('song:s:t', 0, 1); + expect(panners).toHaveLength(1); + expect([gains[0]!.glides, panners[0]!.glides]).toEqual([ + [0.5, 0], + [-0.25, 1], + ]); + }); + + it('says how loud each stretch of a loaded sound is, and nothing before its bytes are there', async () => { + const library = new SoundLibrary(); + expect(library.peaks(FILE)).toBeNull(); + void library.load(FILE); + await settle(); + await arrive(); + const peaks = library.peaks(FILE); + expect(peaks).toHaveLength(1024); + expect(library.peaks(FILE)).toBe(peaks); + }); +}); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index 30f13ece..6eea6d15 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -2,7 +2,7 @@ import { colourDistance } from '../lib/harness.mjs'; import { inView } from './lib/scaffold.mjs'; /** One per entry of `ASSET_TYPES` – a new asset type counts here too. */ -const ASSET_TYPE_COUNT = 29; +const ASSET_TYPE_COUNT = 30; /** The asset panel: placing by dragging into the view, creating through "+", and resetting an asset value. */ export async function assets(t) { diff --git a/tools/e2e/54-struct-types.mjs b/tools/e2e/54-struct-types.mjs index 7a07d286..6ff9f67e 100644 --- a/tools/e2e/54-struct-types.mjs +++ b/tools/e2e/54-struct-types.mjs @@ -7,7 +7,7 @@ * * Everything it adds carries an `st54` id or is an asset it made, and goes again at the end. */ -import { MAKE_OBJECT } from './lib/scaffold.mjs'; +import { inView, MAKE_OBJECT } from './lib/scaffold.mjs'; export async function structTypes(t) { const { @@ -93,6 +93,19 @@ export async function structTypes(t) { await t.showTab('scene'); await clickText('+'); const offered = (await json('__gm.menuRows()')) ?? []; + // Struct sits below the fold of the menu since the Pattern (#333); the lowest hit between the + // box's sides is the entry, not the tab above it. + await inView( + t, + '__gm.overlayRect()', + `(() => { + const box = __gm.overlayRect(); + const hits = (__gm.locateAll('Struct') ?? []).filter( + (hit) => hit.x >= box.x && hit.x <= box.x + box.w && hit.y >= box.y, + ); + return hits.sort((a, b) => a.y - b.y).at(-1) ?? null; + })()`, + ); const entry = await inOverlay('Struct'); check('the + menu offers Struct', offered.includes('Struct') && Boolean(entry), JSON.stringify(offered)); if (!entry) return; diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs new file mode 100644 index 00000000..e1794908 --- /dev/null +++ b/tools/e2e/65-song.mjs @@ -0,0 +1,363 @@ +import { edge, node } from '../lib/builders.mjs'; +import { pickAssetType } from './lib/scaffold.mjs'; + +/** + * Songs and their tracks (#334). "+ → Song" opens the tab on four empty tracks. A pattern and a + * sound dragged in from the Assets panel land as items where the preview showed them; an item's + * right end lengthens it, its body moves it across tracks, its left end cuts it back, Ctrl+D puts a + * copy behind it, Ctrl+E splits it where playing starts, Delete takes it away. A head mutes, solos + * and sets the volume of its track, "+ Track" adds one. Space plays the song through the scheduler: + * the pattern round and round at the song's tempo, the sound as a clip, each track on its own bus. + * A pattern started meanwhile takes over, and a double-click opens a pattern item in its tab. + * Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and clips + * and what the buses were set to, as in e2e 64. + * + * Everything it adds carries a `sg65` id or is the song it made, and goes again at the end. + */ +const BLIP_BYTES = 4044; +const BAR = 1920; +const PATTERN = 'sg65-pat'; +const SOUND = 'sg65-snd'; + +export async function song(t) { + const { + check, + sleep, + json, + evaluate, + mutate, + mouse, + click, + clickText, + doubleClick, + press, + drag, + shot, + showTab, + } = t; + const waitFor = async (read, done, tries = 25) => { + let value = await read(); + for (let i = 0; i < tries && !done(value); i++) { + await sleep(150); + value = await read(); + } + return value; + }; + /** Drags a row of the Assets panel to a point step by step: the drop only exists while the button is down. */ + const dragAsset = async (assetId, to) => { + const from = await json(`__gm.assetRow(${JSON.stringify(assetId)})`); + await mouse('mouseMoved', from.x, from.y); + await mouse('mousePressed', from.x, from.y); + for (let i = 1; i <= 8; i++) { + await mouse( + 'mouseMoved', + from.x + ((to.x - from.x) * i) / 8, + from.y + ((to.y - from.y) * i) / 8, + 'left', + { + buttons: 1, + }, + ); + await sleep(16); + } + const drop = (await json('__gm.song.view()')).drop; + await mouse('mouseReleased', to.x, to.y); + await sleep(200); + return drop; + }; + + await press('Escape'); + await evaluate('__gm.state.select(null)'); + await showTab('scene'); + const stored = await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`); + const blip = stored.find((item) => item.bytes === BLIP_BYTES); + await mutate(` + __gm.state.project.assets.push( + { id: ${JSON.stringify(SOUND)}, type: 'sound', name: 'Blip 65', props: {}, file: ${JSON.stringify(blip?.file ?? '')} }, + { + id: 'sg65-ins', + type: 'instrument', + name: 'Lead 65', + props: {}, + graph: { + nodes: ${JSON.stringify([ + node('sg65-notes', 'InstrumentNotes', 0, 0, { from: 48, to: 84 }), + node('sg65-sample', 'InstrumentSample', 200, 0, { sound: SOUND }), + node('sg65-out', 'InstrumentOutput', 420, 0), + ])}, + edges: ${JSON.stringify([ + edge('sg65-e1', 'sg65-notes', 'any', 'sg65-sample', 'trigger'), + edge('sg65-e2', 'sg65-sample', 'out', 'sg65-out', 'in'), + ])}, + }, + }, + { + id: ${JSON.stringify(PATTERN)}, + type: 'pattern', + name: 'Riff 65', + props: { instrument: 'sg65-ins', bars: 1 }, + notes: [ + { id: 'sg65-n1', tick: 0, length: 480, note: 60, velocity: 1 }, + { id: 'sg65-n2', tick: 960, length: 480, note: 64, velocity: 0.5 }, + ], + }, + );`); + const seconds = await evaluate(`__gm.sound.load(${JSON.stringify(blip?.file ?? '')})`); + + // 65a. "+ → Song" makes a song of four empty tracks and opens its tab. + await clickText('+'); + await pickAssetType(t, 'Song'); + const created = await json(`{ + tab: __gm.tab(), + current: __gm.song.current(), + info: __gm.song.info(), + songs: __gm.state.project.assets.filter((a) => a.type === 'song').map((a) => ({ id: a.id, tracks: a.tracks.length })), + }`); + check( + 'the + menu creates a song of four empty tracks and opens its tab', + created.tab === 'song' && + created.songs.length === 1 && + created.current === created.songs[0].id && + created.songs[0].tracks === 4 && + created.info === '4 tracks · 0 items', + JSON.stringify(created), + ); + const id = created.songs[0]?.id; + const model = () => json(`__gm.song.model(${JSON.stringify(id)})`); + const tracks = (await model()).tracks.map((track) => track.id); + + // 65b. A pattern dragged onto the second track lands on the bar before the pointer, a bar long. + const preview = await dragAsset(PATTERN, await json(`__gm.song.laneAt(${BAR + 400}, 1)`)); + let items = (await model()).items; + const riff = items[0]; + check( + 'a pattern dragged in from the Assets panel shows where it lands and lands there, picked', + preview?.kind === 'pattern' && + preview.track === tracks[1] && + preview.tick === BAR && + items.length === 1 && + riff.kind === 'pattern' && + riff.asset === PATTERN && + riff.track === tracks[1] && + riff.tick === BAR && + riff.length === BAR && + (await json('__gm.song.picked()')).join() === riff.id && + (await json('__gm.state.selection?.kind')) === 'song', + JSON.stringify({ preview, items }), + ); + + // 65c. A sound dragged onto the first track is as long as it plays at 120 BPM. + await dragAsset(SOUND, await json('__gm.song.laneAt(300, 0)')); + items = (await model()).items; + const clip = items.find((item) => item.kind === 'sound'); + const drawn = await waitFor( + () => json('__gm.song.items()'), + (list) => list.find((item) => item.id === clip?.id)?.content === 'wave', + ); + check( + 'a sound dropped on a track lasts as long as the sound at the tempo, and shows its waveform', + clip?.track === tracks[0] && + clip.tick === 0 && + clip.length === Math.max(1, Math.round(seconds * 960)) && + drawn.find((item) => item.id === riff.id)?.content === 'notes' && + drawn.find((item) => item.id === clip.id)?.label === 'Blip 65', + JSON.stringify({ seconds, clip, drawn }), + ); + await shot('01-dropped'); + + // 65d. The pattern's right end takes it to three bars, its body to the third track a bar later. + let p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'end')`); + let to = await json(`__gm.song.laneAt(${4 * BAR}, 1)`); + await drag(p.x, p.y, to.x, to.y); + await sleep(200); + const lengthened = (await model()).items.find((item) => item.id === riff.id); + p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'body')`); + const { pxPerTick } = await json('__gm.song.view()'); + to = await json(`__gm.song.laneAt(0, 2)`); + await drag(p.x, p.y, p.x + BAR * pxPerTick, to.y); + await sleep(200); + const movedRiff = (await model()).items.find((item) => item.id === riff.id); + check( + 'its right end lengthens the pattern to three bars, its body takes it a bar on and a track down', + lengthened?.length === 3 * BAR && movedRiff?.tick === 2 * BAR && movedRiff.track === tracks[2], + JSON.stringify({ lengthened, movedRiff }), + ); + + // 65e. Its left end cuts a bar off the front: it then starts a bar into what it repeats. + p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'start')`); + to = await json(`__gm.song.laneAt(${3 * BAR}, 2)`); + await drag(p.x, p.y, to.x, to.y); + await sleep(200); + const trimmedRiff = (await model()).items.find((item) => item.id === riff.id); + check( + 'its left end cuts the front off, and what it plays starts that far in', + trimmedRiff?.tick === 3 * BAR && trimmedRiff.length === 2 * BAR && trimmedRiff.offset === BAR, + JSON.stringify(trimmedRiff), + ); + await press('z', { ctrl: true }); + check( + 'one undo takes the cut back', + JSON.stringify((await model()).items.find((item) => item.id === riff.id)) === JSON.stringify(movedRiff), + ); + + // 65f. Ctrl+D puts a copy right behind it; a click on the ruler and Ctrl+E split the copy there. + p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'body')`); + await click(p.x, p.y); + await press('d', { ctrl: true }); + items = (await model()).items; + const copy = items.find((item) => item.id !== riff.id && item.kind === 'pattern'); + check( + 'Ctrl+D puts a copy right behind the item on its track, picked', + items.length === 3 && + copy?.tick === 5 * BAR && + copy.length === 3 * BAR && + copy.track === tracks[2] && + (await json('__gm.song.picked()')).join() === copy.id, + JSON.stringify(items), + ); + p = await json('__gm.song.fitAt()'); + await click(p.x, p.y); + p = await json(`__gm.song.rulerAt(${6 * BAR + 300})`); + await click(p.x, p.y); + await press('e', { ctrl: true }); + items = (await model()).items; + const parts = items.filter((item) => item.tick >= 5 * BAR).sort((a, b) => a.tick - b.tick); + check( + 'a click on the ruler sets where playing starts, and Ctrl+E splits the picked item there', + (await json('__gm.song.view()')).start === 6 * BAR && + parts.length === 2 && + parts[0].length === BAR && + parts[1].tick === 6 * BAR && + parts[1].length === 2 * BAR && + parts[1].offset === BAR && + (await json('__gm.song.picked()')).join() === parts[1].id, + JSON.stringify(parts), + ); + await press('Delete'); + p = await json(`__gm.song.itemAt(${JSON.stringify(parts[0].id)}, 'body')`); + await click(p.x, p.y, 'right'); + await sleep(150); + items = (await model()).items; + check( + 'Delete and a right-click take items away', + items.length === 2 && items.every((item) => item.id === riff.id || item.id === clip.id), + JSON.stringify(items), + ); + + // 65g. The heads: mute and solo toggle, the volume slider sets decibels, "+ Track" adds a track. + await click(...Object.values(await json("__gm.song.headAt(0, 'mute')"))); + await click(...Object.values(await json("__gm.song.headAt(2, 'solo')"))); + p = await json("__gm.song.headAt(2, 'volume', 0.5)"); + to = await json("__gm.song.headAt(2, 'volume', 0)"); + await drag(p.x, p.y, to.x + 1, to.y); + await sleep(150); + p = await json('__gm.song.addTrackAt()'); + await click(p.x, p.y); + const heads = await model(); + check( + 'a head mutes, solos and turns down its track, "+ Track" adds a fifth and picks it', + heads.tracks.length === 5 && + heads.tracks[0].mute && + heads.tracks[2].solo && + heads.tracks[2].volume < -55 && + (await json('__gm.song.pickedTrack()')) === heads.tracks[4].id && + (await json('__gm.song.info()')) === '5 tracks · 2 items', + JSON.stringify(heads.tracks), + ); + await mutate(` + const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); + song.tracks[2].volume = -6; + song.tracks[2].solo = false; + song.tracks[0].mute = false;`); + + // 65h. Space plays from the start: the pattern a bar into itself at 2 s a bar, the sound as a + // clip round and round, each on the bus of its track. + p = await json('__gm.song.rulerAt(0)'); + await click(p.x, p.y); + await mutate(` + const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); + song.items.find((i) => i.kind === 'sound').length = ${BAR};`); + await press('Space'); + const played = await waitFor( + () => json('{ planned: __gm.song.planned(), clips: __gm.song.clips(), playing: __gm.song.playing() }'), + (s) => s.planned.length >= 2 && s.clips.length >= 1, + 60, + ); + const busOf = (track) => `song:${id}:${track}`; + const firstClip = played.clips[0]; + const notes = played.planned.slice(0, 2); + check( + 'Space plays the song: the clip at once, the pattern from bar 3 with its notes a second apart, each on its track', + played.playing && + Boolean(firstClip) && + firstClip.sound === SOUND && + firstClip.bus === busOf(tracks[0]) && + Math.abs(firstClip.duration - 2) < 1e-9 && + notes.length === 2 && + notes.every((n) => n.instrument === 'sg65-ins' && n.bus === busOf(tracks[2]) && n.tempo === 120) && + Math.abs(notes[0].at - firstClip.at - 4) < 1e-6 && + Math.abs(notes[1].at - notes[0].at - 1) < 1e-6 && + notes[0].note === 60, + JSON.stringify(played), + ); + const buses = await json('__gm.song.buses()'); + const bus = (track) => buses.find((b) => b.name === busOf(track)); + check( + 'each track is set on its bus: the turned-down one at -6 dB, the others at unity', + Math.abs(bus(tracks[2])?.gain - 10 ** (-6 / 20)) < 1e-6 && + bus(tracks[0])?.gain === 1 && + bus(tracks[1])?.gain === 1, + JSON.stringify(buses), + ); + await click(...Object.values(await json("__gm.song.headAt(0, 'mute')"))); + const muted = await waitFor( + () => json('__gm.song.buses()'), + (list) => list.find((b) => b.name === busOf(tracks[0]))?.gain === 0, + ); + check( + 'muting a track while it plays silences its bus at once', + muted.find((b) => b.name === busOf(tracks[0]))?.gain === 0 && (await json('__gm.song.playing()')), + JSON.stringify(muted), + ); + await shot('02-playing'); + + // 65i. A pattern played in its tab takes over; a double-click on a pattern item opens it there. + await evaluate(`__gm.state.openAsset(${JSON.stringify(PATTERN)})`); + await sleep(200); + p = await json('__gm.pattern.playAt()'); + await click(p.x, p.y); + await sleep(200); + const swapped = await json('{ song: __gm.song.playing(), pattern: __gm.pattern.playing() }'); + await click(p.x, p.y); + check( + 'a pattern started in its tab stops the song', + swapped.pattern && !swapped.song, + JSON.stringify(swapped), + ); + await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`); + await sleep(200); + p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'body')`); + await doubleClick(p.x, p.y); + await sleep(200); + const opened = await json('{ tab: __gm.tab(), pattern: __gm.pattern.current() }'); + check( + 'a double-click on a pattern item opens the pattern in its tab', + opened.tab === 'pattern' && opened.pattern === PATTERN, + JSON.stringify(opened), + ); + + // 65j. The step takes back everything it made. + await evaluate('__gm.sound.stopAll()'); + await mutate(` + const gone = new Set([${JSON.stringify(SOUND)}, 'sg65-ins', ${JSON.stringify(PATTERN)}, ${JSON.stringify(id)}]); + const project = __gm.state.project; + project.assets = project.assets.filter((a) => !gone.has(a.id)); + __gm.state.select(null);`); + await showTab('scene'); + check( + 'the step leaves nothing of its own behind', + (await json( + `__gm.state.project.assets.filter((a) => a.id.startsWith('sg65') || a.type === 'song').length`, + )) === 0, + ); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 6c88f826..c5ab5585 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -67,6 +67,7 @@ import { fullscreen } from './61-fullscreen.mjs'; import { devicePixels } from './62-device-pixels.mjs'; import { renderLayers } from './63-render-layers.mjs'; import { pattern } from './64-pattern.mjs'; +import { song } from './65-song.mjs'; export const STEPS = [ hierarchy, @@ -133,4 +134,5 @@ export const STEPS = [ devicePixels, renderLayers, pattern, + song, ]; diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index 1d5761bb..ac1aa7ac 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -49,6 +49,7 @@ const KEYS = { a: { key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65 }, c: { key: 'c', code: 'KeyC', windowsVirtualKeyCode: 67 }, d: { key: 'd', code: 'KeyD', windowsVirtualKeyCode: 68 }, + e: { key: 'e', code: 'KeyE', windowsVirtualKeyCode: 69 }, g: { key: 'g', code: 'KeyG', windowsVirtualKeyCode: 71 }, p: { key: 'p', code: 'KeyP', windowsVirtualKeyCode: 80 }, s: { key: 's', code: 'KeyS', windowsVirtualKeyCode: 83 },