Show a Limiter node as a device that holds what it took away (#397)
game-maker / gate (push) Successful in 4m23s
game-maker / suite (push) Successful in 16m6s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 20:30:59 +02:00
1 parent 80b25488ce
commit 28909f0adf
13 files changed
+1106 -21

No files matched your search

+5 -4
View File
@@ -447,7 +447,7 @@ export function withTaps(before: FxPlan, after: FxPlan, id: string): FxPlan {
}
const BYPASS = { key: 'bypass', label: 'Bypass', kind: 'boolean' as const, default: false };
const WET = { ...numberRow('wet', 'Wet', 0, 1, 0.01), default: 1 };
export const WET = { ...numberRow('wet', 'Wet', 0, 1, 0.01), default: 1 };
/** Whether a node is bypassed (#354): the graph is compiled as if it were not there. */
const isBypassed = (settings: PropertyBag): boolean => settings[BYPASS.key] === true;
@@ -1060,8 +1060,8 @@ export const SoundFxCompressor: SoundFxNodeDef = effect(
const LIMIT_THRESHOLD = { ...numberRow('threshold', 'Threshold (dB)', -60, 0, 0.5), default: -6 };
const CEILING = { ...numberRow('ceiling', 'Ceiling (dB)', -30, 0, 0.1), default: -1 };
const LIMIT_RELEASE = { ...numberRow('release', 'Release (s)', 0.001, 2, 0.001), default: 0.1 };
const LIMIT_AUTO_GAIN = { ...COMPRESS_AUTO_GAIN, default: true };
const LIMIT_ROWS = {
export const LIMIT_AUTO_GAIN = { ...COMPRESS_AUTO_GAIN, default: true };
export const LIMIT_ROWS = {
threshold: LIMIT_THRESHOLD,
ceiling: CEILING,
release: LIMIT_RELEASE,
@@ -1072,8 +1072,9 @@ export const SoundFxLimiter: SoundFxNodeDef = effect(
type: 'SoundFxLimiter',
label: 'Limiter',
category: 'sound',
tapped: true,
description:
'Keeps every peak of the sound below "threshold" (dB), however loud it comes in: it looks 5 ms ahead and has the volume down by the time the peak is heard, then lets it come back up in "release" seconds. "Auto-gain" on raises the limited sound to "ceiling" (dB), so its peaks sit right at the ceiling; off they sit at the threshold. Nothing ever goes above the ceiling, and a threshold above the ceiling limits at the ceiling. It is 5 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays.' +
'Keeps every peak of the sound below "threshold" (dB), however loud it comes in: it looks 5 ms ahead and has the volume down by the time the peak is heard, then lets it come back up in "release" seconds. "Auto-gain" on raises the limited sound to "ceiling" (dB), so its peaks sit right at the ceiling; off they sit at the threshold. Nothing ever goes above the ceiling, and a threshold above the ceiling limits at the ceiling. It is 5 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays. The node is drawn as a device: a knob for each number, and a screen of how much it takes away and when, from the moment a sound starts, held once the sound ends.' +
BLENDS,
inputs: [audioIn, ...Object.values(LIMIT_ROWS).map(numberIn)],
outputs: [audioOut],
+54
View File
@@ -8,6 +8,7 @@ import { EnvelopePanel } from '../soundfx/EnvelopePanel';
import { CompressorPanel } from '../soundfx/CompressorPanel';
import { KnobPanel } from '../soundfx/KnobPanel';
import { DelayPanel } from '../soundfx/DelayPanel';
import { LimiterPanel } from '../soundfx/LimiterPanel';
import { SaturationPanel } from '../soundfx/SaturationPanel';
import { ReverbPanel } from '../soundfx/ReverbPanel';
import { CurvePanel } from '../soundfx/CurvePanel';
@@ -522,6 +523,59 @@ export function graphHooks({ state, editor }: HookContext) {
};
},
},
/**
* The panel a Limiter node shows (#397), in the sound FX graph or the instrument graph: whether it
* is shown and where, its knobs and lever, which settings are idle, the lamp, what the screen has
* caught and says, and the node's ports.
*/
limiter: {
panel: (nodeId: string) => {
const canvas = [editor.soundFxEditor.canvas, editor.instrumentEditor.canvas].find(
(c) => c.faceOf(nodeId)?.widget instanceof LimiterPanel,
);
const panel = canvas?.faceOf(nodeId)?.widget;
if (!canvas || !(panel instanceof LimiterPanel)) return null;
const node = canvas.nodeScreen(nodeId);
const ports = (keys: string[], isOutput: boolean) =>
Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)]));
const capture = panel.scope.capture;
return {
shown: shownOnScreen(panel),
rect: screenRect(panel),
size: { w: panel.w, h: panel.h },
node: node?.rect ?? null,
inputs: ports(node?.inputs ?? [], false),
outputs: ports(node?.outputs ?? [], true),
knobs: Object.fromEntries(
Object.entries(panel.knobs).map(([key, knob]) => [
key,
{ value: knob.value, number: knob.shown, enabled: knob.enabled, dial: knob.dialGlobal() },
]),
),
idle: [...panel.idle],
autoGain: {
lit: panel.autoGain.lit,
says: panel.autoGain.says.text,
at: screenCentre(panel.autoGain),
},
lamp: panel.lamp,
status: panel.status.text,
scope: {
state: capture.state,
says: panel.scope.texts,
points: capture.points.length,
span: capture.span,
/** Seconds after the sound started of the last point. */
to: capture.to,
/** The most taken away and when, null while nothing was. */
most: capture.most && { db: capture.most.db, t: capture.most.t },
/** How many points took something away, and the loudest that came in, in dB. */
limited: capture.points.filter((p) => p.db > 0).length,
loudest: capture.points.reduce((most, p) => Math.max(most, p.in), -Infinity),
},
};
},
},
instrument: {
current: () => editor.instrumentEditor.currentAssetId,
info: () => editor.instrumentEditor.shownInfo,
+105
View File
@@ -0,0 +1,105 @@
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { findGraphAsset } from '../../core/model/Project';
import { limiterLatency } from '../../core/soundfx/dynamics';
import { LIMIT_AUTO_GAIN, SoundFxLimiter, tapId } from '../../core/soundfx/nodes';
import { soundLibrary } from '../../viewport/SoundLibrary';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor';
import { LEVEL_WINDOW } from './compressorView';
import { type LimiterHost, LimiterPanel } from './LimiterPanel';
import {
idleLimiterKeys,
limiterAutoGainText,
type LimiterKey,
limiterReduction,
limiterValues,
READ_MOST,
} from './limiterView';
import { NodeParamDrag } from './nodeParamDrag';
/** The rate the sound is read at before the page has any audio. */
const SOME_RATE = 48000;
/**
* A Limiter node drawn as its device (#397). While it is in the picture it listens to its taps and
* catches what it takes away from the moment a sound starts; once the sound ends, or the node is no
* longer seen, the capture is held until the next sound.
*/
export class LimiterFace implements NodeFace, LimiterHost {
readonly widget: LimiterPanel;
private readonly drag: NodeParamDrag;
private listening = false;
private lastTick = 0;
constructor(
private readonly state: EditorState,
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new LimiterPanel(this);
this.drag = new NodeParamDrag(state, 'limiter-face');
}
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === SoundFxLimiter.type ? node : null;
}
private wired = (key: string): boolean =>
compressorWired(this.state.project, this.assetId, this.nodeId, key);
sync(): void {
const node = this.node();
if (!node) return;
const values = limiterValues(node.params);
this.widget.sync({
values,
wired: this.wired,
idle: idleLimiterKeys(values),
autoGain: limiterAutoGainText(values),
});
}
/** Reads the taps over the time since the last frame, so no peak falls between two. */
tick(): boolean {
const node = this.node();
if (!node) return false;
const id = tapId(this.assetId, this.nodeId);
soundLibrary.listen(id);
this.listening = true;
const now = performance.now() / 1000;
const since = this.lastTick ? now - this.lastTick : LEVEL_WINDOW;
this.lastTick = now;
const late = limiterLatency(soundLibrary.sampleRate() ?? SOME_RATE);
const levels = soundLibrary.nodeLevels(id, Math.max(LEVEL_WINDOW, Math.min(READ_MOST, since)), late);
const reading = levels && { in: levels.in, db: limiterReduction(levels, limiterValues(node.params)) };
return this.widget.read(now, reading) || this.widget.live;
}
hide(): void {
if (!this.listening) return;
this.listening = false;
this.lastTick = 0;
soundLibrary.unlisten(tapId(this.assetId, this.nodeId));
this.widget.hold();
}
preview(key: LimiterKey, value: number): void {
const node = this.node();
if (node) this.drag.preview(node, key, value);
}
commit(key: LimiterKey, value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, value);
}
switchAutoGain(on: boolean): void {
if (this.node())
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, LIMIT_AUTO_GAIN.key, on));
}
}
+376
View File
@@ -0,0 +1,376 @@
/**
* 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();
}
}
+270
View File
@@ -0,0 +1,270 @@
/**
* What a Limiter's node draws (#397), as numbers: where the parts of its device sit, how its knobs
* turn, what it takes away as read from its taps, and the capture of that on its screen – from the
* moment a sound starts, held once it ends, so how much and when can be read off at leisure. Kept
* apart from the drawing so it is checked without a browser.
*/
import type { PropertyBag } from '../../core/model/Properties';
import { LIMIT_AUTO_GAIN, LIMIT_ROWS, switchOn, WET } from '../../core/soundfx/nodes';
import { formatNumber } from '../../ui/math';
import type { Levels } from '../../viewport/spectrum';
import { secondsText } from './chipView';
import { LEVEL_FLOOR_DB } from './compressorView';
import { BIG_DIAL, type Box, type KnobSpot, screwsOf, SMALL_DIAL } from './hardwareView';
export type LimiterKey = keyof typeof LIMIT_ROWS;
export const LIMITER_KEYS = Object.keys(LIMIT_ROWS) as LimiterKey[];
export interface LimiterValues extends Record<LimiterKey, number> {
wet: number;
autoGain: boolean;
}
const numberOf = (params: PropertyBag, key: string, fallback: number): number => {
const value = params[key];
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
};
/** The numbers a Limiter plays with, each its row's default where none is typed in. */
export function limiterValues(params: PropertyBag): LimiterValues {
return {
threshold: numberOf(params, 'threshold', LIMIT_ROWS.threshold.default),
ceiling: numberOf(params, 'ceiling', LIMIT_ROWS.ceiling.default),
release: numberOf(params, 'release', LIMIT_ROWS.release.default),
wet: numberOf(params, WET.key, WET.default),
autoGain: switchOn(params, LIMIT_AUTO_GAIN),
};
}
/** At wet 0 the node lets the sound through as it is: nothing of how it limits is heard. */
export function idleLimiterKeys(values: LimiterValues): Set<LimiterKey | 'autoGain'> {
return values.wet > 0 ? new Set() : new Set([...LIMITER_KEYS, 'autoGain' as const]);
}
export interface LimiterKnob {
label: string;
taper: number;
decimals: number;
format: (value: number) => string;
}
const dbText = (db: number): string => `${formatNumber(db, 1)} dB`;
/** How each knob turns: the levels evenly, the release with its short times wide. */
export const LIMITER_KNOBS: Record<LimiterKey, LimiterKnob> = {
threshold: { label: 'Threshold', taper: 1, decimals: 1, format: dbText },
ceiling: { label: 'Ceiling', taper: 1, decimals: 1, format: dbText },
release: { label: 'Release', taper: 3, decimals: 3, format: secondsText },
};
/** The gain after the limiting in dB: auto-gain raises the limit to the ceiling. */
export function limiterGainDb(values: LimiterValues): number {
return values.autoGain ? values.ceiling - Math.min(values.threshold, values.ceiling) : 0;
}
/** What the auto-gain lever says it gives. */
export function limiterAutoGainText(values: LimiterValues): string {
return values.autoGain ? `+${formatNumber(limiterGainDb(values), 1)} dB` : 'off';
}
// --- The plate. ---
/** The plate: as the Saturation's (#396), the panel never takes another size. */
export const LIMITER_PANEL = { w: 720, h: 280 };
/** A lever's box, as the Compressor's auto-gain (#379). */
const LEVER = { w: 118, h: 40 };
/** The extra the strip of text under the screen takes. */
export const TEXT_ROW = 14;
export interface LimiterLayout {
title: Box;
/** The lamp that lights while it limits, beside the device's name; its own name after it. */
lamp: { x: number; y: number; r: number };
lampCaption: { x: number; y: number };
autoGain: Box;
bezel: Box;
screen: Box;
knobs: Record<LimiterKey, KnobSpot>;
screws: { x: number; y: number }[];
}
function limiterLayout(): LimiterLayout {
const { w } = LIMITER_PANEL;
// The screen wide, for the time it shows; the knobs as the Compressor's: one big, two small beside it.
const screen = { x: 34, y: 60, w: 380, h: 176 };
const bezel = { x: 24, y: 44, w: screen.w + 20, h: screen.h + 16 + TEXT_ROW + 6 };
const right = w - 24;
const threshold = { x: bezel.x + bezel.w + 24 + BIG_DIAL / 2, y: 160, size: BIG_DIAL };
const column = (threshold.x + BIG_DIAL / 2 + right) / 2;
return {
title: { x: 34, y: 14, w: 100, h: 24 },
lamp: { x: 146, y: 26, r: 6 },
lampCaption: { x: 174, y: 21 },
autoGain: { x: right - LEVER.w, y: 10, ...LEVER },
bezel,
screen,
knobs: {
threshold,
ceiling: { x: column, y: 105, size: SMALL_DIAL },
release: { x: column, y: 205, size: SMALL_DIAL },
},
screws: screwsOf(LIMITER_PANEL),
};
}
export const LIMITER_LAYOUT: LimiterLayout = limiterLayout();
// --- What it takes away, as read from its taps. ---
/** Less taken away than this counts as none: the two sides are read a render quantum apart at worst. */
export const REDUCTION_FLOOR_DB = 0.2;
/**
* What the Limiter takes away now, in dB, from the loudest sample in front of it – read as late as
* the Limiter is – and behind it. Behind it the sound has its auto-gain on top, and Wet blends the
* limited sound with what came in: `out / in = wet * gain * auto + (1 - wet)`, solved for the gain.
*/
export function limiterReduction(levels: Levels | null, values: LimiterValues): number {
if (!levels || !(levels.in > LEVEL_FLOOR_DB) || !Number.isFinite(levels.out)) return 0;
const wet = Math.max(0, Math.min(1, values.wet));
if (wet <= 0) return 0;
const ratio = 10 ** ((levels.out - levels.in) / 20);
const gain = (ratio - (1 - wet)) / (wet * 10 ** (limiterGainDb(values) / 20));
const db = gain > 0 ? -20 * Math.log10(gain) : Infinity;
return db >= REDUCTION_FLOOR_DB ? Math.min(db, REDUCTION_RANGE_DB) : 0;
}
// --- The screen: the reduction hangs down from the top, the level coming in sits at the bottom. ---
/** The most the screen shows taken away; the root spreads the first dB as the Compressor's meter does. */
export const REDUCTION_RANGE_DB = 24;
export const REDUCTION_MARKS = [1, 3, 6, 12, 24];
/** How far down the reduction reaches, 0 at the top to 1 at the range. */
export const reductionDepth = (db: number): number =>
Math.sqrt(Math.max(0, Math.min(REDUCTION_RANGE_DB, db)) / REDUCTION_RANGE_DB);
/** How high the level coming in reaches in its strip, 0 at the floor to 1 at full scale. */
export const levelHeight = (db: number): number =>
Number.isFinite(db) ? Math.max(0, Math.min(1, (db - LEVEL_FLOOR_DB) / -LEVEL_FLOOR_DB)) : 0;
/** The time spans the screen grows through as a sound goes on; past the last it rolls. */
export const CAPTURE_SPANS = [1, 2, 4, 8];
/** The span a capture of `elapsed` seconds is shown in: the shortest it fits in. */
export function captureSpan(elapsed: number): number {
return CAPTURE_SPANS.find((span) => elapsed <= span) ?? CAPTURE_SPANS.at(-1)!;
}
/** The marks along the time, in seconds from the start of what is shown: four to a span. */
export function timeMarks(span: number): number[] {
return [0, 1, 2, 3, 4].map((i) => (i * span) / 4);
}
/** Silence this long ends a capture, and it is held. */
export const QUIET_SECONDS = 0.4;
/** The longest a single reading may look back: the analyser holds about 170 ms. */
export const READ_MOST = 0.15;
export interface CapturePoint {
/** Seconds after the sound started. */
t: number;
/** What was taken away, in dB. */
db: number;
/** The loudest sample coming in, in dB. */
in: number;
}
export type CaptureState = 'waiting' | 'live' | 'held';
/**
* The reduction from the moment a sound starts (#397): a point every frame while it plays, held
* once it has been quiet for `QUIET_SECONDS`, until the next sound starts a new one. A sound longer
* than the last span rolls: only that span of it is kept.
*/
export class ReductionCapture {
points: CapturePoint[] = [];
state: CaptureState = 'waiting';
private start = 0;
private heard = 0;
/** A reading at `now` on the page's clock, null for none. Whether the capture changed. */
feed(now: number, reading: { in: number; db: number } | null): boolean {
const sounds = reading !== null && reading.in > LEVEL_FLOOR_DB;
if (this.state !== 'live') {
if (!sounds) return false;
this.points = [];
this.start = now;
this.state = 'live';
}
if (sounds) this.heard = now;
const t = now - this.start;
this.points.push({ t, db: sounds ? reading.db : 0, in: sounds ? reading.in : -Infinity });
const longest = CAPTURE_SPANS.at(-1)!;
if (t > longest) {
const from = this.points.findIndex((p) => p.t >= t - longest);
if (from > 0) this.points.splice(0, from);
}
if (now - this.heard > QUIET_SECONDS) this.hold();
return true;
}
/** What has been caught so far is held; the trail of silence after the sound is dropped. */
hold(): void {
if (this.state !== 'live') return;
const end = this.heard - this.start;
this.points = this.points.filter((p) => p.t <= end + 1e-9);
this.state = this.points.length > 0 ? 'held' : 'waiting';
}
/** The seconds from the first point kept to the last. */
get from(): number {
return this.points[0]?.t ?? 0;
}
get to(): number {
return this.points.at(-1)?.t ?? 0;
}
get span(): number {
return captureSpan(this.to - this.from);
}
/** The most taken away and when, null while nothing was. */
get most(): CapturePoint | null {
let most: CapturePoint | null = null;
for (const p of this.points) if (p.db > 0 && (!most || p.db > most.db)) most = p;
return most;
}
/** What is taken away now; nothing unless live. */
get now(): number {
return this.state === 'live' ? (this.points.at(-1)?.db ?? 0) : 0;
}
}
/**
* What the screen writes: its state above it; under it the most taken away and when, and on the
* right what is taken away now while live, how long the sound was once held.
*/
export interface CaptureTexts {
status: string;
most: string;
side: string;
}
const reductionText = (db: number): string => (db > 0 ? `-${formatNumber(db, 1)} dB` : '0 dB');
export function captureTexts(capture: ReductionCapture, idle: boolean): CaptureTexts {
if (idle) return { status: 'DRY', most: '', side: '' };
const status = { waiting: 'WAITING FOR SOUND', live: 'LIVE', held: 'HELD' }[capture.state];
if (capture.state === 'waiting') return { status, most: '', side: '' };
const most = capture.most;
return {
status,
most: most ? `MOST ${reductionText(most.db)} AT ${secondsText(most.t)}` : 'NOTHING LIMITED',
side:
capture.state === 'live' ? `NOW ${reductionText(capture.now)}` : `LASTED ${secondsText(capture.to)}`,
};
}
+4 -1
View File
@@ -4,6 +4,7 @@ import {
SoundFxCompressor,
SoundFxDelay,
SoundFxFilter,
SoundFxLimiter,
SoundFxParametricEq,
SoundFxReverb,
SoundFxSaturation,
@@ -19,10 +20,11 @@ import { EqPanel } from './EqPanel';
import { FilterPanel } from './FilterPanel';
import { KnobFace } from './KnobFace';
import { GAIN_DEVICE, MIX_DEVICE, PAN_DEVICE, PITCH_DEVICE } from './KnobPanel';
import { LimiterFace } from './LimiterFace';
import { ReverbFace } from './ReverbFace';
import { SaturationFace } from './SaturationFace';
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392, #393, #396), by type. */
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392, #393, #396, #397), by type. */
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
if (node.type === InstrumentChip.type) return new ChipFace(state, assetId, node.id);
if (node.type === InstrumentEnvelope.type) return new EnvelopeFace(state, assetId, node.id);
@@ -37,5 +39,6 @@ export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode
if (node.type === SoundFxReverb.type) return new ReverbFace(state, assetId, node.id);
if (node.type === SoundFxDelay.type) return new DelayFace(state, assetId, node.id);
if (node.type === SoundFxSaturation.type) return new SaturationFace(state, assetId, node.id);
if (node.type === SoundFxLimiter.type) return new LimiterFace(state, assetId, node.id);
return null;
}
+7 -2
View File
@@ -636,8 +636,13 @@ export class SoundLibrary implements AudioSink {
return this.spectra.read(id);
}
nodeLevels(id: string, seconds: number): Levels | null {
return this.spectra.levels(id, seconds);
nodeLevels(id: string, seconds: number, lateBy = 0): Levels | null {
return this.spectra.levels(id, seconds, lateBy);
}
/** The rate the page's audio runs at, once there is any; null before. */
sampleRate(): number | null {
return this.context?.sampleRate ?? null;
}
/** The node listened to, the taps playing, and how many of them feed an analyser (test hook). */
+11 -10
View File
@@ -106,23 +106,24 @@ export class SpectrumTaps {
}
/**
* The loudest sample on both sides of the node `id` over the last `seconds` (#378). Null before
* anything has played through it, or while it is not listened to.
* The loudest sample on both sides of the node `id` over the last `seconds` (#378), the side in
* front `lateBy` seconds earlier (#397): what came in then is what goes out of a node as late as
* that now. Null before anything has played through it, or while it is not listened to.
*/
levels(id: string, seconds: number): Levels | null {
levels(id: string, seconds: number, lateBy = 0): Levels | null {
const sides = this.listened.get(id);
if (!sides) return null;
const count = Math.max(
1,
Math.min(SPECTRUM_SIZE, Math.round(seconds * sides.in.analyser.context.sampleRate)),
);
const peakOf = ({ analyser, wave }: Analysis) => {
const rate = sides.in.analyser.context.sampleRate;
const late = Math.max(0, Math.min(SPECTRUM_SIZE - 1, Math.round(lateBy * rate)));
const count = Math.max(1, Math.min(SPECTRUM_SIZE - late, Math.round(seconds * rate)));
const peakOf = ({ analyser, wave }: Analysis, back: number) => {
analyser.getFloatTimeDomainData(wave);
let peak = 0;
for (let i = wave.length - count; i < wave.length; i++) peak = Math.max(peak, Math.abs(wave[i]!));
for (let i = wave.length - back - count; i < wave.length - back; i++)
peak = Math.max(peak, Math.abs(wave[i]!));
return peak > 0 ? 20 * Math.log10(peak) : -Infinity;
};
return { in: peakOf(sides.in), out: peakOf(sides.out) };
return { in: peakOf(sides.in, late), out: peakOf(sides.out, 0) };
}
/** The nodes listened to, the taps playing and how many of them feed an analyser (test hook). */