Compare commits

...
19 Commits
Author SHA1 Message Date
LucasDLTG e151ab4f26 fix: bugs using real hardware 2026-10-04 21:01:09 +02:00
LucasDLTG e28f61447f fix: q value 2026-10-02 22:48:47 +02:00
LucasDLTG 388552f1c4 fix: matrix gain 2026-10-02 20:38:23 +02:00
LucasDLTG e68e73d3d5 fix: render error on input 2026-09-30 21:14:08 +02:00
Lucas a5669805c8 fix: make led focus the tab 2026-09-30 16:55:47 +02:00
Lucas cef7336517 feat: remember panel per dsp 2026-09-30 16:53:44 +02:00
Lucas 50a3c1a1e5 fix: handle abort password 2026-09-30 16:35:15 +02:00
Lucas 684f5ce866 fix: too wide misc panel 2026-09-30 16:08:42 +02:00
Lucas 80da40f678 fix: disconnect button 2026-09-30 12:42:14 +02:00
LucasDLTG 3cca2f41f4 fix: connection led 2026-09-29 20:56:34 +02:00
Lucas 6511d9d007 unstable 2026-09-29 16:59:54 +02:00
Lucas d5a2afdea5 feat: add locking styles 2026-09-28 16:41:16 +02:00
Lucas 24830172ae feat: add rename channel 2026-09-25 15:10:19 +02:00
LucasDLTG fd7dd89f55 feat: match bypass button color 2026-09-24 18:13:57 +02:00
LucasDLTG dac6c729cd feat: remove outdated commentaries 2026-09-23 21:44:26 +02:00
LucasDLTG d48155683b feat: disable inputs if peq dont use them 2026-09-23 20:03:39 +02:00
LucasDLTG e1d6398d57 feat: dont draw geq/peq curves if bypass 2026-09-22 21:05:59 +02:00
LucasDLTG 4aae47cd27 merge 2026-09-22 19:12:40 +02:00
LucasDLTG 61f821c3e9 feat: add confirmation windows for reset 2026-09-22 19:09:38 +02:00
32 changed files with 1397 additions and 446 deletions
+21 -7
View File
@@ -7,11 +7,25 @@ This template should help get you started developing with Tauri, React and Types
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
# TODO
- Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly
- Check render to improve perfoormances
- Dont draw geq/peq on gain if bypass
- Diplay windows on reset geq, peq
- Preset change dont reset
- Disbale slider on peq when filter dont use it
- Check end to end behavioour
- Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly
- Check render to improve perfoormances
- Check in meter if clip bool is limit instead
- On close disconenct all ds^p
- Check end to end behavioour
- Roll back on scroll when 2 scroll
- test en dto end connection
Data: 10020001052f 31313131 10032a // lock
Data: 10020001052d 31313131 100328 // unlock fail
Data: 10020100032d 3100 10031f
Data: 10020001062d 0031313131 10032b // unlock success
Data: 10020100032d 0001 10032f
+185 -39
View File
@@ -175,7 +175,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
"syn 3.0.6",
]
[[package]]
@@ -225,6 +225,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -397,7 +403,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"thiserror 2.0.20",
"thiserror 2.0.21",
]
[[package]]
@@ -453,6 +459,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "chrono"
version = "0.4.45"
@@ -543,6 +560,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -618,9 +644,9 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "darling"
version = "0.23.0"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
dependencies = [
"darling_core",
"darling_macro",
@@ -628,26 +654,26 @@ dependencies = [
[[package]]
name = "darling_core"
version = "0.23.0"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.119",
"syn 3.0.6",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [
"darling_core",
"quote",
"syn 2.0.119",
"syn 3.0.6",
]
[[package]]
@@ -661,6 +687,37 @@ dependencies = [
"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]]
name = "deranged"
version = "0.5.8"
@@ -796,12 +853,13 @@ dependencies = [
name = "dsp_thomann"
version = "0.1.0"
dependencies = [
"rand",
"serde",
"serde_json",
"serde_with",
"strum",
"strum_macros",
"thiserror 2.0.20",
"thiserror 2.0.21",
]
[[package]]
@@ -1255,6 +1313,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core",
]
[[package]]
@@ -1762,6 +1821,60 @@ dependencies = [
"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]]
name = "jni"
version = "0.21.1"
@@ -2005,7 +2118,7 @@ dependencies = [
"once_cell",
"png 0.18.1",
"serde",
"thiserror 2.0.20",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
@@ -2493,6 +2606,21 @@ dependencies = [
"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]]
name = "potential_utf"
version = "0.1.5"
@@ -2606,6 +2734,23 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "raw-window-handle"
version = "0.6.2"
@@ -2629,7 +2774,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 2.0.20",
"thiserror 2.0.21",
]
[[package]]
@@ -2649,7 +2794,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
"syn 3.0.6",
]
[[package]]
@@ -2883,7 +3028,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
"syn 3.0.6",
]
[[package]]
@@ -2918,7 +3063,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
"syn 3.0.6",
]
[[package]]
@@ -2941,16 +3086,17 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.21.0"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
dependencies = [
"base64 0.22.1",
"base64 0.23.1",
"bs58",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.14.0",
"jiff",
"schemars 0.9.0",
"schemars 1.2.1",
"serde_core",
@@ -2961,14 +3107,14 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.21.0"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.6",
]
[[package]]
@@ -3009,7 +3155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -3199,9 +3345,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.2"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
@@ -3339,7 +3485,7 @@ dependencies = [
"tauri-runtime",
"tauri-runtime-wry",
"tauri-utils",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tokio",
"tray-icon",
"url",
@@ -3390,7 +3536,7 @@ dependencies = [
"sha2",
"syn 2.0.119",
"tauri-utils",
"thiserror 2.0.20",
"thiserror 2.0.21",
"time",
"url",
"uuid",
@@ -3443,7 +3589,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.20",
"thiserror 2.0.21",
"url",
"windows",
"zbus",
@@ -3467,7 +3613,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
"thiserror 2.0.20",
"thiserror 2.0.21",
"url",
"webkit2gtk",
"webview2-com",
@@ -3530,7 +3676,7 @@ dependencies = [
"serde_json",
"serde_with",
"swift-rs",
"thiserror 2.0.20",
"thiserror 2.0.21",
"toml 1.1.3+spec-1.1.0",
"url",
"urlpattern",
@@ -3582,11 +3728,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.20"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl 2.0.20",
"thiserror-impl 2.0.21",
]
[[package]]
@@ -3602,13 +3748,13 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.20"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
"syn 3.0.6",
]
[[package]]
@@ -3919,7 +4065,7 @@ dependencies = [
"once_cell",
"png 0.18.1",
"serde",
"thiserror 2.0.20",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
@@ -4279,7 +4425,7 @@ version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.20",
"thiserror 2.0.21",
"windows",
"windows-core 0.61.2",
]
@@ -4714,7 +4860,7 @@ dependencies = [
"sha2",
"soup3",
"tao-macros",
"thiserror 2.0.20",
"thiserror 2.0.21",
"url",
"webkit2gtk",
"webkit2gtk-sys",
+1 -3
View File
@@ -46,7 +46,6 @@ pub struct DSP408Snapshot {
pub state: types::DSPState,
pub input_meters: types::InputMeters,
pub output_meters: types::OutputMeters,
pub mode: types::DSPMode,
}
#[tauri::command]
@@ -104,7 +103,6 @@ pub async fn get_dsp_snapshot(
state: device.dsp.state().clone(),
input_meters: device.dsp.input_meters().clone(),
output_meters: device.dsp.output_meters().clone(),
mode: device.dsp.mode(),
})
})
}
@@ -430,7 +428,7 @@ pub async fn set_matrix_routing(
})
}
// Command `0x3B` -- TODO
// Command `0x3B`
#[tauri::command]
pub async fn link_channels(
state: State<'_, AppState>,
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import './App.css';
import { DSP, AppPage } from './types';
import { DSP, AppPage, isConnected } from './types';
import TopBar from './components/Topbar/Topbar';
import DSPPage from './pages/DspPage/DspPage';
import AddDSPForm from './pages/AddDspPage/AddDspPage';
@@ -17,7 +17,7 @@ function App() {
const [selected, setSelected] = useState<number | null>(null);
const [page, setPage] = useState<AppPage>('home');
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 activeInstance = activeDsp ? createDSPInstance(activeDsp, setDsps, notify) : null;
+7 -43
View File
@@ -113,25 +113,14 @@ export function BoltIcon(props: React.SVGProps<SVGSVGElement>) {
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx="12"
cy="12"
r="4"
stroke="currentColor"
strokeWidth="2"
/>
<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}
>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M3 12H7M17 12H21"
stroke="currentColor"
@@ -152,12 +141,7 @@ export function GainIcon(props: React.SVGProps<SVGSVGElement>) {
export function GateIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M3 3V21H21"
stroke="currentColor"
@@ -179,12 +163,7 @@ export function GateIcon(props: React.SVGProps<SVGSVGElement>) {
export function CompressorIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M3 3V21H21"
stroke="currentColor"
@@ -206,12 +185,7 @@ export function CompressorIcon(props: React.SVGProps<SVGSVGElement>) {
export function LimiterIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M3 3V21H21"
stroke="currentColor"
@@ -233,12 +207,7 @@ export function LimiterIcon(props: React.SVGProps<SVGSVGElement>) {
export function DelayIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<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"
@@ -252,12 +221,7 @@ export function DelayIcon(props: React.SVGProps<SVGSVGElement>) {
export function MatrixIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<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" />
+2 -2
View File
@@ -28,7 +28,7 @@
font-family: var(--font-ui);
font-size: 90%;
color: inherit;
cursor: pointer;
@@ -122,4 +122,4 @@
.choice-select__listbox--portal {
position: fixed;
z-index: 1000; /* clear of any local stacking contexts in the panel */
}
}
+43 -43
View File
@@ -40,35 +40,35 @@ function ChoiceSelect<T extends string>({
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);
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;
if (clickedOutside && open) {
setOpen(false);
suppressNextClick.current = true;
}
}
}
function handleClickCapture(e: MouseEvent) {
if (suppressNextClick.current) {
e.preventDefault();
e.stopPropagation();
suppressNextClick.current = false;
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]);
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
@@ -93,27 +93,27 @@ function ChoiceSelect<T extends string>({
};
function updatePlacement() {
const el = triggerRef.current;
if (!el) return;
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);
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);
// 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,
});
}
setCoords({
top: needsFlip ? rect.top - LISTBOX_GAP : rect.bottom + LISTBOX_GAP,
left: rect.left,
width: rect.width,
fontSize,
});
}
function openList() {
if (disabled) return;
@@ -229,4 +229,4 @@ function ChoiceSelect<T extends string>({
);
}
export default ChoiceSelect;
export default ChoiceSelect;
@@ -78,9 +78,15 @@
}
.choice-stepper[data-disabled] {
opacity: 0.5;
opacity: 0.45;
filter: grayscale(1);
}
.choice-stepper__input:disabled {
cursor: not-allowed;
}
.choice-stepper[data-disabled] .choice-stepper__input,
.choice-stepper[data-disabled] .choice-stepper__arrow-btn {
color: var(--border-hairline);
}
+41 -18
View File
@@ -12,6 +12,23 @@ const PENDING_FALLBACK_MS = 1000;
type CommitResult = Promise<boolean | void> | boolean | void;
function useNonPassiveWheel(
ref: React.RefObject<HTMLElement | null>,
handler: (e: WheelEvent) => void
) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const el = ref.current;
if (!el) return;
const listener = (e: WheelEvent) => handlerRef.current(e);
el.addEventListener('wheel', listener, { passive: false });
return () => el.removeEventListener('wheel', listener);
}, [ref]);
}
function StepperArrows({
onUp,
onDown,
@@ -80,6 +97,17 @@ function ChoiceStepperList<T>({
const pendingValue = useRef<T | null>(null);
const wheelTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const fallbackTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useNonPassiveWheel(inputRef, (e) => {
e.preventDefault();
if (e.deltaY < 0 && canUp) {
stepWheel(1);
} else if (e.deltaY > 0 && canDown) {
stepWheel(-1);
}
});
useEffect(() => {
if (interacting.current) {
@@ -180,21 +208,13 @@ function ChoiceStepperList<T>({
return (
<div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
<input
ref={inputRef}
id={id}
className="choice-stepper__input"
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
onWheel={(e) => {
e.preventDefault();
if (e.deltaY < 0 && canUp) {
stepWheel(1);
} else if (e.deltaY > 0 && canDown) {
stepWheel(-1);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
@@ -256,6 +276,17 @@ function ChoiceStepperRange({
const pendingValue = useRef<number | null>(null);
const wheelTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const fallbackTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useNonPassiveWheel(inputRef, (e) => {
e.preventDefault();
if (e.deltaY < 0 && canUp) {
stepWheel(1);
} else if (e.deltaY > 0 && canDown) {
stepWheel(-1);
}
});
useEffect(() => {
if (interacting.current) {
@@ -344,21 +375,13 @@ function ChoiceStepperRange({
return (
<div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
<input
ref={inputRef}
id={id}
className="choice-stepper__input"
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
onWheel={(e) => {
e.preventDefault();
if (e.deltaY < 0 && canUp) {
stepWheel(1);
} else if (e.deltaY > 0 && canDown) {
stepWheel(-1);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
+51
View File
@@ -365,3 +365,54 @@
.app-root--sidebar-expanded .stage--with-sidebar {
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;
}
+78 -1
View File
@@ -6,6 +6,7 @@ export type SidebarItem<T extends string = string> = {
id: T;
label: string;
icon: React.ReactNode;
editable?: boolean;
};
export type SidebarAppOption = {
@@ -17,11 +18,75 @@ function SidebarButton<T extends string>({
item,
active,
onSelect,
onRename,
}: {
item: SidebarItem<T>;
active: boolean;
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 (
<button
onClick={onSelect}
@@ -31,7 +96,14 @@ function SidebarButton<T extends string>({
className={'sidebar-item' + (active ? ' sidebar-item--active' : '')}
>
<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>
);
}
@@ -230,6 +302,7 @@ export function Sidebar<T extends string>({
selectedAppId,
onAppChange,
onAppRename,
onItemRename,
expanded,
onToggle,
}: {
@@ -241,6 +314,7 @@ export function Sidebar<T extends string>({
selectedAppId?: string;
onAppChange: (id: string) => void;
onAppRename: (name: string) => void;
onItemRename?: (id: T, name: string) => void;
expanded: boolean;
onToggle: () => void;
}) {
@@ -298,6 +372,9 @@ export function Sidebar<T extends string>({
item={item}
active={activeId === item.id}
onSelect={() => onSelect(item.id)}
onRename={
item.editable && onItemRename ? (name) => onItemRename(item.id, name) : undefined
}
/>
))}
</div>
@@ -92,6 +92,23 @@ function formatGainDb(value: number) {
return `${sign}${value.toFixed(1)} dB`;
}
export function useNonPassiveWheel(
ref: React.RefObject<Element | null>,
handler: (e: WheelEvent) => void
) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const el = ref.current;
if (!el) return;
const listener = (e: Event) => handlerRef.current(e as WheelEvent);
el.addEventListener('wheel', listener, { passive: false });
return () => el.removeEventListener('wheel', listener);
}, [ref]);
}
type GainTooltipState = {
visible: boolean;
x: number;
@@ -104,11 +121,13 @@ function SignalFlowMatrix({
setMatrixRouting,
setMatrixGain,
setActiveSidebar,
onGainPreview,
}: {
dsp: DSP408;
setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => Promise<boolean>;
setMatrixGain: (input: InputChannel, output: OutputChannel, gain: number) => Promise<boolean>;
setActiveSidebar: React.Dispatch<React.SetStateAction<SidebarId>>;
onGainPreview: (input: InputChannel, output: OutputChannel, value: number | null) => void;
}) {
const nInputs = INPUT_ORDER.length;
const nOutputs = OUTPUT_ORDER.length;
@@ -137,9 +156,16 @@ function SignalFlowMatrix({
});
const hideTooltipTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const WHEEL_COMMIT_DELAY = 250;
// key = `${input}-${output}`
const pendingGains = useRef<Map<string, number>>(new Map());
const commitTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(() => {
return () => {
if (hideTooltipTimer.current) clearTimeout(hideTooltipTimer.current);
commitTimers.current.forEach(clearTimeout);
};
}, []);
@@ -151,28 +177,55 @@ function SignalFlowMatrix({
void setMatrixRouting(output, next);
};
const handleWheelGain = (e: React.WheelEvent, output: OutputChannel, input: InputChannel) => {
const svgRef = useRef<SVGSVGElement>(null);
const handleWheelGain = (e: WheelEvent, output: OutputChannel, input: InputChannel) => {
e.preventDefault();
const current = getGain(dsp, output, input);
const key = `${input}-${output}`;
// Base on the pending value if we're mid-wheel, otherwise the real state
const current = pendingGains.current.get(key) ?? getGain(dsp, output, input);
const delta = e.deltaY < 0 ? MATRIX_GAIN_STEP : -MATRIX_GAIN_STEP;
const next =
Math.round(Math.min(MATRIX_GAIN_MAX, Math.max(MATRIX_GAIN_MIN, current + delta)) * 10) / 10;
void setMatrixGain(input, output, next);
// Show a small tooltip with the new gain value right next to the cursor,
// then let it fade away shortly after wheeling stops.
pendingGains.current.set(key, next);
onGainPreview(input, output, next);
// Debounced send: only the final value goes out once the wheel pauses
const existing = commitTimers.current.get(key);
if (existing) clearTimeout(existing);
commitTimers.current.set(
key,
setTimeout(async () => {
commitTimers.current.delete(key);
const value = pendingGains.current.get(key);
if (value === undefined) return;
await setMatrixGain(input, output, value);
// Only release if the user hasn't wheeled again while we were awaiting
if (pendingGains.current.get(key) === value) {
pendingGains.current.delete(key);
onGainPreview(input, output, null);
}
}, WHEEL_COMMIT_DELAY)
);
// Tooltip (unchanged, shows the pending value)
if (hideTooltipTimer.current) clearTimeout(hideTooltipTimer.current);
setGainTooltip({
visible: true,
x: e.clientX,
y: e.clientY,
label: formatGainDb(next),
});
setGainTooltip({ visible: true, x: e.clientX, y: e.clientY, label: formatGainDb(next) });
hideTooltipTimer.current = setTimeout(() => {
setGainTooltip((t) => ({ ...t, visible: false }));
}, GAIN_TOOLTIP_HOLD_MS);
};
useNonPassiveWheel(svgRef, (e) => {
const hit = (e.target as Element).closest<SVGElement>('[data-wheel-route]');
if (!hit) return; // not over a connected line: let the page scroll normally
const { input, output } = hit.dataset;
if (!input || !output) return;
handleWheelGain(e, output as OutputChannel, input as InputChannel);
});
const routePairs = useMemo(() => {
const pairs: { input: InputChannel; output: OutputChannel; i: number; j: number }[] = [];
OUTPUT_ORDER.forEach((output, j) => {
@@ -196,6 +249,7 @@ function SignalFlowMatrix({
return (
<div className="signal-flow">
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="xMidYMid meet"
className="signal-flow__svg"
@@ -221,7 +275,9 @@ function SignalFlowMatrix({
strokeWidth={LINE_HITBOX_WIDTH}
style={{ cursor: 'pointer' }}
onClick={() => handleToggle(output, input)}
onWheel={connected ? (e) => handleWheelGain(e, output, input) : undefined}
data-wheel-route={connected ? '' : undefined}
data-input={input}
data-output={output}
/>
{/* Visible line, purely decorative — no pointer events, so it never
competes with the hitbox above for hover/click/wheel. */}
+15 -5
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react';
import { DSP, AppPage } from '../../types';
import { DSP, AppPage, isConnected } from '../../types';
import AnimatedLogo from '../../assets/icons/AnimatedLogo';
import { PlusIcon } from '../../assets/icons/PlusIcon';
import { CrownIcon } from '../../assets/icons/CrownIcon';
@@ -36,7 +36,7 @@ function Tab({
onDisconnect: (dsp: DSP) => void;
actions?: React.ReactNode;
}) {
const isConnected = dsp.status === 'connected';
const isDspConnected = isConnected(dsp);
const handleTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -60,18 +60,28 @@ function Tab({
<button
type="button"
className="tab-led"
aria-label={isConnected ? `Disconnect ${dsp.name}` : `Connect ${dsp.name}`}
aria-label={isDspConnected ? `Disconnect ${dsp.name}` : `Connect ${dsp.name}`}
onClick={(e) => {
e.stopPropagation();
if (isConnected) {
onSelect();
if (isDspConnected) {
onDisconnect(dsp);
} else {
onConnect(dsp);
}
}}
>
<Led status={dsp.status} />
<Led
status={
dsp.isConnecting
? 'connecting'
: isConnected(dsp)
? 'connected'
: 'disconnected'
}
/>
</button>
<span className="tab-name" title={dsp.name}>
@@ -146,3 +146,26 @@
font-family: 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;
style?: React.CSSProperties;
/** Greys out the slider and blocks all interaction. */
disabled?: boolean;
};
const defaultStep: StepFn = () => 0.1;
@@ -174,6 +177,7 @@ export default function VerticalSlider({
formatValue,
showTicks = true,
scale = 'linear',
disabled = false,
className,
style,
}: VerticalSliderProps) {
@@ -240,6 +244,26 @@ export default function VerticalSlider({
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!disabled) return;
dragging.current = false;
keyboardEditing.current = false;
keyboardValue.current = null;
wheelEditing.current = false;
wheelValue.current = null;
if (wheelCommitTimeout.current) {
clearTimeout(wheelCommitTimeout.current);
wheelCommitTimeout.current = null;
}
setEditingText(null);
setDisplayValue(value);
dragValue.current = value;
onInput?.(value);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [disabled]);
const decimals = useMemo(() => {
if (listValues) return Math.min(3, getDecimals(displayValue));
return Math.min(3, getDecimals(step(displayValue)));
@@ -273,6 +297,8 @@ export default function VerticalSlider({
}
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (disabled) return;
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
return;
}
@@ -307,6 +333,8 @@ export default function VerticalSlider({
}
async function handleKeyUp() {
if (disabled) return;
if (keyboardValue.current == null) return;
const next = keyboardValue.current;
@@ -323,6 +351,7 @@ export default function VerticalSlider({
}
function handleWheel(e: WheelEvent) {
if (disabled) return;
e.preventDefault();
wheelEditing.current = true;
@@ -393,6 +422,8 @@ export default function VerticalSlider({
}
function startDrag(e: React.PointerEvent<HTMLDivElement>) {
if (disabled) return;
const target = e.currentTarget;
dragging.current = true;
target.setPointerCapture(e.pointerId);
@@ -427,6 +458,11 @@ export default function VerticalSlider({
}
async function commitEditing() {
if (disabled) {
setEditingText(null);
return;
}
if (editingText !== null && editingText !== '' && editingText !== '-') {
const parsed = Number(editingText);
@@ -452,7 +488,10 @@ export default function VerticalSlider({
const percent = valueToPercent(displayValue, min, max, scale, listValues);
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
className={['gauge-track', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}
ref={trackRef}
@@ -468,7 +507,12 @@ export default function VerticalSlider({
<div
className="slider"
ref={sliderRef}
tabIndex={0}
role="slider"
aria-disabled={disabled}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={displayValue}
tabIndex={disabled ? -1 : 0}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPointerDown={startDrag}
@@ -494,6 +538,7 @@ export default function VerticalSlider({
ref={inputRef}
type="text"
inputMode="decimal"
disabled={disabled}
value={editingText ?? formattedValue}
style={{ width: '100%' }}
onFocus={() => setEditingText(value.toFixed(decimals))} // plain number while editing
@@ -11,12 +11,14 @@ import {
PEQ_FILTERS_LABEL,
PEQ_Q_RANGES,
PEAK_Q_VALUES,
PEQ_Q_UNUSED,
PEQ_GAIN_USED,
} from '../../constants';
import { Channel, PEQBand, PEQFilter, PEQ, Crossover } from '../../types';
import { DSP408 as DSP } from '../../../../types';
import { CrossoverFilter } from '../../types';
import { LinearGraph, LinearGraphHandle } from '../../../../components/LinearGraph/LinearGraph';
import { useRef, useState } from 'react';
import { useRef, useState, useEffect } from 'react';
import './ChannelSection.css';
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
import Button from '../../../../components/Button/Button';
@@ -34,6 +36,12 @@ import { getPeqCurvePoints, getPeqPhasePoints } from '../../utils/graph/peq';
import { logSpace, sumCurvePoints, sumPhaseCurvePoints } from '../../utils/graph/curve';
import { getCrossoverCurvePoints, getCrossoverPhasePoints } from '../../utils/graph/crossover';
function nearestQValue(q: number, values: readonly number[] = PEAK_Q_VALUES): number {
return values.reduce((nearest, current) =>
Math.abs(current - q) < Math.abs(nearest - q) ? current : nearest
);
}
type GraphView = 'both' | 'magnitude' | 'phase';
function GraphViewToggle({
@@ -128,10 +136,14 @@ function PeqPanelSliders({
onLiveValue: (value: PEQ) => void;
}) {
const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
const qDisabled = PEQ_Q_UNUSED.has(peq.filter_type);
const gainDisabled = !PEQ_GAIN_USED.has(peq.filter_type);
const nearestQ = nearestQValue(peq.q);
const commit = (overrides: Partial<PEQ>) => {
const next = { ...peq, ...overrides };
return setPEQ(next.gain, next.frequency, next.q, next.filter_type, next.bypass);
return setPEQ(next.gain, next.frequency, overrides.q ?? nearestQ, next.filter_type, next.bypass);
};
return (
@@ -165,9 +177,10 @@ function PeqPanelSliders({
max={qMax}
values={PEAK_Q_VALUES}
formatValue={(v) => v.toFixed(2)}
value={peq.q}
value={nearestQ}
onChange={(value) => commit({ q: value })}
onInput={(value) => onLiveValue({ ...peq, q: value })}
onInput={(value) => onLiveValue({ ...peq, q: nearestQValue(value) })}
disabled={qDisabled}
style={{ width: '100%', flex: '1 1 auto' }}
/>
@@ -184,6 +197,7 @@ function PeqPanelSliders({
formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))}
onChange={(value) => commit({ gain: value })}
onInput={(value) => onLiveValue({ ...peq, gain: value })}
disabled={gainDisabled}
style={{ width: '100%', flex: '1 1 auto' }}
/>
@@ -312,9 +326,16 @@ function PeqBandList({
bandsOrder: PEQBand[];
}) {
const commit = (band: PEQBand, overrides: Partial<PEQ>) => {
const peq = liveBands[band] ?? bands[band];
const next = { ...peq, ...overrides };
return setPEQ(band, next.gain, next.frequency, next.q, next.filter_type, next.bypass);
const live = liveBands[band] ?? bands[band];
const next = { ...live, ...overrides };
return setPEQ(
band,
next.gain,
next.frequency,
nearestQValue(next.q),
next.filter_type,
next.bypass
);
};
return (
@@ -334,6 +355,10 @@ function PeqBandList({
const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
const qValues = PEAK_Q_VALUES.filter((q) => q >= qMin && q <= qMax);
const qValue = nearestQValue(peq.q, qValues.length ? qValues : PEAK_Q_VALUES);
const qDisabled = PEQ_Q_UNUSED.has(peq.filter_type);
const gainDisabled = !PEQ_GAIN_USED.has(peq.filter_type);
return (
<div
@@ -367,9 +392,10 @@ function PeqBandList({
<ChoiceStepper
className="peq-band-list__stepper"
value={peq.q}
value={qValue}
values={qValues}
formatLabel={(v) => v.toFixed(2)}
disabled={qDisabled}
onChange={(value) => commit(band, { q: value })}
/>
@@ -380,6 +406,7 @@ function PeqBandList({
max={12}
step={0.1}
formatLabel={(v) => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`}
disabled={gainDisabled}
onChange={(value) => commit(band, { gain: value })}
/>
@@ -390,7 +417,14 @@ function PeqBandList({
formatLabel={(v) =>
PEQ_FILTERS_LABEL.find((opt) => opt.value === v)?.label ?? String(v)
}
onChange={(value) => commit(band, { filter_type: value, q: 3.0 })}
onChange={(value) => {
const [min, max] = PEQ_Q_RANGES[value];
const allowed = PEAK_Q_VALUES.filter((q) => q >= min && q <= max);
commit(band, {
filter_type: value,
q: nearestQValue(3.0, allowed.length ? allowed : PEAK_Q_VALUES),
});
}}
/>
<Button
@@ -441,6 +475,7 @@ function ChannelSection({
const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1);
const [graphView, setGraphView] = useState<GraphView>('both');
const [resetting, setResetting] = useState(false);
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
// Live values
const [liveHpFreq, setLiveHpFreq] = useState<number>(FREQUENCY_MIN);
@@ -461,6 +496,23 @@ function ChannelSection({
const bandsOrder = isInput ? PEQ_BANDS_ORDER.slice(0, -1) : PEQ_BANDS_ORDER;
useEffect(() => {
if (!isResetConfirmOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsResetConfirmOpen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isResetConfirmOpen]);
const handleResetClick = () => {
setIsResetConfirmOpen(true);
};
const handleResetPeq = async () => {
if (resetting) return;
setResetting(true);
@@ -470,12 +522,17 @@ function ChannelSection({
// 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, nearestQValue(3.0, PEAK_Q_VALUES), PEQFilter.Peak, current.bypass);
}
setResetting(false);
};
const confirmResetPeq = async () => {
setIsResetConfirmOpen(false);
await handleResetPeq();
};
const handleBypassClick = () => {
setPeqBypass(channel, !ActivePeq.bypass);
};
@@ -681,11 +738,32 @@ function ChannelSection({
bypass={ActivePeq.bypass}
resetting={resetting}
onBypass={handleBypassClick}
onReset={handleResetPeq}
onReset={handleResetClick}
/>
</ChannelGroup>
</div>
</div>
{isResetConfirmOpen && (
<div className="confirm-modal-overlay" onClick={() => setIsResetConfirmOpen(false)}>
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<h3>Reset {activeChannelState.name} PEQ?</h3>
<p>
This will set all PEQ bands on {activeChannelState.name} back to flat (0 dB, Peak).
This action cannot be undone.
</p>
<div className="confirm-modal__actions">
<Button variant="outline" onClick={() => setIsResetConfirmOpen(false)}>
Cancel
</Button>
<Button variant="primary" onClick={confirmResetPeq}>
Reset
</Button>
</div>
</div>
</div>
)}
</>
);
}
@@ -213,7 +213,7 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
const steps = 120;
const points: [number, number][] = [];
for (let i = 0; i <= steps; i++) {
const x = -100 + (100 * i) / steps;
const x = -100 + (120 * i) / steps;
points.push([x, compressorOutputDb(x, threshold, ratioNumeric, knee)]);
}
return points;
@@ -227,9 +227,9 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
<LinearGraph
xMin={-100}
xMax={0}
xMax={20}
yMin={-100}
yMax={0}
yMax={20}
xLabel="in dB"
yLabel="out dB"
formatX={(v) => Math.round(v).toString()}
@@ -240,7 +240,7 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
const [ux0, uy0] = toPx(-100, -100);
const [ux1, uy1] = toPx(0, 0);
const [ux1, uy1] = toPx(20, 20);
ctx.beginPath();
ctx.moveTo(ux0, uy0);
ctx.lineTo(ux1, uy1);
@@ -252,7 +252,7 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
ctx.lineWidth = 1;
ctx.setLineDash([2, 3]);
const [tx0, ty0] = toPx(activeComp.threshold, -100);
const [tx1, ty1] = toPx(activeComp.threshold, 0);
const [tx1, ty1] = toPx(activeComp.threshold, 20);
ctx.beginPath();
ctx.moveTo(tx0, ty0);
ctx.lineTo(tx1, ty1);
@@ -1,4 +1,4 @@
import { DSP } from '../../../../types';
import { DSP, isConnected } from '../../../../types';
import Button from '../../../../components/Button/Button';
import Card from '../../../../components/Card/Card';
import Led from '../../../../components/Led/Led';
@@ -14,13 +14,14 @@ function ConnectionPanel({
onConnect: () => void;
onDisconnect: () => void;
}) {
const statusLabel =
dsp.status === 'connected'
? 'Connected'
: dsp.status === 'connecting'
? 'Connecting…'
: 'Disconnected';
const statusLabel =
isConnected(dsp)
? 'Connected'
: dsp.isConnecting
? 'Connecting…'
: 'Disconnected';
return (
<div className="connection-section">
<Card className="connection-panel">
@@ -35,10 +36,10 @@ function ConnectionPanel({
<div className="connection-panel__status">
<span className="connection-panel__led">
<Led status={dsp.status} />
<Led status={dsp.isConnecting ? "connecting" : isConnected(dsp) ? "connected" : "disconnected"} />
</span>
<span className={`status-label status-label--${dsp.status}`}>{statusLabel}</span>
<span>{statusLabel}</span>
</div>
</div>
@@ -64,13 +65,13 @@ function ConnectionPanel({
</dl>
<div className="connection-panel__actions">
{dsp.status === 'connected' ? (
{isConnected(dsp) ? (
<Button variant="outline" onClick={onDisconnect}>
Disconnect
</Button>
) : (
<Button variant="primary" onClick={onConnect} disabled={dsp.status === 'connecting'}>
{dsp.status === 'connecting' ? 'Connecting…' : 'Connect'}
<Button variant="primary" onClick={onConnect} disabled={dsp.isConnecting}>
{dsp.isConnecting ? 'Connecting…' : 'Connect'}
</Button>
)}
</div>
@@ -29,3 +29,9 @@
.app-root:has(.sidebar-rail--expanded) .dsp-loading-overlay {
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 { BoltIcon, CompressorIcon, DelayIcon, GainIcon, GateIcon, InfoIcon, LimiterIcon, MatrixIcon, SlidersVerticalIcon, 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 GainSection from '../GainSection/GainSection';
import CompressorSection from '../CompressorSection/CompressorSection';
@@ -17,11 +28,13 @@ import { DSP408 } from '../../../../types';
import { SidebarId } from '../../constants';
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 }) {
const [activeSidebar, setActiveSidebar] = useState<SidebarId>('overview');
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 inputStates = dsp.state.current_config.input_states;
@@ -36,84 +49,51 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
{ id: 'delay', label: 'Delay', icon: <DelayIcon /> },
{ id: 'matrix', label: 'Matrix', icon: <MatrixIcon /> },
{ id: 'geq', label: 'Graphic Equalizer', icon: <SlidersVerticalIcon /> },
{
id: 'ina',
label: inputStates[InputChannel.InA].name,
icon: <TextIcon>In A</TextIcon>,
},
{
id: 'inb',
label: inputStates[InputChannel.InB].name,
icon: <TextIcon>In B</TextIcon>,
},
{
id: 'inc',
label: inputStates[InputChannel.InC].name,
icon: <TextIcon>In C</TextIcon>,
},
{
id: 'ind',
label: inputStates[InputChannel.InD].name,
icon: <TextIcon>In D</TextIcon>,
},
{
id: 'out1',
label: outputStates[OutputChannel.Out1].name,
icon: <TextIcon>Out 1</TextIcon>,
},
{
id: 'out2',
label: outputStates[OutputChannel.Out2].name,
icon: <TextIcon>Out 2</TextIcon>,
},
{
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: '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>, editable: true },
{ 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>, editable: true },
{ id: 'out1', label: outputStates[OutputChannel.Out1].name, icon: <TextIcon>Out 1</TextIcon>, editable: true },
{ id: 'out2', label: outputStates[OutputChannel.Out2].name, icon: <TextIcon>Out 2</TextIcon>, editable: true },
{ id: 'out3', label: outputStates[OutputChannel.Out3].name, icon: <TextIcon>Out 3</TextIcon>, editable: true },
{ 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: 'out7', label: outputStates[OutputChannel.Out7].name, icon: <TextIcon>Out 7</TextIcon>, editable: true },
{ id: 'out8', label: outputStates[OutputChannel.Out8].name, icon: <TextIcon>Out 8</TextIcon>, editable: true },
{ id: 'miscellaneous', label: 'Miscellaneous', icon: <BoltIcon /> },
];
const presets = dsp.state.presets.names
? [
{ id: 'f00', name: 'Factory Preset' },
...dsp.state.presets.names.map((name, index) => ({
id: `u${String(index + 1).padStart(2, '0')}`,
name,
})),
]
: [
{ id: 'f00', name: 'Factory Preset' },
...Array.from({ length: 20 }, (_, index) => ({
id: `u${String(index + 1).padStart(2, '0')}`,
name: 'Default Preset',
})),
];
const presets = [
{ id: 'f00', name: 'Factory Preset' },
...(dsp.state.presets.names.map((name, index) => ({
id: `u${String(index + 1).padStart(2, '0')}`,
name,
})) ??
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 presetIndex = presets.findIndex((preset) => preset.id === id);
@@ -125,6 +105,14 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
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}
@@ -216,26 +204,36 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
return (
<div className={`app-root ${sidebarExpanded ? 'app-root--sidebar-expanded' : ''}`}>
<Sidebar
items={sidebarItems}
activeId={activeSidebar}
onSelect={setActiveSidebar}
appName={dsp.name}
appOptions={presets}
selectedAppId={presets[selectedPreset]?.id}
onAppChange={onAppChange}
onAppRename={api.setCurrentPresetName}
expanded={sidebarExpanded}
onToggle={() => setSidebarExpanded((v) => !v)}
/>
<Sidebar
items={sidebarItems}
activeId={activeSidebar}
onSelect={setActiveSidebar}
appName={dsp.name}
appOptions={presets}
selectedAppId={presets[selectedPreset]?.id}
onAppChange={onAppChange}
onAppRename={api.setCurrentPresetName}
onItemRename={onItemRename}
expanded={sidebarExpanded}
onToggle={() => setSidebarExpanded((v) => !v)}
/>
<LoadingOverlay
active={presetLoading}
label="Recalling preset…"
overlayClassName="dsp-loading-overlay"
>
<div className="dsp-content">{renderTab()}</div>
</LoadingOverlay>
<LoadingOverlay
active={presetLoading}
label="Recalling preset…"
overlayClassName="dsp-loading-overlay"
>
<div className="dsp-content">{renderTab()}</div>
</LoadingOverlay>
{dsp.passwordRequired && (
<PasswordDialog
name={dsp.name}
onSubmit={(pw: string) => api.connectWithPassword(pw)}
onCancel={() => api.cancelPassword()}
/>
)}
</div>
);
}
@@ -1,5 +1,5 @@
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 Button from '../../../../components/Button/Button';
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
@@ -58,23 +58,33 @@ function getGainCurvePoints(freqs: number[], gainDb: number): CurvePoint[] {
function getPeqCrossoverPoints(
freqs: number[],
peqChain: { bands: Record<string, unknown> },
peqChain: {
bypass: boolean;
bands: Record<PEQBand, PEQ>;
},
crossover: {
high_pass: { frequency: number; slope: unknown };
low_pass: { frequency: number; slope: unknown };
high_pass: { frequency: number; slope: CrossoverFilter };
low_pass: { frequency: number; slope: CrossoverFilter };
}
) {
return sumCurvePoints([
getPeqCurvePoints({ freqs, bands: peqChain.bands as any }),
...(peqChain.bypass
? []
: [
getPeqCurvePoints({
freqs,
bands: peqChain.bands,
}),
]),
getCrossoverCurvePoints({
freqs,
highPass: {
frequency: crossover.high_pass.frequency,
filter_type: crossover.high_pass.slope as any,
filter_type: crossover.high_pass.slope,
},
lowPass: {
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
): ChannelCurves {
const gain = liveGain ?? state.gain;
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 total = sumCurvePoints([geq, peqCrossover, getGainCurvePoints(freqs, gain)]);
return { geq, peqCrossover, total };
@@ -17,7 +17,7 @@ import {
GEQ_BANDS_LABEL,
GEQ_CURVE_Q,
} from '../../constants';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import './GeqSection.css';
import { getGeqCurvePoints } from '../../utils/graph/geq';
import { logSpace } from '../../utils/graph/curve';
@@ -175,7 +175,7 @@ function GeqActions({
<div className="geq-channel-selector">
<Button
variant="toggle"
className={bypass ? 'active-info' : ''}
className={bypass ? 'active-warning' : ''}
onClick={onBypass}
style={{
width: '100%',
@@ -201,7 +201,7 @@ function GeqActions({
minHeight: 0,
}}
>
{resetting ? 'Resetting…' : 'Reset'} {/* NEW */}
{resetting ? 'Resetting…' : 'Reset'}
</Button>
</div>
);
@@ -219,6 +219,20 @@ function GeqSection({
const [activeChannel, setActiveChannel] = useState<InputChannel>(INPUT_ORDER[0]);
const [liveGeq, setLiveGeq] = useState<Partial<Record<InputChannel, LiveBands>>>({});
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) => {
setLiveGeq((prev) => ({
@@ -245,6 +259,10 @@ function GeqSection({
setGeqBypass(activeChannel, !activeGeq.bypass);
};
const handleResetClick = () => {
setIsResetConfirmOpen(true);
};
// Sequential per-band reset, since hardware has no bulk reset command.
const handleReset = async (channel: InputChannel) => {
if (resetting) return;
@@ -261,6 +279,11 @@ function GeqSection({
setResetting(false);
};
const confirmReset = async () => {
setIsResetConfirmOpen(false);
await handleReset(activeChannel);
};
const activeInputState = dsp.state.current_config.input_states[activeChannel];
const activeGeq = activeInputState.geq;
const activeLiveBands = liveGeq[activeChannel];
@@ -306,12 +329,33 @@ function GeqSection({
bypass={activeGeq.bypass}
resetting={resetting}
onBypass={handleBypassClick}
onReset={() => handleReset(activeChannel)}
onReset={handleResetClick}
/>
</ChannelGroup>
</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>
)}
</>
);
}
@@ -13,6 +13,7 @@ import {
} from '../../constants';
import { SidebarId } from '../../constants';
import SignalFlowMatrix from '../../../../components/SignalFlowMatrix/SignalFlowMatrix';
import { useState } from 'react';
import './Matrixsection.css';
const GAIN_VALUES: number[] = (() => {
@@ -39,6 +40,12 @@ function getRoutedInputs(dsp: DSP, output: OutputChannel): InputChannel[] {
return getMatrixRoutes(dsp, output).connected;
}
function snapToGain(g: number): number {
return GAIN_VALUES.reduce((best, v) =>
Math.abs(v - g) < Math.abs(best - g) ? v : best
);
}
function MatrixSection({
dsp,
setMatrixRouting,
@@ -68,6 +75,21 @@ function MatrixSection({
void setMatrixGain(input, output, gain);
};
const [liveGains, setLiveGains] = useState<Record<string, number>>({});
// value = number while wheeling, null once the commit is done
const previewGain = (input: InputChannel, output: OutputChannel, value: number | null) => {
const key = `${input}-${output}`;
setLiveGains((prev) => {
if (value === null) {
if (!(key in prev)) return prev;
const { [key]: _removed, ...rest } = prev;
return rest;
}
return { ...prev, [key]: value };
});
};
return (
<>
<div className="matrix-flow">
@@ -76,6 +98,7 @@ function MatrixSection({
setMatrixRouting={setMatrixRouting}
setMatrixGain={setMatrixGain}
setActiveSidebar={setActiveSidebar}
onGainPreview={previewGain}
/>
</div>
<div className="matrix-section">
@@ -90,7 +113,7 @@ function MatrixSection({
<div className="matrix-panel__rows">
{INPUT_ORDER.map((input) => {
const connected = isRouted(dsp, output, input);
const gain = getLinkGain(dsp, output, input);
const gain = liveGains[`${input}-${output}`] ?? getLinkGain(dsp, output, input);
return (
<div className="matrix-panel__row" key={String(input)}>
@@ -111,7 +134,7 @@ function MatrixSection({
<ChoiceStepper
className="matrix-gain-stepper"
value={gain}
value={snapToGain(gain)}
values={GAIN_VALUES}
formatLabel={(v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`}
onChange={(g) => handleGainChange(output, input, g)}
@@ -9,31 +9,13 @@
.miscellaneous-panel {
width: 100%;
max-width: 720px;
/* margin: 0 auto; */
}
.miscellaneous-panel__header {
margin-bottom: var(--space-6);
}
.miscellaneous-panel__title {
margin: 0;
font-size: var(--font-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.miscellaneous-panel__hint {
margin: var(--space-2) 0 0;
font-size: var(--font-sm);
color: var(--text-muted);
}
/* Sections */
.miscellaneous-panel__section {
padding-top: var(--space-5);
margin-top: var(--space-5);
padding-top: var(--space-2);
margin-top: var(--space-2);
border-top: var(--border-width) solid var(--border-hairline);
}
@@ -45,7 +27,9 @@
}
.miscellaneous-panel__section-header {
margin-bottom: var(--space-4);
display: flex;
align-items: center;
justify-content: space-between;
}
.miscellaneous-panel__section-header h2 {
@@ -57,7 +41,7 @@
}
.miscellaneous-panel__section-header p {
margin: var(--space-1) 0 0;
margin: 0;
font-size: var(--font-sm);
line-height: 1.4;
@@ -74,10 +58,12 @@
.miscellaneous-panel__field {
display: flex;
flex-direction: column;
gap: var(--space-2);
gap: 0;
flex: 1;
min-width: 0;
font-family: var(--font-mono);
}
.miscellaneous-panel__field label {
@@ -122,12 +108,12 @@
/* Stepper */
.miscellaneous-panel__field .choice-stepper__input {
border: none;
background: transparent;
font-family: var(--font-mono);
font-weight: var(--font-weight-normal);
text-align: left;
.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 */
@@ -138,19 +124,62 @@
align-items: center;
gap: var(--space-3);
margin-top: var(--space-4);
margin-top: var(--space-2);
}
/* Danger */
.miscellaneous-panel__section--danger {
padding-top: var(--space-5);
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) {
@@ -218,43 +247,4 @@
margin-top: var(--space-5);
}
/* 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%;
}
@@ -37,10 +37,8 @@ function MiscellaneousPanel({
const [isLinkModalOpen, setIsLinkModalOpen] = useState(false);
const [isCopyModalOpen, setIsCopyModalOpen] = useState(false);
const [inputSource, setInputSourceState] = useState<InputSource>({
type: InputType.Analog,
frequency: DiscreteFrequency.F20,
});
const [liveInputSource, setLiveInputSource] = useState<InputSource | null>(null);
const inputSource = liveInputSource ?? dsp.state.current_config.input_source;
const [password, setPassword] = useState('');
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
@@ -51,18 +49,19 @@ function MiscellaneousPanel({
frequency: type === InputType.SineWave ? inputSource.frequency : DiscreteFrequency.F20,
};
setInputSourceState(nextSource);
await setInputSource(nextSource);
setLiveInputSource(nextSource);
const success = await setInputSource(nextSource);
if (success) setLiveInputSource(null);
};
const handleFrequencyChange = async (frequency: DiscreteFrequency) => {
const nextSource: InputSource = {
...inputSource,
frequency,
};
const nextSource: InputSource = { ...inputSource, frequency };
setInputSourceState(nextSource);
await setInputSource(nextSource);
setLiveInputSource(nextSource);
const success = await setInputSource(nextSource);
if (success) setLiveInputSource(null);
};
const handleFactoryReset = () => {
@@ -109,10 +108,11 @@ function MiscellaneousPanel({
<ChoiceSelect<InputType>
id="misc-input-type"
className='miscellaneous-panel__choice-select'
value={inputSource.type}
options={INPUT_TYPES}
onChange={handleInputTypeChange}
style={{color: 'var(--font-primary)'}}
style={{ color: 'var(--font-primary)' }}
/>
</div>
@@ -122,6 +122,7 @@ function MiscellaneousPanel({
<ChoiceSelect<DiscreteFrequency>
id="misc-frequency"
className='miscellaneous-panel__choice-select'
value={inputSource.frequency}
options={DISCRETE_FREQUENCIES}
formatLabel={(frequency) => {
@@ -132,7 +133,7 @@ function MiscellaneousPanel({
return `${frequency.slice(1)} Hz`;
}}
onChange={handleFrequencyChange}
style={{color: 'var(--font-primary)'}}
style={{ color: 'var(--font-primary)' }}
/>
</div>
)}
@@ -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' },
];
/** 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_MAX = 20160;
export const FREQUENCY_QUANTIZATION = 300;
+57 -22
View File
@@ -12,6 +12,7 @@ import {
PEQBand,
InputSource,
DSP408Snapshot,
DSPMode,
} from './types';
import { SetDsps, NotifyFn, updateDSP, setPollingPaused } from '../../dsp/dspShared';
import { registerPollStopper } from '../../dsp/pollRegistry';
@@ -25,7 +26,7 @@ export type DSP408Api = ReturnType<typeof forDSP408>;
const selectedIds = new Set<number>();
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;
function pollIntervalFor(id: number): number {
@@ -117,8 +118,8 @@ export async function create(params: {
ip: params.ip,
port: params.port,
deviceId: params.deviceId,
mode: snapshot.mode,
status: 'disconnected',
mode: params.demo ? DSPMode.Demo: DSPMode.Normal,
isConnecting: false,
pollingPaused: true,
state: snapshot.state,
input_meters: snapshot.input_meters,
@@ -127,16 +128,61 @@ export async function create(params: {
}
export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
async function updateDSPState(): Promise<void> {
async function updateDSPState(): Promise<DSP408['state']> {
try {
const state = await invoke<DSP408['state']>('get_dsp_state', { id: dsp.id });
updateDSP(setDsps, dsp.id, (d) => ({ ...d, state }) as DSP408);
return state;
} catch (err) {
notify(`Failed to fetch state: ${String(err)}`, 'error', dsp.name);
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 {
type: 'DSP408' as const,
@@ -155,22 +201,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
updateDSPState,
async connect() {
updateDSP(setDsps, dsp.id, (d) => ({ ...d, status: 'connecting' }));
try {
await invoke('connect_dsp408', { id: dsp.id });
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);
}
connect: () => connectInternal(),
connectWithPassword: (password: string) => connectInternal(password),
cancelPassword() {
updateDSP(setDsps, dsp.id, (d) => ({ ...d, passwordRequired: false }));
},
async disconnect() {
@@ -181,9 +215,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
await invoke('disconnect_dsp408', { id: dsp.id });
});
await updateDSPState();
updateDSP(setDsps, dsp.id, (d) => ({
...d,
status: 'disconnected',
pollingPaused: true,
}));
@@ -773,7 +808,7 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
await updateDSPState();
notify('Channels copied successfully', 'success', dsp.name)
notify('Channels copied successfully', 'success', dsp.name);
return true;
} catch (err) {
+10 -2
View File
@@ -243,15 +243,23 @@ export type DSPState = {
flags: DeviceFlags;
presets: PresetBank;
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 = {
state: DSPState;
input_meters: DSP408InputMeters;
output_meters: DSP408OutputMeters;
mode: DSPMode;
};
export interface Meter {
+12 -16
View File
@@ -139,16 +139,14 @@ function peqBandResponseDb(
/** Sum of all bands' contributions at a given frequency. */
function peqTotalCurveDb(
freq: number,
bands: Record<PEQBand, PEQ>,
bands: Partial<Record<PEQBand, PEQ>>,
sampleRate: number = DEFAULT_SAMPLE_RATE
): number {
let sum = 0;
for (const band of Object.values(PEQBand)) {
sum += peqBandResponseDb(freq, bands[band], sampleRate);
}
return sum;
return Object.values(bands).reduce(
(sum, band) =>
band ? sum + peqBandResponseDb(freq, band, sampleRate) : sum,
0
);
}
/**
@@ -307,16 +305,14 @@ function peqBandPhaseDeg(
function peqTotalPhaseDeg(
freq: number,
bands: Record<PEQBand, PEQ>,
bands: Partial<Record<PEQBand, PEQ>>,
sampleRate: number = DEFAULT_SAMPLE_RATE
): number {
let sum = 0;
for (const band of Object.values(PEQBand)) {
sum += peqBandPhaseDeg(freq, bands[band], sampleRate);
}
return sum;
return Object.values(bands).reduce(
(sum, band) =>
band ? sum + peqBandPhaseDeg(freq, band, sampleRate) : sum,
0
);
}
export function getPeqPhasePoints({
+8 -4
View File
@@ -3,10 +3,9 @@ import {
DSPMode,
DSP408OutputMeters,
DSP408InputMeters,
ConnectionStatus,
} from './features/dsp408/types';
export type DSPStatus = 'connected' | 'connecting' | 'disconnected';
export type NotificationType = 'success' | 'error' | 'warning';
export type Notification = {
id: number;
@@ -38,11 +37,16 @@ interface DSPBase {
deviceId: number;
mode: DSPMode;
status: DSPStatus;
passwordRequired?: boolean;
isConnecting: boolean;
pollingPaused: boolean;
}
export function isConnected(dsp: DSP): boolean {
return dsp.type === 'DSP408'
&& dsp.state.connection === ConnectionStatus.Connected;
}
export type DSP408 = DSPBase & {
type: 'DSP408';
state: DSP408State;