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>
This commit is contained in:
1 parent
80b25488ce
commit
28909f0adf
13 files changed
+1106
-21
No files matched your search
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1969 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1970 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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
@@ -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). */
|
||||
|
||||
@@ -459,13 +459,13 @@ describe('a Compressor and a Limiter in a sound FX graph (#326)', () => {
|
||||
(s) => s.kind !== 'tap',
|
||||
);
|
||||
|
||||
it('tap a Compressor on both sides for its tab, a Limiter not (#378)', () => {
|
||||
it('tap a Compressor on both sides for its tab (#378), a Limiter for its screen (#397)', () => {
|
||||
const kinds = (type: string) =>
|
||||
fxChainOf(compileSoundFx(line([{ type }]).fx.graph).compiled!).stages.map((s) =>
|
||||
s.kind === 'tap' ? `tap ${s.at}` : s.kind,
|
||||
);
|
||||
expect(kinds('SoundFxCompressor')).toEqual(['tap in', 'compress', 'tap out']);
|
||||
expect(kinds('SoundFxLimiter')).toEqual(['limit']);
|
||||
expect(kinds('SoundFxLimiter')).toEqual(['tap in', 'limit', 'tap out']);
|
||||
});
|
||||
|
||||
it('compress at their defaults, the gain after it worked out from makeup and auto-gain', () => {
|
||||
@@ -729,7 +729,9 @@ describe('Bypass and Wet (#354)', () => {
|
||||
});
|
||||
|
||||
it('a blend is as late as what it blends and rings as long', () => {
|
||||
const [limit] = fxChainOf({ pitch: [], stages: stagesOf('SoundFxLimiter', { wet: 0.5 }) }).stages;
|
||||
const [limit] = fxChainOf({ pitch: [], stages: stagesOf('SoundFxLimiter', { wet: 0.5 }) }).stages.filter(
|
||||
(s) => s.kind !== 'tap',
|
||||
);
|
||||
expect(limit).toMatchObject({ kind: 'wet' });
|
||||
expect(stageLatency(limit!, 48000)).toBe(0.005);
|
||||
expect(fxRings({ pitch: [], stages: stagesOf('SoundFxLimiter', { wet: 0.5 }) })).toBe(true);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* A Limiter's node as a device (#397): every part on the plate and none over another, what it takes
|
||||
* away as read from its taps, and the capture of that from the moment a sound starts, held once the
|
||||
* sound ends.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView';
|
||||
import {
|
||||
captureSpan,
|
||||
captureTexts,
|
||||
idleLimiterKeys,
|
||||
LIMITER_KEYS,
|
||||
LIMITER_LAYOUT,
|
||||
LIMITER_PANEL,
|
||||
limiterAutoGainText,
|
||||
limiterGainDb,
|
||||
limiterReduction,
|
||||
type LimiterValues,
|
||||
limiterValues,
|
||||
QUIET_SECONDS,
|
||||
ReductionCapture,
|
||||
reductionDepth,
|
||||
timeMarks,
|
||||
} from '../../src/editor/soundfx/limiterView';
|
||||
|
||||
const overlap = (a: Box, b: Box) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
||||
const inside = (a: Box) =>
|
||||
a.x >= 0 && a.y >= 0 && a.x + a.w <= LIMITER_PANEL.w && a.y + a.h <= LIMITER_PANEL.h;
|
||||
|
||||
const values = (over: Partial<LimiterValues> = {}): LimiterValues => ({ ...limiterValues({}), ...over });
|
||||
const db = (gain: number) => 20 * Math.log10(gain);
|
||||
|
||||
describe('the Limiter plate', () => {
|
||||
const L = LIMITER_LAYOUT;
|
||||
const screws = L.screws.map((s) => ({ x: s.x - 7, y: s.y - 7, w: 14, h: 14 }));
|
||||
// A lever's ball hangs 8 below its box when it is off.
|
||||
const lever = { ...L.autoGain, h: L.autoGain.h + 8 };
|
||||
const lamp = { x: L.lamp.x - L.lamp.r, y: L.lamp.y - L.lamp.r, w: 2 * L.lamp.r, h: 2 * L.lamp.r };
|
||||
|
||||
it('keeps the screen, the lever, the lamp and the knobs on the plate, clear of each other and the screws', () => {
|
||||
const parts: Box[] = [L.bezel, lever, ...LIMITER_KEYS.map((key) => knobBox(L.knobs[key])), lamp, L.title];
|
||||
for (const part of parts) {
|
||||
expect(inside(part)).toBe(true);
|
||||
for (const screw of screws) expect(overlap(part, screw)).toBe(false);
|
||||
}
|
||||
for (let i = 0; i < parts.length; i++)
|
||||
for (let j = i + 1; j < parts.length; j++) expect(overlap(parts[i]!, parts[j]!)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Limiter numbers', () => {
|
||||
it('are the defaults of its rows where none is typed in, and idle at wet 0', () => {
|
||||
expect(values()).toEqual({ threshold: -6, ceiling: -1, release: 0.1, wet: 1, autoGain: true });
|
||||
expect(limiterValues({ threshold: -12, wet: 0.5, autoGain: false })).toMatchObject({
|
||||
threshold: -12,
|
||||
wet: 0.5,
|
||||
autoGain: false,
|
||||
});
|
||||
expect([...idleLimiterKeys(values())]).toEqual([]);
|
||||
expect([...idleLimiterKeys(values({ wet: 0 }))]).toEqual(['threshold', 'ceiling', 'release', 'autoGain']);
|
||||
});
|
||||
|
||||
it('raise the limit to the ceiling with auto-gain, and nothing where the threshold is above it', () => {
|
||||
expect(limiterGainDb(values())).toBe(5);
|
||||
expect(limiterAutoGainText(values())).toBe('+5 dB');
|
||||
expect(limiterGainDb(values({ threshold: 0, ceiling: -3 }))).toBe(0);
|
||||
expect(limiterGainDb(values({ autoGain: false }))).toBe(0);
|
||||
expect(limiterAutoGainText(values({ autoGain: false }))).toBe('off');
|
||||
});
|
||||
});
|
||||
|
||||
describe('what the Limiter takes away', () => {
|
||||
it('is the level in less the level out without the auto-gain after it', () => {
|
||||
// Full scale in, limited to -6 and raised to -1.
|
||||
expect(limiterReduction({ in: 0, out: -1 }, values())).toBeCloseTo(6, 9);
|
||||
expect(limiterReduction({ in: 0, out: -6 }, values({ autoGain: false }))).toBeCloseTo(6, 9);
|
||||
// Under the threshold it only gets the auto-gain.
|
||||
expect(limiterReduction({ in: -20, out: -15 }, values())).toBe(0);
|
||||
});
|
||||
|
||||
it('solves the blend for the limited sound when Wet is under 1', () => {
|
||||
// Half the sound limited to half, half as it came: 0.75 comes out.
|
||||
const reading = { in: 0, out: db(0.75) };
|
||||
expect(limiterReduction(reading, values({ wet: 0.5, autoGain: false }))).toBeCloseTo(-db(0.5), 9);
|
||||
expect(limiterReduction(reading, values({ wet: 0, autoGain: false }))).toBe(0);
|
||||
});
|
||||
|
||||
it('is nothing for silence, nothing read, or less than the two sides can be told apart by', () => {
|
||||
expect(limiterReduction(null, values())).toBe(0);
|
||||
expect(limiterReduction({ in: -Infinity, out: -Infinity }, values())).toBe(0);
|
||||
expect(limiterReduction({ in: -70, out: -90 }, values())).toBe(0);
|
||||
expect(limiterReduction({ in: -3, out: -3.1 }, values({ autoGain: false }))).toBe(0);
|
||||
expect(limiterReduction({ in: 6, out: -60 }, values({ autoGain: false }))).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the screen of the Limiter', () => {
|
||||
it('hangs the reduction down by its root, as the Compressor’s meter reads', () => {
|
||||
expect(reductionDepth(0)).toBe(0);
|
||||
expect(reductionDepth(6)).toBeCloseTo(0.5, 9);
|
||||
expect(reductionDepth(24)).toBe(1);
|
||||
expect(reductionDepth(40)).toBe(1);
|
||||
});
|
||||
|
||||
it('grows its time through 1, 2, 4 and 8 seconds as the sound goes on, four marks to a span', () => {
|
||||
expect([0, 0.3, 1, 1.5, 3, 7, 20].map(captureSpan)).toEqual([1, 1, 1, 2, 4, 8, 8]);
|
||||
expect(timeMarks(2)).toEqual([0, 0.5, 1, 1.5, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the capture of the Limiter', () => {
|
||||
const loud = { in: -2, db: 4 };
|
||||
const soft = { in: -20, db: 0 };
|
||||
const quiet = { in: -Infinity, db: 0 };
|
||||
|
||||
/** A reading every 20 ms from `at` on, one for each of `readings`. Gives the time after the last. */
|
||||
const play = (capture: ReductionCapture, at: number, readings: (typeof loud | null)[]) => {
|
||||
readings.forEach((r, i) => capture.feed(at + i * 0.02, r));
|
||||
return at + readings.length * 0.02;
|
||||
};
|
||||
|
||||
it('waits for a sound, then catches a point a frame from the moment it starts', () => {
|
||||
const capture = new ReductionCapture();
|
||||
expect(capture.feed(1, null)).toBe(false);
|
||||
expect(capture.feed(1.02, quiet)).toBe(false);
|
||||
expect(capture.state).toBe('waiting');
|
||||
play(capture, 2, [soft, loud, { in: -1, db: 6 }, soft]);
|
||||
expect(capture.state).toBe('live');
|
||||
expect(capture.points.map((p) => +p.t.toFixed(3))).toEqual([0, 0.02, 0.04, 0.06]);
|
||||
expect(capture.most).toMatchObject({ db: 6 });
|
||||
expect(capture.most!.t).toBeCloseTo(0.04, 9);
|
||||
expect(capture.now).toBe(0);
|
||||
});
|
||||
|
||||
it('holds what it caught once the sound has been quiet a while, without the silence after it', () => {
|
||||
const capture = new ReductionCapture();
|
||||
const after = play(capture, 0, [loud, loud, soft]);
|
||||
const silence = Math.ceil(QUIET_SECONDS / 0.02);
|
||||
play(capture, after, new Array<typeof quiet>(silence - 1).fill(quiet));
|
||||
expect(capture.state).toBe('live');
|
||||
play(capture, after + (silence - 1) * 0.02, [quiet, quiet]);
|
||||
expect(capture.state).toBe('held');
|
||||
expect(capture.points).toHaveLength(3);
|
||||
expect(capture.to).toBeCloseTo(0.04, 9);
|
||||
// Held, it stays as it is whatever is read, until the next sound.
|
||||
expect(capture.feed(5, quiet)).toBe(false);
|
||||
expect(capture.feed(5.02, null)).toBe(false);
|
||||
expect(capture.points).toHaveLength(3);
|
||||
capture.feed(6, soft);
|
||||
expect([capture.state, capture.points.length, capture.most]).toEqual(['live', 1, null]);
|
||||
});
|
||||
|
||||
it('is held at once when its node is no longer seen', () => {
|
||||
const capture = new ReductionCapture();
|
||||
play(capture, 0, [loud, loud]);
|
||||
capture.hold();
|
||||
expect(capture.state).toBe('held');
|
||||
expect(capture.points).toHaveLength(2);
|
||||
const empty = new ReductionCapture();
|
||||
empty.hold();
|
||||
expect(empty.state).toBe('waiting');
|
||||
});
|
||||
|
||||
it('keeps the last 8 seconds of a longer sound, the times still from its start', () => {
|
||||
const capture = new ReductionCapture();
|
||||
play(capture, 0, new Array<typeof loud>(600).fill(loud));
|
||||
expect(capture.to).toBeCloseTo(11.98, 6);
|
||||
expect(capture.from).toBeGreaterThanOrEqual(capture.to - 8 - 1e-9);
|
||||
expect(capture.from).toBeLessThan(capture.to - 7.9);
|
||||
expect(capture.span).toBe(8);
|
||||
});
|
||||
|
||||
it('writes its state, the most taken away and when, and what it takes now or how long the sound lasted', () => {
|
||||
const capture = new ReductionCapture();
|
||||
expect(captureTexts(capture, false)).toEqual({ status: 'WAITING FOR SOUND', most: '', side: '' });
|
||||
play(capture, 0, [soft, loud]);
|
||||
expect(captureTexts(capture, false)).toEqual({
|
||||
status: 'LIVE',
|
||||
most: 'MOST -4 dB AT 20 ms',
|
||||
side: 'NOW -4 dB',
|
||||
});
|
||||
capture.hold();
|
||||
expect(captureTexts(capture, false)).toEqual({
|
||||
status: 'HELD',
|
||||
most: 'MOST -4 dB AT 20 ms',
|
||||
side: 'LASTED 20 ms',
|
||||
});
|
||||
const clean = new ReductionCapture();
|
||||
play(clean, 0, [soft, soft]);
|
||||
expect(captureTexts(clean, false)).toMatchObject({ most: 'NOTHING LIMITED', side: 'NOW 0 dB' });
|
||||
expect(captureTexts(capture, true)).toEqual({ status: 'DRY', most: '', side: '' });
|
||||
});
|
||||
});
|
||||
@@ -130,4 +130,20 @@ describe('the taps of the spectrum', () => {
|
||||
expect(levels?.out).toBe(-Infinity);
|
||||
expect(taps.levels('fx:comp', 1)?.in).toBeCloseTo(0, 9);
|
||||
});
|
||||
|
||||
it('read the side in front as late as the node is, so a peak is met by what it became (#397)', () => {
|
||||
const context = new FakeContext();
|
||||
const taps = new SpectrumTaps();
|
||||
taps.listen('fx:lim');
|
||||
taps.hold(tap('fx:lim', 'in'), new FakeNode(context) as never);
|
||||
const [, analyserIn, , analyserOut] = context.made;
|
||||
// A peak of 1 that came in 5 ms (240 samples) before the newest sample, and the sound at 0.125 since.
|
||||
analyserIn!.wave = [1, ...new Array<number>(240).fill(0.125)];
|
||||
analyserOut!.wave = [0.5];
|
||||
const window = 1 / 480;
|
||||
expect(taps.levels('fx:lim', window)?.in).toBeCloseTo(20 * Math.log10(0.125), 9);
|
||||
const late = taps.levels('fx:lim', window, 0.005);
|
||||
expect(late?.in).toBeCloseTo(0, 9);
|
||||
expect(late?.out).toBeCloseTo(20 * Math.log10(0.5), 9);
|
||||
});
|
||||
});
|
||||
@@ -1495,6 +1495,65 @@ export async function sound(t) {
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-st');`);
|
||||
// #397: a Limiter is a device of its own size whose screen catches what it takes away from the
|
||||
// moment a sound starts and holds it once the sound ends. Wired in before the output, the
|
||||
// most it caught is the loudest that came in over its limit. The lever and the threshold
|
||||
// knob are one undo step each and leave the capture held.
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||
graph.nodes.push(${JSON.stringify(node('fx-lm', 'SoundFxLimiter', 0, 400, { threshold: -20 }))});
|
||||
const last = graph.edges.find((e) => e.to.node === 'fx-out' && e.to.port === 'in');
|
||||
graph.edges.push({ id: 'fx-e-lm', from: { node: 'fx-lm', port: 'out' }, to: { node: 'fx-out', port: 'in' } });
|
||||
last.to = { node: 'fx-lm', port: 'in' };`);
|
||||
await evaluate('__gm.soundfx.fit()');
|
||||
await sleep(100);
|
||||
const lmPanel = () => json("__gm.limiter.panel('fx-lm')");
|
||||
const lmShown = await lmPanel();
|
||||
await evaluate('__gm.sound.stopAll()');
|
||||
await evaluate('__gm.soundfx.play()');
|
||||
const lmHeld = await waitFor(lmPanel, (p) => p.scope.state === 'held', 40);
|
||||
await click(lmShown.autoGain.at.x, lmShown.autoGain.at.y);
|
||||
const lmOff = { params: await knobParams('fx-lm'), panel: await lmPanel() };
|
||||
const lmDial = lmShown.knobs.threshold.dial;
|
||||
await drag(lmDial.x, lmDial.y, lmDial.x, lmDial.y - 20);
|
||||
const lmTurned = (await knobParams('fx-lm')).threshold;
|
||||
for (let i = 0; i < 2; i++) await press('z', { ctrl: true });
|
||||
const lmUndone = { params: await knobParams('fx-lm'), panel: await lmPanel() };
|
||||
check(
|
||||
'a Limiter node holds what it took away once the sound ends, the most of it the loudest over its limit; its lever and threshold are one undo step each',
|
||||
lmShown.size.w === 720 &&
|
||||
lmShown.size.h === 280 &&
|
||||
Object.values(lmShown.inputs).length === 5 &&
|
||||
Object.values(lmShown.inputs).every((p) => p.x < lmShown.rect.x) &&
|
||||
Object.values(lmShown.outputs).every((p) => p.x > lmShown.rect.x + lmShown.rect.w) &&
|
||||
lmShown.knobs.threshold.number === '-20 dB' &&
|
||||
lmShown.autoGain.lit &&
|
||||
lmShown.autoGain.says === '+19 dB' &&
|
||||
lmShown.status === 'WAITING FOR SOUND' &&
|
||||
lmHeld.status === 'HELD' &&
|
||||
lmHeld.scope.limited > 0 &&
|
||||
lmHeld.scope.most !== null &&
|
||||
lmHeld.scope.loudest > -20 &&
|
||||
Math.abs(lmHeld.scope.most.db - (lmHeld.scope.loudest + 20)) < 1 &&
|
||||
/^MOST -\d+(\.\d)? dB AT \d+ ms$/.test(lmHeld.scope.says.most) &&
|
||||
lmHeld.scope.says.side.startsWith('LASTED ') &&
|
||||
!lmHeld.lamp &&
|
||||
lmOff.params.autoGain === false &&
|
||||
lmOff.panel.autoGain.says === 'off' &&
|
||||
lmOff.panel.scope.state === 'held' &&
|
||||
lmTurned > -20 &&
|
||||
lmUndone.params.threshold === -20 &&
|
||||
lmUndone.params.autoGain !== false &&
|
||||
lmUndone.panel.scope.state === 'held' &&
|
||||
lmUndone.panel.scope.most?.db === lmHeld.scope.most.db,
|
||||
JSON.stringify({ lmShown, lmHeld, lmOff, lmTurned, lmUndone }),
|
||||
);
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||
const into = graph.edges.find((e) => e.to.node === 'fx-lm' && e.to.port === 'in');
|
||||
into.to = { node: 'fx-out', port: 'in' };
|
||||
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e-lm');
|
||||
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-lm');`);
|
||||
await showTab('scene');
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||||
|
||||
Reference in new issue
Block a user