Compare commits

...
72 Commits
Author SHA1 Message Date
LucasDLTG e151ab4f26 fix: bugs using real hardware 2026-10-04 21:01:09 +02:00
LucasDLTG e28f61447f fix: q value 2026-10-02 22:48:47 +02:00
LucasDLTG 388552f1c4 fix: matrix gain 2026-10-02 20:38:23 +02:00
LucasDLTG e68e73d3d5 fix: render error on input 2026-09-30 21:14:08 +02:00
Lucas a5669805c8 fix: make led focus the tab 2026-09-30 16:55:47 +02:00
Lucas cef7336517 feat: remember panel per dsp 2026-09-30 16:53:44 +02:00
Lucas 50a3c1a1e5 fix: handle abort password 2026-09-30 16:35:15 +02:00
Lucas 684f5ce866 fix: too wide misc panel 2026-09-30 16:08:42 +02:00
Lucas 80da40f678 fix: disconnect button 2026-09-30 12:42:14 +02:00
LucasDLTG 3cca2f41f4 fix: connection led 2026-09-29 20:56:34 +02:00
Lucas 6511d9d007 unstable 2026-09-29 16:59:54 +02:00
Lucas d5a2afdea5 feat: add locking styles 2026-09-28 16:41:16 +02:00
Lucas 24830172ae feat: add rename channel 2026-09-25 15:10:19 +02:00
LucasDLTG fd7dd89f55 feat: match bypass button color 2026-09-24 18:13:57 +02:00
LucasDLTG dac6c729cd feat: remove outdated commentaries 2026-09-23 21:44:26 +02:00
LucasDLTG d48155683b feat: disable inputs if peq dont use them 2026-09-23 20:03:39 +02:00
LucasDLTG e1d6398d57 feat: dont draw geq/peq curves if bypass 2026-09-22 21:05:59 +02:00
LucasDLTG 4aae47cd27 merge 2026-09-22 19:12:40 +02:00
LucasDLTG 61f821c3e9 feat: add confirmation windows for reset 2026-09-22 19:09:38 +02:00
Lucas b74857d7f6 feat: disable click when exiting select list 2026-09-21 15:58:42 +02:00
Lucas d3a783f77f feat: custom item list in selector 2026-09-21 15:25:21 +02:00
Lucas f965779373 feat: add notification for all dsp query 2026-09-21 14:16:04 +02:00
Lucas b0d4e372cb feat: update logo + app metadata 2026-09-21 10:57:14 +02:00
LucasDLTG e893cab082 fix: unused import 2026-09-20 21:41:09 +02:00
LucasDLTG 10b890b5ae feat: implement copy and link 2026-09-20 21:40:25 +02:00
LucasDLTG e05d589311 fix: command in lib.rs 2026-09-13 22:39:01 +02:00
LucasDLTG 00b6721c42 feat: add MiscellaneousPanel - need to implement UI 2026-09-13 19:09:47 +02:00
LucasDLTG 4f2bce1f5f fix: preset renaming 2026-09-13 17:13:03 +02:00
LucasDLTG 8c0b272ab9 refactor: reorder tauri commands and highlight missing 2026-09-13 16:24:08 +02:00
LucasDLTG 772a331f04 fix: hanging notifications 2026-09-13 15:55:11 +02:00
LucasDLTG 1643a887af feat: add peq output pages 2026-09-13 15:48:26 +02:00
LucasDLTG 8e9a59d026 feat: finalize input section 2026-09-13 14:25:00 +02:00
LucasDLTG 1792910ca1 fix: remove useless vars 2026-09-13 10:51:04 +02:00
LucasDLTG faa019d52b fix: matrix choice selector 2026-09-13 10:50:43 +02:00
LucasDLTG f6bc46dd98 fix; compressoor steppers 2026-09-13 10:06:48 +02:00
LucasDLTG 30e1c0b0a9 feat: wheel behaviour on choiceselect 2026-09-13 09:08:42 +02:00
LucasDLTG 9f2ace9698 feat: use new meter format 2026-09-13 09:04:04 +02:00
LucasDLTG 1e00ca3a7c feat: remove state and let rust handle 2026-09-12 19:37:59 +02:00
LucasDLTG 2717dfe99a feat: prevent text selection on dsp sub pages 2026-09-12 15:19:57 +02:00
LucasDLTG fd545fb2a8 feat: prevent text selection on main pages 2026-09-12 14:14:47 +02:00
LucasDLTG 3793ee9f5b feat: prevent text selection on add dsp page 2026-09-12 13:35:02 +02:00
LucasDLTG 014ccf5197 fix: style of add page selector 2026-09-12 11:52:36 +02:00
LucasDLTG 0f198692ad fix: draw over bounds 2026-09-12 11:22:27 +02:00
LucasDLTG 19c3aedf80 fix: panel on all pages 2026-09-12 10:12:17 +02:00
LucasDLTG 9589f6f3f0 fix: gate panels on linux 2026-09-11 20:27:55 +02:00
LucasDLTG 037d17ff3d feat: graph header on gain panel 2026-09-09 20:56:35 +02:00
LucasDLTG 20182a8e36 fix: font weigth on 'add page' 2026-09-09 17:24:12 +02:00
LucasDLTG 8f5a7c22cc fix: linux display main page 2026-09-08 21:57:38 +02:00
LucasDLTG ac23f98fcd feat: push Topbar 2026-09-06 12:54:45 +02:00
LucasDLTG 81a99b7173 feat: update 2026-09-06 12:45:27 +02:00
LucasDLTG 75ac349790 refactor: graph to use a set of points instead of taking the whole dsp oobject 2026-08-31 20:14:07 +02:00
LucasDLTG 21728b5e6d fix: eslint errors 2026-08-31 14:12:36 +02:00
LucasDLTG d6d0215e09 feat: add peq foor input 2026-08-31 14:12:03 +02:00
LucasDLTG a0c9fbb674 feat: add behaviour to hp/lp filters 2026-08-13 11:32:06 +02:00
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
LucasDLTG d91bcaa20f feat: add demo mode 2026-08-11 22:02:46 +02:00
LucasDLTG b572e1c794 feat: start in tab 2026-08-10 17:58:55 +02:00
LucasDLTG 0f66ed72f4 feat: custom input formatting + custom step 2026-08-10 15:29:18 +02:00
LucasDLTG ee3f56fcdf feat: remove useless api file 2026-08-07 16:02:39 +02:00
LucasDLTG fbcaaf31aa feat: remove useless api file 2026-08-06 21:14:41 +02:00
LucasDLTG 470b95ace3 feat: add delay tab 2026-08-06 20:51:38 +02:00
LucasDLTG e802067056 feat: add limiter 2026-08-06 16:15:46 +02:00
LucasDLTG 56f7aca174 fix: warnings 2026-08-06 14:13:37 +02:00
LucasDLTG 78ea04453e feat: add compressor + fix ui for all sections 2026-08-06 14:13:04 +02:00
LucasDLTG e110a28f80 fix: warnings 2026-07-31 13:25:38 +02:00
LucasDLTG daf2bf32aa feat: gate sectioon 2026-07-31 13:24:24 +02:00
157 changed files with 16388 additions and 5608 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"]
} }
+31 -7
View File
@@ -1,7 +1,31 @@
# 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
- 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
- Check end to end behavioour
- Roll back on scroll when 2 scroll
- test en dto end connection
Data: 10020001052f 31313131 10032a // lock
Data: 10020001052d 31313131 100328 // unlock fail
Data: 10020100032d 3100 10031f
Data: 10020001062d 0031313131 10032b // unlock success
Data: 10020100032d 0001 10032f
+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

+440 -114
View File
@@ -1,23 +1,17 @@
use tauri::State;
use crate::state::{AppState, DSPDevice}; use crate::state::{AppState, DSPDevice};
use dsp_thomann::dsp408::DSP408; 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)
@@ -25,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)
@@ -55,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(())
@@ -117,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())
}) })
} }
@@ -147,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>,
@@ -221,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>,
@@ -246,4 +203,373 @@ pub async fn set_current_preset_name(
.set_current_preset_name(&name) .set_current_preset_name(&name)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
}) })
} }
// 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]
pub async fn set_gate(
state: State<'_, AppState>,
id: u64,
channel: types::InputChannel,
threshold: f32,
attack: u16,
hold: u16,
release: u16,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_gate(channel, threshold, attack, hold, release)
.map_err(|e| e.to_string())
})
}
// Command `0x3F`
#[tauri::command]
pub async fn set_limiter(
state: State<'_, AppState>,
id: u64,
channel: types::OutputChannel,
threshold: f32,
attack: u16,
release: u16,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_limiter(channel, threshold, attack, release)
.map_err(|e| e.to_string())
})
}
// Command `0x40`
#[tauri::command]
pub async fn get_meter_levels(
state: State<'_, AppState>,
id: u64,
) -> Result<(types::InputMeters, types::OutputMeters), String> {
state.with_device_mut(id, |device| {
device.dsp.get_meter_levels().map_err(|e| e.to_string())
})
}
// Command `0x41`
#[tauri::command]
pub async fn set_matrix_gain(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
output_channel: types::OutputChannel,
gain: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_matrix_gain(input_channel, output_channel, gain)
.map_err(|e| e.to_string())
})
}
// Command `0x48`
#[tauri::command]
pub async fn set_geq_band(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
frequency_band: types::DiscreteFrequency,
gain: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_geq_band(input_channel, frequency_band, gain)
.map_err(|e| e.to_string())
})
}
// Command `0x49`
#[tauri::command]
pub async fn set_geq_bypass(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
bypass: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_geq_bypass(input_channel, bypass)
.map_err(|e| e.to_string())
})
}
+35 -14
View File
@@ -1,5 +1,5 @@
mod state;
mod commands; mod commands;
mod state;
use state::AppState; use state::AppState;
@@ -9,19 +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::connect_dsp408, commands::remove_dsp408,
commands::remove_dsp408, commands::get_dsp_snapshot,
commands::disconnect_dsp408, commands::get_dsp_state,
commands::get_dsp_state, commands::connect_dsp408, // 0x10
commands::set_channel_gain, commands::disconnect_dsp408, // 0x11
commands::set_mute, commands::set_delay_unit, // 0x15
commands::set_channel_inverse_gain, commands::recall_preset, // 0x20
commands::recall_preset, commands::store_current_configuration, // 0x21
commands::get_meter_levels, commands::factory_reset, // 0x23
commands::set_current_preset_name, commands::set_current_preset_name, // 0x26
] commands::copy_channel, // 0x2A
) commands::unlock_device, // Command `0x2D`
commands::lock_device, // Command `0x2F`
commands::set_compressor, // 0x30
commands::set_low_pass, // 0x31
commands::set_high_pass, // 0x32
commands::set_peq_band, // 0x33
commands::set_channel_gain, // 0x34
commands::set_mute, // 0x35
commands::set_channel_inverse_gain, // 0x36
commands::set_delay, // 0x38
commands::set_input_source, // 0x39
commands::set_matrix_routing, // 0x3A
commands::link_channels, // 0x3B
commands::set_bypass_peq, // 0x3C
commands::set_channel_name, // 0x3D
commands::set_gate, // 0x3E
commands::set_limiter, // 0x3F
commands::get_meter_levels, // 0x40
commands::set_matrix_gain, // 0x41
commands::set_geq_band, // 0x48
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": 900, "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"
] ]
} }
} }
+32 -19
View File
@@ -1,6 +1,6 @@
import { useState } 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';
@@ -8,17 +8,33 @@ import { useNotifications } from './hooks/useNotifications';
import Notifications from './components/Notifications/Notifications'; import Notifications from './components/Notifications/Notifications';
import CreditsPage from './pages/CreditsPage/CreditsPage'; import CreditsPage from './pages/CreditsPage/CreditsPage';
import HomePage from './pages/HomePage/HomePage'; import HomePage from './pages/HomePage/HomePage';
import { useDSP408 } from './hooks/useDSP408'; import { addDSP, createDSPInstance } from './dsp/dspApi';
import { removeDSP } from './dsp/dspShared';
import { stopAllPolling } from './dsp/pollRegistry';
function App() { function App() {
const [dsps, setDsps] = useState<DSP[]>([]); const [dsps, setDsps] = useState<DSP[]>([]);
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 activeDsp = dsps.find((d) => d.id === selected) ?? null; const connectedDspCount = dsps.filter((dsp) => isConnected(dsp)).length;
const connectedDspCount = dsps.filter((dsp) => dsp.status === 'connected').length;
const dsp408 = useDSP408(dsps, setDsps, selected, setSelected, setPage, notify); const activeDsp = dsps.find((d) => d.id === selected) ?? null;
const activeInstance = activeDsp ? createDSPInstance(activeDsp, setDsps, notify) : null;
const dspsRef = useRef(dsps);
dspsRef.current = dsps;
useEffect(() => {
return () => stopAllPolling(dspsRef.current);
}, []);
useEffect(() => {
if (activeDsp == null) return;
const instance = createDSPInstance(activeDsp, setDsps, notify);
instance.api.setActive(true);
return () => instance.api.setActive(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeDsp?.id]);
return ( return (
<div className="app"> <div className="app">
@@ -34,29 +50,26 @@ function App() {
page={page} page={page}
setPage={setPage} setPage={setPage}
setSelect={setSelected} setSelect={setSelected}
onSelect={(id) => { onSelect={(id) => {
setSelected(id); setSelected(id);
setPage('view-dsp'); setPage('view-dsp');
}} }}
onRemove={(dsp) => removeDSP(setDsps, selected, setSelected, dsp.id, dsp.type)}
onRemove={dsp408.removeDSP} onAddClick={() => setPage('add-dsp')}
onConnect={(dsp) => createDSPInstance(dsp, setDsps, notify).api.connect()}
onAddClick={() => { onDisconnect={(dsp) => createDSPInstance(dsp, setDsps, notify).api.disconnect()}
setPage('add-dsp');
}}
onConnect={dsp408.connectDSP}
onDisconnect={dsp408.disconnectDSP}
/> />
<main className="stage"> <main className="stage">
{page === 'credits' ? ( {page === 'credits' ? (
<CreditsPage /> <CreditsPage />
) : page === 'add-dsp' ? ( ) : page === 'add-dsp' ? (
<AddDSPForm onCancel={() => setPage('home')} onAdd={dsp408.addDSP} /> <AddDSPForm
) : activeDsp ? ( onCancel={() => setPage('home')}
<DSPPage dsp={activeDsp} dsp408={dsp408.forDSP(activeDsp)} /> onAdd={(params) => addDSP(setDsps, setSelected, setPage, notify, params)}
/>
) : activeInstance ? (
<DSPPage instance={activeInstance} />
) : ( ) : (
<HomePage <HomePage
onAddDSP={() => setPage('add-dsp')} onAddDSP={() => setPage('add-dsp')}
-31
View File
@@ -1,31 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import { DSPState, Meters } from '../types/dsp408State';
export const createDSP408 = (ip: string, port: number, deviceId: number) =>
invoke<number>('create_dsp408', {
ip,
port,
deviceId,
});
export const removeDSP408 = (id: number) => invoke('remove_dsp408', { id });
export const connectDSP408 = (id: number) => invoke('connect_dsp408', { id });
export const disconnectDSP408 = (id: number) => invoke('disconnect_dsp408', { id });
export const getDSP408State = (id: number) => invoke<DSPState>('get_dsp_state', { id });
export const recallPresetDSP408 = (id: number, presetIndex: number) =>
invoke<boolean>('recall_preset', {
id,
presetIndex,
});
export const DSP408SetCurrentPresetName = (id: number, name: string) =>
invoke<boolean>('set_current_preset_name', {
id,
name,
});
export const getDSP408MeterLevels = (id: number) => invoke<Meters>('get_meter_levels', { id });
+169
View File
@@ -64,3 +64,172 @@ export function KnobGainIcon(props: React.SVGProps<SVGSVGElement>) {
</svg> </svg>
); );
} }
export function TextIcon({
children,
...props
}: React.SVGProps<SVGSVGElement> & { children: React.ReactNode }) {
return (
<svg viewBox="0 0 24 24" {...props}>
<text
x="12"
y="12"
textAnchor="middle"
dominantBaseline="middle"
fontSize="9.5"
fill="currentColor"
>
{children}
</text>
</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>
);
}
+27
View File
@@ -0,0 +1,27 @@
export function TriangleUpIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M8 3L2 12H14L8 3Z"
stroke="currentColor"
strokeWidth="1.5"
fill="transparent"
strokeLinejoin="round"
/>
</svg>
);
}
export function TriangleDownIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M8 13L2 4H14L8 13Z"
stroke="currentColor"
strokeWidth="1.5"
fill="transparent"
strokeLinejoin="round"
/>
</svg>
);
}
+51 -10
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,60 @@
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 {
background: var(--accent-brand);
border-color: var(--accent-brand);
color: var(--bg-void);
}
.btn-toggle.active-danger { .btn-toggle.active-danger {
background: var(--accent-down); background: var(--accent-down);
border-color: var(--accent-down); border-color: var(--accent-down);
@@ -57,7 +90,15 @@
color: var(--bg-void); color: var(--bg-void);
} }
/* Optional: active state hover */ /* ------------------------------------------------------------------ */
/* Active toggle hover */
/* ------------------------------------------------------------------ */
.btn-toggle.active-info:hover:not(:disabled) {
background: var(--accent-brand);
border-color: var(--accent-brand);
}
.btn-toggle.active-danger:hover:not(:disabled) { .btn-toggle.active-danger:hover:not(:disabled) {
background: var(--accent-down); background: var(--accent-down);
border-color: var(--accent-down); border-color: var(--accent-down);
@@ -66,4 +107,4 @@
.btn-toggle.active-warning:hover:not(:disabled) { .btn-toggle.active-warning:hover:not(:disabled) {
background: var(--accent-connecting); background: var(--accent-connecting);
border-color: var(--accent-connecting); border-color: var(--accent-connecting);
} }
-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}
@@ -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,427 @@
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 useNonPassiveWheel(
ref: React.RefObject<HTMLElement | null>,
handler: (e: WheelEvent) => void
) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const el = ref.current;
if (!el) return;
const listener = (e: WheelEvent) => handlerRef.current(e);
el.addEventListener('wheel', listener, { passive: false });
return () => el.removeEventListener('wheel', listener);
}, [ref]);
}
function StepperArrows({
onUp,
onDown,
canUp,
canDown,
disabled,
}: {
onUp: () => void;
onDown: () => void;
canUp: boolean;
canDown: boolean;
disabled?: boolean;
}) {
return (
<div className="choice-stepper__arrows">
<Button
variant="toggle"
className="choice-stepper__arrow-btn"
onClick={onUp}
disabled={disabled || !canUp}
>
<TriangleUpIcon />
</Button>
<Button
variant="toggle"
className="choice-stepper__arrow-btn"
onClick={onDown}
disabled={disabled || !canDown}
>
<TriangleDownIcon />
</Button>
</div>
);
}
// ---- List variant ----
type ChoiceStepperListProps<T> = {
value: T;
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);
const inputRef = useRef<HTMLInputElement>(null);
useNonPassiveWheel(inputRef, (e) => {
e.preventDefault();
if (e.deltaY < 0 && canUp) {
stepWheel(1);
} else if (e.deltaY > 0 && canDown) {
stepWheel(-1);
}
});
useEffect(() => {
if (interacting.current) {
// 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
ref={inputRef}
id={id}
className="choice-stepper__input"
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
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);
const inputRef = useRef<HTMLInputElement>(null);
useNonPassiveWheel(inputRef, (e) => {
e.preventDefault();
if (e.deltaY < 0 && canUp) {
stepWheel(1);
} else if (e.deltaY > 0 && canDown) {
stepWheel(-1);
}
});
useEffect(() => {
if (interacting.current) {
if (pendingValue.current !== null && Object.is(value, pendingValue.current)) {
interacting.current = false;
pendingValue.current = null;
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
} else {
return;
}
}
setLocalValue(value);
setText(formatLabel(value));
}, [value, formatLabel]);
useEffect(() => {
return () => {
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
};
}, []);
const clamp = (n: number) => Math.min(max, Math.max(min, n));
const canUp = localValue < max;
const canDown = localValue > min;
const stepLocal = (direction: 1 | -1): number | null => {
const next = clamp(localValue + direction * stepSize);
if (next === localValue) return null;
setLocalValue(next);
setText(formatLabel(next));
return next;
};
async function commit(next: number) {
interacting.current = true;
pendingValue.current = next;
const result = await onChange(next);
if (result === false) {
interacting.current = false;
pendingValue.current = null;
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
setLocalValue(value);
setText(formatLabel(value));
return;
}
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
fallbackTimeout.current = setTimeout(() => {
interacting.current = false;
pendingValue.current = null;
}, PENDING_FALLBACK_MS);
}
const stepAndCommit = (direction: 1 | -1) => {
const next = stepLocal(direction);
if (next !== null) void commit(next);
};
const stepWheel = (direction: 1 | -1) => {
const next = stepLocal(direction);
if (next === null) return;
interacting.current = true;
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
wheelTimeout.current = setTimeout(() => {
wheelTimeout.current = null;
void commit(next);
}, WHEEL_COMMIT_DELAY);
};
const commitText = () => {
const parsed = Number(text);
if (Number.isFinite(parsed)) {
const next = clamp(parsed);
setLocalValue(next);
void commit(next);
} else {
setText(formatLabel(value));
}
};
return (
<div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
<input
ref={inputRef}
id={id}
className="choice-stepper__input"
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (canUp) {
interacting.current = true;
stepLocal(1);
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (canDown) {
interacting.current = true;
stepLocal(-1);
}
}
}}
onKeyUp={(e) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
void commit(localValue);
}}
/>
<StepperArrows
onUp={() => stepAndCommit(1)}
onDown={() => stepAndCommit(-1)}
canUp={canUp}
canDown={canDown}
disabled={disabled}
/>
</div>
);
}
function ChoiceStepper<T>(props: ChoiceStepperListProps<T>): JSX.Element;
function ChoiceStepper(props: ChoiceStepperRangeProps): JSX.Element;
function ChoiceStepper(props: ChoiceStepperListProps<unknown> | ChoiceStepperRangeProps) {
if ('values' in props) {
return <ChoiceStepperList {...props} />;
}
return <ChoiceStepperRange {...props} />;
}
export default ChoiceStepper;
+464
View File
@@ -0,0 +1,464 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
export interface LinearGraphHelpers {
toPx: (x: number, y: number) => [number, number];
toPxX: (x: number) => number;
toPxY: (y: number) => number;
toData: (px: number, py: number) => [number, number];
plot: { x: number; y: number; width: number; height: number };
ctx: CanvasRenderingContext2D;
}
export type DrawFn = (helpers: LinearGraphHelpers) => void;
export interface LinearGraphHandle {
redraw: () => void;
getCanvas: () => HTMLCanvasElement | null;
}
export type AxisScale = 'linear' | 'log';
export interface LinearGraphProps {
xMin: number;
xMax: number;
xStep?: number;
yMin: number;
yMax: number;
yStep?: number;
/** 'log' requires xMin > 0. */
xScale?: AxisScale;
/** 'log' requires yMin > 0. */
yScale?: AxisScale;
draw?: DrawFn;
width?: number;
height?: number;
padding?: { top?: number; right?: number; bottom?: number; left?: number };
showGrid?: boolean;
showAxes?: boolean;
showTicks?: boolean;
xLabel?: string;
yLabel?: string;
formatX?: (v: number) => string;
formatY?: (v: number) => string;
className?: string;
style?: React.CSSProperties;
}
const defaultFormat = (v: number) => {
if (Math.abs(v) < 1e-9) return '0';
const abs = Math.abs(v);
if (abs >= 100000 || abs < 0.01) return v.toExponential(1);
return parseFloat(v.toFixed(4)).toString();
};
function niceStep(min: number, max: number, targetTicks = 8): number {
const range = Math.abs(max - min) || 1;
const raw = range / targetTicks;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / mag;
const step = norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10;
return step * mag;
}
function range(min: number, max: number, step: number): number[] {
if (step <= 0) return [];
const out: number[] = [];
const start = Math.ceil((min - 1e-9) / step) * step;
for (let v = start; v <= max + 1e-9; v += step) {
out.push(Math.round(v / step) * step);
}
return out;
}
interface Tick {
value: number;
/** Decade lines (powers of 10) get major=true for a bolder/brighter grid line. */
major: boolean;
}
// Linear ticks: every step is equally weighted, so mark every Nth (default 5th) as "major"
// purely for label emphasis — grid highlighting logic only really matters for log mode.
function linearTicks(min: number, max: number, step: number): Tick[] {
return range(min, max, step).map((value) => ({ value, major: false }));
}
// Log ticks: standard 1/2/3/4/5/6/7/8/9 mantissas within each decade between min and max.
// Mantissa === 1 (i.e. the decade itself: ...0.1, 1, 10, 100, 1000...) is flagged major
// so it can be drawn as a highlighted line distinct from the minor in-decade ticks.
function logTicks(min: number, max: number): Tick[] {
if (min <= 0 || max <= 0 || max <= min) return [];
const startDecade = Math.floor(Math.log10(min));
const endDecade = Math.ceil(Math.log10(max));
const out: Tick[] = [];
for (let d = startDecade; d <= endDecade; d++) {
const decadeValue = Math.pow(10, d);
for (const mantissa of [1, 2, 3, 4, 5, 6, 7, 8, 9]) {
const value = decadeValue * mantissa;
if (value < min - 1e-9 || value > max + 1e-9) continue;
out.push({ value, major: mantissa === 1 });
}
}
return out;
}
export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(function LinearGraph(
{
xMin,
xMax,
xStep,
yMin,
yMax,
yStep,
xScale = 'linear',
yScale = 'linear',
draw,
width,
height,
padding,
showGrid = true,
showAxes = true,
showTicks = true,
xLabel,
yLabel,
formatX = defaultFormat,
formatY = defaultFormat,
style,
},
ref
) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [size, setSize] = useState({ width: width ?? 600, height: height ?? 360 });
// "container query" units, computed from the actual rendered size —
// 1 cqw = 1% of width, 1 cqh = 1% of height, cqmin = smaller of the two.
const cq = useMemo(() => {
const w = size.width || 1;
const h = size.height || 1;
return { w: w / 100, h: h / 100, min: Math.min(w, h) / 100 };
}, [size.width, size.height]);
const pad = useMemo(() => {
const topBase = cq.h * 3;
const rightBase = cq.w * 5;
const bottomBase = cq.h * 4;
const leftBase = cq.w * 7;
const xLabelSpace = xLabel ? cq.h * 7 : 0;
const yLabelSpace = yLabel ? Math.min(cq.w * 6, 24) : 0;
return {
top: padding?.top ?? topBase,
right: padding?.right ?? rightBase,
bottom: padding?.bottom ?? bottomBase + xLabelSpace,
left: padding?.left ?? leftBase + yLabelSpace,
};
}, [padding, xLabel, yLabel, cq]);
// font / stroke / tick metrics, all derived from container size
const metrics = useMemo(
() => ({
tickFont: cq.min * 3,
labelFont: cq.min * 4,
tickLen: cq.min * 1.2,
axisLineWidth: cq.min * 0.4,
yLabelOffset: cq.w * 2,
}),
[cq]
);
const effXStep = xStep ?? niceStep(xMin, xMax);
const effYStep = yStep ?? niceStep(yMin, yMax);
const xLogValid = xScale === 'log' && xMin > 0 && xMax > xMin;
const yLogValid = yScale === 'log' && yMin > 0 && yMax > yMin;
const xTicks = useMemo(
() => (xLogValid ? logTicks(xMin, xMax) : linearTicks(xMin, xMax, effXStep)),
[xLogValid, xMin, xMax, effXStep]
);
const yTicks = useMemo(
() => (yLogValid ? logTicks(yMin, yMax) : linearTicks(yMin, yMax, effYStep)),
[yLogValid, yMin, yMax, effYStep]
);
useEffect(() => {
if (width && height) return;
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width: w, height: h } = entry.contentRect;
if (w > 0 && h > 0) {
setSize({ width: width ?? w, height: height ?? h });
}
});
ro.observe(el);
return () => ro.disconnect();
}, [width, height]);
const paint = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const cssW = size.width;
const cssH = size.height;
if (canvas.width !== cssW * dpr || canvas.height !== cssH * dpr) {
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
}
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
const plot = {
x: pad.left,
y: pad.top,
width: Math.max(1, cssW - pad.left - pad.right - cq.w * 2),
height: Math.max(1, cssH - pad.top - pad.bottom),
};
const logXMin = xLogValid ? Math.log10(xMin) : 0;
const logXMax = xLogValid ? Math.log10(xMax) : 0;
const logYMin = yLogValid ? Math.log10(yMin) : 0;
const logYMax = yLogValid ? Math.log10(yMax) : 0;
const toPxX = (x: number) => {
if (xLogValid) {
const lx = Math.log10(Math.max(x, 1e-9));
return plot.x + ((lx - logXMin) / (logXMax - logXMin || 1)) * plot.width;
}
return plot.x + ((x - xMin) / (xMax - xMin || 1)) * plot.width;
};
const toPxY = (y: number) => {
if (yLogValid) {
const ly = Math.log10(Math.max(y, 1e-9));
return plot.y + plot.height - ((ly - logYMin) / (logYMax - logYMin || 1)) * plot.height;
}
return plot.y + plot.height - ((y - yMin) / (yMax - yMin || 1)) * plot.height;
};
const toPx = (x: number, y: number): [number, number] => [toPxX(x), toPxY(y)];
const toData = (px: number, py: number): [number, number] => {
const xRatio = (px - plot.x) / plot.width;
const yRatio = 1 - (py - plot.y) / plot.height;
const x = xLogValid
? Math.pow(10, logXMin + xRatio * (logXMax - logXMin))
: xMin + xRatio * (xMax - xMin);
const y = yLogValid
? Math.pow(10, logYMin + yRatio * (logYMax - logYMin))
: yMin + yRatio * (yMax - yMin);
return [x, y];
};
const css = getComputedStyle(canvas);
const gridColor = css.getPropertyValue('--graph-grid').trim() || '#2a2f3a';
const gridMajorColor = css.getPropertyValue('--graph-grid-major').trim() || '#4b5563';
const axisColor = css.getPropertyValue('--graph-axis').trim() || '#6b7280';
const textColor = css.getPropertyValue('--graph-text').trim() || '#9ca3af';
const bgColor = css.getPropertyValue('--graph-bg').trim();
if (bgColor) {
ctx.fillStyle = bgColor;
ctx.fillRect(plot.x, plot.y, plot.width, plot.height);
}
const borderInset = 1; // Avoid clipping outside graph bounds
ctx.save();
ctx.beginPath();
ctx.rect(
plot.x + borderInset,
plot.y + borderInset,
plot.width - borderInset * 2,
plot.height - borderInset * 2
);
ctx.clip();
ctx.strokeStyle = axisColor;
ctx.lineWidth = 1;
ctx.strokeRect(
Math.round(plot.x) + 0.5,
Math.round(plot.y) + 0.5,
Math.round(plot.width) - 1,
Math.round(plot.height) - 1
);
if (showGrid) {
// minor grid lines first (thin), then major/decade lines drawn on top so
// they aren't obscured and read clearly as the "multiples of 10" markers.
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.beginPath();
for (const t of xTicks) {
if (t.major) continue;
const px = Math.round(toPxX(t.value)) + 0.5;
ctx.moveTo(px, plot.y);
ctx.lineTo(px, plot.y + plot.height);
}
for (const t of yTicks) {
if (t.major) continue;
const py = Math.round(toPxY(t.value)) + 0.5;
ctx.moveTo(plot.x, py);
ctx.lineTo(plot.x + plot.width, py);
}
ctx.stroke();
const majorX = xTicks.filter((t) => t.major);
const majorY = yTicks.filter((t) => t.major);
if (majorX.length || majorY.length) {
ctx.strokeStyle = gridMajorColor;
ctx.lineWidth = Math.max(1, metrics.axisLineWidth * 0.6);
ctx.beginPath();
for (const t of majorX) {
const px = Math.round(toPxX(t.value)) + 0.5;
ctx.moveTo(px, plot.y);
ctx.lineTo(px, plot.y + plot.height);
}
for (const t of majorY) {
const py = Math.round(toPxY(t.value)) + 0.5;
ctx.moveTo(plot.x, py);
ctx.lineTo(plot.x + plot.width, py);
}
ctx.stroke();
}
}
if (showAxes) {
ctx.strokeStyle = axisColor;
ctx.lineWidth = metrics.axisLineWidth;
ctx.beginPath();
if (!yLogValid && yMin <= 0 && yMax >= 0) {
const py = Math.round(toPxY(0)) + 0.5;
ctx.moveTo(plot.x, py);
ctx.lineTo(plot.x + plot.width, py);
}
if (!xLogValid && xMin <= 0 && xMax >= 0) {
const px = Math.round(toPxX(0)) + 0.5;
ctx.moveTo(px, plot.y);
ctx.lineTo(px, plot.y + plot.height);
}
ctx.stroke();
}
draw?.({ toPx, toPxX, toPxY, toData, plot, ctx });
ctx.restore();
ctx.strokeStyle = axisColor;
ctx.lineWidth = 1;
ctx.strokeRect(
Math.round(plot.x) + 0.5,
Math.round(plot.y) + 0.5,
Math.round(plot.width) - 1,
Math.round(plot.height) - 1
);
if (showTicks) {
ctx.font = `${metrics.tickFont}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
for (const t of xTicks) {
// In log mode, only label the decade (major) ticks — labeling every
// 2,3,4...9 mantissa gets unreadably dense.
if (xLogValid && !t.major) continue;
const px = toPxX(t.value);
ctx.beginPath();
ctx.moveTo(px, plot.y + plot.height);
ctx.lineTo(px, plot.y + plot.height + metrics.tickLen);
ctx.strokeStyle = axisColor;
ctx.stroke();
ctx.fillStyle = t.major && xLogValid ? textColor : textColor;
ctx.fillText(formatX(t.value), px, plot.y + plot.height + metrics.tickLen + 2);
}
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (const t of yTicks) {
if (yLogValid && !t.major) continue;
const py = toPxY(t.value);
ctx.beginPath();
ctx.moveTo(plot.x - metrics.tickLen, py);
ctx.lineTo(plot.x, py);
ctx.strokeStyle = axisColor;
ctx.stroke();
ctx.fillStyle = textColor;
ctx.fillText(formatY(t.value), plot.x - metrics.tickLen - 2, py);
}
}
if (xLabel) {
ctx.fillStyle = textColor;
ctx.font = `${metrics.labelFont}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(xLabel, plot.x + plot.width / 2, cssH - 4);
}
if (yLabel) {
ctx.save();
ctx.fillStyle = textColor;
ctx.font = `${metrics.labelFont}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.translate(metrics.yLabelOffset, plot.y + plot.height / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText(yLabel, 0, 0);
ctx.restore();
}
}, [
size,
pad,
metrics,
xMin,
xMax,
xLogValid,
yMin,
yMax,
yLogValid,
xTicks,
yTicks,
showGrid,
showAxes,
showTicks,
xLabel,
yLabel,
formatX,
formatY,
draw,
]);
useEffect(() => {
paint();
}, [paint]);
useImperativeHandle(ref, () => ({
redraw: paint,
getCanvas: () => canvasRef.current,
}));
return (
<div
ref={containerRef}
className={'container'}
style={{
width: width ?? '100%',
height: height ?? '100%',
...style,
}}
>
<canvas
ref={canvasRef}
className={'canvas'}
style={{ width: size.width, height: size.height }}
/>
</div>
);
});
export default LinearGraph;
+114
View File
@@ -0,0 +1,114 @@
.meter-bar {
display: flex;
flex-direction: column;
align-items: left;
gap: 0.3cqh;
user-select: none;
-webkit-user-select: none;
}
.meter-bar__title {
font-size: 1.5cqh;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
white-space: nowrap;
text-align: left;
justify-content: left;
}
.meter-bar__body {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 0.3cqh;
flex: 1 1 auto;
}
.meter-bar__leds-wrap {
position: relative;
width: 3cqh;
flex: 1 1 auto;
}
/* All rows — the 2 indicator LEDs and every level segment — are direct
siblings in this one flex column with equal flex weight, so they're
guaranteed the exact same height. No row gets a hardcoded pixel height. */
.meter-bar__leds {
display: flex;
flex-direction: column;
gap: 0.5cqh;
width: 100%;
height: 100%;
}
.meter-bar__led {
flex: 1 1 0;
min-height: 0;
width: 100%;
border-radius: 0.5cqh;
box-sizing: border-box;
transition: background-color 60ms linear;
}
.meter-bar__led--indicator {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06) inset;
}
/* Peak-hold marker: a thin line overlaid on the ladder at the peak's position,
independent of which LEDs are lit by rms. */
.meter-bar__peak-marker {
position: absolute;
left: -0.5cqh;
right: -0.5cqh;
height: 0.3cqh;
min-height: 1px;
background-color: #e5e7eb;
box-shadow: 0 0 3px rgba(255, 255, 255, 0.8);
transform: translateY(0.25cqh);
pointer-events: none;
}
/* Scale sits in the same row as leds-wrap, same height, so ticks positioned
by percentage line up with the exact dB position on the ladder. */
.meter-bar__scale {
position: relative;
width: 3.5cqh;
font-size: 1.5cqh;
color: rgba(255, 255, 255, 0.4);
}
.meter-bar__scale-tick {
position: absolute;
left: 0;
transform: translateY(50%);
white-space: nowrap;
}
.meter-bar__scale-tick--end {
color: rgba(255, 255, 255, 0.55);
}
.meter-bar__value {
padding-top: 0.5cqh;
font-size: 1.2cqh;
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 0.7);
}
.meter-bar__indicator-row {
display: flex;
align-items: center;
gap: 0.1cqh;
flex: 1 1 0;
min-height: 0;
font-size: 1.5cqh;
color: rgba(255, 255, 255, 0.45);
white-space: nowrap;
}
.meter-bar__indicator-row .meter-bar__led {
flex: 0 0 auto;
width: 3cqh;
height: 100%;
}
+165
View File
@@ -0,0 +1,165 @@
import './MeterBar.css';
export interface Meter {
rms: number;
peak: number;
clip?: boolean;
}
type MeterBarProps = {
label: string;
meter: Meter;
min?: number;
max?: number;
segments?: number;
/** Intermediate dB values to mark on the scale, e.g. [0] or [-20, -6, 0]. */
ticks?: number[];
};
function segmentColor(posRatioFromBottom: number) {
if (posRatioFromBottom > 0.9) return '#f87171'; // red — near clip
if (posRatioFromBottom > 0.7) return '#fbbf24'; // yellow — hot
return '#4ade80'; // green — normal
}
// Formats a dB value with an explicit '+' sign for positive values (e.g. +12, -60, 0)
function formatDb(v: number, decimals = 0) {
if (!Number.isFinite(v)) return '—';
const fixed = v.toFixed(decimals);
return v > 0 ? `+${fixed}` : fixed;
}
function MeterBar({
label,
meter,
min = -60,
max = +10,
segments = 7,
ticks = [0, -50, -40, -30, -20, -10],
}: MeterBarProps) {
const { rms, peak, clip } = meter;
// Maps a raw dB value to a 0..1 ratio from the bottom of the ladder, clamped to range.
const ratioOf = (v: number) => {
const clamped = Math.min(max, Math.max(min, v));
return (clamped - min) / (max - min);
};
// --- RMS ladder (drives which LEDs are lit) ---
const rmsRatio = ratioOf(rms);
const litCount = Math.round(rmsRatio * segments);
// --- Peak marker (position only, doesn't affect lit count) ---
const peakRatio = ratioOf(peak);
// --- Intermediate tick marks on the scale, e.g. a 0 dB reference line ---
const visibleTicks = ticks.filter((t) => t > min && t < max);
// --- Red "over" LED: lit whenever rms or peak crosses max ---
const isOverMax = rms >= max || peak >= max;
// --- Orange "limit" LED: lit strictly from the meter's own clip flag.
// clip is optional — some meters simply don't report a limiter/clip state.
const hasLimiter = clip !== undefined;
const isClipping = clip === true;
// The over-led and limit-led are rendered as two extra rows in the very
// same flex column as the level segments (see meter-bar__leds below), so
// they get identical row height automatically instead of a hardcoded one.
// Because of that, the ladder itself only occupies `segments / totalRows`
// of the column — the rest of the math (peak marker, scale ticks) has to
// scale down to match.
const totalRows = segments + 2;
const ladderFraction = segments / totalRows;
const toColumnPercent = (ratio: number) => ratio * ladderFraction * 100;
return (
<div className="meter-bar">
<div className="meter-bar__title">{label}</div>
<div className="meter-bar__body">
<div className="meter-bar__leds-wrap">
<div className="meter-bar__leds">
<div
className="meter-bar__indicator-row"
style={{ visibility: hasLimiter ? 'visible' : 'hidden' }}
>
<div
className="meter-bar__led meter-bar__led--indicator"
style={{
backgroundColor: isClipping ? '#fb923c' : 'rgba(255,255,255,0.08)',
}}
title="Limit"
/>
<span>Limit</span>
</div>
<div className="meter-bar__indicator-row">
<div
className="meter-bar__led meter-bar__led--indicator"
style={{
backgroundColor: isOverMax ? '#f87171' : 'rgba(255,255,255,0.08)',
}}
title="Clip"
/>
<span>Clip</span>
</div>
{Array.from({ length: segments }).map((_, i) => {
// i=0 renders at the top; convert to "position from bottom" for lighting + color
const posFromBottom = segments - 1 - i;
const lit = posFromBottom < litCount;
return (
<div
key={i}
className="meter-bar__led"
style={{
backgroundColor: lit
? segmentColor(posFromBottom / segments)
: 'rgba(255,255,255,0.08)',
}}
/>
);
})}
</div>
<div
className="meter-bar__peak-marker"
style={{ bottom: `${toColumnPercent(peakRatio)}%` }}
title={`Peak: ${formatDb(peak, 1)} dB`}
/>
</div>
<div className="meter-bar__scale">
<span
className="meter-bar__scale-tick meter-bar__scale-tick--end"
style={{ bottom: `${ladderFraction * 100}%` }}
>
{formatDb(max)}
</span>
{visibleTicks.map((t) => (
<span
key={t}
className="meter-bar__scale-tick"
style={{ bottom: `${toColumnPercent(ratioOf(t))}%` }}
>
{formatDb(t)}
</span>
))}
<span
className="meter-bar__scale-tick meter-bar__scale-tick--end"
style={{ bottom: '0%' }}
>
{formatDb(min)}
</span>
</div>
</div>
<div className="meter-bar__value">
{Number.isFinite(rms) ? `${formatDb(rms, 1)} dB` : '—'}
</div>
</div>
);
}
export default MeterBar;
@@ -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,410 @@
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`;
}
export function useNonPassiveWheel(
ref: React.RefObject<Element | null>,
handler: (e: WheelEvent) => void
) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const el = ref.current;
if (!el) return;
const listener = (e: Event) => handlerRef.current(e as WheelEvent);
el.addEventListener('wheel', listener, { passive: false });
return () => el.removeEventListener('wheel', listener);
}, [ref]);
}
type GainTooltipState = {
visible: boolean;
x: number;
y: number;
label: string;
};
function SignalFlowMatrix({
dsp,
setMatrixRouting,
setMatrixGain,
setActiveSidebar,
onGainPreview,
}: {
dsp: DSP408;
setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => Promise<boolean>;
setMatrixGain: (input: InputChannel, output: OutputChannel, gain: number) => Promise<boolean>;
setActiveSidebar: React.Dispatch<React.SetStateAction<SidebarId>>;
onGainPreview: (input: InputChannel, output: OutputChannel, value: number | null) => void;
}) {
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);
const WHEEL_COMMIT_DELAY = 250;
// key = `${input}-${output}`
const pendingGains = useRef<Map<string, number>>(new Map());
const commitTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(() => {
return () => {
if (hideTooltipTimer.current) clearTimeout(hideTooltipTimer.current);
commitTimers.current.forEach(clearTimeout);
};
}, []);
const handleToggle = (output: OutputChannel, input: InputChannel) => {
const routes = getRoutes(dsp, output);
const next = routes.connected.includes(input)
? routes.connected.filter((c) => c !== input)
: [...routes.connected, input];
void setMatrixRouting(output, next);
};
const svgRef = useRef<SVGSVGElement>(null);
const handleWheelGain = (e: WheelEvent, output: OutputChannel, input: InputChannel) => {
e.preventDefault();
const key = `${input}-${output}`;
// Base on the pending value if we're mid-wheel, otherwise the real state
const current = pendingGains.current.get(key) ?? getGain(dsp, output, input);
const delta = e.deltaY < 0 ? MATRIX_GAIN_STEP : -MATRIX_GAIN_STEP;
const next =
Math.round(Math.min(MATRIX_GAIN_MAX, Math.max(MATRIX_GAIN_MIN, current + delta)) * 10) / 10;
pendingGains.current.set(key, next);
onGainPreview(input, output, next);
// Debounced send: only the final value goes out once the wheel pauses
const existing = commitTimers.current.get(key);
if (existing) clearTimeout(existing);
commitTimers.current.set(
key,
setTimeout(async () => {
commitTimers.current.delete(key);
const value = pendingGains.current.get(key);
if (value === undefined) return;
await setMatrixGain(input, output, value);
// Only release if the user hasn't wheeled again while we were awaiting
if (pendingGains.current.get(key) === value) {
pendingGains.current.delete(key);
onGainPreview(input, output, null);
}
}, WHEEL_COMMIT_DELAY)
);
// Tooltip (unchanged, shows the pending value)
if (hideTooltipTimer.current) clearTimeout(hideTooltipTimer.current);
setGainTooltip({ visible: true, x: e.clientX, y: e.clientY, label: formatGainDb(next) });
hideTooltipTimer.current = setTimeout(() => {
setGainTooltip((t) => ({ ...t, visible: false }));
}, GAIN_TOOLTIP_HOLD_MS);
};
useNonPassiveWheel(svgRef, (e) => {
const hit = (e.target as Element).closest<SVGElement>('[data-wheel-route]');
if (!hit) return; // not over a connected line: let the page scroll normally
const { input, output } = hit.dataset;
if (!input || !output) return;
handleWheelGain(e, output as OutputChannel, input as InputChannel);
});
const routePairs = useMemo(() => {
const 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
ref={svgRef}
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)}
data-wheel-route={connected ? '' : undefined}
data-input={input}
data-output={output}
/>
{/* Visible line, purely decorative — no pointer events, so it never
competes with the hitbox above for hover/click/wheel. */}
<path
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;
@@ -139,7 +139,7 @@
background: transparent; background: transparent;
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--font-md); font-size: var(--font-xs);
cursor: pointer; cursor: pointer;
box-sizing: border-box; box-sizing: border-box;
scroll-snap-align: start; scroll-snap-align: start;
@@ -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 {
@@ -204,7 +207,7 @@
padding: 0; padding: 0;
font-size: var(--tab-close-font); font-size: var(--tab-close-font);
color: var(--text-muted); color: var(--text-muted);
background: transparent; background: transparent;
} }
@@ -261,4 +264,4 @@
(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)
); );
} }
@@ -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 === ' ') {
@@ -45,11 +47,7 @@ function Tab({
return ( return (
<div <div
className={[ className={['tab', active && 'tab--active', dimmed && 'tab--dimmed']
'tab',
active && 'tab--active',
dimmed && 'tab--dimmed',
]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
data-step={step} data-step={step}
@@ -62,28 +60,36 @@ function Tab({
<button <button
type="button" type="button"
className="tab-led" className="tab-led"
aria-label={ aria-label={isDspConnected ? `Disconnect ${dsp.name}` : `Connect ${dsp.name}`}
isConnected
? `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"
@@ -1,55 +1,66 @@
.gauge-viewport { .gauge-viewport {
container-type: size;
container-name: gauge;
position: relative; position: relative;
flex: 1 1 0; flex: 1 1 0;
min-height: 0; min-height: 0;
width: auto; width: 100%;
overflow: hidden;
}
.gauge-container {
--s: min(calc(100cqw / 70), calc(100cqh / 240));
position: absolute;
bottom: 0;
left: 50%;
width: calc(70 * var(--s));
height: calc(240 * var(--s));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
transform: translateX(-50%);
box-sizing: border-box; box-sizing: border-box;
user-select: none;
-webkit-user-select: none;
} }
.gauge-track { .gauge-track {
position: relative; position: relative;
flex: 1 1 auto;
width: calc(70 * var(--s)); min-height: 0;
height: calc(210 * var(--s)); width: 100%;
display: flex; display: flex;
align-items: stretch;
justify-content: center; justify-content: center;
gap: 1.2cqh;
padding-bottom: 1.5cqh;
padding-top: 1.2cqh;
}
.gauge-track.no-ticks {
padding-left: calc(2.75cqh);
padding-right: calc(2.75cqh);
}
.ticks {
flex: 0 0 auto;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: calc(8 * var(--s)); pointer-events: none;
}
.ticks span {
width: 2.8cqh;
height: 0.55cqh;
min-height: 1px;
background: var(--border-hairline);
border-radius: 999px;
} }
.slider { .slider {
position: relative; position: relative;
flex: 0 0 auto;
width: calc(24 * var(--s)); width: 6.5cqh;
height: 100%; height: 100%;
cursor: pointer; cursor: pointer;
touch-action: none;
user-select: none;
} }
.track { .track {
@@ -59,56 +70,35 @@
transform: translateX(-50%); transform: translateX(-50%);
width: calc(2 * var(--s)); width: 0.55cqh;
min-width: 1px;
height: 100%; height: 100%;
background: var(--border-hairline); background: var(--border-hairline);
border-radius: var(--radius-sm); border-radius: 999px;
} }
.thumb { .thumb {
position: absolute; position: absolute;
left: 50%; left: 50%;
width: calc(18 * var(--s)); width: 5cqh;
aspect-ratio: 1; aspect-ratio: 1;
transform: translate(-50%, 50%); transform: translate(-50%, 50%);
border-radius: 50%; border-radius: 50%;
background: var(--bg-panel); background: var(--bg-panel);
border: var(--border-width-active) solid var(--accent-brand); border: 0.55cqh solid var(--accent-brand);
box-shadow: var(--shadow-sm); box-shadow: 0 0.55cqh 1.6cqh rgba(0, 0, 0, 0.2);
} }
.ticks {
position: absolute;
top: 0;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
}
.ticks span {
width: calc(10 * var(--s));
height: calc(2 * var(--s));
background: var(--border-hairline);
border-radius: var(--radius-sm);
}
.left { left: calc(8 * var(--s)); }
.right { right: calc(8 * var(--s)); }
.value { .value {
flex: 0 0 auto;
width: 100%; width: 100%;
margin-top: calc(4 * var(--s)); margin-top: 0.8cqh;
padding: calc(2 * var(--s)) calc(4 * var(--s)); padding: 0.5cqh 1cqh;
box-sizing: border-box; box-sizing: border-box;
@@ -120,16 +110,21 @@
white-space: nowrap; white-space: nowrap;
color: var(--accent-brand); color: var(--accent-brand);
font-size: var(--font-xxs); font-size: 3.2cqh;
font-family: var(--font-mono); font-family: var(--font-mono);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
box-shadow: var(--shadow-sm); box-shadow: 0 0.55cqh 1.6cqh rgba(0, 0, 0, 0.2);
border-radius: var(--radius-md); border-radius: 1.6cqh;
overflow: hidden; overflow: hidden;
} }
.value.no-ticks {
padding-left: 0;
padding-right: 0;
}
.value input { .value input {
width: auto; width: auto;
min-width: 0; min-width: 0;
@@ -138,8 +133,8 @@
text-align: center; text-align: center;
background: transparent; background: transparent;
border: var(--border-width) solid var(--border-hairline); border: 0.3cqh solid var(--border-hairline);
border-radius: var(--radius-sm); border-radius: 1.2cqh;
appearance: none; appearance: none;
} }
@@ -150,4 +145,27 @@
line-height: 1; line-height: 1;
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;
}
@@ -0,0 +1,559 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import './VerticalSlider.css';
type StepFn = (value: number) => number;
type QuantizeFn = (value: number) => number;
type Scale = 'linear' | 'log';
type VerticalSliderProps = {
min: number;
max: number;
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;
onChange?: (value: number) => Promise<boolean>;
onInput?: (value: number) => void;
unit?: string | ((value: number) => string);
formatValue?: (value: number, decimals: number) => string;
showTicks?: boolean;
/** 'linear' (default) or 'log'. Log scale requires min > 0 and max > 0. Ignored when `values` is set. */
scale?: Scale;
className?: string;
style?: React.CSSProperties;
/** Greys out the slider and blocks all interaction. */
disabled?: boolean;
};
const defaultStep: StepFn = () => 0.1;
function formatValueDefault(v: number, decimals: number) {
return v.toFixed(decimals);
}
const MIN_TICKS = 3;
const MAX_TICK_SPACING_PX = 16;
function getDecimals(n: number) {
const s = n.toString();
const i = s.indexOf('.');
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({
min,
max,
step = defaultStep,
quantize,
values,
value,
onChange,
onInput,
unit = '',
formatValue,
showTicks = true,
scale = 'linear',
disabled = false,
className,
style,
}: VerticalSliderProps) {
const [editingText, setEditingText] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const keyboardValue = useRef<number | null>(null);
const [displayValue, setDisplayValue] = useState(value);
const dragValue = useRef(value);
const dragging = useRef(false);
const thumbRef = useRef<HTMLDivElement>(null);
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);
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(() => {
if (
dragging.current ||
keyboardEditing.current ||
wheelEditing.current ||
editingText !== null
) {
return;
}
setDisplayValue(value);
dragValue.current = value;
}, [value]);
useEffect(() => {
const el = trackRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const recompute = () => {
const h = el.clientHeight;
if (!h) return;
const n = Math.max(MIN_TICKS, Math.ceil(h / MAX_TICK_SPACING_PX) + 1);
setTickCount(n);
};
recompute();
const observer = new ResizeObserver(recompute);
observer.observe(el);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!disabled) return;
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 displayUnit = typeof unit === 'function' ? unit(displayValue) : unit;
function normalizeValue(input: number) {
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 snapped = min + Math.round((clamped - min) / currentStep) * currentStep;
return Number(Math.min(max, Math.max(min, snapped)).toFixed(6));
}
function updateValue(input: number) {
const next = normalizeValue(input);
dragValue.current = next;
setDisplayValue(next);
onInput?.(next);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (disabled) return;
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
return;
}
keyboardEditing.current = true;
e.preventDefault();
// Base off displayValue (our own last-known-good local state), not the
// `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: number;
if (listValues) {
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);
}
keyboardValue.current = next;
updateValue(next);
}
async function handleKeyUp() {
if (disabled) return;
if (keyboardValue.current == null) return;
const next = keyboardValue.current;
const success = await onChange?.(next);
keyboardEditing.current = false;
keyboardValue.current = null;
if (success === false) {
setDisplayValue(value);
dragValue.current = value;
onInput?.(value);
}
}
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) {
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
const percent = 1 - (clientY - rect.top) / rect.height;
const raw = percentToValue(percent, min, max, scale, listValues);
updateValue(raw);
}
function startDrag(e: React.PointerEvent<HTMLDivElement>) {
if (disabled) return;
const target = e.currentTarget;
dragging.current = true;
target.setPointerCapture(e.pointerId);
valueFromPointer(e.clientY);
}
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!dragging.current) return;
valueFromPointer(e.clientY);
}
async function endDrag(e: React.PointerEvent<HTMLDivElement>) {
if (!dragging.current) return;
const target = e.currentTarget;
const pointerId = e.pointerId;
const finalValue = dragValue.current;
const success = await onChange?.(finalValue);
dragging.current = false;
if (success === false) {
setDisplayValue(value);
dragValue.current = value;
onInput?.(value);
}
if (target.hasPointerCapture(pointerId)) {
target.releasePointerCapture(pointerId);
}
}
async function commitEditing() {
if (disabled) {
setEditingText(null);
return;
}
if (editingText !== null && editingText !== '' && editingText !== '-') {
const parsed = Number(editingText);
if (!Number.isNaN(parsed)) {
const next = normalizeValue(parsed);
setDisplayValue(next);
onInput?.(next);
const success = await onChange?.(next);
if (success === false) {
setDisplayValue(value);
dragValue.current = value;
onInput?.(value);
}
}
}
setEditingText(null);
}
const percent = valueToPercent(displayValue, min, max, scale, listValues);
return (
<div
className={['gauge-viewport', disabled && 'is-disabled', className].filter(Boolean).join(' ')}
style={style}
>
<div
className={['gauge-track', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}
ref={trackRef}
>
{showTicks && (
<div className="ticks left">
{Array.from({ length: tickCount }).map((_, i) => (
<span key={i} />
))}
</div>
)}
<div
className="slider"
ref={sliderRef}
role="slider"
aria-disabled={disabled}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={displayValue}
tabIndex={disabled ? -1 : 0}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPointerDown={startDrag}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className="track" />
<div ref={thumbRef} className="thumb" style={{ bottom: `${percent * 100}%` }} />
</div>
{showTicks && (
<div className="ticks right">
{Array.from({ length: tickCount }).map((_, i) => (
<span key={i} />
))}
</div>
)}
</div>
<div className={['value', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}>
<input
ref={inputRef}
type="text"
inputMode="decimal"
disabled={disabled}
value={editingText ?? formattedValue}
style={{ width: '100%' }}
onFocus={() => setEditingText(value.toFixed(decimals))} // plain number while editing
onChange={(e) => setEditingText(e.target.value)}
onBlur={commitEditing}
onKeyDown={(e) => {
if (e.key === 'Enter') inputRef.current?.blur();
if (e.key === 'Escape') {
setEditingText(null);
inputRef.current?.blur();
}
}}
/>
{displayUnit && <span className="value-unit">{' ' + displayUnit}</span>}
</div>
</div>
);
}
@@ -1,86 +0,0 @@
.gain-panel-viewport {
position: relative;
aspect-ratio: 90 / 400;
overflow: hidden;
box-sizing: border-box;
}
.gain-panel {
position: absolute;
top: 50%;
left: 50%;
transform-origin: center center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
gap: var(--space-2);
background: var(--bg-void);
padding-bottom: var(--space-2);
}
.gain-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: var(--font-sm);
font-weight: var(--font-weight-semibold);
padding-top: var(--space-2);
padding-bottom: var(--space-2);
border-bottom: var(--border-width) solid var(--border-hairline);
}
/* Container */
.gain-section {
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
.gain-panels {
height: 100%;
display: flex;
gap: var(--space-2);
overflow-x: auto;
}
/* --- graph strip, sits above the gain section ---
Requires the shared parent (whatever renders <GainSection />) to be
position: relative — it already must be, for .gain-section's absolute
bottom:0 to anchor correctly. */
.linear-graph-section {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: var(--dsp-control-heigth);
box-sizing: border-box;
padding: var(--space-4);
background: var(--bg-panel);
z-index: 10; /* stays below .gain-section's z-index: 20 */
}
@@ -1,191 +0,0 @@
import VerticalSlider from '../Slider/VerticalSlider';
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
import Button from '../../Button/Button';
import ChannelGroup from '../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../LinearGraph/LinearGraph';
import { useEffect, useRef, useState } from 'react';
import './GainSection.css';
const PANEL_DESIGN_WIDTH = 90;
const PANEL_DESIGN_HEIGHT = 400;
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 ?? false;
const inverted = channelState?.phase_inverted ?? false;
const gain = channelState?.gain ?? 0;
const viewportRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const toggleMute = async () => {
await setMute(channel, !muted);
};
const toggleInverse = async () => {
await setInverse(channel, !inverted);
};
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (width === 0 || height === 0) return;
setScale(Math.min(width / PANEL_DESIGN_WIDTH, height / PANEL_DESIGN_HEIGHT));
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div className="gain-panel-viewport" ref={viewportRef}>
<div
className="gain-panel"
style={{
width: PANEL_DESIGN_WIDTH,
height: PANEL_DESIGN_HEIGHT,
transform: `translate(-50%, -50%) scale(${scale})`,
}}
>
<div className="gain-panel__title">{title}</div>
<VerticalSlider
min={-60}
max={12}
step={0.5}
switchStepValue={-10}
switchStep={0.1}
unit=" dB"
value={gain}
onChange={(value) => setGain(channel, value)}
style={{ width: 70, height: 250, flex: '0 0 auto' }} // pin to design size, disable its own auto-shrink
/>
<Button variant="toggle" className={muted ? 'active-danger' : ''} onClick={toggleMute} style={{ width: '70px', flex: '1 1 auto', minHeight: 0 }}>
{muted ? 'Muted' : 'Mute '}
</Button>
<Button
variant="toggle"
className={inverted ? 'active-warning' : ''}
onClick={toggleInverse}
style={{ width: '70px', flex: '1 1 auto', minHeight: 0 }}
>
{inverted ? 'Inverted' : 'Invert'}
</Button>
</div>
</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: { channel: Channel; title: string }[] = [
{ channel: { Input: InputChannel.InA }, title: 'In A' },
{ channel: { Input: InputChannel.InB }, title: 'In B' },
{ channel: { Input: InputChannel.InC }, title: 'In C' },
{ channel: { Input: InputChannel.InD }, title: 'In D' },
];
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="linear-graph-section">
<LinearGraph
xMin={20}
xMax={20000}
yMin={-24}
yMax={24}
yStep={6}
xLabel="Hz"
yLabel="dB"
draw={({ ctx, toPx }) => {
// placeholder flat 0 dB response — swap for real EQ/filter data.
// NOTE: this maps x linearly; for a real frequency response you'll
// likely want a log-frequency x-axis instead (see chat notes).
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-section">
<div className="gain-panels">
<ChannelGroup title="Input">
{inputs.map(({ channel, title }) => (
<GainPanel
key={title}
dsp={dsp}
channel={channel}
title={title}
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,4 +0,0 @@
.rerere {
height: 100%;
width: 10%;
}
@@ -1,31 +0,0 @@
import VerticalSlider from '../Slider/VerticalSlider';
import { invoke } from '@tauri-apps/api/core';
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
import { NotificationType } from '../../../types/types';
import ChannelGroup from '../ChannelGroup/ChannelGroup';
import './GateSection.css';
function GateSection({
dsp,
}: {
dsp: DSP;
}) {
return (
<div className="rerere">
<VerticalSlider
min={-60}
max={12}
step={0.5}
switchStepValue={-10}
switchStep={0.1}
unit=" dB"
value={0}
style={{ width: 70, height: 250, flex: '0 0 auto' }}
/>
</div>
);
}
export default GateSection;

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