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
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+6 -6
View File
@@ -1,7 +1,7 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"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
This template should help get you started developing with Tauri, React and Typescript in Vite.
## 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)
# TODO
- Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly
# Tauri + React + Typescript
This template should help get you started developing with Tauri, React and Typescript in Vite.
## 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)
# TODO
- Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly
- Check render to improve perfoormances
+14 -14
View File
@@ -1,14 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</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",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css}\""
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"prettier": "^3.9.6",
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
}
{
"name": "thomann-dsp-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css}\""
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"prettier": "^3.9.6",
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
}
+7 -7
View File
@@ -1,7 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+3 -3
View File
@@ -1,3 +1,3 @@
fn main() {
tauri_build::build()
}
fn main() {
tauri_build::build()
}
+10 -10
View File
@@ -1,10 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
+6 -6
View File
@@ -1,6 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
thomann_dsp_app_lib::run()
}
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
thomann_dsp_app_lib::run()
}
+37 -37
View File
@@ -1,37 +1,37 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "thomann-dsp-app",
"version": "0.1.0",
"identifier": "com.lucas.thomann-dsp-app",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "thomann-dsp-app",
"width": 1600,
"height": 950,
"minWidth": 1200,
"minHeight": 600
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "thomann-dsp-app",
"version": "0.1.0",
"identifier": "com.lucas.thomann-dsp-app",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "thomann-dsp-app",
"width": 1600,
"height": 950,
"minWidth": 1200,
"minHeight": 600
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"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 Button from '../../../../components/Button/Button';
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
import './GainSection.css';
import { DiscreteFrequency } from '../../types';
import {
DISCRETE_FREQUENCIES,
GEQ_BANDS_LABEL,
GRAPH_X_MAX,
GRAPH_X_MIN,
GRAPH_Y_MAX,
@@ -16,7 +14,13 @@ import {
OUTPUT_ORDER,
} from '../../constants';
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 = [
'#4ade80',
@@ -29,6 +33,9 @@ const CHANNEL_COLORS = [
'#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) => {
@@ -37,6 +44,127 @@ function buildColorMap<T extends string>(channels: T[]): Record<T, string> {
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({
dsp,
channel,
@@ -44,6 +172,7 @@ function GainPanel({
setGain,
setMute,
setInverse,
onLiveGain,
}: {
dsp: DSP;
channel: Channel;
@@ -51,6 +180,7 @@ function GainPanel({
setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
onLiveGain: (value: number | null) => void;
}) {
const channelState =
'Input' in channel
@@ -69,6 +199,18 @@ function GainPanel({
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 (
<div className="gain-panel">
<div className="gain-panel__title">{title}</div>
@@ -80,7 +222,8 @@ function GainPanel({
unit=" dB"
value={gain}
formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))}
onChange={(value) => setGain(channel, value)}
onInput={handleLiveValue}
onChange={commitGain}
/>
<Button
@@ -164,14 +307,40 @@ function GainSection({
setMute: (channel: Channel, muted: 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 outputColors = buildColorMap(OUTPUT_ORDER);
const [selectedInputs, setSelectedInputs] = useState<Set<InputChannel>>(new Set(INPUT_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(
(acc, channel) => ({
...acc,
@@ -180,6 +349,14 @@ function GainSection({
{} 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);
@@ -196,20 +373,52 @@ function GainSection({
});
};
// const inputOverlaySpecs: OverlayChannelSpec<InputChannel>[] = INPUT_ORDER.filter((c) =>
// selectedInputs.has(c)
// ).map((channel) => ({ channel, color: inputColors[channel] }));
const freqs = logSpace(GRAPH_X_MIN, GRAPH_X_MAX, 1000);
// const outputOverlaySpecs: OverlayChannelSpec<OutputChannel>[] = OUTPUT_ORDER.filter((c) =>
// selectedOutputs.has(c)
// ).map((channel) => ({ channel, color: outputColors[channel] }));
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 (
<>
<div className="gain-linear-graph-section">
<div className="gain-graph-panel">
<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
channels={INPUT_ORDER}
labels={inputLabels}
@@ -218,48 +427,75 @@ function GainSection({
onToggle={toggleInput}
/>
</div>
{/* <LinearGraph
<LinearGraph
xMin={GRAPH_X_MIN}
xMax={GRAPH_X_MAX}
xScale="log"
yMin={GRAPH_Y_MIN}
yMax={GRAPH_Y_MAX}
yStep={3}
yMin={inputYMin}
yMax={inputYMax}
yStep={Y_AXIS_STEP}
xLabel="Hz"
yLabel="dB"
showGrid={true}
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={buildInputMagnitudeOverlayDraw({
channels: inputOverlaySpecs,
getGains: (channel) => dsp.state.current_config.input_states[channel].geq.gains,
getBypass: (channel) => dsp.state.current_config.input_states[channel].geq.bypass,
})}
/> */}
</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 }) => {
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
const [x0, y0] = toPx(20, 0);
const [x1, y1] = toPx(20000, 0);
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
drawCurves({
ctx,
toPx,
xMin: GRAPH_X_MIN,
xMax: GRAPH_X_MAX,
yMin: inputYMin,
yMax: inputYMax,
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 className="gain-section">
@@ -274,6 +510,7 @@ function GainSection({
setGain={setGain}
setMute={setMute}
setInverse={setInverse}
onLiveGain={(value) => setLiveInputGain(channel, value)}
/>
))}
</ChannelGroup>
@@ -288,6 +525,7 @@ function GainSection({
setGain={setGain}
setMute={setMute}
setInverse={setInverse}
onLiveGain={(value) => setLiveOutputGain(channel, value)}
/>
))}
</ChannelGroup>
@@ -297,4 +535,4 @@ function GainSection({
);
}
export default GainSection;
export default GainSection;
-1
View File
@@ -3,7 +3,6 @@ import {
CurveStyle,
Curve,
DEFAULT_CURVE_COLORS,
logSpace,
sumCurvePoints,
} from './curve';
-109
View File
@@ -319,115 +319,6 @@ function peqTotalPhaseDeg(
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({
freqs,
bands,
+25 -25
View File
@@ -1,25 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10 -10
View File
@@ -1,10 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+32 -32
View File
@@ -1,32 +1,32 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [react()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [react()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));