Stage 3 of #337. Lanes moved volume and pan only. Now a lane can steer a parameter of anything on its track, and a voice already planned or still waiting for its file follows it too. - A lane may aim at param:<name>. A parameter has no range of its own, so the lane keeps min and max. A parameter lane stays without points, so its range is not lost. Remove lane takes it away. - It reaches the track FX, the FX of an item, the instrument of a note with its own FX, and the FX of a clip. When several have the name, all of them change, as with Set sound FX parameters. - The scheduler hands the lane value to a note or clip at its start. Values a voice only reads at its start act from the next note. - While a voice sounds, the knobs of buses and voices of the track follow the lane on the audio clock, read every 10 ms, because a knob is not always the parameter itself (trackParams.ts). - A voice waiting for its file takes the marks when it starts. - When a lane is removed, the parameter goes back to its default. An edit while playing does not reset a parameter a lane rules. - This holds in the song tab, for Play song, in compositions and in the bake. - Song tab: Add parameter lane in the track head menu opens a dialog for parameter and range (ParamLaneDialog.ts). The lane head menu has Range and Remove lane. - Inspector: while a lane rules a value, a hint at the track names the lanes and says the fields act once the lane is gone. Known limit: a knob that depends on two automated parameters reckons each lane with the last value of the other, so it may lag one window. Tests: - automation-params.test.ts (9, new), 3 new in sound-library and 3 in song-lanes. Affected files 407/407. - 17 counter checks, all compile, all red. - e2e 65 on melektaus-atx: ALL PASSED, 67 checks. 65h3 is new: a clip with FX parameter cut starts at the lane value and its knob stays within 0.04 dB of the lane. It goes red when automateBus does not pass parameters on. - Gate on melektaus-atx: CHECK EXIT 0, 330 files, 5080 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
285 lines
9.8 KiB
TypeScript
285 lines
9.8 KiB
TypeScript
import { SetSongCommand } from '../../core/commands/songCommands';
|
|
import type { SongAsset } from '../../core/model/Asset';
|
|
import { ASSET_TYPE_LABELS } from '../../core/model/AssetTypes';
|
|
import { findAssetOf, type Project } from '../../core/model/Project';
|
|
import { embeddedIn, markedName } from '../../core/music/embedded';
|
|
import { assetRefLabel, soundEntries } from '../../core/properties/assetRef';
|
|
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
|
import {
|
|
ITEM_MIN_DB,
|
|
itemAsset,
|
|
itemMaxDb,
|
|
MAX_TRANSPOSE,
|
|
type SongItem,
|
|
type SongTrack,
|
|
TRACK_MAX_DB,
|
|
TRACK_MIN_DB,
|
|
} from '../../core/music/song';
|
|
import type { MenuEntry } from '../../ui';
|
|
import { laneLabel } from '../song/laneView';
|
|
import { ownFxEntries } from '../song/ownFx';
|
|
import type { EditorState } from '../state';
|
|
import { createFieldRow } from './fieldFactory';
|
|
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
|
|
import { previewingHandlers } from './previewHandlers';
|
|
|
|
const FX_ROW = {
|
|
key: 'fx',
|
|
label: 'Sound FX',
|
|
kind: 'asset',
|
|
default: '',
|
|
assetTypes: ['soundfx'],
|
|
} as const satisfies PropertyDef;
|
|
|
|
/** What an item's pick says while it is empty (#352): what its asset names. */
|
|
const FROM_ASSET = 'From the asset';
|
|
|
|
/** What a row of a track sets: one of its own values, never its lanes (#337). */
|
|
type TrackField = Exclude<keyof SongTrack, 'automation'>;
|
|
|
|
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 },
|
|
FX_ROW,
|
|
];
|
|
|
|
/** 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'],
|
|
none: FROM_ASSET,
|
|
},
|
|
);
|
|
rows.push({ ...FX_ROW, none: FROM_ASSET, noFx: true });
|
|
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}`;
|
|
const track = asset?.tracks.find((t) => t.id === trackId);
|
|
return track ? `track:${trackId}:${ruledLanes(track).join(',')}` : 'missing';
|
|
}
|
|
|
|
/** The lanes with a point on a track (#337): what its fields no longer set while it plays. */
|
|
function ruledLanes(track: SongTrack): string[] {
|
|
return (track.automation ?? []).flatMap((lane) => (lane.points.length > 0 ? [laneLabel(lane.target)] : []));
|
|
}
|
|
|
|
/** 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 TrackField]);
|
|
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'}.`);
|
|
addNote(host, 'Its Sound FX takes everything on it together, before its volume and pan.', 'small');
|
|
const ruled = ruledLanes(track);
|
|
if (ruled.length > 0)
|
|
addNote(
|
|
host,
|
|
`Automation lanes rule ${ruled.join(', ')} while it plays; a field here is only heard once its lane is cleared.`,
|
|
'small',
|
|
);
|
|
for (const def of TRACK_ROWS) {
|
|
const commit = (value: SongTrack[TrackField]) =>
|
|
new SetSongCommand(
|
|
asset.id,
|
|
{ tracks: withTrack(state.project, asset.id, track.id, { [def.key]: value }) },
|
|
`Set track ${def.key}`,
|
|
);
|
|
const handlers = previewingHandlers<SongTrack, SongTrack[TrackField]>(host, {
|
|
target: () => findAssetOf(state.project, asset.id, 'song')?.tracks.find((t) => t.id === track.id),
|
|
read: (target) => target[def.key as TrackField],
|
|
write: (target, value) => Object.assign(target, { [def.key]: value }),
|
|
commit,
|
|
});
|
|
if (def.key === FX_ROW.key)
|
|
handlers.moreChoices = (current) => ownFxEntries(state, asset.id, current, commit);
|
|
const row = createFieldRow(host, def, track[def.key as TrackField], 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 = itemAsset(state.project, item) ? assetRefLabel(state.project, item.asset) : '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 commit = (value: SongItem[keyof SongItem]) =>
|
|
new SetSongCommand(
|
|
asset.id,
|
|
{ items: withItem(state.project, asset.id, item.id, { [def.key]: value }) },
|
|
`Set item ${def.key}`,
|
|
);
|
|
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,
|
|
});
|
|
if (def.key === FX_ROW.key)
|
|
handlers.moreChoices = (current) => ownFxEntries(state, asset.id, current, commit);
|
|
if (def.key === 'asset')
|
|
handlers.moreChoices = (current) =>
|
|
keptEntries(state, asset.id, item.kind, current, (id) => state.execute(commit(id)));
|
|
const row = createFieldRow(host, def, item[def.key as keyof SongItem], handlers);
|
|
host.fields.set(def.key, row);
|
|
host.body.add(row.row);
|
|
}
|
|
addNote(
|
|
host,
|
|
item.kind === 'pattern'
|
|
? 'A Sound FX takes all its notes together, instead of the one of the pattern and before the one of the track.'
|
|
: 'A Sound FX takes the place of the one of the sound.',
|
|
'small',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The sounds with their clips (#357) or the patterns the song keeps (#355), which no list offers:
|
|
* its items share them, so a take's clips are picked here.
|
|
*/
|
|
function keptEntries(
|
|
state: EditorState,
|
|
songId: string,
|
|
kind: SongItem['kind'],
|
|
current: string,
|
|
pick: (id: string) => void,
|
|
): MenuEntry[] {
|
|
const entries = embeddedIn(state.project, songId)
|
|
.filter((a) => a.type === kind)
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
.flatMap((a) => (a.type === 'sound' ? soundEntries(a) : [{ id: a.id, label: markedName(a) }]));
|
|
if (entries.length === 0) return [];
|
|
return [
|
|
{ separator: true },
|
|
...entries.map((entry) => ({
|
|
label: entry.label,
|
|
current: entry.id === current,
|
|
onSelect: () => pick(entry.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));
|
|
}
|