feat: update

This commit is contained in:
2026-09-06 12:45:27 +02:00
parent 75ac349790
commit 81a99b7173
20 changed files with 3633 additions and 3991 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": 1600, "width": 1600,
"height": 950, "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"
] ]
} }
} }
-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;
@@ -3,11 +3,9 @@ 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, GEQ_BANDS_LABEL,
GRAPH_X_MAX, GRAPH_X_MAX,
GRAPH_X_MIN, GRAPH_X_MIN,
GRAPH_Y_MAX, GRAPH_Y_MAX,
@@ -16,7 +14,13 @@ import {
OUTPUT_ORDER, OUTPUT_ORDER,
} from '../../constants'; } from '../../constants';
import { useState } from 'react'; import { useState } from 'react';
// import { OverlayChannelSpec, buildInputMagnitudeOverlayDraw } from '../../utils/overlayCurve'; 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 = [ const CHANNEL_COLORS = [
'#4ade80', '#4ade80',
@@ -29,6 +33,9 @@ const CHANNEL_COLORS = [
'#38bdf8', '#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> { function buildColorMap<T extends string>(channels: T[]): Record<T, string> {
const map = {} as Record<T, string>; const map = {} as Record<T, string>;
channels.forEach((ch, i) => { channels.forEach((ch, i) => {
@@ -37,6 +44,127 @@ function buildColorMap<T extends string>(channels: T[]): Record<T, string> {
return map; 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,
channel, channel,
@@ -44,6 +172,7 @@ function GainPanel({
setGain, setGain,
setMute, setMute,
setInverse, setInverse,
onLiveGain,
}: { }: {
dsp: DSP; dsp: DSP;
channel: Channel; channel: Channel;
@@ -51,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
@@ -69,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>
@@ -80,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
@@ -164,14 +307,40 @@ 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 currentChannel = dsp.state.current_config.input_states[InputChannel.InA];
const inputColors = buildColorMap(INPUT_ORDER); const inputColors = buildColorMap(INPUT_ORDER);
const outputColors = buildColorMap(OUTPUT_ORDER); const outputColors = buildColorMap(OUTPUT_ORDER);
const [selectedInputs, setSelectedInputs] = useState<Set<InputChannel>>(new Set(INPUT_ORDER)); const [selectedInputs, setSelectedInputs] = useState<Set<InputChannel>>(new Set(INPUT_ORDER));
const [selectedOutputs, setSelectedOutputs] = useState<Set<OutputChannel>>(new Set(OUTPUT_ORDER)); const [selectedOutputs, setSelectedOutputs] = useState<Set<OutputChannel>>(new Set(OUTPUT_ORDER));
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( const inputLabels: Record<InputChannel, string> = INPUT_ORDER.reduce(
(acc, channel) => ({ (acc, channel) => ({
...acc, ...acc,
@@ -180,6 +349,14 @@ function GainSection({
{} as Record<InputChannel, string> {} 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) => { const toggleInput = (channel: InputChannel) => {
setSelectedInputs((prev) => { setSelectedInputs((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -196,20 +373,52 @@ function GainSection({
}); });
}; };
// const inputOverlaySpecs: OverlayChannelSpec<InputChannel>[] = INPUT_ORDER.filter((c) => const freqs = logSpace(GRAPH_X_MIN, GRAPH_X_MAX, 1000);
// selectedInputs.has(c)
// ).map((channel) => ({ channel, color: inputColors[channel] }));
// const outputOverlaySpecs: OverlayChannelSpec<OutputChannel>[] = OUTPUT_ORDER.filter((c) => const inputCurves = buildCurvesForChannels(
// selectedOutputs.has(c) INPUT_ORDER,
// ).map((channel) => ({ channel, color: outputColors[channel] })); 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-row"> <div className="gain-graph-title-row">
<div className="gain-graph-title">Input Magnitude</div> <div className="gain-graph-title">Input Response</div>
<Button
variant="toggle"
className={showInputStages ? 'active' : ''}
onClick={() => setShowInputStages((v) => !v)}
>
Show stages
</Button>
<ChannelSelector <ChannelSelector
channels={INPUT_ORDER} channels={INPUT_ORDER}
labels={inputLabels} labels={inputLabels}
@@ -218,48 +427,75 @@ function GainSection({
onToggle={toggleInput} onToggle={toggleInput}
/> />
</div> </div>
{/* <LinearGraph <LinearGraph
xMin={GRAPH_X_MIN} xMin={GRAPH_X_MIN}
xMax={GRAPH_X_MAX} xMax={GRAPH_X_MAX}
xScale="log" xScale="log"
yMin={GRAPH_Y_MIN} yMin={inputYMin}
yMax={GRAPH_Y_MAX} yMax={inputYMax}
yStep={3} yStep={Y_AXIS_STEP}
xLabel="Hz" xLabel="Hz"
yLabel="dB" yLabel="dB"
showGrid={true} showGrid={true}
formatX={(v) => Math.round(v).toString()} formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()} formatY={(v) => Math.round(v).toString()}
draw={buildInputMagnitudeOverlayDraw({
channels: inputOverlaySpecs,
getGains: (channel) => dsp.state.current_config.input_states[channel].geq.gains,
getBypass: (channel) => dsp.state.current_config.input_states[channel].geq.bypass,
})}
/> */}
</div>
{/* <div className="gain-graph-panel">
<div className="gain-graph-title">Phase Response</div>
<LinearGraph
xMin={20}
xMax={20000}
yMin={-180}
yMax={180}
yStep={45}
xLabel="Hz"
yLabel="deg"
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: inputYMin,
ctx.lineTo(x1, y1); yMax: inputYMax,
ctx.stroke(); curves: inputCurves,
});
}} }}
/> />
</div> */} </div>
<div className="gain-graph-panel">
<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
xMin={GRAPH_X_MIN}
xMax={GRAPH_X_MAX}
xScale="log"
yMin={outputYMin}
yMax={outputYMax}
yStep={Y_AXIS_STEP}
xLabel="Hz"
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: outputYMin,
yMax: outputYMax,
curves: outputCurves,
});
}}
/>
</div>
</div> </div>
<div className="gain-section"> <div className="gain-section">
@@ -274,6 +510,7 @@ function GainSection({
setGain={setGain} setGain={setGain}
setMute={setMute} setMute={setMute}
setInverse={setInverse} setInverse={setInverse}
onLiveGain={(value) => setLiveInputGain(channel, value)}
/> />
))} ))}
</ChannelGroup> </ChannelGroup>
@@ -288,6 +525,7 @@ function GainSection({
setGain={setGain} setGain={setGain}
setMute={setMute} setMute={setMute}
setInverse={setInverse} setInverse={setInverse}
onLiveGain={(value) => setLiveOutputGain(channel, value)}
/> />
))} ))}
</ChannelGroup> </ChannelGroup>
@@ -297,4 +535,4 @@ function GainSection({
); );
} }
export default GainSection; export default GainSection;
-1
View File
@@ -3,7 +3,6 @@ import {
CurveStyle, CurveStyle,
Curve, Curve,
DEFAULT_CURVE_COLORS, DEFAULT_CURVE_COLORS,
logSpace,
sumCurvePoints, sumCurvePoints,
} from './curve'; } from './curve';
-109
View File
@@ -319,115 +319,6 @@ function peqTotalPhaseDeg(
return sum; return sum;
} }
function unwrapPhaseDeg(phases: number[]): number[] {
if (phases.length === 0) return [];
const result = [phases[0]];
for (let i = 1; i < phases.length; i++) {
let phase = phases[i];
const previous = result[i - 1];
while (phase - previous > 180) {
phase -= 360;
}
while (phase - previous < -180) {
phase += 360;
}
result.push(phase);
}
return result;
}
// export function getPeqPhaseSegments({
// freqs,
// bands,
// sampleRate = DEFAULT_SAMPLE_RATE,
// }: {
// freqs: number[];
// bands: Record<PEQBand, PEQ>;
// sampleRate?: number;
// }): CurvePoint[][] {
// if (freqs.length === 0) return [];
// const rawPhases = freqs.map((freq) => peqTotalPhaseDeg(freq, bands, sampleRate));
// const phases = unwrapPhaseDeg(rawPhases);
// const wrapToDisplay = (p: number) => ((((p + 180) % 360) + 360) % 360) - 180;
// const segments: CurvePoint[][] = [[]];
// const pushPoint = (x: number, y: number) => {
// segments[segments.length - 1].push({ x, y });
// };
// let previousFreq = freqs[0];
// let previousPhase = phases[0];
// pushPoint(previousFreq, wrapToDisplay(previousPhase));
// for (let i = 1; i < freqs.length; i++) {
// const freq = freqs[i];
// const phase = phases[i];
// const logPrev = Math.log10(previousFreq);
// const logCurr = Math.log10(freq);
// const direction = phase >= previousPhase ? 1 : -1;
// const boundaries: number[] = [];
// if (direction > 0) {
// let b = Math.floor((previousPhase - 180) / 360) * 360 + 180;
// while (b <= previousPhase) {
// b += 360;
// }
// while (b < phase) {
// boundaries.push(b);
// b += 360;
// }
// } else {
// let b = Math.ceil((previousPhase - 180) / 360) * 360 + 180;
// while (b >= previousPhase) {
// b -= 360;
// }
// while (b > phase) {
// boundaries.push(b);
// b -= 360;
// }
// }
// for (const boundary of boundaries) {
// const t = (boundary - previousPhase) / (phase - previousPhase);
// const crossingFreq = Math.pow(10, logPrev + t * (logCurr - logPrev));
// const approach = direction > 0 ? 180 : -180;
// const depart = direction > 0 ? -180 : 180;
// // End current segment at ±180°.
// pushPoint(crossingFreq, approach);
// // Start a new segment at the opposite side of the display.
// segments.push([]);
// pushPoint(crossingFreq, depart);
// }
// pushPoint(freq, wrapToDisplay(phase));
// previousFreq = freq;
// previousPhase = phase;
// }
// return segments.filter((segment) => segment.length >= 2);
// }
export function getPeqPhasePoints({ export function getPeqPhasePoints({
freqs, freqs,
bands, bands,
+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/**"],
}, },
}, },
})); }));