From 2d4909d24879664be73b1cbb024db90d4cacaeb5 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 10 Oct 2026 00:57:26 +0200 Subject: [PATCH] Fit the Compressor's tab to any size, make auto-gain a switch (#378) Two things came up on the new tab. Floated and sized down, it spilled its knobs out of its window, and sized up it left the window empty. Auto-gain lifted far too much: it brought 0 dB back to 0 dB, which at -40 dB, 4:1 and knee 6 is +28.1 dB, so a sound at -24 dB came out at -6 dB. And as a knob from 0 to 1 it blended between makeup and auto-gain, so at 1 the makeup knob did nothing. Layout: - The knobs stay on the bottom line, centred, with the help line above them. - The levels fill the height left above. The curve is a square at their right, centred up and down. It grows with the tab from 180 to 360 px and takes no more than 40 % of its width (picturesLayout). - A floated window cannot be sized below what its pane needs. A pane says so in its minWidth and minHeight, and the Compressor's is every knob and the smallest pictures. Other panes keep 320 x 200. - A pane put into an open window that is too small grows the window. Auto-gain, as decided in #378: - A Compressor's auto-gain gives back what it takes from a sound at -18 dB, so that one comes out as loud as it went in. At the numbers above that is +14.6 dB. - Auto-gain is a switch on the Compressor, the Limiter and the Saturation alike, a setting and no longer an input. The makeup or output is added on top of it instead of being blended with it. - A project from before reads a number as on above 0. Nodes never set keep their defaults: off on the Compressor, on on the other two. - The tab shows the switch at the right of the knobs, with the dB it gives below it. With it on, the curve has a line at -18 dB from the curve up to the diagonal. - Every level reading keeps the gain of its time. Before, the whole picture was read by the current makeup, so after a change the tinting of the last seconds was wrong or missing. Findings: - My first e2e run of the layout clicked Dock where it was before the window was sized up, so the window stayed open and the next check went red. The check reads the button again. Tests: - Gate (melektaus-atx): 282 files, 4344 tests (6 new) - e2e 34 (melektaus-atx): ALL PASSED, 118 checks (2 new) - Offline in Chrome: a sine at -18 dB through -24 dB and 4:1 came out at -17.92 dB with auto-gain on and -22.42 dB with it off. - Counter-checks: the curve set at the top instead of the middle, and every reading read at a gain of 0, each fail exactly their test. - Looked at the window in screenshots: at its least every knob and the switch show. At 1280 x 797 the levels fill the height, the curve is centred at the right and the knobs are at the bottom. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/soundfx/dynamics.ts | 32 ++++--- src/core/soundfx/nodes.ts | 69 ++++++++------- src/editor/FloatWindow.ts | 15 +++- src/editor/debug/graphHooks.ts | 11 +++ src/editor/debugHook.ts | 3 + src/editor/floating.ts | 26 ++++-- src/editor/soundfx/CompressorEditor.ts | 111 +++++++++++++++++++++---- src/editor/soundfx/compressorView.ts | 47 ++++++++--- tests/core/dynamics.test.ts | 35 +++++--- tests/core/soundfx.test.ts | 42 +++++++--- tests/editor/compressor-view.test.ts | 56 ++++++++++--- tests/editor/documents.test.ts | 19 ++++- tests/viewport/sound-library.test.ts | 4 +- tools/e2e/34-sound.mjs | 72 ++++++++++++++-- 15 files changed, 424 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 88041c0d..0eb1da42 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, 1947 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1949 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/dynamics.ts b/src/core/soundfx/dynamics.ts index 97923e9b..d0b1ea2b 100644 --- a/src/core/soundfx/dynamics.ts +++ b/src/core/soundfx/dynamics.ts @@ -41,21 +41,33 @@ export function compressorCurve(threshold: number, knee: number, ratio: number): x < kneeThreshold ? kneeCurve(x, k) : dbToLinear(kneeEndDb + slope * (linearToDb(x) - kneeThresholdDb)); } -/** How much quieter a Compressor makes a sound at full scale, in dB: what full auto-gain gives back. */ -export function compressorAutoGain(threshold: number, knee: number, ratio: number): number { +/** How much quieter a Compressor makes a sound at full scale, in dB; the node gives 0.6 of it back itself. */ +export function compressorFullScaleLoss(threshold: number, knee: number, ratio: number): number { return -linearToDb(compressorCurve(threshold, knee, ratio)(1)); } +/** The level auto-gain keeps as loud as it came in: about where a mixed sound sits on average. */ +export const AUTO_GAIN_REFERENCE_DB = -18; + /** - * The gain after a Compressor, as a factor: `makeup` dB and the auto-gain blended by `autoGain` - * (0 none, 1 all of it), less the 0.6 of the auto-gain the node adds by itself. + * What auto-gain gives back, in dB: as much as the Compressor takes from a sound at + * `AUTO_GAIN_REFERENCE_DB`. At full scale that would be far too much once the threshold is low. + */ +export function compressorAutoGain(threshold: number, knee: number, ratio: number): number { + const at = dbToLinear(AUTO_GAIN_REFERENCE_DB); + return AUTO_GAIN_REFERENCE_DB - linearToDb(compressorCurve(threshold, knee, ratio)(at)); +} + +/** + * The gain after a Compressor, as a factor: `makeup` dB, the auto-gain on top where it is on, less + * the 0.6 of its full-scale loss the node adds by itself. */ export function compressorMakeup( - numbers: Record<'threshold' | 'knee' | 'ratio' | 'makeup' | 'autoGain', number>, + numbers: Record<'threshold' | 'knee' | 'ratio' | 'makeup', number> & { autoGain: boolean }, ): number { - const auto = compressorAutoGain(numbers.threshold, numbers.knee, numbers.ratio); - const w = Math.max(0, Math.min(1, numbers.autoGain)); - return dbToLinear((1 - w) * numbers.makeup + w * auto - 0.6 * auto); + const { threshold, knee, ratio } = numbers; + const auto = numbers.autoGain ? compressorAutoGain(threshold, knee, ratio) : 0; + return dbToLinear(numbers.makeup + auto - 0.6 * compressorFullScaleLoss(threshold, knee, ratio)); } /** How far a Limiter looks ahead: a peak is met by a gain that has come down over this long. */ @@ -80,8 +92,8 @@ const at = (values: Float32Array, i: number) => (values.length > 1 ? values[i]! * A look-ahead limiter: the sound is held back `limiterDelay` samples, and the gain for every one * is the smallest any sample in the window ahead needs, averaged over that window – so it comes * down smoothly and is low enough by the time the peak is heard. Nothing ever goes above the lower - * of threshold and ceiling. The gain then comes back up in `release` seconds, and auto-gain raises - * the result towards the ceiling. + * of threshold and ceiling. The gain then comes back up in `release` seconds, and auto-gain (1 on, + * 0 off) raises the result to the ceiling. */ export class Limiter { private readonly delay: number; diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index 438f5924..e253151a 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -1,7 +1,7 @@ import type { DeclaredPort } from '../graph/Graph'; import { SOUND_FX_INPUT, SOUND_FX_OUTPUT } from '../graph/nested'; import type { NodeDef, PortDef } from '../graph/NodeRegistry'; -import { type PropertyBag, textOf } from '../model/Properties'; +import { booleanOf, type PropertyBag, textOf } from '../model/Properties'; import { DEFAULT_TEMPO, heldTempo, secondsOfQuarters } from '../music/time'; import type { FlatDef } from '../properties/PropertySchema'; import type { ChipShape, ChipWave } from './chip'; @@ -120,7 +120,7 @@ export interface FxCompress { gain: number; } -/** A Limiter (#326) on the worklet: dB, seconds and how much of the auto-gain it gives. */ +/** A Limiter (#326) on the worklet: dB, seconds, and its auto-gain as 1 on or 0 off. */ export interface FxLimit { kind: 'limit'; threshold: number; @@ -318,13 +318,13 @@ export type FxStagePlan = >) | ({ kind: 'flanger' } & Record<'offset' | 'sweep' | 'rate' | 'depth' | 'feedback' | 'mix', FxAmount>) | { kind: 'pan'; pan: FxAmount } - | ({ kind: 'compress' } & Record< - 'threshold' | 'knee' | 'ratio' | 'attack' | 'release' | 'makeup' | 'autoGain', + | ({ kind: 'compress'; autoGain: boolean } & Record< + 'threshold' | 'knee' | 'ratio' | 'attack' | 'release' | 'makeup', FxAmount >) - | ({ kind: 'limit' } & Record<'threshold' | 'ceiling' | 'release' | 'autoGain', FxAmount>) - | ({ kind: 'saturate'; curve: SaturationType } & Record< - 'drive' | 'tone' | 'output' | 'autoGain' | 'mix', + | ({ kind: 'limit'; autoGain: boolean } & Record<'threshold' | 'ceiling' | 'release', FxAmount>) + | ({ kind: 'saturate'; curve: SaturationType; autoGain: boolean } & Record< + 'drive' | 'tone' | 'output' | 'mix', FxAmount >) | { kind: 'tremolo'; shape: LfoShape; rate: FxAmount; depth: FxAmount } @@ -1004,7 +1004,23 @@ const COMPRESS_KNEE = { ...numberRow('knee', 'Knee (dB)', 0, 40, 0.5), default: const COMPRESS_ATTACK = { ...numberRow('attack', 'Attack (s)', 0, 1, 0.001), default: 0.003 }; const COMPRESS_RELEASE = { ...numberRow('release', 'Release (s)', 0, 1, 0.001), default: 0.25 }; const MAKEUP = numberRow('makeup', 'Makeup (dB)', -24, 24, 0.5); -const AUTO_GAIN = numberRow('autoGain', 'Auto-gain', 0, 1, 0.01); +/** + * Auto-gain of a Compressor, Limiter and Saturation: a switch, with the node's own gain on top of + * it. Until #378 it was a number from 0 to 1 blending the two, so a number read is on above 0. + */ +export const COMPRESS_AUTO_GAIN = { + key: 'autoGain', + label: 'Auto-gain', + kind: 'boolean' as const, + default: false, +}; + +/** Whether a node's auto-gain is on: its switch, or its default where none was ever set. */ +export function autoGainOn(settings: PropertyBag, row: { key: string; default: boolean }): boolean { + const value = settings[row.key]; + return value === undefined ? row.default : booleanOf(value); +} + /** The numbers of a Compressor, in the order its tab shows them as knobs (#378). */ export const COMPRESS_ROWS = { threshold: COMPRESS_THRESHOLD, @@ -1013,7 +1029,6 @@ export const COMPRESS_ROWS = { attack: COMPRESS_ATTACK, release: COMPRESS_RELEASE, makeup: MAKEUP, - autoGain: AUTO_GAIN, }; export const SoundFxCompressor: SoundFxNodeDef = effect( @@ -1023,12 +1038,12 @@ export const SoundFxCompressor: SoundFxNodeDef = effect( category: 'sound', tapped: true, description: - 'Squeezes the loudness of the sound together: what goes above "threshold" (dB) comes out only 1/"ratio" as much louder, so 4 makes 8 dB over it 2 dB. "knee" (dB) eases into that from the threshold on instead of at once. "attack" is how fast it turns down once the sound is over the threshold, "release" how fast it lets go, both in seconds. "makeup" (dB) raises it again afterwards. "auto-gain" from 0 to 1 blends that with the gain that brings a sound at full scale back to full scale: 0 is the makeup alone, 1 the auto-gain alone. It is 6 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays. Double-click the node to open its editor: a knob for each number, its curve with threshold and ratio to drag, and the level coming in and going out over the last seconds.' + + 'Squeezes the loudness of the sound together: what goes above "threshold" (dB) comes out only 1/"ratio" as much louder, so 4 makes 8 dB over it 2 dB. "knee" (dB) eases into that from the threshold on instead of at once. "attack" is how fast it turns down once the sound is over the threshold, "release" how fast it lets go, both in seconds. "makeup" (dB) raises it again afterwards. "Auto-gain" on gives back what it takes from a sound at -18 dB, so that one comes out as loud as it went in; the makeup is added on top. It is 6 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays. Double-click the node to open its editor: a knob for each number, its curve with threshold and ratio to drag, and the level coming in and going out over the last seconds.' + BLENDS, inputs: [audioIn, ...Object.values(COMPRESS_ROWS).map(numberIn)], outputs: [audioOut], - params: [], - emit: (sounds, amounts) => + params: [COMPRESS_AUTO_GAIN], + emit: (sounds, amounts, settings) => withStages(arriving(sounds), [ { kind: 'compress', @@ -1038,7 +1053,7 @@ export const SoundFxCompressor: SoundFxNodeDef = effect( attack: held(amounts.attack, COMPRESS_ATTACK), release: held(amounts.release, COMPRESS_RELEASE), makeup: held(amounts.makeup, MAKEUP), - autoGain: held(amounts.autoGain, AUTO_GAIN), + autoGain: autoGainOn(settings, COMPRESS_AUTO_GAIN), }, ]), }, @@ -1048,12 +1063,11 @@ 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 = { ...AUTO_GAIN, default: 1 }; +const LIMIT_AUTO_GAIN = { ...COMPRESS_AUTO_GAIN, default: true }; const LIMIT_ROWS = { threshold: LIMIT_THRESHOLD, ceiling: CEILING, release: LIMIT_RELEASE, - autoGain: LIMIT_AUTO_GAIN, }; export const SoundFxLimiter: SoundFxNodeDef = effect( @@ -1062,19 +1076,19 @@ export const SoundFxLimiter: SoundFxNodeDef = effect( label: 'Limiter', category: 'sound', 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" from 0 to 1 raises the limited sound towards "ceiling" (dB): at 1 its peaks sit right at the ceiling, at 0 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.' + BLENDS, inputs: [audioIn, ...Object.values(LIMIT_ROWS).map(numberIn)], outputs: [audioOut], - params: [], - emit: (sounds, amounts) => + params: [LIMIT_AUTO_GAIN], + emit: (sounds, amounts, settings) => withStages(arriving(sounds), [ { kind: 'limit', threshold: held(amounts.threshold, LIMIT_THRESHOLD), ceiling: held(amounts.ceiling, CEILING), release: held(amounts.release, LIMIT_RELEASE), - autoGain: held(amounts.autoGain, LIMIT_AUTO_GAIN), + autoGain: autoGainOn(settings, LIMIT_AUTO_GAIN), }, ]), }, @@ -1084,13 +1098,12 @@ export const SoundFxLimiter: SoundFxNodeDef = effect( const DRIVE = { ...numberRow('drive', 'Drive (dB)', 0, 48, 0.5), default: 12 }; const TONE = { ...numberRow('tone', 'Tone', 0, 1, 0.01), default: 1 }; const OUTPUT = numberRow('output', 'Output (dB)', -24, 24, 0.5); -const SATURATION_AUTO_GAIN = { ...AUTO_GAIN, default: 1 }; +const SATURATION_AUTO_GAIN = { ...COMPRESS_AUTO_GAIN, default: true }; const SATURATION_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 1 }; const SATURATION_ROWS = { drive: DRIVE, tone: TONE, output: OUTPUT, - autoGain: SATURATION_AUTO_GAIN, mix: SATURATION_MIX, }; const SATURATION_TYPE = { @@ -1111,11 +1124,11 @@ export const SoundFxSaturation: SoundFxNodeDef = effect( label: 'Saturation', category: 'sound', description: - 'Bends the sound, from warmth to a hard distortion. "Type" is the shape it is bent by: Soft (tanh) and Cubic round the peaks off, Hard clip cuts them, Tube and Fuzz bend one half harder than the other, Foldback and Sine fold mirror what goes past full scale back in, Rectify turns the lower half up, an octave higher. "drive" (dB) is how hard the sound is pushed into it. "tone" from 0 to 1 is a low-pass after it, from 1 kHz up to wide open. "output" (dB) sets the level of the bent sound, "auto-gain" from 0 to 1 blends that with the gain that keeps a sound at -12 dBFS as loud as it came in. "wet" is how much of the bent sound is heard instead of the sound itself. It is 4 ms late (192 samples), which a voice, a Mix and a bus make up for. All numbers can change while the sound plays.' + + 'Bends the sound, from warmth to a hard distortion. "Type" is the shape it is bent by: Soft (tanh) and Cubic round the peaks off, Hard clip cuts them, Tube and Fuzz bend one half harder than the other, Foldback and Sine fold mirror what goes past full scale back in, Rectify turns the lower half up, an octave higher. "drive" (dB) is how hard the sound is pushed into it. "tone" from 0 to 1 is a low-pass after it, from 1 kHz up to wide open. "Auto-gain" on keeps a sound at -12 dBFS as loud as it came in, and "output" (dB) is added on top; off, "output" alone sets the level of the bent sound. "wet" is how much of the bent sound is heard instead of the sound itself. It is 4 ms late (192 samples), which a voice, a Mix and a bus make up for. All numbers can change while the sound plays.' + BYPASSES, inputs: [audioIn, ...Object.values(SATURATION_ROWS).map(numberIn)], outputs: [audioOut], - params: [SATURATION_TYPE], + params: [SATURATION_TYPE, SATURATION_AUTO_GAIN], emit: (sounds, amounts, settings) => { const mix = held(amounts.mix, SATURATION_MIX); if (isNone(mix)) return arriving(sounds); @@ -1126,7 +1139,7 @@ export const SoundFxSaturation: SoundFxNodeDef = effect( drive: held(amounts.drive, DRIVE), tone: held(amounts.tone, TONE), output: held(amounts.output, OUTPUT), - autoGain: held(amounts.autoGain, SATURATION_AUTO_GAIN), + autoGain: autoGainOn(settings, SATURATION_AUTO_GAIN), mix, }, ]); @@ -1423,7 +1436,7 @@ function stagesOf(plan: readonly FxStagePlan[], params: FxParams, tempo: number) gain: compressorMakeup({ ...numbers, makeup: amountOf(stage.makeup, params), - autoGain: amountOf(stage.autoGain, params), + autoGain: stage.autoGain, }), }); continue; @@ -1434,14 +1447,14 @@ function stagesOf(plan: readonly FxStagePlan[], params: FxParams, tempo: number) threshold: amountOf(stage.threshold, params), ceiling: amountOf(stage.ceiling, params), release: amountOf(stage.release, params), - autoGain: amountOf(stage.autoGain, params), + autoGain: stage.autoGain ? 1 : 0, }); continue; } if (stage.kind === 'saturate') { const drive = amountOf(stage.drive, params); - const w = amountOf(stage.autoGain, params); - const levelDb = (1 - w) * amountOf(stage.output, params) + w * saturationAutoGain(stage.curve, drive); + const auto = stage.autoGain ? saturationAutoGain(stage.curve, drive) : 0; + const levelDb = amountOf(stage.output, params) + auto; stages.push({ kind: 'saturate', curve: stage.curve, diff --git a/src/editor/FloatWindow.ts b/src/editor/FloatWindow.ts index d086cd08..2959db91 100644 --- a/src/editor/FloatWindow.ts +++ b/src/editor/FloatWindow.ts @@ -1,6 +1,6 @@ import { type Container, type FederatedPointerEvent, Graphics, Rectangle } from 'pixi.js'; import { Button, crisp, drawFloatingBox, Icon, Label, Spacer, Theme, ui, Widget } from '../ui'; -import { onScreen, sized, type WindowBox } from './floating'; +import { leastBox, onScreen, sized, type WindowBox } from './floating'; import { TabHeader } from './TabHeader'; /** The corner taken hold of to size the window. */ @@ -93,6 +93,8 @@ export class FloatWindow extends Widget { content.visible = true; this.addChildAt(content, this.getChildIndex(this.bar)); this.layout(); + // A pane that needs more than the window has grows the window. + if (this.isOpen) this.place(this.box); } /** The name and the icon of the document shown, as its tab had them. */ @@ -139,15 +141,22 @@ export class FloatWindow extends Widget { const screen = ui().app.screen; const dx = e.global.x - g.x; const dy = e.global.y - g.y; - if (g.kind === 'size') this.place(sized(g.box, dx, dy, screen.width, screen.height)); + if (g.kind === 'size') this.place(sized(g.box, dx, dy, screen.width, screen.height, this.least())); else this.place({ ...g.box, x: g.box.x + dx, y: g.box.y + dy }); } private readonly keepOnScreen = () => this.place(this.box); + /** The window's least size: the usual one, or more where the pane in it says it needs more. */ + least(): { w: number; h: number } { + const content = this.content; + if (!content) return leastBox(0, 0); + return leastBox(content.minWidth + 2, content.minHeight + this.bar.measure().h + 2); + } + private place(box: WindowBox): void { const screen = ui().app.screen; - const fit = onScreen(box, screen.width, screen.height); + const fit = onScreen(box, screen.width, screen.height, this.least()); this.position.set(fit.x, fit.y); this.resize(fit.w, fit.h); } diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index ad443063..b16b1849 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -164,8 +164,19 @@ export function graphHooks({ state, editor }: HookContext) { editor.compressorEditor.knobs[key].dialGlobal(), fieldAt: (key: keyof typeof editor.compressorEditor.knobs) => centreOf(editor.compressorEditor.knobs[key].field), + /** The auto-gain switch: on or off, what it says it gives, and where to click it. */ + autoGain: () => ({ + on: editor.compressorEditor.autoGain.checked, + says: editor.compressorEditor.autoGainDb.text, + at: centreOf(editor.compressorEditor.autoGain), + }), handleAt: (key: 'threshold' | 'ratio') => editor.compressorEditor.curve.handleGlobal(key), pointAt: (inDb: number, outDb: number) => editor.compressorEditor.curve.pointGlobal(inDb, outDb), + /** Where the level picture and the curve are on screen (#378 layout). */ + pictures: () => ({ + levels: rectOf(editor.compressorEditor.levels), + curve: rectOf(editor.compressorEditor.curve), + }), /** * The levels read so far (#378): how many readings with something coming in, the loudest on * either side, the most taken away in one, and the taps as for the EQ. diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 84200395..b58b657f 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -7,6 +7,7 @@ import { Music } from '../viewport/music'; import { bothAtCenter, envelopeDriver, followCurve, releaseEnvelope, stageNodes } from '../viewport/fxNodes'; import { soundLibrary } from '../viewport/SoundLibrary'; import { saturationAutoGain } from '../core/soundfx/saturation'; +import { compressorMakeup } from '../core/soundfx/dynamics'; import { bakeHooks } from './debug/bakeHooks'; import { curveHooks } from './debug/curveHooks'; import { soundHooks } from './debug/soundHooks'; @@ -88,6 +89,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E stageNodes(context, stage, undefined, at, impulse), /** The gain in dB a Saturation's auto-gain gives at a drive (#323). */ saturationAutoGain, + /** The gain after a Compressor as a factor, from its makeup and auto-gain (#326, #378). */ + compressorMakeup, /** What drives the two shares of a Mix "Both at center", in any context (unstarted). */ bothAtCenter: (context: BaseAudioContext, mix: number, a: GainNode, b: GainNode) => bothAtCenter(context, mix, a, b), diff --git a/src/editor/floating.ts b/src/editor/floating.ts index 5c21205e..4728a1c8 100644 --- a/src/editor/floating.ts +++ b/src/editor/floating.ts @@ -12,6 +12,13 @@ export interface WindowBox { /** Below this a tab's controls no longer fit in a row, and the title bar loses its buttons. */ export const MIN_WINDOW_WIDTH = 320; export const MIN_WINDOW_HEIGHT = 200; +/** The least a window is sized to; a pane that needs more says so (#378). */ +const LEAST = { w: MIN_WINDOW_WIDTH, h: MIN_WINDOW_HEIGHT }; + +/** The least size of a window around a pane that needs `w` by `h`, never below the usual least. */ +export function leastBox(w: number, h: number): { w: number; h: number } { + return { w: Math.max(MIN_WINDOW_WIDTH, Math.ceil(w)), h: Math.max(MIN_WINDOW_HEIGHT, Math.ceil(h)) }; +} /** How far each further window opens down and to the right, so it does not hide the one before. */ const WINDOW_STEP = 24; @@ -28,17 +35,24 @@ export function openingBox(screenW: number, screenH: number, count: number): Win } /** `box` whole on the screen: no larger than it, and moved in where it sticks out. */ -export function onScreen(box: WindowBox, screenW: number, screenH: number): WindowBox { - const w = Math.max(Math.min(box.w, screenW), Math.min(MIN_WINDOW_WIDTH, screenW)); - const h = Math.max(Math.min(box.h, screenH), Math.min(MIN_WINDOW_HEIGHT, screenH)); +export function onScreen(box: WindowBox, screenW: number, screenH: number, least = LEAST): WindowBox { + const w = Math.min(Math.max(box.w, least.w), screenW); + const h = Math.min(Math.max(box.h, least.h), screenH); const x = Math.max(0, Math.min(box.x, screenW - w)); const y = Math.max(0, Math.min(box.y, screenH - h)); return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) }; } /** `box` with its lower right corner pulled by `dx`, `dy`: at least the minimum, at most to the screen's edge. */ -export function sized(box: WindowBox, dx: number, dy: number, screenW: number, screenH: number): WindowBox { - const w = Math.max(MIN_WINDOW_WIDTH, Math.min(box.w + dx, screenW - box.x)); - const h = Math.max(MIN_WINDOW_HEIGHT, Math.min(box.h + dy, screenH - box.y)); +export function sized( + box: WindowBox, + dx: number, + dy: number, + screenW: number, + screenH: number, + least = LEAST, +): WindowBox { + const w = Math.max(least.w, Math.min(box.w + dx, screenW - box.x)); + const h = Math.max(least.h, Math.min(box.h + dy, screenH - box.y)); return { ...box, w: Math.round(w), h: Math.round(h) }; } diff --git a/src/editor/soundfx/CompressorEditor.ts b/src/editor/soundfx/CompressorEditor.ts index a3222656..3e572420 100644 --- a/src/editor/soundfx/CompressorEditor.ts +++ b/src/editor/soundfx/CompressorEditor.ts @@ -1,6 +1,6 @@ /** * The editor of a Compressor node (#378), in a tab of its own like a Parametric EQ's. A knob for - * each of its numbers; the curve from level in to level out, whose threshold and ratio are dragged + * each of its numbers and a switch for its auto-gain; the curve from level in to level out, whose threshold and ratio are dragged * on it; and the level coming in and going out over the last seconds, tinted where it is squeezed. * A drag is one undo step. A number fed from a link has its knob greyed: the link decides it. */ @@ -9,9 +9,17 @@ import { SetNodeParamCommand } from '../../core/commands/graphCommands'; import type { GraphNode } from '../../core/graph'; import { findGraphAsset, type Project } from '../../core/model/Project'; import type { PropertyValue } from '../../core/model/Properties'; -import { COMPRESS_ROWS, SoundFxCompressor, tapId } from '../../core/soundfx/nodes'; +import { AUTO_GAIN_REFERENCE_DB, compressorAutoGain } from '../../core/soundfx/dynamics'; +import { + autoGainOn, + COMPRESS_AUTO_GAIN, + COMPRESS_ROWS, + SoundFxCompressor, + tapId, +} from '../../core/soundfx/nodes'; import { soundLibrary } from '../../viewport/SoundLibrary'; import { + Checkbox, clamp, crispText, HStack, @@ -42,6 +50,9 @@ import { LEVEL_WINDOW, LevelHistory, levelY, + MIN_CURVE, + MIN_LEVELS_WIDTH, + picturesLayout, ratioFor, ratioHandle, reductionShapes, @@ -66,12 +77,8 @@ const KNOBS: Record = { attack: { label: 'Attack s', taper: 3, decimals: 3 }, release: { label: 'Release s', taper: 2, decimals: 3 }, makeup: { label: 'Makeup dB', taper: 1, decimals: 1 }, - autoGain: { label: 'Auto-gain', taper: 1, decimals: 2 }, }; -const MIN_LEVELS_WIDTH = 240; -/** The side of the curve's square, and the height of the level picture beside it. */ -const PICTURE_SIZE = 240; const HANDLE_RADIUS = 6; const GRAB_RADIUS = HANDLE_RADIUS + 4; const CURVE_STEPS = 120; @@ -114,14 +121,14 @@ class LevelPicture extends Widget { constructor(private readonly host: CompressorHost) { super(); this.minWidth = MIN_LEVELS_WIDTH; - this.minHeight = PICTURE_SIZE; + this.minHeight = MIN_CURVE; this.addChild(this.bg, this.lines); this.labels = gridLabels(this, GRID_DB.length); } read(now: number, levels: Parameters[1]): void { this.now = now; - this.history.push(now, levels); + this.history.push(now, levels, compressorGainDb(this.host.numbers())); this.redraw(); } @@ -149,7 +156,7 @@ class LevelPicture extends Widget { this.lines.clear(); const points = this.history.all; if (points.length < 2) return; - for (const shape of reductionShapes(points, compressorGainDb(numbers), this.now, w, h)) + for (const shape of reductionShapes(points, this.now, w, h)) this.lines.poly(shape).fill({ color: c.accent, alpha: 0.25 }); for (const [side, color, width] of [ ['in', c.textMuted, 1], @@ -179,8 +186,8 @@ class CurvePicture extends Widget { constructor(private readonly host: CompressorHost) { super(); - this.minWidth = PICTURE_SIZE; - this.minHeight = PICTURE_SIZE; + this.minWidth = MIN_CURVE; + this.minHeight = MIN_CURVE; this.cursor = 'crosshair'; this.square.position.set(GRAB_RADIUS, GRAB_RADIUS); this.square.addChild(this.bg, this.lines); @@ -274,6 +281,14 @@ class CurvePicture extends Widget { if (this.drag === key) this.lines.circle(at.x, at.y, HANDLE_RADIUS + 3).stroke({ color: c.symbol, width: 2 }); } + // The level auto-gain keeps as loud as it came in, and how far it is lifted to it. + if (n.autoGain) { + const x = curveX(AUTO_GAIN_REFERENCE_DB, size); + const y = curveY(curveOutDb(AUTO_GAIN_REFERENCE_DB, n.threshold, n.knee, n.ratio), size); + const to = curveY(AUTO_GAIN_REFERENCE_DB, size); + this.lines.moveTo(x, y).lineTo(x, to).stroke({ color: c.symbol, width: 1, alpha: 0.6 }); + this.lines.circle(x, to, 3).stroke({ color: c.symbol, width: 1 }); + } if (this.heard > CURVE_FLOOR_DB) { const x = curveX(this.heard, size); const y = curveY(curveOutDb(this.heard, n.threshold, n.knee, n.ratio), size); @@ -282,6 +297,29 @@ class CurvePicture extends Widget { } } +/** The levels and the curve side by side, as `picturesLayout` places them. */ +class Pictures extends Widget { + constructor( + private readonly levels: LevelPicture, + private readonly curve: CurvePicture, + ) { + super(); + this.addChild(levels, curve); + } + + override measure(): { w: number; h: number } { + return { w: MIN_LEVELS_WIDTH + Theme.padding + MIN_CURVE, h: MIN_CURVE }; + } + + override layout(): void { + const at = picturesLayout(this.w, this.h, Theme.padding); + this.levels.position.set(0, 0); + this.levels.resize(at.levels.w, at.levels.h); + this.curve.position.set(at.curve.x, at.curve.y); + this.curve.resize(at.curve.side, at.curve.side); + } +} + /** * The tab of a Compressor node (#378): one editor, pointed at the node whose tab is showing, as * the EQ's is (#370). @@ -290,6 +328,10 @@ export class CompressorEditor extends VStack implements CompressorHost { readonly levels: LevelPicture; readonly curve: CurvePicture; readonly knobs = {} as Record; + /** Auto-gain: a switch, with the dB it gives below it where a knob has its number. */ + readonly autoGain = new Checkbox(false); + readonly autoGainDb = new Label('', { style: 'small' }); + private readonly autoColumn = new VStack({ gap: 2, align: 'center' }); private assetId = ''; private nodeId = ''; /** The value a drag began with – its undo step's other side; undefined when none was typed in. */ @@ -309,10 +351,9 @@ export class CompressorEditor extends VStack implements CompressorHost { this.header.add(this.title); this.header.add(new Spacer(), 1); - const pictures = new HStack({ gap: Theme.padding }); - pictures.add(this.levels, 1); - pictures.add(this.curve); + // The knobs in the middle of the bottom line, whatever the tab's width. const knobs = new HStack({ gap: Theme.padding * 2, align: 'center' }); + knobs.add(new Spacer(), 1); for (const key of KEYS) { const row = ROWS[key]; const look = KNOBS[key]; @@ -329,16 +370,33 @@ export class CompressorEditor extends VStack implements CompressorHost { this.knobs[key] = knob; knobs.add(knob); } - this.body.add(pictures); - this.body.add(knobs); + // As tall as a knob, the switch where its dial is. + const auto = this.autoColumn; + auto.add(new Label(COMPRESS_AUTO_GAIN.label, { style: 'small' })); + auto.add(new Spacer(), 1); + auto.add(this.autoGain); + auto.add(new Spacer(), 1); + auto.add(this.autoGainDb); + auto.minHeight = this.knobs.threshold.measure().h; + auto.minWidth = this.knobs.threshold.measure().w; + this.autoGain.onChange = (on) => this.switchAutoGain(on); + knobs.add(auto); + knobs.add(new Spacer(), 1); + // The pictures take what height there is; the knobs stay on the bottom line. + const pictures = new Pictures(this.levels, this.curve); + this.body.add(pictures, 1); this.body.add( new Label('Knob: drag (Shift: fine), wheel, double-click: default. Curve: drag threshold and ratio.', { style: 'muted', }), ); - this.body.add(new Spacer(), 1); + this.body.add(knobs); this.add(this.header); this.add(this.body, 1); + // What a window of the tab needs at the least: every knob and the smallest pictures. The help + // line is left out of the width: it is cut short instead. + this.minWidth = Math.max(knobs.measure().w, pictures.measure().w) + this.body.padding * 2; + this.minHeight = this.measure().h; state.on('project', () => this.sync()); } @@ -409,9 +467,25 @@ export class CompressorEditor extends VStack implements CompressorHost { const value = params[key]; numbers[key] = typeof value === 'number' && Number.isFinite(value) ? value : ROWS[key].default; } + numbers.autoGain = autoGainOn(params, COMPRESS_AUTO_GAIN); return numbers; } + /** The dB auto-gain gives at these numbers, or that it is off. */ + private showAutoGainDb(n: CompressorNumbers): void { + const db = compressorAutoGain(n.threshold, n.knee, n.ratio); + const text = n.autoGain ? `+${db.toFixed(1)} dB` : 'off'; + if (text === this.autoGainDb.text) return; + this.autoGainDb.setText(text); + this.autoColumn.relayout(); + } + + /** Auto-gain on or off: one undo step, as a click on a knob is. */ + private switchAutoGain(on: boolean): void { + if (!this.node()) return; + this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, COMPRESS_AUTO_GAIN.key, on)); + } + wired(key: Key): boolean { const graph = findGraphAsset(this.state.project, this.assetId)?.graph; return Boolean(graph?.edges.some((e) => e.to.node === this.nodeId && e.to.port === key)); @@ -427,6 +501,7 @@ export class CompressorEditor extends VStack implements CompressorHost { } node.params[key] = value; this.perFrame.request(); + this.showAutoGainDb(this.numbers()); this.levels.invalidate(); this.curve.invalidate(); } @@ -458,6 +533,8 @@ export class CompressorEditor extends VStack implements CompressorHost { this.knobs[key].setValue(numbers[key]); this.knobs[key].setEnabled(!this.wired(key)); } + this.autoGain.setChecked(numbers.autoGain); + this.showAutoGainDb(numbers); const title = `Compressor – ${findGraphAsset(this.state.project, this.assetId)?.name ?? ''}`; if (title !== this.title.text) { this.title.setText(title); diff --git a/src/editor/soundfx/compressorView.ts b/src/editor/soundfx/compressorView.ts index 47337507..24bd21f3 100644 --- a/src/editor/soundfx/compressorView.ts +++ b/src/editor/soundfx/compressorView.ts @@ -26,16 +26,15 @@ export interface CompressorNumbers { attack: number; release: number; makeup: number; - autoGain: number; + autoGain: boolean; } const dbToLinear = (db: number) => 10 ** (db / 20); const linearToDb = (value: number) => (value <= 0 ? -Infinity : 20 * Math.log10(value)); -/** What the gain after the compressing adds, in dB: makeup and auto-gain blended. */ +/** What the gain after the compressing adds, in dB: the makeup, and the auto-gain where it is on. */ export function compressorGainDb(n: CompressorNumbers): number { - const w = clamp(n.autoGain, 0, 1); - return (1 - w) * n.makeup + w * compressorAutoGain(n.threshold, n.knee, n.ratio); + return n.makeup + (n.autoGain ? compressorAutoGain(n.threshold, n.knee, n.ratio) : 0); } /** The level a steady one of `inDb` comes out at, before the gain after it. */ @@ -94,6 +93,31 @@ export function ratioFor( return clamp(snapToStep((low + high) / 2, range.step), range.min, range.max); } +// --- Where the two pictures go in the space the tab leaves them. --- + +export const MIN_LEVELS_WIDTH = 240; +/** The side of the curve's square: it grows with the tab up to the largest, then stays centred. */ +export const MIN_CURVE = 180; +export const MAX_CURVE = 360; +/** The most of the width the curve takes, so the levels keep the larger part. */ +const CURVE_SHARE = 0.4; + +/** + * The levels fill the height on the left; the curve's square is on the right, centred up and + * down, as large as the height, the largest and its share of the width allow. + */ +export function picturesLayout( + w: number, + h: number, + gap: number, +): { levels: { w: number; h: number }; curve: { x: number; y: number; side: number } } { + const side = Math.round(Math.max(MIN_CURVE, Math.min(h, MAX_CURVE, w * CURVE_SHARE))); + return { + levels: { w: Math.max(0, w - side - gap), h }, + curve: { x: w - side, y: Math.round(Math.max(0, h - side) / 2), side }, + }; +} + // --- The level picture: time across, the newest at the right edge; dB up. --- export const levelY = (db: number, h: number): number => @@ -101,18 +125,22 @@ export const levelY = (db: number, h: number): number => export const historyX = (t: number, now: number, w: number): number => w - ((now - t) / HISTORY_SECONDS) * w; -/** One reading of both sides. */ +/** One reading of both sides, and the gain after the compressing as it was then, in dB. */ export interface LevelPoint extends Levels { t: number; + gain: number; } /** The readings of the last seconds, oldest first. */ export class LevelHistory { private points: LevelPoint[] = []; - /** A reading at `t` seconds; none heard counts as silence on both sides. */ - push(t: number, levels: Levels | null): void { - this.points.push({ t, in: levels?.in ?? -Infinity, out: levels?.out ?? -Infinity }); + /** + * A reading at `t` seconds, with the gain after the compressing at the time: a reading from + * before the makeup or the auto-gain changed is read by what it was. None heard is silence. + */ + push(t: number, levels: Levels | null, gain: number): void { + this.points.push({ t, in: levels?.in ?? -Infinity, out: levels?.out ?? -Infinity, gain }); const old = this.points.findIndex((p) => p.t >= t - HISTORY_SECONDS); if (old > 0) this.points.splice(0, old); } @@ -147,7 +175,6 @@ export function reductionOf(point: Levels, gainDb: number): number { */ export function reductionShapes( points: readonly LevelPoint[], - gainDb: number, now: number, w: number, h: number, @@ -165,7 +192,7 @@ export function reductionShapes( bottom = []; }; for (const point of points) { - const reduction = reductionOf(point, gainDb); + const reduction = reductionOf(point, point.gain); if (reduction < REDUCTION_SHOWN_DB) { close(); continue; diff --git a/tests/core/dynamics.test.ts b/tests/core/dynamics.test.ts index 1f6eb105..7e73d734 100644 --- a/tests/core/dynamics.test.ts +++ b/tests/core/dynamics.test.ts @@ -6,15 +6,16 @@ import { describe, expect, it } from 'vitest'; import { compressorAutoGain, compressorCurve, + compressorFullScaleLoss, compressorMakeup, Limiter, limiterDelay, type LimiterNumbers, } from '../../src/core/soundfx/dynamics'; -/** What the node adds by itself: the 0.6th power of what full auto-gain would be. */ +/** What the node adds by itself: 0.6 of what it takes from a sound at full scale. */ const ownMakeup = (threshold: number, knee: number, ratio: number) => - 10 ** ((0.6 * compressorAutoGain(threshold, knee, ratio)) / 20); + 10 ** ((0.6 * compressorFullScaleLoss(threshold, knee, ratio)) / 20); describe('the curve of a Compressor', () => { it('adds the gain Chrome was measured to add below the threshold (2026-10-07)', () => { @@ -32,17 +33,27 @@ describe('the curve of a Compressor', () => { // From -3 dB to 0 dB in, 3 dB more in is 0.75 dB more out. expect(db(1) - db(10 ** (-3 / 20))).toBeCloseTo(0.75, 3); // A hard knee is the straight line from the threshold on: 0 dB in is -9 dB out. - expect(compressorAutoGain(-12, 0, 4)).toBeCloseTo(9, 3); + expect(compressorFullScaleLoss(-12, 0, 4)).toBeCloseTo(9, 3); }); - it('blends makeup and auto-gain, less what the node adds by itself', () => { - const numbers = { threshold: -12, knee: 0, ratio: 4 }; - const db = (makeup: number, autoGain: number) => + it('has auto-gain give back what it takes from a sound at -18 dB, not at full scale', () => { + // -18 dB is 6 dB over a threshold of -24 and comes out 1.5 dB over it at a ratio of 4. + expect(compressorAutoGain(-24, 0, 4)).toBeCloseTo(4.5, 3); + // Nothing is taken below the threshold, so nothing is given back. + expect(compressorAutoGain(-12, 0, 4)).toBeCloseTo(0, 6); + // The numbers of #378's picture: 14.6 dB, where full scale asked for 28.1. + expect(compressorAutoGain(-40, 6, 4)).toBeCloseTo(14.6, 1); + expect(compressorFullScaleLoss(-40, 6, 4)).toBeCloseTo(28.1, 1); + }); + + it('adds auto-gain on top of the makeup, less what the node adds by itself', () => { + const numbers = { threshold: -24, knee: 0, ratio: 4 }; + const db = (makeup: number, autoGain: boolean) => 20 * Math.log10(compressorMakeup({ ...numbers, makeup, autoGain })); - expect(db(0, 0)).toBeCloseTo(-5.4, 3); - expect(db(0, 1)).toBeCloseTo(3.6, 3); - expect(db(6, 0.5)).toBeCloseTo(3 + 4.5 - 5.4, 3); - expect(db(0, 7)).toBeCloseTo(3.6, 3); + // The node adds 0.6 of the 18 dB it takes at full scale by itself. + expect(db(0, false)).toBeCloseTo(-10.8, 3); + expect(db(0, true)).toBeCloseTo(4.5 - 10.8, 3); + expect(db(6, true)).toBeCloseTo(6 + 4.5 - 10.8, 3); }); }); @@ -95,12 +106,12 @@ describe('a Limiter', () => { expect(peak(low!)).toBeLessThanOrEqual(10 ** (-6 / 20) + 1e-6); }); - it('raises the limited sound towards the ceiling by its auto-gain, never past it', () => { + it('raises the limited sound to the ceiling with auto-gain on, never past it', () => { const limited = (autoGain: number) => peak(run(new Limiter(RATE), [sine(2, 0.2)], numbers(-12, -1, autoGain))[0]!, RATE * 0.1); expect(limited(1)).toBeLessThanOrEqual(10 ** (-1 / 20) + 1e-6); expect(limited(1)).toBeGreaterThan(10 ** (-1 / 20) * 0.98); - expect(20 * Math.log10(limited(0.5))).toBeCloseTo(-6.5, 1); + expect(20 * Math.log10(limited(0))).toBeCloseTo(-12, 1); }); it('turns both sides down together, by the louder one', () => { diff --git a/tests/core/soundfx.test.ts b/tests/core/soundfx.test.ts index 6df116f0..6df79233 100644 --- a/tests/core/soundfx.test.ts +++ b/tests/core/soundfx.test.ts @@ -443,7 +443,7 @@ describe('a Pan in a sound FX graph (#319)', () => { describe('a Compressor and a Limiter in a sound FX graph (#326)', () => { // The taps a Compressor keeps for its tab do nothing to the sound (#378). - const at = (type: string, params?: Record) => + const at = (type: string, params?: Record) => fxChainOf(compileSoundFx(line([{ type, params }]).fx.graph).compiled!).stages.filter( (s) => s.kind !== 'tap', ); @@ -458,17 +458,19 @@ describe('a Compressor and a Limiter in a sound FX graph (#326)', () => { }); it('compress at their defaults, the gain after it worked out from makeup and auto-gain', () => { - const [stage] = at('SoundFxCompressor', { threshold: -12, knee: 0, ratio: 4, autoGain: 1 }); + const [stage] = at('SoundFxCompressor', { threshold: -24, knee: 0, ratio: 4, makeup: 2, autoGain: true }); expect(stage).toMatchObject({ kind: 'compress', - threshold: -12, + threshold: -24, knee: 0, ratio: 4, attack: 0.003, release: 0.25, }); - // 9 dB of full auto-gain, less the 5.4 dB the node adds itself. - expect(20 * Math.log10((stage as { gain: number }).gain)).toBeCloseTo(3.6, 3); + const db = (s: FxStage | undefined) => 20 * Math.log10((s as { gain: number }).gain); + // 2 dB makeup and 4.5 dB of auto-gain on top, less the 10.8 dB the node adds itself. + expect(db(stage)).toBeCloseTo(2 + 4.5 - 10.8, 3); + expect(db(at('SoundFxCompressor', { threshold: -24, knee: 0, ratio: 4 })[0])).toBeCloseTo(-10.8, 3); expect(at('SoundFxCompressor', { ratio: 50, threshold: 10 })).toMatchObject([ { ratio: 20, threshold: 0 }, ]); @@ -479,6 +481,7 @@ describe('a Compressor and a Limiter in a sound FX graph (#326)', () => { { kind: 'limit', threshold: -6, ceiling: -1, release: 0.1, autoGain: 1 }, ]); expect(at('SoundFxLimiter', { ceiling: 3, release: 0 })).toMatchObject([{ ceiling: 0, release: 0.001 }]); + expect(at('SoundFxLimiter', { autoGain: false })).toMatchObject([{ autoGain: 0 }]); const [limit] = at('SoundFxLimiter'); const [compress] = at('SoundFxCompressor'); expect(stageLatency(limit!, 48000)).toBe(0.005); @@ -497,10 +500,28 @@ describe('a Compressor and a Limiter in a sound FX graph (#326)', () => { ); expect(fxLatencyOf(way([]), 48000)).toBe(0); }); + + it('read the auto-gain of a project from before it was a switch: on above 0 (#378)', () => { + const gain = (autoGain?: number) => + ( + at('SoundFxCompressor', { + threshold: -24, + knee: 0, + ratio: 4, + ...(autoGain !== undefined && { autoGain }), + })[0] as { + gain: number; + } + ).gain; + expect(gain(1)).toBeCloseTo(gain(0.5), 9); + expect(gain(1)).toBeGreaterThan(gain(0)); + expect(gain(0)).toBeCloseTo(gain(), 9); + expect(at('SoundFxLimiter', { autoGain: 0 })).toMatchObject([{ autoGain: 0 }]); + }); }); describe('a Saturation in a sound FX graph (#323)', () => { - const at = (params?: Record) => + const at = (params?: Record) => fxChainOf(compileSoundFx(line([{ type: 'SoundFxSaturation', params }]).fx.graph).compiled!).stages; it('bends at its defaults, its level from auto-gain, and is left out at a typed-in mix of 0', () => { @@ -514,10 +535,11 @@ describe('a Saturation in a sound FX graph (#323)', () => { expect(at({ curve: 'nope' })).toMatchObject([{ curve: 'soft' }]); }); - it('blends its output and its auto-gain, and is late by the oversampling', () => { - const [stage] = at({ curve: 'hard', drive: 24, output: 6, autoGain: 0.5 }); - const levelDb = 20 * Math.log10((stage as { level: number }).level); - expect(levelDb).toBeCloseTo(0.5 * 6 + 0.5 * saturationAutoGain('hard', 24), 9); + it('adds its output on top of its auto-gain, and is late by the oversampling', () => { + const levelDb = (s: FxStage | undefined) => 20 * Math.log10((s as { level: number }).level); + const [stage] = at({ curve: 'hard', drive: 24, output: 6 }); + expect(levelDb(stage)).toBeCloseTo(6 + saturationAutoGain('hard', 24), 9); + expect(levelDb(at({ curve: 'hard', drive: 24, output: 6, autoGain: false })[0])).toBeCloseTo(6, 9); expect(stageLatency(stage!, 48000)).toBe(0.004); }); }); diff --git a/tests/editor/compressor-view.test.ts b/tests/editor/compressor-view.test.ts index d169fa7f..f4437ef9 100644 --- a/tests/editor/compressor-view.test.ts +++ b/tests/editor/compressor-view.test.ts @@ -14,6 +14,9 @@ import { HISTORY_SECONDS, LevelHistory, levelY, + MAX_CURVE, + MIN_CURVE, + picturesLayout, ratioFor, ratioHandle, reductionOf, @@ -28,7 +31,7 @@ const numbers: CompressorNumbers = { attack: 0.003, release: 0.25, makeup: 0, - autoGain: 0, + autoGain: false, }; const RATIO = { min: 1, max: 20, step: 0.1 }; @@ -65,10 +68,10 @@ describe('the curve of a Compressor', () => { expect(curveOutDb(0, -24, 6, ratio)).toBeCloseTo(-16, 0); }); - it('counts the makeup, the auto-gain or a blend of both as the gain after it', () => { + it('counts the makeup, and the auto-gain on top of it where it is on, as the gain after it', () => { expect(compressorGainDb({ ...numbers, makeup: 6 })).toBe(6); - expect(compressorGainDb({ ...numbers, makeup: 6, autoGain: 1 })).toBeCloseTo(18, 1); - expect(compressorGainDb({ ...numbers, makeup: 6, autoGain: 0.5 })).toBeCloseTo(12, 1); + // -18 dB in comes out at -22.5: auto-gain gives the 4.5 dB back. + expect(compressorGainDb({ ...numbers, makeup: 6, autoGain: true })).toBeCloseTo(10.5, 3); }); }); @@ -82,10 +85,10 @@ describe('the level picture of a Compressor', () => { it('keeps the readings of the last seconds and knows when all of them are silent', () => { const history = new LevelHistory(); - history.push(0, { in: -6, out: -9 }); - history.push(1, null); + history.push(0, { in: -6, out: -9 }, 0); + history.push(1, null, 0); expect(history.heard).toBe(true); - history.push(1 + HISTORY_SECONDS, null); + history.push(1 + HISTORY_SECONDS, null, 0); expect(history.all.map((p) => p.t)).toEqual([1, 1 + HISTORY_SECONDS]); expect(history.heard).toBe(false); }); @@ -99,16 +102,43 @@ describe('the level picture of a Compressor', () => { it('tints from the level coming in down by what is taken away, one shape per stretch', () => { const points = [ - { t: 0, in: -6, out: -12 }, - { t: 1, in: -6, out: -12 }, - { t: 2, in: -30, out: -30 }, - { t: 3, in: 0, out: -3 }, - { t: 4, in: 0, out: -3 }, + { t: 0, in: -6, out: -12, gain: 0 }, + { t: 1, in: -6, out: -12, gain: 0 }, + { t: 2, in: -30, out: -30, gain: 0 }, + { t: 3, in: 0, out: -3, gain: 0 }, + { t: 4, in: 0, out: -3, gain: 0 }, ]; - const shapes = reductionShapes(points, 0, 4, 400, 660); + const shapes = reductionShapes(points, 4, 400, 660); expect(shapes).toHaveLength(2); // Along the level coming in, then back along it less what is taken away. expect(shapes[0]).toEqual([0, 120, 100, 120, 100, 180, 0, 180]); expect(shapes[1]).toEqual([300, 60, 400, 60, 400, 90, 300, 90]); }); + + it('reads every reading by the gain after the compressing as it was then', () => { + // Raised by 20 dB until a second ago, by 0 since: the same 6 dB taken away both times. + const history = new LevelHistory(); + history.push(0, { in: -24, out: -10 }, 20); + history.push(1, { in: -24, out: -10 }, 20); + history.push(2, { in: -24, out: -30 }, 0); + history.push(3, { in: -24, out: -30 }, 0); + const shapes = reductionShapes(history.all, 3, 300, 660); + expect(shapes).toHaveLength(1); + expect(shapes[0]).toHaveLength(16); + expect(shapes[0]!.filter((_, i) => i % 2 === 1)).toEqual([300, 300, 300, 300, 360, 360, 360, 360]); + }); +}); + +describe('the pictures in the space of the tab', () => { + it('lets the levels fill the height and keeps the curve square, centred up and down', () => { + const at = picturesLayout(1600, 1000, 8); + expect(at.curve).toEqual({ x: 1600 - MAX_CURVE, y: (1000 - MAX_CURVE) / 2, side: MAX_CURVE }); + expect(at.levels).toEqual({ w: 1600 - MAX_CURVE - 8, h: 1000 }); + }); + + it('grows the curve with a lower tab, and holds it at its least in a narrow one', () => { + expect(picturesLayout(1600, 250, 8).curve).toEqual({ x: 1350, y: 0, side: 250 }); + expect(picturesLayout(500, 400, 8).curve.side).toBe(200); + expect(picturesLayout(300, 400, 8).curve.side).toBe(MIN_CURVE); + }); }); diff --git a/tests/editor/documents.test.ts b/tests/editor/documents.test.ts index c303aa13..f685cba9 100644 --- a/tests/editor/documents.test.ts +++ b/tests/editor/documents.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; import { closeDocument, neighbourOf, openDocument, replaceDocument } from '../../src/editor/documents'; -import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, onScreen, openingBox, sized } from '../../src/editor/floating'; +import { + leastBox, + MIN_WINDOW_HEIGHT, + MIN_WINDOW_WIDTH, + onScreen, + openingBox, + sized, +} from '../../src/editor/floating'; import { tabRowLayout } from '../../src/editor/tabRow'; const row = (docs: string[], active: string | null) => ({ docs, active }); @@ -87,6 +94,16 @@ describe('a tab in a window (#371)', () => { expect(sized(box, -500, -500, 1280, 800)).toEqual({ ...box, w: MIN_WINDOW_WIDTH, h: MIN_WINDOW_HEIGHT }); expect(sized(box, 900, 900, 1280, 800)).toEqual({ ...box, w: 680, h: 500 }); }); + + it('is held at the least its pane needs, but never larger than the screen (#378)', () => { + const least = leastBox(700.4, 450); + expect(least).toEqual({ w: 701, h: 450 }); + expect(leastBox(100, 100)).toEqual({ w: MIN_WINDOW_WIDTH, h: MIN_WINDOW_HEIGHT }); + const box = { x: 100, y: 100, w: 800, h: 600 }; + expect(sized(box, -500, -500, 1280, 800, least)).toEqual({ ...box, w: 701, h: 450 }); + expect(onScreen({ ...box, w: 400, h: 300 }, 1280, 800, least)).toEqual({ ...box, w: 701, h: 450 }); + expect(onScreen({ ...box, w: 400, h: 300 }, 600, 400, least)).toEqual({ x: 0, y: 0, w: 600, h: 400 }); + }); }); describe('tab row without an active tab', () => { diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index 607919c1..debf86fc 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -1390,7 +1390,7 @@ describe('notes of an instrument (#331)', () => { threshold: { value: -6 }, ceiling: { value: -1 }, release: { value: 0.1 }, - autoGain: { value: 1 }, + autoGain: true, }; const limited = { ...sample(FILE), stages: [limit] } as FxPlan; library.playNote('instrument:i', [limited, sample(OTHER)], {}, 1, '', 0.5, { at: 2 }); @@ -2720,7 +2720,7 @@ describe('the stages of #318 to #328', () => { attack: { value: 0.003 }, release: { value: 0.25 }, makeup: { value: 0 }, - autoGain: { value: 0 }, + autoGain: false, }; const plan: FxPlan = { mix: { value: 0.5 }, diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 8238bc8e..fb386c22 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -707,7 +707,7 @@ export async function sound(t) { compShown.knobs.ratio.value === 4 && compShown.knobs.knee.value === 0 && compShown.knobs.attack.value === 0.003 && - Object.keys(compShown.knobs).length === 7, + Object.keys(compShown.knobs).length === 6, JSON.stringify(compShown), ); const thresholdDial = await json("__gm.compressor.dialAt('threshold')"); @@ -738,6 +738,26 @@ export async function sound(t) { compTurned.knobs.makeup.value === 3, JSON.stringify({ compDragged, compUndone, compTurned }), ); + // Auto-gain is a switch (#378): a click turns it on as one undo step, and it says what it gives. + const autoOff = await json('__gm.compressor.autoGain()'); + await click(autoOff.at.x, autoOff.at.y); + await sleep(100); + const autoOn = await json('({ ...__gm.compressor.autoGain(), numbers: __gm.compressor.numbers() })'); + await press('z', { ctrl: true }); + await sleep(100); + const autoUndone = await json('({ ...__gm.compressor.autoGain(), numbers: __gm.compressor.numbers() })'); + check( + 'the auto-gain switch turns on with a click as one undo step and says the dB it gives', + !autoOff.on && + autoOff.says === 'off' && + autoOn.on && + autoOn.numbers.autoGain === true && + /^\+\d+\.\d dB$/.test(autoOn.says) && + !autoUndone.on && + autoUndone.numbers.autoGain === false && + autoUndone.says === 'off', + JSON.stringify({ autoOff, autoOn, autoUndone }), + ); const ratioHandle = await json("__gm.compressor.handleAt('ratio')"); const ratioTo = await json('__gm.compressor.pointAt(0, -10)'); await drag(ratioHandle.x, ratioHandle.y, ratioTo.x, ratioTo.y); @@ -773,6 +793,41 @@ export async function sound(t) { JSON.stringify(compLevels), ); await evaluate('__gm.sound.stopAll()'); + // Floated and sized down as far as it goes, the window keeps every knob; sized up, the knobs stay on + // the bottom line and the curve in the middle of the height to its right. + await evaluate(`__gm.floatDocument(${JSON.stringify(`compressor:${fxId}:fx-comp`)})`); + const compWindow = async () => (await json('__gm.floatWindows()')).find((w) => w.kind === 'compressor'); + const compLayout = async () => { + const win = await compWindow(); + const at = await json( + "({ first: __gm.compressor.dialAt('threshold'), last: __gm.compressor.autoGain().at, ...__gm.compressor.pictures() })", + ); + const inside = (p) => p.x > win.box.x && p.x < win.box.x + win.box.w && p.y < win.box.y + win.box.h - 20; + return { box: win.box, ...at, inside: inside(at.first) && inside(at.last) }; + }; + let floatedComp = await compWindow(); + await t.drag(floatedComp.grip.x, floatedComp.grip.y, floatedComp.grip.x - 2000, floatedComp.grip.y - 2000); + const compSmall = await compLayout(); + floatedComp = await compWindow(); + await t.drag(floatedComp.grip.x, floatedComp.grip.y, floatedComp.grip.x + 2000, floatedComp.grip.y + 2000); + const compLarge = await compLayout(); + const { levels: bigLevels, curve: bigCurve } = compLarge; + check( + 'a floated Compressor holds every knob at its least size; sized up, the knobs stay at the bottom, the levels fill the height and the curve sits in its middle', + compSmall.box.w > 320 && + compSmall.box.h > 200 && + compSmall.inside && + compLarge.box.h > compSmall.box.h + 100 && + compLarge.inside && + compLarge.box.y + compLarge.box.h - compLarge.last.y < 100 && + bigLevels.h > compSmall.levels.h + 100 && + bigCurve.h === bigCurve.w && + Math.abs(bigCurve.y + bigCurve.h / 2 - (bigLevels.y + bigLevels.h / 2)) <= 1, + JSON.stringify({ compSmall, compLarge }), + ); + // Read again: Dock moved right with the window's edge. + floatedComp = await compWindow(); + await click(floatedComp.dock.x, floatedComp.dock.y); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.edges.push(${JSON.stringify(edge('fx-e14', 'fx-in', 'depth', 'fx-comp', 'ratio'))});`); @@ -1327,24 +1382,27 @@ export async function sound(t) { }; const loudest = (out, from = 0) => { let at = 0; for (let i = from; i < out.length; i++) if (Math.abs(out[i]) > Math.abs(out[at])) at = i; return at; }; const db = (out) => +(20 * Math.log10(Math.abs(out[loudest(out, RATE / 4)]))).toFixed(2); - const compress = (gain) => ({ kind: 'compress', threshold: -12, knee: 0, ratio: 4, attack: 0.003, release: 0.25, gain }); + const compress = (gain) => ({ kind: 'compress', threshold: -24, knee: 0, ratio: 4, attack: 0.003, release: 0.25, gain }); + const makeup = (autoGain) => __gm.sound.compressorMakeup({ threshold: -24, knee: 0, ratio: 4, makeup: 0, autoGain }); const limit = (autoGain) => ({ kind: 'limit', threshold: -6, ceiling: -1, release: 0.05, autoGain }); const limited = await render(limit(0), sine(2)); + // A sine at the -18 dB auto-gain keeps as loud as it came in (#378). + const reference = sine(10 ** (-18 / 20)); return { compressorLate: loudest(await render(compress(1), impulse)) - 480, limiterLate: loudest(await render(limit(0), impulse)) - 480, - autoGain: db(await render(compress(10 ** (3.6 / 20)), sine(1))), - noMakeup: db(await render(compress(10 ** (-5.4 / 20)), sine(1))), + autoGain: db(await render(compress(makeup(true)), reference)), + noMakeup: db(await render(compress(makeup(false)), reference)), limitedPeak: +(20 * Math.log10(Math.max(...limited.map(Math.abs)))).toFixed(3), ceiling: db(await render(limit(1), sine(2))), }; })()`); check( - 'a Compressor is 6 ms late and auto-gain brings full scale back, a Limiter is 5 ms late and never passes its limit', + 'a Compressor is 6 ms late and auto-gain keeps -18 dB as loud, a Limiter is 5 ms late and never passes its limit', dynamics.compressorLate === 288 && dynamics.limiterLate === 240 && - Math.abs(dynamics.autoGain) < 0.5 && - Math.abs(dynamics.noMakeup + 9) < 0.5 && + Math.abs(dynamics.autoGain + 18) < 0.5 && + Math.abs(dynamics.noMakeup + 22.5) < 0.5 && dynamics.limitedPeak <= -6 + 1e-3 && dynamics.ceiling <= -1 + 1e-3 && dynamics.ceiling > -1.2,