Files
feuerwerk/src/editor/soundfx/LimiterPanel.ts
T
adminandClaude Opus 5.5 28909f0adf
game-maker / gate (push) Successful in 4m23s
game-maker / suite (push) Successful in 16m6s
Show a Limiter node as a device that holds what it took away (#397)
A Limiter only said what it does in rows of numbers. How much it took
away, and when, could not be seen at all, and a short sound is over
before any needle could show it. Now the node is a device of its own
size, and its screen catches the reduction from the moment a sound
starts and holds it once the sound ends.

- LimiterPanel draws a 720x280 plate of brushed brick. The threshold
  knob is large, ceiling and release are small beside it, auto-gain is
  a lever. A LIMIT lamp lights while it takes something away.
- The screen hangs the reduction down from the top, 0 to 24 dB by the
  square root like the Compressor's meter. The level coming in runs
  along the bottom, orange above the limit. Time runs from the start
  of the sound over 1, 2, 4 or 8 s, and a longer sound rolls.
- ReductionCapture in limiterView holds the capture after 0.4 s of
  silence, without the silence, until the next sound. It writes the
  most taken away and when, and NOW or LASTED on the right.
- The Limiter is tapped now, like the Compressor. The face reads the
  loudest sample since the last frame on both sides.
- SpectrumTaps.levels takes a lateBy. The side in front is read 5 ms
  earlier, so a peak going in is met by what it became.
- limiterReduction leaves the auto-gain out and solves Wet's blend for
  the limited part. Less than 0.2 dB counts as none.
- A capture is also held when the node leaves the picture. Changing a
  knob or the lever leaves it as it is.
- Hook __gm.limiter.panel(id) for the sound FX and instrument graphs.

Decided myself:

- The capture stays held until the next sound, not for a fixed time.
- The screen has no wet knob, as the Compressor's has none.
- The Limiter's two taps cost two gains of 1 per voice. The analysers
  only exist while the node is in the picture.

Measured on stein2: the seeded blip at -4.4 dBFS against -12 dB showed
7.56 dB. Three hits at 0, -8 and -2 dBFS showed 11.9 dB at the first.
In e2e 34 on melektaus-atx, -10.44 dBFS in against -20 dB showed
9.56 dB, the difference to the hundredth.

Tests:
- Unit: limiter-view 13 tests (new), spectrum 6 (1 new), soundfx 67
  with the Limiter's taps.
- Counter-test: without Wet's share in the formula the blend test
  fails.
- e2e 34: ALL PASSED, 138 checks (1 new).
- Gate: CHECK EXIT 0, 290 files, 4445 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:30:59 +02:00

377 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The Limiter as a device (#397), what its node shows in the graph: a plate of brick, a screen of
* what it takes away from the moment a sound starts – hanging down from the top, the level coming in
* along the bottom with the limit across it – held once the sound ends, a lamp beside the name that
* lights while it limits, the auto-gain lever, the threshold big and the ceiling and release small.
*/
import { Graphics, type Text, type TextStyle } from 'pixi.js';
import { LIMIT_ROWS } from '../../core/soundfx/nodes';
import { crispText, Knob, textStyle, Widget } from '../../ui';
import { secondsText } from './chipView';
import { drawPlate, engrave, INK, Lever, placeEngraving, type PlateLook } from './CompressorPanel';
import { type Box, KNOB_CAPTION } from './hardwareView';
import {
captureTexts,
type CaptureTexts,
LIMITER_KEYS,
LIMITER_KNOBS,
LIMITER_LAYOUT,
LIMITER_PANEL,
type LimiterKey,
type LimiterValues,
levelHeight,
REDUCTION_MARKS,
ReductionCapture,
reductionDepth,
TEXT_ROW,
timeMarks,
} from './limiterView';
/** What the panel asks of the node it sits in. */
export interface LimiterHost {
preview(key: LimiterKey, value: number): void;
commit(key: LimiterKey, value: number): void;
switchAutoGain(on: boolean): void;
}
/** What the panel shows of a node. */
export interface LimiterShown {
values: LimiterValues;
wired: (key: LimiterKey) => boolean;
/** The settings that do nothing as things stand. */
idle: ReadonlySet<LimiterKey | 'autoGain'>;
autoGain: string;
}
/** Brushed brick: a limiter is a wall. */
const BRICK: PlateLook = {
stops: [
[0, 0xe6b29c],
[0.5, 0xd29680],
[1, 0xb47862],
],
rim: 0x74402e,
grain: 0x5e2c1c,
};
/** The arc of a chrome dial: its orange would vanish on brick. */
const ARC = 0x4a140a;
const BEZEL = 0x2c2220;
const BEZEL_TEXT = 0xe6cbbe;
const SCREEN = {
glass: 0x0e0909,
grid: 0x2e2422,
text: 0xffb9a0,
dim: 0x7a5a50,
reduction: 0xff4a2a,
most: 0xffffff,
level: 0x6b6460,
over: 0xff9a6a,
};
const STATUS: Record<ReductionCapture['state'] | 'dry', number> = {
waiting: BEZEL_TEXT,
live: 0xff7a5a,
held: 0xffc94a,
dry: BEZEL_TEXT,
};
const IDLE_ALPHA = 0.4;
/** The strip at the bottom of the screen with the level coming in, the row of times above it, the dB scale on the right. */
const STRIP = 30;
const TIME_ROW = 13;
const SCALE = 22;
function screenStyle(size: number, fill: number): TextStyle {
const style = textStyle('small').clone();
style.fill = fill;
style.fontFamily = 'monospace';
style.fontSize = size;
style.fontWeight = 'bold';
return style;
}
function bezelStyle(): TextStyle {
const style = textStyle('small').clone();
style.fill = BEZEL_TEXT;
style.fontSize = 9;
style.fontWeight = 'bold';
style.letterSpacing = 1;
return style;
}
/**
* The screen: what is taken away hangs down from the top, the deepest point marked; the level
* coming in rises from the bottom strip, brighter where it goes over the limit. Time runs across
* from the start of the sound.
*/
class ReductionScreen extends Widget {
private readonly glass = new Graphics();
private readonly trace = new Graphics();
private readonly marks: Text[];
private readonly times: Text[];
private readonly under: Record<'most' | 'side', Text>;
readonly capture = new ReductionCapture();
/** The level the sound is limited at, in dB. */
limit = 0;
texts: CaptureTexts = { status: '', most: '', side: '' };
constructor() {
super();
this.addChild(this.glass, this.trace);
this.marks = REDUCTION_MARKS.map((db) => this.made(`${db}`, 0, 9, SCREEN.dim));
this.times = timeMarks(1).map(() => this.made('', 0.5, 9, SCREEN.dim));
this.under = { most: this.made('', 0, 9, SCREEN.text), side: this.made('', 1, 9, SCREEN.text) };
}
private made(text: string, ax: number, size: number, fill: number): Text {
const t = crispText({ text, style: screenStyle(size, fill) });
t.anchor.set(ax, 0);
t.eventMode = 'none';
this.addChild(t);
return t;
}
private get glassH(): number {
return this.h - TEXT_ROW;
}
/** The box the reduction is drawn in, and the strip of the level coming in. */
private get areas(): { top: Box; strip: Box } {
const w = this.w - 8 - SCALE;
const h = this.glassH;
return {
top: { x: 8, y: 8, w, h: h - 8 - STRIP - TIME_ROW - 4 },
strip: { x: 8, y: h - STRIP - 4, w, h: STRIP },
};
}
override redraw(): void {
const w = this.w;
const h = this.glassH;
const { top, strip } = this.areas;
const g = this.glass.clear();
g.roundRect(0, 0, w, h, 4).fill(SCREEN.glass);
g.moveTo(top.x, top.y)
.lineTo(top.x + top.w, top.y)
.stroke({ color: SCREEN.dim, width: 1, alpha: 0.6 });
REDUCTION_MARKS.forEach((db, i) => {
const y = top.y + reductionDepth(db) * top.h;
g.moveTo(top.x, y).lineTo(top.x + top.w, y);
this.marks[i]!.position.set(Math.round(top.x + top.w + 4), Math.round(y - 6));
});
g.stroke({ color: SCREEN.grid, width: 1 });
g.rect(strip.x, strip.y, strip.w, strip.h).fill({ color: SCREEN.grid, alpha: 0.35 });
this.drawCapture();
}
/** The capture as it stands, the times along it and the texts under it. */
drawCapture(): void {
const { top, strip } = this.areas;
const s = this.trace.clear();
const capture = this.capture;
const span = capture.span;
const from = Math.max(0, capture.to - span);
const xOf = (t: number) => top.x + ((t - from) / span) * top.w;
timeMarks(span).forEach((offset, i) => {
const x = xOf(from + offset);
s.moveTo(x, top.y).lineTo(x, strip.y + strip.h);
const text = this.times[i]!;
text.text = capture.state === 'waiting' ? '' : timeText(from + offset);
text.anchor.x = i === 0 ? 0 : i === 4 ? 1 : 0.5;
text.position.set(Math.round(x), Math.round(strip.y - TIME_ROW));
});
s.stroke({ color: SCREEN.grid, width: 1, alpha: 0.7 });
const limitY = strip.y + strip.h - levelHeight(this.limit) * strip.h;
s.moveTo(strip.x, limitY)
.lineTo(strip.x + strip.w, limitY)
.stroke({ color: SCREEN.over, width: 1, alpha: 0.6 });
const points = capture.points;
points.forEach((p, i) => {
const x = xOf(p.t);
const next = points[i + 1];
// Each reading holds until the next one; the last is a frame wide.
const width = Math.max(1, (next ? xOf(next.t) : x + top.w / (span * 60)) - x);
const level = levelHeight(p.in) * strip.h;
if (level > 0) {
const over = Math.max(0, Math.min(level, strip.y + strip.h - limitY));
s.rect(x, strip.y + strip.h - level, width, level - over).fill({ color: SCREEN.level, alpha: 0.8 });
if (over > 0) s.rect(x, limitY, width, over).fill({ color: SCREEN.over, alpha: 0.9 });
}
const depth = reductionDepth(p.db) * top.h;
if (depth > 0) s.rect(x, top.y, width, depth).fill({ color: SCREEN.reduction, alpha: 0.85 });
});
const most = capture.most;
if (most) {
const y = top.y + reductionDepth(most.db) * top.h;
s.moveTo(top.x, y)
.lineTo(top.x + top.w, y)
.stroke({ color: SCREEN.most, width: 1, alpha: 0.45 });
s.circle(Math.max(top.x, xOf(most.t)), y, 3).fill(SCREEN.most);
}
this.under.most.text = this.texts.most;
this.under.most.position.set(0, this.glassH + 3);
this.under.side.text = this.texts.side;
this.under.side.position.set(this.w, this.glassH + 3);
}
}
/** A time along the screen: `0`, `250 ms`, `1.5 s`. */
const timeText = (seconds: number): string => (seconds <= 0 ? '0' : secondsText(seconds));
export class LimiterPanel extends Widget {
private readonly plate = new Graphics();
private readonly bezel = new Graphics();
private readonly lampLight = new Graphics();
readonly scope = new ReductionScreen();
readonly autoGain: Lever;
readonly knobs = {} as Record<LimiterKey, Knob>;
private readonly captions = {} as Record<LimiterKey, { ink: Text; edge: Text }>;
private readonly title: { ink: Text; edge: Text };
private readonly lampCaption: { ink: Text; edge: Text };
private readonly screenName: Text;
/** Whether the screen waits, runs or holds, above it on the right. */
readonly status: Text;
/** The settings shown idle now (checks). */
idle: ReadonlySet<LimiterKey | 'autoGain'> = new Set();
/** Whether the lamp is lit: it limits now. */
lamp = false;
constructor(host: LimiterHost) {
super();
this.minWidth = LIMITER_PANEL.w;
this.minHeight = LIMITER_PANEL.h;
this.addChild(this.plate, this.bezel, this.scope, this.lampLight);
this.title = engrave(this, 'LIMITER', 14);
this.lampCaption = engrave(this, 'LIMIT', 9);
this.screenName = crispText({ text: 'GAIN REDUCTION dB', style: bezelStyle() });
this.screenName.eventMode = 'none';
this.status = crispText({ text: '', style: bezelStyle() });
this.status.anchor.set(1, 0);
this.status.eventMode = 'none';
this.addChild(this.screenName, this.status);
this.autoGain = new Lever('AUTO-GAIN', (on) => host.switchAutoGain(on));
this.addChild(this.autoGain);
for (const key of LIMITER_KEYS) {
const row = LIMIT_ROWS[key];
const look = LIMITER_KNOBS[key];
const knob = new Knob('', row.default, {
min: row.min ?? 0,
max: row.max ?? 1,
step: row.step ?? 0.01,
taper: look.taper,
default: row.default,
decimals: look.decimals,
size: LIMITER_LAYOUT.knobs[key].size,
look: 'chrome',
typed: false,
format: look.format,
ink: INK,
arc: ARC,
onInput: (value) => host.preview(key, value),
onChange: (value) => host.commit(key, value),
});
this.knobs[key] = knob;
this.captions[key] = engrave(this, look.label.toUpperCase(), key === 'threshold' ? 11 : 9);
this.addChild(knob);
}
}
override measure(): { w: number; h: number } {
return { ...LIMITER_PANEL };
}
sync(shown: LimiterShown): void {
const v = shown.values;
for (const key of LIMITER_KEYS) {
const knob = this.knobs[key];
knob.setValue(v[key]);
knob.setEnabled(!shown.wired(key));
knob.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
this.captions[key].ink.alpha = knob.alpha;
}
this.autoGain.set(v.autoGain, shown.autoGain);
this.autoGain.alpha = shown.idle.has('autoGain') ? IDLE_ALPHA : 1;
this.idle = shown.idle;
this.scope.limit = Math.min(v.threshold, v.ceiling);
this.writeTexts();
this.scope.drawCapture();
}
/** A reading at `now` on the page's clock, null for none. Whether anything on the panel changed. */
read(now: number, reading: { in: number; db: number } | null): boolean {
if (!this.scope.capture.feed(now, reading)) return false;
this.writeTexts();
this.scope.drawCapture();
return true;
}
/** The sound is not heard here any more: what is caught so far is held. */
hold(): void {
this.scope.capture.hold();
this.writeTexts();
this.scope.drawCapture();
}
/** Whether the screen runs: a sound is being caught. */
get live(): boolean {
return this.scope.capture.state === 'live';
}
private writeTexts(): void {
const dry = this.idle.size > 0;
const texts = captureTexts(this.scope.capture, dry);
this.scope.texts = texts;
this.status.text = texts.status;
this.status.style.fill = STATUS[dry ? 'dry' : this.scope.capture.state];
const lit = !dry && this.scope.capture.now > 0;
if (lit !== this.lamp) {
this.lamp = lit;
this.drawLamp();
}
}
/** The lamp beside the name: red while it limits, dark otherwise. */
private drawLamp(): void {
const { x, y, r } = LIMITER_LAYOUT.lamp;
const g = this.lampLight.clear();
if (this.lamp) g.circle(x, y, r + 4).fill({ color: SCREEN.reduction, alpha: 0.35 });
g.circle(x, y, r)
.fill(this.lamp ? SCREEN.reduction : 0x4a1e16)
.stroke({ color: 0x2a2c30, width: 1.5 });
g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: this.lamp ? 0.7 : 0.2 });
}
override layout(): void {
const L = LIMITER_LAYOUT;
const put = (widget: Widget, box: Box) => {
widget.position.set(Math.round(box.x), Math.round(box.y));
widget.resize(box.w, box.h);
};
put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW });
this.screenName.position.set(L.screen.x, L.bezel.y + 3);
this.status.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
put(this.autoGain, L.autoGain);
placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y);
for (const key of LIMITER_KEYS) {
const spot = L.knobs[key];
const knob = this.knobs[key];
const size = knob.measure();
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
knob.resize(size.w, size.h);
placeEngraving(this.captions[key], spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
}
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4);
}
override redraw(): void {
const L = LIMITER_LAYOUT;
drawPlate(this.plate.clear(), LIMITER_PANEL, BRICK, L.screws);
const b = L.bezel;
this.bezel
.clear()
.roundRect(b.x, b.y, b.w, b.h, 8)
.fill(BEZEL)
.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8)
.stroke({ color: 0x1b120e, width: 1 });
this.drawLamp();
}
}