The instruments could only come near a 303 with a Chip, a Filter and two envelopes. The biquad filter does not saturate, every note strikes anew, and there was no slide and no accent. - Acid is a new instrument node that plays a saw or a square through a four-pole ladder low-pass with tanh saturation, oversampled twice. Knobs: cutoff, resonance, env mod, decay, accent, tuning and slide time. - An accent comes from the velocity, from 0.9 on. It is louder, opens the filter further with a shorter decay, and accents close behind each other build up. - Two notes that touch or overlap slide. Each note stays a voice of its own, so the scheduler is unchanged. The notes of a line share one synth in the worklet, and the new voice takes it over from its first sample on. - The node sets its own volume, so it needs no envelope behind it. - The graph shows it as a device with the filter curve at rest and at the top of the sweep. - A preset Acid Bass puts an Acid in front of a short echo. Tests: - tsc, eslint, Prettier clean - Unit: 14 new tests in acid.test.ts for pitch, low-pass, resonance, sweep, accent, slide and the handover between two voices. - e2e 34 on melektaus-atx: ALL PASSED, 148 checks (3 new). The handover sits on sample 4800 and jumps by 0.0012, of three notes only the second slides. - Without the handover, or with a slide that strikes anew, the unit tests are red. - Gate on melektaus-atx: tsc, eslint, Prettier clean, 4753 of 4754 tests green. The red one was sound-library.test.ts, which did not know the new slid count in the stats. Fixed, and the file alone is green again with 128 tests. No second full gate. - Not checked: the sound by ear, slide at the keyboard, a bake with Acid, the CPU load of many notes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1700 lines
67 KiB
TypeScript
1700 lines
67 KiB
TypeScript
import type { DeclaredPort } from '../graph/Graph';
|
||
import { SOUND_FX_INPUT, SOUND_FX_OUTPUT } from '../graph/nested';
|
||
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
|
||
import { booleanOf, type PropertyBag, textOf } from '../model/Properties';
|
||
import { DEFAULT_TEMPO, heldTempo, secondsOfQuarters } from '../music/time';
|
||
import type { FlatDef } from '../properties/PropertySchema';
|
||
import { ACCENT_VELOCITY, type AcidShape, type AcidWave } from './acid';
|
||
import type { ChipShape, ChipWave } from './chip';
|
||
import { COMPRESSOR_LATENCY, compressorMakeup, limiterLatency } from './dynamics';
|
||
import { LFO_SHAPES, type LfoShape, vibratoDelay } from './lfo';
|
||
import { REVERB_MAX_DECAY, REVERB_PRESETS, REVERB_ROOMS, type ReverbRoom } from './reverb';
|
||
import {
|
||
SATURATION_TYPES,
|
||
saturationAutoGain,
|
||
type SaturationType,
|
||
SHAPER_LATENCY_FRAMES,
|
||
toneCutoff,
|
||
} from './saturation';
|
||
import { PHASER_STAGES } from './phaser';
|
||
import { eqBandsOf, eqStagesOf, hasGain, isEqBandHeard, MAX_EQ_BANDS, type PassBand } from './parametricEq';
|
||
|
||
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
|
||
|
||
/** The shapes of a Filter (#335, #318), Web Audio's names. */
|
||
export type FxPassFilter =
|
||
'lowpass' | 'highpass' | 'bandpass' | 'notch' | 'lowshelf' | 'highshelf' | 'peaking' | 'allpass';
|
||
|
||
/**
|
||
* One step a voice passes on its way out: a volume, a filter or a band of an EQ, the volume an
|
||
* Envelope gives a note (#335), or an echo (#327). An echo's `damping` is already the cutoff of the
|
||
* low-pass in its feedback, in Hz. A `mod` is an Envelope moving that number while the note plays.
|
||
*/
|
||
export type FxStage =
|
||
| { kind: 'gain'; gain: number; mod?: FxMod }
|
||
| FxPass
|
||
| { kind: 'envelope'; envelope: FxEnvelope }
|
||
| FxDelay
|
||
| FxCrush
|
||
| FxPhaser
|
||
| FxFlanger
|
||
| { kind: 'pan'; pan: number; mod?: FxMod }
|
||
| FxCompress
|
||
| FxLimit
|
||
| FxSaturate
|
||
| FxLfo<'tremolo'>
|
||
| FxLfo<'vibrato'>
|
||
| FxReverb
|
||
| FxWet
|
||
| FxTap;
|
||
|
||
/**
|
||
* Where the sound in front of a node or behind it can be listened to (#374); nothing is done to it.
|
||
* `id` names the node in the project (`tapId`), `at` the side.
|
||
*/
|
||
export interface FxTap {
|
||
kind: 'tap';
|
||
id: string;
|
||
at: 'in' | 'out';
|
||
}
|
||
|
||
/**
|
||
* What one node adds, blended with what arrives at it (#354): `wet` of its stages and the rest of the
|
||
* sound as it came in, held back as far as the stages make it late.
|
||
*/
|
||
export interface FxWet {
|
||
kind: 'wet';
|
||
wet: number;
|
||
wetMod?: FxMod;
|
||
stages: readonly FxStage[];
|
||
}
|
||
|
||
/**
|
||
* A Reverb (#328): the decay and damping its impulse response is made with, or the file of a sound
|
||
* that is its impulse response; the pre-delay before it, and how much of it is heard.
|
||
*/
|
||
export interface FxReverb {
|
||
kind: 'reverb';
|
||
decay: number;
|
||
damping: number;
|
||
predelay: number;
|
||
mix: number;
|
||
impulse?: string;
|
||
}
|
||
|
||
/** A Tremolo or a Vibrato (#320): its shape, its rate in Hz, its depth (0 to 1, or semitones). */
|
||
export interface FxLfo<K extends 'tremolo' | 'vibrato' = 'tremolo' | 'vibrato'> {
|
||
kind: K;
|
||
shape: LfoShape;
|
||
rate: number;
|
||
depth: number;
|
||
}
|
||
|
||
/**
|
||
* A Saturation (#323): the curve of its type, the drive in dB in front of it, the cutoff its tone
|
||
* opens the low-pass after it to, the level the bent sound is set to, and how much of it is heard.
|
||
*/
|
||
export interface FxSaturate {
|
||
kind: 'saturate';
|
||
curve: SaturationType;
|
||
drive: number;
|
||
tone: number;
|
||
level: number;
|
||
mix: number;
|
||
}
|
||
|
||
/**
|
||
* A Compressor (#326) as Web Audio's node takes it, in dB and seconds, and `gain`, the factor after
|
||
* it: makeup and auto-gain, less what the node adds by itself.
|
||
*/
|
||
export interface FxCompress {
|
||
kind: 'compress';
|
||
threshold: number;
|
||
knee: number;
|
||
ratio: number;
|
||
attack: number;
|
||
release: number;
|
||
gain: number;
|
||
}
|
||
|
||
/** A Limiter (#326) on the worklet: dB, seconds, and its auto-gain as 1 on or 0 off. */
|
||
export interface FxLimit {
|
||
kind: 'limit';
|
||
threshold: number;
|
||
ceiling: number;
|
||
release: number;
|
||
autoGain: number;
|
||
}
|
||
|
||
/** A Phaser (#341): its allpass stages, where they turn, how fast and how far an LFO moves them. */
|
||
export interface FxPhaser {
|
||
kind: 'phaser';
|
||
stages: number;
|
||
frequency: number;
|
||
rate: number;
|
||
depth: number;
|
||
feedback: number;
|
||
mix: number;
|
||
frequencyMod?: FxMod;
|
||
mixMod?: FxMod;
|
||
}
|
||
|
||
/** A Flanger (#341): its delay in ms, how it sweeps and swings, all as its worklet takes them. */
|
||
export interface FxFlanger {
|
||
kind: 'flanger';
|
||
offset: number;
|
||
sweep: number;
|
||
rate: number;
|
||
depth: number;
|
||
feedback: number;
|
||
mix: number;
|
||
offsetMod?: FxMod;
|
||
mixMod?: FxMod;
|
||
}
|
||
|
||
/** A Bitcrusher (#322): bits, the rate in Hz it holds the sound at, and how much of it is heard. */
|
||
export interface FxCrush {
|
||
kind: 'crush';
|
||
bits: number;
|
||
rate: number;
|
||
mix: number;
|
||
bitsMod?: FxMod;
|
||
rateMod?: FxMod;
|
||
mixMod?: FxMod;
|
||
}
|
||
|
||
/**
|
||
* A filter as Web Audio takes it: `q` in dB for a low- or high-pass, as a factor for the others,
|
||
* `gain` in dB for a shelf or a peak. `stages` of them run one after another for a steeper slope
|
||
* (#318); only the last has the Q.
|
||
*/
|
||
export interface FxPass {
|
||
kind: 'pass';
|
||
filter: FxPassFilter;
|
||
stages: number;
|
||
cutoff: number;
|
||
q: number;
|
||
gain: number;
|
||
cutoffMod?: FxMod;
|
||
qMod?: FxMod;
|
||
gainMod?: FxMod;
|
||
}
|
||
|
||
/**
|
||
* An Envelope as a note starts it (#335): seconds up to the top, down to `sustain` (0 to 1) and back
|
||
* to silence once the note ends. `id` is its node, so every number it moves follows one curve.
|
||
*/
|
||
export interface FxEnvelope {
|
||
id: string;
|
||
attack: number;
|
||
decay: number;
|
||
sustain: number;
|
||
release: number;
|
||
}
|
||
|
||
/** A number an Envelope moves: `from` at rest, before the note and after its release, `to` at the top. */
|
||
export interface FxMod {
|
||
envelope: FxEnvelope;
|
||
from: number;
|
||
to: number;
|
||
}
|
||
|
||
export interface FxEnvelopePlan {
|
||
id: string;
|
||
attack: FxAmount;
|
||
decay: FxAmount;
|
||
sustain: FxAmount;
|
||
release: FxAmount;
|
||
}
|
||
|
||
export interface FxModPlan {
|
||
envelope: FxEnvelopePlan;
|
||
from: FxAmount;
|
||
to: FxAmount;
|
||
}
|
||
|
||
export interface FxDelay {
|
||
kind: 'delay';
|
||
time: number;
|
||
feedback: number;
|
||
damping: number;
|
||
mix: number;
|
||
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 – one that loops leaves its
|
||
* loop then and plays what comes after it. 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 Chip as a way of a note plays it (#329): its frequency and duty, which an Envelope can move as
|
||
* `frequencyMod` and `dutyMod`, and the rest of its shape, fixed when the note starts.
|
||
*/
|
||
export interface FxChip extends ChipShape {
|
||
frequency: number;
|
||
duty: number;
|
||
frequencyMod?: FxMod;
|
||
dutyMod?: FxMod;
|
||
}
|
||
|
||
/** A Chip with its numbers still open. */
|
||
export type FxChipPlan = { wave: ChipWave } & Record<
|
||
Exclude<keyof FxChip, 'wave' | 'frequencyMod' | 'dutyMod'>,
|
||
FxAmount
|
||
>;
|
||
|
||
/**
|
||
* An Acid as a way of a note plays it (#421): the note's frequency with the tuning in it, whether it
|
||
* is accented, and its knobs, the cutoff of which an Envelope can move. `id` is its node: the notes
|
||
* of one Acid slide on a line of their own.
|
||
*/
|
||
export interface FxAcid extends AcidShape {
|
||
id: string;
|
||
frequency: number;
|
||
accented: boolean;
|
||
cutoff: number;
|
||
resonance: number;
|
||
envMod: number;
|
||
decay: number;
|
||
accent: number;
|
||
cutoffMod?: FxMod;
|
||
}
|
||
|
||
/** An Acid with its numbers still open; `velocity` is the note's, which says whether it is accented. */
|
||
export type FxAcidPlan = { id: string; wave: AcidWave } & Record<
|
||
'frequency' | 'tuning' | 'velocity' | 'slideTime' | 'cutoff' | 'resonance' | 'envMod' | 'decay' | 'accent',
|
||
FxAmount
|
||
>;
|
||
|
||
/**
|
||
* 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`
|
||
* says the way has a Pitch at all – at 0 too, when that comes from a parameter – and so is played
|
||
* by the pitch worklet (#317). Behind a Mix (#316) it is two sounds, `b` making up the share `mix`
|
||
* of what goes on through `stages` – or with the curve `both` each one full up to the middle and
|
||
* only fading on the other half. `pitchMods` and `mixMod` are Envelopes moving them (#335), on top
|
||
* of `pitch` and in place of `mix`.
|
||
*/
|
||
export type FxChain =
|
||
| {
|
||
pitch: number;
|
||
shifted: boolean;
|
||
stages: readonly FxStage[];
|
||
source?: FxSource;
|
||
chip?: FxChip;
|
||
acid?: FxAcid;
|
||
pitchMods?: readonly FxMod[];
|
||
}
|
||
| { mix: number; curve?: MixCurve; a: FxChain; b: FxChain; stages: readonly FxStage[]; mixMod?: FxMod };
|
||
|
||
/** How a Mix shares its two sounds out: `crossfade` (left out of a plan) or `both` at the middle. */
|
||
export type MixCurve = 'crossfade' | 'both';
|
||
|
||
/**
|
||
* A number of the graph as the compiler knows it: typed in, or a parameter of the Input that a
|
||
* `Play sound` may hand a value for (#311). `value` is what it is otherwise, and a parameter is held
|
||
* to the row it ends in only once its value is known. With `mod` an Envelope moves it (#335), and
|
||
* `value` is where it rests. `times` is a share it is taken by, as a Pitch's Wet takes its semitones
|
||
* (#354).
|
||
*/
|
||
export interface FxAmount {
|
||
value: number;
|
||
param?: string;
|
||
min?: number;
|
||
max?: number;
|
||
mod?: FxModPlan;
|
||
times?: FxAmount;
|
||
}
|
||
|
||
export type FxStagePlan =
|
||
| { kind: 'gain'; db: FxAmount }
|
||
| { kind: 'pass'; filter: FxPassFilter; stages: number; cutoff: FxAmount; q: FxAmount; gain: FxAmount }
|
||
| { kind: 'envelope'; envelope: FxEnvelopePlan }
|
||
| {
|
||
kind: 'delay';
|
||
/** Seconds, or quarter notes where `beats` (#332). */
|
||
time: FxAmount;
|
||
beats?: boolean;
|
||
feedback: FxAmount;
|
||
damping: FxAmount;
|
||
mix: FxAmount;
|
||
pingPong: boolean;
|
||
}
|
||
| { kind: 'crush'; bits: FxAmount; rate: FxAmount; mix: FxAmount }
|
||
| ({ kind: 'phaser'; stages: number } & Record<
|
||
'frequency' | 'rate' | 'depth' | 'feedback' | 'mix',
|
||
FxAmount
|
||
>)
|
||
| ({ kind: 'flanger' } & Record<'offset' | 'sweep' | 'rate' | 'depth' | 'feedback' | 'mix', FxAmount>)
|
||
| { kind: 'pan'; pan: FxAmount }
|
||
| ({ kind: 'compress'; autoGain: boolean } & Record<
|
||
'threshold' | 'knee' | 'ratio' | 'attack' | 'release' | 'makeup',
|
||
FxAmount
|
||
>)
|
||
| ({ kind: 'limit'; autoGain: boolean } & Record<'threshold' | 'ceiling' | 'release', FxAmount>)
|
||
| ({ kind: 'saturate'; curve: SaturationType; autoGain: boolean } & Record<
|
||
'drive' | 'tone' | 'output' | 'mix',
|
||
FxAmount
|
||
>)
|
||
| { kind: 'tremolo'; shape: LfoShape; rate: FxAmount; depth: FxAmount }
|
||
| { kind: 'vibrato'; shape: LfoShape; rate: FxAmount; depth: FxAmount }
|
||
| ({ kind: 'reverb'; impulse?: string } & Record<'decay' | 'damping' | 'predelay' | 'mix', FxAmount>)
|
||
| { kind: 'wet'; wet: FxAmount; stages: readonly FxStagePlan[] }
|
||
| FxTap;
|
||
|
||
/** 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[];
|
||
source?: FxSource;
|
||
chip?: FxChipPlan;
|
||
acid?: FxAcidPlan;
|
||
}
|
||
| { mix: FxAmount; curve?: MixCurve; a: FxPlan; b: FxPlan; stages: readonly FxStagePlan[] };
|
||
|
||
export const EMPTY_PLAN: FxPlan = { pitch: [], stages: [] };
|
||
|
||
/** Values for the parameters, by name; a name the plan does not have is left out. */
|
||
export type FxParams = Readonly<Record<string, number>>;
|
||
|
||
/**
|
||
* How a voice goes through its sound FX: the plan and the values it starts with, kept so they can
|
||
* change while it plays (#315), and the tempo a Delay synced to beats counts by (#332).
|
||
*/
|
||
export interface FxVoice {
|
||
plan: FxPlan;
|
||
params: FxParams;
|
||
tempo?: number;
|
||
}
|
||
|
||
export const DRY_VOICE: FxVoice = { plan: EMPTY_PLAN, params: {} };
|
||
|
||
/**
|
||
* A node of a sound FX graph (#311). It never runs: `emit` says what it does to the sounds arriving
|
||
* at its audio inputs, by input key, given the numbers at its other inputs and its own settings.
|
||
* Input and Output have no effect.
|
||
*/
|
||
export interface SoundFxNodeDef extends NodeDef {
|
||
category: 'sound';
|
||
/** The sound in front of the node and behind it can be listened to (#374), see `withTaps`. */
|
||
tapped?: true;
|
||
emit(
|
||
sounds: Readonly<Record<string, FxPlan>>,
|
||
amounts: Readonly<Record<string, FxAmount>>,
|
||
settings: PropertyBag,
|
||
project?: SoundFxProject,
|
||
): FxPlan;
|
||
}
|
||
|
||
/** What a node reaches of the project around it: the file a sound asset plays (#328), '' for none. */
|
||
export interface SoundFxProject {
|
||
soundFile(assetId: string): string;
|
||
}
|
||
|
||
export function isSoundFxNodeDef(def: NodeDef): def is SoundFxNodeDef {
|
||
return def.category === 'sound' && 'emit' in def;
|
||
}
|
||
|
||
const audioIn: PortDef = { key: 'in', label: 'in', type: 'audio' };
|
||
const audioOut: PortDef = { key: 'out', label: 'out', type: 'audio' };
|
||
|
||
function numberRow(key: string, label: string, min: number, max: number, step: number): FlatDef<'number'> {
|
||
return { key, label, kind: 'number', default: 0, min, max, step };
|
||
}
|
||
|
||
/** An input taking a number: typed in under the key the setting had, or fed from a parameter. */
|
||
function numberIn(row: FlatDef<'number'>): PortDef {
|
||
return { key: row.key, label: row.label, type: 'number', field: row };
|
||
}
|
||
|
||
/** A number the audio thread holds, which an Envelope can move while a note plays (#335). */
|
||
function followingIn(row: FlatDef<'number'>): PortDef {
|
||
return { ...numberIn(row), followsEnvelope: true };
|
||
}
|
||
|
||
function holdTo(value: number, row: FlatDef<'number'>): number {
|
||
if (!Number.isFinite(value)) return row.default;
|
||
return Math.max(row.min ?? -Infinity, Math.min(row.max ?? Infinity, value));
|
||
}
|
||
|
||
/**
|
||
* The amount at an input, held to its row, so a hand-edited project cannot blow a speaker. An
|
||
* Envelope's two ends are held there as well.
|
||
*/
|
||
export function held(amount: FxAmount | undefined, row: FlatDef<'number'>): FxAmount {
|
||
if (!amount) return { value: row.default };
|
||
if (amount.mod) {
|
||
const from = held(amount.mod.from, row);
|
||
return { value: from.value, mod: { ...amount.mod, from, to: held(amount.mod.to, row) } };
|
||
}
|
||
const value = holdTo(amount.value, row);
|
||
if (amount.param === undefined) return { value };
|
||
return { value, param: amount.param, min: row.min, max: row.max };
|
||
}
|
||
|
||
/** Whether an amount does nothing however the sound is played: a typed-in 0. */
|
||
const isNone = (amount: FxAmount) =>
|
||
amount.param === undefined && amount.mod === undefined && amount.value === 0;
|
||
|
||
/** Whether an amount is a number typed in, so a node can tell what it does already. */
|
||
const isFixed = (amount: FxAmount) => amount.param === undefined && amount.mod === undefined;
|
||
|
||
const arriving = (sounds: Readonly<Record<string, FxPlan>>) => sounds.in ?? EMPTY_PLAN;
|
||
|
||
export function withStages(plan: FxPlan, stages: readonly FxStagePlan[]): FxPlan {
|
||
return stages.length === 0 ? plan : { ...plan, stages: [...plan.stages, ...stages] };
|
||
}
|
||
|
||
/** A node by the asset its graph is in: node ids alone repeat in a copied asset. */
|
||
export const tapId = (assetId: string, nodeId: string): string => `${assetId}:${nodeId}`;
|
||
|
||
/**
|
||
* What a tapped node made of `before`, with a tap where its own stages begin and one behind them
|
||
* (#374): the sound as it came in, and as it goes on – blended by Wet, or not touched when bypassed.
|
||
*/
|
||
export function withTaps(before: FxPlan, after: FxPlan, id: string): FxPlan {
|
||
const at = before.stages.length;
|
||
return {
|
||
...after,
|
||
stages: [
|
||
...after.stages.slice(0, at),
|
||
{ kind: 'tap', id, at: 'in' },
|
||
...after.stages.slice(at),
|
||
{ kind: 'tap', id, at: 'out' },
|
||
],
|
||
};
|
||
}
|
||
|
||
const BYPASS = { key: 'bypass', label: 'Bypass', kind: 'boolean' as const, default: false };
|
||
export const WET = { ...numberRow('wet', 'Wet', 0, 1, 0.01), default: 1 };
|
||
|
||
/** Whether a node is bypassed (#354): the graph is compiled as if it were not there. */
|
||
const isBypassed = (settings: PropertyBag): boolean => settings[BYPASS.key] === true;
|
||
|
||
/**
|
||
* An effect with a Bypass and a Wet (#354). Bypassed, it hands on what arrives. Its Wet (`blend`)
|
||
* blends what it adds with what arrives, after any ports it derives; one with a wet of its own
|
||
* (`own`) has it already.
|
||
*/
|
||
function effect(def: SoundFxNodeDef, wet: 'blend' | 'own'): SoundFxNodeDef {
|
||
const wetIn = def.inputs.some((port) => port.followsEnvelope) ? followingIn(WET) : numberIn(WET);
|
||
const derive = def.derivePorts;
|
||
const ports: Partial<Pick<NodeDef, 'inputs' | 'derivePorts'>> =
|
||
wet === 'own'
|
||
? {}
|
||
: derive
|
||
? {
|
||
derivePorts: (source) => {
|
||
const grown = derive(source);
|
||
return { ...grown, inputs: [...(grown.inputs ?? []), wetIn] };
|
||
},
|
||
}
|
||
: { inputs: [...def.inputs, wetIn] };
|
||
return {
|
||
...def,
|
||
...ports,
|
||
params: [BYPASS, ...def.params],
|
||
hiddenParams: (params) => (isBypassed(params) ? [] : [BYPASS.key]),
|
||
dimmed: isBypassed,
|
||
emit: (sounds, amounts, settings, project) => {
|
||
if (isBypassed(settings)) return arriving(sounds);
|
||
const made = def.emit(sounds, amounts, settings, project);
|
||
return wet === 'own' ? made : blended(arriving(sounds), made, held(amounts.wet, WET));
|
||
},
|
||
};
|
||
}
|
||
|
||
/** The stages a node added to `before`, blended with it at `wet`; none at a typed-in 0, all at 1. */
|
||
function blended(before: FxPlan, after: FxPlan, wet: FxAmount): FxPlan {
|
||
const own = after.stages.slice(before.stages.length);
|
||
if (own.length === 0 || (isFixed(wet) && wet.value >= 1)) return after;
|
||
if (isFixed(wet) && wet.value <= 0) return before;
|
||
return withStages(before, [{ kind: 'wet', wet, stages: own }]);
|
||
}
|
||
|
||
/** A pitch is made where the sound is read, so behind a Mix it goes to both sounds. */
|
||
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] };
|
||
}
|
||
|
||
/** A parameter of the Input as the nodes it feeds see it. */
|
||
export function soundFxParamPort(port: DeclaredPort): PortDef {
|
||
return { key: port.key, label: port.key, type: 'number' };
|
||
}
|
||
|
||
export const SoundFxInput: SoundFxNodeDef = {
|
||
type: SOUND_FX_INPUT,
|
||
label: 'Input',
|
||
category: 'sound',
|
||
description:
|
||
'The sound as the Play sound node starts it, before any effect. Every parameter declared here is a number output with a default; a Play sound can hand in other values through a Sound FX parameters node.',
|
||
inputs: [],
|
||
outputs: [audioOut],
|
||
params: [],
|
||
declaresPorts: 'outputs',
|
||
portKinds: ['number'],
|
||
declaredPort: (port) => soundFxParamPort(port),
|
||
emit: () => EMPTY_PLAN,
|
||
};
|
||
|
||
export const SoundFxOutput: SoundFxNodeDef = {
|
||
type: SOUND_FX_OUTPUT,
|
||
label: 'Output',
|
||
category: 'sound',
|
||
description: 'What is heard: the sound as it arrives here, with every effect on the way.',
|
||
inputs: [audioIn],
|
||
outputs: [],
|
||
params: [],
|
||
emit: arriving,
|
||
};
|
||
|
||
export const GAIN_ROW = numberRow('gain', 'Gain (dB)', -120, 24, 0.5);
|
||
|
||
/** How an effect with a Bypass and a Wet says so, at the end of its description (#354). */
|
||
const BLENDS =
|
||
' "Bypass" leaves the node out as if it were not there. "wet" blends what it does with the sound as it came in: 1 is all of it, 0 none, 0.5 half of each.';
|
||
|
||
export const SoundFxGain: SoundFxNodeDef = effect(
|
||
{
|
||
type: 'SoundFxGain',
|
||
label: 'Gain',
|
||
category: 'sound',
|
||
description:
|
||
'Makes the sound louder or quieter, in decibels: 0 leaves it as it is, -6 is about half as loud, +6 twice. Above 0 it can clip.' +
|
||
BLENDS,
|
||
inputs: [audioIn, followingIn(GAIN_ROW)],
|
||
outputs: [audioOut],
|
||
params: [],
|
||
emit: (sounds, amounts) => {
|
||
const db = held(amounts.gain, GAIN_ROW);
|
||
return withStages(arriving(sounds), isNone(db) ? [] : [{ kind: 'gain', db }]);
|
||
},
|
||
},
|
||
'blend',
|
||
);
|
||
|
||
export const PITCH_ROW = numberRow('semitones', 'Semitones', -64, 64, 0.1);
|
||
|
||
/** The semitones of a Pitch taken by its Wet (#354): a typed-in share is worked in at once. */
|
||
function pitchTimes(semitones: FxAmount, wet: FxAmount): FxAmount {
|
||
if (!isFixed(wet)) return { ...semitones, times: wet };
|
||
if (wet.value === 1) return semitones;
|
||
if (isFixed(semitones)) return { value: semitones.value * wet.value };
|
||
return { ...semitones, times: wet };
|
||
}
|
||
|
||
export const SoundFxPitch: SoundFxNodeDef = effect(
|
||
{
|
||
type: 'SoundFxPitch',
|
||
label: 'Pitch',
|
||
category: 'sound',
|
||
description:
|
||
'Plays the sound higher or lower, in semitones: 12 is an octave up, 0.5 a quarter tone. The sound keeps its length, also when the pitch changes while it plays. The pitch is made where the sound is read, so an effect before the Pitch acts as if it came after it. On a Chip it moves the frequency of the Chip itself. "Bypass" leaves the node out as if it were not there. "wet" takes the semitones from 0 up to all of them: at 0.5 a Pitch of 12 plays 6 semitones higher.',
|
||
inputs: [audioIn, followingIn(PITCH_ROW), numberIn(WET)],
|
||
outputs: [audioOut],
|
||
params: [],
|
||
emit: (sounds, amounts) => {
|
||
const semitones = pitchTimes(held(amounts.semitones, PITCH_ROW), held(amounts.wet, WET));
|
||
return isNone(semitones) ? arriving(sounds) : withPitch(arriving(sounds), semitones);
|
||
},
|
||
},
|
||
'own',
|
||
);
|
||
|
||
export const SoundFxParametricEq: SoundFxNodeDef = effect(
|
||
{
|
||
type: 'SoundFxParametricEq',
|
||
label: 'Parametric EQ',
|
||
category: 'sound',
|
||
description: `Up to ${MAX_EQ_BANDS} filters working together, drawn as one curve and edited right in the node: double-click the curve to add a band, drag a band for its frequency and gain, turn the mouse wheel over it for its Q, and right-click it to remove it. Double-click the node's title to open the same in a tab, where the wheel anywhere else zooms the dB the curve shows. Drag the node's lower right corner to make it larger or smaller. Each band is a peak, a low or high shelf, a low or high cut, a band-pass or a notch, and a cut, a band-pass or a notch can be steeper with its slope. While the node or its tab is seen, the spectrum of what passes the node is drawn behind the curve: faint as it comes in, strong as it goes on.${BLENDS}`,
|
||
inputs: [audioIn],
|
||
outputs: [audioOut],
|
||
params: [],
|
||
tapped: true,
|
||
emit: (sounds, _amounts, settings) =>
|
||
withStages(
|
||
arriving(sounds),
|
||
eqBandsOf(settings)
|
||
.filter(isEqBandHeard)
|
||
.map((band) => ({
|
||
kind: 'pass',
|
||
filter: band.type,
|
||
stages: eqStagesOf(band),
|
||
cutoff: { value: band.frequency },
|
||
q: { value: band.q },
|
||
gain: { value: hasGain(band.type) ? band.gain : 0 },
|
||
})),
|
||
),
|
||
},
|
||
'blend',
|
||
);
|
||
|
||
/** The Q of a filter with no bump at its cutoff, as a factor. */
|
||
export const FLAT_Q = Math.SQRT1_2;
|
||
|
||
/** A Q as Web Audio takes it for that shape: in dB for a low- or high-pass, else the factor itself. */
|
||
export function passQ(filter: FxPassFilter, q: number): number {
|
||
return filter === 'lowpass' || filter === 'highpass' ? 20 * Math.log10(q) : q;
|
||
}
|
||
|
||
const CUTOFF = { ...numberRow('cutoff', 'Frequency (Hz)', 20, 20000, 1), default: 1000 };
|
||
const Q = { ...numberRow('q', 'Q', 0.1, 30, 0.001), default: 0.707 };
|
||
const FILTER_GAIN = numberRow('gain', 'Gain (dB)', -40, 40, 0.5);
|
||
export const FILTER_ROWS = { cutoff: CUTOFF, q: Q, gain: FILTER_GAIN };
|
||
const PASS_FILTERS: { value: FxPassFilter; label: string }[] = [
|
||
{ value: 'lowpass', label: 'Low-pass' },
|
||
{ value: 'highpass', label: 'High-pass' },
|
||
{ value: 'bandpass', label: 'Band-pass' },
|
||
{ value: 'notch', label: 'Notch' },
|
||
{ value: 'lowshelf', label: 'Low shelf' },
|
||
{ value: 'highshelf', label: 'High shelf' },
|
||
{ value: 'peaking', label: 'Peaking' },
|
||
{ value: 'allpass', label: 'All-pass' },
|
||
];
|
||
const PASS = {
|
||
key: 'filter',
|
||
label: 'Type',
|
||
kind: 'enum' as const,
|
||
default: 'lowpass',
|
||
options: PASS_FILTERS,
|
||
};
|
||
/** How steep a filter falls off (#318): 12 dB an octave for one, 24 for two, 48 for four in a row. */
|
||
const SLOPE = {
|
||
key: 'slope',
|
||
label: 'Slope',
|
||
kind: 'enum' as const,
|
||
default: '12',
|
||
options: [
|
||
{ value: '12', label: '12 dB/oct' },
|
||
{ value: '24', label: '24 dB/oct' },
|
||
{ value: '48', label: '48 dB/oct' },
|
||
],
|
||
};
|
||
/** The shapes that have a gain, and those that get steeper with more of them in a row. */
|
||
const GAINED: ReadonlySet<FxPassFilter> = new Set(['lowshelf', 'highshelf', 'peaking']);
|
||
const SLOPED: ReadonlySet<FxPassFilter> = new Set(['lowpass', 'highpass', 'bandpass', 'notch']);
|
||
|
||
export function passOf(settings: PropertyBag): FxPassFilter {
|
||
return PASS_FILTERS.find((option) => option.value === settings[PASS.key])?.value ?? 'lowpass';
|
||
}
|
||
|
||
/** How many filters a Filter runs in a row: one for a shape a slope does nothing for. */
|
||
export function passStagesOf(settings: PropertyBag): number {
|
||
if (!SLOPED.has(passOf(settings))) return 1;
|
||
const slope = settings[SLOPE.key];
|
||
return slope === '48' ? 4 : slope === '24' ? 2 : 1;
|
||
}
|
||
|
||
export const SoundFxFilter: SoundFxNodeDef = effect(
|
||
{
|
||
type: 'SoundFxFilter',
|
||
label: 'Filter',
|
||
category: 'sound',
|
||
description:
|
||
'Takes away what is above "frequency" (low-pass), what is below it (high-pass), all but a band around it (band-pass) or just that band (notch). A low or high shelf raises or lowers everything below or above it by "gain" in dB, a peak a band around it. An all-pass leaves every frequency as loud as it was and only turns its phase. "Q" from 0.1 to 30 says how sharp it is: at 0.707 a low- or high-pass has no bump, higher it rings at the frequency, and a band, a notch or a peak gets narrower. "Slope" makes a low-pass, high-pass, band-pass or notch steeper: 12 dB an octave is one filter, 24 two and 48 four in a row, and only the last of them has the Q. All three numbers can change while the sound plays, the frequency gliding in octaves, and in an instrument an Envelope can move them, for a filter sweep. The node draws what it does as a curve and is edited right there: drag the handle for the frequency and the gain, and type the numbers below it; a number fed by a link cannot be changed there. Double-click the title of the node to open the same in a tab, where the wheel turns the Q over the handle and zooms the dB elsewhere. While the node or its tab is seen, the spectrum of what passes the node is drawn behind the curve: faint as it comes in, strong as it goes on. Drag the lower right corner of the node to make it larger or smaller.' +
|
||
BLENDS,
|
||
inputs: [audioIn, followingIn(CUTOFF), followingIn(Q)],
|
||
outputs: [audioOut],
|
||
params: [PASS, SLOPE],
|
||
tapped: true,
|
||
idleParams: (params) => (SLOPED.has(passOf(params)) ? [] : [SLOPE.key]),
|
||
derivePorts: ({ node }) => ({
|
||
inputs: GAINED.has(passOf(node.params)) ? [followingIn(FILTER_GAIN)] : [],
|
||
}),
|
||
emit: (sounds, amounts, settings) =>
|
||
withStages(arriving(sounds), [
|
||
{
|
||
kind: 'pass',
|
||
filter: passOf(settings),
|
||
stages: passStagesOf(settings),
|
||
cutoff: held(amounts.cutoff, CUTOFF),
|
||
q: held(amounts.q, Q),
|
||
gain: GAINED.has(passOf(settings)) ? held(amounts.gain, FILTER_GAIN) : { value: 0 },
|
||
},
|
||
]),
|
||
},
|
||
'blend',
|
||
);
|
||
|
||
/** The shapes a Filter offers and its two settings, for its editor (#385). */
|
||
export const FILTER_TYPES: readonly { value: FxPassFilter; label: string }[] = PASS_FILTERS;
|
||
export const FILTER_SETTINGS = { type: PASS.key, slope: SLOPE.key };
|
||
|
||
/** A Filter as one band of the curve, from what is typed into it (#385). */
|
||
export function filterBandOf(params: PropertyBag): PassBand {
|
||
const type = passOf(params);
|
||
const number = (row: FlatDef<'number'>) => {
|
||
const value = params[row.key];
|
||
const n = typeof value === 'number' && Number.isFinite(value) ? value : row.default;
|
||
return Math.max(row.min ?? n, Math.min(row.max ?? n, n));
|
||
};
|
||
const slope = Number(params[SLOPE.key]);
|
||
return {
|
||
type,
|
||
frequency: number(CUTOFF),
|
||
gain: GAINED.has(type) ? number(FILTER_GAIN) : 0,
|
||
q: number(Q),
|
||
slope: slope === 24 || slope === 48 ? slope : 12,
|
||
};
|
||
}
|
||
|
||
export const MIX_ROW = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.5 };
|
||
export const MIX_CURVE_ROW = {
|
||
key: 'curve',
|
||
label: 'Curve',
|
||
kind: 'enum' as const,
|
||
default: 'crossfade',
|
||
options: [
|
||
{ value: 'crossfade', label: 'Crossfade' },
|
||
{ value: 'both', label: 'Both at center' },
|
||
],
|
||
};
|
||
|
||
export const SoundFxMix: SoundFxNodeDef = {
|
||
type: 'SoundFxMix',
|
||
label: 'Mix',
|
||
category: 'sound',
|
||
description:
|
||
'Blends two sounds: at 0 only a is heard, at 1 only b. With the curve "Crossfade" 0.5 is half of each. With "Both at center" each stays full up to the middle and only fades on the other half: 0.25 is a full and b half, 0.5 both full, added as two sounds on top of each other. Both ways can start at the same Input, each with its own effects, its own Pitch included.',
|
||
inputs: [
|
||
{ key: 'a', label: 'a', type: 'audio' },
|
||
{ key: 'b', label: 'b', type: 'audio' },
|
||
followingIn(MIX_ROW),
|
||
],
|
||
outputs: [audioOut],
|
||
params: [MIX_CURVE_ROW],
|
||
emit: (sounds, amounts, settings) => {
|
||
const mix = held(amounts.mix, MIX_ROW);
|
||
const a = sounds.a ?? EMPTY_PLAN;
|
||
const b = sounds.b ?? EMPTY_PLAN;
|
||
if (isFixed(mix) && mix.value <= 0) return a;
|
||
if (isFixed(mix) && mix.value >= 1) return b;
|
||
return settings[MIX_CURVE_ROW.key] === 'both'
|
||
? { mix, curve: 'both', a, b, stages: [] }
|
||
: { mix, a, b, stages: [] };
|
||
},
|
||
};
|
||
|
||
/** As long as a delay line is ever made: every voice holds one this long, whatever its time. */
|
||
export const DELAY_MAX = 2;
|
||
/** Up to two bars of 4/4 in beats; whatever it comes to is held to `DELAY_MAX` seconds. */
|
||
const DELAY_TIME = { ...numberRow('time', 'Time', 0.01, 8, 0.01), default: 0.25 };
|
||
export const DELAY_SYNC = {
|
||
key: 'sync',
|
||
label: 'Sync',
|
||
kind: 'enum' as const,
|
||
default: 'seconds',
|
||
options: [
|
||
{ value: 'seconds', label: 'Seconds' },
|
||
{ value: 'beats', label: 'Beats' },
|
||
],
|
||
};
|
||
/** Never 1: the echoes would never fade, and above it they would grow. */
|
||
const DELAY_FEEDBACK = { ...numberRow('feedback', 'Feedback', 0, 0.95, 0.01), default: 0.4 };
|
||
const DELAY_DAMPING = { ...numberRow('damping', 'Damping', 0, 1, 0.01), default: 0.3 };
|
||
/** Wet of a Delay (#354): 0 the sound alone, 0.5 the sound and its echoes in full, 1 the echoes alone. */
|
||
const DELAY_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 0.5 };
|
||
export const PING_PONG = { key: 'pingPong', label: 'Ping-pong', kind: 'boolean' as const, default: false };
|
||
/** A Delay's numbers, for its device in the graph (#393). */
|
||
export const DELAY_ROWS = {
|
||
time: DELAY_TIME,
|
||
feedback: DELAY_FEEDBACK,
|
||
damping: DELAY_DAMPING,
|
||
mix: DELAY_MIX,
|
||
};
|
||
|
||
/** How loud a Delay at `wet` lets the sound itself through, and how loud its echoes (#354). */
|
||
export function delayShares(wet: number): { dry: number; echo: number } {
|
||
return { dry: Math.min(1, 2 * (1 - wet)), echo: Math.min(1, 2 * wet) };
|
||
}
|
||
|
||
/**
|
||
* How many echoes of a Delay are heard (#324): the echo `k` is `echo * feedback^(k-1)` as loud as
|
||
* the sound, and the last one counted is still above silence. Its tail, and its device's screen (#393).
|
||
*/
|
||
export function delayEchoes(feedback: number, mix: number): number {
|
||
const { echo } = delayShares(mix);
|
||
if (!(echo >= SILENT)) return 0;
|
||
return feedback > 0 ? 1 + Math.floor(Math.log(SILENT / echo) / Math.log(feedback)) : 1;
|
||
}
|
||
|
||
export const SoundFxDelay: SoundFxNodeDef = effect(
|
||
{
|
||
type: 'SoundFxDelay',
|
||
label: 'Delay',
|
||
category: 'sound',
|
||
description:
|
||
'Echoes the sound: it comes back after "time", again and again, each echo "feedback" times as loud as the one before. "time" is in seconds, or with "sync" on beats in quarter notes at the tempo of the voice: 1 is a quarter, 0.5 an eighth, 0.75 a dotted eighth, 0.333 an eighth triplet. A Play sound or Play note says that tempo, a song its own. Either way an echo comes back after 2 s at most. "damping" makes every echo duller than the last, like tape. "wet" 0 is the sound alone, 0.5 the sound and its echoes both in full, 1 the echoes alone; between them one fades as the other stays. With "ping-pong" the echoes take turns left and right. The echoes ring on after the sound ends; a voice lasts until they have faded. "Bypass" leaves the node out as if it were not there.',
|
||
inputs: [audioIn, ...[DELAY_TIME, DELAY_FEEDBACK, DELAY_DAMPING, DELAY_MIX].map(numberIn)],
|
||
outputs: [audioOut],
|
||
params: [DELAY_SYNC, PING_PONG],
|
||
emit: (sounds, amounts, settings) => {
|
||
const mix = held(amounts.mix, DELAY_MIX);
|
||
if (isNone(mix)) return arriving(sounds);
|
||
return withStages(arriving(sounds), [
|
||
{
|
||
kind: 'delay',
|
||
time: held(amounts.time, DELAY_TIME),
|
||
beats: settings[DELAY_SYNC.key] === 'beats',
|
||
feedback: held(amounts.feedback, DELAY_FEEDBACK),
|
||
damping: held(amounts.damping, DELAY_DAMPING),
|
||
mix,
|
||
pingPong: settings[PING_PONG.key] === true,
|
||
},
|
||
]);
|
||
},
|
||
},
|
||
'own',
|
||
);
|
||
|
||
const CRUSH_BITS = { ...numberRow('bits', 'Bits', 1, 16, 0.1), default: 8 };
|
||
const CRUSH_RATE = { ...numberRow('rate', 'Rate (Hz)', 100, 48000, 1), default: 8000 };
|
||
const CRUSH_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 1 };
|
||
export const CRUSH_ROWS = { bits: CRUSH_BITS, rate: CRUSH_RATE, mix: CRUSH_MIX };
|
||
|
||
/** How a node whose wet was its "mix" says the Bypass. */
|
||
const BYPASSES = ' "Bypass" leaves the node out as if it were not there.';
|
||
|
||
export const SoundFxBitcrusher: SoundFxNodeDef = effect(
|
||
{
|
||
type: 'SoundFxBitcrusher',
|
||
label: 'Bitcrusher',
|
||
category: 'sound',
|
||
description:
|
||
'Makes the sound rough, like an old game console. "bits" rounds it to fewer steps: 16 is hardly heard, 8 crackles, 4 and below hisses and buzzes. Bits need not be whole, 3.5 lies between 3 and 4. "rate" takes a sample only so many times a second and holds it until the next: 48000 leaves the sound as it is, 8000 is dull and metallic, a few hundred Hz is a buzz of its own. "wet" is how much of the crushed sound is heard instead of the sound itself. All three can change while the sound plays, and in an instrument an Envelope can move them.' +
|
||
BYPASSES,
|
||
inputs: [audioIn, ...[CRUSH_BITS, CRUSH_RATE, CRUSH_MIX].map(followingIn)],
|
||
outputs: [audioOut],
|
||
params: [],
|
||
emit: (sounds, amounts) => {
|
||
const mix = held(amounts.mix, CRUSH_MIX);
|
||
if (isNone(mix)) return arriving(sounds);
|
||
return withStages(arriving(sounds), [
|
||
{ kind: 'crush', bits: held(amounts.bits, CRUSH_BITS), rate: held(amounts.rate, CRUSH_RATE), mix },
|
||
]);
|
||
},
|
||
},
|
||
'own',
|
||
);
|
||
|
||
const PHASER_FREQUENCY = { ...numberRow('frequency', 'Frequency (Hz)', 20, 20000, 1), default: 800 };
|
||
const PHASER_RATE = { ...numberRow('rate', 'Rate (Hz)', 0, 10, 0.01), default: 0.5 };
|
||
const PHASER_DEPTH = { ...numberRow('depth', 'Depth (oct)', 0, 6, 0.1), default: 2 };
|
||
const PHASER_FEEDBACK = { ...numberRow('feedback', 'Feedback', 0, 0.95, 0.01), default: 0.4 };
|
||
const PHASER_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 1 };
|
||
export const PHASER_ROWS = {
|
||
frequency: PHASER_FREQUENCY,
|
||
rate: PHASER_RATE,
|
||
depth: PHASER_DEPTH,
|
||
feedback: PHASER_FEEDBACK,
|
||
mix: PHASER_MIX,
|
||
};
|
||
export const PHASER_STAGE_ROW = {
|
||
key: 'stages',
|
||
label: 'Stages',
|
||
kind: 'enum' as const,
|
||
default: '4',
|
||
options: PHASER_STAGES.map((n) => ({ value: String(n), label: String(n) })),
|
||
};
|
||
|
||
export function phaserStagesOf(settings: PropertyBag): number {
|
||
const stages = Number(settings[PHASER_STAGE_ROW.key] ?? PHASER_STAGE_ROW.default);
|
||
return PHASER_STAGES.find((n) => n === stages) ?? 4;
|
||
}
|
||
|
||
export const SoundFxPhaser: SoundFxNodeDef = effect(
|
||
{
|
||
type: 'SoundFxPhaser',
|
||
label: 'Phaser',
|
||
category: 'sound',
|
||
description:
|
||
'Sweeps notches through the sound, the swirl of a phaser pedal. "stages" is how many allpass stages the sound passes: every two make one notch, so 4 has two. They sit around "frequency" and an LFO moves them up and down, "depth" octaves wide, "rate" times a second. "feedback" sharpens the notches into a ring. "wet" 0 leaves the sound as it is, 1 is half sound and half phased, where the notches are deepest; it never gets louder. In an instrument an Envelope can move frequency and wet while a note plays.' +
|
||
BYPASSES,
|
||
inputs: [
|
||
audioIn,
|
||
followingIn(PHASER_FREQUENCY),
|
||
numberIn(PHASER_RATE),
|
||
numberIn(PHASER_DEPTH),
|
||
numberIn(PHASER_FEEDBACK),
|
||
followingIn(PHASER_MIX),
|
||
],
|
||
outputs: [audioOut],
|
||
params: [PHASER_STAGE_ROW],
|
||
emit: (sounds, amounts, settings) => {
|
||
const mix = held(amounts.mix, PHASER_MIX);
|
||
if (isNone(mix)) return arriving(sounds);
|
||
return withStages(arriving(sounds), [
|
||
{
|
||
kind: 'phaser',
|
||
stages: phaserStagesOf(settings),
|
||
frequency: held(amounts.frequency, PHASER_FREQUENCY),
|
||
rate: held(amounts.rate, PHASER_RATE),
|
||
depth: held(amounts.depth, PHASER_DEPTH),
|
||
feedback: held(amounts.feedback, PHASER_FEEDBACK),
|
||
mix,
|
||
},
|
||
]);
|
||
},
|
||
},
|
||
'own',
|
||
);
|
||
|
||
const FLANGER_OFFSET = { ...numberRow('offset', 'Offset (ms)', 0, 10, 0.01), default: 1 };
|
||
const FLANGER_SWEEP = numberRow('sweep', 'Sweep (ms/s)', -125, 125, 0.1);
|
||
const FLANGER_RATE = { ...numberRow('rate', 'Rate (Hz)', 0, 10, 0.01), default: 0.25 };
|
||
const FLANGER_DEPTH = { ...numberRow('depth', 'Depth (ms)', 0, 10, 0.01), default: 2 };
|
||
const FLANGER_FEEDBACK = { ...numberRow('feedback', 'Feedback', 0, 0.95, 0.01), default: 0.5 };
|
||
const FLANGER_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 1 };
|
||
export const FLANGER_ROWS = {
|
||
offset: FLANGER_OFFSET,
|
||
sweep: FLANGER_SWEEP,
|
||
rate: FLANGER_RATE,
|
||
depth: FLANGER_DEPTH,
|
||
feedback: FLANGER_FEEDBACK,
|
||
mix: FLANGER_MIX,
|
||
};
|
||
|
||
export const SoundFxFlanger: SoundFxNodeDef = effect(
|
||
{
|
||
type: 'SoundFxFlanger',
|
||
label: 'Flanger',
|
||
category: 'sound',
|
||
description:
|
||
'Blends the sound with a copy of itself a few milliseconds late, which combs out a row of frequencies: the jet sound of a flanger, and what sfxr calls its phaser. "offset" is how late the copy is when the sound starts. "sweep" moves that on by so many ms a second, for as long as the sound plays; one sweeping down passes 0 and comes back up, as in sfxr. An LFO adds up to "depth" ms to it, "rate" times a second; at depth 0 the copy only sweeps. The copy is never more than 20 ms late. "feedback" sends it back in, for a sharper, ringing comb. "wet" 0 leaves the sound as it is, 1 is half sound and half copy, where the comb is deepest; it never gets louder. In an instrument an Envelope can move offset and wet while a note plays.' +
|
||
BYPASSES,
|
||
inputs: [
|
||
audioIn,
|
||
followingIn(FLANGER_OFFSET),
|
||
numberIn(FLANGER_SWEEP),
|
||
numberIn(FLANGER_RATE),
|
||
numberIn(FLANGER_DEPTH),
|
||
numberIn(FLANGER_FEEDBACK),
|
||
followingIn(FLANGER_MIX),
|
||
],
|
||
outputs: [audioOut],
|
||
params: [],
|
||
emit: (sounds, amounts) => {
|
||
const mix = held(amounts.mix, FLANGER_MIX);
|
||
if (isNone(mix)) return arriving(sounds);
|
||
return withStages(arriving(sounds), [
|
||
{
|
||
kind: 'flanger',
|
||
offset: held(amounts.offset, FLANGER_OFFSET),
|
||
sweep: held(amounts.sweep, FLANGER_SWEEP),
|
||
rate: held(amounts.rate, FLANGER_RATE),
|
||
depth: held(amounts.depth, FLANGER_DEPTH),
|
||
feedback: held(amounts.feedback, FLANGER_FEEDBACK),
|
||
mix,
|
||
},
|
||
]);
|
||
},
|
||
},
|
||
'own',
|
||
);
|
||
|
||
export const PAN_ROW = numberRow('pan', 'Pan', -1, 1, 0.01);
|
||
|
||
export const SoundFxPan: SoundFxNodeDef = effect(
|
||
{
|
||
type: 'SoundFxPan',
|
||
label: 'Pan',
|
||
category: 'sound',
|
||
description:
|
||
'Moves the sound left or right: -1 is all left, 1 all right, 0 leaves it in the middle. A mono sound is spread on both sides and then moved. A stereo sound keeps the side it goes to, and the other side moves over into it: at 1 both sides are heard on the right. It can change while the sound plays, and in an instrument an Envelope can move it.' +
|
||
BLENDS,
|
||
inputs: [audioIn, followingIn(PAN_ROW)],
|
||
outputs: [audioOut],
|
||
params: [],
|
||
emit: (sounds, amounts) => {
|
||
const pan = held(amounts.pan, PAN_ROW);
|
||
return withStages(arriving(sounds), isNone(pan) ? [] : [{ kind: 'pan', pan }]);
|
||
},
|
||
},
|
||
'blend',
|
||
);
|
||
|
||
const COMPRESS_THRESHOLD = { ...numberRow('threshold', 'Threshold (dB)', -100, 0, 0.5), default: -24 };
|
||
const COMPRESS_RATIO = { ...numberRow('ratio', 'Ratio', 1, 20, 0.1), default: 4 };
|
||
const COMPRESS_KNEE = { ...numberRow('knee', 'Knee (dB)', 0, 40, 0.5), default: 6 };
|
||
const COMPRESS_ATTACK = { ...numberRow('attack', 'Attack (s)', 0, 1, 0.001), default: 0.003 };
|
||
const COMPRESS_RELEASE = { ...numberRow('release', 'Release (s)', 0, 1, 0.001), default: 0.25 };
|
||
const MAKEUP = numberRow('makeup', 'Makeup (dB)', -24, 24, 0.5);
|
||
/**
|
||
* Auto-gain of a Compressor, Limiter and Saturation: a switch, with the node's own gain on top of
|
||
* it. Until #378 it was a number from 0 to 1 blending the two, so a number read is on above 0.
|
||
*/
|
||
export const COMPRESS_AUTO_GAIN = {
|
||
key: 'autoGain',
|
||
label: 'Auto-gain',
|
||
kind: 'boolean' as const,
|
||
default: false,
|
||
};
|
||
|
||
/** Whether a node's switch is on: as it was set, or its default where it never was. */
|
||
export function switchOn(settings: PropertyBag, row: { key: string; default: boolean }): boolean {
|
||
const value = settings[row.key];
|
||
return value === undefined ? row.default : booleanOf(value);
|
||
}
|
||
|
||
/** The numbers of a Compressor, in the order its tab shows them as knobs (#378). */
|
||
export const COMPRESS_ROWS = {
|
||
threshold: COMPRESS_THRESHOLD,
|
||
ratio: COMPRESS_RATIO,
|
||
knee: COMPRESS_KNEE,
|
||
attack: COMPRESS_ATTACK,
|
||
release: COMPRESS_RELEASE,
|
||
makeup: MAKEUP,
|
||
};
|
||
|
||
export const SoundFxCompressor: SoundFxNodeDef = effect(
|
||
{
|
||
type: 'SoundFxCompressor',
|
||
label: 'Compressor',
|
||
category: 'sound',
|
||
tapped: true,
|
||
description:
|
||
'Squeezes the loudness of the sound together: what goes above "threshold" (dB) comes out only 1/"ratio" as much louder, so 4 makes 8 dB over it 2 dB. "knee" (dB) eases into that from the threshold on instead of at once. "attack" is how fast it turns down once the sound is over the threshold, "release" how fast it lets go, both in seconds. "makeup" (dB) raises it again afterwards. "Auto-gain" on gives back what it takes from a sound at -18 dB, so that one comes out as loud as it went in; the makeup is added on top. It is 6 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays. The node is drawn as a device: a knob for each number, a meter of what it takes away and LEDs for the level going out. Double-click it to open its tab: its curve with threshold and ratio to drag, and the level coming in and going out over the last seconds.' +
|
||
BLENDS,
|
||
inputs: [audioIn, ...Object.values(COMPRESS_ROWS).map(numberIn)],
|
||
outputs: [audioOut],
|
||
params: [COMPRESS_AUTO_GAIN],
|
||
emit: (sounds, amounts, settings) =>
|
||
withStages(arriving(sounds), [
|
||
{
|
||
kind: 'compress',
|
||
threshold: held(amounts.threshold, COMPRESS_THRESHOLD),
|
||
knee: held(amounts.knee, COMPRESS_KNEE),
|
||
ratio: held(amounts.ratio, COMPRESS_RATIO),
|
||
attack: held(amounts.attack, COMPRESS_ATTACK),
|
||
release: held(amounts.release, COMPRESS_RELEASE),
|
||
makeup: held(amounts.makeup, MAKEUP),
|
||
autoGain: switchOn(settings, COMPRESS_AUTO_GAIN),
|
||
},
|
||
]),
|
||
},
|
||
'blend',
|
||
);
|
||
|
||
const LIMIT_THRESHOLD = { ...numberRow('threshold', 'Threshold (dB)', -60, 0, 0.5), default: -6 };
|
||
const CEILING = { ...numberRow('ceiling', 'Ceiling (dB)', -30, 0, 0.1), default: -1 };
|
||
const LIMIT_RELEASE = { ...numberRow('release', 'Release (s)', 0.001, 2, 0.001), default: 0.1 };
|
||
export const LIMIT_AUTO_GAIN = { ...COMPRESS_AUTO_GAIN, default: true };
|
||
export const LIMIT_ROWS = {
|
||
threshold: LIMIT_THRESHOLD,
|
||
ceiling: CEILING,
|
||
release: LIMIT_RELEASE,
|
||
};
|
||
|
||
export const SoundFxLimiter: SoundFxNodeDef = effect(
|
||
{
|
||
type: 'SoundFxLimiter',
|
||
label: 'Limiter',
|
||
category: 'sound',
|
||
tapped: true,
|
||
description:
|
||
'Keeps every peak of the sound below "threshold" (dB), however loud it comes in: it looks 5 ms ahead and has the volume down by the time the peak is heard, then lets it come back up in "release" seconds. "Auto-gain" on raises the limited sound to "ceiling" (dB), so its peaks sit right at the ceiling; off they sit at the threshold. Nothing ever goes above the ceiling, and a threshold above the ceiling limits at the ceiling. It is 5 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays. The node is drawn as a device: a knob for each number, and a screen of how much it takes away and when, from the moment a sound starts, held once the sound ends.' +
|
||
BLENDS,
|
||
inputs: [audioIn, ...Object.values(LIMIT_ROWS).map(numberIn)],
|
||
outputs: [audioOut],
|
||
params: [LIMIT_AUTO_GAIN],
|
||
emit: (sounds, amounts, settings) =>
|
||
withStages(arriving(sounds), [
|
||
{
|
||
kind: 'limit',
|
||
threshold: held(amounts.threshold, LIMIT_THRESHOLD),
|
||
ceiling: held(amounts.ceiling, CEILING),
|
||
release: held(amounts.release, LIMIT_RELEASE),
|
||
autoGain: switchOn(settings, LIMIT_AUTO_GAIN),
|
||
},
|
||
]),
|
||
},
|
||
'blend',
|
||
);
|
||
|
||
const DRIVE = { ...numberRow('drive', 'Drive (dB)', 0, 48, 0.5), default: 12 };
|
||
const TONE = { ...numberRow('tone', 'Tone', 0, 1, 0.01), default: 1 };
|
||
const OUTPUT = numberRow('output', 'Output (dB)', -24, 24, 0.5);
|
||
export const SATURATION_AUTO_GAIN = { ...COMPRESS_AUTO_GAIN, default: true };
|
||
const SATURATION_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 1 };
|
||
export const SATURATION_ROWS = {
|
||
drive: DRIVE,
|
||
tone: TONE,
|
||
output: OUTPUT,
|
||
mix: SATURATION_MIX,
|
||
};
|
||
export const SATURATION_TYPE = {
|
||
key: 'curve',
|
||
label: 'Type',
|
||
kind: 'enum' as const,
|
||
default: 'soft',
|
||
options: SATURATION_TYPES,
|
||
};
|
||
|
||
export function saturationTypeOf(settings: PropertyBag): SaturationType {
|
||
return SATURATION_TYPES.find((option) => option.value === settings[SATURATION_TYPE.key])?.value ?? 'soft';
|
||
}
|
||
|
||
export const SoundFxSaturation: SoundFxNodeDef = effect(
|
||
{
|
||
type: 'SoundFxSaturation',
|
||
label: 'Saturation',
|
||
category: 'sound',
|
||
description:
|
||
'Bends the sound, from warmth to a hard distortion. "Type" is the shape it is bent by: Soft (tanh) and Cubic round the peaks off, Hard clip cuts them, Tube and Fuzz bend one half harder than the other, Foldback and Sine fold mirror what goes past full scale back in, Rectify turns the lower half up, an octave higher. "drive" (dB) is how hard the sound is pushed into it. "tone" from 0 to 1 is a low-pass after it, from 1 kHz up to wide open. "Auto-gain" on keeps a sound at -12 dBFS as loud as it came in, and "output" (dB) is added on top; off, "output" alone sets the level of the bent sound. "wet" is how much of the bent sound is heard instead of the sound itself. It is 4 ms late (192 samples), which a voice, a Mix and a bus make up for. All numbers can change while the sound plays.' +
|
||
BYPASSES,
|
||
inputs: [audioIn, ...Object.values(SATURATION_ROWS).map(numberIn)],
|
||
outputs: [audioOut],
|
||
params: [SATURATION_TYPE, SATURATION_AUTO_GAIN],
|
||
emit: (sounds, amounts, settings) => {
|
||
const mix = held(amounts.mix, SATURATION_MIX);
|
||
if (isNone(mix)) return arriving(sounds);
|
||
return withStages(arriving(sounds), [
|
||
{
|
||
kind: 'saturate',
|
||
curve: saturationTypeOf(settings),
|
||
drive: held(amounts.drive, DRIVE),
|
||
tone: held(amounts.tone, TONE),
|
||
output: held(amounts.output, OUTPUT),
|
||
autoGain: switchOn(settings, SATURATION_AUTO_GAIN),
|
||
mix,
|
||
},
|
||
]);
|
||
},
|
||
},
|
||
'own',
|
||
);
|
||
|
||
export const LFO_SHAPE = {
|
||
key: 'shape',
|
||
label: 'Shape',
|
||
kind: 'enum' as const,
|
||
default: 'sine',
|
||
options: LFO_SHAPES,
|
||
};
|
||
|
||
export function lfoShapeOf(settings: PropertyBag): LfoShape {
|
||
return LFO_SHAPES.find((option) => option.value === settings[LFO_SHAPE.key])?.value ?? 'sine';
|
||
}
|
||
|
||
const TREMOLO_RATE = { ...numberRow('rate', 'Rate (Hz)', 0, 20, 0.01), default: 5 };
|
||
const TREMOLO_DEPTH = { ...numberRow('depth', 'Depth (%)', 0, 100, 1), default: 50 };
|
||
const VIBRATO_RATE = { ...numberRow('rate', 'Rate (Hz)', 0.5, 20, 0.01), default: 5 };
|
||
const VIBRATO_DEPTH = { ...numberRow('depth', 'Depth (st)', 0, 4, 0.01), default: 0.5 };
|
||
export const TREMOLO_ROWS = { rate: TREMOLO_RATE, depth: TREMOLO_DEPTH, wet: WET };
|
||
export const VIBRATO_ROWS = { rate: VIBRATO_RATE, depth: VIBRATO_DEPTH, wet: WET };
|
||
|
||
export const SoundFxTremolo: SoundFxNodeDef = effect(
|
||
{
|
||
type: 'SoundFxTremolo',
|
||
label: 'Tremolo',
|
||
category: 'sound',
|
||
description:
|
||
'Makes the volume swing, "rate" times a second: from full down by "depth" percent and back. "Shape" is how it swings: Sine and Triangle smoothly, Square on and off, Saw up and then down at once. It starts at the same point of its swing every time a sound starts. Rate and depth can change while the sound plays.' +
|
||
BLENDS,
|
||
inputs: [audioIn, numberIn(TREMOLO_RATE), numberIn(TREMOLO_DEPTH)],
|
||
outputs: [audioOut],
|
||
params: [LFO_SHAPE],
|
||
emit: (sounds, amounts, settings) => {
|
||
const depth = held(amounts.depth, TREMOLO_DEPTH);
|
||
if (isNone(depth)) return arriving(sounds);
|
||
return withStages(arriving(sounds), [
|
||
{ kind: 'tremolo', shape: lfoShapeOf(settings), rate: held(amounts.rate, TREMOLO_RATE), depth },
|
||
]);
|
||
},
|
||
},
|
||
'blend',
|
||
);
|
||
|
||
export const SoundFxVibrato: SoundFxNodeDef = effect(
|
||
{
|
||
type: 'SoundFxVibrato',
|
||
label: 'Vibrato',
|
||
category: 'sound',
|
||
description:
|
||
'Makes the pitch swing, "rate" times a second, "depth" semitones up and as far down. "Shape" is how it swings: Sine and Triangle smoothly, Square jumps between two pitches, Saw glides up and drops. It starts at the same point of its swing every time a sound starts, and the sound keeps its length. The pitch is moved by a short delay that swings: the voice is late by as long as it swings out at the rate and depth it starts with, at 5 Hz and half a semitone under a millisecond, which a voice, a Mix and a bus make up for. Rate and depth can change while the sound plays; turning the depth up or the rate down later than the start can flatten the lower half of the swing.' +
|
||
BLENDS,
|
||
inputs: [audioIn, numberIn(VIBRATO_RATE), numberIn(VIBRATO_DEPTH)],
|
||
outputs: [audioOut],
|
||
params: [LFO_SHAPE],
|
||
emit: (sounds, amounts, settings) => {
|
||
const depth = held(amounts.depth, VIBRATO_DEPTH);
|
||
if (isNone(depth)) return arriving(sounds);
|
||
return withStages(arriving(sounds), [
|
||
{ kind: 'vibrato', shape: lfoShapeOf(settings), rate: held(amounts.rate, VIBRATO_RATE), depth },
|
||
]);
|
||
},
|
||
},
|
||
'blend',
|
||
);
|
||
|
||
const REVERB_ROOM = {
|
||
key: 'room',
|
||
label: 'Type',
|
||
kind: 'enum' as const,
|
||
default: 'room',
|
||
options: REVERB_ROOMS,
|
||
};
|
||
const IMPULSE = { key: 'impulse', label: 'Sound', kind: 'sound' as const, default: '' };
|
||
const REVERB_DECAY = { ...numberRow('decay', 'Decay (s)', 0.1, REVERB_MAX_DECAY, 0.01), default: 1.5 };
|
||
const REVERB_DAMPING = { ...numberRow('damping', 'Damping', 0, 1, 0.01), default: 0.4 };
|
||
const PREDELAY = { ...numberRow('predelay', 'Pre-delay (s)', 0, 0.5, 0.001), default: 0.01 };
|
||
const REVERB_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 0.3 };
|
||
|
||
/** The numbers of a Reverb, for its device (#389). */
|
||
export const REVERB_ROWS = {
|
||
decay: REVERB_DECAY,
|
||
damping: REVERB_DAMPING,
|
||
predelay: PREDELAY,
|
||
mix: REVERB_MIX,
|
||
};
|
||
export const REVERB_ROOM_KEY = REVERB_ROOM.key;
|
||
export const REVERB_IMPULSE_KEY = IMPULSE.key;
|
||
|
||
export function reverbRoomOf(settings: PropertyBag): ReverbRoom {
|
||
return REVERB_ROOMS.find((option) => option.value === settings[REVERB_ROOM.key])?.value ?? 'room';
|
||
}
|
||
|
||
export const SoundFxReverb: SoundFxNodeDef = effect(
|
||
{
|
||
type: 'SoundFxReverb',
|
||
label: 'Reverb',
|
||
category: 'sound',
|
||
description:
|
||
'The sound in a space, from a small room to a cave. "Type" is a space with its own decay, damping and pre-delay (Room, Hall, Cave, Plate), Custom to set them yourself, or Sound to take a sound asset as the impulse response of a real space. "decay" (s, up to 10) is how long the reverb takes to fall by 60 dB, "damping" from 0 to 1 how much faster its highs fall, "pre-delay" (s) how long it waits before it starts. "wet" is how much of it is heard instead of the sound itself: 1 is the reverb alone, for a bus that only carries reverb. Only the wet can change while the sound plays; the rest is set when the sound starts, on a bus when it gets its FX. The sound lasts until the reverb has faded.' +
|
||
BYPASSES,
|
||
inputs: [audioIn, numberIn(REVERB_MIX)],
|
||
outputs: [audioOut],
|
||
params: [REVERB_ROOM, IMPULSE],
|
||
idleParams: (params) => (reverbRoomOf(params) === 'sound' ? [] : [IMPULSE.key]),
|
||
derivePorts: ({ node }) => {
|
||
const room = reverbRoomOf(node.params);
|
||
if (room === 'custom') return { inputs: [REVERB_DECAY, REVERB_DAMPING, PREDELAY].map(numberIn) };
|
||
return room === 'sound' ? { inputs: [numberIn(PREDELAY)] } : {};
|
||
},
|
||
emit: (sounds, amounts, settings, project) => {
|
||
const mix = held(amounts.mix, REVERB_MIX);
|
||
if (isNone(mix)) return arriving(sounds);
|
||
const room = reverbRoomOf(settings);
|
||
const preset = room === 'custom' || room === 'sound' ? null : REVERB_PRESETS[room];
|
||
const impulse = room === 'sound' ? (project?.soundFile(textOf(settings[IMPULSE.key] ?? '')) ?? '') : '';
|
||
return withStages(arriving(sounds), [
|
||
{
|
||
kind: 'reverb',
|
||
decay: preset ? { value: preset.decay } : held(amounts.decay, REVERB_DECAY),
|
||
damping: preset ? { value: preset.damping } : held(amounts.damping, REVERB_DAMPING),
|
||
predelay: preset ? { value: preset.predelay } : held(amounts.predelay, PREDELAY),
|
||
mix,
|
||
...(impulse && { impulse }),
|
||
},
|
||
]);
|
||
},
|
||
},
|
||
'own',
|
||
);
|
||
|
||
/** Where damping 0 leaves the echoes and damping 1 takes them: evenly in octaves between. */
|
||
const UNDAMPED_HZ = 20000;
|
||
const DAMPED_HZ = 500;
|
||
|
||
export function dampingCutoff(damping: number): number {
|
||
return UNDAMPED_HZ * (DAMPED_HZ / UNDAMPED_HZ) ** Math.max(0, Math.min(1, damping));
|
||
}
|
||
|
||
export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
|
||
SoundFxInput,
|
||
SoundFxOutput,
|
||
SoundFxGain,
|
||
SoundFxPitch,
|
||
SoundFxParametricEq,
|
||
SoundFxFilter,
|
||
SoundFxMix,
|
||
SoundFxDelay,
|
||
SoundFxBitcrusher,
|
||
SoundFxPhaser,
|
||
SoundFxFlanger,
|
||
SoundFxPan,
|
||
SoundFxCompressor,
|
||
SoundFxLimiter,
|
||
SoundFxSaturation,
|
||
SoundFxTremolo,
|
||
SoundFxVibrato,
|
||
SoundFxReverb,
|
||
];
|
||
|
||
/**
|
||
* An amount with the value handed in for its parameter, held to its row; a 0 there does nothing.
|
||
* One with a share it is taken by is taken by it (#354).
|
||
*/
|
||
function amountOf(amount: FxAmount, params: FxParams): number {
|
||
const share = amount.times ? amountOf(amount.times, params) : 1;
|
||
const given = amount.param !== undefined ? params[amount.param] : undefined;
|
||
if (given === undefined || !Number.isFinite(given)) return amount.value * share;
|
||
return Math.max(amount.min ?? -Infinity, Math.min(amount.max ?? Infinity, given)) * share;
|
||
}
|
||
|
||
/** Seconds of a delay's time, from beats at the voice's tempo where it is synced, never past the line. */
|
||
function delaySeconds(time: number, beats: boolean, tempo: number): number {
|
||
return Math.min(DELAY_MAX, beats ? secondsOfQuarters(time, heldTempo(tempo)) : time);
|
||
}
|
||
|
||
function envelopeOf(plan: FxEnvelopePlan, params: FxParams): FxEnvelope {
|
||
return {
|
||
id: plan.id,
|
||
attack: amountOf(plan.attack, params),
|
||
decay: amountOf(plan.decay, params),
|
||
sustain: amountOf(plan.sustain, params),
|
||
release: amountOf(plan.release, params),
|
||
};
|
||
}
|
||
|
||
/** The Envelope moving an amount, its two ends as what the node holds (`convert`); none for a still one. */
|
||
function modOf(amount: FxAmount, params: FxParams, convert = (value: number) => value): FxMod | undefined {
|
||
const mod = amount.mod;
|
||
if (!mod) return undefined;
|
||
const share = amount.times ? amountOf(amount.times, params) : 1;
|
||
return {
|
||
envelope: envelopeOf(mod.envelope, params),
|
||
from: convert(amountOf(mod.from, params) * share),
|
||
to: convert(amountOf(mod.to, params) * share),
|
||
};
|
||
}
|
||
|
||
const dbToGain = (db: number) => 10 ** (db / 20);
|
||
|
||
/** A stage fed by a parameter stays at 0 as well: a value handed in later has to find it there. */
|
||
function stagesOf(plan: readonly FxStagePlan[], params: FxParams, tempo: number): FxStage[] {
|
||
const stages: FxStage[] = [];
|
||
for (const stage of plan) {
|
||
if (stage.kind === 'tap') {
|
||
stages.push(stage);
|
||
continue;
|
||
}
|
||
if (stage.kind === 'wet') {
|
||
const wetMod = modOf(stage.wet, params);
|
||
stages.push({
|
||
kind: 'wet',
|
||
wet: amountOf(stage.wet, params),
|
||
stages: stagesOf(stage.stages, params, tempo),
|
||
...(wetMod && { wetMod }),
|
||
});
|
||
continue;
|
||
}
|
||
if (stage.kind === 'gain') {
|
||
const db = amountOf(stage.db, params);
|
||
const mod = modOf(stage.db, params, dbToGain);
|
||
if (db !== 0 || stage.db.param !== undefined || mod)
|
||
stages.push({ kind: 'gain', gain: dbToGain(db), ...(mod && { mod }) });
|
||
continue;
|
||
}
|
||
if (stage.kind === 'envelope') {
|
||
stages.push({ kind: 'envelope', envelope: envelopeOf(stage.envelope, params) });
|
||
continue;
|
||
}
|
||
if (stage.kind === 'pass') {
|
||
const cutoffMod = modOf(stage.cutoff, params);
|
||
const qMod = modOf(stage.q, params, (q) => passQ(stage.filter, q));
|
||
const gainMod = modOf(stage.gain, params);
|
||
stages.push({
|
||
kind: 'pass',
|
||
filter: stage.filter,
|
||
stages: stage.stages,
|
||
cutoff: amountOf(stage.cutoff, params),
|
||
q: passQ(stage.filter, amountOf(stage.q, params)),
|
||
gain: amountOf(stage.gain, params),
|
||
...(cutoffMod && { cutoffMod }),
|
||
...(qMod && { qMod }),
|
||
...(gainMod && { gainMod }),
|
||
});
|
||
continue;
|
||
}
|
||
if (stage.kind === 'delay') {
|
||
stages.push({
|
||
kind: 'delay',
|
||
time: delaySeconds(amountOf(stage.time, params), stage.beats === true, tempo),
|
||
feedback: amountOf(stage.feedback, params),
|
||
damping: dampingCutoff(amountOf(stage.damping, params)),
|
||
mix: amountOf(stage.mix, params),
|
||
pingPong: stage.pingPong,
|
||
});
|
||
continue;
|
||
}
|
||
if (stage.kind === 'crush') {
|
||
const bitsMod = modOf(stage.bits, params);
|
||
const rateMod = modOf(stage.rate, params);
|
||
const mixMod = modOf(stage.mix, params);
|
||
stages.push({
|
||
kind: 'crush',
|
||
bits: amountOf(stage.bits, params),
|
||
rate: amountOf(stage.rate, params),
|
||
mix: amountOf(stage.mix, params),
|
||
...(bitsMod && { bitsMod }),
|
||
...(rateMod && { rateMod }),
|
||
...(mixMod && { mixMod }),
|
||
});
|
||
continue;
|
||
}
|
||
if (stage.kind === 'phaser') {
|
||
const frequencyMod = modOf(stage.frequency, params);
|
||
const mixMod = modOf(stage.mix, params);
|
||
stages.push({
|
||
kind: 'phaser',
|
||
stages: stage.stages,
|
||
frequency: amountOf(stage.frequency, params),
|
||
rate: amountOf(stage.rate, params),
|
||
depth: amountOf(stage.depth, params),
|
||
feedback: amountOf(stage.feedback, params),
|
||
mix: amountOf(stage.mix, params),
|
||
...(frequencyMod && { frequencyMod }),
|
||
...(mixMod && { mixMod }),
|
||
});
|
||
continue;
|
||
}
|
||
if (stage.kind === 'compress') {
|
||
const numbers = {
|
||
threshold: amountOf(stage.threshold, params),
|
||
knee: amountOf(stage.knee, params),
|
||
ratio: amountOf(stage.ratio, params),
|
||
};
|
||
stages.push({
|
||
kind: 'compress',
|
||
...numbers,
|
||
attack: amountOf(stage.attack, params),
|
||
release: amountOf(stage.release, params),
|
||
gain: compressorMakeup({
|
||
...numbers,
|
||
makeup: amountOf(stage.makeup, params),
|
||
autoGain: stage.autoGain,
|
||
}),
|
||
});
|
||
continue;
|
||
}
|
||
if (stage.kind === 'limit') {
|
||
stages.push({
|
||
kind: 'limit',
|
||
threshold: amountOf(stage.threshold, params),
|
||
ceiling: amountOf(stage.ceiling, params),
|
||
release: amountOf(stage.release, params),
|
||
autoGain: stage.autoGain ? 1 : 0,
|
||
});
|
||
continue;
|
||
}
|
||
if (stage.kind === 'saturate') {
|
||
const drive = amountOf(stage.drive, params);
|
||
const auto = stage.autoGain ? saturationAutoGain(stage.curve, drive) : 0;
|
||
const levelDb = amountOf(stage.output, params) + auto;
|
||
stages.push({
|
||
kind: 'saturate',
|
||
curve: stage.curve,
|
||
drive,
|
||
tone: toneCutoff(amountOf(stage.tone, params)),
|
||
level: dbToGain(levelDb),
|
||
mix: amountOf(stage.mix, params),
|
||
});
|
||
continue;
|
||
}
|
||
if (stage.kind === 'reverb') {
|
||
stages.push({
|
||
kind: 'reverb',
|
||
decay: amountOf(stage.decay, params),
|
||
damping: amountOf(stage.damping, params),
|
||
predelay: amountOf(stage.predelay, params),
|
||
mix: amountOf(stage.mix, params),
|
||
...(stage.impulse && { impulse: stage.impulse }),
|
||
});
|
||
continue;
|
||
}
|
||
if (stage.kind === 'tremolo') {
|
||
const lfo = { shape: stage.shape, rate: amountOf(stage.rate, params) };
|
||
stages.push({ kind: 'tremolo', ...lfo, depth: amountOf(stage.depth, params) / 100 });
|
||
continue;
|
||
}
|
||
if (stage.kind === 'vibrato') {
|
||
const lfo = { shape: stage.shape, rate: amountOf(stage.rate, params) };
|
||
stages.push({ kind: 'vibrato', ...lfo, depth: amountOf(stage.depth, params) });
|
||
continue;
|
||
}
|
||
if (stage.kind === 'pan') {
|
||
const pan = amountOf(stage.pan, params);
|
||
const mod = modOf(stage.pan, params);
|
||
if (pan !== 0 || stage.pan.param !== undefined || mod)
|
||
stages.push({ kind: 'pan', pan, ...(mod && { mod }) });
|
||
continue;
|
||
}
|
||
const offsetMod = modOf(stage.offset, params);
|
||
const mixMod = modOf(stage.mix, params);
|
||
stages.push({
|
||
kind: 'flanger',
|
||
offset: amountOf(stage.offset, params),
|
||
sweep: amountOf(stage.sweep, params),
|
||
rate: amountOf(stage.rate, params),
|
||
depth: amountOf(stage.depth, params),
|
||
feedback: amountOf(stage.feedback, params),
|
||
mix: amountOf(stage.mix, params),
|
||
...(offsetMod && { offsetMod }),
|
||
...(mixMod && { mixMod }),
|
||
});
|
||
}
|
||
return stages;
|
||
}
|
||
|
||
/**
|
||
* The chain one voice is sent along: the plan with every parameter filled in. Its shape depends on
|
||
* the plan alone, never on the values, so the values of a playing voice can change in place (#315).
|
||
* `tempo` is what a Delay synced to beats counts by (#332).
|
||
*/
|
||
export function fxChainOf(plan: FxPlan, params: FxParams = {}, tempo = DEFAULT_TEMPO): FxChain {
|
||
const stages = stagesOf(plan.stages, params, tempo);
|
||
if (!('mix' in plan)) {
|
||
const pitch = plan.pitch.reduce((sum, amount) => (amount.mod ? sum : sum + amountOf(amount, params)), 0);
|
||
const pitchMods = plan.pitch.flatMap((amount) => modOf(amount, params) ?? []);
|
||
const chain = {
|
||
pitch,
|
||
shifted: plan.pitch.length > 0,
|
||
stages,
|
||
...(pitchMods.length > 0 && { pitchMods }),
|
||
...(plan.chip && { chip: chipOf(plan.chip, params) }),
|
||
...(plan.acid && { acid: acidOf(plan.acid, params) }),
|
||
};
|
||
return plan.source ? { ...chain, source: plan.source } : chain;
|
||
}
|
||
const mixMod = modOf(plan.mix, params);
|
||
return {
|
||
mix: amountOf(plan.mix, params),
|
||
...(plan.curve === 'both' && { curve: plan.curve }),
|
||
a: fxChainOf(plan.a, params, tempo),
|
||
b: fxChainOf(plan.b, params, tempo),
|
||
stages,
|
||
...(mixMod && { mixMod }),
|
||
};
|
||
}
|
||
|
||
function chipOf(plan: FxChipPlan, params: FxParams): FxChip {
|
||
const value = (amount: FxAmount) => amountOf(amount, params);
|
||
const frequencyMod = modOf(plan.frequency, params);
|
||
const dutyMod = modOf(plan.duty, params);
|
||
return {
|
||
wave: plan.wave,
|
||
frequency: value(plan.frequency),
|
||
duty: value(plan.duty),
|
||
dutySweep: value(plan.dutySweep),
|
||
slide: value(plan.slide),
|
||
deltaSlide: value(plan.deltaSlide),
|
||
minFrequency: value(plan.minFrequency),
|
||
vibratoDepth: value(plan.vibratoDepth),
|
||
vibratoSpeed: value(plan.vibratoSpeed),
|
||
arpeggio: value(plan.arpeggio),
|
||
arpeggioTime: value(plan.arpeggioTime),
|
||
repeat: value(plan.repeat),
|
||
...(frequencyMod && { frequencyMod }),
|
||
...(dutyMod && { dutyMod }),
|
||
};
|
||
}
|
||
|
||
function acidOf(plan: FxAcidPlan, params: FxParams): FxAcid {
|
||
const value = (amount: FxAmount) => amountOf(amount, params);
|
||
const cutoffMod = modOf(plan.cutoff, params);
|
||
return {
|
||
id: plan.id,
|
||
wave: plan.wave,
|
||
frequency: value(plan.frequency) * 2 ** (value(plan.tuning) / 12),
|
||
accented: value(plan.velocity) >= ACCENT_VELOCITY,
|
||
slideTime: value(plan.slideTime),
|
||
cutoff: value(plan.cutoff),
|
||
resonance: value(plan.resonance),
|
||
envMod: value(plan.envMod),
|
||
decay: value(plan.decay),
|
||
accent: value(plan.accent),
|
||
...(cutoffMod && { cutoffMod }),
|
||
};
|
||
}
|
||
|
||
/** The stages that sound on after their input ends: an echo, and what holds the sound back on the worklet. */
|
||
const RINGING: ReadonlySet<FxStagePlan['kind']> = new Set(['delay', 'limit', 'vibrato', 'reverb']);
|
||
|
||
/** Whether a plan can ring on after its sources end (#324): it has an echo somewhere, or a Limiter. */
|
||
export function fxRings(plan: FxPlan): boolean {
|
||
if (plan.stages.some(ringing)) return true;
|
||
return 'mix' in plan && (fxRings(plan.a) || fxRings(plan.b));
|
||
}
|
||
|
||
const ringing = (stage: FxStagePlan): boolean =>
|
||
stage.kind === 'wet' ? stage.stages.some(ringing) : RINGING.has(stage.kind);
|
||
|
||
/** How far below the sound a tail has to fall to count as gone: -60 dB. */
|
||
const SILENT = 1e-3;
|
||
|
||
/**
|
||
* How many seconds a chain rings on after its sources end (#324), at the values it has now: until
|
||
* its last echo is 60 dB below the sound. Damping only makes echoes quieter, so it is left out and
|
||
* the tail is never too short. Echoes behind each other add up, two ways of a Mix take the longer.
|
||
* A Reverb (#328) rings its pre-delay and its decay, or as long as the sound that is its impulse
|
||
* response – `soundLength` says how long that is, the longest a Reverb may ring where it is unknown.
|
||
*/
|
||
export function fxTailOf(
|
||
chain: FxChain,
|
||
soundLength: (file: string) => number = () => REVERB_MAX_DECAY,
|
||
): number {
|
||
const ways = 'mix' in chain ? Math.max(fxTailOf(chain.a, soundLength), fxTailOf(chain.b, soundLength)) : 0;
|
||
return ways + stagesTail(chain.stages, soundLength);
|
||
}
|
||
|
||
function stagesTail(stages: readonly FxStage[], soundLength: (file: string) => number): number {
|
||
return stages.reduce((sum, stage) => {
|
||
if (stage.kind === 'delay') return sum + delayTail(stage);
|
||
if (stage.kind === 'reverb')
|
||
return sum + stage.predelay + (stage.impulse ? soundLength(stage.impulse) : stage.decay);
|
||
if (stage.kind === 'wet') return sum + stagesTail(stage.stages, soundLength);
|
||
return sum;
|
||
}, 0);
|
||
}
|
||
|
||
/** The files the Reverbs of a plan take as their impulse response (#328). */
|
||
export function impulsesOf(plan: FxPlan): string[] {
|
||
const own = plan.stages.flatMap((stage) =>
|
||
stage.kind === 'reverb' && stage.impulse ? [stage.impulse] : [],
|
||
);
|
||
return 'mix' in plan ? [...impulsesOf(plan.a), ...impulsesOf(plan.b), ...own] : own;
|
||
}
|
||
|
||
/**
|
||
* Seconds a stage holds the sound back (#326): a Compressor's look-ahead, a Limiter's, a
|
||
* Saturation's oversampling, the delay a Vibrato swings around, and those of the stages a Wet blends.
|
||
*/
|
||
export function stageLatency(stage: FxStage, sampleRate: number): number {
|
||
if (stage.kind === 'compress') return COMPRESSOR_LATENCY;
|
||
if (stage.kind === 'limit') return limiterLatency(sampleRate);
|
||
if (stage.kind === 'saturate') return SHAPER_LATENCY_FRAMES / sampleRate;
|
||
if (stage.kind === 'vibrato') return vibratoDelay(stage.shape, stage.rate, stage.depth);
|
||
if (stage.kind === 'wet')
|
||
return stage.stages.reduce((sum, inner) => sum + stageLatency(inner, sampleRate), 0);
|
||
return 0;
|
||
}
|
||
|
||
/**
|
||
* Seconds a chain of a voice is late (#326): what its stages hold back, the later way of a Mix. A
|
||
* voice's Pitch reads its sound itself and is never late.
|
||
*/
|
||
export function fxLatencyOf(chain: FxChain, sampleRate: number): number {
|
||
const ways =
|
||
'mix' in chain ? Math.max(fxLatencyOf(chain.a, sampleRate), fxLatencyOf(chain.b, sampleRate)) : 0;
|
||
return chain.stages.reduce((sum, stage) => sum + stageLatency(stage, sampleRate), ways);
|
||
}
|
||
|
||
/** The echo `k` is `echo * feedback^(k-1)` as loud as the sound; the last one still heard sets the tail. */
|
||
function delayTail({ time, feedback, mix }: FxDelay): number {
|
||
return time > 0 ? delayEchoes(feedback, mix) * time : 0;
|
||
}
|