diff --git a/src/features/dsp408/components/GainSection/GainSection.css b/src/features/dsp408/components/GainSection/GainSection.css index 05b809a..d376885 100644 --- a/src/features/dsp408/components/GainSection/GainSection.css +++ b/src/features/dsp408/components/GainSection/GainSection.css @@ -103,3 +103,52 @@ flex: 1; min-height: 0; } + +.gain-graph-title-row { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1cqh; + padding: 1cqh 1.5cqh; + flex-wrap: wrap; +} + +.gain-graph-title-row .gain-graph-title { + padding: 0; +} + +.magnitude-selector { + display: flex; + flex-wrap: wrap; + gap: 0.5cqh; +} + +.magnitude-selector__chip { + display: flex; + align-items: center; + gap: 0.6cqh; + font-family: var(--font-ui); + font-size: 1.8cqh; + padding: 0.4cqh 1cqh; + background: var(--bg-void); + color: var(--text-muted); + border: var(--border-width) solid var(--border-hairline); + border-radius: var(--radius-sm); + cursor: pointer; + opacity: 0.5; +} + +.magnitude-selector__chip.active { + opacity: 1; + color: var(--text-primary); + border-color: var(--chip-color); +} + +.magnitude-selector__swatch { + width: 1.2cqh; + height: 1.2cqh; + border-radius: 50%; + background: var(--chip-color); + flex: 0 0 auto; +} diff --git a/src/features/dsp408/components/GainSection/GainSection.tsx b/src/features/dsp408/components/GainSection/GainSection.tsx index 1c22c31..37f9142 100644 --- a/src/features/dsp408/components/GainSection/GainSection.tsx +++ b/src/features/dsp408/components/GainSection/GainSection.tsx @@ -6,7 +6,36 @@ import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup'; import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph'; import './GainSection.css'; import { DiscreteFrequency } from '../../types'; -import { DISCRETE_FREQUENCIES } from '../../constants'; +import { + DISCRETE_FREQUENCIES, + GRAPH_X_MAX, + GRAPH_X_MIN, + GRAPH_Y_MAX, + GRAPH_Y_MIN, + INPUT_ORDER, + OUTPUT_ORDER, +} from '../../constants'; +import { useState } from 'react'; +// import { OverlayChannelSpec, buildInputMagnitudeOverlayDraw } from '../../utils/overlayCurve'; + +const CHANNEL_COLORS = [ + '#4ade80', + '#60a5fa', + '#f472b6', + '#fbbf24', + '#a78bfa', + '#34d399', + '#f87171', + '#38bdf8', +]; + +function buildColorMap(channels: T[]): Record { + const map = {} as Record; + channels.forEach((ch, i) => { + map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length]; + }); + return map; +} function GainPanel({ dsp, @@ -90,6 +119,40 @@ function GainPanel({ ); } +function ChannelSelector({ + channels, + labels, + colors, + selected, + onToggle, +}: { + channels: T[]; + labels: Record; + colors: Record; + selected: Set; + onToggle: (channel: T) => void; +}) { + return ( +
+ {channels.map((channel) => { + const isOn = selected.has(channel); + return ( + + ); + })} +
+ ); +} + function GainSection({ dsp, setGain, @@ -101,87 +164,81 @@ function GainSection({ setMute: (channel: Channel, muted: boolean) => Promise; setInverse: (channel: Channel, inverted: boolean) => Promise; }) { - const inputs: InputChannel[] = [ - InputChannel.InA, - InputChannel.InB, - InputChannel.InC, - InputChannel.InD, - ]; - - const outputs: { channel: Channel; title: string }[] = [ - { channel: { Output: OutputChannel.Out1 }, title: 'Out 1' }, - { channel: { Output: OutputChannel.Out2 }, title: 'Out 2' }, - { channel: { Output: OutputChannel.Out3 }, title: 'Out 3' }, - { channel: { Output: OutputChannel.Out4 }, title: 'Out 4' }, - { channel: { Output: OutputChannel.Out5 }, title: 'Out 5' }, - { channel: { Output: OutputChannel.Out6 }, title: 'Out 6' }, - { channel: { Output: OutputChannel.Out7 }, title: 'Out 7' }, - { channel: { Output: OutputChannel.Out8 }, title: 'Out 8' }, - ]; - const currentChannel = dsp.state.current_config.input_states[InputChannel.InA]; + const inputColors = buildColorMap(INPUT_ORDER); + const outputColors = buildColorMap(OUTPUT_ORDER); + + const [selectedInputs, setSelectedInputs] = useState>(new Set(INPUT_ORDER)); + const [selectedOutputs, setSelectedOutputs] = useState>(new Set(OUTPUT_ORDER)); + + const inputLabels: Record = INPUT_ORDER.reduce( + (acc, channel) => ({ + ...acc, + [channel]: dsp.state.current_config.input_states[channel].name, + }), + {} as Record + ); + + const toggleInput = (channel: InputChannel) => { + setSelectedInputs((prev) => { + const next = new Set(prev); + next.has(channel) ? next.delete(channel) : next.add(channel); + return next; + }); + }; + + const toggleOutput = (channel: OutputChannel) => { + setSelectedOutputs((prev) => { + const next = new Set(prev); + next.has(channel) ? next.delete(channel) : next.add(channel); + return next; + }); + }; + + // const inputOverlaySpecs: OverlayChannelSpec[] = INPUT_ORDER.filter((c) => + // selectedInputs.has(c) + // ).map((channel) => ({ channel, color: inputColors[channel] })); + + // const outputOverlaySpecs: OverlayChannelSpec[] = OUTPUT_ORDER.filter((c) => + // selectedOutputs.has(c) + // ).map((channel) => ({ channel, color: outputColors[channel] })); + return ( <>
-
Frequency Response
- +
Input Magnitude
+ +
+ {/* { - 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.lineWidth = 2; - ctx.lineJoin = 'round'; - ctx.lineCap = 'round'; - - ctx.beginPath(); - ctx.moveTo(points[0].x, points[0].y); - - 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(); - }} - /> + showGrid={true} + formatX={(v) => Math.round(v).toString()} + formatY={(v) => Math.round(v).toString()} + draw={buildInputMagnitudeOverlayDraw({ + channels: inputOverlaySpecs, + getGains: (channel) => dsp.state.current_config.input_states[channel].geq.gains, + getBypass: (channel) => dsp.state.current_config.input_states[channel].geq.bypass, + })} + /> */}
-
+ {/*
Phase Response
-
+
*/}
- {inputs.map((channel) => ( + {INPUT_ORDER.map((channel) => ( - {outputs.map(({ channel, title }) => ( + {OUTPUT_ORDER.map((channel) => ( >; @@ -83,6 +85,7 @@ function GeqGraph({ const values = GEQ_BANDS_LABEL.map( ({ frequency }) => liveBands?.[frequency] ?? geq.gains[frequency] ?? 0 ); + const freqs = logSpace(GRAPH_X_MIN, GRAPH_X_MAX, 1000); return (
@@ -97,16 +100,23 @@ function GeqGraph({ xLabel="Band Hz" yLabel="dB" showGrid={true} - draw={buildGeqCurveDraw({ - values, - bandHz: GEQ_BAND_HZ, - q: GRAPH_GEQ_CURVE_Q * 0.5, - xMin: GRAPH_X_MIN, - xMax: GRAPH_X_MAX, - yMin: GRAPH_Y_MIN, - yMax: GRAPH_Y_MAX, - dimmed: geq.bypass, - })} + draw={({ ctx, toPx }) => { + drawCurves({ + ctx, + toPx, + + xMin: GRAPH_X_MIN, + xMax: GRAPH_X_MAX, + yMin: GRAPH_Y_MIN, + yMax: GRAPH_Y_MAX, + curves: [ + { + points: getGeqCurvePoints({ freqs, values, q: GEQ_CURVE_Q }), + style: { strokeColor: '#e5e7eb', dimmed: geq.bypass }, + }, + ], + }); + }} /> {geq.bypass && (
diff --git a/src/features/dsp408/components/InputSection/InputSection.tsx b/src/features/dsp408/components/InputSection/InputSection.tsx index 017078d..cd12600 100644 --- a/src/features/dsp408/components/InputSection/InputSection.tsx +++ b/src/features/dsp408/components/InputSection/InputSection.tsx @@ -18,7 +18,7 @@ import { useRef, useState } from 'react'; import './InputSection.css'; import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup'; import Button from '../../../../components/Button/Button'; -import { buildTotalCurveDraw, buildTotalPhaseCurveDraw } from '../../utils/totalCurve'; +// import { buildTotalCurveDraw, buildTotalPhaseCurveDraw } from '../../utils/totalCurve'; import { FREQUENCY_MAX, FREQUENCY_MIN, @@ -28,6 +28,10 @@ import { GRAPH_Y_MAX, GRAPH_Y_MIN, } from '../../constants'; +import { drawCurves } from '../../utils/graph/draw'; +import { getPeqCurvePoints, getPeqPhasePoints } from '../../utils/graph/peq'; +import { logSpace, sumCurvePoints, sumPhaseCurvePoints } from '../../utils/graph/curve'; +import { getCrossoverCurvePoints, getCrossoverPhasePoints } from '../../utils/graph/crossover'; type GraphView = 'both' | 'magnitude' | 'phase'; @@ -601,6 +605,8 @@ function InputSection({ const highPassSpec = { frequency: liveHpFreq, filter_type: ActiveHighPassFilter.slope }; const lowPassSpec = { frequency: liveLpFreq, filter_type: ActiveLowPassFilter.slope }; + const freqs = logSpace(GRAPH_X_MIN, GRAPH_X_MAX, 1000); + return ( <>
@@ -625,16 +631,30 @@ function InputSection({ showGrid={true} formatX={(v) => Math.round(v).toString()} formatY={(v) => Math.round(v).toString()} - draw={buildTotalCurveDraw({ - bands: displayPeq, - highPass: highPassSpec, - lowPass: lowPassSpec, - xMin: GRAPH_X_MIN, - xMax: GRAPH_X_MAX, - yMin: GRAPH_Y_MIN, - yMax: GRAPH_Y_MAX, - dimmed: ActivePeq.bypass, - })} + draw={({ ctx, toPx }) => { + drawCurves({ + ctx, + toPx, + + xMin: GRAPH_X_MIN, + xMax: GRAPH_X_MAX, + yMin: GRAPH_Y_MIN, + yMax: GRAPH_Y_MAX, + curves: [ + { + points: sumCurvePoints([ + getPeqCurvePoints({ freqs, bands: displayPeq }), + getCrossoverCurvePoints({ + freqs, + highPass: highPassSpec, + lowPass: lowPassSpec, + }), + ]), + style: { strokeColor: '#e5e7eb', dimmed: ActivePeq.bypass }, + }, + ], + }); + }} />
)} @@ -652,16 +672,30 @@ function InputSection({ showGrid={true} formatX={(v) => Math.round(v).toString()} formatY={(v) => Math.round(v).toString()} - draw={buildTotalPhaseCurveDraw({ - bands: displayPeq, - highPass: highPassSpec, - lowPass: lowPassSpec, - xMin: GRAPH_X_MIN, - xMax: GRAPH_X_MAX, - yMin: -180, - yMax: 180, - dimmed: ActivePeq.bypass, - })} + draw={({ ctx, toPx }) => { + drawCurves({ + ctx, + toPx, + + xMin: GRAPH_X_MIN, + xMax: GRAPH_X_MAX, + yMin: GRAPH_Y_MIN, + yMax: GRAPH_Y_MAX, + curves: [ + { + points: sumPhaseCurvePoints([ + getPeqPhasePoints({ freqs, bands: displayPeq }), + getCrossoverPhasePoints({ + freqs, + highPass: highPassSpec, + lowPass: lowPassSpec, + }), + ]), + style: { strokeColor: '#e5e7eb', dimmed: ActivePeq.bypass }, + }, + ], + }); + }} /> )}
diff --git a/src/features/dsp408/constants.ts b/src/features/dsp408/constants.ts index 52c5862..fee45e4 100644 --- a/src/features/dsp408/constants.ts +++ b/src/features/dsp408/constants.ts @@ -61,7 +61,6 @@ export const DISCRETE_FREQUENCIES: Record = { }; // ------ GEQ constants ------ -export const GRAPH_GEQ_CURVE_Q = 8.65; export const GEQ_BANDS_LABEL = [ { frequency: DiscreteFrequency.F20, label: '20' }, @@ -100,6 +99,7 @@ export const GEQ_BANDS_LABEL = [ export const GEQ_GAIN_MIN = -12; export const GEQ_GAIN_MAX = 12; export const GEQ_GAIN_STEP = 0.1; +export const GEQ_CURVE_Q = 8.65 * 0.5; // ------ Filter constants ------ diff --git a/src/features/dsp408/utils/geqCurve.ts b/src/features/dsp408/utils/geqCurve.ts deleted file mode 100644 index 344443b..0000000 --- a/src/features/dsp408/utils/geqCurve.ts +++ /dev/null @@ -1,137 +0,0 @@ -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; -}; - -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(); - }; -} diff --git a/src/features/dsp408/utils/biquad.ts b/src/features/dsp408/utils/graph/biquad.ts similarity index 100% rename from src/features/dsp408/utils/biquad.ts rename to src/features/dsp408/utils/graph/biquad.ts diff --git a/src/features/dsp408/utils/crossoverCurve.ts b/src/features/dsp408/utils/graph/crossover.ts similarity index 75% rename from src/features/dsp408/utils/crossoverCurve.ts rename to src/features/dsp408/utils/graph/crossover.ts index bef36a3..a4a81a2 100644 --- a/src/features/dsp408/utils/crossoverCurve.ts +++ b/src/features/dsp408/utils/graph/crossover.ts @@ -1,4 +1,4 @@ -import { CrossoverFilter } from '../types'; +import { CrossoverFilter } from '../../types'; import { lp6ResponseDb, hp6ResponseDb, @@ -9,41 +9,52 @@ import { lp12PhaseDeg, hp12PhaseDeg, } from './biquad'; +import { CurvePoint } from './curve'; function butterworthStageQs(order: number): number[] { const qs: number[] = []; const pairs = Math.floor(order / 2); + for (let k = 1; k <= pairs; k++) { qs.push(1 / (2 * Math.cos(((2 * k - 1) * Math.PI) / (2 * order)))); } + return qs; } function butterworthLpResponseDb(freq: number, f0: number, order: number): number { if (order <= 0) return 0; let sum = order % 2 === 1 ? lp6ResponseDb(freq, f0) : 0; - for (const q of butterworthStageQs(order)) sum += lp12ResponseDb(freq, f0, q); + for (const q of butterworthStageQs(order)) { + sum += lp12ResponseDb(freq, f0, q); + } return sum; } function butterworthHpResponseDb(freq: number, f0: number, order: number): number { if (order <= 0) return 0; let sum = order % 2 === 1 ? hp6ResponseDb(freq, f0) : 0; - for (const q of butterworthStageQs(order)) sum += hp12ResponseDb(freq, f0, q); + for (const q of butterworthStageQs(order)) { + sum += hp12ResponseDb(freq, f0, q); + } return sum; } function butterworthLpPhaseDeg(freq: number, f0: number, order: number): number { if (order <= 0) return 0; let sum = order % 2 === 1 ? lp6PhaseDeg(freq, f0) : 0; - for (const q of butterworthStageQs(order)) sum += lp12PhaseDeg(freq, f0, q); + for (const q of butterworthStageQs(order)) { + sum += lp12PhaseDeg(freq, f0, q); + } return sum; } function butterworthHpPhaseDeg(freq: number, f0: number, order: number): number { if (order <= 0) return 0; let sum = order % 2 === 1 ? hp6PhaseDeg(freq, f0) : 0; - for (const q of butterworthStageQs(order)) sum += hp12PhaseDeg(freq, f0, q); + for (const q of butterworthStageQs(order)) { + sum += hp12PhaseDeg(freq, f0, q); + } return sum; } @@ -80,6 +91,7 @@ const CROSSOVER_SLOPE_CONFIG: Record< }; export type CrossoverKind = 'lp' | 'hp'; + export interface CrossoverSpec { frequency: number; filter_type: CrossoverFilter; @@ -91,8 +103,10 @@ export function crossoverMagnitudeDb( freq: number ): number { if (!spec || spec.filter_type === CrossoverFilter.Bypass) return 0; + const cfg = CROSSOVER_SLOPE_CONFIG[spec.filter_type]; if (!cfg) return 0; + const fn = kind === 'lp' ? cfg.topology === 'butterworth' @@ -101,6 +115,7 @@ export function crossoverMagnitudeDb( : cfg.topology === 'butterworth' ? butterworthHpResponseDb : linkwitzRileyHpResponseDb; + return fn(freq, spec.frequency, cfg.order); } @@ -110,8 +125,10 @@ export function crossoverPhaseDeg( freq: number ): number { if (!spec || spec.filter_type === CrossoverFilter.Bypass) return 0; + const cfg = CROSSOVER_SLOPE_CONFIG[spec.filter_type]; if (!cfg) return 0; + const fn = kind === 'lp' ? cfg.topology === 'butterworth' @@ -120,6 +137,7 @@ export function crossoverPhaseDeg( : cfg.topology === 'butterworth' ? butterworthHpPhaseDeg : linkwitzRileyHpPhaseDeg; + return fn(freq, spec.frequency, cfg.order); } @@ -139,51 +157,38 @@ export function crossoverPairPhaseDeg( return crossoverPhaseDeg('hp', highPass, freq) + crossoverPhaseDeg('lp', lowPass, freq); } -export type CrossoverCurveOptions = { - highPass?: CrossoverSpec; - lowPass?: CrossoverSpec; - xMin: number; - xMax: number; - steps?: number; - strokeColor?: string; - lineWidth?: number; - dimmed?: boolean; -}; - -/** HP+LP-only curve — no PEQ. Use if you ever want crossover shown standalone. */ -export function buildCrossoverCurveDraw({ +/** + * Computes crossover magnitude points over a caller-supplied frequency + * grid. Pass the same `freqs` array you use for GEQ/PEQ to keep every + * curve on a shared x-grid (required for `sumCurvePoints`). + */ +export function getCrossoverCurvePoints({ + freqs, highPass, lowPass, - xMin, - xMax, - steps = 1000, - strokeColor = '#f59e0b', - lineWidth = 2, - dimmed = false, -}: CrossoverCurveOptions) { - return ({ - ctx, - toPx, - }: { - ctx: CanvasRenderingContext2D; - toPx: (x: number, y: number) => [number, number]; - }) => { - const logMin = Math.log10(xMin); - const logMax = Math.log10(xMax); - ctx.save(); - if (dimmed) { - ctx.globalAlpha = 0.4; - ctx.setLineDash([6, 4]); - } - ctx.beginPath(); - for (let i = 0; i <= steps; i++) { - const freq = Math.pow(10, logMin + (i / steps) * (logMax - logMin)); - const [xPx, yPx] = toPx(freq, crossoverPairMagnitudeDb(highPass, lowPass, freq)); - i === 0 ? ctx.moveTo(xPx, yPx) : ctx.lineTo(xPx, yPx); - } - ctx.strokeStyle = strokeColor; - ctx.lineWidth = lineWidth; - ctx.stroke(); - ctx.restore(); - }; +}: { + freqs: number[]; + highPass?: CrossoverSpec; + lowPass?: CrossoverSpec; +}): CurvePoint[] { + return freqs.map((freq) => ({ + x: freq, + y: crossoverPairMagnitudeDb(highPass, lowPass, freq), + })); +} + +/** Computes crossover phase points over a caller-supplied frequency grid. */ +export function getCrossoverPhasePoints({ + freqs, + highPass, + lowPass, +}: { + freqs: number[]; + highPass?: CrossoverSpec; + lowPass?: CrossoverSpec; +}): CurvePoint[] { + return freqs.map((freq) => ({ + x: freq, + y: crossoverPairPhaseDeg(highPass, lowPass, freq), + })); } diff --git a/src/features/dsp408/utils/graph/curve.ts b/src/features/dsp408/utils/graph/curve.ts new file mode 100644 index 0000000..110fd37 --- /dev/null +++ b/src/features/dsp408/utils/graph/curve.ts @@ -0,0 +1,100 @@ +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 CurvePoint = { x: number; y: number }; + +export type CurveColors = { + strokeColor: string; + fillAbove: string; + fillAboveFade: string; + fillBelowFade: string; + fillBelow: string; +}; + +export const DEFAULT_CURVE_COLORS: CurveColors = { + 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 CurveStyle = { + strokeColor?: string; + fillColors?: CurveColors; + /** Draw the area fill under/around the curve. Defaults to true. */ + fill?: boolean; + lineWidth?: number; + dimmed?: boolean; +}; + +/** A single polyline ready to be handed to `drawCurves`. */ +export type Curve = { + points: CurvePoint[]; + style?: CurveStyle; +}; + +export function sumCurvePoints(pointArrays: CurvePoint[][]): CurvePoint[] { + if (pointArrays.length === 0) return []; + + const length = pointArrays[0].length; + const result: CurvePoint[] = new Array(length); + + for (let i = 0; i < length; i++) { + let y = 0; + for (const points of pointArrays) { + y += points[i]?.y ?? 0; + } + result[i] = { x: pointArrays[0][i].x, y }; + } + + return result; +} + +export function sumPhaseCurvePoints( + pointArrays: CurvePoint[][] +): CurvePoint[] { + if (pointArrays.length === 0) return []; + + const length = pointArrays[0].length; + const result: CurvePoint[] = new Array(length); + + let previousPhase = 0; + + for (let i = 0; i < length; i++) { + let phase = 0; + + for (const points of pointArrays) { + phase += points[i]?.y ?? 0; + } + + // Keep phase continuous while summing. + while (phase - previousPhase > 180) { + phase -= 360; + } + + while (phase - previousPhase < -180) { + phase += 360; + } + + // Wrap to [-180, 180]. + const wrapped = + ((((phase + 180) % 360) + 360) % 360) - 180; + + result[i] = { + x: pointArrays[0][i].x, + y: wrapped, + }; + + previousPhase = phase; + } + + return result; +} diff --git a/src/features/dsp408/utils/graph/draw.ts b/src/features/dsp408/utils/graph/draw.ts new file mode 100644 index 0000000..7828e30 --- /dev/null +++ b/src/features/dsp408/utils/graph/draw.ts @@ -0,0 +1,105 @@ +import { + CurvePoint, + CurveStyle, + Curve, + DEFAULT_CURVE_COLORS, + logSpace, + sumCurvePoints, +} from './curve'; + +export { sumCurvePoints }; + +type ToPx = (x: number, y: number) => [number, number]; +type CanvasCtx = CanvasRenderingContext2D; + +const DEFAULT_STYLE: Required> = { + strokeColor: DEFAULT_CURVE_COLORS.strokeColor, + fillColors: DEFAULT_CURVE_COLORS, + fill: true, + lineWidth: 2, + dimmed: false, +}; + +function drawSingleCurve( + ctx: CanvasCtx, + toPx: ToPx, + points: CurvePoint[], + xMin: number, + xMax: number, + yMin: number, + yMax: number, + style: Required +) { + if (points.length < 2) return; + + ctx.save(); + + if (style.dimmed) { + ctx.globalAlpha = 0.25; + ctx.setLineDash([6, 4]); + } + + if (style.fill) { + ctx.beginPath(); + + points.forEach(({ x, y }, i) => { + const [px, py] = toPx(x, y); + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + }); + + const [xEnd, zeroY] = toPx(xMax, 0); + const [xStart] = toPx(xMin, 0); + + ctx.lineTo(xEnd, zeroY); + ctx.lineTo(xStart, zeroY); + ctx.closePath(); + + const [, topY] = toPx(xMin, yMax); + const [, bottomY] = toPx(xMin, yMin); + + const gradient = ctx.createLinearGradient(0, topY, 0, bottomY); + gradient.addColorStop(0, style.fillColors.fillAbove); + gradient.addColorStop(0.5, style.fillColors.fillAboveFade); + gradient.addColorStop(0.5, style.fillColors.fillBelowFade); + gradient.addColorStop(1, style.fillColors.fillBelow); + + ctx.fillStyle = gradient; + ctx.fill(); + } + + ctx.beginPath(); + + points.forEach(({ x, y }, i) => { + const [px, py] = toPx(x, y); + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + }); + + ctx.strokeStyle = style.strokeColor; + ctx.lineWidth = style.lineWidth; + ctx.stroke(); + + ctx.restore(); +} + +export type DrawCurvesParams = { + ctx: CanvasCtx; + toPx: ToPx; + xMin: number; + xMax: number; + yMin: number; + yMax: number; + /** Each entry is drawn as its own independent polyline. To draw a + * combined curve, sum the point arrays with `sumCurvePoints` first + * and pass the result as a single entry. */ + curves: Curve[]; +}; + +/** Generic curve drawer — knows nothing about GEQ/PEQ/crossover. */ +export function drawCurves({ ctx, toPx, xMin, xMax, yMin, yMax, curves }: DrawCurvesParams) { + for (const curve of curves) { + const style: Required = { ...DEFAULT_STYLE, ...curve.style }; + drawSingleCurve(ctx, toPx, curve.points, xMin, xMax, yMin, yMax, style); + } +} diff --git a/src/features/dsp408/utils/graph/geq.ts b/src/features/dsp408/utils/graph/geq.ts new file mode 100644 index 0000000..16a3705 --- /dev/null +++ b/src/features/dsp408/utils/graph/geq.ts @@ -0,0 +1,48 @@ +import { CurvePoint } from './curve'; +import { DISCRETE_FREQUENCIES } from '../../constants'; + +/** Peaking-filter magnitude response (dB) of a single band at a given frequency. */ +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. */ +function geqTotalCurveDb( + freq: number, + values: number[], + bandHz: readonly number[] = Object.values(DISCRETE_FREQUENCIES), + q: number +): number { + let sum = 0; + + for (let i = 0; i < values.length; i++) { + sum += geqPeakResponseDb(freq, bandHz[i], values[i], q); + } + + return sum; +} + +/** + * Computes GEQ magnitude points over a caller-supplied frequency grid. + * Pass the same `freqs` array you use for PEQ/crossover to keep every + * curve on a shared x-grid (required for `sumCurvePoints`). + */ +export function getGeqCurvePoints({ + freqs, + values, + q, + bandHz = Object.values(DISCRETE_FREQUENCIES), +}: { + freqs: number[]; + values: number[]; + q: number; + bandHz?: readonly number[]; +}): CurvePoint[] { + return freqs.map((freq) => ({ + x: freq, + y: geqTotalCurveDb(freq, values, bandHz, q), + })); +} diff --git a/src/features/dsp408/utils/peqCurve.ts b/src/features/dsp408/utils/graph/peq.ts similarity index 50% rename from src/features/dsp408/utils/peqCurve.ts rename to src/features/dsp408/utils/graph/peq.ts index c22a7f4..fb9ba05 100644 --- a/src/features/dsp408/utils/peqCurve.ts +++ b/src/features/dsp408/utils/graph/peq.ts @@ -1,11 +1,21 @@ -import { PEQ, PEQBand, PEQFilter } from '../types'; -import { DEFAULT_SAMPLE_RATE } from './biquad'; -import { GeqCurveColors, DEFAULT_GEQ_CURVE_COLORS, logSpace } from './geqCurve'; -import { biquadMagnitudeDb, biquadPhaseDeg } from './biquad'; +import { PEQ, PEQBand, PEQFilter } from '../../types'; +import { + DEFAULT_SAMPLE_RATE, + BiquadCoeffs, + biquadMagnitudeDb, + biquadPhaseDeg, + lp6ResponseDb, + hp6ResponseDb, + lp12ResponseDb, + hp12ResponseDb, + lp6PhaseDeg, + hp6PhaseDeg, + lp12PhaseDeg, + hp12PhaseDeg, +} from './biquad'; +import { CurvePoint } from './curve'; -type BiquadCoeffs = { b0: number; b1: number; b2: number; a0: number; a1: number; a2: number }; - -// ----------- Gain Response ----------- +// ----------- Magnitude Response ----------- /** RBJ Audio EQ Cookbook peaking-EQ biquad, exact magnitude response (dB). */ function peakResponseDb(freq: number, f0: number, gainDb: number, q: number, fs: number): number { @@ -86,34 +96,6 @@ function highShelfResponseDb( return biquadMagnitudeDb(freq, fs, coeffs); } -/** 1-pole lowpass, -6dB/oct. */ -function lp6ResponseDb(freq: number, f0: number): number { - const x = freq / f0; - return -10 * Math.log10(1 + x * x); -} - -/** 1-pole highpass, -6dB/oct. */ -function hp6ResponseDb(freq: number, f0: number): number { - const x = f0 / freq; - return -10 * Math.log10(1 + x * x); -} - -/** 2-pole lowpass, -12dB/oct, resonant. */ -function lp12ResponseDb(freq: number, f0: number, q: number): number { - const x = freq / f0; - const a = 1 - x * x; - const b = x / q; - return -10 * Math.log10(a * a + b * b); -} - -/** 2-pole highpass, -12dB/oct, resonant. */ -function hp12ResponseDb(freq: number, f0: number, q: number): number { - const x = freq / f0; - const a = 1 - x * x; - const b = x / q; - return 10 * Math.log10(x ** 4) - 10 * Math.log10(a * a + b * b); -} - /** Magnitude response (dB) of a single PEQ band at a given frequency. */ function peqBandResponseDb( freq: number, @@ -125,114 +107,68 @@ function peqBandResponseDb( switch (band.filter_type) { case PEQFilter.Peak: return peakResponseDb(freq, band.frequency, band.gain, band.q, sampleRate); + case PEQFilter.LowShelf: return lowShelfResponseDb(freq, band.frequency, band.gain, band.q, sampleRate); + case PEQFilter.HighShelf: return highShelfResponseDb(freq, band.frequency, band.gain, band.q, sampleRate); + case PEQFilter.Lp6Db: return lp6ResponseDb(freq, band.frequency); + case PEQFilter.Hp6Db: return hp6ResponseDb(freq, band.frequency); + case PEQFilter.Lp12Db: return lp12ResponseDb(freq, band.frequency, band.q); + case PEQFilter.Hp12Db: return hp12ResponseDb(freq, band.frequency, band.q); + case PEQFilter.AllPass1: case PEQFilter.AllPass2: // Allpass filters don't affect magnitude, only phase. return 0; + default: return 0; } } /** Sum of all bands' contributions at a given frequency. */ -export function peqTotalCurveDb( +function peqTotalCurveDb( freq: number, bands: Record, sampleRate: number = DEFAULT_SAMPLE_RATE ): number { let sum = 0; + for (const band of Object.values(PEQBand)) { sum += peqBandResponseDb(freq, bands[band], sampleRate); } + return sum; } -export type PeqCurveOptions = { - bands: Record; - xMin: number; - xMax: number; - yMin: number; - yMax: number; - steps?: number; - colors?: GeqCurveColors; // reuse from GEQ module - lineWidth?: number; - dimmed?: boolean; - sampleRate?: number; -}; - -export function buildPeqCurveDraw({ +/** + * Computes PEQ magnitude points over a caller-supplied frequency grid. + * Pass the same `freqs` array you use for GEQ/crossover to keep every + * curve on a shared x-grid (required for `sumCurvePoints`). + */ +export function getPeqCurvePoints({ + freqs, bands, - xMin, - xMax, - yMin, - yMax, - steps = 1000, - colors = DEFAULT_GEQ_CURVE_COLORS, - lineWidth = 2, - dimmed = false, sampleRate = DEFAULT_SAMPLE_RATE, -}: PeqCurveOptions) { - 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 = peqTotalCurveDb(freq, bands, sampleRate); - return toPx(freq, gain); - }); - - ctx.save(); - if (dimmed) ctx.globalAlpha = 0.25; - - 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(); - - 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]); - ctx.stroke(); - - ctx.restore(); - }; +}: { + freqs: number[]; + bands: Record; + sampleRate?: number; +}): CurvePoint[] { + return freqs.map((freq) => ({ + x: freq, + y: peqTotalCurveDb(freq, bands, sampleRate), + })); } // ----------- Phase Response ----------- @@ -307,37 +243,12 @@ function highShelfPhaseDeg( return biquadPhaseDeg(freq, fs, coeffs); } -function lp6PhaseDeg(freq: number, f0: number): number { - return -Math.atan(freq / f0) * (180 / Math.PI); -} - -function hp6PhaseDeg(freq: number, f0: number): number { - return Math.atan(f0 / freq) * (180 / Math.PI) - 90; -} - -function lp12PhaseDeg(freq: number, f0: number, q: number): number { - const x = freq / f0; - const re = 1 - x * x; - const im = x / q; - - return -Math.atan2(im, re) * (180 / Math.PI); -} - -function hp12PhaseDeg(freq: number, f0: number, q: number): number { - const x = freq / f0; - const re = 1 - x * x; - const im = x / q; - - return (Math.PI - Math.atan2(im, re)) * (180 / Math.PI); -} - function allPass1PhaseDeg(freq: number, f0: number): number { return 180 - (2 * Math.atan(freq / f0) * 180) / Math.PI; } function allPass2PhaseDeg(freq: number, f0: number, q: number, fs: number): number { const w0 = (2 * Math.PI * f0) / fs; - // const w = (2 * Math.PI * freq) / fs; const cosw0 = Math.cos(w0); const alpha = Math.sin(w0) / (2 * q); @@ -394,7 +305,7 @@ function peqBandPhaseDeg( } } -export function peqTotalPhaseDeg( +function peqTotalPhaseDeg( freq: number, bands: Record, sampleRate: number = DEFAULT_SAMPLE_RATE @@ -417,8 +328,13 @@ function unwrapPhaseDeg(phases: number[]): number[] { let phase = phases[i]; const previous = result[i - 1]; - while (phase - previous > 180) phase -= 360; - while (phase - previous < -180) phase += 360; + while (phase - previous > 180) { + phase -= 360; + } + + while (phase - previous < -180) { + phase += 360; + } result.push(phase); } @@ -426,142 +342,103 @@ function unwrapPhaseDeg(phases: number[]): number[] { return result; } -export type PeqPhaseCurveOptions = { - bands: Record; - xMin: number; - xMax: number; - yMin: number; - yMax: number; - steps?: number; - colors?: GeqCurveColors; - lineWidth?: number; - dimmed?: boolean; - sampleRate?: number; -}; +// export function getPeqPhaseSegments({ +// freqs, +// bands, +// sampleRate = DEFAULT_SAMPLE_RATE, +// }: { +// freqs: number[]; +// bands: Record; +// sampleRate?: number; +// }): CurvePoint[][] { +// if (freqs.length === 0) return []; -export function buildPeqPhaseCurveDraw({ +// const rawPhases = freqs.map((freq) => peqTotalPhaseDeg(freq, bands, sampleRate)); +// const phases = unwrapPhaseDeg(rawPhases); + +// const wrapToDisplay = (p: number) => ((((p + 180) % 360) + 360) % 360) - 180; + +// const segments: CurvePoint[][] = [[]]; + +// const pushPoint = (x: number, y: number) => { +// segments[segments.length - 1].push({ x, y }); +// }; + +// let previousFreq = freqs[0]; +// let previousPhase = phases[0]; + +// pushPoint(previousFreq, wrapToDisplay(previousPhase)); + +// for (let i = 1; i < freqs.length; i++) { +// const freq = freqs[i]; +// const phase = phases[i]; + +// const logPrev = Math.log10(previousFreq); +// const logCurr = Math.log10(freq); + +// const direction = phase >= previousPhase ? 1 : -1; + +// const boundaries: number[] = []; + +// if (direction > 0) { +// let b = Math.floor((previousPhase - 180) / 360) * 360 + 180; + +// while (b <= previousPhase) { +// b += 360; +// } + +// while (b < phase) { +// boundaries.push(b); +// b += 360; +// } +// } else { +// let b = Math.ceil((previousPhase - 180) / 360) * 360 + 180; + +// while (b >= previousPhase) { +// b -= 360; +// } + +// while (b > phase) { +// boundaries.push(b); +// b -= 360; +// } +// } + +// for (const boundary of boundaries) { +// const t = (boundary - previousPhase) / (phase - previousPhase); +// const crossingFreq = Math.pow(10, logPrev + t * (logCurr - logPrev)); + +// const approach = direction > 0 ? 180 : -180; +// const depart = direction > 0 ? -180 : 180; + +// // End current segment at ±180°. +// pushPoint(crossingFreq, approach); + +// // Start a new segment at the opposite side of the display. +// segments.push([]); +// pushPoint(crossingFreq, depart); +// } + +// pushPoint(freq, wrapToDisplay(phase)); + +// previousFreq = freq; +// previousPhase = phase; +// } + +// return segments.filter((segment) => segment.length >= 2); +// } + +export function getPeqPhasePoints({ + freqs, bands, - xMin, - xMax, - yMin, - yMax, - steps = 3000, - colors = DEFAULT_GEQ_CURVE_COLORS, - lineWidth = 2, - dimmed = false, sampleRate = DEFAULT_SAMPLE_RATE, -}: PeqPhaseCurveOptions) { - return ({ - ctx, - toPx, - }: { - ctx: CanvasRenderingContext2D; - toPx: (x: number, y: number) => [number, number]; - }) => { - const freqs = logSpace(xMin, xMax, steps); - const rawPhases = freqs.map((freq) => peqTotalPhaseDeg(freq, bands, sampleRate)); - const phases = unwrapPhaseDeg(rawPhases); - - const wrapToDisplay = (p: number) => ((((p + 180) % 360) + 360) % 360) - 180; - - ctx.save(); - if (dimmed) { - ctx.globalAlpha = 0.25; - ctx.setLineDash([6, 4]); - } - - // Collect segments as arrays of {freq, display} points, split at - // ±180° boundary crossings — same crossing math as before. - let segments: { freq: number; display: number }[][] = [[]]; - const pushPoint = (freq: number, display: number) => { - segments[segments.length - 1].push({ freq, display }); - }; - - let previousFreq = freqs[0]; - let previousPhase = phases[0]; - pushPoint(previousFreq, wrapToDisplay(previousPhase)); - - for (let i = 1; i < freqs.length; i++) { - const freq = freqs[i]; - const phase = phases[i]; - const logPrev = Math.log10(previousFreq); - const logCurr = Math.log10(freq); - const direction = phase >= previousPhase ? 1 : -1; - - const boundaries: number[] = []; - if (direction > 0) { - let b = Math.floor((previousPhase - 180) / 360) * 360 + 180; - while (b <= previousPhase) b += 360; - while (b < phase) { - boundaries.push(b); - b += 360; - } - } else { - let b = Math.ceil((previousPhase - 180) / 360) * 360 + 180; - while (b >= previousPhase) b -= 360; - while (b > phase) { - boundaries.push(b); - b -= 360; - } - } - - for (const boundary of boundaries) { - const t = (boundary - previousPhase) / (phase - previousPhase); - const crossingFreq = Math.pow(10, logPrev + t * (logCurr - logPrev)); - const approach = direction > 0 ? 180 : -180; - const depart = direction > 0 ? -180 : 180; - - pushPoint(crossingFreq, approach); // end current segment - segments.push([]); // start new segment - pushPoint(crossingFreq, depart); - } - - pushPoint(freq, wrapToDisplay(phase)); - previousFreq = freq; - previousPhase = phase; - } - - // --- Fill each segment against the 0° line --- - for (const seg of segments) { - if (seg.length < 2) continue; - - ctx.beginPath(); - seg.forEach((p, i) => { - const [x, y] = toPx(p.freq, p.display); - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - }); - const [xEndPx, zeroYPx] = toPx(seg[seg.length - 1].freq, 0); - const [xStartPx] = toPx(seg[0].freq, 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 each segment --- - for (const seg of segments) { - if (seg.length < 2) continue; - ctx.beginPath(); - seg.forEach((p, i) => { - const [x, y] = toPx(p.freq, p.display); - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - }); - ctx.strokeStyle = colors.strokeColor; - ctx.lineWidth = lineWidth; - ctx.stroke(); - } - - ctx.restore(); - }; -} +}: { + freqs: number[]; + bands: Record; + sampleRate?: number; +}): CurvePoint[] { + return freqs.map((freq) => ({ + x: freq, + y: peqTotalPhaseDeg(freq, bands, sampleRate), + })); +} \ No newline at end of file diff --git a/src/features/dsp408/utils/totalCurve.ts b/src/features/dsp408/utils/totalCurve.ts deleted file mode 100644 index 6b5aede..0000000 --- a/src/features/dsp408/utils/totalCurve.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { PEQ, PEQBand } from '../types'; -import { GeqCurveColors, DEFAULT_GEQ_CURVE_COLORS, logSpace } from './geqCurve'; -import { peqTotalCurveDb, peqTotalPhaseDeg } from './peqCurve'; -import { CrossoverSpec, crossoverPairMagnitudeDb, crossoverPairPhaseDeg } from './crossoverCurve'; -import { DEFAULT_SAMPLE_RATE } from './biquad'; - -export type TotalCurveOptions = { - bands: Record; - highPass?: CrossoverSpec; - lowPass?: CrossoverSpec; - xMin: number; - xMax: number; - yMin: number; - yMax: number; - steps?: number; - colors?: GeqCurveColors; - lineWidth?: number; - dimmed?: boolean; - sampleRate?: number; -}; - -export type TotalPhaseCurveOptions = { - bands: Record; - highPass?: CrossoverSpec; - lowPass?: CrossoverSpec; - xMin: number; - xMax: number; - yMin: number; - yMax: number; - steps?: number; - colors?: GeqCurveColors; - lineWidth?: number; - dimmed?: boolean; - sampleRate?: number; -}; - -function totalMagnitudeDb( - freq: number, - bands: Record, - highPass: CrossoverSpec | undefined, - lowPass: CrossoverSpec | undefined, - sampleRate: number -): number { - return ( - peqTotalCurveDb(freq, bands, sampleRate) + crossoverPairMagnitudeDb(highPass, lowPass, freq) - ); -} - -function totalPhaseDeg( - freq: number, - bands: Record, - highPass: CrossoverSpec | undefined, - lowPass: CrossoverSpec | undefined, - sampleRate: number -): number { - return peqTotalPhaseDeg(freq, bands, sampleRate) + crossoverPairPhaseDeg(highPass, lowPass, freq); -} - -function unwrapPhaseDeg(phases: number[]): number[] { - if (phases.length === 0) return []; - const result = [phases[0]]; - for (let i = 1; i < phases.length; i++) { - let phase = phases[i]; - const previous = result[i - 1]; - while (phase - previous > 180) phase -= 360; - while (phase - previous < -180) phase += 360; - result.push(phase); - } - return result; -} - -export function buildTotalCurveDraw({ - bands, - highPass, - lowPass, - xMin, - xMax, - yMin, - yMax, - steps = 1000, - colors = DEFAULT_GEQ_CURVE_COLORS, - lineWidth = 2, - dimmed = false, - sampleRate = DEFAULT_SAMPLE_RATE, -}: TotalCurveOptions) { - return ({ - ctx, - toPx, - }: { - ctx: CanvasRenderingContext2D; - toPx: (x: number, y: number) => [number, number]; - }) => { - const freqs = logSpace(xMin, xMax, steps); - const points = freqs.map((freq) => - toPx(freq, totalMagnitudeDb(freq, bands, highPass, lowPass, sampleRate)) - ); - - ctx.save(); - if (dimmed) ctx.globalAlpha = 0.25; - - ctx.beginPath(); - points.forEach(([xPx, yPx], i) => (i === 0 ? ctx.moveTo(xPx, yPx) : 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(); - - ctx.beginPath(); - points.forEach(([xPx, yPx], i) => (i === 0 ? ctx.moveTo(xPx, yPx) : ctx.lineTo(xPx, yPx))); - ctx.strokeStyle = colors.strokeColor; - ctx.lineWidth = lineWidth; - if (dimmed) ctx.setLineDash([6, 4]); - ctx.stroke(); - - ctx.restore(); - }; -} - -export function buildTotalPhaseCurveDraw({ - bands, - highPass, - lowPass, - xMin, - xMax, - yMin, - yMax, - steps = 3000, - colors = DEFAULT_GEQ_CURVE_COLORS, - lineWidth = 2, - dimmed = false, - sampleRate = DEFAULT_SAMPLE_RATE, -}: TotalPhaseCurveOptions) { - return ({ - ctx, - toPx, - }: { - ctx: CanvasRenderingContext2D; - toPx: (x: number, y: number) => [number, number]; - }) => { - const freqs = logSpace(xMin, xMax, steps); - const rawPhases = freqs.map((freq) => - totalPhaseDeg(freq, bands, highPass, lowPass, sampleRate) - ); - const phases = unwrapPhaseDeg(rawPhases); - - const wrapToDisplay = (p: number) => ((((p + 180) % 360) + 360) % 360) - 180; - - ctx.save(); - if (dimmed) { - ctx.globalAlpha = 0.25; - ctx.setLineDash([6, 4]); - } - - let segments: { freq: number; display: number }[][] = [[]]; - const pushPoint = (freq: number, display: number) => { - segments[segments.length - 1].push({ freq, display }); - }; - - let previousFreq = freqs[0]; - let previousPhase = phases[0]; - pushPoint(previousFreq, wrapToDisplay(previousPhase)); - - for (let i = 1; i < freqs.length; i++) { - const freq = freqs[i]; - const phase = phases[i]; - const logPrev = Math.log10(previousFreq); - const logCurr = Math.log10(freq); - const direction = phase >= previousPhase ? 1 : -1; - - const boundaries: number[] = []; - if (direction > 0) { - let b = Math.floor((previousPhase - 180) / 360) * 360 + 180; - while (b <= previousPhase) b += 360; - while (b < phase) { - boundaries.push(b); - b += 360; - } - } else { - let b = Math.ceil((previousPhase - 180) / 360) * 360 + 180; - while (b >= previousPhase) b -= 360; - while (b > phase) { - boundaries.push(b); - b -= 360; - } - } - - for (const boundary of boundaries) { - const t = (boundary - previousPhase) / (phase - previousPhase); - const crossingFreq = Math.pow(10, logPrev + t * (logCurr - logPrev)); - const approach = direction > 0 ? 180 : -180; - const depart = direction > 0 ? -180 : 180; - - pushPoint(crossingFreq, approach); - segments.push([]); - pushPoint(crossingFreq, depart); - } - - pushPoint(freq, wrapToDisplay(phase)); - previousFreq = freq; - previousPhase = phase; - } - - for (const seg of segments) { - if (seg.length < 2) continue; - ctx.beginPath(); - seg.forEach((p, i) => { - const [x, y] = toPx(p.freq, p.display); - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - }); - const [xEndPx, zeroYPx] = toPx(seg[seg.length - 1].freq, 0); - const [xStartPx] = toPx(seg[0].freq, 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(); - } - - for (const seg of segments) { - if (seg.length < 2) continue; - ctx.beginPath(); - seg.forEach((p, i) => { - const [x, y] = toPx(p.freq, p.display); - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - }); - ctx.strokeStyle = colors.strokeColor; - ctx.lineWidth = lineWidth; - ctx.stroke(); - } - - ctx.restore(); - }; -}