Compare commits

...
54 Commits
Author SHA1 Message Date
LucasDLTG e151ab4f26 fix: bugs using real hardware 2026-10-04 21:01:09 +02:00
LucasDLTG e28f61447f fix: q value 2026-10-02 22:48:47 +02:00
LucasDLTG 388552f1c4 fix: matrix gain 2026-10-02 20:38:23 +02:00
LucasDLTG e68e73d3d5 fix: render error on input 2026-09-30 21:14:08 +02:00
Lucas a5669805c8 fix: make led focus the tab 2026-09-30 16:55:47 +02:00
Lucas cef7336517 feat: remember panel per dsp 2026-09-30 16:53:44 +02:00
Lucas 50a3c1a1e5 fix: handle abort password 2026-09-30 16:35:15 +02:00
Lucas 684f5ce866 fix: too wide misc panel 2026-09-30 16:08:42 +02:00
Lucas 80da40f678 fix: disconnect button 2026-09-30 12:42:14 +02:00
LucasDLTG 3cca2f41f4 fix: connection led 2026-09-29 20:56:34 +02:00
Lucas 6511d9d007 unstable 2026-09-29 16:59:54 +02:00
Lucas d5a2afdea5 feat: add locking styles 2026-09-28 16:41:16 +02:00
Lucas 24830172ae feat: add rename channel 2026-09-25 15:10:19 +02:00
LucasDLTG fd7dd89f55 feat: match bypass button color 2026-09-24 18:13:57 +02:00
LucasDLTG dac6c729cd feat: remove outdated commentaries 2026-09-23 21:44:26 +02:00
LucasDLTG d48155683b feat: disable inputs if peq dont use them 2026-09-23 20:03:39 +02:00
LucasDLTG e1d6398d57 feat: dont draw geq/peq curves if bypass 2026-09-22 21:05:59 +02:00
LucasDLTG 4aae47cd27 merge 2026-09-22 19:12:40 +02:00
LucasDLTG 61f821c3e9 feat: add confirmation windows for reset 2026-09-22 19:09:38 +02:00
Lucas b74857d7f6 feat: disable click when exiting select list 2026-09-21 15:58:42 +02:00
Lucas d3a783f77f feat: custom item list in selector 2026-09-21 15:25:21 +02:00
Lucas f965779373 feat: add notification for all dsp query 2026-09-21 14:16:04 +02:00
Lucas b0d4e372cb feat: update logo + app metadata 2026-09-21 10:57:14 +02:00
LucasDLTG e893cab082 fix: unused import 2026-09-20 21:41:09 +02:00
LucasDLTG 10b890b5ae feat: implement copy and link 2026-09-20 21:40:25 +02:00
LucasDLTG e05d589311 fix: command in lib.rs 2026-09-13 22:39:01 +02:00
LucasDLTG 00b6721c42 feat: add MiscellaneousPanel - need to implement UI 2026-09-13 19:09:47 +02:00
LucasDLTG 4f2bce1f5f fix: preset renaming 2026-09-13 17:13:03 +02:00
LucasDLTG 8c0b272ab9 refactor: reorder tauri commands and highlight missing 2026-09-13 16:24:08 +02:00
LucasDLTG 772a331f04 fix: hanging notifications 2026-09-13 15:55:11 +02:00
LucasDLTG 1643a887af feat: add peq output pages 2026-09-13 15:48:26 +02:00
LucasDLTG 8e9a59d026 feat: finalize input section 2026-09-13 14:25:00 +02:00
LucasDLTG 1792910ca1 fix: remove useless vars 2026-09-13 10:51:04 +02:00
LucasDLTG faa019d52b fix: matrix choice selector 2026-09-13 10:50:43 +02:00
LucasDLTG f6bc46dd98 fix; compressoor steppers 2026-09-13 10:06:48 +02:00
LucasDLTG 30e1c0b0a9 feat: wheel behaviour on choiceselect 2026-09-13 09:08:42 +02:00
LucasDLTG 9f2ace9698 feat: use new meter format 2026-09-13 09:04:04 +02:00
LucasDLTG 1e00ca3a7c feat: remove state and let rust handle 2026-09-12 19:37:59 +02:00
LucasDLTG 2717dfe99a feat: prevent text selection on dsp sub pages 2026-09-12 15:19:57 +02:00
LucasDLTG fd545fb2a8 feat: prevent text selection on main pages 2026-09-12 14:14:47 +02:00
LucasDLTG 3793ee9f5b feat: prevent text selection on add dsp page 2026-09-12 13:35:02 +02:00
LucasDLTG 014ccf5197 fix: style of add page selector 2026-09-12 11:52:36 +02:00
LucasDLTG 0f198692ad fix: draw over bounds 2026-09-12 11:22:27 +02:00
LucasDLTG 19c3aedf80 fix: panel on all pages 2026-09-12 10:12:17 +02:00
LucasDLTG 9589f6f3f0 fix: gate panels on linux 2026-09-11 20:27:55 +02:00
LucasDLTG 037d17ff3d feat: graph header on gain panel 2026-09-09 20:56:35 +02:00
LucasDLTG 20182a8e36 fix: font weigth on 'add page' 2026-09-09 17:24:12 +02:00
LucasDLTG 8f5a7c22cc fix: linux display main page 2026-09-08 21:57:38 +02:00
LucasDLTG ac23f98fcd feat: push Topbar 2026-09-06 12:54:45 +02:00
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
139 changed files with 10402 additions and 6060 deletions
+20
View File
@@ -7,5 +7,25 @@ This template should help get you started developing with Tauri, React and Types
- [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
- Check in meter if clip bool is limit instead
- On close disconenct all ds^p
- Check end to end behavioour
- Roll back on scroll when 2 scroll
- test en dto end connection
Data: 10020001052f 31313131 10032a // lock
Data: 10020001052d 31313131 100328 // unlock fail
Data: 10020100032d 3100 10031f
Data: 10020001062d 0031313131 10032b // unlock success
Data: 10020100032d 0001 10032f
+34 -88
View File
@@ -1097,9 +1097,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1114,9 +1111,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1131,9 +1125,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1148,9 +1139,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1165,9 +1153,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1182,9 +1167,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1199,9 +1181,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1216,9 +1195,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1233,9 +1209,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1250,9 +1223,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1267,9 +1237,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1284,9 +1251,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1301,9 +1265,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1493,9 +1454,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1513,9 +1471,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1533,9 +1488,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1553,9 +1505,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1573,9 +1522,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1809,9 +1755,9 @@
} }
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.44", "version": "2.11.21",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
"integrity": "sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==", "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -1822,9 +1768,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.7", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
@@ -1832,13 +1778,13 @@
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.6", "version": "4.28.9",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz",
"integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1856,11 +1802,11 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.42", "baseline-browser-mapping": "^2.11.20",
"caniuse-lite": "^1.0.30001803", "caniuse-lite": "^1.0.30001810",
"electron-to-chromium": "^1.5.389", "electron-to-chromium": "^1.5.420",
"node-releases": "^2.0.51", "node-releases": "^2.0.54",
"update-browserslist-db": "^1.2.3" "update-browserslist-db": "^1.3.2"
}, },
"bin": { "bin": {
"browserslist": "cli.js" "browserslist": "cli.js"
@@ -1870,9 +1816,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001806", "version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1947,9 +1893,9 @@
"peer": true "peer": true
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.394", "version": "1.5.422",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.394.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz",
"integrity": "sha512-Wmt2Gm0o8JWBuGgmc4XZ0u9s1RaCRqhxP47phplmfg04+qypTUurpeJGP45A7Fhv7jdrrVH44PLlR9qXo37cVQ==", "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -2562,9 +2508,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2589,9 +2535,9 @@
"peer": true "peer": true
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.51", "version": "2.0.54",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -2694,9 +2640,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.21", "version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
"integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==", "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2714,7 +2660,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.16", "nanoid": "^3.3.18",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -2962,9 +2908,9 @@
} }
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
+190 -43
View File
@@ -175,7 +175,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.2", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -225,6 +225,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@@ -397,7 +403,7 @@ dependencies = [
"semver", "semver",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.19", "thiserror 2.0.21",
] ]
[[package]] [[package]]
@@ -453,6 +459,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.45" version = "0.4.45"
@@ -543,6 +560,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -618,9 +644,9 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.23.0" version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
dependencies = [ dependencies = [
"darling_core", "darling_core",
"darling_macro", "darling_macro",
@@ -628,26 +654,26 @@ dependencies = [
[[package]] [[package]]
name = "darling_core" name = "darling_core"
version = "0.23.0" version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
dependencies = [ dependencies = [
"ident_case", "ident_case",
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim", "strsim",
"syn 2.0.119", "syn 3.0.6",
] ]
[[package]] [[package]]
name = "darling_macro" name = "darling_macro"
version = "0.23.0" version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [ dependencies = [
"darling_core", "darling_core",
"quote", "quote",
"syn 2.0.119", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -661,6 +687,37 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "defmt"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror 2.0.21",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@@ -796,11 +853,13 @@ dependencies = [
name = "dsp_thomann" name = "dsp_thomann"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"rand",
"serde", "serde",
"serde_json", "serde_json",
"serde_with",
"strum", "strum",
"strum_macros", "strum_macros",
"thiserror 2.0.19", "thiserror 2.0.21",
] ]
[[package]] [[package]]
@@ -1254,6 +1313,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core",
] ]
[[package]] [[package]]
@@ -1761,6 +1821,60 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "jiff"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
dependencies = [
"defmt",
"jiff-core",
"jiff-static",
"jiff-tzdb-platform",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
"windows-link 0.2.1",
]
[[package]]
name = "jiff-core"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
dependencies = [
"defmt",
"log",
]
[[package]]
name = "jiff-static"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
dependencies = [
"jiff-core",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "jiff-tzdb"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
[[package]]
name = "jiff-tzdb-platform"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
dependencies = [
"jiff-tzdb",
]
[[package]] [[package]]
name = "jni" name = "jni"
version = "0.21.1" version = "0.21.1"
@@ -2004,7 +2118,7 @@ dependencies = [
"once_cell", "once_cell",
"png 0.18.1", "png 0.18.1",
"serde", "serde",
"thiserror 2.0.19", "thiserror 2.0.21",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -2492,6 +2606,21 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "portable-atomic-util"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
dependencies = [
"portable-atomic",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@@ -2605,6 +2734,23 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@@ -2628,7 +2774,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
"libredox", "libredox",
"thiserror 2.0.19", "thiserror 2.0.21",
] ]
[[package]] [[package]]
@@ -2648,7 +2794,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.2", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -2882,7 +3028,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.2", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -2917,7 +3063,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.2", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -2940,16 +3086,17 @@ dependencies = [
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.21.0" version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.23.1",
"bs58", "bs58",
"chrono", "chrono",
"hex", "hex",
"indexmap 1.9.3", "indexmap 1.9.3",
"indexmap 2.14.0", "indexmap 2.14.0",
"jiff",
"schemars 0.9.0", "schemars 0.9.0",
"schemars 1.2.1", "schemars 1.2.1",
"serde_core", "serde_core",
@@ -2960,14 +3107,14 @@ dependencies = [
[[package]] [[package]]
name = "serde_with_macros" name = "serde_with_macros"
version = "3.21.0" version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
dependencies = [ dependencies = [
"darling", "darling",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -3008,7 +3155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest",
] ]
@@ -3198,9 +3345,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "3.0.2" version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -3299,9 +3446,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]] [[package]]
name = "tauri" name = "tauri"
version = "2.11.5" version = "2.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" checksum = "6fa5bacdb9bbad5954af3d1bd6cf6ae9192cab1b2e270f4a07f904610b9e85f4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -3338,7 +3485,7 @@ dependencies = [
"tauri-runtime", "tauri-runtime",
"tauri-runtime-wry", "tauri-runtime-wry",
"tauri-utils", "tauri-utils",
"thiserror 2.0.19", "thiserror 2.0.21",
"tokio", "tokio",
"tray-icon", "tray-icon",
"url", "url",
@@ -3389,7 +3536,7 @@ dependencies = [
"sha2", "sha2",
"syn 2.0.119", "syn 2.0.119",
"tauri-utils", "tauri-utils",
"thiserror 2.0.19", "thiserror 2.0.21",
"time", "time",
"url", "url",
"uuid", "uuid",
@@ -3428,9 +3575,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.4" version = "2.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91"
dependencies = [ dependencies = [
"dunce", "dunce",
"glob", "glob",
@@ -3442,7 +3589,7 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-plugin", "tauri-plugin",
"thiserror 2.0.19", "thiserror 2.0.21",
"url", "url",
"windows", "windows",
"zbus", "zbus",
@@ -3466,7 +3613,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tauri-utils", "tauri-utils",
"thiserror 2.0.19", "thiserror 2.0.21",
"url", "url",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
@@ -3529,7 +3676,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_with", "serde_with",
"swift-rs", "swift-rs",
"thiserror 2.0.19", "thiserror 2.0.21",
"toml 1.1.3+spec-1.1.0", "toml 1.1.3+spec-1.1.0",
"url", "url",
"urlpattern", "urlpattern",
@@ -3581,11 +3728,11 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.19" version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [ dependencies = [
"thiserror-impl 2.0.19", "thiserror-impl 2.0.21",
] ]
[[package]] [[package]]
@@ -3601,13 +3748,13 @@ dependencies = [
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.19" version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.2", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -3918,7 +4065,7 @@ dependencies = [
"once_cell", "once_cell",
"png 0.18.1", "png 0.18.1",
"serde", "serde",
"thiserror 2.0.19", "thiserror 2.0.21",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -4278,7 +4425,7 @@ version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [ dependencies = [
"thiserror 2.0.19", "thiserror 2.0.21",
"windows", "windows",
"windows-core 0.61.2", "windows-core 0.61.2",
] ]
@@ -4713,7 +4860,7 @@ dependencies = [
"sha2", "sha2",
"soup3", "soup3",
"tao-macros", "tao-macros",
"thiserror 2.0.19", "thiserror 2.0.21",
"url", "url",
"webkit2gtk", "webkit2gtk",
"webkit2gtk-sys", "webkit2gtk-sys",
+3 -3
View File
@@ -18,8 +18,8 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2.11.6", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2.5.5"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
dsp_thomann = { path = "../../dsp_thomann", features = ["serialization"] } dsp_thomann = { path = "../../dsp-thomann", features = ["serialization"] }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 61 KiB

+87
View File
@@ -0,0 +1,87 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="200"
height="200"
viewBox="0 0 200 200"
>
<defs>
<linearGradient
id="richGradient"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop offset="0%" stop-color="#1e3a8a" stop-opacity="1"/>
<stop offset="25%" stop-color="#2d3485"/>
<stop offset="45%" stop-color="#4c2e7f"/>
<stop offset="60%" stop-color="#6b2878"/>
<stop offset="80%" stop-color="#b9326e"/>
<stop offset="100%" stop-color="#ea437a" stop-opacity="1"/>
</linearGradient>
<radialGradient
id="darkenGradient"
cx="50%"
cy="50%"
r="50%"
>
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.3"/>
<stop offset="30%" stop-color="#ffffff" stop-opacity="0.1"/>
<stop offset="60%" stop-color="#000000" stop-opacity="0"/>
<stop offset="80%" stop-color="#000000" stop-opacity="0.2"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0.5"/>
</radialGradient>
<linearGradient
id="shadowGradient"
x1="20%"
y1="20%"
x2="80%"
y2="80%"
>
<stop offset="0%" stop-color="#0f1d42"/>
<stop offset="25%" stop-color="#1f2a5d"/>
<stop offset="45%" stop-color="#332f78"/>
<stop offset="60%" stop-color="#5f3458"/>
<stop offset="80%" stop-color="#7a2250"/>
<stop offset="100%" stop-color="#a50d57"/>
</linearGradient>
</defs>
<!-- Main donut -->
<path
d="
M100,20
A80,80 0 1,1 99.9,20
M100,60
A40,40 0 1,0 100.1,60
"
fill="url(#richGradient)"
fill-rule="evenodd"
/>
<!-- Darkening / depth overlay -->
<path
d="
M100,20
A80,80 0 1,1 99.9,20
M100,60
A40,40 0 1,0 100.1,60
"
fill="url(#darkenGradient)"
fill-rule="evenodd"
/>
<!-- Inner shadow -->
<path
d="
M100,60
A40,40 0 1,0 100.1,60
M100,75
A25,25 0 1,1 99.9,75
"
fill="url(#shadowGradient)"
fill-rule="evenodd"
/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 834 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+276 -193
View File
@@ -41,16 +41,28 @@ impl AppState {
} }
} }
#[derive(serde::Serialize)]
pub struct DSP408Snapshot {
pub state: types::DSPState,
pub input_meters: types::InputMeters,
pub output_meters: types::OutputMeters,
}
#[tauri::command] #[tauri::command]
pub fn create_dsp408( pub fn create_dsp408(
state: State<AppState>, state: State<AppState>,
ip: String, ip: String,
port: u16, port: u16,
device_id: u8, device_id: u8,
demo: bool,
) -> Result<u64, String> { ) -> Result<u64, String> {
let ip = IpAddr::from_str(&ip).map_err(|e| e.to_string())?; let ip = IpAddr::from_str(&ip).map_err(|e| e.to_string())?;
let dsp = DSP408::new(ip, port, device_id).map_err(|e| e.to_string())?; let dsp = if demo {
DSP408::new_demo()
} else {
DSP408::new(ip, port, device_id).map_err(|e| e.to_string())?
};
let mut next_id = state.next_id.lock().map_err(|e| e.to_string())?; let mut next_id = state.next_id.lock().map_err(|e| e.to_string())?;
@@ -82,14 +94,16 @@ pub async fn remove_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), St
} }
#[tauri::command] #[tauri::command]
pub async fn connect_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> { pub async fn get_dsp_snapshot(
state.with_device_mut(id, |device| device.dsp.connect().map_err(|e| e.to_string())) state: State<'_, AppState>,
} id: u64,
) -> Result<DSP408Snapshot, String> {
#[tauri::command]
pub async fn disconnect_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
device.dsp.disconnect().map_err(|e| e.to_string()) Ok(DSP408Snapshot {
state: device.dsp.state().clone(),
input_meters: device.dsp.input_meters().clone(),
output_meters: device.dsp.output_meters().clone(),
})
}) })
} }
@@ -99,63 +113,39 @@ pub async fn get_dsp_state(
id: u64, id: u64,
) -> Result<dsp_thomann::dsp408::types::DSPState, String> { ) -> Result<dsp_thomann::dsp408::types::DSPState, String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
let dsp_state = device.dsp.state().map_err(|e| e.to_string())?; let dsp_state = device.dsp.state();
Ok(dsp_state.clone()) Ok(dsp_state.clone())
}) })
} }
// Commands
#[tauri::command] #[tauri::command]
pub async fn set_channel_gain( pub async fn connect_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
state: State<'_, AppState>, state.with_device_mut(id, |device| device.dsp.connect().map_err(|e| e.to_string()))
id: u64, }
channel: dsp_thomann::dsp408::types::Channel,
db: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
let r = device
.dsp
.set_channel_gain(channel, db)
.map_err(|e| e.to_string())?;
Ok(r) // Command `0x11`
#[tauri::command]
pub async fn disconnect_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
state.with_device_mut(id, |device| {
device.dsp.disconnect().map_err(|e| e.to_string())
}) })
} }
// Command `0x15`
#[tauri::command] #[tauri::command]
pub async fn set_mute( pub async fn set_delay_unit(
state: State<'_, AppState>, state: State<'_, AppState>,
id: u64, id: u64,
channel: dsp_thomann::dsp408::types::Channel, unit: types::DelayUnit,
muted: bool,
) -> Result<bool, String> { ) -> Result<bool, String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
let r = device device.dsp.set_delay_unit(unit).map_err(|e| e.to_string())
.dsp
.set_mute(channel, muted)
.map_err(|e| e.to_string())?;
Ok(r)
})
}
#[tauri::command]
pub async fn set_channel_inverse_gain(
state: State<'_, AppState>,
id: u64,
channel: dsp_thomann::dsp408::types::Channel,
inverted: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
let r = device
.dsp
.set_channel_inverse_gain(channel, inverted)
.map_err(|e| e.to_string())?;
Ok(r)
}) })
} }
// Command `0x20`
#[tauri::command] #[tauri::command]
pub async fn recall_preset( pub async fn recall_preset(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -172,16 +162,35 @@ pub async fn recall_preset(
}) })
} }
// Command `0x21` -- TODO
#[tauri::command] #[tauri::command]
pub async fn get_meter_levels( pub async fn store_current_configuration(
state: State<'_, AppState>, state: State<'_, AppState>,
id: u64, id: u64,
) -> Result<dsp_thomann::dsp408::types::Meters, String> { preset_index: usize,
force: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
device.dsp.get_meter_levels().map_err(|e| e.to_string()) device
.dsp
.store_current_configuration(preset_index, force)
.map_err(|e| e.to_string())
}) })
} }
// Command `0x23`
#[tauri::command]
pub async fn factory_reset(
state: State<'_, AppState>,
id: u64,
force: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device.dsp.factory_reset(force).map_err(|e| e.to_string())
})
}
// Command `0x26`
#[tauri::command] #[tauri::command]
pub async fn set_current_preset_name( pub async fn set_current_preset_name(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -196,24 +205,53 @@ pub async fn set_current_preset_name(
}) })
} }
// Command `0x2A`
#[tauri::command] #[tauri::command]
pub async fn set_gate( pub async fn copy_channel(
state: State<'_, AppState>, state: State<'_, AppState>,
id: u64, id: u64,
channel: types::InputChannel, channel_1: types::Channel,
threshold: f32, channel_2: types::Channel,
attack: u16,
hold: u16,
release: u16,
) -> Result<bool, String> { ) -> Result<bool, String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
device device
.dsp .dsp
.set_gate(channel, threshold, attack, hold, release) .copy_channel(channel_1, channel_2)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
}) })
} }
// Command `0x2D` -- TODO
#[tauri::command]
pub async fn unlock_device(
state: State<'_, AppState>,
id: u64,
password: String,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.unlock_device(&password)
.map_err(|e| e.to_string())
})
}
// Command `0x2F`
#[tauri::command]
pub async fn lock_device(
state: State<'_, AppState>,
id: u64,
password: String,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.lock_device(&password)
.map_err(|e| e.to_string())
})
}
// Command `0x30`
#[tauri::command] #[tauri::command]
pub async fn set_compressor( pub async fn set_compressor(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -233,112 +271,7 @@ pub async fn set_compressor(
}) })
} }
#[tauri::command] // Command `0x31`
pub async fn set_limiter(
state: State<'_, AppState>,
id: u64,
channel: types::OutputChannel,
threshold: f32,
attack: u16,
release: u16,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_limiter(channel, threshold, attack, release)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_delay_unit(
state: State<'_, AppState>,
id: u64,
unit: types::DelayUnit,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device.dsp.set_delay_unit(unit).map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_delay(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
delay: f32,
unit: DelayUnit,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_delay(channel, delay, unit)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_matrix_routing(
state: State<'_, AppState>,
id: u64,
output: types::OutputChannel,
inputs: Vec<types::InputChannel>,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_matrix_routing(output, inputs)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_matrix_gain(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
output_channel: types::OutputChannel,
gain: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_matrix_gain(input_channel, output_channel, gain)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_geq_band(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
frequency_band: types::DiscreteFrequency,
gain: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_geq_band(input_channel, frequency_band, gain)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_geq_bypass(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
bypass: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_geq_bypass(input_channel, bypass)
.map_err(|e| e.to_string())
})
}
#[tauri::command] #[tauri::command]
pub async fn set_low_pass( pub async fn set_low_pass(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -355,6 +288,7 @@ pub async fn set_low_pass(
}) })
} }
// Command `0x32`
#[tauri::command] #[tauri::command]
pub async fn set_high_pass( pub async fn set_high_pass(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -371,6 +305,7 @@ pub async fn set_high_pass(
}) })
} }
// Command `0x33`
#[tauri::command] #[tauri::command]
pub async fn set_peq_band( pub async fn set_peq_band(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -391,47 +326,78 @@ pub async fn set_peq_band(
}) })
} }
// Command `0x34`
#[tauri::command] #[tauri::command]
pub async fn store_current_configuration( pub async fn set_channel_gain(
state: State<'_, AppState>, state: State<'_, AppState>,
id: u64, id: u64,
preset_index: usize, channel: dsp_thomann::dsp408::types::Channel,
force: bool, db: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
let r = device
.dsp
.set_channel_gain(channel, db)
.map_err(|e| e.to_string())?;
Ok(r)
})
}
// Command `0x35`
#[tauri::command]
pub async fn set_mute(
state: State<'_, AppState>,
id: u64,
channel: dsp_thomann::dsp408::types::Channel,
muted: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
let r = device
.dsp
.set_mute(channel, muted)
.map_err(|e| e.to_string())?;
Ok(r)
})
}
// Command `0x36`
#[tauri::command]
pub async fn set_channel_inverse_gain(
state: State<'_, AppState>,
id: u64,
channel: dsp_thomann::dsp408::types::Channel,
inverted: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
let r = device
.dsp
.set_channel_inverse_gain(channel, inverted)
.map_err(|e| e.to_string())?;
Ok(r)
})
}
// Command `0x38`
#[tauri::command]
pub async fn set_delay(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
delay: f32,
unit: DelayUnit,
) -> Result<bool, String> { ) -> Result<bool, String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
device device
.dsp .dsp
.store_current_configuration(preset_index, force) .set_delay(channel, delay, unit)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn factory_reset(
state: State<'_, AppState>,
id: u64,
force: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device.dsp.factory_reset(force).map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn copy_channel(
state: State<'_, AppState>,
id: u64,
channel_1: types::Channel,
channel_2: types::Channel,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.copy_channel(channel_1, channel_2)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
}) })
} }
// Command `0x39`
#[tauri::command] #[tauri::command]
pub async fn set_input_source( pub async fn set_input_source(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -446,6 +412,23 @@ pub async fn set_input_source(
}) })
} }
// Command `0x3A`
#[tauri::command]
pub async fn set_matrix_routing(
state: State<'_, AppState>,
id: u64,
output: types::OutputChannel,
inputs: Vec<types::InputChannel>,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_matrix_routing(output, inputs)
.map_err(|e| e.to_string())
})
}
// Command `0x3B`
#[tauri::command] #[tauri::command]
pub async fn link_channels( pub async fn link_channels(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -461,6 +444,7 @@ pub async fn link_channels(
}) })
} }
// Command `0x3C`
#[tauri::command] #[tauri::command]
pub async fn set_bypass_peq( pub async fn set_bypass_peq(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -476,6 +460,7 @@ pub async fn set_bypass_peq(
}) })
} }
// Command `0x3D` -- TODO
#[tauri::command] #[tauri::command]
pub async fn set_channel_name( pub async fn set_channel_name(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -490,3 +475,101 @@ pub async fn set_channel_name(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
}) })
} }
// Command `0x3E`
#[tauri::command]
pub async fn set_gate(
state: State<'_, AppState>,
id: u64,
channel: types::InputChannel,
threshold: f32,
attack: u16,
hold: u16,
release: u16,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_gate(channel, threshold, attack, hold, release)
.map_err(|e| e.to_string())
})
}
// Command `0x3F`
#[tauri::command]
pub async fn set_limiter(
state: State<'_, AppState>,
id: u64,
channel: types::OutputChannel,
threshold: f32,
attack: u16,
release: u16,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_limiter(channel, threshold, attack, release)
.map_err(|e| e.to_string())
})
}
// Command `0x40`
#[tauri::command]
pub async fn get_meter_levels(
state: State<'_, AppState>,
id: u64,
) -> Result<(types::InputMeters, types::OutputMeters), String> {
state.with_device_mut(id, |device| {
device.dsp.get_meter_levels().map_err(|e| e.to_string())
})
}
// Command `0x41`
#[tauri::command]
pub async fn set_matrix_gain(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
output_channel: types::OutputChannel,
gain: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_matrix_gain(input_channel, output_channel, gain)
.map_err(|e| e.to_string())
})
}
// Command `0x48`
#[tauri::command]
pub async fn set_geq_band(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
frequency_band: types::DiscreteFrequency,
gain: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_geq_band(input_channel, frequency_band, gain)
.map_err(|e| e.to_string())
})
}
// Command `0x49`
#[tauri::command]
pub async fn set_geq_bypass(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
bypass: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_geq_bypass(input_channel, bypass)
.map_err(|e| e.to_string())
})
}
+3
View File
@@ -11,6 +11,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::create_dsp408, commands::create_dsp408,
commands::remove_dsp408, commands::remove_dsp408,
commands::get_dsp_snapshot,
commands::get_dsp_state, commands::get_dsp_state,
commands::connect_dsp408, // 0x10 commands::connect_dsp408, // 0x10
commands::disconnect_dsp408, // 0x11 commands::disconnect_dsp408, // 0x11
@@ -20,6 +21,8 @@ pub fn run() {
commands::factory_reset, // 0x23 commands::factory_reset, // 0x23
commands::set_current_preset_name, // 0x26 commands::set_current_preset_name, // 0x26
commands::copy_channel, // 0x2A commands::copy_channel, // 0x2A
commands::unlock_device, // Command `0x2D`
commands::lock_device, // Command `0x2F`
commands::set_compressor, // 0x30 commands::set_compressor, // 0x30
commands::set_low_pass, // 0x31 commands::set_low_pass, // 0x31
commands::set_high_pass, // 0x32 commands::set_high_pass, // 0x32
+8 -8
View File
@@ -12,9 +12,9 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "thomann-dsp-app", "title": "Thomann DSP Controller",
"width": 1200, "width": 1600,
"height": 800, "height": 950,
"minWidth": 1200, "minWidth": 1200,
"minHeight": 600 "minHeight": 600
} }
@@ -27,11 +27,11 @@
"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"
] ]
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import './App.css'; import './App.css';
import { DSP, AppPage } from './types'; import { DSP, AppPage, isConnected } from './types';
import TopBar from './components/Topbar/Topbar'; import TopBar from './components/Topbar/Topbar';
import DSPPage from './pages/DspPage/DspPage'; import DSPPage from './pages/DspPage/DspPage';
import AddDSPForm from './pages/AddDspPage/AddDspPage'; import AddDSPForm from './pages/AddDspPage/AddDspPage';
@@ -17,7 +17,7 @@ function App() {
const [selected, setSelected] = useState<number | null>(null); const [selected, setSelected] = useState<number | null>(null);
const [page, setPage] = useState<AppPage>('home'); const [page, setPage] = useState<AppPage>('home');
const { notifications, notify, removeNotification, hoverNotification } = useNotifications(); const { notifications, notify, removeNotification, hoverNotification } = useNotifications();
const connectedDspCount = dsps.filter((dsp) => dsp.status === 'connected').length; const connectedDspCount = dsps.filter((dsp) => isConnected(dsp)).length;
const activeDsp = dsps.find((d) => d.id === selected) ?? null; const activeDsp = dsps.find((d) => d.id === selected) ?? null;
const activeInstance = activeDsp ? createDSPInstance(activeDsp, setDsps, notify) : null; const activeInstance = activeDsp ? createDSPInstance(activeDsp, setDsps, notify) : null;
+149
View File
@@ -84,3 +84,152 @@ export function TextIcon({
</svg> </svg>
); );
} }
export function SlidersVerticalIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M10 8H14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M12 21V12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M12 8V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M17 16H21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M19 12V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M19 21V16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M3 14H7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M5 10V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M5 21V14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function BoltIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="2" />
</svg>
);
}
export function GainIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M3 12H7M17 12H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7 4.5L17 12L7 19.5V4.5Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function GateIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M3 3V21H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.35"
/>
<path
d="M3 21H12L20 5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function CompressorIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M3 3V21H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.35"
/>
<path
d="M3 21L11 13L21 8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function LimiterIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M3 3V21H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.35"
/>
<path
d="M3 21L11 8H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function DelayIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M4 5V19M12 8.5V15.5M20 11V13"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function MatrixIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M3 7C11 7 11 4 21 4" />
<path d="M3 7C11 7 11 20 21 20" />
<path d="M3 17C11 17 11 12 21 12" />
<circle cx="3" cy="7" r="1.6" fill="none" />
<circle cx="3" cy="17" r="1.6" fill="none" />
<circle cx="21" cy="4" r="1.6" fill="none" />
<circle cx="21" cy="12" r="1.6" fill="none" />
<circle cx="21" cy="20" r="1.6" fill="none" />
</svg>
);
}
+42
View File
@@ -0,0 +1,42 @@
export function EyeIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M2 12C2 12 5.5 5 12 5C18.5 5 22 12 22 12C22 12 18.5 19 12 19C5.5 19 2 12 2 12Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" />
</svg>
);
}
export function EyeOffIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M3 3L21 21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10.6 5.2C11.05 5.07 11.52 5 12 5C18.5 5 22 12 22 12C22 12 20.7 14.6 18.3 16.5M6.2 6.2C3.5 8.1 2 12 2 12C2 12 5.5 19 12 19C13.2 19 14.3 18.8 15.3 18.4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.9 9.9C9.35 10.45 9 11.2 9 12C9 13.66 10.34 15 12 15C12.8 15 13.55 14.65 14.1 14.1"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+2 -2
View File
@@ -2,7 +2,7 @@ export function TriangleUpIcon(props: React.SVGProps<SVGSVGElement>) {
return ( return (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}> <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path <path
d="M8 3L13 10H3L8 3Z" d="M8 3L2 12H14L8 3Z"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.5"
fill="transparent" fill="transparent"
@@ -16,7 +16,7 @@ export function TriangleDownIcon(props: React.SVGProps<SVGSVGElement>) {
return ( return (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}> <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path <path
d="M8 13L3 6H13L8 13Z" d="M8 13L2 4H14L8 13Z"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.5"
fill="transparent" fill="transparent"
+39 -11
View File
@@ -2,23 +2,21 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-sizing: border-box; box-sizing: border-box;
min-height: var(--card-control-height); min-height: var(--card-control-height);
padding: 0 var(--space-4); padding: 0 var(--space-4);
border: var(--border-width) solid transparent; border: var(--border-width) solid transparent;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--font-md); font-size: 13px;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
cursor: pointer; cursor: pointer;
user-select: none;
font-size: 13px; -webkit-user-select: none;
} }
.btn:disabled { .btn:disabled {
@@ -26,27 +24,54 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* ------------------------------------------------------------------ */
/* Variants */
/* ------------------------------------------------------------------ */
.btn-primary { .btn-primary {
background: var(--accent-brand); background: var(--accent-brand);
color: var(--bg-void); color: var(--bg-void);
} }
/* Ghost + Toggle */
.btn-ghost, .btn-ghost,
.btn-toggle, .btn-toggle {
.btn-outline {
background: transparent; background: transparent;
border-color: var(--border-hairline); border-color: var(--border-hairline);
color: var(--text-muted); color: var(--text-muted);
} }
/* Outline */
.btn-outline {
background: transparent;
border-color: var(--accent-brand);
color: #fff;
}
/* ------------------------------------------------------------------ */
/* Hover */
/* ------------------------------------------------------------------ */
.btn-primary:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent-brand) 95%, white);
color: var(--text-primary);
}
.btn-ghost:hover:not(:disabled), .btn-ghost:hover:not(:disabled),
.btn-toggle:hover:not(:disabled), .btn-toggle:hover:not(:disabled) {
.btn-outline:hover:not(:disabled) {
background: var(--bg-raised); background: var(--bg-raised);
color: var(--text-primary); color: var(--text-primary);
} }
/* Active toggle states */ .btn-outline:hover:not(:disabled) {
border-color: var(--accent-brand);
color: var(--text-muted);
}
/* ------------------------------------------------------------------ */
/* Active toggle states */
/* ------------------------------------------------------------------ */
.btn-toggle.active-info { .btn-toggle.active-info {
background: var(--accent-brand); background: var(--accent-brand);
border-color: var(--accent-brand); border-color: var(--accent-brand);
@@ -65,7 +90,10 @@
color: var(--bg-void); color: var(--bg-void);
} }
/* Optional: active state hover */ /* ------------------------------------------------------------------ */
/* Active toggle hover */
/* ------------------------------------------------------------------ */
.btn-toggle.active-info:hover:not(:disabled) { .btn-toggle.active-info:hover:not(:disabled) {
background: var(--accent-brand); background: var(--accent-brand);
border-color: var(--accent-brand); border-color: var(--accent-brand);
+10 -4
View File
@@ -1,18 +1,21 @@
.channel-group { .channel-group {
position: relative; position: relative;
/* flex: 1 1 0; */
min-height: 0;
flex: 0 0 auto;
width: fit-content;
min-width: 0;
min-height: 0;
display: flex; display: flex;
border: var(--border-width) solid var(--border-hairline); border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 6cqh var(--space-3) 1cqh; padding: 6cqh var(--space-3) 1cqh;
margin-bottom: 1cqh; margin-bottom: 1cqh;
margin-top: 1cqh; margin-top: 1cqh;
box-sizing: border-box; box-sizing: border-box;
overflow: visible; /* in case something upstream set hidden */ overflow: visible;
} }
.channel-group__label { .channel-group__label {
@@ -36,6 +39,9 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: stretch; align-items: stretch;
width: fit-content;
height: 100%; height: 100%;
gap: var(--space-1); gap: var(--space-1);
} }
@@ -0,0 +1,125 @@
/* ChoiceSelect.css */
.choice-select {
position: relative;
display: flex;
color: var(--accent-brand);
}
.choice-select[data-disabled] {
opacity: 0.5;
}
.choice-select__trigger {
flex: 1 1 auto;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0;
padding-inline: 5%;
background: transparent;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 90%;
color: inherit;
cursor: pointer;
transition: border-color var(--dur-base) var(--ease-standard);
}
.choice-select__trigger:hover,
.choice-select__trigger--open {
border-color: var(--accent-brand);
}
.choice-select__trigger:disabled {
cursor: not-allowed;
}
.choice-select__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.choice-select__chevron {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 4cqh;
aspect-ratio: 1;
color: var(--accent-brand);
transition: transform var(--dur-base) var(--ease-standard);
}
.choice-select__trigger--open .choice-select__chevron {
transform: rotate(180deg);
}
.choice-select__chevron svg {
width: 50%;
height: auto;
display: block;
}
.choice-select__option {
padding: 1%;
border-radius: var(--radius-sm);
font-size: 100%;
color: var(--text-primary);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.choice-select__option--highlight {
background: var(--bg-raised);
}
.choice-select__option--selected {
color: var(--accent-brand);
font-weight: var(--font-weight-semibold);
}
.choice-select__listbox {
position: absolute;
left: 0;
right: 0;
z-index: 40;
top: calc(100% + var(--space-1));
margin: 0;
padding: var(--space-1);
list-style: none;
background: var(--bg-panel);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
max-height: 240px;
overflow-y: auto;
}
.choice-select__listbox--up {
top: auto;
bottom: calc(100% + var(--space-1));
}
.choice-select__listbox--portal {
position: fixed;
z-index: 1000; /* clear of any local stacking contexts in the panel */
}
@@ -0,0 +1,232 @@
// ChoiceSelect.tsx
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { TriangleDownIcon } from '../../assets/icons/Triangle';
import './ChoiceSelect.css';
type ChoiceSelectProps<T extends string> = {
value: T;
options: readonly T[];
formatLabel?: (value: T) => string;
onChange: (value: T) => void;
id?: string;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
};
const LISTBOX_MAX_HEIGHT = 240; // keep in sync with .choice-select__listbox max-height
const LISTBOX_GAP = 4; // px, ~= var(--space-1)
type Coords = { top: number; left: number; width: number; fontSize: number };
function ChoiceSelect<T extends string>({
value,
options,
formatLabel = (v) => v,
onChange,
id,
disabled,
className = '',
style,
}: ChoiceSelectProps<T>) {
const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState(0);
const [dropUp, setDropUp] = useState(false);
const [coords, setCoords] = useState<Coords | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const suppressNextClick = useRef(false);
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
const target = e.target as Node;
const clickedOutside =
rootRef.current &&
!rootRef.current.contains(target) &&
!listboxRef.current?.contains(target);
if (clickedOutside && open) {
setOpen(false);
suppressNextClick.current = true;
}
}
function handleClickCapture(e: MouseEvent) {
if (suppressNextClick.current) {
e.preventDefault();
e.stopPropagation();
suppressNextClick.current = false;
}
}
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('click', handleClickCapture, true); // capture phase
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('click', handleClickCapture, true);
};
}, [open]);
// Re-check placement/position on scroll/resize while open — the trigger's
// position can change (e.g. inside a scrollable panel), and since the
// listbox is portaled it won't move with it automatically.
useEffect(() => {
if (!open) return;
const recalc = () => updatePlacement();
window.addEventListener('scroll', recalc, true);
window.addEventListener('resize', recalc);
return () => {
window.removeEventListener('scroll', recalc, true);
window.removeEventListener('resize', recalc);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const step = (direction: 1 | -1) => {
const currentIndex = options.indexOf(value);
if (currentIndex === -1 || options.length === 0) return;
const nextIndex = (currentIndex + direction + options.length) % options.length;
onChange(options[nextIndex]);
};
function updatePlacement() {
const el = triggerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const needsFlip = spaceBelow < LISTBOX_MAX_HEIGHT && spaceAbove > spaceBelow;
setDropUp(needsFlip);
// Capture the trigger's resolved font-size (its cqh has already resolved
// correctly here, since it's still inside the real container tree) and
// carry it as a plain px value into the portal, where cqh would break.
const fontSize = parseFloat(getComputedStyle(el).fontSize);
setCoords({
top: needsFlip ? rect.top - LISTBOX_GAP : rect.bottom + LISTBOX_GAP,
left: rect.left,
width: rect.width,
fontSize,
});
}
function openList() {
if (disabled) return;
updatePlacement();
setOpen(true);
const idx = options.indexOf(value);
setHighlight(idx >= 0 ? idx : 0);
}
function pick(opt: T) {
if (opt !== value) onChange(opt);
setOpen(false);
triggerRef.current?.focus();
}
function handleKeyDown(e: React.KeyboardEvent) {
if (disabled) return;
if (!open) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault();
openList();
}
return;
}
if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
e.preventDefault();
setHighlight((h) => Math.min(h + 1, options.length - 1));
} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
e.preventDefault();
setHighlight((h) => Math.max(h - 1, 0));
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
pick(options[highlight]);
} else if (e.key === 'Escape') {
e.preventDefault();
setOpen(false);
triggerRef.current?.focus();
}
}
return (
<div
className={`choice-select ${className}`}
data-disabled={disabled || undefined}
ref={rootRef}
style={style}
>
<button
id={id}
type="button"
ref={triggerRef}
className={'choice-select__trigger' + (open ? ' choice-select__trigger--open' : '')}
onClick={() => (open ? setOpen(false) : openList())}
onKeyDown={handleKeyDown}
onWheel={(e) => {
e.preventDefault();
if (disabled) return;
if (e.deltaY < 0) step(-1);
else if (e.deltaY > 0) step(1);
}}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className="choice-select__label">{formatLabel(value)}</span>
<span className="choice-select__chevron" aria-hidden="true">
<TriangleDownIcon />
</span>
</button>
{open &&
coords &&
createPortal(
<ul
ref={listboxRef}
className={
'choice-select__listbox choice-select__listbox--portal' +
(dropUp ? ' choice-select__listbox--up' : '')
}
role="listbox"
style={{
fontSize: coords.fontSize,
left: coords.left,
width: coords.width,
// dropUp needs top: auto + bottom instead, see CSS note below
...(dropUp
? { top: 'auto', bottom: window.innerHeight - coords.top }
: { top: coords.top }),
}}
>
{options.map((option, i) => (
<li
key={option}
role="option"
aria-selected={option === value}
className={
'choice-select__option' +
(option === value ? ' choice-select__option--selected' : '') +
(i === highlight ? ' choice-select__option--highlight' : '')
}
onMouseEnter={() => setHighlight(i)}
onClick={() => pick(option)}
>
{formatLabel(option)}
</li>
))}
</ul>,
document.body
)}
</div>
);
}
export default ChoiceSelect;
+28 -16
View File
@@ -5,7 +5,6 @@
position: relative; position: relative;
display: flex; display: flex;
align-items: stretch; align-items: stretch;
justify-content: center;
border: var(--border-width) solid var(--border-hairline); border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -17,17 +16,14 @@
min-width: 0; min-width: 0;
width: 100%; width: 100%;
font-family: var(--font-ui); font-family: var(--font-mono);
font-size: 3cqh; font-size: var(--font--sm);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-normal);
text-align: center; text-align: left;
color: var(--accent-brand); color: var(--accent-brand);
background: transparent; background: transparent;
border: none; border: none;
border-radius: var(--radius-sm);
padding: 0.5ch;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -40,20 +36,21 @@
.choice-stepper__arrows { .choice-stepper__arrows {
flex: 0 0 auto; flex: 0 0 auto;
align-self: stretch;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
gap: 0.5cqh;
height: 100%; height: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 1cqh;
padding-left: 0; padding-right: 1%;
} }
.choice-stepper__arrow-btn { .choice-stepper__arrow-btn {
aspect-ratio: 1; height: 50%;
height: 4.5cqh; min-width: 0;
min-height: 0; min-height: 0;
display: flex; display: flex;
@@ -61,10 +58,11 @@
justify-content: center; justify-content: center;
padding: 0; padding: 0;
color: var(--accent-brand);
color: var(--accent-brand);
background: transparent; background: transparent;
border-radius: 1cqh; border: none;
border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
} }
@@ -75,6 +73,20 @@
} }
.choice-stepper__arrow-btn svg { .choice-stepper__arrow-btn svg {
width: 100%; display: block;
height: 100%; height: 100%;
} }
.choice-stepper[data-disabled] {
opacity: 0.45;
filter: grayscale(1);
}
.choice-stepper__input:disabled {
cursor: not-allowed;
}
.choice-stepper[data-disabled] .choice-stepper__input,
.choice-stepper[data-disabled] .choice-stepper__arrow-btn {
color: var(--border-hairline);
}
+376 -39
View File
@@ -1,54 +1,218 @@
import { useEffect, useState } from 'react'; import { JSX, useEffect, useRef, useState } from 'react';
import Button from '../Button/Button'; import Button from '../Button/Button';
import { TriangleUpIcon, TriangleDownIcon } from '../../assets/icons/Triangle'; import { TriangleUpIcon, TriangleDownIcon } from '../../assets/icons/Triangle';
import './ChoiceStepper.css'; import './ChoiceStepper.css';
type ChoiceStepperProps<T> = { const WHEEL_COMMIT_DELAY = 250;
// Safety net: if the parent's `value` prop never comes back exactly equal
// to what we committed (rounding, an intermediate transform, etc.), stop
// waiting after this long so the stepper doesn't get stuck ignoring
// external updates.
const PENDING_FALLBACK_MS = 1000;
type CommitResult = Promise<boolean | void> | boolean | void;
function useNonPassiveWheel(
ref: React.RefObject<HTMLElement | null>,
handler: (e: WheelEvent) => void
) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const el = ref.current;
if (!el) return;
const listener = (e: WheelEvent) => handlerRef.current(e);
el.addEventListener('wheel', listener, { passive: false });
return () => el.removeEventListener('wheel', listener);
}, [ref]);
}
function StepperArrows({
onUp,
onDown,
canUp,
canDown,
disabled,
}: {
onUp: () => void;
onDown: () => void;
canUp: boolean;
canDown: boolean;
disabled?: boolean;
}) {
return (
<div className="choice-stepper__arrows">
<Button
variant="toggle"
className="choice-stepper__arrow-btn"
onClick={onUp}
disabled={disabled || !canUp}
>
<TriangleUpIcon />
</Button>
<Button
variant="toggle"
className="choice-stepper__arrow-btn"
onClick={onDown}
disabled={disabled || !canDown}
>
<TriangleDownIcon />
</Button>
</div>
);
}
// ---- List variant ----
type ChoiceStepperListProps<T> = {
value: T; value: T;
values: readonly T[]; values: readonly T[];
formatLabel: (value: T) => string; formatLabel: (value: T) => string;
onChange: (value: T) => void; onChange: (value: T) => CommitResult;
id?: string;
disabled?: boolean;
className?: string;
}; };
function ChoiceStepper<T>({ value, values, formatLabel, onChange }: ChoiceStepperProps<T>) { function ChoiceStepperList<T>({
const index = values.indexOf(value); value,
values,
const canUp = index === -1 || index < values.length - 1; formatLabel,
const canDown = index > 0; onChange,
id,
disabled,
className,
}: ChoiceStepperListProps<T>) {
const [localValue, setLocalValue] = useState(value);
const [text, setText] = useState(formatLabel(value)); const [text, setText] = useState(formatLabel(value));
// True from the moment a commit starts until the prop `value` we get from
// the parent actually reflects it (not just until onChange resolves —
// those are two different moments and the gap between them is what was
// causing the old-value blink).
const interacting = useRef(false);
// The value we're waiting to see echoed back via the `value` prop.
const pendingValue = useRef<T | null>(null);
const wheelTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const fallbackTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useNonPassiveWheel(inputRef, (e) => {
e.preventDefault();
if (e.deltaY < 0 && canUp) {
stepWheel(1);
} else if (e.deltaY > 0 && canDown) {
stepWheel(-1);
}
});
useEffect(() => { useEffect(() => {
if (interacting.current) {
// Only release control once the prop actually catches up to what we
// committed — otherwise keep showing localValue as-is (no flash back
// to the stale old value in between).
if (pendingValue.current !== null && Object.is(value, pendingValue.current)) {
interacting.current = false;
pendingValue.current = null;
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
} else {
return;
}
}
setLocalValue(value);
setText(formatLabel(value)); setText(formatLabel(value));
}, [value, formatLabel]); }, [value, formatLabel]);
const step = (direction: 1 | -1) => { useEffect(() => {
const currentIndex = index === -1 ? 0 : index; return () => {
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
};
}, []);
const nextIndex = Math.min(values.length - 1, Math.max(0, currentIndex + direction)); const index = values.indexOf(localValue);
const canUp = index === -1 || index < values.length - 1;
const canDown = index > 0;
if (nextIndex !== currentIndex) { const stepLocal = (direction: 1 | -1): T | null => {
onChange(values[nextIndex]); const currentIndex = values.indexOf(localValue);
if (currentIndex === -1) return null;
const nextIndex = currentIndex + direction;
if (nextIndex < 0 || nextIndex >= values.length) return null;
const next = values[nextIndex];
setLocalValue(next);
setText(formatLabel(next));
return next;
};
async function commit(next: T) {
interacting.current = true;
pendingValue.current = next;
const result = await onChange(next);
if (result === false) {
interacting.current = false;
pendingValue.current = null;
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
setLocalValue(value);
setText(formatLabel(value));
return;
} }
// Accepted: don't clear `interacting` here. Wait for the prop-sync
// effect above to see `value === next` and release it, so we never
// hand control back to a still-stale prop. Guard with a timeout in
// case it never matches exactly.
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
fallbackTimeout.current = setTimeout(() => {
interacting.current = false;
pendingValue.current = null;
}, PENDING_FALLBACK_MS);
}
const stepAndCommit = (direction: 1 | -1) => {
const next = stepLocal(direction);
if (next !== null) void commit(next);
};
const stepWheel = (direction: 1 | -1) => {
const next = stepLocal(direction);
if (next === null) return;
interacting.current = true;
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
wheelTimeout.current = setTimeout(() => {
wheelTimeout.current = null;
void commit(next);
}, WHEEL_COMMIT_DELAY);
}; };
const commitText = () => { const commitText = () => {
const match = values.find( const match = values.find(
(v) => formatLabel(v).trim().toLowerCase() === text.trim().toLowerCase() (v) => formatLabel(v).trim().toLowerCase() === text.trim().toLowerCase()
); );
if (match !== undefined) { if (match !== undefined) {
onChange(match); setLocalValue(match);
void commit(match);
} else { } else {
setText(formatLabel(value)); setText(formatLabel(value));
} }
}; };
return ( return (
<div className="choice-stepper"> <div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
<input <input
ref={inputRef}
id={id}
className="choice-stepper__input" className="choice-stepper__input"
value={text} value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onBlur={commitText} onBlur={commitText}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -56,35 +220,208 @@ function ChoiceStepper<T>({ value, values, formatLabel, onChange }: ChoiceSteppe
e.currentTarget.blur(); e.currentTarget.blur();
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault(); e.preventDefault();
step(1); interacting.current = true;
stepLocal(1);
} else if (e.key === 'ArrowDown') { } else if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
step(-1); interacting.current = true;
stepLocal(-1);
} }
}} }}
onKeyUp={(e) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
void commit(localValue);
}}
/>
<StepperArrows
onUp={() => stepAndCommit(1)}
onDown={() => stepAndCommit(-1)}
canUp={canUp}
canDown={canDown}
disabled={disabled}
/> />
<div className="choice-stepper__arrows">
<Button
variant="toggle"
className="choice-stepper__arrow-btn"
onClick={() => step(1)}
disabled={!canUp}
>
<TriangleUpIcon />
</Button>
<Button
variant="toggle"
className="choice-stepper__arrow-btn"
onClick={() => step(-1)}
disabled={!canDown}
>
<TriangleDownIcon />
</Button>
</div>
</div> </div>
); );
} }
// ---- Range variant (concrete, no generic) ----
type ChoiceStepperRangeProps = {
value: number;
min: number;
max: number;
step?: number;
formatLabel: (value: number) => string;
onChange: (value: number) => CommitResult;
id?: string;
disabled?: boolean;
className?: string;
};
function ChoiceStepperRange({
value,
min,
max,
step: stepSize = 1,
formatLabel,
onChange,
id,
disabled,
className,
}: ChoiceStepperRangeProps) {
const [localValue, setLocalValue] = useState(value);
const [text, setText] = useState(formatLabel(value));
const interacting = useRef(false);
const pendingValue = useRef<number | null>(null);
const wheelTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const fallbackTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useNonPassiveWheel(inputRef, (e) => {
e.preventDefault();
if (e.deltaY < 0 && canUp) {
stepWheel(1);
} else if (e.deltaY > 0 && canDown) {
stepWheel(-1);
}
});
useEffect(() => {
if (interacting.current) {
if (pendingValue.current !== null && Object.is(value, pendingValue.current)) {
interacting.current = false;
pendingValue.current = null;
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
} else {
return;
}
}
setLocalValue(value);
setText(formatLabel(value));
}, [value, formatLabel]);
useEffect(() => {
return () => {
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
};
}, []);
const clamp = (n: number) => Math.min(max, Math.max(min, n));
const canUp = localValue < max;
const canDown = localValue > min;
const stepLocal = (direction: 1 | -1): number | null => {
const next = clamp(localValue + direction * stepSize);
if (next === localValue) return null;
setLocalValue(next);
setText(formatLabel(next));
return next;
};
async function commit(next: number) {
interacting.current = true;
pendingValue.current = next;
const result = await onChange(next);
if (result === false) {
interacting.current = false;
pendingValue.current = null;
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
setLocalValue(value);
setText(formatLabel(value));
return;
}
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
fallbackTimeout.current = setTimeout(() => {
interacting.current = false;
pendingValue.current = null;
}, PENDING_FALLBACK_MS);
}
const stepAndCommit = (direction: 1 | -1) => {
const next = stepLocal(direction);
if (next !== null) void commit(next);
};
const stepWheel = (direction: 1 | -1) => {
const next = stepLocal(direction);
if (next === null) return;
interacting.current = true;
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
wheelTimeout.current = setTimeout(() => {
wheelTimeout.current = null;
void commit(next);
}, WHEEL_COMMIT_DELAY);
};
const commitText = () => {
const parsed = Number(text);
if (Number.isFinite(parsed)) {
const next = clamp(parsed);
setLocalValue(next);
void commit(next);
} else {
setText(formatLabel(value));
}
};
return (
<div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
<input
ref={inputRef}
id={id}
className="choice-stepper__input"
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (canUp) {
interacting.current = true;
stepLocal(1);
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (canDown) {
interacting.current = true;
stepLocal(-1);
}
}
}}
onKeyUp={(e) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
void commit(localValue);
}}
/>
<StepperArrows
onUp={() => stepAndCommit(1)}
onDown={() => stepAndCommit(-1)}
canUp={canUp}
canDown={canDown}
disabled={disabled}
/>
</div>
);
}
function ChoiceStepper<T>(props: ChoiceStepperListProps<T>): JSX.Element;
function ChoiceStepper(props: ChoiceStepperRangeProps): JSX.Element;
function ChoiceStepper(props: ChoiceStepperListProps<unknown> | ChoiceStepperRangeProps) {
if ('values' in props) {
return <ChoiceStepperList {...props} />;
}
return <ChoiceStepperRange {...props} />;
}
export default ChoiceStepper; export default ChoiceStepper;
+9 -9
View File
@@ -33,7 +33,7 @@ export interface LinearGraphProps {
yMin: number; yMin: number;
yMax: number; yMax: number;
yStep?: number; yStep?: number;
/** 'log' requires xMin > 0. Ticks are drawn at 1/2/5-per-decade mantissas, with decade (power-of-10) lines highlighted. */ /** 'log' requires xMin > 0. */
xScale?: AxisScale; xScale?: AxisScale;
/** 'log' requires yMin > 0. */ /** 'log' requires yMin > 0. */
yScale?: AxisScale; yScale?: AxisScale;
@@ -55,7 +55,7 @@ export interface LinearGraphProps {
const defaultFormat = (v: number) => { const defaultFormat = (v: number) => {
if (Math.abs(v) < 1e-9) return '0'; if (Math.abs(v) < 1e-9) return '0';
const abs = Math.abs(v); const abs = Math.abs(v);
if (abs >= 1000 || abs < 0.01) return v.toExponential(1); if (abs >= 100000 || abs < 0.01) return v.toExponential(1);
return parseFloat(v.toFixed(4)).toString(); return parseFloat(v.toFixed(4)).toString();
}; };
@@ -150,7 +150,7 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
const topBase = cq.h * 3; const topBase = cq.h * 3;
const rightBase = cq.w * 5; const rightBase = cq.w * 5;
const bottomBase = cq.h * 4; const bottomBase = cq.h * 4;
const leftBase = cq.w * 2; const leftBase = cq.w * 7;
const xLabelSpace = xLabel ? cq.h * 7 : 0; const xLabelSpace = xLabel ? cq.h * 7 : 0;
const yLabelSpace = yLabel ? Math.min(cq.w * 6, 24) : 0; const yLabelSpace = yLabel ? Math.min(cq.w * 6, 24) : 0;
@@ -221,7 +221,7 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
ctx.clearRect(0, 0, cssW, cssH); ctx.clearRect(0, 0, cssW, cssH);
const plot = { const plot = {
x: pad.left + cq.w * 2, x: pad.left,
y: pad.top, y: pad.top,
width: Math.max(1, cssW - pad.left - pad.right - cq.w * 2), width: Math.max(1, cssW - pad.left - pad.right - cq.w * 2),
height: Math.max(1, cssH - pad.top - pad.bottom), height: Math.max(1, cssH - pad.top - pad.bottom),
@@ -271,14 +271,14 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
ctx.fillRect(plot.x, plot.y, plot.width, plot.height); ctx.fillRect(plot.x, plot.y, plot.width, plot.height);
} }
const clipPad = 2; const borderInset = 1; // Avoid clipping outside graph bounds
ctx.save(); ctx.save();
ctx.beginPath(); ctx.beginPath();
ctx.rect( ctx.rect(
plot.x - clipPad, plot.x + borderInset,
plot.y - clipPad, plot.y + borderInset,
plot.width + clipPad * 2, plot.width - borderInset * 2,
plot.height + clipPad * 2 plot.height - borderInset * 2
); );
ctx.clip(); ctx.clip();
+3
View File
@@ -3,6 +3,9 @@
flex-direction: column; flex-direction: column;
align-items: left; align-items: left;
gap: 0.3cqh; gap: 0.3cqh;
user-select: none;
-webkit-user-select: none;
} }
.meter-bar__title { .meter-bar__title {
@@ -1,7 +1,7 @@
.notifications { .notifications {
position: fixed; position: fixed;
right: var(--space-5); right: var(--space-5);
bottom: var(--space-5); top: var(--space-5);
z-index: 9999; z-index: 9999;
display: flex; display: flex;
+56 -1
View File
@@ -18,6 +18,9 @@
overflow: visible; overflow: visible;
transition: width var(--dur-base) var(--ease-standard); transition: width var(--dur-base) var(--ease-standard);
user-select: none;
-webkit-user-select: none;
} }
.sidebar-rail--expanded { .sidebar-rail--expanded {
@@ -32,13 +35,14 @@
justify-content: center; justify-content: center;
flex: 0 0 auto; flex: 0 0 auto;
gap: var(--space-1);
padding: var(--space-1); padding: var(--space-1);
height: var(--header-height); height: var(--header-height);
} }
.sidebar-rail--expanded .sidebar-header { .sidebar-rail--expanded .sidebar-header {
justify-content: space-between; justify-content: space-between;
padding-inline: var(--space-3); padding-left: var(--space-3);
} }
.sidebar-toggle { .sidebar-toggle {
@@ -361,3 +365,54 @@
.app-root--sidebar-expanded .stage--with-sidebar { .app-root--sidebar-expanded .stage--with-sidebar {
margin-left: var(--sidebar-width-expanded); margin-left: var(--sidebar-width-expanded);
} }
/* ---------- Item (editing state) ---------- */
.sidebar-item--editing {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sidebar-item-gap);
aspect-ratio: 1;
height: calc(var(--header-height) * 0.7);
border: none;
background: transparent;
padding: 0;
}
.sidebar-item-input {
width: 0;
flex: 1 1 auto;
min-width: 0;
appearance: none;
-webkit-appearance: none;
background: transparent;
border: none;
outline: none;
box-shadow: none;
text-align: center;
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--font-sm);
font-weight: var(--font-weight-semibold);
opacity: 0;
max-width: 0;
transition:
opacity var(--dur-fast) var(--ease-standard),
max-width var(--dur-base) var(--ease-standard);
}
.sidebar-rail--expanded .sidebar-item-input {
width: auto;
opacity: 1;
max-width: 200px;
border: none;
}
+88 -8
View File
@@ -6,6 +6,7 @@ export type SidebarItem<T extends string = string> = {
id: T; id: T;
label: string; label: string;
icon: React.ReactNode; icon: React.ReactNode;
editable?: boolean;
}; };
export type SidebarAppOption = { export type SidebarAppOption = {
@@ -17,11 +18,75 @@ function SidebarButton<T extends string>({
item, item,
active, active,
onSelect, onSelect,
onRename,
}: { }: {
item: SidebarItem<T>; item: SidebarItem<T>;
active: boolean; active: boolean;
onSelect: () => void; onSelect: () => void;
onRename?: (name: string) => void;
}) { }) {
const [editing, setEditing] = useState(false);
const [inputValue, setInputValue] = useState(item.label);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!editing) setInputValue(item.label);
}, [item.label, editing]);
function startEditing(e: React.MouseEvent) {
e.stopPropagation();
if (!onRename) return;
setInputValue(item.label);
setEditing(true);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
function commitRename() {
setEditing(false);
const trimmed = inputValue.trim();
if (!trimmed || trimmed === item.label) {
setInputValue(item.label);
return;
}
onRename?.(trimmed);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') inputRef.current?.blur();
if (e.key === 'Escape') {
setInputValue(item.label);
setEditing(false);
}
}
if (editing) {
return (
<div className="sidebar-item sidebar-item--editing">
<span className="sidebar-icon">{item.icon}</span>
<input
ref={inputRef}
className="sidebar-item-input"
value={inputValue}
maxLength={8}
onChange={(e) => setInputValue(e.target.value.slice(0, 8))}
onBlur={commitRename}
onKeyDown={handleKeyDown}
aria-label="Rename channel"
/>
</div>
);
}
return ( return (
<button <button
onClick={onSelect} onClick={onSelect}
@@ -31,7 +96,14 @@ function SidebarButton<T extends string>({
className={'sidebar-item' + (active ? ' sidebar-item--active' : '')} className={'sidebar-item' + (active ? ' sidebar-item--active' : '')}
> >
<span className="sidebar-icon">{item.icon}</span> <span className="sidebar-icon">{item.icon}</span>
<span className="sidebar-label">{item.label}</span>
<span
className="sidebar-label"
onDoubleClick={onRename ? startEditing : undefined}
title={onRename ? `${item.label} (double click to rename)` : undefined}
>
{item.label}
</span>
</button> </button>
); );
} }
@@ -46,8 +118,8 @@ function AppCombobox({
appOptions: SidebarAppOption[]; appOptions: SidebarAppOption[];
selectedAppId?: string; selectedAppId?: string;
appName: string; appName: string;
onAppChange?: (id: string) => void; onAppChange: (id: string) => void;
onAppRename?: (name: string) => void; onAppRename: (name: string) => void;
}) { }) {
const currentOption = appOptions.find((o) => o.id === selectedAppId) ?? appOptions[0]; const currentOption = appOptions.find((o) => o.id === selectedAppId) ?? appOptions[0];
const canRename = currentOption?.id !== 'f00'; const canRename = currentOption?.id !== 'f00';
@@ -95,6 +167,7 @@ function AppCombobox({
if (!canRename) return; if (!canRename) return;
setInputValue((currentOption?.name ?? appName).trim());
setEditing(true); setEditing(true);
setOpen(false); setOpen(false);
@@ -124,13 +197,13 @@ function AppCombobox({
if (matched) { if (matched) {
if (matched.id !== selectedAppId) { if (matched.id !== selectedAppId) {
onAppChange?.(matched.id); onAppChange(matched.id);
} }
return; return;
} }
if (trimmed !== currentOption?.name) { if (trimmed !== currentOption?.name) {
onAppRename?.(trimmed); onAppRename(trimmed);
} }
} }
@@ -186,6 +259,7 @@ function AppCombobox({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={open} aria-expanded={open}
title={canRename ? 'Double click to rename' : undefined}
> >
<span className="sidebar-app-trigger-label">{currentOption?.name ?? appName}</span> <span className="sidebar-app-trigger-label">{currentOption?.name ?? appName}</span>
<svg className="sidebar-app-trigger-caret" viewBox="0 0 24 24" width="14" height="14"> <svg className="sidebar-app-trigger-caret" viewBox="0 0 24 24" width="14" height="14">
@@ -209,7 +283,8 @@ function AppCombobox({
onMouseEnter={() => setHighlight(i)} onMouseEnter={() => setHighlight(i)}
onClick={() => pick(opt)} onClick={() => pick(opt)}
> >
{opt.name} <span className="sidebar-app-option-id">{opt.id + '-'}</span>
<span>{opt.name}</span>
</li> </li>
))} ))}
</ul> </ul>
@@ -227,6 +302,7 @@ export function Sidebar<T extends string>({
selectedAppId, selectedAppId,
onAppChange, onAppChange,
onAppRename, onAppRename,
onItemRename,
expanded, expanded,
onToggle, onToggle,
}: { }: {
@@ -236,8 +312,9 @@ export function Sidebar<T extends string>({
appName: string; appName: string;
appOptions?: SidebarAppOption[]; appOptions?: SidebarAppOption[];
selectedAppId?: string; selectedAppId?: string;
onAppChange?: (id: string) => void; onAppChange: (id: string) => void;
onAppRename?: (name: string) => void; onAppRename: (name: string) => void;
onItemRename?: (id: T, name: string) => void;
expanded: boolean; expanded: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
@@ -295,6 +372,9 @@ export function Sidebar<T extends string>({
item={item} item={item}
active={activeId === item.id} active={activeId === item.id}
onSelect={() => onSelect(item.id)} onSelect={() => onSelect(item.id)}
onRename={
item.editable && onItemRename ? (name) => onItemRename(item.id, name) : undefined
}
/> />
))} ))}
</div> </div>
@@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { InputChannel, OutputChannel } from '../../features/dsp408/types'; import { InputChannel, OutputChannel } from '../../features/dsp408/types';
import { DSP408 } from '../../types'; import { DSP408 } from '../../types';
import { import {
@@ -69,6 +69,14 @@ const MARGIN = 36;
const LEFT_CHAIN_W = 560; const LEFT_CHAIN_W = 560;
const RIGHT_CHAIN_W = 620; const RIGHT_CHAIN_W = 620;
// Width of the invisible "hitbox" stroke drawn under each visible line, in SVG
// user units. Much wider than the visible 1–2.5px line so it's easy to click
// or catch with a scroll wheel without needing pixel-perfect aim.
const LINE_HITBOX_WIDTH = 14;
// How long the gain tooltip stays visible after the last wheel tick, in ms.
const GAIN_TOOLTIP_HOLD_MS = 700;
function getRoutes(dsp: DSP408, output: OutputChannel) { function getRoutes(dsp: DSP408, output: OutputChannel) {
return dsp.state.current_config.output_states[output].matrix_routes; return dsp.state.current_config.output_states[output].matrix_routes;
} }
@@ -79,16 +87,47 @@ function getGain(dsp: DSP408, output: OutputChannel, input: InputChannel) {
return getRoutes(dsp, output).gains[input]; return getRoutes(dsp, output).gains[input];
} }
function formatGainDb(value: number) {
const sign = value > 0 ? '+' : '';
return `${sign}${value.toFixed(1)} dB`;
}
export function useNonPassiveWheel(
ref: React.RefObject<Element | null>,
handler: (e: WheelEvent) => void
) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const el = ref.current;
if (!el) return;
const listener = (e: Event) => handlerRef.current(e as WheelEvent);
el.addEventListener('wheel', listener, { passive: false });
return () => el.removeEventListener('wheel', listener);
}, [ref]);
}
type GainTooltipState = {
visible: boolean;
x: number;
y: number;
label: string;
};
function SignalFlowMatrix({ function SignalFlowMatrix({
dsp, dsp,
setMatrixRouting, setMatrixRouting,
setMatrixGain, setMatrixGain,
setActiveSidebar, setActiveSidebar,
onGainPreview,
}: { }: {
dsp: DSP408; dsp: DSP408;
setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => Promise<boolean>; setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => Promise<boolean>;
setMatrixGain: (input: InputChannel, output: OutputChannel, gain: number) => Promise<boolean>; setMatrixGain: (input: InputChannel, output: OutputChannel, gain: number) => Promise<boolean>;
setActiveSidebar: React.Dispatch<React.SetStateAction<SidebarId>>; setActiveSidebar: React.Dispatch<React.SetStateAction<SidebarId>>;
onGainPreview: (input: InputChannel, output: OutputChannel, value: number | null) => void;
}) { }) {
const nInputs = INPUT_ORDER.length; const nInputs = INPUT_ORDER.length;
const nOutputs = OUTPUT_ORDER.length; const nOutputs = OUTPUT_ORDER.length;
@@ -109,6 +148,27 @@ function SignalFlowMatrix({
const inputNodeX = leftStart + LEFT_CHAIN_W; const inputNodeX = leftStart + LEFT_CHAIN_W;
const outputNodeX = rightStart; const outputNodeX = rightStart;
const [gainTooltip, setGainTooltip] = useState<GainTooltipState>({
visible: false,
x: 0,
y: 0,
label: '',
});
const hideTooltipTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const WHEEL_COMMIT_DELAY = 250;
// key = `${input}-${output}`
const pendingGains = useRef<Map<string, number>>(new Map());
const commitTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(() => {
return () => {
if (hideTooltipTimer.current) clearTimeout(hideTooltipTimer.current);
commitTimers.current.forEach(clearTimeout);
};
}, []);
const handleToggle = (output: OutputChannel, input: InputChannel) => { const handleToggle = (output: OutputChannel, input: InputChannel) => {
const routes = getRoutes(dsp, output); const routes = getRoutes(dsp, output);
const next = routes.connected.includes(input) const next = routes.connected.includes(input)
@@ -117,15 +177,55 @@ function SignalFlowMatrix({
void setMatrixRouting(output, next); void setMatrixRouting(output, next);
}; };
const handleWheelGain = (e: React.WheelEvent, output: OutputChannel, input: InputChannel) => { const svgRef = useRef<SVGSVGElement>(null);
const handleWheelGain = (e: WheelEvent, output: OutputChannel, input: InputChannel) => {
e.preventDefault(); e.preventDefault();
const current = getGain(dsp, output, input);
const key = `${input}-${output}`;
// Base on the pending value if we're mid-wheel, otherwise the real state
const current = pendingGains.current.get(key) ?? getGain(dsp, output, input);
const delta = e.deltaY < 0 ? MATRIX_GAIN_STEP : -MATRIX_GAIN_STEP; const delta = e.deltaY < 0 ? MATRIX_GAIN_STEP : -MATRIX_GAIN_STEP;
const next = const next =
Math.round(Math.min(MATRIX_GAIN_MAX, Math.max(MATRIX_GAIN_MIN, current + delta)) * 10) / 10; Math.round(Math.min(MATRIX_GAIN_MAX, Math.max(MATRIX_GAIN_MIN, current + delta)) * 10) / 10;
void setMatrixGain(input, output, next);
pendingGains.current.set(key, next);
onGainPreview(input, output, next);
// Debounced send: only the final value goes out once the wheel pauses
const existing = commitTimers.current.get(key);
if (existing) clearTimeout(existing);
commitTimers.current.set(
key,
setTimeout(async () => {
commitTimers.current.delete(key);
const value = pendingGains.current.get(key);
if (value === undefined) return;
await setMatrixGain(input, output, value);
// Only release if the user hasn't wheeled again while we were awaiting
if (pendingGains.current.get(key) === value) {
pendingGains.current.delete(key);
onGainPreview(input, output, null);
}
}, WHEEL_COMMIT_DELAY)
);
// Tooltip (unchanged, shows the pending value)
if (hideTooltipTimer.current) clearTimeout(hideTooltipTimer.current);
setGainTooltip({ visible: true, x: e.clientX, y: e.clientY, label: formatGainDb(next) });
hideTooltipTimer.current = setTimeout(() => {
setGainTooltip((t) => ({ ...t, visible: false }));
}, GAIN_TOOLTIP_HOLD_MS);
}; };
useNonPassiveWheel(svgRef, (e) => {
const hit = (e.target as Element).closest<SVGElement>('[data-wheel-route]');
if (!hit) return; // not over a connected line: let the page scroll normally
const { input, output } = hit.dataset;
if (!input || !output) return;
handleWheelGain(e, output as OutputChannel, input as InputChannel);
});
const routePairs = useMemo(() => { const routePairs = useMemo(() => {
const pairs: { input: InputChannel; output: OutputChannel; i: number; j: number }[] = []; const pairs: { input: InputChannel; output: OutputChannel; i: number; j: number }[] = [];
OUTPUT_ORDER.forEach((output, j) => { OUTPUT_ORDER.forEach((output, j) => {
@@ -134,14 +234,27 @@ function SignalFlowMatrix({
return pairs; return pairs;
}, []); }, []);
// Draw dashed (unconnected) links first so connected (solid) links always
// paint on top of them, regardless of input/output index order. This has
// to be recomputed whenever routing changes, since isRouted reads live
// dsp state — unlike routePairs above, which is just static index pairs.
const orderedRoutePairs = useMemo(() => {
return [...routePairs].sort((a, b) => {
const aConnected = isRouted(dsp, a.output, a.input) ? 1 : 0;
const bConnected = isRouted(dsp, b.output, b.input) ? 1 : 0;
return aConnected - bConnected;
});
}, [routePairs, dsp]);
return ( return (
<div className="signal-flow"> <div className="signal-flow">
<svg <svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`} viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
className="signal-flow__svg" className="signal-flow__svg"
> >
{routePairs.map(({ input, output, i, j }) => { {orderedRoutePairs.map(({ input, output, i, j }) => {
const connected = isRouted(dsp, output, input); const connected = isRouted(dsp, output, input);
const y1 = inputCenterY(i); const y1 = inputCenterY(i);
const y2 = outputCenterY(j); const y2 = outputCenterY(j);
@@ -152,6 +265,22 @@ function SignalFlowMatrix({
return ( return (
<g key={`${input}-${output}`}> <g key={`${input}-${output}`}>
{/* Invisible wide-stroke hitbox: carries the click/wheel handlers so
the line is easy to grab without needing to hit the thin visible
stroke exactly. Sits directly under the visible path. */}
<path
d={path}
fill="none"
stroke="transparent"
strokeWidth={LINE_HITBOX_WIDTH}
style={{ cursor: 'pointer' }}
onClick={() => handleToggle(output, input)}
data-wheel-route={connected ? '' : undefined}
data-input={input}
data-output={output}
/>
{/* Visible line, purely decorative — no pointer events, so it never
competes with the hitbox above for hover/click/wheel. */}
<path <path
d={path} d={path}
fill="none" fill="none"
@@ -159,9 +288,7 @@ function SignalFlowMatrix({
strokeWidth={connected ? 2.5 : 1} strokeWidth={connected ? 2.5 : 1}
strokeDasharray={connected ? undefined : '4 5'} strokeDasharray={connected ? undefined : '4 5'}
opacity={connected ? 0.9 : 0.35} opacity={connected ? 0.9 : 0.35}
style={{ cursor: 'pointer' }} pointerEvents="none"
onClick={() => handleToggle(output, input)}
onWheel={connected ? (e) => handleWheelGain(e, output, input) : undefined}
/> />
</g> </g>
); );
@@ -252,6 +379,30 @@ function SignalFlowMatrix({
); );
})} })}
</svg> </svg>
{gainTooltip.visible && (
<div
className="signal-flow__gain-tooltip"
style={{
position: 'fixed',
left: gainTooltip.x + 14,
top: gainTooltip.y - 28,
pointerEvents: 'none',
zIndex: 50,
padding: '2px 8px',
borderRadius: 6,
fontSize: 12,
fontVariantNumeric: 'tabular-nums',
background: 'var(--surface-elevated, rgba(20, 20, 24, 0.92))',
color: 'var(--text-primary, #fff)',
border: '1px solid var(--border-hairline, #333)',
boxShadow: '0 2px 8px rgba(0,0,0,0.35)',
whiteSpace: 'nowrap',
}}
>
{gainTooltip.label}
</div>
)}
</div> </div>
); );
} }
@@ -150,6 +150,9 @@
(var(--space-3) * 2) + (var(--tab-height) * 0.5) + (var(--space-2) * 2) + 4ch + (var(--space-3) * 2) + (var(--tab-height) * 0.5) + (var(--space-2) * 2) + 4ch +
var(--tab-close-size) var(--tab-close-size)
); );
user-select: none;
-webkit-user-select: none;
} }
.tab:hover { .tab:hover {
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { DSP, AppPage } from '../../types'; import { DSP, AppPage, isConnected } from '../../types';
import AnimatedLogo from '../../assets/icons/AnimatedLogo'; import AnimatedLogo from '../../assets/icons/AnimatedLogo';
import { PlusIcon } from '../../assets/icons/PlusIcon'; import { PlusIcon } from '../../assets/icons/PlusIcon';
import { CrownIcon } from '../../assets/icons/CrownIcon'; import { CrownIcon } from '../../assets/icons/CrownIcon';
@@ -24,6 +24,7 @@ function Tab({
onRemove, onRemove,
onConnect, onConnect,
onDisconnect, onDisconnect,
actions,
}: { }: {
dsp: DSP; dsp: DSP;
active: boolean; active: boolean;
@@ -33,8 +34,9 @@ function Tab({
onRemove: () => void; onRemove: () => void;
onConnect: (dsp: DSP) => void; onConnect: (dsp: DSP) => void;
onDisconnect: (dsp: DSP) => void; onDisconnect: (dsp: DSP) => void;
actions?: React.ReactNode;
}) { }) {
const isConnected = dsp.status === 'connected'; const isDspConnected = isConnected(dsp);
const handleTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { const handleTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -58,24 +60,36 @@ function Tab({
<button <button
type="button" type="button"
className="tab-led" className="tab-led"
aria-label={isConnected ? `Disconnect ${dsp.name}` : `Connect ${dsp.name}`} aria-label={isDspConnected ? `Disconnect ${dsp.name}` : `Connect ${dsp.name}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (isConnected) { onSelect();
if (isDspConnected) {
onDisconnect(dsp); onDisconnect(dsp);
} else { } else {
onConnect(dsp); onConnect(dsp);
} }
}} }}
> >
<Led status={dsp.status} /> <Led
status={
dsp.isConnecting
? 'connecting'
: isConnected(dsp)
? 'connected'
: 'disconnected'
}
/>
</button> </button>
<span className="tab-name" title={dsp.name}> <span className="tab-name" title={dsp.name}>
{dsp.name} {dsp.name}
</span> </span>
{actions}
<button <button
type="button" type="button"
className="tab-close" className="tab-close"
@@ -9,6 +9,9 @@
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
user-select: none;
-webkit-user-select: none;
} }
.gauge-track { .gauge-track {
@@ -58,8 +61,6 @@
height: 100%; height: 100%;
cursor: pointer; cursor: pointer;
touch-action: none;
user-select: none;
} }
.track { .track {
@@ -145,3 +146,26 @@
font-family: inherit; font-family: inherit;
color: inherit; color: inherit;
} }
.gauge-viewport.is-disabled {
opacity: 0.45;
filter: grayscale(1);
}
.gauge-viewport.is-disabled .slider {
cursor: not-allowed;
}
.gauge-viewport.is-disabled .thumb {
border-color: var(--border-hairline);
box-shadow: none;
}
.gauge-viewport.is-disabled .value {
color: var(--border-hairline);
box-shadow: none;
}
.value input:disabled {
cursor: not-allowed;
}
+149 -31
View File
@@ -10,32 +10,37 @@ 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;
style?: React.CSSProperties; style?: React.CSSProperties;
/** Greys out the slider and blocks all interaction. */
disabled?: boolean;
}; };
const defaultStep: StepFn = () => 0.1; const defaultStep: StepFn = () => 0.1;
@@ -60,8 +65,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 +106,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 +169,7 @@ export default function VerticalSlider({
max, max,
step = defaultStep, step = defaultStep,
quantize, quantize,
values,
value, value,
onChange, onChange,
onInput, onInput,
@@ -133,6 +177,7 @@ export default function VerticalSlider({
formatValue, formatValue,
showTicks = true, showTicks = true,
scale = 'linear', scale = 'linear',
disabled = false,
className, className,
style, style,
}: VerticalSliderProps) { }: VerticalSliderProps) {
@@ -151,13 +196,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 +244,42 @@ export default function VerticalSlider({
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
const decimals = useMemo( useEffect(() => {
() => Math.min(3, getDecimals(step(displayValue))), if (!disabled) return;
[step, displayValue]
); dragging.current = false;
keyboardEditing.current = false;
keyboardValue.current = null;
wheelEditing.current = false;
wheelValue.current = null;
if (wheelCommitTimeout.current) {
clearTimeout(wheelCommitTimeout.current);
wheelCommitTimeout.current = null;
}
setEditingText(null);
setDisplayValue(value);
dragValue.current = value;
onInput?.(value);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [disabled]);
const decimals = useMemo(() => {
if (listValues) return Math.min(3, getDecimals(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));
} }
@@ -218,6 +297,8 @@ export default function VerticalSlider({
} }
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) { function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (disabled) return;
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
return; return;
} }
@@ -230,18 +311,30 @@ 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);
} }
async function handleKeyUp() { async function handleKeyUp() {
if (disabled) return;
if (keyboardValue.current == null) return; if (keyboardValue.current == null) return;
const next = keyboardValue.current; const next = keyboardValue.current;
@@ -258,17 +351,26 @@ export default function VerticalSlider({
} }
function handleWheel(e: WheelEvent) { function handleWheel(e: WheelEvent) {
if (disabled) return;
e.preventDefault(); e.preventDefault();
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,11 +417,13 @@ 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);
} }
function startDrag(e: React.PointerEvent<HTMLDivElement>) { function startDrag(e: React.PointerEvent<HTMLDivElement>) {
if (disabled) return;
const target = e.currentTarget; const target = e.currentTarget;
dragging.current = true; dragging.current = true;
target.setPointerCapture(e.pointerId); target.setPointerCapture(e.pointerId);
@@ -354,6 +458,11 @@ export default function VerticalSlider({
} }
async function commitEditing() { async function commitEditing() {
if (disabled) {
setEditingText(null);
return;
}
if (editingText !== null && editingText !== '' && editingText !== '-') { if (editingText !== null && editingText !== '' && editingText !== '-') {
const parsed = Number(editingText); const parsed = Number(editingText);
@@ -376,10 +485,13 @@ 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', disabled && 'is-disabled', className].filter(Boolean).join(' ')}
style={style}
>
<div <div
className={['gauge-track', !showTicks && 'no-ticks'].filter(Boolean).join(' ')} className={['gauge-track', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}
ref={trackRef} ref={trackRef}
@@ -395,7 +507,12 @@ export default function VerticalSlider({
<div <div
className="slider" className="slider"
ref={sliderRef} ref={sliderRef}
tabIndex={0} role="slider"
aria-disabled={disabled}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={displayValue}
tabIndex={disabled ? -1 : 0}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
onPointerDown={startDrag} onPointerDown={startDrag}
@@ -421,6 +538,7 @@ export default function VerticalSlider({
ref={inputRef} ref={inputRef}
type="text" type="text"
inputMode="decimal" inputMode="decimal"
disabled={disabled}
value={editingText ?? formattedValue} value={editingText ?? formattedValue}
style={{ width: '100%' }} style={{ width: '100%' }}
onFocus={() => setEditingText(value.toFixed(decimals))} // plain number while editing onFocus={() => setEditingText(value.toFixed(decimals))} // plain number while editing
@@ -434,7 +552,7 @@ export default function VerticalSlider({
} }
}} }}
/> />
{unit && <span className="value-unit">{unit}</span>} {displayUnit && <span className="value-unit">{' ' + displayUnit}</span>}
</div> </div>
</div> </div>
); );
@@ -1,5 +1,5 @@
/* InputSection.css */ /* InputSection.css */
.input-graph-section { .channel-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth)); flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0; min-height: 0;
@@ -10,9 +10,13 @@
box-sizing: border-box; box-sizing: border-box;
background: var(--bg-panel); background: var(--bg-panel);
user-select: none;
-webkit-user-select: none;
} }
.input-graph-panel { .channel-graph-panel {
position: relative;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
@@ -24,7 +28,28 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
.input-graph__title { .channel-bypass-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 2;
}
.channel-bypass-overlay span {
font-size: 8cqh;
font-weight: 800;
letter-spacing: 0.15em;
color: #f87171;
text-shadow: 0 0 12px rgba(0, 0, 0, 0.6);
transform: rotate(-8deg);
opacity: 0.85;
user-select: none;
}
.channel-graph__title {
flex: 0 0 auto; flex: 0 0 auto;
text-align: center; text-align: center;
@@ -35,12 +60,15 @@
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
} }
.input-graph-panel > .container { .channel-graph-panel > .container {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
.input-control-section { /* Bottom control bar — same pattern as .compressor-section: absolute,
bottom-anchored, panels flow in a row and scroll horizontally instead
of squeezing into a fixed max-width. */
.channel-control-section {
container-type: size; container-type: size;
position: absolute; position: absolute;
left: 0; left: 0;
@@ -50,7 +78,7 @@
height: var(--dsp-control-heigth); height: var(--dsp-control-heigth);
display: flex; display: flex;
align-items: center; align-items: flex-end;
justify-content: center; justify-content: center;
box-sizing: border-box; box-sizing: border-box;
@@ -60,110 +88,29 @@
z-index: 20; z-index: 20;
border-top: var(--border-width) solid var(--border-hairline); border-top: var(--border-width) solid var(--border-hairline);
user-select: none;
-webkit-user-select: none;
} }
.input-control-panel { .channel-control-panel {
display: grid;
grid-auto-flow: column;
height: 100%; height: 100%;
width: 100%; gap: var(--space-2);
max-width: 60cqw; overflow-x: auto;
justify-content: center;
display: flex;
align-items: stretch;
justify-content: center; /* ← add this */
gap: var(--space-4);
box-sizing: border-box; box-sizing: border-box;
padding: 2cqh; padding: 2cqh;
} }
.input-control-panel__sliders {
flex: 1 1 auto;
min-width: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4cqh;
}
.input-control-panel__cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 0;
min-width: 0;
gap: 1cqh;
}
.input-control-panel__cell-label {
flex: 0 0 auto;
font-family: var(--font-ui);
font-size: 2.5cqh;
color: var(--text-muted);
white-space: nowrap;
}
.input-control-panel__side {
flex: 0 0 auto;
width: 16cqh;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 2cqh;
}
.input-control-panel__select {
display: flex;
flex-direction: column;
gap: 0.5cqh;
}
.input-control-panel__select-label {
font-family: var(--font-ui);
font-size: 2.2cqh;
color: var(--text-muted);
}
.input-control-panel__select select {
font-family: var(--font-ui);
font-size: 2.5cqh;
padding: 0.8cqh;
background: var(--bg-void);
color: var(--text-primary);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.input-control-panel__select select:disabled {
opacity: 0.5;
}
.input-control-panel__bypass-btn {
font-family: var(--font-ui);
font-size: 2.5cqh;
font-weight: var(--font-weight-semibold);
padding: 1cqh;
background: var(--bg-void);
color: var(--text-primary);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
cursor: pointer;
}
.input-control-panel__bypass-btn--active {
background: var(--accent, #f87171);
color: var(--bg-void);
border-color: var(--accent, #f87171);
}
/* Peq Sliders */ /* Peq Sliders */
.input-panel { .channel-panel {
position: relative; position: relative;
height: 10; height: 100%;
aspect-ratio: 0.75; aspect-ratio: 0.8;
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
@@ -177,7 +124,7 @@
background: var(--bg-void); background: var(--bg-void);
} }
.input-panel__title { .channel-panel__title {
flex: 0 0 auto; flex: 0 0 auto;
width: 100%; width: 100%;
text-align: center; text-align: center;
@@ -193,7 +140,7 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.input-panel__grid { .channel-panel__grid {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
@@ -207,7 +154,7 @@
box-sizing: border-box; box-sizing: border-box;
} }
.input-panel__cell { .channel-panel__cell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -217,7 +164,7 @@
gap: 1cqh; gap: 1cqh;
} }
.input-panel__cell-label { .channel-panel__cell-label {
flex: 0 0 auto; flex: 0 0 auto;
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 3.2cqh; font-size: 3.2cqh;
@@ -228,7 +175,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;
@@ -260,15 +207,34 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* Now the root of a ChoiceSelect rather than a native <select>. */
.crossover-panel__select { .crossover-panel__select {
flex: 0 0 auto; flex: 0 0 auto;
width: 85%; width: 85%;
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 3cqh; }
padding: 1cqh;
background: var(--bg-panel); .crossover-panel__select .choice-select__input,
color: inherit; .crossover-panel__select .choice-select__input option {
border: var(--border-width) solid var(--border-hairline); font-size: 4cqh;
color: var(--accent-brand);
/* padding-left: 1cqh; */
}
.crossover-panel__select {
flex: 0 0 auto;
width: 85%;
font-family: var(--font-ui);
font-size: 4cqh;
}
.crossover-panel__select .choice-select__input,
.crossover-panel__select .choice-select__input option {
height: 100%;
padding: 0;
padding-left: 2cqh;
font-size: 4cqh;
color: var(--accent-brand);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
@@ -288,17 +254,16 @@
padding: 1cqh; padding: 1cqh;
overflow: hidden; overflow: hidden;
aspect-ratio: 2;
} }
.peq-band-list__row { .peq-band-list__row {
flex: 1 1 0; flex: 1 1 auto;
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
display: grid; display: grid;
grid-template-columns: 1fr 1.2fr 1fr 1fr 1.4fr 1.2fr; grid-template-columns: 0.4fr 0.55fr 0.4fr 0.5fr 1.2fr 0.5fr;
align-items: stretch; align-items: stretch;
gap: 1cqh; gap: 1cqh;
@@ -340,8 +305,7 @@
text-align: center; text-align: center;
} }
.peq-band-list__input, .peq-band-list__input {
.peq-band-list__select {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-width: 0; min-width: 0;
@@ -361,3 +325,75 @@
box-sizing: border-box; box-sizing: border-box;
} }
.peq-band-list__stepper,
.peq-band-list__select {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
font-family: var(--font-ui);
font-size: 4cqh;
}
.channel-graph-panel__magnitude-wrap {
flex: 1 1 0;
min-height: 0;
min-width: 0;
position: relative;
}
.channel-graph__title-row {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1cqh 1.5cqh;
}
.channel-graph__title-row .channel-graph__title {
padding: 0; /* was on the standalone title element; row now owns spacing */
}
.channel-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);
}
.channel-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;
}
.channel-graph-view-toggle__btn.active {
background: var(--accent-brand);
color: var(--bg-void);
}
.peq-band-list__stepper .choice-stepper__input,
.peq-band-list__select .choice-select__input {
height: 100%;
min-height: 0;
font-family: var(--font-mono);
font-size: 4cqh;
color: var(--accent-brand);
background: transparent;
text-align: center;
text-align-last: center;
padding: 0;
margin: 0;
}
@@ -0,0 +1,771 @@
// ChannelSection.tsx
import VerticalSlider, {
makeLogStep,
makeLogQuantize,
} from '../../../../components/VerticalSlider/VerticalSlider';
import ChoiceStepper from '../../../../components/ChoiceStepper/ChoiceStepper';
import ChoiceSelect from '../../../../components/ChoiceSelect/ChoiceSelect';
import {
CROSSOVER_FILTER_LABEL,
PEQ_BANDS_ORDER,
PEQ_FILTERS_LABEL,
PEQ_Q_RANGES,
PEAK_Q_VALUES,
PEQ_Q_UNUSED,
PEQ_GAIN_USED,
} from '../../constants';
import { Channel, PEQBand, PEQFilter, PEQ, Crossover } from '../../types';
import { DSP408 as DSP } from '../../../../types';
import { CrossoverFilter } from '../../types';
import { LinearGraph, LinearGraphHandle } from '../../../../components/LinearGraph/LinearGraph';
import { useRef, useState, useEffect } from 'react';
import './ChannelSection.css';
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import Button from '../../../../components/Button/Button';
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';
function nearestQValue(q: number, values: readonly number[] = PEAK_Q_VALUES): number {
return values.reduce((nearest, current) =>
Math.abs(current - q) < Math.abs(nearest - q) ? current : nearest
);
}
type GraphView = 'both' | 'magnitude' | 'phase';
function GraphViewToggle({
view,
onChange,
}: {
view: GraphView;
onChange: (v: GraphView) => void;
}) {
const options: { value: GraphView; label: string }[] = [
{ value: 'both', label: 'Both' },
{ value: 'magnitude', label: 'Mag' },
{ value: 'phase', label: 'Phase' },
];
return (
<div className="channel-graph-view-toggle">
{options.map((opt) => (
<button
key={opt.value}
type="button"
className={`channel-graph-view-toggle__btn${view === opt.value ? ' active' : ''}`}
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
);
}
function ChannelActions({
bypass,
resetting,
onBypass,
onReset,
}: {
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({
peq,
setPEQ,
onLiveValue,
}: {
peq: PEQ;
setPEQ: (
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
onLiveValue: (value: PEQ) => void;
}) {
const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
const qDisabled = PEQ_Q_UNUSED.has(peq.filter_type);
const gainDisabled = !PEQ_GAIN_USED.has(peq.filter_type);
const nearestQ = nearestQValue(peq.q);
const commit = (overrides: Partial<PEQ>) => {
const next = { ...peq, ...overrides };
return setPEQ(next.gain, next.frequency, overrides.q ?? nearestQ, next.filter_type, next.bypass);
};
return (
<div className="channel-panel">
<div className="channel-panel__title">PEQ Parameters</div>
<div className="channel-panel__grid">
<div className="channel-panel__cell channel-panel__cell--frequency">
<VerticalSlider
min={FREQUENCY_MIN}
max={FREQUENCY_MAX}
scale="log"
step={makeLogStep(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
quantize={makeLogQuantize(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
unit={(value) => (value >= 1000 ? 'kHz' : 'Hz')}
formatValue={(value) =>
value >= 1000 ? `${(value / 1000).toFixed(2)}` : `${value.toFixed(1)}`
}
value={peq.frequency}
onChange={(value) => commit({ frequency: value })}
onInput={(value) => onLiveValue({ ...peq, frequency: value })}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="channel-panel__cell-label">Frequency</div>
</div>
<div className="channel-panel__cell channel-panel__cell--q">
<VerticalSlider
min={qMin}
max={qMax}
values={PEAK_Q_VALUES}
formatValue={(v) => v.toFixed(2)}
value={nearestQ}
onChange={(value) => commit({ q: value })}
onInput={(value) => onLiveValue({ ...peq, q: nearestQValue(value) })}
disabled={qDisabled}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="channel-panel__cell-label">Q</div>
</div>
<div className="channel-panel__cell channel-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 })}
disabled={gainDisabled}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="channel-panel__cell-label">Gain</div>
</div>
</div>
</div>
);
}
function CrossoverPanel({
crossover,
title,
setCrossover,
onLiveValue,
}: {
crossover: Crossover;
title: string;
setCrossover: (frequency: number, filter_type: CrossoverFilter) => Promise<boolean>;
onLiveValue: (value: number) => void;
}) {
const frequency = crossover.frequency;
const filter_type = crossover.slope;
const bypass = filter_type === CrossoverFilter.Bypass;
const lastFilterType = useRef<CrossoverFilter>(
filter_type !== CrossoverFilter.Bypass ? filter_type : CrossoverFilter.Lk48
);
const commit = async (overrides: {
frequency?: number;
filter_type?: CrossoverFilter;
bypass?: boolean;
}) => {
const nextFrequency = overrides.frequency ?? frequency;
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 (
<div className="crossover-panel">
<div className="crossover-panel__title">{title}</div>
<VerticalSlider
min={FREQUENCY_MIN}
max={FREQUENCY_MAX}
scale="log"
step={makeLogStep(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
quantize={makeLogQuantize(FREQUENCY_MIN, FREQUENCY_MAX, FREQUENCY_QUANTIZATION)}
unit={(value) => (value >= 1000 ? 'kHz' : 'Hz')}
value={frequency}
formatValue={(value) =>
value >= 1000 ? `${(value / 1000).toFixed(2)}` : `${value.toFixed(1)}`
}
onChange={(value) => commit({ frequency: value })}
onInput={(value) => onLiveValue(value)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<ChoiceSelect
className="crossover-panel__select"
value={bypass ? lastFilterType.current : filter_type}
options={CROSSOVER_FILTER_LABEL.map((opt) => opt.value)}
formatLabel={(v) =>
CROSSOVER_FILTER_LABEL.find((opt) => opt.value === v)?.label ?? String(v)
}
onChange={(value) => commit({ filter_type: value })}
/>
<Button
variant="toggle"
className={bypass ? 'active-warning' : ''}
onClick={() => commit({ bypass: !bypass })}
style={{
width: '85%',
paddingLeft: 0,
paddingRight: 0,
fontSize: '4cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}}
>
{bypass ? 'Bypassed' : 'Bypass'}
</Button>
</div>
);
}
function PeqBandList({
bands,
liveBands,
activeBand,
setActiveBand,
setPEQ,
bandsOrder,
}: {
bands: Record<PEQBand, PEQ>;
liveBands: Partial<Record<PEQBand, PEQ>>;
activeBand: PEQBand;
setActiveBand: (band: PEQBand) => void;
setPEQ: (
band: PEQBand,
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
bandsOrder: PEQBand[];
}) {
const commit = (band: PEQBand, overrides: Partial<PEQ>) => {
const live = liveBands[band] ?? bands[band];
const next = { ...live, ...overrides };
return setPEQ(
band,
next.gain,
next.frequency,
nearestQValue(next.q),
next.filter_type,
next.bypass
);
};
return (
<div className="peq-band-list">
<div className="peq-band-list__row peq-band-list__row--header">
<div className="peq-band-list__cell">Band</div>
<div className="peq-band-list__cell">Freq</div>
<div className="peq-band-list__cell">Q</div>
<div className="peq-band-list__cell">Gain</div>
<div className="peq-band-list__cell">Type</div>
<div className="peq-band-list__cell">Bypass</div>
</div>
{bandsOrder.map((band, i) => {
const peq = liveBands[band] ?? bands[band];
const isActive = band === activeBand;
const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
const qValues = PEAK_Q_VALUES.filter((q) => q >= qMin && q <= qMax);
const qValue = nearestQValue(peq.q, qValues.length ? qValues : PEAK_Q_VALUES);
const qDisabled = PEQ_Q_UNUSED.has(peq.filter_type);
const gainDisabled = !PEQ_GAIN_USED.has(peq.filter_type);
return (
<div
className={`peq-band-list__row${isActive ? ' peq-band-list__row--active' : ''}`}
key={band}
>
<Button
variant="toggle"
className={isActive ? 'active-info' : ''}
onClick={() => setActiveBand(band)}
style={{
minHeight: 0,
fontSize: '4cqh',
}}
>
{`B${i + 1}`}
</Button>
{/* Frequency */}
<ChoiceStepper
className="peq-band-list__stepper"
value={peq.frequency}
min={FREQUENCY_MIN}
max={FREQUENCY_MAX}
step={FREQUENCY_QUANTIZATION}
formatLabel={(v) =>
v >= 1000 ? `${(v / 1000).toFixed(2)} kHz` : `${v.toFixed(1)} Hz`
}
onChange={(value) => commit(band, { frequency: value })}
/>
<ChoiceStepper
className="peq-band-list__stepper"
value={qValue}
values={qValues}
formatLabel={(v) => v.toFixed(2)}
disabled={qDisabled}
onChange={(value) => commit(band, { q: value })}
/>
<ChoiceStepper
className="peq-band-list__stepper"
value={peq.gain}
min={-12}
max={12}
step={0.1}
formatLabel={(v) => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`}
disabled={gainDisabled}
onChange={(value) => commit(band, { gain: value })}
/>
<ChoiceSelect<PEQFilter>
className="peq-band-list__select"
value={peq.filter_type}
options={PEQ_FILTERS_LABEL.map((opt) => opt.value)}
formatLabel={(v) =>
PEQ_FILTERS_LABEL.find((opt) => opt.value === v)?.label ?? String(v)
}
onChange={(value) => {
const [min, max] = PEQ_Q_RANGES[value];
const allowed = PEAK_Q_VALUES.filter((q) => q >= min && q <= max);
commit(band, {
filter_type: value,
q: nearestQValue(3.0, allowed.length ? allowed : PEAK_Q_VALUES),
});
}}
/>
<Button
variant="toggle"
className={peq.bypass ? 'active-warning' : ''}
onClick={() => commit(band, { bypass: !peq.bypass })}
style={{ minHeight: 0, fontSize: '4cqh', paddingInline: 0 }}
>
{peq.bypass ? 'Bypassed' : 'Bypass'}
</Button>
</div>
);
})}
</div>
);
}
type SetFilterFn = (
channel: Channel,
freq: number,
filterType: CrossoverFilter
) => Promise<boolean>;
function ChannelSection({
dsp,
channel,
setHighPass,
setLowPass,
setPeq,
setPeqBypass,
}: {
dsp: DSP;
channel: Channel;
setHighPass: SetFilterFn;
setLowPass: SetFilterFn;
setPeq: (
channel: Channel,
band: PEQBand,
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
setPeqBypass: (channel: Channel, bypass: boolean) => Promise<boolean>;
}) {
// UI variables
const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1);
const [graphView, setGraphView] = useState<GraphView>('both');
const [resetting, setResetting] = useState(false);
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
// Live values
const [liveHpFreq, setLiveHpFreq] = useState<number>(FREQUENCY_MIN);
const [liveLpFreq, setLiveLpFreq] = useState<number>(FREQUENCY_MAX);
const [livePeq, setLivePeq] = useState<Partial<Record<PEQBand, PEQ>>>({});
const magnitudeGraphRef = useRef<LinearGraphHandle>(null);
// DSP values
const isInput = 'Input' in channel;
const activeChannelState = isInput
? dsp.state.current_config.input_states[channel.Input]
: dsp.state.current_config.output_states[channel.Output];
const ActiveHighPassFilter = activeChannelState.crossover.high_pass;
const ActiveLowPassFilter = activeChannelState.crossover.low_pass;
const ActivePeq = activeChannelState.peq_chain;
const bandsOrder = isInput ? PEQ_BANDS_ORDER.slice(0, -1) : PEQ_BANDS_ORDER;
useEffect(() => {
if (!isResetConfirmOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsResetConfirmOpen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isResetConfirmOpen]);
const handleResetClick = () => {
setIsResetConfirmOpen(true);
};
const handleResetPeq = async () => {
if (resetting) return;
setResetting(true);
for (const band of bandsOrder) {
const current = displayPeq[band];
// Skip only if already flat — nothing to change.
if (current.gain === 0 && current.filter_type === PEQFilter.Peak) continue;
await commitPEQ(band, 0, current.frequency, nearestQValue(3.0, PEAK_Q_VALUES), PEQFilter.Peak, current.bypass);
}
setResetting(false);
};
const confirmResetPeq = async () => {
setIsResetConfirmOpen(false);
await handleResetPeq();
};
const handleBypassClick = () => {
setPeqBypass(channel, !ActivePeq.bypass);
};
// Live handlers
const handleLiveHighPassValue = (value: number) => {
setLiveHpFreq(value);
};
const handleLiveLowPassValue = (value: number) => {
setLiveLpFreq(value);
};
const handleLivePEQValue = (band: PEQBand, value: PEQ) => {
setLivePeq((prev) => ({
...prev,
[band]: value,
}));
};
// Real dsp value handlers
const commitHighPass = async (freq: number, filterType: CrossoverFilter) => {
const success = await setHighPass(channel, freq, filterType);
if (success) {
setLiveHpFreq(freq);
}
return success;
};
const commitLowPass = async (freq: number, filterType: CrossoverFilter) => {
const success = await setLowPass(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(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 (
<>
<div className="channel-graph-section">
<div className="channel-graph-panel">
<div className="channel-graph__title-row">
<div className="channel-graph__title">{activeChannelState.name} - Filters</div>
<GraphViewToggle view={graphView} onChange={setGraphView} />
</div>
{(graphView === 'both' || graphView === 'magnitude') && (
<div className="channel-graph-panel__magnitude-wrap">
<LinearGraph
ref={magnitudeGraphRef}
xMin={GRAPH_X_MIN}
xMax={GRAPH_X_MAX}
xScale="log"
yMin={GRAPH_Y_MIN}
yMax={GRAPH_Y_MAX}
yStep={3}
xLabel="Frequency - Hz"
yLabel="Magnituded - 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="Frequency - Hz"
yLabel="Phase - Deg"
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 },
},
],
});
}}
/>
)}
{ActivePeq.bypass && (
<div className="channel-bypass-overlay">
<span>BYPASSED</span>
</div>
)}
</div>
</div>
<div className="channel-control-section">
<div className="channel-control-panel">
<ChannelGroup title="PEQ">
<PeqBandList
bands={ActivePeq.bands}
liveBands={livePeq}
activeBand={activeBand}
setActiveBand={setActiveBand}
setPEQ={commitPEQ}
bandsOrder={bandsOrder}
/>
<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)}
/>
</ChannelGroup>
<ChannelGroup title="CrossOver">
<CrossoverPanel
crossover={ActiveHighPassFilter}
title="HighPass"
setCrossover={commitHighPass}
onLiveValue={handleLiveHighPassValue}
/>
<CrossoverPanel
crossover={ActiveLowPassFilter}
title="LowPass"
setCrossover={commitLowPass}
onLiveValue={handleLiveLowPassValue}
/>
</ChannelGroup>
<ChannelGroup title="Actions">
<ChannelActions
bypass={ActivePeq.bypass}
resetting={resetting}
onBypass={handleBypassClick}
onReset={handleResetClick}
/>
</ChannelGroup>
</div>
</div>
{isResetConfirmOpen && (
<div className="confirm-modal-overlay" onClick={() => setIsResetConfirmOpen(false)}>
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<h3>Reset {activeChannelState.name} PEQ?</h3>
<p>
This will set all PEQ bands on {activeChannelState.name} back to flat (0 dB, Peak).
This action cannot be undone.
</p>
<div className="confirm-modal__actions">
<Button variant="outline" onClick={() => setIsResetConfirmOpen(false)}>
Cancel
</Button>
<Button variant="primary" onClick={confirmResetPeq}>
Reset
</Button>
</div>
</div>
</div>
)}
</>
);
}
export default ChannelSection;
@@ -18,17 +18,23 @@
z-index: 20; z-index: 20;
border-top: var(--border-width) solid var(--border-hairline); border-top: var(--border-width) solid var(--border-hairline);
user-select: none;
-webkit-user-select: none;
} }
.compressor-panels { .compressor-panels {
display: grid;
grid-auto-flow: column;
height: 100%; height: 100%;
display: flex;
gap: var(--space-2); gap: var(--space-2);
overflow-x: auto; overflow-x: auto;
justify-content: center;
} }
.compressor-panel { .compressor-panel {
position: relative; position: relative;
height: 100%;
aspect-ratio: 0.5; aspect-ratio: 0.5;
flex: 0 0 auto; flex: 0 0 auto;
@@ -91,8 +97,9 @@
.compressor-panel__cell--steppers { .compressor-panel__cell--steppers {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: stretch;
gap: 1cqh; gap: 1cqh;
display: flex;
} }
.compressor-panel__stepper-item { .compressor-panel__stepper-item {
@@ -108,6 +115,19 @@
gap: 1cqh; gap: 1cqh;
} }
.compressor-panel__stepper-item .choice-stepper {
flex: 0 0 auto;
height: auto;
}
.compressor-panel__stepper {
display: flex;
font-size: 4cqh;
text-align: center;
align-items: stretch;
height: 100%;
}
.compressor-panel__cell { .compressor-panel__cell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -142,6 +162,9 @@
box-sizing: border-box; box-sizing: border-box;
background: var(--bg-panel); background: var(--bg-panel);
user-select: none;
-webkit-user-select: none;
} }
.compressor-graph-panel { .compressor-graph-panel {
@@ -206,3 +229,15 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
.compressor-panel__stepper--centered .choice-stepper__input {
text-align: center;
padding: 0;
margin: 0;
}
.compressor-panel__stepper--centered .choice-stepper__arrows {
flex: 0 0 20%;
width: 20%;
padding-right: 0;
}
@@ -8,67 +8,7 @@ import MeterBar from '../../../../components/MeterBar/MeterBar';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import ChoiceStepper from '../../../../components/ChoiceStepper/ChoiceStepper'; import ChoiceStepper from '../../../../components/ChoiceStepper/ChoiceStepper';
import './CompressorSection.css'; import './CompressorSection.css';
import { RATIO_ORDER, RATIO_LABEL, RATIO_NUMERIC, KNEE_VALUES } from '../../constants';
// Enum order defines stepper order (1:1 -> Limit).
const RATIO_ORDER: Ratio[] = [
Ratio.Ratio1_1,
Ratio.Ratio1_1_1,
Ratio.Ratio1_1_3,
Ratio.Ratio1_1_5,
Ratio.Ratio1_1_7,
Ratio.Ratio1_2,
Ratio.Ratio1_2_5,
Ratio.Ratio1_3,
Ratio.Ratio1_3_5,
Ratio.Ratio1_4,
Ratio.Ratio1_5,
Ratio.Ratio1_6,
Ratio.Ratio1_8,
Ratio.Ratio1_10,
Ratio.Ratio1_20,
Ratio.Limit,
];
const RATIO_LABEL: Record<Ratio, string> = {
[Ratio.Ratio1_1]: '1:1',
[Ratio.Ratio1_1_1]: '1.1:1',
[Ratio.Ratio1_1_3]: '1.3:1',
[Ratio.Ratio1_1_5]: '1.5:1',
[Ratio.Ratio1_1_7]: '1.7:1',
[Ratio.Ratio1_2]: '2:1',
[Ratio.Ratio1_2_5]: '2.5:1',
[Ratio.Ratio1_3]: '3:1',
[Ratio.Ratio1_3_5]: '3.5:1',
[Ratio.Ratio1_4]: '4:1',
[Ratio.Ratio1_5]: '5:1',
[Ratio.Ratio1_6]: '6:1',
[Ratio.Ratio1_8]: '8:1',
[Ratio.Ratio1_10]: '10:1',
[Ratio.Ratio1_20]: '20:1',
[Ratio.Limit]: 'Limit',
};
// Numeric ratio used only for drawing the transfer curve; Limit ~= brickwall.
const RATIO_NUMERIC: Record<Ratio, number> = {
[Ratio.Ratio1_1]: 1,
[Ratio.Ratio1_1_1]: 1.1,
[Ratio.Ratio1_1_3]: 1.3,
[Ratio.Ratio1_1_5]: 1.5,
[Ratio.Ratio1_1_7]: 1.7,
[Ratio.Ratio1_2]: 2,
[Ratio.Ratio1_2_5]: 2.5,
[Ratio.Ratio1_3]: 3,
[Ratio.Ratio1_3_5]: 3.5,
[Ratio.Ratio1_4]: 4,
[Ratio.Ratio1_5]: 5,
[Ratio.Ratio1_6]: 6,
[Ratio.Ratio1_8]: 8,
[Ratio.Ratio1_10]: 10,
[Ratio.Ratio1_20]: 20,
[Ratio.Limit]: 1000,
};
const KNEE_VALUES = Array.from({ length: 13 }, (_, i) => i); // 0..12 dB, integer steps
const COMPRESSOR_SLIDER_PARAMS = [ const COMPRESSOR_SLIDER_PARAMS = [
{ key: 'threshold', label: 'Threshold', min: -90, max: 20, step: 0.5, unit: ' dB' }, { key: 'threshold', label: 'Threshold', min: -90, max: 20, step: 0.5, unit: ' dB' },
@@ -76,19 +16,6 @@ const COMPRESSOR_SLIDER_PARAMS = [
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' }, { key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
] as const; ] as const;
// ASSUMPTION: output meter indices follow the 4 input channels (0-3), i.e.
// Out1 -> 4, Out2 -> 5, etc. Adjust to match dsp.meters.channels ordering.
const OUTPUT_METER_INDEX: Record<OutputChannel, number> = {
[OutputChannel.Out1]: 4,
[OutputChannel.Out2]: 5,
[OutputChannel.Out3]: 6,
[OutputChannel.Out4]: 7,
[OutputChannel.Out5]: 8,
[OutputChannel.Out6]: 9,
[OutputChannel.Out7]: 10,
[OutputChannel.Out8]: 11,
} as const;
type CompressorParamKey = 'ratio' | 'attack' | 'release' | 'knee' | 'threshold'; type CompressorParamKey = 'ratio' | 'attack' | 'release' | 'knee' | 'threshold';
type CompressorValues = { type CompressorValues = {
@@ -154,6 +81,7 @@ function CompressorPanel({
<div className="compressor-panel__cell compressor-panel__cell--steppers"> <div className="compressor-panel__cell compressor-panel__cell--steppers">
<div className="compressor-panel__stepper-item"> <div className="compressor-panel__stepper-item">
<ChoiceStepper <ChoiceStepper
className="compressor-panel__stepper compressor-panel__stepper--centered"
value={comp.ratio} value={comp.ratio}
values={RATIO_ORDER} values={RATIO_ORDER}
formatLabel={(v) => RATIO_LABEL[v]} formatLabel={(v) => RATIO_LABEL[v]}
@@ -167,6 +95,7 @@ function CompressorPanel({
<div className="compressor-panel__stepper-item"> <div className="compressor-panel__stepper-item">
<ChoiceStepper <ChoiceStepper
className="compressor-panel__stepper compressor-panel__stepper--centered"
value={comp.knee} value={comp.knee}
values={KNEE_VALUES} values={KNEE_VALUES}
formatLabel={(v) => `${v} dB`} formatLabel={(v) => `${v} dB`}
@@ -284,7 +213,7 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
const steps = 120; const steps = 120;
const points: [number, number][] = []; const points: [number, number][] = [];
for (let i = 0; i <= steps; i++) { for (let i = 0; i <= steps; i++) {
const x = -100 + (100 * i) / steps; const x = -100 + (120 * i) / steps;
points.push([x, compressorOutputDb(x, threshold, ratioNumeric, knee)]); points.push([x, compressorOutputDb(x, threshold, ratioNumeric, knee)]);
} }
return points; return points;
@@ -298,9 +227,9 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
<LinearGraph <LinearGraph
xMin={-100} xMin={-100}
xMax={0} xMax={20}
yMin={-100} yMin={-100}
yMax={0} yMax={20}
xLabel="in dB" xLabel="in dB"
yLabel="out dB" yLabel="out dB"
formatX={(v) => Math.round(v).toString()} formatX={(v) => Math.round(v).toString()}
@@ -311,7 +240,7 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.setLineDash([4, 4]); ctx.setLineDash([4, 4]);
const [ux0, uy0] = toPx(-100, -100); const [ux0, uy0] = toPx(-100, -100);
const [ux1, uy1] = toPx(0, 0); const [ux1, uy1] = toPx(20, 20);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(ux0, uy0); ctx.moveTo(ux0, uy0);
ctx.lineTo(ux1, uy1); ctx.lineTo(ux1, uy1);
@@ -323,7 +252,7 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.setLineDash([2, 3]); ctx.setLineDash([2, 3]);
const [tx0, ty0] = toPx(activeComp.threshold, -100); const [tx0, ty0] = toPx(activeComp.threshold, -100);
const [tx1, ty1] = toPx(activeComp.threshold, 0); const [tx1, ty1] = toPx(activeComp.threshold, 20);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(tx0, ty0); ctx.moveTo(tx0, ty0);
ctx.lineTo(tx1, ty1); ctx.lineTo(tx1, ty1);
@@ -349,10 +278,13 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
<div className="compressor-graph__meters"> <div className="compressor-graph__meters">
{outputs.map((output) => { {outputs.map((output) => {
const outputState = dsp.state.current_config.output_states[output]; const outputState = dsp.state.current_config.output_states[output];
const idx = OUTPUT_METER_INDEX[output];
return ( return (
<MeterBar key={output} label={outputState.name} meter={dsp.meters.channels[idx]} /> <MeterBar
key={output}
label={outputState.name}
meter={dsp.output_meters.meters[output]}
/>
); );
})} })}
</div> </div>
@@ -5,6 +5,9 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
user-select: none;
-webkit-user-select: none;
} }
.connection-panel { .connection-panel {
@@ -1,4 +1,4 @@
import { DSP } from '../../../../types'; import { DSP, isConnected } from '../../../../types';
import Button from '../../../../components/Button/Button'; import Button from '../../../../components/Button/Button';
import Card from '../../../../components/Card/Card'; import Card from '../../../../components/Card/Card';
import Led from '../../../../components/Led/Led'; import Led from '../../../../components/Led/Led';
@@ -14,12 +14,13 @@ function ConnectionPanel({
onConnect: () => void; onConnect: () => void;
onDisconnect: () => void; onDisconnect: () => void;
}) { }) {
const statusLabel =
dsp.status === 'connected' const statusLabel =
? 'Connected' isConnected(dsp)
: dsp.status === 'connecting' ? 'Connected'
? 'Connecting…' : dsp.isConnecting
: 'Disconnected'; ? 'Connecting…'
: 'Disconnected';
return ( return (
<div className="connection-section"> <div className="connection-section">
@@ -35,10 +36,10 @@ function ConnectionPanel({
<div className="connection-panel__status"> <div className="connection-panel__status">
<span className="connection-panel__led"> <span className="connection-panel__led">
<Led status={dsp.status} /> <Led status={dsp.isConnecting ? "connecting" : isConnected(dsp) ? "connected" : "disconnected"} />
</span> </span>
<span className={`status-label status-label--${dsp.status}`}>{statusLabel}</span> <span>{statusLabel}</span>
</div> </div>
</div> </div>
@@ -64,13 +65,13 @@ function ConnectionPanel({
</dl> </dl>
<div className="connection-panel__actions"> <div className="connection-panel__actions">
{dsp.status === 'connected' ? ( {isConnected(dsp) ? (
<Button variant="outline" onClick={onDisconnect}> <Button variant="outline" onClick={onDisconnect}>
Disconnect Disconnect
</Button> </Button>
) : ( ) : (
<Button variant="primary" onClick={onConnect} disabled={dsp.status === 'connecting'}> <Button variant="primary" onClick={onConnect} disabled={dsp.isConnecting}>
{dsp.status === 'connecting' ? 'Connecting…' : 'Connect'} {dsp.isConnecting ? 'Connecting…' : 'Connect'}
</Button> </Button>
)} )}
</div> </div>
@@ -29,3 +29,9 @@
.app-root:has(.sidebar-rail--expanded) .dsp-loading-overlay { .app-root:has(.sidebar-rail--expanded) .dsp-loading-overlay {
left: var(--sidebar-width-expanded); left: var(--sidebar-width-expanded);
} }
.app-root {
position: relative;
width: 100%;
height: 100%;
}
@@ -1,6 +1,17 @@
import { useState } from 'react'; import { useEffect, useState, type SetStateAction } from 'react';
import { Sidebar, SidebarItem } from '../../../../components/Sidebar/Sidebar'; import { Sidebar, SidebarItem } from '../../../../components/Sidebar/Sidebar';
import { InfoIcon, KnobGainIcon, TextIcon } from '../../../../assets/icons/AudioIcons'; import {
BoltIcon,
CompressorIcon,
DelayIcon,
GainIcon,
GateIcon,
InfoIcon,
LimiterIcon,
MatrixIcon,
SlidersVerticalIcon,
TextIcon,
} from '../../../../assets/icons/AudioIcons';
import ConnectionPanel from '../ConnectionSection/ConnectionSection'; import ConnectionPanel from '../ConnectionSection/ConnectionSection';
import GainSection from '../GainSection/GainSection'; import GainSection from '../GainSection/GainSection';
import CompressorSection from '../CompressorSection/CompressorSection'; import CompressorSection from '../CompressorSection/CompressorSection';
@@ -11,91 +22,78 @@ import LoadingOverlay from '../../../../components/LoadingOverlay/LoadingOverlay
import { InputChannel, OutputChannel } from '../../types'; import { InputChannel, OutputChannel } from '../../types';
import MatrixSection from '../Matrixsection/Matrixsection'; import MatrixSection from '../Matrixsection/Matrixsection';
import GeqSection from '../GeqSection/GeqSection'; import GeqSection from '../GeqSection/GeqSection';
import InputSection from '../InputSection/InputSection'; import ChannelSection from '../ChannelSection/ChannelSection';
import { DSP408Api } from '../../dsp408'; import { DSP408Api } from '../../dsp408';
import { DSP408 } from '../../../../types'; import { DSP408 } from '../../../../types';
import { SidebarId } from '../../constants'; import { SidebarId } from '../../constants';
import './DSP408Panel.css'; import './DSP408Panel.css';
import MiscellaneousPanel from '../MiscellaneousSection/MiscellaneousSection';
import { PasswordDialog } from '../PasswordDialog/PasswordDialog';
const activeSidebarByDsp = new Map<number, SidebarId>();
function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) { function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
const [activeSidebar, setActiveSidebar] = useState<SidebarId>('overview');
const [sidebarExpanded, setSidebarExpanded] = useState(false); const [sidebarExpanded, setSidebarExpanded] = useState(false);
const [selectedPreset, setSelectedPreset] = useState(dsp.state?.presets.current_index ?? 0); const [selectedPreset, setSelectedPreset] = useState(dsp.state.presets.current_index);
const [presetLoading, setPresetLoading] = useState(false); const [presetLoading, setPresetLoading] = useState(false);
const inputStates = dsp.state?.current_config.input_states;
const outputStates = dsp.state?.current_config.output_states; const inputStates = dsp.state.current_config.input_states;
const outputStates = dsp.state.current_config.output_states;
const sidebarItems: SidebarItem<SidebarId>[] = [ const sidebarItems: SidebarItem<SidebarId>[] = [
{ id: 'overview', label: 'Overview', icon: <InfoIcon /> }, { id: 'overview', label: 'Overview', icon: <InfoIcon /> },
{ id: 'gain', label: 'Gain', icon: <KnobGainIcon /> }, { id: 'gain', label: 'Gain', icon: <GainIcon /> },
{ id: 'gate', label: 'Gate', icon: <KnobGainIcon /> }, { id: 'gate', label: 'Gate', icon: <GateIcon /> },
{ id: 'compressor', label: 'Compressor', icon: <KnobGainIcon /> }, { id: 'compressor', label: 'Compressor', icon: <CompressorIcon /> },
{ id: 'limiter', label: 'Limiter', icon: <KnobGainIcon /> }, { id: 'limiter', label: 'Limiter', icon: <LimiterIcon /> },
{ id: 'delay', label: 'Delay', icon: <KnobGainIcon /> }, { id: 'delay', label: 'Delay', icon: <DelayIcon /> },
{ id: 'matrix', label: 'Matrix', icon: <KnobGainIcon /> }, { id: 'matrix', label: 'Matrix', icon: <MatrixIcon /> },
{ id: 'geq', label: 'Graphic Equalizer', icon: <KnobGainIcon /> }, { id: 'geq', label: 'Graphic Equalizer', icon: <SlidersVerticalIcon /> },
{ id: 'ina', label: `${inputStates[InputChannel.InA].name}`, icon: <TextIcon>In A</TextIcon> }, { id: 'ina', label: inputStates[InputChannel.InA].name, icon: <TextIcon>In A</TextIcon>, editable: true },
{ id: 'inb', label: `${inputStates[InputChannel.InB].name}`, icon: <TextIcon>In B</TextIcon> }, { id: 'inb', label: inputStates[InputChannel.InB].name, icon: <TextIcon>In B</TextIcon>, editable: true },
{ id: 'inc', label: `${inputStates[InputChannel.InC].name}`, icon: <TextIcon>In C</TextIcon> }, { id: 'inc', label: inputStates[InputChannel.InC].name, icon: <TextIcon>In C</TextIcon>, editable: true },
{ id: 'ind', label: `${inputStates[InputChannel.InD].name}`, icon: <TextIcon>In D</TextIcon> }, { id: 'ind', label: inputStates[InputChannel.InD].name, icon: <TextIcon>In D</TextIcon>, editable: true },
{ { id: 'out1', label: outputStates[OutputChannel.Out1].name, icon: <TextIcon>Out 1</TextIcon>, editable: true },
id: 'out1', { id: 'out2', label: outputStates[OutputChannel.Out2].name, icon: <TextIcon>Out 2</TextIcon>, editable: true },
label: `${outputStates[OutputChannel.Out1].name}`, { id: 'out3', label: outputStates[OutputChannel.Out3].name, icon: <TextIcon>Out 3</TextIcon>, editable: true },
icon: <TextIcon>Out 1</TextIcon>, { id: 'out4', label: outputStates[OutputChannel.Out4].name, icon: <TextIcon>Out 4</TextIcon>, editable: true },
}, { id: 'out5', label: outputStates[OutputChannel.Out5].name, icon: <TextIcon>Out 5</TextIcon>, editable: true },
{ { id: 'out6', label: outputStates[OutputChannel.Out6].name, icon: <TextIcon>Out 6</TextIcon>, editable: true },
id: 'out2', { id: 'out7', label: outputStates[OutputChannel.Out7].name, icon: <TextIcon>Out 7</TextIcon>, editable: true },
label: `${outputStates[OutputChannel.Out2].name}`, { id: 'out8', label: outputStates[OutputChannel.Out8].name, icon: <TextIcon>Out 8</TextIcon>, editable: true },
icon: <TextIcon>Out 2</TextIcon>, { id: 'miscellaneous', label: 'Miscellaneous', icon: <BoltIcon /> },
},
{
id: 'out3',
label: `${outputStates[OutputChannel.Out3].name}`,
icon: <TextIcon>Out 3</TextIcon>,
},
{
id: 'out4',
label: `${outputStates[OutputChannel.Out4].name}`,
icon: <TextIcon>Out 4</TextIcon>,
},
{
id: 'out5',
label: `${outputStates[OutputChannel.Out5].name}`,
icon: <TextIcon>Out 5</TextIcon>,
},
{
id: 'out6',
label: `${outputStates[OutputChannel.Out6].name}`,
icon: <TextIcon>Out 6</TextIcon>,
},
{
id: 'out7',
label: `${outputStates[OutputChannel.Out7].name}`,
icon: <TextIcon>Out 7</TextIcon>,
},
{
id: 'out8',
label: `${outputStates[OutputChannel.Out8].name}`,
icon: <TextIcon>Out 8</TextIcon>,
},
{ id: 'miscellaneous', label: 'Miscellaneous', icon: <TextIcon>Out 1</TextIcon> },
]; ];
const presets = dsp.state?.presets.names const presets = [
? [ { id: 'f00', name: 'Factory Preset' },
{ id: 'f00', name: 'Factory Preset' }, ...(dsp.state.presets.names.map((name, index) => ({
...dsp.state.presets.names.map((name, index) => ({ id: `u${String(index + 1).padStart(2, '0')}`,
id: `u${String(index + 1).padStart(2, '0')}`, name,
name, })) ??
})), Array.from({ length: 20 }, (_, index) => ({
] id: `u${String(index + 1).padStart(2, '0')}`,
: [ name: 'Default Preset',
{ id: 'f00', name: 'Factory Preset' }, }))),
...Array.from({ length: 20 }, (_, index) => ({ ];
id: `u${String(index + 1).padStart(2, '0')}`,
name: 'Default Preset', const [activeSidebar, setActiveSidebarState] = useState<SidebarId>(
})), () => activeSidebarByDsp.get(dsp.id) ?? 'overview'
]; );
useEffect(() => {
const saved = activeSidebarByDsp.get(dsp.id) ?? 'overview';
setActiveSidebarState(saved);
}, [dsp.id]);
const setActiveSidebar = (value: SetStateAction<SidebarId>) => {
setActiveSidebarState(prev => {
const next = typeof value === 'function' ? value(prev) : value;
activeSidebarByDsp.set(dsp.id, next);
return next;
});
};
const onAppChange = async (id: string) => { const onAppChange = async (id: string) => {
const presetIndex = presets.findIndex((preset) => preset.id === id); const presetIndex = presets.findIndex((preset) => preset.id === id);
@@ -107,6 +105,43 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
setPresetLoading(false); setPresetLoading(false);
}; };
const onItemRename = async (id: SidebarId, name: string) => {
const channel = channelMap[id];
if (!channel) return;
const padded = name.slice(0, 8).padEnd(8, '\0');
await api.setChannelName(channel, padded);
};
const renderChannel = (channel: { Input: InputChannel } | { Output: OutputChannel }) => (
<ChannelSection
dsp={dsp}
channel={channel}
setLowPass={api.setLowPass}
setHighPass={api.setHighPass}
setPeq={api.setPeqBand}
setPeqBypass={api.setPEQBypass}
/>
);
const channelMap: Partial<
Record<SidebarId, { Input: InputChannel } | { Output: OutputChannel }>
> = {
ina: { Input: InputChannel.InA },
inb: { Input: InputChannel.InB },
inc: { Input: InputChannel.InC },
ind: { Input: InputChannel.InD },
out1: { Output: OutputChannel.Out1 },
out2: { Output: OutputChannel.Out2 },
out3: { Output: OutputChannel.Out3 },
out4: { Output: OutputChannel.Out4 },
out5: { Output: OutputChannel.Out5 },
out6: { Output: OutputChannel.Out6 },
out7: { Output: OutputChannel.Out7 },
out8: { Output: OutputChannel.Out8 },
};
const renderTab = () => { const renderTab = () => {
switch (activeSidebar) { switch (activeSidebar) {
case 'overview': case 'overview':
@@ -134,18 +169,6 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
case 'delay': case 'delay':
return <DelaySection dsp={dsp} setDelay={api.setDelay} setDelayUnit={api.setDelayUnit} />; return <DelaySection dsp={dsp} setDelay={api.setDelay} setDelayUnit={api.setDelayUnit} />;
case 'ina':
return (
<InputSection
dsp={dsp}
channel={InputChannel.InA}
setLowPass={api.setLowPass}
setHighPass={api.setHighPass}
setPeq={api.setPeqBand}
setPeqBypass={api.setPEQBypass}
/>
);
case 'matrix': case 'matrix':
return ( return (
<MatrixSection <MatrixSection
@@ -159,33 +182,58 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
case 'geq': case 'geq':
return <GeqSection dsp={dsp} setGeqBand={api.setGeqBand} setGeqBypass={api.setGeqBypass} />; return <GeqSection dsp={dsp} setGeqBand={api.setGeqBand} setGeqBypass={api.setGeqBypass} />;
default: case 'miscellaneous':
return null; return (
<MiscellaneousPanel
dsp={dsp}
factoryReset={api.factoryReset}
copyChannel={api.copyChannel}
setInputSource={api.setInputSource}
linkChannels={api.linkChannels}
lockDevice={api.lockDevice}
setActiveSidebar={setActiveSidebar}
/>
);
default: {
const channel = channelMap[activeSidebar];
return channel ? renderChannel(channel) : null;
}
} }
}; };
return ( return (
<div className={`app-root ${sidebarExpanded ? 'app-root--sidebar-expanded' : ''}`}> <div className={`app-root ${sidebarExpanded ? 'app-root--sidebar-expanded' : ''}`}>
<Sidebar <Sidebar
items={sidebarItems} items={sidebarItems}
activeId={activeSidebar} activeId={activeSidebar}
onSelect={setActiveSidebar} onSelect={setActiveSidebar}
appName={dsp.name} appName={dsp.name}
appOptions={presets} appOptions={presets}
selectedAppId={presets[selectedPreset]?.id} selectedAppId={presets[selectedPreset]?.id}
onAppChange={onAppChange} onAppChange={onAppChange}
onAppRename={api.setCurrentPresetName} onAppRename={api.setCurrentPresetName}
expanded={sidebarExpanded} onItemRename={onItemRename}
onToggle={() => setSidebarExpanded((v) => !v)} expanded={sidebarExpanded}
/> onToggle={() => setSidebarExpanded((v) => !v)}
/>
<LoadingOverlay <LoadingOverlay
active={presetLoading} active={presetLoading}
label="Recalling preset…" label="Recalling preset…"
overlayClassName="dsp-loading-overlay" overlayClassName="dsp-loading-overlay"
> >
<div className="dsp-content">{renderTab()}</div> <div className="dsp-content">{renderTab()}</div>
</LoadingOverlay> </LoadingOverlay>
{dsp.passwordRequired && (
<PasswordDialog
name={dsp.name}
onSubmit={(pw: string) => api.connectWithPassword(pw)}
onCancel={() => api.cancelPassword()}
/>
)}
</div> </div>
); );
} }
@@ -19,13 +19,18 @@
z-index: 20; z-index: 20;
border-top: var(--border-width) solid var(--border-hairline); border-top: var(--border-width) solid var(--border-hairline);
user-select: none;
-webkit-user-select: none;
} }
.delay-panels { .delay-panels {
display: grid;
grid-auto-flow: column;
height: 100%; height: 100%;
display: flex;
gap: var(--space-2); gap: var(--space-2);
overflow-x: auto; overflow-x: auto;
justify-content: center;
} }
.delay-panel { .delay-panel {
@@ -86,6 +91,9 @@
box-sizing: border-box; box-sizing: border-box;
background: var(--bg-panel); background: var(--bg-panel);
user-select: none;
-webkit-user-select: none;
} }
.delay-graph-panel { .delay-graph-panel {
@@ -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',
@@ -19,11 +19,14 @@
z-index: 20; z-index: 20;
border-top: var(--border-width) solid var(--border-hairline); border-top: var(--border-width) solid var(--border-hairline);
user-select: none;
-webkit-user-select: none;
} }
.gain-panels { .gain-panels {
display: grid;
grid-auto-flow: column;
height: 100%; height: 100%;
display: flex;
gap: var(--space-2); gap: var(--space-2);
overflow-x: auto; overflow-x: auto;
} }
@@ -74,6 +77,9 @@
box-sizing: border-box; box-sizing: border-box;
background: var(--bg-panel); background: var(--bg-panel);
user-select: none;
-webkit-user-select: none;
} }
.gain-graph-panel { .gain-graph-panel {
@@ -103,3 +109,121 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
.gain-graph-title-row {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.75cqh;
padding: 1cqh 1.5cqh;
min-width: 0;
overflow: hidden;
}
/* First row: title left, Show stages right */
.gain-graph-title-line {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-width: 0;
gap: 1cqh;
}
.gain-graph-title-line .gain-graph-title {
flex: 1 1 auto;
min-width: 0;
padding: 0;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Compact button on the right */
.gain-stages-toggle {
flex: 0 0 auto;
width: auto !important;
min-width: max-content;
padding: 0.4cqh 1cqh;
font-size: 1.8cqh;
line-height: 1.2;
white-space: nowrap;
}
/* Channel Selector */
.magnitude-selector {
width: 100%;
min-width: 0;
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.5cqh;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
.magnitude-selector__chip {
flex: 0 1 auto;
min-width: 0;
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;
white-space: nowrap;
overflow: hidden;
}
.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;
}
.magnitude-selector__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -1,12 +1,195 @@
import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider'; import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider';
import { InputChannel, Channel, OutputChannel } from '../../types'; import { InputChannel, Channel, OutputChannel, CrossoverFilter, PEQBand, PEQ } 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: {
bypass: boolean;
bands: Record<PEQBand, PEQ>;
},
crossover: {
high_pass: { frequency: number; slope: CrossoverFilter };
low_pass: { frequency: number; slope: CrossoverFilter };
}
) {
return sumCurvePoints([
...(peqChain.bypass
? []
: [
getPeqCurvePoints({
freqs,
bands: peqChain.bands,
}),
]),
getCrossoverCurvePoints({
freqs,
highPass: {
frequency: crossover.high_pass.frequency,
filter_type: crossover.high_pass.slope,
},
lowPass: {
frequency: crossover.low_pass.frequency,
filter_type: crossover.low_pass.slope,
},
}),
]);
}
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 = state.geq.bypass
? []
: 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].reverse().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 +198,7 @@ function GainPanel({
setGain, setGain,
setMute, setMute,
setInverse, setInverse,
onLiveGain,
}: { }: {
dsp: DSP; dsp: DSP;
channel: Channel; channel: Channel;
@@ -22,6 +206,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 +225,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 +248,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 +288,41 @@ 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)}
title={labels[channel]}
>
<span className="magnitude-selector__swatch" />
<span className="magnitude-selector__label">{labels[channel]}</span>
</button>
);
})}
</div>
);
}
function GainSection({ function GainSection({
dsp, dsp,
setGain, setGain,
@@ -101,105 +334,218 @@ 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,
12,
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-line">
<div className="gain-graph-title">Input Response</div>
<Button
variant="toggle"
className={`gain-stages-toggle ${showInputStages ? 'active-info' : ''}`}
onClick={() => setShowInputStages((v) => !v)}
>
Show stages
</Button>
</div>
<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) => {
// Always label 0.
if (v === 0) return '0';
// Positive side: label every 12 dB.
if (v > 0) {
return v % 12 === 0 ? `+${Math.round(v)}` : '';
}
// Negative side: label every 15 dB.
return Math.abs(v) % 15 === 0 ? 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-line">
<div className="gain-graph-title">Output Response</div>
<Button
variant={showOutputStages ? 'outline' : 'ghost'}
className="gain-stages-toggle"
onClick={() => setShowOutputStages((v) => !v)}
>
Show stages
</Button>
</div>
<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 +554,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 +563,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>
@@ -7,7 +7,7 @@
right: 0; right: 0;
bottom: 0; bottom: 0;
height: 40%; height: var(--dsp-control-heigth);
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@@ -20,13 +20,18 @@
z-index: 20; z-index: 20;
border-top: var(--border-width) solid var(--border-hairline); border-top: var(--border-width) solid var(--border-hairline);
user-select: none;
-webkit-user-select: none;
} }
.gate-panels { .gate-panels {
display: grid;
grid-auto-flow: column;
height: 100%; height: 100%;
display: flex;
gap: var(--space-2); gap: var(--space-2);
overflow-x: auto; overflow-x: auto;
justify-content: center;
} }
.gate-panel { .gate-panel {
@@ -104,6 +109,9 @@
box-sizing: border-box; box-sizing: border-box;
background: var(--bg-panel); background: var(--bg-panel);
user-select: none;
-webkit-user-select: none;
} }
.gate-graph-panel { .gate-graph-panel {
@@ -15,13 +15,6 @@ const GATE_PARAMS = [
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' }, { key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
] as const; ] as const;
const INPUT_METER_INDEX: Record<InputChannel, number> = {
[InputChannel.InA]: 0,
[InputChannel.InB]: 1,
[InputChannel.InC]: 2,
[InputChannel.InD]: 3,
} as const;
type GateParamKey = (typeof GATE_PARAMS)[number]['key']; type GateParamKey = (typeof GATE_PARAMS)[number]['key'];
type GateValues = { type GateValues = {
@@ -139,8 +132,8 @@ function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
<LinearGraph <LinearGraph
xMin={envelope.t0} xMin={envelope.t0}
xMax={envelope.t5} xMax={envelope.t5}
yMin={-90} yMin={-95}
yMax={0} yMax={5}
xLabel="ms" xLabel="ms"
yLabel="dB" yLabel="dB"
formatX={(v) => Math.round(v).toString()} formatX={(v) => Math.round(v).toString()}
@@ -185,9 +178,14 @@ function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
<div className="gate-graph__meters"> <div className="gate-graph__meters">
{inputs.map((input) => { {inputs.map((input) => {
const inputState = dsp.state.current_config.input_states[input]; const inputState = dsp.state.current_config.input_states[input];
const idx = INPUT_METER_INDEX[input];
const meter = dsp.meters.channels[idx]; return (
return <MeterBar key={input} label={inputState.name} meter={meter} />; <MeterBar
key={input}
label={inputState.name}
meter={dsp.input_meters.meters[input]}
/>
);
})} })}
</div> </div>
</div> </div>
@@ -19,13 +19,18 @@
z-index: 20; z-index: 20;
border-top: var(--border-width) solid var(--border-hairline); border-top: var(--border-width) solid var(--border-hairline);
user-select: none;
-webkit-user-select: none;
} }
.geq-panels { .geq-panels {
display: grid;
grid-auto-flow: column;
height: 100%; height: 100%;
display: flex;
gap: var(--space-2); gap: var(--space-2);
overflow-x: auto; overflow-x: auto;
justify-content: center;
} }
.geq-band-panel { .geq-band-panel {
@@ -112,6 +117,9 @@
box-sizing: border-box; box-sizing: border-box;
background: var(--bg-panel); background: var(--bg-panel);
user-select: none;
-webkit-user-select: none;
} }
.geq-graph-panel { .geq-graph-panel {
@@ -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, useEffect } 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">
@@ -165,7 +175,7 @@ function GeqActions({
<div className="geq-channel-selector"> <div className="geq-channel-selector">
<Button <Button
variant="toggle" variant="toggle"
className={bypass ? 'active-info' : ''} className={bypass ? 'active-warning' : ''}
onClick={onBypass} onClick={onBypass}
style={{ style={{
width: '100%', width: '100%',
@@ -191,7 +201,7 @@ function GeqActions({
minHeight: 0, minHeight: 0,
}} }}
> >
{resetting ? 'Resetting…' : 'Reset'} {/* NEW */} {resetting ? 'Resetting…' : 'Reset'}
</Button> </Button>
</div> </div>
); );
@@ -208,7 +218,21 @@ 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 [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
useEffect(() => {
if (!isResetConfirmOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsResetConfirmOpen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isResetConfirmOpen]);
const handleLiveValue = (channel: InputChannel, band: DiscreteFrequency, value: number) => { const handleLiveValue = (channel: InputChannel, band: DiscreteFrequency, value: number) => {
setLiveGeq((prev) => ({ setLiveGeq((prev) => ({
@@ -231,7 +255,15 @@ function GeqSection({
return success; return success;
}; };
// NEW: sequential per-band reset, since hardware has no bulk reset command. const handleBypassClick = () => {
setGeqBypass(activeChannel, !activeGeq.bypass);
};
const handleResetClick = () => {
setIsResetConfirmOpen(true);
};
// 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);
@@ -247,14 +279,15 @@ function GeqSection({
setResetting(false); setResetting(false);
}; };
const confirmReset = async () => {
setIsResetConfirmOpen(false);
await handleReset(activeChannel);
};
const activeInputState = dsp.state.current_config.input_states[activeChannel]; const activeInputState = dsp.state.current_config.input_states[activeChannel];
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,
@@ -296,12 +329,33 @@ function GeqSection({
bypass={activeGeq.bypass} bypass={activeGeq.bypass}
resetting={resetting} resetting={resetting}
onBypass={handleBypassClick} onBypass={handleBypassClick}
onReset={() => handleReset(activeChannel)} onReset={handleResetClick}
/> />
</ChannelGroup> </ChannelGroup>
</div> </div>
</div> </div>
</div> </div>
{isResetConfirmOpen && (
<div className="confirm-modal-overlay" onClick={() => setIsResetConfirmOpen(false)}>
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<h3>Reset {activeInputState.name} GEQ?</h3>
<p>
This will set all GEQ bands on {activeInputState.name} back to 0 dB. This action
cannot be undone.
</p>
<div className="confirm-modal__actions">
<Button variant="outline" onClick={() => setIsResetConfirmOpen(false)}>
Cancel
</Button>
<Button variant="primary" onClick={confirmReset}>
Reset
</Button>
</div>
</div>
</div>
)}
</> </>
); );
} }
@@ -1,535 +0,0 @@
// InputSection.tsx
import VerticalSlider, {
makeLogStep,
makeLogQuantize,
} from '../../../../components/VerticalSlider/VerticalSlider';
// ASSUMPTION: an InputChannel enum exists alongside OutputChannel, mapping
// input pages 1-8 similarly (In1 -> 0, In2 -> 1, ...). Adjust import/values
// if the actual enum differs.
import { InputChannel, Channel, PEQBand, PEQFilter, PEQ, Meter } from '../../types';
import { DSP408 as DSP } from '../../../../types';
import { CrossoverFilter } from '../../types';
import { LinearGraph } from '../../../../components/LinearGraph/LinearGraph';
import { useRef, useState } from 'react';
import './InputSection.css';
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import Button from '../../../../components/Button/Button';
import MeterBar from '../../../../components/MeterBar/MeterBar';
const PEQ_BANDS: PEQBand[] = [
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
const PEQ_FILTER_TYPE_OPTIONS: { value: PEQFilter; label: string }[] = [
{ value: PEQFilter.Peak, label: 'Peak' },
{ value: PEQFilter.LowShelf, label: 'Low Shelf' },
{ value: PEQFilter.HighShelf, label: 'High Shelf' },
];
const FILTER_TYPE_OPTIONS: { 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' },
];
const PEQ_SLIDER_PARAMS = [
{ key: 'frequency', label: 'Frequency', min: 19.7, max: 20160, step: 0.5, unit: ' Hz' },
{ key: 'q', label: 'Q', min: 1, max: 999, step: 1, unit: '' },
{ key: 'gain', label: 'Gain', min: -12, max: +12, step: 0.1, unit: ' dB' },
] as const;
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;
};
function PeqPanelSliders({
channel,
dsp,
band,
setPEQ,
// onLiveValue,
}: {
channel: InputChannel;
dsp: DSP;
band: PEQBand;
setPEQ: SetPEQFn;
// onLiveValue: (channel: InputChannel, key: PeqParamKey, value: number) => void;
}) {
const inputState = dsp.state.current_config.input_states[channel];
const peq = inputState.peq_chain.bands[band] as PEQ;
const commit = (overrides: Partial<PEQValues>) => {
const next = { ...peq, ...overrides };
return setPEQ(
{ Input: channel },
band,
next.gain,
next.frequency,
next.q,
next.filter_type,
next.bypass
);
};
return (
<div className="input-panel">
<div className="input-panel__title">{'PEQ Parameters'}</div>
<div className="input-panel__grid">
{PEQ_SLIDER_PARAMS.map((param) => (
<div className={`input-panel__cell input-panel__cell--${param.key}`} key={param.key}>
<VerticalSlider
min={param.min}
max={param.max}
step={() => param.step}
unit={param.unit}
value={peq[param.key]}
// onInput={(value) => onLiveValue(channel, param.key, value)}
onChange={(value) => commit({ [param.key]: value } as Partial<PEQValues>)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-panel__cell-label">{param.label}</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({
channel,
dsp,
title,
setCrossover,
}: {
channel: Channel;
dsp: DSP;
title: string;
setCrossover: (
channel: Channel,
frequency: number,
filter_type: CrossoverFilter
) => Promise<boolean>;
}) {
const channelState =
'Input' in channel
? dsp.state?.current_config.input_states[channel.Input]
: dsp.state?.current_config.output_states[channel.Output];
const crossover = channelState?.crossover;
const frequency = crossover.high_pass.frequency;
const filter_type = crossover.high_pass.slope;
const bypass = false;
const commit = (overrides: {
frequency?: number;
filter_type?: CrossoverFilter;
bypass?: boolean;
}) => {
const next = { frequency, filter_type, bypass, ...overrides };
return setCrossover(channel, next.frequency, next.filter_type);
};
return (
<div className="crossover-panel">
<div className="crossover-panel__title">{title}</div>
<VerticalSlider
min={FREQ_MIN}
max={FREQ_MAX}
scale="log"
step={FREQ_STEP}
quantize={FREQ_QUANTIZE}
unit=" Hz"
value={frequency}
onChange={(value) => commit({ frequency: value })}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<select
className="crossover-panel__select"
value={filter_type}
onChange={(e) => commit({ filter_type: e.target.value as CrossoverFilter })}
>
{FILTER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<Button
variant="toggle"
className={bypass ? 'active-warning' : ''}
onClick={() => commit({ bypass: !bypass })}
style={{
width: '85%',
paddingLeft: 0,
paddingRight: 0,
fontSize: '4cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}}
>
{bypass ? 'Bypassed' : 'Bypass'}
</Button>
</div>
);
}
function PeqBandList({
channel,
dsp,
activeBand,
setActiveBand,
setPEQ,
}: {
channel: InputChannel;
dsp: DSP;
activeBand: PEQBand;
setActiveBand: (band: PEQBand) => void;
setPEQ: SetPEQFn;
}) {
const inputState = dsp.state.current_config.input_states[channel];
const commit = (band: PEQBand, overrides: Partial<PEQValues>) => {
const peq = inputState.peq_chain.bands[band] as PEQ;
const next = { ...peq, ...overrides };
return setPEQ(
{ Input: channel },
band,
next.gain,
next.frequency,
next.q,
next.filter_type,
next.bypass
);
};
return (
<div className="peq-band-list">
<div className="peq-band-list__row peq-band-list__row--header">
<div className="peq-band-list__cell">Band</div>
<div className="peq-band-list__cell">Freq</div>
<div className="peq-band-list__cell">Q</div>
<div className="peq-band-list__cell">Gain</div>
<div className="peq-band-list__cell">Type</div>
<div className="peq-band-list__cell">Bypass</div>
</div>
{PEQ_BANDS.map((band, i) => {
const peq = inputState.peq_chain.bands[band] as PEQ;
const isActive = band === activeBand;
return (
<div
className={`peq-band-list__row${isActive ? ' peq-band-list__row--active' : ''}`}
key={band}
>
<Button
variant="toggle"
className={isActive ? 'active' : ''}
onClick={() => setActiveBand(band)}
style={{ minHeight: 0 }}
>
{`B${i + 1}`}
</Button>
<input
className="peq-band-list__input"
type="number"
step={0.5}
min={19.7}
max={20160}
value={peq.frequency}
onChange={(e) => commit(band, { frequency: Number(e.target.value) })}
/>
<input
className="peq-band-list__input"
type="number"
step={1}
min={1}
max={999}
value={peq.q}
onChange={(e) => commit(band, { q: Number(e.target.value) })}
/>
<input
className="peq-band-list__input"
type="number"
step={0.1}
min={-12}
max={12}
value={peq.gain}
onChange={(e) => commit(band, { gain: Number(e.target.value) })}
/>
<select
className="peq-band-list__select"
value={peq.filter_type}
onChange={(e) => commit(band, { filter_type: e.target.value as PEQFilter })}
>
{PEQ_FILTER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<Button
variant="toggle"
className={peq.bypass ? 'active-warning' : ''}
onClick={() => commit(band, { bypass: !peq.bypass })}
style={{ minHeight: 0 }}
>
{peq.bypass ? 'Bypassed' : 'Bypass'}
</Button>
</div>
);
})}
</div>
);
}
type SetFilterFn = (
channel: Channel,
freq: number,
filterType: CrossoverFilter
) => Promise<boolean>;
type setPeqBandFn = (
channel: Channel,
band: PEQBand,
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
function InputSection({
dsp,
channel,
setHighPass,
setLowPass,
setPeq,
setPeqBypass,
}: {
dsp: DSP;
channel: InputChannel;
setHighPass: SetFilterFn;
setLowPass: SetFilterFn;
setPeq: setPeqBandFn;
setPeqBypass: (channel: Channel, bypass: boolean) => Promise<boolean>;
}) {
const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1);
const [liveHpFreq, setLiveHpFreq] = useState<number>(19.7);
const [liveLpFreq, setLiveLpFreq] = useState<number>(20.16);
const [livePeq, setLivePeq] = useState<Partial<Record<PEQBand, PEQValues>>>({});
const inputState = dsp.state.current_config.input_states[channel];
const hp = inputState.crossover.high_pass;
const lp = inputState.crossover.low_pass;
const activePeq = livePeq[activeBand] ?? (inputState.peq_chain.bands[activeBand] as PEQValues);
const bypassed = hp.slope === CrossoverFilter.Bypass;
const activeFilterType = bypassed && lp.slope !== CrossoverFilter.Bypass ? lp.slope : hp.slope;
// Remembers the last non-bypass slope so the Bypass button can restore it.
// const lastNonBypassType = useRef<CrossoverFilter>(
// activeFilterType === CrossoverFilter.Bypass ? DEFAULT_FILTER_TYPE : activeFilterType
// );
// if (activeFilterType !== CrossoverFilter.Bypass) {
// lastNonBypassType.current = activeFilterType;
// }
const hpFreq = liveHpFreq ?? hp.frequency;
const lpFreq = liveLpFreq ?? lp.frequency;
// const commitHighPass = async (freq: number, filterType: CrossoverFilter) => {
// const success = await set_high_pass({ Input: channel }, freq, filterType);
// if (success) setLiveHpFreq(null);
// return success;
// };
// const commitLowPass = async (freq: number, filterType: CrossoverFilter) => {
// const success = await set_low_pass({ Input: channel }, freq, filterType);
// if (success) setLiveLpFreq(null);
// return success;
// };
// const handleFilterTypeChange = (filterType: CrossoverFilter) => {
// // Applying a slope change always takes the crossover out of bypass.
// void commitHighPass(hpFreq, filterType);
// void commitLowPass(lpFreq, filterType);
// };
// const handleBypassToggle = () => {
// if (bypassed) {
// const restore = lastNonBypassType.current;
// void commitHighPass(hpFreq, restore);
// void commitLowPass(lpFreq, restore);
// } else {
// void commitHighPass(hpFreq, CrossoverFilter.Bypass);
// void commitLowPass(lpFreq, CrossoverFilter.Bypass);
// }
// };
return (
<>
<div className="input-graph-section">
<div className="input-graph-panel">
<div className="input-graph__title">{inputState.name} - Filters</div>
{/* TODO: draw HP/LP transfer curves here once filter math is defined,
same pattern as LimiterSection's `draw` prop. */}
<LinearGraph
xMin={20}
xMax={20000}
yMin={-30}
yMax={6}
xLabel="Hz"
yLabel="dB"
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={() => {}}
/>
</div>
</div>
<div className="input-control-section">
<div className="input-control-panel">
<ChannelGroup title="PEQ">
<PeqBandList
channel={channel}
dsp={dsp}
activeBand={activeBand}
setActiveBand={setActiveBand}
setPEQ={setPeq}
/>
<PeqPanelSliders channel={channel} dsp={dsp} band={activeBand} setPEQ={setPeq} />
</ChannelGroup>
<ChannelGroup title="CrossOver">
<CrossoverPanel
channel={{ Input: channel }}
dsp={dsp}
title="LowPass"
setCrossover={setHighPass}
/>
<CrossoverPanel
channel={{ Input: channel }}
dsp={dsp}
title="HighPass"
setCrossover={setHighPass}
/>
</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">
<VerticalSlider
min={FREQ_MIN}
max={FREQ_MAX}
step={FREQ_STEP}
unit=" Hz"
value={lpFreq}
// disabled={bypassed}
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>
</>
);
}
export default InputSection;
@@ -18,18 +18,23 @@
z-index: 20; z-index: 20;
border-top: var(--border-width) solid var(--border-hairline); border-top: var(--border-width) solid var(--border-hairline);
user-select: none;
-webkit-user-select: none;
} }
.limiter-panels { .limiter-panels {
display: grid;
grid-auto-flow: column;
height: 100%; height: 100%;
display: flex;
gap: var(--space-2); gap: var(--space-2);
overflow-x: auto; overflow-x: auto;
justify-content: center;
} }
.limiter-panel { .limiter-panel {
position: relative; position: relative;
height: 10; height: 100%;
aspect-ratio: 0.5; aspect-ratio: 0.5;
flex: 0 0 auto; flex: 0 0 auto;
@@ -116,6 +121,9 @@
box-sizing: border-box; box-sizing: border-box;
background: var(--bg-panel); background: var(--bg-panel);
user-select: none;
-webkit-user-select: none;
} }
.limiter-graph-panel { .limiter-graph-panel {
@@ -14,20 +14,6 @@ const LIMITER_SLIDER_PARAMS = [
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' }, { key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
] as const; ] as const;
// ASSUMPTION: output meter indices follow the 4 input channels (0-3), i.e.
// Out1 -> 4, Out2 -> 5, etc. Same mapping as CompressorSection — adjust if
// dsp.meters.channels ordering differs.
const OUTPUT_METER_INDEX: Record<OutputChannel, number> = {
[OutputChannel.Out1]: 4,
[OutputChannel.Out2]: 5,
[OutputChannel.Out3]: 6,
[OutputChannel.Out4]: 7,
[OutputChannel.Out5]: 8,
[OutputChannel.Out6]: 9,
[OutputChannel.Out7]: 10,
[OutputChannel.Out8]: 11,
} as const;
type LimiterParamKey = 'threshold' | 'attack' | 'release'; type LimiterParamKey = 'threshold' | 'attack' | 'release';
type LimiterValues = { type LimiterValues = {
@@ -206,10 +192,13 @@ function LimiterSection({ dsp, set_limiter }: { dsp: DSP; set_limiter: SetLimite
<div className="limiter-graph__meters"> <div className="limiter-graph__meters">
{outputs.map((output) => { {outputs.map((output) => {
const outputState = dsp.state.current_config.output_states[output]; const outputState = dsp.state.current_config.output_states[output];
const idx = OUTPUT_METER_INDEX[output];
return ( return (
<MeterBar key={output} label={outputState.name} meter={dsp.meters.channels[idx]} /> <MeterBar
key={output}
label={outputState.name}
meter={dsp.output_meters.meters[output]}
/>
); );
})} })}
</div> </div>
@@ -19,19 +19,25 @@
z-index: 20; z-index: 20;
border-top: var(--border-width) solid var(--border-hairline); border-top: var(--border-width) solid var(--border-hairline);
user-select: none;
-webkit-user-select: none;
} }
.matrix-panels { .matrix-panels {
display: grid;
grid-auto-flow: column;
height: 100%; height: 100%;
display: flex;
gap: var(--space-2); gap: var(--space-2);
overflow-x: auto; overflow-x: auto;
justify-content: center;
} }
.matrix-panel { .matrix-panel {
position: relative; position: relative;
height: 100%;
aspect-ratio: 0.6; aspect-ratio: 0.5;
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
@@ -84,10 +90,30 @@
padding-inline: 3cqh; padding-inline: 3cqh;
} }
.matrix-panel__row .choice-stepper {
align-self: center;
aspect-ratio: 3 / 1;
flex: 0 0 auto;
}
.matrix-flow { .matrix-flow {
flex: 0 0 calc(100% - var(--dsp-control-heigth)); flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0; min-height: 0;
box-sizing: border-box; box-sizing: border-box;
padding: var(--space-2); padding: var(--space-2);
border-bottom: var(--border-width) solid var(--border-hairline); border-bottom: var(--border-width) solid var(--border-hairline);
user-select: none;
-webkit-user-select: none;
}
.matrix-gain-stepper .choice-stepper__input {
font-size: 2.8cqh;
text-align: center;
}
.matrix-gain-stepper .choice-stepper__arrows {
flex: 0 0 20%;
width: 20%;
padding-right: 0;
} }

Some files were not shown because too many files have changed in this diff Show More