Compare commits

..
58 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
LucasDLTG 34fad6f905 refactor: folders/files to group dsp logic 2026-08-11 23:17:47 +02:00
153 changed files with 12796 additions and 9563 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

+350 -341
View File
@@ -1,24 +1,17 @@
use dsp_thomann::dsp408::types::DelayUnit;
use tauri::State;
use crate::state::{AppState, DSPDevice}; use crate::state::{AppState, DSPDevice};
use dsp_thomann::dsp408::{DSP408, types}; use dsp_thomann::dsp408::types::DelayUnit;
use std::{net::IpAddr, str::FromStr}; use dsp_thomann::dsp408::{types, DSP408};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::{net::IpAddr, str::FromStr};
use tauri::State;
impl AppState { impl AppState {
pub fn with_device_mut<F, R>( pub fn with_device_mut<F, R>(&self, id: u64, f: F) -> Result<R, String>
&self,
id: u64,
f: F,
) -> Result<R, String>
where where
F: FnOnce(&mut DSPDevice) -> Result<R, String>, F: FnOnce(&mut DSPDevice) -> Result<R, String>,
{ {
let device = { let device = {
let devices = self let devices = self.devices.lock().map_err(|e| e.to_string())?;
.devices
.lock()
.map_err(|e| e.to_string())?;
devices devices
.get(&id) .get(&id)
@@ -26,22 +19,14 @@ impl AppState {
.ok_or_else(|| "DSP not found".to_string())? .ok_or_else(|| "DSP not found".to_string())?
}; };
let mut device = device let mut device = device.lock().map_err(|e| e.to_string())?;
.lock()
.map_err(|e| e.to_string())?;
f(&mut device) f(&mut device)
} }
pub fn remove_device( pub fn remove_device(&self, id: u64) -> Result<(), String> {
&self,
id: u64,
) -> Result<(), String> {
let device = { let device = {
let mut devices = self.devices let mut devices = self.devices.lock().map_err(|e| e.to_string())?;
.lock()
.map_err(|e| e.to_string())?;
devices devices
.remove(&id) .remove(&id)
@@ -56,57 +41,48 @@ 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) let dsp = if demo {
.map_err(|e| e.to_string())?; DSP408::new_demo()
} else {
DSP408::new(ip, port, device_id).map_err(|e| e.to_string())?
};
let dsp = DSP408::new(ip, port, device_id) let mut next_id = state.next_id.lock().map_err(|e| e.to_string())?;
.map_err(|e| e.to_string())?;
let mut next_id = state
.next_id
.lock()
.map_err(|e| e.to_string())?;
let id = *next_id; let id = *next_id;
*next_id += 1; *next_id += 1;
let device = DSPDevice { let device = DSPDevice { dsp };
dsp,
};
let mut devices = state let mut devices = state.devices.lock().map_err(|e| e.to_string())?;
.devices
.lock()
.map_err(|e| e.to_string())?;
devices.insert( devices.insert(id, Arc::new(Mutex::new(device)));
id,
Arc::new(Mutex::new(device)),
);
Ok(id) Ok(id)
} }
#[tauri::command] #[tauri::command]
pub async fn remove_dsp408( pub async fn remove_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
state: State<'_, AppState>,
id: u64,
) -> Result<(), String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
if device.dsp.connected() { if device.dsp.connected() {
device.dsp device.dsp.disconnect().map_err(|e| e.to_string())?;
.disconnect()
.map_err(|e| e.to_string())?;
} }
Ok(()) Ok(())
@@ -118,28 +94,16 @@ pub async fn remove_dsp408(
} }
#[tauri::command] #[tauri::command]
pub async fn connect_dsp408( pub async fn get_dsp_snapshot(
state: State<'_, AppState>, state: State<'_, AppState>,
id: u64, id: u64,
) -> Result<(), String> { ) -> Result<DSP408Snapshot, String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
device.dsp Ok(DSP408Snapshot {
.connect() state: device.dsp.state().clone(),
.map_err(|e| e.to_string()) input_meters: device.dsp.input_meters().clone(),
}) output_meters: device.dsp.output_meters().clone(),
} })
#[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())
}) })
} }
@@ -148,64 +112,40 @@ pub async fn get_dsp_state(
state: State<'_, AppState>, state: State<'_, AppState>,
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 let dsp_state = device.dsp.state();
.state()
.map_err(|e| e.to_string())?;
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.dsp device.dsp.set_delay_unit(unit).map_err(|e| e.to_string())
.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>,
@@ -222,19 +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 device
.dsp .dsp
.get_meter_levels() .store_current_configuration(preset_index, force)
.map_err(|e| e.to_string()) .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>,
@@ -249,6 +205,278 @@ pub async fn set_current_preset_name(
}) })
} }
// Command `0x2A`
#[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())
})
}
// 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]
pub async fn set_compressor(
state: State<'_, AppState>,
id: u64,
channel: types::OutputChannel,
ratio: types::Ratio,
attack: u16,
release: u16,
knee: u16,
threshold: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_compressor(channel, ratio, attack, release, knee, threshold)
.map_err(|e| e.to_string())
})
}
// Command `0x31`
#[tauri::command]
pub async fn set_low_pass(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
freq: f32,
filter_type: types::CrossoverFilter,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_low_pass(channel, freq, filter_type)
.map_err(|e| e.to_string())
})
}
// Command `0x32`
#[tauri::command]
pub async fn set_high_pass(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
freq: f32,
filter_type: types::CrossoverFilter,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_high_pass(channel, freq, filter_type)
.map_err(|e| e.to_string())
})
}
// Command `0x33`
#[tauri::command]
pub async fn set_peq_band(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
band: types::PEQBand,
gain_db: f32,
freq_hz: f32,
q: f32,
filter_type: types::PEQFilter,
bypass: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_peq_band(channel, band, gain_db, freq_hz, q, filter_type, bypass)
.map_err(|e| e.to_string())
})
}
// Command `0x34`
#[tauri::command]
pub async fn set_channel_gain(
state: State<'_, AppState>,
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 `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> {
state.with_device_mut(id, |device| {
device
.dsp
.set_delay(channel, delay, unit)
.map_err(|e| e.to_string())
})
}
// Command `0x39`
#[tauri::command]
pub async fn set_input_source(
state: State<'_, AppState>,
id: u64,
input_source: types::InputSource,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_input_source(input_source)
.map_err(|e| e.to_string())
})
}
// 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]
pub async fn link_channels(
state: State<'_, AppState>,
id: u64,
source: types::Channel,
destinations: Vec<types::Channel>,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.link_channels(source, destinations)
.map_err(|e| e.to_string())
})
}
// Command `0x3C`
#[tauri::command]
pub async fn set_bypass_peq(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
bypass: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_bypass_peq(channel, bypass)
.map_err(|e| e.to_string())
})
}
// Command `0x3D` -- TODO
#[tauri::command]
pub async fn set_channel_name(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
name: String,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_channel_name(channel, &name)
.map_err(|e| e.to_string())
})
}
// Command `0x3E`
#[tauri::command] #[tauri::command]
pub async fn set_gate( pub async fn set_gate(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -267,32 +495,7 @@ pub async fn set_gate(
}) })
} }
#[tauri::command] // Command `0x3F`
pub async fn set_compressor(
state: State<'_, AppState>,
id: u64,
channel: types::OutputChannel,
ratio: types::Ratio,
attack: u16,
release: u16,
knee: u16,
threshold: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_compressor(
channel,
ratio,
attack,
release,
knee,
threshold,
)
.map_err(|e| e.to_string())
})
}
#[tauri::command] #[tauri::command]
pub async fn set_limiter( pub async fn set_limiter(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -310,51 +513,18 @@ pub async fn set_limiter(
}) })
} }
// Command `0x40`
#[tauri::command] #[tauri::command]
pub async fn set_delay_unit( pub async fn get_meter_levels(
state: State<'_, AppState>, state: State<'_, AppState>,
id: u64, id: u64,
unit: types::DelayUnit, ) -> Result<(types::InputMeters, types::OutputMeters), String> {
) -> Result<bool, String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
device device.dsp.get_meter_levels().map_err(|e| e.to_string())
.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())
}) })
} }
// Command `0x41`
#[tauri::command] #[tauri::command]
pub async fn set_matrix_gain( pub async fn set_matrix_gain(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -371,6 +541,7 @@ pub async fn set_matrix_gain(
}) })
} }
// Command `0x48`
#[tauri::command] #[tauri::command]
pub async fn set_geq_band( pub async fn set_geq_band(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -387,6 +558,7 @@ pub async fn set_geq_band(
}) })
} }
// Command `0x49`
#[tauri::command] #[tauri::command]
pub async fn set_geq_bypass( pub async fn set_geq_bypass(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -401,166 +573,3 @@ pub async fn set_geq_bypass(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
}) })
} }
#[tauri::command]
pub async fn set_low_pass(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
freq: f32,
filter_type: types::CrossoverFilter,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_low_pass(channel, freq, filter_type)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_high_pass(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
freq: f32,
filter_type: types::CrossoverFilter,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_high_pass(channel, freq, filter_type)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_peq_band(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
band: types::PEQBand,
gain_db: f32,
freq_hz: f32,
q: f32,
filter_type: types::PEQFilter,
bypass: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_peq_band(
channel,
band,
gain_db,
freq_hz,
q,
filter_type,
bypass,
)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn store_current_configuration(
state: State<'_, AppState>,
id: u64,
preset_index: usize,
force: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.store_current_configuration(preset_index, force)
.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())
})
}
#[tauri::command]
pub async fn set_input_source(
state: State<'_, AppState>,
id: u64,
input_source: types::InputSource,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_input_source(input_source)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn link_channels(
state: State<'_, AppState>,
id: u64,
source: types::Channel,
destinations: Vec<types::Channel>,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.link_channels(source, destinations)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_bypass_peq(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
bypass: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_bypass_peq(channel, bypass)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_channel_name(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
name: String,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_channel_name(channel, &name)
.map_err(|e| e.to_string())
})
}
+35 -37
View File
@@ -1,5 +1,5 @@
mod state;
mod commands; mod commands;
mod state;
use state::AppState; use state::AppState;
@@ -9,42 +9,40 @@ pub fn run() {
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.manage(AppState::new()) .manage(AppState::new())
.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_state, commands::get_dsp_snapshot,
commands::get_dsp_state,
commands::connect_dsp408, // 0x10 commands::connect_dsp408, // 0x10
commands::disconnect_dsp408, // 0x11 commands::disconnect_dsp408, // 0x11
commands::set_delay_unit, // 0x15 commands::set_delay_unit, // 0x15
commands::recall_preset, // 0x20
commands::recall_preset, // 0x20 commands::store_current_configuration, // 0x21
commands::store_current_configuration, // 0x21 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
commands::set_peq_band, // 0x33 commands::set_peq_band, // 0x33
commands::set_channel_gain, // 0x34 commands::set_channel_gain, // 0x34
commands::set_mute, // 0x35 commands::set_mute, // 0x35
commands::set_channel_inverse_gain, // 0x36 commands::set_channel_inverse_gain, // 0x36
commands::set_delay, // 0x38 commands::set_delay, // 0x38
commands::set_input_source, // 0x39 commands::set_input_source, // 0x39
commands::set_matrix_routing, // 0x3A commands::set_matrix_routing, // 0x3A
commands::link_channels, // 0x3B commands::link_channels, // 0x3B
commands::set_bypass_peq, // 0x3C commands::set_bypass_peq, // 0x3C
commands::set_channel_name, // 0x3D commands::set_channel_name, // 0x3D
commands::set_gate, // 0x3E commands::set_gate, // 0x3E
commands::set_limiter, // 0x3F commands::set_limiter, // 0x3F
commands::get_meter_levels, // 0x40
commands::get_meter_levels, // 0x40 commands::set_matrix_gain, // 0x41
commands::set_matrix_gain, // 0x41 commands::set_geq_band, // 0x48
commands::set_geq_band, // 0x48 commands::set_geq_bypass, // 0x49
commands::set_geq_bypass, // 0x49 ])
]
)
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
+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()
} }
+3 -3
View File
@@ -1,6 +1,6 @@
use std::sync::{Mutex, Arc};
use std::collections::HashMap;
use dsp_thomann::dsp408::DSP408; use dsp_thomann::dsp408::DSP408;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
pub struct DSPDevice { pub struct DSPDevice {
pub dsp: DSP408, pub dsp: DSP408,
@@ -18,4 +18,4 @@ impl AppState {
next_id: Mutex::new(1), next_id: Mutex::new(1),
} }
} }
} }
+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"
] ]
} }
} }
+3 -3
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useMemo } from 'react'; import { useState, useEffect, useRef } from 'react';
import './App.css'; import './App.css';
import { DSP, AppPage } from './types/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;
@@ -1,4 +1,4 @@
import type { Notification } from '../../types/types'; import type { Notification } from '../../types';
import './Notifications.css'; import './Notifications.css';
type Props = { type Props = {
+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/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,205 +0,0 @@
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import Button from '../../Button/Button';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import './GainSection.css';
function GainPanel({
dsp,
channel,
title,
setGain,
setMute,
setInverse,
}: {
dsp: DSP;
channel: Channel;
title: string;
setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
}) {
const channelState =
'Input' in channel
? dsp.state?.current_config.input_states[channel.Input]
: dsp.state?.current_config.output_states[channel.Output];
const muted = channelState.mute;
const inverted = channelState.phase_inverted;
const gain = channelState.gain;
const toggleMute = async () => {
await setMute(channel, !muted);
};
const toggleInverse = async () => {
await setInverse(channel, !inverted);
};
return (
<div className="gain-panel">
<div className="gain-panel__title">{title}</div>
<VerticalSlider
min={-60}
max={12}
step={(v) => (v >= -20 ? 0.1 : 0.5)}
unit=" dB"
value={gain}
onChange={(value) => setGain(channel, value)}
/>
<Button
variant="toggle"
className={muted ? 'active-danger' : ''}
onClick={toggleMute}
style={{
width: '85%',
paddingLeft: 0,
paddingRight: 0,
fontSize: '4cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}}
>
{muted ? 'Muted' : 'Mute '}
</Button>
<Button
variant="toggle"
className={inverted ? 'active-warning' : ''}
onClick={toggleInverse}
style={{
width: '85%',
paddingLeft: 0,
paddingRight: 0,
fontSize: '4cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}}
>
{inverted ? 'Inverted' : 'Invert'}
</Button>
</div>
);
}
function GainSection({
dsp,
setGain,
setMute,
setInverse,
}: {
dsp: DSP;
setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
}) {
const inputs: InputChannel[] = [
InputChannel.InA,
InputChannel.InB,
InputChannel.InC,
InputChannel.InD,
];
const outputs: { channel: Channel; title: string }[] = [
{ channel: { Output: OutputChannel.Out1 }, title: 'Out 1' },
{ channel: { Output: OutputChannel.Out2 }, title: 'Out 2' },
{ channel: { Output: OutputChannel.Out3 }, title: 'Out 3' },
{ channel: { Output: OutputChannel.Out4 }, title: 'Out 4' },
{ channel: { Output: OutputChannel.Out5 }, title: 'Out 5' },
{ channel: { Output: OutputChannel.Out6 }, title: 'Out 6' },
{ channel: { Output: OutputChannel.Out7 }, title: 'Out 7' },
{ channel: { Output: OutputChannel.Out8 }, title: 'Out 8' },
];
return (
<>
<div className="gain-linear-graph-section">
<div className="gain-graph-panel">
<div className="gain-graph-title">Frequency Response</div>
<LinearGraph
xMin={10}
xMax={30000}
xScale="log"
yMin={-18}
yMax={18}
yStep={3}
xLabel="Hz"
yLabel="dB"
draw={({ ctx, toPx }) => {
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
const [x0, y0] = toPx(20, 0);
const [x1, y1] = toPx(20000, 0);
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
}}
/>
</div>
<div className="gain-graph-panel">
<div className="gain-graph-title">Phase Response</div>
<LinearGraph
xMin={20}
xMax={20000}
yMin={-180}
yMax={180}
yStep={45}
xLabel="Hz"
yLabel="deg"
draw={({ ctx, toPx }) => {
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
const [x0, y0] = toPx(20, 0);
const [x1, y1] = toPx(20000, 0);
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
}}
/>
</div>
</div>
<div className="gain-section">
<div className="gain-panels">
<ChannelGroup title="Input">
{inputs.map((channel) => (
<GainPanel
key={channel}
dsp={dsp}
channel={{ Input: channel }}
title={dsp.state.current_config.input_states[channel].name}
setGain={setGain}
setMute={setMute}
setInverse={setInverse}
/>
))}
</ChannelGroup>
<ChannelGroup title="Output">
{outputs.map(({ channel, title }) => (
<GainPanel
key={title}
dsp={dsp}
channel={channel}
title={title}
setGain={setGain}
setMute={setMute}
setInverse={setInverse}
/>
))}
</ChannelGroup>
</div>
</div>
</>
);
}
export default GainSection;
@@ -1,504 +0,0 @@
// InputSection.tsx
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
// ASSUMPTION: an InputChannel enum exists alongside OutputChannel, mapping
// input pages 1-8 similarly (In1 -> 0, In2 -> 1, ...). Adjust import/values
// if the actual enum differs.
import { InputChannel, Channel, PEQBand, PEQFilter, PEQ, Meter } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import { CrossoverFilter } from '../../../types/dsp408';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import { useRef, useState } from 'react';
import './InputSection.css';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import Button from '../../Button/Button';
import MeterBar from '../../MeterBar/MeterBar';
const FREQ_MIN = 20;
const FREQ_MAX = 20000;
const FREQ_STEP = 1;
const FILTER_TYPE_OPTIONS: { value: CrossoverFilter; label: string }[] = [
{ value: CrossoverFilter.Bw6, label: 'BW-6' },
{ value: CrossoverFilter.Bw12, label: 'BW-12' },
{ value: CrossoverFilter.Bw18, label: 'BW-18' },
{ value: CrossoverFilter.Bw24, label: 'BW-24' },
{ value: CrossoverFilter.Bw30, label: 'BW-30' },
{ value: CrossoverFilter.Bw36, label: 'BW-36' },
{ value: CrossoverFilter.Bw42, label: 'BW-42' },
{ value: CrossoverFilter.Bw48, label: 'BW-48' },
{ value: CrossoverFilter.Lk12, label: 'LR-12' },
{ value: CrossoverFilter.Lk24, label: 'LR-24' },
{ value: CrossoverFilter.Lk36, label: 'LR-36' },
{ value: CrossoverFilter.Lk48, label: 'LR-48' },
];
const PEQ_SLIDER_PARAMS = [
{ key: 'frequency', label: 'Frequency', min: 19.7, max: 20160, step: 0.5, unit: ' Hz' },
{ key: 'q', label: 'Q', min: 1, max: 999, step: 1, unit: '' },
{ key: 'gain', label: 'Gain', min: -12, max: +12, step: 0.1, unit: ' dB' },
] as const;
type SetPEQFn = (
channel: Channel,
band: PEQBand,
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
type PeqParamKey = 'frequency' | 'q' | 'gain';
type PEQValues = {
frequency: number;
q: number;
gain: number;
filter_type: PEQFilter;
bypass: boolean;
};
function PeqPanelSliders({
channel,
dsp,
band,
setPEQ,
// onLiveValue,
}: {
channel: InputChannel;
dsp: DSP;
band: PEQBand;
setPEQ: SetPEQFn;
// onLiveValue: (channel: InputChannel, key: PeqParamKey, value: number) => void;
}) {
const inputState = dsp.state.current_config.input_states[channel];
const peq = inputState.peq_chain.bands[band] as PEQ;
const commit = (overrides: Partial<PEQValues>) => {
const next = { ...peq, ...overrides };
return setPEQ(
{ Input: channel },
band,
next.gain,
next.frequency,
next.q,
next.filter_type,
next.bypass
);
};
return (
<div className="input-panel">
<div className="input-panel__title">{'PEQ Parameters'}</div>
<div className="input-panel__grid">
{PEQ_SLIDER_PARAMS.map((param) => (
<div className={`input-panel__cell input-panel__cell--${param.key}`} key={param.key}>
<VerticalSlider
min={param.min}
max={param.max}
step={() => param.step}
unit={param.unit}
value={peq[param.key]}
// onInput={(value) => onLiveValue(channel, param.key, value)}
onChange={(value) => commit({ [param.key]: value } as Partial<PEQValues>)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-panel__cell-label">{param.label}</div>
</div>
))}
</div>
</div>
);
}
function CrossoverPanel({
channel,
dsp,
title,
setCrossover,
}: {
channel: Channel;
dsp: DSP;
title: string;
setCrossover: (
channel: Channel,
frequency: number,
filter_type: CrossoverFilter,
bypass: boolean
) => Promise<boolean>;
}) {
const channelState =
'Input' in channel
? dsp.state?.current_config.input_states[channel.Input]
: dsp.state?.current_config.output_states[channel.Output];
const crossover = channelState?.crossover;
const frequency = crossover.high_pass.frequency;
const filter_type = crossover.high_pass.slope;
const bypass = false;
const commit = (overrides: {
frequency?: number;
filter_type?: CrossoverFilter;
bypass?: boolean;
}) => {
const next = { frequency, filter_type, bypass, ...overrides };
return setCrossover(channel, next.frequency, next.filter_type, next.bypass);
};
return (
<div className="crossover-panel">
<div className="crossover-panel__title">{title}</div>
<VerticalSlider
min={20}
max={20000}
step={() => 1}
unit=" Hz"
value={frequency}
onChange={(value) => commit({ frequency: value })}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<select
className="crossover-panel__select"
value={filter_type}
onChange={(e) => commit({ filter_type: e.target.value as CrossoverFilter })}
>
{FILTER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<Button
variant="toggle"
className={bypass ? 'active-warning' : ''}
onClick={() => commit({ bypass: !bypass })}
style={{
width: '85%',
paddingLeft: 0,
paddingRight: 0,
fontSize: '4cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}}
>
{bypass ? 'Bypassed' : 'Bypass'}
</Button>
</div>
);
}
function GainMeterPanel({
dsp,
channel,
title,
meter,
setGain,
setMute,
setInverse,
}: {
dsp: DSP;
channel: Channel;
title: string;
meter: Meter;
setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
}) {
const channelState =
'Input' in channel
? dsp.state?.current_config.input_states[channel.Input]
: dsp.state?.current_config.output_states[channel.Output];
const muted = channelState?.mute ?? false;
const inverted = channelState?.phase_inverted ?? false;
const gain = channelState?.gain ?? 0;
const toggleMute = async () => {
await setMute(channel, !muted);
};
const toggleInverse = async () => {
await setInverse(channel, !inverted);
};
return (
<div className="gain-meter-panel">
<div className="gain-meter-panel__title">{title}</div>
<div className="gain-meter-panel__grid">
<div className="gain-meter-panel__cell gain-meter-panel__cell--slider">
<VerticalSlider
min={-60}
max={12}
step={(v) => (v >= -20 ? 0.1 : 0.5)}
unit=" dB"
value={gain}
onChange={(value) => setGain(channel, value)}
style={{ width: '100%', height: '100%' }}
/>
</div>
<div className="gain-meter-panel__cell gain-meter-panel__cell--meter">
<MeterBar label="" meter={meter} />
</div>
<div className="gain-meter-panel__cell gain-meter-panel__cell--btn">
<Button
variant="toggle"
className={inverted ? 'active-warning' : ''}
onClick={toggleInverse}
style={{
width: '100%',
paddingLeft: 0,
paddingRight: 0,
fontSize: '3.5cqh',
paddingTop: '1cqh',
paddingBottom: '1cqh',
minHeight: 0,
}}
>
{inverted ? 'Inverted' : 'Invert'}
</Button>
</div>
<div className="gain-meter-panel__cell gain-meter-panel__cell--btn">
<Button
variant="toggle"
className={muted ? 'active-danger' : ''}
onClick={toggleMute}
style={{
width: '100%',
paddingLeft: 0,
paddingRight: 0,
fontSize: '3.5cqh',
paddingTop: '1cqh',
paddingBottom: '1cqh',
minHeight: 0,
}}
>
{muted ? 'Muted' : 'Mute'}
</Button>
</div>
</div>
</div>
);
}
type SetFilterFn = (
channel: Channel,
freq: number,
filterType: CrossoverFilter
) => Promise<boolean>;
type setPeqBandFn = (
channel: Channel,
band: PEQBand,
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
function InputSection({
dsp,
channel,
set_high_pass: setHighPass,
set_low_pass,
setPeq,
// setPeqBypass,
setGain,
setMute,
setInverse,
}: {
dsp: DSP;
channel: InputChannel;
set_high_pass: SetFilterFn;
set_low_pass: SetFilterFn;
setPeq: setPeqBandFn;
// setPeqBypass;
setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
}) {
const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1);
const [liveHpFreq, setLiveHpFreq] = useState<number>(19.7);
const [liveLpFreq, setLiveLpFreq] = useState<number>(20.16);
const [livePeq, setLivePeq] = useState<Partial<Record<PEQBand, PEQValues>>>({});
const inputState = dsp.state.current_config.input_states[channel];
const hp = inputState.crossover.high_pass;
const lp = inputState.crossover.low_pass;
const activePeq = livePeq[activeBand] ?? (inputState.peq_chain.bands[activeBand] as PEQValues);
const bypassed = hp.slope === CrossoverFilter.Bypass;
const activeFilterType = bypassed && lp.slope !== CrossoverFilter.Bypass ? lp.slope : hp.slope;
// Remembers the last non-bypass slope so the Bypass button can restore it.
// const lastNonBypassType = useRef<CrossoverFilter>(
// activeFilterType === CrossoverFilter.Bypass ? DEFAULT_FILTER_TYPE : activeFilterType
// );
// if (activeFilterType !== CrossoverFilter.Bypass) {
// lastNonBypassType.current = activeFilterType;
// }
const hpFreq = liveHpFreq ?? hp.frequency;
const lpFreq = liveLpFreq ?? lp.frequency;
// const commitHighPass = async (freq: number, filterType: CrossoverFilter) => {
// const success = await set_high_pass({ Input: channel }, freq, filterType);
// if (success) setLiveHpFreq(null);
// return success;
// };
// const commitLowPass = async (freq: number, filterType: CrossoverFilter) => {
// const success = await set_low_pass({ Input: channel }, freq, filterType);
// if (success) setLiveLpFreq(null);
// return success;
// };
// const handleFilterTypeChange = (filterType: CrossoverFilter) => {
// // Applying a slope change always takes the crossover out of bypass.
// void commitHighPass(hpFreq, filterType);
// void commitLowPass(lpFreq, filterType);
// };
// const handleBypassToggle = () => {
// if (bypassed) {
// const restore = lastNonBypassType.current;
// void commitHighPass(hpFreq, restore);
// void commitLowPass(lpFreq, restore);
// } else {
// void commitHighPass(hpFreq, CrossoverFilter.Bypass);
// void commitLowPass(lpFreq, CrossoverFilter.Bypass);
// }
// };
return (
<>
<div className="input-graph-section">
<div className="input-graph-panel">
<div className="input-graph__title">{inputState.name} - Filters</div>
{/* TODO: draw HP/LP transfer curves here once filter math is defined,
same pattern as LimiterSection's `draw` prop. */}
<LinearGraph
xMin={20}
xMax={20000}
yMin={-30}
yMax={6}
xLabel="Hz"
yLabel="dB"
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={() => {}}
/>
</div>
</div>
<div className="input-control-section">
<div className="input-control-panel">
<ChannelGroup title="PEQ">
<PeqPanelSliders channel={channel} dsp={dsp} band={PEQBand.B1} setPEQ={setPeq} />
</ChannelGroup>
<ChannelGroup title="CrossOver">
<CrossoverPanel
channel={{ Input: channel }}
dsp={dsp}
title="LowPass"
setCrossover={setHighPass}
/>
<CrossoverPanel
channel={{ Input: channel }}
dsp={dsp}
title="HighPass"
setCrossover={setHighPass}
/>
</ChannelGroup>
<ChannelGroup title="Gain">
<GainMeterPanel
dsp={dsp}
channel={{ Input: channel }}
title={dsp.state.current_config.input_states.InA.name}
meter={dsp.meters.channels[0]}
setGain={setGain}
setMute={setMute}
setInverse={setInverse}
/>
</ChannelGroup>
{/* <div className="input-control-panel__sliders">
<div className="input-control-panel__cell input-control-panel__cell--high-pass">
<VerticalSlider
min={FREQ_MIN}
max={FREQ_MAX}
step={FREQ_STEP}
unit=" Hz"
value={hpFreq}
// disabled={bypassed}
onInput={(value) => setLiveHpFreq(value)}
onChange={(value) => commitHighPass(value, activeFilterType)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-control-panel__cell-label">High Pass</div>
</div>
<div className="input-control-panel__cell input-control-panel__cell--low-pass">
<VerticalSlider
min={FREQ_MIN}
max={FREQ_MAX}
step={FREQ_STEP}
unit=" Hz"
value={lpFreq}
// disabled={bypassed}
onInput={(value) => setLiveLpFreq(value)}
onChange={(value) => commitLowPass(value, activeFilterType)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-control-panel__cell-label">Low Pass</div>
</div>
</div>
<div className="input-control-panel__side">
<label className="input-control-panel__select">
<span className="input-control-panel__select-label">Filter Type</span>
<select
value={activeFilterType}
disabled={bypassed}
onChange={(e) => handleFilterTypeChange(e.target.value as CrossoverFilter)}
>
{FILTER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<button
type="button"
className={`input-control-panel__bypass-btn${
bypassed ? ' input-control-panel__bypass-btn--active' : ''
}`}
onClick={handleBypassToggle}
>
{bypassed ? 'Bypassed' : 'Bypass'}
</button>
</div> */}
</div>
</div>
</>
);
}
export default InputSection;
@@ -1,171 +0,0 @@
.matrix-section {
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: stretch;
justify-content: center;
box-sizing: border-box;
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
/* ---------- Placeholder graph area (top) ---------- */
/* Intentionally minimal for now — see chat for ideas on what belongs here. */
.matrix-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box;
background: var(--bg-panel);
}
.matrix-graph-panel {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
border: var(--border-width) dashed var(--border-hairline);
border-radius: var(--radius-md);
font-family: var(--font-ui);
font-size: var(--font-xs);
color: var(--text-muted);
}
/* ---------- Matrix grid (bottom) ---------- */
.matrix-grid-panel {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
padding: var(--space-2);
box-sizing: border-box;
overflow: auto;
}
.matrix-grid {
display: grid;
/* 1 corner cell + 1 column per input */
grid-template-columns: minmax(8ch, 12cqh) repeat(var(--matrix-input-count, 4), minmax(9cqh, 1fr));
/* 1 header row + 1 row per output */
grid-auto-rows: minmax(6cqh, 1fr);
gap: var(--border-width);
background: var(--border-hairline);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
overflow: hidden;
}
.matrix-grid__corner,
.matrix-grid__col-header,
.matrix-grid__row-header {
background: var(--bg-void);
font-family: var(--font-ui);
font-weight: var(--font-weight-semibold);
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 2.5cqh;
padding: 0.5cqh;
min-width: 0;
min-height: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.matrix-grid__row-header {
justify-content: flex-start;
padding-left: 1cqh;
}
.matrix-cell {
position: relative;
background: var(--bg-panel);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5cqh;
min-width: 0;
min-height: 0;
border: none;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
touch-action: none;
}
.matrix-cell:focus-visible {
outline: 2px solid var(--accent-brand);
outline-offset: -2px;
}
.matrix-cell--off {
color: var(--text-muted);
}
.matrix-cell--off .matrix-cell__dot {
width: 0.8cqh;
height: 0.8cqh;
border-radius: 50%;
background: currentColor;
opacity: 0.35;
}
.matrix-cell--on {
background: color-mix(in srgb, var(--accent-brand) 14%, var(--bg-panel));
color: var(--accent-brand);
}
.matrix-cell--dragging {
background: color-mix(in srgb, var(--accent-brand) 26%, var(--bg-panel));
}
.matrix-cell__gain {
font-size: 2.6cqh;
font-weight: var(--font-weight-semibold);
font-family: var(--font-ui);
white-space: nowrap;
}
.matrix-cell__link-icon {
width: 2.2cqh;
height: 2.2cqh;
}
@@ -1,257 +0,0 @@
// MatrixSection.tsx
import { Fragment, useRef, useState } from 'react';
import { InputChannel, OutputChannel } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import './MatrixSection.css';
// ASSUMPTION: an InputChannel enum exists alongside OutputChannel in
// dsp408State, e.g. In1..In4. Adjust the import/labels below if the real
// enum differs.
const INPUTS: InputChannel[] = [
InputChannel.InA,
InputChannel.InB,
InputChannel.InC,
InputChannel.InD,
];
const OUTPUTS: OutputChannel[] = [
OutputChannel.Out1,
OutputChannel.Out2,
OutputChannel.Out3,
OutputChannel.Out4,
OutputChannel.Out5,
OutputChannel.Out6,
OutputChannel.Out7,
OutputChannel.Out8,
];
const INPUT_LABEL: Record<InputChannel, string> = {
[InputChannel.InA]: 'In 1',
[InputChannel.InB]: 'In 2',
[InputChannel.InC]: 'In 3',
[InputChannel.InD]: 'In 4',
};
const GAIN_MIN = -60;
const GAIN_MAX = 12;
const GAIN_DEFAULT = 0; // gain a link is set to the moment it's first routed
const GAIN_STEP = 0.5; // wheel / arrow-key nudge
const DRAG_PX_PER_DB = 4; // vertical drag sensitivity
function clampGain(v: number) {
return Math.min(GAIN_MAX, Math.max(GAIN_MIN, v));
}
function formatGain(v: number) {
return `${v > 0 ? '+' : ''}${v.toFixed(1)}`;
}
// ASSUMPTION: each output tracks which inputs are currently routed to it
// (output_states[out].matrix_inputs: InputChannel[]) and the last-known gain
// per input (output_states[out].matrix_gains: Partial<Record<InputChannel,
// number>>, in dB, kept around even if the link is later removed so it can
// be restored). Adjust these two accessors if the real shape differs —
// everything else only depends on the two functions below.
type MatrixOutputState = {
matrix_inputs?: InputChannel[];
matrix_gains?: Partial<Record<InputChannel, number>>;
};
function isRouted(dsp: DSP, output: OutputChannel, input: InputChannel): boolean {
const state = dsp.state.current_config.output_states[output] as unknown as MatrixOutputState;
return state.matrix_inputs?.includes(input) ?? false;
}
function getLinkGain(dsp: DSP, output: OutputChannel, input: InputChannel): number {
const state = dsp.state.current_config.output_states[output] as unknown as MatrixOutputState;
return state.matrix_gains?.[input] ?? GAIN_DEFAULT;
}
function getRoutedInputs(dsp: DSP, output: OutputChannel): InputChannel[] {
const state = dsp.state.current_config.output_states[output] as unknown as MatrixOutputState;
return state.matrix_inputs ?? [];
}
function MatrixCell({
output,
input,
connected,
gain,
onToggle,
onGainChange,
}: {
output: OutputChannel;
input: InputChannel;
connected: boolean;
gain: number;
onToggle: () => void;
onGainChange: (gain: number) => void;
}) {
const [liveGain, setLiveGain] = useState<number | null>(null);
const [dragging, setDragging] = useState(false);
const dragStart = useRef<{ y: number; base: number } | null>(null);
const displayGain = liveGain ?? gain;
const handlePointerDown = (e: React.PointerEvent) => {
if (!connected) return; // plain click on an off cell just routes it (see onClick)
e.currentTarget.setPointerCapture(e.pointerId);
dragStart.current = { y: e.clientY, base: gain };
setDragging(true);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!dragStart.current) return;
const deltaPx = dragStart.current.y - e.clientY; // up = louder
const next = clampGain(dragStart.current.base + deltaPx / DRAG_PX_PER_DB);
setLiveGain(next);
};
const endDrag = () => {
if (dragStart.current && liveGain !== null) {
onGainChange(liveGain);
}
dragStart.current = null;
setDragging(false);
setLiveGain(null);
};
const handleWheel = (e: React.WheelEvent) => {
if (!connected) return;
e.preventDefault();
onGainChange(clampGain(gain + (e.deltaY < 0 ? GAIN_STEP : -GAIN_STEP)));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onToggle();
} else if (connected && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault();
const dir = e.key === 'ArrowUp' ? 1 : -1;
onGainChange(clampGain(gain + dir * GAIN_STEP));
} else if (connected && (e.key === 'Backspace' || e.key === 'Delete')) {
e.preventDefault();
onToggle();
}
};
return (
<div
role="button"
tabIndex={0}
aria-pressed={connected}
aria-label={`${INPUT_LABEL[input]} to ${String(output)}${
connected ? `, ${formatGain(displayGain)} dB` : ', not routed'
}`}
className={[
'matrix-cell',
connected ? 'matrix-cell--on' : 'matrix-cell--off',
dragging ? 'matrix-cell--dragging' : '',
]
.filter(Boolean)
.join(' ')}
onClick={connected ? undefined : onToggle}
onDoubleClick={connected ? onToggle : undefined}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onWheel={handleWheel}
onKeyDown={handleKeyDown}
title={
connected
? `${formatGain(displayGain)} dB — drag/scroll to adjust, double-click to remove`
: 'Click to route'
}
>
{connected ? (
<span className="matrix-cell__gain">{formatGain(displayGain)}</span>
) : (
<span className="matrix-cell__dot" />
)}
</div>
);
}
function MatrixSection({
dsp,
setMatrixRouting,
setMatrixGain,
}: {
dsp: DSP;
setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => Promise<boolean>;
setMatrixGain: (
inputChannel: InputChannel,
outputChannel: OutputChannel,
gain: number
) => Promise<boolean>;
}) {
// Toggling a cell sends the *whole* next input list for that output —
// setMatrixRouting replaces, it doesn't add/remove a single link.
const handleToggle = (output: OutputChannel, input: InputChannel) => {
const current = getRoutedInputs(dsp, output);
const nowConnecting = !current.includes(input);
const next = nowConnecting ? [...current, input] : current.filter((i) => i !== input);
void setMatrixRouting(output, next).then((ok) => {
// Give a freshly-connected link a defined starting gain rather than
// leaving it at whatever stale value the backend last had for it.
if (ok && nowConnecting) {
void setMatrixGain(input, output, GAIN_DEFAULT);
}
});
};
const handleGainChange = (output: OutputChannel, input: InputChannel, gain: number) => {
void setMatrixGain(input, output, gain);
};
return (
<>
<div className="matrix-graph-section">
<div className="matrix-graph-panel">Routing overview — see chat for ideas</div>
</div>
<div className="matrix-section">
<div
className="matrix-grid-panel"
style={{ ['--matrix-input-count' as string]: INPUTS.length }}
>
<div className="matrix-grid">
<div className="matrix-grid__corner" />
{INPUTS.map((input) => (
<div className="matrix-grid__col-header" key={String(input)}>
{INPUT_LABEL[input]}
</div>
))}
{OUTPUTS.map((output) => (
<Fragment key={String(output)}>
<div className="matrix-grid__row-header">
{dsp.state.current_config.output_states[output].name}
</div>
{INPUTS.map((input) => {
const connected = isRouted(dsp, output, input);
return (
<MatrixCell
key={`${String(output)}-${String(input)}`}
output={output}
input={input}
connected={connected}
gain={getLinkGain(dsp, output, input)}
onToggle={() => handleToggle(output, input)}
onGainChange={(gain) => handleGainChange(output, input, gain)}
/>
);
})}
</Fragment>
))}
</div>
</div>
</div>
</>
);
}
export default MatrixSection;
-1719
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,7 +1,7 @@
import { DSP, DSPTypes, AppPage } from '../types/types'; import { DSP, DSPTypes, AppPage } from '../types';
import { SetDsps, NotifyFn } from './dspShared'; import { SetDsps, NotifyFn } from './dspShared';
import { forDSP408, create as createDSP408 } from './dsp408'; import { forDSP408, create as createDSP408 } from '../features/dsp408/dsp408';
import { DSP408Api } from './dsp408'; import { DSP408Api } from '../features/dsp408/dsp408';
// import { forDSP204 } from './dsp204'; // import { forDSP204 } from './dsp204';
export type DSPInstance = { dsp: Extract<DSP, { type: 'DSP408' }>; api: DSP408Api }; export type DSPInstance = { dsp: Extract<DSP, { type: 'DSP408' }>; api: DSP408Api };
+1 -1
View File
@@ -1,4 +1,4 @@
import { DSP, DSPTypes } from '../types/types'; import { DSP, DSPTypes } from '../types';
import { stopPollingFor } from './pollRegistry'; import { stopPollingFor } from './pollRegistry';
export type SetDsps = React.Dispatch<React.SetStateAction<DSP[]>>; export type SetDsps = React.Dispatch<React.SetStateAction<DSP[]>>;

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