Show Tremolo, Vibrato, Phaser and Flanger as devices that swing (#398)

The four effects that swing were still drawn as rows of numbers. Like
the Delay, the Saturation and the Limiter, each node now shows a device
of a fixed size. Its screen draws what it does over the first seconds
of a sound, and a light plays that in real time, over and over.

- One device for all four (ModulationPanel, ModulationFace and
  modulationView), as KnobPanel is for Gain, Pitch, Mix and Pan. A short
  description gives each its title, plate, buttons and knobs.
- Tremolo, 640x280 in cream: a wave its volume shapes, the volume as
  its outline. It says DOWN TO 50%, EVERY 200 ms and DIPS -6 dB.
- Vibrato, 640x280 in plum: the pitch in semitones, cool below and warm
  above. Under a wet below 1 the dry pitch stands beside it. It says
  how late the voice is (LATE 0.93 ms).
- Phaser, 760x280 in mustard, and Flanger, 680x280 in steel blue: the
  response from 20 Hz to 20 kHz, and under it a lane of the turn or the
  delay over time. The response is drawn where the light is, so the
  notches swirl and the comb jets. A comb finer than a column fills it.
- The light loops over 1, 2, 4 or 8 s, two LFO swings at least, for a
  Flanger as long as its sweep takes to reach 20 ms. The face only
  keeps the editor drawing while something swings and is heard.
- A Rate lamp beside each name goes from dark to lit with the swing.
- Nothing is listened to. The pictures use the worklets' own numbers:
  lfoAt, vibratoSpeedUp (now exported from lfo.ts), the allpass chain
  with its feedback, and the comb, reckoned at 48 kHz.
- Phaser and Flanger take 4 preset columns. Phased Pad was looked at
  in a picture, nothing overlaps.
- Exported from nodes.ts: LFO_SHAPE, lfoShapeOf, TREMOLO_ROWS,
  VIBRATO_ROWS, PHASER_STAGE_ROW and phaserStagesOf. FLANGER_MAX_MS is
  exported from phaser.ts.

Decided here, as the request left the look open:

- A notch counts where the response falls below -3 dB, not some dB
  under the loudest point. Feedback rings peaks far above 0 dB, and the
  first rule counted 7 notches on a Phaser of 12 stages.
- Rate and Depth of Tremolo and Vibrato are big knobs, Wet is small.

Tests:
  Unit modulation-view (stein2): 22 tests, new. Phaser and Flanger
    give the dB of their worklets on a sine within 0.005 dB, the
    Tremolo its samples, the Vibrato the pitch it reads at.
  Counter-check: with the sign of the Phaser's feedback flipped in one
    place, exactly the worklet test fails.
  e2e 34 (atx): ALL PASSED, 142 checks, 4 new. Each device has its
    size, its ports outside, a light that runs by itself, and a button
    and a knob as one undo step each. Picking 8 stages on the Phaser
    draws 4 notches.
  Gate (atx): CHECK EXIT 0, 291 files, 4467 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 21:02:50 +02:00
1 parent 28909f0adf
commit b6749cc43d
12 files changed
+1860 -11

No files matched your search

+83
View File
@@ -1554,6 +1554,89 @@ export async function sound(t) {
into.to = { node: 'fx-out', port: 'in' };
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e-lm');
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-lm');`);
// #398: a Tremolo, a Vibrato, a Phaser and a Flanger are devices of their own size whose screens
// draw what they do over the first seconds of a sound, a light playing it by itself. A
// button and a knob are one undo step each, and the screen follows them.
const swinging = [
{
kind: 'tremolo',
type: 'SoundFxTremolo',
size: [640, 280],
inputs: 4,
pick: 'square',
knob: 'depth',
shown: (p) => p.knobs.rate.number === '5 Hz' && p.side === 'DOWN TO 50%' && p.picked === 'sine',
turned: (p) => p.side !== 'DOWN TO 50%' && p.picked === 'square',
},
{
kind: 'vibrato',
type: 'SoundFxVibrato',
size: [640, 280],
inputs: 4,
pick: 'triangle',
knob: 'depth',
shown: (p) => p.side === 'UP 0.5 st DOWN 0.51 st' && p.scope.says.right === 'LATE 0.93 ms',
turned: (p) => p.scope.highest > 0.5 && p.picked === 'triangle',
},
{
kind: 'phaser',
type: 'SoundFxPhaser',
size: [760, 280],
inputs: 6,
pick: '8',
knob: 'frequency',
shown: (p) => p.side === '2 NOTCHES' && p.scope.notches.length === 2,
turned: (p) => p.side === '4 NOTCHES' && p.scope.notches.length === 4 && p.scope.highest > 1600,
},
{
kind: 'flanger',
type: 'SoundFxFlanger',
size: [680, 280],
inputs: 7,
pick: null,
knob: 'sweep',
shown: (p) => p.side === 'DELAY 1 ms TO 3 ms' && p.scope.says.right === 'NO SWEEP',
turned: (p) => p.scope.says.right.startsWith('SWEEP +'),
},
];
for (const device of swinging) {
const id = `fx-${device.kind}`;
const panel = () => json(`__gm.${device.kind}.panel('${id}')`);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node(id, device.type, 0, 400, {}))});`);
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const shown = await panel();
await sleep(150);
const later = (await panel()).scope.at;
if (device.pick) await click(shown.picks[device.pick].x, shown.picks[device.pick].y);
const dial = shown.knobs[device.knob].dial;
await drag(dial.x, dial.y, dial.x, dial.y - 20);
const turned = { params: await knobParams(id), panel: await panel() };
for (let i = 0; i < (device.pick ? 2 : 1); i++) await press('z', { ctrl: true });
const undone = { params: await knobParams(id), panel: await panel() };
check(
`a ${device.kind} node is a device whose screen plays what it does; a button and a knob are one undo step each`,
shown.size.w === device.size[0] &&
shown.size.h === device.size[1] &&
Object.values(shown.inputs).length === device.inputs &&
Object.values(shown.inputs).every((p) => p.x < shown.rect.x) &&
Object.values(shown.outputs).every((p) => p.x > shown.rect.x + shown.rect.w) &&
device.shown(shown) &&
shown.scope.moving &&
// The light runs on by itself.
later !== shown.scope.at &&
turned.params[device.knob] !== undefined &&
device.turned(turned.panel) &&
Object.keys(undone.params).length === 0 &&
device.shown(undone.panel),
JSON.stringify({ shown, later, turned, undone }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== ${JSON.stringify(id)});`);
}
await showTab('scene');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;