From b145e1f28860d28a8e30541914ea24cc36c79e01 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 10 Oct 2026 21:30:03 +0200 Subject: [PATCH] Draw the Flanger's comb as a thin line with a fading trail (#398) The comb was still drawn too heavy. Each frame showed the whole band the comb had swept over the last 1/20 s, edged by two lines 1.5 wide, and the faster the delay moved, the thicker that band got. - flangerLine draws the comb where the light is as one line, 1 wide. It runs through the ends of each column and the peaks and notches in it, where they are. A comb finer than a column zigzags over it. - Behind the line a faint trail shows what the comb swept over the last 1/20 s, and over the last 1/40 s once more, so it fades with age. A Flanger that stands still has none. - The line fades out where its teeth move more than a quarter of a tooth within the trail (clearAt), and is gone from half a tooth on. There only the calm band of the trail is left, so nothing flickers. - responseFrame gives the screen the line, how clear each point is, the sharp columns for the notches and the trail. It replaces responseColumnsOf, and RESPONSE_BLUR is now RESPONSE_TRAIL. - The notches are counted on the sharp comb again, not on the band. - The Phaser's response is drawn as the same thin line, without a trail. Tests: Unit modulation-view (stein2): 26 tests, 2 new. The line holds the comb's dB at each point and every notch. On a fast swing it fades out over 8 kHz, stays clear under 1 kHz, and the trail is the same band over 16 kHz in three frames. A still Flanger has no trail. Counter-check: with clearAt always 1, the fade test fails. e2e 34 (atx): ALL PASSED, 142 checks. Gate (atx): CHECK EXIT 0, 291 files, 4471 tests. Pictures of the default Flanger, a fast one (2 Hz, 5 ms) and one sweeping to 20 ms, and of the Phaser: a thin line, the trail behind it, a calm band high up. Co-Authored-By: Claude Opus 5.5 --- src/editor/soundfx/ModulationPanel.ts | 49 +++++++++----- src/editor/soundfx/modulationView.ts | 97 ++++++++++++++++++++++----- tests/editor/modulation-view.test.ts | 50 +++++++++++--- 3 files changed, 152 insertions(+), 44 deletions(-) diff --git a/src/editor/soundfx/ModulationPanel.ts b/src/editor/soundfx/ModulationPanel.ts index 675b64c8..d23c2da3 100644 --- a/src/editor/soundfx/ModulationPanel.ts +++ b/src/editor/soundfx/ModulationPanel.ts @@ -24,7 +24,7 @@ import { notchesOf, RESPONSE_FLOOR, RESPONSE_TOP, - responseColumnsOf, + responseFrame, shareOfHz, type SwingPicture, swingValueAt, @@ -481,26 +481,39 @@ class ResponseScope extends ModScope { const values = this.values; if (!picture || !values || this.w <= 0) return; const r = this.response; - const columns = responseColumnsOf(this.device, values, this.at, Math.max(1, Math.round(r.w))); - this.notches = notchesOf(columns); + const frame = responseFrame(this.device, values, this.at, Math.max(1, Math.round(r.w))); + this.notches = notchesOf(frame.columns); const bottom = r.y + r.h; - 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({ + const step = r.w / frame.columns.length; + const xs = frame.columns.map((_, i) => r.x + (i + 0.5) * step); + // Under the response a faint fill, up to the longest trail where there is one, so it stays calm. + const under = frame.trail[0] ?? frame.columns; + c.poly([r.x, bottom, ...xs.flatMap((x, i) => [x, this.yOfDb(under[i]!.high)]), r.x + r.w, bottom]).fill({ color: this.look.trace, - alpha: 0.12, + alpha: 0.1, }); - 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' }); + // Behind the line the trail of what it swept just before, fading with age: each band reaches + // from longer ago to now, so they stack up towards the line. + for (const columns of frame.trail) { + const band = xs.flatMap((x, i) => [x, this.yOfDb(columns[i]!.high)]); + for (let i = xs.length - 1; i >= 0; i--) band.push(xs[i]!, this.yOfDb(columns[i]!.low)); + c.poly(band).fill({ color: this.look.trace, alpha: 0.16 }); + } + // The line itself, thin, fading out where its teeth pass faster than an eye follows: in runs + // of the same few steps of alpha, each starting where the last ended. + const { line, clear } = frame; + const steps = 8; + const at = (i: number) => [r.x + line[i]!.share * r.w, this.yOfDb(line[i]!.db)] as const; + for (let i = 0; i < line.length - 1;) { + const alpha = Math.round(clear[i]! * steps) / steps; + let j = i + 1; + while (j < line.length - 1 && Math.round(clear[j]! * steps) / steps === alpha) j++; + if (alpha > 0) { + c.moveTo(...at(i)); + for (let k = i + 1; k <= j; k++) c.lineTo(...at(k)); + c.stroke({ color: this.look.trace, width: 1, alpha, join: 'round' }); + } + i = j; } const hz = this.markHz(this.reading); if (hz !== null && shareOfHz(hz) >= 0 && shareOfHz(hz) <= 1) diff --git a/src/editor/soundfx/modulationView.ts b/src/editor/soundfx/modulationView.ts index a7d0680b..e39d44d5 100644 --- a/src/editor/soundfx/modulationView.ts +++ b/src/editor/soundfx/modulationView.ts @@ -474,27 +474,92 @@ export function flangerColumns( }); } -/** - * 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 point of a response drawn as a line: how far along the screen, and its dB. */ +export interface ResponsePoint { + share: number; + db: number; +} -/** A Phaser's or a Flanger's response over `columns` columns of the screen, `t` seconds after the sound. */ -export function responseColumnsOf( +/** + * A Flanger's response as a line over `columns` columns while its copy is `ms` late, exactly: the + * ends of each column and the peaks and notches between them, where they are. A column the comb + * runs too fine for gets one peak and one notch, so the line zigzags over all it holds. + */ +export function flangerLine(values: ModValues, ms: number, columns: number): ResponsePoint[] { + const db = (halfTurns: number): number => flangerDb(values, 1, halfTurns * 500); + const halfTurnsAt = (share: number): number => (hzAt(share) * Math.max(0, ms)) / 500; + const shareOf = (halfTurns: number): number => shareOfHz((halfTurns * 500) / Math.max(0, ms)); + const points: ResponsePoint[] = []; + for (let c = 0; c <= columns; c++) { + const from = halfTurnsAt(c / columns); + points.push({ share: c / columns, db: db(from) }); + if (c === columns) break; + const to = halfTurnsAt((c + 1) / columns); + const first = Math.ceil(from); + for (let k = first; k <= first + 1 && k < to; k++) + if (k > from) points.push({ share: shareOf(k), db: db(k) }); + } + return points; +} + +/** + * How long the Flanger's trail lasts, about what an eye blends: the delays its light passed in that + * time, drawn faintly behind the line. High up its teeth pass faster than frames are drawn, and a + * sharp line of them flickers. There the trail is a calm band, over 10 kHz it changes 0.04 dB a frame. + */ +export const RESPONSE_TRAIL = 1 / 20; + +/** + * How clearly the line can be followed at `hz` while the delay moves `movedMs` within the trail: + * fully while its teeth move up to a quarter of one, not at all from half of one on. There they + * would flicker, and only the trail is left. + */ +export function clearAt(hz: number, movedMs: number): number { + const halfTurns = (hz * Math.abs(movedMs)) / 500; + return clamp01(2 - 2 * halfTurns); +} + +/** A Phaser's or a Flanger's response as the screen draws it, `t` seconds after the sound. */ +export interface ResponseFrame { + /** Where the light is now, left to right. */ + line: ResponsePoint[]; + /** How clearly each point of the line can be followed, 0 to 1. */ + clear: number[]; + /** The lowest and highest of the line in each column: its notches are counted on them. */ + columns: { low: number; high: number }[]; + /** What it swept through just before, each band from longer ago to now, the longest first. */ + trail: { low: number; high: number }[][]; +} + +export function responseFrame( 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); +): ResponseFrame { + if (device.kind === 'phaser') { + const db = (hz: number) => phaserDb(values, phaserTurnAt(values, t), hz); + const line = Array.from({ length: columns + 1 }, (_, c) => ({ + share: c / columns, + db: db(hzAt(c / columns)), + })); + return { line, clear: line.map(() => 1), columns: responseColumns(db, columns), trail: [] }; + } + const delayAt = (at: number) => flangerDelayAt(values, Math.max(0, at)); + const ms = delayAt(t); + const line = flangerLine(values, ms, columns); + const band = (age: number) => { + const delays = [t - age, t - age / 2, t].map(delayAt); + return { low: Math.min(...delays), high: Math.max(...delays) }; + }; + const trail = [band(RESPONSE_TRAIL), band(RESPONSE_TRAIL / 2)]; + const moved = trail[0]!.high - trail[0]!.low; + return { + line, + clear: line.map((point) => clearAt(hzAt(point.share), moved)), + columns: flangerColumns(values, ms, ms, columns), + trail: moved > 0 ? trail.map((d) => flangerColumns(values, d.low, d.high, columns)) : [], + }; } /** diff --git a/tests/editor/modulation-view.test.ts b/tests/editor/modulation-view.test.ts index e2ac08ae..f1546737 100644 --- a/tests/editor/modulation-view.test.ts +++ b/tests/editor/modulation-view.test.ts @@ -8,10 +8,13 @@ import { Tremolo, Vibrato, vibratoDelay } from '../../src/core/soundfx/lfo'; import { Flanger, Phaser } from '../../src/core/soundfx/phaser'; import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView'; import { + clearAt, FLANGER_DEVICE, flangerColumns, flangerDb, flangerDelayAt, + flangerLine, + hzAt, idleModKeys, lampOf, lfoSpan, @@ -26,7 +29,7 @@ import { phaserDb, RESPONSE_FLOOR, responseColumns, - responseColumnsOf, + responseFrame, shareOfHz, SHOWN_RATE, swingPicture, @@ -283,23 +286,50 @@ describe('the Flanger screen', () => { } }); - 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. + it('draws the comb as a thin line through its ends, peaks and notches', () => { 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 line = flangerLine(values, 2, 640); + for (const point of line) expect(point.db).toBeCloseTo(flangerDb(values, 2, hzAt(point.share)), 9); + for (let i = 1; i < line.length; i++) expect(line[i]!.share).toBeGreaterThan(line[i - 1]!.share); + // Every notch under the columns' width is on the line, as deep as it goes. + const notch = flangerDb(values, 1, 500); + for (const k of [1, 3, 21]) + expect( + line.some((p) => Math.abs(p.share - shareOfHz(k * 250)) < 1e-9 && Math.abs(p.db - notch) < 1e-9), + ).toBe(true); + }); + + it('fades the line where its teeth pass faster than frames, the trail behind it a calm band', () => { + // The default swings 1 to 3 ms every 4 s, at 0.9 s about its fastest: high up, the trail 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) => responseFrame(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)) { + for (const frame of frames) { + expect(frame.trail).toHaveLength(2); + for (const column of frame.trail[0]!.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. + frame.line.forEach((point, i) => { + if (point.share >= shareOfHz(8000)) expect(frame.clear[i]).toBe(0); + if (point.share <= shareOfHz(1000)) expect(frame.clear[i]).toBe(1); + }); + } + // Below the first notch it stays a sharp line that moves, its trail a sliver. 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); + for (const frame of frames) + expect(frame.trail[0]![low]!.high - frame.trail[0]![low]!.low).toBeLessThan(0.5); + expect(frames[0]!.columns[low]!.high).not.toBeCloseTo(frames[2]!.columns[low]!.high, 3); + }); + + it('draws a still Flanger as a clear line without a trail', () => { + const frame = responseFrame(FLANGER_DEVICE, modValues(FLANGER_DEVICE, { rate: 0 }), 0.9, 640); + expect(frame.trail).toEqual([]); + expect(frame.clear.every((c) => c === 1)).toBe(true); + expect(clearAt(20000, 0)).toBe(1); }); it('sweeps the delay down through 0 and up again, the LFO on top', () => {