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