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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user