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