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
@@ -42,6 +42,7 @@ import { type Command, CommandError } from './Command';
import { nextAssetName } from './naming';
import { requireAsset, requirePropDef, requireStateMachine, requireVariablesAsset } from './require';
import { SetBagValueCommand } from './SetBagValueCommand';
import { SetSongCommand } from './songCommands';
export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
const base = { id, name, props: initialPropsFor(type) };
@@ -539,9 +540,13 @@ export class SetMachineStartCommand implements Command {
}
}
/** An embedded pattern follows the tempo and the meter of its song (#355), in the same undo step. */
/**
* An embedded pattern follows the tempo and the meter of its song (#355), in the same undo step. A
* song that picks another sound FX lets go of the one it kept to itself, in that step too.
*/
abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
private followed: { id: string; previous: PropertyValue | undefined }[] = [];
private settled: SetSongCommand | null = null;
protected constructor(
private readonly assetId: string,
@@ -559,9 +564,13 @@ abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
setBagValue(pattern.props, this.key, asset.props[this.key]);
return { id: pattern.id, previous };
});
this.settled =
asset.type === 'song' && this.key === ASSET_FX_KEY ? new SetSongCommand(asset.id, {}) : null;
this.settled?.execute(project);
}
override undo(project: Project): void {
this.settled?.undo(project);
for (const { id, previous } of this.followed)
setBagValue(requireAsset(project, id).props, this.key, previous);
super.undo(project);
+25 -3
View File
@@ -5,6 +5,8 @@
* track or an item. `settleEmbedded` holds a song to that after every edit, so a split, a duplicate
* or a paste gets a copy of its own and what nothing uses any more goes.
*/
import type { Graph } from '../graph/Graph';
import { SOUND_FX_INPUT, SOUND_FX_OUTPUT } from '../graph/nested';
import { newId } from '../ids';
import type { Asset, PatternAsset, SongAsset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
@@ -40,6 +42,13 @@ export function isEmbedded(asset: Asset): boolean {
return (asset.owner ?? '') !== '';
}
/** In front of the name of what the song keeps to itself, wherever it is named. */
export const EMBEDDED_MARK = '•';
export function markedName(asset: Asset): string {
return isEmbedded(asset) ? `${EMBEDDED_MARK} ${asset.name}` : asset.name;
}
/** The assets a list offers: all but those a song keeps to itself. */
export function listedAssets(project: Project): Asset[] {
return project.assets.filter((a) => !isEmbedded(a));
@@ -93,14 +102,27 @@ export function clockFollowers(project: Project, asset: Asset, key: string): Pat
return embeddedIn(project, asset.id).filter((a): a is PatternAsset => a.type === 'pattern');
}
/** "Pattern 1", 2, 3 …: the first number none of what the song keeps to itself has. */
export function embeddedPatternName(project: Project, songId: string): string {
/** "Pattern 1", 2, 3 … ("Sound FX 1" …): the first number none of what the song keeps to itself has. */
export function embeddedName(project: Project, songId: string, base: string): string {
return nextNumberedName(
embeddedIn(project, songId).map((a) => a.name),
'Pattern',
base,
);
}
/** What a sound FX made in a song starts as (#355): the Input wired to the Output, so it lets the sound through. */
export function passThroughFxGraph(): Graph {
const input = newId();
const output = newId();
return {
nodes: [
{ id: input, type: SOUND_FX_INPUT, x: 0, y: 0, params: {} },
{ id: output, type: SOUND_FX_OUTPUT, x: 260, y: 0, params: {} },
],
edges: [{ id: newId(), from: { node: input, port: 'out' }, to: { node: output, port: 'in' } }],
};
}
/**
* The instrument a pattern made at `tick` on a track is played with: that of the pattern item there
* that starts last before it, else of the first one after; '' on a track without one.
+6 -4
View File
@@ -1,7 +1,7 @@
import type { Asset } from '../model/Asset';
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
import { findAsset, type Project } from '../model/Project';
import { listedAssets } from '../music/embedded';
import { listedAssets, markedName } from '../music/embedded';
import { NO_FX } from './assetFx';
import { MASTER_BUS } from './buses';
@@ -9,13 +9,15 @@ export const NO_ASSET_LABEL = 'None';
export const MASTER_LABEL = 'Master';
/**
* The asset's name, "None" for the empty id, `<id>?` when it no longer resolves – a dangling
* reference shows its id rather than passing for an empty field.
* The asset's name, marked for one a song keeps to itself (#355), "None" for the empty id, `<id>?`
* when it no longer resolves – a dangling reference shows its id rather than passing for an empty
* field.
*/
export function assetRefLabel(project: Project | null, id: string): string {
if (id === '' || id === NO_FX) return NO_ASSET_LABEL;
if (id === MASTER_BUS) return MASTER_LABEL;
return (project ? findAsset(project, id) : undefined)?.name ?? `${id}?`;
const asset = project ? findAsset(project, id) : undefined;
return asset ? markedName(asset) : `${id}?`;
}
/**