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

+3 -1
View File
@@ -396,11 +396,13 @@ const ROW = 340;
/**
* Columns a node takes that is drawn as its editor in the graph (#385): a Filter's curve is wide,
* a Chip's device (#386) wider, an Envelope's (#388) a little less, a Pitch's and a Mix's (#390) two,
* a Delay's (#393) four.
* a Delay's (#393), a Phaser's and a Flanger's (#398) four.
*/
const COLUMNS_OF: Readonly<Record<string, number>> = {
[SoundFxFilter.type]: 4,
[SoundFxDelay.type]: 4,
[SoundFxPhaser.type]: 4,
[SoundFxFlanger.type]: 4,
[SoundFxPitch.type]: 2,
[SoundFxMix.type]: 2,
[INSTRUMENT_CHIP]: 5,
+3 -3
View File
@@ -68,7 +68,7 @@ export function lfoSwing(shape: LfoShape): number {
}
/** How much faster the sound is read at the top of a Vibrato `depth` semitones deep. */
const speedUp = (depth: number) => 2 ** (Math.max(0, depth) / 12) - 1;
export const vibratoSpeedUp = (depth: number) => 2 ** (Math.max(0, depth) / 12) - 1;
/** The longest a Vibrato's delay may get, and so what its line holds. */
const VIBRATO_MAX_DELAY = 0.5;
@@ -79,7 +79,7 @@ const VIBRATO_MAX_DELAY = 0.5;
*/
export function vibratoDelay(shape: LfoShape, rate: number, depth: number): number {
if (!(rate > 0)) return 0;
return Math.min(VIBRATO_MAX_DELAY / 2, (speedUp(depth) * lfoSwing(shape)) / rate);
return Math.min(VIBRATO_MAX_DELAY / 2, (vibratoSpeedUp(depth) * lfoSwing(shape)) / rate);
}
const at = (values: Float32Array, i: number) => (values.length > 1 ? values[i]! : values[0]!);
@@ -134,7 +134,7 @@ export class Vibrato {
while (this.lines.length < outputs.length) this.lines.push(new Float32Array(this.size));
for (let i = 0; i < length; i++) {
const rate = Math.max(1e-3, at(numbers.rate, i));
const swing = (speedUp(at(numbers.depth, i)) / rate) * swingAt(this.shape, this.phase);
const swing = (vibratoSpeedUp(at(numbers.depth, i)) / rate) * swingAt(this.shape, this.phase);
const delay = Math.max(0, Math.min(VIBRATO_MAX_DELAY, this.center + swing)) * this.sampleRate;
const read = this.written - delay;
const from = Math.floor(read);
+6 -4
View File
@@ -871,7 +871,7 @@ export const PHASER_ROWS = {
feedback: PHASER_FEEDBACK,
mix: PHASER_MIX,
};
const PHASER_STAGE_ROW = {
export const PHASER_STAGE_ROW = {
key: 'stages',
label: 'Stages',
kind: 'enum' as const,
@@ -879,7 +879,7 @@ const PHASER_STAGE_ROW = {
options: PHASER_STAGES.map((n) => ({ value: String(n), label: String(n) })),
};
function phaserStagesOf(settings: PropertyBag): number {
export function phaserStagesOf(settings: PropertyBag): number {
const stages = Number(settings[PHASER_STAGE_ROW.key] ?? PHASER_STAGE_ROW.default);
return PHASER_STAGES.find((n) => n === stages) ?? 4;
}
@@ -1146,7 +1146,7 @@ export const SoundFxSaturation: SoundFxNodeDef = effect(
'own',
);
const LFO_SHAPE = {
export const LFO_SHAPE = {
key: 'shape',
label: 'Shape',
kind: 'enum' as const,
@@ -1154,7 +1154,7 @@ const LFO_SHAPE = {
options: LFO_SHAPES,
};
function lfoShapeOf(settings: PropertyBag): LfoShape {
export function lfoShapeOf(settings: PropertyBag): LfoShape {
return LFO_SHAPES.find((option) => option.value === settings[LFO_SHAPE.key])?.value ?? 'sine';
}
@@ -1162,6 +1162,8 @@ const TREMOLO_RATE = { ...numberRow('rate', 'Rate (Hz)', 0, 20, 0.01), default:
const TREMOLO_DEPTH = { ...numberRow('depth', 'Depth (%)', 0, 100, 1), default: 50 };
const VIBRATO_RATE = { ...numberRow('rate', 'Rate (Hz)', 0.5, 20, 0.01), default: 5 };
const VIBRATO_DEPTH = { ...numberRow('depth', 'Depth (st)', 0, 4, 0.01), default: 0.5 };
export const TREMOLO_ROWS = { rate: TREMOLO_RATE, depth: TREMOLO_DEPTH, wet: WET };
export const VIBRATO_ROWS = { rate: VIBRATO_RATE, depth: VIBRATO_DEPTH, wet: WET };
export const SoundFxTremolo: SoundFxNodeDef = effect(
{
+1 -1
View File
@@ -72,7 +72,7 @@ export class Phaser {
}
/** The longest a Flanger delays its copy, in ms; a delay past it stays there, as sfxr's does at its own end. */
const FLANGER_MAX_MS = 20;
export const FLANGER_MAX_MS = 20;
/**
* The flanger, which sfxr calls its phaser: the sound and a copy `offset` ms late. `sweep` moves