Edit a Compressor in a tab of its own, with knobs and levels (#378)
A Compressor had only its number rows in the node. Its numbers are
easier to set by feel on a dial, and what it does is easier to judge
when the level going in and the level going out are seen side by side.
- Knob is a new widget in the toolkit: a slider bent into a circle,
its name above and a number field below. A drag up or down turns
it, Shift a tenth as far, a notch of the wheel a fiftieth of the
turn, a double-click sets the default, the number takes a typed
value. A taper gives the low end more of the turn.
- The Compressor's tab opens on a double-click, one per node, like
the Parametric EQ's. It has a knob for each of the seven numbers,
greyed while its input is linked.
- Its curve shows level in to level out from -60 to 0 dB. The point
where it bends sets the threshold, the end of the curve sets the
ratio that ends there.
- Its levels show the last 4 s on both sides of the node. Where it
takes more than 0.5 dB away, the stretch below the level coming in
is tinted. The makeup is left out of that.
- The Compressor is tapped like the EQ. SpectrumTaps listens to more
than one node at once and reads the loudest sample of the last
25 ms on each side.
- The node tabs share one map in the editor (nodeTabs), so a third
kind needs no new plumbing.
Findings:
- CI task 614 for 23922ee was red on the memory check of the stream:
the tab's renderer grew by 94 MB with nothing decoded. Play mode
made garbage of its own and was read before a collection. Both
readings in play now come after one, and the check compares the
growth streamed with the growth decoded (3.7 MB against 59.8 MB).
- The ratio handle sat on the right edge of the picture, where a
widget's hit area ends. The curve is inset by the grab radius.
Tests:
- Gate (melektaus-atx): 282 files, 4338 tests (17 new)
- e2e 34 (melektaus-atx): ALL PASSED, 116 checks (6 new)
- Counter-checks: an inverted ratio search, stop() letting go of
every node and a drag without its least step each fail exactly
their test.
- Looked at the tab in a screenshot: a blip at -4.4 dB came out at
-16.7 dB, the gap tinted, the threshold line at -24.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
23922ee4a2
commit
1df38bd430
20 files changed
+1471
-103
No files matched your search
@@ -442,8 +442,20 @@ describe('a Pan in a sound FX graph (#319)', () => {
|
||||
});
|
||||
|
||||
describe('a Compressor and a Limiter in a sound FX graph (#326)', () => {
|
||||
// The taps a Compressor keeps for its tab do nothing to the sound (#378).
|
||||
const at = (type: string, params?: Record<string, number>) =>
|
||||
fxChainOf(compileSoundFx(line([{ type, params }]).fx.graph).compiled!).stages;
|
||||
fxChainOf(compileSoundFx(line([{ type, params }]).fx.graph).compiled!).stages.filter(
|
||||
(s) => s.kind !== 'tap',
|
||||
);
|
||||
|
||||
it('tap a Compressor on both sides for its tab, a Limiter not (#378)', () => {
|
||||
const kinds = (type: string) =>
|
||||
fxChainOf(compileSoundFx(line([{ type }]).fx.graph).compiled!).stages.map((s) =>
|
||||
s.kind === 'tap' ? `tap ${s.at}` : s.kind,
|
||||
);
|
||||
expect(kinds('SoundFxCompressor')).toEqual(['tap in', 'compress', 'tap out']);
|
||||
expect(kinds('SoundFxLimiter')).toEqual(['limit']);
|
||||
});
|
||||
|
||||
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 });
|
||||
@@ -676,9 +688,11 @@ describe('Bypass and Wet (#354)', () => {
|
||||
f.input.ports = [{ key: 'blend', kind: 'number', default: 0.5 }];
|
||||
f.edit.connect(f.input, 'blend', compressor, 'wet');
|
||||
const plan = compileSoundFx(f.fx.graph).compiled!;
|
||||
expect(fxChainOf(plan).stages).toMatchObject([{ kind: 'wet', wet: 0.5, stages: [{ kind: 'compress' }] }]);
|
||||
expect(fxChainOf(plan, { blend: 0 }).stages).toMatchObject([{ kind: 'wet', wet: 0 }]);
|
||||
expect(fxChainOf(plan, { blend: 7 }).stages).toMatchObject([{ kind: 'wet', wet: 1 }]);
|
||||
const stages = (params?: Record<string, number>) =>
|
||||
fxChainOf(plan, params).stages.filter((s) => s.kind !== 'tap');
|
||||
expect(stages()).toMatchObject([{ kind: 'wet', wet: 0.5, stages: [{ kind: 'compress' }] }]);
|
||||
expect(stages({ blend: 0 })).toMatchObject([{ kind: 'wet', wet: 0 }]);
|
||||
expect(stages({ blend: 7 })).toMatchObject([{ kind: 'wet', wet: 1 }]);
|
||||
});
|
||||
|
||||
it('a blend is as late as what it blends and rings as long', () => {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* What the tab of a Compressor draws (#378): its curve and the handles on it, the ratio a drag of
|
||||
* the end of the curve asks for, and the level picture tinted where the compressor works.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
type CompressorNumbers,
|
||||
compressorGainDb,
|
||||
curveDbAtX,
|
||||
curveDbAtY,
|
||||
curveOutDb,
|
||||
curveX,
|
||||
curveY,
|
||||
HISTORY_SECONDS,
|
||||
LevelHistory,
|
||||
levelY,
|
||||
ratioFor,
|
||||
ratioHandle,
|
||||
reductionOf,
|
||||
reductionShapes,
|
||||
thresholdHandle,
|
||||
} from '../../src/editor/soundfx/compressorView';
|
||||
|
||||
const numbers: CompressorNumbers = {
|
||||
threshold: -24,
|
||||
knee: 0,
|
||||
ratio: 4,
|
||||
attack: 0.003,
|
||||
release: 0.25,
|
||||
makeup: 0,
|
||||
autoGain: 0,
|
||||
};
|
||||
const RATIO = { min: 1, max: 20, step: 0.1 };
|
||||
|
||||
describe('the curve of a Compressor', () => {
|
||||
it('leaves a level below the threshold as it is and squeezes one above it by the ratio', () => {
|
||||
expect(curveOutDb(-40, -24, 0, 4)).toBeCloseTo(-40, 6);
|
||||
// 24 dB over a threshold of -24 come out 6 dB over it at a ratio of 4.
|
||||
expect(curveOutDb(0, -24, 0, 4)).toBeCloseTo(-18, 1);
|
||||
});
|
||||
|
||||
it('spans -60 to 0 dB on both axes, level out going up', () => {
|
||||
expect(curveX(-60, 300)).toBe(0);
|
||||
expect(curveX(0, 300)).toBe(300);
|
||||
expect(curveY(0, 300)).toBe(0);
|
||||
expect(curveDbAtX(150, 300)).toBe(-30);
|
||||
expect(curveDbAtY(100, 300)).toBe(-20);
|
||||
});
|
||||
|
||||
it('has the threshold where it starts to bend and the ratio at its end', () => {
|
||||
expect(thresholdHandle(numbers, 300)).toEqual({ x: 180, y: 120 });
|
||||
const end = ratioHandle(numbers, 300);
|
||||
expect(end.x).toBe(300);
|
||||
expect(curveDbAtY(end.y, 300)).toBeCloseTo(-18, 1);
|
||||
});
|
||||
|
||||
it('finds the ratio whose end is where it is dragged to, held to the range', () => {
|
||||
expect(ratioFor(-18, numbers, RATIO)).toBe(4);
|
||||
expect(ratioFor(-12, numbers, RATIO)).toBe(2);
|
||||
expect(ratioFor(5, numbers, RATIO)).toBe(1);
|
||||
expect(ratioFor(-40, numbers, RATIO)).toBe(20);
|
||||
// With a knee the curve ends lower than the straight line would, and the ratio found still lands there.
|
||||
const kneed = { ...numbers, knee: 6 };
|
||||
const ratio = ratioFor(-16, kneed, RATIO);
|
||||
expect(curveOutDb(0, -24, 6, ratio)).toBeCloseTo(-16, 0);
|
||||
});
|
||||
|
||||
it('counts the makeup, the auto-gain or a blend of both as the gain after it', () => {
|
||||
expect(compressorGainDb({ ...numbers, makeup: 6 })).toBe(6);
|
||||
expect(compressorGainDb({ ...numbers, makeup: 6, autoGain: 1 })).toBeCloseTo(18, 1);
|
||||
expect(compressorGainDb({ ...numbers, makeup: 6, autoGain: 0.5 })).toBeCloseTo(12, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the level picture of a Compressor', () => {
|
||||
it('spans +6 to -60 dB top to bottom, silence at the bottom', () => {
|
||||
expect(levelY(6, 660)).toBe(0);
|
||||
expect(levelY(-60, 660)).toBe(660);
|
||||
expect(levelY(-Infinity, 660)).toBe(660);
|
||||
expect(levelY(0, 660)).toBe(60);
|
||||
});
|
||||
|
||||
it('keeps the readings of the last seconds and knows when all of them are silent', () => {
|
||||
const history = new LevelHistory();
|
||||
history.push(0, { in: -6, out: -9 });
|
||||
history.push(1, null);
|
||||
expect(history.heard).toBe(true);
|
||||
history.push(1 + HISTORY_SECONDS, null);
|
||||
expect(history.all.map((p) => p.t)).toEqual([1, 1 + HISTORY_SECONDS]);
|
||||
expect(history.heard).toBe(false);
|
||||
});
|
||||
|
||||
it('takes away what came in less what went out, the gain after it left out', () => {
|
||||
expect(reductionOf({ in: -6, out: -12 }, 0)).toBe(6);
|
||||
expect(reductionOf({ in: -6, out: -6 }, 6)).toBe(6);
|
||||
expect(reductionOf({ in: -30, out: -29 }, 0)).toBe(0);
|
||||
expect(reductionOf({ in: -Infinity, out: -Infinity }, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('tints from the level coming in down by what is taken away, one shape per stretch', () => {
|
||||
const points = [
|
||||
{ t: 0, in: -6, out: -12 },
|
||||
{ t: 1, in: -6, out: -12 },
|
||||
{ t: 2, in: -30, out: -30 },
|
||||
{ t: 3, in: 0, out: -3 },
|
||||
{ t: 4, in: 0, out: -3 },
|
||||
];
|
||||
const shapes = reductionShapes(points, 0, 4, 400, 660);
|
||||
expect(shapes).toHaveLength(2);
|
||||
// Along the level coming in, then back along it less what is taken away.
|
||||
expect(shapes[0]).toEqual([0, 120, 100, 120, 100, 180, 0, 180]);
|
||||
expect(shapes[1]).toEqual([300, 60, 400, 60, 400, 90, 300, 90]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* The rules of a Knob (#378): where a value sits on the turn, and what a drag and the wheel make of it.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
draggedValue,
|
||||
KNOB_TRAVEL,
|
||||
knobOrigin,
|
||||
knobPosition,
|
||||
knobValue,
|
||||
wheeledValue,
|
||||
} from '../../src/ui/knob';
|
||||
|
||||
const threshold = { min: -100, max: 0, step: 0.5 };
|
||||
const attack = { min: 0, max: 1, step: 0.001, taper: 3 };
|
||||
const makeup = { min: -24, max: 24, step: 0.5 };
|
||||
|
||||
describe('a knob', () => {
|
||||
it('turns evenly without a taper, and gives the low end more of the turn with one', () => {
|
||||
expect(knobPosition(-50, threshold)).toBeCloseTo(0.5, 9);
|
||||
expect(knobValue(0.5, threshold)).toBe(-50);
|
||||
// An attack of 3 ms is an eighth of the way round, not three thousandths.
|
||||
expect(knobPosition(0.003, attack)).toBeCloseTo(0.144, 3);
|
||||
expect(knobValue(knobPosition(0.003, attack), attack)).toBe(0.003);
|
||||
expect(knobValue(-1, attack)).toBe(0);
|
||||
expect(knobValue(2, attack)).toBe(1);
|
||||
});
|
||||
|
||||
it('turns up as it is dragged up, a tenth as far with Shift', () => {
|
||||
expect(draggedValue(-50, -KNOB_TRAVEL / 2, false, threshold)).toBe(0);
|
||||
expect(draggedValue(-50, KNOB_TRAVEL / 4, false, threshold)).toBe(-75);
|
||||
expect(draggedValue(-50, -KNOB_TRAVEL / 2, true, threshold)).toBe(-45);
|
||||
expect(draggedValue(-50, KNOB_TRAVEL * 10, false, threshold)).toBe(-100);
|
||||
});
|
||||
|
||||
it('moves at least one step, however short the drag', () => {
|
||||
expect(draggedValue(-50, -1, true, threshold)).toBe(-49.5);
|
||||
expect(draggedValue(-50, 1, true, threshold)).toBe(-50.5);
|
||||
expect(draggedValue(0, -1, true, threshold)).toBe(0);
|
||||
expect(draggedValue(-50, 0, false, threshold)).toBe(-50);
|
||||
});
|
||||
|
||||
it('notches up on the wheel turned up, and down on it turned down', () => {
|
||||
expect(wheeledValue(-50, -100, threshold)).toBe(-48);
|
||||
expect(wheeledValue(-50, 100, threshold)).toBe(-52);
|
||||
expect(wheeledValue(0.003, -100, attack)).toBeGreaterThan(0.003);
|
||||
});
|
||||
|
||||
it('fills its arc from 0 when the range has it inside, else from the low end', () => {
|
||||
expect(knobOrigin(makeup)).toBeCloseTo(0.5, 9);
|
||||
expect(knobOrigin(threshold)).toBe(0);
|
||||
expect(knobOrigin(attack)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,12 @@ class FakeNode {
|
||||
getFloatFrequencyData(data: Float32Array): void {
|
||||
data.fill(this.context.made.length);
|
||||
}
|
||||
/** What the analyser heard last: `wave`, at the end of `data` like the newest samples. */
|
||||
wave: number[] = [];
|
||||
getFloatTimeDomainData(data: Float32Array): void {
|
||||
data.fill(0);
|
||||
data.set(this.wave, data.length - this.wave.length);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeContext {
|
||||
@@ -50,7 +56,7 @@ describe('the taps of the spectrum', () => {
|
||||
taps.hold(tap('fx:eq', 'in'), new FakeNode(context) as never);
|
||||
expect(taps.info()).toEqual({ listened: '', held: 1, feeding: 0 });
|
||||
expect(context.made).toHaveLength(0);
|
||||
expect(taps.read()).toBeNull();
|
||||
expect(taps.read('fx:eq')).toBeNull();
|
||||
});
|
||||
|
||||
it('feed the analyser of their side while listened to, a voice started before as well', () => {
|
||||
@@ -69,7 +75,7 @@ describe('the taps of the spectrum', () => {
|
||||
expect([...before.links]).toEqual([sumIn]);
|
||||
expect([...after.links]).toEqual([sumOut]);
|
||||
expect(other.links.size).toBe(0);
|
||||
expect(taps.read()?.binHz).toBeCloseTo(48000 / 8192, 9);
|
||||
expect(taps.read('fx:eq')?.binHz).toBeCloseTo(48000 / 8192, 9);
|
||||
});
|
||||
|
||||
it('let go of the analysers once nobody listens, or once the voice stops', () => {
|
||||
@@ -87,6 +93,41 @@ describe('the taps of the spectrum', () => {
|
||||
taps.stop();
|
||||
expect(bus.links.size).toBe(0);
|
||||
expect(taps.info()).toEqual({ listened: '', held: 1, feeding: 0 });
|
||||
expect(taps.read()).toBeNull();
|
||||
expect(taps.read('fx:eq')).toBeNull();
|
||||
});
|
||||
|
||||
it('listen to two nodes at once, and let one go without the other (#378)', () => {
|
||||
const context = new FakeContext();
|
||||
const taps = new SpectrumTaps();
|
||||
const eq = new FakeNode(context);
|
||||
const compressor = new FakeNode(context);
|
||||
taps.hold(tap('fx:eq', 'in'), eq as never);
|
||||
taps.hold(tap('fx:comp', 'in'), compressor as never);
|
||||
taps.listen('fx:eq');
|
||||
taps.listen('fx:comp');
|
||||
expect(taps.info()).toEqual({ listened: 'fx:eq fx:comp', held: 2, feeding: 2 });
|
||||
expect(eq.links.size).toBe(1);
|
||||
expect(compressor.links.size).toBe(1);
|
||||
taps.stop('fx:eq');
|
||||
expect(eq.links.size).toBe(0);
|
||||
expect(compressor.links.size).toBe(1);
|
||||
expect(taps.info()).toEqual({ listened: 'fx:comp', held: 2, feeding: 1 });
|
||||
expect(taps.read('fx:comp')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('give the loudest sample of the last stretch on each side, in dB (#378)', () => {
|
||||
const context = new FakeContext();
|
||||
const taps = new SpectrumTaps();
|
||||
expect(taps.levels('fx:comp', 0.01)).toBeNull();
|
||||
taps.listen('fx:comp');
|
||||
taps.hold(tap('fx:comp', 'in'), new FakeNode(context) as never);
|
||||
const [, analyserIn, , analyserOut] = context.made;
|
||||
// A peak of 1 long ago, then 0.5: a window of 10 ms (480 samples) only sees the 0.5.
|
||||
analyserIn!.wave = [1, ...new Array<number>(1000).fill(0), -0.5, 0.25];
|
||||
analyserOut!.wave = [];
|
||||
const levels = taps.levels('fx:comp', 0.01);
|
||||
expect(levels?.in).toBeCloseTo(20 * Math.log10(0.5), 9);
|
||||
expect(levels?.out).toBe(-Infinity);
|
||||
expect(taps.levels('fx:comp', 1)?.in).toBeCloseTo(0, 9);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user