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:
adminandClaude Opus 5.5 committed 2026-10-08 10:54:40 +02:00
1 parent 23cb34f781
commit 49669017b1
5 files changed
+335 -20

No files matched your search

+27 -3
View File
@@ -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);
}
+71 -7
View File
@@ -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);
+55 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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(