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
@@ -17,6 +17,7 @@ import {
|
||||
MASTER_LABEL,
|
||||
NO_ASSET_LABEL,
|
||||
} from '../../core/properties/assetRef';
|
||||
import { NO_FX } from '../../core/properties/assetFx';
|
||||
import { busesInto, MASTER_BUS } from '../../core/properties/buses';
|
||||
import { keyName } from '../../core/keys';
|
||||
import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap';
|
||||
@@ -398,6 +399,7 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
|
||||
const left = new Set(extras?.intoFor ? busesInto(project, extras.intoFor) : []);
|
||||
return [
|
||||
...(extras?.master ? [{ id: MASTER_BUS, label: MASTER_LABEL }] : []),
|
||||
...(extras?.noFx ? [{ id: NO_FX, label: NO_ASSET_LABEL }] : []),
|
||||
...assetRefChoices(project, extras?.assetTypes)
|
||||
.filter((asset) => !left.has(asset.id))
|
||||
.map((asset) => ({ id: asset.id, label: assetChoiceLabel(asset) })),
|
||||
|
||||
@@ -16,13 +16,16 @@ import { createFieldRow } from './fieldFactory';
|
||||
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { previewingHandlers } from './previewHandlers';
|
||||
|
||||
const FX_ROW: PropertyDef = {
|
||||
const FX_ROW = {
|
||||
key: 'fx',
|
||||
label: 'Sound FX',
|
||||
kind: 'asset',
|
||||
default: '',
|
||||
assetTypes: ['soundfx'],
|
||||
};
|
||||
} as const satisfies PropertyDef;
|
||||
|
||||
/** What an item's pick says while it is empty (#352): what its asset names. */
|
||||
const FROM_ASSET = 'From the asset';
|
||||
|
||||
const TRACK_ROWS: readonly PropertyDef[] = [
|
||||
{
|
||||
@@ -72,9 +75,16 @@ function itemRows(item: SongItem): PropertyDef[] {
|
||||
step: 1,
|
||||
decimals: 0,
|
||||
},
|
||||
{ key: 'instrument', label: 'Instrument', kind: 'asset', default: '', assetTypes: ['instrument'] },
|
||||
{
|
||||
key: 'instrument',
|
||||
label: 'Instrument',
|
||||
kind: 'asset',
|
||||
default: '',
|
||||
assetTypes: ['instrument'],
|
||||
none: FROM_ASSET,
|
||||
},
|
||||
);
|
||||
rows.push(FX_ROW);
|
||||
rows.push({ ...FX_ROW, none: FROM_ASSET, noFx: true });
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -185,12 +195,13 @@ function buildItem(host: InspectorHost, asset: SongAsset, item: SongItem, picked
|
||||
host.fields.set(def.key, row);
|
||||
host.body.add(row.row);
|
||||
}
|
||||
if (item.kind === 'pattern')
|
||||
addNote(
|
||||
host,
|
||||
'An empty Instrument plays the pattern with its own. A Sound FX takes all its notes together, before the one of the track.',
|
||||
'small',
|
||||
);
|
||||
addNote(
|
||||
host,
|
||||
item.kind === 'pattern'
|
||||
? 'A Sound FX takes all its notes together, instead of the one of the pattern and before the one of the track.'
|
||||
: 'A Sound FX takes the place of the one of the sound.',
|
||||
'small',
|
||||
);
|
||||
}
|
||||
|
||||
function itemOf(asset: SongAsset | undefined, itemIds: readonly string[]): SongItem | undefined {
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
} from '../../core/instrument/compile';
|
||||
import { MIDDLE_C, noteName } from '../../core/instrument/notes';
|
||||
import type { Asset, InstrumentAsset } from '../../core/model/Asset';
|
||||
import { instrumentBus, playNote } from '../../runtime/systems/behaviorContext';
|
||||
import { fxVoiceOf } from '../../core/music/needs';
|
||||
import { DEFAULT_TEMPO } from '../../core/music/time';
|
||||
import { instrumentBus, instrumentFx, playNote } from '../../runtime/systems/behaviorContext';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
@@ -165,6 +167,7 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
|
||||
PREVIEW_OWNER,
|
||||
{ ...this.values },
|
||||
{ bus: instrumentBus(this.state.project, asset.id, '') },
|
||||
fxVoiceOf(this.state.project, instrumentFx(this.state.project, asset.id, ''), DEFAULT_TEMPO),
|
||||
);
|
||||
if (voice) this.voices.push(voice);
|
||||
return voice;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,12 +12,14 @@ import {
|
||||
} from '../../core/music/song';
|
||||
import { roundAt } from '../../core/music/ghosts';
|
||||
import { patternLength } from '../../core/music/pattern';
|
||||
import { fxVoiceOf } from '../../core/music/needs';
|
||||
import { songBuses } from '../../core/music/songBuses';
|
||||
import { sampleCount, takeName, takeStart, takeTrack, takeTrackIndex, wavBytes } from '../../core/music/take';
|
||||
import { ticksOfSeconds } from '../../core/music/time';
|
||||
import type { Asset, SongAsset } from '../../core/model/Asset';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { textOf } from '../../core/model/Properties';
|
||||
import { assetFxOf } from '../../core/properties/assetFx';
|
||||
import type { SoundStore } from '../../core/store/SoundStore';
|
||||
import { Button, Dialog, Dropdown, Label, Menu, ui, VStack, type Widget } from '../../ui';
|
||||
import { Music } from '../../viewport/music';
|
||||
@@ -82,10 +84,14 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
readonly routing: GraphCanvas;
|
||||
private shown: SongView = 'timeline';
|
||||
private readonly pane = new VStack();
|
||||
/** 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), songTempo(asset.props));
|
||||
},
|
||||
);
|
||||
/** The song playing, `''` while none is. */
|
||||
private playingId = '';
|
||||
@@ -137,9 +143,9 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
const asset = this.asset();
|
||||
if (asset && this.isPlaying) this.music.setBuses(songBuses(this.state.project, { ...asset, tracks }));
|
||||
};
|
||||
timeline.onOpenPattern = (item, tick) => {
|
||||
timeline.onOpenItem = (item, tick) => {
|
||||
const song = this.asset();
|
||||
const pattern = findAssetOf(state.project, item.asset, 'pattern');
|
||||
const pattern = item.kind === 'pattern' ? findAssetOf(state.project, item.asset, 'pattern') : undefined;
|
||||
if (song && pattern)
|
||||
state.patternPlaces.set(pattern.id, {
|
||||
songId: song.id,
|
||||
|
||||
@@ -233,8 +233,8 @@ export class SongTimeline extends Widget {
|
||||
onStart: (tick: number) => void = () => {};
|
||||
/** The tracks as a slider in a head would leave them: heard at once, written when it lets go. */
|
||||
onTracksPreview: (tracks: readonly SongTrack[]) => void = () => {};
|
||||
/** A pattern item was double-clicked, at a tick of the song. */
|
||||
onOpenPattern: (item: SongItem, tick: number) => void = () => {};
|
||||
/** An item was double-clicked, at a tick of the song. */
|
||||
onOpenItem: (item: SongItem, tick: number) => void = () => {};
|
||||
onDiscardTake: () => void = () => {};
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
@@ -722,8 +722,8 @@ export class SongTimeline extends Widget {
|
||||
this.gesture = { kind: 'empty', start: g, current: g, active: false, add: e.shiftKey };
|
||||
return;
|
||||
}
|
||||
if (pressCount(e, `song-item:${anchor.id}`) === 2 && anchor.kind === 'pattern') {
|
||||
this.onOpenPattern(anchor, xToTick(this.view, g.x));
|
||||
if (pressCount(e, `song-item:${anchor.id}`) === 2) {
|
||||
this.onOpenItem(anchor, xToTick(this.view, g.x));
|
||||
return;
|
||||
}
|
||||
const picked = this.pickedIds;
|
||||
|
||||
@@ -17,6 +17,9 @@ import { Button, Dropdown, Label, Menu, NumberField } from '../../ui';
|
||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||
import { playedBus } from '../../core/music/mixer';
|
||||
import { DRY_VOICE } from '../../core/soundfx/nodes';
|
||||
import { fxVoiceOf } from '../../core/music/needs';
|
||||
import { DEFAULT_TEMPO } from '../../core/music/time';
|
||||
import { assetFxOf } from '../../core/properties/assetFx';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { claimTransport, releaseTransport } from '../music/transport';
|
||||
import { HEADER_ICON_SVG } from '../panels/headerIcons';
|
||||
@@ -151,10 +154,11 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
|
||||
this.stop();
|
||||
claimTransport(this.halt);
|
||||
const props = resolveAssetProps(this.state.project, asset);
|
||||
// Through its bus and the master, as the game hears it (#325).
|
||||
// Through its sound FX (#352), its bus and the master, as the game hears it (#325).
|
||||
const bus = playedBus(this.state.project, '', props);
|
||||
const fx = fxVoiceOf(this.state.project, assetFxOf(props), DEFAULT_TEMPO) ?? DRY_VOICE;
|
||||
const file = playedSoundOf(this.state.project, asset);
|
||||
this.voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, DRY_VOICE, bus);
|
||||
this.voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus);
|
||||
this.playingId = this.voice ? asset.id : '';
|
||||
this.syncPlayButton();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user