Give sounds, instruments, patterns and songs a sound FX (#352)
A sound FX could only be picked where something was played: on a Play
node, a track or an item of a song. A sound that should always sound
through the same effect needed that pick everywhere it was used. Now
the asset says it once, and every place that plays it follows.
- New row Sound FX on a sound, an instrument, a pattern and a song,
right before Bus. A sound goes through it on every voice, an
instrument on every note after its graph, a pattern on all its
notes together and a song on its output.
- The fx input of Play sound, Play note, Play pattern and Play song
and the Sound FX of a song item now read "From the asset" while
empty. None is a pick of its own, stored as `#none` like `#master`.
A pick replaces the FX of the asset, it does not come on top.
- A pattern item's Instrument reads "From the asset" instead of None.
The meaning is unchanged.
- A double-click on a sound item opens the sound, as one on a pattern
item opens the pattern.
- The tabs play through the FX of their asset. The Pattern and Song
tab put it on a bus of their own between the output and the bus of
the asset. The keys of the Pattern tab play there too.
- Notes of a pattern or song go through the FX of their instrument.
Its chain is loaded with the other needs of the sequence.
Decided without asking:
- Old items and Play nodes with an empty fx now mean "From the
asset". No asset has an FX yet, so nothing sounds different.
- The label is "From the asset" as on Loop and Bus, not "From asset".
- A sound used as a Sample, as the impulse of a Reverb or as the test
sound of the Sound FX tab plays without its own FX.
Findings:
- In e2e, `__gm.inspectorField('Sound FX')` and `('Instrument')` find
the group of the Assets panel first. The check asks for the last
match.
Tests:
- Unit asset-fx (new): 8 green. Six counter-checks each turned red.
- Field lists of sound, pattern and song: Sound FX added.
- e2e 65 (melektaus-atx): ALL PASSED, 46 checks (3 new). Counter-check
without the FX bus of the tab: that check red.
- Gate (melektaus-atx): CHECK EXIT 0, 268 files, 4142 tests.
- Not checked: how it sounds, the Sound, Instrument and Pattern tab
in the browser.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d349251a01
commit
a295d233ed
26 files changed
+521
-60
No files matched your search
@@ -238,7 +238,8 @@ export interface NodeContext {
|
||||
* The voice belongs to the behavior that started it and ends when that behavior is destroyed –
|
||||
* a sound is something a thing in the world is doing. `detached` gives that up on purpose, for
|
||||
* the one case where the rule is in the way: the death sound of an object that destroys itself.
|
||||
* `fx` names a sound FX asset the voice is played through (#311); `''` plays it as it is.
|
||||
* `fx` names a sound FX asset the voice is played through (#311); `''` the one the sound asset
|
||||
* names (#352), `NO_FX` none.
|
||||
* `fxParams` are values for its parameters; one not given is the default the FX declares.
|
||||
* `tempo` is what a Delay synced to beats counts by (#332). `bus` is the bus it is heard on
|
||||
* (#325): a bus asset or `MASTER_BUS`, `''` for the one the sound asset names.
|
||||
@@ -259,7 +260,8 @@ export interface NodeContext {
|
||||
* `velocity` (0 to 1, how loud it is next to `volume`) and released after `duration` seconds, or
|
||||
* held until it is stopped where that is 0. Owned like a `playSound` voice, `instrumentParams` are
|
||||
* values for the instrument's parameters, `tempo` as for `playSound`. `fx` sends the note through a
|
||||
* sound FX of its own (#346), `fxParams` with values for its parameters, apart from the instrument's.
|
||||
* sound FX of its own (#346), `fxParams` with values for its parameters, apart from the instrument's;
|
||||
* `''` the one the instrument names, `NO_FX` none (#352).
|
||||
* `bus` as for `playSound`, `''` for the one the instrument names.
|
||||
* Returns the number of the voice, 0 where nothing plays.
|
||||
*/
|
||||
@@ -279,7 +281,8 @@ export interface NodeContext {
|
||||
/**
|
||||
* Plays a pattern asset through its instrument, at its own tempo, once or round and round, every
|
||||
* note at `volume`. Owned like a `playSound` voice. `fx` is a sound FX all of it goes through as
|
||||
* one chain (#346), `fxParams` values for its parameters, `bus` as for `playSound`. Returns its
|
||||
* one chain (#346), `''` the one the pattern names, `NO_FX` none (#352); `fxParams` values for its
|
||||
* parameters, `bus` as for `playSound`. Returns its
|
||||
* number, for `stopVoice` and `tuneSound`; 0 where there is nothing to play.
|
||||
*/
|
||||
playPattern(
|
||||
|
||||
@@ -10,7 +10,7 @@ export const PlayNote: NodeDef = {
|
||||
label: 'Play note',
|
||||
category: 'action',
|
||||
description:
|
||||
'Plays one note of an instrument asset. "note" is its number as MIDI counts it: 60 is C4, 61 C#4, 72 C5. "velocity" is how hard it is struck, 0 to 1 – it makes the note quieter and is handed to what the instrument does with it. "duration" is how many seconds until the note is released; at 0 it is held until a Stop sound ends it. A note belongs to this object like a sound and stops when the object is destroyed, unless "detached" is on. "params" takes values for the parameters of the instrument from an Instrument parameters node. "fx" sends the note through a sound FX on top of what the instrument does, "fx params" takes values for its parameters from a Sound FX parameters node – the two sets stay apart. "voice" is the number of this note, for Set sound FX parameters and Stop sound; 0 where the instrument plays nothing for it. "tempo" is what a Delay in the instrument synced to beats counts by, in quarter notes a minute. "bus" is the bus it is heard on; left empty, the one the instrument names.',
|
||||
'Plays one note of an instrument asset. "note" is its number as MIDI counts it: 60 is C4, 61 C#4, 72 C5. "velocity" is how hard it is struck, 0 to 1 – it makes the note quieter and is handed to what the instrument does with it. "duration" is how many seconds until the note is released; at 0 it is held until a Stop sound ends it. A note belongs to this object like a sound and stops when the object is destroyed, unless "detached" is on. "params" takes values for the parameters of the instrument from an Instrument parameters node. "fx" sends the note through a sound FX on top of what the instrument does; left empty, the one the instrument names, and None through none. "fx params" takes values for its parameters from a Sound FX parameters node – the two sets stay apart. "voice" is the number of this note, for Set sound FX parameters and Stop sound; 0 where the instrument plays nothing for it. "tempo" is what a Delay in the instrument synced to beats counts by, in quarter notes a minute. "bus" is the bus it is heard on; left empty, the one the instrument names.',
|
||||
inputs: [
|
||||
exec(),
|
||||
{
|
||||
|
||||
@@ -21,7 +21,8 @@ export const PlayPattern: NodeDef = {
|
||||
'it quieter. Each play is a playing of its own: firing this twice plays it twice, overlapping. ' +
|
||||
'It belongs to this object like a sound and stops when the object is destroyed, unless ' +
|
||||
'"detached" is on. "fx" sends all of it through one sound FX, its notes together: an LFO runs on ' +
|
||||
'across them, an echo repeats the whole. "fx params" takes values for its parameters from a Sound ' +
|
||||
'across them, an echo repeats the whole. Left empty, it is the one the pattern names; None is ' +
|
||||
'none. "fx params" takes values for its parameters from a Sound ' +
|
||||
'FX parameters node. "playback" is the number of this playing, for Stop pattern and for Set sound ' +
|
||||
'FX parameters; 0 where the pattern has no instrument or no notes. "bus" is the bus all of it is ' +
|
||||
'heard on; left empty, the one the pattern names.',
|
||||
|
||||
@@ -22,6 +22,7 @@ export const PlaySong: NodeDef = {
|
||||
'playing of its own: firing this twice plays it twice, side by side. It belongs to this object ' +
|
||||
'like a sound and stops when the object is destroyed or the scene is left, unless "detached" ' +
|
||||
'is on. "fx" sends the whole song through one sound FX, after its tracks and before "volume"; ' +
|
||||
'left empty, the one the song names, and None is none. ' +
|
||||
'"fx params" takes values for its parameters from a Sound FX parameters node. "playback" is the ' +
|
||||
'number of this playing, for Stop song and for Set sound FX parameters; 0 where the song has ' +
|
||||
'nothing to play. "bus" is the bus all of it is heard on; left empty, the one the song names.',
|
||||
|
||||
@@ -59,7 +59,8 @@ export const PlaySound: NodeDef = {
|
||||
"leaves the asset's own setting alone or overrules it, so the asset says what the sound is and " +
|
||||
'the node only colors it. Each play is a voice of its own: firing this twice in a row is heard ' +
|
||||
'twice, overlapping. The sound belongs to this object and stops when the object is destroyed, ' +
|
||||
'unless "detached" is on. "fx" sends it through a sound FX asset; left empty, it plays as it is. ' +
|
||||
'unless "detached" is on. "fx" sends it through a sound FX asset instead of the one the sound ' +
|
||||
'names; left empty, it takes that one, and None plays it as it is. ' +
|
||||
'"fx params" takes values for its parameters from a Sound FX parameters node. "voice" is the ' +
|
||||
'number of this one play, for Set sound FX parameters to change it while it plays; 0 where ' +
|
||||
'nothing was played. "length" is how many seconds one run of the sound lasts, to hand to the ' +
|
||||
|
||||
@@ -94,12 +94,23 @@ export const soundPort = (): PortDef => ({
|
||||
field: { key: 'sound', label: 'Sound', kind: 'sound', default: '' },
|
||||
});
|
||||
|
||||
/** The `fx` input of `Play sound` (#311); unconnected, it shows the picker of sound FX assets. */
|
||||
/**
|
||||
* The `fx` input of a Play node (#311); unconnected, it shows the picker of sound FX assets. Empty is
|
||||
* the one its asset names, `NO_FX` none (#352).
|
||||
*/
|
||||
export const soundFxPort = (): PortDef => ({
|
||||
key: 'fx',
|
||||
label: 'fx',
|
||||
type: 'string',
|
||||
field: { key: 'fx', label: 'FX', kind: 'asset', default: '', assetTypes: ['soundfx'] },
|
||||
field: {
|
||||
key: 'fx',
|
||||
label: 'FX',
|
||||
kind: 'asset',
|
||||
default: '',
|
||||
assetTypes: ['soundfx'],
|
||||
none: 'From the asset',
|
||||
noFx: true,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { instrumentBuildFor, instrumentSourcesOf } from '../instrument/compile';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { assetFxOf } from '../properties/assetFx';
|
||||
import { playedSoundById } from '../properties/playedSound';
|
||||
import { soundFxBuildFor } from '../soundfx/compile';
|
||||
import { DRY_VOICE, type FxParams, type FxPlan, type FxVoice } from '../soundfx/nodes';
|
||||
@@ -26,7 +27,7 @@ export function clipFx(project: Project, fxId: string, tempo: number): FxVoice {
|
||||
return fxVoiceOf(project, fxId, tempo) ?? DRY_VOICE;
|
||||
}
|
||||
|
||||
/** Every layer of every note and every file and FX of every clip a sequence plays. */
|
||||
/** Every layer and FX of every note and every file and FX of every clip a sequence plays. */
|
||||
export function sequenceNeeds(project: Project, sequence: Sequence): SequenceNeeds {
|
||||
const sources = instrumentSourcesOf(project);
|
||||
const seen = new Set<string>();
|
||||
@@ -35,6 +36,11 @@ export function sequenceNeeds(project: Project, sequence: Sequence): SequenceNee
|
||||
const key = `${instrument}\u0000${note}`;
|
||||
const asset = findAssetOf(project, instrument, 'instrument');
|
||||
if (seen.has(key) || !asset) continue;
|
||||
if (!seen.has(instrument)) {
|
||||
seen.add(instrument);
|
||||
const fx = fxVoiceOf(project, assetFxOf(asset.props), 120);
|
||||
if (fx) layers.push(fx.plan);
|
||||
}
|
||||
seen.add(key);
|
||||
layers.push(...instrumentBuildFor(asset, note, sources).layers);
|
||||
}
|
||||
|
||||
+15
-6
@@ -9,7 +9,8 @@ import { newId } from '../ids';
|
||||
import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes';
|
||||
import type { PatternAsset, SongAsset } from '../model/Asset';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { findAsset, findAssetOf, type Project } from '../model/Project';
|
||||
import { pickedFx } from '../properties/assetFx';
|
||||
import { gridTicks, GRID_OPTIONS, patternInstrument, patternLength } from './pattern';
|
||||
import { heldLoop, type LoopRange, type Sequence, type SequenceClip, type SequenceNote } from './sequence';
|
||||
import { heldMeter, heldTempo, type Meter, secondsOfTicks, ticksPerBar } from './time';
|
||||
@@ -47,8 +48,8 @@ export interface SongItem {
|
||||
/** The instrument a pattern is played with instead of its own, `''` for its own. */
|
||||
instrument: string;
|
||||
/**
|
||||
* The sound FX it goes through, `''` for none: a sound by itself, a pattern all its notes together
|
||||
* on a bus of their own (#346).
|
||||
* The sound FX it goes through: a sound by itself, a pattern all its notes together on a bus of
|
||||
* their own (#346). `''` is the one its asset names, `NO_FX` none (#352).
|
||||
*/
|
||||
fx: string;
|
||||
}
|
||||
@@ -145,9 +146,17 @@ export function dbToGain(db: number): number {
|
||||
return db <= TRACK_MIN_DB ? 0 : 10 ** (db / 20);
|
||||
}
|
||||
|
||||
/** Whether a pattern item is heard on a bus of its own (#346): it names a sound FX that is there. */
|
||||
/** The sound FX an item goes through (#352): its own pick, else the one of what it plays; '' for none. */
|
||||
export function itemFx(project: Project, item: SongItem): string {
|
||||
const played = findAsset(project, item.asset);
|
||||
return pickedFx(item.fx, played?.type === item.kind ? played.props : {});
|
||||
}
|
||||
|
||||
/** Whether a pattern item is heard on a bus of its own (#346): it goes through a sound FX that is there. */
|
||||
export function hasItemBus(project: Project, item: SongItem): boolean {
|
||||
return item.kind === 'pattern' && item.fx !== '' && findAssetOf(project, item.fx, 'soundfx') !== undefined;
|
||||
if (item.kind !== 'pattern') return false;
|
||||
const fx = itemFx(project, item);
|
||||
return fx !== '' && findAssetOf(project, fx, 'soundfx') !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,7 +212,7 @@ export function songSequence(project: Project, song: SongAsset): Sequence {
|
||||
tick: item.tick,
|
||||
length: item.length,
|
||||
sound: item.asset,
|
||||
fx: item.fx,
|
||||
fx: itemFx(project, item),
|
||||
offset: item.offset,
|
||||
volume,
|
||||
bus,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
dbToGain,
|
||||
hasItemBus,
|
||||
itemBus,
|
||||
itemFx,
|
||||
type SongItem,
|
||||
songTempo,
|
||||
type SongTrack,
|
||||
@@ -68,7 +69,7 @@ export function songBuses(
|
||||
gain: 1,
|
||||
pan: 0,
|
||||
into: trackItemsBus(song.id, item.track),
|
||||
...fxOf(item.fx),
|
||||
...fxOf(itemFx(project, item)),
|
||||
}));
|
||||
return [...tracks, ...own, ...items];
|
||||
}
|
||||
@@ -139,6 +139,7 @@ import {
|
||||
SONG_TEMPO_KEY,
|
||||
} from '../music/song';
|
||||
import { DEFAULT_TEMPO, MAX_TEMPO, MIN_TEMPO } from '../music/time';
|
||||
import { ASSET_FX_KEY } from './assetFx';
|
||||
import { BUS_FX_KEY, BUS_INTO_KEY, BUS_KEY, BUS_VOLUME_KEY } from './buses';
|
||||
import { Theme } from '../../ui';
|
||||
|
||||
@@ -147,7 +148,8 @@ import { Theme } from '../../ui';
|
||||
* properties that are integral by meaning (counts, sizes in whole pixels), not on geometry: dragging
|
||||
* produces fractions, and the field rounds the value away rather than hiding it. Without
|
||||
* `assetTypes` an asset picker offers every placeable asset; `none` is what it calls an empty pick,
|
||||
* `master` offers the master bus as a choice of its own, and `intoFor` names the bus the row is
|
||||
* `master` offers the master bus as a choice of its own, `noFx` "None" as one where empty means
|
||||
* "from the asset" (#352), and `intoFor` names the bus the row is
|
||||
* the Into of, which leaves out that bus and every one going into it when the picker opens (#325);
|
||||
* `uploadedOnly` keeps drawn pictures out
|
||||
* of a texture picker (#186). `renderLayers` makes a layer row pick from the project's render layers
|
||||
@@ -158,7 +160,13 @@ type Extras<K extends FlatKind> = K extends 'number'
|
||||
: K extends 'enum'
|
||||
? { options: { value: string; label: string }[] }
|
||||
: K extends 'asset'
|
||||
? { assetTypes?: readonly AssetType[]; none?: string; master?: boolean; intoFor?: string }
|
||||
? {
|
||||
assetTypes?: readonly AssetType[];
|
||||
none?: string;
|
||||
master?: boolean;
|
||||
noFx?: boolean;
|
||||
intoFor?: string;
|
||||
}
|
||||
: K extends 'texture'
|
||||
? { uploadedOnly?: boolean }
|
||||
: K extends 'layer' | 'layers'
|
||||
@@ -503,6 +511,15 @@ const BUS_ROW: PropertyDef = {
|
||||
none: 'Master',
|
||||
};
|
||||
|
||||
/** The sound FX a sound, an instrument, a pattern or a song goes through, before its bus (#352). */
|
||||
const FX_ROW: PropertyDef = {
|
||||
key: ASSET_FX_KEY,
|
||||
label: 'Sound FX',
|
||||
kind: 'asset',
|
||||
default: '',
|
||||
assetTypes: ['soundfx'],
|
||||
};
|
||||
|
||||
/** Position is deliberately absent: it belongs to scene objects, not to asset types. */
|
||||
const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
scene: [
|
||||
@@ -733,12 +750,13 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
options: FADE_CURVES,
|
||||
},
|
||||
]),
|
||||
FX_ROW,
|
||||
BUS_ROW,
|
||||
],
|
||||
// The effects are the asset's graph (see SoundFxAsset), not a property.
|
||||
soundfx: [],
|
||||
// What a note sounds like is the asset's graph (see InstrumentAsset), not a property.
|
||||
instrument: [BUS_ROW],
|
||||
instrument: [FX_ROW, BUS_ROW],
|
||||
// The notes and the loop are intrinsic data on the asset (see PatternAsset). The meter and the
|
||||
// tempo are for drawing and hearing it on its own; a song plays it at its own tempo (#334).
|
||||
pattern: [
|
||||
@@ -780,6 +798,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
max: MAX_TEMPO,
|
||||
step: 1,
|
||||
},
|
||||
FX_ROW,
|
||||
BUS_ROW,
|
||||
],
|
||||
// The tracks, items and loop are intrinsic data on the asset (see SongAsset).
|
||||
@@ -811,6 +830,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
default: DEFAULT_SONG_GRID,
|
||||
options: SONG_GRID_OPTIONS,
|
||||
},
|
||||
FX_ROW,
|
||||
BUS_ROW,
|
||||
],
|
||||
// Where sounds are mixed (#325): how loud, through which sound FX, and on into which bus.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { coerceAs } from './kinds';
|
||||
|
||||
/** The sound FX a sound, an instrument, a pattern or a song goes through (#352); empty is none. */
|
||||
export const ASSET_FX_KEY = 'fx';
|
||||
|
||||
/**
|
||||
* What a pick says for "no sound FX" where empty means "the one of the asset": on a Play node and on
|
||||
* an item of a song. No asset id has a `#`.
|
||||
*/
|
||||
export const NO_FX = '#none';
|
||||
|
||||
export function assetFxOf(bag: PropertyBag): string {
|
||||
return coerceAs(bag[ASSET_FX_KEY], 'asset', '');
|
||||
}
|
||||
|
||||
/** The sound FX something plays through: the one picked, else the one its asset names; '' for none. */
|
||||
export function pickedFx(picked: string, assetProps: PropertyBag): string {
|
||||
if (picked === NO_FX) return '';
|
||||
return picked !== '' ? picked : assetFxOf(assetProps);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Asset } from '../model/Asset';
|
||||
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
|
||||
import { findAsset, type Project } from '../model/Project';
|
||||
import { NO_FX } from './assetFx';
|
||||
import { MASTER_BUS } from './buses';
|
||||
|
||||
export const NO_ASSET_LABEL = 'None';
|
||||
@@ -11,7 +12,7 @@ export const MASTER_LABEL = 'Master';
|
||||
* reference shows its id rather than passing for an empty field.
|
||||
*/
|
||||
export function assetRefLabel(project: Project | null, id: string): string {
|
||||
if (id === '') return NO_ASSET_LABEL;
|
||||
if (id === '' || id === NO_FX) return NO_ASSET_LABEL;
|
||||
if (id === MASTER_BUS) return MASTER_LABEL;
|
||||
return (project ? findAsset(project, id) : undefined)?.name ?? `${id}?`;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user