Keep the Flanger's comb calm and smooth where its teeth crowd (#398)
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:
1 parent
b6749cc43d
commit
f9a953f3d3
3 files changed
+117
-23
No files matched your search
@@ -24,8 +24,7 @@ import {
|
||||
notchesOf,
|
||||
RESPONSE_FLOOR,
|
||||
RESPONSE_TOP,
|
||||
responseColumns,
|
||||
responseDb,
|
||||
responseColumnsOf,
|
||||
shareOfHz,
|
||||
type SwingPicture,
|
||||
swingValueAt,
|
||||
@@ -482,23 +481,27 @@ class ResponseScope extends ModScope {
|
||||
const values = this.values;
|
||||
if (!picture || !values || this.w <= 0) return;
|
||||
const r = this.response;
|
||||
const columns = responseColumns(
|
||||
(hz) => responseDb(this.device, values, this.reading, hz),
|
||||
Math.max(1, Math.round(r.w)),
|
||||
);
|
||||
const columns = responseColumnsOf(this.device, values, this.at, Math.max(1, Math.round(r.w)));
|
||||
this.notches = notchesOf(columns);
|
||||
const bottom = r.y + r.h;
|
||||
// Under the response a faint fill, the response itself as thin as a line where it is smooth
|
||||
// and as tall as a column holds where a comb runs too fine for them.
|
||||
columns.forEach((column, i) =>
|
||||
c.rect(r.x + i, this.yOfDb(column.high), 1, bottom - this.yOfDb(column.high)),
|
||||
);
|
||||
c.fill({ color: this.look.trace, alpha: 0.12 });
|
||||
columns.forEach((column, i) => {
|
||||
const top = this.yOfDb(column.high);
|
||||
c.rect(r.x + i, top - 0.75, 1, Math.max(1.5, this.yOfDb(column.low) - top + 0.75));
|
||||
const step = r.w / columns.length;
|
||||
const xs = columns.map((_, i) => r.x + (i + 0.5) * step);
|
||||
const highs = columns.map((column) => this.yOfDb(column.high));
|
||||
const lows = columns.map((column) => this.yOfDb(column.low));
|
||||
// Under the response a faint fill. The response is a band from the highest to the lowest of
|
||||
// each column, edged by two smooth lines: one line where it is smooth, filled where a comb runs
|
||||
// too fine for the columns.
|
||||
c.poly([r.x, bottom, ...xs.flatMap((x, i) => [x, highs[i]!]), r.x + r.w, bottom]).fill({
|
||||
color: this.look.trace,
|
||||
alpha: 0.12,
|
||||
});
|
||||
c.fill(this.look.trace);
|
||||
const band = xs.flatMap((x, i) => [x, highs[i]!]);
|
||||
for (let i = xs.length - 1; i >= 0; i--) band.push(xs[i]!, lows[i]!);
|
||||
c.poly(band).fill(this.look.trace);
|
||||
for (const edge of [highs, lows]) {
|
||||
xs.forEach((x, i) => (i === 0 ? c.moveTo(x, edge[i]!) : c.lineTo(x, edge[i]!)));
|
||||
c.stroke({ color: this.look.trace, width: 1.5, join: 'round' });
|
||||
}
|
||||
const hz = this.markHz(this.reading);
|
||||
if (hz !== null && shareOfHz(hz) >= 0 && shareOfHz(hz) <= 1)
|
||||
s.rect(this.xOfHz(hz) - 0.5, r.y, 1, r.h).fill({ color: 0xffffff, alpha: 0.3 });
|
||||
|
||||
@@ -423,11 +423,6 @@ export function flangerDb(values: ModValues, ms: number, hz: number): number {
|
||||
return blendDb(clamp01(num(values, 'mix')) / 2, (zr * fr + zi * fi) / f, (zi * fr - zr * fi) / f);
|
||||
}
|
||||
|
||||
/** A Phaser's or a Flanger's response in dB at `hz`, at the light's reading `at` – where it turns, how late it is. */
|
||||
export function responseDb(device: ModDevice, values: ModValues, at: number, hz: number): number {
|
||||
return device.kind === 'phaser' ? phaserDb(values, at, hz) : flangerDb(values, at, hz);
|
||||
}
|
||||
|
||||
/** Readings within each column of the screen: enough that a notch never slips between them. */
|
||||
const PER_COLUMN = 6;
|
||||
|
||||
@@ -438,12 +433,13 @@ const PER_COLUMN = 6;
|
||||
export function responseColumns(
|
||||
db: (hz: number) => number,
|
||||
columns: number,
|
||||
perColumn = PER_COLUMN,
|
||||
): { low: number; high: number }[] {
|
||||
return Array.from({ length: columns }, (_, c) => {
|
||||
let low = Infinity;
|
||||
let high = -Infinity;
|
||||
for (let k = 0; k < PER_COLUMN; k++) {
|
||||
const value = db(hzAt((c + (k + 0.5) / PER_COLUMN) / columns));
|
||||
for (let k = 0; k < perColumn; k++) {
|
||||
const value = db(hzAt((c + (k + 0.5) / perColumn) / columns));
|
||||
low = Math.min(low, value);
|
||||
high = Math.max(high, value);
|
||||
}
|
||||
@@ -451,6 +447,56 @@ export function responseColumns(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A Flanger's response over `columns` columns while its copy is `fromMs` to `toMs` late, exactly:
|
||||
* it only hangs on the phase `2π·hz·ms`, and between two multiples of π it runs one way. So a
|
||||
* column holds its lowest and highest at the ends of the phases it spans or on the peaks and
|
||||
* notches between them. Readings would catch other teeth each frame and flicker where they are
|
||||
* finer than a column.
|
||||
*/
|
||||
export function flangerColumns(
|
||||
values: ModValues,
|
||||
fromMs: number,
|
||||
toMs: number,
|
||||
columns: number,
|
||||
): { low: number; high: number }[] {
|
||||
// The phase in half turns: a peak at every even one, a notch at every odd one.
|
||||
const db = (halfTurns: number): number => flangerDb(values, 1, halfTurns * 500);
|
||||
const early = Math.max(0, Math.min(fromMs, toMs)) / 500;
|
||||
const late = Math.max(0, fromMs, toMs) / 500;
|
||||
return Array.from({ length: columns }, (_, c) => {
|
||||
const from = hzAt(c / columns) * early;
|
||||
const to = hzAt((c + 1) / columns) * late;
|
||||
const ends = [db(from), db(to)];
|
||||
const first = Math.ceil(from);
|
||||
for (let k = first; k <= first + 1 && k <= to; k++) ends.push(db(k));
|
||||
return { low: Math.min(...ends), high: Math.max(...ends) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a frame of the Flanger's response is blurred over, about what an eye blends: the delays
|
||||
* its light passed in that time. High up its teeth pass faster than frames are drawn, and sharp
|
||||
* frames of them flicker. Over 10 kHz this calms a column from 14 dB a frame to 0.04, under 5 kHz,
|
||||
* where the teeth can be followed, it hardly changes them.
|
||||
*/
|
||||
export const RESPONSE_BLUR = 1 / 20;
|
||||
|
||||
/** A Phaser's or a Flanger's response over `columns` columns of the screen, `t` seconds after the sound. */
|
||||
export function responseColumnsOf(
|
||||
device: ModDevice,
|
||||
values: ModValues,
|
||||
t: number,
|
||||
columns: number,
|
||||
): { low: number; high: number }[] {
|
||||
if (device.kind === 'phaser')
|
||||
return responseColumns((hz) => phaserDb(values, phaserTurnAt(values, t), hz), columns);
|
||||
const delays = [t - RESPONSE_BLUR, t - RESPONSE_BLUR / 2, t].map((at) =>
|
||||
flangerDelayAt(values, Math.max(0, at)),
|
||||
);
|
||||
return flangerColumns(values, Math.min(...delays), Math.max(...delays), columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* How far under the sound as it came a column has to reach to count as in a notch: half its power.
|
||||
* Below the loudest would not do, as feedback rings peaks far over 0 dB between the notches.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user