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:
adminandClaude Opus 5.5 committed 2026-10-07 22:02:26 +02:00
1 parent d349251a01
commit a295d233ed
26 files changed
+521 -60

No files matched your search

+16 -4
View File
@@ -18,7 +18,10 @@ import {
import type { Asset, PatternAsset } from '../../core/model/Asset';
import { textOf } from '../../core/model/Properties';
import { findAssetOf } from '../../core/model/Project';
import { playNote } from '../../runtime/systems/behaviorContext';
import { fxVoiceOf } from '../../core/music/needs';
import { assetFxOf } from '../../core/properties/assetFx';
import { DEFAULT_TEMPO } from '../../core/music/time';
import { instrumentFx, playNote } from '../../runtime/systems/behaviorContext';
import { Button, Dropdown, Label, Menu, type Widget } from '../../ui';
import { Music } from '../../viewport/music';
import { playedBus } from '../../core/music/mixer';
@@ -51,10 +54,14 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
readonly ghostsButton: Button;
/** Where the ghost notes come from: song, track and bar. */
readonly ghostSource = new Label('', { style: 'muted' });
/** Heard on the bus of the asset and the master, as in the game (#325). */
/** Heard through the sound FX (#352) and on the bus of the asset and the master, as in the game (#325). */
readonly music = new Music(
() => this.state.project,
() => playedBus(this.state.project, '', this.asset()?.props ?? {}),
() => {
const asset = this.asset();
return asset && fxVoiceOf(this.state.project, assetFxOf(asset.props), patternTempo(asset.props));
},
);
/** The pattern playing, `''` while none is. */
private playingId = '';
@@ -90,7 +97,10 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
this.ghostsButton.setToggled(true);
const roll = new PianoRoll(state);
roll.onPreview = (note, held) => this.preview(note, held);
roll.onRelease = (voice) => soundLibrary.release(voice);
roll.onRelease = (voice) => {
soundLibrary.release(voice);
this.music.idle();
};
roll.onTogglePlay = () => this.togglePlay();
roll.onStart = (tick) => {
if (this.isPlaying) this.music.current?.seek(tick);
@@ -167,13 +177,15 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
const instrument = asset ? patternInstrument(asset.props) : '';
if (!instrument) return 0;
const struck = { note, velocity: 1, volume: 1, duration: held ? 0 : PREVIEW_SECONDS };
if (!held) setTimeout(() => this.music.idle(), PREVIEW_SECONDS * 1000);
return playNote(
{ project: this.state.project, audio: soundLibrary },
instrument,
struck,
PREVIEW_OWNER,
{},
{ bus: playedBus(this.state.project, '', asset?.props ?? {}) },
{ bus: this.music.heardOn() },
fxVoiceOf(this.state.project, instrumentFx(this.state.project, instrument, ''), DEFAULT_TEMPO),
);
}