Send a track, a pattern item or a whole song through a sound FX (#346)

A sound FX could only be put on a single sound. Now a track, a pattern
item, a Play pattern and a Play song send everything they play through
one chain of it, as an insert in a DAW. A Play note gets one of its own.

- Track: an FX box in the head next to M and S opens a menu of the
  project's sound FX, and the track's inspector has the row too.
  Order: items, track FX, volume, pan.
- Pattern item: Sound FX in its inspector. Its notes play on a bus of
  their own into the track's bus.
- Play pattern and Play song take fx and fx params. The chain sits on
  the master of the playing, after the tracks and before the level and
  the fades. Set sound FX parameters reaches it by the playback.
- Play note takes fx and fx params next to the instrument's params.
  The FX parameters go by "fx:" so the two sets stay apart. Set sound
  FX parameters on a note changes a name both have in both.
- A Pitch on a bus is a new PitchShifter: the phase vocoder of the
  PitchPlayer, fed while it plays. It cannot look ahead and is exactly
  one frame late at every pitch, 2048 samples.
- That latency is made up. The other way of a Mix is held back by a
  delay of the same length. Every note and clip starts as much earlier
  as the buses it passes are late. The scheduler plans that much
  further ahead, and the first note waits that much after Play.
- The Song tab waits for the chains before it plays and lets its buses
  go a moment after Stop. A playing in a game lets them go after its
  echoes and what the shifters still hold.
- songBuses moved to its own file. Kept in song.ts it pulled the FX
  compiler into the node registry through an import cycle.

Decisions of the user: one chain for the whole (1a), FX everywhere
asked for (2a), Set sound FX parameters on playbacks (3a), and a
pitch shifter of its own with exact latency compensation for a Pitch
on a bus.

Tests:
- Unit pitch-shifter (new): 11 green. At 0 semitones the output is
  the input 2048 samples later, off by at most 1.2e-7.
- Unit sound-library: 84 green, 8 new. Counter-checks (no early
  start, no Mix delay, chain made every time, tune misses the master)
  each turned their test red.
- Unit bus-fx (new): 6 green, 4 counter-checks red. Scheduler ahead: 1
  new, its counter-check red.
- e2e 65 (melektaus-atx): ALL PASSED, 35 checks, 4 new, first run.
  Chrome at 44.1 kHz: the bus was 0.046440 s late, the note started
  exactly that much early. Offline the shifter moved 1 kHz to 2000 Hz
  and passed a click 2048 samples later.
- Gate (melektaus-atx): CHECK EXIT 0, 259 files, 4021 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 13:15:52 +02:00
1 parent 01fd6e6464
commit b519cd192f
32 files changed
+1666 -226

No files matched your search

+6 -2
View File
@@ -2,7 +2,7 @@ import { MIDDLE_C } from '../../../instrument/notes';
import { heldTempo } from '../../../music/time';
import type { NodeDef } from '../../NodeRegistry';
import { asBoolean, asNumber, asString } from '../../values';
import { exec, num, withDefault } from '../shared';
import { exec, num, soundFxPort, withDefault } from '../shared';
import { fxParamsOf, tempoPort } from './PlaySound';
export const PlayNote: NodeDef = {
@@ -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. "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.',
'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.',
inputs: [
exec(),
{
@@ -44,6 +44,8 @@ export const PlayNote: NodeDef = {
},
{ key: 'fxParams', label: 'params', type: 'fxparams' },
tempoPort(),
{ ...soundFxPort(), key: 'soundFx' },
{ key: 'soundFxParams', label: 'fx params', type: 'fxparams' },
],
outputs: [exec(), num('voice')],
params: [],
@@ -59,6 +61,8 @@ export const PlayNote: NodeDef = {
asBoolean(inputs.detached),
fxParamsOf(inputs.fxParams),
heldTempo(asNumber(inputs.tempo)),
asString(inputs.soundFx),
fxParamsOf(inputs.soundFxParams),
)
: 0;
return { next: 'exec', outputs: { voice } };
+16 -4
View File
@@ -1,6 +1,7 @@
import type { NodeDef, PortDef } from '../../NodeRegistry';
import { asBoolean, asNumber, asString } from '../../values';
import { exec, num, withDefault } from '../shared';
import { exec, num, soundFxPort, withDefault } from '../shared';
import { fxParamsOf } from './PlaySound';
export const patternPort = (): PortDef => ({
key: 'pattern',
@@ -19,8 +20,10 @@ export const PlayPattern: NodeDef = {
'pattern ends it – the loop set on its ruler is only for editing. "volume" makes every note of ' +
'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. "playback" is the number of this playing, for Stop pattern; 0 where the ' +
'pattern has no instrument or no notes.',
'"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 ' +
'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.',
inputs: [
exec(),
patternPort(),
@@ -39,13 +42,22 @@ export const PlayPattern: NodeDef = {
default: false,
field: { key: 'detached', label: 'Detached (outlives this object)', kind: 'boolean', default: false },
},
soundFxPort(),
{ key: 'fxParams', label: 'fx params', type: 'fxparams' },
],
outputs: [exec(), num('playback')],
params: [],
run: (ctx, inputs) => {
const pattern = asString(inputs.pattern);
const playback = pattern
? ctx.playPattern(pattern, asNumber(inputs.volume), asBoolean(inputs.loop), asBoolean(inputs.detached))
? ctx.playPattern(
pattern,
asNumber(inputs.volume),
asBoolean(inputs.loop),
asBoolean(inputs.detached),
asString(inputs.fx),
fxParamsOf(inputs.fxParams),
)
: 0;
return { next: 'exec', outputs: { playback } };
},
+10 -3
View File
@@ -1,6 +1,7 @@
import type { NodeDef, PortDef } from '../../NodeRegistry';
import { asBoolean, asNumber, asString } from '../../values';
import { exec, num, withDefault } from '../shared';
import { exec, num, soundFxPort, withDefault } from '../shared';
import { fxParamsOf } from './PlaySound';
export const songPort = (): PortDef => ({
key: 'song',
@@ -20,8 +21,10 @@ export const PlaySong: NodeDef = {
'level of the whole song, "fade in" the seconds it rises there from silence. Each play is a ' +
'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. "playback" is the number of this playing, for Stop song; 0 where the song has nothing ' +
'to play.',
'is on. "fx" sends the whole song through one sound FX, after its tracks and before "volume"; ' +
'"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.',
inputs: [
exec(),
songPort(),
@@ -41,6 +44,8 @@ export const PlaySong: NodeDef = {
default: false,
field: { key: 'detached', label: 'Detached (outlives this object)', kind: 'boolean', default: false },
},
soundFxPort(),
{ key: 'fxParams', label: 'fx params', type: 'fxparams' },
],
outputs: [exec(), num('playback')],
params: [],
@@ -53,6 +58,8 @@ export const PlaySong: NodeDef = {
asNumber(inputs.fadeIn),
asBoolean(inputs.loop),
asBoolean(inputs.detached),
asString(inputs.fx),
fxParamsOf(inputs.fxParams),
)
: 0;
return { next: 'exec', outputs: { playback } };
@@ -16,8 +16,10 @@ export const SetSoundFxParameters: NodeDef = {
label: 'Set sound FX parameters',
category: 'action',
description:
'Changes the sound FX parameters of a sound while it plays: the voice a Play sound handed out ' +
'glides to the values from a Sound FX parameters node. Keep the voice in a variable to reach it ' +
'Changes the sound FX parameters of a sound while it plays: the voice a Play sound or Play note ' +
'handed out, or the playback of a Play pattern or Play song with an fx, glides to the values from ' +
'a Sound FX parameters node. On a note a name its instrument and its fx both have changes both. ' +
'Keep the voice in a variable to reach it ' +
'from another event. A parameter not given keeps its value, a voice that has ended is not ' +
'reached. A pitch keeps the length of the sound here too, but is heard about 20 ms after it is set. ' +
'time: seconds the values take to get there, along the chosen easing and on the clock of the ' +
@@ -13,7 +13,7 @@ export const SoundFxParameters: NodeDef = {
label: 'Sound FX parameters',
category: 'data',
description:
'Values for the parameters of a sound FX, for the "fx params" input of a Play sound or a Set sound FX parameters. Pick the parameters to set; one not picked keeps the default its Input gives it, or on a playing sound the value it has.',
'Values for the parameters of a sound FX, for the "fx params" input of a Play sound, Play note, Play pattern, Play song or a Set sound FX parameters. Pick the parameters to set; one not picked keeps the default its Input gives it, or on a playing sound the value it has.',
inputs: [],
outputs: [{ key: 'params', label: 'params', type: 'fxparams' }],
params: [SOUND_FX_SETTING],