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 } from '../soundfx/compile'; import { type FxAmount, type FxEnvelopePlan, type FxModPlan, type FxPlan, type FxStagePlan, held, isSoundFxNodeDef, SOUND_FX_INPUT, SOUND_FX_OUTPUT, SoundFxMix, type SoundFxNodeDef, withPitch, withStages, } from '../soundfx/nodes'; import { ENVELOPE_ROWS, 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).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 ? graphFingerprint(fx.graph) : ''; }, }; } 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; /** 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 walk = graphEmitter(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, node: GraphNode): Record { const plansAt = (key: string) => { const value = inputs[key]; return value && 'plans' in value ? value.plans : []; }; const amounts: Record = {}; 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_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)] } }; } const [arriving] = plansAt('in'); return { out: arriving ? { plans: [def.emit({ in: arriving }, amounts, node.params)] } : 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', }, }; } 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>): 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 { const { from, to } = notesRange(node.params); const inside = note >= from && note <= to; const outputs: Record = { 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>): FxPlan { const bind = (amount: FxAmount): FxAmount => { 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 }; }; const stages = fx.stages.map((stage) => bindStage(stage, bind)); if ('mix' in fx) return { ...fx, mix: bind(fx.mix), a: throughFx(fx.a, sound, given), b: throughFx(fx.b, sound, given), stages, }; const pitched = fx.pitch.reduce((plan, semitones) => withPitch(plan, bind(semitones)), sound); return withStages(pitched, stages); } function bindStage(stage: FxStagePlan, bind: (amount: FxAmount) => FxAmount): FxStagePlan { if (stage.kind === 'gain') return { ...stage, db: bind(stage.db) }; if (stage.kind === 'filter') return { ...stage, gain: bind(stage.gain) }; if (stage.kind === 'pass') return { ...stage, cutoff: bind(stage.cutoff), resonance: bind(stage.resonance) }; if (stage.kind === 'envelope') return stage; if (stage.kind === 'crush') return { ...stage, bits: bind(stage.bits), rate: bind(stage.rate), mix: bind(stage.mix) }; return { ...stage, time: bind(stage.time), feedback: bind(stage.feedback), damping: bind(stage.damping), mix: bind(stage.mix), }; } const builds = new Map }>(); /** * 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 .map((node) => textOf(node.params[node.type === INSTRUMENT_SOUND_FX ? 'fx' : 'sound'] ?? '')) .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 { const notes = new Set(); 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 { const names = new Map(); 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; }