Compare commits

...
29 Commits
Author SHA1 Message Date
LucasDLTG e68e73d3d5 fix: render error on input 2026-09-30 21:14:08 +02:00
Lucas a5669805c8 fix: make led focus the tab 2026-09-30 16:55:47 +02:00
Lucas cef7336517 feat: remember panel per dsp 2026-09-30 16:53:44 +02:00
Lucas 50a3c1a1e5 fix: handle abort password 2026-09-30 16:35:15 +02:00
Lucas 684f5ce866 fix: too wide misc panel 2026-09-30 16:08:42 +02:00
Lucas 80da40f678 fix: disconnect button 2026-09-30 12:42:14 +02:00
LucasDLTG 3cca2f41f4 fix: connection led 2026-09-29 20:56:34 +02:00
Lucas 6511d9d007 unstable 2026-09-29 16:59:54 +02:00
Lucas d5a2afdea5 feat: add locking styles 2026-09-28 16:41:16 +02:00
Lucas 24830172ae feat: add rename channel 2026-09-25 15:10:19 +02:00
LucasDLTG fd7dd89f55 feat: match bypass button color 2026-09-24 18:13:57 +02:00
LucasDLTG dac6c729cd feat: remove outdated commentaries 2026-09-23 21:44:26 +02:00
LucasDLTG d48155683b feat: disable inputs if peq dont use them 2026-09-23 20:03:39 +02:00
LucasDLTG e1d6398d57 feat: dont draw geq/peq curves if bypass 2026-09-22 21:05:59 +02:00
LucasDLTG 4aae47cd27 merge 2026-09-22 19:12:40 +02:00
LucasDLTG 61f821c3e9 feat: add confirmation windows for reset 2026-09-22 19:09:38 +02:00
Lucas b74857d7f6 feat: disable click when exiting select list 2026-09-21 15:58:42 +02:00
Lucas d3a783f77f feat: custom item list in selector 2026-09-21 15:25:21 +02:00
Lucas f965779373 feat: add notification for all dsp query 2026-09-21 14:16:04 +02:00
Lucas b0d4e372cb feat: update logo + app metadata 2026-09-21 10:57:14 +02:00
LucasDLTG e893cab082 fix: unused import 2026-09-20 21:41:09 +02:00
LucasDLTG 10b890b5ae feat: implement copy and link 2026-09-20 21:40:25 +02:00
LucasDLTG e05d589311 fix: command in lib.rs 2026-09-13 22:39:01 +02:00
LucasDLTG 00b6721c42 feat: add MiscellaneousPanel - need to implement UI 2026-09-13 19:09:47 +02:00
LucasDLTG 4f2bce1f5f fix: preset renaming 2026-09-13 17:13:03 +02:00
LucasDLTG 8c0b272ab9 refactor: reorder tauri commands and highlight missing 2026-09-13 16:24:08 +02:00
LucasDLTG 772a331f04 fix: hanging notifications 2026-09-13 15:55:11 +02:00
LucasDLTG 1643a887af feat: add peq output pages 2026-09-13 15:48:26 +02:00
LucasDLTG 8e9a59d026 feat: finalize input section 2026-09-13 14:25:00 +02:00
95 changed files with 3536 additions and 609 deletions
+7 -1
View File
@@ -8,4 +8,10 @@ This template should help get you started developing with Tauri, React and Types
# TODO # TODO
- Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly - Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly
- Check render to improve perfoormances - Check render to improve perfoormances
- Check in meter if clip bool is limit instead
- On close disconenct all ds^p
- Ma-trix gain block sometimes
- Q out of range
- Check end to end behavioour
+189 -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.20", "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,12 +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", "serde_with",
"strum", "strum",
"strum_macros", "strum_macros",
"thiserror 2.0.20", "thiserror 2.0.21",
] ]
[[package]] [[package]]
@@ -1255,6 +1313,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core",
] ]
[[package]] [[package]]
@@ -1762,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"
@@ -2005,7 +2118,7 @@ dependencies = [
"once_cell", "once_cell",
"png 0.18.1", "png 0.18.1",
"serde", "serde",
"thiserror 2.0.20", "thiserror 2.0.21",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -2493,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"
@@ -2606,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"
@@ -2629,7 +2774,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
"libredox", "libredox",
"thiserror 2.0.20", "thiserror 2.0.21",
] ]
[[package]] [[package]]
@@ -2649,7 +2794,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.2", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -2883,7 +3028,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.2", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -2918,7 +3063,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.2", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -2941,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",
@@ -2961,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]]
@@ -3009,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",
] ]
@@ -3199,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",
@@ -3300,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",
@@ -3339,7 +3485,7 @@ dependencies = [
"tauri-runtime", "tauri-runtime",
"tauri-runtime-wry", "tauri-runtime-wry",
"tauri-utils", "tauri-utils",
"thiserror 2.0.20", "thiserror 2.0.21",
"tokio", "tokio",
"tray-icon", "tray-icon",
"url", "url",
@@ -3390,7 +3536,7 @@ dependencies = [
"sha2", "sha2",
"syn 2.0.119", "syn 2.0.119",
"tauri-utils", "tauri-utils",
"thiserror 2.0.20", "thiserror 2.0.21",
"time", "time",
"url", "url",
"uuid", "uuid",
@@ -3429,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",
@@ -3443,7 +3589,7 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-plugin", "tauri-plugin",
"thiserror 2.0.20", "thiserror 2.0.21",
"url", "url",
"windows", "windows",
"zbus", "zbus",
@@ -3467,7 +3613,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tauri-utils", "tauri-utils",
"thiserror 2.0.20", "thiserror 2.0.21",
"url", "url",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
@@ -3530,7 +3676,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_with", "serde_with",
"swift-rs", "swift-rs",
"thiserror 2.0.20", "thiserror 2.0.21",
"toml 1.1.3+spec-1.1.0", "toml 1.1.3+spec-1.1.0",
"url", "url",
"urlpattern", "urlpattern",
@@ -3582,11 +3728,11 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.20" 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 = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [ dependencies = [
"thiserror-impl 2.0.20", "thiserror-impl 2.0.21",
] ]
[[package]] [[package]]
@@ -3602,13 +3748,13 @@ dependencies = [
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.20" 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 = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.2", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -3919,7 +4065,7 @@ dependencies = [
"once_cell", "once_cell",
"png 0.18.1", "png 0.18.1",
"serde", "serde",
"thiserror 2.0.20", "thiserror 2.0.21",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -4279,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.20", "thiserror 2.0.21",
"windows", "windows",
"windows-core 0.61.2", "windows-core 0.61.2",
] ]
@@ -4714,7 +4860,7 @@ dependencies = [
"sha2", "sha2",
"soup3", "soup3",
"tao-macros", "tao-macros",
"thiserror 2.0.20", "thiserror 2.0.21",
"url", "url",
"webkit2gtk", "webkit2gtk",
"webkit2gtk-sys", "webkit2gtk-sys",
+2 -2
View File
@@ -18,8 +18,8 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2.11.6", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2.5.5"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
dsp_thomann = { path = "../../dsp-thomann", features = ["serialization"] } dsp_thomann = { path = "../../dsp-thomann", features = ["serialization"] }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

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

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

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

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 61 KiB

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

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 834 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+253 -198
View File
@@ -46,7 +46,6 @@ pub struct DSP408Snapshot {
pub state: types::DSPState, pub state: types::DSPState,
pub input_meters: types::InputMeters, pub input_meters: types::InputMeters,
pub output_meters: types::OutputMeters, pub output_meters: types::OutputMeters,
pub mode: types::DSPMode,
} }
#[tauri::command] #[tauri::command]
@@ -104,23 +103,10 @@ pub async fn get_dsp_snapshot(
state: device.dsp.state().clone(), state: device.dsp.state().clone(),
input_meters: device.dsp.input_meters().clone(), input_meters: device.dsp.input_meters().clone(),
output_meters: device.dsp.output_meters().clone(), output_meters: device.dsp.output_meters().clone(),
mode: device.dsp.mode(),
}) })
}) })
} }
#[tauri::command]
pub async fn connect_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
state.with_device_mut(id, |device| device.dsp.connect().map_err(|e| e.to_string()))
}
#[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())
})
}
#[tauri::command] #[tauri::command]
pub async fn get_dsp_state( pub async fn get_dsp_state(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -133,57 +119,33 @@ pub async fn get_dsp_state(
}) })
} }
// Commands
#[tauri::command] #[tauri::command]
pub async fn set_channel_gain( pub async fn connect_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
state: State<'_, AppState>, state.with_device_mut(id, |device| device.dsp.connect().map_err(|e| e.to_string()))
id: u64, }
channel: dsp_thomann::dsp408::types::Channel,
db: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
let r = device
.dsp
.set_channel_gain(channel, db)
.map_err(|e| e.to_string())?;
Ok(r) // Command `0x11`
#[tauri::command]
pub async fn disconnect_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
state.with_device_mut(id, |device| {
device.dsp.disconnect().map_err(|e| e.to_string())
}) })
} }
// Command `0x15`
#[tauri::command] #[tauri::command]
pub async fn set_mute( pub async fn set_delay_unit(
state: State<'_, AppState>, state: State<'_, AppState>,
id: u64, id: u64,
channel: dsp_thomann::dsp408::types::Channel, unit: types::DelayUnit,
muted: bool,
) -> Result<bool, String> { ) -> Result<bool, String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
let r = device device.dsp.set_delay_unit(unit).map_err(|e| e.to_string())
.dsp
.set_mute(channel, muted)
.map_err(|e| e.to_string())?;
Ok(r)
})
}
#[tauri::command]
pub async fn set_channel_inverse_gain(
state: State<'_, AppState>,
id: u64,
channel: dsp_thomann::dsp408::types::Channel,
inverted: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
let r = device
.dsp
.set_channel_inverse_gain(channel, inverted)
.map_err(|e| e.to_string())?;
Ok(r)
}) })
} }
// Command `0x20`
#[tauri::command] #[tauri::command]
pub async fn recall_preset( pub async fn recall_preset(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -200,16 +162,35 @@ pub async fn recall_preset(
}) })
} }
// Command `0x21` -- TODO
#[tauri::command] #[tauri::command]
pub async fn get_meter_levels( pub async fn store_current_configuration(
state: State<'_, AppState>, state: State<'_, AppState>,
id: u64, id: u64,
) -> Result<(types::InputMeters, types::OutputMeters), String> { preset_index: usize,
force: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
device.dsp.get_meter_levels().map_err(|e| e.to_string()) device
.dsp
.store_current_configuration(preset_index, force)
.map_err(|e| e.to_string())
}) })
} }
// Command `0x23`
#[tauri::command]
pub async fn factory_reset(
state: State<'_, AppState>,
id: u64,
force: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device.dsp.factory_reset(force).map_err(|e| e.to_string())
})
}
// Command `0x26`
#[tauri::command] #[tauri::command]
pub async fn set_current_preset_name( pub async fn set_current_preset_name(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -224,24 +205,53 @@ pub async fn set_current_preset_name(
}) })
} }
// Command `0x2A`
#[tauri::command] #[tauri::command]
pub async fn set_gate( pub async fn copy_channel(
state: State<'_, AppState>, state: State<'_, AppState>,
id: u64, id: u64,
channel: types::InputChannel, channel_1: types::Channel,
threshold: f32, channel_2: types::Channel,
attack: u16,
hold: u16,
release: u16,
) -> Result<bool, String> { ) -> Result<bool, String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
device device
.dsp .dsp
.set_gate(channel, threshold, attack, hold, release) .copy_channel(channel_1, channel_2)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
}) })
} }
// Command `0x2D` -- TODO
#[tauri::command]
pub async fn unlock_device(
state: State<'_, AppState>,
id: u64,
password: String,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.unlock_device(&password)
.map_err(|e| e.to_string())
})
}
// Command `0x2F`
#[tauri::command]
pub async fn lock_device(
state: State<'_, AppState>,
id: u64,
password: String,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.lock_device(&password)
.map_err(|e| e.to_string())
})
}
// Command `0x30`
#[tauri::command] #[tauri::command]
pub async fn set_compressor( pub async fn set_compressor(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -261,112 +271,7 @@ pub async fn set_compressor(
}) })
} }
#[tauri::command] // Command `0x31`
pub async fn set_limiter(
state: State<'_, AppState>,
id: u64,
channel: types::OutputChannel,
threshold: f32,
attack: u16,
release: u16,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_limiter(channel, threshold, attack, release)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_delay_unit(
state: State<'_, AppState>,
id: u64,
unit: types::DelayUnit,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device.dsp.set_delay_unit(unit).map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_delay(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
delay: f32,
unit: DelayUnit,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_delay(channel, delay, unit)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_matrix_routing(
state: State<'_, AppState>,
id: u64,
output: types::OutputChannel,
inputs: Vec<types::InputChannel>,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_matrix_routing(output, inputs)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_matrix_gain(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
output_channel: types::OutputChannel,
gain: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_matrix_gain(input_channel, output_channel, gain)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_geq_band(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
frequency_band: types::DiscreteFrequency,
gain: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_geq_band(input_channel, frequency_band, gain)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_geq_bypass(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
bypass: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_geq_bypass(input_channel, bypass)
.map_err(|e| e.to_string())
})
}
#[tauri::command] #[tauri::command]
pub async fn set_low_pass( pub async fn set_low_pass(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -383,6 +288,7 @@ pub async fn set_low_pass(
}) })
} }
// Command `0x32`
#[tauri::command] #[tauri::command]
pub async fn set_high_pass( pub async fn set_high_pass(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -399,6 +305,7 @@ pub async fn set_high_pass(
}) })
} }
// Command `0x33`
#[tauri::command] #[tauri::command]
pub async fn set_peq_band( pub async fn set_peq_band(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -419,47 +326,78 @@ pub async fn set_peq_band(
}) })
} }
// Command `0x34`
#[tauri::command] #[tauri::command]
pub async fn store_current_configuration( pub async fn set_channel_gain(
state: State<'_, AppState>, state: State<'_, AppState>,
id: u64, id: u64,
preset_index: usize, channel: dsp_thomann::dsp408::types::Channel,
force: bool, db: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
let r = device
.dsp
.set_channel_gain(channel, db)
.map_err(|e| e.to_string())?;
Ok(r)
})
}
// Command `0x35`
#[tauri::command]
pub async fn set_mute(
state: State<'_, AppState>,
id: u64,
channel: dsp_thomann::dsp408::types::Channel,
muted: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
let r = device
.dsp
.set_mute(channel, muted)
.map_err(|e| e.to_string())?;
Ok(r)
})
}
// Command `0x36`
#[tauri::command]
pub async fn set_channel_inverse_gain(
state: State<'_, AppState>,
id: u64,
channel: dsp_thomann::dsp408::types::Channel,
inverted: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
let r = device
.dsp
.set_channel_inverse_gain(channel, inverted)
.map_err(|e| e.to_string())?;
Ok(r)
})
}
// Command `0x38`
#[tauri::command]
pub async fn set_delay(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
delay: f32,
unit: DelayUnit,
) -> Result<bool, String> { ) -> Result<bool, String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
device device
.dsp .dsp
.store_current_configuration(preset_index, force) .set_delay(channel, delay, unit)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn factory_reset(
state: State<'_, AppState>,
id: u64,
force: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device.dsp.factory_reset(force).map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn copy_channel(
state: State<'_, AppState>,
id: u64,
channel_1: types::Channel,
channel_2: types::Channel,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.copy_channel(channel_1, channel_2)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
}) })
} }
// Command `0x39`
#[tauri::command] #[tauri::command]
pub async fn set_input_source( pub async fn set_input_source(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -474,6 +412,23 @@ pub async fn set_input_source(
}) })
} }
// Command `0x3A`
#[tauri::command]
pub async fn set_matrix_routing(
state: State<'_, AppState>,
id: u64,
output: types::OutputChannel,
inputs: Vec<types::InputChannel>,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_matrix_routing(output, inputs)
.map_err(|e| e.to_string())
})
}
// Command `0x3B`
#[tauri::command] #[tauri::command]
pub async fn link_channels( pub async fn link_channels(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -489,6 +444,7 @@ pub async fn link_channels(
}) })
} }
// Command `0x3C`
#[tauri::command] #[tauri::command]
pub async fn set_bypass_peq( pub async fn set_bypass_peq(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -504,6 +460,7 @@ pub async fn set_bypass_peq(
}) })
} }
// Command `0x3D` -- TODO
#[tauri::command] #[tauri::command]
pub async fn set_channel_name( pub async fn set_channel_name(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -518,3 +475,101 @@ pub async fn set_channel_name(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
}) })
} }
// Command `0x3E`
#[tauri::command]
pub async fn set_gate(
state: State<'_, AppState>,
id: u64,
channel: types::InputChannel,
threshold: f32,
attack: u16,
hold: u16,
release: u16,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_gate(channel, threshold, attack, hold, release)
.map_err(|e| e.to_string())
})
}
// Command `0x3F`
#[tauri::command]
pub async fn set_limiter(
state: State<'_, AppState>,
id: u64,
channel: types::OutputChannel,
threshold: f32,
attack: u16,
release: u16,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_limiter(channel, threshold, attack, release)
.map_err(|e| e.to_string())
})
}
// Command `0x40`
#[tauri::command]
pub async fn get_meter_levels(
state: State<'_, AppState>,
id: u64,
) -> Result<(types::InputMeters, types::OutputMeters), String> {
state.with_device_mut(id, |device| {
device.dsp.get_meter_levels().map_err(|e| e.to_string())
})
}
// Command `0x41`
#[tauri::command]
pub async fn set_matrix_gain(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
output_channel: types::OutputChannel,
gain: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_matrix_gain(input_channel, output_channel, gain)
.map_err(|e| e.to_string())
})
}
// Command `0x48`
#[tauri::command]
pub async fn set_geq_band(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
frequency_band: types::DiscreteFrequency,
gain: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_geq_band(input_channel, frequency_band, gain)
.map_err(|e| e.to_string())
})
}
// Command `0x49`
#[tauri::command]
pub async fn set_geq_bypass(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
bypass: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_geq_bypass(input_channel, bypass)
.map_err(|e| e.to_string())
})
}
+2
View File
@@ -21,6 +21,8 @@ pub fn run() {
commands::factory_reset, // 0x23 commands::factory_reset, // 0x23
commands::set_current_preset_name, // 0x26 commands::set_current_preset_name, // 0x26
commands::copy_channel, // 0x2A commands::copy_channel, // 0x2A
commands::unlock_device, // Command `0x2D`
commands::lock_device, // Command `0x2F`
commands::set_compressor, // 0x30 commands::set_compressor, // 0x30
commands::set_low_pass, // 0x31 commands::set_low_pass, // 0x31
commands::set_high_pass, // 0x32 commands::set_high_pass, // 0x32
+6 -6
View File
@@ -12,7 +12,7 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "thomann-dsp-app", "title": "Thomann DSP Controller",
"width": 1600, "width": 1600,
"height": 950, "height": 950,
"minWidth": 1200, "minWidth": 1200,
@@ -27,11 +27,11 @@
"active": true, "active": true,
"targets": "all", "targets": "all",
"icon": [ "icon": [
"icons/32x32.png", "./icons/32x32.png",
"icons/128x128.png", "./icons/128x128.png",
"icons/128x128@2x.png", "./icons/128x128@2x.png",
"icons/icon.icns", "./icons/icon.icns",
"icons/icon.ico" "./icons/icon.ico"
] ]
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import './App.css'; import './App.css';
import { DSP, AppPage } from './types'; import { DSP, AppPage, isConnected } from './types';
import TopBar from './components/Topbar/Topbar'; import TopBar from './components/Topbar/Topbar';
import DSPPage from './pages/DspPage/DspPage'; import DSPPage from './pages/DspPage/DspPage';
import AddDSPForm from './pages/AddDspPage/AddDspPage'; import AddDSPForm from './pages/AddDspPage/AddDspPage';
@@ -17,7 +17,7 @@ function App() {
const [selected, setSelected] = useState<number | null>(null); const [selected, setSelected] = useState<number | null>(null);
const [page, setPage] = useState<AppPage>('home'); const [page, setPage] = useState<AppPage>('home');
const { notifications, notify, removeNotification, hoverNotification } = useNotifications(); const { notifications, notify, removeNotification, hoverNotification } = useNotifications();
const connectedDspCount = dsps.filter((dsp) => dsp.status === 'connected').length; const connectedDspCount = dsps.filter((dsp) => isConnected(dsp)).length;
const activeDsp = dsps.find((d) => d.id === selected) ?? null; const activeDsp = dsps.find((d) => d.id === selected) ?? null;
const activeInstance = activeDsp ? createDSPInstance(activeDsp, setDsps, notify) : null; const activeInstance = activeDsp ? createDSPInstance(activeDsp, setDsps, notify) : null;
+149
View File
@@ -84,3 +84,152 @@ export function TextIcon({
</svg> </svg>
); );
} }
export function SlidersVerticalIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M10 8H14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M12 21V12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M12 8V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M17 16H21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M19 12V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M19 21V16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M3 14H7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M5 10V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M5 21V14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function BoltIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="2" />
</svg>
);
}
export function GainIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M3 12H7M17 12H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7 4.5L17 12L7 19.5V4.5Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function GateIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M3 3V21H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.35"
/>
<path
d="M3 21H12L20 5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function CompressorIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M3 3V21H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.35"
/>
<path
d="M3 21L11 13L21 8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function LimiterIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M3 3V21H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.35"
/>
<path
d="M3 21L11 8H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function DelayIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M4 5V19M12 8.5V15.5M20 11V13"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function MatrixIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M3 7C11 7 11 4 21 4" />
<path d="M3 7C11 7 11 20 21 20" />
<path d="M3 17C11 17 11 12 21 12" />
<circle cx="3" cy="7" r="1.6" fill="none" />
<circle cx="3" cy="17" r="1.6" fill="none" />
<circle cx="21" cy="4" r="1.6" fill="none" />
<circle cx="21" cy="12" r="1.6" fill="none" />
<circle cx="21" cy="20" r="1.6" fill="none" />
</svg>
);
}
+42
View File
@@ -0,0 +1,42 @@
export function EyeIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M2 12C2 12 5.5 5 12 5C18.5 5 22 12 22 12C22 12 18.5 19 12 19C5.5 19 2 12 2 12Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" />
</svg>
);
}
export function EyeOffIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M3 3L21 21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10.6 5.2C11.05 5.07 11.52 5 12 5C18.5 5 22 12 22 12C22 12 20.7 14.6 18.3 16.5M6.2 6.2C3.5 8.1 2 12 2 12C2 12 5.5 19 12 19C13.2 19 14.3 18.8 15.3 18.4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.9 9.9C9.35 10.45 9 11.2 9 12C9 13.66 10.34 15 12 15C12.8 15 13.55 14.65 14.1 14.1"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+91 -21
View File
@@ -2,54 +2,124 @@
.choice-select { .choice-select {
position: relative; position: relative;
display: flex; display: flex;
align-items: stretch;
color: var(--accent-brand);
} }
.choice-select[data-disabled] { .choice-select[data-disabled] {
opacity: 0.5; opacity: 0.5;
} }
.choice-select__input { .choice-select__trigger {
flex: 1 1 auto; flex: 1 1 auto;
height: var(--card-control-height); width: 100%;
padding: 0 var(--space-6) 0 var(--space-3); height: 100%;
background: var(--bg-panel); display: flex;
color: var(--text-primary); align-items: center;
justify-content: space-between;
padding: 0;
padding-inline: 5%;
background: transparent;
border: var(--border-width) solid var(--border-hairline); border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--font-sm); font-size: 90%;
appearance: none; color: inherit;
-webkit-appearance: none;
-moz-appearance: none;
outline: none;
cursor: pointer; cursor: pointer;
transition: border-color var(--dur-base) var(--ease-standard); transition: border-color var(--dur-base) var(--ease-standard);
} }
.choice-select__input:focus { .choice-select__trigger:hover,
.choice-select__trigger--open {
border-color: var(--accent-brand); border-color: var(--accent-brand);
} }
.choice-select__input:disabled { .choice-select__trigger:disabled {
cursor: not-allowed; cursor: not-allowed;
} }
.choice-select__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.choice-select__chevron { .choice-select__chevron {
position: absolute; flex-shrink: 0;
right: var(--space-2); display: flex;
top: 50%; align-items: center;
height: 45%; justify-content: center;
width: 4cqh;
aspect-ratio: 1; aspect-ratio: 1;
transform: translateY(-50%);
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);
}
pointer-events: none; .choice-select__option--selected {
color: var(--accent-brand); color: var(--accent-brand);
font-size: var(--font-sm); 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 */
} }
+187 -29
View File
@@ -1,4 +1,6 @@
// ChoiceSelect.tsx // ChoiceSelect.tsx
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { TriangleDownIcon } from '../../assets/icons/Triangle'; import { TriangleDownIcon } from '../../assets/icons/Triangle';
import './ChoiceSelect.css'; import './ChoiceSelect.css';
@@ -13,6 +15,11 @@ type ChoiceSelectProps<T extends string> = {
style?: React.CSSProperties; 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>({ function ChoiceSelect<T extends string>({
value, value,
options, options,
@@ -23,50 +30,201 @@ function ChoiceSelect<T extends string>({
className = '', className = '',
style, style,
}: ChoiceSelectProps<T>) { }: 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 step = (direction: 1 | -1) => {
const currentIndex = options.indexOf(value); const currentIndex = options.indexOf(value);
if (currentIndex === -1 || options.length === 0) return; if (currentIndex === -1 || options.length === 0) return;
const nextIndex = (currentIndex + direction + options.length) % options.length; const nextIndex = (currentIndex + direction + options.length) % options.length;
onChange(options[nextIndex]); 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 ( return (
<div className={`choice-select ${className}`} data-disabled={disabled || undefined}> <div
<select className={`choice-select ${className}`}
data-disabled={disabled || undefined}
ref={rootRef}
style={style}
>
<button
id={id} id={id}
className="choice-select__input" type="button"
style={style} ref={triggerRef}
value={value} className={'choice-select__trigger' + (open ? ' choice-select__trigger--open' : '')}
disabled={disabled} onClick={() => (open ? setOpen(false) : openList())}
onChange={(e) => { onKeyDown={handleKeyDown}
onChange(e.target.value as T);
e.currentTarget.blur();
}}
onWheel={(e) => { onWheel={(e) => {
e.preventDefault(); e.preventDefault();
if (disabled) return; if (disabled) return;
if (e.deltaY < 0) step(-1);
if (e.deltaY < 0) { else if (e.deltaY > 0) step(1);
step(1);
} else if (e.deltaY > 0) {
step(-1);
}
}} }}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
> >
{options.map((option) => ( <span className="choice-select__label">{formatLabel(value)}</span>
<option key={option} value={option}> <span className="choice-select__chevron" aria-hidden="true">
{formatLabel(option)} <TriangleDownIcon />
</option> </span>
))} </button>
</select>
<span className="choice-select__chevron" aria-hidden="true"> {open &&
<TriangleDownIcon /> coords &&
</span> 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> </div>
); );
} }
@@ -78,9 +78,15 @@
} }
.choice-stepper[data-disabled] { .choice-stepper[data-disabled] {
opacity: 0.5; opacity: 0.45;
filter: grayscale(1);
} }
.choice-stepper__input:disabled { .choice-stepper__input:disabled {
cursor: not-allowed; cursor: not-allowed;
} }
.choice-stepper[data-disabled] .choice-stepper__input,
.choice-stepper[data-disabled] .choice-stepper__arrow-btn {
color: var(--border-hairline);
}
@@ -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;
+51
View File
@@ -365,3 +365,54 @@
.app-root--sidebar-expanded .stage--with-sidebar { .app-root--sidebar-expanded .stage--with-sidebar {
margin-left: var(--sidebar-width-expanded); margin-left: var(--sidebar-width-expanded);
} }
/* ---------- Item (editing state) ---------- */
.sidebar-item--editing {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sidebar-item-gap);
aspect-ratio: 1;
height: calc(var(--header-height) * 0.7);
border: none;
background: transparent;
padding: 0;
}
.sidebar-item-input {
width: 0;
flex: 1 1 auto;
min-width: 0;
appearance: none;
-webkit-appearance: none;
background: transparent;
border: none;
outline: none;
box-shadow: none;
text-align: center;
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--font-sm);
font-weight: var(--font-weight-semibold);
opacity: 0;
max-width: 0;
transition:
opacity var(--dur-fast) var(--ease-standard),
max-width var(--dur-base) var(--ease-standard);
}
.sidebar-rail--expanded .sidebar-item-input {
width: auto;
opacity: 1;
max-width: 200px;
border: none;
}
+88 -8
View File
@@ -6,6 +6,7 @@ export type SidebarItem<T extends string = string> = {
id: T; id: T;
label: string; label: string;
icon: React.ReactNode; icon: React.ReactNode;
editable?: boolean;
}; };
export type SidebarAppOption = { export type SidebarAppOption = {
@@ -17,11 +18,75 @@ function SidebarButton<T extends string>({
item, item,
active, active,
onSelect, onSelect,
onRename,
}: { }: {
item: SidebarItem<T>; item: SidebarItem<T>;
active: boolean; active: boolean;
onSelect: () => void; onSelect: () => void;
onRename?: (name: string) => void;
}) { }) {
const [editing, setEditing] = useState(false);
const [inputValue, setInputValue] = useState(item.label);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!editing) setInputValue(item.label);
}, [item.label, editing]);
function startEditing(e: React.MouseEvent) {
e.stopPropagation();
if (!onRename) return;
setInputValue(item.label);
setEditing(true);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
function commitRename() {
setEditing(false);
const trimmed = inputValue.trim();
if (!trimmed || trimmed === item.label) {
setInputValue(item.label);
return;
}
onRename?.(trimmed);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') inputRef.current?.blur();
if (e.key === 'Escape') {
setInputValue(item.label);
setEditing(false);
}
}
if (editing) {
return (
<div className="sidebar-item sidebar-item--editing">
<span className="sidebar-icon">{item.icon}</span>
<input
ref={inputRef}
className="sidebar-item-input"
value={inputValue}
maxLength={8}
onChange={(e) => setInputValue(e.target.value.slice(0, 8))}
onBlur={commitRename}
onKeyDown={handleKeyDown}
aria-label="Rename channel"
/>
</div>
);
}
return ( return (
<button <button
onClick={onSelect} onClick={onSelect}
@@ -31,7 +96,14 @@ function SidebarButton<T extends string>({
className={'sidebar-item' + (active ? ' sidebar-item--active' : '')} className={'sidebar-item' + (active ? ' sidebar-item--active' : '')}
> >
<span className="sidebar-icon">{item.icon}</span> <span className="sidebar-icon">{item.icon}</span>
<span className="sidebar-label">{item.label}</span>
<span
className="sidebar-label"
onDoubleClick={onRename ? startEditing : undefined}
title={onRename ? `${item.label} (double click to rename)` : undefined}
>
{item.label}
</span>
</button> </button>
); );
} }
@@ -46,8 +118,8 @@ function AppCombobox({
appOptions: SidebarAppOption[]; appOptions: SidebarAppOption[];
selectedAppId?: string; selectedAppId?: string;
appName: string; appName: string;
onAppChange?: (id: string) => void; onAppChange: (id: string) => void;
onAppRename?: (name: string) => void; onAppRename: (name: string) => void;
}) { }) {
const currentOption = appOptions.find((o) => o.id === selectedAppId) ?? appOptions[0]; const currentOption = appOptions.find((o) => o.id === selectedAppId) ?? appOptions[0];
const canRename = currentOption?.id !== 'f00'; const canRename = currentOption?.id !== 'f00';
@@ -95,6 +167,7 @@ function AppCombobox({
if (!canRename) return; if (!canRename) return;
setInputValue((currentOption?.name ?? appName).trim());
setEditing(true); setEditing(true);
setOpen(false); setOpen(false);
@@ -124,13 +197,13 @@ function AppCombobox({
if (matched) { if (matched) {
if (matched.id !== selectedAppId) { if (matched.id !== selectedAppId) {
onAppChange?.(matched.id); onAppChange(matched.id);
} }
return; return;
} }
if (trimmed !== currentOption?.name) { if (trimmed !== currentOption?.name) {
onAppRename?.(trimmed); onAppRename(trimmed);
} }
} }
@@ -186,6 +259,7 @@ function AppCombobox({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={open} aria-expanded={open}
title={canRename ? 'Double click to rename' : undefined}
> >
<span className="sidebar-app-trigger-label">{currentOption?.name ?? appName}</span> <span className="sidebar-app-trigger-label">{currentOption?.name ?? appName}</span>
<svg className="sidebar-app-trigger-caret" viewBox="0 0 24 24" width="14" height="14"> <svg className="sidebar-app-trigger-caret" viewBox="0 0 24 24" width="14" height="14">
@@ -209,7 +283,8 @@ function AppCombobox({
onMouseEnter={() => setHighlight(i)} onMouseEnter={() => setHighlight(i)}
onClick={() => pick(opt)} onClick={() => pick(opt)}
> >
{opt.name} <span className="sidebar-app-option-id">{opt.id + '-'}</span>
<span>{opt.name}</span>
</li> </li>
))} ))}
</ul> </ul>
@@ -227,6 +302,7 @@ export function Sidebar<T extends string>({
selectedAppId, selectedAppId,
onAppChange, onAppChange,
onAppRename, onAppRename,
onItemRename,
expanded, expanded,
onToggle, onToggle,
}: { }: {
@@ -236,8 +312,9 @@ export function Sidebar<T extends string>({
appName: string; appName: string;
appOptions?: SidebarAppOption[]; appOptions?: SidebarAppOption[];
selectedAppId?: string; selectedAppId?: string;
onAppChange?: (id: string) => void; onAppChange: (id: string) => void;
onAppRename?: (name: string) => void; onAppRename: (name: string) => void;
onItemRename?: (id: T, name: string) => void;
expanded: boolean; expanded: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
@@ -295,6 +372,9 @@ export function Sidebar<T extends string>({
item={item} item={item}
active={activeId === item.id} active={activeId === item.id}
onSelect={() => onSelect(item.id)} onSelect={() => onSelect(item.id)}
onRename={
item.editable && onItemRename ? (name) => onItemRename(item.id, name) : undefined
}
/> />
))} ))}
</div> </div>
+19 -5
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { DSP, AppPage } from '../../types'; import { DSP, AppPage, isConnected } from '../../types';
import AnimatedLogo from '../../assets/icons/AnimatedLogo'; import AnimatedLogo from '../../assets/icons/AnimatedLogo';
import { PlusIcon } from '../../assets/icons/PlusIcon'; import { PlusIcon } from '../../assets/icons/PlusIcon';
import { CrownIcon } from '../../assets/icons/CrownIcon'; import { CrownIcon } from '../../assets/icons/CrownIcon';
@@ -24,6 +24,7 @@ function Tab({
onRemove, onRemove,
onConnect, onConnect,
onDisconnect, onDisconnect,
actions,
}: { }: {
dsp: DSP; dsp: DSP;
active: boolean; active: boolean;
@@ -33,8 +34,9 @@ function Tab({
onRemove: () => void; onRemove: () => void;
onConnect: (dsp: DSP) => void; onConnect: (dsp: DSP) => void;
onDisconnect: (dsp: DSP) => void; onDisconnect: (dsp: DSP) => void;
actions?: React.ReactNode;
}) { }) {
const isConnected = dsp.status === 'connected'; const isDspConnected = isConnected(dsp);
const handleTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { const handleTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -58,24 +60,36 @@ function Tab({
<button <button
type="button" type="button"
className="tab-led" className="tab-led"
aria-label={isConnected ? `Disconnect ${dsp.name}` : `Connect ${dsp.name}`} aria-label={isDspConnected ? `Disconnect ${dsp.name}` : `Connect ${dsp.name}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (isConnected) { onSelect();
if (isDspConnected) {
onDisconnect(dsp); onDisconnect(dsp);
} else { } else {
onConnect(dsp); onConnect(dsp);
} }
}} }}
> >
<Led status={dsp.status} /> <Led
status={
dsp.isConnecting
? 'connecting'
: isConnected(dsp)
? 'connected'
: 'disconnected'
}
/>
</button> </button>
<span className="tab-name" title={dsp.name}> <span className="tab-name" title={dsp.name}>
{dsp.name} {dsp.name}
</span> </span>
{actions}
<button <button
type="button" type="button"
className="tab-close" className="tab-close"
@@ -146,3 +146,26 @@
font-family: inherit; font-family: inherit;
color: inherit; color: inherit;
} }
.gauge-viewport.is-disabled {
opacity: 0.45;
filter: grayscale(1);
}
.gauge-viewport.is-disabled .slider {
cursor: not-allowed;
}
.gauge-viewport.is-disabled .thumb {
border-color: var(--border-hairline);
box-shadow: none;
}
.gauge-viewport.is-disabled .value {
color: var(--border-hairline);
box-shadow: none;
}
.value input:disabled {
cursor: not-allowed;
}
@@ -38,6 +38,9 @@ type VerticalSliderProps = {
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
/** Greys out the slider and blocks all interaction. */
disabled?: boolean;
}; };
const defaultStep: StepFn = () => 0.1; const defaultStep: StepFn = () => 0.1;
@@ -174,6 +177,7 @@ export default function VerticalSlider({
formatValue, formatValue,
showTicks = true, showTicks = true,
scale = 'linear', scale = 'linear',
disabled = false,
className, className,
style, style,
}: VerticalSliderProps) { }: VerticalSliderProps) {
@@ -240,6 +244,26 @@ export default function VerticalSlider({
return () => observer.disconnect(); 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(() => { const decimals = useMemo(() => {
if (listValues) return Math.min(3, getDecimals(displayValue)); if (listValues) return Math.min(3, getDecimals(displayValue));
return Math.min(3, getDecimals(step(displayValue))); return Math.min(3, getDecimals(step(displayValue)));
@@ -273,6 +297,8 @@ export default function VerticalSlider({
} }
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) { function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (disabled) return;
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
return; return;
} }
@@ -307,6 +333,8 @@ export default function VerticalSlider({
} }
async function handleKeyUp() { async function handleKeyUp() {
if (disabled) return;
if (keyboardValue.current == null) return; if (keyboardValue.current == null) return;
const next = keyboardValue.current; const next = keyboardValue.current;
@@ -323,6 +351,7 @@ export default function VerticalSlider({
} }
function handleWheel(e: WheelEvent) { function handleWheel(e: WheelEvent) {
if (disabled) return;
e.preventDefault(); e.preventDefault();
wheelEditing.current = true; wheelEditing.current = true;
@@ -393,6 +422,8 @@ export default function VerticalSlider({
} }
function startDrag(e: React.PointerEvent<HTMLDivElement>) { function startDrag(e: React.PointerEvent<HTMLDivElement>) {
if (disabled) return;
const target = e.currentTarget; const target = e.currentTarget;
dragging.current = true; dragging.current = true;
target.setPointerCapture(e.pointerId); target.setPointerCapture(e.pointerId);
@@ -427,6 +458,11 @@ export default function VerticalSlider({
} }
async function commitEditing() { async function commitEditing() {
if (disabled) {
setEditingText(null);
return;
}
if (editingText !== null && editingText !== '' && editingText !== '-') { if (editingText !== null && editingText !== '' && editingText !== '-') {
const parsed = Number(editingText); const parsed = Number(editingText);
@@ -452,7 +488,10 @@ export default function VerticalSlider({
const percent = valueToPercent(displayValue, min, max, scale, listValues); const percent = valueToPercent(displayValue, min, max, scale, listValues);
return ( return (
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}> <div
className={['gauge-viewport', disabled && 'is-disabled', className].filter(Boolean).join(' ')}
style={style}
>
<div <div
className={['gauge-track', !showTicks && 'no-ticks'].filter(Boolean).join(' ')} className={['gauge-track', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}
ref={trackRef} ref={trackRef}
@@ -468,7 +507,12 @@ export default function VerticalSlider({
<div <div
className="slider" className="slider"
ref={sliderRef} ref={sliderRef}
tabIndex={0} role="slider"
aria-disabled={disabled}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={displayValue}
tabIndex={disabled ? -1 : 0}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
onPointerDown={startDrag} onPointerDown={startDrag}
@@ -494,6 +538,7 @@ export default function VerticalSlider({
ref={inputRef} ref={inputRef}
type="text" type="text"
inputMode="decimal" inputMode="decimal"
disabled={disabled}
value={editingText ?? formattedValue} value={editingText ?? formattedValue}
style={{ width: '100%' }} style={{ width: '100%' }}
onFocus={() => setEditingText(value.toFixed(decimals))} // plain number while editing onFocus={() => setEditingText(value.toFixed(decimals))} // plain number while editing
@@ -1,5 +1,5 @@
/* InputSection.css */ /* InputSection.css */
.input-graph-section { .channel-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth)); flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0; min-height: 0;
@@ -15,7 +15,8 @@
-webkit-user-select: none; -webkit-user-select: none;
} }
.input-graph-panel { .channel-graph-panel {
position: relative;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
@@ -27,7 +28,28 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
.input-graph__title { .channel-bypass-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 2;
}
.channel-bypass-overlay span {
font-size: 8cqh;
font-weight: 800;
letter-spacing: 0.15em;
color: #f87171;
text-shadow: 0 0 12px rgba(0, 0, 0, 0.6);
transform: rotate(-8deg);
opacity: 0.85;
user-select: none;
}
.channel-graph__title {
flex: 0 0 auto; flex: 0 0 auto;
text-align: center; text-align: center;
@@ -38,7 +60,7 @@
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
} }
.input-graph-panel > .container { .channel-graph-panel > .container {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
@@ -46,7 +68,7 @@
/* Bottom control bar — same pattern as .compressor-section: absolute, /* Bottom control bar — same pattern as .compressor-section: absolute,
bottom-anchored, panels flow in a row and scroll horizontally instead bottom-anchored, panels flow in a row and scroll horizontally instead
of squeezing into a fixed max-width. */ of squeezing into a fixed max-width. */
.input-control-section { .channel-control-section {
container-type: size; container-type: size;
position: absolute; position: absolute;
left: 0; left: 0;
@@ -71,7 +93,7 @@
-webkit-user-select: none; -webkit-user-select: none;
} }
.input-control-panel { .channel-control-panel {
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: column;
height: 100%; height: 100%;
@@ -84,9 +106,9 @@
} }
/* Peq Sliders */ /* Peq Sliders */
.input-panel { .channel-panel {
position: relative; position: relative;
height: 10; height: 100%;
aspect-ratio: 0.8; aspect-ratio: 0.8;
flex: 0 0 auto; flex: 0 0 auto;
@@ -102,7 +124,7 @@
background: var(--bg-void); background: var(--bg-void);
} }
.input-panel__title { .channel-panel__title {
flex: 0 0 auto; flex: 0 0 auto;
width: 100%; width: 100%;
text-align: center; text-align: center;
@@ -118,7 +140,7 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.input-panel__grid { .channel-panel__grid {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
@@ -132,7 +154,7 @@
box-sizing: border-box; box-sizing: border-box;
} }
.input-panel__cell { .channel-panel__cell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -142,7 +164,7 @@
gap: 1cqh; gap: 1cqh;
} }
.input-panel__cell-label { .channel-panel__cell-label {
flex: 0 0 auto; flex: 0 0 auto;
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 3.2cqh; font-size: 3.2cqh;
@@ -200,11 +222,20 @@
} }
.crossover-panel__select { .crossover-panel__select {
flex: 0 0 auto;
width: 85%;
font-family: var(--font-ui);
font-size: 4cqh; font-size: 4cqh;
} }
.crossover-panel__select .choice-select__input { .crossover-panel__select .choice-select__input,
.crossover-panel__select .choice-select__input option {
height: 100%;
padding: 0;
padding-left: 2cqh;
font-size: 4cqh; font-size: 4cqh;
color: var(--accent-brand);
border-radius: var(--radius-sm);
} }
.peq-band-list { .peq-band-list {
@@ -223,17 +254,16 @@
padding: 1cqh; padding: 1cqh;
overflow: hidden; overflow: hidden;
aspect-ratio: 2;
} }
.peq-band-list__row { .peq-band-list__row {
flex: 1 1 0; flex: 1 1 auto;
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
display: grid; display: grid;
grid-template-columns: 1fr 1.2fr 1fr 1fr 1.6fr 1.2fr; grid-template-columns: 0.4fr 0.55fr 0.4fr 0.5fr 1.2fr 0.5fr;
align-items: stretch; align-items: stretch;
gap: 1cqh; gap: 1cqh;
@@ -307,14 +337,14 @@
font-size: 4cqh; font-size: 4cqh;
} }
.input-graph-panel__magnitude-wrap { .channel-graph-panel__magnitude-wrap {
flex: 1 1 0; flex: 1 1 0;
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
position: relative; position: relative;
} }
.input-graph__title-row { .channel-graph__title-row {
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -322,11 +352,11 @@
padding: 1cqh 1.5cqh; padding: 1cqh 1.5cqh;
} }
.input-graph__title-row .input-graph__title { .channel-graph__title-row .channel-graph__title {
padding: 0; /* was on the standalone title element; row now owns spacing */ padding: 0; /* was on the standalone title element; row now owns spacing */
} }
.input-graph-view-toggle { .channel-graph-view-toggle {
display: flex; display: flex;
gap: 0.4cqh; gap: 0.4cqh;
border: var(--border-width) solid var(--border-hairline); border: var(--border-width) solid var(--border-hairline);
@@ -335,7 +365,7 @@
background: var(--bg-void); background: var(--bg-void);
} }
.input-graph-view-toggle__btn { .channel-graph-view-toggle__btn {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 2cqh; font-size: 2cqh;
padding: 0.6cqh 1.2cqh; padding: 0.6cqh 1.2cqh;
@@ -346,8 +376,8 @@
cursor: pointer; cursor: pointer;
} }
.input-graph-view-toggle__btn.active { .channel-graph-view-toggle__btn.active {
background: var(--accent, #4dd0e1); background: var(--accent-brand);
color: var(--bg-void); color: var(--bg-void);
} }
@@ -361,4 +391,9 @@
color: var(--accent-brand); color: var(--accent-brand);
background: transparent; background: transparent;
text-align: center;
text-align-last: center;
padding: 0;
margin: 0;
} }
@@ -1,4 +1,4 @@
// InputSection.tsx // ChannelSection.tsx
import VerticalSlider, { import VerticalSlider, {
makeLogStep, makeLogStep,
makeLogQuantize, makeLogQuantize,
@@ -11,13 +11,15 @@ import {
PEQ_FILTERS_LABEL, PEQ_FILTERS_LABEL,
PEQ_Q_RANGES, PEQ_Q_RANGES,
PEAK_Q_VALUES, PEAK_Q_VALUES,
PEQ_Q_UNUSED,
PEQ_GAIN_USED,
} from '../../constants'; } from '../../constants';
import { InputChannel, Channel, PEQBand, PEQFilter, PEQ, Crossover } from '../../types'; import { Channel, PEQBand, PEQFilter, PEQ, Crossover } from '../../types';
import { DSP408 as DSP } from '../../../../types'; import { DSP408 as DSP } from '../../../../types';
import { CrossoverFilter } from '../../types'; import { CrossoverFilter } from '../../types';
import { LinearGraph, LinearGraphHandle } from '../../../../components/LinearGraph/LinearGraph'; import { LinearGraph, LinearGraphHandle } from '../../../../components/LinearGraph/LinearGraph';
import { useRef, useState } from 'react'; import { useRef, useState, useEffect } from 'react';
import './InputSection.css'; import './ChannelSection.css';
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup'; import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import Button from '../../../../components/Button/Button'; import Button from '../../../../components/Button/Button';
import { import {
@@ -50,12 +52,12 @@ function GraphViewToggle({
]; ];
return ( return (
<div className="input-graph-view-toggle"> <div className="channel-graph-view-toggle">
{options.map((opt) => ( {options.map((opt) => (
<button <button
key={opt.value} key={opt.value}
type="button" type="button"
className={`input-graph-view-toggle__btn${view === opt.value ? ' active' : ''}`} className={`channel-graph-view-toggle__btn${view === opt.value ? ' active' : ''}`}
onClick={() => onChange(opt.value)} onClick={() => onChange(opt.value)}
> >
{opt.label} {opt.label}
@@ -65,7 +67,7 @@ function GraphViewToggle({
); );
} }
function InputActions({ function ChannelActions({
bypass, bypass,
resetting, resetting,
onBypass, onBypass,
@@ -128,6 +130,8 @@ function PeqPanelSliders({
onLiveValue: (value: PEQ) => void; onLiveValue: (value: PEQ) => void;
}) { }) {
const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type]; const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
const qDisabled = PEQ_Q_UNUSED.has(peq.filter_type);
const gainDisabled = !PEQ_GAIN_USED.has(peq.filter_type);
const commit = (overrides: Partial<PEQ>) => { const commit = (overrides: Partial<PEQ>) => {
const next = { ...peq, ...overrides }; const next = { ...peq, ...overrides };
@@ -135,11 +139,11 @@ function PeqPanelSliders({
}; };
return ( return (
<div className="input-panel"> <div className="channel-panel">
<div className="input-panel__title">PEQ Parameters</div> <div className="channel-panel__title">PEQ Parameters</div>
<div className="input-panel__grid"> <div className="channel-panel__grid">
<div className="input-panel__cell input-panel__cell--frequency"> <div className="channel-panel__cell channel-panel__cell--frequency">
<VerticalSlider <VerticalSlider
min={FREQUENCY_MIN} min={FREQUENCY_MIN}
max={FREQUENCY_MAX} max={FREQUENCY_MAX}
@@ -156,10 +160,10 @@ function PeqPanelSliders({
style={{ width: '100%', flex: '1 1 auto' }} style={{ width: '100%', flex: '1 1 auto' }}
/> />
<div className="input-panel__cell-label">Frequency</div> <div className="channel-panel__cell-label">Frequency</div>
</div> </div>
<div className="input-panel__cell input-panel__cell--q"> <div className="channel-panel__cell channel-panel__cell--q">
<VerticalSlider <VerticalSlider
min={qMin} min={qMin}
max={qMax} max={qMax}
@@ -168,13 +172,14 @@ function PeqPanelSliders({
value={peq.q} value={peq.q}
onChange={(value) => commit({ q: value })} onChange={(value) => commit({ q: value })}
onInput={(value) => onLiveValue({ ...peq, q: value })} onInput={(value) => onLiveValue({ ...peq, q: value })}
disabled={qDisabled}
style={{ width: '100%', flex: '1 1 auto' }} style={{ width: '100%', flex: '1 1 auto' }}
/> />
<div className="input-panel__cell-label">Q</div> <div className="channel-panel__cell-label">Q</div>
</div> </div>
<div className="input-panel__cell input-panel__cell--gain"> <div className="channel-panel__cell channel-panel__cell--gain">
<VerticalSlider <VerticalSlider
min={-12} min={-12}
max={12} max={12}
@@ -184,10 +189,11 @@ function PeqPanelSliders({
formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))} formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))}
onChange={(value) => commit({ gain: value })} onChange={(value) => commit({ gain: value })}
onInput={(value) => onLiveValue({ ...peq, gain: value })} onInput={(value) => onLiveValue({ ...peq, gain: value })}
disabled={gainDisabled}
style={{ width: '100%', flex: '1 1 auto' }} style={{ width: '100%', flex: '1 1 auto' }}
/> />
<div className="input-panel__cell-label">Gain</div> <div className="channel-panel__cell-label">Gain</div>
</div> </div>
</div> </div>
</div> </div>
@@ -295,6 +301,7 @@ function PeqBandList({
activeBand, activeBand,
setActiveBand, setActiveBand,
setPEQ, setPEQ,
bandsOrder,
}: { }: {
bands: Record<PEQBand, PEQ>; bands: Record<PEQBand, PEQ>;
liveBands: Partial<Record<PEQBand, PEQ>>; liveBands: Partial<Record<PEQBand, PEQ>>;
@@ -308,6 +315,7 @@ function PeqBandList({
filterType: PEQFilter, filterType: PEQFilter,
bypass: boolean bypass: boolean
) => Promise<boolean>; ) => Promise<boolean>;
bandsOrder: PEQBand[];
}) { }) {
const commit = (band: PEQBand, overrides: Partial<PEQ>) => { const commit = (band: PEQBand, overrides: Partial<PEQ>) => {
const peq = liveBands[band] ?? bands[band]; const peq = liveBands[band] ?? bands[band];
@@ -326,13 +334,16 @@ function PeqBandList({
<div className="peq-band-list__cell">Bypass</div> <div className="peq-band-list__cell">Bypass</div>
</div> </div>
{PEQ_BANDS_ORDER.map((band, i) => { {bandsOrder.map((band, i) => {
const peq = liveBands[band] ?? bands[band]; const peq = liveBands[band] ?? bands[band];
const isActive = band === activeBand; const isActive = band === activeBand;
const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type]; const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
const qValues = PEAK_Q_VALUES.filter((q) => q >= qMin && q <= qMax); const qValues = PEAK_Q_VALUES.filter((q) => q >= qMin && q <= qMax);
const qDisabled = PEQ_Q_UNUSED.has(peq.filter_type);
const gainDisabled = !PEQ_GAIN_USED.has(peq.filter_type);
return ( return (
<div <div
className={`peq-band-list__row${isActive ? ' peq-band-list__row--active' : ''}`} className={`peq-band-list__row${isActive ? ' peq-band-list__row--active' : ''}`}
@@ -368,6 +379,7 @@ function PeqBandList({
value={peq.q} value={peq.q}
values={qValues} values={qValues}
formatLabel={(v) => v.toFixed(2)} formatLabel={(v) => v.toFixed(2)}
disabled={qDisabled}
onChange={(value) => commit(band, { q: value })} onChange={(value) => commit(band, { q: value })}
/> />
@@ -378,6 +390,7 @@ function PeqBandList({
max={12} max={12}
step={0.1} step={0.1}
formatLabel={(v) => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`} formatLabel={(v) => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`}
disabled={gainDisabled}
onChange={(value) => commit(band, { gain: value })} onChange={(value) => commit(band, { gain: value })}
/> />
@@ -395,7 +408,7 @@ function PeqBandList({
variant="toggle" variant="toggle"
className={peq.bypass ? 'active-warning' : ''} className={peq.bypass ? 'active-warning' : ''}
onClick={() => commit(band, { bypass: !peq.bypass })} onClick={() => commit(band, { bypass: !peq.bypass })}
style={{ minHeight: 0 }} style={{ minHeight: 0, fontSize: '4cqh', paddingInline: 0 }}
> >
{peq.bypass ? 'Bypassed' : 'Bypass'} {peq.bypass ? 'Bypassed' : 'Bypass'}
</Button> </Button>
@@ -412,7 +425,7 @@ type SetFilterFn = (
filterType: CrossoverFilter filterType: CrossoverFilter
) => Promise<boolean>; ) => Promise<boolean>;
function InputSection({ function ChannelSection({
dsp, dsp,
channel, channel,
setHighPass, setHighPass,
@@ -421,7 +434,7 @@ function InputSection({
setPeqBypass, setPeqBypass,
}: { }: {
dsp: DSP; dsp: DSP;
channel: InputChannel; channel: Channel;
setHighPass: SetFilterFn; setHighPass: SetFilterFn;
setLowPass: SetFilterFn; setLowPass: SetFilterFn;
setPeq: ( setPeq: (
@@ -439,6 +452,7 @@ function InputSection({
const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1); const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1);
const [graphView, setGraphView] = useState<GraphView>('both'); const [graphView, setGraphView] = useState<GraphView>('both');
const [resetting, setResetting] = useState(false); const [resetting, setResetting] = useState(false);
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
// Live values // Live values
const [liveHpFreq, setLiveHpFreq] = useState<number>(FREQUENCY_MIN); const [liveHpFreq, setLiveHpFreq] = useState<number>(FREQUENCY_MIN);
@@ -448,18 +462,42 @@ function InputSection({
const magnitudeGraphRef = useRef<LinearGraphHandle>(null); const magnitudeGraphRef = useRef<LinearGraphHandle>(null);
// DSP values // DSP values
const activeInputState = dsp.state.current_config.input_states[channel]; const isInput = 'Input' in channel;
const ActiveHighPassFilter = activeInputState.crossover.high_pass;
const ActiveLowPassFilter = activeInputState.crossover.low_pass; const activeChannelState = isInput
const ActivePeq = activeInputState.peq_chain; ? dsp.state.current_config.input_states[channel.Input]
: dsp.state.current_config.output_states[channel.Output];
const ActiveHighPassFilter = activeChannelState.crossover.high_pass;
const ActiveLowPassFilter = activeChannelState.crossover.low_pass;
const ActivePeq = activeChannelState.peq_chain;
const bandsOrder = isInput ? PEQ_BANDS_ORDER.slice(0, -1) : PEQ_BANDS_ORDER;
useEffect(() => {
if (!isResetConfirmOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsResetConfirmOpen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isResetConfirmOpen]);
const handleResetClick = () => {
setIsResetConfirmOpen(true);
};
const handleResetPeq = async () => { const handleResetPeq = async () => {
if (resetting) return; if (resetting) return;
setResetting(true); setResetting(true);
for (const band of PEQ_BANDS_ORDER) { for (const band of bandsOrder) {
const current = displayPeq[band]; const current = displayPeq[band];
if (current.gain === 0 || current.filter_type === PEQFilter.Peak) continue; // skip bands already flat // Skip only if already flat — nothing to change.
if (current.gain === 0 && current.filter_type === PEQFilter.Peak) continue;
await commitPEQ(band, 0, current.frequency, current.q, PEQFilter.Peak, current.bypass); await commitPEQ(band, 0, current.frequency, current.q, PEQFilter.Peak, current.bypass);
} }
@@ -467,8 +505,13 @@ function InputSection({
setResetting(false); setResetting(false);
}; };
const confirmResetPeq = async () => {
setIsResetConfirmOpen(false);
await handleResetPeq();
};
const handleBypassClick = () => { const handleBypassClick = () => {
setPeqBypass({ Input: channel }, !ActivePeq.bypass); setPeqBypass(channel, !ActivePeq.bypass);
}; };
// Live handlers // Live handlers
@@ -489,7 +532,7 @@ function InputSection({
// Real dsp value handlers // Real dsp value handlers
const commitHighPass = async (freq: number, filterType: CrossoverFilter) => { const commitHighPass = async (freq: number, filterType: CrossoverFilter) => {
const success = await setHighPass({ Input: channel }, freq, filterType); const success = await setHighPass(channel, freq, filterType);
if (success) { if (success) {
setLiveHpFreq(freq); setLiveHpFreq(freq);
} }
@@ -497,7 +540,7 @@ function InputSection({
}; };
const commitLowPass = async (freq: number, filterType: CrossoverFilter) => { const commitLowPass = async (freq: number, filterType: CrossoverFilter) => {
const success = await setLowPass({ Input: channel }, freq, filterType); const success = await setLowPass(channel, freq, filterType);
if (success) { if (success) {
setLiveLpFreq(freq); setLiveLpFreq(freq);
} }
@@ -512,7 +555,7 @@ function InputSection({
filter_type: PEQFilter, filter_type: PEQFilter,
bypass: boolean bypass: boolean
) => { ) => {
const success = await setPeq({ Input: channel }, band, gain, frequency, q, filter_type, bypass); const success = await setPeq(channel, band, gain, frequency, q, filter_type, bypass);
if (success) { if (success) {
setLivePeq((prev) => { setLivePeq((prev) => {
const next = { ...prev }; const next = { ...prev };
@@ -536,15 +579,15 @@ function InputSection({
return ( return (
<> <>
<div className="input-graph-section"> <div className="channel-graph-section">
<div className="input-graph-panel"> <div className="channel-graph-panel">
<div className="input-graph__title-row"> <div className="channel-graph__title-row">
<div className="input-graph__title">{activeInputState.name} - Filters</div> <div className="channel-graph__title">{activeChannelState.name} - Filters</div>
<GraphViewToggle view={graphView} onChange={setGraphView} /> <GraphViewToggle view={graphView} onChange={setGraphView} />
</div> </div>
{(graphView === 'both' || graphView === 'magnitude') && ( {(graphView === 'both' || graphView === 'magnitude') && (
<div className="input-graph-panel__magnitude-wrap"> <div className="channel-graph-panel__magnitude-wrap">
<LinearGraph <LinearGraph
ref={magnitudeGraphRef} ref={magnitudeGraphRef}
xMin={GRAPH_X_MIN} xMin={GRAPH_X_MIN}
@@ -623,11 +666,17 @@ function InputSection({
}} }}
/> />
)} )}
{ActivePeq.bypass && (
<div className="channel-bypass-overlay">
<span>BYPASSED</span>
</div>
)}
</div> </div>
</div> </div>
<div className="input-control-section"> <div className="channel-control-section">
<div className="input-control-panel"> <div className="channel-control-panel">
<ChannelGroup title="PEQ"> <ChannelGroup title="PEQ">
<PeqBandList <PeqBandList
bands={ActivePeq.bands} bands={ActivePeq.bands}
@@ -635,6 +684,7 @@ function InputSection({
activeBand={activeBand} activeBand={activeBand}
setActiveBand={setActiveBand} setActiveBand={setActiveBand}
setPEQ={commitPEQ} setPEQ={commitPEQ}
bandsOrder={bandsOrder}
/> />
<PeqPanelSliders <PeqPanelSliders
peq={ActivePeq.bands[activeBand]} peq={ActivePeq.bands[activeBand]}
@@ -661,17 +711,38 @@ function InputSection({
</ChannelGroup> </ChannelGroup>
<ChannelGroup title="Actions"> <ChannelGroup title="Actions">
<InputActions <ChannelActions
bypass={ActivePeq.bypass} bypass={ActivePeq.bypass}
resetting={resetting} resetting={resetting}
onBypass={handleBypassClick} onBypass={handleBypassClick}
onReset={handleResetPeq} onReset={handleResetClick}
/> />
</ChannelGroup> </ChannelGroup>
</div> </div>
</div> </div>
{isResetConfirmOpen && (
<div className="confirm-modal-overlay" onClick={() => setIsResetConfirmOpen(false)}>
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<h3>Reset {activeChannelState.name} PEQ?</h3>
<p>
This will set all PEQ bands on {activeChannelState.name} back to flat (0 dB, Peak).
This action cannot be undone.
</p>
<div className="confirm-modal__actions">
<Button variant="outline" onClick={() => setIsResetConfirmOpen(false)}>
Cancel
</Button>
<Button variant="primary" onClick={confirmResetPeq}>
Reset
</Button>
</div>
</div>
</div>
)}
</> </>
); );
} }
export default InputSection; export default ChannelSection;
@@ -1,4 +1,4 @@
import { DSP } from '../../../../types'; import { DSP, isConnected } from '../../../../types';
import Button from '../../../../components/Button/Button'; import Button from '../../../../components/Button/Button';
import Card from '../../../../components/Card/Card'; import Card from '../../../../components/Card/Card';
import Led from '../../../../components/Led/Led'; import Led from '../../../../components/Led/Led';
@@ -14,13 +14,14 @@ function ConnectionPanel({
onConnect: () => void; onConnect: () => void;
onDisconnect: () => void; onDisconnect: () => void;
}) { }) {
const statusLabel =
dsp.status === 'connected'
? 'Connected'
: dsp.status === 'connecting'
? 'Connecting…'
: 'Disconnected';
const statusLabel =
isConnected(dsp)
? 'Connected'
: dsp.isConnecting
? 'Connecting…'
: 'Disconnected';
return ( return (
<div className="connection-section"> <div className="connection-section">
<Card className="connection-panel"> <Card className="connection-panel">
@@ -35,10 +36,10 @@ function ConnectionPanel({
<div className="connection-panel__status"> <div className="connection-panel__status">
<span className="connection-panel__led"> <span className="connection-panel__led">
<Led status={dsp.status} /> <Led status={dsp.isConnecting ? "connecting" : isConnected(dsp) ? "connected" : "disconnected"} />
</span> </span>
<span className={`status-label status-label--${dsp.status}`}>{statusLabel}</span> <span>{statusLabel}</span>
</div> </div>
</div> </div>
@@ -64,13 +65,13 @@ function ConnectionPanel({
</dl> </dl>
<div className="connection-panel__actions"> <div className="connection-panel__actions">
{dsp.status === 'connected' ? ( {isConnected(dsp) ? (
<Button variant="outline" onClick={onDisconnect}> <Button variant="outline" onClick={onDisconnect}>
Disconnect Disconnect
</Button> </Button>
) : ( ) : (
<Button variant="primary" onClick={onConnect} disabled={dsp.status === 'connecting'}> <Button variant="primary" onClick={onConnect} disabled={dsp.isConnecting}>
{dsp.status === 'connecting' ? 'Connecting…' : 'Connect'} {dsp.isConnecting ? 'Connecting…' : 'Connect'}
</Button> </Button>
)} )}
</div> </div>
@@ -29,3 +29,9 @@
.app-root:has(.sidebar-rail--expanded) .dsp-loading-overlay { .app-root:has(.sidebar-rail--expanded) .dsp-loading-overlay {
left: var(--sidebar-width-expanded); left: var(--sidebar-width-expanded);
} }
.app-root {
position: relative;
width: 100%;
height: 100%;
}
@@ -1,6 +1,17 @@
import { useState } from 'react'; import { useEffect, useState, type SetStateAction } from 'react';
import { Sidebar, SidebarItem } from '../../../../components/Sidebar/Sidebar'; import { Sidebar, SidebarItem } from '../../../../components/Sidebar/Sidebar';
import { InfoIcon, KnobGainIcon, TextIcon } from '../../../../assets/icons/AudioIcons'; import {
BoltIcon,
CompressorIcon,
DelayIcon,
GainIcon,
GateIcon,
InfoIcon,
LimiterIcon,
MatrixIcon,
SlidersVerticalIcon,
TextIcon,
} from '../../../../assets/icons/AudioIcons';
import ConnectionPanel from '../ConnectionSection/ConnectionSection'; import ConnectionPanel from '../ConnectionSection/ConnectionSection';
import GainSection from '../GainSection/GainSection'; import GainSection from '../GainSection/GainSection';
import CompressorSection from '../CompressorSection/CompressorSection'; import CompressorSection from '../CompressorSection/CompressorSection';
@@ -11,91 +22,78 @@ import LoadingOverlay from '../../../../components/LoadingOverlay/LoadingOverlay
import { InputChannel, OutputChannel } from '../../types'; import { InputChannel, OutputChannel } from '../../types';
import MatrixSection from '../Matrixsection/Matrixsection'; import MatrixSection from '../Matrixsection/Matrixsection';
import GeqSection from '../GeqSection/GeqSection'; import GeqSection from '../GeqSection/GeqSection';
import InputSection from '../InputSection/InputSection'; import ChannelSection from '../ChannelSection/ChannelSection';
import { DSP408Api } from '../../dsp408'; import { DSP408Api } from '../../dsp408';
import { DSP408 } from '../../../../types'; import { DSP408 } from '../../../../types';
import { SidebarId } from '../../constants'; import { SidebarId } from '../../constants';
import './DSP408Panel.css'; import './DSP408Panel.css';
import MiscellaneousPanel from '../MiscellaneousSection/MiscellaneousSection';
import { PasswordDialog } from '../PasswordDialog/PasswordDialog';
const activeSidebarByDsp = new Map<number, SidebarId>();
function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) { function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
const [activeSidebar, setActiveSidebar] = useState<SidebarId>('overview');
const [sidebarExpanded, setSidebarExpanded] = useState(false); const [sidebarExpanded, setSidebarExpanded] = useState(false);
const [selectedPreset, setSelectedPreset] = useState(dsp.state?.presets.current_index ?? 0); const [selectedPreset, setSelectedPreset] = useState(dsp.state.presets.current_index);
const [presetLoading, setPresetLoading] = useState(false); const [presetLoading, setPresetLoading] = useState(false);
const inputStates = dsp.state?.current_config.input_states;
const outputStates = dsp.state?.current_config.output_states; const inputStates = dsp.state.current_config.input_states;
const outputStates = dsp.state.current_config.output_states;
const sidebarItems: SidebarItem<SidebarId>[] = [ const sidebarItems: SidebarItem<SidebarId>[] = [
{ id: 'overview', label: 'Overview', icon: <InfoIcon /> }, { id: 'overview', label: 'Overview', icon: <InfoIcon /> },
{ id: 'gain', label: 'Gain', icon: <KnobGainIcon /> }, { id: 'gain', label: 'Gain', icon: <GainIcon /> },
{ id: 'gate', label: 'Gate', icon: <KnobGainIcon /> }, { id: 'gate', label: 'Gate', icon: <GateIcon /> },
{ id: 'compressor', label: 'Compressor', icon: <KnobGainIcon /> }, { id: 'compressor', label: 'Compressor', icon: <CompressorIcon /> },
{ id: 'limiter', label: 'Limiter', icon: <KnobGainIcon /> }, { id: 'limiter', label: 'Limiter', icon: <LimiterIcon /> },
{ id: 'delay', label: 'Delay', icon: <KnobGainIcon /> }, { id: 'delay', label: 'Delay', icon: <DelayIcon /> },
{ id: 'matrix', label: 'Matrix', icon: <KnobGainIcon /> }, { id: 'matrix', label: 'Matrix', icon: <MatrixIcon /> },
{ id: 'geq', label: 'Graphic Equalizer', icon: <KnobGainIcon /> }, { id: 'geq', label: 'Graphic Equalizer', icon: <SlidersVerticalIcon /> },
{ id: 'ina', label: `${inputStates[InputChannel.InA].name}`, icon: <TextIcon>In A</TextIcon> }, { id: 'ina', label: inputStates[InputChannel.InA].name, icon: <TextIcon>In A</TextIcon>, editable: true },
{ id: 'inb', label: `${inputStates[InputChannel.InB].name}`, icon: <TextIcon>In B</TextIcon> }, { id: 'inb', label: inputStates[InputChannel.InB].name, icon: <TextIcon>In B</TextIcon>, editable: true },
{ id: 'inc', label: `${inputStates[InputChannel.InC].name}`, icon: <TextIcon>In C</TextIcon> }, { id: 'inc', label: inputStates[InputChannel.InC].name, icon: <TextIcon>In C</TextIcon>, editable: true },
{ id: 'ind', label: `${inputStates[InputChannel.InD].name}`, icon: <TextIcon>In D</TextIcon> }, { id: 'ind', label: inputStates[InputChannel.InD].name, icon: <TextIcon>In D</TextIcon>, editable: true },
{ { id: 'out1', label: outputStates[OutputChannel.Out1].name, icon: <TextIcon>Out 1</TextIcon>, editable: true },
id: 'out1', { id: 'out2', label: outputStates[OutputChannel.Out2].name, icon: <TextIcon>Out 2</TextIcon>, editable: true },
label: `${outputStates[OutputChannel.Out1].name}`, { id: 'out3', label: outputStates[OutputChannel.Out3].name, icon: <TextIcon>Out 3</TextIcon>, editable: true },
icon: <TextIcon>Out 1</TextIcon>, { id: 'out4', label: outputStates[OutputChannel.Out4].name, icon: <TextIcon>Out 4</TextIcon>, editable: true },
}, { id: 'out5', label: outputStates[OutputChannel.Out5].name, icon: <TextIcon>Out 5</TextIcon>, editable: true },
{ { id: 'out6', label: outputStates[OutputChannel.Out6].name, icon: <TextIcon>Out 6</TextIcon>, editable: true },
id: 'out2', { id: 'out7', label: outputStates[OutputChannel.Out7].name, icon: <TextIcon>Out 7</TextIcon>, editable: true },
label: `${outputStates[OutputChannel.Out2].name}`, { id: 'out8', label: outputStates[OutputChannel.Out8].name, icon: <TextIcon>Out 8</TextIcon>, editable: true },
icon: <TextIcon>Out 2</TextIcon>, { id: 'miscellaneous', label: 'Miscellaneous', icon: <BoltIcon /> },
},
{
id: 'out3',
label: `${outputStates[OutputChannel.Out3].name}`,
icon: <TextIcon>Out 3</TextIcon>,
},
{
id: 'out4',
label: `${outputStates[OutputChannel.Out4].name}`,
icon: <TextIcon>Out 4</TextIcon>,
},
{
id: 'out5',
label: `${outputStates[OutputChannel.Out5].name}`,
icon: <TextIcon>Out 5</TextIcon>,
},
{
id: 'out6',
label: `${outputStates[OutputChannel.Out6].name}`,
icon: <TextIcon>Out 6</TextIcon>,
},
{
id: 'out7',
label: `${outputStates[OutputChannel.Out7].name}`,
icon: <TextIcon>Out 7</TextIcon>,
},
{
id: 'out8',
label: `${outputStates[OutputChannel.Out8].name}`,
icon: <TextIcon>Out 8</TextIcon>,
},
{ id: 'miscellaneous', label: 'Miscellaneous', icon: <TextIcon>Out 1</TextIcon> },
]; ];
const presets = dsp.state?.presets.names const presets = [
? [ { id: 'f00', name: 'Factory Preset' },
{ id: 'f00', name: 'Factory Preset' }, ...(dsp.state.presets.names.map((name, index) => ({
...dsp.state.presets.names.map((name, index) => ({ id: `u${String(index + 1).padStart(2, '0')}`,
id: `u${String(index + 1).padStart(2, '0')}`, name,
name, })) ??
})), Array.from({ length: 20 }, (_, index) => ({
] id: `u${String(index + 1).padStart(2, '0')}`,
: [ name: 'Default Preset',
{ id: 'f00', name: 'Factory Preset' }, }))),
...Array.from({ length: 20 }, (_, index) => ({ ];
id: `u${String(index + 1).padStart(2, '0')}`,
name: 'Default Preset', const [activeSidebar, setActiveSidebarState] = useState<SidebarId>(
})), () => activeSidebarByDsp.get(dsp.id) ?? 'overview'
]; );
useEffect(() => {
const saved = activeSidebarByDsp.get(dsp.id) ?? 'overview';
setActiveSidebarState(saved);
}, [dsp.id]);
const setActiveSidebar = (value: SetStateAction<SidebarId>) => {
setActiveSidebarState(prev => {
const next = typeof value === 'function' ? value(prev) : value;
activeSidebarByDsp.set(dsp.id, next);
return next;
});
};
const onAppChange = async (id: string) => { const onAppChange = async (id: string) => {
const presetIndex = presets.findIndex((preset) => preset.id === id); const presetIndex = presets.findIndex((preset) => preset.id === id);
@@ -107,6 +105,43 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
setPresetLoading(false); setPresetLoading(false);
}; };
const onItemRename = async (id: SidebarId, name: string) => {
const channel = channelMap[id];
if (!channel) return;
const padded = name.slice(0, 8).padEnd(8, '\0');
await api.setChannelName(channel, padded);
};
const renderChannel = (channel: { Input: InputChannel } | { Output: OutputChannel }) => (
<ChannelSection
dsp={dsp}
channel={channel}
setLowPass={api.setLowPass}
setHighPass={api.setHighPass}
setPeq={api.setPeqBand}
setPeqBypass={api.setPEQBypass}
/>
);
const channelMap: Partial<
Record<SidebarId, { Input: InputChannel } | { Output: OutputChannel }>
> = {
ina: { Input: InputChannel.InA },
inb: { Input: InputChannel.InB },
inc: { Input: InputChannel.InC },
ind: { Input: InputChannel.InD },
out1: { Output: OutputChannel.Out1 },
out2: { Output: OutputChannel.Out2 },
out3: { Output: OutputChannel.Out3 },
out4: { Output: OutputChannel.Out4 },
out5: { Output: OutputChannel.Out5 },
out6: { Output: OutputChannel.Out6 },
out7: { Output: OutputChannel.Out7 },
out8: { Output: OutputChannel.Out8 },
};
const renderTab = () => { const renderTab = () => {
switch (activeSidebar) { switch (activeSidebar) {
case 'overview': case 'overview':
@@ -134,54 +169,6 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
case 'delay': case 'delay':
return <DelaySection dsp={dsp} setDelay={api.setDelay} setDelayUnit={api.setDelayUnit} />; return <DelaySection dsp={dsp} setDelay={api.setDelay} setDelayUnit={api.setDelayUnit} />;
case 'ina':
return (
<InputSection
dsp={dsp}
channel={InputChannel.InA}
setLowPass={api.setLowPass}
setHighPass={api.setHighPass}
setPeq={api.setPeqBand}
setPeqBypass={api.setPEQBypass}
/>
);
case 'inb':
return (
<InputSection
dsp={dsp}
channel={InputChannel.InB}
setLowPass={api.setLowPass}
setHighPass={api.setHighPass}
setPeq={api.setPeqBand}
setPeqBypass={api.setPEQBypass}
/>
);
case 'inc':
return (
<InputSection
dsp={dsp}
channel={InputChannel.InC}
setLowPass={api.setLowPass}
setHighPass={api.setHighPass}
setPeq={api.setPeqBand}
setPeqBypass={api.setPEQBypass}
/>
);
case 'ind':
return (
<InputSection
dsp={dsp}
channel={InputChannel.InD}
setLowPass={api.setLowPass}
setHighPass={api.setHighPass}
setPeq={api.setPeqBand}
setPeqBypass={api.setPEQBypass}
/>
);
case 'matrix': case 'matrix':
return ( return (
<MatrixSection <MatrixSection
@@ -195,33 +182,58 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
case 'geq': case 'geq':
return <GeqSection dsp={dsp} setGeqBand={api.setGeqBand} setGeqBypass={api.setGeqBypass} />; return <GeqSection dsp={dsp} setGeqBand={api.setGeqBand} setGeqBypass={api.setGeqBypass} />;
default: case 'miscellaneous':
return null; return (
<MiscellaneousPanel
dsp={dsp}
factoryReset={api.factoryReset}
copyChannel={api.copyChannel}
setInputSource={api.setInputSource}
linkChannels={api.linkChannels}
lockDevice={api.lockDevice}
setActiveSidebar={setActiveSidebar}
/>
);
default: {
const channel = channelMap[activeSidebar];
return channel ? renderChannel(channel) : null;
}
} }
}; };
return ( return (
<div className={`app-root ${sidebarExpanded ? 'app-root--sidebar-expanded' : ''}`}> <div className={`app-root ${sidebarExpanded ? 'app-root--sidebar-expanded' : ''}`}>
<Sidebar <Sidebar
items={sidebarItems} items={sidebarItems}
activeId={activeSidebar} activeId={activeSidebar}
onSelect={setActiveSidebar} onSelect={setActiveSidebar}
appName={dsp.name} appName={dsp.name}
appOptions={presets} appOptions={presets}
selectedAppId={presets[selectedPreset]?.id} selectedAppId={presets[selectedPreset]?.id}
onAppChange={onAppChange} onAppChange={onAppChange}
onAppRename={api.setCurrentPresetName} onAppRename={api.setCurrentPresetName}
expanded={sidebarExpanded} onItemRename={onItemRename}
onToggle={() => setSidebarExpanded((v) => !v)} expanded={sidebarExpanded}
/> onToggle={() => setSidebarExpanded((v) => !v)}
/>
<LoadingOverlay <LoadingOverlay
active={presetLoading} active={presetLoading}
label="Recalling preset…" label="Recalling preset…"
overlayClassName="dsp-loading-overlay" overlayClassName="dsp-loading-overlay"
> >
<div className="dsp-content">{renderTab()}</div> <div className="dsp-content">{renderTab()}</div>
</LoadingOverlay> </LoadingOverlay>
{dsp.passwordRequired && (
<PasswordDialog
name={dsp.name}
onSubmit={(pw: string) => api.connectWithPassword(pw)}
onCancel={() => api.cancelPassword()}
/>
)}
</div> </div>
); );
} }
@@ -1,5 +1,5 @@
import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider'; import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider';
import { InputChannel, Channel, OutputChannel } from '../../types'; import { InputChannel, Channel, OutputChannel, CrossoverFilter, PEQBand, PEQ } from '../../types';
import { DSP408 as DSP } from '../../../../types'; import { DSP408 as DSP } from '../../../../types';
import Button from '../../../../components/Button/Button'; import Button from '../../../../components/Button/Button';
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup'; import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
@@ -58,23 +58,33 @@ function getGainCurvePoints(freqs: number[], gainDb: number): CurvePoint[] {
function getPeqCrossoverPoints( function getPeqCrossoverPoints(
freqs: number[], freqs: number[],
peqChain: { bands: Record<string, unknown> }, peqChain: {
bypass: boolean;
bands: Record<PEQBand, PEQ>;
},
crossover: { crossover: {
high_pass: { frequency: number; slope: unknown }; high_pass: { frequency: number; slope: CrossoverFilter };
low_pass: { frequency: number; slope: unknown }; low_pass: { frequency: number; slope: CrossoverFilter };
} }
) { ) {
return sumCurvePoints([ return sumCurvePoints([
getPeqCurvePoints({ freqs, bands: peqChain.bands as any }), ...(peqChain.bypass
? []
: [
getPeqCurvePoints({
freqs,
bands: peqChain.bands,
}),
]),
getCrossoverCurvePoints({ getCrossoverCurvePoints({
freqs, freqs,
highPass: { highPass: {
frequency: crossover.high_pass.frequency, frequency: crossover.high_pass.frequency,
filter_type: crossover.high_pass.slope as any, filter_type: crossover.high_pass.slope,
}, },
lowPass: { lowPass: {
frequency: crossover.low_pass.frequency, frequency: crossover.low_pass.frequency,
filter_type: crossover.low_pass.slope as any, filter_type: crossover.low_pass.slope,
}, },
}), }),
]); ]);
@@ -86,9 +96,19 @@ function getInputChannelCurves(
liveGain: number | undefined liveGain: number | undefined
): ChannelCurves { ): ChannelCurves {
const gain = liveGain ?? state.gain; const gain = liveGain ?? state.gain;
const geqValues = GEQ_BANDS_LABEL.map(({ frequency }) => state.geq.gains[frequency] ?? 0); const geqValues = GEQ_BANDS_LABEL.map(({ frequency }) => state.geq.gains[frequency] ?? 0);
const geq = getGeqCurvePoints({ freqs, values: geqValues, q: GEQ_CURVE_Q });
const geq = state.geq.bypass
? []
: getGeqCurvePoints({
freqs,
values: geqValues,
q: GEQ_CURVE_Q,
});
const peqCrossover = getPeqCrossoverPoints(freqs, state.peq_chain, state.crossover); const peqCrossover = getPeqCrossoverPoints(freqs, state.peq_chain, state.crossover);
const total = sumCurvePoints([geq, peqCrossover, getGainCurvePoints(freqs, gain)]); const total = sumCurvePoints([geq, peqCrossover, getGainCurvePoints(freqs, gain)]);
return { geq, peqCrossover, total }; return { geq, peqCrossover, total };
@@ -17,7 +17,7 @@ import {
GEQ_BANDS_LABEL, GEQ_BANDS_LABEL,
GEQ_CURVE_Q, GEQ_CURVE_Q,
} from '../../constants'; } from '../../constants';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import './GeqSection.css'; import './GeqSection.css';
import { getGeqCurvePoints } from '../../utils/graph/geq'; import { getGeqCurvePoints } from '../../utils/graph/geq';
import { logSpace } from '../../utils/graph/curve'; import { logSpace } from '../../utils/graph/curve';
@@ -175,7 +175,7 @@ function GeqActions({
<div className="geq-channel-selector"> <div className="geq-channel-selector">
<Button <Button
variant="toggle" variant="toggle"
className={bypass ? 'active-info' : ''} className={bypass ? 'active-warning' : ''}
onClick={onBypass} onClick={onBypass}
style={{ style={{
width: '100%', width: '100%',
@@ -201,7 +201,7 @@ function GeqActions({
minHeight: 0, minHeight: 0,
}} }}
> >
{resetting ? 'Resetting…' : 'Reset'} {/* NEW */} {resetting ? 'Resetting…' : 'Reset'}
</Button> </Button>
</div> </div>
); );
@@ -219,6 +219,20 @@ function GeqSection({
const [activeChannel, setActiveChannel] = useState<InputChannel>(INPUT_ORDER[0]); const [activeChannel, setActiveChannel] = useState<InputChannel>(INPUT_ORDER[0]);
const [liveGeq, setLiveGeq] = useState<Partial<Record<InputChannel, LiveBands>>>({}); const [liveGeq, setLiveGeq] = useState<Partial<Record<InputChannel, LiveBands>>>({});
const [resetting, setResetting] = useState(false); const [resetting, setResetting] = useState(false);
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
useEffect(() => {
if (!isResetConfirmOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsResetConfirmOpen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isResetConfirmOpen]);
const handleLiveValue = (channel: InputChannel, band: DiscreteFrequency, value: number) => { const handleLiveValue = (channel: InputChannel, band: DiscreteFrequency, value: number) => {
setLiveGeq((prev) => ({ setLiveGeq((prev) => ({
@@ -245,6 +259,10 @@ function GeqSection({
setGeqBypass(activeChannel, !activeGeq.bypass); setGeqBypass(activeChannel, !activeGeq.bypass);
}; };
const handleResetClick = () => {
setIsResetConfirmOpen(true);
};
// Sequential per-band reset, since hardware has no bulk reset command. // Sequential per-band reset, since hardware has no bulk reset command.
const handleReset = async (channel: InputChannel) => { const handleReset = async (channel: InputChannel) => {
if (resetting) return; if (resetting) return;
@@ -261,6 +279,11 @@ function GeqSection({
setResetting(false); setResetting(false);
}; };
const confirmReset = async () => {
setIsResetConfirmOpen(false);
await handleReset(activeChannel);
};
const activeInputState = dsp.state.current_config.input_states[activeChannel]; const activeInputState = dsp.state.current_config.input_states[activeChannel];
const activeGeq = activeInputState.geq; const activeGeq = activeInputState.geq;
const activeLiveBands = liveGeq[activeChannel]; const activeLiveBands = liveGeq[activeChannel];
@@ -306,12 +329,33 @@ function GeqSection({
bypass={activeGeq.bypass} bypass={activeGeq.bypass}
resetting={resetting} resetting={resetting}
onBypass={handleBypassClick} onBypass={handleBypassClick}
onReset={() => handleReset(activeChannel)} onReset={handleResetClick}
/> />
</ChannelGroup> </ChannelGroup>
</div> </div>
</div> </div>
</div> </div>
{isResetConfirmOpen && (
<div className="confirm-modal-overlay" onClick={() => setIsResetConfirmOpen(false)}>
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<h3>Reset {activeInputState.name} GEQ?</h3>
<p>
This will set all GEQ bands on {activeInputState.name} back to 0 dB. This action
cannot be undone.
</p>
<div className="confirm-modal__actions">
<Button variant="outline" onClick={() => setIsResetConfirmOpen(false)}>
Cancel
</Button>
<Button variant="primary" onClick={confirmReset}>
Reset
</Button>
</div>
</div>
</div>
)}
</> </>
); );
} }
@@ -116,4 +116,4 @@
flex: 0 0 20%; flex: 0 0 20%;
width: 20%; width: 20%;
padding-right: 0; padding-right: 0;
} }
@@ -0,0 +1,126 @@
.copy-channel-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-5);
background: rgba(0, 0, 0, 0.5);
animation: copy-channel-fade-in var(--dur-base) var(--ease-standard);
}
@keyframes copy-channel-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.copy-channel-modal {
width: 100%;
max-width: 440px;
max-height: 90vh;
overflow-y: auto;
animation: copy-channel-scale-in var(--dur-base) var(--ease-standard);
}
@keyframes copy-channel-scale-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
.copy-channel-modal__header {
margin-bottom: var(--space-5);
}
.copy-channel-modal__header h2 {
margin: 0;
font-size: var(--font-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.copy-channel-modal__header p {
margin: var(--space-1) 0 0;
font-size: var(--font-sm);
line-height: 1.4;
color: var(--text-muted);
}
/* Source -> destination */
.copy-channel-modal__transfer {
display: flex;
align-items: flex-end;
gap: var(--space-3);
}
.copy-channel-modal__field {
display: flex;
flex-direction: column;
gap: var(--space-2);
flex: 1;
min-width: 0;
}
.copy-channel-modal__field label {
font-size: var(--font-sm);
color: var(--text-muted);
}
.copy-channel-modal__arrow {
display: flex;
align-items: center;
justify-content: center;
height: var(--card-control-height);
color: var(--text-muted);
flex: 0 0 auto;
}
.copy-channel-modal__arrow svg {
width: var(--space-5);
height: var(--space-5);
}
.copy-channel-modal__summary {
margin: var(--space-3) 0 var(--space-5);
font-size: var(--font-sm);
line-height: 1.4;
color: var(--text-muted);
}
/* Footer actions */
.copy-channel-modal__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
@media (max-width: 600px) {
.copy-channel-modal__transfer {
flex-direction: column;
align-items: stretch;
}
.copy-channel-modal__arrow {
height: auto;
transform: rotate(90deg);
}
}
@@ -0,0 +1,141 @@
import { useEffect, useMemo, useState } from 'react';
import { Channel, InputChannel, OutputChannel } from '../../types';
import Button from '../../../../components/Button/Button';
import Card from '../../../../components/Card/Card';
import ChoiceSelect from '../../../../components/ChoiceSelect/ChoiceSelect';
import './CopyChannelModal.css';
const INPUT_CHANNELS: readonly string[] = Object.values(InputChannel);
const OUTPUT_CHANNELS: readonly string[] = Object.values(OutputChannel);
const ALL_CHANNELS: readonly string[] = [...INPUT_CHANNELS, ...OUTPUT_CHANNELS];
const isInputChannel = (raw: string) => INPUT_CHANNELS.includes(raw);
/** Wraps a raw enum string back into the { Input } / { Output } shape. */
const wrapChannel = (raw: string): Channel =>
isInputChannel(raw) ? { Input: raw as InputChannel } : { Output: raw as OutputChannel };
/** The channels a given source can be copied onto: same type, minus itself. */
const destinationsFor = (source: string): string[] =>
(isInputChannel(source) ? INPUT_CHANNELS : OUTPUT_CHANNELS).filter(
(channel) => channel !== source
);
function CopyChannelModal({
onClose,
copyChannel,
}: {
onClose: () => void;
copyChannel: (channel1: Channel, channel2: Channel) => Promise<boolean>;
}) {
const [source, setSource] = useState<string>(ALL_CHANNELS[0]);
const [destination, setDestination] = useState<string>(() => destinationsFor(ALL_CHANNELS[0])[0]);
const [isCopying, setIsCopying] = useState(false);
const destinationOptions = useMemo(() => destinationsFor(source), [source]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const handleSourceChange = (nextSource: string) => {
setSource(nextSource);
// Picking a source of the other type (or picking the current
// destination as source) invalidates the destination.
const nextDestinations = destinationsFor(nextSource);
if (!nextDestinations.includes(destination)) {
setDestination(nextDestinations[0]);
}
};
const handleCopy = async () => {
setIsCopying(true);
try {
await copyChannel(wrapChannel(source), wrapChannel(destination));
onClose();
} finally {
setIsCopying(false);
}
};
return (
<div
className="copy-channel-overlay"
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<Card className="copy-channel-modal">
<div className="copy-channel-modal__header">
<h2>Copy Channel</h2>
<p>
Copy every setting of one channel onto another. Channels can only be copied onto
channels of the same type.
</p>
</div>
<div className="copy-channel-modal__transfer">
<div className="copy-channel-modal__field">
<label htmlFor="copy-channel-source">From</label>
<ChoiceSelect<string>
id="copy-channel-source"
value={source}
options={ALL_CHANNELS}
onChange={handleSourceChange}
/>
</div>
<div className="copy-channel-modal__arrow" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div className="copy-channel-modal__field">
<label htmlFor="copy-channel-destination">To</label>
<ChoiceSelect<string>
id="copy-channel-destination"
value={destination}
options={destinationOptions}
onChange={setDestination}
/>
</div>
</div>
<p className="copy-channel-modal__summary">
{destination} will take the settings of {source}.
<br />
This overwrites {destination}.
</p>
<div className="copy-channel-modal__actions">
<Button variant="outline" onClick={onClose} disabled={isCopying}>
Cancel
</Button>
<Button variant="outline" onClick={handleCopy} disabled={isCopying}>
{isCopying ? 'Copying...' : 'Copy'}
</Button>
</div>
</Card>
</div>
);
}
export default CopyChannelModal;
@@ -0,0 +1,211 @@
.link-channels-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-5);
background: rgba(0, 0, 0, 0.5);
animation: link-channels-fade-in var(--dur-base) var(--ease-standard);
}
@keyframes link-channels-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.link-channels-modal {
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
animation: link-channels-scale-in var(--dur-base) var(--ease-standard);
}
@keyframes link-channels-scale-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
.link-channels-modal__header {
margin-bottom: var(--space-4);
}
.link-channels-modal__header h2 {
margin: 0;
font-size: var(--font-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.link-channels-modal__header p {
margin: var(--space-1) 0 0;
font-size: var(--font-sm);
line-height: 1.4;
color: var(--text-muted);
}
/* Mode toggle */
.link-channels-modal__mode-toggle {
display: inline-flex;
padding: var(--space-1);
margin-bottom: var(--space-5);
background: var(--bg-panel);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
}
.link-channels-modal__mode-btn {
padding: var(--space-2) var(--space-4);
background: transparent;
color: var(--text-muted);
border: none;
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: var(--font-sm);
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition:
background var(--dur-base) var(--ease-standard),
color var(--dur-base) var(--ease-standard);
}
.link-channels-modal__mode-btn.is-active {
background: var(--accent-brand);
color: var(--bg-panel);
}
/* Triangular link matrix */
.link-matrix {
display: grid;
gap: var(--space-1);
margin-bottom: var(--space-5);
}
.link-matrix__corner {
width: var(--space-6);
}
.link-matrix__col-label,
.link-matrix__row-label {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: var(--font-sm);
color: var(--text-muted);
}
.link-matrix__row-label {
justify-content: flex-start;
padding-right: var(--space-2);
}
.link-matrix__row {
display: contents;
}
.link-matrix__cell {
aspect-ratio: 1 / 1;
min-width: 32px;
border-radius: var(--radius-sm);
}
.link-matrix__cell--empty {
background: transparent;
}
.link-matrix__cell--toggle {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: var(--bg-panel);
border: var(--border-width) solid var(--border-hairline);
cursor: pointer;
transition:
background var(--dur-base) var(--ease-standard),
border-color var(--dur-base) var(--ease-standard);
}
.link-matrix__cell--toggle:hover {
border-color: var(--accent-brand);
}
.link-matrix__cell--toggle.is-active {
background: var(--accent-brand);
border-color: var(--accent-brand);
cursor: default;
}
.link-matrix__cell--toggle:disabled:hover {
border-color: var(--accent-brand);
}
/* A row whose channel is itself following another channel can't also
lead others - its other tiles are locked rather than just inactive. */
.link-matrix__cell--unavailable {
background: repeating-linear-gradient(
45deg,
var(--border-hairline),
var(--border-hairline) 2px,
transparent 2px,
transparent 6px
);
border-color: var(--border-hairline);
cursor: not-allowed;
}
.link-matrix__cell--unavailable:hover {
border-color: var(--border-hairline);
}
/* Footer actions */
.link-channels-modal__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
@media (max-width: 600px) {
.link-matrix__cell {
min-width: 26px;
}
}
@@ -0,0 +1,368 @@
import { useEffect, useState } from 'react';
import { DSP408 } from '../../../../types';
import { Channel, InputChannel, OutputChannel } from '../../types';
import Button from '../../../../components/Button/Button';
import Card from '../../../../components/Card/Card';
import './LinkChannelsModal.css';
const INPUT_CHANNELS: readonly InputChannel[] = Object.values(InputChannel);
const OUTPUT_CHANNELS: readonly OutputChannel[] = Object.values(OutputChannel);
type LinkMode = 'input' | 'output';
type LinkGroup = {
source: Channel;
destinations: Channel[];
};
/**
* Maps every channel (by its raw enum string) to the channel it currently
* follows. A channel that follows itself is unlinked. Several channels can
* follow the same leader (many-to-one), but a channel that is itself
* following someone can't also be followed by others - no chains.
*/
type LeaderMap = Record<string, string>;
const wrapInputChannel = (raw: string): Channel => ({ Input: raw as InputChannel });
const wrapOutputChannel = (raw: string): Channel => ({ Output: raw as OutputChannel });
/**
* Gets the raw enum string (InA, Out1, ...) out of a channel entry.
* The device state has been observed to store these as plain strings
* (e.g. "InA") rather than the wrapped { Input: ... } / { Output: ... }
* shape used elsewhere, so this accepts either form defensively.
*/
function channelRawId(channel: Channel | string): string {
if (typeof channel === 'string') {
return channel;
}
return 'Input' in channel ? channel.Input : channel.Output;
}
/**
* Breaks any chains that might be present in a leader map (a channel that
* both follows someone and is followed by others) by releasing whoever
* follows a follower back to themselves. Keeps the invariant - a channel
* can lead or follow, never both - true regardless of what the device
* reported.
*/
function sanitizeLeaderMap(leaderOf: LeaderMap): LeaderMap {
const next = { ...leaderOf };
Object.keys(next).forEach((channel) => {
const leader = next[channel];
if (leader !== channel && next[leader] !== leader) {
// `channel` follows a leader that itself follows someone else -
// release `channel` back to itself to break the chain.
next[channel] = channel;
}
});
return next;
}
/**
* Reads the DSP's real link state (per-channel `linked_channels: Channel[]`)
* and turns it into a leader map, so the modal opens already reflecting the
* device. Every channel defaults to following itself (unlinked); channels
* reported as linked follow whichever channel the device reported first.
*/
function extractLeaderMap<T extends string>(
states: Partial<Record<T, { linked_channels: (Channel | string)[] }>> | undefined,
orderedChannels: readonly T[]
): LeaderMap {
const leaderOf: LeaderMap = {};
orderedChannels.forEach((channel) => {
leaderOf[channel] = channel;
});
if (!states) {
return leaderOf;
}
orderedChannels.forEach((channel) => {
const state = states[channel];
if (!state || state.linked_channels.length === 0) {
return;
}
const leaderId = channelRawId(state.linked_channels[0]);
if (leaderId !== channel) {
leaderOf[channel] = leaderId;
}
});
return sanitizeLeaderMap(leaderOf);
}
/**
* Turns a leader map into one linkChannels call per leader that has
* followers (source = leader, destinations = its followers), plus one call
* per still-unlinked, follower-less channel with no destinations. Channels
* that are themselves following someone don't get their own call - they're
* already covered by their leader's call.
*/
function buildLinkCalls(
leaderOf: LeaderMap,
orderedChannels: readonly string[],
wrap: (raw: string) => Channel
): LinkGroup[] {
const followersByLeader = new Map<string, string[]>();
orderedChannels.forEach((channel) => {
const leader = leaderOf[channel];
if (leader === channel) {
return;
}
const existing = followersByLeader.get(leader) ?? [];
existing.push(channel);
followersByLeader.set(leader, existing);
});
const calls: LinkGroup[] = [];
orderedChannels.forEach((channel) => {
const followers = followersByLeader.get(channel);
if (followers) {
calls.push({ source: wrap(channel), destinations: followers.map(wrap) });
return;
}
if (leaderOf[channel] === channel) {
calls.push({ source: wrap(channel), destinations: [] });
}
});
return calls;
}
function LinkChannelsModal({
dsp,
onClose,
linkChannels,
}: {
dsp: DSP408;
onClose: () => void;
linkChannels: (source: Channel, destinations: Channel[]) => Promise<boolean>;
}) {
const [mode, setMode] = useState<LinkMode>('input');
const [inputLeaders, setInputLeaders] = useState<LeaderMap>(() =>
extractLeaderMap(dsp.state?.current_config?.input_states, INPUT_CHANNELS)
);
const [outputLeaders, setOutputLeaders] = useState<LeaderMap>(() =>
extractLeaderMap(dsp.state?.current_config?.output_states, OUTPUT_CHANNELS)
);
const [isSaving, setIsSaving] = useState(false);
const channels: readonly string[] = mode === 'input' ? INPUT_CHANNELS : OUTPUT_CHANNELS;
const leaderOf = mode === 'input' ? inputLeaders : outputLeaders;
const setLeaderOf = mode === 'input' ? setInputLeaders : setOutputLeaders;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const followChannel = (follower: string, leader: string) => {
setLeaderOf((prev) => {
const next = { ...prev };
// `follower` can no longer lead anyone once it follows `leader` -
// release whoever currently follows `follower` back to themselves.
Object.keys(next).forEach((channel) => {
if (next[channel] === follower) {
next[channel] = channel;
}
});
next[follower] = leader;
return next;
});
};
const unlinkChannel = (channel: string) => {
setLeaderOf((prev) => ({ ...prev, [channel]: channel }));
};
const handleCellClick = (row: string, col: string) => {
const isSelf = row === col;
if (isSelf) {
if (leaderOf[row] !== row) {
unlinkChannel(row);
}
return;
}
if (leaderOf[row] !== row || leaderOf[col] === row) {
// Row is locked (it's itself following someone) or this pair is
// already the current state - nothing to do.
return;
}
followChannel(col, row);
};
const handleValidate = async () => {
setIsSaving(true);
try {
const groups = [
...buildLinkCalls(inputLeaders, INPUT_CHANNELS, wrapInputChannel),
...buildLinkCalls(outputLeaders, OUTPUT_CHANNELS, wrapOutputChannel),
];
for (const group of groups) {
// eslint-disable-next-line no-await-in-loop
await linkChannels(group.source, group.destinations);
}
onClose();
} finally {
setIsSaving(false);
}
};
return (
<div
className="link-channels-overlay"
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<Card className="link-channels-modal">
<div className="link-channels-modal__header">
<h2>Link Channels</h2>
<p>
Link one or more channels to a single channel. A channel not linked to another is linked
to itself.
</p>
</div>
<div className="link-channels-modal__mode-toggle" role="tablist" aria-label="Channel type">
<button
type="button"
role="tab"
aria-selected={mode === 'input'}
className={`link-channels-modal__mode-btn ${mode === 'input' ? 'is-active' : ''}`}
onClick={() => setMode('input')}
>
Input
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'output'}
className={`link-channels-modal__mode-btn ${mode === 'output' ? 'is-active' : ''}`}
onClick={() => setMode('output')}
>
Output
</button>
</div>
<div
className="link-matrix"
style={{ gridTemplateColumns: `auto repeat(${channels.length}, 1fr)` }}
>
<div className="link-matrix__corner" />
{channels.map((col) => (
<div className="link-matrix__col-label" key={`col-${col}`}>
{col}
</div>
))}
{channels.map((row, rowIndex) => {
const isRowLocked = leaderOf[row] !== row;
return (
<div className="link-matrix__row" key={`row-${row}`}>
<div className="link-matrix__row-label">{row}</div>
{channels.map((col, colIndex) => {
if (colIndex < rowIndex) {
return (
<div
className="link-matrix__cell link-matrix__cell--empty"
key={`cell-${row}-${col}`}
/>
);
}
const isSelf = row === col;
const isActive = isSelf ? leaderOf[row] === row : leaderOf[col] === row;
const isUnavailable = !isSelf && !isActive && isRowLocked;
const label = isSelf
? isActive
? `${row} not linked`
: `Unlink ${row}`
: isUnavailable
? `${row} is already linked to another channel`
: isActive
? `${col} linked to ${row}`
: `Link ${col} to ${row}`;
return (
<button
type="button"
key={`cell-${row}-${col}`}
className={[
'link-matrix__cell',
'link-matrix__cell--toggle',
isUnavailable && 'link-matrix__cell--unavailable',
isActive && 'is-active',
]
.filter(Boolean)
.join(' ')}
aria-pressed={isActive}
disabled={isActive || isUnavailable}
aria-label={label}
onClick={() => handleCellClick(row, col)}
/>
);
})}
</div>
);
})}
</div>
<div className="link-channels-modal__actions">
<Button variant="outline" onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button variant="outline" onClick={handleValidate} disabled={isSaving}>
{isSaving ? 'Linking...' : 'Validate'}
</Button>
</div>
</Card>
</div>
);
}
export default LinkChannelsModal;
@@ -0,0 +1,250 @@
.miscellaneous-section {
width: 100%;
min-height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.miscellaneous-panel {
width: 100%;
max-width: 720px;
}
/* Sections */
.miscellaneous-panel__section {
padding-top: var(--space-2);
margin-top: var(--space-2);
border-top: var(--border-width) solid var(--border-hairline);
}
.miscellaneous-panel__section:first-of-type {
padding-top: 0;
margin-top: 0;
border-top: none;
}
.miscellaneous-panel__section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.miscellaneous-panel__section-header h2 {
margin: 0;
font-size: var(--font-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.miscellaneous-panel__section-header p {
margin: 0;
font-size: var(--font-sm);
line-height: 1.4;
color: var(--text-muted);
}
/* Fields */
.miscellaneous-panel__row {
display: flex;
gap: var(--space-3);
}
.miscellaneous-panel__field {
display: flex;
flex-direction: column;
gap: 0;
flex: 1;
min-width: 0;
font-family: var(--font-mono);
}
.miscellaneous-panel__field label {
font-size: var(--font-sm);
color: var(--text-muted);
}
.miscellaneous-panel__field input {
height: var(--card-control-height);
padding: 0 var(--space-3);
background: var(--bg-panel);
color: var(--text-primary);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: var(--font-sm);
outline: none;
appearance: none;
-webkit-appearance: none;
transition: border-color var(--dur-base) var(--ease-standard);
}
.miscellaneous-panel__field input.mono {
font-family: var(--font-mono);
font-weight: var(--font-weight-normal);
}
.miscellaneous-panel__field input:focus {
border-color: var(--accent-brand);
}
.miscellaneous-panel__field input::placeholder {
color: var(--text-muted);
opacity: 0.5;
}
/* Stepper */
.miscellaneous-panel__choice-select {
height: var(--card-control-height);
min-height: var(--card-control-height);
max-height: var(--card-control-height);
box-sizing: border-box;
flex-shrink: 0;
}
/* Actions */
.miscellaneous-panel__actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-2);
}
/* Danger */
.miscellaneous-panel__section--danger {
padding-top: var(--space-2);
}
.miscellaneous-panel__section--danger .miscellaneous-panel__section-header p {
max-width: 560px;
}
/* Password Input */
.miscellaneous-panel__password-wrap {
position: relative;
width: 100%;
}
.miscellaneous-panel__password-wrap input {
width: 100%;
}
.miscellaneous-panel__password-toggle {
position: absolute;
top: 50%;
right: 3%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: auto;
height: 100%;
padding: 0;
border: none;
background: transparent;
color: var(--accent-brand);
cursor: pointer;
}
.miscellaneous-panel__password-toggle:hover {
color: var(--text-muted);
}
.miscellaneous-panel__password-toggle svg {
width: auto;
height: 65%;
}
/* Mobile */
@media (max-width: 600px) {
.miscellaneous-panel__row {
flex-direction: column;
}
.miscellaneous-panel__actions {
justify-content: flex-start;
flex-wrap: wrap;
}
}
.miscellaneous-panel {
user-select: none;
-webkit-user-select: none;
}
.miscellaneous-panel input,
.miscellaneous-panel select {
user-select: text;
-webkit-user-select: text;
}
.confirm-modal-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.confirm-modal {
width: 100%;
max-width: 400px;
padding: var(--space-5);
margin: var(--space-4);
background: var(--bg-panel);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.confirm-modal h3 {
margin: 0 0 var(--space-2);
font-size: var(--font-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.confirm-modal p {
margin: 0;
font-size: var(--font-sm);
line-height: 1.4;
color: var(--text-muted);
}
.confirm-modal__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
margin-top: var(--space-5);
}
@@ -0,0 +1,258 @@
import { useState } from 'react';
import { DSP408 } from '../../../../types';
import { Channel, InputSource, InputType, DiscreteFrequency } from '../../types';
import { SidebarId } from '../../constants';
import Button from '../../../../components/Button/Button';
import Card from '../../../../components/Card/Card';
import ChoiceSelect from '../../../../components/ChoiceSelect/ChoiceSelect';
import LinkChannelsModal from './LinkChannelsModal';
import CopyChannelModal from './CopyChannelModal';
import './MiscellaneousSection.css';
import { EyeIcon, EyeOffIcon } from '../../../../assets/icons/EyesIcons';
const INPUT_TYPES: readonly InputType[] = Object.values(InputType);
const DISCRETE_FREQUENCIES: readonly DiscreteFrequency[] = Object.values(DiscreteFrequency);
function MiscellaneousPanel({
dsp,
factoryReset,
copyChannel,
setInputSource,
linkChannels,
lockDevice,
setActiveSidebar,
}: {
dsp: DSP408;
factoryReset: (force: boolean) => Promise<boolean>;
copyChannel: (channel1: Channel, channel2: Channel) => Promise<boolean>;
setInputSource: (inputSource: InputSource) => Promise<boolean>;
linkChannels: (source: Channel, destinations: Channel[]) => Promise<boolean>;
lockDevice: (password: string) => Promise<boolean>;
setActiveSidebar: (id: SidebarId) => void;
}) {
const [isFactoryResetting, setIsFactoryResetting] = useState(false);
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
const [isLinkModalOpen, setIsLinkModalOpen] = useState(false);
const [isCopyModalOpen, setIsCopyModalOpen] = useState(false);
const [inputSource, setInputSourceState] = useState<InputSource>({
type: InputType.Analog,
frequency: DiscreteFrequency.F20,
});
const [password, setPassword] = useState('');
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const handleInputTypeChange = async (type: InputType) => {
const nextSource: InputSource = {
type,
frequency: type === InputType.SineWave ? inputSource.frequency : DiscreteFrequency.F20,
};
setInputSourceState(nextSource);
await setInputSource(nextSource);
};
const handleFrequencyChange = async (frequency: DiscreteFrequency) => {
const nextSource: InputSource = {
...inputSource,
frequency,
};
setInputSourceState(nextSource);
await setInputSource(nextSource);
};
const handleFactoryReset = () => {
setIsResetConfirmOpen(true);
};
const confirmFactoryReset = async () => {
setIsResetConfirmOpen(false);
setIsFactoryResetting(true);
try {
await factoryReset(true);
} finally {
setIsFactoryResetting(false);
}
};
const handleLock = async () => {
if (!password) {
return;
}
const success = await lockDevice(password);
if (success) {
setPassword('');
setActiveSidebar('overview');
}
};
return (
<div className="miscellaneous-section">
<Card className="miscellaneous-panel">
{/* Input Source */}
<section className="miscellaneous-panel__section">
<div className="miscellaneous-panel__section-header">
<h2>Input Source</h2>
<p>Select the signal source used by the DSP.</p>
</div>
<div className="miscellaneous-panel__row">
<div className="miscellaneous-panel__field">
<label htmlFor="misc-input-type">Source</label>
<ChoiceSelect<InputType>
id="misc-input-type"
className='miscellaneous-panel__choice-select'
value={inputSource.type}
options={INPUT_TYPES}
onChange={handleInputTypeChange}
style={{ color: 'var(--font-primary)' }}
/>
</div>
{inputSource.type === InputType.SineWave && (
<div className="miscellaneous-panel__field">
<label htmlFor="misc-frequency">Frequency</label>
<ChoiceSelect<DiscreteFrequency>
id="misc-frequency"
className='miscellaneous-panel__choice-select'
value={inputSource.frequency}
options={DISCRETE_FREQUENCIES}
formatLabel={(frequency) => {
if (frequency === DiscreteFrequency.F31_5) {
return '31.5 Hz';
}
return `${frequency.slice(1)} Hz`;
}}
onChange={handleFrequencyChange}
style={{ color: 'var(--font-primary)' }}
/>
</div>
)}
</div>
</section>
{/* Channel Tools */}
<section className="miscellaneous-panel__section">
<div className="miscellaneous-panel__section-header">
<h2>Channel Tools</h2>
<p>Copy settings between channels or link multiple channels.</p>
</div>
<div className="miscellaneous-panel__actions">
<Button variant="outline" onClick={() => setIsCopyModalOpen(true)}>
Copy Channel
</Button>
<Button variant="outline" onClick={() => setIsLinkModalOpen(true)}>
Link Channels
</Button>
</div>
</section>
{/* Security */}
<section className="miscellaneous-panel__section">
<div className="miscellaneous-panel__section-header">
<h2>Device Security</h2>
<p>Lock access to the DSP.</p>
</div>
<div className="miscellaneous-panel__field">
<label htmlFor="misc-password">Password</label>
<div className="miscellaneous-panel__password-wrap">
<input
id="misc-password"
type={isPasswordVisible ? 'text' : 'password'}
maxLength={4}
value={password}
placeholder="Enter password"
onChange={(e) => {
const value = e.target.value;
if (/^[\x20-\x7E]*$/.test(value)) {
setPassword(value);
}
}}
/>
<button
type="button"
className="miscellaneous-panel__password-toggle"
onClick={() => setIsPasswordVisible((v) => !v)}
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
aria-pressed={isPasswordVisible}
tabIndex={-1}
>
{isPasswordVisible ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
</div>
<div className="miscellaneous-panel__actions">
<Button variant="outline" onClick={handleLock} disabled={password.length !== 4}>
Lock Device
</Button>
</div>
</section>
{/* Factory Reset */}
<section className="miscellaneous-panel__section miscellaneous-panel__section--danger">
<div className="miscellaneous-panel__section-header">
<h2>Factory Reset</h2>
<p>Restore the DSP to its factory configuration. This cannot be undone.</p>
</div>
<div className="miscellaneous-panel__actions">
<Button variant="outline" onClick={handleFactoryReset} disabled={isFactoryResetting}>
{isFactoryResetting ? 'Resetting...' : 'Factory Reset'}
</Button>
</div>
</section>
</Card>
{isLinkModalOpen && (
<LinkChannelsModal
dsp={dsp}
onClose={() => setIsLinkModalOpen(false)}
linkChannels={linkChannels}
/>
)}
{isCopyModalOpen && (
<CopyChannelModal onClose={() => setIsCopyModalOpen(false)} copyChannel={copyChannel} />
)}
{isResetConfirmOpen && (
<div className="confirm-modal-overlay" onClick={() => setIsResetConfirmOpen(false)}>
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<h3>Factory Reset DSP?</h3>
<p>
This will restore the DSP to its factory configuration. This action cannot be undone.
</p>
<div className="confirm-modal__actions">
<Button variant="outline" onClick={() => setIsResetConfirmOpen(false)}>
Cancel
</Button>
<Button variant="primary" onClick={confirmFactoryReset}>
Factory Reset
</Button>
</div>
</div>
</div>
)}
</div>
);
}
export default MiscellaneousPanel;
@@ -0,0 +1,204 @@
.password-dialog-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
background: rgba(0, 0, 0, 0.55);
z-index: 1000;
user-select: none;
-webkit-user-select: none;
}
.password-dialog {
width: 340px;
max-width: 100%;
display: flex;
flex-direction: column;
animation: password-dialog-enter 120ms ease-out;
}
/* Header */
.password-dialog__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
min-width: 0;
}
.password-dialog__header-text {
flex: 1;
min-width: 0;
}
.password-dialog__title {
margin: 0;
font-size: var(--font-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.password-dialog__type {
margin-top: var(--space-1);
font-size: var(--font-sm);
color: var(--text-muted);
font-family: var(--font-mono);
letter-spacing: 0.03em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Content */
.password-dialog__content {
margin-top: var(--space-5);
}
.password-dialog__field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.password-dialog__field label {
font-size: var(--font-sm);
color: var(--text-muted);
}
/* Password */
.password-dialog__password-wrap {
position: relative;
width: 100%;
}
.password-dialog__password-wrap input {
width: 100%;
height: var(--card-control-height);
box-sizing: border-box;
padding: 0 var(--space-3);
padding-right: calc(var(--space-3) + 28px);
background: var(--bg-panel);
color: var(--text-primary);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: var(--font-sm);
outline: none;
appearance: none;
-webkit-appearance: none;
transition:
border-color var(--dur-base) var(--ease-standard),
box-shadow var(--dur-base) var(--ease-standard);
}
.password-dialog__password-wrap input:focus {
border-color: var(--accent-brand);
box-shadow:
0 0 0 2px
color-mix(in srgb, var(--accent-brand) 20%, transparent);
}
.password-dialog__password-wrap input::placeholder {
color: var(--text-muted);
opacity: 0.5;
}
.password-dialog__password-toggle {
position: absolute;
top: 50%;
right: var(--space-2);
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
color: var(--accent-brand);
cursor: pointer;
}
.password-dialog__password-toggle:hover {
color: var(--text-muted);
}
.password-dialog__password-toggle svg {
width: auto;
height: 65%;
}
/* Actions */
.password-dialog__actions {
margin-top: var(--space-5);
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--space-3);
}
/* Animation */
@keyframes password-dialog-enter {
from {
opacity: 0;
transform: translateY(4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.password-dialog__password-wrap input::-ms-reveal,
.password-dialog__password-wrap input::-ms-clear {
display: none;
}
.password-dialog__password-wrap input::-webkit-credentials-auto-fill-button {
display: none;
}
@@ -0,0 +1,116 @@
import { useState } from 'react';
import Button from '../../../../components/Button/Button';
import Card from '../../../../components/Card/Card';
import { EyeIcon, EyeOffIcon } from '../../../../assets/icons/EyesIcons';
import './PasswordDialog.css';
export function PasswordDialog({
name,
onSubmit,
onCancel,
}: {
name: string;
onSubmit: (password: string) => void;
onCancel: () => void;
}) {
const [password, setPassword] = useState('');
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const handleSubmit = () => {
if (password.length === 4) {
onSubmit(password);
}
};
const handlePasswordChange = (value: string) => {
if (/^[\x20-\x7E]*$/.test(value) && value.length <= 4) {
setPassword(value);
}
};
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onCancel();
}
};
return (
<div
className="password-dialog-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="password-dialog-title"
onMouseDown={handleOverlayClick}
>
<div className="password-dialog-wrapper">
<Card className="password-dialog">
<div className="password-dialog__header">
<div className="password-dialog__header-text">
<h1 id="password-dialog-title" className="password-dialog__title">
Device Locked
</h1>
<div className="password-dialog__type" title={name}>
{name}
</div>
</div>
</div>
<div className="password-dialog__content">
<div className="password-dialog__field">
<label htmlFor="device-password">Password</label>
<div className="password-dialog__password-wrap">
<input
id="device-password"
type={isPasswordVisible ? 'text' : 'password'}
value={password}
maxLength={4}
autoFocus
autoComplete="off"
onChange={(e) => handlePasswordChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && password.length === 4) {
handleSubmit();
}
if (e.key === 'Escape') {
onCancel();
}
}}
/>
<button
type="button"
className="password-dialog__password-toggle"
onClick={() => setIsPasswordVisible((visible) => !visible)}
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
aria-pressed={isPasswordVisible}
tabIndex={-1}
>
{isPasswordVisible ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
</div>
</div>
<div className="password-dialog__actions">
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button
variant="primary"
disabled={password.length !== 4}
onClick={handleSubmit}
>
Unlock
</Button>
</div>
</Card>
</div>
</div>
);
}
+14
View File
@@ -225,6 +225,20 @@ export const PEQ_FILTERS_LABEL: { value: PEQFilter; label: string }[] = [
{ value: PEQFilter.AllPass2, label: 'All Pass 2' }, { value: PEQFilter.AllPass2, label: 'All Pass 2' },
]; ];
/** Filter types that ignore Q (first-order filters). */
export const PEQ_Q_UNUSED = new Set<PEQFilter>([
PEQFilter.Lp6Db,
PEQFilter.Hp6Db,
PEQFilter.AllPass1,
]);
/** Filter types that ignore gain (everything except Peak and the shelves). */
export const PEQ_GAIN_USED = new Set<PEQFilter>([
PEQFilter.Peak,
PEQFilter.LowShelf,
PEQFilter.HighShelf,
]);
export const FREQUENCY_MIN = 19.7; export const FREQUENCY_MIN = 19.7;
export const FREQUENCY_MAX = 20160; export const FREQUENCY_MAX = 20160;
export const FREQUENCY_QUANTIZATION = 300; export const FREQUENCY_QUANTIZATION = 300;
+116 -21
View File
@@ -12,6 +12,7 @@ import {
PEQBand, PEQBand,
InputSource, InputSource,
DSP408Snapshot, DSP408Snapshot,
DSPMode,
} from './types'; } from './types';
import { SetDsps, NotifyFn, updateDSP, setPollingPaused } from '../../dsp/dspShared'; import { SetDsps, NotifyFn, updateDSP, setPollingPaused } from '../../dsp/dspShared';
import { registerPollStopper } from '../../dsp/pollRegistry'; import { registerPollStopper } from '../../dsp/pollRegistry';
@@ -25,7 +26,7 @@ export type DSP408Api = ReturnType<typeof forDSP408>;
const selectedIds = new Set<number>(); const selectedIds = new Set<number>();
const pollIntervals = new Map<number, ReturnType<typeof setInterval>>(); const pollIntervals = new Map<number, ReturnType<typeof setInterval>>();
const ACTIVE_POLL_INTERVAL_MS = 1000; const ACTIVE_POLL_INTERVAL_MS = 100;
const BACKGROUND_POLL_INTERVAL_MS = 5000; const BACKGROUND_POLL_INTERVAL_MS = 5000;
function pollIntervalFor(id: number): number { function pollIntervalFor(id: number): number {
@@ -117,8 +118,8 @@ export async function create(params: {
ip: params.ip, ip: params.ip,
port: params.port, port: params.port,
deviceId: params.deviceId, deviceId: params.deviceId,
mode: snapshot.mode, mode: params.demo ? DSPMode.Demo: DSPMode.Normal,
status: 'disconnected', isConnecting: false,
pollingPaused: true, pollingPaused: true,
state: snapshot.state, state: snapshot.state,
input_meters: snapshot.input_meters, input_meters: snapshot.input_meters,
@@ -127,16 +128,61 @@ export async function create(params: {
} }
export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) { export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
async function updateDSPState(): Promise<void> { async function updateDSPState(): Promise<DSP408['state']> {
try { try {
const state = await invoke<DSP408['state']>('get_dsp_state', { id: dsp.id }); const state = await invoke<DSP408['state']>('get_dsp_state', { id: dsp.id });
updateDSP(setDsps, dsp.id, (d) => ({ ...d, state }) as DSP408); updateDSP(setDsps, dsp.id, (d) => ({ ...d, state }) as DSP408);
return state;
} catch (err) { } catch (err) {
notify(`Failed to fetch state: ${String(err)}`, 'error', dsp.name); notify(`Failed to fetch state: ${String(err)}`, 'error', dsp.name);
throw err; throw err;
} }
} }
const isLocked = (s: DSP408['state']) => s.flags.is_locked;
async function abortLocked() {
await invoke('disconnect_dsp408', { id: dsp.id }).catch(() => {});
updateDSPState();
updateDSP(setDsps, dsp.id, (d) => ({ ...d, isConnecting: false, passwordRequired: true }));
}
async function connectInternal(password?: string) {
updateDSP(setDsps, dsp.id, (d) => ({ ...d, isConnecting: true, passwordRequired: false }));
try {
await invoke('connect_dsp408', { id: dsp.id });
let state = await updateDSPState();
if (isLocked(state)) {
if (password === undefined) {
await abortLocked();
return;
}
await invoke('unlock_device', { id: dsp.id, password });
state = await updateDSPState();
if (isLocked(state)) {
await abortLocked();
notify('Wrong password', 'error', dsp.name);
return;
}
}
updateDSP(setDsps, dsp.id, (d) => ({ ...d, isConnecting: false, pollingPaused: false }));
notify('Connected', 'success', dsp.name);
startPolling(dsp, setDsps);
} catch (err) {
updateDSP(setDsps, dsp.id, (d) => ({ ...d, isConnecting: false }));
notify(`Connection failed: ${String(err)}`, 'error', dsp.name);
}
}
return { return {
type: 'DSP408' as const, type: 'DSP408' as const,
@@ -155,22 +201,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
updateDSPState, updateDSPState,
async connect() { connect: () => connectInternal(),
updateDSP(setDsps, dsp.id, (d) => ({ ...d, status: 'connecting' })); connectWithPassword: (password: string) => connectInternal(password),
try { cancelPassword() {
await invoke('connect_dsp408', { id: dsp.id }); updateDSP(setDsps, dsp.id, (d) => ({ ...d, passwordRequired: false }));
await updateDSPState();
updateDSP(setDsps, dsp.id, (d) => ({ ...d, status: 'connected', pollingPaused: false }));
notify('Connected', 'success', dsp.name);
startPolling(dsp, setDsps);
} catch (err) {
updateDSP(setDsps, dsp.id, (d) => ({ ...d, status: 'disconnected' }));
notify(`Connection failed: ${String(err)}`, 'error', dsp.name);
}
}, },
async disconnect() { async disconnect() {
@@ -181,9 +215,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
await invoke('disconnect_dsp408', { id: dsp.id }); await invoke('disconnect_dsp408', { id: dsp.id });
}); });
await updateDSPState();
updateDSP(setDsps, dsp.id, (d) => ({ updateDSP(setDsps, dsp.id, (d) => ({
...d, ...d,
status: 'disconnected',
pollingPaused: true, pollingPaused: true,
})); }));
@@ -720,6 +755,8 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
await updateDSPState(); await updateDSPState();
notify(`Preset stored in ${dsp.state.presets.names[presetIndex]}`, 'success', dsp.name);
return true; return true;
} catch (err) { } catch (err) {
notify(`Store configuration failed: ${String(err)}`, 'error', dsp.name); notify(`Store configuration failed: ${String(err)}`, 'error', dsp.name);
@@ -746,6 +783,8 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
await updateDSPState(); await updateDSPState();
notify('Factory reset completed', 'success', dsp.name);
return true; return true;
} catch (err) { } catch (err) {
notify(`Factory reset failed: ${String(err)}`, 'error', dsp.name); notify(`Factory reset failed: ${String(err)}`, 'error', dsp.name);
@@ -769,6 +808,8 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
await updateDSPState(); await updateDSPState();
notify('Channels copied successfully', 'success', dsp.name);
return true; return true;
} catch (err) { } catch (err) {
notify(`Channel copy failed: ${String(err)}`, 'error', dsp.name); notify(`Channel copy failed: ${String(err)}`, 'error', dsp.name);
@@ -791,6 +832,8 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
await updateDSPState(); await updateDSPState();
notify('Input source updated', 'success', dsp.name);
return true; return true;
} catch (err) { } catch (err) {
notify(`Input source update failed: ${String(err)}`, 'error', dsp.name); notify(`Input source update failed: ${String(err)}`, 'error', dsp.name);
@@ -814,6 +857,8 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
await updateDSPState(); await updateDSPState();
notify('Channels linked successfully', 'success', dsp.name);
return true; return true;
} catch (err) { } catch (err) {
notify(`Channel linking failed: ${String(err)}`, 'error', dsp.name); notify(`Channel linking failed: ${String(err)}`, 'error', dsp.name);
@@ -837,6 +882,8 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
await updateDSPState(); await updateDSPState();
notify('Channel name updated', 'success', dsp.name);
return true; return true;
} catch (err) { } catch (err) {
notify(`Channel name update failed: ${String(err)}`, 'error', dsp.name); notify(`Channel name update failed: ${String(err)}`, 'error', dsp.name);
@@ -844,5 +891,53 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
} }
}); });
}, },
async unlockDevice(password: string): Promise<boolean> {
return withPollingPaused(dsp, setDsps, async () => {
try {
const success = await invoke<boolean>('unlock_device', {
id: dsp.id,
password,
});
if (!success) {
return false;
}
await updateDSPState();
notify('Device unlocked', 'success', dsp.name);
return true;
} catch (err) {
notify(`Unlock failed: ${String(err)}`, 'error', dsp.name);
return false;
}
});
},
async lockDevice(password: string): Promise<boolean> {
return withPollingPaused(dsp, setDsps, async () => {
try {
const success = await invoke<boolean>('lock_device', {
id: dsp.id,
password,
});
if (!success) {
return false;
}
await updateDSPState();
notify('Device locked', 'success', dsp.name);
return true;
} catch (err) {
notify(`Lock failed: ${String(err)}`, 'error', dsp.name);
return false;
}
});
},
}; };
} }
+19 -4
View File
@@ -183,9 +183,16 @@ export type DeviceFlags = {
is_locked: boolean; is_locked: boolean;
}; };
export enum InputType {
Analog = 'Analog',
PinkNoise = 'PinkNoise',
WhiteNoise = 'WhiteNoise',
SineWave = 'SineWave',
}
export type InputSource = { export type InputSource = {
type: string; type: InputType;
frequency: string; frequency: DiscreteFrequency;
}; };
export type InputChannelState = { export type InputChannelState = {
@@ -236,15 +243,23 @@ export type DSPState = {
flags: DeviceFlags; flags: DeviceFlags;
presets: PresetBank; presets: PresetBank;
current_config: DSPConfigState; current_config: DSPConfigState;
connection: ConnectionStatus,
}; };
export type DSPMode = 'demo' | 'disconnected' | 'connected'; export enum ConnectionStatus {
Connected = 'Connected',
Disconnected = 'Disconnected',
}
export enum DSPMode {
Demo = 'Demo',
Normal = 'Normal',
}
export type DSP408Snapshot = { export type DSP408Snapshot = {
state: DSPState; state: DSPState;
input_meters: DSP408InputMeters; input_meters: DSP408InputMeters;
output_meters: DSP408OutputMeters; output_meters: DSP408OutputMeters;
mode: DSPMode;
}; };
export interface Meter { export interface Meter {
+12 -16
View File
@@ -139,16 +139,14 @@ function peqBandResponseDb(
/** Sum of all bands' contributions at a given frequency. */ /** Sum of all bands' contributions at a given frequency. */
function peqTotalCurveDb( function peqTotalCurveDb(
freq: number, freq: number,
bands: Record<PEQBand, PEQ>, bands: Partial<Record<PEQBand, PEQ>>,
sampleRate: number = DEFAULT_SAMPLE_RATE sampleRate: number = DEFAULT_SAMPLE_RATE
): number { ): number {
let sum = 0; return Object.values(bands).reduce(
(sum, band) =>
for (const band of Object.values(PEQBand)) { band ? sum + peqBandResponseDb(freq, band, sampleRate) : sum,
sum += peqBandResponseDb(freq, bands[band], sampleRate); 0
} );
return sum;
} }
/** /**
@@ -307,16 +305,14 @@ function peqBandPhaseDeg(
function peqTotalPhaseDeg( function peqTotalPhaseDeg(
freq: number, freq: number,
bands: Record<PEQBand, PEQ>, bands: Partial<Record<PEQBand, PEQ>>,
sampleRate: number = DEFAULT_SAMPLE_RATE sampleRate: number = DEFAULT_SAMPLE_RATE
): number { ): number {
let sum = 0; return Object.values(bands).reduce(
(sum, band) =>
for (const band of Object.values(PEQBand)) { band ? sum + peqBandPhaseDeg(freq, band, sampleRate) : sum,
sum += peqBandPhaseDeg(freq, bands[band], sampleRate); 0
} );
return sum;
} }
export function getPeqPhasePoints({ export function getPeqPhasePoints({
+4 -10
View File
@@ -4,28 +4,21 @@ import { NotificationType, Notification } from '../types';
export function useNotifications() { export function useNotifications() {
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const hovered = useRef<Set<number>>(new Set()); const hovered = useRef<Set<number>>(new Set());
const nextId = useRef(0); // monotonically increasing counter
const notify = useCallback( const notify = useCallback(
(message: string, type: NotificationType = 'error', dspName?: string) => { (message: string, type: NotificationType = 'error', dspName?: string) => {
const id = Date.now(); const id = nextId.current++;
const fullMessage = dspName ? `${dspName}: ${message}` : message; const fullMessage = dspName ? `${dspName}: ${message}` : message;
setNotifications((prev) => [ setNotifications((prev) => [...prev, { id, message: fullMessage, type }]);
...prev,
{
id,
message: fullMessage,
type,
},
]);
const removeLater = () => { const removeLater = () => {
if (hovered.current.has(id)) { if (hovered.current.has(id)) {
setTimeout(removeLater, 1000); setTimeout(removeLater, 1000);
return; return;
} }
setNotifications((prev) => prev.filter((n) => n.id !== id)); setNotifications((prev) => prev.filter((n) => n.id !== id));
}; };
@@ -35,6 +28,7 @@ export function useNotifications() {
); );
const removeNotification = useCallback((id: number) => { const removeNotification = useCallback((id: number) => {
hovered.current.delete(id); // prevent leaking stale hover state
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, removing: true } : n))); setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, removing: true } : n)));
setTimeout(() => { setTimeout(() => {
+5
View File
@@ -155,6 +155,11 @@
height: var(--card-control-height); height: var(--card-control-height);
} }
.add-dsp-form__selector {
color: var(--text-primary);
height: 100%;
}
.add-dsp-form__field .choice-stepper__input { .add-dsp-form__field .choice-stepper__input {
border: none; border: none;
background: transparent; background: transparent;
+1 -1
View File
@@ -107,7 +107,7 @@ function AddDSPForm({
<div className="add-dsp-form__field"> <div className="add-dsp-form__field">
<label htmlFor="f-type">Model</label> <label htmlFor="f-type">Model</label>
<ChoiceSelect <ChoiceSelect
className="" className="add-dsp-form__selector"
id="f-type" id="f-type"
value={form.type} value={form.type}
options={DSP_TYPES} options={DSP_TYPES}
+8 -6
View File
@@ -3,10 +3,9 @@ import {
DSPMode, DSPMode,
DSP408OutputMeters, DSP408OutputMeters,
DSP408InputMeters, DSP408InputMeters,
ConnectionStatus,
} from './features/dsp408/types'; } from './features/dsp408/types';
export type DSPStatus = 'connected' | 'connecting' | 'disconnected';
export type NotificationType = 'success' | 'error' | 'warning'; export type NotificationType = 'success' | 'error' | 'warning';
export type Notification = { export type Notification = {
id: number; id: number;
@@ -38,11 +37,16 @@ interface DSPBase {
deviceId: number; deviceId: number;
mode: DSPMode; mode: DSPMode;
status: DSPStatus; passwordRequired?: boolean;
isConnecting: boolean;
pollingPaused: boolean; pollingPaused: boolean;
} }
export function isConnected(dsp: DSP): boolean {
return dsp.type === 'DSP408'
&& dsp.state.connection === ConnectionStatus.Connected;
}
export type DSP408 = DSPBase & { export type DSP408 = DSPBase & {
type: 'DSP408'; type: 'DSP408';
state: DSP408State; state: DSP408State;
@@ -52,8 +56,6 @@ export type DSP408 = DSPBase & {
export type DSP204 = DSPBase & { export type DSP204 = DSPBase & {
type: 'DSP204'; type: 'DSP204';
state: null;
meters: null;
}; };
export type DSP = DSP408 | DSP204; export type DSP = DSP408 | DSP204;