Files
feuerwerk/src/core/instrument/compile.ts
T
adminandClaude Opus 5.5 19902a9f02
feuerwerk / gate (push) Successful in 4m47s
feuerwerk / suite (push) Successful in 19m5s
Keep groups, comments and silent music in view for the agent (#429)
A live test of the music areas built a pattern, a song and a
composition that stayed silent: no instrument at first, and later
notes outside the C4 to B4 range of a new Notes node. The agent got
no hint of either. The same test showed that new nodes left groups
behind, that a comment could end up under other nodes, and that an
end node like Output stayed left of the devices put in front of it.

- get_asset lists under silent what of a pattern, song or
  composition is not heard. That is a missing instrument, an
  instrument that sounds no note, or notes outside what its Notes
  nodes take in, in a song after the transpose.
- soundingNotes moves from the pattern tab to
  src/core/instrument/compile.ts. The tab and the agent use the
  same rule.
- When new nodes go between two that have no room, everything from
  the fed node on moves right together. This holds for Arrange with
  a selection too, so no link runs backwards.
- A new node linked to a node in a group joins that group, and the
  group grows in the same undo step. A group that would take in a
  foreign node stays as it is.
- Arrange takes groups and comments along: a group whose nodes all
  moved the same keeps its size, otherwise it is fitted anew. The
  rules live in the new src/editor/graph/annotationFollow.ts.
- add_comment and fit_group put a comment below its nodes when a
  node or a group title is in the way above. lint_graph counts
  covered comments as coveredNotes.
- add_notes, get_asset, lint_graph and the relay instructions name
  the default range and how to mend a covered comment.

Tests:
- graph-follow (new, 12), agent-edit, agent-music (11) and the
  other agent, graph, pattern and relay tests green
- 10 deliberate breakages, each one red in its own test
- e2e 71: 25/25
- Gate: CHECK EXIT 0, 324 files, 5002 tests

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

502 lines
20 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 { findSound } from '../model/sound';
import { playedSoundOf } from '../properties/playedSound';
import { resolveAssetProps } from '../properties/resolve';
import { volumeOf } from '../properties/sounds';
import { soundFxBuildFor, soundFxKey } from '../soundfx/compile';
import {
type FxAcidPlan,
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,
tapId,
withPitch,
withStages,
withTaps,
} from '../soundfx/nodes';
import {
ACID_ROWS,
acidWave,
CHIP_ROWS,
chipWave,
ENVELOPE_ROWS,
INSTRUMENT_ACID,
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 ref = findSound(project, assetId);
if (!ref) return null;
// An asset without a file stays without one, so the Sample can say so.
return { file: playedSoundOf(ref), volume: volumeOf(resolveAssetProps(project, ref.asset)) };
},
soundFx: (assetId) => {
const asset = findAssetOf(project, assetId, 'soundfx');
return asset ? soundFxBuildFor(asset, project).compiled : null;
},
key: (assetId) => {
const sound = findSound(project, assetId);
if (sound) return `${playedSoundOf(sound)}@${volumeOf(resolveAssetProps(project, sound.asset))}`;
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. `assetId` is
* the instrument, which the taps of a node are named by (#374).
*/
export function compileInstrument(
graph: Graph,
note: number,
sources: InstrumentSources,
assetId = '',
): 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_ACID: {
const trigger = inputs.trigger;
if (!trigger || !('on' in trigger) || !trigger.on) return { out: SILENT };
return { out: { plans: [{ pitch: [], stages: [], acid: acidOf(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');
if (!arriving) return { out: SILENT };
const plan = def.emit({ in: arriving }, amounts, node.params, project);
return { out: { plans: [def.tapped ? withTaps(arriving, plan, tapId(assetId, node.id)) : plan] } };
}
/** 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),
};
}
/** An Acid's numbers, held to its rows (#421): it always plays the note, as loud as its velocity. */
function acidOf(node: GraphNode, played: number, amounts: Readonly<Record<string, FxAmount>>): FxAcidPlan {
const rows = ACID_ROWS;
return {
id: node.id,
wave: acidWave(node.params),
frequency: { value: noteFrequency(played) },
velocity: { value: 1, param: VELOCITY_PARAM, min: 0, max: 1 },
cutoff: held(amounts.cutoff, rows.cutoff),
resonance: held(amounts.resonance, rows.resonance),
envMod: held(amounts.envMod, rows.envMod),
attack: held(amounts.attack, rows.attack),
decay: held(amounts.decay, rows.decay),
sustain: held(amounts.sustain, rows.sustain),
release: held(amounts.release, rows.release),
glide: held(amounts.glide, rows.glide),
};
}
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, boundAmount(semitones, bind)), 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;
if (stage.kind === 'wet')
return {
...stage,
wet: boundAmount(stage.wet, bind),
stages: stage.stages.map((inner) => bindStage(inner, bind)),
};
const bound: Record<string, unknown> = {};
for (const [key, value] of Object.entries(stage))
bound[key] = isAmount(value) ? boundAmount(value, bind) : value;
return bound as FxStagePlan;
}
/** An amount bound, and the share it is taken by (#354). */
function boundAmount(amount: FxAmount, bind: (amount: FxAmount) => FxAmount): FxAmount {
const done = bind(amount);
return amount.times ? { ...done, times: boundAmount(amount.times, bind) } : done;
}
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, asset.id);
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;
}
/** Which notes are heard: inside a Notes node's range, and where they lead to something that sounds. */
export function soundingNotes(asset: InstrumentAsset, sources: InstrumentSources): Set<number> {
return new Set(
[...playedNotes(asset.graph)].filter(
(note) => instrumentBuildFor(asset, note, sources).layers.length > 0,
),
);
}
/** 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;
}