Play sounds as notes of an instrument asset #331
game-maker / suite (push) Failing after 2m40s
game-maker / gate (push) Successful in 3m14s

A sound could only be played as it was, one at a time. An instrument
is a node graph that makes a note a sound: Notes nodes bring the note
in, Samples play sound assets for it, the effects of a sound FX shape
it and the Output sums the layers. Its tab plays it from a keyboard,
a behavior with Play note. It is what patterns and songs (#330) will
play.

- New asset type instrument, under the heading Music, not placeable.
  A new one starts as Notes into a Sample into the Output.
- Notes are counted as MIDI does: 60 is C4, names like C#4, sharps
  only, the lowest C-1. Notes has From and To, outputs any, note,
  frequency and velocity, and with Single notes a trigger per note,
  each with an optional name for a drum kit.
- New port type trigger, only into the trigger of a source. Inputs
  can take any number of edges now (PortDef.many): a trigger, and
  the Output, which plays everything into it side by side.
- Sample plays a sound asset. Follow note shifts it by note - root
  through the pitch worklet of #317, so it keeps its length. Gate
  fades it out at the end of the note, One-shot plays through. Loop
  runs between Loop start and Loop end, also in the worklet.
- Parameters declares the instrument's own parameters with a
  default. Sound FX sends the sound through a sound FX asset, its
  parameters as inputs, the sound set in where its Input was.
- compileInstrument walks the graph once per note. A trigger is on
  only for the notes it stands for, a silent source silences what it
  alone feeds, a Mix with a silent side is the other side. velocity
  and the parameters stay open for the voice. Builds are cached per
  note until the graph or a sound or FX it names changes.
- The player takes a note as one voice of several layers, each way
  with its own file and volume. release fades the gated ways out,
  one-shots play on. playNote can release after a duration.
- Play note plays one note with velocity, volume, duration and
  detached, and hands out voice for Set sound FX parameters and
  Stop sound. Velocity is always loudness. Instrument parameters
  picks values for the instrument's parameters like Sound FX
  parameters does for an FX, renames follow.
- The tab has the graph, a header field per parameter, Stop, and
  128 keys below. Keys without sound are grey. A key plays while it
  is held. Focused, the computer keyboard plays as in a tracker by
  KeyboardEvent.code, the arrows move the octave.

Findings:
- A label of the keys set fill on the shared small text style and
  darkened every small label of the editor. Caught in a screenshot
  before the commit, the keys have a style of their own now.
- Suite task 523 on cf6799a failed two checks of e2e 34 that timed
  audio by the page's clock: a 2.95 s echo tail was gone after 1 s,
  and Quad in was further along than 0.7 s allow. Both time on the
  audio clock now through a new hook __gm.sound.clock().

Tests:
- instrument.test.ts (new, 22): notes, asset, palette, round trip,
  compile per note, drum kit, zones, Mix, velocity and parameters,
  Sound FX node with rename, problems, cache, Play note, the keys
- sound-library +7 (layers, release, duration, early release, loop
  region in buffer and worklet), pitch-player +2 (loop region),
  node-catalog +4, graph adjusted. 582 tests in the touched files.
- e2e 4 and 34 on melektaus-atx: ALL PASSED, 34 with 44 checks (+7)
- Gate on melektaus-atx: CHECK EXIT 0, 243 test files, 3750 tests
- Counter-checks: any always on, release ending one-shots, Sound FX
  inputs ignored, loop region ignored. 3, 2, 1 and 1 tests failed.
- Browser probe on stein2: keys, held key, Single notes names and
  the header looked at in screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 10:07:40 +02:00
1 parent cf6799a784
commit 8a4e4a6762
54 files changed
+2561 -143

No files matched your search

+3
View File
@@ -1,5 +1,6 @@
import { createEmptyGraph } from '../graph/Graph';
import { newId } from '../ids';
import { starterInstrumentGraph } from '../instrument/nodes';
import type {
Asset,
FontAsset,
@@ -103,6 +104,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'sound', file: '' };
case 'soundfx':
return { ...base, type: 'soundfx', graph: createEmptyGraph() };
case 'instrument':
return { ...base, type: 'instrument', graph: starterInstrumentGraph() };
case 'particles':
return { ...base, type: 'particles', graph: createEmptyGraph() };
}
+2 -2
View File
@@ -10,7 +10,7 @@ import {
import type { GraphLookup } from '../graph/lookup';
import { isTerminalType, resolveDef, terminalNode } from '../graph/nested';
import { nextTransitionName, parallelTransitions } from '../graph/stateMachine';
import { nodeDef } from '../graph/NodeRegistry';
import { nodeDef, takesManyEdges } from '../graph/NodeRegistry';
import { graphLookup } from '../model/graphLookup';
import { newId } from '../ids';
import type { PropertyBag, PropertyValue } from '../model/Properties';
@@ -229,7 +229,7 @@ export class ConnectCommand implements Command {
throw new CommandError('Already connected');
this.edge.label = nextTransitionName(graph, this.edge);
}
const occupied = inp.type === 'exec' ? [] : edgesInto(graph, this.edge.to.node, this.edge.to.port);
const occupied = takesManyEdges(inp) ? [] : edgesInto(graph, this.edge.to.node, this.edge.to.port);
this.replaced = removeWhere(graph.edges, (e) => occupied.includes(e));
graph.edges.push(this.edge);
}
+15 -5
View File
@@ -1,5 +1,6 @@
import {
BEHAVIOR_PARAM,
declaresParameters,
declaresPropertyPorts,
declaresSoundFxPorts,
declaresVariablePorts,
@@ -17,8 +18,8 @@ import {
namesBehavior,
OBJECT_BEHAVIOR_PARAM,
propertyTargetOf,
INSTRUMENT_SOUND_FX,
SHADER_CALL,
SOUND_FX_INPUT,
soundFxSourceOf,
variablesSourceOf,
} from '../graph';
@@ -345,11 +346,14 @@ export class RenamePortCommand extends RenameCommand {
if (ports.some((p) => p.key === to)) throw new CommandError(`"${to}" already exists`);
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
for (const site of sites) carryPort(site, from, to);
if (node.type === SOUND_FX_INPUT) renameSoundFxParamPorts(project, this.assetId, from, to);
if (declaresParameters(node.type)) renameSoundFxParamPorts(project, this.assetId, from, to);
}
}
/** The default of a sound FX parameter (#311): what it is wherever no `Play sound` hands a value in. */
/**
* The default of a sound FX parameter (#311) or an instrument's (#331): what it is wherever no
* `Play sound` or `Play note` hands a value in.
*/
export class SetPortDefaultCommand implements Command {
readonly label: string;
private was: number | undefined;
@@ -365,7 +369,8 @@ export class SetPortDefaultCommand implements Command {
execute(project: Project): void {
const node = requireNode(project, this.assetId, this.nodeId);
if (node.type !== SOUND_FX_INPUT) throw new CommandError('Only a sound FX Input has defaults');
if (!declaresParameters(node.type))
throw new CommandError('Only a sound FX Input or an instrument Parameters node has defaults');
const port = (node.ports ?? []).find((p) => p.key === this.key);
if (!port) throw new CommandError(`Port "${this.key}" not found`);
if (!Number.isFinite(this.value)) throw new CommandError('A default is a number');
@@ -386,13 +391,18 @@ export class SetPortDefaultCommand implements Command {
}
}
/** A renamed parameter of a sound FX, followed into every node that sets it. */
/**
* A renamed parameter of a sound FX or an instrument, followed into every node that sets it – and
* into the Sound FX nodes of instruments (#331), whose inputs are the FX's parameters themselves.
*/
function renameSoundFxParamPorts(project: Project, fxId: string, from: string, to: string): void {
for (const asset of project.assets) {
if (!isGraphAsset(asset)) continue;
for (const node of asset.graph.nodes)
if (declaresSoundFxPorts(node.type) && soundFxSourceOf(node.params) === fxId)
renamePortOn(asset.graph, node, from, to);
else if (node.type === INSTRUMENT_SOUND_FX && node.params.fx === fxId)
carryPort({ graph: asset.graph, nodeId: node.id, asInput: true }, from, to);
}
}
+5 -2
View File
@@ -7,7 +7,8 @@ export type { DeclaredPortKind } from '../properties/kinds';
* `exec` carries control flow, the others values; `any` accepts every value type. `vec3` is a
* colour inside a shader graph, where the values are GLSL and no behavior node ever sees one;
* `picture` is what a shader's Pass draws (#301), for the nodes that read a picture. `fxparams` is
* the values a `Play sound` hands to the parameters of its sound FX (#311).
* the values a `Play sound` hands to the parameters of its sound FX (#311). `trigger` is a note
* arriving at a source of an instrument (#331): on for the notes it stands for, off for all others.
*/
export type PortType =
| 'exec'
@@ -21,6 +22,7 @@ export type PortType =
| 'picture'
| 'audio'
| 'fxparams'
| 'trigger'
| 'any';
export interface PortRef {
@@ -159,7 +161,7 @@ export function findNode(graph: Graph, nodeId: string): GraphNode | undefined {
return graph.nodes.find((n) => n.id === nodeId);
}
/** Edges arriving at one input port (data inputs allow at most one, exec inputs any number). */
/** Edges arriving at one input port (data inputs allow at most one, exec and `many` inputs any number). */
export function edgesInto(graph: Graph, nodeId: string, port: string): GraphEdge[] {
return graph.edges.filter((e) => e.to.node === nodeId && e.to.port === port);
}
@@ -195,6 +197,7 @@ export function portsCompatible(from: PortType, to: PortType): boolean {
if (from === 'picture' || to === 'picture') return from === to;
if (from === 'audio' || to === 'audio') return from === to;
if (from === 'fxparams' || to === 'fxparams') return from === to;
if (from === 'trigger' || to === 'trigger') return from === to;
if (from === 'number' && to === 'vec3') return true;
return from === to || from === 'any' || to === 'any';
}
+15
View File
@@ -250,6 +250,21 @@ export interface NodeContext {
fx: string,
fxParams: FxParams,
): number;
/**
* Plays one note of an instrument asset (#331): `note` as MIDI counts it (60 is C4), struck with
* `velocity` (0 to 1, how loud it is next to `volume`) and released after `duration` seconds, or
* held until it is stopped where that is 0. Owned like a `playSound` voice, `instrumentParams` are
* values for the instrument's parameters. Returns the number of the voice, 0 where nothing plays.
*/
playNote(
instrumentId: string,
note: number,
velocity: number,
volume: number,
duration: number,
detached: boolean,
instrumentParams: FxParams,
): number;
/**
* How many seconds one run of that sound asset lasts, so a change can span it; 0 where it does not
* exist or has not been loaded yet.
+9 -1
View File
@@ -27,6 +27,8 @@ export interface PortDef {
paintType?: PortType;
/** On a port writing a declared variable: what the written value is held to (#271). */
typing?: ValueTyping;
/** An input taking any number of edges, like an exec input: the layers of an instrument (#331). */
many?: true;
}
/**
@@ -197,6 +199,11 @@ export function isEventNode(def: NodeDef): boolean {
return def.category === 'event';
}
/** Whether an input keeps the edges it has when another one is connected to it. */
export function takesManyEdges(port: PortDef): boolean {
return port.type === 'exec' || port.many === true;
}
export function hasExecInput(def: NodeDef): boolean {
return def.inputs.some((p) => p.type === 'exec');
}
@@ -219,7 +226,8 @@ function isInlinePort(port: PortDef): boolean {
port.type !== 'struct' &&
port.type !== 'picture' &&
port.type !== 'audio' &&
port.type !== 'fxparams'
port.type !== 'fxparams' &&
port.type !== 'trigger'
);
}
+6 -6
View File
@@ -1,7 +1,7 @@
import { edgesInto, findNode, type Graph, type PortType, portsCompatible } from './Graph';
import type { GraphLookup } from './lookup';
import { resolveDef } from './nested';
import type { NodeDef } from './NodeRegistry';
import { type NodeDef, takesManyEdges } from './NodeRegistry';
/** One end of an edge being dragged: a port on a node, seen from its own side. */
export interface PortSide {
@@ -44,9 +44,9 @@ export function dropTargetPort(
const fromType = portTypeOf(graph, from, lookup);
if (!def || fromType === undefined) return null;
const candidates = compatiblePorts(def, fromType, from.isOutput)
.map((p) => ({ side: { nodeId, port: p.key, isOutput: !from.isOutput }, type: p.type }))
.map((p) => ({ side: { nodeId, port: p.key, isOutput: !from.isOutput }, many: takesManyEdges(p) }))
.filter((c) => !linked(graph, from, c.side));
const free = candidates.find((c) => isFree(graph, c.side, c.type));
const free = candidates.find((c) => isFree(graph, c.side, c.many));
return (free ?? candidates.at(-1))?.side ?? null;
}
@@ -73,9 +73,9 @@ function compatiblePorts(def: NodeDef, fromType: PortType, fromIsOutput: boolean
);
}
/** Only a data input is exclusive – exec inputs and every output take any number of edges. */
function isFree(graph: Graph, side: PortSide, type: PortType): boolean {
if (side.isOutput || type === 'exec') return true;
/** Only a data input is exclusive – exec and `many` inputs and every output take any number of edges. */
function isFree(graph: Graph, side: PortSide, many: boolean): boolean {
if (side.isOutput || many) return true;
return edgesInto(graph, side.nodeId, side.port).length === 0;
}
+24 -9
View File
@@ -1,4 +1,4 @@
import { edgesInto, findNode, type Graph, type GraphNode } from './Graph';
import { edgesInto, findNode, type Graph, type GraphEdge, type GraphNode } from './Graph';
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
export interface EmitRules<D extends NodeDef, T> {
@@ -16,6 +16,13 @@ export interface EmitRules<D extends NodeDef, T> {
* reporting a problem). Undefined for every other node.
*/
cut?: (def: D, node: GraphNode) => Record<string, T> | null | undefined;
/**
* What an input taking any number of edges (`PortDef.many`) gets from the values of all of them,
* in edge order. Without it such an input reads its first edge like any other.
*/
gather?: (node: GraphNode, port: PortDef, values: T[]) => T | null;
/** The definition as it applies to this node, for a node whose ports are derived; else the registered one. */
resolve?: (node: GraphNode, def: D) => D;
}
export interface GraphEmitter<T> {
@@ -56,11 +63,12 @@ export function graphEmitter<D extends NodeDef, T>(graph: Graph, rules: EmitRule
problem(node, 'Feeds itself');
return null;
}
const def = nodeDef(node.type);
if (!def || !rules.isDef(def)) {
const registered = nodeDef(node.type);
if (!registered || !rules.isDef(registered)) {
problem(node, `Not a ${rules.noun} node`);
return null;
}
const def = rules.resolve ? rules.resolve(node, registered) : registered;
const cut = rules.cut?.(def, node);
if (cut !== undefined) {
if (cut) emitted.set(node.id, cut);
@@ -81,14 +89,21 @@ export function graphEmitter<D extends NodeDef, T>(graph: Graph, rules: EmitRule
return outputs;
};
const arriving = (node: GraphNode, port: PortDef, edge: GraphEdge): T | null => {
const source = findNode(graph, edge.from.node);
const value = (source && emitNode(source))?.[edge.from.port];
if (!value) return null;
return rules.conform ? rules.conform(node, port, value) : value;
};
const input = (node: GraphNode, port: PortDef): T | null => {
const edge = edgesInto(graph, node.id, port.key)[0];
if (edge) {
const source = findNode(graph, edge.from.node);
const value = (source && emitNode(source))?.[edge.from.port];
if (!value) return null;
return rules.conform ? rules.conform(node, port, value) : value;
const edges = edgesInto(graph, node.id, port.key);
if (port.many && rules.gather && edges.length > 0) {
const values = edges.map((edge) => arriving(node, port, edge));
return values.every((value): value is T => value !== null) ? rules.gather(node, port, values) : null;
}
const edge = edges[0];
if (edge) return arriving(node, port, edge);
const def = nodeDef(node.type);
return rules.unfed(node, port, def && rules.isDef(def) ? def : undefined);
};
+4 -1
View File
@@ -17,7 +17,10 @@ export interface ShaderFunction {
graph: Graph;
}
/** A sound FX as a node naming it sees it: the parameters its Input declares (#311). */
/**
* A sound FX as a node naming it sees it: the parameters its Input declares (#311). An instrument
* looks the same from outside, with the parameters of its Parameters node (#331).
*/
export interface SoundFxShape {
name: string;
params: readonly DeclaredPort[];
+47 -8
View File
@@ -33,6 +33,17 @@ export const SOUND_FX_INPUT = 'SoundFxInput';
export const SOUND_FX_OUTPUT = 'SoundFxOutput';
export const SOUND_FX_PARAMETERS = 'SoundFxParameters';
const PLAY_SOUND = 'PlaySound';
/**
* The nodes of an instrument's graph that are its own (#331), and the behavior node naming its
* parameters. The effects it shares with a sound FX keep their types.
*/
export const INSTRUMENT_NOTES = 'InstrumentNotes';
export const INSTRUMENT_SAMPLE = 'InstrumentSample';
export const INSTRUMENT_SOUND_FX = 'InstrumentSoundFx';
export const INSTRUMENT_PARAMETERS = 'InstrumentParameters';
export const INSTRUMENT_OUTPUT = 'InstrumentOutput';
export const INSTRUMENT_PARAMETER_VALUES = 'InstrumentParameterValues';
const PLAY_NOTE = 'PlayNote';
export const BEHAVIOR_PARAM = 'behavior';
export const FUNCTION_PARAM = 'function';
@@ -48,6 +59,14 @@ export const SOUND_FX_SETTING: PropertyDef = {
assetTypes: ['soundfx'],
};
/** Whose parameters an `Instrument parameters` node sets: the same key, another kind of asset. */
export const INSTRUMENT_SETTING: PropertyDef = {
...SOUND_FX_SETTING,
label: 'Instrument',
assetTypes: ['instrument'],
};
/** The sound FX – or the instrument (#331) – whose parameters a node names. */
export function soundFxSourceOf(params: PropertyBag): string {
return textOf(params[SOUND_FX_PARAM] ?? '');
}
@@ -90,9 +109,14 @@ export function declaresPropertyPorts(type: string): boolean {
return type === PROPERTIES;
}
/** Nodes whose declared ports are parameters of the sound FX they name. */
/** Nodes whose declared ports are parameters of the sound FX or the instrument they name. */
export function declaresSoundFxPorts(type: string): boolean {
return type === SOUND_FX_PARAMETERS;
return type === SOUND_FX_PARAMETERS || type === INSTRUMENT_PARAMETER_VALUES;
}
/** The node declaring the parameters of a sound FX or an instrument, each with a default. */
export function declaresParameters(type: string): boolean {
return type === SOUND_FX_INPUT || type === INSTRUMENT_PARAMETERS;
}
/** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */
@@ -153,7 +177,9 @@ export function isTerminalType(type: string): boolean {
type === SHADER_FUNCTION_INPUT ||
type === SHADER_FUNCTION_OUTPUT ||
type === SOUND_FX_INPUT ||
type === SOUND_FX_OUTPUT
type === SOUND_FX_OUTPUT ||
type === INSTRUMENT_PARAMETERS ||
type === INSTRUMENT_OUTPUT
);
}
@@ -381,7 +407,8 @@ export function ghostProblem(node: GraphNode, graph: Graph, def: NodeDef): strin
export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null {
if (node.type === ON_BEHAVIOR) return listenProblem(node, lookup, ownerId);
if (node.type === SHADER_CALL) return functionCallProblem(node, lookup, ownerId);
if (node.type === SOUND_FX_PARAMETERS) return soundFxParametersProblem(node, lookup);
if (declaresSoundFxPorts(node.type)) return soundFxParametersProblem(node, lookup);
if (node.type === INSTRUMENT_SOUND_FX) return nestedFxProblem(node, lookup);
if (node.type !== RUN_BEHAVIOR) return easingProblem(node, lookup);
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
if (!id) return 'No behavior picked';
@@ -427,7 +454,15 @@ function functionCallProblem(node: GraphNode, lookup: GraphLookup, ownerId: stri
}
function soundFxParametersProblem(node: GraphNode, lookup: GraphLookup): string | null {
const noun = node.type === INSTRUMENT_PARAMETER_VALUES ? 'instrument' : 'sound FX';
const id = soundFxSourceOf(node.params);
if (!id) return `No ${noun} picked`;
return lookup.soundFx(id) ? null : `That ${noun} is gone`;
}
/** A Sound FX node in an instrument (#331) without its FX lets the sound through untouched. */
function nestedFxProblem(node: GraphNode, lookup: GraphLookup): string | null {
const id = textOf(node.params.fx ?? '');
if (!id) return 'No sound FX picked';
return lookup.soundFx(id) ? null : 'That sound FX is gone';
}
@@ -438,13 +473,17 @@ function soundFxParametersProblem(node: GraphNode, lookup: GraphLookup): string
* are picked in place – one coming over an edge may be either.
*/
export function soundFxParamsMismatch(node: GraphNode, graph: Graph): string | null {
if (node.type !== PLAY_SOUND || edgesInto(graph, node.id, 'fx').length > 0) return null;
const played = textOf(node.params.fx ?? '');
const playsNote = node.type === PLAY_NOTE;
if (node.type !== PLAY_SOUND && !playsNote) return null;
const pick = playsNote ? 'instrument' : 'fx';
if (edgesInto(graph, node.id, pick).length > 0) return null;
const played = textOf(node.params[pick] ?? '');
const edge = edgesInto(graph, node.id, 'fxParams')[0];
const from = edge && findNode(graph, edge.from.node);
if (!played || from?.type !== SOUND_FX_PARAMETERS) return null;
if (!played || !from || !declaresSoundFxPorts(from.type)) return null;
const named = soundFxSourceOf(from.params);
return named && named !== played ? 'The parameters are for another sound FX' : null;
if (!named || named === played) return null;
return playsNote ? 'The parameters are for another instrument' : 'The parameters are for another sound FX';
}
/** The ids the nodes of one type in that graph name in `param`, picked or not found alike. */
+63
View File
@@ -0,0 +1,63 @@
import { MIDDLE_C } from '../../../instrument/notes';
import type { NodeDef } from '../../NodeRegistry';
import { asBoolean, asNumber, asString } from '../../values';
import { exec, num, withDefault } from '../shared';
import { fxParamsOf } from './PlaySound';
export const PlayNote: NodeDef = {
type: 'PlayNote',
label: 'Play note',
category: 'action',
description:
'Plays one note of an instrument asset. "note" is its number as MIDI counts it: 60 is C4, 61 C#4, 72 C5. "velocity" is how hard it is struck, 0 to 1 – it makes the note quieter and is handed to what the instrument does with it. "duration" is how many seconds until the note is released; at 0 it is held until a Stop sound ends it. A note belongs to this object like a sound and stops when the object is destroyed, unless "detached" is on. "params" takes values for the parameters of the instrument from an Instrument parameters node. "voice" is the number of this note, for Set sound FX parameters and Stop sound; 0 where the instrument plays nothing for it.',
inputs: [
exec(),
{
key: 'instrument',
label: 'instrument',
type: 'string',
field: {
key: 'instrument',
label: 'Instrument',
kind: 'asset',
default: '',
assetTypes: ['instrument'],
},
},
{
key: 'note',
label: 'note',
type: 'number',
default: MIDDLE_C,
field: { key: 'note', label: 'Note', kind: 'number', default: MIDDLE_C, min: 0, max: 127, step: 1 },
},
withDefault(num('velocity'), 1),
withDefault(num('volume'), 1),
withDefault(num('duration'), 0),
{
key: 'detached',
label: 'detached',
type: 'boolean',
default: false,
field: { key: 'detached', label: 'Detached (outlives this object)', kind: 'boolean', default: false },
},
{ key: 'fxParams', label: 'params', type: 'fxparams' },
],
outputs: [exec(), num('voice')],
params: [],
run: (ctx, inputs) => {
const instrument = asString(inputs.instrument);
const voice = instrument
? ctx.playNote(
instrument,
asNumber(inputs.note),
asNumber(inputs.velocity),
asNumber(inputs.volume),
asNumber(inputs.duration),
asBoolean(inputs.detached),
fxParamsOf(inputs.fxParams),
)
: 0;
return { next: 'exec', outputs: { voice } };
},
};
@@ -0,0 +1,37 @@
import type { StructValue } from '../../../model/Properties';
import { INSTRUMENT_PARAMETER_VALUES, INSTRUMENT_SETTING, soundFxSourceOf } from '../../nested';
import type { NodeDef } from '../../NodeRegistry';
import { asNumber } from '../../values';
/**
* Values for the parameters of one instrument (#331), picked like those of a sound FX: each one picked
* is a number input starting at the instrument's default. The bundle goes into the `params` input of
* a `Play note`, or the `fx params` of a `Set sound FX parameters` that changes a playing note.
*/
export const InstrumentParameterValues: NodeDef = {
type: INSTRUMENT_PARAMETER_VALUES,
label: 'Instrument parameters',
category: 'data',
description:
'Values for the parameters of an instrument, for the "params" input of a Play note or the "fx params" of a Set sound FX parameters. Pick the parameters to set; one not picked keeps the default its Parameters node gives it, or on a playing note the value it has.',
inputs: [],
outputs: [{ key: 'params', label: 'params', type: 'fxparams' }],
params: [INSTRUMENT_SETTING],
declaresPorts: 'inputs',
portKinds: ['number'],
declaredPort: (port, node, _graph, lookup) => {
const declared = lookup.soundFx(soundFxSourceOf(node.params))?.params.find((p) => p.key === port.key);
const value = declared?.default ?? 0;
return {
key: port.key,
label: port.key,
type: 'number',
default: value,
field: { key: port.key, label: port.key, kind: 'number', default: value },
};
},
run: (_ctx, inputs, _params, def) => {
const values: StructValue = Object.fromEntries(def.inputs.map((p) => [p.key, asNumber(inputs[p.key])]));
return { outputs: { params: values } };
},
};
+6
View File
@@ -24,6 +24,7 @@ import { RaycastAll } from './action/RaycastAll';
import { RestartGame } from './action/RestartGame';
import { SetPosition } from './action/SetPosition';
import { SetSoundFxParameters } from './action/SetSoundFxParameters';
import { PlayNote } from './action/PlayNote';
import { SetZ } from './action/SetZ';
import { Spawn } from './action/Spawn';
import { StopGame } from './action/StopGame';
@@ -91,6 +92,7 @@ import { Owner } from './data/Owner';
import { PackStruct } from './data/PackStruct';
import { Properties } from './data/Properties';
import { SoundFxParameters } from './data/SoundFxParameters';
import { InstrumentParameterValues } from './data/InstrumentParameterValues';
import { Parent } from './data/Parent';
import { Random } from './data/Random';
import { RenderLayerOf } from './data/RenderLayerOf';
@@ -150,6 +152,7 @@ import { PARTICLE_NODES } from '../../particles/nodes';
import { SHADER_FUNCTION_NODES } from '../../shader/functions';
import { SHADER_NODES } from '../../shader/nodes';
import { SOUND_FX_NODES } from '../../soundfx/nodes';
import { INSTRUMENT_NODES } from '../../instrument/nodes';
import { State } from '../stateMachine';
import { Variables } from './variable/Variables';
@@ -303,6 +306,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
StopSound,
SetSoundFxParameters,
SoundFxParameters,
PlayNote,
InstrumentParameterValues,
LoadScene,
Log,
StopGame,
@@ -322,3 +327,4 @@ for (const def of SHADER_NODES) registerNode(def);
for (const def of SHADER_FUNCTION_NODES) registerNode(def);
for (const def of PARTICLE_NODES) registerNode(def);
for (const def of SOUND_FX_NODES) registerNode(def);
for (const def of INSTRUMENT_NODES) registerNode(def);
+320
View File
@@ -0,0 +1,320 @@
import { graphEmitter } from '../graph/emitGraph';
import { type DeclaredPort, 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 { resolveAssetProps } from '../properties/resolve';
import { volumeOf } from '../properties/sounds';
import { soundFxBuildFor } from '../soundfx/compile';
import {
type FxAmount,
type FxPlan,
type FxStagePlan,
isSoundFxNodeDef,
SOUND_FX_INPUT,
SOUND_FX_OUTPUT,
SoundFxMix,
type SoundFxNodeDef,
withPitch,
withStages,
} from '../soundfx/nodes';
import {
INSTRUMENT_NOTES,
INSTRUMENT_OUTPUT,
INSTRUMENT_PARAMETERS,
INSTRUMENT_SAMPLE,
INSTRUMENT_SOUND_FX,
isInstrumentNodeDef,
noteOutputKey,
noteParam,
notesRange,
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');
return asset ? { file: asset.file, volume: volumeOf(resolveAssetProps(project, asset)) } : null;
},
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 `${sound.file}@${volumeOf(resolveAssetProps(project, sound))}`;
const fx = findAssetOf(project, assetId, 'soundfx');
return fx ? graphFingerprint(fx.graph) : '';
},
};
}
export interface InstrumentBuild {
/** What the note is made of, each played on its own and summed; empty is 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, more
* than one only into the Output), a number, or whether a trigger is on for this note.
*/
type Value = { plans: FxPlan[] } | { amount: FxAmount } | { on: boolean };
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<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)) } };
if (port.type === 'audio' && port.many) 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) => {
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;
},
gather: (_node, port, values) =>
port.type === 'trigger'
? { on: values.some((v) => 'on' in v && v.on) }
: { plans: values.flatMap((v) => ('plans' in v ? v.plans : [])) },
});
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_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_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 loop = node.params.loop === true;
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: textOf(node.params.mode ?? 'gate') !== 'oneshot',
},
};
}
const output = graph.nodes.find((n) => n.type === INSTRUMENT_OUTPUT);
if (!output) return { layers: [], problems: walk.problems, reason: 'No Output node' };
const arrived = walk.input(output, { key: 'in', label: 'in', type: 'audio', many: true });
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: '' };
}
/** 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 {
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 {
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) };
return {
...stage,
time: bind(stage.time),
feedback: bind(stage.feedback),
damping: bind(stage.damping),
mix: bind(stage.mix),
};
}
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
.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<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;
}
+246
View File
@@ -0,0 +1,246 @@
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 { type PropertyBag, textOf } from '../model/Properties';
import type { PropertyDef } from '../properties/PropertySchema';
import { 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. */
export 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 };
export const SAMPLE_MODES = [
{ value: 'gate', label: 'Gate' },
{ value: 'oneshot', label: 'One-shot' },
];
const MODE: PropertyDef = {
key: 'mode',
label: 'Mode',
kind: 'enum',
default: 'gate',
options: SAMPLE_MODES,
};
const LOOP = { key: 'loop', label: 'Loop', kind: 'boolean' as const, default: false };
const LOOP_START: PropertyDef = {
key: 'loopStart',
label: 'Loop start (s)',
kind: 'number',
default: 0,
min: 0,
step: 0.01,
};
const LOOP_END: PropertyDef = {
key: 'loopEnd',
label: 'Loop end (s)',
kind: 'number',
default: 0,
min: 0,
step: 0.01,
};
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. "Gate" ends it with the note, faded out in a few milliseconds; "One-shot" plays it through whatever the note does. "Loop" repeats it, between "loop start" and "loop end" in seconds where they are set (0 is the end of the sound), until the note ends – or, as a one-shot, until it is stopped. The volume of the sound asset counts.',
inputs: [triggerIn],
outputs: [audioOut],
params: [SOUND, ROOT, FOLLOW, MODE, LOOP, LOOP_START, LOOP_END],
idleParams: (params) => [
...(params[FOLLOW.key] === false ? [ROOT.key] : []),
...(params[LOOP.key] === true ? [] : [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 InstrumentOutput: InstrumentNodeDef = {
type: INSTRUMENT_OUTPUT,
label: 'Output',
category: 'sound',
instrument: true,
description:
'What is heard of a note. It takes any number of sounds and plays them together – the layers of the instrument.',
inputs: [{ ...audioIn, many: true }],
outputs: [],
params: [],
};
export const INSTRUMENT_NODES: readonly InstrumentNodeDef[] = [
InstrumentNotes,
InstrumentSample,
InstrumentParameters,
InstrumentSoundFx,
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.
*/
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: 460, 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' } },
],
};
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Notes as an instrument counts them (#331): by number as MIDI does, 0 the lowest, 60 the middle C
* and 69 the A at 440 Hz. A name has the octave after it without a dash (`C4`, `C#4`), sharps only,
* so the lowest is `C-1`.
*/
export const LOWEST_NOTE = 0;
export const HIGHEST_NOTE = 127;
export const MIDDLE_C = 60;
const NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
export function noteName(note: number): string {
const n = Math.round(note);
return `${NAMES[((n % 12) + 12) % 12]}${Math.floor(n / 12) - 1}`;
}
/** The frequency of a note in Hz, a fractional one between its neighbours. */
export function noteFrequency(note: number): number {
return 440 * 2 ** ((note - 69) / 12);
}
/** A note as a whole number in range: what a pattern, a setting or a `Play note` hands in. */
export function heldNote(value: number): number {
if (!Number.isFinite(value)) return MIDDLE_C;
return Math.max(LOWEST_NOTE, Math.min(HIGHEST_NOTE, Math.round(value)));
}
/** Every note, for a setting that picks one by name. */
export const NOTE_OPTIONS: { value: string; label: string }[] = Array.from(
{ length: HIGHEST_NOTE - LOWEST_NOTE + 1 },
(_, i) => ({ value: String(LOWEST_NOTE + i), label: noteName(LOWEST_NOTE + i) }),
);
/** Whether a note is a black key. */
export function isSharp(note: number): boolean {
return NAMES[((Math.round(note) % 12) + 12) % 12]!.length > 1;
}
+18 -1
View File
@@ -319,6 +319,15 @@ export interface SoundFxAsset extends AssetBase {
graph: Graph;
}
/**
* What makes a note a sound (#331): the graph runs from its `Notes` nodes over sources such as
* `Sample` and the effects of a sound FX to its `Output`, compiled once per note (see `core/instrument`).
*/
export interface InstrumentAsset extends AssetBase {
type: 'instrument';
graph: Graph;
}
/**
* A tile a tilemap paints with: `pictures` names one block per animation step (a texture id for a
* sheet that holds nothing else, a frame id for one block cut out of a sheet), and `props` say
@@ -572,6 +581,7 @@ export type Asset =
| FontAsset
| SoundAsset
| SoundFxAsset
| InstrumentAsset
| ParticlesAsset;
/** The assets whose `variables` are declared by the user – see `requireVariableOwner`. */
@@ -583,7 +593,13 @@ export function isVariableOwner(asset: Asset | undefined): asset is VariableOwne
/** The assets edited as a node graph – what the graph canvas and the graph commands work on. */
export type GraphAsset =
BehaviorAsset | StateMachineAsset | ShaderAsset | ShaderFunctionAsset | ParticlesAsset | SoundFxAsset;
| BehaviorAsset
| StateMachineAsset
| ShaderAsset
| ShaderFunctionAsset
| ParticlesAsset
| SoundFxAsset
| InstrumentAsset;
const GRAPH_TYPES: ReadonlySet<AssetType> = new Set([
'behavior',
@@ -592,6 +608,7 @@ const GRAPH_TYPES: ReadonlySet<AssetType> = new Set([
'shaderfunction',
'particles',
'soundfx',
'instrument',
]);
export function isGraphAsset(asset: Asset): asset is GraphAsset {
+6 -1
View File
@@ -25,6 +25,7 @@ export const ASSET_TYPES = [
'font',
'sound',
'soundfx',
'instrument',
'particles',
] as const;
@@ -57,6 +58,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
font: 'Font',
sound: 'Sound',
soundfx: 'Sound FX',
instrument: 'Instrument',
particles: 'Particles',
};
@@ -69,7 +71,8 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
* is the tilemap. A curve is the way a key or a `Lerp` blends, picked by id like a state machine.
* A struct is a shape that variables and fields take on (#253), nothing that exists on its own.
* A shader function is part of other shaders (#303), worn by nothing itself. A sound FX is what a
* sound is played through (#311), heard but never seen.
* sound is played through (#311), heard but never seen, and an instrument what a note is played
* with (#331).
*/
export type PlaceableAssetType = Exclude<
AssetType,
@@ -87,6 +90,7 @@ export type PlaceableAssetType = Exclude<
| 'font'
| 'sound'
| 'soundfx'
| 'instrument'
| 'variables'
| 'struct'
>;
@@ -106,6 +110,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
'font',
'sound',
'soundfx',
'instrument',
'variables',
'struct',
]);
+5 -5
View File
@@ -1,5 +1,5 @@
import type { GraphLookup } from '../graph/lookup';
import { SOUND_FX_INPUT, terminalNode } from '../graph/nested';
import { INSTRUMENT_PARAMETERS, SOUND_FX_INPUT, terminalNode } from '../graph/nested';
import { startStateOf, statesOf, transitionsOf } from '../graph/stateMachine';
import { pictureSize } from '../properties/aspect';
import { resolveAssetProps } from '../properties/resolve';
@@ -36,10 +36,10 @@ export function graphLookup(project: Project): GraphLookup {
},
shaderFunction: shaderFunctionsOf(project),
soundFx: (assetId) => {
const asset = findAssetOf(project, assetId, 'soundfx');
return asset
? { name: asset.name, params: terminalNode(asset.graph, SOUND_FX_INPUT)?.ports ?? [] }
: null;
const asset = findAssetOf(project, assetId, 'soundfx') ?? findAssetOf(project, assetId, 'instrument');
if (!asset) return null;
const declaring = asset.type === 'soundfx' ? SOUND_FX_INPUT : INSTRUMENT_PARAMETERS;
return { name: asset.name, params: terminalNode(asset.graph, declaring)?.ports ?? [] };
},
machine: (assetId) => {
const asset = findAssetOf(project, assetId, 'statemachine');
+2
View File
@@ -657,6 +657,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
],
// The effects are the asset's graph (see SoundFxAsset), not a property.
soundfx: [],
// What a note sounds like is the asset's graph (see InstrumentAsset), not a property.
instrument: [],
// How many particles and for how long; what one of them does is the asset's graph, whose Property
// nodes add their rows after these – see `schemaForBag`.
particles: [
+20 -5
View File
@@ -27,6 +27,20 @@ export interface FxDelay {
pingPong: boolean;
}
/**
* Where a way of an instrument's note gets its sound (#331): a file of its own, how loud and how it
* loops, and whether it ends with the note (`gate`) or plays through. A way of a sound FX has none –
* it plays the sound the voice was started with. `loopEnd` 0 is the end of the sound.
*/
export interface FxSource {
file: string;
volume: number;
loop: boolean;
loopStart: number;
loopEnd: number;
gate: boolean;
}
/**
* A sound on its way through the graph: how many semitones it is played up or down, and what it
* passes, in order. `gain` is a factor, a filter's `gain` is in dB as Web Audio takes it. `shifted`
@@ -35,7 +49,7 @@ export interface FxDelay {
* of what goes on through `stages`.
*/
export type FxChain =
| { pitch: number; shifted: boolean; stages: readonly FxStage[] }
| { pitch: number; shifted: boolean; stages: readonly FxStage[]; source?: FxSource }
| { mix: number; a: FxChain; b: FxChain; stages: readonly FxStage[] };
export const DRY: FxChain = { pitch: 0, shifted: false, stages: [] };
@@ -66,7 +80,7 @@ export type FxStagePlan =
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
export type FxPlan =
| { pitch: readonly FxAmount[]; stages: readonly FxStagePlan[] }
| { pitch: readonly FxAmount[]; stages: readonly FxStagePlan[]; source?: FxSource }
| { mix: FxAmount; a: FxPlan; b: FxPlan; stages: readonly FxStagePlan[] };
export const EMPTY_PLAN: FxPlan = { pitch: [], stages: [] };
@@ -130,12 +144,12 @@ const isNone = (amount: FxAmount) => amount.param === undefined && amount.value
const arriving = (sounds: Readonly<Record<string, FxPlan>>) => sounds.in ?? EMPTY_PLAN;
function withStages(plan: FxPlan, stages: readonly FxStagePlan[]): FxPlan {
export function withStages(plan: FxPlan, stages: readonly FxStagePlan[]): FxPlan {
return stages.length === 0 ? plan : { ...plan, stages: [...plan.stages, ...stages] };
}
/** A pitch is made where the sound is read, so behind a Mix it goes to both sounds. */
function withPitch(plan: FxPlan, semitones: FxAmount): FxPlan {
export function withPitch(plan: FxPlan, semitones: FxAmount): FxPlan {
if ('mix' in plan) return { ...plan, a: withPitch(plan.a, semitones), b: withPitch(plan.b, semitones) };
return { ...plan, pitch: [...plan.pitch, semitones] };
}
@@ -352,7 +366,8 @@ export function fxChainOf(plan: FxPlan, params: FxParams = {}): FxChain {
const stages = stagesOf(plan, params);
if (!('mix' in plan)) {
const pitch = plan.pitch.reduce((sum, amount) => sum + amountOf(amount, params), 0);
return { pitch, shifted: plan.pitch.length > 0, stages };
const chain = { pitch, shifted: plan.pitch.length > 0, stages };
return plan.source ? { ...chain, source: plan.source } : chain;
}
return {
mix: amountOf(plan.mix, params),
+15 -6
View File
@@ -57,16 +57,19 @@ export class PitchPlayer {
private primed = false;
/** How many samples of the output the sound lasts; a looping one never ends. */
private readonly end: number;
/** Where a loop runs in the samples of the sound; null for a sound that does not loop. */
private readonly loop: { start: number; end: number } | null;
/**
* `channels` are the samples of the sound, `step` how many of them pass for one sample of the
* output (the sound's rate over the output's) and `rate` the output's rate, which sets the frame.
* `loop` repeats the whole sound, or the samples from `start` up to `end` (#331) once it gets there.
*/
constructor(
private readonly channels: readonly Float32Array[],
private readonly step: number,
rate: number,
private readonly loop: boolean,
loop: boolean | { start: number; end: number },
) {
// About 43 ms: a frame holds a few periods of a low note.
this.size = 2 ** Math.round(Math.log2(rate * 0.043));
@@ -91,6 +94,11 @@ export class PitchPlayer {
this.frameAt = -this.size;
const length = channels[0]?.length ?? 0;
this.end = loop ? Infinity : Math.ceil(length / step);
const region = loop === true ? { start: 0, end: length } : loop || null;
this.loop =
region && region.start >= 0 && region.end <= length && region.start < region.end
? region
: region && { start: 0, end: length };
}
/**
@@ -256,15 +264,16 @@ export class PitchPlayer {
private readFrame(c: number, from: number): Float64Array {
const { reading, size, step, loop } = this;
const samples = this.channels[c]!;
const length = samples.length;
const end = loop ? loop.end : samples.length;
for (let j = 0; j < size; j++) {
let position = (from + j) * step;
if (position < 0 || position >= length) {
if (!loop) {
if (position < 0 || position >= end) {
if (!loop || position < 0) {
reading[j] = 0;
continue;
}
position -= Math.floor(position / length) * length;
const span = loop.end - loop.start;
position = loop.start + ((position - loop.start) % span);
}
const below = Math.floor(position);
const fraction = position - below;
@@ -272,7 +281,7 @@ export class PitchPlayer {
reading[j] = samples[below]!;
continue;
}
const next = below + 1 < length ? samples[below + 1]! : loop ? samples[0]! : 0;
const next = below + 1 < end ? samples[below + 1]! : loop ? samples[loop.start]! : 0;
reading[j] = samples[below]! * (1 - fraction) + next * fraction;
}
return reading;
+4
View File
@@ -20,6 +20,7 @@ import {
import { isParticleNodeDef } from '../particles/nodes';
import { isShaderNodeDef } from '../shader/nodes';
import { isSoundFxNodeDef } from '../soundfx/nodes';
import { isInstrumentNodeDef } from '../instrument/nodes';
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
import type {
AnimationKey,
@@ -237,6 +238,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
assets.push({ id, type, name, props, graph: particleGraphs.get(id) ?? createEmptyGraph() });
} else if (type === 'soundfx') {
assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, isSoundFxNodeDef) });
} else if (type === 'instrument') {
const keep = (def: NodeDef) => isInstrumentNodeDef(def) || isSoundFxNodeDef(def);
assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, keep) });
} else if (type === 'texture') {
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
} else if (type === 'font') {