Close moves of a few dB were hard to set and to see on a curve that always spans -24 to +24 dB. The wheel on free space now zooms the dB the curve shows, and the node's small curve shows the same range. - The range is one of ±3, ±6, ±12, ±24 and ±48 dB, always the same either side of 0. Up zooms in. - It is saved on the node as `range`, so it is back after loading. Like the game scale (#226) it is no undo step. EditorState gets `setNodeView` for such a setting of a node. - The lines across the curve follow the range: every 1, 2, 3, 6 or 12 dB. A handle whose gain lies beyond the range stays on the edge, in sight and in reach. - The wheel over a handle still turns the band's Q. It no longer does so anywhere while a band is picked: after opening, a band nearly always is, and the zoom would be out of reach. - `EqArea` carries the range, so the tab and the node map dB alike. The spectrum keeps its own scale. Tests: - Unit parametric-eq: 16 green, 5 new (range in compile and save, node picture, wheel steps, grid lines, handle on the edge) - Counter-check: the node's picture without the saved range turns the new picture test red - e2e 34 (melektaus-atx): ALL PASSED, 99 checks, 1 new - Gate (melektaus-atx): CHECK EXIT 0, 275 files, 4252 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
115 lines
4.3 KiB
TypeScript
115 lines
4.3 KiB
TypeScript
/**
|
||
* What the Parametric EQ's tab (#367) lays onto its picture beside the curve, whose axes are in
|
||
* `parametricEq.ts`: the handles of the bands, and the spectrum of what passes the node (#374).
|
||
*/
|
||
import {
|
||
type EqArea,
|
||
type EqBand,
|
||
dbToY,
|
||
EQ_Q,
|
||
EQ_RANGES,
|
||
frequencyToX,
|
||
hasGain,
|
||
xToFrequency,
|
||
} from '../../core/soundfx/parametricEq';
|
||
|
||
/**
|
||
* Where a band's handle sits: at its frequency, and at its gain or – without one – on the 0 dB line.
|
||
* A gain beyond what the curve is zoomed to keeps its handle on the edge, in sight and in reach.
|
||
*/
|
||
export function handleOf(band: EqBand, area: EqArea): { x: number; y: number } {
|
||
const y = dbToY(hasGain(band.type) ? band.gain : 0, area);
|
||
return { x: frequencyToX(band.frequency, area), y: Math.max(0, Math.min(area.h, y)) };
|
||
}
|
||
|
||
/** The band whose handle is under a point, the nearest one if several are; -1 for none. */
|
||
export function bandAt(bands: readonly EqBand[], x: number, y: number, area: EqArea, radius: number): number {
|
||
let found = -1;
|
||
let best = radius * radius;
|
||
bands.forEach((band, i) => {
|
||
const at = handleOf(band, area);
|
||
const d = (at.x - x) ** 2 + (at.y - y) ** 2;
|
||
if (d <= best) {
|
||
best = d;
|
||
found = i;
|
||
}
|
||
});
|
||
return found;
|
||
}
|
||
|
||
/** The Q after a turn of the wheel: a notch of it is a sixth of an octave narrower or wider. */
|
||
export function wheeledQ(q: number, deltaY: number): number {
|
||
const turned = q * 2 ** (-Math.sign(deltaY) / 6);
|
||
return Math.max(EQ_Q.min, Math.min(EQ_Q.max, Math.round(turned * 1000) / 1000));
|
||
}
|
||
|
||
/** The zoom after a turn of the wheel (#376): up shows the next smaller range, down the next larger. */
|
||
export function wheeledRange(range: number, deltaY: number): number {
|
||
const at = EQ_RANGES.indexOf(range) + Math.sign(deltaY);
|
||
return EQ_RANGES[Math.max(0, Math.min(EQ_RANGES.length - 1, at))]!;
|
||
}
|
||
|
||
/** The lines the picture is ruled with, and what they say. */
|
||
export const EQ_GRID_FREQUENCIES: readonly { hz: number; label: string }[] = [
|
||
{ hz: 20, label: '20' },
|
||
{ hz: 50, label: '50' },
|
||
{ hz: 100, label: '100' },
|
||
{ hz: 200, label: '200' },
|
||
{ hz: 500, label: '500' },
|
||
{ hz: 1000, label: '1k' },
|
||
{ hz: 2000, label: '2k' },
|
||
{ hz: 5000, label: '5k' },
|
||
{ hz: 10000, label: '10k' },
|
||
{ hz: 20000, label: '20k' },
|
||
];
|
||
|
||
/** The dB lines for a range: in thirds of it up to ±6 dB, in quarters above, so whole numbers. */
|
||
export function eqGridDb(range: number): number[] {
|
||
const parts = range <= 6 ? 3 : 4;
|
||
return Array.from({ length: parts * 2 - 1 }, (_, i) => ((i + 1 - parts) * range) / parts);
|
||
}
|
||
|
||
/** The spectrum's own scale (#374): full scale at the top of the picture, this many dB at its foot. */
|
||
export const SPECTRUM_FLOOR_DB = -96;
|
||
|
||
const spectrumY = (db: number, area: EqArea): number =>
|
||
(Math.min(0, Math.max(SPECTRUM_FLOOR_DB, db)) / SPECTRUM_FLOOR_DB) * area.h;
|
||
|
||
/**
|
||
* A spectrum, one level in dB every `binHz`, laid onto the picture at `xs`: each point takes the
|
||
* loudest bin from halfway to the point before to halfway to the next, or – where the bins lie
|
||
* further apart than the points, down in the bass – the level between the two nearest.
|
||
*/
|
||
export function spectrumYs(
|
||
dbs: ArrayLike<number>,
|
||
binHz: number,
|
||
xs: readonly number[],
|
||
area: EqArea,
|
||
): number[] {
|
||
const last = dbs.length - 1;
|
||
const level = (bin: number) => {
|
||
const db = dbs[Math.max(0, Math.min(last, bin))]!;
|
||
return Number.isFinite(db) ? db : SPECTRUM_FLOOR_DB;
|
||
};
|
||
return xs.map((x, i) => {
|
||
const from = Math.ceil(xToFrequency(i > 0 ? (xs[i - 1]! + x) / 2 : x, area) / binHz);
|
||
const to = Math.floor(xToFrequency(i < xs.length - 1 ? (x + xs[i + 1]!) / 2 : x, area) / binHz);
|
||
let db = -Infinity;
|
||
for (let bin = from; bin <= Math.min(to, last); bin++) db = Math.max(db, level(bin));
|
||
if (db === -Infinity) {
|
||
const at = xToFrequency(x, area) / binHz;
|
||
const below = Math.floor(at);
|
||
db = level(below) + (level(below + 1) - level(below)) * (at - below);
|
||
}
|
||
return spectrumY(db, area);
|
||
});
|
||
}
|
||
|
||
/** The frequency of the loudest bin, and how loud it is; null for a spectrum that is all silence. */
|
||
export function spectrumPeak(dbs: ArrayLike<number>, binHz: number): { hz: number; db: number } | null {
|
||
let best = -1;
|
||
for (let bin = 1; bin < dbs.length; bin++)
|
||
if (Number.isFinite(dbs[bin]) && (best < 0 || dbs[bin]! > dbs[best]!)) best = bin;
|
||
return best < 0 ? null : { hz: best * binHz, db: dbs[best]! };
|
||
}
|