Tame the loudness with a Compressor and a Limiter node (#326)
A sound FX could not control dynamics. A Compressor squeezes the loudness together, and a Limiter keeps every peak below a line. Both work on a voice and on a bus, where they tame everything at once. - Compressor: DynamicsCompressorNode with Threshold, Ratio, Knee, Attack and Release, then Makeup and Auto-gain from 0 to 1. After the node the sound gets (1 - w) * makeup + w * A dB. A is what the curve takes off at 0 dBFS. - The node adds 0.6 of A by itself, as the spec says. That gain is taken back out, so 0 really means no makeup. - The knee shape is left to the browser by the spec. dynamics.ts ports Chromium's curve. It gives the gain Chrome was measured to add at four settings to three places. - Limiter: a class on the FX worklet with Threshold, Ceiling, Release and Auto-gain. It looks 5 ms ahead: the smallest gain of the window ahead, averaged over the window, then a release. No sample passes the lower of threshold and ceiling. Auto-gain raises it toward the ceiling. - Deviation from the plan in the issue: the Limiter has a Threshold as well. Without it Auto-gain has nothing to measure against. - Latency is made up on voices now too. A voice starts as much earlier as its stages are late (Compressor 6 ms, Limiter 5 ms). The other way of a Mix and the other layers of a note are held back to the latest, and a bus counts it into its latency. - A voice with a Limiter counts as ringing, so the worklet is stopped only after what it still holds is out. Tests: - Unit dynamics (new) 8, soundfx 49 (3 new), sound-library 100 (4 new), green. - Counter-checks, each turns tests red: no look-ahead in the Limiter (3), no taking back of the node's gain (1), a voice not started earlier (1), no stage latency on a bus (1), no hold-back in a Mix (1). - e2e 34 (melektaus-atx): ALL PASSED, 78 checks (1 new). Chrome offline: Compressor 288 samples late, Limiter 240. Full scale through -12 dB 4:1 hard knee: +0.19 dB with full auto-gain, -8.81 dB without. A sine at 2 through the Limiter at -6 dB peaks at -6.00 dB, with auto-gain 1 at -1.00 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
c99bda6489
commit
a645026568
8 files changed
+743
-18
No files matched your search
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Compressor and Limiter (#326). Web Audio's DynamicsCompressorNode does the compressing; what it
|
||||
* leaves to the user agent is the shape of its knee and a gain it adds on its own. Both are taken
|
||||
* from Chromium here, so the gain a Compressor adds after the node can be worked out. The Limiter
|
||||
* runs on the FX worklet and is a plain class, so it is tested in Node.
|
||||
*/
|
||||
|
||||
const dbToLinear = (db: number) => 10 ** (db / 20);
|
||||
const linearToDb = (value: number) => (value <= 0 ? -1000 : 20 * Math.log10(value));
|
||||
|
||||
/** How late a DynamicsCompressorNode hands its sound on: the spec's fixed look-ahead. */
|
||||
export const COMPRESSOR_LATENCY = 0.006;
|
||||
|
||||
/**
|
||||
* The static curve of a DynamicsCompressorNode as Chromium draws it: the identity up to the
|
||||
* threshold, an exponential knee whose slope reaches 1/ratio at threshold + knee, and a straight
|
||||
* line in dB after it. Gives the level that comes out of a steady one going in, both linear.
|
||||
*/
|
||||
export function compressorCurve(threshold: number, knee: number, ratio: number): (x: number) => number {
|
||||
const linearThreshold = dbToLinear(threshold);
|
||||
const slope = 1 / Math.max(1, ratio);
|
||||
const kneeCurve = (x: number, k: number) =>
|
||||
x < linearThreshold ? x : linearThreshold + (1 - Math.exp(-k * (x - linearThreshold))) / k;
|
||||
const slopeAt = (x: number, k: number) => {
|
||||
if (x < linearThreshold) return 1;
|
||||
const x2 = x * 1.001;
|
||||
return (linearToDb(kneeCurve(x2, k)) - linearToDb(kneeCurve(x, k))) / (linearToDb(x2) - linearToDb(x));
|
||||
};
|
||||
const kneeThresholdDb = threshold + knee;
|
||||
const kneeThreshold = dbToLinear(kneeThresholdDb);
|
||||
let low = 0.1;
|
||||
let high = 10000;
|
||||
let k = 5;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
if (slopeAt(kneeThreshold, k) < slope) high = k;
|
||||
else low = k;
|
||||
k = Math.sqrt(low * high);
|
||||
}
|
||||
const kneeEndDb = linearToDb(kneeCurve(kneeThreshold, k));
|
||||
return (x) =>
|
||||
x < kneeThreshold ? kneeCurve(x, k) : dbToLinear(kneeEndDb + slope * (linearToDb(x) - kneeThresholdDb));
|
||||
}
|
||||
|
||||
/** How much quieter a Compressor makes a sound at full scale, in dB: what full auto-gain gives back. */
|
||||
export function compressorAutoGain(threshold: number, knee: number, ratio: number): number {
|
||||
return -linearToDb(compressorCurve(threshold, knee, ratio)(1));
|
||||
}
|
||||
|
||||
/**
|
||||
* The gain after a Compressor, as a factor: `makeup` dB and the auto-gain blended by `autoGain`
|
||||
* (0 none, 1 all of it), less the 0.6 of the auto-gain the node adds by itself.
|
||||
*/
|
||||
export function compressorMakeup(
|
||||
numbers: Record<'threshold' | 'knee' | 'ratio' | 'makeup' | 'autoGain', number>,
|
||||
): number {
|
||||
const auto = compressorAutoGain(numbers.threshold, numbers.knee, numbers.ratio);
|
||||
const w = Math.max(0, Math.min(1, numbers.autoGain));
|
||||
return dbToLinear((1 - w) * numbers.makeup + w * auto - 0.6 * auto);
|
||||
}
|
||||
|
||||
/** How far a Limiter looks ahead: a peak is met by a gain that has come down over this long. */
|
||||
export const LIMITER_LOOKAHEAD = 0.005;
|
||||
|
||||
/** Samples a Limiter holds the sound back at this rate. */
|
||||
export function limiterDelay(sampleRate: number): number {
|
||||
return Math.max(1, Math.round(LIMITER_LOOKAHEAD * sampleRate));
|
||||
}
|
||||
|
||||
/** Seconds a Limiter is late at this rate. */
|
||||
export function limiterLatency(sampleRate: number): number {
|
||||
return limiterDelay(sampleRate) / sampleRate;
|
||||
}
|
||||
|
||||
/** The numbers of a Limiter as the worklet hands them in: one value, or one per sample. */
|
||||
export type LimiterNumbers = Record<'threshold' | 'ceiling' | 'release' | 'autoGain', Float32Array>;
|
||||
|
||||
const at = (values: Float32Array, i: number) => (values.length > 1 ? values[i]! : values[0]!);
|
||||
|
||||
/**
|
||||
* A look-ahead limiter: the sound is held back `limiterDelay` samples, and the gain for every one
|
||||
* is the smallest any sample in the window ahead needs, averaged over that window – so it comes
|
||||
* down smoothly and is low enough by the time the peak is heard. Nothing ever goes above the lower
|
||||
* of threshold and ceiling. The gain then comes back up in `release` seconds, and auto-gain raises
|
||||
* the result towards the ceiling.
|
||||
*/
|
||||
export class Limiter {
|
||||
private readonly delay: number;
|
||||
private readonly window: number;
|
||||
/** The sound held back, per channel. */
|
||||
private readonly held: Float32Array[] = [];
|
||||
/** The gain each sample of the window needs, and the smallest from each on, as a monotonic queue. */
|
||||
private readonly needed: Float64Array;
|
||||
private readonly queue: Float64Array;
|
||||
private head = 0;
|
||||
private tail = 0;
|
||||
/** The smallest gains of the last `window` samples, and their sum. */
|
||||
private readonly smallest: Float64Array;
|
||||
private sum: number;
|
||||
private gain = 1;
|
||||
private n = 0;
|
||||
|
||||
constructor(private readonly sampleRate: number) {
|
||||
this.delay = limiterDelay(sampleRate);
|
||||
this.window = this.delay + 1;
|
||||
this.needed = new Float64Array(this.window);
|
||||
this.queue = new Float64Array(this.window + 1);
|
||||
this.smallest = new Float64Array(this.window).fill(1);
|
||||
this.sum = this.window;
|
||||
}
|
||||
|
||||
render(inputs: readonly Float32Array[], outputs: Float32Array[], numbers: LimiterNumbers): void {
|
||||
const length = outputs[0]?.length ?? 0;
|
||||
while (this.held.length < outputs.length) this.held.push(new Float32Array(this.delay));
|
||||
for (let i = 0; i < length; i++) {
|
||||
const thresholdDb = at(numbers.threshold, i);
|
||||
const ceilingDb = at(numbers.ceiling, i);
|
||||
const limitDb = Math.min(thresholdDb, ceilingDb);
|
||||
const limit = dbToLinear(limitDb);
|
||||
let peak = 0;
|
||||
for (const channel of inputs) peak = Math.max(peak, Math.abs(channel[i] ?? 0));
|
||||
const need = peak > limit ? limit / peak : 1;
|
||||
const smallest = this.windowMin(need);
|
||||
const slot = this.n % this.window;
|
||||
this.sum += smallest - this.smallest[slot]!;
|
||||
this.smallest[slot] = smallest;
|
||||
const average = this.sum / this.window;
|
||||
const release = at(numbers.release, i);
|
||||
const back = release > 0 ? 1 - Math.exp(-1 / (release * this.sampleRate)) : 1;
|
||||
this.gain = average < this.gain ? average : this.gain + (average - this.gain) * back;
|
||||
const w = Math.max(0, Math.min(1, at(numbers.autoGain, i)));
|
||||
const level = this.gain * dbToLinear(w * (ceilingDb - limitDb));
|
||||
const from = this.n % this.delay;
|
||||
outputs.forEach((out, c) => {
|
||||
const line = this.held[c]!;
|
||||
out[i] = line[from]! * level;
|
||||
line[from] = inputs[c]?.[i] ?? inputs[0]?.[i] ?? 0;
|
||||
});
|
||||
this.n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts the gain this sample needs into the window and gives the smallest one in it. */
|
||||
private windowMin(need: number): number {
|
||||
const slot = this.n % this.window;
|
||||
this.needed[slot] = need;
|
||||
const size = this.queue.length;
|
||||
while (this.tail !== this.head) {
|
||||
const last = (this.tail - 1 + size) % size;
|
||||
if (this.needed[this.queue[last]! % this.window]! < need) break;
|
||||
this.tail = last;
|
||||
}
|
||||
this.queue[this.tail] = this.n;
|
||||
this.tail = (this.tail + 1) % size;
|
||||
while (this.queue[this.head]! <= this.n - this.window) this.head = (this.head + 1) % size;
|
||||
return this.needed[this.queue[this.head]! % this.window]!;
|
||||
}
|
||||
}
|
||||
+159
-4
@@ -5,6 +5,7 @@ import type { PropertyBag } from '../model/Properties';
|
||||
import { DEFAULT_TEMPO, heldTempo, secondsOfQuarters } from '../music/time';
|
||||
import type { FlatDef } from '../properties/PropertySchema';
|
||||
import type { ChipShape, ChipWave } from './chip';
|
||||
import { COMPRESSOR_LATENCY, compressorMakeup, limiterLatency } from './dynamics';
|
||||
import { PHASER_STAGES } from './phaser';
|
||||
|
||||
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
|
||||
@@ -30,7 +31,32 @@ export type FxStage =
|
||||
| FxCrush
|
||||
| FxPhaser
|
||||
| FxFlanger
|
||||
| { kind: 'pan'; pan: number; mod?: FxMod };
|
||||
| { kind: 'pan'; pan: number; mod?: FxMod }
|
||||
| FxCompress
|
||||
| FxLimit;
|
||||
|
||||
/**
|
||||
* A Compressor (#326) as Web Audio's node takes it, in dB and seconds, and `gain`, the factor after
|
||||
* it: makeup and auto-gain, less what the node adds by itself.
|
||||
*/
|
||||
export interface FxCompress {
|
||||
kind: 'compress';
|
||||
threshold: number;
|
||||
knee: number;
|
||||
ratio: number;
|
||||
attack: number;
|
||||
release: number;
|
||||
gain: number;
|
||||
}
|
||||
|
||||
/** A Limiter (#326) on the worklet: dB, seconds and how much of the auto-gain it gives. */
|
||||
export interface FxLimit {
|
||||
kind: 'limit';
|
||||
threshold: number;
|
||||
ceiling: number;
|
||||
release: number;
|
||||
autoGain: number;
|
||||
}
|
||||
|
||||
/** A Phaser (#341): its allpass stages, where they turn, how fast and how far an LFO moves them. */
|
||||
export interface FxPhaser {
|
||||
@@ -220,7 +246,12 @@ export type FxStagePlan =
|
||||
FxAmount
|
||||
>)
|
||||
| ({ kind: 'flanger' } & Record<'offset' | 'sweep' | 'rate' | 'depth' | 'feedback' | 'mix', FxAmount>)
|
||||
| { kind: 'pan'; pan: FxAmount };
|
||||
| { kind: 'pan'; pan: FxAmount }
|
||||
| ({ kind: 'compress' } & Record<
|
||||
'threshold' | 'knee' | 'ratio' | 'attack' | 'release' | 'makeup' | 'autoGain',
|
||||
FxAmount
|
||||
>)
|
||||
| ({ kind: 'limit' } & Record<'threshold' | 'ceiling' | 'release' | 'autoGain', FxAmount>);
|
||||
|
||||
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
|
||||
export type FxPlan =
|
||||
@@ -726,6 +757,79 @@ export const SoundFxPan: SoundFxNodeDef = {
|
||||
},
|
||||
};
|
||||
|
||||
const COMPRESS_THRESHOLD = { ...numberRow('threshold', 'Threshold (dB)', -100, 0, 0.5), default: -24 };
|
||||
const COMPRESS_RATIO = { ...numberRow('ratio', 'Ratio', 1, 20, 0.1), default: 4 };
|
||||
const COMPRESS_KNEE = { ...numberRow('knee', 'Knee (dB)', 0, 40, 0.5), default: 6 };
|
||||
const COMPRESS_ATTACK = { ...numberRow('attack', 'Attack (s)', 0, 1, 0.001), default: 0.003 };
|
||||
const COMPRESS_RELEASE = { ...numberRow('release', 'Release (s)', 0, 1, 0.001), default: 0.25 };
|
||||
const MAKEUP = numberRow('makeup', 'Makeup (dB)', -24, 24, 0.5);
|
||||
const AUTO_GAIN = numberRow('autoGain', 'Auto-gain', 0, 1, 0.01);
|
||||
export const COMPRESS_ROWS = {
|
||||
threshold: COMPRESS_THRESHOLD,
|
||||
ratio: COMPRESS_RATIO,
|
||||
knee: COMPRESS_KNEE,
|
||||
attack: COMPRESS_ATTACK,
|
||||
release: COMPRESS_RELEASE,
|
||||
makeup: MAKEUP,
|
||||
autoGain: AUTO_GAIN,
|
||||
};
|
||||
|
||||
export const SoundFxCompressor: SoundFxNodeDef = {
|
||||
type: 'SoundFxCompressor',
|
||||
label: 'Compressor',
|
||||
category: 'sound',
|
||||
description:
|
||||
'Squeezes the loudness of the sound together: what goes above "threshold" (dB) comes out only 1/"ratio" as much louder, so 4 makes 8 dB over it 2 dB. "knee" (dB) eases into that from the threshold on instead of at once. "attack" is how fast it turns down once the sound is over the threshold, "release" how fast it lets go, both in seconds. "makeup" (dB) raises it again afterwards. "auto-gain" from 0 to 1 blends that with the gain that brings a sound at full scale back to full scale: 0 is the makeup alone, 1 the auto-gain alone. It is 6 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays.',
|
||||
inputs: [audioIn, ...Object.values(COMPRESS_ROWS).map(numberIn)],
|
||||
outputs: [audioOut],
|
||||
params: [],
|
||||
emit: (sounds, amounts) =>
|
||||
withStages(arriving(sounds), [
|
||||
{
|
||||
kind: 'compress',
|
||||
threshold: held(amounts.threshold, COMPRESS_THRESHOLD),
|
||||
knee: held(amounts.knee, COMPRESS_KNEE),
|
||||
ratio: held(amounts.ratio, COMPRESS_RATIO),
|
||||
attack: held(amounts.attack, COMPRESS_ATTACK),
|
||||
release: held(amounts.release, COMPRESS_RELEASE),
|
||||
makeup: held(amounts.makeup, MAKEUP),
|
||||
autoGain: held(amounts.autoGain, AUTO_GAIN),
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
const LIMIT_THRESHOLD = { ...numberRow('threshold', 'Threshold (dB)', -60, 0, 0.5), default: -6 };
|
||||
const CEILING = { ...numberRow('ceiling', 'Ceiling (dB)', -30, 0, 0.1), default: -1 };
|
||||
const LIMIT_RELEASE = { ...numberRow('release', 'Release (s)', 0.001, 2, 0.001), default: 0.1 };
|
||||
const LIMIT_AUTO_GAIN = { ...AUTO_GAIN, default: 1 };
|
||||
export const LIMIT_ROWS = {
|
||||
threshold: LIMIT_THRESHOLD,
|
||||
ceiling: CEILING,
|
||||
release: LIMIT_RELEASE,
|
||||
autoGain: LIMIT_AUTO_GAIN,
|
||||
};
|
||||
|
||||
export const SoundFxLimiter: SoundFxNodeDef = {
|
||||
type: 'SoundFxLimiter',
|
||||
label: 'Limiter',
|
||||
category: 'sound',
|
||||
description:
|
||||
'Keeps every peak of the sound below "threshold" (dB), however loud it comes in: it looks 5 ms ahead and has the volume down by the time the peak is heard, then lets it come back up in "release" seconds. "auto-gain" from 0 to 1 raises the limited sound towards "ceiling" (dB): at 1 its peaks sit right at the ceiling, at 0 at the threshold. Nothing ever goes above the ceiling, and a threshold above the ceiling limits at the ceiling. It is 5 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays.',
|
||||
inputs: [audioIn, ...Object.values(LIMIT_ROWS).map(numberIn)],
|
||||
outputs: [audioOut],
|
||||
params: [],
|
||||
emit: (sounds, amounts) =>
|
||||
withStages(arriving(sounds), [
|
||||
{
|
||||
kind: 'limit',
|
||||
threshold: held(amounts.threshold, LIMIT_THRESHOLD),
|
||||
ceiling: held(amounts.ceiling, CEILING),
|
||||
release: held(amounts.release, LIMIT_RELEASE),
|
||||
autoGain: held(amounts.autoGain, LIMIT_AUTO_GAIN),
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
/** Where damping 0 leaves the echoes and damping 1 takes them: evenly in octaves between. */
|
||||
const UNDAMPED_HZ = 20000;
|
||||
const DAMPED_HZ = 500;
|
||||
@@ -747,6 +851,8 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
|
||||
SoundFxPhaser,
|
||||
SoundFxFlanger,
|
||||
SoundFxPan,
|
||||
SoundFxCompressor,
|
||||
SoundFxLimiter,
|
||||
];
|
||||
|
||||
/** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */
|
||||
@@ -858,6 +964,35 @@ function stagesOf(plan: FxPlan, params: FxParams, tempo: number): FxStage[] {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (stage.kind === 'compress') {
|
||||
const numbers = {
|
||||
threshold: amountOf(stage.threshold, params),
|
||||
knee: amountOf(stage.knee, params),
|
||||
ratio: amountOf(stage.ratio, params),
|
||||
};
|
||||
stages.push({
|
||||
kind: 'compress',
|
||||
...numbers,
|
||||
attack: amountOf(stage.attack, params),
|
||||
release: amountOf(stage.release, params),
|
||||
gain: compressorMakeup({
|
||||
...numbers,
|
||||
makeup: amountOf(stage.makeup, params),
|
||||
autoGain: amountOf(stage.autoGain, params),
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (stage.kind === 'limit') {
|
||||
stages.push({
|
||||
kind: 'limit',
|
||||
threshold: amountOf(stage.threshold, params),
|
||||
ceiling: amountOf(stage.ceiling, params),
|
||||
release: amountOf(stage.release, params),
|
||||
autoGain: amountOf(stage.autoGain, params),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (stage.kind === 'pan') {
|
||||
const pan = amountOf(stage.pan, params);
|
||||
const mod = modOf(stage.pan, params);
|
||||
@@ -941,9 +1076,12 @@ function chipOf(plan: FxChipPlan, params: FxParams): FxChip {
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a plan can ring on after its sources end (#324): it has an echo somewhere. */
|
||||
/** The stages that sound on after their input ends: an echo, and what holds the sound back on the worklet. */
|
||||
const RINGING: ReadonlySet<FxStagePlan['kind']> = new Set(['delay', 'limit']);
|
||||
|
||||
/** Whether a plan can ring on after its sources end (#324): it has an echo somewhere, or a Limiter. */
|
||||
export function fxRings(plan: FxPlan): boolean {
|
||||
if (plan.stages.some((stage) => stage.kind === 'delay')) return true;
|
||||
if (plan.stages.some((stage) => RINGING.has(stage.kind))) return true;
|
||||
return 'mix' in plan && (fxRings(plan.a) || fxRings(plan.b));
|
||||
}
|
||||
|
||||
@@ -960,6 +1098,23 @@ export function fxTailOf(chain: FxChain): number {
|
||||
return chain.stages.reduce((sum, stage) => sum + (stage.kind === 'delay' ? delayTail(stage) : 0), ways);
|
||||
}
|
||||
|
||||
/** Seconds a stage holds the sound back (#326): a Compressor's look-ahead, a Limiter's. */
|
||||
export function stageLatency(stage: FxStage, sampleRate: number): number {
|
||||
if (stage.kind === 'compress') return COMPRESSOR_LATENCY;
|
||||
if (stage.kind === 'limit') return limiterLatency(sampleRate);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds a chain of a voice is late (#326): what its stages hold back, the later way of a Mix. A
|
||||
* voice's Pitch reads its sound itself and is never late.
|
||||
*/
|
||||
export function fxLatencyOf(chain: FxChain, sampleRate: number): number {
|
||||
const ways =
|
||||
'mix' in chain ? Math.max(fxLatencyOf(chain.a, sampleRate), fxLatencyOf(chain.b, sampleRate)) : 0;
|
||||
return chain.stages.reduce((sum, stage) => sum + stageLatency(stage, sampleRate), ways);
|
||||
}
|
||||
|
||||
/** The echo `k` is `mix * feedback^(k-1)` as loud as the sound; the last one still heard sets the tail. */
|
||||
function delayTail({ time, feedback, mix }: FxDelay): number {
|
||||
if (!(mix >= SILENT) || !(time > 0)) return 0;
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
FLAT_Q,
|
||||
passQ,
|
||||
fxChainOf,
|
||||
fxLatencyOf,
|
||||
type FxChain,
|
||||
type FxChip,
|
||||
type FxCompress,
|
||||
type FxCrush,
|
||||
type FxDelay,
|
||||
type FxEnvelope,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
type FxStage,
|
||||
type FxStagePlan,
|
||||
type FxVoice,
|
||||
stageLatency,
|
||||
} from '../core/soundfx/nodes';
|
||||
import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope';
|
||||
import type { EaseShare } from '../core/model/animation';
|
||||
@@ -760,6 +763,7 @@ export class SoundLibrary implements AudioSink {
|
||||
// A sound FX has no Envelope; one would only silence the bus.
|
||||
if (stage.kind === 'envelope') continue;
|
||||
const stageMade = stageNodes(context, stage, undefined, context.currentTime);
|
||||
latency += stageLatency(stage, context.sampleRate);
|
||||
tail.connect(stageMade.input);
|
||||
tail = stageMade.output;
|
||||
knobs.push(...stageMade.knobs);
|
||||
@@ -1424,13 +1428,17 @@ export class SoundLibrary implements AudioSink {
|
||||
if (voice.bus !== '') this.sweep();
|
||||
}
|
||||
|
||||
/** The longest tail of its layers: they are heard side by side. */
|
||||
/**
|
||||
* The longest tail of its layers: they are heard side by side. What its stages hold back (#326)
|
||||
* is still to be heard once its sources end, too.
|
||||
*/
|
||||
private tailOf(fx: Layered): number {
|
||||
return fx.layers.reduce(
|
||||
(longest, plan) =>
|
||||
fxRings(plan) ? Math.max(longest, fxTailOf(fxChainOf(plan, fx.params, fx.tempo))) : longest,
|
||||
0,
|
||||
);
|
||||
const rate = this.audio().sampleRate;
|
||||
return fx.layers.reduce((longest, plan) => {
|
||||
if (!fxRings(plan)) return longest;
|
||||
const chain = fxChainOf(plan, fx.params, fx.tempo);
|
||||
return Math.max(longest, fxTailOf(chain) + fxLatencyOf(chain, rate));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1472,8 +1480,12 @@ export class SoundLibrary implements AudioSink {
|
||||
// A context starts suspended where the page has not been interacted with; without this the
|
||||
// voice would run silently and end as if it had played.
|
||||
if (context.state === 'suspended' && !this.held) void context.resume();
|
||||
// As much earlier as its bus is late, so it is heard when it was planned (#346).
|
||||
const early = this.busLatency(bus);
|
||||
// As much earlier as its bus and its own stages are late, so it is heard when it was planned
|
||||
// (#346, #326). A layer that is less late is held back to the latest.
|
||||
const chains = fx.layers.map((plan) => fxChainOf(plan, fx.params, fx.tempo));
|
||||
const lates = chains.map((chain) => fxLatencyOf(chain, context.sampleRate));
|
||||
const late = Math.max(0, ...lates);
|
||||
const early = this.busLatency(bus) + late;
|
||||
const at = heard > 0 ? heard - early : heard;
|
||||
const clip = asked && { ...asked, end: asked.end - early };
|
||||
const when = Math.max(at, context.currentTime);
|
||||
@@ -1504,7 +1516,7 @@ export class SoundLibrary implements AudioSink {
|
||||
knobs: [],
|
||||
stages: 0,
|
||||
};
|
||||
for (const plan of fx.layers) this.wire(wiring, fxChainOf(plan, fx.params, fx.tempo)).connect(gain);
|
||||
chains.forEach((chain, i) => heldBack(context, this.wire(wiring, chain), late - lates[i]!).connect(gain));
|
||||
gain.connect(this.busInput(bus));
|
||||
const { sources } = wiring;
|
||||
this.last = {
|
||||
@@ -1569,14 +1581,17 @@ export class SoundLibrary implements AudioSink {
|
||||
const both = chain.curve === 'both';
|
||||
// An Envelope moving the share stands in for the knob: a parameter no longer reaches it.
|
||||
const moved = chain.mixMod;
|
||||
const wayA = this.wire(wiring, chain.a);
|
||||
// The later way sets when both are heard; the other is held back to it (#326).
|
||||
const lateA = fxLatencyOf(chain.a, context.sampleRate);
|
||||
const lateB = fxLatencyOf(chain.b, context.sampleRate);
|
||||
const wayA = heldBack(context, this.wire(wiring, chain.a), Math.max(lateA, lateB) - lateA);
|
||||
const shareA = gainNode(context, both || moved ? 0 : 1 - chain.mix);
|
||||
wayA.connect(shareA).connect(tail);
|
||||
if (!both) {
|
||||
knobs.push({ param: moved ? null : shareA.gain, target: 1 - chain.mix, even: 'value' });
|
||||
if (moved) follow(moved, shareA.gain, false, (mix) => 1 - mix);
|
||||
}
|
||||
const wayB = this.wire(wiring, chain.b);
|
||||
const wayB = heldBack(context, this.wire(wiring, chain.b), Math.max(lateA, lateB) - lateB);
|
||||
const shareB = gainNode(context, both || moved ? 0 : chain.mix);
|
||||
wayB.connect(shareB).connect(tail);
|
||||
if (!both) {
|
||||
@@ -1939,7 +1954,7 @@ interface LastVoice {
|
||||
}
|
||||
|
||||
/** The stages that run on the worklet. */
|
||||
const ON_WORKLET: ReadonlySet<FxStagePlan['kind']> = new Set(['crush', 'phaser', 'flanger']);
|
||||
const ON_WORKLET: ReadonlySet<FxStagePlan['kind']> = new Set(['crush', 'phaser', 'flanger', 'limit']);
|
||||
|
||||
/** 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 {
|
||||
@@ -2036,6 +2051,14 @@ export function stageNodes(
|
||||
if (stage.kind === 'crush') return crushNodes(context, stage, follow);
|
||||
if (stage.kind === 'phaser') return phaserNodes(context, stage, follow);
|
||||
if (stage.kind === 'flanger') return flangerNodes(context, stage, at, follow);
|
||||
if (stage.kind === 'compress') return compressNodes(context, stage);
|
||||
if (stage.kind === 'limit')
|
||||
return workletNodes(context, 'limiter', [
|
||||
{ name: 'threshold', value: stage.threshold, even: 'value' },
|
||||
{ name: 'ceiling', value: stage.ceiling, even: 'value' },
|
||||
{ name: 'release', value: stage.release, even: 'ratio' },
|
||||
{ name: 'autoGain', value: stage.autoGain, even: 'value' },
|
||||
]);
|
||||
if (stage.kind === 'pan') {
|
||||
const panner = context.createStereoPanner();
|
||||
panner.pan.value = stage.mod ? 0 : stage.pan;
|
||||
@@ -2152,6 +2175,33 @@ export function releaseEnvelope(
|
||||
level.setTargetAtTime(0, at, releaseTime(envelope) / SETTLE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function compressNodes(context: BaseAudioContext, stage: FxCompress): StageNodes {
|
||||
const node = context.createDynamicsCompressor();
|
||||
node.threshold.value = stage.threshold;
|
||||
node.knee.value = stage.knee;
|
||||
node.ratio.value = stage.ratio;
|
||||
node.attack.value = stage.attack;
|
||||
node.release.value = stage.release;
|
||||
const gain = gainNode(context, stage.gain);
|
||||
node.connect(gain);
|
||||
return {
|
||||
input: node,
|
||||
output: gain,
|
||||
knobs: [
|
||||
{ param: node.threshold, target: stage.threshold, even: 'value' },
|
||||
{ param: node.knee, target: stage.knee, even: 'value' },
|
||||
{ param: node.ratio, target: stage.ratio, even: 'ratio' },
|
||||
{ param: node.attack, target: stage.attack, even: 'ratio' },
|
||||
{ param: node.release, target: stage.release, even: 'ratio' },
|
||||
{ param: gain.gain, target: stage.gain, even: 'ratio' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An echo (#327): the sound goes on as it is, and through a delay line whose output is damped, fed
|
||||
* back into it and added at `mix`. Ping-pong sends the sound into the left of the line only and
|
||||
@@ -2328,6 +2378,14 @@ export function bothAtCenter(
|
||||
return driver;
|
||||
}
|
||||
|
||||
/** A node's sound held back `seconds` by a delay line after it, or the node itself for none. */
|
||||
function heldBack(context: BaseAudioContext, node: AudioNode, seconds: number): AudioNode {
|
||||
if (!(seconds > 1e-9)) return node;
|
||||
const line = context.createDelay(seconds);
|
||||
line.delayTime.value = seconds;
|
||||
return node.connect(line);
|
||||
}
|
||||
|
||||
/** Sends a level to silence in `FADE` seconds from wherever it is now. */
|
||||
function fadeOut(param: AudioParam, now: number): void {
|
||||
param.cancelScheduledValues(now);
|
||||
@@ -2428,6 +2486,9 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] {
|
||||
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 !== 'envelope') into.push(stage.gain);
|
||||
return into;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
* plays one sound through a `PitchPlayer`, its pitch the AudioParam `pitch` in semitones, so it
|
||||
* glides, ramps and eases like any other knob. A Bitcrusher (#322) runs a `Crusher`, a Chip
|
||||
* (#329) a `ChipPlayer`, a Phaser and a Flanger (#341) theirs, the pitch of a bus (#346) a
|
||||
* `PitchShifter`. Loaded into the context once by the sound library; nothing on the page
|
||||
* `PitchShifter`, a Limiter (#326) a `Limiter`. Loaded into the context once by the sound library; nothing on the page
|
||||
* imports it.
|
||||
*/
|
||||
import { type ChipShape, ChipPlayer } from '../core/soundfx/chip';
|
||||
import { Crusher } from '../core/soundfx/crusher';
|
||||
import { Limiter, type LimiterNumbers } from '../core/soundfx/dynamics';
|
||||
import { Flanger, type FlangerNumbers, Phaser, type PhaserNumbers } from '../core/soundfx/phaser';
|
||||
import { PitchPlayer, PitchShifter } from '../core/soundfx/pitchPlayer';
|
||||
|
||||
@@ -242,6 +243,37 @@ class FlangerProcessor extends AudioWorkletProcessor {
|
||||
|
||||
registerProcessor('flanger', FlangerProcessor);
|
||||
|
||||
const LIMITER_PARAMS = ['threshold', 'ceiling', 'release', 'autoGain'] as const;
|
||||
|
||||
/** A Limiter (#326) between two stages of a voice or a bus; it runs until it is told to stop. */
|
||||
class LimitProcessor extends AudioWorkletProcessor {
|
||||
private readonly limiter = new Limiter(sampleRate);
|
||||
private stopped = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.port.onmessage = () => {
|
||||
this.stopped = true;
|
||||
};
|
||||
}
|
||||
|
||||
process(
|
||||
inputs: Float32Array[][],
|
||||
outputs: Float32Array[][],
|
||||
parameters: Record<string, Float32Array>,
|
||||
): boolean {
|
||||
if (this.stopped) return false;
|
||||
this.limiter.render(inputs[0] ?? [], outputs[0]!, parameters as LimiterNumbers);
|
||||
return true;
|
||||
}
|
||||
|
||||
static get parameterDescriptors() {
|
||||
return LIMITER_PARAMS.map((name) => ({ name, defaultValue: 0 }));
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('limiter', LimitProcessor);
|
||||
|
||||
export interface ChipOptions extends ChipShape {
|
||||
/** The second of the audio clock its first sample is heard at; before it, silence. */
|
||||
startAt: number;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Compressor and Limiter (#326): the curve of a DynamicsCompressorNode as Chromium draws it, the
|
||||
* gain it adds by itself, and the look-ahead Limiter of the FX worklet.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
compressorAutoGain,
|
||||
compressorCurve,
|
||||
compressorMakeup,
|
||||
Limiter,
|
||||
limiterDelay,
|
||||
type LimiterNumbers,
|
||||
} from '../../src/core/soundfx/dynamics';
|
||||
|
||||
/** What the node adds by itself: the 0.6th power of what full auto-gain would be. */
|
||||
const ownMakeup = (threshold: number, knee: number, ratio: number) =>
|
||||
10 ** ((0.6 * compressorAutoGain(threshold, knee, ratio)) / 20);
|
||||
|
||||
describe('the curve of a Compressor', () => {
|
||||
it('adds the gain Chrome was measured to add below the threshold (2026-10-07)', () => {
|
||||
// A 1 kHz sine far below the threshold, rendered offline: what comes out over what went in.
|
||||
expect(ownMakeup(-24, 30, 12)).toBeCloseTo(1.52412, 3);
|
||||
expect(ownMakeup(-12, 0, 4)).toBeCloseTo(1.86206, 3);
|
||||
expect(ownMakeup(-20, 10, 2)).toBeCloseTo(1.67503, 3);
|
||||
expect(ownMakeup(0, 0, 4)).toBeCloseTo(1, 4);
|
||||
});
|
||||
|
||||
it('is the identity below the threshold and falls by 1/ratio in dB above the knee', () => {
|
||||
const curve = compressorCurve(-12, 6, 4);
|
||||
expect(curve(0.1)).toBeCloseTo(0.1, 9);
|
||||
const db = (x: number) => 20 * Math.log10(curve(x));
|
||||
// From -3 dB to 0 dB in, 3 dB more in is 0.75 dB more out.
|
||||
expect(db(1) - db(10 ** (-3 / 20))).toBeCloseTo(0.75, 3);
|
||||
// A hard knee is the straight line from the threshold on: 0 dB in is -9 dB out.
|
||||
expect(compressorAutoGain(-12, 0, 4)).toBeCloseTo(9, 3);
|
||||
});
|
||||
|
||||
it('blends makeup and auto-gain, less what the node adds by itself', () => {
|
||||
const numbers = { threshold: -12, knee: 0, ratio: 4 };
|
||||
const db = (makeup: number, autoGain: number) =>
|
||||
20 * Math.log10(compressorMakeup({ ...numbers, makeup, autoGain }));
|
||||
expect(db(0, 0)).toBeCloseTo(-5.4, 3);
|
||||
expect(db(0, 1)).toBeCloseTo(3.6, 3);
|
||||
expect(db(6, 0.5)).toBeCloseTo(3 + 4.5 - 5.4, 3);
|
||||
expect(db(0, 7)).toBeCloseTo(3.6, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a Limiter', () => {
|
||||
const RATE = 48000;
|
||||
const numbers = (threshold: number, ceiling: number, autoGain = 0, release = 0.05): LimiterNumbers => ({
|
||||
threshold: new Float32Array([threshold]),
|
||||
ceiling: new Float32Array([ceiling]),
|
||||
release: new Float32Array([release]),
|
||||
autoGain: new Float32Array([autoGain]),
|
||||
});
|
||||
/** Runs `sound` through in blocks of 128, as the worklet does; gives back what came out. */
|
||||
const run = (limiter: Limiter, sound: Float32Array[], set: LimiterNumbers) => {
|
||||
const out = sound.map((channel) => new Float32Array(channel.length));
|
||||
for (let from = 0; from < sound[0]!.length; from += 128) {
|
||||
const to = Math.min(from + 128, sound[0]!.length);
|
||||
limiter.render(
|
||||
sound.map((c) => c.subarray(from, to)),
|
||||
out.map((c) => c.subarray(from, to)),
|
||||
set,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const sine = (amplitude: number, seconds: number, hz = 1000) =>
|
||||
Float32Array.from(
|
||||
{ length: RATE * seconds },
|
||||
(_, i) => Math.sin((2 * Math.PI * hz * i) / RATE) * amplitude,
|
||||
);
|
||||
const peak = (data: Float32Array, from = 0) => Math.max(...Array.from(data.subarray(from), Math.abs));
|
||||
|
||||
it('hands a quiet sound on as it is, limiterDelay samples late', () => {
|
||||
const delay = limiterDelay(RATE);
|
||||
expect(delay).toBe(240);
|
||||
const impulse = new Float32Array(1000);
|
||||
impulse[10] = 0.25;
|
||||
const [out] = run(new Limiter(RATE), [impulse], numbers(-6, -1));
|
||||
expect(out![10 + delay]).toBeCloseTo(0.25, 6);
|
||||
expect(peak(out!)).toBeCloseTo(0.25, 6);
|
||||
});
|
||||
|
||||
it('never lets a sample past the lower of threshold and ceiling, also a sudden peak', () => {
|
||||
const loud = sine(2, 0.2);
|
||||
loud[5000] = 8;
|
||||
const [out] = run(new Limiter(RATE), [loud], numbers(-6, -1, 0, 0.01));
|
||||
expect(peak(out!)).toBeLessThanOrEqual(10 ** (-6 / 20) + 1e-6);
|
||||
// A steady loud sine sits right at the limit once the gain has come down.
|
||||
expect(peak(out!, RATE * 0.15)).toBeGreaterThan(10 ** (-6 / 20) * 0.98);
|
||||
const [low] = run(new Limiter(RATE), [sine(2, 0.2)], numbers(-1, -6));
|
||||
expect(peak(low!)).toBeLessThanOrEqual(10 ** (-6 / 20) + 1e-6);
|
||||
});
|
||||
|
||||
it('raises the limited sound towards the ceiling by its auto-gain, never past it', () => {
|
||||
const limited = (autoGain: number) =>
|
||||
peak(run(new Limiter(RATE), [sine(2, 0.2)], numbers(-12, -1, autoGain))[0]!, RATE * 0.1);
|
||||
expect(limited(1)).toBeLessThanOrEqual(10 ** (-1 / 20) + 1e-6);
|
||||
expect(limited(1)).toBeGreaterThan(10 ** (-1 / 20) * 0.98);
|
||||
expect(20 * Math.log10(limited(0.5))).toBeCloseTo(-6.5, 1);
|
||||
});
|
||||
|
||||
it('turns both sides down together, by the louder one', () => {
|
||||
const [left, right] = run(new Limiter(RATE), [sine(2, 0.1), sine(0.5, 0.1)], numbers(-6, 0));
|
||||
expect(peak(left!, RATE * 0.05)).toBeCloseTo(10 ** (-6 / 20), 2);
|
||||
expect(peak(right!, RATE * 0.05)).toBeCloseTo(10 ** (-6 / 20) / 4, 2);
|
||||
});
|
||||
|
||||
it('comes back up in its release once the peak is over', () => {
|
||||
const sound = sine(2, 0.4);
|
||||
sound.fill(0, RATE * 0.1);
|
||||
sound.set(sine(0.1, 0.3), RATE * 0.1);
|
||||
const fast = run(new Limiter(RATE), [sound], numbers(-6, 0, 0, 0.01))[0]!;
|
||||
const slow = run(new Limiter(RATE), [sound], numbers(-6, 0, 0, 0.5))[0]!;
|
||||
const after = RATE * 0.2;
|
||||
expect(peak(fast, after)).toBeCloseTo(0.1, 3);
|
||||
expect(peak(slow, after)).toBeLessThan(0.09);
|
||||
});
|
||||
});
|
||||
@@ -19,11 +19,13 @@ import {
|
||||
type FxChain,
|
||||
fxChainOf,
|
||||
type FxDelay,
|
||||
fxLatencyOf,
|
||||
type FxPlan,
|
||||
fxRings,
|
||||
type FxStage,
|
||||
fxTailOf,
|
||||
type FxVoice,
|
||||
stageLatency,
|
||||
} from '../../src/core/soundfx/nodes';
|
||||
import { deserializeProject } from '../../src/core/store/serialize';
|
||||
import type { AudioSink } from '../../src/runtime/systems/Audio';
|
||||
@@ -125,6 +127,8 @@ describe('the sound FX asset', () => {
|
||||
'SoundFxPhaser',
|
||||
'SoundFxFlanger',
|
||||
'SoundFxPan',
|
||||
'SoundFxCompressor',
|
||||
'SoundFxLimiter',
|
||||
]);
|
||||
f.edit.addNode('SoundFxInput');
|
||||
f.edit.addNode('SoundFxOutput');
|
||||
@@ -139,6 +143,8 @@ describe('the sound FX asset', () => {
|
||||
'SoundFxPhaser',
|
||||
'SoundFxFlanger',
|
||||
'SoundFxPan',
|
||||
'SoundFxCompressor',
|
||||
'SoundFxLimiter',
|
||||
]);
|
||||
|
||||
const behavior = makeBehavior(f, 'Logic');
|
||||
@@ -416,6 +422,52 @@ describe('a Pan in a sound FX graph (#319)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a Compressor and a Limiter in a sound FX graph (#326)', () => {
|
||||
const at = (type: string, params?: Record<string, number>) =>
|
||||
fxChainOf(compileSoundFx(line([{ type, params }]).fx.graph).compiled!).stages;
|
||||
|
||||
it('compress at their defaults, the gain after it worked out from makeup and auto-gain', () => {
|
||||
const [stage] = at('SoundFxCompressor', { threshold: -12, knee: 0, ratio: 4, autoGain: 1 });
|
||||
expect(stage).toMatchObject({
|
||||
kind: 'compress',
|
||||
threshold: -12,
|
||||
knee: 0,
|
||||
ratio: 4,
|
||||
attack: 0.003,
|
||||
release: 0.25,
|
||||
});
|
||||
// 9 dB of full auto-gain, less the 5.4 dB the node adds itself.
|
||||
expect(20 * Math.log10((stage as { gain: number }).gain)).toBeCloseTo(3.6, 3);
|
||||
expect(at('SoundFxCompressor', { ratio: 50, threshold: 10 })).toMatchObject([
|
||||
{ ratio: 20, threshold: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('limit at their defaults, held to their rows, late by their look-ahead', () => {
|
||||
expect(at('SoundFxLimiter')).toEqual([
|
||||
{ kind: 'limit', threshold: -6, ceiling: -1, release: 0.1, autoGain: 1 },
|
||||
]);
|
||||
expect(at('SoundFxLimiter', { ceiling: 3, release: 0 })).toMatchObject([{ ceiling: 0, release: 0.001 }]);
|
||||
const [limit] = at('SoundFxLimiter');
|
||||
const [compress] = at('SoundFxCompressor');
|
||||
expect(stageLatency(limit!, 48000)).toBe(0.005);
|
||||
expect(stageLatency(limit!, 44100)).toBe(221 / 44100);
|
||||
expect(stageLatency(compress!, 44100)).toBe(0.006);
|
||||
expect(fxRings(compileSoundFx(line([{ type: 'SoundFxLimiter' }]).fx.graph).compiled!)).toBe(true);
|
||||
});
|
||||
|
||||
it('make a chain as late as its latest way and the stages after it', () => {
|
||||
const limit = at('SoundFxLimiter')[0]!;
|
||||
const compress = at('SoundFxCompressor')[0]!;
|
||||
const way = (stages: FxStage[]) => ({ pitch: 0, shifted: false, stages });
|
||||
expect(fxLatencyOf({ mix: 0.5, a: way([limit]), b: way([]), stages: [compress] }, 48000)).toBeCloseTo(
|
||||
0.011,
|
||||
9,
|
||||
);
|
||||
expect(fxLatencyOf(way([]), 48000)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the tail of a voice (#324)', () => {
|
||||
const echo = (time: number, feedback: number, mix: number): FxStage => ({
|
||||
kind: 'delay',
|
||||
|
||||
@@ -144,6 +144,8 @@ const clocks: {
|
||||
}[] = [];
|
||||
/** The pan of every track's panner made (#334), in order. */
|
||||
const panners: FakeParam[] = [];
|
||||
/** Every DynamicsCompressor made (#326), in order. */
|
||||
const compressors: Record<'threshold' | 'knee' | 'ratio' | 'attack' | 'release', FakeParam>[] = [];
|
||||
/** The curve of every WaveShaper made, in order. */
|
||||
const shapers: { curve: Float32Array | null }[] = [];
|
||||
let contextState = 'running';
|
||||
@@ -278,6 +280,17 @@ function fakeContext() {
|
||||
clocks.push(clock);
|
||||
return clock;
|
||||
},
|
||||
createDynamicsCompressor: () => {
|
||||
const node = wired('compressor', {
|
||||
threshold: param(-24),
|
||||
knee: param(30),
|
||||
ratio: param(12),
|
||||
attack: param(0.003),
|
||||
release: param(0.25),
|
||||
});
|
||||
compressors.push(node);
|
||||
return node;
|
||||
},
|
||||
createWaveShaper: () => {
|
||||
const shaper = wired('shaper', { curve: null as Float32Array | null });
|
||||
shapers.push(shaper);
|
||||
@@ -302,6 +315,7 @@ beforeEach(() => {
|
||||
disconnects.length = 0;
|
||||
clocks.length = 0;
|
||||
shapers.length = 0;
|
||||
compressors.length = 0;
|
||||
panners.length = 0;
|
||||
contextState = 'running';
|
||||
decodes = [];
|
||||
@@ -326,7 +340,7 @@ beforeEach(() => {
|
||||
? 'crush'
|
||||
: name === 'chip-player'
|
||||
? 'chip'
|
||||
: name === 'phaser' || name === 'flanger'
|
||||
: name === 'phaser' || name === 'flanger' || name === 'limiter'
|
||||
? name
|
||||
: name === 'pitch-shifter'
|
||||
? 'shifter'
|
||||
@@ -1250,6 +1264,31 @@ describe('notes of an instrument (#331)', () => {
|
||||
expect(sources[1]!.startedAt).toBe(0);
|
||||
});
|
||||
|
||||
it('starts a note with a Limiter as much earlier as it is late, its other layer held back to it (#326)', async () => {
|
||||
const library = new SoundLibrary();
|
||||
const limit: FxStagePlan = {
|
||||
kind: 'limit',
|
||||
threshold: { value: -6 },
|
||||
ceiling: { value: -1 },
|
||||
release: { value: 0.1 },
|
||||
autoGain: { value: 1 },
|
||||
};
|
||||
const limited = { ...sample(FILE), stages: [limit] } as FxPlan;
|
||||
library.playNote('instrument:i', [limited, sample(OTHER)], {}, 1, '', 0.5, { at: 2 });
|
||||
await settle();
|
||||
await arrive();
|
||||
// 240 samples at 48 kHz.
|
||||
expect(sources.map((source) => source.startedAt)).toEqual([1.995, 1.995]);
|
||||
expect(delays.map((delay) => delay.delayTime.value)).toEqual([0.005]);
|
||||
expect(worklets.map((worklet) => worklet.kind)).toEqual(['limiter']);
|
||||
expect(gains[1]!.ramps).toEqual([
|
||||
['set', 0.5, 2.495],
|
||||
['linear', 0, 2.505],
|
||||
]);
|
||||
// What the Limiter holds back is heard after the sources end, before the voice is let go.
|
||||
expect(library.tail(1)).toBeCloseTo(0.005, 9);
|
||||
});
|
||||
|
||||
it('tells a pitched note planned ahead when to start, the worklet having no start of its own (#332)', async () => {
|
||||
const library = new SoundLibrary();
|
||||
const pitched = { ...sample(FILE), pitch: [{ value: 7 }] } as FxPlan;
|
||||
@@ -2262,4 +2301,61 @@ describe('the stages of #318 to #328', () => {
|
||||
expect(panners.map((p) => p.value)).toEqual([-0.4]);
|
||||
expect(made.knobs).toEqual([{ param: panners[0], target: -0.4, even: 'value' }]);
|
||||
});
|
||||
|
||||
it('compresses with the node of Web Audio and a gain after it for makeup and auto-gain (#326)', () => {
|
||||
const context = fakeContext() as unknown as BaseAudioContext;
|
||||
const made = stageNodes(context, {
|
||||
kind: 'compress',
|
||||
threshold: -12,
|
||||
knee: 0,
|
||||
ratio: 4,
|
||||
attack: 0.01,
|
||||
release: 0.2,
|
||||
gain: 2,
|
||||
});
|
||||
const node = compressors[0]!;
|
||||
expect([node.threshold, node.knee, node.ratio, node.attack, node.release].map((p) => p.value)).toEqual([
|
||||
-12, 0, 4, 0.01, 0.2,
|
||||
]);
|
||||
expect(wires).toEqual(['compressor > gain']);
|
||||
expect(made.knobs.map((k) => [k.target, k.even])).toEqual([
|
||||
[-12, 'value'],
|
||||
[0, 'value'],
|
||||
[4, 'ratio'],
|
||||
[0.01, 'ratio'],
|
||||
[0.2, 'ratio'],
|
||||
[2, 'ratio'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('holds back the other way of a Mix as far as a Compressor makes one late, on a voice and a bus (#326)', async () => {
|
||||
const library = new SoundLibrary();
|
||||
const compress: FxStagePlan = {
|
||||
kind: 'compress',
|
||||
threshold: { value: -24 },
|
||||
knee: { value: 6 },
|
||||
ratio: { value: 4 },
|
||||
attack: { value: 0.003 },
|
||||
release: { value: 0.25 },
|
||||
makeup: { value: 0 },
|
||||
autoGain: { value: 0 },
|
||||
};
|
||||
const plan: FxPlan = {
|
||||
mix: { value: 0.5 },
|
||||
a: { pitch: [], stages: [compress] },
|
||||
b: { pitch: [], stages: [] },
|
||||
stages: [],
|
||||
};
|
||||
await play(library, FILE, {}, { plan, params: {} });
|
||||
expect(delays.map((delay) => delay.delayTime.value)).toEqual([0.006]);
|
||||
// Each way has a source of its own: one through the Compressor, one through the delay line.
|
||||
expect(wires).toEqual(expect.arrayContaining(['source > compressor', 'source > delay', 'delay > gain']));
|
||||
expect(library.startedAt(1)).toBeCloseTo(0, 9);
|
||||
|
||||
await library.setMix([
|
||||
{ name: 'mix', volume: 1, into: null, fx: null },
|
||||
{ name: 'mix:c', volume: 1, into: 'mix', fx: { plan, params: {} } },
|
||||
]);
|
||||
expect(library.busLatency('mix:c')).toBeCloseTo(0.006, 9);
|
||||
});
|
||||
});
|
||||
@@ -1005,6 +1005,56 @@ export async function sound(t) {
|
||||
JSON.stringify({ left: [0.5, 0], middle: [0.354, 0.354], balance: [0.354, 0.854] }),
|
||||
JSON.stringify(panned),
|
||||
);
|
||||
// #326: Compressor and Limiter rendered offline. An impulse comes out of the Compressor 288
|
||||
// samples late (the spec's 6 ms) and out of the Limiter 240 (5 ms). A 1 kHz sine at full
|
||||
// scale through a hard-knee Compressor at -12 dB, 4:1 with full auto-gain comes back near
|
||||
// full scale; with no auto-gain and no makeup it is 9 dB down. A sine at 2 through a
|
||||
// Limiter at -6 dB never goes past it, with auto-gain 1 it sits at the ceiling of -1 dB.
|
||||
const dynamics = await evaluate(`
|
||||
(async () => {
|
||||
const RATE = 48000;
|
||||
const render = async (stage, fill) => {
|
||||
const context = new OfflineAudioContext(1, RATE / 2, RATE);
|
||||
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
|
||||
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 impulse = (data) => { data[480] = 0.01; };
|
||||
const sine = (amplitude) => (data) => {
|
||||
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 1000 * i) / RATE) * amplitude;
|
||||
};
|
||||
const loudest = (out, from = 0) => { let at = 0; for (let i = from; i < out.length; i++) if (Math.abs(out[i]) > Math.abs(out[at])) at = i; return at; };
|
||||
const db = (out) => +(20 * Math.log10(Math.abs(out[loudest(out, RATE / 4)]))).toFixed(2);
|
||||
const compress = (gain) => ({ kind: 'compress', threshold: -12, knee: 0, ratio: 4, attack: 0.003, release: 0.25, gain });
|
||||
const limit = (autoGain) => ({ kind: 'limit', threshold: -6, ceiling: -1, release: 0.05, autoGain });
|
||||
const limited = await render(limit(0), sine(2));
|
||||
return {
|
||||
compressorLate: loudest(await render(compress(1), impulse)) - 480,
|
||||
limiterLate: loudest(await render(limit(0), impulse)) - 480,
|
||||
autoGain: db(await render(compress(10 ** (3.6 / 20)), sine(1))),
|
||||
noMakeup: db(await render(compress(10 ** (-5.4 / 20)), sine(1))),
|
||||
limitedPeak: +(20 * Math.log10(Math.max(...limited.map(Math.abs)))).toFixed(3),
|
||||
ceiling: db(await render(limit(1), sine(2))),
|
||||
};
|
||||
})()`);
|
||||
check(
|
||||
'a Compressor is 6 ms late and auto-gain brings full scale back, a Limiter is 5 ms late and never passes its limit',
|
||||
dynamics.compressorLate === 288 &&
|
||||
dynamics.limiterLate === 240 &&
|
||||
Math.abs(dynamics.autoGain) < 0.5 &&
|
||||
Math.abs(dynamics.noMakeup + 9) < 0.5 &&
|
||||
dynamics.limitedPeak <= -6 + 1e-3 &&
|
||||
dynamics.ceiling <= -1 + 1e-3 &&
|
||||
dynamics.ceiling > -1.2,
|
||||
JSON.stringify(dynamics),
|
||||
);
|
||||
// #329: the Chip on the worklet, rendered offline. A square at 220 Hz with a pitch of 12 and a duty
|
||||
// of 0.25, starting at 0.25 s: silence until then, 440 periods a second after it, up a
|
||||
// quarter of each. Sliding two octaves a second down from 440 Hz with a lowest frequency of
|
||||
|
||||
Reference in new issue
Block a user