import type { DeclaredPort, Graph } from '../graph/Graph'; import { newId } from '../ids'; import { INSTRUMENT_NOTES, INSTRUMENT_OUTPUT, INSTRUMENT_PARAMETERS, INSTRUMENT_SAMPLE, INSTRUMENT_SOUND_FX, } from '../graph/nested'; import type { NodeDef, PortDef } from '../graph/NodeRegistry'; import { asNumber } from '../graph/values'; import { type PropertyBag, textOf } from '../model/Properties'; import type { FlatDef, PropertyDef } from '../properties/PropertySchema'; import type { AcidWave } from '../soundfx/acid'; import type { ChipWave } from '../soundfx/chip'; import { type FxChipPlan, soundFxParamPort } from '../soundfx/nodes'; import { HIGHEST_NOTE, heldNote, LOWEST_NOTE, MIDDLE_C, NOTE_OPTIONS, noteName } from './notes'; export { INSTRUMENT_NOTES, INSTRUMENT_OUTPUT, INSTRUMENT_PARAMETERS, INSTRUMENT_SAMPLE, INSTRUMENT_SOUND_FX }; /** * A node only an instrument's graph has (#331). Like a sound FX node it never runs: the instrument's * compiler (`compile.ts`) reads what it stands for, once per note. */ export interface InstrumentNodeDef extends NodeDef { category: 'sound'; instrument: true; } export function isInstrumentNodeDef(def: NodeDef): def is InstrumentNodeDef { return def.category === 'sound' && 'instrument' in def; } /** * The value of an instrument's `velocity` while its voice is open: not a name a parameter can have, * so it never meets one, and a `Set sound FX parameters` cannot reach it by accident. */ export const VELOCITY_PARAM = '@velocity'; const triggerIn: PortDef = { key: 'trigger', label: 'trigger', type: 'trigger', many: true }; const audioIn: PortDef = { key: 'in', label: 'in', type: 'audio' }; const audioOut: PortDef = { key: 'out', label: 'out', type: 'audio' }; function noteRow(key: string, label: string, note: number): PropertyDef { return { key, label, kind: 'enum', default: String(note), options: NOTE_OPTIONS }; } /** A note setting as a number, held to the keyboard. */ export function noteParam(params: PropertyBag, key: string, fallback: number): number { const text = textOf(params[key] ?? ''); return text === '' ? fallback : heldNote(Number(text)); } const FROM = noteRow('from', 'From', MIDDLE_C); const TO = noteRow('to', 'To', MIDDLE_C + 11); const SINGLE = { key: 'singleNotes', label: 'Single notes', kind: 'boolean' as const, default: false }; /** Where a note's own name is kept: `name60` for C4. */ export const noteNameKey = (note: number) => `name${note}`; /** The output a single note leaves through: `n60` for C4, whatever it is called. */ export const noteOutputKey = (note: number) => `n${note}`; const NAME_ROWS: PropertyDef[] = Array.from({ length: HIGHEST_NOTE - LOWEST_NOTE + 1 }, (_, i) => ({ key: noteNameKey(LOWEST_NOTE + i), label: `Name of ${noteName(LOWEST_NOTE + i)}`, kind: 'text', default: '', })); const NAME_KEYS = NAME_ROWS.map((row) => row.key); /** The lowest and the highest note of a Notes node, whichever way round they were picked. */ export function notesRange(params: PropertyBag): { from: number; to: number } { const a = noteParam(params, FROM.key, MIDDLE_C); const b = noteParam(params, TO.key, MIDDLE_C + 11); return { from: Math.min(a, b), to: Math.max(a, b) }; } export function singleNotes(params: PropertyBag): boolean { return params[SINGLE.key] === true; } /** What a note is called on its output and in a piano roll: its own name, else its note name. */ function noteLabel(params: PropertyBag, note: number): string { return textOf(params[noteNameKey(note)] ?? '').trim() || noteName(note); } export const InstrumentNotes: InstrumentNodeDef = { type: INSTRUMENT_NOTES, label: 'Notes', category: 'sound', instrument: true, description: 'Where the notes come in. "any" triggers what it is connected to for every note from "from" to "to"; "note" is the number of the note played (60 is C4), "frequency" its pitch in Hz and "velocity" how hard it is struck, 0 to 1. With "single notes" there is a trigger output for each note in the range as well, for a drum kit, each with a name of its own if you give it one. Several Notes nodes split the keyboard into zones; where two overlap, both play.', inputs: [], outputs: [ { key: 'any', label: 'any', type: 'trigger' }, { key: 'note', label: 'note', type: 'number' }, { key: 'frequency', label: 'frequency', type: 'number' }, { key: 'velocity', label: 'velocity', type: 'number' }, ], params: [FROM, TO, SINGLE, ...NAME_ROWS], // The node shows the names on its outputs already. hiddenParams: () => NAME_KEYS, idleParams: (params) => { if (!singleNotes(params)) return NAME_KEYS; const { from, to } = notesRange(params); return NAME_KEYS.filter((_, i) => LOWEST_NOTE + i < from || LOWEST_NOTE + i > to); }, derivePorts: ({ node }) => { if (!singleNotes(node.params)) return {}; const { from, to } = notesRange(node.params); const outputs: PortDef[] = []; for (let note = from; note <= to; note++) outputs.push({ key: noteOutputKey(note), label: noteLabel(node.params, note), type: 'trigger' }); return { outputs }; }, }; const SOUND: PropertyDef = { key: 'sound', label: 'Sound', kind: 'sound', default: '' }; const ROOT = noteRow('root', 'Root', MIDDLE_C); const FOLLOW = { key: 'followNote', label: 'Follow note', kind: 'boolean' as const, default: true }; /** How a Sample plays while its note is held and once it is let go. */ export type SamplePlay = 'gate' | 'oneshot' | 'loop' | 'release'; export const SAMPLE_PLAYS: { value: SamplePlay; label: string }[] = [ { value: 'gate', label: 'Gate' }, { value: 'oneshot', label: 'One-shot' }, { value: 'loop', label: 'Loop' }, { value: 'release', label: 'Loop, then release' }, ]; const PLAY: PropertyDef = { key: 'play', label: 'Play', kind: 'enum', default: 'gate', options: SAMPLE_PLAYS, }; /** * What a Sample plays as. Before "Play" it had "Mode" and "Loop": a looping one ended with the note * either way then, or ran until it was stopped, which is gone – both are "Loop" now. */ export function samplePlay(params: PropertyBag): SamplePlay { const play = SAMPLE_PLAYS.find((option) => option.value === params[PLAY.key]); if (play) return play.value; if (params.loop === true) return 'loop'; return params.mode === 'oneshot' ? 'oneshot' : 'gate'; } export function loops(play: SamplePlay): boolean { return play === 'loop' || play === 'release'; } /** A Sample as it was saved before "Play", with its "Mode" and "Loop" turned into one. */ export function withSamplePlay(params: PropertyBag): PropertyBag { const { mode: _mode, loop: _loop, ...rest } = params; return { ...rest, [PLAY.key]: samplePlay(params) }; } const LOOP_START: FlatDef<'number'> = { key: 'loopStart', label: 'Loop start (s)', kind: 'number', default: 0, min: 0, step: 0.01, }; const LOOP_END: FlatDef<'number'> = { key: 'loopEnd', label: 'Loop end (s)', kind: 'number', default: 0, min: 0, step: 0.01, }; /** Where a Sample loops, in seconds; its device (#400) types them in. */ export const SAMPLE_LOOP_ROWS = { loopStart: LOOP_START, loopEnd: LOOP_END }; export const InstrumentSample: InstrumentNodeDef = { type: INSTRUMENT_SAMPLE, label: 'Sample', category: 'sound', instrument: true, description: 'Plays a sound asset when a note triggers it. With "follow note" it is played higher or lower by the distance from "root" – the note it sounds as it is – and keeps its length. Off, every note plays it as it is, as for a drum. "Play" says what it does: "Gate" plays it once and ends it with the note, faded out in a few milliseconds; "One-shot" plays it once through whatever the note does. "Loop" repeats it between "loop start" and "loop end" in seconds (0 is the end of the sound) until the note ends; "Loop, then release" leaves the loop when the note ends and plays what comes after "loop end" – with "loop end" at 0 there is nothing after it, and the note ends with the loop. The volume of the sound asset counts.', inputs: [triggerIn], outputs: [audioOut], params: [SOUND, ROOT, FOLLOW, PLAY, LOOP_START, LOOP_END], idleParams: (params) => [ ...(params[FOLLOW.key] === false ? [ROOT.key] : []), ...(loops(samplePlay(params)) ? [] : [LOOP_START.key, LOOP_END.key]), ], }; export const InstrumentParameters: InstrumentNodeDef = { type: INSTRUMENT_PARAMETERS, label: 'Parameters', category: 'sound', instrument: true, description: 'The parameters of the instrument: every one declared here is a number output with a default. A Play note can hand in other values through an Instrument parameters node, and a Set sound FX parameters can change them on a note while it plays.', inputs: [], outputs: [], params: [], declaresPorts: 'outputs', portKinds: ['number'], declaredPort: (port: DeclaredPort) => soundFxParamPort(port), }; const FX: PropertyDef = { key: 'fx', label: 'Sound FX', kind: 'asset', default: '', assetTypes: ['soundfx'] }; export const InstrumentSoundFx: InstrumentNodeDef = { type: INSTRUMENT_SOUND_FX, label: 'Sound FX', category: 'sound', instrument: true, description: "Sends the sound through a sound FX asset, as if it were the sound at the FX's Input. Each parameter of the FX is an input here; one left open keeps the FX's default. A Mix in the FX plays the sound on both of its ways.", inputs: [audioIn], outputs: [audioOut], params: [FX], derivePorts: ({ node, lookup }) => { const fx = lookup.soundFx(textOf(node.params[FX.key] ?? '')); if (!fx) return {}; return { label: fx.name, inputs: fx.params.map((port) => ({ key: port.key, label: port.key, type: 'number' as const, default: port.default ?? 0, field: { key: port.key, label: port.key, kind: 'number' as const, default: port.default ?? 0 }, })), }; }, }; export const INSTRUMENT_ENVELOPE = 'InstrumentEnvelope'; function seconds(key: string, label: string, value: number): FlatDef<'number'> { return { key, label, kind: 'number', default: value, min: 0, max: 30, step: 0.001 }; } export const ENVELOPE_ROWS = { attack: seconds('attack', 'Attack (s)', 0.005), decay: seconds('decay', 'Decay (s)', 0.1), sustain: { key: 'sustain', label: 'Sustain', kind: 'number', default: 1, min: 0, max: 1, step: 0.01 }, release: seconds('release', 'Release (s)', 0.1), from: { key: 'from', label: 'From', kind: 'number', default: 0, step: 0.01 }, to: { key: 'to', label: 'To', kind: 'number', default: 1, step: 0.01 }, } satisfies Record>; const numberIn = (row: FlatDef<'number'>): PortDef => ({ key: row.key, label: row.label, type: 'number', field: row, }); export const InstrumentEnvelope: InstrumentNodeDef = { type: INSTRUMENT_ENVELOPE, label: 'Envelope', category: 'sound', instrument: true, description: 'Shapes a note as it is played: from silence up to full in "attack" seconds, down to the level "sustain" (0 to 1) in "decay", held there while the note is, and back to silence in "release" once it ends. The rise is a straight line, the falls ease out the way a struck string does. On the way of the sound it is the volume: a Gate or a Loop plays on through the release, and the note lasts until it has faded. A One-shot under it alone plays through as it would. With "sustain" at 0 the note ends once its decay is over, however long it is held, whatever plays under it. "value" moves a number with the same curve, from "from" at rest to "to" at the top: the cutoff or resonance of a Filter, a Pitch, a Gain, the share of a Mix, a band of the EQ. For a curve of its own, take a second Envelope and leave its "in" open. The numbers count from the moment the note starts.', inputs: [audioIn, ...Object.values(ENVELOPE_ROWS).map(numberIn)], outputs: [audioOut, { key: 'value', label: 'value', type: 'envelope' }], params: [], }; export const INSTRUMENT_CHIP = 'InstrumentChip'; const CHIP_WAVES: { value: ChipWave; label: string }[] = [ { value: 'square', label: 'Square' }, { value: 'triangle', label: 'Triangle' }, { value: 'saw', label: 'Saw' }, { value: 'sine', label: 'Sine' }, { value: 'noise', label: 'Noise' }, { value: 'periodic', label: 'Noise (periodic)' }, ]; const WAVE: PropertyDef = { key: 'wave', label: 'Wave', kind: 'enum', default: 'square', options: CHIP_WAVES, }; export function chipWave(params: PropertyBag): ChipWave { return CHIP_WAVES.find((option) => option.value === params[WAVE.key])?.value ?? 'square'; } function chipRow( key: string, label: string, min: number, max: number, step: number, value = 0, ): FlatDef<'number'> { return { key, label, kind: 'number', default: value, min, max, step }; } export const CHIP_ROWS = { frequency: chipRow('frequency', 'Frequency (Hz)', 1, 20000, 1, 440), duty: chipRow('duty', 'Duty', 0.01, 0.99, 0.01, 0.5), dutySweep: chipRow('dutySweep', 'Duty sweep (/s)', -10, 10, 0.01), slide: chipRow('slide', 'Slide (st/s)', -2000, 2000, 1), deltaSlide: chipRow('deltaSlide', 'Delta slide (st/s²)', -20000, 20000, 1), minFrequency: chipRow('minFrequency', 'Min frequency (Hz)', 0, 20000, 1), vibratoDepth: chipRow('vibratoDepth', 'Vibrato depth (st)', 0, 12, 0.1), vibratoSpeed: chipRow('vibratoSpeed', 'Vibrato speed (Hz)', 0, 70, 0.1, 6), arpeggio: chipRow('arpeggio', 'Arpeggio (st)', -48, 48, 1), arpeggioTime: chipRow('arpeggioTime', 'Arpeggio time (s)', 0, 10, 0.001, 0.1), repeat: chipRow('repeat', 'Repeat (s)', 0, 10, 0.001), } satisfies Record, FlatDef<'number'>>; /** The numbers an Envelope can move while a note plays: the rest is fixed as it starts. */ const CHIP_FOLLOWING = new Set(['frequency', 'duty']); export const InstrumentChip: InstrumentNodeDef = { type: INSTRUMENT_CHIP, label: 'Chip', category: 'sound', instrument: true, description: 'A raw wave as old game consoles made it, for every note that triggers it, the way sfxr makes its sounds. "Wave" is Square, Triangle, Saw, Sine, Noise, or Noise (periodic), the short metallic noise of the NES; a square and that noise are half as loud as the others, as in sfxr. With "follow note" it plays at the pitch of the note, else at "frequency" in Hz; a number connected to "frequency" goes before both. "duty" is how much of a square is up: 0.5 is even, 0.125 thin and nasal; "duty sweep" moves it by that much per second. "slide" glides the pitch in semitones per second, "delta slide" changes the slide by that much per second. Where it falls below "min frequency" the note ends; at 0 it never does. "vibrato depth" in semitones and "vibrato speed" in Hz make it wobble. "arpeggio" jumps it by that many semitones "arpeggio time" seconds into the note. "repeat" starts slide, duty sweep and arpeggio over every so many seconds, 0 never. The Chip plays until its note ends, faded out in a few milliseconds; under an Envelope it plays on through the release. An Envelope can move "frequency" and "duty" while it plays, a Pitch moves its frequency itself. Settings that do nothing as things stand are grey. In the graph the node is a device: it draws its wave over two periods, and what slide, vibrato, arpeggio and repeat make of the pitch over a note, from A4 when it follows the note.', inputs: [ triggerIn, ...Object.values(CHIP_ROWS).map((row): PortDef => CHIP_FOLLOWING.has(row.key) ? { ...numberIn(row), followsEnvelope: true } : numberIn(row), ), ], outputs: [audioOut], params: [WAVE, FOLLOW], idleParams: (params, fed = () => false) => { const off = (key: keyof typeof CHIP_ROWS) => !fed(key) && asNumber(params[key] ?? CHIP_ROWS[key].default) === 0; const square = chipWave(params) === 'square'; return [ ...(fed('frequency') ? [FOLLOW.key] : params[FOLLOW.key] === false ? [] : ['frequency']), ...(square ? [] : ['duty', 'dutySweep']), ...(off('vibratoDepth') ? ['vibratoSpeed'] : []), ...(off('arpeggio') ? ['arpeggioTime'] : []), ]; }, }; export const INSTRUMENT_ACID = 'InstrumentAcid'; export const ACID_WAVES: { value: AcidWave; label: string }[] = [ { value: 'saw', label: 'Saw' }, { value: 'square', label: 'Square' }, ]; const ACID_WAVE: PropertyDef = { key: 'wave', label: 'Wave', kind: 'enum', default: 'saw', options: ACID_WAVES, }; export function acidWave(params: PropertyBag): AcidWave { return ACID_WAVES.find((option) => option.value === params[ACID_WAVE.key])?.value ?? 'saw'; } /** The defaults are nodeseq's acid bass: Cutoff 30, Reso 78, Env 62 of its 100, its ADSR and its glide. */ export const ACID_ROWS = { cutoff: chipRow('cutoff', 'Cutoff (Hz)', 40, 18000, 1, 250), resonance: chipRow('resonance', 'Resonance', 0, 1, 0.01, 0.78), envMod: chipRow('envMod', 'Env mod', 0, 1, 0.01, 0.62), attack: chipRow('attack', 'Attack (s)', 0.001, 2, 0.001, 0.003), decay: chipRow('decay', 'Decay (s)', 0.02, 3, 0.01, 0.16), sustain: chipRow('sustain', 'Sustain', 0, 1, 0.01, 0.15), release: chipRow('release', 'Release (s)', 0.02, 4, 0.01, 0.08), glide: chipRow('glide', 'Glide (s)', 0, 0.5, 0.001, 0.05), } satisfies Record>; export const InstrumentAcid: InstrumentNodeDef = { type: INSTRUMENT_ACID, label: 'Acid', category: 'sound', instrument: true, description: 'An acid bass, one note at a time, as nodeseq.com plays it; its defaults are nodeseq\'s acid. "Wave" is a Saw or a Square at the pitch of the note, through a low-pass with a sharp resonance. "cutoff" is where the filter rests, "resonance" how sharply it rings there. One ADSR shapes the volume and the filter alike: in "attack" seconds the filter opens by up to five octaves with "env mod", in "decay" it falls back to where "sustain" holds it, and when the note ends it closes in "release". Every note strikes anew and glides from the pitch of the note before in "glide" seconds; a note that starts while the one before still sounds cuts it short. Notes that start together all play. The velocity makes it louder or softer. The Acid shapes its own volume and plays out its release, so it needs no Envelope behind it. An Envelope can move "cutoff", a Pitch moves its frequency. In the graph the node is a device: it draws the filter where it rests, at the top of its sweep and where the sustain holds it.', inputs: [ triggerIn, ...Object.values(ACID_ROWS).map((row): PortDef => row.key === ACID_ROWS.cutoff.key ? { ...numberIn(row), followsEnvelope: true } : numberIn(row), ), ], outputs: [audioOut], params: [ACID_WAVE], // At full sustain and without a sweep nothing falls: the decay has nothing to do. idleParams: (params, fed = () => false) => !fed('sustain') && !fed('envMod') && asNumber(params.sustain ?? ACID_ROWS.sustain.default) >= 1 && asNumber(params.envMod ?? ACID_ROWS.envMod.default) <= 0 ? [ACID_ROWS.decay.key] : [], }; export const InstrumentOutput: InstrumentNodeDef = { type: INSTRUMENT_OUTPUT, label: 'Output', category: 'sound', instrument: true, description: 'What is heard of a note. It takes one sound; to play several together, put a Mix in front of it.', inputs: [audioIn], outputs: [], params: [], }; export const INSTRUMENT_NODES: readonly InstrumentNodeDef[] = [ InstrumentNotes, InstrumentSample, InstrumentChip, InstrumentAcid, InstrumentParameters, InstrumentSoundFx, InstrumentEnvelope, InstrumentOutput, ]; /** * What a new instrument starts as: every note of an octave from C4 plays one Sample, which waits for * its sound to be picked. The Output sits past the Sample's device (#400), some 580 wide. */ export function starterInstrumentGraph(): Graph { const notes = newId(); const sample = newId(); const output = newId(); return { nodes: [ { id: notes, type: INSTRUMENT_NOTES, x: 0, y: 0, params: {} }, { id: sample, type: INSTRUMENT_SAMPLE, x: 220, y: 0, params: {} }, { id: output, type: INSTRUMENT_OUTPUT, x: 840, y: 0, params: {} }, ], edges: [ { id: newId(), from: { node: notes, port: 'any' }, to: { node: sample, port: 'trigger' } }, { id: newId(), from: { node: sample, port: 'out' }, to: { node: output, port: 'in' } }, ], }; }