The ruler of the Pattern and Song tabs did two things with one drag,
and the wheel did three things with Alt and Shift. Both now do one
thing each, and the Song tab gets a few edits it was missing.
- The ruler has a loop strip along its top. A drag there sets the
loop, a press or drag below it moves only where playing starts.
- The wheel only zooms: the time over the grid, lanes and ruler, the
rows over the keys, the tracks over their heads. Alt and Shift no
longer change it. A middle-drag still pans.
- Tracks can be zoomed from 28 to 160 px. Below 46 px a head shows
only its name, M and S. An old saved view gets the old height.
- A click on nothing in the lanes picks the song itself.
- A right-click on an item opens a menu with Delete instead of
deleting it. The inspector has no delete buttons any more.
- A sound item goes up to +12 dB, a track up to +12 dB. A pattern
item stays at 0 dB, since a note is held to unity.
- An end of an item under the pointer lights up and shows a resize
cursor. A long item marks every point where it starts over.
- The Pattern tab has an Audition switch in its header, off at first.
Only with it on does a note sound when drawn, grabbed or moved.
The keys down the left sound either way.
- A looped sound item draws every round alike. Each round is drawn
from its own start, and each column shows the loudest part of the
sound it covers instead of one sampled bucket.
- A song is at least one bar long instead of four. An empty song
fits that bar across, and Play song ends after the last item.
- The ghost notes held back in #334 got their own issue, #339.
Finding: suite 542 for f97961c failed 5 game checks in e2e 64 and 65
while my runs on the same Windows machine overlapped it. Modules
60-65 alone without load passed, so this push's suite has to show it.
Tests:
- Unit: song-edit 11, piano-roll 17, song 14, sound-library 67.
Four of them are new, one in song changed.
- e2e 64+65 on melektaus-atx: ALL PASSED - 46 checks (+3).
- e2e 60-65 on melektaus-atx: ALL PASSED - 86 checks.
- Gate on melektaus-atx: 251 files, 3907 tests, CHECK EXIT 0.
- Audition, after the gate: e2e 64 alone ALL PASSED - 25 checks
(+1), transitions and piano-roll 30 tests, typecheck and ESLint
clean.
- Waveform and one bar, after the gate: song, song-edit, song-play
and the node catalog 438 tests. e2e 65 alone ALL PASSED - 25
checks (+1). A probe shot of a looped pop sound: all six rounds
pixel for pixel alike.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
7.0 KiB
TypeScript
201 lines
7.0 KiB
TypeScript
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,
|
|
itemMaxDb,
|
|
MAX_TRANSPOSE,
|
|
type SongItem,
|
|
type SongTrack,
|
|
TRACK_MAX_DB,
|
|
TRACK_MIN_DB,
|
|
} from '../../core/music/song';
|
|
import { createFieldRow } from './fieldFactory';
|
|
import { 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: itemMaxDb(item.kind),
|
|
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. */
|
|
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);
|
|
}
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
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));
|
|
}
|