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

+3
View File
@@ -73,6 +73,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
playbacks: () => soundLibrary.playbacks(),
/** Whether the audio clock is held by the pause of the toolbar or runs. */
clockState: () => soundLibrary.clockState(),
/** The second of the audio clock a voice started at, and the rate that clock runs at (#346). */
startedAt: (voice: number) => soundLibrary.startedAt(voice),
sampleRate: () => soundLibrary.liveContext().sampleRate,
/**
* The nodes a stage of a voice becomes, in any context – an offline one renders what it does.
* `at` is when its voice starts, which a Flanger's sweep counts from (#341).
+17 -2
View File
@@ -16,6 +16,14 @@ import { createFieldRow } from './fieldFactory';
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
const FX_ROW: PropertyDef = {
key: 'fx',
label: 'Sound FX',
kind: 'asset',
default: '',
assetTypes: ['soundfx'],
};
const TRACK_ROWS: readonly PropertyDef[] = [
{
key: 'volume',
@@ -29,6 +37,7 @@ const TRACK_ROWS: readonly PropertyDef[] = [
{ key: 'pan', label: 'Pan', kind: 'number', default: 0, min: -1, max: 1, step: 0.05 },
{ key: 'mute', label: 'Mute', kind: 'boolean', default: false },
{ key: 'solo', label: 'Solo', kind: 'boolean', default: false },
FX_ROW,
];
/** The rows of an item: what it plays, how loud, and per kind how. */
@@ -65,7 +74,7 @@ function itemRows(item: SongItem): PropertyDef[] {
},
{ key: 'instrument', label: 'Instrument', kind: 'asset', default: '', assetTypes: ['instrument'] },
);
else rows.push({ key: 'fx', label: 'Sound FX', kind: 'asset', default: '', assetTypes: ['soundfx'] });
rows.push(FX_ROW);
return rows;
}
@@ -134,6 +143,7 @@ function buildTrack(host: InspectorHost, asset: SongAsset, track: SongTrack): vo
});
const count = asset.items.filter((i) => i.track === track.id).length;
addNote(host, `Track of the song "${asset.name}", ${count} ${count === 1 ? 'item' : 'items'}.`);
addNote(host, 'Its Sound FX takes everything on it together, before its volume and pan.', 'small');
for (const def of TRACK_ROWS) {
const handlers = previewingHandlers<SongTrack, SongTrack[keyof SongTrack]>(host, {
target: () => findAssetOf(state.project, asset.id, 'song')?.tracks.find((t) => t.id === track.id),
@@ -175,7 +185,12 @@ 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.', 'small');
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',
);
}
function itemOf(asset: SongAsset | undefined, itemIds: readonly string[]): SongItem | undefined {
+5 -5
View File
@@ -5,12 +5,12 @@ import {
newItem,
SONG_GRID_KEY,
SONG_GRID_OPTIONS,
songBuses,
songLoop,
songMeter,
songSequence,
songTempo,
} from '../../core/music/song';
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';
@@ -104,7 +104,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
};
timeline.onTracksPreview = (tracks) => {
const asset = this.asset();
if (asset && this.isPlaying) this.music.setBuses(songBuses({ id: asset.id, tracks }));
if (asset && this.isPlaying) this.music.setBuses(songBuses(this.state.project, { ...asset, tracks }));
};
timeline.onOpenPattern = (patternId) => activateAsset(state, patternId);
timeline.onDiscardTake = () => this.stop({ discard: true });
@@ -148,7 +148,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
claimTransport(this.halt);
this.playingId = asset.id;
this.told = this.settingsOf(asset);
this.music.setBuses(songBuses(asset));
this.music.setBuses(songBuses(this.state.project, asset));
void this.music.play(songSequence(this.state.project, asset), {
tempo: songTempo(asset.props),
meter: songMeter(asset.props),
@@ -218,7 +218,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
this.showTake(take, false);
this.syncPlayButton();
ui().focus.focus(this.canvas);
this.music.setBuses(songBuses(asset));
this.music.setBuses(songBuses(this.state.project, asset));
await this.music.play(songSequence(this.state.project, asset), {
tempo,
meter: songMeter(asset.props),
@@ -413,7 +413,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
return;
}
this.music.edit(songSequence(this.state.project, asset));
this.music.setBuses(songBuses(asset));
this.music.setBuses(songBuses(this.state.project, asset));
const settings = this.settingsOf(asset);
if (settings === this.told) return;
this.told = settings;
+27 -3
View File
@@ -113,9 +113,10 @@ const TAKE_COLOR = 0xb33a3a;
/** The id the track of a take being recorded is drawn under, until it is made. */
const TAKE_TRACK = '__take';
const SOLO_COLOR = 0xc9c23a;
const FX_COLOR = 0x8fb8de;
/** Height of an item's name strip; what it plays is drawn below. */
const LABEL_STRIP = 15;
/** The letter on a lit mute or solo button. */
/** The letter on a lit mute, solo or FX button. */
const DARK_LABEL = textStyle('small').clone();
DARK_LABEL.fill = 0x30343c;
@@ -673,7 +674,8 @@ export class SongTimeline extends Widget {
}
if (e.button !== 0) return;
const part = headPartAt(x, y - trackTop(this.view, row), this.view.trackHeight);
if (part === 'mute' || part === 'solo') {
if (part === 'fx') this.openTrackFxMenu(asset, track, e.global);
else if (part === 'mute' || part === 'solo') {
const label =
part === 'mute'
? track.mute
@@ -1014,6 +1016,24 @@ export class SongTimeline extends Widget {
new Menu([{ label: 'Delete', onSelect: () => this.deleteSelection() }]).open(global.x, global.y);
}
/** The sound FX a track goes through (#346): none, or one of the project's, by name. */
private openTrackFxMenu(asset: SongAsset, track: SongTrack, global: Vec2): void {
const set = (fx: string) =>
this.commit(
asset,
{ tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, fx } : t)) },
'Set track sound FX',
);
const effects = this.state.project.assets
.filter((a) => a.type === 'soundfx')
.sort((a, b) => a.name.localeCompare(b.name));
new Menu([
{ label: 'No sound FX', current: track.fx === '', onSelect: () => set('') },
...(effects.length > 0 ? [{ separator: true as const }] : []),
...effects.map((fx) => ({ label: fx.name, current: fx.id === track.fx, onSelect: () => set(fx.id) })),
]).open(global.x, global.y);
}
private openTrackMenu(asset: SongAsset, row: number, global: Vec2): void {
const track = asset.tracks[row]!;
const swap = (other: number) => {
@@ -1111,6 +1131,7 @@ export class SongTimeline extends Widget {
pan: 0,
mute: false,
solo: false,
fx: '',
});
return shown;
}
@@ -1441,8 +1462,11 @@ export class SongTimeline extends Widget {
this.headsG.rect(0, y, HEAD_WIDTH - 1, th).fill(lit ? { color: c.accent, alpha: 0.18 } : c.panel);
if (i === 0) this.headsG.rect(0, y, HEAD_WIDTH - 1, 1).fill(c.border);
this.headsG.rect(0, y + th - 1, HEAD_WIDTH - 1, 1).fill(c.border);
const { name, mute, solo, volume, pan } = HEAD_BOXES;
const { name, fx, mute, solo, volume, pan } = HEAD_BOXES;
labels.push({ text: track.name, x: name.x, y: y + name.y, w: name.w });
const hasFx = track.fx !== '';
this.toggle(fx, y, hasFx, FX_COLOR);
labels.push({ text: 'FX', x: fx.x, y: y + fx.y, w: fx.w, h: fx.h, dark: hasFx });
this.toggle(mute, y, track.mute, MUTE_COLOR);
this.toggle(solo, y, track.solo, SOLO_COLOR);
labels.push(
+5 -4
View File
@@ -157,11 +157,12 @@ export function indexOf(tracks: readonly SongTrack[]): Map<string, number> {
return new Map(tracks.map((t, i) => [t.id, i]));
}
/** The parts of a track's head, in its own pixels: name, mute, solo, volume and pan. */
export type HeadPart = 'name' | 'mute' | 'solo' | 'volume' | 'pan';
/** The parts of a track's head, in its own pixels: name, sound FX (#346), mute, solo, volume and pan. */
export type HeadPart = 'name' | 'fx' | 'mute' | 'solo' | 'volume' | 'pan';
export const HEAD_BOXES: Readonly<Record<HeadPart, Box>> = {
name: { x: 8, y: 4, w: 112, h: 18 },
name: { x: 8, y: 4, w: 88, h: 18 },
fx: { x: 102, y: 5, w: 22, h: 16 },
mute: { x: 128, y: 5, w: 18, h: 16 },
solo: { x: 150, y: 5, w: 18, h: 16 },
volume: { x: 8, y: 30, w: 100, h: 12 },
@@ -170,7 +171,7 @@ export const HEAD_BOXES: Readonly<Record<HeadPart, Box>> = {
export function headPartAt(x: number, yInTrack: number, trackHeight = TRACK_HEIGHT): HeadPart {
const sliders = trackHeight >= SLIDERS_MIN_HEIGHT;
for (const part of ['mute', 'solo', 'volume', 'pan'] as const) {
for (const part of ['fx', 'mute', 'solo', 'volume', 'pan'] as const) {
if (!sliders && (part === 'volume' || part === 'pan')) continue;
const box = HEAD_BOXES[part];
if (x >= box.x - 2 && x <= box.x + box.w + 2 && yInTrack >= box.y - 3 && yInTrack <= box.y + box.h + 3)