Give every sound FX effect a Bypass and a Wet (#354)

A node could only be taken out of a sound FX by unplugging it, and
only six of them could be blended with the sound they get. Every
effect now has both, in the sound FX graph and in the instrument
graph alike. The Mix, the Input and the Output have neither.

- Bypass is a checkbox setting. A bypassed node is left out when
  the graph is compiled, so it costs nothing and holds nothing back.
  The graph draws it faded, and its body says "Bypass: true".
- Wet is a number input like the others. It can be a parameter, be
  moved by Set sound FX parameters and, where the node allows it for
  its other numbers, follow an Envelope.
- A blend is a stage of its own (`wet`) around what the node adds.
  The sound as it came in is held back as far as the node is late,
  so a Compressor, a Limiter or a Vibrato do not comb it out.
- The six nodes that had a Mix keep their key `mix` and only call
  it Wet. Saved values, parameters and edges stay as they were.
- The Delay crossfades now: the sound alone at 0, the sound and its
  echoes in full at 0.5, the echoes alone at 1. Before, the sound
  was always in full and `mix` was the echoes.
- The Pitch takes its semitones by Wet instead of blending, through
  a share `times` on the amount that an instrument binds as well.

Findings:

- A Delay saved before this sounds different. It sounds the same at
  half its old value. The two chip presets with a Delay (Echo Lead,
  Bell) are halved to keep their sound.
- Project cbbf160bb7e8 has two Delays (mix 1 and 0.5). They are
  left as they are.

Tests:

- Unit: soundfx 65 (8 new), sound-library 107 (3 new), instrument
  35 (1 new), envelope 13. Two delay tests and the filter ports test
  follow the new numbers.
- Counter-checks: 8, each turns its test red.
- e2e 34 (melektaus-atx): ALL PASSED, 83 checks (2 new). The
  ping-pong check at wet 0.25 measures what it did at mix 0.5.
- Gate (melektaus-atx): CHECK EXIT 0, 268 files, 4154 tests.
- After the gate the derived ports of a blend are asked for once
  instead of twice: typecheck, lint and the three unit files green
  again (113 tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-08 08:20:52 +02:00
1 parent c95fbc167e
commit 7a17030472
13 files changed
+698 -105

No files matched your search

+2
View File
@@ -133,6 +133,8 @@ export interface NodeDef {
* input has an edge, which a value typed into it then does not count against. * input has an edge, which a value typed into it then does not count against.
*/ */
idleParams?(params: PropertyBag, fed?: (key: string) => boolean): string[]; idleParams?(params: PropertyBag, fed?: (key: string) => boolean): string[];
/** Whether the node does nothing at this value set and is drawn faded, like a bypassed effect (#354). */
dimmed?(params: PropertyBag): boolean;
/** /**
* Which side the ports this node declares for itself (`GraphNode.ports`) appear on. Setting it * Which side the ports this node declares for itself (`GraphNode.ports`) appear on. Setting it
* is what gives a node the port editor in the inspector. `both`: every port is an output, and an * is what gives a node the port editor in the inspector. `both`: every port is an output, and an
+5 -4
View File
@@ -503,8 +503,9 @@ const pass = (filter: FxPassFilter, cutoff: number, resonance = 0) =>
const pitch = (semitones: number) => [SoundFxPitch.type, { semitones }] as const; const pitch = (semitones: number) => [SoundFxPitch.type, { semitones }] as const;
const phaser = (stages: number, frequency: number, rate: number, depth: number, feedback: number) => const phaser = (stages: number, frequency: number, rate: number, depth: number, feedback: number) =>
[SoundFxPhaser.type, { stages: String(stages), frequency, rate, depth, feedback, mix: 1 }] as const; [SoundFxPhaser.type, { stages: String(stages), frequency, rate, depth, feedback, mix: 1 }] as const;
const echo = (time: number, feedback: number, mix: number, pingPong = false) => /** Below 0.5 a Delay's wet keeps the sound in full and has the echoes at twice it (#354). */
[SoundFxDelay.type, { time, sync: 'beats', feedback, damping: 0.4, mix, pingPong }] as const; const echo = (time: number, feedback: number, wet: number, pingPong = false) =>
[SoundFxDelay.type, { time, sync: 'beats', feedback, damping: 0.4, mix: wet, pingPong }] as const;
/** Two ways into one Mix, a column right of the further one; `b` weighs `share`. */ /** Two ways into one Mix, a column right of the further one; `b` weighs `share`. */
function mixed(s: Sketch, a: GraphNode, b: GraphNode, share: number, curve = 'crossfade'): GraphNode { function mixed(s: Sketch, a: GraphNode, b: GraphNode, share: number, curve = 'crossfade'): GraphNode {
@@ -615,7 +616,7 @@ const TONES: Record<Exclude<TonePreset, 'drumKit'>, (s: Sketch, notes: GraphNode
s.line( s.line(
chipOn(s, notes, 0, 'square', { duty: 0.25 }), chipOn(s, notes, 0, 'square', { duty: 0.25 }),
envelope(0.005, 0.15, 0.5, 0.1), envelope(0.005, 0.15, 0.5, 0.1),
echo(0.75, 0.35, 0.35, true), echo(0.75, 0.35, 0.175, true),
), ),
// The note and its octave by turns, 25 times a second: how a console played a chord. // The note and its octave by turns, 25 times a second: how a console played a chord.
octaveArp: (s, notes) => octaveArp: (s, notes) =>
@@ -666,7 +667,7 @@ const TONES: Record<Exclude<TonePreset, 'drumKit'>, (s: Sketch, notes: GraphNode
bell: (s, notes) => { bell: (s, notes) => {
const low = s.line(chipOn(s, notes, 0, 'sine'), envelope(0.001, 2, 0, 0.4)); const low = s.line(chipOn(s, notes, 0, 'sine'), envelope(0.001, 2, 0, 0.4));
const high = s.line(chipOn(s, notes, 1, 'sine'), pitch(17.6), envelope(0.001, 0.6, 0, 0.2)); const high = s.line(chipOn(s, notes, 1, 'sine'), pitch(17.6), envelope(0.001, 0.6, 0, 0.2));
return s.line(mixed(s, low, high, 0.35), echo(0.5, 0.3, 0.25)); return s.line(mixed(s, low, high, 0.35), echo(0.5, 0.3, 0.125));
}, },
// Struck, bright at once and dull soon after, gone in a moment however long it is held. // Struck, bright at once and dull soon after, gone in a moment however long it is held.
pluck: (s, notes) => pluck: (s, notes) =>
+15 -2
View File
@@ -361,18 +361,31 @@ function throughFxWith(fx: FxPlan, sound: FxPlan, bind: (amount: FxAmount) => Fx
b: throughFxWith(fx.b, sound, bind), b: throughFxWith(fx.b, sound, bind),
stages, stages,
}; };
const pitched = fx.pitch.reduce((plan, semitones) => withPitch(plan, bind(semitones)), sound); const pitched = fx.pitch.reduce((plan, semitones) => withPitch(plan, boundAmount(semitones, bind)), sound);
return withStages(pitched, stages); return withStages(pitched, stages);
} }
/** Every number of a stage, bound; an Envelope's own numbers are the instrument's and stay. */ /** Every number of a stage, bound; an Envelope's own numbers are the instrument's and stay. */
function bindStage(stage: FxStagePlan, bind: (amount: FxAmount) => FxAmount): FxStagePlan { function bindStage(stage: FxStagePlan, bind: (amount: FxAmount) => FxAmount): FxStagePlan {
if (stage.kind === 'envelope') return stage; if (stage.kind === 'envelope') return stage;
if (stage.kind === 'wet')
return {
...stage,
wet: boundAmount(stage.wet, bind),
stages: stage.stages.map((inner) => bindStage(inner, bind)),
};
const bound: Record<string, unknown> = {}; const bound: Record<string, unknown> = {};
for (const [key, value] of Object.entries(stage)) bound[key] = isAmount(value) ? bind(value) : value; for (const [key, value] of Object.entries(stage))
bound[key] = isAmount(value) ? boundAmount(value, bind) : value;
return bound as FxStagePlan; return bound as FxStagePlan;
} }
/** An amount bound, and the share it is taken by (#354). */
function boundAmount(amount: FxAmount, bind: (amount: FxAmount) => FxAmount): FxAmount {
const done = bind(amount);
return amount.times ? { ...done, times: boundAmount(amount.times, bind) } : done;
}
const isAmount = (value: unknown): value is FxAmount => const isAmount = (value: unknown): value is FxAmount =>
typeof value === 'object' && value !== null && typeof (value as FxAmount).value === 'number'; typeof value === 'object' && value !== null && typeof (value as FxAmount).value === 'number';
+241 -72
View File
@@ -46,7 +46,19 @@ export type FxStage =
| FxSaturate | FxSaturate
| FxLfo<'tremolo'> | FxLfo<'tremolo'>
| FxLfo<'vibrato'> | FxLfo<'vibrato'>
| FxReverb; | FxReverb
| FxWet;
/**
* 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 * A Reverb (#328): the decay and damping its impulse response is made with, or the file of a sound
@@ -262,7 +274,8 @@ export const DRY: FxChain = { pitch: 0, shifted: false, stages: [] };
* A number of the graph as the compiler knows it: typed in, or a parameter of the Input that a * 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 * `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 * 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. * `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 { export interface FxAmount {
value: number; value: number;
@@ -270,6 +283,7 @@ export interface FxAmount {
min?: number; min?: number;
max?: number; max?: number;
mod?: FxModPlan; mod?: FxModPlan;
times?: FxAmount;
} }
export type FxStagePlan = export type FxStagePlan =
@@ -305,7 +319,8 @@ export type FxStagePlan =
>) >)
| { kind: 'tremolo'; shape: LfoShape; rate: FxAmount; depth: FxAmount } | { kind: 'tremolo'; shape: LfoShape; rate: FxAmount; depth: FxAmount }
| { kind: 'vibrato'; 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: 'reverb'; impulse?: string } & Record<'decay' | 'damping' | 'predelay' | 'mix', FxAmount>)
| { kind: 'wet'; wet: FxAmount; stages: readonly FxStagePlan[] };
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */ /** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
export type FxPlan = export type FxPlan =
@@ -403,6 +418,53 @@ export function withStages(plan: FxPlan, stages: readonly FxStagePlan[]): FxPlan
return stages.length === 0 ? plan : { ...plan, stages: [...plan.stages, ...stages] }; return stages.length === 0 ? plan : { ...plan, stages: [...plan.stages, ...stages] };
} }
const BYPASS = { key: 'bypass', label: 'Bypass', kind: 'boolean' as const, default: false };
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. */
export 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. */ /** 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 { export function withPitch(plan: FxPlan, semitones: FxAmount): FxPlan {
if ('mix' in plan) return { ...plan, a: withPitch(plan.a, semitones), b: withPitch(plan.b, semitones) }; if ('mix' in plan) return { ...plan, a: withPitch(plan.a, semitones), b: withPitch(plan.b, semitones) };
@@ -442,12 +504,18 @@ export const SoundFxOutput: SoundFxNodeDef = {
const GAIN = numberRow('gain', 'Gain (dB)', -120, 24, 0.5); const GAIN = numberRow('gain', 'Gain (dB)', -120, 24, 0.5);
export const SoundFxGain: SoundFxNodeDef = { /** 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', type: 'SoundFxGain',
label: 'Gain', label: 'Gain',
category: 'sound', category: 'sound',
description: 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.', '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)], inputs: [audioIn, followingIn(GAIN)],
outputs: [audioOut], outputs: [audioOut],
params: [], params: [],
@@ -455,24 +523,37 @@ export const SoundFxGain: SoundFxNodeDef = {
const db = held(amounts.gain, GAIN); const db = held(amounts.gain, GAIN);
return withStages(arriving(sounds), isNone(db) ? [] : [{ kind: 'gain', db }]); return withStages(arriving(sounds), isNone(db) ? [] : [{ kind: 'gain', db }]);
}, },
}; },
'blend',
);
const SEMITONES = numberRow('semitones', 'Semitones', -64, 64, 0.1); const SEMITONES = numberRow('semitones', 'Semitones', -64, 64, 0.1);
export const SoundFxPitch: SoundFxNodeDef = { /** 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', type: 'SoundFxPitch',
label: 'Pitch', label: 'Pitch',
category: 'sound', category: 'sound',
description: 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.', '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(SEMITONES)], inputs: [audioIn, followingIn(SEMITONES), numberIn(WET)],
outputs: [audioOut], outputs: [audioOut],
params: [], params: [],
emit: (sounds, amounts) => { emit: (sounds, amounts) => {
const semitones = held(amounts.semitones, SEMITONES); const semitones = pitchTimes(held(amounts.semitones, SEMITONES), held(amounts.wet, WET));
return isNone(semitones) ? arriving(sounds) : withPitch(arriving(sounds), semitones); return isNone(semitones) ? arriving(sounds) : withPitch(arriving(sounds), semitones);
}, },
}; },
'own',
);
/** The five bands of the EQ: a shelf at either end and three peaks between them. */ /** The five bands of the EQ: a shelf at either end and three peaks between them. */
export const EQ_BANDS: readonly { key: string; label: string; filter: FxFilter; frequency: number }[] = [ export const EQ_BANDS: readonly { key: string; label: string; filter: FxFilter; frequency: number }[] = [
@@ -487,12 +568,14 @@ export const EQ_BANDS: readonly { key: string; label: string; filter: FxFilter;
const PEAK_Q = 1; const PEAK_Q = 1;
const EQ_ROWS = EQ_BANDS.map((band) => numberRow(band.key, band.label, -60, 24, 0.5)); const EQ_ROWS = EQ_BANDS.map((band) => numberRow(band.key, band.label, -60, 24, 0.5));
export const SoundFxEq: SoundFxNodeDef = { export const SoundFxEq: SoundFxNodeDef = effect(
{
type: 'SoundFxEq', type: 'SoundFxEq',
label: '5-Band-EQ', label: '5-Band-EQ',
category: 'sound', category: 'sound',
description: description:
'A five-band equalizer: raises or lowers the lows (80 Hz), low mids (250 Hz), mids (1 kHz), high mids (4 kHz) and highs (12 kHz), each in decibels. A band at 0 does nothing.', 'A five-band equalizer: raises or lowers the lows (80 Hz), low mids (250 Hz), mids (1 kHz), high mids (4 kHz) and highs (12 kHz), each in decibels. A band at 0 does nothing.' +
BLENDS,
inputs: [audioIn, ...EQ_ROWS.map(followingIn)], inputs: [audioIn, ...EQ_ROWS.map(followingIn)],
outputs: [audioOut], outputs: [audioOut],
params: [], params: [],
@@ -505,7 +588,9 @@ export const SoundFxEq: SoundFxNodeDef = {
}); });
return withStages(arriving(sounds), stages); return withStages(arriving(sounds), stages);
}, },
}; },
'blend',
);
/** The Q of a filter with no bump at its cutoff, as a factor. */ /** The Q of a filter with no bump at its cutoff, as a factor. */
export const FLAT_Q = Math.SQRT1_2; export const FLAT_Q = Math.SQRT1_2;
@@ -563,17 +648,21 @@ export function passStagesOf(settings: PropertyBag): number {
return slope === '48' ? 4 : slope === '24' ? 2 : 1; return slope === '48' ? 4 : slope === '24' ? 2 : 1;
} }
export const SoundFxFilter: SoundFxNodeDef = { export const SoundFxFilter: SoundFxNodeDef = effect(
{
type: 'SoundFxFilter', type: 'SoundFxFilter',
label: 'Filter', label: 'Filter',
category: 'sound', category: 'sound',
description: 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.', '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.' +
BLENDS,
inputs: [audioIn, followingIn(CUTOFF), followingIn(Q)], inputs: [audioIn, followingIn(CUTOFF), followingIn(Q)],
outputs: [audioOut], outputs: [audioOut],
params: [PASS, SLOPE], params: [PASS, SLOPE],
idleParams: (params) => (SLOPED.has(passOf(params)) ? [] : [SLOPE.key]), idleParams: (params) => (SLOPED.has(passOf(params)) ? [] : [SLOPE.key]),
derivePorts: ({ node }) => (GAINED.has(passOf(node.params)) ? { inputs: [followingIn(FILTER_GAIN)] } : {}), derivePorts: ({ node }) => ({
inputs: GAINED.has(passOf(node.params)) ? [followingIn(FILTER_GAIN)] : [],
}),
emit: (sounds, amounts, settings) => emit: (sounds, amounts, settings) =>
withStages(arriving(sounds), [ withStages(arriving(sounds), [
{ {
@@ -585,7 +674,9 @@ export const SoundFxFilter: SoundFxNodeDef = {
gain: GAINED.has(passOf(settings)) ? held(amounts.gain, FILTER_GAIN) : { value: 0 }, gain: GAINED.has(passOf(settings)) ? held(amounts.gain, FILTER_GAIN) : { value: 0 },
}, },
]), ]),
}; },
'blend',
);
const MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.5 }; const MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.5 };
const MIX_CURVE = { const MIX_CURVE = {
@@ -641,15 +732,22 @@ const DELAY_SYNC = {
/** Never 1: the echoes would never fade, and above it they would grow. */ /** 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_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 }; const DELAY_DAMPING = { ...numberRow('damping', 'Damping', 0, 1, 0.01), default: 0.3 };
const DELAY_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.5 }; /** 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 };
const PING_PONG = { key: 'pingPong', label: 'Ping-pong', kind: 'boolean' as const, default: false }; const PING_PONG = { key: 'pingPong', label: 'Ping-pong', kind: 'boolean' as const, default: false };
export const SoundFxDelay: SoundFxNodeDef = { /** 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) };
}
export const SoundFxDelay: SoundFxNodeDef = effect(
{
type: 'SoundFxDelay', type: 'SoundFxDelay',
label: 'Delay', label: 'Delay',
category: 'sound', category: 'sound',
description: 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. "mix" is how loud the echoes are next to the sound, which itself stays as it is. 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.', '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)], inputs: [audioIn, ...[DELAY_TIME, DELAY_FEEDBACK, DELAY_DAMPING, DELAY_MIX].map(numberIn)],
outputs: [audioOut], outputs: [audioOut],
params: [DELAY_SYNC, PING_PONG], params: [DELAY_SYNC, PING_PONG],
@@ -668,19 +766,26 @@ export const SoundFxDelay: SoundFxNodeDef = {
}, },
]); ]);
}, },
}; },
'own',
);
const CRUSH_BITS = { ...numberRow('bits', 'Bits', 1, 16, 0.1), default: 8 }; 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_RATE = { ...numberRow('rate', 'Rate (Hz)', 100, 48000, 1), default: 8000 };
const CRUSH_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 1 }; 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 }; export const CRUSH_ROWS = { bits: CRUSH_BITS, rate: CRUSH_RATE, mix: CRUSH_MIX };
export const SoundFxBitcrusher: SoundFxNodeDef = { /** 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', type: 'SoundFxBitcrusher',
label: 'Bitcrusher', label: 'Bitcrusher',
category: 'sound', category: 'sound',
description: 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. "mix" 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.', '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)], inputs: [audioIn, ...[CRUSH_BITS, CRUSH_RATE, CRUSH_MIX].map(followingIn)],
outputs: [audioOut], outputs: [audioOut],
params: [], params: [],
@@ -691,13 +796,15 @@ export const SoundFxBitcrusher: SoundFxNodeDef = {
{ kind: 'crush', bits: held(amounts.bits, CRUSH_BITS), rate: held(amounts.rate, CRUSH_RATE), mix }, { 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_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_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_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_FEEDBACK = { ...numberRow('feedback', 'Feedback', 0, 0.95, 0.01), default: 0.4 };
const PHASER_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 1 }; const PHASER_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 1 };
export const PHASER_ROWS = { export const PHASER_ROWS = {
frequency: PHASER_FREQUENCY, frequency: PHASER_FREQUENCY,
rate: PHASER_RATE, rate: PHASER_RATE,
@@ -718,12 +825,14 @@ export function phaserStagesOf(settings: PropertyBag): number {
return PHASER_STAGES.find((n) => n === stages) ?? 4; return PHASER_STAGES.find((n) => n === stages) ?? 4;
} }
export const SoundFxPhaser: SoundFxNodeDef = { export const SoundFxPhaser: SoundFxNodeDef = effect(
{
type: 'SoundFxPhaser', type: 'SoundFxPhaser',
label: 'Phaser', label: 'Phaser',
category: 'sound', category: 'sound',
description: 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. "mix" 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 mix while a note plays.', '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: [ inputs: [
audioIn, audioIn,
followingIn(PHASER_FREQUENCY), followingIn(PHASER_FREQUENCY),
@@ -749,14 +858,16 @@ export const SoundFxPhaser: SoundFxNodeDef = {
}, },
]); ]);
}, },
}; },
'own',
);
const FLANGER_OFFSET = { ...numberRow('offset', 'Offset (ms)', 0, 10, 0.01), default: 1 }; 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_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_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_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_FEEDBACK = { ...numberRow('feedback', 'Feedback', 0, 0.95, 0.01), default: 0.5 };
const FLANGER_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 1 }; const FLANGER_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 1 };
export const FLANGER_ROWS = { export const FLANGER_ROWS = {
offset: FLANGER_OFFSET, offset: FLANGER_OFFSET,
sweep: FLANGER_SWEEP, sweep: FLANGER_SWEEP,
@@ -766,12 +877,14 @@ export const FLANGER_ROWS = {
mix: FLANGER_MIX, mix: FLANGER_MIX,
}; };
export const SoundFxFlanger: SoundFxNodeDef = { export const SoundFxFlanger: SoundFxNodeDef = effect(
{
type: 'SoundFxFlanger', type: 'SoundFxFlanger',
label: 'Flanger', label: 'Flanger',
category: 'sound', category: 'sound',
description: 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. "mix" 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 mix while a note plays.', '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: [ inputs: [
audioIn, audioIn,
followingIn(FLANGER_OFFSET), followingIn(FLANGER_OFFSET),
@@ -798,16 +911,20 @@ export const SoundFxFlanger: SoundFxNodeDef = {
}, },
]); ]);
}, },
}; },
'own',
);
const PAN = numberRow('pan', 'Pan', -1, 1, 0.01); const PAN = numberRow('pan', 'Pan', -1, 1, 0.01);
export const SoundFxPan: SoundFxNodeDef = { export const SoundFxPan: SoundFxNodeDef = effect(
{
type: 'SoundFxPan', type: 'SoundFxPan',
label: 'Pan', label: 'Pan',
category: 'sound', category: 'sound',
description: 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.', '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)], inputs: [audioIn, followingIn(PAN)],
outputs: [audioOut], outputs: [audioOut],
params: [], params: [],
@@ -815,7 +932,9 @@ export const SoundFxPan: SoundFxNodeDef = {
const pan = held(amounts.pan, PAN); const pan = held(amounts.pan, PAN);
return withStages(arriving(sounds), isNone(pan) ? [] : [{ kind: 'pan', pan }]); 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_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_RATIO = { ...numberRow('ratio', 'Ratio', 1, 20, 0.1), default: 4 };
@@ -834,12 +953,14 @@ export const COMPRESS_ROWS = {
autoGain: AUTO_GAIN, autoGain: AUTO_GAIN,
}; };
export const SoundFxCompressor: SoundFxNodeDef = { export const SoundFxCompressor: SoundFxNodeDef = effect(
{
type: 'SoundFxCompressor', type: 'SoundFxCompressor',
label: 'Compressor', label: 'Compressor',
category: 'sound', category: 'sound',
description: 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" from 0 to 1 blends that with the gain that brings a sound at full scale back to full scale: 0 is the makeup alone, 1 the auto-gain alone. 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.', '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" from 0 to 1 blends that with the gain that brings a sound at full scale back to full scale: 0 is the makeup alone, 1 the auto-gain alone. 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.' +
BLENDS,
inputs: [audioIn, ...Object.values(COMPRESS_ROWS).map(numberIn)], inputs: [audioIn, ...Object.values(COMPRESS_ROWS).map(numberIn)],
outputs: [audioOut], outputs: [audioOut],
params: [], params: [],
@@ -856,7 +977,9 @@ export const SoundFxCompressor: SoundFxNodeDef = {
autoGain: held(amounts.autoGain, AUTO_GAIN), autoGain: held(amounts.autoGain, AUTO_GAIN),
}, },
]), ]),
}; },
'blend',
);
const LIMIT_THRESHOLD = { ...numberRow('threshold', 'Threshold (dB)', -60, 0, 0.5), default: -6 }; 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 CEILING = { ...numberRow('ceiling', 'Ceiling (dB)', -30, 0, 0.1), default: -1 };
@@ -869,12 +992,14 @@ export const LIMIT_ROWS = {
autoGain: LIMIT_AUTO_GAIN, autoGain: LIMIT_AUTO_GAIN,
}; };
export const SoundFxLimiter: SoundFxNodeDef = { export const SoundFxLimiter: SoundFxNodeDef = effect(
{
type: 'SoundFxLimiter', type: 'SoundFxLimiter',
label: 'Limiter', label: 'Limiter',
category: 'sound', category: 'sound',
description: 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" from 0 to 1 raises the limited sound towards "ceiling" (dB): at 1 its peaks sit right at the ceiling, at 0 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.', '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" from 0 to 1 raises the limited sound towards "ceiling" (dB): at 1 its peaks sit right at the ceiling, at 0 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.' +
BLENDS,
inputs: [audioIn, ...Object.values(LIMIT_ROWS).map(numberIn)], inputs: [audioIn, ...Object.values(LIMIT_ROWS).map(numberIn)],
outputs: [audioOut], outputs: [audioOut],
params: [], params: [],
@@ -888,13 +1013,15 @@ export const SoundFxLimiter: SoundFxNodeDef = {
autoGain: held(amounts.autoGain, LIMIT_AUTO_GAIN), autoGain: held(amounts.autoGain, LIMIT_AUTO_GAIN),
}, },
]), ]),
}; },
'blend',
);
const DRIVE = { ...numberRow('drive', 'Drive (dB)', 0, 48, 0.5), default: 12 }; const DRIVE = { ...numberRow('drive', 'Drive (dB)', 0, 48, 0.5), default: 12 };
const TONE = { ...numberRow('tone', 'Tone', 0, 1, 0.01), default: 1 }; const TONE = { ...numberRow('tone', 'Tone', 0, 1, 0.01), default: 1 };
const OUTPUT = numberRow('output', 'Output (dB)', -24, 24, 0.5); const OUTPUT = numberRow('output', 'Output (dB)', -24, 24, 0.5);
const SATURATION_AUTO_GAIN = { ...AUTO_GAIN, default: 1 }; const SATURATION_AUTO_GAIN = { ...AUTO_GAIN, default: 1 };
const SATURATION_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 1 }; const SATURATION_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 1 };
export const SATURATION_ROWS = { export const SATURATION_ROWS = {
drive: DRIVE, drive: DRIVE,
tone: TONE, tone: TONE,
@@ -914,12 +1041,14 @@ export function saturationTypeOf(settings: PropertyBag): SaturationType {
return SATURATION_TYPES.find((option) => option.value === settings[SATURATION_TYPE.key])?.value ?? 'soft'; return SATURATION_TYPES.find((option) => option.value === settings[SATURATION_TYPE.key])?.value ?? 'soft';
} }
export const SoundFxSaturation: SoundFxNodeDef = { export const SoundFxSaturation: SoundFxNodeDef = effect(
{
type: 'SoundFxSaturation', type: 'SoundFxSaturation',
label: 'Saturation', label: 'Saturation',
category: 'sound', category: 'sound',
description: 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. "output" (dB) sets the level of the bent sound, "auto-gain" from 0 to 1 blends that with the gain that keeps a sound at -12 dBFS as loud as it came in. "mix" 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.', '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. "output" (dB) sets the level of the bent sound, "auto-gain" from 0 to 1 blends that with the gain that keeps a sound at -12 dBFS as loud as it came in. "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)], inputs: [audioIn, ...Object.values(SATURATION_ROWS).map(numberIn)],
outputs: [audioOut], outputs: [audioOut],
params: [SATURATION_TYPE], params: [SATURATION_TYPE],
@@ -938,7 +1067,9 @@ export const SoundFxSaturation: SoundFxNodeDef = {
}, },
]); ]);
}, },
}; },
'own',
);
const LFO_SHAPE = { const LFO_SHAPE = {
key: 'shape', key: 'shape',
@@ -959,12 +1090,14 @@ const VIBRATO_DEPTH = { ...numberRow('depth', 'Depth (st)', 0, 4, 0.01), default
export const TREMOLO_ROWS = { rate: TREMOLO_RATE, depth: TREMOLO_DEPTH }; export const TREMOLO_ROWS = { rate: TREMOLO_RATE, depth: TREMOLO_DEPTH };
export const VIBRATO_ROWS = { rate: VIBRATO_RATE, depth: VIBRATO_DEPTH }; export const VIBRATO_ROWS = { rate: VIBRATO_RATE, depth: VIBRATO_DEPTH };
export const SoundFxTremolo: SoundFxNodeDef = { export const SoundFxTremolo: SoundFxNodeDef = effect(
{
type: 'SoundFxTremolo', type: 'SoundFxTremolo',
label: 'Tremolo', label: 'Tremolo',
category: 'sound', category: 'sound',
description: 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.', '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)], inputs: [audioIn, numberIn(TREMOLO_RATE), numberIn(TREMOLO_DEPTH)],
outputs: [audioOut], outputs: [audioOut],
params: [LFO_SHAPE], params: [LFO_SHAPE],
@@ -975,14 +1108,18 @@ export const SoundFxTremolo: SoundFxNodeDef = {
{ kind: 'tremolo', shape: lfoShapeOf(settings), rate: held(amounts.rate, TREMOLO_RATE), depth }, { kind: 'tremolo', shape: lfoShapeOf(settings), rate: held(amounts.rate, TREMOLO_RATE), depth },
]); ]);
}, },
}; },
'blend',
);
export const SoundFxVibrato: SoundFxNodeDef = { export const SoundFxVibrato: SoundFxNodeDef = effect(
{
type: 'SoundFxVibrato', type: 'SoundFxVibrato',
label: 'Vibrato', label: 'Vibrato',
category: 'sound', category: 'sound',
description: 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.', '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)], inputs: [audioIn, numberIn(VIBRATO_RATE), numberIn(VIBRATO_DEPTH)],
outputs: [audioOut], outputs: [audioOut],
params: [LFO_SHAPE], params: [LFO_SHAPE],
@@ -993,7 +1130,9 @@ export const SoundFxVibrato: SoundFxNodeDef = {
{ kind: 'vibrato', shape: lfoShapeOf(settings), rate: held(amounts.rate, VIBRATO_RATE), depth }, { kind: 'vibrato', shape: lfoShapeOf(settings), rate: held(amounts.rate, VIBRATO_RATE), depth },
]); ]);
}, },
}; },
'blend',
);
const REVERB_ROOM = { const REVERB_ROOM = {
key: 'room', key: 'room',
@@ -1006,7 +1145,7 @@ const IMPULSE = { key: 'impulse', label: 'Sound', kind: 'sound' as const, defaul
const REVERB_DECAY = { ...numberRow('decay', 'Decay (s)', 0.1, REVERB_MAX_DECAY, 0.01), default: 1.5 }; 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 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 PREDELAY = { ...numberRow('predelay', 'Pre-delay (s)', 0, 0.5, 0.001), default: 0.01 };
const REVERB_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.3 }; const REVERB_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 0.3 };
export const REVERB_ROWS = { export const REVERB_ROWS = {
decay: REVERB_DECAY, decay: REVERB_DECAY,
damping: REVERB_DAMPING, damping: REVERB_DAMPING,
@@ -1018,12 +1157,14 @@ export function reverbRoomOf(settings: PropertyBag): ReverbRoom {
return REVERB_ROOMS.find((option) => option.value === settings[REVERB_ROOM.key])?.value ?? 'room'; return REVERB_ROOMS.find((option) => option.value === settings[REVERB_ROOM.key])?.value ?? 'room';
} }
export const SoundFxReverb: SoundFxNodeDef = { export const SoundFxReverb: SoundFxNodeDef = effect(
{
type: 'SoundFxReverb', type: 'SoundFxReverb',
label: 'Reverb', label: 'Reverb',
category: 'sound', category: 'sound',
description: 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. "mix" 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 mix 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.', '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)], inputs: [audioIn, numberIn(REVERB_MIX)],
outputs: [audioOut], outputs: [audioOut],
params: [REVERB_ROOM, IMPULSE], params: [REVERB_ROOM, IMPULSE],
@@ -1050,7 +1191,9 @@ export const SoundFxReverb: SoundFxNodeDef = {
}, },
]); ]);
}, },
}; },
'own',
);
/** Where damping 0 leaves the echoes and damping 1 takes them: evenly in octaves between. */ /** Where damping 0 leaves the echoes and damping 1 takes them: evenly in octaves between. */
const UNDAMPED_HZ = 20000; const UNDAMPED_HZ = 20000;
@@ -1081,11 +1224,15 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
SoundFxReverb, SoundFxReverb,
]; ];
/** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */ /**
* 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 { 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; const given = amount.param !== undefined ? params[amount.param] : undefined;
if (given === undefined || !Number.isFinite(given)) return amount.value; if (given === undefined || !Number.isFinite(given)) return amount.value * share;
return Math.max(amount.min ?? -Infinity, Math.min(amount.max ?? Infinity, given)); 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. */ /** Seconds of a delay's time, from beats at the voice's tempo where it is synced, never past the line. */
@@ -1107,19 +1254,30 @@ export function envelopeOf(plan: FxEnvelopePlan, params: FxParams): FxEnvelope {
function modOf(amount: FxAmount, params: FxParams, convert = (value: number) => value): FxMod | undefined { function modOf(amount: FxAmount, params: FxParams, convert = (value: number) => value): FxMod | undefined {
const mod = amount.mod; const mod = amount.mod;
if (!mod) return undefined; if (!mod) return undefined;
const share = amount.times ? amountOf(amount.times, params) : 1;
return { return {
envelope: envelopeOf(mod.envelope, params), envelope: envelopeOf(mod.envelope, params),
from: convert(amountOf(mod.from, params)), from: convert(amountOf(mod.from, params) * share),
to: convert(amountOf(mod.to, params)), to: convert(amountOf(mod.to, params) * share),
}; };
} }
const dbToGain = (db: number) => 10 ** (db / 20); 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. */ /** A stage fed by a parameter stays at 0 as well: a value handed in later has to find it there. */
function stagesOf(plan: FxPlan, params: FxParams, tempo: number): FxStage[] { function stagesOf(plan: readonly FxStagePlan[], params: FxParams, tempo: number): FxStage[] {
const stages: FxStage[] = []; const stages: FxStage[] = [];
for (const stage of plan.stages) { for (const stage of plan) {
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') { if (stage.kind === 'gain') {
const db = amountOf(stage.db, params); const db = amountOf(stage.db, params);
const mod = modOf(stage.db, params, dbToGain); const mod = modOf(stage.db, params, dbToGain);
@@ -1291,7 +1449,7 @@ function stagesOf(plan: FxPlan, params: FxParams, tempo: number): FxStage[] {
* `tempo` is what a Delay synced to beats counts by (#332). * `tempo` is what a Delay synced to beats counts by (#332).
*/ */
export function fxChainOf(plan: FxPlan, params: FxParams = {}, tempo = DEFAULT_TEMPO): FxChain { export function fxChainOf(plan: FxPlan, params: FxParams = {}, tempo = DEFAULT_TEMPO): FxChain {
const stages = stagesOf(plan, params, tempo); const stages = stagesOf(plan.stages, params, tempo);
if (!('mix' in plan)) { if (!('mix' in plan)) {
const pitch = plan.pitch.reduce((sum, amount) => (amount.mod ? sum : sum + amountOf(amount, params)), 0); 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 pitchMods = plan.pitch.flatMap((amount) => modOf(amount, params) ?? []);
@@ -1342,10 +1500,13 @@ const RINGING: ReadonlySet<FxStagePlan['kind']> = new Set(['delay', 'limit', 'vi
/** Whether a plan can ring on after its sources end (#324): it has an echo somewhere, or a Limiter. */ /** 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 { export function fxRings(plan: FxPlan): boolean {
if (plan.stages.some((stage) => RINGING.has(stage.kind))) return true; if (plan.stages.some(ringing)) return true;
return 'mix' in plan && (fxRings(plan.a) || fxRings(plan.b)); 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. */ /** How far below the sound a tail has to fall to count as gone: -60 dB. */
const SILENT = 1e-3; const SILENT = 1e-3;
@@ -1361,12 +1522,17 @@ export function fxTailOf(
soundLength: (file: string) => number = () => REVERB_MAX_DECAY, soundLength: (file: string) => number = () => REVERB_MAX_DECAY,
): number { ): number {
const ways = 'mix' in chain ? Math.max(fxTailOf(chain.a, soundLength), fxTailOf(chain.b, soundLength)) : 0; const ways = 'mix' in chain ? Math.max(fxTailOf(chain.a, soundLength), fxTailOf(chain.b, soundLength)) : 0;
return chain.stages.reduce((sum, stage) => { 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 === 'delay') return sum + delayTail(stage);
if (stage.kind === 'reverb') if (stage.kind === 'reverb')
return sum + stage.predelay + (stage.impulse ? soundLength(stage.impulse) : stage.decay); return sum + stage.predelay + (stage.impulse ? soundLength(stage.impulse) : stage.decay);
if (stage.kind === 'wet') return sum + stagesTail(stage.stages, soundLength);
return sum; return sum;
}, ways); }, 0);
} }
/** The files the Reverbs of a plan take as their impulse response (#328). */ /** The files the Reverbs of a plan take as their impulse response (#328). */
@@ -1379,13 +1545,15 @@ export function impulsesOf(plan: FxPlan): string[] {
/** /**
* Seconds a stage holds the sound back (#326): a Compressor's look-ahead, a Limiter's, a * 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. * 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 { export function stageLatency(stage: FxStage, sampleRate: number): number {
if (stage.kind === 'compress') return COMPRESSOR_LATENCY; if (stage.kind === 'compress') return COMPRESSOR_LATENCY;
if (stage.kind === 'limit') return limiterLatency(sampleRate); if (stage.kind === 'limit') return limiterLatency(sampleRate);
if (stage.kind === 'saturate') return SHAPER_LATENCY_FRAMES / 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 === '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; return 0;
} }
@@ -1399,9 +1567,10 @@ export function fxLatencyOf(chain: FxChain, sampleRate: number): number {
return chain.stages.reduce((sum, stage) => sum + stageLatency(stage, sampleRate), ways); return chain.stages.reduce((sum, stage) => sum + stageLatency(stage, sampleRate), ways);
} }
/** The echo `k` is `mix * feedback^(k-1)` as loud as the sound; the last one still heard sets the tail. */ /** 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 { function delayTail({ time, feedback, mix }: FxDelay): number {
if (!(mix >= SILENT) || !(time > 0)) return 0; const { echo } = delayShares(mix);
const echoes = feedback > 0 ? 1 + Math.floor(Math.log(SILENT / mix) / Math.log(feedback)) : 1; if (!(echo >= SILENT) || !(time > 0)) return 0;
const echoes = feedback > 0 ? 1 + Math.floor(Math.log(SILENT / echo) / Math.log(feedback)) : 1;
return echoes * time; return echoes * time;
} }
+2
View File
@@ -101,6 +101,8 @@ export function graphHooks({ state, editor }: HookContext) {
info: () => editor.soundFxEditor.shownInfo, info: () => editor.soundFxEditor.shownInfo,
canvasRect: () => editor.soundFxEditor.canvas.canvasRect(), canvasRect: () => editor.soundFxEditor.canvas.canvasRect(),
nodeProblem: (nodeId: string) => editor.soundFxEditor.canvas.nodeProblem(nodeId), nodeProblem: (nodeId: string) => editor.soundFxEditor.canvas.nodeProblem(nodeId),
nodeSettings: (nodeId: string) => editor.soundFxEditor.canvas.nodeSettings(nodeId),
nodeAlpha: (nodeId: string) => editor.soundFxEditor.canvas.nodeAlpha(nodeId),
/** The header's listening controls: which sound, and whether Play is offered. */ /** The header's listening controls: which sound, and whether Play is offered. */
preview: () => ({ preview: () => ({
sound: editor.soundFxEditor.previewSoundId, sound: editor.soundFxEditor.previewSoundId,
+5
View File
@@ -1159,6 +1159,11 @@ export class GraphCanvas extends Widget {
return this.views.get(nodeId)?.settingLines ?? null; return this.views.get(nodeId)?.settingLines ?? null;
} }
/** How opaque a node is drawn: below 1 for one that does nothing, a bypassed effect (#354). */
nodeAlpha(nodeId: string): number | null {
return this.views.get(nodeId)?.alpha ?? null;
}
nodeInputs(nodeId: string): string[] | null { nodeInputs(nodeId: string): string[] | null {
return this.views.get(nodeId)?.inputLines ?? null; return this.views.get(nodeId)?.inputLines ?? null;
} }
+3
View File
@@ -208,6 +208,7 @@ export class NodeView extends Widget {
): void { ): void {
this.def = def; this.def = def;
this.problem = problem; this.problem = problem;
this.alpha = def.dimmed?.(node.params) ? DIMMED_ALPHA : 1;
const hidden = new Set([ const hidden = new Set([
...(this.def.hiddenParams?.(node.params) ?? []), ...(this.def.hiddenParams?.(node.params) ?? []),
...(this.def.idleParams?.(node.params, (key) => edgesInto(graph, node.id, key).length > 0) ?? []), ...(this.def.idleParams?.(node.params, (key) => edgesInto(graph, node.id, key).length > 0) ?? []),
@@ -594,6 +595,8 @@ function formatField(def: PropertyDef, value: Value, project: Project | null): s
} }
/** How a node says "no value" and "empty value" – for inputs and live outputs alike. */ /** How a node says "no value" and "empty value" – for inputs and live outputs alike. */
/** How a node that does nothing at its settings is drawn, a bypassed effect (#354). */
const DIMMED_ALPHA = 0.45;
const NULL_TEXT = '(null)'; const NULL_TEXT = '(null)';
const EMPTY_TEXT = '(empty)'; const EMPTY_TEXT = '(empty)';
+66 -11
View File
@@ -26,6 +26,8 @@ import {
type FxStage, type FxStage,
type FxStagePlan, type FxStagePlan,
type FxVoice, type FxVoice,
type FxWet,
delayShares,
stageLatency, stageLatency,
} from '../core/soundfx/nodes'; } from '../core/soundfx/nodes';
import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope'; import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope';
@@ -1987,10 +1989,13 @@ const ON_WORKLET: ReadonlySet<FxStagePlan['kind']> = new Set([
/** Whether a plan has a Pitch, a Chip or a stage of the worklet anywhere, and so needs the worklet. */ /** Whether a plan has a Pitch, a Chip or a stage of the worklet anywhere, and so needs the worklet. */
function needsWorklet(plan: FxPlan): boolean { function needsWorklet(plan: FxPlan): boolean {
if (plan.stages.some((stage) => ON_WORKLET.has(stage.kind))) return true; if (plan.stages.some(onWorklet)) return true;
return 'mix' in plan ? needsWorklet(plan.a) || needsWorklet(plan.b) : plan.pitch.length > 0 || !!plan.chip; return 'mix' in plan ? needsWorklet(plan.a) || needsWorklet(plan.b) : plan.pitch.length > 0 || !!plan.chip;
} }
const onWorklet = (stage: FxStagePlan): boolean =>
stage.kind === 'wet' ? stage.stages.some(onWorklet) : ON_WORKLET.has(stage.kind);
/** Whether a way of a plan plays a Chip (#329), which needs no file. */ /** Whether a way of a plan plays a Chip (#329), which needs no file. */
function hasChip(plan: FxPlan): boolean { function hasChip(plan: FxPlan): boolean {
return 'mix' in plan ? hasChip(plan.a) || hasChip(plan.b) : plan.chip !== undefined; return 'mix' in plan ? hasChip(plan.a) || hasChip(plan.b) : plan.chip !== undefined;
@@ -2085,6 +2090,7 @@ export function stageNodes(
if (stage.kind === 'saturate') return saturateNodes(context, stage); if (stage.kind === 'saturate') return saturateNodes(context, stage);
if (stage.kind === 'reverb') return reverbNodes(context, stage, impulse); if (stage.kind === 'reverb') return reverbNodes(context, stage, impulse);
if (stage.kind === 'tremolo' || stage.kind === 'vibrato') return lfoNodes(context, stage, at); if (stage.kind === 'tremolo' || stage.kind === 'vibrato') return lfoNodes(context, stage, at);
if (stage.kind === 'wet') return wetNodes(context, stage, follow, at);
if (stage.kind === 'limit') if (stage.kind === 'limit')
return workletNodes(context, 'limiter', [ return workletNodes(context, 'limiter', [
{ name: 'threshold', value: stage.threshold, even: 'value' }, { name: 'threshold', value: stage.threshold, even: 'value' },
@@ -2208,6 +2214,44 @@ export function releaseEnvelope(
level.setTargetAtTime(0, at, releaseTime(envelope) / SETTLE); level.setTargetAtTime(0, at, releaseTime(envelope) / SETTLE);
} }
/**
* What one node adds, blended with what arrives at it (#354): its stages one after another at `wet`,
* and the sound as it came in at the rest, held back as far as the stages are late.
*/
function wetNodes(
context: BaseAudioContext,
stage: FxWet,
follow: Follow | undefined,
at: number,
): StageNodes {
const input = context.createGain();
const output = context.createGain();
const knobs: Knob[] = [];
const stops: (() => void)[] = [];
let tail: AudioNode = input;
for (const inner of stage.stages) {
const made = stageNodes(context, inner, follow, at);
tail.connect(made.input);
tail = made.output;
knobs.push(...made.knobs);
if (made.stop) stops.push(made.stop);
}
const moved = stage.wetMod;
const wet = gainNode(context, moved ? 0 : stage.wet);
const dry = gainNode(context, moved ? 0 : 1 - stage.wet);
if (moved) {
follow?.(moved, wet.gain, false);
follow?.(moved, dry.gain, false, (share) => 1 - share);
}
tail.connect(wet).connect(output);
heldBack(context, input, stageLatency(stage, context.sampleRate)).connect(dry).connect(output);
knobs.push(
{ param: moved ? null : wet.gain, target: stage.wet, even: 'value' },
{ param: moved ? null : dry.gain, target: 1 - stage.wet, even: 'value' },
);
return { input, output, knobs, ...(stops.length > 0 && { stop: () => stops.forEach((stop) => stop()) }) };
}
/** /**
* A Compressor (#326): Web Audio's node, and a gain after it for makeup and auto-gain. Its times * A Compressor (#326): Web Audio's node, and a gain after it for makeup and auto-gain. Its times
* move in ratios, everything in dB as it is. * move in ratios, everything in dB as it is.
@@ -2348,14 +2392,16 @@ function saturateNodes(context: BaseAudioContext, stage: FxSaturate): StageNodes
} }
/** /**
* An echo (#327): the sound goes on as it is, and through a delay line whose output is damped, fed * An echo (#327): the sound goes on, and through a delay line whose output is damped, fed back into
* back into it and added at `mix`. Ping-pong sends the sound into the left of the line only and * it and added – each at its share of the wet `mix` (#354). Ping-pong sends the sound into the left
* swaps the sides on every round. * of the line only and swaps the sides on every round.
*/ */
function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes { function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes {
const input = context.createGain(); const input = context.createGain();
const output = context.createGain(); const output = context.createGain();
input.connect(output); const shares = delayShares(stage.mix);
const dry = gainNode(context, shares.dry);
input.connect(dry).connect(output);
const line = context.createDelay(DELAY_MAX); const line = context.createDelay(DELAY_MAX);
line.delayTime.value = stage.time; line.delayTime.value = stage.time;
const damp = context.createBiquadFilter(); const damp = context.createBiquadFilter();
@@ -2363,7 +2409,7 @@ function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes {
damp.frequency.value = stage.damping; damp.frequency.value = stage.damping;
damp.Q.value = passQ('lowpass', FLAT_Q); damp.Q.value = passQ('lowpass', FLAT_Q);
const feedback = gainNode(context, stage.feedback); const feedback = gainNode(context, stage.feedback);
const wet = gainNode(context, stage.mix); const wet = gainNode(context, shares.echo);
line.connect(damp); line.connect(damp);
damp.connect(wet).connect(output); damp.connect(wet).connect(output);
if (stage.pingPong) { if (stage.pingPong) {
@@ -2390,7 +2436,8 @@ function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes {
{ param: line.delayTime, target: stage.time, even: 'value' }, { param: line.delayTime, target: stage.time, even: 'value' },
{ param: feedback.gain, target: stage.feedback, even: 'value' }, { param: feedback.gain, target: stage.feedback, even: 'value' },
{ param: damp.frequency, target: stage.damping, even: 'ratio' }, { param: damp.frequency, target: stage.damping, even: 'ratio' },
{ param: wet.gain, target: stage.mix, even: 'value' }, { param: wet.gain, target: shares.echo, even: 'value' },
{ param: dry.gain, target: shares.dry, even: 'value' },
], ],
}; };
} }
@@ -2622,9 +2669,18 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] {
if (chain.shifted) into.push(chain.pitch); if (chain.shifted) into.push(chain.pitch);
if (chain.chip) into.push(chain.chip.frequency, chain.chip.duty); if (chain.chip) into.push(chain.chip.frequency, chain.chip.duty);
} }
for (const stage of chain.stages) for (const stage of chain.stages) stageLevels(stage, into);
if (stage.kind === 'delay') into.push(stage.time, stage.feedback, stage.damping, stage.mix); return into;
else if (stage.kind === 'pass') into.push(stage.cutoff, stage.q, stage.gain); }
function stageLevels(stage: FxStage, into: number[]): void {
if (stage.kind === 'delay') {
const shares = delayShares(stage.mix);
into.push(stage.time, stage.feedback, stage.damping, shares.echo, shares.dry);
} else if (stage.kind === 'wet') {
for (const inner of stage.stages) stageLevels(inner, into);
into.push(stage.wet, 1 - stage.wet);
} else if (stage.kind === 'pass') into.push(stage.cutoff, stage.q, stage.gain);
else if (stage.kind === 'crush') into.push(stage.bits, stage.rate, stage.mix); else if (stage.kind === 'crush') into.push(stage.bits, stage.rate, stage.mix);
else if (stage.kind === 'phaser') else if (stage.kind === 'phaser')
into.push(stage.frequency, stage.rate, stage.depth, stage.feedback, stage.mix); into.push(stage.frequency, stage.rate, stage.depth, stage.feedback, stage.mix);
@@ -2640,7 +2696,6 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] {
else if (stage.kind === 'saturate') else if (stage.kind === 'saturate')
into.push(driveGain(stage.drive), stage.tone, stage.level, stage.mix, 1 - stage.mix); into.push(driveGain(stage.drive), stage.tone, stage.level, stage.mix, 1 - stage.mix);
else if (stage.kind !== 'envelope') into.push(stage.gain); else if (stage.kind !== 'envelope') into.push(stage.gain);
return into;
} }
export const soundLibrary = new SoundLibrary(); export const soundLibrary = new SoundLibrary();
+2 -2
View File
@@ -116,7 +116,7 @@ describe('a Filter', () => {
it('has a gain only as a shelf or a peak, and a slope only where it gets steeper (#318)', () => { it('has a gain only as a shelf or a peak, and a slope only where it gets steeper (#318)', () => {
const peak = filterIn({ filter: 'peaking', gain: 6, slope: '48' }); const peak = filterIn({ filter: 'peaking', gain: 6, slope: '48' });
const def = (node: GraphNode, fx: SoundFxAsset) => resolveDef(node, fx.graph, NO_LOOKUP)!; const def = (node: GraphNode, fx: SoundFxAsset) => resolveDef(node, fx.graph, NO_LOOKUP)!;
expect(def(peak.filter, peak.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'gain']); expect(def(peak.filter, peak.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'gain', 'wet']);
expect(SoundFxFilter.idleParams!(peak.filter.params)).toEqual(['slope']); expect(SoundFxFilter.idleParams!(peak.filter.params)).toEqual(['slope']);
expect(fxChainOf(compileSoundFx(peak.fx.graph).compiled!).stages[0]).toMatchObject({ expect(fxChainOf(compileSoundFx(peak.fx.graph).compiled!).stages[0]).toMatchObject({
stages: 1, stages: 1,
@@ -124,7 +124,7 @@ describe('a Filter', () => {
}); });
const notch = filterIn({ filter: 'notch', gain: 6, slope: '48' }); const notch = filterIn({ filter: 'notch', gain: 6, slope: '48' });
expect(def(notch.filter, notch.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q']); expect(def(notch.filter, notch.fx).inputs.map((p) => p.key)).toEqual(['in', 'cutoff', 'q', 'wet']);
expect(SoundFxFilter.idleParams!(notch.filter.params)).toEqual([]); expect(SoundFxFilter.idleParams!(notch.filter.params)).toEqual([]);
// A gain typed in before the type changed does nothing. // A gain typed in before the type changed does nothing.
expect(fxChainOf(compileSoundFx(notch.fx.graph).compiled!).stages[0]).toMatchObject({ expect(fxChainOf(compileSoundFx(notch.fx.graph).compiled!).stages[0]).toMatchObject({
+40
View File
@@ -403,6 +403,46 @@ describe('compiling a note', () => {
expect(callProblem(through, lookup, f.instrument.id)).toBe('No sound FX picked'); expect(callProblem(through, lookup, f.instrument.id)).toBe('No sound FX picked');
}); });
it('binds the wet of a sound FX it sends a sound through, of a blend and of a Pitch (#354)', () => {
const f = makeInstrument();
nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id;
// The FX: Input, a Gain and a Pitch, both at the wet of its parameter "blend", Output.
const made = new CreateAssetCommand('soundfx', 'Half');
f.stack.execute(made);
const fx = made.asset as SoundFxAsset;
const fxEdit = graphEditor(f, fx as unknown as BehaviorAsset);
const input = fxEdit.addNode('SoundFxInput');
f.stack.execute(new AddPortCommand(fx.id, input.id, { key: 'blend', kind: 'number' }));
f.stack.execute(new SetPortDefaultCommand(fx.id, input.id, 'blend', 0.5));
const gain = fxEdit.addNode('SoundFxGain', { gain: -6 });
const pitch = fxEdit.addNode('SoundFxPitch', { semitones: 12 });
const fxOut = fxEdit.addNode('SoundFxOutput');
fxEdit.wire([
[input, 'out', gain, 'in'],
[input, 'blend', gain, 'wet'],
[gain, 'out', pitch, 'in'],
[input, 'blend', pitch, 'wet'],
[pitch, 'out', fxOut, 'in'],
]);
const sample = nodeOf(f, 'InstrumentSample');
const output = nodeOf(f, 'InstrumentOutput');
f.instrument.graph.edges = f.instrument.graph.edges.filter((e) => e.to.node !== output.id);
const through = f.edit.addNode('InstrumentSoundFx', { fx: fx.id });
f.edit.wire([
[sample, 'out', through, 'in'],
[through, 'out', output, 'in'],
]);
const params = f.edit.addNode('InstrumentParameters');
f.stack.execute(new AddPortCommand(f.instrument.id, params.id, { key: 'dry', kind: 'number' }));
f.edit.connect(params, 'dry', through, 'blend');
const bound = { value: 0, param: 'dry', min: 0, max: 1 };
const played = way(compile(f, 60).layers[0])!;
expect(played.stages).toEqual([
{ kind: 'wet', wet: bound, stages: [{ kind: 'gain', db: { value: -6 } }] },
]);
expect(played.pitch).toEqual([{ value: 12, times: bound }]);
});
it('keeps the curve of a Mix inside a sound FX it sends a sound through', () => { it('keeps the curve of a Mix inside a sound FX it sends a sound through', () => {
const f = makeInstrument(); const f = makeInstrument();
nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id; nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id;
+158
View File
@@ -25,7 +25,9 @@ import {
type FxStage, type FxStage,
fxTailOf, fxTailOf,
type FxVoice, type FxVoice,
delayShares,
impulsesOf, impulsesOf,
SOUND_FX_NODES,
SoundFxReverb, SoundFxReverb,
stageLatency, stageLatency,
} from '../../src/core/soundfx/nodes'; } from '../../src/core/soundfx/nodes';
@@ -582,6 +584,162 @@ describe('a Reverb in a sound FX graph (#328)', () => {
}); });
}); });
describe('Bypass and Wet (#354)', () => {
/** Every effect, and how it says its wet: a row of its own, or the "mix" it had. */
const EFFECTS: Record<string, 'wet' | 'mix'> = {
SoundFxEq: 'wet',
SoundFxBitcrusher: 'mix',
SoundFxCompressor: 'wet',
SoundFxDelay: 'mix',
SoundFxFilter: 'wet',
SoundFxFlanger: 'mix',
SoundFxGain: 'wet',
SoundFxLimiter: 'wet',
SoundFxPan: 'wet',
SoundFxPhaser: 'mix',
SoundFxPitch: 'wet',
SoundFxReverb: 'mix',
SoundFxSaturation: 'mix',
SoundFxTremolo: 'wet',
SoundFxVibrato: 'wet',
};
const resolvedInputs = (type: string) => {
const f = line([{ type }]);
const node = f.fx.graph.nodes.find((n) => n.type === type)!;
return resolveDef(node, f.fx.graph, graphLookup(f.project))!.inputs;
};
const stagesOf = (type: string, params: Record<string, number | boolean | string> = {}) =>
compileSoundFx(line([{ type, params }]).fx.graph).compiled!.stages;
it('are on every effect, the wet of six of them the "mix" they had, and on nothing else', () => {
for (const def of SOUND_FX_NODES) {
const wet = EFFECTS[def.type];
const inputs = wet ? resolvedInputs(def.type) : def.inputs;
expect([def.type, def.params.some((p) => p.key === 'bypass')]).toEqual([def.type, wet !== undefined]);
expect([def.type, inputs.filter((p) => p.label === 'Wet').map((p) => p.key)]).toEqual([
def.type,
wet ? [wet] : [],
]);
expect([def.type, inputs.some((p) => p.label === 'Mix')]).toEqual([
def.type,
def.type === 'SoundFxMix',
]);
}
expect(Object.keys(EFFECTS)).toHaveLength(15);
});
it('bypassed, an effect is left out as if it were not there, and its node is drawn faded', () => {
for (const type of Object.keys(EFFECTS)) {
const on = { gain: -6, semitones: 5, low: 3, pan: 1, depth: 50 };
expect([type, stagesOf(type, on).length > 0 || type === 'SoundFxPitch']).toEqual([type, true]);
expect([
type,
compileSoundFx(line([{ type, params: { ...on, bypass: true } }]).fx.graph).compiled,
]).toEqual([type, EMPTY_PLAN]);
const def = SOUND_FX_NODES.find((d) => d.type === type)!;
expect([def.dimmed?.({ bypass: true }), def.dimmed?.({})]).toEqual([true, false]);
expect([def.hiddenParams?.({ bypass: true }), def.hiddenParams?.({})]).toEqual([[], ['bypass']]);
}
});
it('blends what a node adds with what arrives, all of it at 1 and none at a typed-in 0', () => {
expect(stagesOf('SoundFxGain', { gain: -6 })).toEqual([{ kind: 'gain', db: { value: -6 } }]);
expect(stagesOf('SoundFxGain', { gain: -6, wet: 0 })).toEqual([]);
expect(stagesOf('SoundFxGain', { gain: -6, wet: 0.25 })).toEqual([
{ kind: 'wet', wet: { value: 0.25 }, stages: [{ kind: 'gain', db: { value: -6 } }] },
]);
// Every band of an EQ in one blend, not each band on its own.
const eq = stagesOf('SoundFxEq', { low: 6, high: -3, wet: 0.5 });
expect(eq).toMatchObject([{ kind: 'wet', stages: [{ filter: 'lowshelf' }, { filter: 'highshelf' }] }]);
// What came before the node stays outside the blend.
const f = line([
{ type: 'SoundFxGain', params: { gain: 3 } },
{ type: 'SoundFxPan', params: { pan: 1, wet: 0.5 } },
]);
expect(compileSoundFx(f.fx.graph).compiled!.stages).toMatchObject([
{ kind: 'gain' },
{ kind: 'wet', stages: [{ kind: 'pan' }] },
]);
});
it('takes its wet from a parameter and keeps the blend for it, also at 0 or 1 (#315)', () => {
const f = line([{ type: 'SoundFxCompressor' }]);
const compressor = f.fx.graph.nodes.find((n) => n.type === 'SoundFxCompressor')!;
f.input.ports = [{ key: 'blend', kind: 'number', default: 0.5 }];
f.edit.connect(f.input, 'blend', compressor, 'wet');
const plan = compileSoundFx(f.fx.graph).compiled!;
expect(fxChainOf(plan).stages).toMatchObject([{ kind: 'wet', wet: 0.5, stages: [{ kind: 'compress' }] }]);
expect(fxChainOf(plan, { blend: 0 }).stages).toMatchObject([{ kind: 'wet', wet: 0 }]);
expect(fxChainOf(plan, { blend: 7 }).stages).toMatchObject([{ kind: 'wet', wet: 1 }]);
});
it('a blend is as late as what it blends and rings as long', () => {
const [limit] = fxChainOf({ pitch: [], stages: stagesOf('SoundFxLimiter', { wet: 0.5 }) }).stages;
expect(limit).toMatchObject({ kind: 'wet' });
expect(stageLatency(limit!, 48000)).toBe(0.005);
expect(fxRings({ pitch: [], stages: stagesOf('SoundFxLimiter', { wet: 0.5 }) })).toBe(true);
expect(fxRings({ pitch: [], stages: stagesOf('SoundFxTremolo', { wet: 0.5 }) })).toBe(false);
});
it('the wet of a Filter comes after the gain a shelf adds to it', () => {
const f = line([{ type: 'SoundFxFilter', params: { filter: 'lowshelf' } }]);
const filter = f.fx.graph.nodes.find((n) => n.type === 'SoundFxFilter')!;
const keys = () => resolveDef(filter, f.fx.graph, graphLookup(f.project))!.inputs.map((p) => p.key);
expect(keys()).toEqual(['in', 'cutoff', 'q', 'gain', 'wet']);
filter.params.filter = 'lowpass';
expect(keys()).toEqual(['in', 'cutoff', 'q', 'wet']);
});
it('the wet of a Pitch takes its semitones from 0 up to all of them', () => {
expect(
pitchOf(
fxChainOf(
compileSoundFx(line([{ type: 'SoundFxPitch', params: { semitones: 12, wet: 0.5 } }]).fx.graph)
.compiled!,
),
),
).toBe(6);
expect(
compileSoundFx(line([{ type: 'SoundFxPitch', params: { semitones: 12, wet: 0 } }]).fx.graph).compiled,
).toEqual(EMPTY_PLAN);
const f = line([{ type: 'SoundFxPitch', params: { semitones: 12 } }]);
const pitch = f.fx.graph.nodes.find((n) => n.type === 'SoundFxPitch')!;
f.input.ports = [
{ key: 'up', kind: 'number', default: 7 },
{ key: 'blend', kind: 'number', default: 1 },
];
f.edit.connect(f.input, 'blend', pitch, 'wet');
const plan = compileSoundFx(f.fx.graph).compiled!;
expect(pitchOf(fxChainOf(plan))).toBe(12);
expect(pitchOf(fxChainOf(plan, { blend: 0.25 }))).toBe(3);
expect(fxChainOf(plan, { blend: 0 })).toMatchObject({ pitch: 0, shifted: true });
f.edit.connect(f.input, 'up', pitch, 'semitones');
const both = compileSoundFx(f.fx.graph).compiled!;
expect(pitchOf(fxChainOf(both, { up: -10, blend: 0.5 }))).toBe(-5);
});
it('a Delay crossfades: the sound alone at 0, both in full at 0.5, the echoes alone at 1', () => {
expect([0, 0.25, 0.5, 0.75, 1].map(delayShares)).toEqual([
{ dry: 1, echo: 0 },
{ dry: 1, echo: 0.5 },
{ dry: 1, echo: 1 },
{ dry: 0.5, echo: 1 },
{ dry: 0, echo: 1 },
]);
// An echo at wet 0.25 is half as loud as at 0.5, so one fewer of them is still heard.
const tail = (mix: number) =>
fxTailOf({
pitch: 0,
shifted: false,
stages: [{ kind: 'delay', time: 1, feedback: 0.5, damping: 20000, mix, pingPong: false }],
});
expect(tail(0.5)).toBe(10);
expect(tail(0.25)).toBe(9);
expect(tail(1)).toBe(10);
});
});
describe('the tail of a voice (#324)', () => { describe('the tail of a voice (#324)', () => {
const echo = (time: number, feedback: number, mix: number): FxStage => ({ const echo = (time: number, feedback: number, mix: number): FxStage => ({
kind: 'delay', kind: 'delay',
+80 -7
View File
@@ -1014,14 +1014,15 @@ describe('the sound library', () => {
expect(clocks).toHaveLength(0); expect(clocks).toHaveLength(0);
}); });
it('wires an echo: the sound goes on, the line feeds back through the damping, added at mix', async () => { it('wires an echo: the sound goes on, the line feeds back through the damping, each at its share of wet', async () => {
await play(new SoundLibrary(), FILE, {}, { pitch: 0, stages: [echo(0.4)] }); await play(new SoundLibrary(), FILE, {}, { pitch: 0, stages: [echo(0.4)] });
expect(delays).toMatchObject([{ max: 2, delayTime: { value: 0.25 } }]); expect(delays).toMatchObject([{ max: 2, delayTime: { value: 0.25 } }]);
expect(filters.map((f) => [f.type, f.frequency.value])).toEqual([['lowpass', 20000]]); expect(filters.map((f) => [f.type, f.frequency.value])).toEqual([['lowpass', 20000]]);
expect(filters[0]!.Q.value).toBeCloseTo(-3.0103, 3); expect(filters[0]!.Q.value).toBeCloseTo(-3.0103, 3);
expect(wires).toEqual([ expect(wires).toEqual([
'source > gain', // the source through its fade 'source > gain', // the source through its fade
'gain > gain', // the sound on, as it is 'gain > gain', // the sound on, at its share
'gain > gain',
'delay > filter', 'delay > filter',
'filter > gain', // into the mix 'filter > gain', // into the mix
'gain > gain', // and out 'gain > gain', // and out
@@ -1032,8 +1033,9 @@ describe('the sound library', () => {
'gain > gain', 'gain > gain',
...INTO_MASTER, ...INTO_MASTER,
]); ]);
// The voice, the fade made before it, the echo's in, out, feedback and mix, then the master's two. // The voice, the fade made before it, the echo's in, out, sound, feedback and echoes, then the
expect(gains.map((g) => g.value)).toEqual([1, 1, 1, 1, 0.4, 0.5, 1, 1]); // master's two. Wet 0.5 (#354): the sound and the echoes both in full.
expect(gains.map((g) => g.value)).toEqual([1, 1, 1, 1, 1, 0.4, 1, 1, 1]);
}); });
it('wires a ping-pong echo into the left only, crossing sides on every round', async () => { it('wires a ping-pong echo into the left only, crossing sides on every round', async () => {
@@ -1190,9 +1192,10 @@ describe('the sound library', () => {
library.tune(voice, { time: 0.5, dull: 1, wet: 0.2 }, 2); library.tune(voice, { time: 0.5, dull: 1, wet: 0.2 }, 2);
expect(delays[0]!.delayTime.ramps).toEqual([['linear', 0.5, 2]]); expect(delays[0]!.delayTime.ramps).toEqual([['linear', 0.5, 2]]);
expect(filters[0]!.frequency.ramps).toEqual([['exponential', expect.closeTo(500), 2]]); expect(filters[0]!.frequency.ramps).toEqual([['exponential', expect.closeTo(500), 2]]);
// The mix of the echo, made before the master's two. // The echoes, made before the master's two: wet 0.2 has them at 0.4, the sound stays in full.
expect(gains.at(-3)!.ramps).toEqual([['linear', 0.2, 2]]); expect(gains.at(-3)!.ramps).toEqual([['linear', 0.4, 2]]);
expect(library.levels(FILE)).toEqual([[0.25, 0.4, 20000, 0.5]]); expect(gains.at(-5)!.ramps).toEqual([]);
expect(library.levels(FILE)).toEqual([[0.25, 0.4, 20000, 1, 1]]);
}); });
}); });
}); });
@@ -2478,3 +2481,73 @@ describe('the stages of #318 to #328', () => {
expect(library.tail(voice)).toBeCloseTo(1.6, 9); expect(library.tail(voice)).toBeCloseTo(1.6, 9);
}); });
}); });
describe('Wet (#354)', () => {
const compress: FxStage = {
kind: 'compress',
threshold: -24,
knee: 6,
ratio: 4,
attack: 0.003,
release: 0.25,
gain: 1,
};
it('blends what a node adds with the sound as it came in, held back as far as the node is late', () => {
const context = fakeContext() as unknown as BaseAudioContext;
const made = stageNodes(context, { kind: 'wet', wet: 0.3, stages: [compress] });
expect(delays.map((delay) => delay.delayTime.value)).toEqual([0.006]);
expect(wires).toEqual([
'compressor > gain', // the node, made first
'gain > compressor', // in, through it
'gain > gain', // at its wet
'gain > gain', // and out
'gain > delay', // the sound as it came in, held back
'delay > gain', // at the rest
'gain > gain',
]);
expect(made.knobs.slice(-2).map((knob) => [knob.target, knob.even])).toEqual([
[0.3, 'value'],
[expect.closeTo(0.7, 9), 'value'],
]);
expect(made.knobs).toHaveLength(8);
});
it('an Envelope moving the wet moves both shares, the rest as 1 - wet', () => {
const context = fakeContext() as unknown as BaseAudioContext;
const followed: [AudioParam, number | undefined][] = [];
const envelope = { id: 'e', attack: 0.1, decay: 0.1, sustain: 0.5, release: 0.1 };
const made = stageNodes(
context,
{ kind: 'wet', wet: 0, wetMod: { envelope, from: 0, to: 1 }, stages: [{ kind: 'pan', pan: 1 }] },
(_mod, param, _ratio, map) => followed.push([param, map?.(0.25)]),
);
const [wet, dry] = made.knobs.slice(-2);
expect(wet!.param).toBeNull();
expect(dry!.param).toBeNull();
expect(followed.map(([, mapped]) => mapped)).toEqual([undefined, 0.75]);
expect(followed.map(([param]) => param.value)).toEqual([0, 0]);
});
it('a wet from a parameter glides both shares while the voice plays', async () => {
const library = new SoundLibrary();
const plan: FxPlan = {
pitch: [],
stages: [
{
kind: 'wet',
wet: { value: 1, param: 'w', min: 0, max: 1 },
stages: [{ kind: 'pan', pan: { value: 1 } }],
},
],
};
const voice = await play(library, FILE, {}, { plan, params: {} });
expect(library.levels(FILE)).toEqual([[1, 1, 0]]);
library.tune(voice, { w: 0.25 }, 2);
const ramps = gains.flatMap((gain) => gain.ramps);
expect(ramps).toEqual([
['linear', 0.25, 2],
['linear', 0.75, 2],
]);
});
});
+79 -7
View File
@@ -436,9 +436,10 @@ export async function sound(t) {
graph.edges = graph.edges.filter((e) => !['fx-e5', 'fx-e6', 'fx-e7'].includes(e.id)); graph.edges = graph.edges.filter((e) => !['fx-e5', 'fx-e6', 'fx-e7'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`); graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
// #324, #327: a ping-pong Delay behind the gain. The blip is a quarter of a second, its echoes come // #324, #327: a ping-pong Delay behind the gain. The blip is a quarter of a second, its echoes come
// 0.3 s apart, each half as loud as the one before: nine are heard, 2.7 s – the voice // 0.3 s apart, each half as loud as the one before: at wet 0.5 the first is in full
// stays that long after its sound and goes then. Asked to let ring, only the sound // (#354) and ten are heard, 3 s – the voice stays that long after its sound and goes
// stops and the voice stays; a stop after that cuts it. // then. Asked to let ring, only the sound stops and the voice stays; a stop after that
// cuts it.
await mutate(` await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify( graph.nodes.push(${JSON.stringify(
@@ -466,11 +467,12 @@ export async function sound(t) {
'a Delay keeps the voice after its sound has ended, until its echoes have faded', 'a Delay keeps the voice after its sound has ended, until its echoes have faded',
delayInfo === '3 effects' && delayInfo === '3 effects' &&
echoStarted.last?.stages === 2 && echoStarted.last?.stages === 2 &&
echoKnobs.length === 4 && echoKnobs.length === 5 &&
Math.abs(echoKnobs[0] - 0.3) < 1e-6 && Math.abs(echoKnobs[0] - 0.3) < 1e-6 &&
Math.abs(echoKnobs[1] - 0.5) < 1e-6 && Math.abs(echoKnobs[1] - 0.5) < 1e-6 &&
Math.abs(echoKnobs[2] / (20000 * 0.025 ** 0.3) - 1) < 1e-3 && Math.abs(echoKnobs[2] / (20000 * 0.025 ** 0.3) - 1) < 1e-3 &&
Math.abs(echoKnobs[3] - 0.5) < 1e-6 && Math.abs(echoKnobs[3] - 1) < 1e-6 &&
Math.abs(echoKnobs[4] - 1) < 1e-6 &&
ringing.playing === 1 && ringing.playing === 1 &&
ringing.clock - startedAt < 2.4 && ringing.clock - startedAt < 2.4 &&
quiet.playing === 0 && quiet.playing === 0 &&
@@ -494,6 +496,25 @@ export async function sound(t) {
cut.stopped === beforeLetRing.stopped + 2, cut.stopped === beforeLetRing.stopped + 2,
JSON.stringify({ before: beforeLetRing.stopped, letRing, cut }), JSON.stringify({ before: beforeLetRing.stopped, letRing, cut }),
); );
// #354: the Delay bypassed is drawn faded and says so, and the voice plays as if it were not there.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.find((n) => n.id === 'fx-delay').params.bypass = true;`);
const bypassed = await json(
"({ alpha: __gm.soundfx.nodeAlpha('fx-delay'), settings: __gm.soundfx.nodeSettings('fx-delay'), gain: __gm.soundfx.nodeAlpha('fx-gain') })",
);
const beforeBypass = await stats();
await click(fxPlayAt.x, fxPlayAt.y);
const heardBypass = await waitFor(stats, (s) => s.started > beforeBypass.started);
await evaluate('__gm.sound.stopAll()');
check(
'a bypassed Delay is drawn faded with "Bypass: true", and the voice passes only the gain',
bypassed.alpha < 0.5 &&
bypassed.gain === 1 &&
bypassed.settings?.includes('Bypass: true') &&
heardBypass.last?.stages === 1,
JSON.stringify({ bypassed, last: heardBypass.last }),
);
await mutate(` await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-delay'); graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-delay');
@@ -719,13 +740,13 @@ export async function sound(t) {
JSON.stringify(offline), JSON.stringify(offline),
); );
// #327: the player's own echo, rendered offline. A 10 ms burst into a ping-pong Delay (0.3 s, // #327: the player's own echo, rendered offline. A 10 ms burst into a ping-pong Delay (0.3 s,
// feedback 0.5, mix 0.5) comes back left at 0.3 s at half its level, right at 0.6 s at // feedback 0.5, wet 0.25 – the mix 0.5 of before #354) comes back left at 0.3 s at half its level, right at 0.6 s at
// half of that, left again at 0.9 s – in Chrome each round 128 samples later than that. Then // half of that, left again at 0.9 s – in Chrome each round 128 samples later than that. Then
// 64 stereo voices of it for 2 s, timed, for how many this machine plays at once (#324). // 64 stereo voices of it for 2 s, timed, for how many this machine plays at once (#324).
const echo = await evaluate(` const echo = await evaluate(`
(async () => { (async () => {
const RATE = 48000; const RATE = 48000;
const echoOf = (pingPong) => ({ kind: 'delay', time: 0.3, feedback: 0.5, damping: 20000, mix: 0.5, pingPong }); const echoOf = (pingPong) => ({ kind: 'delay', time: 0.3, feedback: 0.5, damping: 20000, mix: 0.25, pingPong });
const render = async (voices, seconds, stage = echoOf(true)) => { const render = async (voices, seconds, stage = echoOf(true)) => {
const context = new OfflineAudioContext(2, RATE * seconds, RATE); const context = new OfflineAudioContext(2, RATE * seconds, RATE);
const burst = context.createBuffer(1, RATE * 0.01, RATE); const burst = context.createBuffer(1, RATE * 0.01, RATE);
@@ -1196,6 +1217,57 @@ export async function sound(t) {
JSON.stringify(reverbed.sound) === JSON.stringify([0.3 * 48000, 0.3 * 48000]), JSON.stringify(reverbed.sound) === JSON.stringify([0.3 * 48000, 0.3 * 48000]),
JSON.stringify(reverbed), JSON.stringify(reverbed),
); );
// #354: Wet rendered offline. A steady 0.5 through a Gain of 0 at wet 0.25 is 0.375. A Delay of
// 0.1 s without feedback on it: at wet 0.25 the sound in full and the echo at half, at 0.75
// the sound at half and the echo in full, at 1 the echo alone. A Compressor at 1:1 blended
// half and half at 1083 Hz – where its 6 ms would cancel – stays as loud: the sound as it
// came in is held back to meet it.
const wetted = await evaluate(`
(async () => {
const RATE = 48000;
const render = async (stage, fill) => {
const context = new OfflineAudioContext(1, RATE / 2, RATE);
const sound = context.createBuffer(1, RATE / 2, RATE);
fill(sound.getChannelData(0));
const source = context.createBufferSource();
source.buffer = sound;
const nodes = __gm.sound.stageNodes(context, stage);
source.connect(nodes.input);
nodes.output.connect(context.destination);
source.start();
return (await context.startRendering()).getChannelData(0);
};
const steady = (data) => data.fill(0.5);
const sine = (data) => { for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1083.333 * i) / RATE) * 0.5; };
const at = (out, seconds) => +out[Math.round(seconds * RATE)].toFixed(3);
const delay = async (mix) => {
const out = await render({ kind: 'delay', time: 0.1, feedback: 0, damping: 20000, mix, pingPong: false }, steady);
return [at(out, 0.05), at(out, 0.2)];
};
const compress = { kind: 'compress', threshold: 0, knee: 0, ratio: 1, attack: 0.003, release: 0.25, gain: 1 };
const blended = await render({ kind: 'wet', wet: 0.5, stages: [compress] }, sine);
let sum = 0;
for (let i = RATE / 4; i < RATE / 2; i++) sum += blended[i] ** 2;
return {
gain: at(await render({ kind: 'wet', wet: 0.25, stages: [{ kind: 'gain', gain: 0 }] }, steady), 0.1),
quarter: await delay(0.25),
threeQuarters: await delay(0.75),
echoOnly: await delay(1),
compressed: +(20 * Math.log10(Math.sqrt(sum / (RATE / 4)) / (0.5 * Math.SQRT1_2))).toFixed(2),
};
})()`);
check(
'Wet blends a node with the sound as it came in, a Delay crossfades its echo, a late node meets it in step',
wetted.gain === 0.375 &&
JSON.stringify([wetted.quarter, wetted.threeQuarters, wetted.echoOnly]) ===
JSON.stringify([
[0.5, 0.75],
[0.25, 0.75],
[0, 0.5],
]) &&
Math.abs(wetted.compressed) < 0.1,
JSON.stringify(wetted),
);
// #329: the Chip on the worklet, rendered offline. A square at 220 Hz with a pitch of 12 and a duty // #329: the Chip on the worklet, rendered offline. A square at 220 Hz with a pitch of 12 and a duty
// of 0.25, starting at 0.25 s: silence until then, 440 periods a second after it, up a // of 0.25, starting at 0.25 s: silence until then, 440 periods a second after it, up a
// quarter of each. Sliding two octaves a second down from 440 Hz with a lowest frequency of // quarter of each. Sliding two octaves a second down from 440 Hz with a lowest frequency of