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
+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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user