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
+978 -385

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.
*/
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
* 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 phaser = (stages: number, frequency: number, rate: number, depth: number, feedback: number) =>
[SoundFxPhaser.type, { stages: String(stages), frequency, rate, depth, feedback, mix: 1 }] as const;
const echo = (time: number, feedback: number, mix: number, pingPong = false) =>
[SoundFxDelay.type, { time, sync: 'beats', feedback, damping: 0.4, mix, pingPong }] as const;
/** Below 0.5 a Delay's wet keeps the sound in full and has the echoes at twice it (#354). */
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`. */
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(
chipOn(s, notes, 0, 'square', { duty: 0.25 }),
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.
octaveArp: (s, notes) =>
@@ -666,7 +667,7 @@ const TONES: Record<Exclude<TonePreset, 'drumKit'>, (s: Sketch, notes: GraphNode
bell: (s, notes) => {
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));
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.
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),
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);
}
/** 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 {
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> = {};
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;
}
/** 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 =>
typeof value === 'object' && value !== null && typeof (value as FxAmount).value === 'number';
+507 -338
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -101,6 +101,8 @@ export function graphHooks({ state, editor }: HookContext) {
info: () => editor.soundFxEditor.shownInfo,
canvasRect: () => editor.soundFxEditor.canvas.canvasRect(),
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. */
preview: () => ({
sound: editor.soundFxEditor.previewSoundId,
+5
View File
@@ -1159,6 +1159,11 @@ export class GraphCanvas extends Widget {
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 {
return this.views.get(nodeId)?.inputLines ?? null;
}
+3
View File
@@ -208,6 +208,7 @@ export class NodeView extends Widget {
): void {
this.def = def;
this.problem = problem;
this.alpha = def.dimmed?.(node.params) ? DIMMED_ALPHA : 1;
const hidden = new Set([
...(this.def.hiddenParams?.(node.params) ?? []),
...(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 that does nothing at its settings is drawn, a bypassed effect (#354). */
const DIMMED_ALPHA = 0.45;
const NULL_TEXT = '(null)';
const EMPTY_TEXT = '(empty)';
+80 -25
View File
@@ -26,6 +26,8 @@ import {
type FxStage,
type FxStagePlan,
type FxVoice,
type FxWet,
delayShares,
stageLatency,
} from '../core/soundfx/nodes';
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. */
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;
}
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. */
function hasChip(plan: FxPlan): boolean {
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 === 'reverb') return reverbNodes(context, stage, impulse);
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')
return workletNodes(context, 'limiter', [
{ name: 'threshold', value: stage.threshold, even: 'value' },
@@ -2208,6 +2214,44 @@ export function releaseEnvelope(
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
* 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
* back into it and added at `mix`. Ping-pong sends the sound into the left of the line only and
* swaps the sides on every round.
* An echo (#327): the sound goes on, and through a delay line whose output is damped, fed back into
* it and added – each at its share of the wet `mix` (#354). Ping-pong sends the sound into the left
* of the line only and swaps the sides on every round.
*/
function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes {
const input = 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);
line.delayTime.value = stage.time;
const damp = context.createBiquadFilter();
@@ -2363,7 +2409,7 @@ function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes {
damp.frequency.value = stage.damping;
damp.Q.value = passQ('lowpass', FLAT_Q);
const feedback = gainNode(context, stage.feedback);
const wet = gainNode(context, stage.mix);
const wet = gainNode(context, shares.echo);
line.connect(damp);
damp.connect(wet).connect(output);
if (stage.pingPong) {
@@ -2390,7 +2436,8 @@ function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes {
{ param: line.delayTime, target: stage.time, even: 'value' },
{ param: feedback.gain, target: stage.feedback, even: 'value' },
{ 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,25 +2669,33 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] {
if (chain.shifted) into.push(chain.pitch);
if (chain.chip) into.push(chain.chip.frequency, chain.chip.duty);
}
for (const stage of chain.stages)
if (stage.kind === 'delay') into.push(stage.time, stage.feedback, stage.damping, stage.mix);
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 === 'phaser')
into.push(stage.frequency, stage.rate, stage.depth, stage.feedback, stage.mix);
else if (stage.kind === 'flanger')
into.push(stage.offset, stage.sweep, stage.rate, stage.depth, stage.feedback, stage.mix);
else if (stage.kind === 'pan') into.push(stage.pan);
else if (stage.kind === 'compress')
into.push(stage.threshold, stage.knee, stage.ratio, stage.attack, stage.release, stage.gain);
else if (stage.kind === 'limit') into.push(stage.threshold, stage.ceiling, stage.release, stage.autoGain);
else if (stage.kind === 'tremolo' || stage.kind === 'vibrato') into.push(stage.rate, stage.depth);
else if (stage.kind === 'reverb')
into.push(stage.decay, stage.damping, stage.predelay, stage.mix, 1 - stage.mix);
else if (stage.kind === 'saturate')
into.push(driveGain(stage.drive), stage.tone, stage.level, stage.mix, 1 - stage.mix);
else if (stage.kind !== 'envelope') into.push(stage.gain);
for (const stage of chain.stages) stageLevels(stage, into);
return into;
}
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 === 'phaser')
into.push(stage.frequency, stage.rate, stage.depth, stage.feedback, stage.mix);
else if (stage.kind === 'flanger')
into.push(stage.offset, stage.sweep, stage.rate, stage.depth, stage.feedback, stage.mix);
else if (stage.kind === 'pan') into.push(stage.pan);
else if (stage.kind === 'compress')
into.push(stage.threshold, stage.knee, stage.ratio, stage.attack, stage.release, stage.gain);
else if (stage.kind === 'limit') into.push(stage.threshold, stage.ceiling, stage.release, stage.autoGain);
else if (stage.kind === 'tremolo' || stage.kind === 'vibrato') into.push(stage.rate, stage.depth);
else if (stage.kind === 'reverb')
into.push(stage.decay, stage.damping, stage.predelay, stage.mix, 1 - stage.mix);
else if (stage.kind === 'saturate')
into.push(driveGain(stage.drive), stage.tone, stage.level, stage.mix, 1 - stage.mix);
else if (stage.kind !== 'envelope') into.push(stage.gain);
}
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)', () => {
const peak = filterIn({ filter: 'peaking', gain: 6, slope: '48' });
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(fxChainOf(compileSoundFx(peak.fx.graph).compiled!).stages[0]).toMatchObject({
stages: 1,
@@ -124,7 +124,7 @@ describe('a Filter', () => {
});
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([]);
// A gain typed in before the type changed does nothing.
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');
});
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', () => {
const f = makeInstrument();
nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id;
+158
View File
@@ -25,7 +25,9 @@ import {
type FxStage,
fxTailOf,
type FxVoice,
delayShares,
impulsesOf,
SOUND_FX_NODES,
SoundFxReverb,
stageLatency,
} 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)', () => {
const echo = (time: number, feedback: number, mix: number): FxStage => ({
kind: 'delay',
+80 -7
View File
@@ -1014,14 +1014,15 @@ describe('the sound library', () => {
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)] });
expect(delays).toMatchObject([{ max: 2, delayTime: { value: 0.25 } }]);
expect(filters.map((f) => [f.type, f.frequency.value])).toEqual([['lowpass', 20000]]);
expect(filters[0]!.Q.value).toBeCloseTo(-3.0103, 3);
expect(wires).toEqual([
'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',
'filter > gain', // into the mix
'gain > gain', // and out
@@ -1032,8 +1033,9 @@ describe('the sound library', () => {
'gain > gain',
...INTO_MASTER,
]);
// The voice, the fade made before it, the echo's in, out, feedback and mix, then the master's two.
expect(gains.map((g) => g.value)).toEqual([1, 1, 1, 1, 0.4, 0.5, 1, 1]);
// The voice, the fade made before it, the echo's in, out, sound, feedback and echoes, then the
// 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 () => {
@@ -1190,9 +1192,10 @@ describe('the sound library', () => {
library.tune(voice, { time: 0.5, dull: 1, wet: 0.2 }, 2);
expect(delays[0]!.delayTime.ramps).toEqual([['linear', 0.5, 2]]);
expect(filters[0]!.frequency.ramps).toEqual([['exponential', expect.closeTo(500), 2]]);
// The mix of the echo, made before the master's two.
expect(gains.at(-3)!.ramps).toEqual([['linear', 0.2, 2]]);
expect(library.levels(FILE)).toEqual([[0.25, 0.4, 20000, 0.5]]);
// 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.4, 2]]);
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);
});
});
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.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
// 0.3 s apart, each half as loud as the one before: nine are heard, 2.7 s – the voice
// stays that long after its sound and goes then. Asked to let ring, only the sound
// stops and the voice stays; a stop after that cuts it.
// 0.3 s apart, each half as loud as the one before: at wet 0.5 the first is in full
// (#354) and ten are heard, 3 s – the voice stays that long after its sound and goes
// then. Asked to let ring, only the sound stops and the voice stays; a stop after that
// cuts it.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
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',
delayInfo === '3 effects' &&
echoStarted.last?.stages === 2 &&
echoKnobs.length === 4 &&
echoKnobs.length === 5 &&
Math.abs(echoKnobs[0] - 0.3) < 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[3] - 0.5) < 1e-6 &&
Math.abs(echoKnobs[3] - 1) < 1e-6 &&
Math.abs(echoKnobs[4] - 1) < 1e-6 &&
ringing.playing === 1 &&
ringing.clock - startedAt < 2.4 &&
quiet.playing === 0 &&
@@ -494,6 +496,25 @@ export async function sound(t) {
cut.stopped === beforeLetRing.stopped + 2,
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(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-delay');
@@ -719,13 +740,13 @@ export async function sound(t) {
JSON.stringify(offline),
);
// #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
// 64 stereo voices of it for 2 s, timed, for how many this machine plays at once (#324).
const echo = await evaluate(`
(async () => {
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 context = new OfflineAudioContext(2, RATE * seconds, 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),
);
// #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
// 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