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:
1 parent
c95fbc167e
commit
7a17030472
13 files changed
+978
-385
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
+507
-338
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)';
|
||||||
|
|
||||||
|
|||||||
@@ -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,25 +2669,33 @@ 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);
|
|
||||||
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);
|
|
||||||
return 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();
|
export const soundLibrary = new SoundLibrary();
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user