Give a song, a track or an item a sound FX of its own (#355)

A sound FX for one place in a song had to be a project asset, which
then stood in every picker. Now every pick of a sound FX in a song
can make one the song keeps for that place alone, on top of the
embedded assets of the first two parts.

- Create sound FX sits at the end of the Sound FX row of the song,
  a track and an item, and of the menu of a track's FX button. It
  makes "Sound FX 1", 2, … per song with the Input wired to the
  Output, picks it and opens it. That is one undo step.
- Embed a copy, while the place names a project FX, gives the place
  a copy under the same name.
- While the place names its own, the menu shows it with the mark and
  offers Edit sound FX and Make project asset.
- Picking anything else lets it go in the same undo step. For a
  track or an item `settleEmbedded` did that already. For the song's
  own row `AssetPropertyCommand` now settles the song after `fx`.
- The mark `•` moved to the core. `assetRefLabel` puts it in front of
  an embedded asset, so the inspector rows show it like the timeline.
- `FieldHandlers.moreChoices` lets a caller end a reference menu
  with entries of its own. `embeddedPatternName` is `embeddedName`
  with a base now.

Tests:
- Unit own-fx (new): 6 green, 6 counter-checks each red
- Unit embedded and 80 neighbouring files: 1434 green
- e2e 65 (melektaus-atx): ALL PASSED, 51 checks (2 new)
- Gate (melektaus-atx): CHECK EXIT 0, 270 files, 4178 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-08 11:32:41 +02:00
1 parent 49669017b1
commit 668682c4ff
11 files changed
+339 -37

No files matched your search

+10 -1
View File
@@ -44,6 +44,7 @@ import { findAsset, type Project } from '../../core/model/Project';
import { isRowHiddenWhenEmbedded } from '../../core/music/embedded';
import type { PropertyBag, PropertyValue } from '../../core/model/Properties';
import { readScope, readSharedScope, type Scope, scopeOf, sharedScopeOf } from '../../core/model/Scope';
import { ASSET_FX_KEY } from '../../core/properties/assetFx';
import { type NestedKind, VARIABLE_KINDS } from '../../core/properties/kinds';
import {
COMMON_PROPS,
@@ -59,6 +60,7 @@ import { FontPreview } from '../font/FontPreview';
import { clearSaved, hasSaved } from '../saves';
import { SoundPreview } from '../sound/SoundPreview';
import type { VariablesOwner } from '../state';
import { ownFxEntries } from '../song/ownFx';
import { aspectActions, sizeCommand } from './aspectAction';
import { buildCutRows, CUT_ROW_KEYS, refreshCutRows } from './cutSection';
import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory';
@@ -241,13 +243,20 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property
}
}
/** Previews straight into the asset while editing; the command records the value from before the preview. */
/**
* Previews straight into the asset while editing; the command records the value from before the
* preview. A song's Sound FX may also be one of its own (#355).
*/
function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): FieldHandlers {
const spec = assetValue(host.state, assetId, def.key);
const isSongFx = def.key === ASSET_FX_KEY && findAsset(host.state.project, assetId)?.type === 'song';
const place = (id: string) => new SetAssetPropertyCommand(assetId, def.key, id);
const own = isSongFx ? (current: string) => ownFxEntries(host.state, assetId, current, place) : undefined;
return {
...previewingHandlers<Asset>(host, spec),
...listSource(host, def, () => ({ kind: 'asset', assetId, key: def.key })),
...structFields<Asset>(host, def, spec, (path) => ({ kind: 'asset', assetId, key: def.key, path })),
...(own ? { moreChoices: own } : {}),
};
}
+8 -1
View File
@@ -43,6 +43,7 @@ import {
HStack,
Label,
Menu,
type MenuEntry,
type MenuItem,
NumberField,
type Size,
@@ -98,6 +99,11 @@ export interface FieldHandlers {
* what it holds.
*/
structFieldHandlers?: (path: readonly string[], field: StructFieldDef) => FieldHandlers;
/**
* What the menu of a reference row ends with, asked on every open with the id it holds – a sound
* FX the song makes for that place alone (#355). Only the caller knows the place.
*/
moreChoices?: (current: string) => MenuEntry[];
}
export interface FieldRow {
@@ -457,7 +463,7 @@ function referenceField(
const labelOf = (id: string) => (id === '' ? none : ref.label(ctx.project(), id));
const button = new Dropdown(labelOf(current));
button.onClick = () => {
const items: MenuItem[] = [
const items: MenuEntry[] = [
{ label: none, current: current === '', onSelect: () => handlers.onChange('') },
// Built on click, not once per row: assets are created, renamed and deleted while the
// inspector stands. Searchable: one sliced sheet alone contributes an entry per frame.
@@ -466,6 +472,7 @@ function referenceField(
current: choice.id === current,
onSelect: () => handlers.onChange(choice.id),
})),
...(handlers.moreChoices?.(current) ?? []),
];
new Menu(items, { searchable: true }).openBelow(button);
};
+19 -12
View File
@@ -12,6 +12,7 @@ import {
TRACK_MAX_DB,
TRACK_MIN_DB,
} from '../../core/music/song';
import { ownFxEntries } from '../song/ownFx';
import { createFieldRow } from './fieldFactory';
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
@@ -155,17 +156,20 @@ function buildTrack(host: InspectorHost, asset: SongAsset, track: SongTrack): vo
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');
for (const def of TRACK_ROWS) {
const commit = (value: SongTrack[keyof SongTrack]) =>
new SetSongCommand(
asset.id,
{ tracks: withTrack(state.project, asset.id, track.id, { [def.key]: value }) },
`Set track ${def.key}`,
);
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}`,
),
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 keyof SongTrack], handlers);
host.fields.set(def.key, row);
host.body.add(row.row);
@@ -180,17 +184,20 @@ function buildItem(host: InspectorHost, asset: SongAsset, item: SongItem, picked
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: (value) =>
new SetSongCommand(
asset.id,
{ items: withItem(state.project, asset.id, item.id, { [def.key]: value }) },
`Set item ${def.key}`,
),
commit,
});
if (def.key === FX_ROW.key)
handlers.moreChoices = (current) => ownFxEntries(state, asset.id, current, commit);
const row = createFieldRow(host, def, item[def.key as keyof SongItem], handlers);
host.fields.set(def.key, row);
host.body.add(row.row);
+12 -13
View File
@@ -10,10 +10,11 @@ import { CompoundCommand } from '../../core/commands/CompoundCommand';
import { SetSongCommand, SetSongLoopCommand, type SongParts } from '../../core/commands/songCommands';
import {
embeddedCopy,
embeddedPatternName,
embeddedName,
embeddedUsedBy,
instrumentNear,
isEmbedded,
markedName,
listedAssets,
songClockProps,
} from '../../core/music/embedded';
@@ -70,6 +71,7 @@ import {
} from '../music/timeView';
import { snapDown, snapNear } from '../pattern/rollEdit';
import type { EditorState } from '../state';
import { ownFxEntries } from './ownFx';
import { removeTrack } from './songActions';
import {
clampMove,
@@ -127,8 +129,6 @@ const SOLO_COLOR = 0xc9c23a;
const FX_COLOR = 0x8fb8de;
/** Height of an item's name strip; what it plays is drawn below. */
const LABEL_STRIP = 15;
/** In front of the name of what the song keeps to itself (#355). */
export const EMBEDDED_MARK = '•';
/** The letter on a lit mute, solo or FX button. */
const DARK_LABEL = textStyle('small').clone();
DARK_LABEL.fill = 0x30343c;
@@ -1068,7 +1068,7 @@ export class SongTimeline extends Widget {
const start = snapDown(tick, songGrid(asset.props));
const create = new CreateAssetCommand(
'pattern',
embeddedPatternName(project, asset.id),
embeddedName(project, asset.id, 'Pattern'),
{
...songClockProps(asset.props),
[PATTERN_BARS_KEY]: 1,
@@ -1098,14 +1098,13 @@ export class SongTimeline extends Widget {
return true;
}
/** The sound FX a track goes through (#346): none, or one of the project's, by name. */
/** The sound FX a track goes through (#346): none, one of the project's by name, or its own (#355). */
private openTrackFxMenu(asset: SongAsset, track: SongTrack, global: Vec2): void {
const set = (fx: string) =>
this.commit(
asset,
{ tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, fx } : t)) },
'Set track sound FX',
);
const parts = (fx: string) => ({
tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, fx } : t)),
});
const place = (fx: string) => new SetSongCommand(asset.id, parts(fx), 'Set track sound FX');
const set = (fx: string) => this.commit(asset, parts(fx), 'Set track sound FX');
const effects = listedAssets(this.state.project)
.filter((a) => a.type === 'soundfx')
.sort((a, b) => a.name.localeCompare(b.name));
@@ -1113,6 +1112,7 @@ export class SongTimeline extends Widget {
{ label: 'No sound FX', current: track.fx === '', onSelect: () => set('') },
...(effects.length > 0 ? [{ separator: true as const }] : []),
...effects.map((fx) => ({ label: fx.name, current: fx.id === track.fx, onSelect: () => set(fx.id) })),
...ownFxEntries(this.state, asset.id, track.fx, place),
]).open(global.x, global.y);
}
@@ -1247,8 +1247,7 @@ export class SongTimeline extends Widget {
/** Its asset's name, with a mark in front for one the song keeps to itself (#355). */
private labelOf(item: SongItem): string {
const asset = findAsset(this.state.project, item.asset);
if (!asset) return 'missing';
return isEmbedded(asset) ? `${EMBEDDED_MARK} ${asset.name}` : asset.name;
return asset ? markedName(asset) : 'missing';
}
private contentOf(item: SongItem): 'notes' | 'wave' | 'none' {
+64
View File
@@ -0,0 +1,64 @@
/**
* A sound FX of its own for a song, a track or an item (#355): the entries every pick of a sound FX
* in a song ends with. The FX made here is kept by the song and named by that one place, so the
* place letting go of it takes it away (`settleEmbedded`).
*/
import { CreateAssetCommand, MakeProjectAssetCommand } from '../../core/commands/assetCommands';
import type { Command } from '../../core/commands/Command';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import type { SoundFxAsset } from '../../core/model/Asset';
import { findAssetOf } from '../../core/model/Project';
import { EMBEDDED_MARK, embeddedName, passThroughFxGraph } from '../../core/music/embedded';
import type { MenuEntry } from '../../ui';
import type { EditorState } from '../state';
/** The command that makes the place name the sound FX of this id. */
export type FxPlace = (fxId: string) => Command;
/**
* Its own FX with Edit and Make project asset while the place names one; else Create sound FX, and
* Embed a copy while it names one of the project's.
*/
export function ownFxEntries(
state: EditorState,
songId: string,
current: string,
place: FxPlace,
): MenuEntry[] {
const project = state.project;
if (!findAssetOf(project, songId, 'song')) return [];
const picked = findAssetOf(project, current, 'soundfx');
if (picked?.owner === songId)
return [
{ separator: true },
{ label: `${EMBEDDED_MARK} ${picked.name}`, current: true, onSelect: () => state.openAsset(picked.id) },
{ label: 'Edit sound FX', onSelect: () => state.openAsset(picked.id) },
{ label: 'Make project asset', onSelect: () => state.execute(new MakeProjectAssetCommand(picked.id)) },
];
return [
{ separator: true },
{ label: 'Create sound FX', onSelect: () => createOwnFx(state, songId, place) },
...(picked ? [{ label: 'Embed a copy', onSelect: () => createOwnFx(state, songId, place, picked) }] : []),
];
}
/**
* A sound FX the song keeps for the place, named there and opened right away, in one undo step: one
* that lets the sound through, or a copy of `from`.
*/
export function createOwnFx(
state: EditorState,
songId: string,
place: FxPlace,
from?: SoundFxAsset,
): string | null {
const name = from?.name ?? embeddedName(state.project, songId, 'Sound FX');
const create = new CreateAssetCommand('soundfx', name, {}, songId);
const fx = create.asset as SoundFxAsset;
fx.graph = from ? structuredClone(from.graph) : passThroughFxGraph();
if (from) fx.props = structuredClone(from.props);
const label = from ? 'Embed a copy' : 'Create sound FX';
if (!state.execute(new CompoundCommand(label, [create, place(fx.id)]))) return null;
state.openAsset(fx.id);
return fx.id;
}