Compare commits

...
57 Commits
Author SHA1 Message Date
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
LucasDLTG a28dd890dd feat: start input section 2026-08-12 22:19:09 +02:00
LucasDLTG 00adc4df53 feat: add matrix section 2026-08-12 18:45:57 +02:00
LucasDLTG eb751f9404 feat: round max on delay graph 2026-08-12 12:23:08 +02:00
LucasDLTG 5945b48b98 feat: improve compressor graph 2026-08-12 12:19:17 +02:00
LucasDLTG ab9de39556 fix: error js 2026-08-12 11:57:39 +02:00
LucasDLTG ddec10ca8f feat: add geq graph + bypass warning 2026-08-12 11:51:26 +02:00
141 changed files with 11216 additions and 7840 deletions
+24 -24
View File
@@ -1,24 +1,24 @@
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea
.DS_Store .DS_Store
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
+6 -6
View File
@@ -1,7 +1,7 @@
{ {
"semi": true, "semi": true,
"singleQuote": true, "singleQuote": true,
"trailingComma": "es5", "trailingComma": "es5",
"printWidth": 100, "printWidth": 100,
"tabWidth": 2 "tabWidth": 2
} }
+3 -3
View File
@@ -1,3 +1,3 @@
{ {
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
} }
+17 -10
View File
@@ -1,10 +1,17 @@
# Tauri + React + Typescript # Tauri + React + Typescript
This template should help get you started developing with Tauri, React and Typescript in Vite. This template should help get you started developing with Tauri, React and Typescript in Vite.
## Recommended IDE Setup ## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) - [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
# TODO # TODO
- Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly - Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly
- Check render to improve perfoormances
- Check in meter if clip bool is limit instead
- On close disconenct all ds^p
- Ma-trix gain block sometimes
- Q out of range
- Check end to end behavioour
+14 -14
View File
@@ -1,14 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title> <title>Tauri + React + Typescript</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+3077 -3131
View File
File diff suppressed because it is too large Load Diff
+31 -31
View File
@@ -1,31 +1,31 @@
{ {
"name": "thomann-dsp-app", "name": "thomann-dsp-app",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css}\"", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css}\"" "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css}\""
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6", "eslint-plugin-prettier": "^5.5.6",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^7.0.4" "vite": "^7.0.4"
} }
} }
+7 -7
View File
@@ -1,7 +1,7 @@
# Generated by Cargo # Generated by Cargo
# will have compiled files and executables # will have compiled files and executables
/target/ /target/
# Generated by Tauri # Generated by Tauri
# will have schema files for capabilities auto-completion # will have schema files for capabilities auto-completion
/gen/schemas /gen/schemas
+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"] }
+3 -3
View File
@@ -1,3 +1,3 @@
fn main() { fn main() {
tauri_build::build() tauri_build::build()
} }
+10 -10
View File
@@ -1,10 +1,10 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default" "opener:default"
] ]
} }
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
+6 -6
View File
@@ -1,6 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!! // Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { fn main() {
thomann_dsp_app_lib::run() thomann_dsp_app_lib::run()
} }
+37 -37
View File
@@ -1,37 +1,37 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "thomann-dsp-app", "productName": "thomann-dsp-app",
"version": "0.1.0", "version": "0.1.0",
"identifier": "com.lucas.thomann-dsp-app", "identifier": "com.lucas.thomann-dsp-app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build", "beforeBuildCommand": "npm run build",
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "thomann-dsp-app", "title": "Thomann DSP Controller",
"width": 1200, "width": 1600,
"height": 800, "height": 950,
"minWidth": 1200, "minWidth": 1200,
"minHeight": 600 "minHeight": 600
} }
], ],
"security": { "security": {
"csp": null "csp": null
} }
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"icon": [ "icon": [
"icons/32x32.png", "./icons/32x32.png",
"icons/128x128.png", "./icons/128x128.png",
"icons/128x128@2x.png", "./icons/128x128@2x.png",
"icons/icon.icns", "./icons/icon.icns",
"icons/icon.ico" "./icons/icon.ico"
] ]
} }
} }
+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 -9
View File
@@ -2,21 +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;
-webkit-user-select: none;
} }
.btn:disabled { .btn:disabled {
@@ -24,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);
@@ -63,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);
-3
View File
@@ -7,7 +7,6 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant; variant?: ButtonVariant;
children: ReactNode; children: ReactNode;
className?: string; className?: string;
fontSize?: number;
} }
export default function Button({ export default function Button({
@@ -15,7 +14,6 @@ export default function Button({
children, children,
className = '', className = '',
disabled, disabled,
fontSize = 13,
type = 'button', type = 'button',
style, style,
...props ...props
@@ -26,7 +24,6 @@ export default function Button({
className={`btn btn-${variant} ${className}`} className={`btn btn-${variant} ${className}`}
disabled={disabled} disabled={disabled}
style={{ style={{
fontSize: `${fontSize}px`,
...style, ...style,
}} }}
{...props} {...props}
+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;
@@ -0,0 +1,92 @@
.choice-stepper {
width: 100%;
flex: 1 1 auto;
position: relative;
display: flex;
align-items: stretch;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
background: transparent;
}
.choice-stepper__input {
flex: 1 1 auto;
min-width: 0;
width: 100%;
font-family: var(--font-mono);
font-size: var(--font--sm);
font-weight: var(--font-weight-normal);
text-align: left;
color: var(--accent-brand);
background: transparent;
border: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.choice-stepper__input:focus {
outline: none;
}
.choice-stepper__arrows {
flex: 0 0 auto;
align-self: stretch;
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
box-sizing: border-box;
padding-right: 1%;
}
.choice-stepper__arrow-btn {
height: 50%;
min-width: 0;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
color: var(--accent-brand);
background: transparent;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
}
.choice-stepper__arrow-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.choice-stepper__arrow-btn svg {
display: block;
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);
}
@@ -0,0 +1,404 @@
import { JSX, useEffect, useRef, useState } from 'react';
import Button from '../Button/Button';
import { TriangleUpIcon, TriangleDownIcon } from '../../assets/icons/Triangle';
import './ChoiceStepper.css';
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 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;
values: readonly T[];
formatLabel: (value: T) => string;
onChange: (value: T) => CommitResult;
id?: string;
disabled?: boolean;
className?: string;
};
function ChoiceStepperList<T>({
value,
values,
formatLabel,
onChange,
id,
disabled,
className,
}: ChoiceStepperListProps<T>) {
const [localValue, setLocalValue] = useState(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);
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));
}, [value, formatLabel]);
useEffect(() => {
return () => {
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
};
}, []);
const index = values.indexOf(localValue);
const canUp = index === -1 || index < values.length - 1;
const canDown = index > 0;
const stepLocal = (direction: 1 | -1): T | null => {
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 match = values.find(
(v) => formatLabel(v).trim().toLowerCase() === text.trim().toLowerCase()
);
if (match !== undefined) {
setLocalValue(match);
void commit(match);
} else {
setText(formatLabel(value));
}
};
return (
<div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
<input
id={id}
className="choice-stepper__input"
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
onWheel={(e) => {
e.preventDefault();
if (e.deltaY < 0 && canUp) {
stepWheel(1);
} else if (e.deltaY > 0 && canDown) {
stepWheel(-1);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
interacting.current = true;
stepLocal(1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
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>
);
}
// ---- 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);
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
id={id}
className="choice-stepper__input"
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
onWheel={(e) => {
e.preventDefault();
if (e.deltaY < 0 && canUp) {
stepWheel(1);
} else if (e.deltaY > 0 && canDown) {
stepWheel(-1);
}
}}
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;
+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;
}
+96 -16
View File
@@ -2,10 +2,11 @@ import { useEffect, useRef, useState } from 'react';
import './Sidebar.css'; import './Sidebar.css';
import { ChevronLeftIcon } from '../../assets/icons/ChevronLeftIcon'; import { ChevronLeftIcon } from '../../assets/icons/ChevronLeftIcon';
export type SidebarItem = { export type SidebarItem<T extends string = string> = {
id: string; id: T;
label: string; label: string;
icon: React.ReactNode; icon: React.ReactNode;
editable?: boolean;
}; };
export type SidebarAppOption = { export type SidebarAppOption = {
@@ -13,15 +14,79 @@ export type SidebarAppOption = {
name: string; name: string;
}; };
function SidebarButton({ function SidebarButton<T extends string>({
item, item,
active, active,
onSelect, onSelect,
onRename,
}: { }: {
item: SidebarItem; 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({
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>
@@ -218,7 +293,7 @@ function AppCombobox({
); );
} }
export default function Sidebar({ export function Sidebar<T extends string>({
items, items,
activeId, activeId,
onSelect, onSelect,
@@ -227,17 +302,19 @@ export default function Sidebar({
selectedAppId, selectedAppId,
onAppChange, onAppChange,
onAppRename, onAppRename,
onItemRename,
expanded, expanded,
onToggle, onToggle,
}: { }: {
items: SidebarItem[]; items: SidebarItem<T>[];
activeId: string | null; activeId: T | null;
onSelect: (id: string) => void; onSelect: (id: T) => void;
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 default function Sidebar({
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>
@@ -0,0 +1,33 @@
.signal-flow {
width: 100%;
height: 100%;
}
.signal-flow__svg {
width: 100%;
height: 100%;
display: block;
}
.signal-flow__chain-line {
stroke: var(--border-hairline, #333);
stroke-width: 1;
}
.signal-flow__stage {
fill: var(--bg-void);
stroke: var(--border-hairline);
stroke-width: 1;
transition: fill 0.1s ease;
}
.signal-flow__stage:hover {
fill: var(--bg-panel);
}
.signal-flow__stage-label {
font-family: var(--font-ui);
font-size: 13px;
fill: var(--text-primary, #e5e7eb);
pointer-events: none;
}
@@ -0,0 +1,354 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { InputChannel, OutputChannel } from '../../features/dsp408/types';
import { DSP408 } from '../../types';
import {
INPUT_ORDER,
OUTPUT_ORDER,
MATRIX_GAIN_MIN,
MATRIX_GAIN_MAX,
MATRIX_GAIN_STEP,
} from '../../features/dsp408/constants';
import { SidebarId } from '../../features/dsp408/constants';
import './SignalFlowMatrix.css';
const INPUT_SIDEBAR: Record<InputChannel, SidebarId> = {
InA: 'ina',
InB: 'inb',
InC: 'inc',
InD: 'ind',
};
const OUTPUT_SIDEBAR: Record<OutputChannel, SidebarId> = {
Out1: 'out1',
Out2: 'out2',
Out3: 'out3',
Out4: 'out4',
Out5: 'out5',
Out6: 'out6',
Out7: 'out7',
Out8: 'out8',
};
const INPUT_STAGES: {
key: string;
label: string;
sidebar: SidebarId | 'input';
}[] = [
{ key: 'gain', label: 'GAIN', sidebar: 'gain' },
{ key: 'gate', label: 'GATE', sidebar: 'gate' },
{ key: 'geq', label: 'GEQ', sidebar: 'geq' },
{ key: 'peq', label: 'PEQ', sidebar: 'input' },
{ key: 'hplp', label: 'HP/LP', sidebar: 'input' },
{ key: 'delay', label: 'DELAY', sidebar: 'delay' },
];
const OUTPUT_STAGES: {
key: string;
label: string;
sidebar: SidebarId | 'output';
}[] = [
{ key: 'xover', label: 'XOVER', sidebar: 'output' },
{ key: 'peq', label: 'PEQ', sidebar: 'output' },
{ key: 'gain', label: 'GAIN', sidebar: 'gain' },
{ key: 'compressor', label: 'COMP', sidebar: 'compressor' },
{ key: 'limiter', label: 'LIMIT', sidebar: 'limiter' },
{ key: 'delay', label: 'DELAY', sidebar: 'delay' },
{ key: 'mute', label: 'MUTE', sidebar: 'output' },
];
const INPUT_COLORS = [
'var(--accent-a, #60a5fa)',
'var(--accent-b, #34d399)',
'var(--accent-c, #f59e0b)',
'var(--accent-d, #f472b6)',
];
const W = 1800;
const H = 520;
const MARGIN = 36;
const LEFT_CHAIN_W = 560;
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) {
return dsp.state.current_config.output_states[output].matrix_routes;
}
function isRouted(dsp: DSP408, output: OutputChannel, input: InputChannel) {
return getRoutes(dsp, output).connected.includes(input);
}
function getGain(dsp: DSP408, output: OutputChannel, input: InputChannel) {
return getRoutes(dsp, output).gains[input];
}
function formatGainDb(value: number) {
const sign = value > 0 ? '+' : '';
return `${sign}${value.toFixed(1)} dB`;
}
type GainTooltipState = {
visible: boolean;
x: number;
y: number;
label: string;
};
function SignalFlowMatrix({
dsp,
setMatrixRouting,
setMatrixGain,
setActiveSidebar,
}: {
dsp: DSP408;
setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => Promise<boolean>;
setMatrixGain: (input: InputChannel, output: OutputChannel, gain: number) => Promise<boolean>;
setActiveSidebar: React.Dispatch<React.SetStateAction<SidebarId>>;
}) {
const nInputs = INPUT_ORDER.length;
const nOutputs = OUTPUT_ORDER.length;
const usableH = H - MARGIN * 2;
const inputRowH = usableH / nInputs;
const outputRowH = usableH / nOutputs;
const inputStageW = LEFT_CHAIN_W / (INPUT_STAGES.length + 1);
const outputStageW = RIGHT_CHAIN_W / (OUTPUT_STAGES.length + 1);
const leftStart = MARGIN;
const rightStart = W - MARGIN - RIGHT_CHAIN_W;
const inputCenterY = (i: number) => MARGIN + inputRowH * (i + 0.5);
const outputCenterY = (j: number) => MARGIN + outputRowH * (j + 0.5);
const inputNodeX = leftStart + LEFT_CHAIN_W;
const outputNodeX = rightStart;
const [gainTooltip, setGainTooltip] = useState<GainTooltipState>({
visible: false,
x: 0,
y: 0,
label: '',
});
const hideTooltipTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (hideTooltipTimer.current) clearTimeout(hideTooltipTimer.current);
};
}, []);
const handleToggle = (output: OutputChannel, input: InputChannel) => {
const routes = getRoutes(dsp, output);
const next = routes.connected.includes(input)
? routes.connected.filter((c) => c !== input)
: [...routes.connected, input];
void setMatrixRouting(output, next);
};
const handleWheelGain = (e: React.WheelEvent, output: OutputChannel, input: InputChannel) => {
e.preventDefault();
const current = getGain(dsp, output, input);
const delta = e.deltaY < 0 ? MATRIX_GAIN_STEP : -MATRIX_GAIN_STEP;
const next =
Math.round(Math.min(MATRIX_GAIN_MAX, Math.max(MATRIX_GAIN_MIN, current + delta)) * 10) / 10;
void setMatrixGain(input, output, next);
// Show a small tooltip with the new gain value right next to the cursor,
// then let it fade away shortly after wheeling stops.
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);
};
const routePairs = useMemo(() => {
const pairs: { input: InputChannel; output: OutputChannel; i: number; j: number }[] = [];
OUTPUT_ORDER.forEach((output, j) => {
INPUT_ORDER.forEach((input, i) => pairs.push({ input, output, i, j }));
});
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 (
<div className="signal-flow">
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="xMidYMid meet"
className="signal-flow__svg"
>
{orderedRoutePairs.map(({ input, output, i, j }) => {
const connected = isRouted(dsp, output, input);
const y1 = inputCenterY(i);
const y2 = outputCenterY(j);
const cx1 = inputNodeX + (outputNodeX - inputNodeX) * 0.4;
const cx2 = inputNodeX + (outputNodeX - inputNodeX) * 0.6;
const path = `M ${inputNodeX} ${y1} C ${cx1} ${y1}, ${cx2} ${y2}, ${outputNodeX} ${y2}`;
const color = INPUT_COLORS[i % INPUT_COLORS.length];
return (
<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)}
onWheel={connected ? (e) => handleWheelGain(e, output, input) : undefined}
/>
{/* Visible line, purely decorative — no pointer events, so it never
competes with the hitbox above for hover/click/wheel. */}
<path
d={path}
fill="none"
stroke={connected ? color : 'var(--border-hairline, #333)'}
strokeWidth={connected ? 2.5 : 1}
strokeDasharray={connected ? undefined : '4 5'}
opacity={connected ? 0.9 : 0.35}
pointerEvents="none"
/>
</g>
);
})}
{INPUT_ORDER.map((input, i) => {
const y = inputCenterY(i);
const boxH = Math.min(inputRowH * 0.55, 40);
const name = dsp.state.current_config.input_states[input].name;
const boxes = [{ key: 'name', label: name, sidebar: 'input' as const }, ...INPUT_STAGES];
return (
<g key={input}>
{boxes.map((stage, si) => {
const x = leftStart + si * inputStageW;
const sidebarId = stage.sidebar === 'input' ? INPUT_SIDEBAR[input] : stage.sidebar;
return (
<g
key={stage.key}
style={{ cursor: 'pointer' }}
onClick={() => setActiveSidebar(sidebarId as SidebarId)}
>
<rect
x={x + 3}
y={y - boxH / 2}
width={inputStageW - 6}
height={boxH}
rx={6}
className="signal-flow__stage"
/>
<text
x={x + inputStageW / 2}
y={y}
textAnchor="middle"
dominantBaseline="central"
className="signal-flow__stage-label"
>
{si === 0 ? name : stage.label}
</text>
</g>
);
})}
</g>
);
})}
{OUTPUT_ORDER.map((output, j) => {
const y = outputCenterY(j);
const boxH = Math.min(outputRowH * 0.55, 32);
const name = dsp.state.current_config.output_states[output].name;
const boxes = [
...OUTPUT_STAGES,
{ key: 'name', label: name, sidebar: 'output' as const },
];
return (
<g key={output}>
{boxes.map((stage, si) => {
const x = rightStart + si * outputStageW;
const sidebarId =
stage.sidebar === 'output' ? OUTPUT_SIDEBAR[output] : stage.sidebar;
const isLast = si === boxes.length - 1;
return (
<g
key={stage.key}
style={{ cursor: 'pointer' }}
onClick={() => setActiveSidebar(sidebarId as SidebarId)}
>
<rect
x={x + 3}
y={y - boxH / 2}
width={outputStageW - 6}
height={boxH}
rx={6}
className="signal-flow__stage"
/>
<text
x={x + outputStageW / 2}
y={y}
textAnchor="middle"
dominantBaseline="central"
className="signal-flow__stage-label"
>
{isLast ? name : stage.label}
</text>
</g>
);
})}
</g>
);
})}
</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>
);
}
export default SignalFlowMatrix;
@@ -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;
}
+304 -19
View File
@@ -2,24 +2,45 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import './VerticalSlider.css'; import './VerticalSlider.css';
type StepFn = (value: number) => number; type StepFn = (value: number) => number;
type QuantizeFn = (value: number) => number;
type Scale = 'linear' | 'log';
type VerticalSliderProps = { type VerticalSliderProps = {
min: number; min: number;
max: number; max: number;
step?: StepFn; step?: StepFn;
quantize?: QuantizeFn;
/**
* Optional discrete value set (must be sorted ascending). When provided,
* this takes full precedence over `step`/`quantize`/`scale`: the slider
* only ever lands on entries from this array. `min`/`max` are used to
* slice out the applicable sub-range of `values` (e.g. per-filter-type
* Q ranges that all share one master Q table) — both bounds should be
* exact members of `values`. Thumb position and drag/keyboard/wheel
* navigation are index-based rather than value-based, since spacing in
* the array need not be uniform or log-uniform.
*/
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. Ignored when `values` is set. */
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;
@@ -37,16 +58,126 @@ function getDecimals(n: number) {
return i === -1 ? 0 : s.length - i - 1; return i === -1 ? 0 : s.length - i - 1;
} }
// --- scale helpers -----------------------------------------------------
/** Guards against log(0) / log(negative); falls back to a tiny positive epsilon. */
function safeLogBound(n: number) {
return n > 0 ? n : Number.EPSILON;
}
/** Binary search for the index of the array entry closest to v. Assumes arr is sorted ascending and non-empty. */
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') {
const logMin = Math.log(safeLogBound(min));
const logMax = Math.log(safeLogBound(max));
if (logMax === logMin) return 0;
const logV = Math.log(safeLogBound(v));
return (logV - logMin) / (logMax - logMin);
}
if (max === min) return 0;
return (v - min) / (max - min);
}
/** 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,
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') {
const logMin = Math.log(safeLogBound(min));
const logMax = Math.log(safeLogBound(max));
return Math.exp(logMin + percent * (logMax - logMin));
}
return min + percent * (max - min);
}
/**
* Builds a StepFn that yields roughly `points` evenly-spaced steps between
* `min` and `max` on a logarithmic scale — e.g. makeLogStep(19.7, 20160, 300)
* gives ~300 points across a 20 Hz - 20 kHz range, with step size growing
* proportionally to the current value (fine resolution at low frequencies,
* coarser at high ones — matches how the ear perceives frequency).
*
* This is an approximation used to size keyboard/wheel nudges — it does NOT
* guarantee landing on the same exact grid a real device would produce.
* Pair it with `quantize` (see makeLogQuantize) when you need the slider to
* always land exactly on the device's own value set.
*/
export function makeLogStep(min: number, max: number, points: number): StepFn {
const ratio = Math.pow(max / min, 1 / Math.max(1, points));
const stepSize = ratio - 1;
return (value: number) => Math.max(min, value) * stepSize;
}
/**
* Builds a QuantizeFn that snaps to the exact same log-spaced grid a device
* using a `raw` 0..points index would produce, e.g. for a frequency raw
* value stored as:
* raw = round(log(Hz / min) / log(max / min) * points)
* Hz = min * (max / min) ** (raw / points)
* makeLogQuantize(19.7, 20160, 300) reproduces exactly that 301-point set
* (raw 0..300), so the slider only ever settles on values the device itself
* would encode/decode to — no drift from an approximate step size.
*/
export function makeLogQuantize(min: number, max: number, points: number): QuantizeFn {
const logRatio = Math.log(max / min);
return (value: number) => {
const v = Math.max(min, Math.min(max, value));
const raw = Math.round((Math.log(v / min) / logRatio) * points);
const clampedRaw = Math.min(points, Math.max(0, raw));
return min * Math.pow(max / min, clampedRaw / points);
};
}
export default function VerticalSlider({ export default function VerticalSlider({
min, min,
max, max,
step = defaultStep, step = defaultStep,
quantize,
values,
value, value,
onChange, onChange,
onInput, onInput,
unit = '', unit = '',
formatValue, formatValue,
showTicks = true, showTicks = true,
scale = 'linear',
disabled = false,
className, className,
style, style,
}: VerticalSliderProps) { }: VerticalSliderProps) {
@@ -60,10 +191,33 @@ export default function VerticalSlider({
const dragging = useRef(false); const dragging = useRef(false);
const thumbRef = useRef<HTMLDivElement>(null); const thumbRef = useRef<HTMLDivElement>(null);
const keyboardEditing = useRef(false); const keyboardEditing = useRef(false);
const wheelValue = useRef<number | null>(null);
const wheelEditing = useRef(false);
const wheelCommitTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const [tickCount, setTickCount] = useState(MIN_TICKS); const [tickCount, setTickCount] = useState(MIN_TICKS);
if (scale === 'log' && !values && (min <= 0 || max <= 0)) {
// eslint-disable-next-line no-console
console.warn(
'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 (dragging.current || keyboardEditing.current || editingText !== null) { if (
dragging.current ||
keyboardEditing.current ||
wheelEditing.current ||
editingText !== null
) {
return; return;
} }
setDisplayValue(value); setDisplayValue(value);
@@ -90,15 +244,46 @@ 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) {
return Number(Math.min(max, Math.max(min, quantize(clamped))).toFixed(6));
}
const currentStep = step(clamped); const currentStep = step(clamped);
const snapped = min + Math.round((clamped - min) / currentStep) * currentStep; const snapped = min + Math.round((clamped - min) / currentStep) * currentStep;
return Number(Math.min(max, Math.max(min, snapped)).toFixed(6)); return Number(Math.min(max, Math.max(min, snapped)).toFixed(6));
@@ -112,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;
} }
@@ -119,19 +306,35 @@ export default function VerticalSlider({
keyboardEditing.current = true; keyboardEditing.current = true;
e.preventDefault(); e.preventDefault();
const base = keyboardValue.current ?? value; // Base off displayValue (our own last-known-good local state), not the
const currentStep = step(base); // `value` prop: if onChange's round trip through the parent/audio engine
// hasn't landed yet, `value` can still be stale, which made repeated
// key presses appear to snap back to the pre-press position.
const base = keyboardValue.current ?? displayValue;
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;
@@ -147,15 +350,80 @@ export default function VerticalSlider({
} }
} }
function handleWheel(e: WheelEvent) {
if (disabled) return;
e.preventDefault();
wheelEditing.current = true;
const base = wheelValue.current ?? displayValue;
// deltaY < 0 means scrolling up/away from the user — treat that as
// increasing the value, matching the vertical slider's "up = more".
const direction = e.deltaY < 0 ? 1 : -1;
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;
updateValue(next);
if (wheelCommitTimeout.current) clearTimeout(wheelCommitTimeout.current);
wheelCommitTimeout.current = setTimeout(() => {
void commitWheel();
}, 250);
}
async function commitWheel() {
if (wheelValue.current == null) return;
const next = wheelValue.current;
const success = await onChange?.(next);
wheelEditing.current = false;
wheelValue.current = null;
if (success === false) {
setDisplayValue(value);
dragValue.current = value;
onInput?.(value);
}
}
useEffect(() => {
const el = sliderRef.current;
if (!el) return;
// React attaches onWheel passively by default, so preventDefault()
// wouldn't stop the page from scrolling underneath the slider — attach
// natively with { passive: false } instead.
el.addEventListener('wheel', handleWheel, { passive: false });
return () => el.removeEventListener('wheel', handleWheel);
});
useEffect(() => {
return () => {
if (wheelCommitTimeout.current) clearTimeout(wheelCommitTimeout.current);
};
}, []);
function valueFromPointer(clientY: number) { function valueFromPointer(clientY: number) {
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 = min + percent * (max - min); 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);
@@ -170,7 +438,10 @@ export default function VerticalSlider({
async function endDrag(e: React.PointerEvent<HTMLDivElement>) { async function endDrag(e: React.PointerEvent<HTMLDivElement>) {
if (!dragging.current) return; if (!dragging.current) return;
const target = e.currentTarget;
const pointerId = e.pointerId;
const finalValue = dragValue.current; const finalValue = dragValue.current;
const success = await onChange?.(finalValue); const success = await onChange?.(finalValue);
dragging.current = false; dragging.current = false;
@@ -181,12 +452,17 @@ export default function VerticalSlider({
onInput?.(value); onInput?.(value);
} }
if (e.currentTarget.hasPointerCapture(e.pointerId)) { if (target.hasPointerCapture(pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId); target.releasePointerCapture(pointerId);
} }
} }
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);
@@ -209,10 +485,13 @@ export default function VerticalSlider({
setEditingText(null); setEditingText(null);
} }
const percent = (displayValue - min) / (max - min); 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}
@@ -228,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}
@@ -254,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
@@ -267,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,109 +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;
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;
@@ -176,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;
@@ -192,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;
@@ -206,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;
@@ -216,10 +164,10 @@
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: 2.5cqh; font-size: 3.2cqh;
color: var(--text-muted); color: var(--text-muted);
white-space: nowrap; white-space: nowrap;
} }
@@ -227,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;
@@ -259,84 +207,193 @@
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);
} }
.gain-meter-panel { .peq-band-list {
container-type: size; flex: 1 1 auto;
min-width: 0;
position: relative; min-height: 0;
height: 100%;
aspect-ratio: 1 / 2;
flex: 0 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; gap: 1.5cqh;
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);
box-sizing: border-box; box-sizing: border-box;
background: var(--bg-void); background: var(--bg-void);
padding: 1cqh;
overflow: hidden;
} }
.gain-meter-panel__title { .peq-band-list__row {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: grid;
grid-template-columns: 0.4fr 0.55fr 0.4fr 0.5fr 1.2fr 0.5fr;
align-items: stretch;
gap: 1cqh;
box-sizing: border-box;
}
.peq-band-list__row--header {
flex: 0 0 auto; flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 4cqh; font-size: 4cqh;
color: var(--text-primary);
text-align: center;
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
padding-top: 2cqh;
padding-bottom: 2cqh;
border-bottom: var(--border-width) solid var(--border-hairline); border-bottom: var(--border-width) solid var(--border-hairline);
padding-top: 1cqh;
padding-bottom: 1cqh;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.gain-meter-panel__grid { .peq-band-list__row--active {
flex: 1 1 auto; background: var(--bg-panel);
min-height: 0; border-radius: var(--radius-sm);
width: 100%;
padding: 2cqh;
box-sizing: border-box;
display: grid;
grid-template-columns: 1fr 1fr;
/* slider/meter row gets the bulk of the height, button row stays compact */
grid-template-rows: 1fr auto;
gap: 1.5cqh;
} }
.gain-meter-panel__cell { .peq-band-list__cell {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
display: flex;
align-items: stretch;
justify-content: center;
text-align: center;
}
.peq-band-list__input {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
font-family: var(--font-ui);
font-size: 2.2cqh;
padding: 0.5cqh;
text-align: center;
background: var(--bg-panel);
color: var(--text-primary);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between;
padding: 1cqh 1.5cqh;
} }
.gain-meter-panel__cell--slider, .channel-graph__title-row .channel-graph__title {
.gain-meter-panel__cell--meter { 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%; height: 100%;
} min-height: 0;
.gain-meter-panel__cell--btn { font-family: var(--font-mono);
flex: 0 0 auto; font-size: 4cqh;
} color: var(--accent-brand);
.gain-meter-panel__cell--meter { background: transparent;
align-items: stretch;
justify-content: stretch; text-align: center;
text-align-last: center;
padding: 0;
margin: 0;
} }
@@ -0,0 +1,748 @@
// 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';
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 commit = (overrides: Partial<PEQ>) => {
const next = { ...peq, ...overrides };
return setPEQ(next.gain, next.frequency, next.q, 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={peq.q}
onChange={(value) => commit({ q: value })}
onInput={(value) => onLiveValue({ ...peq, q: 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 peq = liveBands[band] ?? bands[band];
const next = { ...peq, ...overrides };
return setPEQ(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>
{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 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={peq.q}
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) => commit(band, { filter_type: value, q: 3.0 })}
/>
<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, current.q, 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;
@@ -126,87 +146,6 @@
white-space: nowrap; white-space: nowrap;
} }
.choice-stepper {
width: 100%;
flex: 1 1 auto;
position: relative;
display: flex;
align-items: stretch;
justify-content: center;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
background: transparent;
}
.choice-stepper__input {
flex: 1 1 auto;
min-width: 0;
width: 100%;
font-family: var(--font-ui);
font-size: 3cqh;
font-weight: var(--font-weight-semibold);
text-align: center;
color: var(--accent-brand);
background: transparent;
border: none;
border-radius: var(--radius-sm);
padding: 0.5ch;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.choice-stepper__input:focus {
outline: none;
}
.choice-stepper__arrows {
flex: 0 0 auto;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.5cqh;
height: 100%;
box-sizing: border-box;
padding: 1cqh;
padding-left: 0;
}
.choice-stepper__arrow-btn {
aspect-ratio: 1;
height: 4.5cqh;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
color: var(--accent-brand);
background: transparent;
border-radius: 1cqh;
cursor: pointer;
}
.choice-stepper__arrow-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.choice-stepper__arrow-btn svg {
width: 100%;
height: 100%;
}
.compressor-graph__channel { .compressor-graph__channel {
font-size: var(--font-xs); font-size: var(--font-xs);
color: var(--text-muted); color: var(--text-muted);
@@ -223,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 {
@@ -287,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;
}
@@ -2,74 +2,13 @@
import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider'; import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider';
import { OutputChannel, Ratio } from '../../types'; import { OutputChannel, Ratio } from '../../types';
import { DSP408 as DSP } from '../../../../types'; import { DSP408 as DSP } from '../../../../types';
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 MeterBar from '../../../../components/MeterBar/MeterBar'; import MeterBar from '../../../../components/MeterBar/MeterBar';
import { useMemo, useState, useEffect } from 'react'; import { useMemo, useState } from 'react';
import { TriangleUpIcon, TriangleDownIcon } from '../../../../assets/icons/Triangle'; 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' },
@@ -77,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 = {
@@ -109,85 +35,6 @@ type SetCompressorFn = (
threshold: number threshold: number
) => Promise<boolean>; ) => Promise<boolean>;
function ChoiceStepper<T>({
value,
values,
formatLabel,
onChange,
}: {
value: T;
values: readonly T[];
formatLabel: (value: T) => string;
onChange: (value: T) => void;
}) {
const index = values.indexOf(value);
const canUp = index === -1 || index < values.length - 1;
const canDown = index > 0;
const [text, setText] = useState(formatLabel(value));
useEffect(() => {
setText(formatLabel(value));
}, [value, formatLabel]);
const step = (dir: 1 | -1) => {
const currentIndex = index === -1 ? 0 : index;
const nextIndex = Math.min(values.length - 1, Math.max(0, currentIndex + dir));
if (nextIndex !== currentIndex) onChange(values[nextIndex]);
};
const commitText = () => {
const match = values.find(
(v) => formatLabel(v).trim().toLowerCase() === text.trim().toLowerCase()
);
if (match !== undefined) {
onChange(match);
} else {
setText(formatLabel(value));
}
};
return (
<div className="choice-stepper">
<input
className="choice-stepper__input"
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
step(1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
step(-1);
}
}}
/>
<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>
);
}
function CompressorPanel({ function CompressorPanel({
channel, channel,
dsp, dsp,
@@ -234,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]}
@@ -247,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`}
@@ -263,19 +112,44 @@ function CompressorPanel({
); );
} }
// Soft-knee compressor transfer curve, in dB. // Compressor transfer curve, in dB.
//
// The knee is NOT a smooth/quadratic blend. It is three straight
// (piecewise-linear) segments:
// 1) input <= kneeStart -> 1:1 line (output = input)
// 2) kneeStart < input < kneeEnd -> a straight line connecting the
// point on segment (1) at kneeStart
// to the point on segment (3) at kneeEnd
// 3) input >= kneeEnd -> ratio line (output = threshold + (input-threshold)/ratio)
//
// kneeStart/kneeEnd are simply the threshold shifted down/up by half the
// knee width, so increasing "knee" widens the middle straight segment
// symmetrically around the threshold.
function compressorOutputDb(input: number, threshold: number, ratio: number, knee: number): number { function compressorOutputDb(input: number, threshold: number, ratio: number, knee: number): number {
const kneeStart = threshold - knee / 2; const kneeStart = threshold - knee / 2;
const kneeEnd = threshold + knee / 2; const kneeEnd = threshold + knee / 2;
if (knee <= 0 || input <= kneeStart) { // No knee: simple two-segment (hard-knee) curve.
if (knee <= 0) {
return input <= threshold ? input : threshold + (input - threshold) / ratio; return input <= threshold ? input : threshold + (input - threshold) / ratio;
} }
// Segment 1: below the knee, straight 1:1 line.
if (input <= kneeStart) {
return input;
}
// Segment 3: above the knee, straight ratio line.
if (input >= kneeEnd) { if (input >= kneeEnd) {
return threshold + (input - threshold) / ratio; return threshold + (input - threshold) / ratio;
} }
const x = input - kneeStart;
return input + ((1 / ratio - 1) * x * x) / (2 * knee); // Segment 2: straight line between the two boundary points
// (kneeStart, kneeStart) and (kneeEnd, threshold + (kneeEnd - threshold) / ratio).
const y1 = kneeStart;
const y2 = threshold + (kneeEnd - threshold) / ratio;
const t = (input - kneeStart) / (kneeEnd - kneeStart);
return y1 + t * (y2 - y1);
} }
function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor: SetCompressorFn }) { function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor: SetCompressorFn }) {
@@ -339,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 = -60 + (60 * i) / steps; const x = -100 + (100 * i) / steps;
points.push([x, compressorOutputDb(x, threshold, ratioNumeric, knee)]); points.push([x, compressorOutputDb(x, threshold, ratioNumeric, knee)]);
} }
return points; return points;
@@ -352,9 +226,9 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
<div className="compressor-graph__title">{activeOutputState.name} - RMS Compressor</div> <div className="compressor-graph__title">{activeOutputState.name} - RMS Compressor</div>
<LinearGraph <LinearGraph
xMin={-60} xMin={-100}
xMax={0} xMax={0}
yMin={-60} yMin={-100}
yMax={0} yMax={0}
xLabel="in dB" xLabel="in dB"
yLabel="out dB" yLabel="out dB"
@@ -365,7 +239,7 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
ctx.strokeStyle = '#6b7280'; ctx.strokeStyle = '#6b7280';
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.setLineDash([4, 4]); ctx.setLineDash([4, 4]);
const [ux0, uy0] = toPx(-60, -60); const [ux0, uy0] = toPx(-100, -100);
const [ux1, uy1] = toPx(0, 0); const [ux1, uy1] = toPx(0, 0);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(ux0, uy0); ctx.moveTo(ux0, uy0);
@@ -377,7 +251,7 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
ctx.strokeStyle = '#fbbf24'; ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.setLineDash([2, 3]); ctx.setLineDash([2, 3]);
const [tx0, ty0] = toPx(activeComp.threshold, -60); const [tx0, ty0] = toPx(activeComp.threshold, -100);
const [tx1, ty1] = toPx(activeComp.threshold, 0); const [tx1, ty1] = toPx(activeComp.threshold, 0);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(tx0, ty0); ctx.moveTo(tx0, ty0);
@@ -404,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,13 +14,14 @@ function ConnectionPanel({
onConnect: () => void; onConnect: () => void;
onDisconnect: () => void; onDisconnect: () => void;
}) { }) {
const statusLabel =
dsp.status === 'connected'
? 'Connected'
: dsp.status === 'connecting'
? 'Connecting…'
: 'Disconnected';
const statusLabel =
isConnected(dsp)
? 'Connected'
: dsp.isConnecting
? 'Connecting…'
: 'Disconnected';
return ( return (
<div className="connection-section"> <div className="connection-section">
<Card className="connection-panel"> <Card className="connection-panel">
@@ -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,7 +1,17 @@
import { useState } from 'react'; import { useEffect, useState, type SetStateAction } from 'react';
import { DSP } from '../../../../types'; import { Sidebar, SidebarItem } from '../../../../components/Sidebar/Sidebar';
import Sidebar from '../../../../components/Sidebar/Sidebar'; import {
import { InfoIcon, KnobGainIcon, TextIcon } from '../../../../assets/icons/AudioIcons'; 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';
@@ -12,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 { SidebarId } from '../../constants';
import './DSP408Panel.css'; import './DSP408Panel.css';
import MiscellaneousPanel from '../MiscellaneousSection/MiscellaneousSection';
import { PasswordDialog } from '../PasswordDialog/PasswordDialog';
type DSP408 = Extract<DSP, { type: 'DSP408' }>; 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('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 sidebarItems = [ const inputStates = dsp.state.current_config.input_states;
const outputStates = dsp.state.current_config.output_states;
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);
@@ -108,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':
@@ -135,59 +169,71 @@ 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}
set_low_pass={api.setLowPass}
set_high_pass={api.setHighPass}
setPeq={api.setPeqBand}
setGain={api.setGain}
setMute={api.setMute}
setInverse={api.setInverse}
/>
);
case 'matrix': case 'matrix':
return ( return (
<MatrixSection <MatrixSection
dsp={dsp} dsp={dsp}
setMatrixRouting={api.setMatrixRouting} setMatrixRouting={api.setMatrixRouting}
setMatrixGain={api.setMatrixGain} setMatrixGain={api.setMatrixGain}
setActiveSidebar={setActiveSidebar}
/> />
); );
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 {

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