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>
This commit is contained in:
1 parent
23922ee4a2
commit
1df38bd430
20 files changed
+1471
-103
No files matched your search
+129
-14
@@ -680,6 +680,120 @@ 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 === 7,
|
||||
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 }),
|
||||
);
|
||||
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()');
|
||||
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()');
|
||||
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
|
||||
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 }),
|
||||
);
|
||||
await showTab('scene');
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||||
@@ -2664,8 +2778,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 +2793,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 +2819,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 +2831,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')");
|
||||
|
||||
Reference in new issue
Block a user