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:
adminandClaude Opus 5.5 committed 2026-10-09 22:40:12 +02:00
1 parent 23922ee4a2
commit 1df38bd430
20 files changed
+1471 -103

No files matched your search

+18 -4
View File
@@ -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', () => {