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

+179
View File
@@ -0,0 +1,179 @@
/**
* What the tab of a Compressor (#378) draws, as numbers: the level coming in and going out over
* the last seconds, the curve from level in to level out, and where its threshold and ratio are
* grabbed. Kept apart from the drawing so it is checked without a browser.
*/
import { compressorAutoGain, compressorCurve } from '../../core/soundfx/dynamics';
import { clamp, snapToStep } from '../../ui/math';
import type { Levels } from '../../viewport/spectrum';
/** The dB the level picture spans, bottom to top. */
export const LEVEL_FLOOR_DB = -60;
export const LEVEL_TOP_DB = 6;
/** How long the level picture looks back. */
export const HISTORY_SECONDS = 4;
/** How long a level is the loudest sample of: a frame and a bit, so no peak falls between two. */
export const LEVEL_WINDOW = 0.025;
/** The dB the curve spans on both axes, from here to 0. */
export const CURVE_FLOOR_DB = -60;
/** Less taken away than this is not tinted: the two sides are read a few ms apart. */
export const REDUCTION_SHOWN_DB = 0.5;
export interface CompressorNumbers {
threshold: number;
knee: number;
ratio: number;
attack: number;
release: number;
makeup: number;
autoGain: number;
}
const dbToLinear = (db: number) => 10 ** (db / 20);
const linearToDb = (value: number) => (value <= 0 ? -Infinity : 20 * Math.log10(value));
/** What the gain after the compressing adds, in dB: makeup and auto-gain blended. */
export function compressorGainDb(n: CompressorNumbers): number {
const w = clamp(n.autoGain, 0, 1);
return (1 - w) * n.makeup + w * compressorAutoGain(n.threshold, n.knee, n.ratio);
}
/** The level a steady one of `inDb` comes out at, before the gain after it. */
export function curveOutDb(inDb: number, threshold: number, knee: number, ratio: number): number {
return linearToDb(compressorCurve(threshold, knee, ratio)(dbToLinear(inDb)));
}
// --- The curve: a square, level in across, level out up, both CURVE_FLOOR_DB..0. ---
export const curveX = (db: number, size: number): number =>
((clamp(db, CURVE_FLOOR_DB, 0) - CURVE_FLOOR_DB) / -CURVE_FLOOR_DB) * size;
export const curveY = (db: number, size: number): number => size - curveX(db, size);
export const curveDbAtX = (x: number, size: number): number => CURVE_FLOOR_DB + (x / size) * -CURVE_FLOOR_DB;
export const curveDbAtY = (y: number, size: number): number => curveDbAtX(size - y, size);
/** The curve as points across the square. */
export function curvePoints(n: CompressorNumbers, size: number, steps: number): number[] {
const curve = compressorCurve(n.threshold, n.knee, n.ratio);
const points: number[] = [];
for (let i = 0; i <= steps; i++) {
const db = CURVE_FLOOR_DB + (i / steps) * -CURVE_FLOOR_DB;
points.push(curveX(db, size), curveY(linearToDb(curve(dbToLinear(db))), size));
}
return points;
}
/** Where the threshold is grabbed: on the curve where it starts to bend. */
export function thresholdHandle(n: CompressorNumbers, size: number): { x: number; y: number } {
return { x: curveX(n.threshold, size), y: curveY(n.threshold, size) };
}
/** Where the ratio is grabbed: the end of the curve, at a level of 0 dB going in. */
export function ratioHandle(n: CompressorNumbers, size: number): { x: number; y: number } {
return { x: size, y: curveY(curveOutDb(0, n.threshold, n.knee, n.ratio), size) };
}
/**
* The ratio whose curve comes out at `outDb` for 0 dB going in, held to `min`..`max` and rounded
* to `step`. The higher the ratio, the lower the end of the curve, so it is found by halving.
*/
export function ratioFor(
outDb: number,
n: CompressorNumbers,
range: { min: number; max: number; step: number },
): number {
const end = (ratio: number) => curveOutDb(0, n.threshold, n.knee, ratio);
if (outDb >= end(range.min)) return range.min;
if (outDb <= end(range.max)) return range.max;
let low = range.min;
let high = range.max;
for (let i = 0; i < 40; i++) {
const mid = (low + high) / 2;
if (end(mid) > outDb) low = mid;
else high = mid;
}
return clamp(snapToStep((low + high) / 2, range.step), range.min, range.max);
}
// --- The level picture: time across, the newest at the right edge; dB up. ---
export const levelY = (db: number, h: number): number =>
((LEVEL_TOP_DB - clamp(db, LEVEL_FLOOR_DB, LEVEL_TOP_DB)) / (LEVEL_TOP_DB - LEVEL_FLOOR_DB)) * h;
export const historyX = (t: number, now: number, w: number): number => w - ((now - t) / HISTORY_SECONDS) * w;
/** One reading of both sides. */
export interface LevelPoint extends Levels {
t: number;
}
/** The readings of the last seconds, oldest first. */
export class LevelHistory {
private points: LevelPoint[] = [];
/** A reading at `t` seconds; none heard counts as silence on both sides. */
push(t: number, levels: Levels | null): void {
this.points.push({ t, in: levels?.in ?? -Infinity, out: levels?.out ?? -Infinity });
const old = this.points.findIndex((p) => p.t >= t - HISTORY_SECONDS);
if (old > 0) this.points.splice(0, old);
}
clear(): void {
this.points = [];
}
get all(): readonly LevelPoint[] {
return this.points;
}
/** Whether anything above the floor is still in the picture, so it has to move on. */
get heard(): boolean {
return this.points.some((p) => p.in > LEVEL_FLOOR_DB || p.out > LEVEL_FLOOR_DB);
}
get latest(): LevelPoint | null {
return this.points.at(-1) ?? null;
}
}
/** How much the compressing takes away at a reading, in dB: the gain after it left out. */
export function reductionOf(point: Levels, gainDb: number): number {
if (!Number.isFinite(point.in) || !Number.isFinite(point.out)) return 0;
return Math.max(0, point.in - (point.out - gainDb));
}
/**
* The tinted shapes where the compressor works: from the level coming in down by what it takes
* away, one shape for each stretch where it takes away more than `REDUCTION_SHOWN_DB`.
*/
export function reductionShapes(
points: readonly LevelPoint[],
gainDb: number,
now: number,
w: number,
h: number,
): number[][] {
const shapes: number[][] = [];
let top: number[] = [];
let bottom: number[] = [];
const close = () => {
if (top.length >= 4) {
const back: number[] = [];
for (let i = bottom.length - 2; i >= 0; i -= 2) back.push(bottom[i]!, bottom[i + 1]!);
shapes.push([...top, ...back]);
}
top = [];
bottom = [];
};
for (const point of points) {
const reduction = reductionOf(point, gainDb);
if (reduction < REDUCTION_SHOWN_DB) {
close();
continue;
}
const x = historyX(point.t, now, w);
top.push(x, levelY(point.in, h));
bottom.push(x, levelY(point.in - reduction, h));
}
close();
return shapes;
}