Make a pattern right in a song with a right-click (#355)
A pattern for one song had to be made in the Assets panel first and dragged in, and then stood in every picker of the project. Now a right-click on an empty spot of a track makes one the song keeps to itself, on top of the embedded assets of the first part. - Create pattern lays an item from the grid line before the spot, a bar long, and opens the pattern in its tab. Pattern, item and name are one undo step. The name is "Pattern 1", 2, … per song. - It plays the instrument of the track's pattern item that starts last before the spot, else of the first one after it. An item's own pick counts before its pattern's. - An embedded pattern runs on the song's clock. It takes the tempo and the meter of the song, and a change on the song moves it along in the same undo step (`AssetPropertyCommand`). - Its rows Tempo, Beats per bar, Beat and Bus are hidden, as the song decides them. Length, grid, instrument and sound FX stay. - A copy that comes to a song (Embed a copy, a paste from another song) takes that song's clock. Made a project asset, it keeps the values it had. - `CreateAssetCommand` takes an owner. `embeddedCopy` takes the song now, not its id. - The cleanup of e2e 65 also takes what the song keeps. Tests: - Unit embedded: 18 green (5 new), 6 counter-checks each red - Unit take, song, pattern and the asset commands: green - e2e 65 (melektaus-atx): ALL PASSED, 49 checks (2 new) - Gate (melektaus-atx): CHECK EXIT 0, 269 files, 4172 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
23cb34f781
commit
49669017b1
5 files changed
+335
-20
No files matched your search
@@ -12,7 +12,7 @@ import type {
|
||||
VariablesAsset,
|
||||
} from '../model/Asset';
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
import { embeddedIn } from '../music/embedded';
|
||||
import { clockFollowers, embeddedIn } from '../music/embedded';
|
||||
import { defaultTracks } from '../music/song';
|
||||
import { heldSongGraph } from '../music/routing';
|
||||
import { linearCurve } from '../model/curve';
|
||||
@@ -21,7 +21,7 @@ import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
|
||||
import { isSoundFile } from '../model/sound';
|
||||
import { removeSubtrees } from '../model/hierarchy';
|
||||
import { dropReferencesToGone, renamedReference, shownObjectIds, swapReferences } from '../model/objectRefs';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import { type PropertyBag, type PropertyValue, setBagValue } from '../model/Properties';
|
||||
import { objectHolders, type Project } from '../model/Project';
|
||||
import {
|
||||
type Scope,
|
||||
@@ -130,13 +130,18 @@ export class CreateAssetCommand implements Command {
|
||||
readonly asset: Asset;
|
||||
private previousActiveScene = '';
|
||||
|
||||
/** `props` replace schema defaults and pass through the schema like any other edit. */
|
||||
/**
|
||||
* `props` replace schema defaults and pass through the schema like any other edit. `owner` makes
|
||||
* it an asset the song of that id keeps to itself (#355).
|
||||
*/
|
||||
constructor(
|
||||
type: AssetType,
|
||||
name: string,
|
||||
private readonly props: PropertyBag = {},
|
||||
owner = '',
|
||||
) {
|
||||
this.asset = buildAsset(type, name);
|
||||
if (owner) this.asset.owner = owner;
|
||||
this.label = `Create ${type} "${name}"`;
|
||||
}
|
||||
|
||||
@@ -534,7 +539,10 @@ export class SetMachineStartCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** An embedded pattern follows the tempo and the meter of its song (#355), in the same undo step. */
|
||||
abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
|
||||
private followed: { id: string; previous: PropertyValue | undefined }[] = [];
|
||||
|
||||
protected constructor(
|
||||
private readonly assetId: string,
|
||||
key: string,
|
||||
@@ -543,6 +551,22 @@ abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
|
||||
super(key, label);
|
||||
}
|
||||
|
||||
override execute(project: Project): void {
|
||||
super.execute(project);
|
||||
const asset = requireAsset(project, this.assetId);
|
||||
this.followed = clockFollowers(project, asset, this.key).map((pattern) => {
|
||||
const previous = pattern.props[this.key];
|
||||
setBagValue(pattern.props, this.key, asset.props[this.key]);
|
||||
return { id: pattern.id, previous };
|
||||
});
|
||||
}
|
||||
|
||||
override undo(project: Project): void {
|
||||
for (const { id, previous } of this.followed)
|
||||
setBagValue(requireAsset(project, id).props, this.key, previous);
|
||||
super.undo(project);
|
||||
}
|
||||
|
||||
protected override resolveTarget(project: Project): Asset {
|
||||
return requireAsset(project, this.assetId);
|
||||
}
|
||||
|
||||
@@ -6,17 +6,30 @@
|
||||
* or a paste gets a copy of its own and what nothing uses any more goes.
|
||||
*/
|
||||
import { newId } from '../ids';
|
||||
import type { Asset, SongAsset } from '../model/Asset';
|
||||
import type { Asset, PatternAsset, SongAsset } from '../model/Asset';
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
import { findAsset, type Project } from '../model/Project';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { findAsset, findAssetOf, type Project } from '../model/Project';
|
||||
import { ASSET_FX_KEY, assetFxOf, NO_FX } from '../properties/assetFx';
|
||||
import type { SongItem, SongTrack } from './song';
|
||||
import { nextNumberedName } from '../commands/naming';
|
||||
import { PATTERN_BEAT_KEY, PATTERN_BEATS_KEY, PATTERN_TEMPO_KEY, patternInstrument } from './pattern';
|
||||
import { type SongItem, songMeter, songTempo, type SongTrack } from './song';
|
||||
|
||||
export const EMBEDDABLE_TYPES: readonly AssetType[] = ['sound', 'pattern', 'soundfx'];
|
||||
|
||||
/** The rows of an embedded asset that do nothing in a song: where it goes and how it repeats is the song's. */
|
||||
/**
|
||||
* What an embedded pattern takes from its song: it is drawn and heard on the song's clock. A pattern
|
||||
* names these the way a song does, so one key serves both.
|
||||
*/
|
||||
export const SONG_CLOCK_KEYS: readonly string[] = [PATTERN_TEMPO_KEY, PATTERN_BEATS_KEY, PATTERN_BEAT_KEY];
|
||||
|
||||
/**
|
||||
* The rows of an embedded asset that do nothing in a song: where it goes and how it repeats is the
|
||||
* song's, and a pattern's clock is the song's too.
|
||||
*/
|
||||
const HIDDEN_ROWS: Partial<Record<AssetType, readonly string[]>> = {
|
||||
sound: ['loop', 'bus'],
|
||||
pattern: [...SONG_CLOCK_KEYS, 'bus'],
|
||||
};
|
||||
|
||||
export function isEmbeddable(type: AssetType): boolean {
|
||||
@@ -52,9 +65,60 @@ export function assetTitle(project: Project, asset: Asset): string {
|
||||
return song ? `${song.name} › ${asset.name}` : asset.name;
|
||||
}
|
||||
|
||||
/** The tempo and the meter of a song, as a pattern's props. */
|
||||
export function songClockProps(props: PropertyBag): PropertyBag {
|
||||
const meter = songMeter(props);
|
||||
return {
|
||||
[PATTERN_TEMPO_KEY]: songTempo(props),
|
||||
[PATTERN_BEATS_KEY]: meter.beats,
|
||||
[PATTERN_BEAT_KEY]: String(meter.unit),
|
||||
};
|
||||
}
|
||||
|
||||
/** Gives an asset that comes to live in `song` its owner, and a pattern the song's clock. */
|
||||
function ownedBy<T extends Asset>(asset: T, song: SongAsset): T {
|
||||
asset.owner = song.id;
|
||||
if (asset.type === 'pattern') Object.assign(asset.props, songClockProps(song.props));
|
||||
return asset;
|
||||
}
|
||||
|
||||
/** A copy of a project asset for the song to keep to itself (#355), to bend without touching the rest. */
|
||||
export function embeddedCopy(asset: Asset, songId: string): Asset {
|
||||
return { ...structuredClone(asset), id: newId(), owner: songId };
|
||||
export function embeddedCopy(asset: Asset, song: SongAsset): Asset {
|
||||
return ownedBy({ ...structuredClone(asset), id: newId() }, song);
|
||||
}
|
||||
|
||||
/** The patterns that follow `key` of an asset: those a song keeps, when the key is part of its clock. */
|
||||
export function clockFollowers(project: Project, asset: Asset, key: string): PatternAsset[] {
|
||||
if (asset.type !== 'song' || !SONG_CLOCK_KEYS.includes(key)) return [];
|
||||
return embeddedIn(project, asset.id).filter((a): a is PatternAsset => a.type === 'pattern');
|
||||
}
|
||||
|
||||
/** "Pattern 1", 2, 3 …: the first number none of what the song keeps to itself has. */
|
||||
export function embeddedPatternName(project: Project, songId: string): string {
|
||||
return nextNumberedName(
|
||||
embeddedIn(project, songId).map((a) => a.name),
|
||||
'Pattern',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The instrument a pattern made at `tick` on a track is played with: that of the pattern item there
|
||||
* that starts last before it, else of the first one after; '' on a track without one.
|
||||
*/
|
||||
export function instrumentNear(
|
||||
project: Project,
|
||||
items: readonly SongItem[],
|
||||
track: string,
|
||||
tick: number,
|
||||
): string {
|
||||
const patterns = items
|
||||
.filter((i) => i.track === track && i.kind === 'pattern')
|
||||
.sort((a, b) => a.tick - b.tick);
|
||||
const near = patterns.filter((i) => i.tick <= tick).at(-1) ?? patterns[0];
|
||||
if (!near) return '';
|
||||
if (near.instrument !== '') return near.instrument;
|
||||
const pattern = findAssetOf(project, near.asset, 'pattern');
|
||||
return pattern ? patternInstrument(pattern.props) : '';
|
||||
}
|
||||
|
||||
/** A place in a song that names an asset, in the order the places claim what they name. */
|
||||
@@ -117,7 +181,7 @@ export function settleEmbedded(
|
||||
}
|
||||
if (index === 0) return;
|
||||
const id = found || taken.has(source.id) ? newId() : source.id;
|
||||
const copy = { ...structuredClone(source), id, owner: song.id };
|
||||
const copy = ownedBy({ ...structuredClone(source), id }, song);
|
||||
taken.add(id);
|
||||
added.push(copy);
|
||||
claimed.add(id);
|
||||
|
||||
@@ -5,10 +5,19 @@ import {
|
||||
Graphics,
|
||||
type Text,
|
||||
} from 'pixi.js';
|
||||
import { MakeProjectAssetCommand } from '../../core/commands/assetCommands';
|
||||
import { CreateAssetCommand, MakeProjectAssetCommand } from '../../core/commands/assetCommands';
|
||||
import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
||||
import { SetSongCommand, SetSongLoopCommand, type SongParts } from '../../core/commands/songCommands';
|
||||
import { embeddedCopy, embeddedUsedBy, isEmbedded, listedAssets } from '../../core/music/embedded';
|
||||
import { patternLength } from '../../core/music/pattern';
|
||||
import {
|
||||
embeddedCopy,
|
||||
embeddedPatternName,
|
||||
embeddedUsedBy,
|
||||
instrumentNear,
|
||||
isEmbedded,
|
||||
listedAssets,
|
||||
songClockProps,
|
||||
} from '../../core/music/embedded';
|
||||
import { PATTERN_BARS_KEY, PATTERN_INSTRUMENT_KEY, patternLength } from '../../core/music/pattern';
|
||||
import type { LoopRange } from '../../core/music/sequence';
|
||||
import {
|
||||
itemEnd,
|
||||
@@ -1015,11 +1024,11 @@ export class SongTimeline extends Widget {
|
||||
]).open(global.x, global.y);
|
||||
}
|
||||
|
||||
/** The menu of the item under a right-click, picked first unless it is already. */
|
||||
/** The menu of the item under a right-click, picked first unless it is already; off one, of the spot. */
|
||||
private openItemMenu(asset: SongAsset, g: Vec2, global: Vec2): void {
|
||||
const at = itemAt(this.view, asset.items, asset.tracks, g);
|
||||
const hit = at && asset.items.find((i) => i.id === at.id);
|
||||
if (!hit) return;
|
||||
if (!hit) return this.openSpotMenu(asset, g, global);
|
||||
if (!this.pickedIds.includes(hit.id)) this.pick([hit.id]);
|
||||
const played = findAsset(this.state.project, hit.asset);
|
||||
const embedded = played !== undefined && isEmbedded(played);
|
||||
@@ -1036,11 +1045,51 @@ export class SongTimeline extends Widget {
|
||||
]).open(global.x, global.y);
|
||||
}
|
||||
|
||||
/** The menu of an empty spot on a track. */
|
||||
private openSpotMenu(asset: SongAsset, g: Vec2, global: Vec2): void {
|
||||
const row = trackAtY(this.view, asset.tracks.length, g.y);
|
||||
const tick = xToTick(this.view, g.x);
|
||||
if (row === null || tick < 0) return;
|
||||
new Menu([{ label: 'Create pattern', onSelect: () => this.createPattern(asset, row, tick) }]).open(
|
||||
global.x,
|
||||
global.y,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A pattern the song keeps to itself (#355), a bar long on the song's clock, on an item from the
|
||||
* grid line before `tick` on; opened right away. It plays the instrument of the track's pattern
|
||||
* nearest before it.
|
||||
*/
|
||||
createPattern(asset: SongAsset, row: number, tick: number): boolean {
|
||||
const track = asset.tracks[row];
|
||||
if (!track || tick < 0) return false;
|
||||
const project = this.state.project;
|
||||
const start = snapDown(tick, songGrid(asset.props));
|
||||
const create = new CreateAssetCommand(
|
||||
'pattern',
|
||||
embeddedPatternName(project, asset.id),
|
||||
{
|
||||
...songClockProps(asset.props),
|
||||
[PATTERN_BARS_KEY]: 1,
|
||||
[PATTERN_INSTRUMENT_KEY]: instrumentNear(project, asset.items, track.id, start),
|
||||
},
|
||||
asset.id,
|
||||
);
|
||||
const length = ticksPerBar(songMeter(asset.props));
|
||||
const item = newItem({ track: track.id, kind: 'pattern', asset: create.asset.id, tick: start, length });
|
||||
const placed = new SetSongCommand(asset.id, { items: [...asset.items, item] }, 'Create pattern');
|
||||
if (!this.state.execute(new CompoundCommand('Create pattern', [create, placed]))) return false;
|
||||
this.pick([item.id]);
|
||||
this.onOpenItem(item, item.tick);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Points the item at a copy of its asset that only the song has (#355), to bend without touching the rest. */
|
||||
embedCopy(asset: SongAsset, item: SongItem): boolean {
|
||||
const played = findAsset(this.state.project, item.asset);
|
||||
if (!played || isEmbedded(played)) return false;
|
||||
const copy = embeddedCopy(played, asset.id);
|
||||
const copy = embeddedCopy(played, asset);
|
||||
this.commit(
|
||||
asset,
|
||||
{ items: asset.items.map((i) => (i.id === item.id ? { ...i, asset: copy.id } : i)), spare: [copy] },
|
||||
|
||||
+121
-2
@@ -10,19 +10,25 @@ import {
|
||||
DeleteAssetCommand,
|
||||
DuplicateAssetCommand,
|
||||
MakeProjectAssetCommand,
|
||||
ResetAssetPropertyCommand,
|
||||
SetAssetPropertyCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import { nextAssetName } from '../../src/core/commands/naming';
|
||||
import { SetSongCommand } from '../../src/core/commands/songCommands';
|
||||
import type { Asset, SongAsset } from '../../src/core/model/Asset';
|
||||
import type { Asset, PatternAsset, SongAsset } from '../../src/core/model/Asset';
|
||||
import { findAsset, findAssetOf } from '../../src/core/model/Project';
|
||||
import {
|
||||
assetTitle,
|
||||
embeddedCopy,
|
||||
embeddedIn,
|
||||
embeddedPatternName,
|
||||
embeddedUsedBy,
|
||||
instrumentNear,
|
||||
isRowHiddenWhenEmbedded,
|
||||
listedAssets,
|
||||
songClockProps,
|
||||
} from '../../src/core/music/embedded';
|
||||
import { patternLength, patternMeter, patternTempo } from '../../src/core/music/pattern';
|
||||
import { newItem, type SongItem, songSequence } from '../../src/core/music/song';
|
||||
import { assetRefChoices } from '../../src/core/properties/assetRef';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
@@ -151,7 +157,7 @@ describe('an embedded asset (#355)', () => {
|
||||
|
||||
it('is embedded as a copy of a project asset, which stays', () => {
|
||||
const { f, song, blipId, edit, track } = setup();
|
||||
const copy = embeddedCopy(findAsset(f.project, blipId)!, song().id);
|
||||
const copy = embeddedCopy(findAsset(f.project, blipId)!, song());
|
||||
edit([newItem({ track, kind: 'sound', asset: copy.id, tick: 0, length: BAR })], { spare: [copy] });
|
||||
expect(song().items[0]!.asset).toBe(copy.id);
|
||||
expect(findAsset(f.project, copy.id)?.owner).toBe(song().id);
|
||||
@@ -211,3 +217,116 @@ describe('an embedded asset (#355)', () => {
|
||||
expect(deserializeProject(sprite).assets.find((a) => a.id === 'sp')?.owner).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a pattern made in a song (#355)', () => {
|
||||
/** The song of `setup` at 140 BPM in 3/4, with a project pattern "Loose" and two instruments. */
|
||||
function withPatterns() {
|
||||
const s = setup();
|
||||
const { f, songId } = s;
|
||||
f.stack.execute(new SetAssetPropertyCommand(songId, 'tempo', 140));
|
||||
f.stack.execute(new SetAssetPropertyCommand(songId, 'beats', 3));
|
||||
const loose = new CreateAssetCommand('pattern', 'Loose');
|
||||
f.stack.execute(loose);
|
||||
const lead = new CreateAssetCommand('instrument', 'Lead');
|
||||
const bass = new CreateAssetCommand('instrument', 'Bass');
|
||||
f.stack.execute(lead);
|
||||
f.stack.execute(bass);
|
||||
const make = (props: Record<string, string | number> = {}) => {
|
||||
const made = new CreateAssetCommand(
|
||||
'pattern',
|
||||
embeddedPatternName(f.project, songId),
|
||||
{ ...songClockProps(s.song().props), bars: 1, ...props },
|
||||
songId,
|
||||
);
|
||||
f.stack.execute(made);
|
||||
return made.asset as PatternAsset;
|
||||
};
|
||||
return { ...s, loose: loose.asset as PatternAsset, leadId: lead.asset.id, bassId: bass.asset.id, make };
|
||||
}
|
||||
|
||||
it('is a bar of the song on its clock, numbered among what the song keeps', () => {
|
||||
const { f, songId, make } = withPatterns();
|
||||
f.stack.execute(new CreateAssetCommand('pattern', 'Pattern 1'));
|
||||
const first = make();
|
||||
expect([first.name, first.owner, patternTempo(first.props)]).toEqual(['Pattern 1', songId, 140]);
|
||||
expect(patternMeter(first.props)).toEqual({ beats: 3, unit: 4 });
|
||||
expect(patternLength(first)).toBe(3 * 480);
|
||||
expect(make().name).toBe('Pattern 2');
|
||||
});
|
||||
|
||||
it('follows the tempo and the meter of its song in the same undo step, and nothing else', () => {
|
||||
const { f, songId, loose, make } = withPatterns();
|
||||
const own = make();
|
||||
f.stack.execute(new SetAssetPropertyCommand(songId, 'tempo', 90));
|
||||
expect([patternTempo(own.props), patternTempo(loose.props)]).toEqual([90, 120]);
|
||||
f.stack.execute(new SetAssetPropertyCommand(songId, 'beat', '8'));
|
||||
expect(patternMeter(own.props)).toEqual({ beats: 3, unit: 8 });
|
||||
f.stack.execute(new ResetAssetPropertyCommand(songId, 'beats'));
|
||||
expect(patternMeter(own.props).beats).toBe(4);
|
||||
const grid = own.props.grid;
|
||||
f.stack.execute(new SetAssetPropertyCommand(songId, 'grid', '1/8'));
|
||||
expect([own.props.grid, findAsset(f.project, songId)?.props.grid]).toEqual([grid, '1/8']);
|
||||
f.stack.undo();
|
||||
f.stack.undo();
|
||||
expect(patternMeter(own.props)).toEqual({ beats: 3, unit: 8 });
|
||||
f.stack.undo();
|
||||
f.stack.undo();
|
||||
expect([patternTempo(own.props), patternMeter(own.props).unit]).toEqual([140, 4]);
|
||||
});
|
||||
|
||||
it('hides its clock and its bus, not its length, instrument or sound FX', () => {
|
||||
const { loose, make } = withPatterns();
|
||||
const own = make();
|
||||
const keys = ['tempo', 'beats', 'beat', 'bus', 'bars', 'instrument', 'fx'];
|
||||
expect(keys.map((k) => isRowHiddenWhenEmbedded(own, k))).toEqual([
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(isRowHiddenWhenEmbedded(loose, 'tempo')).toBe(false);
|
||||
});
|
||||
|
||||
it('plays the instrument of the pattern on its track that starts last before it, else the first after', () => {
|
||||
const { f, track, edit, song, takeId, leadId, bassId, make } = withPatterns();
|
||||
const withLead = make({ instrument: leadId });
|
||||
const other = make();
|
||||
const items = [
|
||||
...song().items,
|
||||
newItem({ track, kind: 'pattern', asset: withLead.id, tick: BAR, length: BAR }),
|
||||
{
|
||||
...newItem({ track, kind: 'pattern', asset: other.id, tick: 3 * BAR, length: BAR }),
|
||||
instrument: bassId,
|
||||
},
|
||||
newItem({ track, kind: 'sound', asset: takeId, tick: 4 * BAR, length: BAR }),
|
||||
];
|
||||
edit(items);
|
||||
const near = (tick: number, on = track) => instrumentNear(f.project, song().items, on, tick);
|
||||
expect([near(0), near(2 * BAR), near(3 * BAR), near(5 * BAR)]).toEqual([leadId, leadId, bassId, bassId]);
|
||||
expect(near(0, 'elsewhere')).toBe('');
|
||||
});
|
||||
|
||||
it("takes the clock of the song it comes to as a copy, a project pattern's or another song's", () => {
|
||||
const { f, song, loose, track, edit } = withPatterns();
|
||||
const copy = embeddedCopy(loose, song()) as PatternAsset;
|
||||
expect([patternTempo(copy.props), patternTempo(loose.props)]).toEqual([140, 120]);
|
||||
const other = new CreateAssetCommand('song', 'Other');
|
||||
f.stack.execute(other);
|
||||
const theirs = new CreateAssetCommand('pattern', 'Theirs', { bars: 1 }, other.asset.id);
|
||||
f.stack.execute(theirs);
|
||||
edit([
|
||||
...song().items,
|
||||
newItem({ track, kind: 'pattern', asset: theirs.asset.id, tick: 0, length: BAR }),
|
||||
]);
|
||||
const named = findAssetOf(f.project, song().items.at(-1)!.asset, 'pattern')!;
|
||||
expect(named.id).not.toBe(theirs.asset.id);
|
||||
expect([named.owner, patternTempo(named.props), patternMeter(named.props).beats]).toEqual([
|
||||
song().id,
|
||||
140,
|
||||
3,
|
||||
]);
|
||||
});
|
||||
});
|
||||
+61
-2
@@ -13,7 +13,8 @@ import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs';
|
||||
* the volume of its track, a double-click sets a slider back to 0, its menu 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. Record
|
||||
* takes chromium's test tone onto a track of its own (#342), Esc throws a take away.
|
||||
* takes chromium's test tone onto a track of its own (#342), Esc throws a take away. A right-click
|
||||
* on an empty spot of a track makes a pattern the song keeps to itself (#355).
|
||||
* 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.
|
||||
*
|
||||
@@ -1070,12 +1071,70 @@ export async function song(t) {
|
||||
JSON.stringify({ fxGone: fxGone.map((b) => b.name), openedSound }),
|
||||
);
|
||||
|
||||
// 65o. A right-click on an empty spot of a track makes a pattern of the song's own (#355): a bar
|
||||
// from the grid line before it, with the instrument of the track's pattern, opened in its tab.
|
||||
// One Ctrl+Z takes pattern and item back.
|
||||
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
|
||||
await sleep(200);
|
||||
const spotSong = await model();
|
||||
const riffItem = spotSong.items.find((item) => item.id === patternItem.id);
|
||||
const spotRow = spotSong.tracks.findIndex((track) => track.id === riffItem.track);
|
||||
const onTrack = spotSong.items.filter((item) => item.track === riffItem.track);
|
||||
let spotBar = Math.ceil((riffItem.tick + riffItem.length) / BAR);
|
||||
while (onTrack.some((item) => item.tick < (spotBar + 1) * BAR && item.tick + item.length > spotBar * BAR))
|
||||
spotBar++;
|
||||
p = await json(`__gm.song.laneAt(${spotBar * BAR + BAR / 4}, ${spotRow})`);
|
||||
await click(p.x, p.y, 'right');
|
||||
await sleep(150);
|
||||
const spotRows = await json('__gm.menuRows()');
|
||||
await clickText('Create pattern');
|
||||
await sleep(250);
|
||||
const made = await model();
|
||||
const madeItem = made.items.find((item) => !spotSong.items.some((other) => other.id === item.id));
|
||||
const madeId = JSON.stringify(madeItem?.asset ?? '');
|
||||
const madePattern = await json(
|
||||
`(() => { const a = __gm.state.project.assets.find((x) => x.id === ${madeId}); return a ? { name: a.name, owner: a.owner, type: a.type, props: a.props, notes: a.notes.length } : null; })()`,
|
||||
);
|
||||
const madeTab = await json(
|
||||
`{ tab: __gm.tab(), title: __gm.tabRow().labels['asset:' + ${madeId}], row: __gm.assetRow(${madeId}) }`,
|
||||
);
|
||||
check(
|
||||
"Create pattern on an empty spot: a bar of the song's own from the bar before, Lead 65, opened in its tab",
|
||||
spotRows?.includes('Create pattern') &&
|
||||
madeItem?.kind === 'pattern' &&
|
||||
madeItem.track === riffItem.track &&
|
||||
madeItem.tick === spotBar * BAR &&
|
||||
madeItem.length === BAR &&
|
||||
madePattern?.type === 'pattern' &&
|
||||
madePattern.owner === id &&
|
||||
madePattern.name === 'Pattern 1' &&
|
||||
madePattern.notes === 0 &&
|
||||
madePattern.props.bars === 1 &&
|
||||
madePattern.props.instrument === 'sg65-ins' &&
|
||||
madePattern.props.tempo === 120 &&
|
||||
madePattern.props.beats === 4 &&
|
||||
madeTab.tab === 'pattern' &&
|
||||
madeTab.title === `${songName} › Pattern 1` &&
|
||||
madeTab.row === null,
|
||||
JSON.stringify({ spotRows, spotBar, madeItem, madePattern, madeTab }),
|
||||
);
|
||||
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
|
||||
await sleep(200);
|
||||
await press('z', { ctrl: true });
|
||||
await sleep(150);
|
||||
check(
|
||||
'one Ctrl+Z takes the pattern and its item back',
|
||||
(await model()).items.length === spotSong.items.length &&
|
||||
(await json(`__gm.state.project.assets.some((a) => a.id === ${madeId})`)) === false,
|
||||
JSON.stringify((await model()).items.map((item) => item.id)),
|
||||
);
|
||||
|
||||
// 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)}, 'sg65-fx']);
|
||||
const project = __gm.state.project;
|
||||
project.assets = project.assets.filter((a) => !gone.has(a.id));
|
||||
project.assets = project.assets.filter((a) => !gone.has(a.id) && a.owner !== ${JSON.stringify(id)});
|
||||
__gm.state.select(null);`);
|
||||
await showTab('scene');
|
||||
check(
|
||||
|
||||
Reference in new issue
Block a user