Keep the Flanger's comb calm and smooth where its teeth crowd (#398)
game-maker / gate (push) Successful in 4m24s
game-maker / suite (push) Failing after 16m35s

High up the Flanger's comb flickered and looked pixelated. Each column
of its screen took six readings, and its teeth pass there faster than
frames are drawn, up to 0.4 of a tooth a frame at 15 kHz by default.
Each frame caught other teeth, and each column was drawn as a sharp
rectangle.

- flangerColumns reckons each column exactly. The response only hangs
  on the phase, and between two multiples of pi it runs one way. So a
  column holds its lowest and highest at its ends or on the peaks and
  notches inside it.
- Each frame is blurred over the delays of the last 1/20 s
  (RESPONSE_BLUR), about what an eye blends. Where teeth pass faster
  than that, they become a calm band from the deepest notch to the
  highest peak. Further down, where they can be followed, they stay
  sharp and move.
- The response is drawn as a band between two smooth lines through
  the columns, no longer as one rectangle per column.
- responseColumnsOf picks the Phaser's readings or the Flanger's exact
  columns. responseDb had no caller left and is gone.

Measured before choosing the blur, as the mean change of a column from
one frame to the next on the default Flanger (2-5 / 5-10 / 10-20 kHz):

  readings as before   5.6 / 10.6 / 14.3 dB
  exact, no blur       5.5 / 10.2 / 12.9 dB
  exact, 1/60 s        5.0 /  7.6 /  4.0 dB
  exact, 1/20 s        3.9 /  2.6 /  0.04 dB

Under 5 kHz most of what is left is the teeth really moving.

Tests:
  Unit modulation-view (stein2): 24 tests, 2 new. The exact columns
    hold what 4000 readings a column find there, and a fast swing
    leaves the same band over 16 kHz in three frames in a row.
  Counter-check: without the peaks and notches inside a column, both
    new tests fail.
  e2e 34 (atx): ALL PASSED, 142 checks.
  Gate (atx): CHECK EXIT 0, 291 files, 4469 tests.
  Pictures of the default Flanger and of one sweeping to 20 ms, near
    and far, two of each 40 ms apart: smooth edges, the band the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 21:15:53 +02:00
1 parent b6749cc43d
commit f9a953f3d3
3 files changed
+117 -23

No files matched your search

+45
View File
@@ -9,6 +9,7 @@ import { Flanger, Phaser } from '../../src/core/soundfx/phaser';
import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView';
import {
FLANGER_DEVICE,
flangerColumns,
flangerDb,
flangerDelayAt,
idleModKeys,
@@ -23,7 +24,10 @@ import {
notchesOf,
PHASER_DEVICE,
phaserDb,
RESPONSE_FLOOR,
responseColumns,
responseColumnsOf,
shareOfHz,
SHOWN_RATE,
swingPicture,
swingValueAt,
@@ -257,6 +261,47 @@ describe('the Flanger screen', () => {
expect(flangerDb(values, 1, 500)).toBeLessThan(-15);
});
it('holds in each column what a fine reading of the comb finds there', () => {
// A notch without feedback goes down to nothing, where no reading lands: compare what is shown.
const shown = (db: number) => Math.max(RESPONSE_FLOOR, db);
for (const [feedback, mix] of [
[0, 1],
[0.5, 1],
[0.9, 0.4],
] as const) {
const values = modValues(FLANGER_DEVICE, { feedback, mix });
for (const ms of [0.3, 3, 12]) {
const columns = flangerColumns(values, ms, ms, 200);
const fine = responseColumns((hz) => flangerDb(values, ms, hz), 200, 4000);
columns.forEach((column, c) => {
expect(column.low).toBeLessThanOrEqual(fine[c]!.low + 1e-9);
expect(column.high).toBeGreaterThanOrEqual(fine[c]!.high - 1e-9);
expect(shown(column.low)).toBeCloseTo(shown(fine[c]!.low), 1);
expect(column.high).toBeCloseTo(fine[c]!.high, 1);
});
}
}
});
it('blurs the teeth that pass faster than frames into the same band, so they do not flicker', () => {
// The default swings 1 to 3 ms every 4 s, at 0.9 s about its fastest: high up, the time of a
// blur lets more than a tooth pass, low down a sliver of one.
const values = modValues(FLANGER_DEVICE, { feedback: 0.5 });
const frames = [0, 1, 2].map((k) => responseColumnsOf(FLANGER_DEVICE, values, 0.9 + k / 60, 640));
const peak = 20 * Math.log10(1.5);
const notch = flangerDb(values, 1, 500);
const high = Math.ceil(640 * shareOfHz(16000));
for (const columns of frames)
for (const column of columns.slice(high)) {
expect(column.high).toBeCloseTo(peak, 6);
expect(column.low).toBeCloseTo(notch, 6);
}
// Below the first notch it stays a sharp line that moves.
const low = Math.floor(640 * shareOfHz(100));
for (const columns of frames) expect(columns[low]!.high - columns[low]!.low).toBeLessThan(0.5);
expect(frames[0]![low]!.high).not.toBeCloseTo(frames[2]![low]!.high, 3);
});
it('sweeps the delay down through 0 and up again, the LFO on top', () => {
const values = modValues(FLANGER_DEVICE, { offset: 1, sweep: -2, depth: 0 });
expect(flangerDelayAt(values, 0.5)).toBeCloseTo(0);