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>
377 lines
13 KiB
TypeScript
377 lines
13 KiB
TypeScript
/**
|
||
* 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();
|
||
}
|
||
}
|