Compare commits

...
4 Commits
Author SHA1 Message Date
LucasDLTG eb751f9404 feat: round max on delay graph 2026-08-12 12:23:08 +02:00
LucasDLTG 5945b48b98 feat: improve compressor graph 2026-08-12 12:19:17 +02:00
LucasDLTG ab9de39556 fix: error js 2026-08-12 11:57:39 +02:00
LucasDLTG ddec10ca8f feat: add geq graph + bypass warning 2026-08-12 11:51:26 +02:00
10 changed files with 383 additions and 94 deletions
+2 -1
View File
@@ -7,4 +7,5 @@ This template should help get you started developing with Tauri, React and Types
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) - [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
# TODO # TODO
- Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly - Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly
- Check render to improve perfoormances
@@ -170,7 +170,10 @@ export default function VerticalSlider({
async function endDrag(e: React.PointerEvent<HTMLDivElement>) { async function endDrag(e: React.PointerEvent<HTMLDivElement>) {
if (!dragging.current) return; if (!dragging.current) return;
const target = e.currentTarget;
const pointerId = e.pointerId;
const finalValue = dragValue.current; const finalValue = dragValue.current;
const success = await onChange?.(finalValue); const success = await onChange?.(finalValue);
dragging.current = false; dragging.current = false;
@@ -181,8 +184,8 @@ export default function VerticalSlider({
onInput?.(value); onInput?.(value);
} }
if (e.currentTarget.hasPointerCapture(e.pointerId)) { if (target.hasPointerCapture(pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId); target.releasePointerCapture(pointerId);
} }
} }
@@ -263,19 +263,44 @@ function CompressorPanel({
); );
} }
// Soft-knee compressor transfer curve, in dB. // Compressor transfer curve, in dB.
//
// The knee is NOT a smooth/quadratic blend. It is three straight
// (piecewise-linear) segments:
// 1) input <= kneeStart -> 1:1 line (output = input)
// 2) kneeStart < input < kneeEnd -> a straight line connecting the
// point on segment (1) at kneeStart
// to the point on segment (3) at kneeEnd
// 3) input >= kneeEnd -> ratio line (output = threshold + (input-threshold)/ratio)
//
// kneeStart/kneeEnd are simply the threshold shifted down/up by half the
// knee width, so increasing "knee" widens the middle straight segment
// symmetrically around the threshold.
function compressorOutputDb(input: number, threshold: number, ratio: number, knee: number): number { function compressorOutputDb(input: number, threshold: number, ratio: number, knee: number): number {
const kneeStart = threshold - knee / 2; const kneeStart = threshold - knee / 2;
const kneeEnd = threshold + knee / 2; const kneeEnd = threshold + knee / 2;
if (knee <= 0 || input <= kneeStart) { // No knee: simple two-segment (hard-knee) curve.
if (knee <= 0) {
return input <= threshold ? input : threshold + (input - threshold) / ratio; return input <= threshold ? input : threshold + (input - threshold) / ratio;
} }
// Segment 1: below the knee, straight 1:1 line.
if (input <= kneeStart) {
return input;
}
// Segment 3: above the knee, straight ratio line.
if (input >= kneeEnd) { if (input >= kneeEnd) {
return threshold + (input - threshold) / ratio; return threshold + (input - threshold) / ratio;
} }
const x = input - kneeStart;
return input + ((1 / ratio - 1) * x * x) / (2 * knee); // Segment 2: straight line between the two boundary points
// (kneeStart, kneeStart) and (kneeEnd, threshold + (kneeEnd - threshold) / ratio).
const y1 = kneeStart;
const y2 = threshold + (kneeEnd - threshold) / ratio;
const t = (input - kneeStart) / (kneeEnd - kneeStart);
return y1 + t * (y2 - y1);
} }
function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor: SetCompressorFn }) { function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor: SetCompressorFn }) {
@@ -339,7 +364,7 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
const steps = 120; const steps = 120;
const points: [number, number][] = []; const points: [number, number][] = [];
for (let i = 0; i <= steps; i++) { for (let i = 0; i <= steps; i++) {
const x = -60 + (60 * i) / steps; const x = -100 + (100 * i) / steps;
points.push([x, compressorOutputDb(x, threshold, ratioNumeric, knee)]); points.push([x, compressorOutputDb(x, threshold, ratioNumeric, knee)]);
} }
return points; return points;
@@ -352,9 +377,9 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
<div className="compressor-graph__title">{activeOutputState.name} - RMS Compressor</div> <div className="compressor-graph__title">{activeOutputState.name} - RMS Compressor</div>
<LinearGraph <LinearGraph
xMin={-60} xMin={-100}
xMax={0} xMax={0}
yMin={-60} yMin={-100}
yMax={0} yMax={0}
xLabel="in dB" xLabel="in dB"
yLabel="out dB" yLabel="out dB"
@@ -365,7 +390,7 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
ctx.strokeStyle = '#6b7280'; ctx.strokeStyle = '#6b7280';
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.setLineDash([4, 4]); ctx.setLineDash([4, 4]);
const [ux0, uy0] = toPx(-60, -60); const [ux0, uy0] = toPx(-100, -100);
const [ux1, uy1] = toPx(0, 0); const [ux1, uy1] = toPx(0, 0);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(ux0, uy0); ctx.moveTo(ux0, uy0);
@@ -377,7 +402,7 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
ctx.strokeStyle = '#fbbf24'; ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.setLineDash([2, 3]); ctx.setLineDash([2, 3]);
const [tx0, ty0] = toPx(activeComp.threshold, -60); const [tx0, ty0] = toPx(activeComp.threshold, -100);
const [tx1, ty1] = toPx(activeComp.threshold, 0); const [tx1, ty1] = toPx(activeComp.threshold, 0);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(tx0, ty0); ctx.moveTo(tx0, ty0);
@@ -79,6 +79,7 @@ function DelayGraph({
unit: DelayUnit; unit: DelayUnit;
}) { }) {
const [min, max] = DELAY_RANGES[unit]; const [min, max] = DELAY_RANGES[unit];
const roundedMax = (Math.floor(max / 100) + 1) * 100;
const labelByX = useMemo(() => { const labelByX = useMemo(() => {
const map = new Map<number, string>(); const map = new Map<number, string>();
rows.forEach((row, i) => map.set(i + 1, row.label)); rows.forEach((row, i) => map.set(i + 1, row.label));
@@ -93,7 +94,7 @@ function DelayGraph({
xMax={rows.length + 0.5} xMax={rows.length + 0.5}
xStep={1} xStep={1}
yMin={min} yMin={min}
yMax={max} yMax={roundedMax}
xLabel="Channel" xLabel="Channel"
yLabel={UNIT_LABEL[unit]} yLabel={UNIT_LABEL[unit]}
showGrid={true} showGrid={true}
@@ -5,6 +5,8 @@ import Button from '../../../../components/Button/Button';
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup'; import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph'; import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
import './GainSection.css'; import './GainSection.css';
import { DiscreteFrequency } from '../../types';
import { DISCRETE_FREQUENCIES } from '../../constants';
function GainPanel({ function GainPanel({
dsp, dsp,
@@ -48,6 +50,7 @@ function GainPanel({
step={(v) => (v >= -20 ? 0.1 : 0.5)} step={(v) => (v >= -20 ? 0.1 : 0.5)}
unit=" dB" unit=" dB"
value={gain} value={gain}
formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))}
onChange={(value) => setGain(channel, value)} onChange={(value) => setGain(channel, value)}
/> />
@@ -116,6 +119,8 @@ function GainSection({
{ channel: { Output: OutputChannel.Out8 }, title: 'Out 8' }, { channel: { Output: OutputChannel.Out8 }, title: 'Out 8' },
]; ];
const currentChannel = dsp.state.current_config.input_states[InputChannel.InA];
return ( return (
<> <>
<div className="gain-linear-graph-section"> <div className="gain-linear-graph-section">
@@ -131,13 +136,46 @@ function GainSection({
xLabel="Hz" xLabel="Hz"
yLabel="dB" yLabel="dB"
draw={({ ctx, toPx }) => { draw={({ ctx, toPx }) => {
const geq = currentChannel.geq;
if (geq.bypass) {
return;
}
const points = Object.entries(geq.gains)
.map(([frequency, gain]) => ({
frequency: DISCRETE_FREQUENCIES[frequency as DiscreteFrequency],
gain,
}))
.sort((a, b) => a.frequency - b.frequency)
.map(({ frequency, gain }) => {
const [x, y] = toPx(frequency, gain);
return { x, y };
});
if (points.length < 2) {
return;
}
ctx.strokeStyle = '#4ade80'; ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2; ctx.lineWidth = 2;
const [x0, y0] = toPx(20, 0); ctx.lineJoin = 'round';
const [x1, y1] = toPx(20000, 0); ctx.lineCap = 'round';
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x0, y0); ctx.moveTo(points[0].x, points[0].y);
ctx.lineTo(x1, y1);
for (let i = 1; i < points.length; i++) {
const previous = points[i - 1];
const current = points[i];
const midX = (previous.x + current.x) / 2;
ctx.quadraticCurveTo(previous.x, previous.y, midX, (previous.y + current.y) / 2);
ctx.quadraticCurveTo(current.x, current.y, current.x, current.y);
}
ctx.stroke(); ctx.stroke();
}} }}
/> />
@@ -187,7 +187,7 @@ function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
const inputState = dsp.state.current_config.input_states[input]; const inputState = dsp.state.current_config.input_states[input];
const idx = INPUT_METER_INDEX[input]; const idx = INPUT_METER_INDEX[input];
const meter = dsp.meters.channels[idx]; const meter = dsp.meters.channels[idx];
return <MeterBar label={inputState.name} meter={meter} />; return <MeterBar key={input} label={inputState.name} meter={meter} />;
})} })}
</div> </div>
</div> </div>
@@ -141,3 +141,29 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
.geq-graph-canvas-wrap {
position: relative;
}
.geq-bypass-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 2;
padding-bottom: 40cqh;
}
.geq-bypass-overlay span {
font-size: 8cqh;
font-weight: 800;
letter-spacing: 0.15em;
color: #f87171;
text-shadow: 0 0 12px rgba(0, 0, 0, 0.6);
transform: rotate(-8deg);
opacity: 0.85;
user-select: none;
}
@@ -4,54 +4,22 @@ import { DSP408 as DSP } from '../../../../types';
import Button from '../../../../components/Button/Button'; import Button from '../../../../components/Button/Button';
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup'; import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph'; import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
import { useMemo, useState } from 'react'; import { GEQ_BAND_HZ, buildGeqCurveDraw } from '../../utils/geqCurve';
import {
INPUT_ORDER,
GRAPH_X_MIN,
GRAPH_X_MAX,
GRAPH_Y_MIN,
GRAPH_Y_MAX,
GRAPH_GEQ_CURVE_Q,
GEQ_GAIN_MIN,
GEQ_GAIN_MAX,
GEQ_GAIN_STEP,
GEQ_BANDS_LABEL,
} from '../../constants';
import { useState } from 'react';
import './GeqSection.css'; import './GeqSection.css';
const GEQ_BANDS = [
{ frequency: DiscreteFrequency.F20, label: '20' },
{ frequency: DiscreteFrequency.F25, label: '25' },
{ frequency: DiscreteFrequency.F31_5, label: '31.5' },
{ frequency: DiscreteFrequency.F40, label: '40' },
{ frequency: DiscreteFrequency.F50, label: '50' },
{ frequency: DiscreteFrequency.F63, label: '63' },
{ frequency: DiscreteFrequency.F80, label: '80' },
{ frequency: DiscreteFrequency.F100, label: '100' },
{ frequency: DiscreteFrequency.F125, label: '125' },
{ frequency: DiscreteFrequency.F160, label: '160' },
{ frequency: DiscreteFrequency.F200, label: '200' },
{ frequency: DiscreteFrequency.F250, label: '250' },
{ frequency: DiscreteFrequency.F315, label: '315' },
{ frequency: DiscreteFrequency.F400, label: '400' },
{ frequency: DiscreteFrequency.F500, label: '500' },
{ frequency: DiscreteFrequency.F630, label: '630' },
{ frequency: DiscreteFrequency.F800, label: '800' },
{ frequency: DiscreteFrequency.F1000, label: '1k' },
{ frequency: DiscreteFrequency.F1250, label: '1.25k' },
{ frequency: DiscreteFrequency.F1600, label: '1.6k' },
{ frequency: DiscreteFrequency.F2000, label: '2k' },
{ frequency: DiscreteFrequency.F2500, label: '2.5k' },
{ frequency: DiscreteFrequency.F3150, label: '3.15k' },
{ frequency: DiscreteFrequency.F4000, label: '4k' },
{ frequency: DiscreteFrequency.F5000, label: '5k' },
{ frequency: DiscreteFrequency.F6300, label: '6.3k' },
{ frequency: DiscreteFrequency.F8000, label: '8k' },
{ frequency: DiscreteFrequency.F10000, label: '10k' },
{ frequency: DiscreteFrequency.F12500, label: '12.5k' },
{ frequency: DiscreteFrequency.F16000, label: '16k' },
{ frequency: DiscreteFrequency.F20000, label: '20k' },
] as const;
const GEQ_GAIN_MIN = -12;
const GEQ_GAIN_MAX = 12;
const GEQ_GAIN_STEP = 0.1;
const INPUT_ORDER: InputChannel[] = [
InputChannel.InA,
InputChannel.InB,
InputChannel.InC,
InputChannel.InD,
];
type LiveBands = Partial<Record<DiscreteFrequency, number>>; type LiveBands = Partial<Record<DiscreteFrequency, number>>;
type SetGeqBandFn = ( type SetGeqBandFn = (
@@ -78,7 +46,7 @@ function GeqBandPanel({
return ( return (
<div className="geq-band-panel"> <div className="geq-band-panel">
{GEQ_BANDS.map(({ frequency, label }) => ( {GEQ_BANDS_LABEL.map(({ frequency, label }) => (
<div className="geq-band-cell" key={frequency}> <div className="geq-band-cell" key={frequency}>
<VerticalSlider <VerticalSlider
min={GEQ_GAIN_MIN} min={GEQ_GAIN_MIN}
@@ -112,13 +80,7 @@ function GeqGraph({
}) { }) {
const geq = dsp.state.current_config.input_states[channel].geq; const geq = dsp.state.current_config.input_states[channel].geq;
const labelByX = useMemo(() => { const values = GEQ_BANDS_LABEL.map(
const map = new Map<number, string>();
GEQ_BANDS.forEach((band, i) => map.set(i + 1, band.label));
return map;
}, []);
const values = GEQ_BANDS.map(
({ frequency }) => liveBands?.[frequency] ?? geq.gains[frequency] ?? 0 ({ frequency }) => liveBands?.[frequency] ?? geq.gains[frequency] ?? 0
); );
@@ -126,31 +88,31 @@ function GeqGraph({
<div className="geq-graph-panel"> <div className="geq-graph-panel">
<div className="geq-graph-title">{title}</div> <div className="geq-graph-title">{title}</div>
<LinearGraph <LinearGraph
xMin={0.5} xMin={GRAPH_X_MIN}
xMax={GEQ_BANDS.length + 0.5} xMax={GRAPH_X_MAX}
xStep={1} xScale="log"
yMin={GEQ_GAIN_MIN} yMin={GRAPH_Y_MIN}
yMax={GEQ_GAIN_MAX} yMax={GRAPH_Y_MAX}
yStep={3}
xLabel="Band Hz" xLabel="Band Hz"
yLabel="dB" yLabel="dB"
showGrid={true} showGrid={true}
formatX={(v) => labelByX.get(Math.round(v)) ?? ''} draw={buildGeqCurveDraw({
formatY={(v) => v.toFixed(0)} values,
draw={({ ctx, toPx, plot }) => { bandHz: GEQ_BAND_HZ,
const barWidth = (plot.width / GEQ_BANDS.length) * 0.6; q: GRAPH_GEQ_CURVE_Q * 0.5,
xMin: GRAPH_X_MIN,
values.forEach((gain, i) => { xMax: GRAPH_X_MAX,
const x = i + 1; yMin: GRAPH_Y_MIN,
const [xPx, zeroY] = toPx(x, 0); yMax: GRAPH_Y_MAX,
const [, gainY] = toPx(x, gain); dimmed: geq.bypass,
})}
ctx.fillStyle = gain >= 0 ? '#4ade80' : '#f87171';
const top = Math.min(zeroY, gainY);
const height = Math.abs(gainY - zeroY);
ctx.fillRect(xPx - barWidth / 2, top, barWidth, height);
});
}}
/> />
{geq.bypass && (
<div className="geq-bypass-overlay">
<span>BYPASSED</span>
</div>
)}
</div> </div>
); );
} }
@@ -274,7 +236,7 @@ function GeqSection({
if (resetting) return; if (resetting) return;
setResetting(true); setResetting(true);
for (const { frequency } of GEQ_BANDS) { for (const { frequency } of GEQ_BANDS_LABEL) {
// Skip bands already at 0 to save a round trip. // Skip bands already at 0 to save a round trip.
const current = dsp.state.current_config.input_states[channel].geq.gains[frequency] ?? 0; const current = dsp.state.current_config.input_states[channel].geq.gains[frequency] ?? 0;
if (current === 0) continue; if (current === 0) continue;
+89
View File
@@ -0,0 +1,89 @@
import { DiscreteFrequency, InputChannel } from './types';
export const INPUT_ORDER: InputChannel[] = [
InputChannel.InA,
InputChannel.InB,
InputChannel.InC,
InputChannel.InD,
];
export const DISCRETE_FREQUENCIES: Record<DiscreteFrequency, number> = {
[DiscreteFrequency.F20]: 20,
[DiscreteFrequency.F25]: 25,
[DiscreteFrequency.F31_5]: 31.5,
[DiscreteFrequency.F40]: 40,
[DiscreteFrequency.F50]: 50,
[DiscreteFrequency.F63]: 63,
[DiscreteFrequency.F80]: 80,
[DiscreteFrequency.F100]: 100,
[DiscreteFrequency.F125]: 125,
[DiscreteFrequency.F160]: 160,
[DiscreteFrequency.F200]: 200,
[DiscreteFrequency.F250]: 250,
[DiscreteFrequency.F315]: 315,
[DiscreteFrequency.F400]: 400,
[DiscreteFrequency.F500]: 500,
[DiscreteFrequency.F630]: 630,
[DiscreteFrequency.F800]: 800,
[DiscreteFrequency.F1000]: 1000,
[DiscreteFrequency.F1250]: 1250,
[DiscreteFrequency.F1600]: 1600,
[DiscreteFrequency.F2000]: 2000,
[DiscreteFrequency.F2500]: 2500,
[DiscreteFrequency.F3150]: 3150,
[DiscreteFrequency.F4000]: 4000,
[DiscreteFrequency.F5000]: 5000,
[DiscreteFrequency.F6300]: 6300,
[DiscreteFrequency.F8000]: 8000,
[DiscreteFrequency.F10000]: 10000,
[DiscreteFrequency.F12500]: 12500,
[DiscreteFrequency.F16000]: 16000,
[DiscreteFrequency.F20000]: 20000,
};
// ------ GEQ constants ------
export const GRAPH_GEQ_CURVE_Q = 8.65;
export const GEQ_BANDS_LABEL = [
{ frequency: DiscreteFrequency.F20, label: '20' },
{ frequency: DiscreteFrequency.F25, label: '25' },
{ frequency: DiscreteFrequency.F31_5, label: '31.5' },
{ frequency: DiscreteFrequency.F40, label: '40' },
{ frequency: DiscreteFrequency.F50, label: '50' },
{ frequency: DiscreteFrequency.F63, label: '63' },
{ frequency: DiscreteFrequency.F80, label: '80' },
{ frequency: DiscreteFrequency.F100, label: '100' },
{ frequency: DiscreteFrequency.F125, label: '125' },
{ frequency: DiscreteFrequency.F160, label: '160' },
{ frequency: DiscreteFrequency.F200, label: '200' },
{ frequency: DiscreteFrequency.F250, label: '250' },
{ frequency: DiscreteFrequency.F315, label: '315' },
{ frequency: DiscreteFrequency.F400, label: '400' },
{ frequency: DiscreteFrequency.F500, label: '500' },
{ frequency: DiscreteFrequency.F630, label: '630' },
{ frequency: DiscreteFrequency.F800, label: '800' },
{ frequency: DiscreteFrequency.F1000, label: '1k' },
{ frequency: DiscreteFrequency.F1250, label: '1.25k' },
{ frequency: DiscreteFrequency.F1600, label: '1.6k' },
{ frequency: DiscreteFrequency.F2000, label: '2k' },
{ frequency: DiscreteFrequency.F2500, label: '2.5k' },
{ frequency: DiscreteFrequency.F3150, label: '3.15k' },
{ frequency: DiscreteFrequency.F4000, label: '4k' },
{ frequency: DiscreteFrequency.F5000, label: '5k' },
{ frequency: DiscreteFrequency.F6300, label: '6.3k' },
{ frequency: DiscreteFrequency.F8000, label: '8k' },
{ frequency: DiscreteFrequency.F10000, label: '10k' },
{ frequency: DiscreteFrequency.F12500, label: '12.5k' },
{ frequency: DiscreteFrequency.F16000, label: '16k' },
{ frequency: DiscreteFrequency.F20000, label: '20k' },
] as const;
export const GEQ_GAIN_MIN = -12;
export const GEQ_GAIN_MAX = 12;
export const GEQ_GAIN_STEP = 0.1;
// ------ Graph constants ------
export const GRAPH_X_MIN = 10;
export const GRAPH_X_MAX = 30000;
export const GRAPH_Y_MIN = -21;
export const GRAPH_Y_MAX = 21;
+144
View File
@@ -0,0 +1,144 @@
export const GEQ_BAND_HZ = [
20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160, 200, 250, 315, 400, 500, 630, 800, 1000, 1250, 1600,
2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000,
] as const;
/** Peaking-filter magnitude response (dB) of a single band at a given frequency. */
export function geqPeakResponseDb(
freq: number,
bandFreq: number,
gainDb: number,
q: number
): number {
if (gainDb === 0) return 0;
const ratio = freq / bandFreq - bandFreq / freq;
return gainDb / (1 + (q * ratio) ** 2);
}
/** Sum of all bands' contributions at a given frequency. */
export function geqTotalCurveDb(
freq: number,
values: number[],
bandHz: readonly number[] = GEQ_BAND_HZ,
q: number
): number {
let sum = 0;
for (let i = 0; i < values.length; i++) {
sum += geqPeakResponseDb(freq, bandHz[i], values[i], q);
}
return sum;
}
/** Log-spaced sample points from xMin to xMax, for a pixel-smooth curve on a log x-axis. */
export function logSpace(xMin: number, xMax: number, steps: number): number[] {
const logMin = Math.log10(xMin);
const logMax = Math.log10(xMax);
const out: number[] = [];
for (let s = 0; s <= steps; s++) {
out.push(Math.pow(10, logMin + ((logMax - logMin) * s) / steps));
}
return out;
}
export type GeqCurveColors = {
strokeColor: string;
fillAbove: string;
fillAboveFade: string;
fillBelowFade: string;
fillBelow: string;
};
export const DEFAULT_GEQ_CURVE_COLORS: GeqCurveColors = {
strokeColor: '#e5e7eb',
fillAbove: 'rgba(74, 222, 128, 0.35)',
fillAboveFade: 'rgba(74, 222, 128, 0.08)',
fillBelowFade: 'rgba(248, 113, 113, 0.08)',
fillBelow: 'rgba(248, 113, 113, 0.35)',
};
export type GeqCurveOptions = {
values: number[];
bandHz?: readonly number[];
q: number;
xMin: number;
xMax: number;
yMin: number;
yMax: number;
steps?: number;
colors?: GeqCurveColors;
lineWidth?: number;
dimmed?: boolean;
};
/**
* Returns a `draw` callback compatible with LinearGraph's `draw` prop:
* ({ ctx, toPx }) => void
*
* Usage:
* <LinearGraph ... draw={buildGeqCurveDraw({ values, q: 8.65, xMin, xMax, yMin, yMax })} />
*/
export function buildGeqCurveDraw({
values,
bandHz = GEQ_BAND_HZ,
q,
xMin,
xMax,
yMin,
yMax,
steps = 500,
colors = DEFAULT_GEQ_CURVE_COLORS,
lineWidth = 2,
dimmed = false,
}: GeqCurveOptions) {
return ({
ctx,
toPx,
}: {
ctx: CanvasRenderingContext2D;
toPx: (x: number, y: number) => [number, number];
}) => {
const freqs = logSpace(xMin, xMax, steps);
const points = freqs.map((freq) => {
const gain = geqTotalCurveDb(freq, values, bandHz, q);
return toPx(freq, gain);
});
ctx.save();
if (dimmed) ctx.globalAlpha = 0.25; // NEW: fade everything drawn below
// Fill area under the curve down to 0 dB
ctx.beginPath();
points.forEach(([xPx, yPx], i) => {
if (i === 0) ctx.moveTo(xPx, yPx);
else ctx.lineTo(xPx, yPx);
});
const [xEndPx, zeroYPx] = toPx(xMax, 0);
const [xStartPx] = toPx(xMin, 0);
ctx.lineTo(xEndPx, zeroYPx);
ctx.lineTo(xStartPx, zeroYPx);
ctx.closePath();
const [, topPx] = toPx(xMin, yMax);
const [, bottomPx] = toPx(xMin, yMin);
const gradient = ctx.createLinearGradient(0, topPx, 0, bottomPx);
gradient.addColorStop(0, colors.fillAbove);
gradient.addColorStop(0.5, colors.fillAboveFade);
gradient.addColorStop(0.5, colors.fillBelowFade);
gradient.addColorStop(1, colors.fillBelow);
ctx.fillStyle = gradient;
ctx.fill();
// Stroke the curve on top
ctx.beginPath();
points.forEach(([xPx, yPx], i) => {
if (i === 0) ctx.moveTo(xPx, yPx);
else ctx.lineTo(xPx, yPx);
});
ctx.strokeStyle = colors.strokeColor;
ctx.lineWidth = lineWidth;
if (dimmed) ctx.setLineDash([6, 4]); // dashed = "not actually applied"
ctx.stroke();
ctx.restore();
};
}