diff --git a/src/editor/soundfx/ModulationPanel.ts b/src/editor/soundfx/ModulationPanel.ts index e20485bc..675b64c8 100644 --- a/src/editor/soundfx/ModulationPanel.ts +++ b/src/editor/soundfx/ModulationPanel.ts @@ -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 }); diff --git a/src/editor/soundfx/modulationView.ts b/src/editor/soundfx/modulationView.ts index 54fe7a20..a7d0680b 100644 --- a/src/editor/soundfx/modulationView.ts +++ b/src/editor/soundfx/modulationView.ts @@ -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. diff --git a/tests/editor/modulation-view.test.ts b/tests/editor/modulation-view.test.ts index 19025afc..e2ac08ae 100644 --- a/tests/editor/modulation-view.test.ts +++ b/tests/editor/modulation-view.test.ts @@ -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);