Shape a sound with every filter type, a Q and a slope (#318)
The Filter node from #335 only knew low-pass, high-pass and band-pass and had a Resonance from 0 to 1. It now has every type Web Audio offers, a Q as a plain factor and a slope up to 48 dB per octave. - Types: low-pass, high-pass, band-pass, notch, low shelf, high shelf, peaking and all-pass. The cutoff row is now labelled Frequency, its key stays `cutoff`. - Q from 0.1 to 30, 0.707 by default, replaces Resonance. Web Audio takes the Q of a low- or high-pass in dB, so it is converted when the voice is wired. No project used the node. - Gain in dB is an input only for the shelves and the peak. It is a derived port, so the sound FX compiler now resolves derived ports. - Slope 12, 24 or 48 dB/oct runs 1, 2 or 4 filters in a row. They share one cutoff carried by a ConstantSource, only the last has the Q. Shelves, peak and all-pass hide the row. - The Chip Presets get the Q their resonance gave before. Mutate moves Q in octaves and gain by up to a dB. - bindStage in the instrument compiler binds every amount of a stage generically, so new stages need no case of their own there. Finding: FLAT_Q was in dB and the delay damping used it. It is a factor now, the damping takes it through passQ and is unchanged. Tests: - Unit envelope 13, sound-library 96 (2 new), chip-presets 14, soundfx 45, all green. - Counter-checks: the Q on every filter in a row, the gain row for every type. Each turns one test red. - e2e 34 (melektaus-atx): ALL PASSED, 76 checks (1 new). Chrome offline, 2 kHz through a low-pass at 1 kHz: -12.37 dB at 12 dB/oct, -49.5 dB at 48. A +6 dB peak gives 6.00 dB, a notch -124 dB. - The gate runs once on the last commit of this series. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
daeffe0f9d
commit
e1ca6a5f5a
9 files changed
+282
-98
No files matched your search
@@ -7,6 +7,7 @@ import type { ChipWave } from '../soundfx/chip';
|
|||||||
import {
|
import {
|
||||||
CRUSH_ROWS,
|
CRUSH_ROWS,
|
||||||
FILTER_ROWS,
|
FILTER_ROWS,
|
||||||
|
FLAT_Q,
|
||||||
type FxPassFilter,
|
type FxPassFilter,
|
||||||
passOf,
|
passOf,
|
||||||
SoundFxBitcrusher,
|
SoundFxBitcrusher,
|
||||||
@@ -341,12 +342,22 @@ function envelopeNumbers(p: Sfxr): Record<'attack' | 'decay' | 'sustain' | 'rele
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Q a Filter had at a resonance from 0 to 1 before it took a Q (#318): from no bump up to a
|
||||||
|
* 20 dB ring for a low- or high-pass, from about two octaves wide to a narrow ring for a band-pass.
|
||||||
|
*/
|
||||||
|
function resonanceQ(filter: FxPassFilter, resonance: number): number {
|
||||||
|
const r = Math.max(0, Math.min(1, resonance));
|
||||||
|
if (filter === 'bandpass') return 0.7 * (20 / 0.7) ** r;
|
||||||
|
return FLAT_Q * (10 / FLAT_Q) ** r;
|
||||||
|
}
|
||||||
|
|
||||||
/** Where sfxr's filters come out, run 8 times a sample: about 17.8 kHz times `f^1.5` low, 5.6 kHz times `f²` high. */
|
/** Where sfxr's filters come out, run 8 times a sample: about 17.8 kHz times `f^1.5` low, 5.6 kHz times `f²` high. */
|
||||||
function filterNumbers(p: Sfxr, filter: FxPassFilter): Record<'cutoff' | 'resonance', number> {
|
function filterNumbers(p: Sfxr, filter: FxPassFilter): Record<'cutoff' | 'q', number> {
|
||||||
if (filter === 'highpass') return { cutoff: 5615 * p.hpfFreq ** 2, resonance: 0 };
|
if (filter === 'highpass') return { cutoff: 5615 * p.hpfFreq ** 2, q: FLAT_Q };
|
||||||
// Wide open, as far as the row goes.
|
// Wide open, as far as the row goes.
|
||||||
if (p.lpfFreq >= 1) return { cutoff: Infinity, resonance: 0 };
|
if (p.lpfFreq >= 1) return { cutoff: Infinity, q: FLAT_Q };
|
||||||
return { cutoff: 17756 * Math.max(0, p.lpfFreq) ** 1.5, resonance: Math.abs(p.lpfResonance) };
|
return { cutoff: 17756 * Math.max(0, p.lpfFreq) ** 1.5, q: resonanceQ(filter, Math.abs(p.lpfResonance)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -488,7 +499,7 @@ function chipOn(
|
|||||||
const envelope = (attack: number, decay: number, sustain: number, release: number) =>
|
const envelope = (attack: number, decay: number, sustain: number, release: number) =>
|
||||||
[INSTRUMENT_ENVELOPE, held({ attack, decay, sustain, release }, ENVELOPE_ROWS)] as const;
|
[INSTRUMENT_ENVELOPE, held({ attack, decay, sustain, release }, ENVELOPE_ROWS)] as const;
|
||||||
const pass = (filter: FxPassFilter, cutoff: number, resonance = 0) =>
|
const pass = (filter: FxPassFilter, cutoff: number, resonance = 0) =>
|
||||||
[SoundFxFilter.type, { filter, cutoff, resonance }] as const;
|
[SoundFxFilter.type, { filter, cutoff, q: heldTo(resonanceQ(filter, resonance), FILTER_ROWS.q) }] as const;
|
||||||
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;
|
||||||
@@ -801,7 +812,8 @@ const MUTATE: Readonly<Record<string, { octaves: number } | { by: number }>> = {
|
|||||||
attack: { by: 0.005 },
|
attack: { by: 0.005 },
|
||||||
decay: { by: 0.02 },
|
decay: { by: 0.02 },
|
||||||
release: { by: 0.01 },
|
release: { by: 0.01 },
|
||||||
resonance: { by: 0.05 },
|
q: { octaves: 0.25 },
|
||||||
|
gain: { by: 1 },
|
||||||
bits: { by: 0.5 },
|
bits: { by: 0.5 },
|
||||||
offset: { by: 0.1 },
|
offset: { by: 0.1 },
|
||||||
sweep: { by: 2 },
|
sweep: { by: 2 },
|
||||||
|
|||||||
@@ -360,42 +360,17 @@ function throughFxWith(fx: FxPlan, sound: FxPlan, bind: (amount: FxAmount) => Fx
|
|||||||
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. */
|
||||||
function bindStage(stage: FxStagePlan, bind: (amount: FxAmount) => FxAmount): FxStagePlan {
|
function bindStage(stage: FxStagePlan, bind: (amount: FxAmount) => FxAmount): FxStagePlan {
|
||||||
if (stage.kind === 'gain') return { ...stage, db: bind(stage.db) };
|
|
||||||
if (stage.kind === 'filter') return { ...stage, gain: bind(stage.gain) };
|
|
||||||
if (stage.kind === 'pass')
|
|
||||||
return { ...stage, cutoff: bind(stage.cutoff), resonance: bind(stage.resonance) };
|
|
||||||
if (stage.kind === 'envelope') return stage;
|
if (stage.kind === 'envelope') return stage;
|
||||||
if (stage.kind === 'crush')
|
const bound: Record<string, unknown> = {};
|
||||||
return { ...stage, bits: bind(stage.bits), rate: bind(stage.rate), mix: bind(stage.mix) };
|
for (const [key, value] of Object.entries(stage)) bound[key] = isAmount(value) ? bind(value) : value;
|
||||||
if (stage.kind === 'phaser')
|
return bound as FxStagePlan;
|
||||||
return {
|
|
||||||
...stage,
|
|
||||||
frequency: bind(stage.frequency),
|
|
||||||
rate: bind(stage.rate),
|
|
||||||
depth: bind(stage.depth),
|
|
||||||
feedback: bind(stage.feedback),
|
|
||||||
mix: bind(stage.mix),
|
|
||||||
};
|
|
||||||
if (stage.kind === 'flanger')
|
|
||||||
return {
|
|
||||||
...stage,
|
|
||||||
offset: bind(stage.offset),
|
|
||||||
sweep: bind(stage.sweep),
|
|
||||||
rate: bind(stage.rate),
|
|
||||||
depth: bind(stage.depth),
|
|
||||||
feedback: bind(stage.feedback),
|
|
||||||
mix: bind(stage.mix),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
...stage,
|
|
||||||
time: bind(stage.time),
|
|
||||||
feedback: bind(stage.feedback),
|
|
||||||
damping: bind(stage.damping),
|
|
||||||
mix: bind(stage.mix),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAmount = (value: unknown): value is FxAmount =>
|
||||||
|
typeof value === 'object' && value !== null && typeof (value as FxAmount).value === 'number';
|
||||||
|
|
||||||
const builds = new Map<string, { fingerprint: string; notes: Map<number, InstrumentBuild> }>();
|
const builds = new Map<string, { fingerprint: string; notes: Map<number, InstrumentBuild> }>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { graphEmitter } from '../graph/emitGraph';
|
import { graphEmitter } from '../graph/emitGraph';
|
||||||
import { type DeclaredPort, type Graph, graphFingerprint, type GraphNode } from '../graph/Graph';
|
import { type DeclaredPort, type Graph, graphFingerprint, type GraphNode } from '../graph/Graph';
|
||||||
import { terminalNode } from '../graph/nested';
|
import { resolveDef, terminalNode } from '../graph/nested';
|
||||||
|
import { NO_LOOKUP } from '../graph/lookup';
|
||||||
import { inlineValue, nodeDef } from '../graph/NodeRegistry';
|
import { inlineValue, nodeDef } from '../graph/NodeRegistry';
|
||||||
import { asNumber } from '../graph/values';
|
import { asNumber } from '../graph/values';
|
||||||
import type { SoundFxAsset } from '../model/Asset';
|
import type { SoundFxAsset } from '../model/Asset';
|
||||||
@@ -50,6 +51,8 @@ export function compileSoundFx(graph: Graph): SoundFxBuild {
|
|||||||
const walk = graphEmitter<SoundFxNodeDef, FxValue>(graph, {
|
const walk = graphEmitter<SoundFxNodeDef, FxValue>(graph, {
|
||||||
isDef: isSoundFxNodeDef,
|
isDef: isSoundFxNodeDef,
|
||||||
noun: 'sound FX',
|
noun: 'sound FX',
|
||||||
|
resolve: (node, def) =>
|
||||||
|
def.derivePorts ? ((resolveDef(node, graph, NO_LOOKUP) as SoundFxNodeDef | undefined) ?? def) : def,
|
||||||
emit: (def, inputs, node) => {
|
emit: (def, inputs, node) => {
|
||||||
if (node.type === SOUND_FX_INPUT) return inputOutputs(node);
|
if (node.type === SOUND_FX_INPUT) return inputOutputs(node);
|
||||||
const amounts: Record<string, FxAmount> = {};
|
const amounts: Record<string, FxAmount> = {};
|
||||||
|
|||||||
+62
-25
@@ -12,8 +12,9 @@ export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
|
|||||||
/** The filter shapes the EQ uses – Web Audio's names, so the player hands them on as they are. */
|
/** The filter shapes the EQ uses – Web Audio's names, so the player hands them on as they are. */
|
||||||
export type FxFilter = 'lowshelf' | 'peaking' | 'highshelf';
|
export type FxFilter = 'lowshelf' | 'peaking' | 'highshelf';
|
||||||
|
|
||||||
/** The shapes of a Filter (#335). */
|
/** The shapes of a Filter (#335, #318), Web Audio's names. */
|
||||||
export type FxPassFilter = 'lowpass' | 'highpass' | 'bandpass';
|
export type FxPassFilter =
|
||||||
|
'lowpass' | 'highpass' | 'bandpass' | 'notch' | 'lowshelf' | 'highshelf' | 'peaking' | 'allpass';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One step a voice passes on its way out: a volume, one band of an equalizer, a filter, the volume an
|
* One step a voice passes on its way out: a volume, one band of an equalizer, a filter, the volume an
|
||||||
@@ -67,14 +68,21 @@ export interface FxCrush {
|
|||||||
mixMod?: FxMod;
|
mixMod?: FxMod;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A filter as Web Audio takes it: `q` in dB for a low- or high-pass, as a factor for a band-pass. */
|
/**
|
||||||
|
* A filter as Web Audio takes it: `q` in dB for a low- or high-pass, as a factor for the others,
|
||||||
|
* `gain` in dB for a shelf or a peak. `stages` of them run one after another for a steeper slope
|
||||||
|
* (#318); only the last has the Q.
|
||||||
|
*/
|
||||||
export interface FxPass {
|
export interface FxPass {
|
||||||
kind: 'pass';
|
kind: 'pass';
|
||||||
filter: FxPassFilter;
|
filter: FxPassFilter;
|
||||||
|
stages: number;
|
||||||
cutoff: number;
|
cutoff: number;
|
||||||
q: number;
|
q: number;
|
||||||
|
gain: number;
|
||||||
cutoffMod?: FxMod;
|
cutoffMod?: FxMod;
|
||||||
qMod?: FxMod;
|
qMod?: FxMod;
|
||||||
|
gainMod?: FxMod;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -193,7 +201,7 @@ export interface FxAmount {
|
|||||||
export type FxStagePlan =
|
export type FxStagePlan =
|
||||||
| { kind: 'gain'; db: FxAmount }
|
| { kind: 'gain'; db: FxAmount }
|
||||||
| { kind: 'filter'; filter: FxFilter; frequency: number; gain: FxAmount; q: number }
|
| { kind: 'filter'; filter: FxFilter; frequency: number; gain: FxAmount; q: number }
|
||||||
| { kind: 'pass'; filter: FxPassFilter; cutoff: FxAmount; resonance: FxAmount }
|
| { kind: 'pass'; filter: FxPassFilter; stages: number; cutoff: FxAmount; q: FxAmount; gain: FxAmount }
|
||||||
| { kind: 'envelope'; envelope: FxEnvelopePlan }
|
| { kind: 'envelope'; envelope: FxEnvelopePlan }
|
||||||
| {
|
| {
|
||||||
kind: 'delay';
|
kind: 'delay';
|
||||||
@@ -406,28 +414,27 @@ export const SoundFxEq: SoundFxNodeDef = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The Q of a low- or high-pass with no bump at its cutoff, in the decibels Web Audio takes it in. */
|
/** The Q of a filter with no bump at its cutoff, as a factor. */
|
||||||
export const FLAT_Q = 20 * Math.log10(Math.SQRT1_2);
|
export const FLAT_Q = Math.SQRT1_2;
|
||||||
/** How far a low- or high-pass at resonance 1 rises at its cutoff, in dB: it rings, but stays usable. */
|
|
||||||
const RESONANT_Q = 20;
|
|
||||||
/** A band-pass from about two octaves wide at resonance 0 to a narrow ring at 1, as a factor. */
|
|
||||||
const WIDE_BAND_Q = 0.7;
|
|
||||||
const NARROW_BAND_Q = 20;
|
|
||||||
|
|
||||||
/** The Q a Filter of that shape has at a resonance from 0 to 1. */
|
/** A Q as Web Audio takes it for that shape: in dB for a low- or high-pass, else the factor itself. */
|
||||||
export function passQ(filter: FxPassFilter, resonance: number): number {
|
export function passQ(filter: FxPassFilter, q: number): number {
|
||||||
const r = Math.max(0, Math.min(1, resonance));
|
return filter === 'lowpass' || filter === 'highpass' ? 20 * Math.log10(q) : q;
|
||||||
if (filter === 'bandpass') return WIDE_BAND_Q * (NARROW_BAND_Q / WIDE_BAND_Q) ** r;
|
|
||||||
return FLAT_Q + (RESONANT_Q - FLAT_Q) * r;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CUTOFF = { ...numberRow('cutoff', 'Cutoff (Hz)', 20, 20000, 1), default: 1000 };
|
const CUTOFF = { ...numberRow('cutoff', 'Frequency (Hz)', 20, 20000, 1), default: 1000 };
|
||||||
const RESONANCE = numberRow('resonance', 'Resonance', 0, 1, 0.01);
|
const Q = { ...numberRow('q', 'Q', 0.1, 30, 0.001), default: 0.707 };
|
||||||
export const FILTER_ROWS = { cutoff: CUTOFF, resonance: RESONANCE };
|
const FILTER_GAIN = numberRow('gain', 'Gain (dB)', -40, 40, 0.5);
|
||||||
|
export const FILTER_ROWS = { cutoff: CUTOFF, q: Q, gain: FILTER_GAIN };
|
||||||
const PASS_FILTERS: { value: FxPassFilter; label: string }[] = [
|
const PASS_FILTERS: { value: FxPassFilter; label: string }[] = [
|
||||||
{ value: 'lowpass', label: 'Low-pass' },
|
{ value: 'lowpass', label: 'Low-pass' },
|
||||||
{ value: 'highpass', label: 'High-pass' },
|
{ value: 'highpass', label: 'High-pass' },
|
||||||
{ value: 'bandpass', label: 'Band-pass' },
|
{ value: 'bandpass', label: 'Band-pass' },
|
||||||
|
{ value: 'notch', label: 'Notch' },
|
||||||
|
{ value: 'lowshelf', label: 'Low shelf' },
|
||||||
|
{ value: 'highshelf', label: 'High shelf' },
|
||||||
|
{ value: 'peaking', label: 'Peaking' },
|
||||||
|
{ value: 'allpass', label: 'All-pass' },
|
||||||
];
|
];
|
||||||
const PASS = {
|
const PASS = {
|
||||||
key: 'filter',
|
key: 'filter',
|
||||||
@@ -436,27 +443,53 @@ const PASS = {
|
|||||||
default: 'lowpass',
|
default: 'lowpass',
|
||||||
options: PASS_FILTERS,
|
options: PASS_FILTERS,
|
||||||
};
|
};
|
||||||
|
/** How steep a filter falls off (#318): 12 dB an octave for one, 24 for two, 48 for four in a row. */
|
||||||
|
const SLOPE = {
|
||||||
|
key: 'slope',
|
||||||
|
label: 'Slope',
|
||||||
|
kind: 'enum' as const,
|
||||||
|
default: '12',
|
||||||
|
options: [
|
||||||
|
{ value: '12', label: '12 dB/oct' },
|
||||||
|
{ value: '24', label: '24 dB/oct' },
|
||||||
|
{ value: '48', label: '48 dB/oct' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
/** The shapes that have a gain, and those that get steeper with more of them in a row. */
|
||||||
|
const GAINED: ReadonlySet<FxPassFilter> = new Set(['lowshelf', 'highshelf', 'peaking']);
|
||||||
|
const SLOPED: ReadonlySet<FxPassFilter> = new Set(['lowpass', 'highpass', 'bandpass', 'notch']);
|
||||||
|
|
||||||
export function passOf(settings: PropertyBag): FxPassFilter {
|
export function passOf(settings: PropertyBag): FxPassFilter {
|
||||||
return PASS_FILTERS.find((option) => option.value === settings[PASS.key])?.value ?? 'lowpass';
|
return PASS_FILTERS.find((option) => option.value === settings[PASS.key])?.value ?? 'lowpass';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many filters a Filter runs in a row: one for a shape a slope does nothing for. */
|
||||||
|
export function passStagesOf(settings: PropertyBag): number {
|
||||||
|
if (!SLOPED.has(passOf(settings))) return 1;
|
||||||
|
const slope = settings[SLOPE.key];
|
||||||
|
return slope === '48' ? 4 : slope === '24' ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
export const SoundFxFilter: SoundFxNodeDef = {
|
export const SoundFxFilter: SoundFxNodeDef = {
|
||||||
type: 'SoundFxFilter',
|
type: 'SoundFxFilter',
|
||||||
label: 'Filter',
|
label: 'Filter',
|
||||||
category: 'sound',
|
category: 'sound',
|
||||||
description:
|
description:
|
||||||
'Takes away what is above "cutoff" (low-pass), what is below it (high-pass), or both, leaving a band around it (band-pass). "Resonance" from 0 to 1 raises what is right at the cutoff: at 0 a low- or high-pass has no bump, towards 1 it rings, and a band-pass gets narrower. In an instrument an Envelope can move both while a note plays, 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.',
|
||||||
inputs: [audioIn, followingIn(CUTOFF), followingIn(RESONANCE)],
|
inputs: [audioIn, followingIn(CUTOFF), followingIn(Q)],
|
||||||
outputs: [audioOut],
|
outputs: [audioOut],
|
||||||
params: [PASS],
|
params: [PASS, SLOPE],
|
||||||
|
idleParams: (params) => (SLOPED.has(passOf(params)) ? [] : [SLOPE.key]),
|
||||||
|
derivePorts: ({ node }) => (GAINED.has(passOf(node.params)) ? { inputs: [followingIn(FILTER_GAIN)] } : {}),
|
||||||
emit: (sounds, amounts, settings) =>
|
emit: (sounds, amounts, settings) =>
|
||||||
withStages(arriving(sounds), [
|
withStages(arriving(sounds), [
|
||||||
{
|
{
|
||||||
kind: 'pass',
|
kind: 'pass',
|
||||||
filter: passOf(settings),
|
filter: passOf(settings),
|
||||||
|
stages: passStagesOf(settings),
|
||||||
cutoff: held(amounts.cutoff, CUTOFF),
|
cutoff: held(amounts.cutoff, CUTOFF),
|
||||||
resonance: held(amounts.resonance, RESONANCE),
|
q: held(amounts.q, Q),
|
||||||
|
gain: GAINED.has(passOf(settings)) ? held(amounts.gain, FILTER_GAIN) : { value: 0 },
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
@@ -748,14 +781,18 @@ function stagesOf(plan: FxPlan, params: FxParams, tempo: number): FxStage[] {
|
|||||||
}
|
}
|
||||||
if (stage.kind === 'pass') {
|
if (stage.kind === 'pass') {
|
||||||
const cutoffMod = modOf(stage.cutoff, params);
|
const cutoffMod = modOf(stage.cutoff, params);
|
||||||
const qMod = modOf(stage.resonance, params, (r) => passQ(stage.filter, r));
|
const qMod = modOf(stage.q, params, (q) => passQ(stage.filter, q));
|
||||||
|
const gainMod = modOf(stage.gain, params);
|
||||||
stages.push({
|
stages.push({
|
||||||
kind: 'pass',
|
kind: 'pass',
|
||||||
filter: stage.filter,
|
filter: stage.filter,
|
||||||
|
stages: stage.stages,
|
||||||
cutoff: amountOf(stage.cutoff, params),
|
cutoff: amountOf(stage.cutoff, params),
|
||||||
q: passQ(stage.filter, amountOf(stage.resonance, params)),
|
q: passQ(stage.filter, amountOf(stage.q, params)),
|
||||||
|
gain: amountOf(stage.gain, params),
|
||||||
...(cutoffMod && { cutoffMod }),
|
...(cutoffMod && { cutoffMod }),
|
||||||
...(qMod && { qMod }),
|
...(qMod && { qMod }),
|
||||||
|
...(gainMod && { gainMod }),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
DELAY_MAX,
|
DELAY_MAX,
|
||||||
DRY_VOICE,
|
DRY_VOICE,
|
||||||
FLAT_Q,
|
FLAT_Q,
|
||||||
|
passQ,
|
||||||
fxChainOf,
|
fxChainOf,
|
||||||
type FxChain,
|
type FxChain,
|
||||||
type FxChip,
|
type FxChip,
|
||||||
@@ -2048,22 +2049,48 @@ export function stageNodes(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A Filter (#335): a cutoff that moves in octaves, and a Q – a band-pass's as a factor, so in ratios too. */
|
/**
|
||||||
|
* A Filter (#335, #318): a cutoff that moves in octaves, a Q – as a factor in ratios too, in dB for a
|
||||||
|
* low- or high-pass – and a gain in dB. Several in a row share one cutoff, carried by a source of its
|
||||||
|
* own into each of them; only the last has the Q, the others none.
|
||||||
|
*/
|
||||||
function passNodes(context: BaseAudioContext, stage: FxPass, follow?: Follow): StageNodes {
|
function passNodes(context: BaseAudioContext, stage: FxPass, follow?: Follow): StageNodes {
|
||||||
const filter = context.createBiquadFilter();
|
const qEven = stage.filter === 'lowpass' || stage.filter === 'highpass' ? 'value' : 'ratio';
|
||||||
filter.type = stage.filter;
|
const filters = Array.from({ length: Math.max(1, stage.stages) }, () => {
|
||||||
filter.frequency.value = stage.cutoffMod ? 0 : stage.cutoff;
|
const filter = context.createBiquadFilter();
|
||||||
filter.Q.value = stage.qMod ? 0 : stage.q;
|
filter.type = stage.filter;
|
||||||
const qEven = stage.filter === 'bandpass' ? 'ratio' : 'value';
|
filter.Q.value = passQ(stage.filter, FLAT_Q);
|
||||||
if (stage.cutoffMod) follow?.(stage.cutoffMod, filter.frequency, true);
|
return filter;
|
||||||
if (stage.qMod) follow?.(stage.qMod, filter.Q, qEven === 'ratio');
|
});
|
||||||
|
filters.slice(1).forEach((filter, i) => filters[i]!.connect(filter));
|
||||||
|
const last = filters.at(-1)!;
|
||||||
|
last.Q.value = stage.qMod ? 0 : stage.q;
|
||||||
|
last.gain.value = stage.gainMod ? 0 : stage.gain;
|
||||||
|
if (stage.qMod) follow?.(stage.qMod, last.Q, qEven === 'ratio');
|
||||||
|
if (stage.gainMod) follow?.(stage.gainMod, last.gain, false);
|
||||||
|
let cutoff = filters[0]!.frequency;
|
||||||
|
let stop: (() => void) | undefined;
|
||||||
|
if (filters.length > 1) {
|
||||||
|
const driver = context.createConstantSource();
|
||||||
|
for (const filter of filters) {
|
||||||
|
filter.frequency.value = 0;
|
||||||
|
driver.connect(filter.frequency);
|
||||||
|
}
|
||||||
|
driver.start();
|
||||||
|
cutoff = driver.offset;
|
||||||
|
stop = () => driver.stop();
|
||||||
|
}
|
||||||
|
cutoff.value = stage.cutoffMod ? 0 : stage.cutoff;
|
||||||
|
if (stage.cutoffMod) follow?.(stage.cutoffMod, cutoff, true);
|
||||||
return {
|
return {
|
||||||
input: filter,
|
input: filters[0]!,
|
||||||
output: filter,
|
output: last,
|
||||||
knobs: [
|
knobs: [
|
||||||
{ param: stage.cutoffMod ? null : filter.frequency, target: stage.cutoff, even: 'ratio' },
|
{ param: stage.cutoffMod ? null : cutoff, target: stage.cutoff, even: 'ratio' },
|
||||||
{ param: stage.qMod ? null : filter.Q, target: stage.q, even: qEven },
|
{ param: stage.qMod ? null : last.Q, target: stage.q, even: qEven },
|
||||||
|
{ param: stage.gainMod ? null : last.gain, target: stage.gain, even: 'value' },
|
||||||
],
|
],
|
||||||
|
...(stop && { stop }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2129,7 +2156,7 @@ function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes {
|
|||||||
const damp = context.createBiquadFilter();
|
const damp = context.createBiquadFilter();
|
||||||
damp.type = 'lowpass';
|
damp.type = 'lowpass';
|
||||||
damp.frequency.value = stage.damping;
|
damp.frequency.value = stage.damping;
|
||||||
damp.Q.value = 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, stage.mix);
|
||||||
line.connect(damp);
|
line.connect(damp);
|
||||||
@@ -2384,7 +2411,7 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] {
|
|||||||
}
|
}
|
||||||
for (const stage of chain.stages)
|
for (const stage of chain.stages)
|
||||||
if (stage.kind === 'delay') into.push(stage.time, stage.feedback, stage.damping, stage.mix);
|
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);
|
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);
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ describe('a preset (#329)', () => {
|
|||||||
arpeggio: 0,
|
arpeggio: 0,
|
||||||
});
|
});
|
||||||
expect(byType(low, 'InstrumentEnvelope').params).toMatchObject({ attack: 0, decay: 0.068, sustain: 0 });
|
expect(byType(low, 'InstrumentEnvelope').params).toMatchObject({ attack: 0, decay: 0.068, sustain: 0 });
|
||||||
expect(byType(low, 'SoundFxFilter').params).toEqual({ filter: 'lowpass', cutoff: 20000, resonance: 0 });
|
expect(byType(low, 'SoundFxFilter').params).toEqual({ filter: 'lowpass', cutoff: 20000, q: 0.707 });
|
||||||
expect(byType(low, 'SoundFxBitcrusher').params).toEqual({ bits: 8, rate: 22050, mix: 1 });
|
expect(byType(low, 'SoundFxBitcrusher').params).toEqual({ bits: 8, rate: 22050, mix: 1 });
|
||||||
// Near 1 the coin jumps up by sfxr's arpeggio: a period 0.676 times as long is about 7 semitones.
|
// Near 1 the coin jumps up by sfxr's arpeggio: a period 0.676 times as long is about 7 semitones.
|
||||||
const high = byType(
|
const high = byType(
|
||||||
|
|||||||
+58
-16
@@ -11,9 +11,20 @@ import { portsMeet } from '../../src/core/graph/NodeRegistry';
|
|||||||
import { compileInstrument, instrumentSourcesOf } from '../../src/core/instrument/compile';
|
import { compileInstrument, instrumentSourcesOf } from '../../src/core/instrument/compile';
|
||||||
import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../../src/core/instrument/envelope';
|
import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../../src/core/instrument/envelope';
|
||||||
import { VELOCITY_PARAM } from '../../src/core/instrument/nodes';
|
import { VELOCITY_PARAM } from '../../src/core/instrument/nodes';
|
||||||
import type { BehaviorAsset, InstrumentAsset } from '../../src/core/model/Asset';
|
import { NO_LOOKUP } from '../../src/core/graph/lookup';
|
||||||
|
import type { PropertyBag } from '../../src/core/model/Properties';
|
||||||
|
import { resolveDef } from '../../src/core/graph/nested';
|
||||||
|
import type { BehaviorAsset, InstrumentAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
||||||
import { compileSoundFx } from '../../src/core/soundfx/compile';
|
import { compileSoundFx } from '../../src/core/soundfx/compile';
|
||||||
import { FLAT_Q, fxChainOf, type FxEnvelope, type FxPlan, passQ } from '../../src/core/soundfx/nodes';
|
import {
|
||||||
|
FLAT_Q,
|
||||||
|
fxChainOf,
|
||||||
|
type FxEnvelope,
|
||||||
|
type FxPlan,
|
||||||
|
passQ,
|
||||||
|
passStagesOf,
|
||||||
|
SoundFxFilter,
|
||||||
|
} from '../../src/core/soundfx/nodes';
|
||||||
import { graphEditor, makeProject } from '../helpers';
|
import { graphEditor, makeProject } from '../helpers';
|
||||||
|
|
||||||
const FILE = `${'c3'.repeat(32)}.wav`;
|
const FILE = `${'c3'.repeat(32)}.wav`;
|
||||||
@@ -70,29 +81,58 @@ describe('the curve of an Envelope', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('a Filter', () => {
|
describe('a Filter', () => {
|
||||||
it('has no bump at resonance 0 and rings towards 1; a band-pass gets narrower', () => {
|
it('takes its Q as a factor, and hands it to Web Audio in dB for a low- or high-pass', () => {
|
||||||
expect(passQ('lowpass', 0)).toBeCloseTo(FLAT_Q, 9);
|
expect(passQ('lowpass', FLAT_Q)).toBeCloseTo(-3.0103, 4);
|
||||||
expect(passQ('highpass', 1)).toBe(20);
|
expect(passQ('highpass', 10)).toBeCloseTo(20, 9);
|
||||||
expect(passQ('bandpass', 0)).toBeCloseTo(0.7, 9);
|
expect(passQ('bandpass', 4)).toBe(4);
|
||||||
expect(passQ('bandpass', 1)).toBeCloseTo(20, 9);
|
expect(passQ('peaking', 0.5)).toBe(0.5);
|
||||||
expect(passQ('lowpass', 7)).toBe(20);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is an effect of a sound FX as well, its cutoff in Hz and its resonance as a Q', () => {
|
const filterIn = (params: PropertyBag) => {
|
||||||
const f = makeProject();
|
const f = makeProject();
|
||||||
const made = new CreateAssetCommand('soundfx', 'Muffle');
|
const made = new CreateAssetCommand('soundfx', 'Muffle');
|
||||||
f.stack.execute(made);
|
f.stack.execute(made);
|
||||||
const fx = made.asset as unknown as BehaviorAsset;
|
const fx = made.asset as unknown as BehaviorAsset;
|
||||||
const edit = graphEditor(f, fx);
|
const edit = graphEditor(f, fx);
|
||||||
const input = edit.addNode('SoundFxInput');
|
const input = edit.addNode('SoundFxInput');
|
||||||
const filter = edit.addNode('SoundFxFilter', { filter: 'highpass', cutoff: 300, resonance: 1 });
|
const filter = edit.addNode('SoundFxFilter', params);
|
||||||
const output = edit.addNode('SoundFxOutput');
|
const output = edit.addNode('SoundFxOutput');
|
||||||
edit.wire([
|
edit.wire([
|
||||||
[input, 'out', filter, 'in'],
|
[input, 'out', filter, 'in'],
|
||||||
[filter, 'out', output, 'in'],
|
[filter, 'out', output, 'in'],
|
||||||
]);
|
]);
|
||||||
const plan = compileSoundFx((fx as unknown as { graph: never }).graph).compiled!;
|
return { fx: fx as unknown as SoundFxAsset, filter };
|
||||||
expect(fxChainOf(plan).stages).toEqual([{ kind: 'pass', filter: 'highpass', cutoff: 300, q: 20 }]);
|
};
|
||||||
|
|
||||||
|
it('is an effect of a sound FX as well, its frequency in Hz and its Q held to 0.1 to 30', () => {
|
||||||
|
const { fx } = filterIn({ filter: 'highpass', cutoff: 300, q: 50 });
|
||||||
|
const plan = compileSoundFx(fx.graph).compiled!;
|
||||||
|
const [stage] = fxChainOf(plan).stages;
|
||||||
|
expect(stage).toMatchObject({ kind: 'pass', filter: 'highpass', stages: 1, cutoff: 300, gain: 0 });
|
||||||
|
// 30 as a factor, in dB.
|
||||||
|
expect((stage as { q: number }).q).toBeCloseTo(29.54, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(SoundFxFilter.idleParams!(peak.filter.params)).toEqual(['slope']);
|
||||||
|
expect(fxChainOf(compileSoundFx(peak.fx.graph).compiled!).stages[0]).toMatchObject({
|
||||||
|
stages: 1,
|
||||||
|
gain: 6,
|
||||||
|
});
|
||||||
|
|
||||||
|
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(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({
|
||||||
|
stages: 4,
|
||||||
|
gain: 0,
|
||||||
|
});
|
||||||
|
expect(passStagesOf({ filter: 'lowpass', slope: '24' })).toBe(2);
|
||||||
|
expect(passStagesOf({ filter: 'lowpass' })).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,15 +151,15 @@ describe('an Envelope in an instrument', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('moves the cutoff of a Filter from "from" to "to", and its resonance as a Q', () => {
|
it('moves the cutoff of a Filter from "from" to "to", and its Q', () => {
|
||||||
const f = makeInstrument();
|
const f = makeInstrument();
|
||||||
const envelope = f.edit.addNode('InstrumentEnvelope', { from: 200, to: 5000 });
|
const envelope = f.edit.addNode('InstrumentEnvelope', { from: 200, to: 5000 });
|
||||||
const sweep = f.edit.addNode('InstrumentEnvelope', { decay: 1, from: 0, to: 1 });
|
const sweep = f.edit.addNode('InstrumentEnvelope', { decay: 1, from: Math.SQRT1_2, to: 10 });
|
||||||
const filter = f.edit.addNode('SoundFxFilter');
|
const filter = f.edit.addNode('SoundFxFilter');
|
||||||
f.edit.wire([
|
f.edit.wire([
|
||||||
[f.sample, 'out', filter, 'in'],
|
[f.sample, 'out', filter, 'in'],
|
||||||
[envelope, 'value', filter, 'cutoff'],
|
[envelope, 'value', filter, 'cutoff'],
|
||||||
[sweep, 'value', filter, 'resonance'],
|
[sweep, 'value', filter, 'q'],
|
||||||
[filter, 'out', f.output, 'in'],
|
[filter, 'out', f.output, 'in'],
|
||||||
]);
|
]);
|
||||||
const build = f.compile();
|
const build = f.compile();
|
||||||
@@ -131,8 +171,10 @@ describe('an Envelope in an instrument', () => {
|
|||||||
filter: 'lowpass',
|
filter: 'lowpass',
|
||||||
cutoff: 200,
|
cutoff: 200,
|
||||||
cutoffMod: { envelope: { id: envelope.id }, from: 200, to: 5000 },
|
cutoffMod: { envelope: { id: envelope.id }, from: 200, to: 5000 },
|
||||||
qMod: { envelope: { id: sweep.id, decay: 1 }, from: FLAT_Q, to: 20 },
|
qMod: { envelope: { id: sweep.id, decay: 1 } },
|
||||||
});
|
});
|
||||||
|
const { qMod } = stage as { qMod: { from: number; to: number } };
|
||||||
|
expect([qMod.from, qMod.to]).toEqual([expect.closeTo(-3.01, 2), expect.closeTo(20, 9)]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('holds both ends to the row of the number it moves, and fills in what a note hands it', () => {
|
it('holds both ends to the row of the number it moves, and fills in what a note hands it', () => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type { MixBus } from '../../src/core/music/mixer';
|
|||||||
import type { SequencePlay } from '../../src/core/music/playbacks';
|
import type { SequencePlay } from '../../src/core/music/playbacks';
|
||||||
import type { ScheduledNote } from '../../src/core/music/Scheduler';
|
import type { ScheduledNote } from '../../src/core/music/Scheduler';
|
||||||
import type { PitchOptions } from '../../src/viewport/fxWorklet';
|
import type { PitchOptions } from '../../src/viewport/fxWorklet';
|
||||||
import { SoundLibrary } from '../../src/viewport/SoundLibrary';
|
import { SoundLibrary, stageNodes } from '../../src/viewport/SoundLibrary';
|
||||||
|
|
||||||
/** What a test wants to hear: a chain whose pitch, where it is not 0, is a Pitch typed in. */
|
/** What a test wants to hear: a chain whose pitch, where it is not 0, is a Pitch typed in. */
|
||||||
type Chain =
|
type Chain =
|
||||||
@@ -1451,8 +1451,10 @@ describe('notes with an Envelope (#335)', () => {
|
|||||||
const sweep: FxStagePlan = {
|
const sweep: FxStagePlan = {
|
||||||
kind: 'pass',
|
kind: 'pass',
|
||||||
filter: 'lowpass',
|
filter: 'lowpass',
|
||||||
|
stages: 1,
|
||||||
cutoff: { value: 200, mod: { envelope: PLANNED, from: { value: 200 }, to: { value: 5000 } } },
|
cutoff: { value: 200, mod: { envelope: PLANNED, from: { value: 200 }, to: { value: 5000 } } },
|
||||||
resonance: { value: 0 },
|
q: { value: Math.SQRT1_2 },
|
||||||
|
gain: { value: 0 },
|
||||||
};
|
};
|
||||||
const voice = await note(library, way(true, false, [sweep]));
|
const voice = await note(library, way(true, false, [sweep]));
|
||||||
// The cutoff is all the curve; the Q stays as it is.
|
// The cutoff is all the curve; the Q stays as it is.
|
||||||
@@ -1469,7 +1471,7 @@ describe('notes with an Envelope (#335)', () => {
|
|||||||
expect(gains[1]!.ramps).toEqual([['linear', 0, 0.01]]);
|
expect(gains[1]!.ramps).toEqual([['linear', 0, 0.01]]);
|
||||||
expect(driver().offset.glides).toEqual([0.5, 0]);
|
expect(driver().offset.glides).toEqual([0.5, 0]);
|
||||||
// A parameter does not reach a number an Envelope moves.
|
// A parameter does not reach a number an Envelope moves.
|
||||||
expect(library.levels('instrument:i')).toEqual([[200, expect.closeTo(-3.01, 2)]]);
|
expect(library.levels('instrument:i')).toEqual([[200, expect.closeTo(-3.01, 2), 0]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ends once its decay is over where the sustain is 0, however long the note is held (#329)', async () => {
|
it('ends once its decay is over where the sustain is 0, however long the note is held (#329)', async () => {
|
||||||
@@ -2204,3 +2206,53 @@ describe('the master and the buses of the project (#325)', () => {
|
|||||||
expect(library.tuneMix('mix:gone', { b: 3 })).toBe(false);
|
expect(library.tuneMix('mix:gone', { b: 3 })).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the stages of #318 to #328', () => {
|
||||||
|
it('runs a steeper Filter as several in a row, one cutoff for all, the Q on the last only (#318)', () => {
|
||||||
|
const context = fakeContext() as unknown as BaseAudioContext;
|
||||||
|
const made = stageNodes(context, {
|
||||||
|
kind: 'pass',
|
||||||
|
filter: 'lowpass',
|
||||||
|
stages: 4,
|
||||||
|
cutoff: 800,
|
||||||
|
q: 12,
|
||||||
|
gain: 0,
|
||||||
|
});
|
||||||
|
expect(filters.map((f) => f.type)).toEqual(['lowpass', 'lowpass', 'lowpass', 'lowpass']);
|
||||||
|
expect(wires.filter((w) => w === 'filter > filter')).toHaveLength(3);
|
||||||
|
expect(filters.map((f) => f.frequency.value)).toEqual([0, 0, 0, 0]);
|
||||||
|
expect(wires.filter((w) => w === 'clock > ?')).toHaveLength(4);
|
||||||
|
expect(clocks[0]!.started).toBe(true);
|
||||||
|
expect(clocks[0]!.offset.value).toBe(800);
|
||||||
|
expect(filters.slice(0, 3).map((f) => f.Q.value)).toEqual([
|
||||||
|
expect.closeTo(-3.01, 2),
|
||||||
|
expect.closeTo(-3.01, 2),
|
||||||
|
expect.closeTo(-3.01, 2),
|
||||||
|
]);
|
||||||
|
expect(filters[3]!.Q.value).toBe(12);
|
||||||
|
expect(made.knobs.map((k) => k.param)).toEqual([clocks[0]!.offset, filters[3]!.Q, filters[3]!.gain]);
|
||||||
|
made.stop!();
|
||||||
|
expect(clocks[0]!.stopped).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a shelf its gain, and a single filter its own cutoff (#318)', () => {
|
||||||
|
const context = fakeContext() as unknown as BaseAudioContext;
|
||||||
|
const made = stageNodes(context, {
|
||||||
|
kind: 'pass',
|
||||||
|
filter: 'highshelf',
|
||||||
|
stages: 1,
|
||||||
|
cutoff: 5000,
|
||||||
|
q: 1,
|
||||||
|
gain: -6,
|
||||||
|
});
|
||||||
|
expect(filters).toHaveLength(1);
|
||||||
|
expect([filters[0]!.type, filters[0]!.frequency.value, filters[0]!.gain.value]).toEqual([
|
||||||
|
'highshelf',
|
||||||
|
5000,
|
||||||
|
-6,
|
||||||
|
]);
|
||||||
|
expect(clocks).toHaveLength(0);
|
||||||
|
expect(made.knobs.map((k) => k.even)).toEqual(['ratio', 'ratio', 'value']);
|
||||||
|
expect(made.stop).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -939,6 +939,42 @@ export async function sound(t) {
|
|||||||
JSON.stringify(swirled.sweeping.slice(1).map(([i]) => i)) === JSON.stringify([533, 534]),
|
JSON.stringify(swirled.sweeping.slice(1).map(([i]) => i)) === JSON.stringify([533, 534]),
|
||||||
JSON.stringify(swirled),
|
JSON.stringify(swirled),
|
||||||
);
|
);
|
||||||
|
// #318: the Filter rendered offline. A 2 kHz sine at 0.5 through a low-pass at 1 kHz with no bump
|
||||||
|
// falls by 12.3 dB at 12 dB/oct and by four times that at 48 dB/oct. A peak of +6 dB at
|
||||||
|
// 1 kHz raises a 1 kHz sine by 6 dB, a notch there takes it out.
|
||||||
|
const filtered = await evaluate(`
|
||||||
|
(async () => {
|
||||||
|
const RATE = 48000;
|
||||||
|
const level = async (hz, stage) => {
|
||||||
|
const context = new OfflineAudioContext(1, RATE / 2, RATE);
|
||||||
|
const tone = context.createOscillator();
|
||||||
|
tone.frequency.value = hz;
|
||||||
|
const half = context.createGain();
|
||||||
|
half.gain.value = 0.5;
|
||||||
|
const nodes = __gm.sound.stageNodes(context, { kind: 'pass', stages: 1, cutoff: 1000, q: 0.7071, gain: 0, ...stage });
|
||||||
|
tone.connect(half).connect(nodes.input);
|
||||||
|
nodes.output.connect(context.destination);
|
||||||
|
tone.start();
|
||||||
|
const out = (await context.startRendering()).getChannelData(0).subarray(RATE / 4);
|
||||||
|
let sum = 0;
|
||||||
|
for (const v of out) sum += v * v;
|
||||||
|
return +(20 * Math.log10(Math.sqrt(sum / out.length) / (0.5 * Math.SQRT1_2))).toFixed(2);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
low12: await level(2000, { filter: 'lowpass', q: -3.0103 }),
|
||||||
|
low48: await level(2000, { filter: 'lowpass', q: -3.0103, stages: 4 }),
|
||||||
|
peak: await level(1000, { filter: 'peaking', q: 1, gain: 6 }),
|
||||||
|
notch: await level(1000, { filter: 'notch', q: 1 }),
|
||||||
|
};
|
||||||
|
})()`);
|
||||||
|
check(
|
||||||
|
'a Filter at 48 dB/oct falls four times as steeply as at 12, a peak raises its frequency by its gain, a notch takes it out',
|
||||||
|
Math.abs(filtered.low12 + 12.3) < 0.2 &&
|
||||||
|
Math.abs(filtered.low48 + 49.2) < 0.8 &&
|
||||||
|
Math.abs(filtered.peak - 6) < 0.1 &&
|
||||||
|
filtered.notch < -40,
|
||||||
|
JSON.stringify(filtered),
|
||||||
|
);
|
||||||
// #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