diff --git a/README.md b/README.md index afb45c1d..32fcf750 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index 455c30d9..e8549ab5 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -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], diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 9e10b049..adac4223 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -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, diff --git a/src/editor/soundfx/LimiterFace.ts b/src/editor/soundfx/LimiterFace.ts new file mode 100644 index 00000000..e0c997da --- /dev/null +++ b/src/editor/soundfx/LimiterFace.ts @@ -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)); + } +} diff --git a/src/editor/soundfx/LimiterPanel.ts b/src/editor/soundfx/LimiterPanel.ts new file mode 100644 index 00000000..35ecf909 --- /dev/null +++ b/src/editor/soundfx/LimiterPanel.ts @@ -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; + 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 = { + 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; + private readonly captions = {} as Record; + 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 = 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(); + } +} diff --git a/src/editor/soundfx/limiterView.ts b/src/editor/soundfx/limiterView.ts new file mode 100644 index 00000000..bc19c09d --- /dev/null +++ b/src/editor/soundfx/limiterView.ts @@ -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 { + 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 { + 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 = { + 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; + 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)}`, + }; +} diff --git a/src/editor/soundfx/nodeFaces.ts b/src/editor/soundfx/nodeFaces.ts index 5207eb05..6fb4a36c 100644 --- a/src/editor/soundfx/nodeFaces.ts +++ b/src/editor/soundfx/nodeFaces.ts @@ -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; } diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index a8baabfe..bfcb2e07 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -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). */ diff --git a/src/viewport/spectrum.ts b/src/viewport/spectrum.ts index 351b775d..7ac5d4bf 100644 --- a/src/viewport/spectrum.ts +++ b/src/viewport/spectrum.ts @@ -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). */ diff --git a/tests/core/soundfx.test.ts b/tests/core/soundfx.test.ts index d8f0ec79..04e15fbf 100644 --- a/tests/core/soundfx.test.ts +++ b/tests/core/soundfx.test.ts @@ -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); diff --git a/tests/editor/limiter-view.test.ts b/tests/editor/limiter-view.test.ts new file mode 100644 index 00000000..8c31d3b2 --- /dev/null +++ b/tests/editor/limiter-view.test.ts @@ -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({}), ...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(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(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: '' }); + }); +}); diff --git a/tests/viewport/spectrum.test.ts b/tests/viewport/spectrum.test.ts index 3bbeb672..5e796392 100644 --- a/tests/viewport/spectrum.test.ts +++ b/tests/viewport/spectrum.test.ts @@ -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(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); + }); }); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 42770148..04f1f277 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -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;