Round 12 of the clean-up. Duplicate code had grown from 33 clones (249 lines) after round 11 to 140 clones (1263 lines), almost all of it in the panels and faces of the sound FX devices (#379 to #401). It is down to 57 clones (416 lines) now, and src is 816 lines shorter. - deviceParts.ts holds what the device panels built by hand: the head, rows of knobs, the screen style and the row layout for groups of knobs. Twelve panels use it. panel.knobs stays because the debug hooks read it. - DeviceFace is the base of the twelve faces. It finds the node and does wired, preview, commit, set, hide, reload and audition. compressorWired lives there as paramWired. - TextPool replaces ten copies of "make texts as needed, hide the rest" in the wave and video tabs, the rulers, the piano roll and the song and composition timelines. clipStrip.ts is the clip label strip the wave and video tabs share. - core/util/bytes.ts has the concat that ogg, matroska and avcColour each wrote out. - Dead code is gone: clear on the video players (never called since #402), posterFileOf and videoTimeAt (only tests called them) and easeOut (the same as glide(t, 3)). Values only used in their own file lose their export. - Comments over 125 columns are wrapped, and comments that told the history ("as before", "used to") say what is, in src and in the e2e modules. - Left alone on purpose: AnimationEditor and CompositionEditor share only the play button, and the curve panels are built differently. - Chip now places its boxes through put, which rounds. They were whole numbers already. The three row layouts are byte-identical before and after. - README lists the video and composition assets and the Videos and Compositions pages. Tests: - Gate (melektaus-atx): CHECK EXIT 0, 313 files, 4835 tests. - e2e 25, 34, 64, 65, 66, 69 (melektaus-atx): ALL PASSED, 330 checks. - TextPool without keeping its texts and framesToRoom without making room both go red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
446 lines
20 KiB
TypeScript
446 lines
20 KiB
TypeScript
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<string, FlatDef<'number'>>;
|
||
|
||
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<Exclude<keyof FxChipPlan, 'wave'>, 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<string, FlatDef<'number'>>;
|
||
|
||
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' } },
|
||
],
|
||
};
|
||
}
|