Files
feuerwerk/src/core/instrument/compile.ts
T
adminandClaude Opus 5.5 d349251a01
game-maker / gate (push) Successful in 4m0s
game-maker / suite (push) Failing after 13m51s
Put a sound in a room, a hall or a cave with a Reverb node (#328)
A sound FX had no space around it. The Reverb node folds the sound
with an impulse response: one it makes itself, or a sound asset as the
answer of a real space. It works on a voice and on a bus.

- Type: Room, Hall, Cave and Plate have fixed numbers. Custom shows
  Decay (up to 10 s), Damping and Pre-delay. Sound shows the pick of a
  sound asset and Pre-delay. The rows are derived ports, the sound pick
  an idle param, so they only appear for their type.
- The made impulse response is seeded noise per side, 60 dB down at
  Decay, through a low-pass closing from 20 kHz toward 500 Hz as far
  as Damping says. It is made once per rate, decay and damping, the
  last 16 are kept.
- A sound as impulse response: the sound FX compilers now get the
  project, so a node can turn a sound asset into its file. The build
  key holds that file, so a cut or swapped sound builds again. The
  instrument compiler does the same for a Reverb inside an instrument.
- The library loads that file with the voice, a sequence or before a
  bus builds its chain. A missing file leaves the reverb silent.
- Mix blends dry and reverb and is the only number that moves while
  the sound plays, as the issue said. The voice rings for pre-delay
  plus decay, or as long as its sound.
- The open point from #325 about sends on project buses is #351, not
  part of this.

Tests:
- Unit reverb (new) 4, soundfx 57 (4 new), sound-library 104 (2 new),
  green.
- Counter-checks, each turns one test red: the impulse file not loaded
  with the voice, the tail without the length of the sound.
- e2e 34 (melektaus-atx): ALL PASSED, 81 checks (1 new). Chrome
  offline: silent during the pre-delay, 35.9 dB fall over 0.6 s of a
  1 s decay (36 expected), the dry half at 0.25 for mix 0.5. A sound
  with one sample at 0.2 s gives the impulse back 0.25 s later and
  nowhere else.
- Gate (melektaus-atx) over the end of this series #318 to #328:
  CHECK EXIT 0, 267 files, 4134 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:27:22 +02:00

444 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { graphEmitter } from '../graph/emitGraph';
import {
type DeclaredPort,
edgesInto,
edgesOutOf,
type Graph,
graphFingerprint,
type GraphNode,
} from '../graph/Graph';
import type { GraphLookup } from '../graph/lookup';
import { resolveDef, terminalNode } from '../graph/nested';
import { inlineValue, type NodeDef } from '../graph/NodeRegistry';
import { asNumber } from '../graph/values';
import type { InstrumentAsset } from '../model/Asset';
import { graphLookup } from '../model/graphLookup';
import { textOf } from '../model/Properties';
import { findAssetOf, type Project } from '../model/Project';
import { playedSoundOf } from '../properties/playedSound';
import { resolveAssetProps } from '../properties/resolve';
import { volumeOf } from '../properties/sounds';
import { soundFxBuildFor, soundFxKey } from '../soundfx/compile';
import {
type FxAmount,
type FxChipPlan,
type FxEnvelopePlan,
type FxModPlan,
type FxPlan,
type FxStagePlan,
held,
isSoundFxNodeDef,
SOUND_FX_INPUT,
SOUND_FX_OUTPUT,
SoundFxMix,
type SoundFxNodeDef,
type SoundFxProject,
SoundFxReverb,
withPitch,
withStages,
} from '../soundfx/nodes';
import {
CHIP_ROWS,
chipWave,
ENVELOPE_ROWS,
INSTRUMENT_CHIP,
INSTRUMENT_ENVELOPE,
INSTRUMENT_NOTES,
INSTRUMENT_OUTPUT,
INSTRUMENT_PARAMETERS,
INSTRUMENT_SAMPLE,
INSTRUMENT_SOUND_FX,
InstrumentOutput,
isInstrumentNodeDef,
loops,
noteNameKey,
noteOutputKey,
noteParam,
notesRange,
samplePlay,
singleNotes,
VELOCITY_PARAM,
} from './nodes';
import { MIDDLE_C, noteFrequency } from './notes';
/**
* What an instrument's graph reaches outside itself: the sounds its Samples play and the sound FX
* its Sound FX nodes send them through. `key` changes whenever one of them does, so a cached note is
* compiled again when the sound or the FX it was made of is edited.
*/
export interface InstrumentSources {
lookup: GraphLookup;
sound(assetId: string): { file: string; volume: number } | null;
soundFx(assetId: string): FxPlan | null;
key(assetId: string): string;
}
export function instrumentSourcesOf(project: Project): InstrumentSources {
return {
lookup: graphLookup(project),
sound: (assetId) => {
const asset = findAssetOf(project, assetId, 'sound');
if (!asset) return null;
// An asset without a file stays without one, so the Sample can say so.
const file = asset.file ? playedSoundOf(project, asset) : '';
return { file, volume: volumeOf(resolveAssetProps(project, asset)) };
},
soundFx: (assetId) => {
const asset = findAssetOf(project, assetId, 'soundfx');
return asset ? soundFxBuildFor(asset, project).compiled : null;
},
key: (assetId) => {
const sound = findAssetOf(project, assetId, 'sound');
if (sound) return `${playedSoundOf(project, sound)}@${volumeOf(resolveAssetProps(project, sound))}`;
const fx = findAssetOf(project, assetId, 'soundfx');
return fx ? soundFxKey(fx, project) : '';
},
};
}
export interface InstrumentBuild {
/** What the note is made of: the one sound at the Output, or none for silence. */
layers: readonly FxPlan[];
/** Per node id, what keeps the graph from compiling. */
problems: Record<string, string>;
/** One line for the tab: empty once compiled. */
reason: string;
}
/**
* What travels along an edge of an instrument: sounds (none where nothing plays for this note), a
* number, whether a trigger is on for this note, or the curve of an Envelope (#335).
*/
type Value = { plans: FxPlan[] } | { amount: FxAmount } | { on: boolean } | { curve: FxModPlan };
const SILENT: Value = { plans: [] };
type Def = SoundFxNodeDef | (NodeDef & { instrument: true });
function isInstrumentDef(def: NodeDef): def is Def {
if (isInstrumentNodeDef(def)) return true;
return isSoundFxNodeDef(def) && def.type !== SOUND_FX_INPUT && def.type !== SOUND_FX_OUTPUT;
}
/** The parameters the Parameters node declares, in their order, each with its default. */
export function instrumentParams(graph: Graph): readonly DeclaredPort[] {
return terminalNode(graph, INSTRUMENT_PARAMETERS)?.ports ?? [];
}
/**
* One note of an instrument (#331): the graph walked back from the Output, with the triggers that
* stand for this note on and every other one off. A source whose trigger is off is silence, and so
* is everything it alone feeds; a Mix with one silent side is its other side. `note` and `frequency`
* are numbers here, `velocity` and the parameters stay open for every voice to fill in.
*/
export function compileInstrument(graph: Graph, note: number, sources: InstrumentSources): InstrumentBuild {
const project: SoundFxProject = { soundFile: (assetId) => sources.sound(assetId)?.file ?? '' };
const walk = graphEmitter<Def, Value>(graph, {
isDef: isInstrumentDef,
noun: 'instrument',
resolve: (node, def) =>
def.derivePorts ? ((resolveDef(node, graph, sources.lookup) as Def | undefined) ?? def) : def,
emit: (def, inputs, node) => emitNode(def, inputs, node),
unfed: (node, port) => {
if (port.type === 'number') return { amount: { value: asNumber(inlineValue(node, port)) } };
// An Envelope that only moves numbers has no sound to shape.
if (node.type === INSTRUMENT_ENVELOPE && edgesOutOf(graph, node.id, 'out').length === 0) return SILENT;
walk.problem(
node,
port.type === 'trigger'
? 'Nothing triggers it: connect it to a Notes node'
: 'Nothing comes in: connect it to a Sample',
);
return null;
},
conform: (node, port, value) => {
if ('curve' in value) {
if (port.type === 'number' && port.followsEnvelope)
return { amount: { value: value.curve.from.value, mod: value.curve } };
walk.problem(node, `"${port.label}" cannot follow an Envelope`);
return null;
}
const fits =
port.type === 'audio'
? 'plans' in value
: port.type === 'trigger'
? 'on' in value
: 'amount' in value;
if (fits) return value;
walk.problem(
node,
`"${port.label}" takes ${port.type === 'audio' ? 'a sound' : port.type === 'trigger' ? 'a trigger' : 'a number'}`,
);
return null;
},
// Only a trigger takes more than one edge: either one turns it on.
gather: (_node, _port, values) => ({ on: values.some((v) => 'on' in v && v.on) }),
});
function emitNode(def: Def, inputs: Record<string, Value>, node: GraphNode): Record<string, Value> {
const plansAt = (key: string) => {
const value = inputs[key];
return value && 'plans' in value ? value.plans : [];
};
const amounts: Record<string, FxAmount> = {};
for (const [key, value] of Object.entries(inputs)) if ('amount' in value) amounts[key] = value.amount;
switch (node.type) {
case INSTRUMENT_NOTES:
return notesOutputs(node, note);
case INSTRUMENT_PARAMETERS:
return Object.fromEntries(
(node.ports ?? []).map((port) => [
port.key,
{ amount: { value: port.default ?? 0, param: port.key } },
]),
);
case INSTRUMENT_SAMPLE: {
const plan = sampleOf(node, note);
const trigger = inputs.trigger;
return { out: plan && trigger && 'on' in trigger && trigger.on ? { plans: [plan] } : SILENT };
}
case INSTRUMENT_CHIP: {
const trigger = inputs.trigger;
if (!trigger || !('on' in trigger) || !trigger.on) return { out: SILENT };
return { out: { plans: [{ pitch: [], stages: [], chip: chipOf(node, note, amounts) }] } };
}
case INSTRUMENT_SOUND_FX: {
const fx = sources.soundFx(textOf(node.params.fx ?? ''));
const arriving = plansAt('in');
return { out: { plans: fx ? arriving.map((plan) => throughFx(fx, plan, amounts)) : arriving } };
}
case INSTRUMENT_ENVELOPE: {
const envelope = envelopeOf(node, amounts);
const curve = {
envelope,
from: held(amounts.from, ENVELOPE_ROWS.from),
to: held(amounts.to, ENVELOPE_ROWS.to),
};
return {
out: { plans: plansAt('in').map((plan) => withStages(plan, [{ kind: 'envelope', envelope }])) },
value: { curve },
};
}
case INSTRUMENT_OUTPUT:
return { out: { plans: plansAt('in') } };
}
if (!isSoundFxNodeDef(def)) return {};
if (def.type === SoundFxMix.type) {
const [a] = plansAt('a');
const [b] = plansAt('b');
if (!a || !b) return { out: { plans: a ? [a] : b ? [b] : [] } };
return { out: { plans: [def.emit({ a, b }, amounts, node.params, project)] } };
}
const [arriving] = plansAt('in');
return {
out: arriving ? { plans: [def.emit({ in: arriving }, amounts, node.params, project)] } : SILENT,
};
}
/** A Sample's sound, or null after saying why it has none – also where its trigger is off. */
function sampleOf(node: GraphNode, played: number): FxPlan | null {
const id = textOf(node.params.sound ?? '');
const sound = id ? sources.sound(id) : null;
if (!id) walk.problem(node, 'No sound picked');
else if (!sound) walk.problem(node, 'That sound is gone');
else if (!sound.file) walk.problem(node, 'The sound has no file');
if (!sound?.file) return null;
const follows = node.params.followNote !== false;
const shift = follows ? played - noteParam(node.params, 'root', MIDDLE_C) : 0;
const play = samplePlay(node.params);
const loop = loops(play);
return {
pitch: shift === 0 ? [] : [{ value: shift }],
stages: [],
source: {
file: sound.file,
volume: sound.volume,
loop,
loopStart: loop ? Math.max(0, asNumber(node.params.loopStart ?? 0)) : 0,
loopEnd: loop ? Math.max(0, asNumber(node.params.loopEnd ?? 0)) : 0,
gate: play === 'gate' || play === 'loop',
},
};
}
/**
* A Chip's numbers, held to its rows. Its frequency is the note's where it follows the note and
* nothing is connected – what it is connected to goes first, else the frequency typed in.
*/
function chipOf(node: GraphNode, played: number, amounts: Readonly<Record<string, FxAmount>>): FxChipPlan {
const rows = CHIP_ROWS;
const own =
edgesInto(graph, node.id, rows.frequency.key).length === 0 && node.params.followNote !== false;
return {
wave: chipWave(node.params),
frequency: own ? { value: noteFrequency(played) } : held(amounts.frequency, rows.frequency),
duty: held(amounts.duty, rows.duty),
dutySweep: held(amounts.dutySweep, rows.dutySweep),
slide: held(amounts.slide, rows.slide),
deltaSlide: held(amounts.deltaSlide, rows.deltaSlide),
minFrequency: held(amounts.minFrequency, rows.minFrequency),
vibratoDepth: held(amounts.vibratoDepth, rows.vibratoDepth),
vibratoSpeed: held(amounts.vibratoSpeed, rows.vibratoSpeed),
arpeggio: held(amounts.arpeggio, rows.arpeggio),
arpeggioTime: held(amounts.arpeggioTime, rows.arpeggioTime),
repeat: held(amounts.repeat, rows.repeat),
};
}
const output = graph.nodes.find((n) => n.type === INSTRUMENT_OUTPUT);
if (!output) return { layers: [], problems: walk.problems, reason: 'No Output node' };
// Saved while the Output still took any number of sounds: only the first would be heard.
if (edgesInto(graph, output.id, 'in').length > 1)
walk.problem(output, 'Takes one sound: put a Mix in front of it to play several');
const arrived = walk.input(output, InstrumentOutput.inputs[0]!);
const failed = walk.firstProblem();
if (failed !== null || !arrived || !('plans' in arrived))
return { layers: [], problems: walk.problems, reason: failed ?? 'Cannot compile' };
return { layers: arrived.plans, problems: walk.problems, reason: '' };
}
/** An Envelope's times and level, held to its rows; one fed by a parameter is filled in as its note starts. */
function envelopeOf(node: GraphNode, amounts: Readonly<Record<string, FxAmount>>): FxEnvelopePlan {
const rows = ENVELOPE_ROWS;
return {
id: node.id,
attack: held(amounts.attack, rows.attack),
decay: held(amounts.decay, rows.decay),
sustain: held(amounts.sustain, rows.sustain),
release: held(amounts.release, rows.release),
};
}
/** The outputs of a Notes node for one note: its triggers, and the note as numbers. */
function notesOutputs(node: GraphNode, note: number): Record<string, Value> {
const { from, to } = notesRange(node.params);
const inside = note >= from && note <= to;
const outputs: Record<string, Value> = {
any: { on: inside },
note: { amount: { value: note } },
frequency: { amount: { value: noteFrequency(note) } },
velocity: { amount: { value: 1, param: VELOCITY_PARAM, min: 0, max: 1 } },
};
if (singleNotes(node.params))
for (let at = from; at <= to; at++) outputs[noteOutputKey(at)] = { on: at === note };
return outputs;
}
/**
* A sound sent through a sound FX: the plan of the FX with the sound set in where its Input was, and
* each of its parameters bound to what arrives at the input of that name – held to the FX's row, as
* a value handed to the FX itself would be. One left open keeps its default.
*/
function throughFx(fx: FxPlan, sound: FxPlan, given: Readonly<Record<string, FxAmount>>): FxPlan {
return throughFxWith(fx, sound, (amount) => {
if (amount.param === undefined) return amount;
const fed = given[amount.param];
if (!fed) return amount;
const value = Math.max(amount.min ?? -Infinity, Math.min(amount.max ?? Infinity, fed.value));
return fed.param === undefined
? { value }
: { value, param: fed.param, min: amount.min, max: amount.max };
});
}
/**
* A note sent through a sound FX of its own (#346), as a `Play note` with `fx` does: the parameters of
* the FX go by `scope` and their name, so they cannot meet the instrument's of the same name.
*/
export function throughScopedFx(fx: FxPlan, sound: FxPlan, scope: string): FxPlan {
return throughFxWith(fx, sound, (amount) =>
amount.param === undefined ? amount : { ...amount, param: `${scope}${amount.param}` },
);
}
function throughFxWith(fx: FxPlan, sound: FxPlan, bind: (amount: FxAmount) => FxAmount): FxPlan {
const stages = fx.stages.map((stage) => bindStage(stage, bind));
if ('mix' in fx)
return {
...fx,
mix: bind(fx.mix),
a: throughFxWith(fx.a, sound, bind),
b: throughFxWith(fx.b, sound, bind),
stages,
};
const pitched = fx.pitch.reduce((plan, semitones) => withPitch(plan, bind(semitones)), sound);
return withStages(pitched, stages);
}
/** Every number of a stage, bound; an Envelope's own numbers are the instrument's and stay. */
function bindStage(stage: FxStagePlan, bind: (amount: FxAmount) => FxAmount): FxStagePlan {
if (stage.kind === 'envelope') return stage;
const bound: Record<string, unknown> = {};
for (const [key, value] of Object.entries(stage)) bound[key] = isAmount(value) ? bind(value) : value;
return bound as FxStagePlan;
}
const isAmount = (value: unknown): value is FxAmount =>
typeof value === 'object' && value !== null && typeof (value as FxAmount).value === 'number';
const builds = new Map<string, { fingerprint: string; notes: Map<number, InstrumentBuild> }>();
/**
* One note of the asset, compiled again only when what it is made of has changed: the graph, or a
* sound or sound FX it names. A note is played far more often than its instrument is edited.
*/
export function instrumentBuildFor(
asset: InstrumentAsset,
note: number,
sources: InstrumentSources,
): InstrumentBuild {
const named = asset.graph.nodes
.flatMap((node) =>
node.type === INSTRUMENT_SOUND_FX
? [node.params.fx]
: [node.params.sound, ...(node.type === SoundFxReverb.type ? [node.params.impulse] : [])],
)
.map((id) => textOf(id ?? ''))
.filter((id) => id !== '');
const fingerprint = `${graphFingerprint(asset.graph)}|${named.map((id) => sources.key(id)).join('|')}`;
let cached = builds.get(asset.id);
if (!cached || cached.fingerprint !== fingerprint) {
cached = { fingerprint, notes: new Map() };
builds.set(asset.id, cached);
}
let build = cached.notes.get(note);
if (!build) {
build = compileInstrument(asset.graph, note, sources);
cached.notes.set(note, build);
}
return build;
}
/**
* The instrument's problems, the same for every note: the walk from the Output reaches every node
* whatever is played, only the triggers differ.
*/
export function instrumentCheck(asset: InstrumentAsset, sources: InstrumentSources): InstrumentBuild {
return instrumentBuildFor(asset, MIDDLE_C, sources);
}
/** Which notes of the keyboard make a sound at all: those inside a Notes node's range. */
export function playedNotes(graph: Graph): Set<number> {
const notes = new Set<number>();
for (const node of graph.nodes) {
if (node.type !== INSTRUMENT_NOTES) continue;
const { from, to } = notesRange(node.params);
for (let note = from; note <= to; note++) notes.add(note);
}
return notes;
}
/** The notes a Notes node with single notes has given a name of their own (a drum kit's). */
export function namedNotes(graph: Graph): Map<number, string> {
const names = new Map<number, string>();
for (const node of graph.nodes) {
if (node.type !== INSTRUMENT_NOTES || !singleNotes(node.params)) continue;
const { from, to } = notesRange(node.params);
for (let note = from; note <= to; note++) {
const name = textOf(node.params[noteNameKey(note)] ?? '').trim();
if (name && !names.has(note)) names.set(note, name);
}
}
return names;
}