feat: add demo mode

This commit is contained in:
2026-08-11 22:02:46 +02:00
parent b572e1c794
commit d91bcaa20f
28 changed files with 3904 additions and 1876 deletions
@@ -24,6 +24,8 @@ export interface LinearGraphHandle {
getCanvas: () => HTMLCanvasElement | null;
}
export type AxisScale = 'linear' | 'log';
export interface LinearGraphProps {
xMin: number;
xMax: number;
@@ -31,6 +33,10 @@ export interface LinearGraphProps {
yMin: number;
yMax: number;
yStep?: number;
/** 'log' requires xMin > 0. Ticks are drawn at 1/2/5-per-decade mantissas, with decade (power-of-10) lines highlighted. */
xScale?: AxisScale;
/** 'log' requires yMin > 0. */
yScale?: AxisScale;
draw?: DrawFn;
width?: number;
height?: number;
@@ -72,6 +78,37 @@ function range(min: number, max: number, step: number): number[] {
return out;
}
interface Tick {
value: number;
/** Decade lines (powers of 10) get major=true for a bolder/brighter grid line. */
major: boolean;
}
// Linear ticks: every step is equally weighted, so mark every Nth (default 5th) as "major"
// purely for label emphasis — grid highlighting logic only really matters for log mode.
function linearTicks(min: number, max: number, step: number): Tick[] {
return range(min, max, step).map((value) => ({ value, major: false }));
}
// Log ticks: standard 1/2/3/4/5/6/7/8/9 mantissas within each decade between min and max.
// Mantissa === 1 (i.e. the decade itself: ...0.1, 1, 10, 100, 1000...) is flagged major
// so it can be drawn as a highlighted line distinct from the minor in-decade ticks.
function logTicks(min: number, max: number): Tick[] {
if (min <= 0 || max <= 0 || max <= min) return [];
const startDecade = Math.floor(Math.log10(min));
const endDecade = Math.ceil(Math.log10(max));
const out: Tick[] = [];
for (let d = startDecade; d <= endDecade; d++) {
const decadeValue = Math.pow(10, d);
for (const mantissa of [1, 2, 3, 4, 5, 6, 7, 8, 9]) {
const value = decadeValue * mantissa;
if (value < min - 1e-9 || value > max + 1e-9) continue;
out.push({ value, major: mantissa === 1 });
}
}
return out;
}
export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(function LinearGraph(
{
xMin,
@@ -80,6 +117,8 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
yMin,
yMax,
yStep,
xScale = 'linear',
yScale = 'linear',
draw,
width,
height,
@@ -138,6 +177,18 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
const effXStep = xStep ?? niceStep(xMin, xMax);
const effYStep = yStep ?? niceStep(yMin, yMax);
const xLogValid = xScale === 'log' && xMin > 0 && xMax > xMin;
const yLogValid = yScale === 'log' && yMin > 0 && yMax > yMin;
const xTicks = useMemo(
() => (xLogValid ? logTicks(xMin, xMax) : linearTicks(xMin, xMax, effXStep)),
[xLogValid, xMin, xMax, effXStep]
);
const yTicks = useMemo(
() => (yLogValid ? logTicks(yMin, yMax) : linearTicks(yMin, yMax, effYStep)),
[yLogValid, yMin, yMax, effYStep]
);
useEffect(() => {
if (width && height) return;
const el = containerRef.current;
@@ -176,17 +227,41 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
height: Math.max(1, cssH - pad.top - pad.bottom),
};
const toPxX = (x: number) => plot.x + ((x - xMin) / (xMax - xMin || 1)) * plot.width;
const toPxY = (y: number) =>
plot.y + plot.height - ((y - yMin) / (yMax - yMin || 1)) * plot.height;
const logXMin = xLogValid ? Math.log10(xMin) : 0;
const logXMax = xLogValid ? Math.log10(xMax) : 0;
const logYMin = yLogValid ? Math.log10(yMin) : 0;
const logYMax = yLogValid ? Math.log10(yMax) : 0;
const toPxX = (x: number) => {
if (xLogValid) {
const lx = Math.log10(Math.max(x, 1e-9));
return plot.x + ((lx - logXMin) / (logXMax - logXMin || 1)) * plot.width;
}
return plot.x + ((x - xMin) / (xMax - xMin || 1)) * plot.width;
};
const toPxY = (y: number) => {
if (yLogValid) {
const ly = Math.log10(Math.max(y, 1e-9));
return plot.y + plot.height - ((ly - logYMin) / (logYMax - logYMin || 1)) * plot.height;
}
return plot.y + plot.height - ((y - yMin) / (yMax - yMin || 1)) * plot.height;
};
const toPx = (x: number, y: number): [number, number] => [toPxX(x), toPxY(y)];
const toData = (px: number, py: number): [number, number] => [
xMin + ((px - plot.x) / plot.width) * (xMax - xMin),
yMin + (1 - (py - plot.y) / plot.height) * (yMax - yMin),
];
const toData = (px: number, py: number): [number, number] => {
const xRatio = (px - plot.x) / plot.width;
const yRatio = 1 - (py - plot.y) / plot.height;
const x = xLogValid
? Math.pow(10, logXMin + xRatio * (logXMax - logXMin))
: xMin + xRatio * (xMax - xMin);
const y = yLogValid
? Math.pow(10, logYMin + yRatio * (logYMax - logYMin))
: yMin + yRatio * (yMax - yMin);
return [x, y];
};
const css = getComputedStyle(canvas);
const gridColor = css.getPropertyValue('--graph-grid').trim() || '#2a2f3a';
const gridMajorColor = css.getPropertyValue('--graph-grid-major').trim() || '#4b5563';
const axisColor = css.getPropertyValue('--graph-axis').trim() || '#6b7280';
const textColor = css.getPropertyValue('--graph-text').trim() || '#9ca3af';
const bgColor = css.getPropertyValue('--graph-bg').trim();
@@ -217,32 +292,55 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
);
if (showGrid) {
// minor grid lines first (thin), then major/decade lines drawn on top so
// they aren't obscured and read clearly as the "multiples of 10" markers.
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.beginPath();
for (const gx of range(xMin, xMax, effXStep)) {
const px = Math.round(toPxX(gx)) + 0.5;
for (const t of xTicks) {
if (t.major) continue;
const px = Math.round(toPxX(t.value)) + 0.5;
ctx.moveTo(px, plot.y);
ctx.lineTo(px, plot.y + plot.height);
}
for (const gy of range(yMin, yMax, effYStep)) {
const py = Math.round(toPxY(gy)) + 0.5;
for (const t of yTicks) {
if (t.major) continue;
const py = Math.round(toPxY(t.value)) + 0.5;
ctx.moveTo(plot.x, py);
ctx.lineTo(plot.x + plot.width, py);
}
ctx.stroke();
const majorX = xTicks.filter((t) => t.major);
const majorY = yTicks.filter((t) => t.major);
if (majorX.length || majorY.length) {
ctx.strokeStyle = gridMajorColor;
ctx.lineWidth = Math.max(1, metrics.axisLineWidth * 0.6);
ctx.beginPath();
for (const t of majorX) {
const px = Math.round(toPxX(t.value)) + 0.5;
ctx.moveTo(px, plot.y);
ctx.lineTo(px, plot.y + plot.height);
}
for (const t of majorY) {
const py = Math.round(toPxY(t.value)) + 0.5;
ctx.moveTo(plot.x, py);
ctx.lineTo(plot.x + plot.width, py);
}
ctx.stroke();
}
}
if (showAxes) {
ctx.strokeStyle = axisColor;
ctx.lineWidth = metrics.axisLineWidth;
ctx.beginPath();
if (yMin <= 0 && yMax >= 0) {
if (!yLogValid && yMin <= 0 && yMax >= 0) {
const py = Math.round(toPxY(0)) + 0.5;
ctx.moveTo(plot.x, py);
ctx.lineTo(plot.x + plot.width, py);
}
if (xMin <= 0 && xMax >= 0) {
if (!xLogValid && xMin <= 0 && xMax >= 0) {
const px = Math.round(toPxX(0)) + 0.5;
ctx.moveTo(px, plot.y);
ctx.lineTo(px, plot.y + plot.height);
@@ -264,29 +362,34 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
);
if (showTicks) {
ctx.fillStyle = textColor;
ctx.font = `${metrics.tickFont}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
for (const gx of range(xMin, xMax, effXStep)) {
const px = toPxX(gx);
for (const t of xTicks) {
// In log mode, only label the decade (major) ticks — labeling every
// 2,3,4...9 mantissa gets unreadably dense.
if (xLogValid && !t.major) continue;
const px = toPxX(t.value);
ctx.beginPath();
ctx.moveTo(px, plot.y + plot.height);
ctx.lineTo(px, plot.y + plot.height + metrics.tickLen);
ctx.strokeStyle = axisColor;
ctx.stroke();
ctx.fillText(formatX(gx), px, plot.y + plot.height + metrics.tickLen + 2);
ctx.fillStyle = t.major && xLogValid ? textColor : textColor;
ctx.fillText(formatX(t.value), px, plot.y + plot.height + metrics.tickLen + 2);
}
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (const gy of range(yMin, yMax, effYStep)) {
const py = toPxY(gy);
for (const t of yTicks) {
if (yLogValid && !t.major) continue;
const py = toPxY(t.value);
ctx.beginPath();
ctx.moveTo(plot.x - metrics.tickLen, py);
ctx.lineTo(plot.x, py);
ctx.strokeStyle = axisColor;
ctx.stroke();
ctx.fillText(formatY(gy), plot.x - metrics.tickLen - 2, py);
ctx.fillStyle = textColor;
ctx.fillText(formatY(t.value), plot.x - metrics.tickLen - 2, py);
}
}
@@ -314,10 +417,12 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
metrics,
xMin,
xMax,
effXStep,
xLogValid,
yMin,
yMax,
effYStep,
yLogValid,
xTicks,
yTicks,
showGrid,
showAxes,
showTicks,
@@ -1,10 +1,10 @@
// CompressorSection.tsx
import VerticalSlider from '../VerticalSlider/VerticalSlider';
import { OutputChannel, Ratio } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { OutputChannel, Ratio } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import Button from '../../Button/Button';
import ChannelGroup from '../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../LinearGraph/LinearGraph';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import MeterBar from '../../MeterBar/MeterBar';
import { useMemo, useState, useEffect } from 'react';
import { TriangleUpIcon, TriangleDownIcon } from '../../../assets/icons/Triangle';
@@ -1,9 +1,9 @@
import VerticalSlider from '../VerticalSlider/VerticalSlider';
import { InputChannel, Channel, OutputChannel, DelayUnit } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { InputChannel, Channel, OutputChannel, DelayUnit } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import Button from '../../Button/Button';
import ChannelGroup from '../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../LinearGraph/LinearGraph';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import { useMemo, useState } from 'react';
import './DelaySection.css';
@@ -1,9 +1,9 @@
import VerticalSlider from '../VerticalSlider/VerticalSlider';
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import Button from '../../Button/Button';
import ChannelGroup from '../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../LinearGraph/LinearGraph';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import './GainSection.css';
function GainPanel({
@@ -26,9 +26,9 @@ function GainPanel({
? dsp.state?.current_config.input_states[channel.Input]
: dsp.state?.current_config.output_states[channel.Output];
const muted = channelState?.mute ?? false;
const inverted = channelState?.phase_inverted ?? false;
const gain = channelState?.gain ?? 0;
const muted = channelState.mute;
const inverted = channelState.phase_inverted;
const gain = channelState.gain;
const toggleMute = async () => {
await setMute(channel, !muted);
@@ -122,11 +122,12 @@ function GainSection({
<div className="gain-graph-panel">
<div className="gain-graph-title">Frequency Response</div>
<LinearGraph
xMin={20}
xMax={20000}
yMin={-24}
yMax={24}
yStep={6}
xMin={10}
xMax={30000}
xScale="log"
yMin={-18}
yMax={18}
yStep={3}
xLabel="Hz"
yLabel="dB"
draw={({ ctx, toPx }) => {
@@ -1,9 +1,9 @@
// GateSection.tsx
import VerticalSlider from '../VerticalSlider/VerticalSlider';
import { InputChannel } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
import ChannelGroup from '../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../LinearGraph/LinearGraph';
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { InputChannel } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import MeterBar from '../../MeterBar/MeterBar';
import { useMemo, useState } from 'react';
import './GateSection.css';
@@ -1,9 +1,9 @@
import VerticalSlider from '../VerticalSlider/VerticalSlider';
import { InputChannel, DiscreteFrequency } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { InputChannel, DiscreteFrequency } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import Button from '../../Button/Button';
import ChannelGroup from '../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../LinearGraph/LinearGraph';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import { useMemo, useState } from 'react';
import './GeqSection.css';
@@ -1,15 +1,15 @@
// InputSection.tsx
import VerticalSlider from '../VerticalSlider/VerticalSlider';
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
// ASSUMPTION: an InputChannel enum exists alongside OutputChannel, mapping
// input pages 1-8 similarly (In1 -> 0, In2 -> 1, ...). Adjust import/values
// if the actual enum differs.
import { InputChannel, Channel, PEQBand, PEQFilter, PEQ, Meter } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
import { CrossoverFilter } from '../../../types/dsp408State';
import { LinearGraph } from '../LinearGraph/LinearGraph';
import { InputChannel, Channel, PEQBand, PEQFilter, PEQ, Meter } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import { CrossoverFilter } from '../../../types/dsp408';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import { useRef, useState } from 'react';
import './InputSection.css';
import ChannelGroup from '../ChannelGroup/ChannelGroup';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import Button from '../../Button/Button';
import MeterBar from '../../MeterBar/MeterBar';
@@ -1,9 +1,9 @@
// LimiterSection.tsx
import VerticalSlider from '../VerticalSlider/VerticalSlider';
import { OutputChannel } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
import ChannelGroup from '../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../LinearGraph/LinearGraph';
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { OutputChannel } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import MeterBar from '../../MeterBar/MeterBar';
import { useMemo, useState } from 'react';
import './LimiterSection.css';
@@ -1,7 +1,7 @@
// MatrixSection.tsx
import { Fragment, useRef, useState } from 'react';
import { InputChannel, OutputChannel } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
import { InputChannel, OutputChannel } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import './MatrixSection.css';
// ASSUMPTION: an InputChannel enum exists alongside OutputChannel in