sfxr's "phaser" is a comb filter, the sound plus a copy delayed by up to 2.9 ms. That is a flanger, not the phaser a guitarist knows. Both effects come in as nodes of their own, so each name fits its sound and sfxr is covered in full. Both run on the FX worklet like the Bitcrusher, because a feedback loop in Web Audio costs a render quantum per round. - Phaser: first-order allpasses (Stages 2, 4, 6, 8, 12) around Frequency, moved by a sine LFO Depth octaves wide at Rate Hz, with Feedback and Mix. An Envelope can move Frequency and Mix. - Flanger: a delay line of Offset ms. Sweep moves it on in ms/s from the start of the voice, an LFO adds up to Depth ms. As in sfxr the delay is mirrored at 0, and it never goes past 20 ms. Feedback comes back exactly one delay later. An Envelope can move Offset and Mix. - Mix 0 leaves the sound as it is, 1 is half sound and half copy. The notches are deepest there and the peaks stay at the sound's level. sfxr adds both at full, so its sound comes out 6 dB louder. - The Flanger's processor gets the start of its voice and begins the sweep on that sample. stageNodes takes it as a new argument `at`. - Explosion gets a Flanger in one draw out of two, with sfxr's offset and sweep and no LFO. Offset is offset^2 * 2.89 ms, Sweep is ramp^2 * 125 ms/s, signed so the direction holds. - Randomize draws a Flanger's offset and sweep like sfxr. Its LFO and feedback and all of a Phaser are drawn within bounds of their own. Mix and Stages stay, as a Bitcrusher's Mix does. Mutate moves them too, and a 0 stays 0. - New instrument preset Phased Pad: saw, Envelope, low-pass, Phaser of six stages. The other 14 are unchanged. - The Bitcrusher's worklet setup is now shared with both nodes (workletNodes), its behaviour is unchanged. Tests: - Unit phaser (new): 8 green. Counter-check with clamping at 0 instead of mirroring and a flipped allpass coefficient: 3 red. - Unit soundfx, sound-library, chip-presets: 45 / 76 / 14 green. Counter-checks: start time not passed on, phaser frequency eased linearly, sweep sign dropped, Phaser left out of Mutate, each red. - Unit core, viewport, graph: 179 files, 2979 tests green. - e2e 34 (melektaus-atx): ALL PASSED, 70 checks (1 new), first run. Chrome offline: 1 kHz notch RMS 0.0000 with two stages, 0.5657 with four. Flanger echo after 48 samples, swept echo at 533/534. About 47 Phasers (12 stages) or 63 Flangers fill the audio thread. - Gate (melektaus-atx): CHECK EXIT 0, 257 files, 3991 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
446 lines
17 KiB
TypeScript
446 lines
17 KiB
TypeScript
import { graphEmitter } from '../graph/emitGraph';
|
||
import {
|
||
type DeclaredPort,
|
||
edgesInto,
|
||
edgesOutOf,
|
||
type Graph,
|
||
graphFingerprint,
|
||
type GraphNode,
|
||
} from '../graph/Graph';
|
||
import type { GraphLookup } from '../graph/lookup';
|
||
import { resolveDef, terminalNode } from '../graph/nested';
|
||
import { inlineValue, type NodeDef } from '../graph/NodeRegistry';
|
||
import { asNumber } from '../graph/values';
|
||
import type { InstrumentAsset } from '../model/Asset';
|
||
import { graphLookup } from '../model/graphLookup';
|
||
import { textOf } from '../model/Properties';
|
||
import { findAssetOf, type Project } from '../model/Project';
|
||
import { playedSoundOf } from '../properties/playedSound';
|
||
import { resolveAssetProps } from '../properties/resolve';
|
||
import { volumeOf } from '../properties/sounds';
|
||
import { soundFxBuildFor } from '../soundfx/compile';
|
||
import {
|
||
type FxAmount,
|
||
type FxChipPlan,
|
||
type FxEnvelopePlan,
|
||
type FxModPlan,
|
||
type FxPlan,
|
||
type FxStagePlan,
|
||
held,
|
||
isSoundFxNodeDef,
|
||
SOUND_FX_INPUT,
|
||
SOUND_FX_OUTPUT,
|
||
SoundFxMix,
|
||
type SoundFxNodeDef,
|
||
withPitch,
|
||
withStages,
|
||
} from '../soundfx/nodes';
|
||
import {
|
||
CHIP_ROWS,
|
||
chipWave,
|
||
ENVELOPE_ROWS,
|
||
INSTRUMENT_CHIP,
|
||
INSTRUMENT_ENVELOPE,
|
||
INSTRUMENT_NOTES,
|
||
INSTRUMENT_OUTPUT,
|
||
INSTRUMENT_PARAMETERS,
|
||
INSTRUMENT_SAMPLE,
|
||
INSTRUMENT_SOUND_FX,
|
||
InstrumentOutput,
|
||
isInstrumentNodeDef,
|
||
loops,
|
||
noteNameKey,
|
||
noteOutputKey,
|
||
noteParam,
|
||
notesRange,
|
||
samplePlay,
|
||
singleNotes,
|
||
VELOCITY_PARAM,
|
||
} from './nodes';
|
||
import { MIDDLE_C, noteFrequency } from './notes';
|
||
|
||
/**
|
||
* What an instrument's graph reaches outside itself: the sounds its Samples play and the sound FX
|
||
* its Sound FX nodes send them through. `key` changes whenever one of them does, so a cached note is
|
||
* compiled again when the sound or the FX it was made of is edited.
|
||
*/
|
||
export interface InstrumentSources {
|
||
lookup: GraphLookup;
|
||
sound(assetId: string): { file: string; volume: number } | null;
|
||
soundFx(assetId: string): FxPlan | null;
|
||
key(assetId: string): string;
|
||
}
|
||
|
||
export function instrumentSourcesOf(project: Project): InstrumentSources {
|
||
return {
|
||
lookup: graphLookup(project),
|
||
sound: (assetId) => {
|
||
const asset = findAssetOf(project, assetId, 'sound');
|
||
if (!asset) return null;
|
||
// An asset without a file stays without one, so the Sample can say so.
|
||
const file = asset.file ? playedSoundOf(project, asset) : '';
|
||
return { file, volume: volumeOf(resolveAssetProps(project, asset)) };
|
||
},
|
||
soundFx: (assetId) => {
|
||
const asset = findAssetOf(project, assetId, 'soundfx');
|
||
return asset ? soundFxBuildFor(asset).compiled : null;
|
||
},
|
||
key: (assetId) => {
|
||
const sound = findAssetOf(project, assetId, 'sound');
|
||
if (sound) return `${playedSoundOf(project, sound)}@${volumeOf(resolveAssetProps(project, sound))}`;
|
||
const fx = findAssetOf(project, assetId, 'soundfx');
|
||
return fx ? graphFingerprint(fx.graph) : '';
|
||
},
|
||
};
|
||
}
|
||
|
||
export interface InstrumentBuild {
|
||
/** What the note is made of: the one sound at the Output, or none for silence. */
|
||
layers: readonly FxPlan[];
|
||
/** Per node id, what keeps the graph from compiling. */
|
||
problems: Record<string, string>;
|
||
/** One line for the tab: empty once compiled. */
|
||
reason: string;
|
||
}
|
||
|
||
/**
|
||
* What travels along an edge of an instrument: sounds (none where nothing plays for this note), a
|
||
* number, whether a trigger is on for this note, or the curve of an Envelope (#335).
|
||
*/
|
||
type Value = { plans: FxPlan[] } | { amount: FxAmount } | { on: boolean } | { curve: FxModPlan };
|
||
|
||
const SILENT: Value = { plans: [] };
|
||
|
||
type Def = SoundFxNodeDef | (NodeDef & { instrument: true });
|
||
|
||
function isInstrumentDef(def: NodeDef): def is Def {
|
||
if (isInstrumentNodeDef(def)) return true;
|
||
return isSoundFxNodeDef(def) && def.type !== SOUND_FX_INPUT && def.type !== SOUND_FX_OUTPUT;
|
||
}
|
||
|
||
/** The parameters the Parameters node declares, in their order, each with its default. */
|
||
export function instrumentParams(graph: Graph): readonly DeclaredPort[] {
|
||
return terminalNode(graph, INSTRUMENT_PARAMETERS)?.ports ?? [];
|
||
}
|
||
|
||
/**
|
||
* One note of an instrument (#331): the graph walked back from the Output, with the triggers that
|
||
* stand for this note on and every other one off. A source whose trigger is off is silence, and so
|
||
* is everything it alone feeds; a Mix with one silent side is its other side. `note` and `frequency`
|
||
* are numbers here, `velocity` and the parameters stay open for every voice to fill in.
|
||
*/
|
||
export function compileInstrument(graph: Graph, note: number, sources: InstrumentSources): InstrumentBuild {
|
||
const walk = graphEmitter<Def, Value>(graph, {
|
||
isDef: isInstrumentDef,
|
||
noun: 'instrument',
|
||
resolve: (node, def) =>
|
||
def.derivePorts ? ((resolveDef(node, graph, sources.lookup) as Def | undefined) ?? def) : def,
|
||
emit: (def, inputs, node) => emitNode(def, inputs, node),
|
||
unfed: (node, port) => {
|
||
if (port.type === 'number') return { amount: { value: asNumber(inlineValue(node, port)) } };
|
||
// An Envelope that only moves numbers has no sound to shape.
|
||
if (node.type === INSTRUMENT_ENVELOPE && edgesOutOf(graph, node.id, 'out').length === 0) return SILENT;
|
||
walk.problem(
|
||
node,
|
||
port.type === 'trigger'
|
||
? 'Nothing triggers it: connect it to a Notes node'
|
||
: 'Nothing comes in: connect it to a Sample',
|
||
);
|
||
return null;
|
||
},
|
||
conform: (node, port, value) => {
|
||
if ('curve' in value) {
|
||
if (port.type === 'number' && port.followsEnvelope)
|
||
return { amount: { value: value.curve.from.value, mod: value.curve } };
|
||
walk.problem(node, `"${port.label}" cannot follow an Envelope`);
|
||
return null;
|
||
}
|
||
const fits =
|
||
port.type === 'audio'
|
||
? 'plans' in value
|
||
: port.type === 'trigger'
|
||
? 'on' in value
|
||
: 'amount' in value;
|
||
if (fits) return value;
|
||
walk.problem(
|
||
node,
|
||
`"${port.label}" takes ${port.type === 'audio' ? 'a sound' : port.type === 'trigger' ? 'a trigger' : 'a number'}`,
|
||
);
|
||
return null;
|
||
},
|
||
// Only a trigger takes more than one edge: either one turns it on.
|
||
gather: (_node, _port, values) => ({ on: values.some((v) => 'on' in v && v.on) }),
|
||
});
|
||
|
||
function emitNode(def: Def, inputs: Record<string, Value>, node: GraphNode): Record<string, Value> {
|
||
const plansAt = (key: string) => {
|
||
const value = inputs[key];
|
||
return value && 'plans' in value ? value.plans : [];
|
||
};
|
||
const amounts: Record<string, FxAmount> = {};
|
||
for (const [key, value] of Object.entries(inputs)) if ('amount' in value) amounts[key] = value.amount;
|
||
switch (node.type) {
|
||
case INSTRUMENT_NOTES:
|
||
return notesOutputs(node, note);
|
||
case INSTRUMENT_PARAMETERS:
|
||
return Object.fromEntries(
|
||
(node.ports ?? []).map((port) => [
|
||
port.key,
|
||
{ amount: { value: port.default ?? 0, param: port.key } },
|
||
]),
|
||
);
|
||
case INSTRUMENT_SAMPLE: {
|
||
const plan = sampleOf(node, note);
|
||
const trigger = inputs.trigger;
|
||
return { out: plan && trigger && 'on' in trigger && trigger.on ? { plans: [plan] } : SILENT };
|
||
}
|
||
case INSTRUMENT_CHIP: {
|
||
const trigger = inputs.trigger;
|
||
if (!trigger || !('on' in trigger) || !trigger.on) return { out: SILENT };
|
||
return { out: { plans: [{ pitch: [], stages: [], chip: chipOf(node, note, amounts) }] } };
|
||
}
|
||
case INSTRUMENT_SOUND_FX: {
|
||
const fx = sources.soundFx(textOf(node.params.fx ?? ''));
|
||
const arriving = plansAt('in');
|
||
return { out: { plans: fx ? arriving.map((plan) => throughFx(fx, plan, amounts)) : arriving } };
|
||
}
|
||
case INSTRUMENT_ENVELOPE: {
|
||
const envelope = envelopeOf(node, amounts);
|
||
const curve = {
|
||
envelope,
|
||
from: held(amounts.from, ENVELOPE_ROWS.from),
|
||
to: held(amounts.to, ENVELOPE_ROWS.to),
|
||
};
|
||
return {
|
||
out: { plans: plansAt('in').map((plan) => withStages(plan, [{ kind: 'envelope', envelope }])) },
|
||
value: { curve },
|
||
};
|
||
}
|
||
case INSTRUMENT_OUTPUT:
|
||
return { out: { plans: plansAt('in') } };
|
||
}
|
||
if (!isSoundFxNodeDef(def)) return {};
|
||
if (def.type === SoundFxMix.type) {
|
||
const [a] = plansAt('a');
|
||
const [b] = plansAt('b');
|
||
if (!a || !b) return { out: { plans: a ? [a] : b ? [b] : [] } };
|
||
return { out: { plans: [def.emit({ a, b }, amounts, node.params)] } };
|
||
}
|
||
const [arriving] = plansAt('in');
|
||
return { out: arriving ? { plans: [def.emit({ in: arriving }, amounts, node.params)] } : SILENT };
|
||
}
|
||
|
||
/** A Sample's sound, or null after saying why it has none – also where its trigger is off. */
|
||
function sampleOf(node: GraphNode, played: number): FxPlan | null {
|
||
const id = textOf(node.params.sound ?? '');
|
||
const sound = id ? sources.sound(id) : null;
|
||
if (!id) walk.problem(node, 'No sound picked');
|
||
else if (!sound) walk.problem(node, 'That sound is gone');
|
||
else if (!sound.file) walk.problem(node, 'The sound has no file');
|
||
if (!sound?.file) return null;
|
||
const follows = node.params.followNote !== false;
|
||
const shift = follows ? played - noteParam(node.params, 'root', MIDDLE_C) : 0;
|
||
const play = samplePlay(node.params);
|
||
const loop = loops(play);
|
||
return {
|
||
pitch: shift === 0 ? [] : [{ value: shift }],
|
||
stages: [],
|
||
source: {
|
||
file: sound.file,
|
||
volume: sound.volume,
|
||
loop,
|
||
loopStart: loop ? Math.max(0, asNumber(node.params.loopStart ?? 0)) : 0,
|
||
loopEnd: loop ? Math.max(0, asNumber(node.params.loopEnd ?? 0)) : 0,
|
||
gate: play === 'gate' || play === 'loop',
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A Chip's numbers, held to its rows. Its frequency is the note's where it follows the note and
|
||
* nothing is connected – what it is connected to goes first, else the frequency typed in.
|
||
*/
|
||
function chipOf(node: GraphNode, played: number, amounts: Readonly<Record<string, FxAmount>>): FxChipPlan {
|
||
const rows = CHIP_ROWS;
|
||
const own =
|
||
edgesInto(graph, node.id, rows.frequency.key).length === 0 && node.params.followNote !== false;
|
||
return {
|
||
wave: chipWave(node.params),
|
||
frequency: own ? { value: noteFrequency(played) } : held(amounts.frequency, rows.frequency),
|
||
duty: held(amounts.duty, rows.duty),
|
||
dutySweep: held(amounts.dutySweep, rows.dutySweep),
|
||
slide: held(amounts.slide, rows.slide),
|
||
deltaSlide: held(amounts.deltaSlide, rows.deltaSlide),
|
||
minFrequency: held(amounts.minFrequency, rows.minFrequency),
|
||
vibratoDepth: held(amounts.vibratoDepth, rows.vibratoDepth),
|
||
vibratoSpeed: held(amounts.vibratoSpeed, rows.vibratoSpeed),
|
||
arpeggio: held(amounts.arpeggio, rows.arpeggio),
|
||
arpeggioTime: held(amounts.arpeggioTime, rows.arpeggioTime),
|
||
repeat: held(amounts.repeat, rows.repeat),
|
||
};
|
||
}
|
||
|
||
const output = graph.nodes.find((n) => n.type === INSTRUMENT_OUTPUT);
|
||
if (!output) return { layers: [], problems: walk.problems, reason: 'No Output node' };
|
||
// Saved while the Output still took any number of sounds: only the first would be heard.
|
||
if (edgesInto(graph, output.id, 'in').length > 1)
|
||
walk.problem(output, 'Takes one sound: put a Mix in front of it to play several');
|
||
const arrived = walk.input(output, InstrumentOutput.inputs[0]!);
|
||
const failed = walk.firstProblem();
|
||
if (failed !== null || !arrived || !('plans' in arrived))
|
||
return { layers: [], problems: walk.problems, reason: failed ?? 'Cannot compile' };
|
||
return { layers: arrived.plans, problems: walk.problems, reason: '' };
|
||
}
|
||
|
||
/** An Envelope's times and level, held to its rows; one fed by a parameter is filled in as its note starts. */
|
||
function envelopeOf(node: GraphNode, amounts: Readonly<Record<string, FxAmount>>): FxEnvelopePlan {
|
||
const rows = ENVELOPE_ROWS;
|
||
return {
|
||
id: node.id,
|
||
attack: held(amounts.attack, rows.attack),
|
||
decay: held(amounts.decay, rows.decay),
|
||
sustain: held(amounts.sustain, rows.sustain),
|
||
release: held(amounts.release, rows.release),
|
||
};
|
||
}
|
||
|
||
/** The outputs of a Notes node for one note: its triggers, and the note as numbers. */
|
||
function notesOutputs(node: GraphNode, note: number): Record<string, Value> {
|
||
const { from, to } = notesRange(node.params);
|
||
const inside = note >= from && note <= to;
|
||
const outputs: Record<string, Value> = {
|
||
any: { on: inside },
|
||
note: { amount: { value: note } },
|
||
frequency: { amount: { value: noteFrequency(note) } },
|
||
velocity: { amount: { value: 1, param: VELOCITY_PARAM, min: 0, max: 1 } },
|
||
};
|
||
if (singleNotes(node.params))
|
||
for (let at = from; at <= to; at++) outputs[noteOutputKey(at)] = { on: at === note };
|
||
return outputs;
|
||
}
|
||
|
||
/**
|
||
* A sound sent through a sound FX: the plan of the FX with the sound set in where its Input was, and
|
||
* each of its parameters bound to what arrives at the input of that name – held to the FX's row, as
|
||
* a value handed to the FX itself would be. One left open keeps its default.
|
||
*/
|
||
function throughFx(fx: FxPlan, sound: FxPlan, given: Readonly<Record<string, FxAmount>>): FxPlan {
|
||
const bind = (amount: FxAmount): FxAmount => {
|
||
if (amount.param === undefined) return amount;
|
||
const fed = given[amount.param];
|
||
if (!fed) return amount;
|
||
const value = Math.max(amount.min ?? -Infinity, Math.min(amount.max ?? Infinity, fed.value));
|
||
return fed.param === undefined
|
||
? { value }
|
||
: { value, param: fed.param, min: amount.min, max: amount.max };
|
||
};
|
||
const stages = fx.stages.map((stage) => bindStage(stage, bind));
|
||
if ('mix' in fx)
|
||
return {
|
||
...fx,
|
||
mix: bind(fx.mix),
|
||
a: throughFx(fx.a, sound, given),
|
||
b: throughFx(fx.b, sound, given),
|
||
stages,
|
||
};
|
||
const pitched = fx.pitch.reduce((plan, semitones) => withPitch(plan, bind(semitones)), sound);
|
||
return withStages(pitched, stages);
|
||
}
|
||
|
||
function bindStage(stage: FxStagePlan, bind: (amount: FxAmount) => FxAmount): FxStagePlan {
|
||
if (stage.kind === 'gain') return { ...stage, db: bind(stage.db) };
|
||
if (stage.kind === 'filter') return { ...stage, gain: bind(stage.gain) };
|
||
if (stage.kind === 'pass')
|
||
return { ...stage, cutoff: bind(stage.cutoff), resonance: bind(stage.resonance) };
|
||
if (stage.kind === 'envelope') return stage;
|
||
if (stage.kind === 'crush')
|
||
return { ...stage, bits: bind(stage.bits), rate: bind(stage.rate), mix: bind(stage.mix) };
|
||
if (stage.kind === 'phaser')
|
||
return {
|
||
...stage,
|
||
frequency: bind(stage.frequency),
|
||
rate: bind(stage.rate),
|
||
depth: bind(stage.depth),
|
||
feedback: bind(stage.feedback),
|
||
mix: bind(stage.mix),
|
||
};
|
||
if (stage.kind === 'flanger')
|
||
return {
|
||
...stage,
|
||
offset: bind(stage.offset),
|
||
sweep: bind(stage.sweep),
|
||
rate: bind(stage.rate),
|
||
depth: bind(stage.depth),
|
||
feedback: bind(stage.feedback),
|
||
mix: bind(stage.mix),
|
||
};
|
||
return {
|
||
...stage,
|
||
time: bind(stage.time),
|
||
feedback: bind(stage.feedback),
|
||
damping: bind(stage.damping),
|
||
mix: bind(stage.mix),
|
||
};
|
||
}
|
||
|
||
const builds = new Map<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;
|
||
}
|
||
|
||
/** The notes a Notes node with single notes has given a name of their own (a drum kit's). */
|
||
export function namedNotes(graph: Graph): Map<number, string> {
|
||
const names = new Map<number, string>();
|
||
for (const node of graph.nodes) {
|
||
if (node.type !== INSTRUMENT_NOTES || !singleNotes(node.params)) continue;
|
||
const { from, to } = notesRange(node.params);
|
||
for (let note = from; note <= to; note++) {
|
||
const name = textOf(node.params[noteNameKey(note)] ?? '').trim();
|
||
if (name && !names.has(note)) names.set(note, name);
|
||
}
|
||
}
|
||
return names;
|
||
}
|