Compare commits

...
5 Commits
Author SHA1 Message Date
LucasDLTG 81a99b7173 feat: update 2026-09-06 12:45:27 +02:00
LucasDLTG 75ac349790 refactor: graph to use a set of points instead of taking the whole dsp oobject 2026-08-31 20:14:07 +02:00
LucasDLTG 21728b5e6d fix: eslint errors 2026-08-31 14:12:36 +02:00
LucasDLTG d6d0215e09 feat: add peq foor input 2026-08-31 14:12:03 +02:00
LucasDLTG a0c9fbb674 feat: add behaviour to hp/lp filters 2026-08-13 11:32:06 +02:00
36 changed files with 5731 additions and 4614 deletions
+24 -24
View File
@@ -1,24 +1,24 @@
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea
.DS_Store .DS_Store
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
+6 -6
View File
@@ -1,7 +1,7 @@
{ {
"semi": true, "semi": true,
"singleQuote": true, "singleQuote": true,
"trailingComma": "es5", "trailingComma": "es5",
"printWidth": 100, "printWidth": 100,
"tabWidth": 2 "tabWidth": 2
} }
+3 -3
View File
@@ -1,3 +1,3 @@
{ {
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
} }
+10 -10
View File
@@ -1,11 +1,11 @@
# Tauri + React + Typescript # Tauri + React + Typescript
This template should help get you started developing with Tauri, React and Typescript in Vite. This template should help get you started developing with Tauri, React and Typescript in Vite.
## Recommended IDE Setup ## Recommended IDE Setup
- [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 - Check render to improve perfoormances
+14 -14
View File
@@ -1,14 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title> <title>Tauri + React + Typescript</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+3131 -3131
View File
File diff suppressed because it is too large Load Diff
+31 -31
View File
@@ -1,31 +1,31 @@
{ {
"name": "thomann-dsp-app", "name": "thomann-dsp-app",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css}\"", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css}\"" "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css}\""
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6", "eslint-plugin-prettier": "^5.5.6",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^7.0.4" "vite": "^7.0.4"
} }
} }
+7 -7
View File
@@ -1,7 +1,7 @@
# Generated by Cargo # Generated by Cargo
# will have compiled files and executables # will have compiled files and executables
/target/ /target/
# Generated by Tauri # Generated by Tauri
# will have schema files for capabilities auto-completion # will have schema files for capabilities auto-completion
/gen/schemas /gen/schemas
+3 -3
View File
@@ -1,3 +1,3 @@
fn main() { fn main() {
tauri_build::build() tauri_build::build()
} }
+10 -10
View File
@@ -1,10 +1,10 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default" "opener:default"
] ]
} }
+6 -6
View File
@@ -1,6 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!! // Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { fn main() {
thomann_dsp_app_lib::run() thomann_dsp_app_lib::run()
} }
+37 -37
View File
@@ -1,37 +1,37 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "thomann-dsp-app", "productName": "thomann-dsp-app",
"version": "0.1.0", "version": "0.1.0",
"identifier": "com.lucas.thomann-dsp-app", "identifier": "com.lucas.thomann-dsp-app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build", "beforeBuildCommand": "npm run build",
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "thomann-dsp-app", "title": "thomann-dsp-app",
"width": 1200, "width": 1600,
"height": 800, "height": 950,
"minWidth": 1200, "minWidth": 1200,
"minHeight": 600 "minHeight": 600
} }
], ],
"security": { "security": {
"csp": null "csp": null
} }
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
] ]
} }
} }
+102 -29
View File
@@ -10,28 +10,30 @@ type VerticalSliderProps = {
max: number; max: number;
step?: StepFn; step?: StepFn;
quantize?: QuantizeFn;
/** /**
* Optional hard snap function applied after every value change (drag, * Optional discrete value set (must be sorted ascending). When provided,
* keyboard, wheel, or typed input) — takes precedence over the built-in * this takes full precedence over `step`/`quantize`/`scale`: the slider
* step-based rounding. Use this when the underlying hardware/DSP only * only ever lands on entries from this array. `min`/`max` are used to
* accepts a fixed, non-uniformly-spaced set of values (e.g. a 0..N raw * slice out the applicable sub-range of `values` (e.g. per-filter-type
* index mapped through a log curve) and the slider must always land on * Q ranges that all share one master Q table) — both bounds should be
* one of those exact values rather than an interpolated approximation. * exact members of `values`. Thumb position and drag/keyboard/wheel
* See makeLogQuantize(). * navigation are index-based rather than value-based, since spacing in
* the array need not be uniform or log-uniform.
*/ */
quantize?: QuantizeFn; values?: readonly number[];
value: number; value: number;
onChange?: (value: number) => Promise<boolean>; onChange?: (value: number) => Promise<boolean>;
onInput?: (value: number) => void; onInput?: (value: number) => void;
unit?: string; unit?: string | ((value: number) => string);
formatValue?: (value: number, decimals: number) => string; formatValue?: (value: number, decimals: number) => string;
showTicks?: boolean; showTicks?: boolean;
/** 'linear' (default) or 'log'. Log scale requires min > 0 and max > 0. */ /** 'linear' (default) or 'log'. Log scale requires min > 0 and max > 0. Ignored when `values` is set. */
scale?: Scale; scale?: Scale;
className?: string; className?: string;
@@ -60,8 +62,36 @@ function safeLogBound(n: number) {
return n > 0 ? n : Number.EPSILON; return n > 0 ? n : Number.EPSILON;
} }
/** Maps a real value to a 0..1 position, honoring the chosen scale. */ /** Binary search for the index of the array entry closest to v. Assumes arr is sorted ascending and non-empty. */
function valueToPercent(v: number, min: number, max: number, scale: Scale) { function closestIndex(arr: readonly number[], v: number) {
let lo = 0;
let hi = arr.length - 1;
if (v <= arr[0]) return 0;
if (v >= arr[hi]) return hi;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (arr[mid] === v) return mid;
if (arr[mid] < v) lo = mid + 1;
else hi = mid;
}
const a = arr[lo - 1];
const b = arr[lo];
return v - a <= b - v ? lo - 1 : lo;
}
/** Maps a real value to a 0..1 position, honoring the chosen scale (or index position within `list`). */
function valueToPercent(
v: number,
min: number,
max: number,
scale: Scale,
list: readonly number[] | null
) {
if (list && list.length > 1) {
return closestIndex(list, v) / (list.length - 1);
}
if (scale === 'log') { if (scale === 'log') {
const logMin = Math.log(safeLogBound(min)); const logMin = Math.log(safeLogBound(min));
const logMax = Math.log(safeLogBound(max)); const logMax = Math.log(safeLogBound(max));
@@ -73,8 +103,18 @@ function valueToPercent(v: number, min: number, max: number, scale: Scale) {
return (v - min) / (max - min); return (v - min) / (max - min);
} }
/** Maps a 0..1 position back to a real value, honoring the chosen scale. */ /** Maps a 0..1 position back to a real value, honoring the chosen scale (or nearest index within `list`). */
function percentToValue(percent: number, min: number, max: number, scale: Scale) { function percentToValue(
percent: number,
min: number,
max: number,
scale: Scale,
list: readonly number[] | null
) {
if (list && list.length > 1) {
const idx = Math.round(percent * (list.length - 1));
return list[Math.min(list.length - 1, Math.max(0, idx))];
}
if (scale === 'log') { if (scale === 'log') {
const logMin = Math.log(safeLogBound(min)); const logMin = Math.log(safeLogBound(min));
const logMax = Math.log(safeLogBound(max)); const logMax = Math.log(safeLogBound(max));
@@ -126,6 +166,7 @@ export default function VerticalSlider({
max, max,
step = defaultStep, step = defaultStep,
quantize, quantize,
values,
value, value,
onChange, onChange,
onInput, onInput,
@@ -151,13 +192,21 @@ export default function VerticalSlider({
const wheelCommitTimeout = useRef<ReturnType<typeof setTimeout> | null>(null); const wheelCommitTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const [tickCount, setTickCount] = useState(MIN_TICKS); const [tickCount, setTickCount] = useState(MIN_TICKS);
if (scale === 'log' && (min <= 0 || max <= 0)) { if (scale === 'log' && !values && (min <= 0 || max <= 0)) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn( console.warn(
'VerticalSlider: scale="log" requires min > 0 and max > 0. Falling back to linear behavior for out-of-range bounds.' 'VerticalSlider: scale="log" requires min > 0 and max > 0. Falling back to linear behavior for out-of-range bounds.'
); );
} }
// Slice the discrete value set down to the applicable [min, max] sub-range.
// Falls back to the full array if the slice is empty (e.g. misconfigured bounds).
const listValues = useMemo(() => {
if (!values || values.length === 0) return null;
const filtered = values.filter((v) => v >= min && v <= max);
return filtered.length > 0 ? filtered : values;
}, [values, min, max]);
useEffect(() => { useEffect(() => {
if ( if (
dragging.current || dragging.current ||
@@ -191,16 +240,22 @@ export default function VerticalSlider({
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
const decimals = useMemo( const decimals = useMemo(() => {
() => Math.min(3, getDecimals(step(displayValue))), if (listValues) return Math.min(3, getDecimals(displayValue));
[step, displayValue] return Math.min(3, getDecimals(step(displayValue)));
); }, [listValues, step, displayValue]);
const formattedValue = (formatValue ?? formatValueDefault)(displayValue, decimals); const formattedValue = (formatValue ?? formatValueDefault)(displayValue, decimals);
const displayUnit = typeof unit === 'function' ? unit(displayValue) : unit;
function normalizeValue(input: number) { function normalizeValue(input: number) {
const clamped = Math.min(max, Math.max(min, input)); const clamped = Math.min(max, Math.max(min, input));
if (listValues) {
return listValues[closestIndex(listValues, clamped)];
}
if (quantize) { if (quantize) {
return Number(Math.min(max, Math.max(min, quantize(clamped))).toFixed(6)); return Number(Math.min(max, Math.max(min, quantize(clamped))).toFixed(6));
} }
@@ -230,13 +285,23 @@ export default function VerticalSlider({
// hasn't landed yet, `value` can still be stale, which made repeated // hasn't landed yet, `value` can still be stale, which made repeated
// key presses appear to snap back to the pre-press position. // key presses appear to snap back to the pre-press position.
const base = keyboardValue.current ?? displayValue; const base = keyboardValue.current ?? displayValue;
const currentStep = step(base);
let next = base; let next: number;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next += currentStep; if (listValues) {
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next -= currentStep; const idx = closestIndex(listValues, base);
let nextIdx = idx;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight')
nextIdx = Math.min(listValues.length - 1, idx + 1);
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') nextIdx = Math.max(0, idx - 1);
next = listValues[nextIdx];
} else {
const currentStep = step(base);
next = base;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next += currentStep;
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next -= currentStep;
next = normalizeValue(next);
}
next = normalizeValue(next);
keyboardValue.current = next; keyboardValue.current = next;
updateValue(next); updateValue(next);
} }
@@ -263,12 +328,20 @@ export default function VerticalSlider({
wheelEditing.current = true; wheelEditing.current = true;
const base = wheelValue.current ?? displayValue; const base = wheelValue.current ?? displayValue;
const currentStep = step(base);
// deltaY < 0 means scrolling up/away from the user — treat that as // deltaY < 0 means scrolling up/away from the user — treat that as
// increasing the value, matching the vertical slider's "up = more". // increasing the value, matching the vertical slider's "up = more".
const direction = e.deltaY < 0 ? 1 : -1; const direction = e.deltaY < 0 ? 1 : -1;
const next = normalizeValue(base + direction * currentStep); let next: number;
if (listValues) {
const idx = closestIndex(listValues, base);
const nextIdx = Math.min(listValues.length - 1, Math.max(0, idx + direction));
next = listValues[nextIdx];
} else {
const currentStep = step(base);
next = normalizeValue(base + direction * currentStep);
}
wheelValue.current = next; wheelValue.current = next;
updateValue(next); updateValue(next);
@@ -315,7 +388,7 @@ export default function VerticalSlider({
if (!sliderRef.current) return; if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect(); const rect = sliderRef.current.getBoundingClientRect();
const percent = 1 - (clientY - rect.top) / rect.height; const percent = 1 - (clientY - rect.top) / rect.height;
const raw = percentToValue(percent, min, max, scale); const raw = percentToValue(percent, min, max, scale, listValues);
updateValue(raw); updateValue(raw);
} }
@@ -376,7 +449,7 @@ export default function VerticalSlider({
setEditingText(null); setEditingText(null);
} }
const percent = valueToPercent(displayValue, min, max, scale); const percent = valueToPercent(displayValue, min, max, scale, listValues);
return ( return (
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}> <div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
@@ -434,7 +507,7 @@ export default function VerticalSlider({
} }
}} }}
/> />
{unit && <span className="value-unit">{unit}</span>} {displayUnit && <span className="value-unit">{' ' + displayUnit}</span>}
</div> </div>
</div> </div>
); );
-264
View File
@@ -1,264 +0,0 @@
.topbar {
display: flex;
align-items: center;
height: var(--header-height);
background: var(--bg-void);
border-bottom: 1px solid var(--border-hairline);
-webkit-app-region: drag;
padding-inline: var(--space-3);
gap: var(--space-3);
box-sizing: border-box;
}
.logo-slot {
display: flex;
height: 85%;
aspect-ratio: 1 / 1;
padding: 0;
border: none;
background: transparent;
overflow: hidden;
cursor: pointer;
-webkit-app-region: no-drag;
border-radius: var(--radius-pill);
}
.tabstrip {
display: flex;
height: var(--header-height);
flex: 1 1 auto;
overflow-x: auto;
overflow-y: hidden;
min-width: 0;
-webkit-app-region: no-drag;
border-left: 1px solid var(--border-hairline);
scrollbar-width: thin;
scrollbar-color: var(--border-hairline) transparent;
scroll-behavior: smooth;
scroll-padding-inline: var(--space-3);
scroll-snap-type: x proximity;
align-items: center;
gap: var(--space-4);
padding-left: var(--space-4);
container-type: inline-size;
container-name: tabstrip;
-webkit-mask-image: linear-gradient(to right, transparent, black 0);
mask-image: linear-gradient(to right, transparent, black 0);
}
.tabstrip-actions {
display: flex;
align-items: center;
justify-content: center;
height: var(--header-height);
flex: 0 0 auto;
gap: var(--space-3);
}
.tab-action {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
font: inherit;
background: transparent;
border: var(--border-width-active) solid transparent;
}
.tab-action:hover {
background: var(--bg-raised);
border-color: var(--accent-brand);
color: var(--text-primary);
}
.tab-add {
height: 70%;
border-radius: 50%;
color: var(--text-muted);
aspect-ratio: 1;
}
.crown-action {
height: 70%;
aspect-ratio: 1;
border-radius: 50%;
color: #facc15;
width: auto;
}
.crown-action:hover {
color: #fde047;
}
.icon-crown,
.icon-tab-add,
.icon-tab-add-empty {
aspect-ratio: 1/1;
height: 80%;
}
.tab-add-empty {
height: 70%;
border-radius: var(--radius-pill);
border: var(--border-width-active) dashed var(--border-hairline);
color: var(--text-muted);
font-family: var(--font-ui);
font-weight: var(--font-weight-medium);
flex-shrink: 0;
gap: var(--space-2);
padding-inline: var(--space-2);
}
.tab-add-empty:hover {
border-color: var(--accent-brand);
border-style: solid;
}
/* Tab */
.tab {
display: flex;
align-items: center;
gap: var(--space-3);
height: var(--tab-height);
padding: 0 var(--space-3);
border: var(--border-width-active) solid var(--border-hairline);
border-radius: var(--radius-lg);
background: transparent;
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--font-xs);
cursor: pointer;
box-sizing: border-box;
scroll-snap-align: start;
flex: 0 0 25%;
max-width: 25%;
min-width: calc(
(var(--space-3) * 2) + (var(--tab-height) * 0.5) + (var(--space-2) * 2) + 4ch +
var(--tab-close-size)
);
}
.tab:hover {
background: var(--bg-raised);
color: var(--text-primary);
border-color: var(--accent-brand);
}
.tab-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
}
.tab-led {
height: 50%;
aspect-ratio: 1 / 1;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
padding: 0;
}
.tab-close {
height: 50%;
aspect-ratio: 1 / 1;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: var(--radius-sm);
margin-left: auto;
padding: 0;
font-size: var(--tab-close-font);
color: var(--text-muted);
background: transparent;
}
.tab--active {
background: var(--bg-panel);
border-color: var(--accent-brand);
color: var(--text-primary);
}
.tab--dimmed {
opacity: 0.55;
}
.tab-close:hover {
background: var(--bg-panel);
color: var(--text-primary);
}
.tabstrip[data-fade-start='true'] {
-webkit-mask-image: linear-gradient(
to right,
transparent,
black 24px,
black calc(100% - 24px),
black
);
mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), black);
}
.tabstrip[data-fade-end='true'] {
-webkit-mask-image: linear-gradient(to right, black, black calc(100% - 24px), transparent);
mask-image: linear-gradient(to right, black, black calc(100% - 24px), transparent);
}
.tabstrip[data-fade-start='true'][data-fade-end='true'] {
-webkit-mask-image: linear-gradient(
to right,
transparent,
black 24px,
black calc(100% - 24px),
transparent
);
mask-image: linear-gradient(
to right,
transparent,
black 24px,
black calc(100% - 24px),
transparent
);
}
.tab[data-step='6'],
.tab[data-step='8'],
.tab[data-step='10'] {
min-width: calc(
(var(--space-3) * 2) + (var(--tab-height) * 0.5) + (var(--space-2) * 2) + 4ch +
var(--tab-close-size)
);
}
-222
View File
@@ -1,222 +0,0 @@
import { useEffect, useRef } from 'react';
import { DSP, AppPage } from '../../types';
import AnimatedLogo from '../../assets/icons/AnimatedLogo';
import { PlusIcon } from '../../assets/icons/PlusIcon';
import { CrownIcon } from '../../assets/icons/CrownIcon';
import Led from '../Led/Led';
import { CloseIcon } from '../../assets/icons/CloseIcon';
import './Topbar.css';
function getStep(count: number) {
if (count <= 4) return 4;
if (count <= 6) return 6;
if (count <= 8) return 8;
return 10;
}
function Tab({
dsp,
active,
dimmed,
step,
onSelect,
onRemove,
onConnect,
onDisconnect,
}: {
dsp: DSP;
active: boolean;
dimmed: boolean;
step: number;
onSelect: () => void;
onRemove: () => void;
onConnect: (dsp: DSP) => void;
onDisconnect: (dsp: DSP) => void;
}) {
const isConnected = dsp.status === 'connected';
const handleTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
}
};
return (
<div
className={['tab', active && 'tab--active', dimmed && 'tab--dimmed']
.filter(Boolean)
.join(' ')}
data-step={step}
tabIndex={0}
role="tab"
aria-selected={active}
onClick={onSelect}
onKeyDown={handleTabKeyDown}
>
<button
type="button"
className="tab-led"
aria-label={isConnected ? `Disconnect ${dsp.name}` : `Connect ${dsp.name}`}
onClick={(e) => {
e.stopPropagation();
if (isConnected) {
onDisconnect(dsp);
} else {
onConnect(dsp);
}
}}
>
<Led status={dsp.status} />
</button>
<span className="tab-name" title={dsp.name}>
{dsp.name}
</span>
<button
type="button"
className="tab-close"
aria-label={`Remove ${dsp.name}`}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<CloseIcon />
</button>
</div>
);
}
function TopBar({
dsps,
selected,
page,
setPage,
setSelect,
onSelect,
onRemove,
onAddClick,
onConnect,
onDisconnect,
}: {
dsps: DSP[];
selected: number | null;
page: AppPage;
setPage: (page: AppPage) => void;
setSelect: (select: number | null) => void;
onSelect: (id: number) => void;
onRemove: (dsp: DSP) => void;
onAddClick: () => void;
onConnect: (dsp: DSP) => void;
onDisconnect: (dsp: DSP) => void;
}) {
const hasTabs = dsps.length > 0;
const tabstripRef = useRef<HTMLDivElement>(null);
const prevCount = useRef(dsps.length);
const step = getStep(dsps.length);
const updateFade = () => {
const el = tabstripRef.current;
if (!el) return;
const atStart = el.scrollLeft <= 1;
const atEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1;
el.dataset.fadeStart = String(!atStart);
el.dataset.fadeEnd = String(!atEnd && el.scrollWidth > el.clientWidth);
};
useEffect(() => {
const el = tabstripRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
el.scrollLeft += e.deltaY;
e.preventDefault();
}
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
useEffect(() => {
updateFade();
const el = tabstripRef.current;
el?.addEventListener('scroll', updateFade);
window.addEventListener('resize', updateFade);
return () => {
el?.removeEventListener('scroll', updateFade);
window.removeEventListener('resize', updateFade);
};
}, [dsps.length]);
useEffect(() => {
if (dsps.length > prevCount.current && tabstripRef.current) {
tabstripRef.current.scrollTo({
left: tabstripRef.current.scrollWidth,
behavior: 'smooth',
});
}
prevCount.current = dsps.length;
}, [dsps.length]);
return (
<header className="topbar">
<button
className="logo-slot"
onClick={() => {
setSelect(null);
setPage('home');
}}
aria-label="Go to home"
>
<AnimatedLogo />
</button>
<div className="tabstrip" ref={tabstripRef}>
{dsps.map((dsp) => (
<Tab
key={dsp.id}
dsp={dsp}
step={step}
active={selected === dsp.id && !(page == 'add-dsp')}
dimmed={page === 'add-dsp'}
onSelect={() => onSelect(dsp.id)}
onRemove={() => onRemove(dsp)}
onConnect={onConnect}
onDisconnect={onDisconnect}
/>
))}
</div>
<div className="tabstrip-actions">
{hasTabs ? (
<button className="tab-add tab-action" aria-label="Add DSP" onClick={onAddClick}>
<PlusIcon className="icon-tab-add" />
</button>
) : (
<button className="tab-add-empty tab-action" onClick={onAddClick}>
<PlusIcon className="icon-tab-add-empty" />
<span>Add DSP</span>
</button>
)}
<button
className="crown-action tab-action"
title="Credits & Support"
aria-label="Credits & Support"
onClick={() => {
setPage('credits');
setSelect(null);
}}
>
<CrownIcon className="icon-crown" />
</button>
</div>
</header>
);
}
export default TopBar;
@@ -146,6 +146,42 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
/> />
); );
case 'inb':
return (
<InputSection
dsp={dsp}
channel={InputChannel.InB}
setLowPass={api.setLowPass}
setHighPass={api.setHighPass}
setPeq={api.setPeqBand}
setPeqBypass={api.setPEQBypass}
/>
);
case 'inc':
return (
<InputSection
dsp={dsp}
channel={InputChannel.InC}
setLowPass={api.setLowPass}
setHighPass={api.setHighPass}
setPeq={api.setPeqBand}
setPeqBypass={api.setPEQBypass}
/>
);
case 'ind':
return (
<InputSection
dsp={dsp}
channel={InputChannel.InD}
setLowPass={api.setLowPass}
setHighPass={api.setHighPass}
setPeq={api.setPeqBand}
setPeqBypass={api.setPEQBypass}
/>
);
case 'matrix': case 'matrix':
return ( return (
<MatrixSection <MatrixSection
@@ -6,7 +6,7 @@ import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph'; import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import './DelaySection.css'; import './DelaySection.css';
import { DELAY_RANGES } from '../../types'; import { DELAY_RANGES } from '../../constants';
const UNIT_LABEL: Record<DelayUnit, string> = { const UNIT_LABEL: Record<DelayUnit, string> = {
[DelayUnit.Millisecond]: 'ms', [DelayUnit.Millisecond]: 'ms',
@@ -103,3 +103,52 @@
flex: 1; flex: 1;
min-height: 0; 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;
}
@@ -3,10 +3,167 @@ import { InputChannel, Channel, OutputChannel } from '../../types';
import { DSP408 as DSP } from '../../../../types'; 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 './GainSection.css'; import './GainSection.css';
import { DiscreteFrequency } from '../../types'; import {
import { DISCRETE_FREQUENCIES } from '../../constants'; GEQ_BANDS_LABEL,
GRAPH_X_MAX,
GRAPH_X_MIN,
GRAPH_Y_MAX,
GRAPH_Y_MIN,
INPUT_ORDER,
OUTPUT_ORDER,
} from '../../constants';
import { useState } from 'react';
import LinearGraph from '../../../../components/LinearGraph/LinearGraph';
import { drawCurves } from '../../utils/graph/draw';
import { Curve, CurvePoint, sumCurvePoints } from '../../utils/graph/curve';
import { getGeqCurvePoints } from '../../utils/graph/geq';
import { getPeqCurvePoints } from '../../utils/graph/peq';
import { getCrossoverCurvePoints } from '../../utils/graph/crossover';
import { logSpace } from '../../utils/graph/curve';
const CHANNEL_COLORS = [
'#4ade80',
'#60a5fa',
'#f472b6',
'#fbbf24',
'#a78bfa',
'#34d399',
'#f87171',
'#38bdf8',
];
const GEQ_CURVE_Q = 5;
const Y_AXIS_STEP = 3; // must match the graph's yStep
function buildColorMap<T extends string>(channels: T[]): Record<T, string> {
const map = {} as Record<T, string>;
channels.forEach((ch, i) => {
map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
});
return map;
}
// ---- Curve construction -----------------------------------------------
type ChannelCurves = {
geq: CurvePoint[];
peqCrossover: CurvePoint[];
total: CurvePoint[];
};
function getGainCurvePoints(freqs: number[], gainDb: number): CurvePoint[] {
return freqs.map((x) => ({ x, y: gainDb }));
}
function getPeqCrossoverPoints(
freqs: number[],
peqChain: { bands: Record<string, unknown> },
crossover: {
high_pass: { frequency: number; slope: unknown };
low_pass: { frequency: number; slope: unknown };
}
) {
return sumCurvePoints([
getPeqCurvePoints({ freqs, bands: peqChain.bands as any }),
getCrossoverCurvePoints({
freqs,
highPass: { frequency: crossover.high_pass.frequency, filter_type: crossover.high_pass.slope as any },
lowPass: { frequency: crossover.low_pass.frequency, filter_type: crossover.low_pass.slope as any },
}),
]);
}
function getInputChannelCurves(
state: DSP['state']['current_config']['input_states'][InputChannel],
freqs: number[],
liveGain: number | undefined
): ChannelCurves {
const gain = liveGain ?? state.gain;
const geqValues = GEQ_BANDS_LABEL.map(({ frequency }) => state.geq.gains[frequency] ?? 0);
const geq = getGeqCurvePoints({ freqs, values: geqValues, q: GEQ_CURVE_Q });
const peqCrossover = getPeqCrossoverPoints(freqs, state.peq_chain, state.crossover);
const total = sumCurvePoints([geq, peqCrossover, getGainCurvePoints(freqs, gain)]);
return { geq, peqCrossover, total };
}
function getOutputChannelCurves(
state: DSP['state']['current_config']['output_states'][OutputChannel],
freqs: number[],
liveGain: number | undefined
): ChannelCurves {
const gain = liveGain ?? state.gain;
const peqCrossover = getPeqCrossoverPoints(freqs, state.peq_chain, state.crossover);
const total = sumCurvePoints([peqCrossover, getGainCurvePoints(freqs, gain)]);
return { geq: [], peqCrossover, total };
}
// fill:false everywhere — with multiple overlapping curves the fill/fade
// just muddies things; stroke-only makes each channel/stage legible.
function buildCurvesForChannels<T extends string>(
channels: T[],
selected: Set<T>,
colors: Record<T, string>,
showStages: boolean,
getCurves: (channel: T) => ChannelCurves
): Curve[] {
const curves: Curve[] = [];
channels.forEach((channel) => {
if (!selected.has(channel)) return;
const { geq, peqCrossover, total } = getCurves(channel);
const color = colors[channel];
if (showStages) {
if (geq.length > 0) {
curves.push({
points: geq,
style: { strokeColor: color, fill: false, dimmed: true, lineWidth: 1 },
});
}
if (peqCrossover.length > 0) {
curves.push({
points: peqCrossover,
style: { strokeColor: color, fill: false, dimmed: true, lineWidth: 1 },
});
}
}
curves.push({ points: total, style: { strokeColor: color, fill: false, lineWidth: 2 } });
});
return curves;
}
// Expands [baseMin, baseMax] outward to fit whatever the curves actually
// contain (e.g. gain + geq + peq stacking past the nominal graph range),
// snapped to the axis step with one step of headroom so nothing touches
// the plot edge. Never shrinks below the base range.
function computeYAxisBounds(
curves: Curve[],
baseMin: number,
baseMax: number,
step: number
): { yMin: number; yMax: number } {
let dataMax = baseMax;
let dataMin = baseMin;
for (const curve of curves) {
for (const { y } of curve.points) {
if (y > dataMax) dataMax = y;
if (y < dataMin) dataMin = y;
}
}
const yMax = Math.ceil((dataMax + step) / step) * step;
const yMin = Math.floor((dataMin - step) / step) * step;
return { yMin, yMax };
}
// ---- Panels -------------------------------------------------------------
function GainPanel({ function GainPanel({
dsp, dsp,
@@ -15,6 +172,7 @@ function GainPanel({
setGain, setGain,
setMute, setMute,
setInverse, setInverse,
onLiveGain,
}: { }: {
dsp: DSP; dsp: DSP;
channel: Channel; channel: Channel;
@@ -22,6 +180,7 @@ function GainPanel({
setGain: (channel: Channel, gain: number) => Promise<boolean>; setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>; setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>; setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
onLiveGain: (value: number | null) => void;
}) { }) {
const channelState = const channelState =
'Input' in channel 'Input' in channel
@@ -40,6 +199,18 @@ function GainPanel({
await setInverse(channel, !inverted); await setInverse(channel, !inverted);
}; };
const handleLiveValue = (value: number) => {
onLiveGain(value);
};
const commitGain = async (value: number) => {
const success = await setGain(channel, value);
if (success) {
onLiveGain(null);
}
return success;
};
return ( return (
<div className="gain-panel"> <div className="gain-panel">
<div className="gain-panel__title">{title}</div> <div className="gain-panel__title">{title}</div>
@@ -51,7 +222,8 @@ function GainPanel({
unit=" dB" unit=" dB"
value={gain} value={gain}
formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))} formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))}
onChange={(value) => setGain(channel, value)} onInput={handleLiveValue}
onChange={commitGain}
/> />
<Button <Button
@@ -90,6 +262,40 @@ function GainPanel({
); );
} }
function ChannelSelector<T extends string>({
channels,
labels,
colors,
selected,
onToggle,
}: {
channels: T[];
labels: Record<T, string>;
colors: Record<T, string>;
selected: Set<T>;
onToggle: (channel: T) => void;
}) {
return (
<div className="magnitude-selector">
{channels.map((channel) => {
const isOn = selected.has(channel);
return (
<button
key={channel}
type="button"
className={`magnitude-selector__chip${isOn ? ' active' : ''}`}
style={{ '--chip-color': colors[channel] } as React.CSSProperties}
onClick={() => onToggle(channel)}
>
<span className="magnitude-selector__swatch" />
{labels[channel]}
</button>
);
})}
</div>
);
}
function GainSection({ function GainSection({
dsp, dsp,
setGain, setGain,
@@ -101,105 +307,192 @@ function GainSection({
setMute: (channel: Channel, muted: boolean) => Promise<boolean>; setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>; setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
}) { }) {
const inputs: InputChannel[] = [ const inputColors = buildColorMap(INPUT_ORDER);
InputChannel.InA, const outputColors = buildColorMap(OUTPUT_ORDER);
InputChannel.InB,
InputChannel.InC,
InputChannel.InD,
];
const outputs: { channel: Channel; title: string }[] = [ const [selectedInputs, setSelectedInputs] = useState<Set<InputChannel>>(new Set(INPUT_ORDER));
{ channel: { Output: OutputChannel.Out1 }, title: 'Out 1' }, const [selectedOutputs, setSelectedOutputs] = useState<Set<OutputChannel>>(new Set(OUTPUT_ORDER));
{ 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 [showInputStages, setShowInputStages] = useState(false);
const [showOutputStages, setShowOutputStages] = useState(false);
const [liveInputGains, setLiveInputGains] = useState<Partial<Record<InputChannel, number>>>({});
const [liveOutputGains, setLiveOutputGains] = useState<Partial<Record<OutputChannel, number>>>({});
const setLiveInputGain = (channel: InputChannel, value: number | null) => {
setLiveInputGains((prev) => {
if (value === null) {
const next = { ...prev };
delete next[channel];
return next;
}
return { ...prev, [channel]: value };
});
};
const setLiveOutputGain = (channel: OutputChannel, value: number | null) => {
setLiveOutputGains((prev) => {
if (value === null) {
const next = { ...prev };
delete next[channel];
return next;
}
return { ...prev, [channel]: value };
});
};
const inputLabels: Record<InputChannel, string> = INPUT_ORDER.reduce(
(acc, channel) => ({
...acc,
[channel]: dsp.state.current_config.input_states[channel].name,
}),
{} as Record<InputChannel, string>
);
const outputLabels: Record<OutputChannel, string> = OUTPUT_ORDER.reduce(
(acc, channel) => ({
...acc,
[channel]: dsp.state.current_config.output_states[channel].name,
}),
{} as Record<OutputChannel, string>
);
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 freqs = logSpace(GRAPH_X_MIN, GRAPH_X_MAX, 1000);
const inputCurves = buildCurvesForChannels(
INPUT_ORDER,
selectedInputs,
inputColors,
showInputStages,
(channel) =>
getInputChannelCurves(dsp.state.current_config.input_states[channel], freqs, liveInputGains[channel])
);
const outputCurves = buildCurvesForChannels(
OUTPUT_ORDER,
selectedOutputs,
outputColors,
showOutputStages,
(channel) =>
getOutputChannelCurves(dsp.state.current_config.output_states[channel], freqs, liveOutputGains[channel])
);
const { yMin: inputYMin, yMax: inputYMax } = computeYAxisBounds(
inputCurves,
GRAPH_Y_MIN,
GRAPH_Y_MAX,
Y_AXIS_STEP
);
const { yMin: outputYMin, yMax: outputYMax } = computeYAxisBounds(
outputCurves,
GRAPH_Y_MIN,
GRAPH_Y_MAX,
Y_AXIS_STEP
);
return ( return (
<> <>
<div className="gain-linear-graph-section"> <div className="gain-linear-graph-section">
<div className="gain-graph-panel"> <div className="gain-graph-panel">
<div className="gain-graph-title">Frequency Response</div> <div className="gain-graph-title-row">
<div className="gain-graph-title">Input Response</div>
<Button
variant="toggle"
className={showInputStages ? 'active' : ''}
onClick={() => setShowInputStages((v) => !v)}
>
Show stages
</Button>
<ChannelSelector
channels={INPUT_ORDER}
labels={inputLabels}
colors={inputColors}
selected={selectedInputs}
onToggle={toggleInput}
/>
</div>
<LinearGraph <LinearGraph
xMin={10} xMin={GRAPH_X_MIN}
xMax={30000} xMax={GRAPH_X_MAX}
xScale="log" xScale="log"
yMin={-18} yMin={inputYMin}
yMax={18} yMax={inputYMax}
yStep={3} yStep={Y_AXIS_STEP}
xLabel="Hz" xLabel="Hz"
yLabel="dB" yLabel="dB"
showGrid={true}
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={({ ctx, toPx }) => { draw={({ ctx, toPx }) => {
const geq = currentChannel.geq; drawCurves({
ctx,
if (geq.bypass) { toPx,
return; xMin: GRAPH_X_MIN,
} xMax: GRAPH_X_MAX,
yMin: inputYMin,
const points = Object.entries(geq.gains) yMax: inputYMax,
.map(([frequency, gain]) => ({ curves: inputCurves,
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();
}} }}
/> />
</div> </div>
<div className="gain-graph-panel"> <div className="gain-graph-panel">
<div className="gain-graph-title">Phase Response</div> <div className="gain-graph-title-row">
<div className="gain-graph-title">Output Response</div>
<Button
variant="toggle"
className={showOutputStages ? 'active' : ''}
onClick={() => setShowOutputStages((v) => !v)}
>
Show stages
</Button>
<ChannelSelector
channels={OUTPUT_ORDER}
labels={outputLabels}
colors={outputColors}
selected={selectedOutputs}
onToggle={toggleOutput}
/>
</div>
<LinearGraph <LinearGraph
xMin={20} xMin={GRAPH_X_MIN}
xMax={20000} xMax={GRAPH_X_MAX}
yMin={-180} xScale="log"
yMax={180} yMin={outputYMin}
yStep={45} yMax={outputYMax}
yStep={Y_AXIS_STEP}
xLabel="Hz" xLabel="Hz"
yLabel="deg" yLabel="dB"
showGrid={true}
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={({ ctx, toPx }) => { draw={({ ctx, toPx }) => {
ctx.strokeStyle = '#4ade80'; drawCurves({
ctx.lineWidth = 2; ctx,
const [x0, y0] = toPx(20, 0); toPx,
const [x1, y1] = toPx(20000, 0); xMin: GRAPH_X_MIN,
ctx.beginPath(); xMax: GRAPH_X_MAX,
ctx.moveTo(x0, y0); yMin: outputYMin,
ctx.lineTo(x1, y1); yMax: outputYMax,
ctx.stroke(); curves: outputCurves,
});
}} }}
/> />
</div> </div>
@@ -208,7 +501,7 @@ function GainSection({
<div className="gain-section"> <div className="gain-section">
<div className="gain-panels"> <div className="gain-panels">
<ChannelGroup title="Input"> <ChannelGroup title="Input">
{inputs.map((channel) => ( {INPUT_ORDER.map((channel) => (
<GainPanel <GainPanel
key={channel} key={channel}
dsp={dsp} dsp={dsp}
@@ -217,20 +510,22 @@ function GainSection({
setGain={setGain} setGain={setGain}
setMute={setMute} setMute={setMute}
setInverse={setInverse} setInverse={setInverse}
onLiveGain={(value) => setLiveInputGain(channel, value)}
/> />
))} ))}
</ChannelGroup> </ChannelGroup>
<ChannelGroup title="Output"> <ChannelGroup title="Output">
{outputs.map(({ channel, title }) => ( {OUTPUT_ORDER.map((channel) => (
<GainPanel <GainPanel
key={title} key={channel}
dsp={dsp} dsp={dsp}
channel={channel} channel={{ Output: channel }}
title={title} title={dsp.state.current_config.output_states[channel].name}
setGain={setGain} setGain={setGain}
setMute={setMute} setMute={setMute}
setInverse={setInverse} setInverse={setInverse}
onLiveGain={(value) => setLiveOutputGain(channel, value)}
/> />
))} ))}
</ChannelGroup> </ChannelGroup>
@@ -240,4 +535,4 @@ function GainSection({
); );
} }
export default GainSection; export default GainSection;
@@ -4,21 +4,23 @@ 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 { GEQ_BAND_HZ, buildGeqCurveDraw } from '../../utils/geqCurve'; import { drawCurves } from '../../utils/graph/draw';
import { import {
INPUT_ORDER, INPUT_ORDER,
GRAPH_X_MIN, GRAPH_X_MIN,
GRAPH_X_MAX, GRAPH_X_MAX,
GRAPH_Y_MIN, GRAPH_Y_MIN,
GRAPH_Y_MAX, GRAPH_Y_MAX,
GRAPH_GEQ_CURVE_Q,
GEQ_GAIN_MIN, GEQ_GAIN_MIN,
GEQ_GAIN_MAX, GEQ_GAIN_MAX,
GEQ_GAIN_STEP, GEQ_GAIN_STEP,
GEQ_BANDS_LABEL, GEQ_BANDS_LABEL,
GEQ_CURVE_Q,
} from '../../constants'; } from '../../constants';
import { useState } from 'react'; import { useState } from 'react';
import './GeqSection.css'; import './GeqSection.css';
import { getGeqCurvePoints } from '../../utils/graph/geq';
import { logSpace } from '../../utils/graph/curve';
type LiveBands = Partial<Record<DiscreteFrequency, number>>; type LiveBands = Partial<Record<DiscreteFrequency, number>>;
@@ -83,6 +85,7 @@ function GeqGraph({
const values = GEQ_BANDS_LABEL.map( const values = GEQ_BANDS_LABEL.map(
({ frequency }) => liveBands?.[frequency] ?? geq.gains[frequency] ?? 0 ({ frequency }) => liveBands?.[frequency] ?? geq.gains[frequency] ?? 0
); );
const freqs = logSpace(GRAPH_X_MIN, GRAPH_X_MAX, 1000);
return ( return (
<div className="geq-graph-panel"> <div className="geq-graph-panel">
@@ -97,16 +100,23 @@ function GeqGraph({
xLabel="Band Hz" xLabel="Band Hz"
yLabel="dB" yLabel="dB"
showGrid={true} showGrid={true}
draw={buildGeqCurveDraw({ draw={({ ctx, toPx }) => {
values, drawCurves({
bandHz: GEQ_BAND_HZ, ctx,
q: GRAPH_GEQ_CURVE_Q * 0.5, toPx,
xMin: GRAPH_X_MIN,
xMax: GRAPH_X_MAX, xMin: GRAPH_X_MIN,
yMin: GRAPH_Y_MIN, xMax: GRAPH_X_MAX,
yMax: GRAPH_Y_MAX, yMin: GRAPH_Y_MIN,
dimmed: geq.bypass, yMax: GRAPH_Y_MAX,
})} curves: [
{
points: getGeqCurvePoints({ freqs, values, q: GEQ_CURVE_Q }),
style: { strokeColor: '#e5e7eb', dimmed: geq.bypass },
},
],
});
}}
/> />
{geq.bypass && ( {geq.bypass && (
<div className="geq-bypass-overlay"> <div className="geq-bypass-overlay">
@@ -208,7 +218,7 @@ function GeqSection({
}) { }) {
const [activeChannel, setActiveChannel] = useState<InputChannel>(INPUT_ORDER[0]); const [activeChannel, setActiveChannel] = useState<InputChannel>(INPUT_ORDER[0]);
const [liveGeq, setLiveGeq] = useState<Partial<Record<InputChannel, LiveBands>>>({}); const [liveGeq, setLiveGeq] = useState<Partial<Record<InputChannel, LiveBands>>>({});
const [resetting, setResetting] = useState(false); // NEW const [resetting, setResetting] = useState(false);
const handleLiveValue = (channel: InputChannel, band: DiscreteFrequency, value: number) => { const handleLiveValue = (channel: InputChannel, band: DiscreteFrequency, value: number) => {
setLiveGeq((prev) => ({ setLiveGeq((prev) => ({
@@ -231,7 +241,11 @@ function GeqSection({
return success; return success;
}; };
// NEW: sequential per-band reset, since hardware has no bulk reset command. const handleBypassClick = () => {
setGeqBypass(activeChannel, !activeGeq.bypass);
};
// Sequential per-band reset, since hardware has no bulk reset command.
const handleReset = async (channel: InputChannel) => { const handleReset = async (channel: InputChannel) => {
if (resetting) return; if (resetting) return;
setResetting(true); setResetting(true);
@@ -251,10 +265,6 @@ function GeqSection({
const activeGeq = activeInputState.geq; const activeGeq = activeInputState.geq;
const activeLiveBands = liveGeq[activeChannel]; const activeLiveBands = liveGeq[activeChannel];
const handleBypassClick = () => {
setGeqBypass(activeChannel, !activeGeq.bypass);
};
const channelOptions = INPUT_ORDER.map((channel) => ({ const channelOptions = INPUT_ORDER.map((channel) => ({
channel, channel,
label: dsp.state.current_config.input_states[channel].name, label: dsp.state.current_config.input_states[channel].name,
@@ -163,7 +163,7 @@
position: relative; position: relative;
height: 10; height: 10;
aspect-ratio: 0.75; aspect-ratio: 0.8;
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
@@ -228,7 +228,7 @@
.crossover-panel { .crossover-panel {
position: relative; position: relative;
height: 100%; height: 100%;
aspect-ratio: 0.28; aspect-ratio: 0.3;
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
@@ -361,3 +361,47 @@
box-sizing: border-box; box-sizing: border-box;
} }
.input-graph-panel__magnitude-wrap {
flex: 1 1 0;
min-height: 0;
min-width: 0;
position: relative;
}
.input-graph__title-row {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1cqh 1.5cqh;
}
.input-graph__title-row .input-graph__title {
padding: 0; /* was on the standalone title element; row now owns spacing */
}
.input-graph-view-toggle {
display: flex;
gap: 0.4cqh;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
padding: 0.3cqh;
background: var(--bg-void);
}
.input-graph-view-toggle__btn {
font-family: var(--font-ui);
font-size: 2cqh;
padding: 0.6cqh 1.2cqh;
background: transparent;
color: var(--text-muted);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.input-graph-view-toggle__btn.active {
background: var(--accent, #4dd0e1);
color: var(--bg-void);
}
@@ -3,171 +3,239 @@ import VerticalSlider, {
makeLogStep, makeLogStep,
makeLogQuantize, makeLogQuantize,
} from '../../../../components/VerticalSlider/VerticalSlider'; } from '../../../../components/VerticalSlider/VerticalSlider';
// ASSUMPTION: an InputChannel enum exists alongside OutputChannel, mapping import {
// input pages 1-8 similarly (In1 -> 0, In2 -> 1, ...). Adjust import/values CROSSOVER_FILTER_LABEL,
// if the actual enum differs. PEQ_BANDS_ORDER,
import { InputChannel, Channel, PEQBand, PEQFilter, PEQ, Meter } from '../../types'; PEQ_FILTERS_LABEL,
PEQ_Q_RANGES,
PEAK_Q_VALUES,
} from '../../constants';
import { InputChannel, Channel, PEQBand, PEQFilter, PEQ, Crossover } from '../../types';
import { DSP408 as DSP } from '../../../../types'; import { DSP408 as DSP } from '../../../../types';
import { CrossoverFilter } from '../../types'; import { CrossoverFilter } from '../../types';
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph'; import { LinearGraph, LinearGraphHandle } from '../../../../components/LinearGraph/LinearGraph';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import './InputSection.css'; import './InputSection.css';
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup'; import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import Button from '../../../../components/Button/Button'; import Button from '../../../../components/Button/Button';
import MeterBar from '../../../../components/MeterBar/MeterBar'; // import { buildTotalCurveDraw, buildTotalPhaseCurveDraw } from '../../utils/totalCurve';
import {
FREQUENCY_MAX,
FREQUENCY_MIN,
FREQUENCY_QUANTIZATION,
GRAPH_X_MAX,
GRAPH_X_MIN,
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';
const PEQ_BANDS: PEQBand[] = [ type GraphView = 'both' | 'magnitude' | 'phase';
PEQBand.B1,
PEQBand.B2,
PEQBand.B3,
PEQBand.B4,
PEQBand.B5,
PEQBand.B6,
PEQBand.B7,
PEQBand.B8,
];
// ASSUMPTION: adjust these to match the real PEQFilter enum in ../../types function GraphViewToggle({
const PEQ_FILTER_TYPE_OPTIONS: { value: PEQFilter; label: string }[] = [ view,
{ value: PEQFilter.Peak, label: 'Peak' }, onChange,
{ value: PEQFilter.LowShelf, label: 'Low Shelf' }, }: {
{ value: PEQFilter.HighShelf, label: 'High Shelf' }, view: GraphView;
]; onChange: (v: GraphView) => void;
}) {
const options: { value: GraphView; label: string }[] = [
{ value: 'both', label: 'Both' },
{ value: 'magnitude', label: 'Mag' },
{ value: 'phase', label: 'Phase' },
];
const FILTER_TYPE_OPTIONS: { value: CrossoverFilter; label: string }[] = [ return (
{ value: CrossoverFilter.Bw6, label: 'BW-6' }, <div className="input-graph-view-toggle">
{ value: CrossoverFilter.Bw12, label: 'BW-12' }, {options.map((opt) => (
{ value: CrossoverFilter.Bw18, label: 'BW-18' }, <button
{ value: CrossoverFilter.Bw24, label: 'BW-24' }, key={opt.value}
{ value: CrossoverFilter.Bw30, label: 'BW-30' }, type="button"
{ value: CrossoverFilter.Bw36, label: 'BW-36' }, className={`input-graph-view-toggle__btn${view === opt.value ? ' active' : ''}`}
{ value: CrossoverFilter.Bw42, label: 'BW-42' }, onClick={() => onChange(opt.value)}
{ value: CrossoverFilter.Bw48, label: 'BW-48' }, >
{ value: CrossoverFilter.Lk12, label: 'LR-12' }, {opt.label}
{ value: CrossoverFilter.Lk24, label: 'LR-24' }, </button>
{ value: CrossoverFilter.Lk36, label: 'LR-36' }, ))}
{ value: CrossoverFilter.Lk48, label: 'LR-48' }, </div>
]; );
}
const PEQ_SLIDER_PARAMS = [ function InputActions({
{ key: 'frequency', label: 'Frequency', min: 19.7, max: 20160, step: 0.5, unit: ' Hz' }, bypass,
{ key: 'q', label: 'Q', min: 1, max: 999, step: 1, unit: '' }, resetting,
{ key: 'gain', label: 'Gain', min: -12, max: +12, step: 0.1, unit: ' dB' }, onBypass,
] as const; onReset,
}: {
type SetPEQFn = (
channel: Channel,
band: PEQBand,
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
type PeqParamKey = 'frequency' | 'q' | 'gain';
type PEQValues = {
frequency: number;
q: number;
gain: number;
filter_type: PEQFilter;
bypass: boolean; bypass: boolean;
}; resetting: boolean;
onBypass: () => void;
onReset: () => void;
}) {
return (
<div className="geq-channel-selector">
<Button
variant="toggle"
className={bypass ? 'active-warning' : ''}
onClick={onBypass}
style={{
width: '100%',
fontSize: '3.5cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
whiteSpace: 'nowrap',
}}
>
{bypass ? 'Bypassed' : 'Bypass'}
</Button>
<Button
variant="toggle"
onClick={onReset}
disabled={resetting}
style={{
width: '100%',
fontSize: '3.5cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}}
>
{resetting ? 'Resetting…' : 'Reset'}
</Button>
</div>
);
}
function PeqPanelSliders({ function PeqPanelSliders({
channel, peq,
dsp,
band,
setPEQ, setPEQ,
// onLiveValue, onLiveValue,
}: { }: {
channel: InputChannel; peq: PEQ;
dsp: DSP; setPEQ: (
band: PEQBand; gainDb: number,
setPEQ: SetPEQFn; freqHz: number,
// onLiveValue: (channel: InputChannel, key: PeqParamKey, value: number) => void; q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
onLiveValue: (value: PEQ) => void;
}) { }) {
const inputState = dsp.state.current_config.input_states[channel]; const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
const peq = inputState.peq_chain.bands[band] as PEQ;
const commit = (overrides: Partial<PEQValues>) => { const commit = (overrides: Partial<PEQ>) => {
const next = { ...peq, ...overrides }; const next = { ...peq, ...overrides };
return setPEQ( return setPEQ(next.gain, next.frequency, next.q, next.filter_type, next.bypass);
{ Input: channel },
band,
next.gain,
next.frequency,
next.q,
next.filter_type,
next.bypass
);
}; };
return ( return (
<div className="input-panel"> <div className="input-panel">
<div className="input-panel__title">{'PEQ Parameters'}</div> <div className="input-panel__title">PEQ Parameters</div>
<div className="input-panel__grid"> <div className="input-panel__grid">
{PEQ_SLIDER_PARAMS.map((param) => ( <div className="input-panel__cell input-panel__cell--frequency">
<div className={`input-panel__cell input-panel__cell--${param.key}`} key={param.key}> <VerticalSlider
<VerticalSlider min={FREQUENCY_MIN}
min={param.min} max={FREQUENCY_MAX}
max={param.max} scale="log"
step={() => param.step} step={makeLogStep(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
unit={param.unit} quantize={makeLogQuantize(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
value={peq[param.key]} unit={(value) => (value >= 1000 ? 'kHz' : 'Hz')}
// onInput={(value) => onLiveValue(channel, param.key, value)} formatValue={(value) =>
onChange={(value) => commit({ [param.key]: value } as Partial<PEQValues>)} value >= 1000 ? `${(value / 1000).toFixed(2)}` : `${value.toFixed(1)}`
style={{ width: '100%', flex: '1 1 auto' }} }
/> value={peq.frequency}
<div className="input-panel__cell-label">{param.label}</div> onChange={(value) => commit({ frequency: value })}
</div> onInput={(value) => onLiveValue({ ...peq, frequency: value })}
))} style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-panel__cell-label">Frequency</div>
</div>
<div className="input-panel__cell input-panel__cell--q">
<VerticalSlider
min={qMin}
max={qMax}
values={PEAK_Q_VALUES}
formatValue={(v) => v.toFixed(2)}
value={peq.q}
onChange={(value) => commit({ q: value })}
onInput={(value) => onLiveValue({ ...peq, q: value })}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-panel__cell-label">Q</div>
</div>
<div className="input-panel__cell input-panel__cell--gain">
<VerticalSlider
min={-12}
max={12}
step={() => 0.1}
unit="dB"
value={peq.gain}
formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))}
onChange={(value) => commit({ gain: value })}
onInput={(value) => onLiveValue({ ...peq, gain: value })}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-panel__cell-label">Gain</div>
</div>
</div> </div>
</div> </div>
); );
} }
const FREQ_MIN = 19.7;
const FREQ_MAX = 20160;
const FREQ_POINTS = 300;
const FREQ_STEP = makeLogStep(FREQ_MIN, FREQ_MAX, FREQ_POINTS);
const FREQ_QUANTIZE = makeLogQuantize(FREQ_MIN, FREQ_MAX, FREQ_POINTS);
function CrossoverPanel({ function CrossoverPanel({
channel, crossover,
dsp,
title, title,
setCrossover, setCrossover,
onLiveValue,
}: { }: {
channel: Channel; crossover: Crossover;
dsp: DSP;
title: string; title: string;
setCrossover: ( setCrossover: (frequency: number, filter_type: CrossoverFilter) => Promise<boolean>;
channel: Channel, onLiveValue: (value: number) => void;
frequency: number,
filter_type: CrossoverFilter
) => Promise<boolean>;
}) { }) {
const channelState = const frequency = crossover.frequency;
'Input' in channel const filter_type = crossover.slope;
? dsp.state?.current_config.input_states[channel.Input] const bypass = filter_type === CrossoverFilter.Bypass;
: dsp.state?.current_config.output_states[channel.Output];
const crossover = channelState?.crossover; const lastFilterType = useRef<CrossoverFilter>(
filter_type !== CrossoverFilter.Bypass ? filter_type : CrossoverFilter.Lk48
);
const frequency = crossover.high_pass.frequency; const commit = async (overrides: {
const filter_type = crossover.high_pass.slope;
const bypass = false;
const commit = (overrides: {
frequency?: number; frequency?: number;
filter_type?: CrossoverFilter; filter_type?: CrossoverFilter;
bypass?: boolean; bypass?: boolean;
}) => { }) => {
const next = { frequency, filter_type, bypass, ...overrides }; const nextFrequency = overrides.frequency ?? frequency;
return setCrossover(channel, next.frequency, next.filter_type);
const selectedFilterType =
overrides.filter_type !== undefined
? overrides.filter_type
: bypass
? lastFilterType.current
: filter_type;
// Always remember the latest non-bypass selection.
if (selectedFilterType !== CrossoverFilter.Bypass) {
lastFilterType.current = selectedFilterType;
}
const nextBypass = overrides.bypass ?? bypass;
const nextFilterType = nextBypass ? CrossoverFilter.Bypass : selectedFilterType;
return setCrossover(nextFrequency, nextFilterType);
}; };
return ( return (
@@ -175,23 +243,27 @@ function CrossoverPanel({
<div className="crossover-panel__title">{title}</div> <div className="crossover-panel__title">{title}</div>
<VerticalSlider <VerticalSlider
min={FREQ_MIN} min={FREQUENCY_MIN}
max={FREQ_MAX} max={FREQUENCY_MAX}
scale="log" scale="log"
step={FREQ_STEP} step={makeLogStep(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
quantize={FREQ_QUANTIZE} quantize={makeLogQuantize(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
unit=" Hz" unit={(value) => (value >= 1000 ? 'kHz' : 'Hz')}
value={frequency} value={frequency}
formatValue={(value) =>
value >= 1000 ? `${(value / 1000).toFixed(2)}` : `${value.toFixed(1)}`
}
onChange={(value) => commit({ frequency: value })} onChange={(value) => commit({ frequency: value })}
onInput={(value) => onLiveValue(value)}
style={{ width: '100%', flex: '1 1 auto' }} style={{ width: '100%', flex: '1 1 auto' }}
/> />
<select <select
className="crossover-panel__select" className="crossover-panel__select"
value={filter_type} value={bypass ? lastFilterType.current : filter_type}
onChange={(e) => commit({ filter_type: e.target.value as CrossoverFilter })} onChange={(e) => commit({ filter_type: e.target.value as CrossoverFilter })}
> >
{FILTER_TYPE_OPTIONS.map((opt) => ( {CROSSOVER_FILTER_LABEL.map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value}>
{opt.label} {opt.label}
</option> </option>
@@ -219,32 +291,29 @@ function CrossoverPanel({
} }
function PeqBandList({ function PeqBandList({
channel, bands,
dsp, liveBands,
activeBand, activeBand,
setActiveBand, setActiveBand,
setPEQ, setPEQ,
}: { }: {
channel: InputChannel; bands: Record<PEQBand, PEQ>;
dsp: DSP; liveBands: Partial<Record<PEQBand, PEQ>>;
activeBand: PEQBand; activeBand: PEQBand;
setActiveBand: (band: PEQBand) => void; setActiveBand: (band: PEQBand) => void;
setPEQ: SetPEQFn; setPEQ: (
band: PEQBand,
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
}) { }) {
const inputState = dsp.state.current_config.input_states[channel]; const commit = (band: PEQBand, overrides: Partial<PEQ>) => {
const peq = liveBands[band] ?? bands[band];
const commit = (band: PEQBand, overrides: Partial<PEQValues>) => {
const peq = inputState.peq_chain.bands[band] as PEQ;
const next = { ...peq, ...overrides }; const next = { ...peq, ...overrides };
return setPEQ( return setPEQ(band, next.gain, next.frequency, next.q, next.filter_type, next.bypass);
{ Input: channel },
band,
next.gain,
next.frequency,
next.q,
next.filter_type,
next.bypass
);
}; };
return ( return (
@@ -258,8 +327,8 @@ function PeqBandList({
<div className="peq-band-list__cell">Bypass</div> <div className="peq-band-list__cell">Bypass</div>
</div> </div>
{PEQ_BANDS.map((band, i) => { {PEQ_BANDS_ORDER.map((band, i) => {
const peq = inputState.peq_chain.bands[band] as PEQ; const peq = liveBands[band] ?? bands[band];
const isActive = band === activeBand; const isActive = band === activeBand;
return ( return (
@@ -276,14 +345,22 @@ function PeqBandList({
{`B${i + 1}`} {`B${i + 1}`}
</Button> </Button>
{/* Frequency */}
<input <input
className="peq-band-list__input" className="peq-band-list__input"
type="number" type="text"
step={0.5} value={
min={19.7} peq.frequency >= 1000 ? (peq.frequency / 1000).toFixed(2) : peq.frequency.toFixed(1)
max={20160} }
value={peq.frequency} onChange={(e) => {
onChange={(e) => commit(band, { frequency: Number(e.target.value) })} const value = Number(e.target.value);
if (!Number.isNaN(value)) {
commit(band, {
frequency: peq.frequency >= 1000 ? value * 1000 : value,
});
}
}}
/> />
<input <input
@@ -309,9 +386,9 @@ function PeqBandList({
<select <select
className="peq-band-list__select" className="peq-band-list__select"
value={peq.filter_type} value={peq.filter_type}
onChange={(e) => commit(band, { filter_type: e.target.value as PEQFilter })} onChange={(e) => commit(band, { filter_type: e.target.value as PEQFilter, q: 3.0 })}
> >
{PEQ_FILTER_TYPE_OPTIONS.map((opt) => ( {PEQ_FILTERS_LABEL.map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value}>
{opt.label} {opt.label}
</option> </option>
@@ -339,15 +416,74 @@ type SetFilterFn = (
filterType: CrossoverFilter filterType: CrossoverFilter
) => Promise<boolean>; ) => Promise<boolean>;
type setPeqBandFn = ( /** Small DOM-overlay markers positioned on top of the magnitude graph via its exposed toPxX/toPxY. */
channel: Channel, // function GraphMarkers({
band: PEQBand, // graphRef,
gainDb: number, // bands,
freqHz: number, // activeBand,
q: number, // setActiveBand,
filterType: PEQFilter, // hpFreq,
bypass: boolean // hpBypassed,
) => Promise<boolean>; // lpFreq,
// lpBypassed,
// }: {
// graphRef: React.RefObject<LinearGraphHandle>;
// bands: Record<PEQBand, PEQ>;
// activeBand: PEQBand;
// setActiveBand: (band: PEQBand) => void;
// hpFreq: number;
// hpBypassed: boolean;
// lpFreq: number;
// lpBypassed: boolean;
// }) {
// const g = graphRef.current;
// if (!g) return null;
// return (
// <>
// {PEQ_BANDS_ORDER.map((band, i) => {
// const peq = bands[band];
// const left = g.toPxX(peq.frequency);
// const top = g.toPxY(peq.gain);
// const isActive = band === activeBand;
// return (
// <button
// key={band}
// type="button"
// className={`graph-marker graph-marker--peq${isActive ? ' graph-marker--active' : ''}${
// peq.bypass ? ' graph-marker--bypassed' : ''
// }`}
// style={{ left, top }}
// onClick={() => setActiveBand(band)}
// title={`B${i + 1} · ${Math.round(peq.frequency)} Hz · ${peq.gain.toFixed(1)} dB`}
// >
// {`B${i + 1}`}
// </button>
// );
// })}
// <div
// className={`graph-marker graph-marker--crossover graph-marker--hp${
// hpBypassed ? ' graph-marker--bypassed' : ''
// }`}
// style={{ left: g.toPxX(hpFreq), top: g.toPxY(0) }}
// title={`HPF · ${Math.round(hpFreq)} Hz`}
// >
// HP
// </div>
// <div
// className={`graph-marker graph-marker--crossover graph-marker--lp${
// lpBypassed ? ' graph-marker--bypassed' : ''
// }`}
// style={{ left: g.toPxX(lpFreq), top: g.toPxY(0) }}
// title={`LPF · ${Math.round(lpFreq)} Hz`}
// >
// LP
// </div>
// </>
// );
// }
function InputSection({ function InputSection({
dsp, dsp,
@@ -361,83 +497,207 @@ function InputSection({
channel: InputChannel; channel: InputChannel;
setHighPass: SetFilterFn; setHighPass: SetFilterFn;
setLowPass: SetFilterFn; setLowPass: SetFilterFn;
setPeq: setPeqBandFn; setPeq: (
channel: Channel,
band: PEQBand,
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
setPeqBypass: (channel: Channel, bypass: boolean) => Promise<boolean>; setPeqBypass: (channel: Channel, bypass: boolean) => Promise<boolean>;
}) { }) {
// UI variables
const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1); const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1);
const [graphView, setGraphView] = useState<GraphView>('both');
const [resetting, setResetting] = useState(false);
const [liveHpFreq, setLiveHpFreq] = useState<number>(19.7); // Live values
const [liveLpFreq, setLiveLpFreq] = useState<number>(20.16); const [liveHpFreq, setLiveHpFreq] = useState<number>(FREQUENCY_MIN);
const [livePeq, setLivePeq] = useState<Partial<Record<PEQBand, PEQValues>>>({}); const [liveLpFreq, setLiveLpFreq] = useState<number>(FREQUENCY_MAX);
const [livePeq, setLivePeq] = useState<Partial<Record<PEQBand, PEQ>>>({});
const inputState = dsp.state.current_config.input_states[channel]; const magnitudeGraphRef = useRef<LinearGraphHandle>(null);
const hp = inputState.crossover.high_pass; // DSP values
const lp = inputState.crossover.low_pass; const activeInputState = dsp.state.current_config.input_states[channel];
const activePeq = livePeq[activeBand] ?? (inputState.peq_chain.bands[activeBand] as PEQValues); const ActiveHighPassFilter = activeInputState.crossover.high_pass;
const ActiveLowPassFilter = activeInputState.crossover.low_pass;
const ActivePeq = activeInputState.peq_chain;
const bypassed = hp.slope === CrossoverFilter.Bypass; const handleResetPeq = async () => {
const activeFilterType = bypassed && lp.slope !== CrossoverFilter.Bypass ? lp.slope : hp.slope; if (resetting) return;
setResetting(true);
// Remembers the last non-bypass slope so the Bypass button can restore it. for (const band of PEQ_BANDS_ORDER) {
// const lastNonBypassType = useRef<CrossoverFilter>( const current = displayPeq[band];
// activeFilterType === CrossoverFilter.Bypass ? DEFAULT_FILTER_TYPE : activeFilterType if (current.gain === 0 || current.filter_type === PEQFilter.Peak) continue; // skip bands already flat
// );
// if (activeFilterType !== CrossoverFilter.Bypass) {
// lastNonBypassType.current = activeFilterType;
// }
const hpFreq = liveHpFreq ?? hp.frequency; await commitPEQ(band, 0, current.frequency, current.q, PEQFilter.Peak, current.bypass);
const lpFreq = liveLpFreq ?? lp.frequency; }
// const commitHighPass = async (freq: number, filterType: CrossoverFilter) => { setResetting(false);
// const success = await set_high_pass({ Input: channel }, freq, filterType); };
// if (success) setLiveHpFreq(null);
// return success;
// };
// const commitLowPass = async (freq: number, filterType: CrossoverFilter) => { const handleBypassClick = () => {
// const success = await set_low_pass({ Input: channel }, freq, filterType); setPeqBypass({ Input: channel }, !ActivePeq.bypass);
// if (success) setLiveLpFreq(null); };
// return success;
// };
// const handleFilterTypeChange = (filterType: CrossoverFilter) => { // Live handlers
// // Applying a slope change always takes the crossover out of bypass. const handleLiveHighPassValue = (value: number) => {
// void commitHighPass(hpFreq, filterType); setLiveHpFreq(value);
// void commitLowPass(lpFreq, filterType); };
// };
// const handleBypassToggle = () => { const handleLiveLowPassValue = (value: number) => {
// if (bypassed) { setLiveLpFreq(value);
// const restore = lastNonBypassType.current; };
// void commitHighPass(hpFreq, restore);
// void commitLowPass(lpFreq, restore); const handleLivePEQValue = (band: PEQBand, value: PEQ) => {
// } else { setLivePeq((prev) => ({
// void commitHighPass(hpFreq, CrossoverFilter.Bypass); ...prev,
// void commitLowPass(lpFreq, CrossoverFilter.Bypass); [band]: value,
// } }));
// }; };
// Real dsp value handlers
const commitHighPass = async (freq: number, filterType: CrossoverFilter) => {
const success = await setHighPass({ Input: channel }, freq, filterType);
if (success) {
setLiveHpFreq(freq);
}
return success;
};
const commitLowPass = async (freq: number, filterType: CrossoverFilter) => {
const success = await setLowPass({ Input: channel }, freq, filterType);
if (success) {
setLiveLpFreq(freq);
}
return success;
};
const commitPEQ = async (
band: PEQBand,
gain: number,
frequency: number,
q: number,
filter_type: PEQFilter,
bypass: boolean
) => {
const success = await setPeq({ Input: channel }, band, gain, frequency, q, filter_type, bypass);
if (success) {
setLivePeq((prev) => {
const next = { ...prev };
delete next[band];
return next;
});
}
return success;
};
// Diplay value (merge live and real)
const displayPeq: Record<PEQBand, PEQ> = {
...ActivePeq.bands,
...livePeq,
};
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 ( return (
<> <>
<div className="input-graph-section"> <div className="input-graph-section">
<div className="input-graph-panel"> <div className="input-graph-panel">
<div className="input-graph__title">{inputState.name} - Filters</div> <div className="input-graph__title-row">
<div className="input-graph__title">{activeInputState.name} - Filters</div>
<GraphViewToggle view={graphView} onChange={setGraphView} />
</div>
{/* TODO: draw HP/LP transfer curves here once filter math is defined, {(graphView === 'both' || graphView === 'magnitude') && (
same pattern as LimiterSection's `draw` prop. */} <div className="input-graph-panel__magnitude-wrap">
<LinearGraph <LinearGraph
xMin={20} ref={magnitudeGraphRef}
xMax={20000} xMin={GRAPH_X_MIN}
yMin={-30} xMax={GRAPH_X_MAX}
yMax={6} xScale="log"
xLabel="Hz" yMin={GRAPH_Y_MIN}
yLabel="dB" yMax={GRAPH_Y_MAX}
formatX={(v) => Math.round(v).toString()} yStep={3}
formatY={(v) => Math.round(v).toString()} xLabel="Hz"
draw={() => {}} yLabel="dB"
/> showGrid={true}
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
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 },
},
],
});
}}
/>
</div>
)}
{(graphView === 'both' || graphView === 'phase') && (
<LinearGraph
xMin={GRAPH_X_MIN}
xMax={GRAPH_X_MAX}
xScale="log"
yMin={-180}
yMax={180}
yStep={45}
xLabel="Hz"
yLabel="°"
showGrid={true}
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
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 },
},
],
});
}}
/>
)}
</div> </div>
</div> </div>
@@ -445,87 +705,44 @@ function InputSection({
<div className="input-control-panel"> <div className="input-control-panel">
<ChannelGroup title="PEQ"> <ChannelGroup title="PEQ">
<PeqBandList <PeqBandList
channel={channel} bands={ActivePeq.bands}
dsp={dsp} liveBands={livePeq}
activeBand={activeBand} activeBand={activeBand}
setActiveBand={setActiveBand} setActiveBand={setActiveBand}
setPEQ={setPeq} setPEQ={commitPEQ}
/>
<PeqPanelSliders
peq={ActivePeq.bands[activeBand]}
setPEQ={(gain, frequency, q, filter_type, bypass) =>
commitPEQ(activeBand, gain, frequency, q, filter_type, bypass)
}
onLiveValue={(value) => handleLivePEQValue(activeBand, value)}
/> />
<PeqPanelSliders channel={channel} dsp={dsp} band={activeBand} setPEQ={setPeq} />
</ChannelGroup> </ChannelGroup>
<ChannelGroup title="CrossOver"> <ChannelGroup title="CrossOver">
<CrossoverPanel <CrossoverPanel
channel={{ Input: channel }} crossover={ActiveHighPassFilter}
dsp={dsp} title="HighPass"
title="LowPass" setCrossover={commitHighPass}
setCrossover={setHighPass} onLiveValue={handleLiveHighPassValue}
/> />
<CrossoverPanel <CrossoverPanel
channel={{ Input: channel }} crossover={ActiveLowPassFilter}
dsp={dsp} title="LowPass"
title="HighPass" setCrossover={commitLowPass}
setCrossover={setHighPass} onLiveValue={handleLiveLowPassValue}
/> />
</ChannelGroup> </ChannelGroup>
{/* <div className="input-control-panel__sliders">
<div className="input-control-panel__cell input-control-panel__cell--high-pass">
<VerticalSlider
min={FREQ_MIN}
max={FREQ_MAX}
step={FREQ_STEP}
unit=" Hz"
value={hpFreq}
// disabled={bypassed}
onInput={(value) => setLiveHpFreq(value)}
onChange={(value) => commitHighPass(value, activeFilterType)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-control-panel__cell-label">High Pass</div>
</div>
<div className="input-control-panel__cell input-control-panel__cell--low-pass"> <ChannelGroup title="Actions">
<VerticalSlider <InputActions
min={FREQ_MIN} bypass={ActivePeq.bypass}
max={FREQ_MAX} resetting={resetting}
step={FREQ_STEP} onBypass={handleBypassClick}
unit=" Hz" onReset={handleResetPeq}
value={lpFreq} />
// disabled={bypassed} </ChannelGroup>
onInput={(value) => setLiveLpFreq(value)}
onChange={(value) => commitLowPass(value, activeFilterType)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-control-panel__cell-label">Low Pass</div>
</div>
</div>
<div className="input-control-panel__side">
<label className="input-control-panel__select">
<span className="input-control-panel__select-label">Filter Type</span>
<select
value={activeFilterType}
disabled={bypassed}
onChange={(e) => handleFilterTypeChange(e.target.value as CrossoverFilter)}
>
{FILTER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<button
type="button"
className={`input-control-panel__bypass-btn${
bypassed ? ' input-control-panel__bypass-btn--active' : ''
}`}
onClick={handleBypassToggle}
>
{bypassed ? 'Bypassed' : 'Bypass'}
</button>
</div> */}
</div> </div>
</div> </div>
</> </>
+82 -2
View File
@@ -1,4 +1,12 @@
import { DiscreteFrequency, InputChannel, OutputChannel } from './types'; import {
DiscreteFrequency,
InputChannel,
OutputChannel,
CrossoverFilter,
PEQBand,
PEQFilter,
DelayUnit,
} from './types';
export const INPUT_ORDER: InputChannel[] = [ export const INPUT_ORDER: InputChannel[] = [
InputChannel.InA, InputChannel.InA,
@@ -53,7 +61,6 @@ export const DISCRETE_FREQUENCIES: Record<DiscreteFrequency, number> = {
}; };
// ------ GEQ constants ------ // ------ GEQ constants ------
export const GRAPH_GEQ_CURVE_Q = 8.65;
export const GEQ_BANDS_LABEL = [ export const GEQ_BANDS_LABEL = [
{ frequency: DiscreteFrequency.F20, label: '20' }, { frequency: DiscreteFrequency.F20, label: '20' },
@@ -92,12 +99,85 @@ export const GEQ_BANDS_LABEL = [
export const GEQ_GAIN_MIN = -12; export const GEQ_GAIN_MIN = -12;
export const GEQ_GAIN_MAX = 12; export const GEQ_GAIN_MAX = 12;
export const GEQ_GAIN_STEP = 0.1; export const GEQ_GAIN_STEP = 0.1;
export const GEQ_CURVE_Q = 8.65 * 0.5;
// ------ Filter constants ------
export const CROSSOVER_FILTER_LABEL: { value: CrossoverFilter; label: string }[] = [
{ value: CrossoverFilter.Bw6, label: 'BW-6' },
{ value: CrossoverFilter.Bw12, label: 'BW-12' },
{ value: CrossoverFilter.Bw18, label: 'BW-18' },
{ value: CrossoverFilter.Bw24, label: 'BW-24' },
{ value: CrossoverFilter.Bw30, label: 'BW-30' },
{ value: CrossoverFilter.Bw36, label: 'BW-36' },
{ value: CrossoverFilter.Bw42, label: 'BW-42' },
{ value: CrossoverFilter.Bw48, label: 'BW-48' },
{ value: CrossoverFilter.Lk12, label: 'LR-12' },
{ value: CrossoverFilter.Lk24, label: 'LR-24' },
{ value: CrossoverFilter.Lk36, label: 'LR-36' },
{ value: CrossoverFilter.Lk48, label: 'LR-48' },
];
export const PEQ_BANDS_ORDER: PEQBand[] = [
PEQBand.B1,
PEQBand.B2,
PEQBand.B3,
PEQBand.B4,
PEQBand.B5,
PEQBand.B6,
PEQBand.B7,
PEQBand.B8,
];
export const PEQ_Q_RANGES: Record<PEQFilter, readonly [number, number]> = {
[PEQFilter.Peak]: [0.4, 128.0],
[PEQFilter.LowShelf]: [0.4, 3.0],
[PEQFilter.HighShelf]: [0.4, 3.0],
[PEQFilter.Lp6Db]: [0.4, 3.0],
[PEQFilter.Lp12Db]: [0.4, 3.0],
[PEQFilter.Hp6Db]: [0.4, 3.0],
[PEQFilter.Hp12Db]: [0.4, 3.0],
[PEQFilter.AllPass1]: [0.4, 128.0],
[PEQFilter.AllPass2]: [0.4, 128.0],
};
export const PEAK_Q_VALUES = [
0.4, 0.42, 0.45, 0.47, 0.5, 0.53, 0.56, 0.59, 0.63, 0.67, 0.71, 0.75, 0.79, 0.84, 0.89, 0.94, 1.0,
1.06, 1.12, 1.19, 1.26, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0, 2.1, 2.2, 2.4, 2.5, 2.7, 2.8, 3.0,
3.2, 3.4, 3.6, 3.8, 4.0, 4.2, 4.5, 4.8, 5.0, 5.3, 5.7, 6.0, 6.3, 6.7, 7.1, 7.6, 8.0, 8.5, 9.0,
9.5, 10.1, 10.7, 11.3, 12.0, 12.7, 13.5, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.0, 23.0,
24.0, 25.0, 27.0, 29.0, 30.0, 32.0, 34.0, 36.0, 38.0, 40.0, 43.0, 45.0, 48.0, 51.0, 54.0, 57.0,
60.0, 64.0, 68.0, 72.0, 76.0, 81.0, 85.0, 91.0, 96.0, 102.0, 108.0, 114.0, 121.0, 128.0,
] as const;
export const PEQ_FILTERS_LABEL: { value: PEQFilter; label: string }[] = [
{ value: PEQFilter.Peak, label: 'Peak' },
{ value: PEQFilter.LowShelf, label: 'Low Shelf' },
{ value: PEQFilter.HighShelf, label: 'High Shelf' },
{ value: PEQFilter.Lp6Db, label: 'Low Pass 6 dB/oct' },
{ value: PEQFilter.Lp12Db, label: 'Low Pass 12 dB/oct' },
{ value: PEQFilter.Hp6Db, label: 'High Pass 6 dB/oct' },
{ value: PEQFilter.Hp12Db, label: 'High Pass 12 dB/oct' },
{ value: PEQFilter.AllPass1, label: 'All Pass 1' },
{ value: PEQFilter.AllPass2, label: 'All Pass 2' },
];
export const FREQUENCY_MIN = 19.7;
export const FREQUENCY_MAX = 20160;
export const FREQUENCY_QUANTIZATION = 300;
// ------ Matrix constants ------ // ------ Matrix constants ------
export const MATRIX_GAIN_MIN = -60; export const MATRIX_GAIN_MIN = -60;
export const MATRIX_GAIN_MAX = 0; export const MATRIX_GAIN_MAX = 0;
export const MATRIX_GAIN_STEP = 0.1; export const MATRIX_GAIN_STEP = 0.1;
// ------ Delay constants ------
export const DELAY_RANGES: Record<DelayUnit, [number, number]> = {
[DelayUnit.Millisecond]: [0, 680.0],
[DelayUnit.Meter]: [0, 233.58],
[DelayUnit.Feet]: [0, 766.329],
};
// ------ Graph constants ------ // ------ Graph constants ------
export const GRAPH_X_MIN = 10; export const GRAPH_X_MIN = 10;
export const GRAPH_X_MAX = 30000; export const GRAPH_X_MAX = 30000;
+196
View File
@@ -0,0 +1,196 @@
import {
Gate,
GraphicEQ,
DiscreteFrequency,
PEQ,
PEQFilter,
PEQBand,
PEQChain,
Crossover,
CrossoverFilter,
CrossoverFilters,
InputChannelState,
OutputChannelState,
InputChannel,
DSPState,
Meters,
Meter,
Ratio,
OutputChannel,
DelayUnit,
} from './types';
import { FREQUENCY_MIN, FREQUENCY_MAX } from './constants';
const createDefaultGate = (): Gate => ({
attack: 10,
release: 10,
hold: 10,
threshold: 0,
});
const createDefaultGraphicEQ = (): GraphicEQ => ({
gains: {
[DiscreteFrequency.F20]: 0,
[DiscreteFrequency.F25]: 0,
[DiscreteFrequency.F31_5]: 0,
[DiscreteFrequency.F40]: 0,
[DiscreteFrequency.F50]: 0,
[DiscreteFrequency.F63]: 0,
[DiscreteFrequency.F80]: 0,
[DiscreteFrequency.F100]: 0,
[DiscreteFrequency.F125]: 0,
[DiscreteFrequency.F160]: 0,
[DiscreteFrequency.F200]: 0,
[DiscreteFrequency.F250]: 0,
[DiscreteFrequency.F315]: 0,
[DiscreteFrequency.F400]: 0,
[DiscreteFrequency.F500]: 0,
[DiscreteFrequency.F630]: 0,
[DiscreteFrequency.F800]: 0,
[DiscreteFrequency.F1000]: 0,
[DiscreteFrequency.F1250]: 0,
[DiscreteFrequency.F1600]: 0,
[DiscreteFrequency.F2000]: 0,
[DiscreteFrequency.F2500]: 0,
[DiscreteFrequency.F3150]: 0,
[DiscreteFrequency.F4000]: 0,
[DiscreteFrequency.F5000]: 0,
[DiscreteFrequency.F6300]: 0,
[DiscreteFrequency.F8000]: 0,
[DiscreteFrequency.F10000]: 0,
[DiscreteFrequency.F12500]: 0,
[DiscreteFrequency.F16000]: 0,
[DiscreteFrequency.F20000]: 0,
},
bypass: false,
});
const createDefaultPEQ = (): PEQ => ({
gain: 0,
frequency: 1000,
q: 1,
filter_type: PEQFilter.Peak,
bypass: false,
});
const createDefaultPEQChain = (): PEQChain => ({
bands: Object.fromEntries(
Object.values(PEQBand).map((band) => [band, createDefaultPEQ()])
) as Record<PEQBand, PEQ>,
bypass: false,
});
const createDefaultCrossover = (frequency: number): Crossover => ({
frequency: frequency,
slope: CrossoverFilter.Bypass,
});
const createDefaultCrossoverFilters = (): CrossoverFilters => ({
high_pass: createDefaultCrossover(FREQUENCY_MIN),
low_pass: createDefaultCrossover(FREQUENCY_MAX),
});
const createDefaultInputChannelState = (name: string): InputChannelState => ({
name,
gain: 0,
phase_inverted: false,
delay: 0,
gate: createDefaultGate(),
geq: createDefaultGraphicEQ(),
peq_chain: createDefaultPEQChain(),
crossover: createDefaultCrossoverFilters(),
linked_channels: [],
mute: false,
});
const createDefaultOutputChannelState = (name: string): OutputChannelState => ({
name,
matrix_routes: {
connected: [InputChannel.InA],
gains: {
[InputChannel.InA]: 0,
[InputChannel.InB]: 0,
[InputChannel.InC]: 0,
[InputChannel.InD]: 0,
},
},
crossover: createDefaultCrossoverFilters(),
peq_chain: createDefaultPEQChain(),
compressor: {
threshold: -90,
ratio: Ratio.Ratio1_1,
attack: 1,
release: 10,
knee: 0,
},
limiter: {
threshold: -90,
attack: 1,
release: 10,
},
gain: 0,
phase_inverted: false,
delay: 0,
linked_channels: [],
mute: false,
});
export const defaultDSPState: DSPState = {
name: 'Default',
flags: {
is_locked: false,
},
presets: {
current_index: 0,
names: Array(20).fill('Default'),
modified: Array(20).fill(false),
},
current_config: {
input_source: {
type: 'Analog',
frequency: '48kHz',
},
delay_unit: DelayUnit.Millisecond,
input_states: {
[InputChannel.InA]: createDefaultInputChannelState('InInInIn'),
[InputChannel.InB]: createDefaultInputChannelState('In B'),
[InputChannel.InC]: createDefaultInputChannelState('In C'),
[InputChannel.InD]: createDefaultInputChannelState('In D'),
},
output_states: {
[OutputChannel.Out1]: createDefaultOutputChannelState('Out 1'),
[OutputChannel.Out2]: createDefaultOutputChannelState('Out 2'),
[OutputChannel.Out3]: createDefaultOutputChannelState('Out 3'),
[OutputChannel.Out4]: createDefaultOutputChannelState('Out 4'),
[OutputChannel.Out5]: createDefaultOutputChannelState('Out 5'),
[OutputChannel.Out6]: createDefaultOutputChannelState('Out 6'),
[OutputChannel.Out7]: createDefaultOutputChannelState('Out 7'),
[OutputChannel.Out8]: createDefaultOutputChannelState('Out 8'),
},
},
};
const createDefaultMeter = (): Meter => ({
rms: -60,
peak: -60,
clip: false,
});
export const defaultMeters: Meters = {
channels: Array.from({ length: 12 }, createDefaultMeter),
};
+66 -17
View File
@@ -11,11 +11,12 @@ import {
PEQFilter, PEQFilter,
PEQBand, PEQBand,
InputSource, InputSource,
Crossover,
} from './types'; } from './types';
import { SetDsps, NotifyFn, updateDSP, setPollingPaused } from '../../dsp/dspShared'; import { SetDsps, NotifyFn, updateDSP, setPollingPaused } from '../../dsp/dspShared';
import { registerPollStopper } from '../../dsp/pollRegistry'; import { registerPollStopper } from '../../dsp/pollRegistry';
import { defaultDSPState, defaultMeters } from './types'; import { defaultDSPState, defaultMeters } from './defaults';
import { DELAY_RANGES } from './types'; import { DELAY_RANGES, PEQ_Q_RANGES } from './constants';
type DSP408 = Extract<DSP, { type: 'DSP408' }>; type DSP408 = Extract<DSP, { type: 'DSP408' }>;
@@ -1144,9 +1145,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
if (!d.state) return d; if (!d.state) return d;
const cfg = d.state.current_config; const cfg = d.state.current_config;
const lowPass = {
const lowPass: Crossover = {
frequency: freq, frequency: freq,
filter_type: filterType, slope: filterType,
}; };
if ('Input' in channel) { if ('Input' in channel) {
@@ -1160,7 +1162,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
...cfg.input_states, ...cfg.input_states,
[channel.Input]: { [channel.Input]: {
...cfg.input_states[channel.Input], ...cfg.input_states[channel.Input],
low_pass: lowPass, crossover: {
...cfg.input_states[channel.Input].crossover,
low_pass: lowPass,
},
}, },
}, },
}, },
@@ -1178,7 +1183,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
...cfg.output_states, ...cfg.output_states,
[channel.Output]: { [channel.Output]: {
...cfg.output_states[channel.Output], ...cfg.output_states[channel.Output],
low_pass: lowPass, crossover: {
...cfg.output_states[channel.Output].crossover,
low_pass: lowPass,
},
}, },
}, },
}, },
@@ -1227,9 +1235,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
if (!d.state) return d; if (!d.state) return d;
const cfg = d.state.current_config; const cfg = d.state.current_config;
const highPass = {
const highPass: Crossover = {
frequency: freq, frequency: freq,
filter_type: filterType, slope: filterType,
}; };
if ('Input' in channel) { if ('Input' in channel) {
@@ -1243,7 +1252,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
...cfg.input_states, ...cfg.input_states,
[channel.Input]: { [channel.Input]: {
...cfg.input_states[channel.Input], ...cfg.input_states[channel.Input],
high_pass: highPass, crossover: {
...cfg.input_states[channel.Input].crossover,
high_pass: highPass,
},
}, },
}, },
}, },
@@ -1261,7 +1273,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
...cfg.output_states, ...cfg.output_states,
[channel.Output]: { [channel.Output]: {
...cfg.output_states[channel.Output], ...cfg.output_states[channel.Output],
high_pass: highPass, crossover: {
...cfg.output_states[channel.Output].crossover,
high_pass: highPass,
},
}, },
}, },
}, },
@@ -1310,20 +1325,40 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
bypass: boolean bypass: boolean
): Promise<boolean> { ): Promise<boolean> {
const updatePeqBand = () => { const updatePeqBand = () => {
function convertPeqQ(value: number, from: PEQFilter, to: PEQFilter): number {
if (from === to) return value;
const [fromMin, fromMax] = PEQ_Q_RANGES[from];
const [toMin, toMax] = PEQ_Q_RANGES[to];
if (fromMax === fromMin) return toMin;
const ratio = (value - fromMin) / (fromMax - fromMin);
return toMin + ratio * (toMax - toMin);
}
updateDSP(setDsps, dsp.id, (d) => { updateDSP(setDsps, dsp.id, (d) => {
if (!d.state) return d; if (!d.state) return d;
const cfg = d.state.current_config; const cfg = d.state.current_config;
const peqBand = {
gain: gainDb,
frequency: freqHz,
q,
filter_type: filterType,
bypass,
};
if ('Input' in channel) { if ('Input' in channel) {
const inputState = cfg.input_states[channel.Input]; const inputState = cfg.input_states[channel.Input];
const existingBand = inputState.peq_chain.bands[band];
const nextQ =
existingBand.filter_type !== filterType
? convertPeqQ(existingBand.q, existingBand.filter_type, filterType)
: q;
const peqBand = {
gain: gainDb,
frequency: freqHz,
q: nextQ,
filter_type: filterType,
bypass,
};
return { return {
...d, ...d,
@@ -1350,6 +1385,20 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
} }
const outputState = cfg.output_states[channel.Output]; const outputState = cfg.output_states[channel.Output];
const existingBand = outputState.peq_chain.bands[band];
const nextQ =
existingBand.filter_type !== filterType
? convertPeqQ(existingBand.q, existingBand.filter_type, filterType)
: q;
const peqBand = {
gain: gainDb,
frequency: freqHz,
q: nextQ,
filter_type: filterType,
bypass,
};
return { return {
...d, ...d,
-182
View File
@@ -247,185 +247,3 @@ export interface Meter {
export interface Meters { export interface Meters {
channels: Meter[]; channels: Meter[];
} }
/* -- Default Value -- */
const createDefaultGate = (): Gate => ({
attack: 10,
release: 10,
hold: 10,
threshold: 0,
});
const createDefaultGraphicEQ = (): GraphicEQ => ({
gains: {
[DiscreteFrequency.F20]: 0,
[DiscreteFrequency.F25]: 0,
[DiscreteFrequency.F31_5]: 0,
[DiscreteFrequency.F40]: 0,
[DiscreteFrequency.F50]: 0,
[DiscreteFrequency.F63]: 0,
[DiscreteFrequency.F80]: 0,
[DiscreteFrequency.F100]: 0,
[DiscreteFrequency.F125]: 0,
[DiscreteFrequency.F160]: 0,
[DiscreteFrequency.F200]: 0,
[DiscreteFrequency.F250]: 0,
[DiscreteFrequency.F315]: 0,
[DiscreteFrequency.F400]: 0,
[DiscreteFrequency.F500]: 0,
[DiscreteFrequency.F630]: 0,
[DiscreteFrequency.F800]: 0,
[DiscreteFrequency.F1000]: 0,
[DiscreteFrequency.F1250]: 0,
[DiscreteFrequency.F1600]: 0,
[DiscreteFrequency.F2000]: 0,
[DiscreteFrequency.F2500]: 0,
[DiscreteFrequency.F3150]: 0,
[DiscreteFrequency.F4000]: 0,
[DiscreteFrequency.F5000]: 0,
[DiscreteFrequency.F6300]: 0,
[DiscreteFrequency.F8000]: 0,
[DiscreteFrequency.F10000]: 0,
[DiscreteFrequency.F12500]: 0,
[DiscreteFrequency.F16000]: 0,
[DiscreteFrequency.F20000]: 0,
},
bypass: false,
});
const createDefaultPEQ = (): PEQ => ({
gain: 0,
frequency: 1000,
q: 1,
filter_type: PEQFilter.Peak,
bypass: false,
});
const createDefaultPEQChain = (): PEQChain => ({
bands: Object.fromEntries(
Object.values(PEQBand).map((band) => [band, createDefaultPEQ()])
) as Record<PEQBand, PEQ>,
bypass: false,
});
const createDefaultCrossover = (): Crossover => ({
frequency: 20000,
slope: CrossoverFilter.Bypass,
});
const createDefaultCrossoverFilters = (): CrossoverFilters => ({
high_pass: createDefaultCrossover(),
low_pass: createDefaultCrossover(),
});
const createDefaultInputChannelState = (name: string): InputChannelState => ({
name,
gain: 0,
phase_inverted: false,
delay: 0,
gate: createDefaultGate(),
geq: createDefaultGraphicEQ(),
peq_chain: createDefaultPEQChain(),
crossover: createDefaultCrossoverFilters(),
linked_channels: [],
mute: false,
});
const createDefaultOutputChannelState = (name: string): OutputChannelState => ({
name,
matrix_routes: {
connected: [InputChannel.InA],
gains: {
[InputChannel.InA]: 0,
[InputChannel.InB]: 0,
[InputChannel.InC]: 0,
[InputChannel.InD]: 0,
},
},
crossover: createDefaultCrossoverFilters(),
peq_chain: createDefaultPEQChain(),
compressor: {
threshold: -90,
ratio: Ratio.Ratio1_1,
attack: 1,
release: 10,
knee: 0,
},
limiter: {
threshold: -90,
attack: 1,
release: 10,
},
gain: 0,
phase_inverted: false,
delay: 0,
linked_channels: [],
mute: false,
});
export const defaultDSPState: DSPState = {
name: 'Default',
flags: {
is_locked: false,
},
presets: {
current_index: 0,
names: Array(20).fill('Default'),
modified: Array(20).fill(false),
},
current_config: {
input_source: {
type: 'Analog',
frequency: '48kHz',
},
delay_unit: DelayUnit.Millisecond,
input_states: {
[InputChannel.InA]: createDefaultInputChannelState('InInInIn'),
[InputChannel.InB]: createDefaultInputChannelState('In B'),
[InputChannel.InC]: createDefaultInputChannelState('In C'),
[InputChannel.InD]: createDefaultInputChannelState('In D'),
},
output_states: {
[OutputChannel.Out1]: createDefaultOutputChannelState('Out 1'),
[OutputChannel.Out2]: createDefaultOutputChannelState('Out 2'),
[OutputChannel.Out3]: createDefaultOutputChannelState('Out 3'),
[OutputChannel.Out4]: createDefaultOutputChannelState('Out 4'),
[OutputChannel.Out5]: createDefaultOutputChannelState('Out 5'),
[OutputChannel.Out6]: createDefaultOutputChannelState('Out 6'),
[OutputChannel.Out7]: createDefaultOutputChannelState('Out 7'),
[OutputChannel.Out8]: createDefaultOutputChannelState('Out 8'),
},
},
};
const createDefaultMeter = (): Meter => ({
rms: -60,
peak: -60,
clip: false,
});
export const defaultMeters: Meters = {
channels: Array.from({ length: 12 }, createDefaultMeter),
};
export const DELAY_RANGES: Record<DelayUnit, [number, number]> = {
[DelayUnit.Millisecond]: [0, 680.0],
[DelayUnit.Meter]: [0, 233.58],
[DelayUnit.Feet]: [0, 766.329],
};
-144
View File
@@ -1,144 +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;
};
/**
* 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();
};
}
+99
View File
@@ -0,0 +1,99 @@
export const DEFAULT_SAMPLE_RATE = 96000;
export type BiquadCoeffs = {
b0: number;
b1: number;
b2: number;
a0: number;
a1: number;
a2: number;
};
/** Exact magnitude response (dB) of a digital biquad, evaluated on the unit circle. */
export function biquadMagnitudeDb(
freq: number,
fs: number,
{ b0, b1, b2, a0, a1, a2 }: BiquadCoeffs
): number {
const w = (2 * Math.PI * freq) / fs;
const cosw = Math.cos(w);
const sinw = Math.sin(w);
const cos2w = Math.cos(2 * w);
const sin2w = Math.sin(2 * w);
const numRe = b0 + b1 * cosw + b2 * cos2w;
const numIm = -b1 * sinw - b2 * sin2w;
const denRe = a0 + a1 * cosw + a2 * cos2w;
const denIm = -a1 * sinw - a2 * sin2w;
return 20 * Math.log10(Math.hypot(numRe, numIm) / Math.hypot(denRe, denIm));
}
/** Exact phase response (degrees) of a digital biquad. */
export function biquadPhaseDeg(
freq: number,
fs: number,
{ b0, b1, b2, a0, a1, a2 }: BiquadCoeffs
): number {
const w = (2 * Math.PI * freq) / fs;
const cosw = Math.cos(w);
const sinw = Math.sin(w);
const cos2w = Math.cos(2 * w);
const sin2w = Math.sin(2 * w);
const numRe = b0 + b1 * cosw + b2 * cos2w;
const numIm = -b1 * sinw - b2 * sin2w;
const denRe = a0 + a1 * cosw + a2 * cos2w;
const denIm = -a1 * sinw - a2 * sin2w;
return ((Math.atan2(numIm, numRe) - Math.atan2(denIm, denRe)) * 180) / Math.PI;
}
// ----------- Shared simple filter primitives (used by both PEQ Lp6/Hp6/Lp12/Hp12
// filter types AND crossover Butterworth stage composition) -----------
/** 1-pole lowpass, -6dB/oct. */
export function lp6ResponseDb(freq: number, f0: number): number {
const x = freq / f0;
return -10 * Math.log10(1 + x * x);
}
/** 1-pole highpass, -6dB/oct. */
export function hp6ResponseDb(freq: number, f0: number): number {
const x = f0 / freq;
return -10 * Math.log10(1 + x * x);
}
/** 2-pole lowpass, -12dB/oct, resonant. */
export 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. */
export 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);
}
export function lp6PhaseDeg(freq: number, f0: number): number {
return -Math.atan(freq / f0) * (180 / Math.PI);
}
export function hp6PhaseDeg(freq: number, f0: number): number {
return Math.atan(f0 / freq) * (180 / Math.PI) - 90;
}
export function lp12PhaseDeg(freq: number, f0: number, q: number): number {
const x = freq / f0;
return -Math.atan2(x / q, 1 - x * x) * (180 / Math.PI);
}
export function hp12PhaseDeg(freq: number, f0: number, q: number): number {
const x = freq / f0;
return (Math.PI - Math.atan2(x / q, 1 - x * x)) * (180 / Math.PI);
}
@@ -0,0 +1,194 @@
import { CrossoverFilter } from '../../types';
import {
lp6ResponseDb,
hp6ResponseDb,
lp12ResponseDb,
hp12ResponseDb,
lp6PhaseDeg,
hp6PhaseDeg,
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);
}
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);
}
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);
}
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);
}
return sum;
}
const linkwitzRileyLpResponseDb = (f: number, f0: number, o: number) =>
2 * butterworthLpResponseDb(f, f0, o / 2);
const linkwitzRileyHpResponseDb = (f: number, f0: number, o: number) =>
2 * butterworthHpResponseDb(f, f0, o / 2);
const linkwitzRileyLpPhaseDeg = (f: number, f0: number, o: number) =>
2 * butterworthLpPhaseDeg(f, f0, o / 2);
const linkwitzRileyHpPhaseDeg = (f: number, f0: number, o: number) =>
2 * butterworthHpPhaseDeg(f, f0, o / 2);
type CrossoverTopology = 'butterworth' | 'linkwitz-riley';
const CROSSOVER_SLOPE_CONFIG: Record<
Exclude<CrossoverFilter, CrossoverFilter.Bypass>,
{ topology: CrossoverTopology; order: number }
> = {
[CrossoverFilter.Bw6]: { topology: 'butterworth', order: 1 },
[CrossoverFilter.Bw12]: { topology: 'butterworth', order: 2 },
[CrossoverFilter.Bw18]: { topology: 'butterworth', order: 3 },
[CrossoverFilter.Bw24]: { topology: 'butterworth', order: 4 },
[CrossoverFilter.Bw30]: { topology: 'butterworth', order: 5 },
[CrossoverFilter.Bw36]: { topology: 'butterworth', order: 6 },
[CrossoverFilter.Bw42]: { topology: 'butterworth', order: 7 },
[CrossoverFilter.Bw48]: { topology: 'butterworth', order: 8 },
[CrossoverFilter.Lk12]: { topology: 'linkwitz-riley', order: 2 },
[CrossoverFilter.Lk24]: { topology: 'linkwitz-riley', order: 4 },
[CrossoverFilter.Lk36]: { topology: 'linkwitz-riley', order: 6 },
[CrossoverFilter.Lk48]: { topology: 'linkwitz-riley', order: 8 },
};
export type CrossoverKind = 'lp' | 'hp';
export interface CrossoverSpec {
frequency: number;
filter_type: CrossoverFilter;
}
export function crossoverMagnitudeDb(
kind: CrossoverKind,
spec: CrossoverSpec | undefined,
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'
? butterworthLpResponseDb
: linkwitzRileyLpResponseDb
: cfg.topology === 'butterworth'
? butterworthHpResponseDb
: linkwitzRileyHpResponseDb;
return fn(freq, spec.frequency, cfg.order);
}
export function crossoverPhaseDeg(
kind: CrossoverKind,
spec: CrossoverSpec | undefined,
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'
? butterworthLpPhaseDeg
: linkwitzRileyLpPhaseDeg
: cfg.topology === 'butterworth'
? butterworthHpPhaseDeg
: linkwitzRileyHpPhaseDeg;
return fn(freq, spec.frequency, cfg.order);
}
export function crossoverPairMagnitudeDb(
highPass: CrossoverSpec | undefined,
lowPass: CrossoverSpec | undefined,
freq: number
): number {
return crossoverMagnitudeDb('hp', highPass, freq) + crossoverMagnitudeDb('lp', lowPass, freq);
}
export function crossoverPairPhaseDeg(
highPass: CrossoverSpec | undefined,
lowPass: CrossoverSpec | undefined,
freq: number
): number {
return crossoverPhaseDeg('hp', highPass, freq) + crossoverPhaseDeg('lp', lowPass, freq);
}
/**
* 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,
}: {
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),
}));
}
+100
View File
@@ -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;
}
+104
View File
@@ -0,0 +1,104 @@
import {
CurvePoint,
CurveStyle,
Curve,
DEFAULT_CURVE_COLORS,
sumCurvePoints,
} from './curve';
export { sumCurvePoints };
type ToPx = (x: number, y: number) => [number, number];
type CanvasCtx = CanvasRenderingContext2D;
const DEFAULT_STYLE: Required<Omit<CurveStyle, never>> = {
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<CurveStyle>
) {
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<CurveStyle> = { ...DEFAULT_STYLE, ...curve.style };
drawSingleCurve(ctx, toPx, curve.points, xMin, xMax, yMin, yMax, style);
}
}
+48
View File
@@ -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),
}));
}
+335
View File
@@ -0,0 +1,335 @@
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';
// ----------- 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 {
if (gainDb === 0) return 0;
const A = 10 ** (gainDb / 40);
const w0 = (2 * Math.PI * f0) / fs;
const cosw0 = Math.cos(w0);
const alpha = (A * Math.sin(w0)) / (2 * q);
const coeffs: BiquadCoeffs = {
b0: 1 + alpha * A,
b1: -2 * cosw0,
b2: 1 - alpha * A,
a0: 1 + alpha / A,
a1: -2 * cosw0,
a2: 1 - alpha / A,
};
return biquadMagnitudeDb(freq, fs, coeffs);
}
/** RBJ Audio EQ Cookbook low-shelf biquad, exact magnitude response (dB). */
function lowShelfResponseDb(
freq: number,
f0: number,
gainDb: number,
q: number,
fs: number
): number {
if (gainDb === 0) return 0;
const A = 10 ** (gainDb / 40);
const w0 = (2 * Math.PI * f0) / fs;
const cosw0 = Math.cos(w0);
const alpha = Math.sin(w0) / (2 * q);
const sqrtA = Math.sqrt(A);
const twoSqrtAAlpha = 2 * sqrtA * alpha;
const coeffs: BiquadCoeffs = {
b0: A * (A + 1 - (A - 1) * cosw0 + twoSqrtAAlpha),
b1: 2 * A * (A - 1 - (A + 1) * cosw0),
b2: A * (A + 1 - (A - 1) * cosw0 - twoSqrtAAlpha),
a0: A + 1 + (A - 1) * cosw0 + twoSqrtAAlpha,
a1: -2 * (A - 1 + (A + 1) * cosw0),
a2: A + 1 + (A - 1) * cosw0 - twoSqrtAAlpha,
};
return biquadMagnitudeDb(freq, fs, coeffs);
}
/** RBJ Audio EQ Cookbook high-shelf biquad, exact magnitude response (dB). */
function highShelfResponseDb(
freq: number,
f0: number,
gainDb: number,
q: number,
fs: number
): number {
if (gainDb === 0) return 0;
const A = 10 ** (gainDb / 40);
const w0 = (2 * Math.PI * f0) / fs;
const cosw0 = Math.cos(w0);
const alpha = Math.sin(w0) / (2 * q);
const sqrtA = Math.sqrt(A);
const twoSqrtAAlpha = 2 * sqrtA * alpha;
const coeffs: BiquadCoeffs = {
b0: A * (A + 1 + (A - 1) * cosw0 + twoSqrtAAlpha),
b1: -2 * A * (A - 1 + (A + 1) * cosw0),
b2: A * (A + 1 + (A - 1) * cosw0 - twoSqrtAAlpha),
a0: A + 1 - (A - 1) * cosw0 + twoSqrtAAlpha,
a1: 2 * (A - 1 - (A + 1) * cosw0),
a2: A + 1 - (A - 1) * cosw0 - twoSqrtAAlpha,
};
return biquadMagnitudeDb(freq, fs, coeffs);
}
/** Magnitude response (dB) of a single PEQ band at a given frequency. */
function peqBandResponseDb(
freq: number,
band: PEQ,
sampleRate: number = DEFAULT_SAMPLE_RATE
): number {
if (band.bypass) return 0;
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. */
function peqTotalCurveDb(
freq: number,
bands: Record<PEQBand, PEQ>,
sampleRate: number = DEFAULT_SAMPLE_RATE
): number {
let sum = 0;
for (const band of Object.values(PEQBand)) {
sum += peqBandResponseDb(freq, bands[band], sampleRate);
}
return sum;
}
/**
* 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,
sampleRate = DEFAULT_SAMPLE_RATE,
}: {
freqs: number[];
bands: Record<PEQBand, PEQ>;
sampleRate?: number;
}): CurvePoint[] {
return freqs.map((freq) => ({
x: freq,
y: peqTotalCurveDb(freq, bands, sampleRate),
}));
}
// ----------- Phase Response -----------
function peakPhaseDeg(freq: number, f0: number, gainDb: number, q: number, fs: number): number {
if (gainDb === 0) return 0;
const A = 10 ** (gainDb / 40);
const w0 = (2 * Math.PI * f0) / fs;
const cosw0 = Math.cos(w0);
const alpha = (A * Math.sin(w0)) / (2 * q);
const coeffs: BiquadCoeffs = {
b0: 1 + alpha * A,
b1: -2 * cosw0,
b2: 1 - alpha * A,
a0: 1 + alpha / A,
a1: -2 * cosw0,
a2: 1 - alpha / A,
};
return biquadPhaseDeg(freq, fs, coeffs);
}
function lowShelfPhaseDeg(freq: number, f0: number, gainDb: number, q: number, fs: number): number {
if (gainDb === 0) return 0;
const A = 10 ** (gainDb / 40);
const w0 = (2 * Math.PI * f0) / fs;
const cosw0 = Math.cos(w0);
const alpha = Math.sin(w0) / (2 * q);
const sqrtA = Math.sqrt(A);
const twoSqrtAAlpha = 2 * sqrtA * alpha;
const coeffs: BiquadCoeffs = {
b0: A * (A + 1 - (A - 1) * cosw0 + twoSqrtAAlpha),
b1: 2 * A * (A - 1 - (A + 1) * cosw0),
b2: A * (A + 1 - (A - 1) * cosw0 - twoSqrtAAlpha),
a0: A + 1 + (A - 1) * cosw0 + twoSqrtAAlpha,
a1: -2 * (A - 1 + (A + 1) * cosw0),
a2: A + 1 + (A - 1) * cosw0 - twoSqrtAAlpha,
};
return biquadPhaseDeg(freq, fs, coeffs);
}
function highShelfPhaseDeg(
freq: number,
f0: number,
gainDb: number,
q: number,
fs: number
): number {
if (gainDb === 0) return 0;
const A = 10 ** (gainDb / 40);
const w0 = (2 * Math.PI * f0) / fs;
const cosw0 = Math.cos(w0);
const alpha = Math.sin(w0) / (2 * q);
const sqrtA = Math.sqrt(A);
const twoSqrtAAlpha = 2 * sqrtA * alpha;
const coeffs: BiquadCoeffs = {
b0: A * (A + 1 + (A - 1) * cosw0 + twoSqrtAAlpha),
b1: -2 * A * (A - 1 + (A + 1) * cosw0),
b2: A * (A + 1 + (A - 1) * cosw0 - twoSqrtAAlpha),
a0: A + 1 - (A - 1) * cosw0 + twoSqrtAAlpha,
a1: 2 * (A - 1 - (A + 1) * cosw0),
a2: A + 1 - (A - 1) * cosw0 - twoSqrtAAlpha,
};
return biquadPhaseDeg(freq, fs, coeffs);
}
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 cosw0 = Math.cos(w0);
const alpha = Math.sin(w0) / (2 * q);
const coeffs: BiquadCoeffs = {
b0: 1 - alpha,
b1: -2 * cosw0,
b2: 1 + alpha,
a0: 1 + alpha,
a1: -2 * cosw0,
a2: 1 - alpha,
};
return biquadPhaseDeg(freq, fs, coeffs);
}
function peqBandPhaseDeg(
freq: number,
band: PEQ,
sampleRate: number = DEFAULT_SAMPLE_RATE
): number {
if (band.bypass) return 0;
switch (band.filter_type) {
case PEQFilter.Peak:
return peakPhaseDeg(freq, band.frequency, band.gain, band.q, sampleRate);
case PEQFilter.LowShelf:
return lowShelfPhaseDeg(freq, band.frequency, band.gain, band.q, sampleRate);
case PEQFilter.HighShelf:
return highShelfPhaseDeg(freq, band.frequency, band.gain, band.q, sampleRate);
case PEQFilter.Lp6Db:
return lp6PhaseDeg(freq, band.frequency);
case PEQFilter.Hp6Db:
return hp6PhaseDeg(freq, band.frequency);
case PEQFilter.Lp12Db:
return lp12PhaseDeg(freq, band.frequency, band.q);
case PEQFilter.Hp12Db:
return hp12PhaseDeg(freq, band.frequency, band.q);
case PEQFilter.AllPass1:
return allPass1PhaseDeg(freq, band.frequency);
case PEQFilter.AllPass2:
return allPass2PhaseDeg(freq, band.frequency, band.q, sampleRate);
default:
return 0;
}
}
function peqTotalPhaseDeg(
freq: number,
bands: Record<PEQBand, PEQ>,
sampleRate: number = DEFAULT_SAMPLE_RATE
): number {
let sum = 0;
for (const band of Object.values(PEQBand)) {
sum += peqBandPhaseDeg(freq, bands[band], sampleRate);
}
return sum;
}
export function getPeqPhasePoints({
freqs,
bands,
sampleRate = DEFAULT_SAMPLE_RATE,
}: {
freqs: number[];
bands: Record<PEQBand, PEQ>;
sampleRate?: number;
}): CurvePoint[] {
return freqs.map((freq) => ({
x: freq,
y: peqTotalPhaseDeg(freq, bands, sampleRate),
}));
}
+25 -25
View File
@@ -1,25 +1,25 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src"], "include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
} }
+10 -10
View File
@@ -1,10 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"skipLibCheck": true, "skipLibCheck": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }
+32 -32
View File
@@ -1,32 +1,32 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
// @ts-expect-error process is a nodejs global // @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig(async () => ({ export default defineConfig(async () => ({
plugins: [react()], plugins: [react()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// //
// 1. prevent Vite from obscuring rust errors // 1. prevent Vite from obscuring rust errors
clearScreen: false, clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available // 2. tauri expects a fixed port, fail if that port is not available
server: { server: {
port: 1420, port: 1420,
strictPort: true, strictPort: true,
host: host || false, host: host || false,
hmr: host hmr: host
? { ? {
protocol: "ws", protocol: "ws",
host, host,
port: 1421, port: 1421,
} }
: undefined, : undefined,
watch: { watch: {
// 3. tell Vite to ignore watching `src-tauri` // 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"], ignored: ["**/src-tauri/**"],
}, },
}, },
})); }));