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>
444 lines
17 KiB
TypeScript
444 lines
17 KiB
TypeScript
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;
|
||
}
|