Compare commits

...
4 Commits
Author SHA1 Message Date
adminandClaude Opus 5.5 1f2b64fb4c Show a Compressor's panel in its node, its tab on a double-click (#380)
game-maker / gate (push) Successful in 4m2s
game-maker / suite (push) Successful in 16m14s
The panel of #379 opened in a window of its own and replaced the tab.
The user wanted the device in the graph instead, and the tab of #378
back on a double-click. The switch Hardware look goes again.

- A node can show a face instead of its rows (`NodeFace`). Its inputs
  sit on the left of it and its outputs on the right, in the middle of
  the height. Each side is as wide as its longest port name.
- A Compressor node shows the panel at full size, 820x280 graph units.
  Its settings and numbers are not listed as rows, the panel has them.
- The knobs and the lever work in the node. A drag, a notch of the
  wheel and a double-click are one undo step each. A press on them
  neither picks nor moves the node, and the wheel does not zoom the
  graph. The number below a knob is only shown, typing stays in the
  tab and the inspector.
- The meter and the LEDs run while the node is in the picture of a sound
  FX or instrument graph that is seen. Out of the picture, folded or
  hidden, the node lets go of its taps.
- The window of one size, `fixedSize`, `refit`, `keptSize` and the rule
  that kept a Compressor out of the tab row are gone again.
- A knob's drag as one undo step lives in `nodeParamDrag.ts` now, for
  the tab and the node. The numbers of a node and what auto-gain says
  moved to `compressorView.ts`.

Found and fixed on the way:
- The tab and the node listen to the same taps. The tab let go of them
  on every frame it was not seen, so the node's meter stood still while
  the tab was open. Now the tab lets go once, and in a frame everything
  unseen lets go before anything seen listens.

Tests:
- Unit: compressor-view (2 new), documents (the test of `keptSize` is
  gone with it)
- e2e 34 (stein2, melektaus-atx was off): ALL PASSED, 122 checks. The
  four checks of #379 are now four of the node: ports beside the panel,
  knobs and lever in the node, the needle when played (21.7 dB with the
  tab open), and the double-click opening the tab.
- Gate (stein2): CHECK EXIT 0, 283 files, 4356 tests
- Counter-checks: an old auto-gain number read as off turns exactly its
  unit test red. A tab that lets go on every frame leaves the node's
  needle at 0 dB and no LED lit (probe on stein2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:41:28 +02:00
adminandClaude Opus 5.5 9917253751 Open a Compressor as a device in a window of one size (#379)
The tab from #378 shows a Compressor as an editor. The user wanted it
to look like a device as well, after a brushed aluminium plug-in, with
a meter of the gain reduction instead of the pictures. The tab stays
and is switched to per node.

- Hardware look is a switch under Settings, on by default for new and
  existing Compressors. Only the editor reads it.
- The panel is 820x280 px and opens straight in a window of that size.
  The window has no sizing corner and no Dock. A pane says so through
  `fixedSize`, and `keptSize` keeps the window's middle when it
  changes to that size.
- A Compressor in this look never sits in the tab row. Opening one
  floats it, and switching the look on moves its tab into the window.
- The panel has chrome knobs (threshold and makeup large), a meter
  with a needle that moves like a VU's, an LCD with the name and the
  reading, a lever with a lamp for auto-gain, and LEDs for the level
  going out. Everything is drawn in code.
- The Knob takes a size and a chrome look, and leaves out its caption
  when it has no name, so the panel engraves its own.
- `autoGainOn` is now `switchOn`, because the new switch reads the
  same way.
- Where parts sit, the meter's scale, the needle's speed, the LEDs and
  the plate's grain are in `hardwareView.ts`, apart from the drawing.

Decided myself:
- The meter's scale runs by the square root, so the first dB is about
  as wide as the last nine. It is red from 10 dB on.
- The LEDs light one every 3 dB from -39 to 0 dB and fall by 20 dB a
  second.
- Switching the look changes the graph's fingerprint, like any other
  setting of a node. The sound FX is built again once.

Findings, fixed on the way:
- Pixi's `arc` goes on from the last point of the path. The meter's
  scale had a line from the corner of the face until each arc got a
  `moveTo` to its own start.
- A text put into a Graphics makes Pixi warn that only Containers take
  children. The LCD is a Container now.
- The first browser check read the needle at one moment and missed the
  short sound. The panel keeps the most its needle and LEDs showed.

Tests:
- Unit: hardware-view (10 new), documents (1 new)
- e2e 34 (melektaus-atx): ALL PASSED, 122 checks (4 new). Played,
  the needle reached 20.9 dB and 5 LEDs lit.
- Gate (melektaus-atx): CHECK EXIT 0, 283 files, 4355 tests (11 new)
- Counter-checks: a window that keeps its corner instead of its
  middle, and a linear meter scale, each turn exactly their test red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:06:56 +02:00
adminandClaude Opus 5.5 2d4909d248 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 <noreply@anthropic.com>
2026-10-10 00:57:26 +02:00
adminandClaude Opus 5.5 1df38bd430 Edit a Compressor in a tab of its own, with knobs and levels (#378)
A Compressor had only its number rows in the node. Its numbers are
easier to set by feel on a dial, and what it does is easier to judge
when the level going in and the level going out are seen side by side.

- Knob is a new widget in the toolkit: a slider bent into a circle,
  its name above and a number field below. A drag up or down turns
  it, Shift a tenth as far, a notch of the wheel a fiftieth of the
  turn, a double-click sets the default, the number takes a typed
  value. A taper gives the low end more of the turn.
- The Compressor's tab opens on a double-click, one per node, like
  the Parametric EQ's. It has a knob for each of the seven numbers,
  greyed while its input is linked.
- Its curve shows level in to level out from -60 to 0 dB. The point
  where it bends sets the threshold, the end of the curve sets the
  ratio that ends there.
- Its levels show the last 4 s on both sides of the node. Where it
  takes more than 0.5 dB away, the stretch below the level coming in
  is tinted. The makeup is left out of that.
- The Compressor is tapped like the EQ. SpectrumTaps listens to more
  than one node at once and reads the loudest sample of the last
  25 ms on each side.
- The node tabs share one map in the editor (nodeTabs), so a third
  kind needs no new plumbing.

Findings:

- CI task 614 for 23922ee was red on the memory check of the stream:
  the tab's renderer grew by 94 MB with nothing decoded. Play mode
  made garbage of its own and was read before a collection. Both
  readings in play now come after one, and the check compares the
  growth streamed with the growth decoded (3.7 MB against 59.8 MB).
- The ratio handle sat on the right edge of the picture, where a
  widget's hit area ends. The curve is inset by the grab radius.

Tests:

- Gate (melektaus-atx): 282 files, 4338 tests (17 new)
- e2e 34 (melektaus-atx): ALL PASSED, 116 checks (6 new)
- Counter-checks: an inverted ratio search, stop() letting go of
  every node and a drag without its least step each fail exactly
  their test.
- Looked at the tab in a screenshot: a blip at -4.4 dB came out at
  -16.7 dB, the gap tinted, the threshold line at -24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:40:12 +02:00
35 changed files with 3171 additions and 193 deletions

No files matched your search

+1 -1
View File
@@ -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, 1941 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1953 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
+22 -10
View File
@@ -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;
+44 -29
View File
@@ -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,15 +1004,31 @@ 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);
const COMPRESS_ROWS = {
/**
* 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 switch is on: as it was set, or its default where it never was. */
export function switchOn(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,
ratio: COMPRESS_RATIO,
knee: COMPRESS_KNEE,
attack: COMPRESS_ATTACK,
release: COMPRESS_RELEASE,
makeup: MAKEUP,
autoGain: AUTO_GAIN,
};
export const SoundFxCompressor: SoundFxNodeDef = effect(
@@ -1020,13 +1036,14 @@ export const SoundFxCompressor: SoundFxNodeDef = effect(
type: 'SoundFxCompressor',
label: 'Compressor',
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.' +
'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. The node is drawn as a device: a knob for each number, a meter of what it takes away and LEDs for the level going out. Double-click it to open its tab: 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',
@@ -1036,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: switchOn(settings, COMPRESS_AUTO_GAIN),
},
]),
},
@@ -1046,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(
@@ -1060,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: switchOn(settings, LIMIT_AUTO_GAIN),
},
]),
},
@@ -1082,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 = {
@@ -1109,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);
@@ -1124,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: switchOn(settings, SATURATION_AUTO_GAIN),
mix,
},
]);
@@ -1421,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;
@@ -1432,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,
+63 -24
View File
@@ -98,7 +98,9 @@ import { PluginsPane } from './plugins/PluginsPane';
import { ManagementPane } from './users/ManagementPane';
import { UsersPane } from './users/UsersPane';
import { RoutingEditor } from './routing/RoutingEditor';
import { EqEditor, eqNodeOf, eqTabLabel } from './soundfx/EqEditor';
import { CompressorEditor } from './soundfx/CompressorEditor';
import { EqEditor } from './soundfx/EqEditor';
import { NODE_TAB_KINDS, nodeOfTab, nodeTabLabel, type NodeTabKind, nodeTabOf } from './soundfx/nodeTabs';
export type CentreTab =
| 'scene'
@@ -122,6 +124,7 @@ export type CentreTab =
| 'variables'
| 'routing'
| 'eq'
| 'compressor'
| 'management';
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
@@ -131,9 +134,11 @@ export type TabId = CentreTab | PluginTabId;
/**
* An open document (#171): an asset by its id, or what a Variables tab shows (#252) by its
* `valuesKey` – as many of those as were opened. The user management (#179) and the master routing
* (#351) are no documents, but they open and close like one. A Parametric EQ has a tab per node (#370).
* (#351) are no documents, but they open and close like one. A Parametric EQ has a tab per node (#370),
* and so has a Compressor (#378).
*/
export type DocumentId = `asset:${string}` | `values:${string}` | `eq:${string}` | 'routing' | 'management';
export type DocumentId =
`asset:${string}` | `values:${string}` | `${NodeTabKind}:${string}` | 'routing' | 'management';
/** The account behind this session; the menu for it sits at the far right of the toolbar. */
export interface EditorAccount {
/** Who is signed in – the menu says so, since the toolbar has no room for a second label. */
@@ -151,7 +156,7 @@ export type StripTab = FixedTab | PluginTabId | DocumentId;
type FixedTab = (typeof FIXED_TABS)[number];
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */
type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'routing' | 'eq' | 'management'>;
type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'routing' | NodeTabKind | 'management'>;
const FIXED_TABS = ['scene', 'game'] as const;
@@ -177,6 +182,7 @@ const PANE_ORDER = [
'variables',
'routing',
'eq',
'compressor',
'management',
] as const;
@@ -203,6 +209,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
variables: 'Variables',
routing: 'Master routing',
eq: 'Parametric EQ',
compressor: 'Compressor',
management: 'User management',
};
@@ -218,10 +225,15 @@ function isDocument(tab: StripTab): tab is DocumentId {
tab === 'routing' ||
tab.startsWith('asset:') ||
tab.startsWith('values:') ||
tab.startsWith('eq:')
nodeTabOfDocument(tab) !== null
);
}
/** The kind of a node's tab (#370, #378) a document is, or null for any other. */
function nodeTabOfDocument(doc: string): NodeTabKind | null {
return NODE_TAB_KINDS.find((kind) => doc.startsWith(`${kind}:`)) ?? null;
}
const MIN_LEFT_WIDTH = 160;
const MAX_LEFT_WIDTH = 500;
const MIN_RIGHT_WIDTH = 200;
@@ -266,10 +278,11 @@ export class Editor implements PlayHost {
readonly variablesEditor: VariablesEditor;
readonly routingEditor: RoutingEditor;
readonly eqEditor: EqEditor;
readonly compressorEditor: CompressorEditor;
/** What each open Variables tab shows, by its document. */
private readonly valueTargets = new Map<DocumentId, ValuesTarget>();
/** The node behind each EQ tab (#370). */
private readonly eqTargets = new Map<DocumentId, { assetId: string; nodeId: string }>();
/** The node behind each tab of an EQ (#370) or a Compressor (#378). */
private readonly nodeTargets = new Map<DocumentId, { assetId: string; nodeId: string }>();
/** The switch per file in `data/plugins/`, in the user management tab (#179). */
readonly pluginsPane = new PluginsPane();
/** The accounts, above the plugins; null unless an administrator is signed in. */
@@ -320,6 +333,8 @@ export class Editor implements PlayHost {
/** Drawn-frame count at the last measurement, not a counter of its own. */
private fpsFrames = 0;
private fpsElapsed = 0;
/** Whether a node's face (#380) moved in the last frame: a meter falling back still needs drawing. */
private facesMoving = false;
/** What this editor registered outside itself, as the calls that take it away again (#177). */
private readonly cleanups: (() => void)[] = [];
private readonly autosave: { flush: () => Promise<void>; stop: () => void };
@@ -368,6 +383,7 @@ export class Editor implements PlayHost {
this.variablesEditor = new VariablesEditor(state);
this.routingEditor = new RoutingEditor(state);
this.eqEditor = new EqEditor(state);
this.compressorEditor = new CompressorEditor(state);
this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state);
this.tilemapPanel = new TilemapPanel(state);
@@ -491,6 +507,7 @@ export class Editor implements PlayHost {
variables: this.variablesEditor,
routing: this.routingEditor,
eq: this.eqEditor,
compressor: this.compressorEditor,
management: this.managementPane,
};
// Following a call goes on in the same tab: the tab changes its document, not its place.
@@ -597,12 +614,13 @@ export class Editor implements PlayHost {
if (!this.valueTargets.has(doc)) this.valueTargets.set(doc, target);
this.openDocument(doc);
});
// A tab per Parametric EQ node (#370), opened by a double-click on it.
state.on('eq', () => {
const target = state.openedEq;
if (!target) return;
const doc: DocumentId = `eq:${target.assetId}:${target.nodeId}`;
if (!this.eqTargets.has(doc)) this.eqTargets.set(doc, target);
// A tab per Parametric EQ (#370) or Compressor (#378) node, opened by a double-click on it.
state.on('nodeTab', () => {
const target = state.openedNode;
const kind = target && nodeTabOf(target.type);
if (!target || !kind) return;
const doc: DocumentId = `${kind}:${target.assetId}:${target.nodeId}`;
if (!this.nodeTargets.has(doc)) this.nodeTargets.set(doc, target);
this.openDocument(doc);
});
// The tabs play through the buses and the master as the game would (#325).
@@ -621,8 +639,9 @@ export class Editor implements PlayHost {
for (const doc of this.documents.docs) this.tabs.removeTab(doc);
this.documents = { docs: [], active: null };
this.valueTargets.clear();
this.eqTargets.clear();
this.nodeTargets.clear();
this.eqEditor.close();
this.compressorEditor.close();
this.showTab('scene');
});
state.on('mode', () => this.editorRun.modeChanged());
@@ -886,7 +905,10 @@ export class Editor implements PlayHost {
if (kind && kind in this.assetEditors)
this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
this.valueTargets.delete(doc);
if (this.eqTargets.delete(doc) && kind === 'eq') this.eqEditor.close();
if (this.nodeTargets.delete(doc)) {
if (kind === 'eq') this.eqEditor.close();
if (kind === 'compressor') this.compressorEditor.close();
}
if (doc === 'routing') this.routingEditor.close();
}
@@ -1091,9 +1113,10 @@ export class Editor implements PlayHost {
return target && valuesTitle(this.state, target) !== null ? 'variables' : null;
}
if (doc === 'management' || doc === 'routing') return doc;
if (doc.startsWith('eq:')) {
const target = this.eqTargets.get(doc);
return target && eqNodeOf(this.state.project, target.assetId, target.nodeId) ? 'eq' : null;
const nodeTab = nodeTabOfDocument(doc);
if (nodeTab) {
const target = this.nodeTargets.get(doc);
return target && nodeOfTab(this.state.project, nodeTab, target.assetId, target.nodeId) ? nodeTab : null;
}
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
const kind = type ? TAB_OF_ASSET[type] : undefined;
@@ -1119,16 +1142,18 @@ export class Editor implements PlayHost {
// Read on every showing: an account may have been changed from another tab in the meantime.
if (kind === 'management') void this.usersPane?.reload();
if (kind === 'routing') this.routingEditor.open();
const eq = this.eqTargets.get(doc);
if (kind === 'eq' && eq) this.eqEditor.show(eq.assetId, eq.nodeId);
const node = this.nodeTargets.get(doc);
if (kind === 'eq' && node) this.eqEditor.show(node.assetId, node.nodeId);
if (kind === 'compressor' && node) this.compressorEditor.show(node.assetId, node.nodeId);
}
/** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */
private labelOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
if (doc === 'routing') return TAB_LABELS.routing;
const eq = this.eqTargets.get(doc);
if (eq) return eqTabLabel(this.state.project, eq.assetId, eq.nodeId);
const node = this.nodeTargets.get(doc);
const nodeTab = nodeTabOfDocument(doc);
if (node && nodeTab) return nodeTabLabel(this.state.project, nodeTab, node.assetId, node.nodeId);
const target = this.valueTargets.get(doc);
if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? '';
const asset = findAsset(this.state.project, doc.slice('asset:'.length));
@@ -1139,7 +1164,7 @@ export class Editor implements PlayHost {
private iconOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG;
if (doc === 'routing') return ASSET_ICON_SVG.bus;
if (doc.startsWith('eq:')) return ASSET_ICON_SVG.soundfx;
if (nodeTabOfDocument(doc)) return ASSET_ICON_SVG.soundfx;
const target = this.valueTargets.get(doc);
if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables;
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
@@ -1305,6 +1330,18 @@ export class Editor implements PlayHost {
if (this.shows('sound')) this.soundEditor.tick();
if (this.shows('eq')) this.eqEditor.tick();
else this.eqEditor.hideSpectrum();
// A Compressor's tab and its node (#380) listen to the same taps: what is not seen lets go of
// them first, so it cannot take them from what is.
const faces = [
['soundfx', this.soundFxEditor.canvas],
['instrument', this.instrumentEditor.canvas],
] as const;
if (!this.shows('compressor')) this.compressorEditor.hideLevels();
for (const [kind, canvas] of faces) if (!this.shows(kind)) canvas.hideFaces();
if (this.shows('compressor')) this.compressorEditor.tick();
this.facesMoving = false;
for (const [kind, canvas] of faces)
if (this.shows(kind)) this.facesMoving = canvas.tickFaces() || this.facesMoving;
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
// to do it, and its states move just the same.
if (this.shows('statemachine') && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
@@ -1341,7 +1378,9 @@ export class Editor implements PlayHost {
(this.shows('pattern') && this.patternEditor.isPlaying) ||
(this.shows('song') && this.songEditor.isPlaying) ||
(this.shows('sound') && this.soundEditor.isPlaying) ||
(this.shows('eq') && this.eqEditor.hasSpectrum)
(this.shows('eq') && this.eqEditor.hasSpectrum) ||
(this.shows('compressor') && this.compressorEditor.moving) ||
this.facesMoving
);
}
+12 -3
View File
@@ -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);
}
+76
View File
@@ -1,6 +1,8 @@
import type { Container } from 'pixi.js';
import { NameTransitionCommand } from '../../core/commands/graphCommands';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { currentNodePalette } from '../graph/NodePalette';
import { CompressorPanel } from '../soundfx/CompressorPanel';
import { rectOf } from '../textProbe';
import { centreOf, snapButton, type HookContext } from './hookHelpers';
@@ -118,6 +120,7 @@ export function graphHooks({ state, editor }: HookContext) {
params: () => editor.soundFxEditor.previewValues,
setParam: (key: string, value: number) => editor.soundFxEditor.setPreviewValue(key, value),
nodeAt: (nodeId: string) => editor.soundFxEditor.canvas.nodeGlobal(nodeId),
fit: () => editor.soundFxEditor.canvas.fit(),
},
/** The Parametric EQ's tab (#367, #370): for which node it is showing, its bands and where to aim. */
eq: {
@@ -148,6 +151,79 @@ export function graphHooks({ state, editor }: HookContext) {
*/
spectrum: () => ({ ...editor.eqEditor.curve.seen, taps: soundLibrary.spectrumTaps() }),
},
/** The Compressor's tab (#378): its node, its numbers and knobs, and where to aim on the curve. */
compressor: {
shown: () => (editor.tab === 'compressor' ? editor.compressorEditor.shownFor : null),
numbers: () => editor.compressorEditor.numbers(),
/** Each knob's value, and whether it can be turned (a linked input greys it). */
knobs: () =>
Object.fromEntries(
Object.entries(editor.compressorEditor.knobs).map(([key, knob]) => [
key,
{ value: knob.value, enabled: knob.enabled, field: knob.field.value },
]),
),
dialAt: (key: keyof typeof editor.compressorEditor.knobs) =>
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.
*/
levels: () => ({ ...editor.compressorEditor.seen, taps: soundLibrary.spectrumTaps() }),
/**
* The panel a Compressor node shows in the sound FX graph (#379, #380): whether it is shown and
* where, its knobs' dials and numbers, the reduction the needle shows and is heading for, the
* LEDs lit, the most of both so far, the auto-gain lever, the LCD, and the node's ports.
*/
panel: (nodeId: string) => {
const canvas = editor.soundFxEditor.canvas;
const panel = canvas.faceOf(nodeId)?.widget;
if (!(panel instanceof CompressorPanel)) return null;
const seen = (node: Container | null): boolean => !node || (node.visible && seen(node.parent));
const node = canvas.nodeScreen(nodeId);
const ports = (keys: string[], isOutput: boolean) =>
Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)]));
return {
shown: seen(panel),
/** On screen, at the graph's zoom; `size` is the panel's own. */
rect: (() => {
const from = panel.toGlobal({ x: 0, y: 0 });
const to = panel.toGlobal({ x: panel.w, y: panel.h });
return { x: from.x, y: from.y, w: to.x - from.x, h: to.y - from.y };
})(),
size: { w: panel.w, h: panel.h },
node: node?.rect ?? null,
inputs: ports(node?.inputs ?? [], false),
outputs: ports(node?.outputs ?? [], true),
dials: Object.fromEntries(
Object.entries(panel.knobs).map(([key, knob]) => [key, knob.dialGlobal()]),
),
fields: Object.fromEntries(
Object.entries(panel.knobs).map(([key, knob]) => [key, centreOf(knob.field)]),
),
needle: { shown: panel.meter.shown, target: panel.meter.target },
leds: panel.leds.litCount,
seen: panel.seen,
autoGain: { on: panel.autoGain.lit, says: panel.autoGain.says.text, at: centreOf(panel.autoGain) },
lcd: panel.lcdText,
};
},
},
instrument: {
current: () => editor.instrumentEditor.currentAssetId,
info: () => editor.instrumentEditor.shownInfo,
+3
View File
@@ -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),
+20 -6
View File
@@ -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) };
}
+41 -4
View File
@@ -30,7 +30,8 @@ import { graphLookup } from '../../core/model/graphLookup';
import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { soundFxBuildFor } from '../../core/soundfx/compile';
import { SoundFxParametricEq } from '../../core/soundfx/nodes';
import { nodeFaceFor } from '../soundfx/nodeFaces';
import { nodeTabOf } from '../soundfx/nodeTabs';
import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile';
import { masterRoutingProblems } from '../../core/music/projectRouting';
import { routingProblems } from '../../core/music/routing';
@@ -79,6 +80,7 @@ import { NodePalette, type PaletteAction } from './NodePalette';
import { splitEntries } from './splitMenu';
import { type PaletteEntry, paletteEntries } from './paletteEntries';
import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView';
import type { NodeFace } from './nodeFace';
import { drawGraphGrid, FIT_MARGIN, inView, ZOOM_LIMITS, zoomedAbout } from './graphView';
import { GraphWiring } from './wiring';
@@ -330,7 +332,7 @@ export class GraphCanvas extends Widget {
let view = this.views.get(node.id);
if (!view || view.node !== node) {
view?.destroy({ children: true });
view = new NodeView(node, def);
view = new NodeView(node, def, nodeFaceFor(this.state, asset.id, node));
view.on('pointerdown', (e: FederatedPointerEvent) => this.onNodePointerDown(view!, e));
this.views.set(node.id, view);
this.nodeLayer.addChild(view);
@@ -506,6 +508,41 @@ export class GraphCanvas extends Widget {
requestRender();
}
/**
* A frame on for the nodes drawn with a face (#380): those in the picture read what they hear,
* the others stop listening. Whether any of them still moves.
*/
tickFaces(): boolean {
let moving = false;
for (const view of this.views.values()) {
if (!view.face) continue;
if (view.visible && !view.collapsed) moving = view.face.tick() || moving;
else view.face.hide();
}
return moving;
}
/** The graph is not seen: no face listens. */
hideFaces(): void {
for (const view of this.views.values()) view.face?.hide();
}
/** A node's face, for the check scripts. */
faceOf(nodeId: string): NodeFace | null {
return this.views.get(nodeId)?.face ?? null;
}
/** A node's box on screen and the keys of its ports, for the check scripts. */
nodeScreen(nodeId: string): { rect: Rect; inputs: string[]; outputs: string[] } | null {
const view = this.views.get(nodeId);
if (!view) return null;
return {
rect: globalRect(view, view.w, view.h),
inputs: view.def.inputs.map((p) => p.key),
outputs: view.def.outputs.map((p) => p.key),
};
}
/** Takes the nodes outside the picture out of the drawing – see `inView`. */
private cull(): void {
const view = { pan: this.pan, zoom: this.zoom };
@@ -670,8 +707,8 @@ export class GraphCanvas extends Widget {
}
private openCalledBehavior(node: GraphNode): boolean {
if (node.type === SoundFxParametricEq.type) {
this.state.openEq(this.assetId, node.id);
if (nodeTabOf(node.type)) {
this.state.openNodeTab(this.assetId, node.id, node.type);
return true;
}
// A node of the master routing (#351) stands for its asset; a bus has no tab, its node shows it already.
+78 -12
View File
@@ -33,6 +33,7 @@ import {
} from '../../ui';
import { keyLabel, referenceLabel } from '../inspector/fieldFactory';
import { collapsedNodeWidth } from './folding';
import type { NodeFace } from './nodeFace';
import { inputTextRoom, valueSpan } from './portRow';
/** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */
@@ -48,6 +49,8 @@ const CHEVRON_SLOT = 14;
const TITLE_X = PAD + CHEVRON_SLOT;
/** Right of a collapsed title: the side's link dot sits centred on the edge. */
const COLLAPSED_TITLE_END = PAD + PORT_RADIUS;
/** From a node's edge to the name of a port. */
const PORT_TEXT_X = PAD + PORT_RADIUS + 4;
function categoryColor(category: NodeCategory): number {
const c = Theme.colors;
@@ -163,6 +166,8 @@ export class NodeView extends Widget {
private textsOf = '';
private drawnOf = '';
private pictureOf = '';
/** How wide the sides of a face are, with the pins and their names (#380). */
private sides = { left: 0, right: 0 };
/** How often the texts and the box were really made, for the checks. */
readonly made = { texts: 0, box: 0 };
@@ -175,6 +180,8 @@ export class NodeView extends Widget {
constructor(
readonly node: GraphNode,
def: NodeDef,
/** What the node shows instead of its rows (#380), if anything. */
readonly face: NodeFace | null = null,
) {
super();
this.def = def;
@@ -185,6 +192,7 @@ export class NodeView extends Widget {
this.picture.context.batchMode = 'batch';
this.ports.context.batchMode = 'batch';
this.addChild(this.box, this.picture, this.ports);
if (face) this.addChild(face.widget);
this.sync(node, { nodes: [node], edges: [] }, null, def);
}
@@ -251,16 +259,37 @@ export class NodeView extends Widget {
)
this.inlineTexts.set(port.key, NULL_TEXT);
}
// A face shows the settings and the values itself: its sides name the ports only.
if (this.shownFace()) {
this.paramLines = [];
this.inlineTexts = new Map();
}
this.linked = {
inputs: graph.edges.some((e) => e.to.node === node.id),
outputs: graph.edges.some((e) => e.from.node === node.id),
};
const picture = this.shownPicture();
const face = this.shownFace();
if (face) this.sides = this.faceSides();
const height = this.collapsed
? TITLE_HEIGHT
: this.rowsEnd() + PAD + (picture ? picture.height + PAD / 2 : 0);
: face
? this.headerHeight() + this.faceSize().h + PAD
: this.rowsEnd() + PAD + (picture ? picture.height + PAD / 2 : 0);
// Sized before the texts: output labels are placed against the right edge.
this.resize(this.collapsed ? this.collapsedWidth() : NODE_WIDTH, height);
this.resize(
this.collapsed
? this.collapsedWidth()
: face
? this.sides.left + this.faceSize().w + this.sides.right
: NODE_WIDTH,
height,
);
if (this.face) {
this.face.widget.visible = face !== null;
if (face) this.placeFace(face);
else this.face.hide();
}
const texts = this.textSignature();
if (texts !== this.textsOf) {
this.textsOf = texts;
@@ -400,6 +429,36 @@ export class NodeView extends Widget {
return this.headerHeight() + this.rowCount() * ROW_HEIGHT;
}
/** The node's face, while the node is open. */
private shownFace(): NodeFace | null {
return (!this.collapsed && this.face) || null;
}
private faceSize(): { w: number; h: number } {
return this.face?.widget.measure() ?? { w: 0, h: 0 };
}
/** Each side of a face as wide as the longest name on it. */
private faceSides(): { left: number; right: number } {
const side = (ports: PortDef[]) =>
Math.ceil(
PORT_TEXT_X +
Math.max(
0,
...ports.map((p) => CanvasTextMetrics.measureText(p.label, textStyle(rowStyle(p))).width),
) +
PAD,
);
return { left: side(this.def.inputs), right: side(this.def.outputs) };
}
private placeFace(face: NodeFace): void {
const size = this.faceSize();
face.widget.position.set(this.sides.left, this.headerHeight());
face.widget.resize(size.w, size.h);
face.sync();
}
/** The node's picture, while the node is open. */
private shownPicture(): NodePicture | null {
return (!this.collapsed && this.def.picture) || null;
@@ -444,8 +503,10 @@ export class NodeView extends Widget {
return { x: isOutput ? this.w : 0, y: this.collapsed ? TITLE_HEIGHT / 2 : this.rowY(index) };
}
/** A face has its ports in the middle of its sides. */
private rowY(index: number): number {
return this.headerHeight() + index * ROW_HEIGHT + ROW_HEIGHT / 2;
const top = this.shownFace() ? (this.faceSize().h - this.rowCount() * ROW_HEIGHT) / 2 : 0;
return this.headerHeight() + top + index * ROW_HEIGHT + ROW_HEIGHT / 2;
}
/**
@@ -506,15 +567,13 @@ export class NodeView extends Widget {
add(line, 'small', PAD + 2, y + 2);
y += ROW_HEIGHT;
}
if (this.problem) {
add(this.problem, 'warning', PAD + 2, y + 2);
y += ROW_HEIGHT;
}
const inputX = PAD + PORT_RADIUS + 4;
if (this.problem) add(this.problem, 'warning', PAD + 2, y + 2);
const inputX = PORT_TEXT_X;
const face = this.shownFace() !== null;
for (let i = 0; i < this.rowCount(); i++) {
const input = this.def.inputs[i];
const output = this.def.outputs[i];
const rowTop = y + i * ROW_HEIGHT + 2;
const rowTop = this.rowY(i) - ROW_HEIGHT / 2 + 2;
const outputText = output
? add(
output.label,
@@ -522,15 +581,17 @@ export class NodeView extends Widget {
this.w - PAD - PORT_RADIUS - 4,
rowTop,
'right',
NODE_WIDTH / 2 - 10,
face ? this.sides.right : NODE_WIDTH / 2 - 10,
)
: null;
const room = inputTextRoom(inputX, NODE_WIDTH / 2 + 20, outputText ? outputText.x : null);
const room = face
? this.sides.left
: inputTextRoom(inputX, NODE_WIDTH / 2 + 20, outputText ? outputText.x : null);
const inputText = input
? add(this.inputText(input), rowStyle(input), inputX, rowTop, 'left', room)
: null;
this.rowTexts.push({ input: inputText, output: outputText });
if (output && outputText && output.type !== 'exec' && !output.ghost) {
if (!face && output && outputText && output.type !== 'exec' && !output.ghost) {
const inputEnd = inputText ? inputText.x + inputText.width : PAD;
this.valueSlots.set(output.key, { ...valueSpan(inputEnd, outputText.x), top: rowTop + 1 });
}
@@ -538,6 +599,11 @@ export class NodeView extends Widget {
this.placeValues();
}
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
this.face?.hide();
super.destroy(options);
}
override redraw(): void {
this.drawPicture();
const drawn = this.drawSignature();
+15
View File
@@ -0,0 +1,15 @@
import type { Widget } from '../../ui';
/**
* What a node draws instead of its rows (#380): a widget of one size between its inputs on the
* left and its outputs on the right, worked right there in the graph.
*/
export interface NodeFace {
readonly widget: Widget;
/** After every change to the model: the widget shows the node as it now is. */
sync(): void;
/** A frame on while the node is in the picture. Whether it still moves. */
tick(): boolean;
/** Out of the picture, folded or gone: it stops listening. */
hide(): void;
}
+523
View File
@@ -0,0 +1,523 @@
/**
* 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 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.
*/
import { Container, type FederatedPointerEvent, Graphics, type Text } from 'pixi.js';
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { findGraphAsset, type Project } from '../../core/model/Project';
import { AUTO_GAIN_REFERENCE_DB } from '../../core/soundfx/dynamics';
import { COMPRESS_AUTO_GAIN, SoundFxCompressor, tapId } from '../../core/soundfx/nodes';
import { soundLibrary } from '../../viewport/SoundLibrary';
import {
Checkbox,
clamp,
crispText,
HStack,
Knob,
Label,
snapToStep,
Spacer,
Theme,
textStyle,
VStack,
Widget,
} from '../../ui';
import type { EditorState } from '../state';
import { TabHeader } from '../TabHeader';
import {
COMPRESSOR_KEYS,
COMPRESSOR_KNOBS,
COMPRESSOR_ROWS,
type CompressorKey,
type CompressorNumbers,
autoGainText,
compressorGainDb,
compressorNumbers,
CURVE_FLOOR_DB,
curveDbAtX,
curveDbAtY,
curveOutDb,
curvePoints,
curveX,
curveY,
historyX,
LEVEL_FLOOR_DB,
LEVEL_WINDOW,
LevelHistory,
levelY,
MIN_CURVE,
MIN_LEVELS_WIDTH,
picturesLayout,
ratioFor,
ratioHandle,
reductionShapes,
thresholdHandle,
} from './compressorView';
import { NodeParamDrag } from './nodeParamDrag';
type Key = CompressorKey;
const KEYS = COMPRESSOR_KEYS;
const ROWS = COMPRESSOR_ROWS;
const KNOBS = COMPRESSOR_KNOBS;
const HANDLE_RADIUS = 6;
const GRAB_RADIUS = HANDLE_RADIUS + 4;
const CURVE_STEPS = 120;
/** The dB lines of both pictures. */
const GRID_DB = [0, -12, -24, -36, -48];
/** What the pictures ask of the tab around them. */
interface CompressorHost {
numbers(): CompressorNumbers;
preview(key: Key, value: number): void;
commit(key: Key, value: number): void;
wired(key: Key): boolean;
}
/** Whether a press is near a point. */
const near = (at: { x: number; y: number }, x: number, y: number) =>
Math.hypot(at.x - x, at.y - y) <= GRAB_RADIUS;
/** dB labels at the lines of a picture; hidden where the line is not in it. */
function gridLabels(parent: Container, count: number): Text[] {
const muted = textStyle('small').clone();
muted.fill = Theme.colors.textMuted;
return Array.from({ length: count }, () => {
const text = crispText({ text: '', style: muted });
text.eventMode = 'none';
parent.addChild(text);
return text;
});
}
/** The level coming in (grey) and going out (coloured) over the last seconds, newest at the right. */
class LevelPicture extends Widget {
private readonly bg = new Graphics();
private readonly lines = new Graphics();
private readonly labels: Text[];
readonly history = new LevelHistory();
/** The time of the newest reading, in seconds. */
private now = 0;
constructor(private readonly host: CompressorHost) {
super();
this.minWidth = MIN_LEVELS_WIDTH;
this.minHeight = MIN_CURVE;
this.addChild(this.bg, this.lines);
this.labels = gridLabels(this, GRID_DB.length);
}
read(now: number, levels: Parameters<LevelHistory['push']>[1]): void {
this.now = now;
this.history.push(now, levels, compressorGainDb(this.host.numbers()));
this.redraw();
}
clear(): void {
this.history.clear();
this.redraw();
}
override redraw(): void {
const c = Theme.colors;
const { w, h } = this;
this.bg.clear().rect(0, 0, w, h).fill(c.graphBackground).stroke({ color: c.border, width: 1 });
GRID_DB.forEach((db, i) => {
const y = Math.round(levelY(db, h)) + 0.5;
this.bg.moveTo(0, y).lineTo(w, y);
const text = this.labels[i]!;
text.text = `${db}`;
text.position.set(3, y - text.height - 1);
});
this.bg.stroke({ color: c.border, width: 1, alpha: 0.6 });
const numbers = this.host.numbers();
const threshold = Math.round(levelY(numbers.threshold, h)) + 0.5;
this.bg.moveTo(0, threshold).lineTo(w, threshold).stroke({ color: c.accent, width: 1, alpha: 0.5 });
this.lines.clear();
const points = this.history.all;
if (points.length < 2) return;
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],
['out', c.portAudio, 2],
] as const) {
points.forEach((p, i) => {
const x = historyX(p.t, this.now, w);
const y = levelY(p[side], h);
if (i === 0) this.lines.moveTo(x, y);
else this.lines.lineTo(x, y);
});
this.lines.stroke({ color, width });
}
}
}
/** The curve from level in (across) to level out (up), with its threshold and ratio to drag. */
class CurvePicture extends Widget {
/** The curve's square, inset so a handle on its edge is grabbed whole. */
private readonly square = new Container();
private readonly bg = new Graphics();
private readonly lines = new Graphics();
private readonly labels: Text[];
private drag: Key | null = null;
/** The level coming in now, for the point on the curve. */
private heard = -Infinity;
constructor(private readonly host: CompressorHost) {
super();
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);
this.addChild(this.square);
this.labels = gridLabels(this.square, GRID_DB.length);
this.on('pointerdown', (e: FederatedPointerEvent) => this.press(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.move(e));
this.onPointerRelease(() => this.release());
}
private get size(): number {
return Math.min(this.w, this.h) - GRAB_RADIUS * 2;
}
/** Where a handle is on screen – the check scripts aim with it. */
handleGlobal(key: 'threshold' | 'ratio'): { x: number; y: number } {
const n = this.host.numbers();
const at = key === 'threshold' ? thresholdHandle(n, this.size) : ratioHandle(n, this.size);
const origin = this.square.getGlobalPosition();
return { x: origin.x + at.x, y: origin.y + at.y };
}
/** Where a level in and a level out are on screen. */
pointGlobal(inDb: number, outDb: number): { x: number; y: number } {
const origin = this.square.getGlobalPosition();
return { x: origin.x + curveX(inDb, this.size), y: origin.y + curveY(outDb, this.size) };
}
hear(inDb: number): void {
if (inDb === this.heard) return;
this.heard = inDb;
this.redraw();
}
private press(e: FederatedPointerEvent): void {
if (e.button !== 0) return;
const at = this.square.toLocal(e.global);
const n = this.host.numbers();
if (!this.host.wired('ratio') && near(ratioHandle(n, this.size), at.x, at.y)) this.drag = 'ratio';
else if (!this.host.wired('threshold') && near(thresholdHandle(n, this.size), at.x, at.y))
this.drag = 'threshold';
}
private move(e: FederatedPointerEvent): void {
if (!this.drag) return;
const at = this.square.toLocal(e.global);
const n = this.host.numbers();
if (this.drag === 'threshold') {
const row = ROWS.threshold;
this.host.preview(
'threshold',
clamp(snapToStep(curveDbAtX(at.x, this.size), row.step), row.min, row.max),
);
return;
}
this.host.preview('ratio', ratioFor(curveDbAtY(at.y, this.size), n, ROWS.ratio));
}
private release(): void {
const key = this.drag;
this.drag = null;
if (key) this.host.commit(key, this.host.numbers()[key]);
}
override redraw(): void {
const c = Theme.colors;
const size = this.size;
this.bg.clear().rect(0, 0, size, size).fill(c.graphBackground).stroke({ color: c.border, width: 1 });
GRID_DB.forEach((db, i) => {
const at = Math.round(curveX(db, size)) + 0.5;
this.bg.moveTo(at, 0).lineTo(at, size);
const y = Math.round(curveY(db, size)) + 0.5;
this.bg.moveTo(0, y).lineTo(size, y);
const text = this.labels[i]!;
text.text = `${db}`;
text.visible = db > CURVE_FLOOR_DB;
text.position.set(3, y - text.height - 1);
});
this.bg.stroke({ color: c.border, width: 1, alpha: 0.6 });
// What a curve without compressing would be.
this.bg.moveTo(0, size).lineTo(size, 0).stroke({ color: c.textMuted, width: 1, alpha: 0.4 });
const n = this.host.numbers();
this.lines.clear();
this.lines.poly(curvePoints(n, size, CURVE_STEPS), false).stroke({ color: c.accent, width: 2 });
for (const [key, at] of [
['threshold', thresholdHandle(n, size)],
['ratio', ratioHandle(n, size)],
] as const) {
this.lines.circle(at.x, at.y, HANDLE_RADIUS).fill(this.host.wired(key) ? c.control : c.accent);
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);
this.lines.circle(x, y, 4).fill(c.portAudio);
}
}
}
/** 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).
*/
export class CompressorEditor extends VStack implements CompressorHost {
readonly levels: LevelPicture;
readonly curve: CurvePicture;
readonly knobs = {} as Record<Key, Knob>;
/** 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 = '';
private readonly drag: NodeParamDrag;
/** Whether the tab holds its node's taps: let go of once, not on every frame it is not seen. */
private listening = false;
private readonly title = new Label('Compressor', { style: 'bodyBold' });
private readonly header = new TabHeader();
private readonly body = new VStack({ gap: Theme.padding, padding: Theme.padding * 2 });
/** Readings so far, and the most taken away in one (checks). */
seen = { frames: 0, in: -Infinity, out: -Infinity, reduction: 0 };
constructor(private readonly state: EditorState) {
super();
this.drag = new NodeParamDrag(state, 'compressor-knob');
this.levels = new LevelPicture(this);
this.curve = new CurvePicture(this);
this.header.add(this.title);
this.header.add(new Spacer(), 1);
// 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];
const knob = new Knob(look.label, row.default, {
min: row.min,
max: row.max,
step: row.step,
taper: look.taper,
default: row.default,
decimals: look.decimals,
onInput: (value) => this.preview(key, value),
onChange: (value) => this.commit(key, value),
});
this.knobs[key] = knob;
knobs.add(knob);
}
// 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(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());
}
show(assetId: string, nodeId: string): void {
if (assetId !== this.assetId || nodeId !== this.nodeId) {
this.drag.forget();
this.hideLevels();
this.levels.clear();
this.assetId = assetId;
this.nodeId = nodeId;
this.seen = { frames: 0, in: -Infinity, out: -Infinity, reduction: 0 };
}
this.sync();
}
close(): void {
this.drag.cancel();
this.hideLevels();
const node = this.node();
if (node) this.drag.restore(node);
this.assetId = '';
this.nodeId = '';
}
get shownFor(): { assetId: string; nodeId: string } | null {
return this.nodeId ? { assetId: this.assetId, nodeId: this.nodeId } : null;
}
/** Only called while the tab is seen: a reading of both sides, a frame on. */
tick(): void {
if (!this.nodeId) return;
const id = tapId(this.assetId, this.nodeId);
soundLibrary.listen(id);
this.listening = true;
const levels = soundLibrary.nodeLevels(id, LEVEL_WINDOW);
if (!levels && !this.levels.history.heard) return;
this.levels.read(performance.now() / 1000, levels);
this.curve.hear(levels?.in ?? -Infinity);
if (levels && levels.in > LEVEL_FLOOR_DB) {
this.seen = {
frames: this.seen.frames + 1,
in: Math.max(this.seen.in, levels.in),
out: Math.max(this.seen.out, levels.out),
reduction: Math.max(this.seen.reduction, levels.in - (levels.out - compressorGainDb(this.numbers()))),
};
}
}
/** The tab is not seen: no analyser is fed for it. */
hideLevels(): void {
if (this.listening) soundLibrary.unlisten(tapId(this.assetId, this.nodeId));
this.listening = false;
this.curve.hear(-Infinity);
}
/** Whether the level picture is moving, so the tab has to be drawn every frame. */
get moving(): boolean {
return this.levels.history.heard;
}
private node(): GraphNode | null {
return compressorNodeOf(this.state.project, this.assetId, this.nodeId);
}
numbers(): CompressorNumbers {
return compressorNumbers(this.node()?.params ?? {});
}
/** The dB auto-gain gives at these numbers, or that it is off. */
private showAutoGainDb(n: CompressorNumbers): void {
const text = autoGainText(n);
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 {
return compressorWired(this.state.project, this.assetId, this.nodeId, key);
}
/** Writes a number into the node as it is dragged; `commit` makes the undo step of it. */
preview(key: Key, value: number): void {
const node = this.node();
if (!node) return;
this.drag.preview(node, key, value);
this.showAutoGainDb(this.numbers());
this.levels.invalidate();
this.curve.invalidate();
}
commit(key: Key, value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, value);
}
private sync(): void {
if (!this.node()) return;
const numbers = this.numbers();
for (const key of KEYS) {
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);
this.relayout();
}
this.levels.invalidate();
this.curve.invalidate();
}
}
/** Whether a link decides one of a Compressor's numbers: its knob is greyed then. */
export function compressorWired(project: Project, assetId: string, nodeId: string, key: Key): boolean {
const graph = findGraphAsset(project, assetId)?.graph;
return Boolean(graph?.edges.some((e) => e.to.node === nodeId && e.to.port === key));
}
/** The Compressor node a tab is about, or null once it, or its asset, is gone. */
export function compressorNodeOf(project: Project, assetId: string, nodeId: string): GraphNode | null {
const node = findGraphAsset(project, assetId)?.graph.nodes.find((n) => n.id === nodeId);
return node?.type === SoundFxCompressor.type ? node : null;
}
/** What a Compressor's tab says: its asset's name, and which one it is when the asset has more. */
export function compressorTabLabel(project: Project, assetId: string, nodeId: string): string {
const asset = findGraphAsset(project, assetId);
if (!asset) return '';
const all = asset.graph.nodes.filter((n) => n.type === SoundFxCompressor.type);
const index = all.findIndex((n) => n.id === nodeId);
return all.length > 1 ? `${asset.name} · Compressor ${index + 1}` : `${asset.name} · Compressor`;
}
+96
View File
@@ -0,0 +1,96 @@
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { findGraphAsset } from '../../core/model/Project';
import { COMPRESS_AUTO_GAIN, tapId } from '../../core/soundfx/nodes';
import { soundLibrary } from '../../viewport/SoundLibrary';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { compressorNodeOf, compressorWired } from './CompressorEditor';
import { CompressorPanel, type PanelHost } from './CompressorPanel';
import {
autoGainText,
type CompressorKey,
compressorGainDb,
compressorNumbers,
LEVEL_WINDOW,
} from './compressorView';
import { gainReduction } from './hardwareView';
import { NodeParamDrag } from './nodeParamDrag';
/**
* A Compressor node drawn as its device (#380): the panel of #379, turned right in the graph. Each
* node has its own, and its meter runs while the node is in the picture.
*/
export class CompressorFace implements NodeFace, PanelHost {
readonly widget: CompressorPanel;
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 CompressorPanel(this);
this.drag = new NodeParamDrag(state, 'compressor-face');
}
private node(): GraphNode | null {
return compressorNodeOf(this.state.project, this.assetId, this.nodeId);
}
sync(): void {
const node = this.node();
if (!node) return;
const project = this.state.project;
const numbers = compressorNumbers(node.params);
this.widget.sync(
numbers,
(key) => compressorWired(project, this.assetId, this.nodeId, key),
findGraphAsset(project, this.assetId)?.name ?? '',
);
this.widget.autoGain.set(numbers.autoGain, autoGainText(numbers));
}
tick(): boolean {
const node = this.node();
if (!node) return false;
const id = tapId(this.assetId, this.nodeId);
soundLibrary.listen(id);
this.listening = true;
const levels = soundLibrary.nodeLevels(id, LEVEL_WINDOW);
const now = performance.now() / 1000;
const dt = this.lastTick ? now - this.lastTick : 0;
this.lastTick = now;
const numbers = compressorNumbers(node.params);
this.widget.read(gainReduction(levels, compressorGainDb(numbers)), levels?.out ?? -Infinity, dt);
return this.widget.moving;
}
hide(): void {
if (!this.listening) return;
this.listening = false;
this.lastTick = 0;
soundLibrary.unlisten(tapId(this.assetId, this.nodeId));
this.widget.reset();
}
preview(key: CompressorKey, value: number): void {
const node = this.node();
if (!node) return;
this.drag.preview(node, key, value);
const numbers = compressorNumbers(node.params);
this.widget.autoGain.set(numbers.autoGain, autoGainText(numbers));
}
commit(key: CompressorKey, value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, value);
}
switchAutoGain(on: boolean): void {
if (!this.node()) return;
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, COMPRESS_AUTO_GAIN.key, on));
}
}
+464
View File
@@ -0,0 +1,464 @@
/**
* The Compressor as a device (#379): a brushed plate of one size with chrome knobs, a meter whose
* needle shows what is taken away, a lever for auto-gain and LEDs for the level going out. It is
* what its node shows in the graph (#380).
*/
import {
Container,
FillGradient,
type FederatedPointerEvent,
Graphics,
type Text,
type TextStyle,
} from 'pixi.js';
import { crispText, Knob, textStyle, Widget } from '../../ui';
import {
COMPRESSOR_KEYS,
COMPRESSOR_KNOBS,
COMPRESSOR_ROWS,
type CompressorKey,
type CompressorNumbers,
} from './compressorView';
import {
type Box,
brushStrokes,
KNOB_CAPTION,
LED_COUNT,
ledColour,
ledsLit,
ledStep,
METER_LABELS,
METER_RANGE_DB,
METER_RED_DB,
METER_TICKS,
meterPosition,
needleAngle,
needleStep,
PANEL,
PANEL_LAYOUT,
} from './hardwareView';
/** What the panel asks of the node it sits in. */
export interface PanelHost {
preview(key: CompressorKey, value: number): void;
commit(key: CompressorKey, value: number): void;
switchAutoGain(on: boolean): void;
}
const INK = 0x26282c;
const LCD_GLOW = 0x9cf58c;
const LED_LIT = { green: 0x4dff5a, amber: 0xffc21a, red: 0xff3b2a };
/** Captions are engraved: dark letters with a light edge under them. */
function engrave(parent: Container, text: string, size = 10): { ink: Text; edge: Text } {
const style = (fill: number): TextStyle => {
const s = textStyle('small').clone();
s.fill = fill;
s.fontSize = size;
s.fontWeight = 'bold';
s.letterSpacing = 1;
return s;
};
const edge = crispText({ text, style: style(0xffffff) });
edge.alpha = 0.55;
const ink = crispText({ text, style: style(INK) });
for (const t of [edge, ink]) {
t.eventMode = 'none';
t.anchor.set(0.5, 0);
parent.addChild(t);
}
return { ink, edge };
}
function placeEngraving(e: { ink: Text; edge: Text }, x: number, y: number): void {
e.ink.position.set(Math.round(x), Math.round(y));
e.edge.position.set(Math.round(x), Math.round(y) + 1);
}
let plateFill: FillGradient | null = null;
let faceFill: FillGradient | null = null;
const gradient = (stops: [number, number][], end = { x: 0, y: 1 }) =>
new FillGradient({
type: 'linear',
start: { x: 0, y: 0 },
end,
colorStops: stops.map(([offset, color]) => ({ offset, color })),
textureSpace: 'local',
});
/** The meter: a lit face, a scale from 0 to the range, and a needle that swings as a VU's does. */
class ReductionMeter extends Widget {
private readonly face = new Graphics();
private readonly needle = new Graphics();
private readonly labels: Text[];
private readonly caption: Text;
/** The reduction the needle shows, and the one it moves towards. */
shown = 0;
target = 0;
constructor() {
super();
this.addChild(this.face);
const style = textStyle('small').clone();
style.fill = 0x2b3324;
style.fontSize = 11;
this.labels = METER_LABELS.map((db) => {
const text = crispText({ text: `${db}`, style });
text.anchor.set(0.5);
this.addChild(text);
return text;
});
const captionStyle = style.clone();
captionStyle.fontSize = 9;
captionStyle.letterSpacing = 1;
this.caption = crispText({ text: 'GAIN REDUCTION dB', style: captionStyle });
this.caption.anchor.set(0.5);
this.addChild(this.caption, this.needle);
}
private get hub(): { x: number; y: number; r: number } {
return { x: this.w / 2, y: this.h - 22, r: this.h - 72 };
}
/** `dt` seconds on: the needle a step nearer its reading. Whether it still moves. */
step(dt: number): boolean {
const next = needleStep(this.shown, this.target, dt);
if (next === this.shown) return false;
this.shown = next;
this.drawNeedle();
return true;
}
get moving(): boolean {
return this.shown !== this.target;
}
rest(): void {
this.shown = this.target = 0;
this.drawNeedle();
}
override redraw(): void {
const { w, h } = this;
const p = this.hub;
const g = this.face.clear();
g.roundRect(0, 0, w, h, 8).fill(0x2a2c30).stroke({ color: 0x4b4e54, width: 2 });
faceFill ??= gradient([
[0, 0xf1fad4],
[0.6, 0xcfe6a0],
[1, 0xa9c97a],
]);
g.roundRect(8, 8, w - 16, h - 16, 5).fill(faceFill);
const at = (pos: number, r: number) => {
const a = needleAngle(pos);
return { x: p.x + Math.cos(a) * r, y: p.y + Math.sin(a) * r };
};
// An arc goes on from wherever the path is: each starts at its own first point.
const arc = (r: number, from: number, to: number) => {
const a = at(from, r);
g.moveTo(a.x, a.y).arc(p.x, p.y, r, needleAngle(from), needleAngle(to));
};
arc(p.r + 3, 0, meterPosition(METER_RED_DB));
g.stroke({ color: 0xc8321e, width: 4 });
arc(p.r, 0, 1);
g.stroke({ color: 0x2b3324, width: 1.5 });
for (const [marks, length, width] of [
[METER_LABELS, 10, 2],
[METER_TICKS, 6, 1],
] as const) {
for (const db of marks) {
const pos = meterPosition(db);
const a = at(pos, p.r);
const b = at(pos, p.r + length);
g.moveTo(a.x, a.y).lineTo(b.x, b.y);
}
g.stroke({ color: 0x2b3324, width });
}
METER_LABELS.forEach((db, i) => {
const spot = at(meterPosition(db), p.r + 22);
this.labels[i]!.position.set(Math.round(spot.x), Math.round(spot.y));
});
this.caption.position.set(Math.round(p.x), Math.round(p.y - 40));
// The glass over the face.
g.poly([10, 10, w - 10, 10, w - 10, h * 0.32, 10, h * 0.5]).fill({ color: 0xffffff, alpha: 0.12 });
this.drawNeedle();
}
private drawNeedle(): void {
const p = this.hub;
const a = needleAngle(meterPosition(this.shown));
this.needle
.clear()
.moveTo(p.x, p.y)
.lineTo(p.x + Math.cos(a) * (p.r + 12), p.y + Math.sin(a) * (p.r + 12))
.stroke({ color: 0x15171a, width: 2, cap: 'round' })
.circle(p.x, p.y, 9)
.fill(0x1d1f22)
.circle(p.x - 2, p.y - 2, 3)
.fill({ color: 0xffffff, alpha: 0.25 });
}
}
/** The level going out, as a column of LEDs: lit at once, going out slowly. */
class OutputLeds extends Widget {
private readonly lights = new Graphics();
shown = -Infinity;
private lit = -1;
constructor() {
super();
this.addChild(this.lights);
}
/** `dt` seconds on, with the level read now. Whether the LEDs still move. */
step(reading: number, dt: number): boolean {
this.shown = ledStep(this.shown, reading, dt);
const lit = ledsLit(this.shown);
if (lit !== this.lit) {
this.lit = lit;
this.invalidate();
}
return this.shown > reading;
}
darken(): void {
this.shown = -Infinity;
this.lit = 0;
this.invalidate();
}
get litCount(): number {
return Math.max(0, this.lit);
}
override redraw(): void {
const g = this.lights.clear();
const pitch = this.h / LED_COUNT;
for (let i = 0; i < LED_COUNT; i++) {
const y = this.h - (i + 1) * pitch + 1;
const colour = LED_LIT[ledColour(i)];
g.roundRect(0, y, this.w, pitch - 2, 2).fill(0x1a1b1e);
if (i < this.lit) {
g.roundRect(1, y + 1, this.w - 2, pitch - 4, 2).fill(colour);
g.rect(3, y + 2, this.w - 6, 2).fill({ color: 0xffffff, alpha: 0.5 });
} else g.roundRect(1, y + 1, this.w - 2, pitch - 4, 2).fill({ color: colour, alpha: 0.18 });
}
}
}
/** Auto-gain: a lever and a lamp, and what it gives beside them. */
class AutoGainLever extends Widget {
private readonly g = new Graphics();
private readonly caption: { ink: Text; edge: Text };
readonly says: Text;
lit = false;
constructor(onToggle: (on: boolean) => void) {
super();
this.cursor = 'pointer';
this.addChild(this.g);
this.caption = engrave(this, 'AUTO GAIN', 9);
const style = textStyle('small').clone();
style.fill = INK;
style.fontSize = 11;
this.says = crispText({ text: 'off', style });
this.says.eventMode = 'none';
this.addChild(this.says);
// A press on the lever is not one on the node: it neither picks nor moves it.
this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
this.on('pointertap', (e: FederatedPointerEvent) => {
if (e.button === 0) onToggle(!this.lit);
});
}
set(on: boolean, says: string): void {
if (on === this.lit && says === this.says.text) return;
this.lit = on;
this.says.text = says;
this.invalidate();
}
override redraw(): void {
const g = this.g.clear();
const lamp = { x: 10, y: this.h / 2 + 4 };
if (this.lit) g.circle(lamp.x, lamp.y, 10).fill({ color: 0xff5a2a, alpha: 0.25 });
g.circle(lamp.x, lamp.y, 6)
.fill(this.lit ? 0xff5a2a : 0x5a2a20)
.stroke({ color: 0x2a2c30, width: 1.5 });
g.circle(lamp.x - 2, lamp.y - 2, 2).fill({ color: 0xffffff, alpha: this.lit ? 0.7 : 0.2 });
// The lever: up is on.
const body = { x: 24, y: 10, w: 20, h: this.h - 10 };
g.roundRect(body.x, body.y, body.w, body.h, 4).fill(0x2a2c30).stroke({ color: 0x55595f, width: 1 });
const cx = body.x + body.w / 2;
const cy = body.y + body.h / 2;
const tip = this.lit ? body.y - 4 : body.y + body.h + 4;
g.moveTo(cx, cy).lineTo(cx, tip).stroke({ color: 0xb9bdc3, width: 5, cap: 'round' });
g.circle(cx, tip, 4).fill(0xe8eaed).stroke({ color: 0x6d7178, width: 1 });
placeEngraving(this.caption, 52 + 33, 2);
this.says.position.set(52, 20);
}
}
export class CompressorPanel extends Widget {
private readonly plate = new Graphics();
private readonly lcd = new Container();
private readonly lcdBox = new Graphics();
private readonly lcdName: Text;
private readonly lcdReading: Text;
readonly meter = new ReductionMeter();
readonly leds = new OutputLeds();
readonly autoGain: AutoGainLever;
readonly knobs = {} as Record<CompressorKey, Knob>;
private readonly captions: Record<CompressorKey, { ink: Text; edge: Text }>;
private readonly title: { ink: Text; edge: Text };
private readonly ledCaption: { ink: Text; edge: Text };
/** The most the needle and the LEDs have shown since the node was shown (checks). */
seen = { needle: 0, leds: 0 };
constructor(host: PanelHost) {
super();
this.minWidth = PANEL.w;
this.minHeight = PANEL.h;
this.lcd.addChild(this.lcdBox);
this.addChild(this.plate, this.lcd);
const lcdStyle = textStyle('body').clone();
lcdStyle.fill = LCD_GLOW;
lcdStyle.fontFamily = 'monospace';
this.lcdName = crispText({ text: '', style: lcdStyle });
this.lcdReading = crispText({ text: '', style: lcdStyle });
this.lcdReading.anchor.set(1, 0);
this.lcd.addChild(this.lcdName, this.lcdReading);
this.title = engrave(this, 'COMPRESSOR', 14);
this.ledCaption = engrave(this, 'OUT', 9);
this.captions = {} as Record<CompressorKey, { ink: Text; edge: Text }>;
for (const key of COMPRESSOR_KEYS) {
const row = COMPRESSOR_ROWS[key];
const look = COMPRESSOR_KNOBS[key];
const knob = new Knob('', row.default, {
min: row.min,
max: row.max,
step: row.step,
taper: look.taper,
default: row.default,
decimals: look.decimals,
size: PANEL_LAYOUT.knobs[key].size,
look: 'chrome',
typed: false,
onInput: (value) => host.preview(key, value),
onChange: (value) => host.commit(key, value),
});
this.knobs[key] = knob;
this.captions[key] = engrave(
this,
look.label.toUpperCase(),
PANEL_LAYOUT.knobs[key].size > 60 ? 11 : 9,
);
this.addChild(knob);
}
this.autoGain = new AutoGainLever((on) => host.switchAutoGain(on));
this.addChild(this.meter, this.leds, this.autoGain);
}
override measure(): { w: number; h: number } {
return { ...PANEL };
}
/** The numbers and the links of the node shown; auto-gain is set apart, with what it gives. */
sync(numbers: CompressorNumbers, wired: (key: CompressorKey) => boolean, name: string): void {
for (const key of COMPRESSOR_KEYS) {
this.knobs[key].setValue(numbers[key]);
this.knobs[key].setEnabled(!wired(key));
}
if (this.lcdName.text !== name) this.lcdName.text = name;
}
/** Another node: the needle at rest, the LEDs out. */
reset(): void {
this.meter.rest();
this.leds.darken();
this.seen = { needle: 0, leds: 0 };
}
/** A reading, `dt` seconds after the last: the needle, the LEDs and the LCD move. Whether they still do. */
read(reduction: number, out: number, dt: number): boolean {
this.meter.target = Math.min(reduction, METER_RANGE_DB);
const needle = this.meter.step(dt);
const leds = this.leds.step(out, dt);
this.seen = {
needle: Math.max(this.seen.needle, this.meter.shown),
leds: Math.max(this.seen.leds, this.leds.litCount),
};
const text = `GR ${this.meter.shown.toFixed(1).padStart(4)} dB`;
if (this.lcdReading.text !== text) this.lcdReading.text = text;
return needle || leds;
}
get lcdText(): string {
return `${this.lcdName.text} | ${this.lcdReading.text}`;
}
/** Nothing heard: the needle goes home and the LEDs out. */
get moving(): boolean {
return this.meter.moving || this.leds.litCount > 0;
}
override layout(): void {
const L = PANEL_LAYOUT;
const put = (w: Widget, b: Box) => {
w.position.set(b.x, b.y);
w.resize(b.w, b.h);
};
put(this.meter, L.meter);
put(this.leds, L.leds);
put(this.autoGain, L.autoGain);
for (const key of COMPRESSOR_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 + 60, L.title.y + 6);
placeEngraving(this.ledCaption, L.leds.x + L.leds.w / 2, L.leds.y - 14);
this.lcd.position.set(L.lcd.x, L.lcd.y);
this.lcdName.position.set(8, Math.round((L.lcd.h - this.lcdName.height) / 2));
this.lcdReading.position.set(L.lcd.w - 8, Math.round((L.lcd.h - this.lcdReading.height) / 2));
}
override redraw(): void {
const { w, h } = PANEL;
const g = this.plate.clear();
plateFill ??= gradient([
[0, 0xe2e4e7],
[0.5, 0xcfd2d6],
[1, 0xb4b8bd],
]);
g.roundRect(0, 0, w, h, 6).fill(plateFill);
for (const stroke of brushStrokes(h - 4))
g.rect(3, stroke.y + 2, w - 6, 1).fill({
color: stroke.light ? 0xffffff : 0x6c7076,
alpha: stroke.alpha,
});
g.roundRect(0.5, 0.5, w - 1, h - 1, 6).stroke({ color: 0x8a8e94, width: 1 });
g.moveTo(3, 1.5)
.lineTo(w - 3, 1.5)
.stroke({ color: 0xffffff, width: 1, alpha: 0.8 });
for (const screw of PANEL_LAYOUT.screws) {
g.circle(screw.x, screw.y, 6).fill(0x9a9ea4).stroke({ color: 0x5f6369, width: 1 });
g.circle(screw.x - 1, screw.y - 1, 4).fill({ color: 0xffffff, alpha: 0.35 });
g.moveTo(screw.x - 4, screw.y + 3)
.lineTo(screw.x + 4, screw.y - 3)
.stroke({ color: 0x45484d, width: 1.5 });
}
const lcd = PANEL_LAYOUT.lcd;
this.lcdBox
.clear()
.roundRect(-2, -2, lcd.w + 4, lcd.h + 4, 4)
.fill(0x3a3d42)
.roundRect(0, 0, lcd.w, lcd.h, 3)
.fill(0x0d1a10)
.rect(2, 2, lcd.w - 4, lcd.h / 2 - 2)
.fill({ color: LCD_GLOW, alpha: 0.05 });
}
}
+6 -4
View File
@@ -462,7 +462,7 @@ export class EqEditor extends VStack implements EqHost {
if (assetId !== this.assetId || nodeId !== this.nodeId) {
this.curve.settle();
this.perFrame.cancel();
this.curve.showSpectrum(null);
this.hideSpectrum();
this.before = null;
this.assetId = assetId;
this.nodeId = nodeId;
@@ -474,6 +474,7 @@ export class EqEditor extends VStack implements EqHost {
/** The tab is closed: the editor lets go of its node. */
close(): void {
this.perFrame.cancel();
this.hideSpectrum();
const node = this.node();
if (node) this.restore(node);
this.assetId = '';
@@ -489,13 +490,14 @@ export class EqEditor extends VStack implements EqHost {
/** Only called while the tab is seen: the spectrum of what passes the node, a frame on (#374). */
tick(): void {
if (!this.nodeId) return;
soundLibrary.listen(tapId(this.assetId, this.nodeId));
this.curve.showSpectrum(soundLibrary.spectrum());
const id = tapId(this.assetId, this.nodeId);
soundLibrary.listen(id);
this.curve.showSpectrum(soundLibrary.spectrum(id));
}
/** The tab is not seen: no analyser is fed for it. */
hideSpectrum(): void {
soundLibrary.unlisten();
if (this.nodeId) soundLibrary.unlisten(tapId(this.assetId, this.nodeId));
this.curve.showSpectrum(null);
}
+244
View File
@@ -0,0 +1,244 @@
/**
* What the tab of a Compressor (#378) draws, as numbers: the level coming in and going out over
* the last seconds, the curve from level in to level out, and where its threshold and ratio are
* grabbed. Kept apart from the drawing so it is checked without a browser.
*/
import { compressorAutoGain, compressorCurve } from '../../core/soundfx/dynamics';
import type { PropertyBag } from '../../core/model/Properties';
import { COMPRESS_AUTO_GAIN, COMPRESS_ROWS, switchOn } from '../../core/soundfx/nodes';
import { clamp, snapToStep } from '../../ui/math';
import type { Levels } from '../../viewport/spectrum';
/** The dB the level picture spans, bottom to top. */
export const LEVEL_FLOOR_DB = -60;
export const LEVEL_TOP_DB = 6;
/** How long the level picture looks back. */
export const HISTORY_SECONDS = 4;
/** How long a level is the loudest sample of: a frame and a bit, so no peak falls between two. */
export const LEVEL_WINDOW = 0.025;
/** The dB the curve spans on both axes, from here to 0. */
export const CURVE_FLOOR_DB = -60;
/** Less taken away than this is not tinted: the two sides are read a few ms apart. */
export const REDUCTION_SHOWN_DB = 0.5;
export type CompressorKey = keyof typeof COMPRESS_ROWS;
export const COMPRESSOR_KEYS = Object.keys(COMPRESS_ROWS) as CompressorKey[];
/** The rows with the bounds every one of them has. */
export const COMPRESSOR_ROWS = Object.fromEntries(
COMPRESSOR_KEYS.map((key) => {
const row = COMPRESS_ROWS[key];
return [key, { min: row.min ?? 0, max: row.max ?? 0, step: row.step ?? 1, default: row.default }];
}),
) as Record<CompressorKey, { min: number; max: number; step: number; default: number }>;
/** How the knobs turn: the times and the ratio have their useful part low in the range. */
export const COMPRESSOR_KNOBS: Record<CompressorKey, { label: string; taper: number; decimals: number }> = {
threshold: { label: 'Threshold dB', taper: 1, decimals: 1 },
ratio: { label: 'Ratio', taper: 2, decimals: 1 },
knee: { label: 'Knee dB', taper: 1, decimals: 1 },
attack: { label: 'Attack s', taper: 3, decimals: 3 },
release: { label: 'Release s', taper: 2, decimals: 3 },
makeup: { label: 'Makeup dB', taper: 1, decimals: 1 },
};
export interface CompressorNumbers {
threshold: number;
knee: number;
ratio: number;
attack: number;
release: number;
makeup: number;
autoGain: boolean;
}
const dbToLinear = (db: number) => 10 ** (db / 20);
const linearToDb = (value: number) => (value <= 0 ? -Infinity : 20 * Math.log10(value));
/** A Compressor's numbers as its node has them: the defaults where none is set. */
export function compressorNumbers(params: PropertyBag): CompressorNumbers {
const numbers = {} as CompressorNumbers;
for (const key of COMPRESSOR_KEYS) {
const value = params[key];
numbers[key] = typeof value === 'number' && Number.isFinite(value) ? value : COMPRESSOR_ROWS[key].default;
}
numbers.autoGain = switchOn(params, COMPRESS_AUTO_GAIN);
return numbers;
}
/** What auto-gain gives at these numbers, or that it is off. */
export function autoGainText(n: CompressorNumbers): string {
return n.autoGain ? `+${compressorAutoGain(n.threshold, n.knee, n.ratio).toFixed(1)} dB` : 'off';
}
/** 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 {
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. */
export function curveOutDb(inDb: number, threshold: number, knee: number, ratio: number): number {
return linearToDb(compressorCurve(threshold, knee, ratio)(dbToLinear(inDb)));
}
// --- The curve: a square, level in across, level out up, both CURVE_FLOOR_DB..0. ---
export const curveX = (db: number, size: number): number =>
((clamp(db, CURVE_FLOOR_DB, 0) - CURVE_FLOOR_DB) / -CURVE_FLOOR_DB) * size;
export const curveY = (db: number, size: number): number => size - curveX(db, size);
export const curveDbAtX = (x: number, size: number): number => CURVE_FLOOR_DB + (x / size) * -CURVE_FLOOR_DB;
export const curveDbAtY = (y: number, size: number): number => curveDbAtX(size - y, size);
/** The curve as points across the square. */
export function curvePoints(n: CompressorNumbers, size: number, steps: number): number[] {
const curve = compressorCurve(n.threshold, n.knee, n.ratio);
const points: number[] = [];
for (let i = 0; i <= steps; i++) {
const db = CURVE_FLOOR_DB + (i / steps) * -CURVE_FLOOR_DB;
points.push(curveX(db, size), curveY(linearToDb(curve(dbToLinear(db))), size));
}
return points;
}
/** Where the threshold is grabbed: on the curve where it starts to bend. */
export function thresholdHandle(n: CompressorNumbers, size: number): { x: number; y: number } {
return { x: curveX(n.threshold, size), y: curveY(n.threshold, size) };
}
/** Where the ratio is grabbed: the end of the curve, at a level of 0 dB going in. */
export function ratioHandle(n: CompressorNumbers, size: number): { x: number; y: number } {
return { x: size, y: curveY(curveOutDb(0, n.threshold, n.knee, n.ratio), size) };
}
/**
* The ratio whose curve comes out at `outDb` for 0 dB going in, held to `min`..`max` and rounded
* to `step`. The higher the ratio, the lower the end of the curve, so it is found by halving.
*/
export function ratioFor(
outDb: number,
n: CompressorNumbers,
range: { min: number; max: number; step: number },
): number {
const end = (ratio: number) => curveOutDb(0, n.threshold, n.knee, ratio);
if (outDb >= end(range.min)) return range.min;
if (outDb <= end(range.max)) return range.max;
let low = range.min;
let high = range.max;
for (let i = 0; i < 40; i++) {
const mid = (low + high) / 2;
if (end(mid) > outDb) low = mid;
else high = mid;
}
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 =>
((LEVEL_TOP_DB - clamp(db, LEVEL_FLOOR_DB, LEVEL_TOP_DB)) / (LEVEL_TOP_DB - LEVEL_FLOOR_DB)) * h;
export const historyX = (t: number, now: number, w: number): number => w - ((now - t) / HISTORY_SECONDS) * w;
/** 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, 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);
}
clear(): void {
this.points = [];
}
get all(): readonly LevelPoint[] {
return this.points;
}
/** Whether anything above the floor is still in the picture, so it has to move on. */
get heard(): boolean {
return this.points.some((p) => p.in > LEVEL_FLOOR_DB || p.out > LEVEL_FLOOR_DB);
}
get latest(): LevelPoint | null {
return this.points.at(-1) ?? null;
}
}
/** How much the compressing takes away at a reading, in dB: the gain after it left out. */
export function reductionOf(point: Levels, gainDb: number): number {
if (!Number.isFinite(point.in) || !Number.isFinite(point.out)) return 0;
return Math.max(0, point.in - (point.out - gainDb));
}
/**
* The tinted shapes where the compressor works: from the level coming in down by what it takes
* away, one shape for each stretch where it takes away more than `REDUCTION_SHOWN_DB`.
*/
export function reductionShapes(
points: readonly LevelPoint[],
now: number,
w: number,
h: number,
): number[][] {
const shapes: number[][] = [];
let top: number[] = [];
let bottom: number[] = [];
const close = () => {
if (top.length >= 4) {
const back: number[] = [];
for (let i = bottom.length - 2; i >= 0; i -= 2) back.push(bottom[i]!, bottom[i + 1]!);
shapes.push([...top, ...back]);
}
top = [];
bottom = [];
};
for (const point of points) {
const reduction = reductionOf(point, point.gain);
if (reduction < REDUCTION_SHOWN_DB) {
close();
continue;
}
const x = historyX(point.t, now, w);
top.push(x, levelY(point.in, h));
bottom.push(x, levelY(point.in - reduction, h));
}
close();
return shapes;
}
+169
View File
@@ -0,0 +1,169 @@
/**
* What the Compressor's hardware panel (#379) draws, as numbers: where its parts sit on a plate of
* one size, how the needle of its meter reads and moves, which LEDs are lit, and the grain of the
* brushed plate. Kept apart from the drawing so it is checked without a browser.
*/
import { clamp } from '../../ui/math';
import type { Levels } from '../../viewport/spectrum';
import { LEVEL_FLOOR_DB } from './compressorView';
export interface Box {
x: number;
y: number;
w: number;
h: number;
}
/** The plate: the panel never takes another size. */
export const PANEL = { w: 820, h: 280 };
/** The engraved name above a knob, and the number below its dial. */
export const KNOB_CAPTION = 14;
export const KNOB_FIELD = 24;
/** The narrowest a knob is: its number below. */
const KNOB_LEAST_WIDTH = 60;
export const BIG_DIAL = 100;
export const SMALL_DIAL = 48;
export type PanelKnob = 'threshold' | 'ratio' | 'knee' | 'attack' | 'release' | 'makeup';
/** A knob by the middle of its dial. */
export interface KnobSpot {
x: number;
y: number;
size: number;
}
export interface PanelLayout {
title: Box;
lcd: Box;
autoGain: Box;
meter: Box;
leds: Box;
knobs: Record<PanelKnob, KnobSpot>;
screws: { x: number; y: number }[];
}
const SCREW_INSET = 14;
/** Threshold big on the left, makeup big on the right, the times and the shape small beside the meter. */
export const PANEL_LAYOUT: PanelLayout = {
title: { x: 34, y: 14, w: 220, h: 30 },
lcd: { x: (PANEL.w - 240) / 2, y: 14, w: 240, h: 30 },
autoGain: { x: 660, y: 10, w: 118, h: 40 },
meter: { x: 270, y: 62, w: 280, h: 190 },
leds: { x: 782, y: 74, w: 14, h: 168 },
knobs: {
threshold: { x: 100, y: 160, size: BIG_DIAL },
ratio: { x: 222, y: 105, size: SMALL_DIAL },
knee: { x: 222, y: 205, size: SMALL_DIAL },
attack: { x: 598, y: 105, size: SMALL_DIAL },
release: { x: 598, y: 205, size: SMALL_DIAL },
makeup: { x: 712, y: 160, size: BIG_DIAL },
},
screws: [
{ x: SCREW_INSET, y: SCREW_INSET },
{ x: PANEL.w - SCREW_INSET, y: SCREW_INSET },
{ x: SCREW_INSET, y: PANEL.h - SCREW_INSET },
{ x: PANEL.w - SCREW_INSET, y: PANEL.h - SCREW_INSET },
],
};
/** All a knob takes on the plate: its name above, its dial and its number below. */
export function knobBox(spot: KnobSpot): Box {
const w = Math.max(spot.size, KNOB_LEAST_WIDTH);
return {
x: spot.x - w / 2,
y: spot.y - spot.size / 2 - KNOB_CAPTION,
w,
h: KNOB_CAPTION + spot.size + KNOB_FIELD,
};
}
// --- The meter: what is taken away, 0 dB at the right, swinging left as more is. ---
export const METER_RANGE_DB = 24;
/** How far the needle swings from end to end, in radians. */
export const METER_SWEEP = (96 * Math.PI) / 180;
/** The marks with a number, and the ones between. */
export const METER_LABELS = [0, 1, 3, 6, 10, 15, 24];
export const METER_TICKS = [2, 4, 5, 8, 12, 20];
/** From here on the scale is red. */
export const METER_RED_DB = 10;
/** How fast the needle follows: a VU's, which is near its reading after 300 ms. */
const NEEDLE_TIME = 0.065;
/**
* Where on the scale a reduction is, from 1 (none) to 0 (the range or more). The root spreads the
* first dB, where most of the work is heard, about as wide as the last nine.
*/
export function meterPosition(reductionDb: number): number {
return 1 - Math.sqrt(clamp(reductionDb, 0, METER_RANGE_DB) / METER_RANGE_DB);
}
/** The needle's angle at a position: straight up in the middle, the screen's y going down. */
export function needleAngle(position: number): number {
return -Math.PI / 2 + (clamp(position, 0, 1) - 0.5) * METER_SWEEP;
}
/** The reduction the needle shows `dt` seconds on, moving towards `target` as a VU's does. */
export function needleStep(shown: number, target: number, dt: number): number {
const share = 1 - Math.exp(-clamp(dt, 0, 0.25) / NEEDLE_TIME);
const next = shown + (target - shown) * share;
return Math.abs(next - target) < 0.01 ? target : next;
}
/** What the node takes away now: the level in, less the level out without the gain after it. */
export function gainReduction(levels: Levels | null, gainDb: number): number {
if (!levels || !(levels.in > LEVEL_FLOOR_DB)) return 0;
return Math.max(0, levels.in - (levels.out - gainDb));
}
// --- The LEDs: the level going out, one every 3 dB up to 0. ---
export const LED_COUNT = 14;
const LED_STEP_DB = 3;
/** How fast a lit LED goes out again, in dB a second. */
const LED_FALL = 20;
/** The level an LED lights at, from the bottom one up. */
export const ledDb = (index: number): number => (index - (LED_COUNT - 1)) * LED_STEP_DB;
export function ledColour(index: number): 'green' | 'amber' | 'red' {
const db = ledDb(index);
if (db >= -3) return 'red';
return db >= -12 ? 'amber' : 'green';
}
export function ledsLit(db: number): number {
let lit = 0;
for (let i = 0; i < LED_COUNT; i++) if (db >= ledDb(i)) lit = i + 1;
return lit;
}
/** The level the LEDs show `dt` seconds on: up at once, down slowly. */
export function ledStep(shown: number, reading: number, dt: number): number {
return Math.max(reading, shown - LED_FALL * clamp(dt, 0, 0.25));
}
// --- The plate. ---
export interface Stroke {
y: number;
alpha: number;
light: boolean;
}
/** One stroke of the brush per row of the plate, each a little lighter or darker, the same every time. */
export function brushStrokes(h: number, seed = 379): Stroke[] {
let state = seed >>> 0;
const next = () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
return state / 2 ** 32;
};
return Array.from({ length: Math.max(0, Math.floor(h)) }, (_, y) => ({
y,
alpha: 0.03 + next() * 0.09,
light: next() < 0.5,
}));
}
+10
View File
@@ -0,0 +1,10 @@
import type { GraphNode } from '../../core/graph';
import { SoundFxCompressor } from '../../core/soundfx/nodes';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { CompressorFace } from './CompressorFace';
/** The nodes drawn as a face of their own instead of rows (#380), by type. */
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
return node.type === SoundFxCompressor.type ? new CompressorFace(state, assetId, node.id) : null;
}
+60
View File
@@ -0,0 +1,60 @@
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import type { PropertyValue } from '../../core/model/Properties';
import { FramePreview } from '../framePreview';
import type { EditorState } from '../state';
/**
* A number of a node turned by a knob: written into the node as it goes, one undo step when it
* ends. A Compressor's tab (#378) and its node (#380) each have one.
*/
export class NodeParamDrag {
/** The value a drag began with – its undo step's other side; undefined when none was typed in. */
private before: { key: string; value: PropertyValue | undefined } | null = null;
private readonly perFrame: FramePreview;
constructor(
private readonly state: EditorState,
name: string,
) {
this.perFrame = new FramePreview(state, name);
}
preview(node: GraphNode, key: string, value: number): void {
if (this.before?.key !== key) {
this.restore(node);
this.before = { key, value: node.params[key] };
}
node.params[key] = value;
this.perFrame.request();
}
commit(assetId: string, node: GraphNode, key: string, value: PropertyValue): void {
this.perFrame.cancel();
this.restore(node);
if (node.params[key] === value) {
this.state.preview();
return;
}
this.state.execute(new SetNodeParamCommand(assetId, node.id, key, value));
}
/** The node as it was before the drag, which is left without a step. */
restore(node: GraphNode): void {
const before = this.before;
if (!before) return;
if (before.value === undefined) delete node.params[before.key];
else node.params[before.key] = before.value;
this.before = null;
}
/** Another node: what was begun on the last one is dropped. */
forget(): void {
this.perFrame.cancel();
this.before = null;
}
cancel(): void {
this.perFrame.cancel();
}
}
+36
View File
@@ -0,0 +1,36 @@
/**
* The nodes of a sound FX graph with a tab of their own, opened by a double-click on them: a
* Parametric EQ (#370) and a Compressor (#378). A tab is a document named by its kind, the asset
* and the node (`eq:<asset>:<node>`).
*/
import type { GraphNode } from '../../core/graph';
import type { Project } from '../../core/model/Project';
import { SoundFxCompressor, SoundFxParametricEq } from '../../core/soundfx/nodes';
import { compressorNodeOf, compressorTabLabel } from './CompressorEditor';
import { eqNodeOf, eqTabLabel } from './EqEditor';
export type NodeTabKind = 'eq' | 'compressor';
export const NODE_TAB_KINDS: readonly NodeTabKind[] = ['eq', 'compressor'];
const KIND_OF_TYPE: Readonly<Record<string, NodeTabKind>> = {
[SoundFxParametricEq.type]: 'eq',
[SoundFxCompressor.type]: 'compressor',
};
/** The kind of tab a node opens, or null for one without. */
export const nodeTabOf = (type: string): NodeTabKind | null => KIND_OF_TYPE[type] ?? null;
/** The node a tab is about, or null once it, or its asset, is gone. */
export function nodeOfTab(
project: Project,
kind: NodeTabKind,
assetId: string,
nodeId: string,
): GraphNode | null {
return kind === 'eq' ? eqNodeOf(project, assetId, nodeId) : compressorNodeOf(project, assetId, nodeId);
}
export function nodeTabLabel(project: Project, kind: NodeTabKind, assetId: string, nodeId: string): string {
return kind === 'eq' ? eqTabLabel(project, assetId, nodeId) : compressorTabLabel(project, assetId, nodeId);
}
+12 -9
View File
@@ -228,8 +228,8 @@ export type EditorEvent =
| 'selection'
/** A Variables tab was asked for (`openedValues`). */
| 'values'
/** A Parametric EQ's tab was asked for (`openedEq`). */
| 'eq'
/** The tab of a Parametric EQ or a Compressor node was asked for (`openedNode`). */
| 'nodeTab'
/** An asset was opened (double-click, Enter, or just created): its tab should show. */
| 'open'
/** An asset should light up where it is listed (`pingedAssetId`), without being selected. */
@@ -258,8 +258,8 @@ export class EditorState {
selection: Selection = null;
/** The last target asked for by `openValues`; the tabs themselves are the editor's. */
openedValues: ValuesTarget | null = null;
/** The last Parametric EQ node asked for by `openEq`. */
openedEq: { assetId: string; nodeId: string } | null = null;
/** The last node asked for by `openNodeTab`. */
openedNode: { assetId: string; nodeId: string; type: string } | null = null;
mode: EditorMode = 'edit';
/**
* A sub-state of play rather than a third mode: the runtime copy, the keyboard and unsaved edits all
@@ -323,7 +323,7 @@ export class EditorState {
this.commands = new CommandStack(project);
this.selection = null;
this.openedValues = null;
this.openedEq = null;
this.openedNode = null;
this.clipboard = null;
this.patternPlaces.clear();
this.saveStatus = 'saved';
@@ -538,10 +538,13 @@ export class EditorState {
this.emit('values');
}
/** Asks for the tab of a Parametric EQ node (#370), the way `openValues` asks for a Variables tab. */
openEq(assetId: string, nodeId: string): void {
this.openedEq = { assetId, nodeId };
this.emit('eq');
/**
* Asks for the tab of a Parametric EQ (#370) or Compressor (#378) node, the way `openValues` asks
* for a Variables tab.
*/
openNodeTab(assetId: string, nodeId: string, type: string): void {
this.openedNode = { assetId, nodeId, type };
this.emit('nodeTab');
}
/**
+1
View File
@@ -56,6 +56,7 @@ export { UI_ICON_SVG } from './icons';
export { Checkbox } from './widgets/Checkbox';
export { Panel } from './widgets/Panel';
export { Slider } from './widgets/Slider';
export { Knob, type KnobOptions } from './widgets/Knob';
export { Splitter } from './widgets/Splitter';
export { ScrollContainer } from './widgets/ScrollContainer';
export { type DropSpot, ListView, type ListItem, type PickModifiers } from './widgets/ListView';
+56
View File
@@ -0,0 +1,56 @@
/**
* The rules of a Knob (#378): where on its turn a value sits, and what a drag or a wheel makes of
* it. A turn is a position from 0 to 1; `taper` bends it, so a range whose useful part is small –
* an attack of a few milliseconds out of a second – gets most of the turn.
*/
import { clamp, snapToStep } from './math';
export interface KnobRange {
min: number;
max: number;
/** The grid a value snaps to. */
step: number;
/** 1 turns evenly; above it the low end gets more of the turn. */
taper?: number;
}
/** Pixels of a vertical drag that take the knob from one end to the other. */
export const KNOB_TRAVEL = 200;
/** How much of the turn Shift leaves to a drag. */
export const KNOB_FINE = 0.1;
/** How far one notch of the wheel turns the knob. */
export const KNOB_NOTCH = 0.02;
/** The turn as an angle: from down-left round over the top to down-right, in radians. */
export const KNOB_START = Math.PI * 0.75;
export const KNOB_SWEEP = Math.PI * 1.5;
export function knobPosition(value: number, range: KnobRange): number {
const share = clamp((value - range.min) / (range.max - range.min), 0, 1);
return share ** (1 / (range.taper ?? 1));
}
export function knobValue(position: number, range: KnobRange): number {
const share = clamp(position, 0, 1) ** (range.taper ?? 1);
return clamp(snapToStep(range.min + share * (range.max - range.min), range.step), range.min, range.max);
}
/**
* The value a vertical drag reaches from `start`: up turns it up. A drag too short to reach the
* next step still moves it by one, so a fine drag over a coarse range is never stuck.
*/
export function draggedValue(start: number, dy: number, fine: boolean, range: KnobRange): number {
const travel = (-dy / KNOB_TRAVEL) * (fine ? KNOB_FINE : 1);
const value = knobValue(knobPosition(start, range) + travel, range);
if (value !== start || travel === 0) return value;
return clamp(snapToStep(start + Math.sign(travel) * range.step, range.step), range.min, range.max);
}
/** The value one notch of the wheel reaches: up turns it up, by at least one step. */
export function wheeledValue(value: number, deltaY: number, range: KnobRange): number {
return draggedValue(value, Math.sign(deltaY) * KNOB_NOTCH * KNOB_TRAVEL, false, range);
}
/** Where the filled arc starts: at 0 when the range has it inside, else at the low end. */
export function knobOrigin(range: KnobRange): number {
return range.min < 0 && range.max > 0 ? knobPosition(0, range) : 0;
}
+249
View File
@@ -0,0 +1,249 @@
import { FillGradient, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js';
import { pressCount } from '../clicks';
import {
draggedValue,
KNOB_START,
KNOB_SWEEP,
knobOrigin,
knobPosition,
type KnobRange,
wheeledValue,
} from '../knob';
import { VStack } from '../layout/Stack';
import { Theme } from '../Theme';
import { Widget } from '../Widget';
import { Label } from './Label';
import { NumberField } from './NumberField';
const DIAL_SIZE = 44;
const TRACK_WIDTH = 4;
export interface KnobOptions extends KnobRange {
/** What a double-click on the dial goes back to. */
default: number;
/** Decimal places the number below shows. */
decimals: number;
/** Fires on every step of a drag, and as a number is typed. */
onInput?: (value: number) => void;
/** Fires once a drag ends, on a notch of the wheel, a double-click or a typed number. */
onChange?: (value: number) => void;
/** The dial's width and height. */
size?: number;
/** `chrome`: a metal cap on a black skirt in a ring of marks, as on a device (#379). */
look?: 'flat' | 'chrome';
/** Whether a click on the number types one; a knob on a node (#380) only shows it. */
typed?: boolean;
}
/** One for all chrome dials, laid over each cap's own bounds: light from the upper left. */
let chromeCap: FillGradient | null = null;
const chromeGradient = () =>
(chromeCap ??= new FillGradient({
type: 'linear',
start: { x: 0.15, y: 0.1 },
end: { x: 0.85, y: 0.95 },
colorStops: [
{ offset: 0, color: 0xfafbfc },
{ offset: 0.45, color: 0xc9ccd1 },
{ offset: 1, color: 0x7d8288 },
],
textureSpace: 'local',
}));
/** The round part: a drag up or down turns it, the wheel notches it, a double-click resets it. */
class Dial extends Widget {
private readonly arc = new Graphics();
private drag: { startY: number; start: number; moved: boolean } | null = null;
constructor(private readonly knob: Knob) {
super();
this.minWidth = knob.options.size ?? DIAL_SIZE;
this.minHeight = knob.options.size ?? DIAL_SIZE;
this.cursor = 'ns-resize';
this.addChild(this.arc);
this.trackHover();
this.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
if (pressCount(e, this) === 2) {
this.drag = null;
knob.commit(knob.options.default);
return;
}
this.drag = { startY: e.global.y, start: knob.value, moved: false };
});
this.on('globalpointermove', (e: FederatedPointerEvent) => {
const drag = this.drag;
if (!drag) return;
const dy = e.global.y - drag.startY;
if (!drag.moved && dy === 0) return;
drag.moved = true;
knob.preview(draggedValue(drag.start, dy, e.shiftKey, knob.options));
});
this.onPointerRelease(() => {
const drag = this.drag;
this.drag = null;
if (drag?.moved) knob.commit(knob.value);
this.invalidate();
});
this.on('wheel', (e: FederatedWheelEvent) => {
if (e.deltaY === 0) return;
e.stopPropagation();
knob.commit(wheeledValue(knob.value, e.deltaY, knob.options));
});
}
get dragging(): boolean {
return this.drag !== null;
}
override redraw(): void {
if (this.knob.options.look === 'chrome') {
this.drawChrome();
return;
}
const c = Theme.colors;
const size = Math.min(this.w, this.h);
const x = this.w / 2;
const y = this.h / 2;
const r = size / 2 - TRACK_WIDTH;
const angle = (position: number) => KNOB_START + position * KNOB_SWEEP;
const at = knobPosition(this.knob.value, this.knob.options);
const origin = knobOrigin(this.knob.options);
this.arc.clear();
this.arc.arc(x, y, r, angle(0), angle(1)).stroke({ color: c.control, width: TRACK_WIDTH, cap: 'round' });
if (at !== origin)
this.arc
.arc(x, y, r, angle(Math.min(at, origin)), angle(Math.max(at, origin)))
.stroke({ color: c.accent, width: TRACK_WIDTH, cap: 'round' });
const inner = r - TRACK_WIDTH - 2;
this.arc
.circle(x, y, inner)
.fill(this.hovered || this.dragging ? c.controlHover : c.control)
.stroke({ width: 1, color: c.border });
const a = angle(at);
this.arc
.moveTo(x + Math.cos(a) * inner * 0.3, y + Math.sin(a) * inner * 0.3)
.lineTo(x + Math.cos(a) * inner, y + Math.sin(a) * inner)
.stroke({ color: c.symbol, width: 2, cap: 'round' });
}
private drawChrome(): void {
const r = Math.min(this.w, this.h) / 2;
const x = this.w / 2;
const y = this.h / 2;
const angle = (position: number) => KNOB_START + position * KNOB_SWEEP;
const at = knobPosition(this.knob.value, this.knob.options);
const origin = knobOrigin(this.knob.options);
const g = this.arc.clear();
for (let i = 0; i <= 10; i++) {
const a = angle(i / 10);
const inner = i % 5 === 0 ? r * 0.84 : r * 0.89;
g.moveTo(x + Math.cos(a) * inner, y + Math.sin(a) * inner).lineTo(
x + Math.cos(a) * r,
y + Math.sin(a) * r,
);
}
g.stroke({ color: 0x34373c, width: 1.5 });
const track = r * 0.8;
g.arc(x, y, track, angle(0), angle(1)).stroke({ color: 0x55595f, width: 3, alpha: 0.35 });
if (at !== origin)
g.arc(x, y, track, angle(Math.min(at, origin)), angle(Math.max(at, origin))).stroke({
color: this.knob.enabled ? CHROME_ARC : 0x8b8f95,
width: 3,
});
// The black skirt with its grip, the metal cap on it.
const skirt = r * 0.72;
g.circle(x, y, skirt).fill(0x1d1f22);
for (let i = 0; i < 40; i++) {
const a = (i / 40) * Math.PI * 2;
g.moveTo(x + Math.cos(a) * skirt * 0.86, y + Math.sin(a) * skirt * 0.86).lineTo(
x + Math.cos(a) * skirt,
y + Math.sin(a) * skirt,
);
}
g.stroke({ color: 0x40444a, width: 1 });
const cap = skirt * 0.8;
g.circle(x, y, cap)
.fill(chromeGradient())
.stroke({ color: this.hovered || this.dragging ? CHROME_ARC : 0x5d6168, width: 1 });
g.ellipse(x - cap * 0.3, y - cap * 0.38, cap * 0.42, cap * 0.2).fill({ color: 0xffffff, alpha: 0.45 });
const a = angle(at);
g.moveTo(x + Math.cos(a) * cap * 0.25, y + Math.sin(a) * cap * 0.25)
.lineTo(x + Math.cos(a) * cap * 0.92, y + Math.sin(a) * cap * 0.92)
.stroke({ color: 0x1b1d20, width: Math.max(2, r / 16), cap: 'round' });
}
}
/** The value's arc on a chrome dial: the orange of a lit scale. */
const CHROME_ARC = 0xff8a1e;
/**
* A number on a round dial (#378): a slider bent into a circle, its name above and the number
* below, which takes a typed value on a click.
*/
export class Knob extends VStack {
readonly dial: Dial;
readonly field: NumberField;
private readonly caption: Label;
private current: number;
constructor(
label: string,
value: number,
readonly options: KnobOptions,
) {
super({ gap: 2, align: 'center' });
this.current = value;
this.caption = new Label(label, { style: 'small' });
this.dial = new Dial(this);
this.field = new NumberField(value, {
width: 56,
min: options.min,
max: options.max,
step: options.step,
decimals: options.decimals,
onInput: (typed) => this.preview(typed),
onChange: (typed) => this.commit(typed),
});
if (options.typed === false) this.field.eventMode = 'none';
// Without a name the knob has none above it: a panel engraves its own.
if (label) this.add(this.caption);
this.add(this.dial);
this.add(this.field);
}
get value(): number {
return this.current;
}
/** Shows a value from outside; fires nothing. A drag going on keeps its own. */
setValue(value: number): void {
if (this.dial.dragging || value === this.current) return;
this.current = value;
this.field.setNumber(value);
this.dial.invalidate();
}
preview(value: number): void {
this.show(value);
this.options.onInput?.(value);
}
commit(value: number): void {
this.show(value);
this.options.onChange?.(value);
}
private show(value: number): void {
this.current = value;
this.field.setNumber(value);
this.dial.invalidate();
}
/** Where the dial is on screen, for the check scripts. */
dialGlobal(): { x: number; y: number } {
const at = this.dial.getGlobalPosition();
return { x: at.x + this.dial.w / 2, y: at.y + this.dial.h / 2 };
}
}
+12 -7
View File
@@ -58,7 +58,7 @@ import {
releaseEnvelope,
stopProcessor,
} from './fxNodes';
import { type Spectrum, SpectrumTaps } from './spectrum';
import { type Levels, type Spectrum, SpectrumTaps } from './spectrum';
import { decodeSpan, StreamFeed, StreamFile } from './streaming';
import { wake } from './wake';
@@ -620,19 +620,24 @@ export class SoundLibrary implements AudioSink {
}
/**
* Listens to the sound in front of the node `id` (`tapId`) and behind it (#374): a Parametric
* EQ's tab while it is seen. `spectrum` reads it, `unlisten` lets it go.
* Listens to the sound in front of the node `id` (`tapId`) and behind it (#374): the tab of a
* Parametric EQ or a Compressor (#378) while it is seen. `spectrum` and `nodeLevels` read it,
* `unlisten` lets it go.
*/
listen(id: string): void {
this.spectra.listen(id);
}
unlisten(): void {
this.spectra.stop();
unlisten(id: string): void {
this.spectra.stop(id);
}
spectrum(): Spectrum | null {
return this.spectra.read();
spectrum(id: string): Spectrum | null {
return this.spectra.read(id);
}
nodeLevels(id: string, seconds: number): Levels | null {
return this.spectra.levels(id, seconds);
}
/** The node listened to, the taps playing, and how many of them feed an analyser (test hook). */
+71 -32
View File
@@ -1,7 +1,8 @@
/**
* The sound in front of a node and behind it, as an analyser hears it (#374). Every voice and bus
* passing a tap hands its node in for as long as it plays; while the node is listened to, they all
* feed one analyser for each side. Nothing is made before a tap of that node plays.
* feed one analyser for each side. Nothing is made before a tap of that node plays. More than one
* node can be listened to at once: a Parametric EQ and a Compressor (#378) each in a tab of their own.
*/
import type { FxTap } from '../core/soundfx/nodes';
@@ -15,6 +16,8 @@ interface Analysis {
sum: GainNode;
analyser: AnalyserNode;
data: Float32Array<ArrayBuffer>;
/** The latest samples, for `levels`. */
wave: Float32Array<ArrayBuffer>;
}
/** Both sides of the node in dB, one level every `binHz`. */
@@ -24,6 +27,12 @@ export interface Spectrum {
binHz: number;
}
/** The loudest sample on each side of the node over a short while, in dB; -Infinity for silence. */
export interface Levels {
in: number;
out: number;
}
const keyOf = (id: string, at: Side) => `${at}:${id}`;
/** Cuts one link; it may be gone with the rest of a voice already. */
@@ -38,9 +47,11 @@ function cut(from: AudioNode, to: AudioNode): void {
export class SpectrumTaps {
/** The nodes of the taps that play now, by node and side. */
private readonly held = new Map<string, Set<AudioNode>>();
private listened = '';
/** Made once a tap of the node listened to plays; kept while it is listened to. */
private sides: Record<Side, Analysis> | null = null;
/**
* The nodes listened to, each with its analysers: null until a tap of it plays, then kept while
* it is listened to.
*/
private readonly listened = new Map<string, Record<Side, Analysis> | null>();
/** A tap of a voice or a bus as it starts to play; what it hands back is called once it stops. */
hold(tap: FxTap, node: AudioNode): () => void {
@@ -48,42 +59,43 @@ export class SpectrumTaps {
const nodes = this.held.get(key) ?? new Set<AudioNode>();
this.held.set(key, nodes);
nodes.add(node);
if (tap.id === this.listened) node.connect(this.analysis(node.context)[tap.at].sum);
if (this.listened.has(tap.id)) node.connect(this.analysis(tap.id, node.context)[tap.at].sum);
return () => {
if (!nodes.delete(node)) return;
if (nodes.size === 0 && this.held.get(key) === nodes) this.held.delete(key);
if (tap.id === this.listened && this.sides) cut(node, this.sides[tap.at].sum);
const sides = this.listened.get(tap.id);
if (sides) cut(node, sides[tap.at].sum);
};
}
/** Listens to the node `id` (`tapId`) and to no other. */
/** Listens to the node `id` (`tapId`), besides those listened to already. */
listen(id: string): void {
if (id === this.listened) return;
this.stop();
this.listened = id;
if (this.listened.has(id)) return;
this.listened.set(id, null);
for (const at of SIDES)
for (const node of this.held.get(keyOf(id, at)) ?? [])
node.connect(this.analysis(node.context)[at].sum);
node.connect(this.analysis(id, node.context)[at].sum);
}
/** Lets the analysers go: no tap feeds one any more. */
stop(): void {
const { sides, listened } = this;
this.listened = '';
this.sides = null;
if (!sides) return;
for (const at of SIDES) {
for (const node of this.held.get(keyOf(listened, at)) ?? []) cut(node, sides[at].sum);
sides[at].sum.disconnect();
/** Lets the analysers of the node `id` go, or of every node: no tap feeds one any more. */
stop(id?: string): void {
for (const listened of id === undefined ? [...this.listened.keys()] : [id]) {
const sides = this.listened.get(listened);
this.listened.delete(listened);
if (!sides) continue;
for (const at of SIDES) {
for (const node of this.held.get(keyOf(listened, at)) ?? []) cut(node, sides[at].sum);
sides[at].sum.disconnect();
}
}
}
/**
* The spectrum on both sides of the node listened to, smoothed by the analyser: once its sound has
* gone it falls away. Null before anything has played through it.
* The spectrum on both sides of the node `id`, smoothed by the analyser: once its sound has gone
* it falls away. Null before anything has played through it, or while it is not listened to.
*/
read(): Spectrum | null {
const sides = this.sides;
read(id: string): Spectrum | null {
const sides = this.listened.get(id);
if (!sides) return null;
for (const at of SIDES) sides[at].analyser.getFloatFrequencyData(sides[at].data);
return {
@@ -93,18 +105,40 @@ export class SpectrumTaps {
};
}
/** The node listened to, the taps playing and how many of them feed an analyser (test hook). */
/**
* 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.
*/
levels(id: string, seconds: number): 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) => {
analyser.getFloatTimeDomainData(wave);
let peak = 0;
for (let i = wave.length - count; i < wave.length; 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) };
}
/** The nodes listened to, the taps playing and how many of them feed an analyser (test hook). */
info(): { listened: string; held: number; feeding: number } {
let held = 0;
for (const nodes of this.held.values()) held += nodes.size;
const feeding = this.sides
? SIDES.reduce((sum, at) => sum + (this.held.get(keyOf(this.listened, at))?.size ?? 0), 0)
: 0;
return { listened: this.listened, held, feeding };
let feeding = 0;
for (const [id, sides] of this.listened)
if (sides) for (const at of SIDES) feeding += this.held.get(keyOf(id, at))?.size ?? 0;
return { listened: [...this.listened.keys()].join(' '), held, feeding };
}
private analysis(context: BaseAudioContext): Record<Side, Analysis> {
return (this.sides ??= { in: analysisOf(context), out: analysisOf(context) });
private analysis(id: string, context: BaseAudioContext): Record<Side, Analysis> {
const made = this.listened.get(id) ?? { in: analysisOf(context), out: analysisOf(context) };
this.listened.set(id, made);
return made;
}
}
@@ -117,5 +151,10 @@ function analysisOf(context: BaseAudioContext): Analysis {
const analyser = context.createAnalyser();
analyser.fftSize = SPECTRUM_SIZE;
sum.connect(analyser);
return { sum, analyser, data: new Float32Array(analyser.frequencyBinCount) };
return {
sum,
analyser,
data: new Float32Array(analyser.frequencyBinCount),
wave: new Float32Array(SPECTRUM_SIZE),
};
}
+23 -12
View File
@@ -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', () => {
+50 -14
View File
@@ -442,21 +442,35 @@ describe('a Pan in a sound FX graph (#319)', () => {
});
describe('a Compressor and a Limiter in a sound FX graph (#326)', () => {
const at = (type: string, params?: Record<string, number>) =>
fxChainOf(compileSoundFx(line([{ type, params }]).fx.graph).compiled!).stages;
// The taps a Compressor keeps for its tab do nothing to the sound (#378).
const at = (type: string, params?: Record<string, number | boolean>) =>
fxChainOf(compileSoundFx(line([{ type, params }]).fx.graph).compiled!).stages.filter(
(s) => s.kind !== 'tap',
);
it('tap a Compressor on both sides for its tab, a Limiter not (#378)', () => {
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']);
});
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 },
]);
@@ -467,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);
@@ -485,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<string, string | number>) =>
const at = (params?: Record<string, string | number | boolean>) =>
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', () => {
@@ -502,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);
});
});
@@ -676,9 +710,11 @@ describe('Bypass and Wet (#354)', () => {
f.input.ports = [{ key: 'blend', kind: 'number', default: 0.5 }];
f.edit.connect(f.input, 'blend', compressor, 'wet');
const plan = compileSoundFx(f.fx.graph).compiled!;
expect(fxChainOf(plan).stages).toMatchObject([{ kind: 'wet', wet: 0.5, stages: [{ kind: 'compress' }] }]);
expect(fxChainOf(plan, { blend: 0 }).stages).toMatchObject([{ kind: 'wet', wet: 0 }]);
expect(fxChainOf(plan, { blend: 7 }).stages).toMatchObject([{ kind: 'wet', wet: 1 }]);
const stages = (params?: Record<string, number>) =>
fxChainOf(plan, params).stages.filter((s) => s.kind !== 'tap');
expect(stages()).toMatchObject([{ kind: 'wet', wet: 0.5, stages: [{ kind: 'compress' }] }]);
expect(stages({ blend: 0 })).toMatchObject([{ kind: 'wet', wet: 0 }]);
expect(stages({ blend: 7 })).toMatchObject([{ kind: 'wet', wet: 1 }]);
});
it('a blend is as late as what it blends and rings as long', () => {
+166
View File
@@ -0,0 +1,166 @@
/**
* What the tab of a Compressor draws (#378): its curve and the handles on it, the ratio a drag of
* the end of the curve asks for, and the level picture tinted where the compressor works.
*/
import { describe, expect, it } from 'vitest';
import {
autoGainText,
type CompressorNumbers,
compressorGainDb,
compressorNumbers,
curveDbAtX,
curveDbAtY,
curveOutDb,
curveX,
curveY,
HISTORY_SECONDS,
LevelHistory,
levelY,
MAX_CURVE,
MIN_CURVE,
picturesLayout,
ratioFor,
ratioHandle,
reductionOf,
reductionShapes,
thresholdHandle,
} from '../../src/editor/soundfx/compressorView';
const numbers: CompressorNumbers = {
threshold: -24,
knee: 0,
ratio: 4,
attack: 0.003,
release: 0.25,
makeup: 0,
autoGain: false,
};
const RATIO = { min: 1, max: 20, step: 0.1 };
describe('the curve of a Compressor', () => {
it('leaves a level below the threshold as it is and squeezes one above it by the ratio', () => {
expect(curveOutDb(-40, -24, 0, 4)).toBeCloseTo(-40, 6);
// 24 dB over a threshold of -24 come out 6 dB over it at a ratio of 4.
expect(curveOutDb(0, -24, 0, 4)).toBeCloseTo(-18, 1);
});
it('spans -60 to 0 dB on both axes, level out going up', () => {
expect(curveX(-60, 300)).toBe(0);
expect(curveX(0, 300)).toBe(300);
expect(curveY(0, 300)).toBe(0);
expect(curveDbAtX(150, 300)).toBe(-30);
expect(curveDbAtY(100, 300)).toBe(-20);
});
it('has the threshold where it starts to bend and the ratio at its end', () => {
expect(thresholdHandle(numbers, 300)).toEqual({ x: 180, y: 120 });
const end = ratioHandle(numbers, 300);
expect(end.x).toBe(300);
expect(curveDbAtY(end.y, 300)).toBeCloseTo(-18, 1);
});
it('finds the ratio whose end is where it is dragged to, held to the range', () => {
expect(ratioFor(-18, numbers, RATIO)).toBe(4);
expect(ratioFor(-12, numbers, RATIO)).toBe(2);
expect(ratioFor(5, numbers, RATIO)).toBe(1);
expect(ratioFor(-40, numbers, RATIO)).toBe(20);
// With a knee the curve ends lower than the straight line would, and the ratio found still lands there.
const kneed = { ...numbers, knee: 6 };
const ratio = ratioFor(-16, kneed, RATIO);
expect(curveOutDb(0, -24, 6, ratio)).toBeCloseTo(-16, 0);
});
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);
// -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);
});
});
describe('the numbers of a Compressor node (#380)', () => {
it('takes what the node has set, its defaults where it has none or no number, and reads an old auto-gain', () => {
expect(compressorNumbers({ threshold: -30, ratio: 'x', autoGain: 0.5 })).toEqual({
threshold: -30,
ratio: 4,
knee: 6,
attack: 0.003,
release: 0.25,
makeup: 0,
autoGain: true,
});
expect(compressorNumbers({}).autoGain).toBe(false);
});
it('says what auto-gain gives, or that it is off', () => {
expect(autoGainText({ ...numbers, autoGain: false })).toBe('off');
expect(autoGainText({ ...numbers, autoGain: true })).toMatch(/^\+\d+\.\d dB$/);
});
});
describe('the level picture of a Compressor', () => {
it('spans +6 to -60 dB top to bottom, silence at the bottom', () => {
expect(levelY(6, 660)).toBe(0);
expect(levelY(-60, 660)).toBe(660);
expect(levelY(-Infinity, 660)).toBe(660);
expect(levelY(0, 660)).toBe(60);
});
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 }, 0);
history.push(1, null, 0);
expect(history.heard).toBe(true);
history.push(1 + HISTORY_SECONDS, null, 0);
expect(history.all.map((p) => p.t)).toEqual([1, 1 + HISTORY_SECONDS]);
expect(history.heard).toBe(false);
});
it('takes away what came in less what went out, the gain after it left out', () => {
expect(reductionOf({ in: -6, out: -12 }, 0)).toBe(6);
expect(reductionOf({ in: -6, out: -6 }, 6)).toBe(6);
expect(reductionOf({ in: -30, out: -29 }, 0)).toBe(0);
expect(reductionOf({ in: -Infinity, out: -Infinity }, 0)).toBe(0);
});
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, 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, 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);
});
});
+18 -1
View File
@@ -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', () => {
+131
View File
@@ -0,0 +1,131 @@
/**
* The Compressor's hardware panel (#379): every part on the plate and none over another, the meter's
* scale and needle, the LEDs, and the grain of the plate the same every time.
*/
import { describe, expect, it } from 'vitest';
import {
type Box,
brushStrokes,
gainReduction,
knobBox,
LED_COUNT,
ledColour,
ledDb,
ledsLit,
ledStep,
METER_LABELS,
METER_RANGE_DB,
meterPosition,
needleAngle,
needleStep,
PANEL,
PANEL_LAYOUT,
} from '../../src/editor/soundfx/hardwareView';
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;
describe('the plate', () => {
const L = PANEL_LAYOUT;
const parts: [string, Box][] = [
['title', L.title],
['lcd', L.lcd],
['autoGain', L.autoGain],
['meter', L.meter],
['leds', L.leds],
...Object.entries(L.knobs).map(([key, spot]): [string, Box] => [key, knobBox(spot)]),
];
it('holds every part inside it, clear of the screws', () => {
for (const [, box] of parts) {
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.x + box.w).toBeLessThanOrEqual(PANEL.w);
expect(box.y + box.h).toBeLessThanOrEqual(PANEL.h);
for (const screw of L.screws)
expect(overlap(box, { x: screw.x - 7, y: screw.y - 7, w: 14, h: 14 })).toBe(false);
}
});
it('puts no part over another', () => {
const over = parts.flatMap(([a, boxA], i) =>
parts.slice(i + 1).flatMap(([b, boxB]) => (overlap(boxA, boxB) ? [`${a}/${b}`] : [])),
);
expect(over).toEqual([]);
});
it('has the big knobs at its ends and the small ones beside the meter', () => {
const { threshold, makeup, ratio, attack } = L.knobs;
expect(threshold.size).toBeGreaterThan(ratio.size);
expect(threshold.x).toBeLessThan(ratio.x);
expect(ratio.x).toBeLessThan(L.meter.x);
expect(attack.x).toBeGreaterThan(L.meter.x + L.meter.w);
expect(makeup.x).toBeGreaterThan(attack.x);
});
it('is brushed the same every time, a stroke per row', () => {
const strokes = brushStrokes(PANEL.h);
expect(strokes).toHaveLength(PANEL.h);
expect(brushStrokes(PANEL.h)).toEqual(strokes);
const alphas = strokes.map((s) => s.alpha);
expect(Math.max(...alphas) - Math.min(...alphas)).toBeGreaterThan(0.07);
expect(strokes.some((s) => s.light) && strokes.some((s) => !s.light)).toBe(true);
});
});
describe('the meter', () => {
it('reads 0 dB at the right end, the range at the left, and the first dB about as wide as the last nine', () => {
expect(meterPosition(0)).toBe(1);
expect(meterPosition(METER_RANGE_DB)).toBe(0);
expect(meterPosition(40)).toBe(0);
expect(meterPosition(-3)).toBe(1);
expect(1 - meterPosition(1)).toBeCloseTo(meterPosition(15) - meterPosition(24), 1);
const spots = METER_LABELS.map(meterPosition);
for (let i = 1; i < spots.length; i++) expect(spots[i]).toBeLessThan(spots[i - 1]!);
});
it('swings the needle symmetrically about straight up', () => {
expect(needleAngle(0.5)).toBeCloseTo(-Math.PI / 2);
expect(needleAngle(0) + needleAngle(1)).toBeCloseTo(-Math.PI);
expect(needleAngle(1)).toBeGreaterThan(needleAngle(0));
});
it('moves the needle as a VU: most of the way in 300 ms, and there in the end', () => {
let shown = 0;
for (let t = 0; t < 0.3; t += 1 / 60) shown = needleStep(shown, 10, 1 / 60);
expect(shown).toBeGreaterThan(9);
expect(shown).toBeLessThan(10);
expect(needleStep(0, 10, 1 / 60)).toBeLessThan(3);
for (let i = 0; i < 60; i++) shown = needleStep(shown, 10, 1 / 60);
expect(shown).toBe(10);
// A long pause between two frames moves it no further than a quarter of a second does.
expect(needleStep(5, 0, 10)).toBe(needleStep(5, 0, 0.25));
});
it('takes the gain after the compressing out of what it reads, and nothing from silence', () => {
expect(gainReduction({ in: -10, out: -6 }, 10)).toBeCloseTo(6);
expect(gainReduction({ in: -10, out: -10 }, 0)).toBe(0);
expect(gainReduction({ in: -10, out: -2 }, 0)).toBe(0);
expect(gainReduction({ in: -Infinity, out: -Infinity }, 0)).toBe(0);
expect(gainReduction(null, 0)).toBe(0);
});
});
describe('the LEDs', () => {
it('light one every 3 dB up to 0, red at the top and green at the bottom', () => {
expect(ledDb(LED_COUNT - 1)).toBe(0);
expect(ledDb(LED_COUNT - 2)).toBe(-3);
expect(ledColour(LED_COUNT - 1)).toBe('red');
expect(ledColour(LED_COUNT - 2)).toBe('red');
expect(ledColour(LED_COUNT - 4)).toBe('amber');
expect(ledColour(0)).toBe('green');
expect(ledsLit(-Infinity)).toBe(0);
expect(ledsLit(0)).toBe(LED_COUNT);
expect(ledsLit(-4)).toBe(LED_COUNT - 2);
});
it('go up at once and down by 20 dB a second', () => {
expect(ledStep(-30, -6, 1 / 60)).toBe(-6);
expect(ledStep(-6, -30, 0.1)).toBeCloseTo(-8);
expect(ledStep(-6, -Infinity, 10)).toBe(-11);
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* The rules of a Knob (#378): where a value sits on the turn, and what a drag and the wheel make of it.
*/
import { describe, expect, it } from 'vitest';
import {
draggedValue,
KNOB_TRAVEL,
knobOrigin,
knobPosition,
knobValue,
wheeledValue,
} from '../../src/ui/knob';
const threshold = { min: -100, max: 0, step: 0.5 };
const attack = { min: 0, max: 1, step: 0.001, taper: 3 };
const makeup = { min: -24, max: 24, step: 0.5 };
describe('a knob', () => {
it('turns evenly without a taper, and gives the low end more of the turn with one', () => {
expect(knobPosition(-50, threshold)).toBeCloseTo(0.5, 9);
expect(knobValue(0.5, threshold)).toBe(-50);
// An attack of 3 ms is an eighth of the way round, not three thousandths.
expect(knobPosition(0.003, attack)).toBeCloseTo(0.144, 3);
expect(knobValue(knobPosition(0.003, attack), attack)).toBe(0.003);
expect(knobValue(-1, attack)).toBe(0);
expect(knobValue(2, attack)).toBe(1);
});
it('turns up as it is dragged up, a tenth as far with Shift', () => {
expect(draggedValue(-50, -KNOB_TRAVEL / 2, false, threshold)).toBe(0);
expect(draggedValue(-50, KNOB_TRAVEL / 4, false, threshold)).toBe(-75);
expect(draggedValue(-50, -KNOB_TRAVEL / 2, true, threshold)).toBe(-45);
expect(draggedValue(-50, KNOB_TRAVEL * 10, false, threshold)).toBe(-100);
});
it('moves at least one step, however short the drag', () => {
expect(draggedValue(-50, -1, true, threshold)).toBe(-49.5);
expect(draggedValue(-50, 1, true, threshold)).toBe(-50.5);
expect(draggedValue(0, -1, true, threshold)).toBe(0);
expect(draggedValue(-50, 0, false, threshold)).toBe(-50);
});
it('notches up on the wheel turned up, and down on it turned down', () => {
expect(wheeledValue(-50, -100, threshold)).toBe(-48);
expect(wheeledValue(-50, 100, threshold)).toBe(-52);
expect(wheeledValue(0.003, -100, attack)).toBeGreaterThan(0.003);
});
it('fills its arc from 0 when the range has it inside, else from the low end', () => {
expect(knobOrigin(makeup)).toBeCloseTo(0.5, 9);
expect(knobOrigin(threshold)).toBe(0);
expect(knobOrigin(attack)).toBe(0);
});
});
+2 -2
View File
@@ -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 },
+44 -3
View File
@@ -23,6 +23,12 @@ class FakeNode {
getFloatFrequencyData(data: Float32Array): void {
data.fill(this.context.made.length);
}
/** What the analyser heard last: `wave`, at the end of `data` like the newest samples. */
wave: number[] = [];
getFloatTimeDomainData(data: Float32Array): void {
data.fill(0);
data.set(this.wave, data.length - this.wave.length);
}
}
class FakeContext {
@@ -50,7 +56,7 @@ describe('the taps of the spectrum', () => {
taps.hold(tap('fx:eq', 'in'), new FakeNode(context) as never);
expect(taps.info()).toEqual({ listened: '', held: 1, feeding: 0 });
expect(context.made).toHaveLength(0);
expect(taps.read()).toBeNull();
expect(taps.read('fx:eq')).toBeNull();
});
it('feed the analyser of their side while listened to, a voice started before as well', () => {
@@ -69,7 +75,7 @@ describe('the taps of the spectrum', () => {
expect([...before.links]).toEqual([sumIn]);
expect([...after.links]).toEqual([sumOut]);
expect(other.links.size).toBe(0);
expect(taps.read()?.binHz).toBeCloseTo(48000 / 8192, 9);
expect(taps.read('fx:eq')?.binHz).toBeCloseTo(48000 / 8192, 9);
});
it('let go of the analysers once nobody listens, or once the voice stops', () => {
@@ -87,6 +93,41 @@ describe('the taps of the spectrum', () => {
taps.stop();
expect(bus.links.size).toBe(0);
expect(taps.info()).toEqual({ listened: '', held: 1, feeding: 0 });
expect(taps.read()).toBeNull();
expect(taps.read('fx:eq')).toBeNull();
});
it('listen to two nodes at once, and let one go without the other (#378)', () => {
const context = new FakeContext();
const taps = new SpectrumTaps();
const eq = new FakeNode(context);
const compressor = new FakeNode(context);
taps.hold(tap('fx:eq', 'in'), eq as never);
taps.hold(tap('fx:comp', 'in'), compressor as never);
taps.listen('fx:eq');
taps.listen('fx:comp');
expect(taps.info()).toEqual({ listened: 'fx:eq fx:comp', held: 2, feeding: 2 });
expect(eq.links.size).toBe(1);
expect(compressor.links.size).toBe(1);
taps.stop('fx:eq');
expect(eq.links.size).toBe(0);
expect(compressor.links.size).toBe(1);
expect(taps.info()).toEqual({ listened: 'fx:comp', held: 2, feeding: 1 });
expect(taps.read('fx:comp')).not.toBeNull();
});
it('give the loudest sample of the last stretch on each side, in dB (#378)', () => {
const context = new FakeContext();
const taps = new SpectrumTaps();
expect(taps.levels('fx:comp', 0.01)).toBeNull();
taps.listen('fx:comp');
taps.hold(tap('fx:comp', 'in'), new FakeNode(context) as never);
const [, analyserIn, , analyserOut] = context.made;
// A peak of 1 long ago, then 0.5: a window of 10 ms (480 samples) only sees the 0.5.
analyserIn!.wave = [1, ...new Array<number>(1000).fill(0), -0.5, 0.25];
analyserOut!.wave = [];
const levels = taps.levels('fx:comp', 0.01);
expect(levels?.in).toBeCloseTo(20 * Math.log10(0.5), 9);
expect(levels?.out).toBe(-Infinity);
expect(taps.levels('fx:comp', 1)?.in).toBeCloseTo(0, 9);
});
});
+299 -20
View File
@@ -680,6 +680,281 @@ export async function sound(t) {
eqHeard.last?.stages === 2 && !eqDocsAfter.docs.some((doc) => doc.startsWith('eq:')),
JSON.stringify({ last: eqHeard.last, eqDocsAfter }),
);
// #378: a Compressor behind the gain opens a tab of its own as well. Its knobs turn with a drag up
// (one undo step), a notch of the wheel and a double-click back to the default, and take a
// typed number. The end of its curve is dragged for the ratio. Played, the level going out
// stays under the one coming in, and a knob whose input is linked is greyed.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(
node('fx-comp', 'SoundFxCompressor', -240, 0, {
threshold: -30,
ratio: 4,
knee: 0,
}),
)});
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
graph.edges.push(
${JSON.stringify(edge('fx-e12', 'fx-gain', 'out', 'fx-comp', 'in'))},
${JSON.stringify(edge('fx-e13', 'fx-comp', 'out', 'fx-out', 'in'))},
);`);
const compNode = await json("__gm.soundfx.nodeAt('fx-comp')");
await t.doubleClick(compNode.x, compNode.y);
const compShown = await json(
'({ shown: __gm.compressor.shown(), docs: __gm.documents(), knobs: __gm.compressor.knobs() })',
);
check(
'a double-click opens the Compressor in a tab, a knob for each of its numbers',
compShown.shown?.nodeId === 'fx-comp' &&
compShown.docs.active === `compressor:${fxId}:fx-comp` &&
compShown.knobs.threshold.value === -30 &&
compShown.knobs.ratio.value === 4 &&
compShown.knobs.knee.value === 0 &&
compShown.knobs.attack.value === 0.003 &&
Object.keys(compShown.knobs).length === 6,
JSON.stringify(compShown),
);
const thresholdDial = await json("__gm.compressor.dialAt('threshold')");
await drag(thresholdDial.x, thresholdDial.y, thresholdDial.x, thresholdDial.y - 20);
const compDragged = await json('__gm.compressor.numbers()');
await press('z', { ctrl: true });
const compUndone = await json('__gm.compressor.numbers()');
await press('z', { ctrl: true, shift: true });
const ratioDial = await json("__gm.compressor.dialAt('ratio')");
await t.wheel(ratioDial.x, ratioDial.y, -100);
const kneeDial = await json("__gm.compressor.dialAt('knee')");
await t.doubleClick(kneeDial.x, kneeDial.y);
const makeupField = await json("__gm.compressor.fieldAt('makeup')");
await click(makeupField.x, makeupField.y);
await t.typeText('3');
await press('Enter');
await sleep(150);
const compTurned = await json('({ numbers: __gm.compressor.numbers(), knobs: __gm.compressor.knobs() })');
check(
'a knob turns up with a drag up as one undo step, a notch of the wheel, a double-click and a typed number',
compDragged.threshold === -20 &&
compUndone.threshold === -30 &&
compTurned.numbers.threshold === -20 &&
compTurned.numbers.ratio > 4 &&
compTurned.numbers.ratio < 5 &&
compTurned.numbers.knee === 6 &&
compTurned.numbers.makeup === 3 &&
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);
const compCurve = await json(
"({ numbers: __gm.compressor.numbers(), handle: __gm.compressor.handleAt('ratio'), at: __gm.compressor.pointAt(0, -10) })",
);
await press('z', { ctrl: true });
const compCurveUndone = await json('__gm.compressor.numbers()');
check(
'dragging the end of the curve sets the ratio that ends there, as one undo step',
compCurve.numbers.ratio < compTurned.numbers.ratio &&
Math.abs(compCurve.handle.y - compCurve.at.y) < 2 &&
compCurveUndone.ratio === compTurned.numbers.ratio,
JSON.stringify({ compCurve, compCurveUndone }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
Object.assign(graph.nodes.find((n) => n.id === 'fx-comp').params, { threshold: -50, ratio: 8, knee: 0, makeup: 0 });`);
await evaluate('__gm.sound.stopAll()');
const levelsBefore = (await json('__gm.compressor.levels()')).frames;
await evaluate('__gm.soundfx.play()');
const compLevels = await waitFor(
() => json('__gm.compressor.levels()'),
(l) => l.frames >= levelsBefore + 3 && l.reduction > 3,
);
await shot('34-compressor');
check(
'played, the tab reads the level on both sides of the node, the one going out squeezed under the one coming in',
compLevels.taps.listened === `${fxId}:fx-comp` &&
compLevels.in > -50 &&
compLevels.out < compLevels.in &&
compLevels.reduction > 3,
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'))});`);
const compLinked = await json('__gm.compressor.knobs()');
// Not the sound FX graph: the node there listens to the same taps (#380).
await showTab('scene');
await sleep(100);
const compUnseen = await json('__gm.compressor.levels()');
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-comp');
graph.edges = graph.edges.filter((e) => !['fx-e12', 'fx-e13', 'fx-e14'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
const compDocsAfter = await json('__gm.documents()');
check(
'a linked input greys its knob, an unseen tab feeds no analyser, and the tab goes with its node',
!compLinked.ratio.enabled &&
compLinked.threshold.enabled &&
compUnseen.taps.listened === '' &&
!compDocsAfter.docs.some((doc) => doc.startsWith('compressor:')),
JSON.stringify({ compLinked, unseen: compUnseen.taps, compDocsAfter }),
);
// #379, #380: a Compressor node is drawn as its device – its inputs on the left, its output on the
// right, the panel between them. Its knobs and its lever work right in the graph, and
// played, the meter's needle swings and the LEDs light. A double-click opens the tab of #378.
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-hw', 'SoundFxCompressor', -240, 0, { threshold: -50, ratio: 8, knee: 0, makeup: 20 }))});
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
graph.edges.push(
${JSON.stringify(edge('fx-e15', 'fx-gain', 'out', 'fx-hw', 'in'))},
${JSON.stringify(edge('fx-e16', 'fx-hw', 'out', 'fx-out', 'in'))},
);`);
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const hwPanel = () => json("__gm.compressor.panel('fx-hw')");
const hwShown = await hwPanel();
const hwRect = hwShown.rect;
const hwInputs = Object.values(hwShown.inputs);
const hwOutputs = Object.values(hwShown.outputs);
const besideThePanel = (p) => p.y > hwRect.y && p.y < hwRect.y + hwRect.h;
check(
'a Compressor node shows its panel at full size, its inputs on the left of it and its output on the right',
hwShown.shown &&
hwShown.size.w === 820 &&
hwShown.size.h === 280 &&
hwInputs.length === 8 &&
hwInputs.every((p) => p.x < hwRect.x && besideThePanel(p)) &&
hwOutputs.length === 1 &&
hwOutputs.every((p) => p.x > hwRect.x + hwRect.w && besideThePanel(p)) &&
hwShown.node.w > hwRect.w &&
hwShown.node.h > hwRect.h,
JSON.stringify(hwShown),
);
const hwThreshold = hwShown.dials.threshold;
await drag(hwThreshold.x, hwThreshold.y, hwThreshold.x, hwThreshold.y - 20);
const hwNumbers = () =>
json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-hw').params`,
);
const hwDragged = await hwNumbers();
await press('z', { ctrl: true });
const hwUndone = await hwNumbers();
const hwRatio = hwShown.dials.ratio;
await t.wheel(hwRatio.x, hwRatio.y, -100);
const hwWheeled = { numbers: await hwNumbers(), panel: await hwPanel() };
await t.doubleClick(hwRatio.x, hwRatio.y);
const hwReset = { numbers: await hwNumbers(), docs: await json('__gm.documents()') };
await click(hwShown.autoGain.at.x, hwShown.autoGain.at.y);
await sleep(100);
const hwLever = { numbers: await hwNumbers(), panel: await hwPanel() };
check(
'in the node, a knob turns with a drag up as one undo step and a notch of the wheel without zooming the graph, a double-click resets it without opening the tab, and the lever switches auto-gain',
hwDragged.threshold > -50 &&
hwUndone.threshold === -50 &&
hwWheeled.numbers.ratio > 8 &&
Math.abs(hwWheeled.panel.rect.w - hwRect.w) < 0.5 &&
hwReset.numbers.ratio === 4 &&
hwReset.docs.active === `asset:${fxId}` &&
hwLever.numbers.autoGain === true &&
hwLever.panel.autoGain.on &&
/^\+\d+\.\d dB$/.test(hwLever.panel.autoGain.says),
JSON.stringify({ hwDragged, hwUndone, hwWheeled, hwReset, hwLever }),
);
await press('z', { ctrl: true });
await evaluate('__gm.sound.stopAll()');
await evaluate('__gm.soundfx.play()');
const hwPlayed = await waitFor(hwPanel, (p) => p.seen.needle > 3 && p.seen.leds > 0);
await shot('34-compressor-node');
check(
'played, the needle in the node swings towards what is taken away and the LEDs light with the level going out',
hwPlayed.seen.needle > 3 && hwPlayed.seen.leds > 0 && /GR +\d+\.\d dB/.test(hwPlayed.lcd),
JSON.stringify(hwPlayed),
);
await evaluate('__gm.sound.stopAll()');
const hwTitle = await json("__gm.soundfx.nodeAt('fx-hw')");
await t.doubleClick(hwTitle.x, hwTitle.y);
await sleep(100);
const hwTab = {
docs: await json('__gm.documents()'),
windows: await json('__gm.floatWindows()'),
shown: await json('__gm.compressor.shown()'),
};
// The tab and the node listen to the same taps: the tab, open but not seen, must leave them be.
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
await sleep(100);
await evaluate('__gm.soundfx.play()');
const hwBesideTab = await waitFor(hwPanel, (p) => p.seen.needle > 3);
await evaluate('__gm.sound.stopAll()');
check(
'a double-click on the node opens its tab in the row, and with the tab open the node’s needle still swings',
hwTab.docs.active === `compressor:${fxId}:fx-hw` &&
hwTab.windows.length === 0 &&
hwTab.shown?.nodeId === 'fx-hw' &&
hwBesideTab.seen.needle > 3,
JSON.stringify({ hwTab, seen: hwBesideTab.seen }),
);
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-hw');
graph.edges = graph.edges.filter((e) => !['fx-e15', 'fx-e16'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
await showTab('scene');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
@@ -1213,24 +1488,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,
@@ -2664,8 +2942,9 @@ export async function sound(t) {
() => json(`__gm.routing.peaks(${tapsLong})`),
(peaks) => Math.min(Math.max(...(peaks.mix ?? [0])), Math.max(...(peaks['mix:sn-bus'] ?? [0]))) > 0.01,
);
await sleep(2500);
const playingStreamed = rendererMb();
await sleep(1000);
// After a garbage collection as at rest: play mode makes garbage of its own (#378, CI task 614).
const playingStreamed = await restingMb();
const renderersPlaying = renderers();
await t.clickToolbar('pause');
await sleep(500);
@@ -2678,20 +2957,12 @@ export async function sound(t) {
await stop();
const stoppedLong = await stats();
check(
'played, the streamed sound is heard on its bus and the master, the renderer growing by less than 20 MB, none of the file decoded',
'played, the streamed sound is heard on its bus and the master, none of the file decoded',
streaming?.last?.file === long.file &&
streaming.last.bus === 'mix:sn-bus' &&
streaming.decoded === decodedBefore &&
playingStreamed - restStreamed < 20 &&
Math.max(...(heardLong?.mix ?? [0])) > 0.01,
JSON.stringify({
restStreamed,
playingStreamed,
renderersBefore,
renderersPlaying,
streaming,
heardLong,
}),
JSON.stringify({ streaming, heardLong }),
);
check(
'the pause holds the stream where it is, it plays on after it without a sample lost, and stopping ends it',
@@ -2712,8 +2983,8 @@ export async function sound(t) {
await mutate(`__gm.state.project.assets.find((a) => a.id === 'sn-long').props.stream = false;`);
await play();
const decodedNow = await waitFor(stats, (s) => s.playing > 0 && s.last?.file === long.file, 60);
await sleep(2500);
const playingDecoded = rendererMb();
await sleep(1000);
const playingDecoded = await restingMb();
await stop();
await evaluate('__gm.streams.idle()');
check(
@@ -2724,6 +2995,14 @@ export async function sound(t) {
(await json("__gm.streams.copies('sn-long')"))?.sound === null,
JSON.stringify({ restDecoded, playingDecoded, decodedNow }),
);
// What play mode makes itself is in both, so it is the difference that tells (#378, CI task 614).
const grownStreamed = playingStreamed - restStreamed;
const grownDecoded = playingDecoded - restDecoded;
check(
'streamed, the renderer grows by at least 30 MB less than decoded',
grownStreamed + 30 < grownDecoded,
JSON.stringify({ grownStreamed, grownDecoded, renderersBefore, renderersPlaying }),
);
// The Sound tab points a long sound at the switch, and says once it streams.
await evaluate("__gm.state.openAsset('sn-long')");