commit 14b34b172794748c6d3fa9f23f7ba33fc19bb37c
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date: Sun, 31 May 2026 17:02:20 -0500
Valentine: terminal control panel for Scarlett 18i20 3rd Gen
scarlett2 USB control protocol (clean-room, rusb/EP0) + ratatui TUI.
Six panels (Inputs/Monitor/Mixer/Routing/Meters/Clock), presets +
standalone NVRAM save, swappable themes (Ember default). Protocol layer
hardware-validated on the 18i20 g3; routing read-only for now.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
28 files changed, 4977 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,3 @@
+/target
+**/*.rs.bk
+.DS_Store
diff --git a/Cargo.lock b/Cargo.lock
@@ -0,0 +1,881 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+
+[[package]]
+name = "cassowary"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
+
+[[package]]
+name = "castaway"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "compact_str"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "static_assertions",
+]
+
+[[package]]
+name = "crossterm"
+version = "0.28.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
+dependencies = [
+ "bitflags",
+ "crossterm_winapi",
+ "mio",
+ "parking_lot",
+ "rustix",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm_winapi"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "darling"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "dirs-next"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
+dependencies = [
+ "cfg-if",
+ "dirs-sys-next",
+]
+
+[[package]]
+name = "dirs-sys-next"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
+[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "indoc"
+version = "2.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "instability"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
+dependencies = [
+ "darling",
+ "indoc",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libredox"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "libusb1-sys"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
+
+[[package]]
+name = "lru"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
+
+[[package]]
+name = "mio"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "ratatui"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
+dependencies = [
+ "bitflags",
+ "cassowary",
+ "compact_str",
+ "crossterm",
+ "indoc",
+ "instability",
+ "itertools",
+ "lru",
+ "paste",
+ "strum",
+ "unicode-segmentation",
+ "unicode-truncate",
+ "unicode-width 0.2.0",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom",
+ "libredox",
+ "thiserror",
+]
+
+[[package]]
+name = "rusb"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4"
+dependencies = [
+ "libc",
+ "libusb1-sys",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "scarlett-core"
+version = "0.0.0"
+dependencies = [
+ "rusb",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
+dependencies = [
+ "libc",
+ "mio",
+ "signal-hook",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "spike"
+version = "0.0.0"
+dependencies = [
+ "anyhow",
+ "rusb",
+ "scarlett-core",
+]
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_write",
+ "winnow",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
+
+[[package]]
+name = "unicode-truncate"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
+dependencies = [
+ "itertools",
+ "unicode-segmentation",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
+
+[[package]]
+name = "valentine"
+version = "0.0.0"
+dependencies = [
+ "anyhow",
+ "crossterm",
+ "dirs-next",
+ "ratatui",
+ "scarlett-core",
+ "serde",
+ "toml",
+]
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
@@ -0,0 +1,19 @@
+# Valentine — a terminal replacement for Focusrite Control (Scarlett 18i20 3rd Gen)
+#
+# Workspace layout (core + bin split, like ivory-rust):
+# scarlett-core — pure scarlett2 protocol + device model (no UI, fully testable)
+# valentine — ratatui front-end (added in Phase 2)
+# spike — Phase 0 USB feasibility gate (throwaway; remove once core lands)
+
+[workspace]
+members = ["scarlett-core", "valentine", "spike"]
+resolver = "2"
+
+[workspace.package]
+edition = "2021"
+license = "MIT"
+authors = ["Ganten"]
+
+[workspace.dependencies]
+nusb = "0.2"
+anyhow = "1"
diff --git a/README.md b/README.md
@@ -0,0 +1,107 @@
+# Valentine
+
+A terminal control panel for the **Focusrite Scarlett 18i20 (3rd Gen)** on macOS —
+a from-scratch replacement for Focusrite Control, in the terminal.
+
+Valentine speaks the device's proprietary "scarlett2" USB control protocol directly
+(the same one Focusrite Control uses), so you get the mixer, routing, preamp
+options, monitor section, metering, and clock status without the GUI — while Core
+Audio keeps streaming audio normally.
+
+> Status: works on the author's 18i20 3rd Gen. The protocol layer is hardware-
+> validated; routing is read-only for now (see **Limitations**).
+
+## Features
+
+- **Inputs** — Air, Pad, Inst/Line, and 48 V phantom per channel
+- **Monitor** — master level, Mute, Dim
+- **Mixer** — the full crosspoint matrix, gain in dB
+- **Routing** — see what feeds each output (read-only)
+- **Meters** — live input/output/mixer levels
+- **Clock** — sync-lock status
+- **Presets** — save/load a full configuration to disk
+- **Standalone** — write the current config to the device's NVRAM
+- **Themes** — a swappable TOML palette; ships with "Ember" (red/magenta)
+
+## Install
+
+Requires a Rust toolchain. No system libraries needed — `libusb` is built from
+source (vendored).
+
+```
+cargo build --release
+./target/release/valentine
+```
+
+Quit Focusrite Control first — only one application can own the device at a time.
+Valentine must be run in a real interactive terminal.
+
+## Keys
+
+```
+Tab / Shift-Tab switch panels
+↑ ↓ ← → / hjkl move within a panel
+Space / Enter toggle the focused control
++ - 0 m mixer: ±1 dB, unity, mute
+S save preset to ~/.config/valentine/presets/preset.json
+L load preset and apply
+W write current config to device NVRAM (standalone)
+r reconnect ? help q quit
+```
+
+## Theming
+
+The palette is a TOML file. The bundled default is **Ember**
+(`themes/default.toml`). To use your own, copy it to
+`~/.config/valentine/theme.toml` and edit the hex values — it's loaded on startup
+if present.
+
+## Project layout
+
+```
+valentine/
+├── scarlett-core/ protocol engine (no UI, fully unit-tested)
+│ └── src/
+│ ├── packet.rs 16-byte scarlett2 wire codec
+│ ├── transport.rs USB EP0 transport (rusb) + test mock
+│ ├── protocol.rs init / get / set / activate / sync
+│ ├── model.rs 18i20 g3 config-offset map + topology
+│ ├── controls.rs named controls (air, pad, 48V, monitor…)
+│ ├── matrix.rs mixer / mux / meter ops + dB conversion
+│ ├── ports.rs hardware port-ID → name decoding
+│ └── preset.rs save/apply a configuration snapshot
+├── valentine/ the ratatui TUI
+│ └── src/
+│ ├── main.rs app loop, key handling, layout
+│ ├── theme.rs palette loading
+│ └── panels/ one module per tab
+└── themes/
+ └── default.toml the bundled "Ember" palette
+```
+
+## How it works
+
+Focusrite interfaces are USB-Audio class compliant, so audio streaming needs no
+driver. Everything else — mixer, routing, preamp, monitor, clock — is driven by
+vendor control transfers on endpoint 0 of a vendor-specific interface. Valentine
+implements that wire protocol in `scarlett-core`. The protocol facts (opcodes,
+packet layout, config offsets, port IDs) are interoperability data derived from
+the GPL Linux kernel driver `sound/usb/mixer_scarlett2.c` and the work of the
+[alsa-scarlett-gui](https://github.com/geoffreybennett/alsa-scarlett-gui) project;
+no GPL source is copied.
+
+## Limitations
+
+- **Scarlett 18i20 3rd Gen only.** The protocol generalizes (the core carries a
+ per-model descriptor), but only this model is wired up and tested.
+- **Routing is read-only.** Editing requires the device's per-sample-rate mux-table
+ write semantics, which aren't implemented/verified yet.
+- **Firmware version** may read as 0 (its reply arrives on an interrupt endpoint
+ macOS won't let us claim while the Focusrite daemon is present).
+- **Meter scaling** is approximate pending a proper dB calibration.
+
+## Credits
+
+Built on the reverse-engineering of the scarlett2 protocol by Geoffrey D. Bennett
+and contributors (Linux kernel driver + alsa-scarlett-gui). Valentine is an
+independent macOS reimplementation of the control protocol.
diff --git a/scarlett-core/Cargo.toml b/scarlett-core/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "scarlett-core"
+version = "0.0.0"
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+description = "scarlett2 control protocol and device model for Focusrite Scarlett interfaces"
+
+[dependencies]
+# rusb (libusb, vendored = built from source, no system dependency). Chosen over
+# nusb because on macOS nusb's device-level control transfers auto-claim the
+# device, which fails here (AppleUSBAudio owns the audio interfaces; the vendor
+# interface is held by FocusriteControlServer). rusb's read_control/write_control
+# drive EP0 with no claim — the path proven on the real 18i20 by the Phase-0 spike.
+rusb = { version = "0.9", features = ["vendored"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
diff --git a/scarlett-core/src/controls.rs b/scarlett-core/src/controls.rs
@@ -0,0 +1,289 @@
+//! High-level, named controls built on the config-offset map ([`crate::model`])
+//! and the raw primitives ([`crate::protocol`]). This is the layer the TUI talks
+//! to: "turn on Air for input 3" rather than "write 1 at offset 0x8f, activate 8".
+//!
+//! Every setter writes the value then sends the parameter's activation code, so
+//! the change takes effect immediately (the device's two-step set/activate).
+
+use crate::model::{Param, S18I20_GEN3};
+use crate::protocol::Scarlett;
+use crate::transport::{Transport, TransportError};
+
+/// The device stores signed monitor volume in dB as `dB + VOLUME_BIAS`, giving
+/// a 0..=127 range where 127 = 0 dB (unity) and 0 = -127 dB (≈ off).
+pub const VOLUME_BIAS: i16 = 127;
+
+/// The two monitor hardware buttons, in device index order (`DIM_MUTE` config).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum MonitorButton {
+ Mute = 0,
+ Dim = 1,
+}
+
+/// The per-channel boolean switches on the input strip.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum InputSwitch {
+ /// Air (ISA-preamp emulation). Per input (8).
+ Air,
+ /// 10 dB pad. Per input (8).
+ Pad,
+ /// Inst (vs Line) level/impedance. Per input (first 2).
+ Inst,
+}
+
+impl InputSwitch {
+ fn param(self) -> Param {
+ match self {
+ InputSwitch::Air => Param::AirSwitch,
+ InputSwitch::Pad => Param::PadSwitch,
+ InputSwitch::Inst => Param::LevelSwitch,
+ }
+ }
+
+ /// How many input channels this switch applies to on the 18i20 g3.
+ pub fn channel_count(self) -> u8 {
+ match self {
+ InputSwitch::Air => S18I20_GEN3.air_input_count,
+ InputSwitch::Pad => S18I20_GEN3.pad_input_count,
+ InputSwitch::Inst => S18I20_GEN3.level_input_count,
+ }
+ }
+}
+
+/// A snapshot of the monitor/output section.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct MonitorState {
+ /// Master monitor level in dB (0 = unity).
+ pub master_db: i16,
+ pub mute: bool,
+ pub dim: bool,
+}
+
+/// A snapshot of the whole input strip, as read from the device.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct InputState {
+ pub air: Vec<bool>,
+ pub pad: Vec<bool>,
+ pub inst: Vec<bool>,
+ /// Per phantom *group* (2 groups of 4 inputs each on the 18i20 g3).
+ pub phantom: Vec<bool>,
+}
+
+impl<T: Transport> Scarlett<T> {
+ /// Read a single byte-sized switch for `channel` (0-based).
+ pub fn get_input_switch(
+ &mut self,
+ switch: InputSwitch,
+ channel: u8,
+ ) -> Result<bool, TransportError> {
+ let cfg = switch.param().config();
+ let off = cfg.channel_offset(channel as u32);
+ let data = self.get_data(off, cfg.byte_width())?;
+ Ok(data.first().is_some_and(|&b| b != 0))
+ }
+
+ /// Set a byte-sized switch for `channel`, then activate it.
+ pub fn set_input_switch(
+ &mut self,
+ switch: InputSwitch,
+ channel: u8,
+ on: bool,
+ ) -> Result<(), TransportError> {
+ let cfg = switch.param().config();
+ let off = cfg.channel_offset(channel as u32);
+ self.set_data(off, cfg.byte_width(), on as u32)?;
+ self.activate(cfg.activate as u32)
+ }
+
+ /// Read a 48V phantom *group* (0-based). Phantom is bit-sized, one byte per
+ /// group on this model.
+ pub fn get_phantom(&mut self, group: u8) -> Result<bool, TransportError> {
+ let cfg = Param::PhantomSwitch.config();
+ let off = cfg.channel_offset(group as u32);
+ let data = self.get_data(off, 1)?;
+ Ok(data.first().is_some_and(|&b| b != 0))
+ }
+
+ /// Set a 48V phantom group, then activate it.
+ pub fn set_phantom(&mut self, group: u8, on: bool) -> Result<(), TransportError> {
+ let cfg = Param::PhantomSwitch.config();
+ let off = cfg.channel_offset(group as u32);
+ self.set_data(off, 1, on as u32)?;
+ self.activate(cfg.activate as u32)
+ }
+
+ // ---- Monitor / output section -------------------------------------------
+
+ /// Read the master monitor volume as dB (signed; 0 = unity, negative = quieter).
+ /// `MASTER_VOLUME` is read-only — it tracks the hardware monitor knob.
+ pub fn get_master_volume_db(&mut self) -> Result<i16, TransportError> {
+ let cfg = Param::MasterVolume.config();
+ let data = self.get_data(cfg.offset as u32, 2)?;
+ let raw = i16::from_le_bytes([
+ *data.first().unwrap_or(&0),
+ *data.get(1).unwrap_or(&0),
+ ]);
+ Ok(raw)
+ }
+
+ /// Read a monitor button (Mute or Dim) state.
+ pub fn get_monitor_button(&mut self, btn: MonitorButton) -> Result<bool, TransportError> {
+ let cfg = Param::DimMute.config();
+ let off = cfg.offset as u32 + btn as u32;
+ let data = self.get_data(off, 1)?;
+ Ok(data.first().is_some_and(|&b| b != 0))
+ }
+
+ /// Set a monitor button (Mute or Dim), then activate it.
+ pub fn set_monitor_button(
+ &mut self,
+ btn: MonitorButton,
+ on: bool,
+ ) -> Result<(), TransportError> {
+ let cfg = Param::DimMute.config();
+ let off = cfg.offset as u32 + btn as u32;
+ self.set_data(off, 1, on as u32)?;
+ self.activate(cfg.activate as u32)
+ }
+
+ /// A snapshot of the monitor section for a UI refresh.
+ pub fn read_monitor_state(&mut self) -> Result<MonitorState, TransportError> {
+ Ok(MonitorState {
+ master_db: self.get_master_volume_db().unwrap_or(0),
+ mute: self.get_monitor_button(MonitorButton::Mute).unwrap_or(false),
+ dim: self.get_monitor_button(MonitorButton::Dim).unwrap_or(false),
+ })
+ }
+
+ /// Read the entire input strip in one call (for a UI refresh).
+ pub fn read_input_state(&mut self) -> Result<InputState, TransportError> {
+ let mut s = InputState::default();
+ for ch in 0..InputSwitch::Air.channel_count() {
+ s.air.push(self.get_input_switch(InputSwitch::Air, ch)?);
+ }
+ for ch in 0..InputSwitch::Pad.channel_count() {
+ s.pad.push(self.get_input_switch(InputSwitch::Pad, ch)?);
+ }
+ for ch in 0..InputSwitch::Inst.channel_count() {
+ s.inst.push(self.get_input_switch(InputSwitch::Inst, ch)?);
+ }
+ for g in 0..S18I20_GEN3.phantom_count {
+ s.phantom.push(self.get_phantom(g)?);
+ }
+ Ok(s)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::protocol::op;
+ use crate::transport::mock::MockTransport;
+
+ #[test]
+ fn set_air_writes_offset_then_activates() {
+ let mut m = MockTransport::new();
+ m.push_response(op::SET_DATA, &[]); // the write
+ m.push_response(op::DATA_CMD, &[]); // the activate
+ let mut dev = Scarlett::new(m);
+
+ // Air on input 2 (0-based) -> offset 0x8c + 2 = 0x8e, value 1, activate 8.
+ dev.set_input_switch(InputSwitch::Air, 2, true).unwrap();
+
+ let m = dev.into_transport();
+ assert_eq!(m.sent[0].0, op::SET_DATA);
+ // offset 0x8e LE, size 1 LE, value 1
+ assert_eq!(m.sent[0].1, vec![0x8e, 0, 0, 0, 1, 0, 0, 0, 1]);
+ assert_eq!(m.sent[1], (op::DATA_CMD, 8u32.to_le_bytes().to_vec()));
+ }
+
+ #[test]
+ fn get_air_reads_byte_as_bool() {
+ let mut m = MockTransport::new();
+ m.push_response(op::GET_DATA, &[1]);
+ let mut dev = Scarlett::new(m);
+ assert!(dev.get_input_switch(InputSwitch::Air, 0).unwrap());
+
+ let mut m2 = MockTransport::new();
+ m2.push_response(op::GET_DATA, &[0]);
+ let mut dev2 = Scarlett::new(m2);
+ assert!(!dev2.get_input_switch(InputSwitch::Air, 0).unwrap());
+ }
+
+ #[test]
+ fn phantom_group_offsets_and_activate() {
+ let mut m = MockTransport::new();
+ m.push_response(op::SET_DATA, &[]);
+ m.push_response(op::DATA_CMD, &[]);
+ let mut dev = Scarlett::new(m);
+
+ // group 1 -> offset 0x9c + 1 = 0x9d, activate 8
+ dev.set_phantom(1, true).unwrap();
+
+ let m = dev.into_transport();
+ assert_eq!(m.sent[0].1, vec![0x9d, 0, 0, 0, 1, 0, 0, 0, 1]);
+ assert_eq!(m.sent[1], (op::DATA_CMD, 8u32.to_le_bytes().to_vec()));
+ }
+
+ #[test]
+ fn monitor_button_offsets_and_activate() {
+ let mut m = MockTransport::new();
+ m.push_response(op::SET_DATA, &[]);
+ m.push_response(op::DATA_CMD, &[]);
+ let mut dev = Scarlett::new(m);
+
+ // Dim = index 1 -> offset 0x31 + 1 = 0x32, activate 2
+ dev.set_monitor_button(MonitorButton::Dim, true).unwrap();
+
+ let m = dev.into_transport();
+ assert_eq!(m.sent[0].1, vec![0x32, 0, 0, 0, 1, 0, 0, 0, 1]);
+ assert_eq!(m.sent[1], (op::DATA_CMD, 2u32.to_le_bytes().to_vec()));
+ }
+
+ #[test]
+ fn master_volume_reads_signed_db() {
+ let mut m = MockTransport::new();
+ // -6 dB as i16 LE
+ m.push_response(op::GET_DATA, &(-6i16).to_le_bytes());
+ let mut dev = Scarlett::new(m);
+ assert_eq!(dev.get_master_volume_db().unwrap(), -6);
+ }
+
+ #[test]
+ fn read_monitor_state_collects_vol_mute_dim() {
+ let mut m = MockTransport::new();
+ m.push_response(op::GET_DATA, &0i16.to_le_bytes()); // master 0 dB
+ m.push_response(op::GET_DATA, &[1]); // mute on
+ m.push_response(op::GET_DATA, &[0]); // dim off
+ let mut dev = Scarlett::new(m);
+ let s = dev.read_monitor_state().unwrap();
+ assert_eq!(s.master_db, 0);
+ assert!(s.mute);
+ assert!(!s.dim);
+ }
+
+ #[test]
+ fn read_input_state_fills_all_channels() {
+ let mut m = MockTransport::new();
+ // 8 air + 8 pad + 2 inst + 2 phantom = 20 GET_DATA responses
+ for _ in 0..8 {
+ m.push_response(op::GET_DATA, &[0]);
+ }
+ for _ in 0..8 {
+ m.push_response(op::GET_DATA, &[1]); // all pads on
+ }
+ for _ in 0..2 {
+ m.push_response(op::GET_DATA, &[0]);
+ }
+ for _ in 0..2 {
+ m.push_response(op::GET_DATA, &[1]); // both phantom groups on
+ }
+ let mut dev = Scarlett::new(m);
+
+ let s = dev.read_input_state().unwrap();
+ assert_eq!(s.air.len(), 8);
+ assert_eq!(s.pad, vec![true; 8]);
+ assert_eq!(s.inst, vec![false, false]);
+ assert_eq!(s.phantom, vec![true, true]);
+ }
+}
diff --git a/scarlett-core/src/lib.rs b/scarlett-core/src/lib.rs
@@ -0,0 +1,33 @@
+//! scarlett-core — clean-room Rust implementation of the Focusrite "scarlett2"
+//! USB control protocol (what Focusrite Control speaks to Scarlett / Clarett /
+//! Vocaster interfaces).
+//!
+//! Protocol facts (constants, packet layout, config offsets) are derived from the
+//! GPL Linux kernel driver `sound/usb/mixer_scarlett2.c` and `alsa-scarlett-gui`,
+//! and confirmed against real hardware by the Phase-0 spike. No GPL source is
+//! copied — only the wire format, which is interoperability fact.
+//!
+//! ## Validated transport (macOS, Scarlett 18i20 3rd Gen)
+//! - Commands go to the **vendor-specific interface (class 0xFF, wIndex 3)** over
+//! endpoint 0: OUT `0x21`/req 2, IN `0xA1`/req 3, raw priming read req 0.
+//! - The interrupt notify endpoint can't be claimed while FocusriteControlServer
+//! holds it, so live updates are obtained by **polling**.
+//!
+//! ## Layers
+//! - [`packet`] — the 16-byte wire packet codec.
+//! - [`transport`] — [`transport::Transport`]: USB (nusb, EP0) or a test mock.
+//! - [`protocol`] — [`protocol::Scarlett`]: init + get/set/activate/sync primitives.
+
+#![forbid(unsafe_code)]
+
+pub mod controls;
+pub mod matrix;
+pub mod model;
+pub mod packet;
+pub mod ports;
+pub mod preset;
+pub mod protocol;
+pub mod transport;
+
+pub use protocol::Scarlett;
+pub use transport::{Transport, TransportError, UsbTransport};
diff --git a/scarlett-core/src/matrix.rs b/scarlett-core/src/matrix.rs
@@ -0,0 +1,328 @@
+//! The mixer matrix, routing matrix (mux), and metering — the three parts of the
+//! device that aren't simple config offsets. Request/response framing transcribed
+//! from the kernel driver and confirmed against the gen3 device.
+//!
+//! - **Mixer** (`GET_MIX`/`SET_MIX`): per mix bus, a `u16` gain for each input.
+//! - **Routing** (`GET_MUX`/`SET_MUX`): a flat list of `u32` entries, each packing
+//! `destination | (source << 12)` — destination in the low 12 bits, source in
+//! the high 12 (confirmed against the kernel's `populate_mux`/`set_mux`).
+//! - **Meters** (`GET_METER`): a snapshot of `u32` levels, one per metering point.
+
+use crate::protocol::{op, Scarlett};
+use crate::transport::{Transport, TransportError};
+
+/// Mixer crosspoint gain range, in dB (−80 … +12, matching the device).
+pub const MIXER_MIN_DB: f32 = -80.0;
+pub const MIXER_MAX_DB: f32 = 12.0;
+/// Raw mixer value that means 0 dB (unity). The device's curve is
+/// `value = 8192 · 10^(dB/20)` (confirmed from the kernel's generating formula).
+const MIXER_UNITY: f32 = 8192.0;
+
+/// Convert a raw mixer value (as returned by [`Scarlett::get_mix`]) to dB,
+/// clamped to the device's range. 0 → silence floor (`MIXER_MIN_DB`).
+pub fn mixer_value_to_db(value: u16) -> f32 {
+ if value == 0 {
+ return MIXER_MIN_DB;
+ }
+ (20.0 * (value as f32 / MIXER_UNITY).log10()).clamp(MIXER_MIN_DB, MIXER_MAX_DB)
+}
+
+/// Convert a dB gain to the raw mixer value for [`Scarlett::set_mix`].
+pub fn db_to_mixer_value(db: f32) -> u16 {
+ let db = db.clamp(MIXER_MIN_DB, MIXER_MAX_DB);
+ (MIXER_UNITY * 10f32.powf(db / 20.0)) as u16
+}
+
+/// Bit shift separating source (high bits) from destination (low bits).
+const MUX_SRC_SHIFT: u32 = 12;
+/// Mask for the destination field (low 12 bits).
+const MUX_DST_MASK: u32 = (1 << MUX_SRC_SHIFT) - 1;
+
+/// One routing assignment: which `source` feeds which `dest` (sink). Both are
+/// device-internal hardware port IDs.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct MuxEntry {
+ pub source: u16,
+ pub dest: u16,
+}
+
+impl MuxEntry {
+ /// Pack into the device's `dest | (source << 12)` u32.
+ pub fn pack(self) -> u32 {
+ (self.dest as u32 & MUX_DST_MASK) | ((self.source as u32) << MUX_SRC_SHIFT)
+ }
+
+ /// Unpack a device u32 into source/dest.
+ pub fn unpack(raw: u32) -> Self {
+ MuxEntry {
+ dest: (raw & MUX_DST_MASK) as u16,
+ source: (raw >> MUX_SRC_SHIFT) as u16,
+ }
+ }
+}
+
+/// Convenience operations layered on a connected [`Scarlett`].
+impl<T: Transport> Scarlett<T> {
+ /// Read the input gains feeding mix bus `mix_num`. Returns one raw `u16`
+ /// mixer value per input (`num_inputs`); convert to dB with the device's
+ /// mixer curve at the UI layer.
+ pub fn get_mix(&mut self, mix_num: u16, num_inputs: usize) -> Result<Vec<u16>, TransportError> {
+ // Request is { mix_num: u16, count: u16 } (the kernel sends both; sending
+ // only mix_num gets device error 0x9).
+ let mut req = Vec::with_capacity(4);
+ req.extend_from_slice(&mix_num.to_le_bytes());
+ req.extend_from_slice(&(num_inputs as u16).to_le_bytes());
+ let resp = self.command(op::GET_MIX, &req, num_inputs * 2)?;
+ Ok(le_u16s(&resp.payload, num_inputs))
+ }
+
+ /// Set every input gain for mix bus `mix_num` at once (raw mixer `u16`s).
+ pub fn set_mix(&mut self, mix_num: u16, levels: &[u16]) -> Result<(), TransportError> {
+ let mut payload = Vec::with_capacity(2 + levels.len() * 2);
+ payload.extend_from_slice(&mix_num.to_le_bytes());
+ for &l in levels {
+ payload.extend_from_slice(&l.to_le_bytes());
+ }
+ self.command(op::SET_MIX, &payload, 0)?;
+ Ok(())
+ }
+
+ /// Read the full routing table: `count` destination assignments.
+ pub fn get_mux(&mut self, count: usize) -> Result<Vec<MuxEntry>, TransportError> {
+ let mut payload = Vec::with_capacity(4);
+ payload.extend_from_slice(&0u16.to_le_bytes()); // num (start index)
+ payload.extend_from_slice(&(count as u16).to_le_bytes());
+ let resp = self.command(op::GET_MUX, &payload, count * 4)?;
+ Ok(le_u32s(&resp.payload, count)
+ .into_iter()
+ .map(MuxEntry::unpack)
+ .collect())
+ }
+
+ /// Read the routing table and decode each entry to `(sink_name, source_name)`
+ /// using [`crate::ports`]. `count` is the number of destinations to read.
+ ///
+ /// NOTE: read-only/display use. Editing routing needs the kernel's 3-table
+ /// (per sample-rate-band) write semantics, which [`Self::set_mux`] does not
+ /// yet replicate — verify on hardware before exposing edits.
+ pub fn read_routing(&mut self, count: usize) -> Result<Vec<(String, String)>, TransportError> {
+ Ok(self
+ .get_mux(count)?
+ .into_iter()
+ .map(|e| {
+ (
+ crate::ports::sink_name(e.dest),
+ crate::ports::source_name(e.source),
+ )
+ })
+ .collect())
+ }
+
+ /// Write the full routing table.
+ pub fn set_mux(&mut self, entries: &[MuxEntry]) -> Result<(), TransportError> {
+ let mut payload = Vec::with_capacity(4 + entries.len() * 4);
+ payload.extend_from_slice(&0u16.to_le_bytes()); // pad
+ payload.extend_from_slice(&0u16.to_le_bytes()); // num (start index)
+ for e in entries {
+ payload.extend_from_slice(&e.pack().to_le_bytes());
+ }
+ self.command(op::SET_MUX, &payload, 0)?;
+ Ok(())
+ }
+
+ /// Read every mix bus into a `buses × inputs` grid of dB gains (for a UI
+ /// refresh of the whole mixer matrix).
+ pub fn read_mixer_db(
+ &mut self,
+ buses: u16,
+ inputs: usize,
+ ) -> Result<Vec<Vec<f32>>, TransportError> {
+ let mut grid = Vec::with_capacity(buses as usize);
+ for bus in 0..buses {
+ let raw = self.get_mix(bus, inputs)?;
+ grid.push(raw.into_iter().map(mixer_value_to_db).collect());
+ }
+ Ok(grid)
+ }
+
+ /// Set one crosspoint: input `input` → mix bus `bus` at `db`. Reads the bus's
+ /// current levels, changes the one input, and writes the bus back.
+ pub fn set_mix_point_db(
+ &mut self,
+ bus: u16,
+ input: usize,
+ db: f32,
+ num_inputs: usize,
+ ) -> Result<(), TransportError> {
+ let mut levels = self.get_mix(bus, num_inputs)?;
+ if let Some(slot) = levels.get_mut(input) {
+ *slot = db_to_mixer_value(db);
+ }
+ self.set_mix(bus, &levels)
+ }
+
+ /// Snapshot `num_meters` metering points. Each level is a raw `u32`.
+ pub fn get_meters(&mut self, num_meters: u16) -> Result<Vec<u32>, TransportError> {
+ const METER_MAGIC: u32 = 1;
+ let mut payload = Vec::with_capacity(8);
+ payload.extend_from_slice(&0u16.to_le_bytes()); // pad
+ payload.extend_from_slice(&num_meters.to_le_bytes());
+ payload.extend_from_slice(&METER_MAGIC.to_le_bytes());
+ let resp = self.command(op::GET_METER, &payload, num_meters as usize * 4)?;
+ Ok(le_u32s(&resp.payload, num_meters as usize))
+ }
+}
+
+fn le_u16s(bytes: &[u8], count: usize) -> Vec<u16> {
+ bytes
+ .chunks_exact(2)
+ .take(count)
+ .map(|c| u16::from_le_bytes([c[0], c[1]]))
+ .collect()
+}
+
+fn le_u32s(bytes: &[u8], count: usize) -> Vec<u32> {
+ bytes
+ .chunks_exact(4)
+ .take(count)
+ .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]))
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::transport::mock::MockTransport;
+
+ #[test]
+ fn mixer_db_unity_and_extremes() {
+ // 8192 is unity = 0 dB.
+ assert!((mixer_value_to_db(8192) - 0.0).abs() < 0.05);
+ assert_eq!(mixer_value_to_db(0), MIXER_MIN_DB);
+ // round-trip 0 dB
+ assert_eq!(db_to_mixer_value(0.0), 8192);
+ // +12 dB is the ceiling
+ assert!(db_to_mixer_value(20.0) <= db_to_mixer_value(MIXER_MAX_DB) + 1);
+ }
+
+ #[test]
+ fn mixer_db_round_trips_within_quantization() {
+ for &db in &[-60.0_f32, -20.0, -6.0, 0.0, 6.0, 12.0] {
+ let v = db_to_mixer_value(db);
+ let back = mixer_value_to_db(v);
+ assert!((back - db).abs() < 0.5, "db {db} -> {v} -> {back}");
+ }
+ }
+
+ #[test]
+ fn read_mixer_db_builds_grid() {
+ let mut m = MockTransport::new();
+ // 2 buses × 2 inputs, all unity
+ for _ in 0..2 {
+ let mut p = Vec::new();
+ p.extend_from_slice(&8192u16.to_le_bytes());
+ p.extend_from_slice(&8192u16.to_le_bytes());
+ m.push_response(op::GET_MIX, &p);
+ }
+ let mut dev = Scarlett::new(m);
+ let grid = dev.read_mixer_db(2, 2).unwrap();
+ assert_eq!(grid.len(), 2);
+ assert!((grid[0][0]).abs() < 0.05); // ~0 dB
+ }
+
+ #[test]
+ fn mux_entry_round_trips_through_packing() {
+ // dest in low 12 bits, source in high 12 (kernel order)
+ let e = MuxEntry { source: 5, dest: 9 };
+ let packed = e.pack();
+ assert_eq!(packed, 9 | (5 << 12));
+ assert_eq!(MuxEntry::unpack(packed), e);
+ }
+
+ #[test]
+ fn get_mix_requests_bus_and_parses_levels() {
+ let mut m = MockTransport::new();
+ // two inputs: levels 0x1000, 0x2000
+ m.push_response(op::GET_MIX, &[0x00, 0x10, 0x00, 0x20]);
+ let mut dev = Scarlett::new(m);
+
+ let levels = dev.get_mix(3, 2).unwrap();
+ assert_eq!(levels, vec![0x1000, 0x2000]);
+
+ let m = dev.into_transport();
+ assert_eq!(m.sent[0].0, op::GET_MIX);
+ // request = mix_num(3) + count(2), both u16 LE
+ assert_eq!(m.sent[0].1, vec![0x03, 0x00, 0x02, 0x00]);
+ }
+
+ #[test]
+ fn set_mix_packs_bus_then_levels() {
+ let mut m = MockTransport::new();
+ m.push_response(op::SET_MIX, &[]);
+ let mut dev = Scarlett::new(m);
+
+ dev.set_mix(1, &[0xaabb, 0xccdd]).unwrap();
+
+ let m = dev.into_transport();
+ let (cmd, payload) = &m.sent[0];
+ assert_eq!(*cmd, op::SET_MIX);
+ assert_eq!(payload, &vec![0x01, 0x00, 0xbb, 0xaa, 0xdd, 0xcc]);
+ }
+
+ #[test]
+ fn get_mux_parses_packed_entries() {
+ let mut m = MockTransport::new();
+ // dest|src<<12 : (dest 0, src 2) and (dest 3, src 7)
+ let raw0 = (0u32 | (2 << 12)).to_le_bytes();
+ let raw1 = (3u32 | (7 << 12)).to_le_bytes();
+ let mut payload = Vec::new();
+ payload.extend_from_slice(&raw0);
+ payload.extend_from_slice(&raw1);
+ m.push_response(op::GET_MUX, &payload);
+ let mut dev = Scarlett::new(m);
+
+ let entries = dev.get_mux(2).unwrap();
+ assert_eq!(entries[0], MuxEntry { source: 2, dest: 0 });
+ assert_eq!(entries[1], MuxEntry { source: 7, dest: 3 });
+
+ let m = dev.into_transport();
+ // request = num(0) + count(2)
+ assert_eq!(m.sent[0].1, vec![0x00, 0x00, 0x02, 0x00]);
+ }
+
+ #[test]
+ fn set_mux_writes_pad_num_then_packed_entries() {
+ let mut m = MockTransport::new();
+ m.push_response(op::SET_MUX, &[]);
+ let mut dev = Scarlett::new(m);
+
+ dev.set_mux(&[MuxEntry { source: 1, dest: 4 }]).unwrap();
+
+ let m = dev.into_transport();
+ let (cmd, payload) = &m.sent[0];
+ assert_eq!(*cmd, op::SET_MUX);
+ let entry = (4u32 | (1 << 12)).to_le_bytes(); // dest 4, source 1
+ let mut expect = vec![0x00, 0x00, 0x00, 0x00]; // pad, num
+ expect.extend_from_slice(&entry);
+ assert_eq!(payload, &expect);
+ }
+
+ #[test]
+ fn get_meters_sends_magic_and_parses_u32_levels() {
+ let mut m = MockTransport::new();
+ let mut payload = Vec::new();
+ payload.extend_from_slice(&0x0000_1234u32.to_le_bytes());
+ payload.extend_from_slice(&0x0000_5678u32.to_le_bytes());
+ m.push_response(op::GET_METER, &payload);
+ let mut dev = Scarlett::new(m);
+
+ let meters = dev.get_meters(2).unwrap();
+ assert_eq!(meters, vec![0x1234, 0x5678]);
+
+ let m = dev.into_transport();
+ let (cmd, sent) = &m.sent[0];
+ assert_eq!(*cmd, op::GET_METER);
+ // pad(0,u16) + num_meters(2,u16) + magic(1,u32)
+ assert_eq!(sent, &vec![0x00, 0x00, 0x02, 0x00, 0x01, 0x00, 0x00, 0x00]);
+ }
+}
diff --git a/scarlett-core/src/model.rs b/scarlett-core/src/model.rs
@@ -0,0 +1,225 @@
+//! Device model for the **Scarlett 18i20 3rd Gen** — the config-parameter map and
+//! the I/O topology, transcribed from the kernel driver's `gen3c` config set and
+//! `s18i20_gen3_info` descriptor. These are wire facts (offsets/sizes/activation
+//! codes), the contract the hardware exposes.
+//!
+//! Most front-panel controls are a `(offset, size, activate)` triple: write the
+//! value at `base_offset + channel * byte_width`, then send the `activate` code.
+
+/// A readable/writable configuration parameter, located by byte `offset` in the
+/// device data space. `bits` is the on-wire width (1, 8, 16, or 32); `activate`
+/// is the [`crate::protocol::Scarlett::activate`] code that applies a write.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Config {
+ pub offset: u16,
+ pub bits: u8,
+ pub activate: u8,
+}
+
+impl Config {
+ /// Width in bytes for a `set_data` write (bit-sized params write one byte).
+ pub fn byte_width(&self) -> u32 {
+ match self.bits {
+ 1 => 1,
+ n => (n as u32) / 8,
+ }
+ }
+
+ /// Byte offset of element `channel` in a multi-channel parameter (air per
+ /// input, line-out volume per output, …). Bit-packed params (`bits == 1`,
+ /// e.g. phantom) keep the base offset; the caller addresses the group.
+ pub fn channel_offset(&self, channel: u32) -> u32 {
+ if self.bits == 1 {
+ self.offset as u32 + channel // group index, one byte each here
+ } else {
+ self.offset as u32 + channel * self.byte_width()
+ }
+ }
+}
+
+/// The gen3c configuration parameters present on the 18i20 g3. Indices are our
+/// own; the driver's enum order is irrelevant once we carry explicit offsets.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Param {
+ DimMute,
+ LineOutVolume,
+ MuteSwitch,
+ SwHwSwitch,
+ MasterVolume,
+ LevelSwitch, // Inst/Line per input (2 inputs)
+ PadSwitch, // 10 dB pad per input (8 inputs)
+ AirSwitch, // Air per input (8 inputs)
+ StandaloneSwitch,
+ PhantomSwitch, // 48V per phantom group (2 groups, 4 inputs each)
+ MsdSwitch,
+ PhantomPersistence,
+ MonitorOtherSwitch,
+ MonitorOtherEnable,
+ TalkbackMap,
+ SpdifMode,
+}
+
+impl Param {
+ /// The `gen3c` config entry for this parameter (offsets confirmed against
+ /// the kernel driver: `scarlett2_config_set_gen3c`).
+ pub fn config(self) -> Config {
+ use Param::*;
+ let (offset, bits, activate) = match self {
+ DimMute => (0x31, 8, 2),
+ LineOutVolume => (0x34, 16, 1),
+ MuteSwitch => (0x5c, 8, 1),
+ SwHwSwitch => (0x66, 8, 3),
+ MasterVolume => (0x76, 16, 0), // read-only (hardware-controlled)
+ LevelSwitch => (0x7c, 8, 7),
+ PadSwitch => (0x84, 8, 8),
+ AirSwitch => (0x8c, 8, 8),
+ StandaloneSwitch => (0x95, 8, 6),
+ PhantomSwitch => (0x9c, 1, 8),
+ MsdSwitch => (0x9d, 8, 6),
+ PhantomPersistence => (0x9e, 8, 6),
+ MonitorOtherSwitch => (0x9f, 1, 10),
+ MonitorOtherEnable => (0xa0, 1, 10),
+ TalkbackMap => (0xb0, 16, 10),
+ SpdifMode => (0x94, 8, 6),
+ };
+ Config { offset, bits, activate }
+ }
+}
+
+/// A category of physical or virtual signal port. `(inputs, outputs)` counts are
+/// from the device descriptor — "inputs" are sources into the routing matrix,
+/// "outputs" are sinks out of it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PortType {
+ Analogue,
+ Spdif,
+ Adat,
+ /// The internal mixer matrix.
+ Mix,
+ /// DAW playback/capture channels.
+ Pcm,
+}
+
+/// Static topology of the Scarlett 18i20 3rd Gen.
+#[derive(Debug, Clone, Copy)]
+pub struct DeviceInfo {
+ pub name: &'static str,
+ pub vid: u16,
+ pub pid: u16,
+ /// Inputs that support Inst/Line switching.
+ pub level_input_count: u8,
+ /// Inputs that support the 10 dB pad.
+ pub pad_input_count: u8,
+ /// Inputs that support Air.
+ pub air_input_count: u8,
+ /// Number of independent 48V phantom groups.
+ pub phantom_count: u8,
+ /// Physical inputs fed by each phantom group.
+ pub inputs_per_phantom: u8,
+ pub has_talkback: bool,
+ pub has_speaker_switching: bool,
+ /// Total metering points returned by GET_METER (sum of the device's
+ /// `meter_map` spans: 8 + 10 + 20 + 2 + 25 on the 18i20 g3).
+ pub meter_count: u16,
+}
+
+impl DeviceInfo {
+ /// Number of mixer inputs (signals that can feed a mix bus) — the MIX port
+ /// type's sink count.
+ pub fn mixer_inputs(&self) -> u16 {
+ self.port_count(PortType::Mix).1 as u16
+ }
+
+ /// Number of mix buses (mixer outputs) — the MIX port type's source count.
+ pub fn mix_buses(&self) -> u16 {
+ self.port_count(PortType::Mix).0 as u16
+ }
+
+ /// Total routing destinations (sinks) = sum of every port type's sink count.
+ /// Used as the `count` for reading the full mux/routing table.
+ pub fn mux_dst_count(&self) -> usize {
+ [
+ PortType::Analogue,
+ PortType::Spdif,
+ PortType::Adat,
+ PortType::Mix,
+ PortType::Pcm,
+ ]
+ .iter()
+ .map(|&t| self.port_count(t).1 as usize)
+ .sum()
+ }
+
+ /// `(sources_into_matrix, sinks_out_of_matrix)` for a port type, transcribed
+ /// from the kernel's `s18i20_gen3_info.port_count[type][IN/OUT]`. Note the 9th
+ /// analogue *source* is the talkback mic.
+ pub fn port_count(&self, ty: PortType) -> (u8, u8) {
+ match ty {
+ PortType::Analogue => (9, 10),
+ PortType::Spdif => (2, 2),
+ PortType::Adat => (8, 8),
+ PortType::Mix => (12, 25),
+ PortType::Pcm => (20, 20),
+ }
+ }
+}
+
+/// The Scarlett 18i20 3rd Gen.
+pub const S18I20_GEN3: DeviceInfo = DeviceInfo {
+ name: "Scarlett 18i20 3rd Gen",
+ vid: 0x1235,
+ pid: 0x8215,
+ level_input_count: 2,
+ pad_input_count: 8,
+ air_input_count: 8,
+ phantom_count: 2,
+ inputs_per_phantom: 4,
+ has_talkback: true,
+ has_speaker_switching: true,
+ meter_count: 65,
+};
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn air_offsets_step_one_byte_per_input() {
+ let c = Param::AirSwitch.config();
+ assert_eq!(c.offset, 0x8c);
+ assert_eq!(c.byte_width(), 1);
+ assert_eq!(c.channel_offset(0), 0x8c);
+ assert_eq!(c.channel_offset(7), 0x8c + 7); // 8th input
+ }
+
+ #[test]
+ fn line_out_volume_is_16bit_two_bytes_per_channel() {
+ let c = Param::LineOutVolume.config();
+ assert_eq!(c.bits, 16);
+ assert_eq!(c.byte_width(), 2);
+ assert_eq!(c.channel_offset(0), 0x34);
+ assert_eq!(c.channel_offset(1), 0x36); // +2 bytes
+ assert_eq!(c.activate, 1);
+ }
+
+ #[test]
+ fn phantom_is_bit_sized_per_group() {
+ let c = Param::PhantomSwitch.config();
+ assert_eq!(c.bits, 1);
+ assert_eq!(c.byte_width(), 1);
+ assert_eq!(c.channel_offset(0), 0x9c); // group 0
+ assert_eq!(c.channel_offset(1), 0x9d); // group 1
+ assert_eq!(c.activate, 8);
+ }
+
+ #[test]
+ fn descriptor_matches_18i20_topology() {
+ let d = S18I20_GEN3;
+ assert_eq!(d.port_count(PortType::Analogue), (9, 10)); // 9th = talkback
+ assert_eq!(d.port_count(PortType::Pcm), (20, 20));
+ assert_eq!(d.air_input_count, 8);
+ assert_eq!(d.phantom_count, 2);
+ assert_eq!(d.meter_count, 65);
+ assert!(d.has_talkback);
+ }
+}
diff --git a/scarlett-core/src/packet.rs b/scarlett-core/src/packet.rs
@@ -0,0 +1,200 @@
+//! The scarlett2 wire packet: a 16-byte little-endian header followed by an
+//! optional payload. Both requests and responses share this layout.
+//!
+//! ```text
+//! offset size field
+//! 0 4 cmd (u32) opcode
+//! 4 2 size (u16) payload length in bytes
+//! 6 2 seq (u16) sequence number, +1 per request
+//! 8 4 error (u32) device error code (0 = ok)
+//! 12 4 pad (u32) reserved, always 0
+//! 16 .. data payload
+//! ```
+//!
+//! Validated against a real Scarlett 18i20 3rd Gen (see the Phase-0 spike): the
+//! device echoes `cmd`, and `seq` except during INIT where it replies `seq-1`
+//! (the kernel resets seq to 1 for INIT_1/INIT_2 and accepts a response seq of 0).
+
+/// Size of the fixed packet header in bytes.
+pub const HEADER_LEN: usize = 16;
+
+/// Error decoding a response packet.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum PacketError {
+ /// Fewer than [`HEADER_LEN`] bytes were returned.
+ TooShort { got: usize },
+ /// The device reported a non-zero error code.
+ Device { code: u32 },
+ /// The response opcode did not match the request.
+ CmdMismatch { sent: u32, got: u32 },
+ /// The declared payload size exceeds the bytes actually received.
+ SizeOverrun { declared: usize, available: usize },
+}
+
+impl core::fmt::Display for PacketError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ match self {
+ PacketError::TooShort { got } => {
+ write!(f, "response too short: {got} bytes (need {HEADER_LEN})")
+ }
+ PacketError::Device { code } => write!(f, "device error code {code:#x}"),
+ PacketError::CmdMismatch { sent, got } => {
+ write!(f, "response cmd {got:#x} != request {sent:#x}")
+ }
+ PacketError::SizeOverrun { declared, available } => write!(
+ f,
+ "declared payload {declared} > available {available}"
+ ),
+ }
+ }
+}
+
+impl std::error::Error for PacketError {}
+
+/// Build a request packet: 16-byte header + `payload`.
+pub fn encode_request(cmd: u32, seq: u16, payload: &[u8]) -> Vec<u8> {
+ let mut out = Vec::with_capacity(HEADER_LEN + payload.len());
+ out.extend_from_slice(&cmd.to_le_bytes());
+ out.extend_from_slice(&(payload.len() as u16).to_le_bytes());
+ out.extend_from_slice(&seq.to_le_bytes());
+ out.extend_from_slice(&0u32.to_le_bytes()); // error
+ out.extend_from_slice(&0u32.to_le_bytes()); // pad
+ out.extend_from_slice(payload);
+ out
+}
+
+/// A decoded response header plus a view of its payload.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Response {
+ pub cmd: u32,
+ pub size: u16,
+ pub seq: u16,
+ pub error: u32,
+ pub payload: Vec<u8>,
+}
+
+/// Parse a raw response buffer, validating it against the request `cmd`.
+///
+/// `seq` is intentionally NOT validated here: the device's seq accounting has an
+/// init-time off-by-one (confirmed on hardware), and callers that care can check
+/// `Response::seq` themselves. We DO enforce a zero error code and a matching cmd.
+pub fn decode_response(cmd: u32, buf: &[u8]) -> Result<Response, PacketError> {
+ if buf.len() < HEADER_LEN {
+ return Err(PacketError::TooShort { got: buf.len() });
+ }
+ let r_cmd = u32::from_le_bytes(buf[0..4].try_into().unwrap());
+ let size = u16::from_le_bytes(buf[4..6].try_into().unwrap());
+ let seq = u16::from_le_bytes(buf[6..8].try_into().unwrap());
+ let error = u32::from_le_bytes(buf[8..12].try_into().unwrap());
+
+ if error != 0 {
+ return Err(PacketError::Device { code: error });
+ }
+ if r_cmd != cmd {
+ return Err(PacketError::CmdMismatch { sent: cmd, got: r_cmd });
+ }
+
+ let available = buf.len() - HEADER_LEN;
+ let declared = size as usize;
+ if declared > available {
+ return Err(PacketError::SizeOverrun { declared, available });
+ }
+ let payload = buf[HEADER_LEN..HEADER_LEN + declared].to_vec();
+
+ Ok(Response { cmd: r_cmd, size, seq, error, payload })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn request_header_is_little_endian_and_16_bytes() {
+ // GET_DATA with an 8-byte {offset:u32, size:u32} payload, seq 3.
+ let mut payload = Vec::new();
+ payload.extend_from_slice(&0u32.to_le_bytes()); // offset 0
+ payload.extend_from_slice(&8u32.to_le_bytes()); // size 8
+ let req = encode_request(0x0080_0000, 3, &payload);
+
+ assert_eq!(req.len(), HEADER_LEN + 8);
+ assert_eq!(&req[0..4], &[0x00, 0x00, 0x80, 0x00]); // cmd LE
+ assert_eq!(&req[4..6], &[0x08, 0x00]); // size = 8
+ assert_eq!(&req[6..8], &[0x03, 0x00]); // seq = 3
+ assert_eq!(&req[8..12], &[0; 4]); // error
+ assert_eq!(&req[12..16], &[0; 4]); // pad
+ }
+
+ #[test]
+ fn empty_payload_request_is_just_the_header() {
+ let req = encode_request(0x0000_0000, 1, &[]); // INIT_1
+ assert_eq!(req.len(), HEADER_LEN);
+ assert_eq!(&req[4..6], &[0, 0]); // size 0
+ }
+
+ fn raw_response(cmd: u32, seq: u16, error: u32, payload: &[u8]) -> Vec<u8> {
+ let mut b = Vec::new();
+ b.extend_from_slice(&cmd.to_le_bytes());
+ b.extend_from_slice(&(payload.len() as u16).to_le_bytes());
+ b.extend_from_slice(&seq.to_le_bytes());
+ b.extend_from_slice(&error.to_le_bytes());
+ b.extend_from_slice(&0u32.to_le_bytes());
+ b.extend_from_slice(payload);
+ b
+ }
+
+ #[test]
+ fn decodes_real_get_sync_response() {
+ // From the spike: GET_SYNC returned payload 01 00 00 00 (locked).
+ let raw = raw_response(0x0000_6004, 1, 0, &[0x01, 0x00, 0x00, 0x00]);
+ let resp = decode_response(0x0000_6004, &raw).unwrap();
+ assert_eq!(resp.payload, vec![0x01, 0x00, 0x00, 0x00]);
+ assert_eq!(u32::from_le_bytes(resp.payload[..4].try_into().unwrap()), 1);
+ }
+
+ #[test]
+ fn decodes_real_get_data_response() {
+ // From the spike: GET_DATA@0 returned ec 20 00 00 00 00 01 00.
+ let p = [0xec, 0x20, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00];
+ let raw = raw_response(0x0080_0000, 2, 0, &p);
+ let resp = decode_response(0x0080_0000, &raw).unwrap();
+ assert_eq!(resp.payload, p);
+ }
+
+ #[test]
+ fn rejects_device_error_code() {
+ let raw = raw_response(0x0080_0000, 5, 0xdead, &[]);
+ assert_eq!(
+ decode_response(0x0080_0000, &raw),
+ Err(PacketError::Device { code: 0xdead })
+ );
+ }
+
+ #[test]
+ fn rejects_cmd_mismatch() {
+ let raw = raw_response(0x0000_2001, 5, 0, &[]);
+ assert_eq!(
+ decode_response(0x0080_0000, &raw),
+ Err(PacketError::CmdMismatch { sent: 0x0080_0000, got: 0x0000_2001 })
+ );
+ }
+
+ #[test]
+ fn rejects_short_buffer() {
+ assert_eq!(
+ decode_response(0, &[0u8; 8]),
+ Err(PacketError::TooShort { got: 8 })
+ );
+ }
+
+ #[test]
+ fn rejects_size_overrun() {
+ // header claims 16-byte payload but only 4 bytes follow
+ let mut b = encode_request(0x10, 0, &[]);
+ b[4] = 16; // size = 16
+ b.extend_from_slice(&[0u8; 4]);
+ assert_eq!(
+ decode_response(0x10, &b),
+ Err(PacketError::SizeOverrun { declared: 16, available: 4 })
+ );
+ }
+}
diff --git a/scarlett-core/src/ports.rs b/scarlett-core/src/ports.rs
@@ -0,0 +1,112 @@
+//! Hardware port identity: decode the 12-bit port IDs used in mux (routing)
+//! entries into a human-readable (port-type, index, name) — the data the kernel
+//! driver carries in `scarlett2_ports[]`.
+//!
+//! Every routing assignment is `dest_id | (src_id << 12)` (see [`crate::matrix`]).
+//! Each `*_id` is a 12-bit value `base | index`, where the bases are spaced far
+//! enough apart that the type is recoverable by range — no direction-count math
+//! needed, which makes this layer pure and fully testable.
+
+/// A category of physical/virtual port, with its ID base and naming.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct PortKind {
+ pub base: u16,
+ /// How a *source* of this kind is named (e.g. "Analogue", "Mix", "PCM").
+ pub src_word: &'static str,
+ /// How a *sink* of this kind is named (e.g. "Analogue Output").
+ pub sink_word: &'static str,
+}
+
+/// The 18i20 g3 port kinds, in mux-enumeration order (matches the kernel's
+/// `scarlett2_ports[]`). Order matters: source flat-indexing walks these in turn.
+pub const PORT_KINDS: &[PortKind] = &[
+ PortKind { base: 0x000, src_word: "Off", sink_word: "Off" },
+ PortKind { base: 0x080, src_word: "Analogue", sink_word: "Analogue Out" },
+ PortKind { base: 0x180, src_word: "S/PDIF", sink_word: "S/PDIF Out" },
+ PortKind { base: 0x200, src_word: "ADAT", sink_word: "ADAT Out" },
+ PortKind { base: 0x300, src_word: "Mix", sink_word: "Mixer In" },
+ PortKind { base: 0x600, src_word: "PCM", sink_word: "PCM" },
+];
+
+/// Find the port kind whose ID range contains `id`, plus the index within it.
+/// Returns `(kind, index)`. The ranges are the half-open spans between
+/// consecutive bases (the last extends to 0xFFF).
+fn locate(id: u16) -> Option<(&'static PortKind, u16)> {
+ let id = id & 0x0fff;
+ // NONE/Off is exactly the 0x000 region but only index 0 is meaningful.
+ let mut best: Option<&'static PortKind> = None;
+ for k in PORT_KINDS {
+ if id >= k.base {
+ best = Some(k);
+ }
+ }
+ best.map(|k| (k, id - k.base))
+}
+
+/// Human name for a **source** hardware id (e.g. 0x088 → "Analogue 9",
+/// 0x300 → "Mix A", 0x000 → "Off").
+pub fn source_name(id: u16) -> String {
+ name(id, true)
+}
+
+/// Human name for a **sink/destination** hardware id (e.g. 0x080 →
+/// "Analogue Out 1", 0x30a → "Mixer In 11").
+pub fn sink_name(id: u16) -> String {
+ name(id, false)
+}
+
+fn name(id: u16, source: bool) -> String {
+ match locate(id) {
+ None => format!("?{id:#05x}"),
+ Some((k, idx)) => {
+ if k.base == 0x000 {
+ return "Off".to_string();
+ }
+ let word = if source { k.src_word } else { k.sink_word };
+ if k.base == 0x300 && source {
+ // Mix sources are lettered: Mix A, Mix B, …
+ let letter = (b'A' + (idx as u8 % 26)) as char;
+ format!("{word} {letter}")
+ } else {
+ format!("{word} {}", idx + 1)
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn decodes_analogue_source_including_talkback() {
+ assert_eq!(source_name(0x080), "Analogue 1");
+ assert_eq!(source_name(0x088), "Analogue 9"); // talkback mic
+ }
+
+ #[test]
+ fn decodes_off() {
+ assert_eq!(source_name(0x000), "Off");
+ assert_eq!(sink_name(0x000), "Off");
+ }
+
+ #[test]
+ fn decodes_mix_sources_as_letters() {
+ assert_eq!(source_name(0x300), "Mix A");
+ assert_eq!(source_name(0x309), "Mix J");
+ }
+
+ #[test]
+ fn decodes_pcm_and_adat_and_spdif() {
+ assert_eq!(source_name(0x600), "PCM 1");
+ assert_eq!(source_name(0x200), "ADAT 1");
+ assert_eq!(source_name(0x181), "S/PDIF 2");
+ }
+
+ #[test]
+ fn decodes_sinks() {
+ assert_eq!(sink_name(0x080), "Analogue Out 1");
+ assert_eq!(sink_name(0x30a), "Mixer In 11");
+ assert_eq!(sink_name(0x600), "PCM 1");
+ }
+}
diff --git a/scarlett-core/src/preset.rs b/scarlett-core/src/preset.rs
@@ -0,0 +1,179 @@
+//! Presets — capture the device's user-settable state to a serializable snapshot,
+//! and apply one back. This powers "save/load a configuration" in the TUI and is
+//! independent of the device's own standalone (NVRAM) save.
+//!
+//! What's captured: the per-input switches (Air / Pad / Inst), the 48 V phantom
+//! groups, the monitor Mute / Dim, and the full mixer matrix. **Not** captured:
+//! the master monitor level (it's read-only — owned by the hardware knob) and
+//! routing (edit path isn't validated yet). Applying only writes fields present,
+//! so older/newer presets degrade gracefully.
+
+use serde::{Deserialize, Serialize};
+
+use crate::controls::{InputState, InputSwitch, MonitorButton, MonitorState};
+use crate::matrix::db_to_mixer_value;
+use crate::protocol::Scarlett;
+use crate::transport::{Transport, TransportError};
+
+/// Snapshot format version, so future readers can adapt.
+pub const PRESET_VERSION: u32 = 1;
+
+/// A saved device configuration.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+pub struct Preset {
+ pub version: u32,
+ /// Free-text label shown in the UI.
+ pub name: String,
+ /// Device product id this was captured from (sanity check on load).
+ pub pid: u16,
+ pub air: Vec<bool>,
+ pub pad: Vec<bool>,
+ pub inst: Vec<bool>,
+ pub phantom: Vec<bool>,
+ pub mute: bool,
+ pub dim: bool,
+ /// Mixer matrix as `[bus][input]` in dB.
+ pub mixer: Vec<Vec<f32>>,
+}
+
+impl Preset {
+ /// Build a preset from already-read state (no device I/O).
+ pub fn from_state(
+ name: impl Into<String>,
+ pid: u16,
+ inputs: &InputState,
+ monitor: &MonitorState,
+ mixer: &[Vec<f32>],
+ ) -> Self {
+ Preset {
+ version: PRESET_VERSION,
+ name: name.into(),
+ pid,
+ air: inputs.air.clone(),
+ pad: inputs.pad.clone(),
+ inst: inputs.inst.clone(),
+ phantom: inputs.phantom.clone(),
+ mute: monitor.mute,
+ dim: monitor.dim,
+ mixer: mixer.to_vec(),
+ }
+ }
+
+ /// Serialize to pretty JSON (human-editable on disk).
+ pub fn to_json(&self) -> String {
+ serde_json::to_string_pretty(self).unwrap_or_default()
+ }
+
+ /// Parse from JSON.
+ pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
+ serde_json::from_str(s)
+ }
+}
+
+impl<T: Transport> Scarlett<T> {
+ /// Apply a preset to the device. Writes each switch + activates, sets phantom
+ /// groups, monitor mute/dim, and every mixer crosspoint. Returns the number
+ /// of device writes performed (useful for a status line).
+ pub fn apply_preset(&mut self, p: &Preset) -> Result<usize, TransportError> {
+ let mut writes = 0;
+
+ for (i, &on) in p.air.iter().enumerate() {
+ self.set_input_switch(InputSwitch::Air, i as u8, on)?;
+ writes += 1;
+ }
+ for (i, &on) in p.pad.iter().enumerate() {
+ self.set_input_switch(InputSwitch::Pad, i as u8, on)?;
+ writes += 1;
+ }
+ for (i, &on) in p.inst.iter().enumerate() {
+ self.set_input_switch(InputSwitch::Inst, i as u8, on)?;
+ writes += 1;
+ }
+ for (g, &on) in p.phantom.iter().enumerate() {
+ self.set_phantom(g as u8, on)?;
+ writes += 1;
+ }
+
+ self.set_monitor_button(MonitorButton::Mute, p.mute)?;
+ self.set_monitor_button(MonitorButton::Dim, p.dim)?;
+ writes += 2;
+
+ for (bus, inputs) in p.mixer.iter().enumerate() {
+ let levels: Vec<u16> = inputs.iter().map(|&db| db_to_mixer_value(db)).collect();
+ if !levels.is_empty() {
+ self.set_mix(bus as u16, &levels)?;
+ writes += 1;
+ }
+ }
+
+ Ok(writes)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::protocol::op;
+ use crate::transport::mock::MockTransport;
+
+ fn sample() -> Preset {
+ Preset {
+ version: PRESET_VERSION,
+ name: "Vocal chain".into(),
+ pid: 0x8215,
+ air: vec![true, false],
+ pad: vec![false],
+ inst: vec![true],
+ phantom: vec![true, false],
+ mute: false,
+ dim: true,
+ mixer: vec![vec![0.0, -6.0]],
+ }
+ }
+
+ #[test]
+ fn json_round_trips() {
+ let p = sample();
+ let json = p.to_json();
+ let back = Preset::from_json(&json).unwrap();
+ assert_eq!(p, back);
+ assert!(json.contains("\"name\": \"Vocal chain\""));
+ }
+
+ #[test]
+ fn from_state_captures_fields() {
+ let inputs = InputState {
+ air: vec![true, true],
+ pad: vec![false, false],
+ inst: vec![false, true],
+ phantom: vec![true, false],
+ };
+ let mon = MonitorState { master_db: -3, mute: true, dim: false };
+ let mixer = vec![vec![0.0, 0.0]];
+ let p = Preset::from_state("test", 0x8215, &inputs, &mon, &mixer);
+ assert_eq!(p.air, vec![true, true]);
+ assert_eq!(p.inst, vec![false, true]);
+ assert!(p.mute);
+ assert_eq!(p.pid, 0x8215);
+ // master volume is intentionally not stored (read-only hardware knob)
+ }
+
+ #[test]
+ fn apply_preset_writes_and_activates() {
+ // apply_preset interleaves set_data/activate/set_mix; an echoing mock
+ // returns a matching empty success for each, so we just assert the count
+ // of high-level writes and that the right commands were emitted.
+ let mut dev = Scarlett::new(MockTransport::echoing());
+ let writes = dev.apply_preset(&sample()).unwrap();
+ // 2 air + 1 pad + 1 inst + 2 phantom + 2 monitor + 1 mixer bus = 9
+ assert_eq!(writes, 9);
+
+ let m = dev.into_transport();
+ let set_mix = m.sent.iter().filter(|(c, _)| *c == op::SET_MIX).count();
+ let set_data = m.sent.iter().filter(|(c, _)| *c == op::SET_DATA).count();
+ assert_eq!(set_mix, 1); // one mixer bus
+ // SET_DATA = 2 air + 1 pad + 1 inst + 2 phantom + 2 monitor(mute,dim) = 8
+ // (phantom and monitor buttons are byte writes via set_data too)
+ assert_eq!(set_data, 8);
+ }
+}
diff --git a/scarlett-core/src/protocol.rs b/scarlett-core/src/protocol.rs
@@ -0,0 +1,213 @@
+//! High-level scarlett2 operations built on a [`Transport`].
+//!
+//! These are the primitives the whole feature set rides on. Most controls
+//! (phantom power, air, pad, inst/line, mutes, monitor level, …) are just
+//! [`Scarlett::get_data`] / [`Scarlett::set_data`] at a per-feature offset, then
+//! an [`Scarlett::activate`]. The matrix mixer / routing / metering use their own
+//! opcodes and are layered on top in `matrix.rs`.
+
+use crate::packet::Response;
+use crate::transport::{Transport, TransportError};
+
+/// scarlett2 command opcodes (from the kernel driver, confirmed by the spike).
+pub mod op {
+ pub const INIT_1: u32 = 0x0000_0000;
+ pub const INIT_2: u32 = 0x0000_0002;
+ pub const REBOOT: u32 = 0x0000_0003;
+ pub const GET_METER: u32 = 0x0000_1001;
+ pub const GET_MIX: u32 = 0x0000_2001;
+ pub const SET_MIX: u32 = 0x0000_2002;
+ pub const GET_MUX: u32 = 0x0000_3001;
+ pub const SET_MUX: u32 = 0x0000_3002;
+ pub const GET_SYNC: u32 = 0x0000_6004;
+ pub const GET_DATA: u32 = 0x0080_0000;
+ pub const SET_DATA: u32 = 0x0080_0001;
+ pub const DATA_CMD: u32 = 0x0080_0002;
+}
+
+/// `activate` argument that persists the current settings to the device's flash
+/// (standalone mode).
+pub const CONFIG_SAVE: u32 = 6;
+
+/// A connected (or mocked) Scarlett, speaking the scarlett2 protocol.
+pub struct Scarlett<T: Transport> {
+ transport: T,
+ firmware: u32,
+}
+
+impl<T: Transport> Scarlett<T> {
+ pub fn new(transport: T) -> Self {
+ Self { transport, firmware: 0 }
+ }
+
+ /// Firmware version reported by [`Scarlett::init`] (0 until init runs).
+ pub fn firmware(&self) -> u32 {
+ self.firmware
+ }
+
+ /// Issue one raw scarlett2 command (used by the matrix/mux/meter ops in
+ /// `matrix.rs`). Crate-internal so the wire stays encapsulated.
+ pub(crate) fn command(
+ &mut self,
+ cmd: u32,
+ payload: &[u8],
+ resp_cap: usize,
+ ) -> Result<Response, TransportError> {
+ self.transport.command(cmd, payload, resp_cap)
+ }
+
+ /// Run the INIT handshake: a raw priming read, then INIT_1 and INIT_2 each
+ /// with the sequence counter reset to 1.
+ ///
+ /// INIT_2 (which carries the firmware version at byte 8) is **best-effort**:
+ /// on macOS its response is normally signalled on the interrupt endpoint,
+ /// which we can't claim while FocusriteControlServer holds it, so the IN read
+ /// times out. The Phase-0 spike confirmed every other command (GET_SYNC,
+ /// GET_DATA, …) works regardless, so a timed-out INIT_2 must not abort init —
+ /// we just report firmware 0. INIT_1 must still succeed.
+ pub fn init(&mut self) -> Result<u32, TransportError> {
+ let _ = self.transport.raw_in(0, 24); // step 0 (best-effort)
+
+ self.transport.set_seq(1);
+ self.transport.command(op::INIT_1, &[], 0)?;
+
+ self.transport.set_seq(1);
+ self.firmware = match self.transport.command(op::INIT_2, &[], 32) {
+ Ok(resp) => resp
+ .payload
+ .get(8..12)
+ .map(|s| u32::from_le_bytes(s.try_into().unwrap()))
+ .unwrap_or(0),
+ Err(_) => 0, // firmware unknown; not fatal
+ };
+ Ok(self.firmware)
+ }
+
+ /// Read `size` bytes from the device data space at `offset`.
+ pub fn get_data(&mut self, offset: u32, size: u32) -> Result<Vec<u8>, TransportError> {
+ let mut payload = Vec::with_capacity(8);
+ payload.extend_from_slice(&offset.to_le_bytes());
+ payload.extend_from_slice(&size.to_le_bytes());
+ Ok(self
+ .transport
+ .command(op::GET_DATA, &payload, size as usize)?
+ .payload)
+ }
+
+ /// Write a `size`-byte (1, 2, or 4) little-endian `value` at `offset`.
+ /// Remember to [`Scarlett::activate`] afterwards to apply it.
+ pub fn set_data(&mut self, offset: u32, size: u32, value: u32) -> Result<(), TransportError> {
+ let size = size.clamp(1, 4);
+ let mut payload = Vec::with_capacity(8 + size as usize);
+ payload.extend_from_slice(&offset.to_le_bytes());
+ payload.extend_from_slice(&size.to_le_bytes());
+ payload.extend_from_slice(&value.to_le_bytes()[..size as usize]);
+ self.transport.command(op::SET_DATA, &payload, 0)?;
+ Ok(())
+ }
+
+ /// Apply previously uploaded `set_data` changes. `activate` is the per-item
+ /// activation value (or [`CONFIG_SAVE`] to persist to flash).
+ pub fn activate(&mut self, activate: u32) -> Result<(), TransportError> {
+ self.transport
+ .command(op::DATA_CMD, &activate.to_le_bytes(), 0)?;
+ Ok(())
+ }
+
+ /// Persist the current configuration to the device's flash (standalone mode).
+ pub fn save_to_flash(&mut self) -> Result<(), TransportError> {
+ self.activate(CONFIG_SAVE)
+ }
+
+ /// Clock-sync lock status. The device returns a 4-byte value; non-zero = locked.
+ pub fn get_sync(&mut self) -> Result<bool, TransportError> {
+ let resp = self.transport.command(op::GET_SYNC, &[], 4)?;
+ Ok(resp.payload.iter().any(|&b| b != 0))
+ }
+
+ /// Consume the wrapper and get the transport back (e.g. to re-issue init).
+ pub fn into_transport(self) -> T {
+ self.transport
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::transport::mock::MockTransport;
+
+ #[test]
+ fn init_resets_seq_and_extracts_firmware() {
+ let mut m = MockTransport::new();
+ m.push_response(op::INIT_1, &[]);
+ let mut p = vec![0u8; 12];
+ p[8..12].copy_from_slice(&0x1234u32.to_le_bytes());
+ m.push_response(op::INIT_2, &p);
+
+ let mut dev = Scarlett::new(m);
+ let fw = dev.init().unwrap();
+ assert_eq!(fw, 0x1234);
+ assert_eq!(dev.firmware(), 0x1234);
+
+ let m = dev.into_transport();
+ assert_eq!(m.sent[0].0, op::INIT_1);
+ assert_eq!(m.sent[1].0, op::INIT_2);
+ }
+
+ #[test]
+ fn get_data_builds_offset_size_payload() {
+ let mut m = MockTransport::new();
+ m.push_response(op::GET_DATA, &[0xaa, 0xbb]);
+ let mut dev = Scarlett::new(m);
+
+ let data = dev.get_data(0x40, 2).unwrap();
+ assert_eq!(data, vec![0xaa, 0xbb]);
+
+ let m = dev.into_transport();
+ let (cmd, payload) = &m.sent[0];
+ assert_eq!(*cmd, op::GET_DATA);
+ assert_eq!(payload, &vec![0x40, 0, 0, 0, 0x02, 0, 0, 0]);
+ }
+
+ #[test]
+ fn set_data_packs_value_to_declared_width() {
+ let mut m = MockTransport::new();
+ m.push_response(op::SET_DATA, &[]);
+ let mut dev = Scarlett::new(m);
+
+ dev.set_data(0x9c, 1, 1).unwrap();
+
+ let m = dev.into_transport();
+ let (cmd, payload) = &m.sent[0];
+ assert_eq!(*cmd, op::SET_DATA);
+ assert_eq!(payload, &vec![0x9c, 0, 0, 0, 0x01, 0, 0, 0, 0x01]);
+ }
+
+ #[test]
+ fn activate_and_save_send_data_cmd() {
+ let mut m = MockTransport::new();
+ m.push_response(op::DATA_CMD, &[]);
+ m.push_response(op::DATA_CMD, &[]);
+ let mut dev = Scarlett::new(m);
+
+ dev.activate(0x05).unwrap();
+ dev.save_to_flash().unwrap();
+
+ let m = dev.into_transport();
+ assert_eq!(m.sent[0], (op::DATA_CMD, 0x05u32.to_le_bytes().to_vec()));
+ assert_eq!(m.sent[1], (op::DATA_CMD, CONFIG_SAVE.to_le_bytes().to_vec()));
+ }
+
+ #[test]
+ fn get_sync_reads_lock_state() {
+ let mut m = MockTransport::new();
+ m.push_response(op::GET_SYNC, &[0x01, 0, 0, 0]); // locked (real spike bytes)
+ let mut dev = Scarlett::new(m);
+ assert!(dev.get_sync().unwrap());
+
+ let mut m2 = MockTransport::new();
+ m2.push_response(op::GET_SYNC, &[0, 0, 0, 0]); // unlocked
+ let mut dev2 = Scarlett::new(m2);
+ assert!(!dev2.get_sync().unwrap());
+ }
+}
diff --git a/scarlett-core/src/transport.rs b/scarlett-core/src/transport.rs
@@ -0,0 +1,247 @@
+//! The command channel to the device.
+//!
+//! [`Transport`] is the seam between the protocol logic and the wire: the real
+//! [`UsbTransport`] drives endpoint 0 with rusb (libusb), and tests use a mock.
+//!
+//! rusb is used rather than nusb because on macOS nusb's device-level control
+//! transfers require claiming the device (or an EP0-bearing interface), and both
+//! are blocked here: AppleUSBAudio is attached to the audio interfaces, and
+//! FocusriteControlServer holds the vendor interface. rusb's read_control /
+//! write_control hit EP0 with no claim — proven on the real 18i20 by the spike.
+
+use std::time::Duration;
+
+use rusb::{Direction, Recipient, RequestType, UsbContext};
+
+use crate::packet::{self, PacketError, Response, HEADER_LEN};
+
+/// Focusrite / Novation USB vendor id.
+pub const VID: u16 = 0x1235;
+/// Scarlett 18i20 3rd Gen product id.
+pub const PID_18I20_G3: u16 = 0x8215;
+
+/// The scarlett2 control protocol lives on this (vendor-specific, class 0xFF)
+/// interface — confirmed by the Phase-0 spike. Used as the control-transfer
+/// `wIndex`.
+pub const CONTROL_INTERFACE: u16 = 3;
+
+const REQ_CMD: u8 = 2; // bRequest: host -> device
+const RESP_CMD: u8 = 3; // bRequest: device -> host
+const TIMEOUT: Duration = Duration::from_millis(1000);
+/// The IN read can time out when the device defers a larger response (it normally
+/// signals readiness on the interrupt EP, which we can't claim on macOS), so we
+/// retry the read a few times before giving up.
+const READ_ATTEMPTS: u32 = 4;
+
+#[derive(Debug)]
+pub enum TransportError {
+ /// No matching device is connected.
+ NotFound,
+ /// A USB-layer failure (open/transfer); message is from the OS/libusb.
+ Usb(String),
+ /// The response packet was malformed or the device returned an error code.
+ Packet(PacketError),
+}
+
+impl core::fmt::Display for TransportError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ match self {
+ TransportError::NotFound => write!(f, "device not found"),
+ TransportError::Usb(m) => write!(f, "usb error: {m}"),
+ TransportError::Packet(e) => write!(f, "protocol error: {e}"),
+ }
+ }
+}
+
+impl std::error::Error for TransportError {}
+
+impl From<PacketError> for TransportError {
+ fn from(e: PacketError) -> Self {
+ TransportError::Packet(e)
+ }
+}
+
+/// One scarlett2 round-trip plus the small amount of state (sequence number) the
+/// protocol requires.
+pub trait Transport {
+ /// Send `cmd` with `payload` and return the validated response, reading up to
+ /// `resp_cap` payload bytes back.
+ fn command(
+ &mut self,
+ cmd: u32,
+ payload: &[u8],
+ resp_cap: usize,
+ ) -> Result<Response, TransportError>;
+
+ /// Raw control-IN with an arbitrary `bRequest` (the INIT "step 0" priming
+ /// read uses bRequest 0, outside the packet framing).
+ fn raw_in(&mut self, request: u8, len: usize) -> Result<Vec<u8>, TransportError>;
+
+ /// Force the next sequence number (the INIT_1/INIT_2 handshake resets it to 1).
+ fn set_seq(&mut self, seq: u16);
+}
+
+/// Live USB transport over endpoint 0 (rusb / libusb).
+pub struct UsbTransport {
+ handle: rusb::DeviceHandle<rusb::Context>,
+ index: u16,
+ seq: u16,
+}
+
+impl UsbTransport {
+ /// Open the first connected Scarlett 18i20 3rd Gen.
+ pub fn open_default() -> Result<Self, TransportError> {
+ Self::open(VID, PID_18I20_G3, CONTROL_INTERFACE)
+ }
+
+ /// Open a specific device + control interface.
+ pub fn open(vid: u16, pid: u16, index: u16) -> Result<Self, TransportError> {
+ let ctx = rusb::Context::new().map_err(|e| TransportError::Usb(e.to_string()))?;
+ let device = ctx
+ .devices()
+ .map_err(|e| TransportError::Usb(e.to_string()))?
+ .iter()
+ .find(|d| {
+ d.device_descriptor()
+ .map(|x| x.vendor_id() == vid && x.product_id() == pid)
+ .unwrap_or(false)
+ })
+ .ok_or(TransportError::NotFound)?;
+ let handle = device
+ .open()
+ .map_err(|e| TransportError::Usb(format!("{e} (quit Focusrite Control?)")))?;
+ Ok(Self { handle, index, seq: 1 })
+ }
+
+ fn rt_out() -> u8 {
+ rusb::request_type(Direction::Out, RequestType::Class, Recipient::Interface)
+ }
+
+ fn rt_in() -> u8 {
+ rusb::request_type(Direction::In, RequestType::Class, Recipient::Interface)
+ }
+}
+
+impl Transport for UsbTransport {
+ fn command(
+ &mut self,
+ cmd: u32,
+ payload: &[u8],
+ resp_cap: usize,
+ ) -> Result<Response, TransportError> {
+ let seq = self.seq;
+ self.seq = self.seq.wrapping_add(1);
+
+ let req = packet::encode_request(cmd, seq, payload);
+ self.handle
+ .write_control(Self::rt_out(), REQ_CMD, 0, self.index, &req, TIMEOUT)
+ .map_err(|e| TransportError::Usb(e.to_string()))?;
+
+ let mut buf = vec![0u8; HEADER_LEN + resp_cap];
+ let mut last = None;
+ for _ in 0..READ_ATTEMPTS {
+ match self
+ .handle
+ .read_control(Self::rt_in(), RESP_CMD, 0, self.index, &mut buf, TIMEOUT)
+ {
+ Ok(n) => {
+ buf.truncate(n);
+ return Ok(packet::decode_response(cmd, &buf)?);
+ }
+ Err(rusb::Error::Timeout) => last = Some("read timed out".to_string()),
+ Err(e) => last = Some(e.to_string()),
+ }
+ }
+ Err(TransportError::Usb(last.unwrap_or_else(|| "read failed".into())))
+ }
+
+ fn raw_in(&mut self, request: u8, len: usize) -> Result<Vec<u8>, TransportError> {
+ let mut buf = vec![0u8; len];
+ let n = self
+ .handle
+ .read_control(Self::rt_in(), request, 0, self.index, &mut buf, TIMEOUT)
+ .map_err(|e| TransportError::Usb(e.to_string()))?;
+ buf.truncate(n);
+ Ok(buf)
+ }
+
+ fn set_seq(&mut self, seq: u16) {
+ self.seq = seq;
+ }
+}
+
+#[cfg(test)]
+pub(crate) mod mock {
+ use super::*;
+ use std::collections::VecDeque;
+
+ /// Records what the protocol layer sends and replays canned raw response
+ /// buffers, so protocol logic can be tested without hardware.
+ pub struct MockTransport {
+ /// Raw response buffers (header + payload) to return, in order.
+ pub responses: VecDeque<Vec<u8>>,
+ /// Every (cmd, payload) the protocol layer sent.
+ pub sent: Vec<(u32, Vec<u8>)>,
+ pub seq: u16,
+ /// When true, any command with no queued response gets a synthesized
+ /// empty success echo. Handy for write-heavy flows (e.g. apply_preset)
+ /// where only the `sent` log matters.
+ pub auto_echo: bool,
+ }
+
+ impl MockTransport {
+ pub fn new() -> Self {
+ Self { responses: VecDeque::new(), sent: Vec::new(), seq: 1, auto_echo: false }
+ }
+
+ /// A mock that auto-echoes an empty success for every command.
+ pub fn echoing() -> Self {
+ Self { auto_echo: true, ..Self::new() }
+ }
+
+ /// Queue a response with the given cmd echoed and `payload` attached.
+ pub fn push_response(&mut self, cmd: u32, payload: &[u8]) {
+ let mut b = Vec::new();
+ b.extend_from_slice(&cmd.to_le_bytes());
+ b.extend_from_slice(&(payload.len() as u16).to_le_bytes());
+ b.extend_from_slice(&self.seq.to_le_bytes());
+ b.extend_from_slice(&0u32.to_le_bytes()); // error
+ b.extend_from_slice(&0u32.to_le_bytes()); // pad
+ b.extend_from_slice(payload);
+ self.responses.push_back(b);
+ }
+ }
+
+ impl Transport for MockTransport {
+ fn command(
+ &mut self,
+ cmd: u32,
+ payload: &[u8],
+ _resp_cap: usize,
+ ) -> Result<Response, TransportError> {
+ self.sent.push((cmd, payload.to_vec()));
+ if let Some(raw) = self.responses.pop_front() {
+ return Ok(packet::decode_response(cmd, &raw)?);
+ }
+ if self.auto_echo {
+ // Synthesize an empty matching response.
+ let mut b = Vec::new();
+ b.extend_from_slice(&cmd.to_le_bytes());
+ b.extend_from_slice(&0u16.to_le_bytes()); // size
+ b.extend_from_slice(&self.seq.to_le_bytes());
+ b.extend_from_slice(&0u32.to_le_bytes()); // error
+ b.extend_from_slice(&0u32.to_le_bytes()); // pad
+ return Ok(packet::decode_response(cmd, &b)?);
+ }
+ panic!("MockTransport: no queued response for cmd {cmd:#x}");
+ }
+
+ fn raw_in(&mut self, _request: u8, _len: usize) -> Result<Vec<u8>, TransportError> {
+ Ok(Vec::new())
+ }
+
+ fn set_seq(&mut self, seq: u16) {
+ self.seq = seq;
+ }
+ }
+}
diff --git a/spike/Cargo.toml b/spike/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "spike"
+version = "0.0.0"
+edition.workspace = true
+license.workspace = true
+publish = false
+
+# Phase 0 feasibility gate. Throwaway: proves we can speak scarlett2 over EP0 on
+# macOS while Core Audio keeps streaming. Delete once scarlett-core lands.
+#
+# Uses rusb (libusb) with the vendored feature so it builds libusb from source —
+# no Homebrew/system dependency. read_control/write_control hit EP0 without
+# claiming the kernel-owned USB-audio interface, which is exactly the macOS
+# behaviour we need to validate.
+
+[[bin]]
+name = "spike"
+path = "src/main.rs"
+
+# Headless hardware check for scarlett-core ops (the TUI needs a real TTY).
+[[bin]]
+name = "hwcheck"
+path = "src/bin/hwcheck.rs"
+
+[dependencies]
+rusb = { version = "0.9", features = ["vendored"] }
+anyhow.workspace = true
+scarlett-core = { path = "../scarlett-core" }
diff --git a/spike/src/bin/hwcheck.rs b/spike/src/bin/hwcheck.rs
@@ -0,0 +1,76 @@
+//! Headless hardware check for Valentine's core ops (the TUI needs a real TTY,
+//! so this exercises scarlett-core directly). Read-modify-read-restore on a
+//! couple of safe controls, plus a meter snapshot. Run with Focusrite Control
+//! quit: cargo run -p spike --bin hwcheck
+//!
+//! It is careful: it records each switch's original value and restores it, so
+//! your device ends up exactly as it started.
+
+use scarlett_core::controls::{InputSwitch, MonitorButton};
+use scarlett_core::model::S18I20_GEN3;
+use scarlett_core::{Scarlett, UsbTransport};
+
+fn main() {
+ if let Err(e) = run() {
+ eprintln!("\x1b[31mHWCHECK FAILED:\x1b[0m {e}");
+ eprintln!("(If access/busy: quit Focusrite Control first.)");
+ std::process::exit(1);
+ }
+}
+
+fn run() -> Result<(), Box<dyn std::error::Error>> {
+ let mut dev = Scarlett::new(UsbTransport::open_default()?);
+ let fw = dev.init()?;
+ println!("connected: {} firmware {}", S18I20_GEN3.name, fw);
+ println!("clock locked: {}", dev.get_sync()?);
+
+ // --- Read the full input strip ---
+ let before = dev.read_input_state()?;
+ println!("\ninput strip read back:");
+ println!(" air: {:?}", before.air);
+ println!(" pad: {:?}", before.pad);
+ println!(" inst: {:?}", before.inst);
+ println!(" phantom: {:?}", before.phantom);
+
+ // --- Monitor + master volume ---
+ let mon = dev.read_monitor_state()?;
+ println!("\nmonitor: master {} dB, mute {}, dim {}", mon.master_db, mon.mute, mon.dim);
+
+ // --- Round-trip test: toggle AIR on input 1, verify, restore ---
+ let orig_air1 = dev.get_input_switch(InputSwitch::Air, 0)?;
+ println!("\nround-trip AIR input 1 (currently {orig_air1}):");
+ dev.set_input_switch(InputSwitch::Air, 0, !orig_air1)?;
+ let flipped = dev.get_input_switch(InputSwitch::Air, 0)?;
+ println!(" after toggle -> {flipped} (expected {})", !orig_air1);
+ let ok_air = flipped == !orig_air1;
+ dev.set_input_switch(InputSwitch::Air, 0, orig_air1)?; // restore
+ let restored = dev.get_input_switch(InputSwitch::Air, 0)?;
+ println!(" restored -> {restored} (expected {orig_air1})");
+
+ // --- Mixer: read bus A, report the first few crosspoints in dB ---
+ let inputs = S18I20_GEN3.mixer_inputs() as usize;
+ let bus0 = dev.get_mix(0, inputs)?;
+ let db: Vec<f32> = bus0
+ .iter()
+ .take(4)
+ .map(|&v| scarlett_core::matrix::mixer_value_to_db(v))
+ .collect();
+ println!("\nmix bus A, first 4 inputs (dB): {db:?}");
+
+ // --- Meters snapshot ---
+ let meters = dev.get_meters(S18I20_GEN3.meter_count)?;
+ let peak = meters.iter().copied().max().unwrap_or(0);
+ let nonzero = meters.iter().filter(|&&m| m > 0).count();
+ println!(
+ "\nmeters: {} points, {nonzero} nonzero, peak raw {peak}",
+ meters.len()
+ );
+
+ let verdict = ok_air && restored == orig_air1;
+ if verdict {
+ println!("\n\x1b[32mHWCHECK PASSED\x1b[0m — read/write/restore all correct.");
+ } else {
+ println!("\n\x1b[33mHWCHECK INCOMPLETE\x1b[0m — air round-trip mismatch (see above).");
+ }
+ Ok(())
+}
diff --git a/spike/src/main.rs b/spike/src/main.rs
@@ -0,0 +1,291 @@
+//! Phase 0 feasibility spike for scarlett-tui.
+//!
+//! Goal: prove that on macOS we can speak the Focusrite "scarlett2" control
+//! protocol to a Scarlett 18i20 3rd Gen over endpoint 0, WITHOUT taking the
+//! audio streaming interfaces away from Core Audio — i.e. replicate what
+//! Focusrite Control does, from userspace, in Rust.
+//!
+//! It is deliberately chatty and defensive: every step prints what happened so
+//! we can read the real macOS behaviour rather than guess it.
+//!
+//! Run with Focusrite Control QUIT (only one process may own the device):
+//! cargo run -p spike
+//!
+//! Protocol facts (from the GPL Linux driver sound/usb/mixer_scarlett2.c):
+//! * control transfers on EP0, recipient = interface
+//! TX: bmRequestType 0x21 (Class|Interface|OUT), bRequest 2 (CMD_REQ)
+//! RX: bmRequestType 0xA1 (Class|Interface|IN), bRequest 3 (CMD_RESP)
+//! * 16-byte little-endian header: cmd u32, size u16, seq u16, error u32, pad u32
+//! * seq increments by 1 per request; response echoes cmd/seq, error must be 0
+
+use std::time::Duration;
+
+use anyhow::{anyhow, bail, Context, Result};
+use rusb::{Direction, Recipient, RequestType, TransferType, UsbContext};
+
+const VID: u16 = 0x1235; // Focusrite / Novation
+const PID: u16 = 0x8215; // Scarlett 18i20 3rd Gen
+
+// scarlett2 opcodes
+const CMD_INIT_1: u32 = 0x0000_0000;
+const CMD_INIT_2: u32 = 0x0000_0002;
+const CMD_GET_SYNC: u32 = 0x0000_6004;
+const CMD_GET_DATA: u32 = 0x0080_0000;
+
+const REQ: u8 = 2; // CMD_REQ (host -> device)
+const RESP: u8 = 3; // CMD_RESP (device -> host)
+const CMD_INIT: u8 = 0; // bRequest for the raw "step 0" priming read
+const HDR: usize = 16;
+const TIMEOUT: Duration = Duration::from_millis(1000);
+
+fn main() {
+ if let Err(e) = run() {
+ eprintln!("\n\x1b[31mSPIKE FAILED:\x1b[0m {e:#}");
+ eprintln!("\nIf this is an access/ownership error, make sure Focusrite Control");
+ eprintln!("is fully quit (it holds the device exclusively).");
+ std::process::exit(1);
+ }
+}
+
+fn run() -> Result<()> {
+ let ctx = rusb::Context::new().context("create libusb context")?;
+
+ let device = ctx
+ .devices()?
+ .iter()
+ .find(|d| {
+ d.device_descriptor()
+ .map(|x| x.vendor_id() == VID && x.product_id() == PID)
+ .unwrap_or(false)
+ })
+ .ok_or_else(|| anyhow!("Scarlett 18i20 3rd Gen ({VID:04x}:{PID:04x}) not found"))?;
+
+ let desc = device.device_descriptor()?;
+ println!("== Device ==");
+ println!(
+ " {:04x}:{:04x} bDeviceClass={:#04x} subclass={:#04x} configs={}",
+ desc.vendor_id(),
+ desc.product_id(),
+ desc.class_code(),
+ desc.sub_class_code(),
+ desc.num_configurations()
+ );
+
+ // --- 1. Enumerate interfaces + endpoints; find the control interface and a
+ // candidate interrupt-in (notification) endpoint. ----------------
+ let config = device
+ .active_config_descriptor()
+ .context("read active config descriptor")?;
+
+ let mut control_iface: Option<u8> = None;
+ let mut notify: Option<(u8, u8)> = None; // (interface number, endpoint address)
+
+ println!("\n== Interfaces ==");
+ for iface in config.interfaces() {
+ for d in iface.descriptors() {
+ let ifn = d.interface_number();
+ let class = d.class_code();
+ let sub = d.sub_class_code();
+ println!(
+ " iface {ifn} alt {} class={class:#04x} subclass={sub:#04x} endpoints={}",
+ d.setting_number(),
+ d.num_endpoints()
+ );
+ // The scarlett2 control protocol (Gen3+) lives on the VENDOR-SPECIFIC
+ // interface (class 0xFF) that carries the interrupt notify endpoint —
+ // NOT the USB-Audio control interface. wIndex must be this interface.
+ if class == 0xFF {
+ control_iface.get_or_insert(ifn);
+ }
+ for ep in d.endpoint_descriptors() {
+ let addr = ep.address();
+ let is_int = ep.transfer_type() == TransferType::Interrupt;
+ let is_in = ep.direction() == Direction::In;
+ println!(
+ " ep {addr:#04x} {} {}",
+ if is_in { "IN " } else { "OUT" },
+ transfer_type_name(ep.transfer_type())
+ );
+ if is_int && is_in {
+ notify.get_or_insert((ifn, addr));
+ }
+ }
+ }
+ }
+
+ // Prefer the vendor-specific interface; otherwise whichever carries the
+ // interrupt-in endpoint; last resort interface 0.
+ let ctl_iface = control_iface.or(notify.map(|(i, _)| i)).unwrap_or(0);
+ println!("\n -> using control interface {ctl_iface}");
+ if let Some((ifn, ep)) = notify {
+ println!(" -> candidate notify endpoint {ep:#04x} on interface {ifn}");
+ } else {
+ println!(" -> no interrupt-in endpoint found (will rely on polling)");
+ }
+
+ // --- 2. Open the device. This is the first macOS gate: can a userspace app
+ // open the device while Core Audio drives the audio interfaces? ---
+ let handle = device.open().context(
+ "open device — if this fails with 'access denied' or 'busy', quit Focusrite Control",
+ )?;
+ println!("\n\x1b[32mOK\x1b[0m device opened (Core Audio should still be streaming)");
+
+ // --- 3. scarlett2 INIT handshake over EP0 (mirrors the kernel exactly). ---
+ //
+ // step 0: raw control-IN, bRequest = CMD_INIT (0) — primes the device.
+ // INIT_1: packet cmd 0, seq reset to 1 (the "seq sent=1, response=0" quirk).
+ // INIT_2: packet cmd 2, seq reset to 1; firmware version is u32 LE at byte 8.
+ println!("\n== INIT step 0 (raw read, bRequest=0) ==");
+ {
+ let rt_in = rusb::request_type(Direction::In, RequestType::Class, Recipient::Interface);
+ let mut buf = [0u8; 24];
+ match handle.read_control(rt_in, CMD_INIT, 0, ctl_iface as u16, &mut buf, TIMEOUT) {
+ Ok(n) => println!(" ok, {n} bytes: {}", hex(&buf[..n])),
+ Err(e) => eprintln!(" step 0 failed (continuing): {e}"),
+ }
+ }
+
+ let mut seq: u16 = 1;
+
+ println!("\n== INIT_1 ==");
+ // seq already 1
+ match cmd(&handle, ctl_iface, &mut seq, CMD_INIT_1, &[], 0) {
+ Ok(data) => println!(" ok ({} payload bytes){}", data.len(), maybe_hex(&data)),
+ Err(e) => return Err(e).context("INIT_1 — the handshake gate"),
+ }
+
+ println!("\n== INIT_2 (device info / firmware) ==");
+ seq = 1; // reset per kernel
+ match cmd(&handle, ctl_iface, &mut seq, CMD_INIT_2, &[], 32) {
+ Ok(data) => {
+ println!(" ok, {} bytes: {}", data.len(), hex(&data));
+ if data.len() >= 12 {
+ let fw = u32::from_le_bytes(data[8..12].try_into().unwrap());
+ println!(" \x1b[36mfirmware version: {fw}\x1b[0m");
+ }
+ }
+ Err(e) => eprintln!(" INIT_2 failed (non-fatal for the gate): {e:#}"),
+ }
+
+ println!("\n== GET_SYNC (clock lock status) ==");
+ match cmd(&handle, ctl_iface, &mut seq, CMD_GET_SYNC, &[], 16) {
+ Ok(data) => println!(" ok, {} bytes: {} (nonzero usually = locked)", data.len(), hex(&data)),
+ Err(e) => eprintln!(" GET_SYNC failed: {e:#}"),
+ }
+
+ println!("\n== GET_DATA (read config offset 0, 8 bytes) ==");
+ // GET_DATA payload = { offset: u32 LE, size: u32 LE }
+ let mut p = Vec::new();
+ p.extend_from_slice(&0u32.to_le_bytes()); // offset 0
+ p.extend_from_slice(&8u32.to_le_bytes()); // 8 bytes
+ match cmd(&handle, ctl_iface, &mut seq, CMD_GET_DATA, &p, 8) {
+ Ok(data) => println!(" ok, {} bytes: {}", data.len(), hex(&data)),
+ Err(e) => eprintln!(" GET_DATA failed: {e:#}"),
+ }
+
+ // --- 4. Notification endpoint: try to claim the interface and read it.
+ // On macOS this likely fails (kernel owns the audio interface) — in
+ // which case we fall back to polling, which is fine. ---------------
+ if let Some((ifn, ep)) = notify {
+ println!("\n== Notify endpoint (claim + interrupt read, 500ms) ==");
+ match handle.claim_interface(ifn) {
+ Ok(()) => {
+ println!(" claimed interface {ifn}; waiting for a notification...");
+ let mut buf = [0u8; 64];
+ match handle.read_interrupt(ep, &mut buf, Duration::from_millis(500)) {
+ Ok(n) => println!(" got {n} bytes: {}", hex(&buf[..n])),
+ Err(rusb::Error::Timeout) => {
+ println!(" no notification within 500ms (expected when idle)")
+ }
+ Err(e) => eprintln!(" interrupt read error: {e}"),
+ }
+ let _ = handle.release_interface(ifn);
+ }
+ Err(e) => {
+ println!(
+ " could not claim interface {ifn}: {e}\n -> EXPECTED on macOS; we'll use polling for live updates."
+ );
+ }
+ }
+ }
+
+ println!("\n\x1b[32mSPIKE PASSED\x1b[0m — EP0 control works; build the TUI on this foundation.");
+ println!("Confirm audio kept playing throughout, then hand the device back to Focusrite Control if needed.");
+ Ok(())
+}
+
+/// One scarlett2 request/response round-trip over EP0.
+fn cmd(
+ handle: &rusb::DeviceHandle<rusb::Context>,
+ iface: u8,
+ seq: &mut u16,
+ cmd: u32,
+ payload: &[u8],
+ resp_payload_cap: usize,
+) -> Result<Vec<u8>> {
+ let this_seq = *seq;
+ *seq = seq.wrapping_add(1);
+
+ // request packet: 16-byte header + payload
+ let mut req = Vec::with_capacity(HDR + payload.len());
+ req.extend_from_slice(&cmd.to_le_bytes());
+ req.extend_from_slice(&(payload.len() as u16).to_le_bytes());
+ req.extend_from_slice(&this_seq.to_le_bytes());
+ req.extend_from_slice(&0u32.to_le_bytes()); // error
+ req.extend_from_slice(&0u32.to_le_bytes()); // pad
+ req.extend_from_slice(payload);
+
+ let rt_out = rusb::request_type(Direction::Out, RequestType::Class, Recipient::Interface);
+ handle
+ .write_control(rt_out, REQ, 0, iface as u16, &req, TIMEOUT)
+ .context("control OUT (CMD_REQ)")?;
+
+ let mut buf = vec![0u8; HDR + resp_payload_cap];
+ let rt_in = rusb::request_type(Direction::In, RequestType::Class, Recipient::Interface);
+ let n = handle
+ .read_control(rt_in, RESP, 0, iface as u16, &mut buf, TIMEOUT)
+ .context("control IN (CMD_RESP)")?;
+ buf.truncate(n);
+
+ if n < HDR {
+ bail!("short response: {n} bytes (< {HDR}-byte header)");
+ }
+ let r_cmd = u32::from_le_bytes(buf[0..4].try_into().unwrap());
+ let r_size = u16::from_le_bytes(buf[4..6].try_into().unwrap());
+ let r_seq = u16::from_le_bytes(buf[6..8].try_into().unwrap());
+ let r_err = u32::from_le_bytes(buf[8..12].try_into().unwrap());
+
+ if r_err != 0 {
+ bail!("device error code {r_err:#x} (cmd {cmd:#x}, seq {this_seq})");
+ }
+ if r_cmd != cmd {
+ eprintln!(" warn: response cmd {r_cmd:#x} != request {cmd:#x}");
+ }
+ if r_seq != this_seq {
+ eprintln!(" warn: response seq {r_seq} != request {this_seq}");
+ }
+
+ let end = (HDR + r_size as usize).min(buf.len());
+ Ok(buf[HDR..end].to_vec())
+}
+
+fn transfer_type_name(t: rusb::TransferType) -> &'static str {
+ match t {
+ rusb::TransferType::Control => "control",
+ rusb::TransferType::Isochronous => "isochronous",
+ rusb::TransferType::Bulk => "bulk",
+ rusb::TransferType::Interrupt => "interrupt",
+ }
+}
+
+fn hex(b: &[u8]) -> String {
+ b.iter().map(|x| format!("{x:02x} ")).collect()
+}
+
+fn maybe_hex(b: &[u8]) -> String {
+ if b.is_empty() {
+ String::new()
+ } else {
+ format!(": {}", hex(b))
+ }
+}
diff --git a/themes/default.toml b/themes/default.toml
@@ -0,0 +1,31 @@
+# Ember — Valentine's bundled default theme.
+#
+# A tasteful dark plum / rose / coral palette: warm and a little romantic
+# (fitting for "Valentine"), readable, and distinct from the usual blue dev TUI.
+# This is what ships and what every user gets out of the box.
+#
+# Override it by dropping a TOML with these same keys at
+# ~/.config/valentine/theme.toml
+#
+# Role meanings (shared by every theme):
+# accent = selection / cursor / titles
+# armed = a control that is engaged / live / changed
+# good = locked / signal present
+# danger = mute / error / clip
+# meter_* = level-bar gradient (low → mid → high/clip)
+
+bg = "#170e15" # plum-black — main background
+bg_elevated = "#251524" # raised panel background
+bg_selected = "#3a2035" # selected row / focused cell
+border = "#5e2c4e" # wine — idle panel border
+border_focus = "#ff5fa2" # rose — focused panel border
+fg = "#f3dce7" # warm off-white
+fg_dim = "#a3768f" # mauve-grey — labels / inactive
+accent = "#ff5fa2" # rose — selection / cursor / titles
+armed = "#ff8a5b" # coral ember — engaged / live / changed
+good = "#4cc7a4" # jade — locked / present (cool counterpoint)
+warn = "#ffb454" # amber
+danger = "#ff3b5c" # crimson — mute / error / clip
+meter_low = "#4cc7a4" # jade
+meter_mid = "#ffb454" # amber
+meter_high = "#ff3b5c" # crimson (clip)
diff --git a/valentine/Cargo.toml b/valentine/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "valentine"
+version = "0.0.0"
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+description = "A terminal control panel for the Focusrite Scarlett 18i20 3rd Gen"
+
+[[bin]]
+name = "valentine"
+path = "src/main.rs"
+
+[dependencies]
+scarlett-core = { path = "../scarlett-core" }
+anyhow.workspace = true
+ratatui = "0.29"
+crossterm = "0.28"
+toml = "0.8"
+serde = { version = "1", features = ["derive"] }
+dirs-next = "2"
diff --git a/valentine/src/main.rs b/valentine/src/main.rs
@@ -0,0 +1,800 @@
+//! Valentine — a terminal control panel for the Focusrite Scarlett 18i20 3rd Gen.
+//!
+//! Six panels (Inputs / Monitor / Mixer / Routing / Meters / Clock) over the
+//! `scarlett-core` protocol library. Global keys also save/load a preset to disk
+//! and write the current config to the device's standalone (NVRAM) memory.
+
+mod panels;
+mod theme;
+
+use std::io;
+use std::time::{Duration, Instant};
+
+use anyhow::Result;
+use crossterm::event::{self, Event, KeyCode, KeyEventKind};
+use crossterm::execute;
+use crossterm::terminal::{
+ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
+};
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders, Paragraph, Tabs};
+
+use scarlett_core::controls::{InputState, MonitorState};
+use scarlett_core::model::S18I20_GEN3;
+use scarlett_core::preset::Preset;
+use scarlett_core::{Scarlett, TransportError, UsbTransport};
+
+use panels::clock;
+use panels::inputs::{self, col_switch, Col, Cursor as InputCursor};
+use panels::meters;
+use panels::mixer::{self, Cursor as MixerCursor};
+use panels::monitor::{self, Cursor as MonitorCursor};
+use panels::routing::{self, Cursor as RoutingCursor};
+use theme::Theme;
+
+const TABS: [&str; 6] = ["Inputs", "Monitor", "Mixer", "Routing", "Meters", "Clock"];
+
+/// Poll cadence for changed device state (notifications can't be claimed on macOS
+/// while FocusriteControlServer holds the interrupt EP — see the Phase-0 spike).
+const POLL_INTERVAL: Duration = Duration::from_millis(750);
+
+/// Live connection plus the last values read from the device.
+struct Device {
+ scarlett: Scarlett<UsbTransport>,
+ firmware: u32,
+ locked: bool,
+ inputs: InputState,
+ monitor: MonitorState,
+ meters: Vec<u32>,
+ /// Mixer grid `[bus][input]` in dB; loaded on demand when the Mixer tab opens.
+ mixer: Vec<Vec<f32>>,
+ /// Decoded `(sink, source)` routing; loaded on demand when the Routing tab opens.
+ routing: Vec<(String, String)>,
+}
+
+impl Device {
+ fn connect() -> Result<Self, TransportError> {
+ let mut scarlett = Scarlett::new(UsbTransport::open_default()?);
+ let firmware = scarlett.init()?;
+ let locked = scarlett.get_sync().unwrap_or(false);
+ let inputs = scarlett.read_input_state().unwrap_or_default();
+ let monitor = scarlett.read_monitor_state().unwrap_or_default();
+ Ok(Self {
+ scarlett,
+ firmware,
+ locked,
+ inputs,
+ monitor,
+ meters: Vec::new(),
+ mixer: Vec::new(),
+ routing: Vec::new(),
+ })
+ }
+
+ /// Load the full mixer matrix (12 buses × 25 inputs) — called when the Mixer
+ /// tab is first opened; it's 12 round-trips so we don't do it every poll.
+ fn load_mixer(&mut self) {
+ let inputs = S18I20_GEN3.mixer_inputs() as usize;
+ if let Ok(grid) = self.scarlett.read_mixer_db(S18I20_GEN3.mix_buses(), inputs) {
+ self.mixer = grid;
+ }
+ }
+
+ /// Load the routing table (decoded sink←source names) on Routing-tab open.
+ fn load_routing(&mut self) {
+ if let Ok(r) = self.scarlett.read_routing(S18I20_GEN3.mux_dst_count()) {
+ self.routing = r;
+ }
+ }
+
+ /// Fast path: just refresh the meters (called every UI tick when on the
+ /// Meters tab so the bars feel live).
+ fn poll_meters(&mut self) {
+ if let Ok(m) = self.scarlett.get_meters(S18I20_GEN3.meter_count) {
+ self.meters = m;
+ }
+ }
+
+ fn poll(&mut self) {
+ if let Ok(l) = self.scarlett.get_sync() {
+ self.locked = l;
+ }
+ if let Ok(s) = self.scarlett.read_input_state() {
+ self.inputs = s;
+ }
+ if let Ok(m) = self.scarlett.read_monitor_state() {
+ self.monitor = m;
+ }
+ }
+}
+
+struct App {
+ theme: Theme,
+ tab: usize,
+ device: Result<Device, String>,
+ input_cursor: InputCursor,
+ monitor_cursor: MonitorCursor,
+ mixer_cursor: MixerCursor,
+ routing_cursor: RoutingCursor,
+ status: Option<String>,
+ show_help: bool,
+ last_poll: Instant,
+ should_quit: bool,
+}
+
+impl App {
+ fn new() -> Self {
+ let device = Device::connect().map_err(describe);
+ Self::with_device(device)
+ }
+
+ /// Build an App around an already-resolved device result (no USB I/O here —
+ /// lets tests construct the disconnected state without hardware).
+ fn with_device(device: Result<Device, String>) -> Self {
+ App {
+ theme: Theme::load(),
+ tab: 0,
+ device,
+ input_cursor: InputCursor::default(),
+ monitor_cursor: MonitorCursor::default(),
+ mixer_cursor: MixerCursor::default(),
+ routing_cursor: RoutingCursor::default(),
+ status: None,
+ show_help: false,
+ last_poll: Instant::now(),
+ should_quit: false,
+ }
+ }
+
+ fn on_key(&mut self, code: KeyCode) {
+ // Global keys first.
+ match code {
+ KeyCode::Char('q') => {
+ self.should_quit = true;
+ return;
+ }
+ KeyCode::Char('r') => {
+ self.device = Device::connect().map_err(describe);
+ self.status = Some("reconnected".into());
+ return;
+ }
+ KeyCode::Char('S') => {
+ self.save_preset();
+ return;
+ }
+ KeyCode::Char('L') => {
+ self.load_preset();
+ return;
+ }
+ KeyCode::Char('W') => {
+ self.save_to_nvram();
+ return;
+ }
+ KeyCode::Char('?') => {
+ self.show_help = !self.show_help;
+ return;
+ }
+ KeyCode::Esc if self.show_help => {
+ self.show_help = false;
+ return;
+ }
+ KeyCode::Tab | KeyCode::Right => {
+ if self.tab != 0 || !matches!(code, KeyCode::Right) {
+ // Right is consumed by the Inputs grid; Tab always cycles.
+ }
+ }
+ _ => {}
+ }
+
+ // Tab cycling: Tab/BackTab always; on non-Inputs tabs, h/l/arrows too.
+ if matches!(code, KeyCode::Tab) {
+ self.tab = (self.tab + 1) % TABS.len();
+ self.on_tab_enter();
+ return;
+ }
+ if matches!(code, KeyCode::BackTab) {
+ self.tab = (self.tab + TABS.len() - 1) % TABS.len();
+ self.on_tab_enter();
+ return;
+ }
+
+ // Panel-specific keys.
+ match self.tab {
+ 0 => self.inputs_key(code),
+ 1 => self.monitor_key(code),
+ 2 => self.mixer_key(code),
+ 3 => match code {
+ KeyCode::Up | KeyCode::Char('k') => self.routing_cursor.up(),
+ KeyCode::Down | KeyCode::Char('j') => {
+ let n = self.device.as_ref().map(|d| d.routing.len()).unwrap_or(0);
+ self.routing_cursor.down(n);
+ }
+ _ => {}
+ },
+ // Tabs without interactive controls: h/l moves between tabs.
+ _ => match code {
+ KeyCode::Char('l') => {
+ self.tab = (self.tab + 1) % TABS.len();
+ self.on_tab_enter();
+ }
+ KeyCode::Char('h') => {
+ self.tab = (self.tab + TABS.len() - 1) % TABS.len();
+ self.on_tab_enter();
+ }
+ _ => {}
+ },
+ }
+ }
+
+ /// Lazy-load data a freshly-opened tab needs.
+ fn on_tab_enter(&mut self) {
+ if self.tab == 2 {
+ if let Ok(dev) = &mut self.device {
+ dev.load_mixer();
+ }
+ }
+ if self.tab == 3 {
+ if let Ok(dev) = &mut self.device {
+ dev.load_routing();
+ }
+ }
+ }
+
+ fn mixer_key(&mut self, code: KeyCode) {
+ let inputs = S18I20_GEN3.mixer_inputs() as usize;
+ let buses = S18I20_GEN3.mix_buses() as usize;
+ match code {
+ KeyCode::Up | KeyCode::Char('k') => self.mixer_cursor.up(),
+ KeyCode::Down | KeyCode::Char('j') => self.mixer_cursor.down(inputs),
+ KeyCode::Left | KeyCode::Char('h') => self.mixer_cursor.left(),
+ KeyCode::Right | KeyCode::Char('l') => self.mixer_cursor.right(buses),
+ KeyCode::Char('+') | KeyCode::Char('=') => self.nudge_mix(1.0),
+ KeyCode::Char('-') | KeyCode::Char('_') => self.nudge_mix(-1.0),
+ KeyCode::Char('0') => self.set_mix_abs(0.0),
+ KeyCode::Char('m') => self.set_mix_abs(scarlett_core::matrix::MIXER_MIN_DB),
+ _ => {}
+ }
+ }
+
+ fn nudge_mix(&mut self, delta: f32) {
+ let cur = self.mixer_cell();
+ self.set_mix_abs(mixer::clamp_db(cur + delta));
+ }
+
+ fn mixer_cell(&self) -> f32 {
+ if let Ok(dev) = &self.device {
+ dev.mixer
+ .get(self.mixer_cursor.bus)
+ .and_then(|b| b.get(self.mixer_cursor.input))
+ .copied()
+ .unwrap_or(scarlett_core::matrix::MIXER_MIN_DB)
+ } else {
+ scarlett_core::matrix::MIXER_MIN_DB
+ }
+ }
+
+ fn set_mix_abs(&mut self, db: f32) {
+ let (bus, input) = (self.mixer_cursor.bus, self.mixer_cursor.input);
+ let inputs = S18I20_GEN3.mixer_inputs() as usize;
+ let dev = match &mut self.device {
+ Ok(d) => d,
+ Err(_) => return,
+ };
+ match dev
+ .scarlett
+ .set_mix_point_db(bus as u16, input, db, inputs)
+ {
+ Ok(()) => {
+ if let Some(slot) = dev.mixer.get_mut(bus).and_then(|b| b.get_mut(input)) {
+ *slot = db;
+ }
+ }
+ Err(e) => self.status = Some(format!("mix set failed: {e}")),
+ }
+ }
+
+ fn monitor_key(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Up | KeyCode::Char('k') => self.monitor_cursor.up(),
+ KeyCode::Down | KeyCode::Char('j') => self.monitor_cursor.down(),
+ KeyCode::Char(' ') | KeyCode::Enter => self.toggle_monitor_button(),
+ _ => {}
+ }
+ }
+
+ fn toggle_monitor_button(&mut self) {
+ let row = self.monitor_cursor.current();
+ let dev = match &mut self.device {
+ Ok(d) => d,
+ Err(_) => return,
+ };
+ let btn = row.button();
+ let cur = match btn {
+ scarlett_core::controls::MonitorButton::Mute => dev.monitor.mute,
+ scarlett_core::controls::MonitorButton::Dim => dev.monitor.dim,
+ };
+ match dev.scarlett.set_monitor_button(btn, !cur) {
+ Ok(()) => {
+ match btn {
+ scarlett_core::controls::MonitorButton::Mute => dev.monitor.mute = !cur,
+ scarlett_core::controls::MonitorButton::Dim => dev.monitor.dim = !cur,
+ }
+ self.status = Some(format!("monitor {:?} toggled", btn));
+ }
+ Err(e) => self.status = Some(format!("toggle failed: {e}")),
+ }
+ }
+
+ fn inputs_key(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Up | KeyCode::Char('k') => self.input_cursor.up(),
+ KeyCode::Down | KeyCode::Char('j') => self.input_cursor.down(),
+ KeyCode::Left | KeyCode::Char('h') => self.input_cursor.left(),
+ KeyCode::Right | KeyCode::Char('l') => self.input_cursor.right(),
+ KeyCode::Char(' ') | KeyCode::Enter => self.toggle_focused_switch(),
+ _ => {}
+ }
+ }
+
+ fn toggle_focused_switch(&mut self) {
+ let cursor = self.input_cursor;
+ let col = cursor.current_col();
+ let input = cursor.input;
+
+ let dev = match &mut self.device {
+ Ok(d) => d,
+ Err(_) => return,
+ };
+
+ let result = match col {
+ Col::P48 => {
+ let group = input / S18I20_GEN3.inputs_per_phantom;
+ let cur = dev.inputs.phantom.get(group as usize).copied().unwrap_or(false);
+ dev.scarlett.set_phantom(group, !cur).map(|_| {
+ if let Some(p) = dev.inputs.phantom.get_mut(group as usize) {
+ *p = !cur;
+ }
+ })
+ }
+ other => {
+ let sw = col_switch(other).expect("non-P48 columns map to a switch");
+ if !applies(other, input) {
+ self.status = Some(format!("{:?} not available on input {}", other, input + 1));
+ return;
+ }
+ let cur = switch_state(&dev.inputs, other, input);
+ dev.scarlett.set_input_switch(sw, input, !cur).map(|_| {
+ set_switch_state(&mut dev.inputs, other, input, !cur);
+ })
+ }
+ };
+
+ match result {
+ Ok(()) => self.status = Some(format!("{:?} input {} toggled", col, input + 1)),
+ Err(e) => self.status = Some(format!("toggle failed: {e}")),
+ }
+ }
+
+ // ---- Persistence (S/L/W) ------------------------------------------------
+
+ /// Capture the current state to `~/.config/valentine/presets/preset.json`.
+ /// Loads the mixer first if it hasn't been read yet, so the preset is complete.
+ fn save_preset(&mut self) {
+ let dev = match &mut self.device {
+ Ok(d) => d,
+ Err(_) => {
+ self.status = Some("save: not connected".into());
+ return;
+ }
+ };
+ if dev.mixer.is_empty() {
+ dev.load_mixer();
+ }
+ let preset = Preset::from_state(
+ "valentine preset",
+ S18I20_GEN3.pid,
+ &dev.inputs,
+ &dev.monitor,
+ &dev.mixer,
+ );
+ match preset_path() {
+ Some(path) => {
+ if let Some(parent) = path.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+ match std::fs::write(&path, preset.to_json()) {
+ Ok(()) => self.status = Some(format!("preset saved → {}", path.display())),
+ Err(e) => self.status = Some(format!("save failed: {e}")),
+ }
+ }
+ None => self.status = Some("save: no config dir".into()),
+ }
+ }
+
+ /// Load the preset from disk and apply it to the device.
+ fn load_preset(&mut self) {
+ let path = match preset_path() {
+ Some(p) => p,
+ None => {
+ self.status = Some("load: no config dir".into());
+ return;
+ }
+ };
+ let text = match std::fs::read_to_string(&path) {
+ Ok(t) => t,
+ Err(_) => {
+ self.status = Some(format!("no preset at {}", path.display()));
+ return;
+ }
+ };
+ let preset = match Preset::from_json(&text) {
+ Ok(p) => p,
+ Err(e) => {
+ self.status = Some(format!("preset parse error: {e}"));
+ return;
+ }
+ };
+ let dev = match &mut self.device {
+ Ok(d) => d,
+ Err(_) => {
+ self.status = Some("load: not connected".into());
+ return;
+ }
+ };
+ match dev.scarlett.apply_preset(&preset) {
+ Ok(n) => {
+ // refresh cached state so the UI reflects what we just wrote
+ dev.inputs = dev.scarlett.read_input_state().unwrap_or_default();
+ dev.monitor = dev.scarlett.read_monitor_state().unwrap_or_default();
+ dev.mixer.clear();
+ self.status = Some(format!("preset “{}” applied ({n} writes)", preset.name));
+ }
+ Err(e) => self.status = Some(format!("apply failed: {e}")),
+ }
+ }
+
+ /// Persist the device's current settings to its own NVRAM (standalone mode).
+ fn save_to_nvram(&mut self) {
+ let dev = match &mut self.device {
+ Ok(d) => d,
+ Err(_) => {
+ self.status = Some("NVRAM save: not connected".into());
+ return;
+ }
+ };
+ match dev.scarlett.save_to_flash() {
+ Ok(()) => self.status = Some("saved to device NVRAM (standalone)".into()),
+ Err(e) => self.status = Some(format!("NVRAM save failed: {e}")),
+ }
+ }
+
+ fn tick(&mut self) {
+ // Meters refresh every tick while their tab is visible (they want to
+ // feel live); everything else on the slower poll cadence.
+ if self.tab == 4 {
+ if let Ok(dev) = &mut self.device {
+ dev.poll_meters();
+ }
+ }
+ if self.last_poll.elapsed() >= POLL_INTERVAL {
+ if let Ok(dev) = &mut self.device {
+ dev.poll();
+ }
+ self.last_poll = Instant::now();
+ }
+ }
+}
+
+fn applies(col: Col, input: u8) -> bool {
+ match col {
+ Col::Inst => input < S18I20_GEN3.level_input_count,
+ Col::Air => input < S18I20_GEN3.air_input_count,
+ Col::Pad => input < S18I20_GEN3.pad_input_count,
+ Col::P48 => true,
+ }
+}
+
+fn switch_state(s: &InputState, col: Col, input: u8) -> bool {
+ let i = input as usize;
+ match col {
+ Col::Inst => s.inst.get(i).copied().unwrap_or(false),
+ Col::Air => s.air.get(i).copied().unwrap_or(false),
+ Col::Pad => s.pad.get(i).copied().unwrap_or(false),
+ Col::P48 => false,
+ }
+}
+
+fn set_switch_state(s: &mut InputState, col: Col, input: u8, on: bool) {
+ let i = input as usize;
+ let v = match col {
+ Col::Inst => &mut s.inst,
+ Col::Air => &mut s.air,
+ Col::Pad => &mut s.pad,
+ Col::P48 => return,
+ };
+ if let Some(slot) = v.get_mut(i) {
+ *slot = on;
+ }
+}
+
+/// `~/.config/valentine/presets/preset.json` — the single preset slot.
+fn preset_path() -> Option<std::path::PathBuf> {
+ dirs_next::config_dir().map(|d| d.join("valentine").join("presets").join("preset.json"))
+}
+
+fn describe(e: TransportError) -> String {
+ match e {
+ TransportError::NotFound => "No Scarlett 18i20 found — is it plugged in?".into(),
+ TransportError::Usb(m) if m.to_lowercase().contains("access") || m.contains("busy") => {
+ format!("Device busy — quit Focusrite Control, then press 'r'. ({m})")
+ }
+ other => format!("Connection failed: {other}"),
+ }
+}
+
+/// Restore the terminal out of raw mode + alternate screen. Safe to call twice.
+fn restore_terminal() {
+ let _ = disable_raw_mode();
+ let _ = execute!(io::stdout(), LeaveAlternateScreen);
+}
+
+fn main() -> Result<()> {
+ // Install a panic hook that restores the terminal BEFORE the default hook
+ // prints — otherwise a panic inside the alternate screen is wiped on exit and
+ // you see nothing (the symptom of "the TUI flashed and vanished").
+ let default_hook = std::panic::take_hook();
+ std::panic::set_hook(Box::new(move |info| {
+ restore_terminal();
+ default_hook(info);
+ }));
+
+ enable_raw_mode()?;
+ let mut stdout = io::stdout();
+ execute!(stdout, EnterAlternateScreen)?;
+ let backend = CrosstermBackend::new(stdout);
+ let mut terminal = Terminal::new(backend)?;
+
+ let mut app = App::new();
+ let res = run(&mut terminal, &mut app);
+
+ restore_terminal();
+ let _ = terminal.show_cursor();
+ res
+}
+
+fn run<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
+ while !app.should_quit {
+ terminal.draw(|f| ui(f, app))?;
+ if event::poll(Duration::from_millis(100))? {
+ if let Event::Key(key) = event::read()? {
+ if key.kind == KeyEventKind::Press {
+ app.on_key(key.code);
+ }
+ }
+ }
+ app.tick();
+ }
+ Ok(())
+}
+
+fn ui(f: &mut Frame, app: &App) {
+ let t = &app.theme;
+ let area = f.area();
+ f.render_widget(Block::default().style(Style::default().bg(t.bg)), area);
+
+ let chunks = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(3),
+ Constraint::Min(0),
+ Constraint::Length(1),
+ ])
+ .split(area);
+
+ // Title
+ let title = Line::from(vec![
+ Span::styled(
+ " ♥ Valentine ",
+ Style::default().fg(t.armed).add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(S18I20_GEN3.name, Style::default().fg(t.fg_dim)),
+ ]);
+ f.render_widget(Paragraph::new(title), chunks[0]);
+
+ // Tabs
+ let tabs = Tabs::new(TABS.iter().map(|s| Span::raw(*s)).collect::<Vec<_>>())
+ .select(app.tab)
+ .block(
+ Block::default()
+ .borders(Borders::ALL)
+ .border_type(ratatui::widgets::BorderType::Rounded)
+ .border_style(Style::default().fg(t.border)),
+ )
+ .style(Style::default().fg(t.fg_dim))
+ .highlight_style(Style::default().fg(t.accent).add_modifier(Modifier::BOLD));
+ f.render_widget(tabs, chunks[1]);
+
+ // Body
+ match (&app.device, app.tab) {
+ (Ok(dev), 0) => {
+ inputs::render(f, chunks[2], t, &dev.inputs, app.input_cursor, true);
+ }
+ (Ok(dev), 1) => {
+ monitor::render(f, chunks[2], t, &dev.monitor, app.monitor_cursor, true);
+ }
+ (Ok(dev), 2) => {
+ mixer::render(f, chunks[2], t, &dev.mixer, app.mixer_cursor, true);
+ }
+ (Ok(dev), 3) => {
+ routing::render(f, chunks[2], t, &dev.routing, app.routing_cursor, true);
+ }
+ (Ok(dev), 4) => {
+ meters::render(f, chunks[2], t, &dev.meters, true);
+ }
+ (Ok(dev), 5) => {
+ clock::render(f, chunks[2], t, dev.locked, true);
+ }
+ (Ok(_), _) => placeholder(f, chunks[2], t, TABS[app.tab]),
+ (Err(msg), _) => {
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_type(ratatui::widgets::BorderType::Rounded)
+ .border_style(Style::default().fg(t.danger));
+ let inner = block.inner(chunks[2]);
+ f.render_widget(block, chunks[2]);
+ f.render_widget(
+ Paragraph::new(format!("⚠ {msg}\n\nPress 'r' to retry, 'q' to quit."))
+ .style(Style::default().fg(t.danger)),
+ inner,
+ );
+ }
+ }
+
+ f.render_widget(status_bar(app), chunks[3]);
+
+ if app.show_help {
+ help_overlay(f, t);
+ }
+}
+
+/// A centered modal listing every key binding (toggled with `?`).
+fn help_overlay(f: &mut Frame, t: &Theme) {
+ let area = f.area();
+ let w = 56u16.min(area.width.saturating_sub(4));
+ let h = 18u16.min(area.height.saturating_sub(2));
+ let x = (area.width.saturating_sub(w)) / 2;
+ let y = (area.height.saturating_sub(h)) / 2;
+ let rect = Rect { x, y, width: w, height: h };
+
+ f.render_widget(ratatui::widgets::Clear, rect);
+ let block = Block::default()
+ .title(Span::styled(" Help ", Style::default().fg(t.accent)))
+ .borders(Borders::ALL)
+ .border_type(ratatui::widgets::BorderType::Rounded)
+ .border_style(Style::default().fg(t.border_focus))
+ .style(Style::default().bg(t.bg_elevated));
+ let inner = block.inner(rect);
+ f.render_widget(block, rect);
+
+ let key = |k: &str, d: &str| {
+ Line::from(vec![
+ Span::styled(format!(" {k:<10}"), Style::default().fg(t.accent)),
+ Span::styled(d.to_string(), Style::default().fg(t.fg)),
+ ])
+ };
+ let head = |s: &str| {
+ Line::from(Span::styled(
+ format!(" {s}"),
+ Style::default().fg(t.fg_dim).add_modifier(Modifier::BOLD),
+ ))
+ };
+
+ let lines = vec![
+ head("Navigation"),
+ key("Tab", "next panel"),
+ key("Shift-Tab", "previous panel"),
+ key("↑↓←→ / hjkl", "move within a panel"),
+ key("Space/Enter", "toggle / engage focused control"),
+ key("+ - 0 m", "mixer: ±1 dB, unity, mute (on Mixer tab)"),
+ Line::from(""),
+ head("Presets & device"),
+ key("S", "save current config to a preset file"),
+ key("L", "load preset and apply to device"),
+ key("W", "write current config to device NVRAM"),
+ key("r", "reconnect to the device"),
+ Line::from(""),
+ key("?", "toggle this help · q quit"),
+ ];
+ f.render_widget(Paragraph::new(lines), inner);
+}
+
+fn placeholder(f: &mut Frame, area: Rect, t: &Theme, name: &str) {
+ let block = Block::default()
+ .title(Span::styled(format!(" {name} "), Style::default().fg(t.accent)))
+ .borders(Borders::ALL)
+ .border_type(ratatui::widgets::BorderType::Rounded)
+ .border_style(Style::default().fg(t.border));
+ let inner = block.inner(area);
+ f.render_widget(block, area);
+ f.render_widget(
+ Paragraph::new(format!("“{name}” panel — coming next in Phase 3."))
+ .style(Style::default().fg(t.fg_dim)),
+ inner,
+ );
+}
+
+fn status_bar(app: &App) -> Paragraph<'_> {
+ let t = &app.theme;
+ let mut spans = vec![];
+
+ match &app.device {
+ Ok(dev) => {
+ spans.push(Span::styled(
+ " ● CONNECTED ",
+ Style::default()
+ .fg(t.good)
+ .bg(t.bg_elevated)
+ .add_modifier(Modifier::BOLD),
+ ));
+ spans.push(Span::styled(
+ format!("fw {} ", dev.firmware),
+ Style::default().fg(t.fg).bg(t.bg_elevated),
+ ));
+ let (lock_txt, lock_col) = if dev.locked {
+ ("clock: LOCKED", t.good)
+ } else {
+ ("clock: unlocked", t.warn)
+ };
+ spans.push(Span::styled(
+ format!("{lock_txt} "),
+ Style::default().fg(lock_col).bg(t.bg_elevated),
+ ));
+ }
+ Err(_) => spans.push(Span::styled(
+ " ○ DISCONNECTED ",
+ Style::default()
+ .fg(t.danger)
+ .bg(t.bg_elevated)
+ .add_modifier(Modifier::BOLD),
+ )),
+ }
+
+ if let Some(msg) = &app.status {
+ spans.push(Span::styled(
+ format!("· {msg} "),
+ Style::default().fg(t.accent).bg(t.bg_elevated),
+ ));
+ }
+
+ spans.push(Span::styled(
+ " Tab panels · S save · L load · W →NVRAM · ? help · q quit",
+ Style::default().fg(t.fg_dim).bg(t.bg_elevated),
+ ));
+
+ Paragraph::new(Line::from(spans)).style(Style::default().bg(t.bg_elevated))
+}
+
+#[cfg(test)]
+mod smoke {
+ //! Headless render smoke tests over every tab and a range of terminal sizes,
+ //! using ratatui's TestBackend. These catch render-path panics (the cause of
+ //! "the TUI flashed and vanished") without needing the device.
+ use super::*;
+ use ratatui::backend::TestBackend;
+
+ fn render_all_tabs(app: &mut App, w: u16, h: u16) {
+ let backend = TestBackend::new(w, h);
+ let mut term = Terminal::new(backend).unwrap();
+ for tab in 0..TABS.len() {
+ app.tab = tab;
+ term.draw(|f| ui(f, app)).unwrap();
+ }
+ }
+
+ #[test]
+ fn disconnected_renders_every_tab_at_many_sizes() {
+ let mut app = App::with_device(Err("Device busy — quit Focusrite Control.".into()));
+ for (w, h) in [(80, 24), (120, 40), (40, 12), (20, 8), (200, 60), (10, 5)] {
+ render_all_tabs(&mut app, w, h);
+ }
+ }
+}
diff --git a/valentine/src/panels/clock.rs b/valentine/src/panels/clock.rs
@@ -0,0 +1,47 @@
+//! The Clock panel — sync lock status (read-only). The 18i20 g3 reports lock via
+//! GET_SYNC; sample-rate readback isn't exposed by the scarlett2 control protocol
+//! (it's owned by the USB-audio side / Core Audio), so we show lock state and a
+//! note. A green ● means the device is locked to its clock source.
+
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders, Paragraph};
+
+use crate::theme::Theme;
+
+pub fn render(f: &mut Frame, area: Rect, theme: &Theme, locked: bool, focused: bool) {
+ let border = if focused { theme.border_focus } else { theme.border };
+ let block = Block::default()
+ .title(Span::styled(" Clock ", Style::default().fg(theme.accent)))
+ .borders(Borders::ALL)
+ .border_type(ratatui::widgets::BorderType::Rounded)
+ .border_style(Style::default().fg(border));
+ let inner = block.inner(area);
+ f.render_widget(block, area);
+
+ let (dot, word, color) = if locked {
+ ("●", "LOCKED", theme.good)
+ } else {
+ ("○", "UNLOCKED", theme.warn)
+ };
+
+ let lines = vec![
+ Line::from(""),
+ Line::from(vec![
+ Span::styled(" Sync status: ", Style::default().fg(theme.fg_dim)),
+ Span::styled(
+ format!("{dot} {word}"),
+ Style::default().fg(color).add_modifier(Modifier::BOLD),
+ ),
+ ]),
+ Line::from(""),
+ Line::from(Span::styled(
+ " Sample rate is set in Audio MIDI Setup / your DAW",
+ Style::default().fg(theme.fg_dim),
+ )),
+ Line::from(Span::styled(
+ " (the scarlett2 control path doesn't expose rate).",
+ Style::default().fg(theme.fg_dim),
+ )),
+ ];
+ f.render_widget(Paragraph::new(lines), inner);
+}
diff --git a/valentine/src/panels/inputs.rs b/valentine/src/panels/inputs.rs
@@ -0,0 +1,189 @@
+//! The Inputs panel — a navigable grid of the per-channel preamp switches
+//! (Inst/Line, Air, Pad, 48V phantom) for the 8 analogue inputs. Amber = engaged.
+//!
+//! Arrow keys move the cursor; Space/Enter toggles the focused switch via the
+//! `scarlett-core` control layer. 48V is per phantom *group* (inputs 1–4, 5–8),
+//! so toggling it on any input in a group flips the whole group.
+
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders, Paragraph};
+
+use scarlett_core::controls::{InputState, InputSwitch};
+use scarlett_core::model::S18I20_GEN3;
+
+use crate::theme::Theme;
+
+/// Columns of the input grid, left to right.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Col {
+ Inst,
+ Air,
+ Pad,
+ P48,
+}
+
+impl Col {
+ pub const ALL: [Col; 4] = [Col::Inst, Col::Air, Col::Pad, Col::P48];
+
+ fn label(self) -> &'static str {
+ match self {
+ Col::Inst => "INST",
+ Col::Air => "AIR",
+ Col::Pad => "PAD",
+ Col::P48 => "48V",
+ }
+ }
+
+ /// Whether this switch exists for the given 0-based input.
+ fn applies_to(self, input: u8) -> bool {
+ match self {
+ Col::Inst => input < S18I20_GEN3.level_input_count,
+ Col::Air => input < S18I20_GEN3.air_input_count,
+ Col::Pad => input < S18I20_GEN3.pad_input_count,
+ Col::P48 => true,
+ }
+ }
+}
+
+/// Cursor position within the grid.
+#[derive(Debug, Clone, Copy)]
+pub struct Cursor {
+ pub input: u8,
+ pub col: usize,
+}
+
+impl Default for Cursor {
+ fn default() -> Self {
+ Cursor { input: 0, col: 1 } // start on AIR of input 1
+ }
+}
+
+impl Cursor {
+ pub fn up(&mut self) {
+ self.input = self.input.saturating_sub(1);
+ }
+ pub fn down(&mut self) {
+ if self.input + 1 < S18I20_GEN3.air_input_count {
+ self.input += 1;
+ }
+ }
+ pub fn left(&mut self) {
+ self.col = self.col.saturating_sub(1);
+ }
+ pub fn right(&mut self) {
+ if self.col + 1 < Col::ALL.len() {
+ self.col += 1;
+ }
+ }
+
+ pub fn current_col(&self) -> Col {
+ Col::ALL[self.col]
+ }
+}
+
+/// Is the switch at (input, col) currently on, per `state`?
+fn is_on(state: &InputState, input: u8, col: Col) -> bool {
+ let i = input as usize;
+ match col {
+ Col::Inst => state.inst.get(i).copied().unwrap_or(false),
+ Col::Air => state.air.get(i).copied().unwrap_or(false),
+ Col::Pad => state.pad.get(i).copied().unwrap_or(false),
+ Col::P48 => {
+ let group = (input / S18I20_GEN3.inputs_per_phantom) as usize;
+ state.phantom.get(group).copied().unwrap_or(false)
+ }
+ }
+}
+
+/// Map a column to its core `InputSwitch` (None for 48V, handled separately).
+pub fn col_switch(col: Col) -> Option<InputSwitch> {
+ match col {
+ Col::Inst => Some(InputSwitch::Inst),
+ Col::Air => Some(InputSwitch::Air),
+ Col::Pad => Some(InputSwitch::Pad),
+ Col::P48 => None,
+ }
+}
+
+/// Render the input grid into `area`.
+pub fn render(
+ f: &mut Frame,
+ area: Rect,
+ theme: &Theme,
+ state: &InputState,
+ cursor: Cursor,
+ focused: bool,
+) {
+ let border = if focused { theme.border_focus } else { theme.border };
+ let block = Block::default()
+ .title(Span::styled(" Inputs ", Style::default().fg(theme.accent)))
+ .borders(Borders::ALL)
+ .border_type(ratatui::widgets::BorderType::Rounded)
+ .border_style(Style::default().fg(border));
+ let inner = block.inner(area);
+ f.render_widget(block, area);
+
+ let mut lines: Vec<Line> = Vec::new();
+
+ // Header row.
+ let mut header = vec![Span::styled(
+ format!("{:<10}", "Input"),
+ Style::default().fg(theme.fg_dim),
+ )];
+ for col in Col::ALL {
+ header.push(Span::styled(
+ format!("{:^7}", col.label()),
+ Style::default().fg(theme.fg_dim).add_modifier(Modifier::BOLD),
+ ));
+ }
+ lines.push(Line::from(header));
+ lines.push(Line::from(""));
+
+ // One row per analogue input.
+ for input in 0..S18I20_GEN3.air_input_count {
+ let label = if input < S18I20_GEN3.level_input_count {
+ format!("In {} (HiZ)", input + 1)
+ } else {
+ format!("In {}", input + 1)
+ };
+ let mut row = vec![Span::styled(
+ format!("{label:<10}"),
+ Style::default().fg(theme.fg),
+ )];
+
+ for (ci, col) in Col::ALL.iter().enumerate() {
+ let col = *col;
+ let here = focused && cursor.input == input && cursor.col == ci;
+
+ let cell = if !col.applies_to(input) {
+ Span::styled(format!("{:^7}", "·"), Style::default().fg(theme.fg_dim))
+ } else {
+ let on = is_on(state, input, col);
+ let glyph = if on { "● ON" } else { " ·" };
+ let mut style = if on {
+ Style::default().fg(theme.armed).add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(theme.fg_dim)
+ };
+ if here {
+ style = style.bg(theme.bg_selected).fg(if on {
+ theme.armed
+ } else {
+ theme.accent
+ });
+ }
+ Span::styled(format!("{glyph:^7}"), style)
+ };
+ row.push(cell);
+ }
+ lines.push(Line::from(row));
+ }
+
+ lines.push(Line::from(""));
+ lines.push(Line::from(Span::styled(
+ "↑↓ input ←→ switch space/enter toggle",
+ Style::default().fg(theme.fg_dim),
+ )));
+
+ f.render_widget(Paragraph::new(lines), inner);
+}
diff --git a/valentine/src/panels/meters.rs b/valentine/src/panels/meters.rs
@@ -0,0 +1,66 @@
+//! The Meters panel — a live wall of horizontal level bars over GET_METER.
+//!
+//! The device returns raw u32 levels (dBFS-ish, larger = louder). We don't yet
+//! decode the exact dB curve, so bars are normalized against the observed max and
+//! coloured green→amber→red by fill. This is a monitoring view; no input.
+
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders};
+
+use crate::theme::Theme;
+
+/// Raw meter level treated as full scale. Hardware (18i20 g3) returns ~12-bit
+/// values — idle peak was 4095 — so 4095 is the working ceiling until the exact
+/// dB curve is mapped. Revisit with signal present.
+const FULL_SCALE: f32 = 4095.0;
+
+pub fn render(f: &mut Frame, area: Rect, theme: &Theme, meters: &[u32], focused: bool) {
+ let border = if focused { theme.border_focus } else { theme.border };
+ let block = Block::default()
+ .title(Span::styled(
+ format!(" Meters ({}) ", meters.len()),
+ Style::default().fg(theme.accent),
+ ))
+ .borders(Borders::ALL)
+ .border_type(ratatui::widgets::BorderType::Rounded)
+ .border_style(Style::default().fg(border));
+ let inner = block.inner(area);
+ f.render_widget(block, area);
+
+ if meters.is_empty() {
+ return;
+ }
+
+ // Lay meters into as many columns as fit (each meter row is one text line).
+ let rows_per_col = inner.height.max(1) as usize;
+ let col_count = meters.len().div_ceil(rows_per_col);
+ let col_width = (inner.width as usize / col_count.max(1)).max(8) as u16;
+
+ let bar_cells = col_width.saturating_sub(6) as usize; // leave room for "## "
+
+ let mut lines: Vec<Line> = Vec::new();
+ for _ in 0..rows_per_col {
+ lines.push(Line::from(""));
+ }
+
+ for (i, &raw) in meters.iter().enumerate() {
+ let level = (raw as f32 / FULL_SCALE).clamp(0.0, 1.0);
+ let filled = (level * bar_cells as f32).round() as usize;
+ let color = theme.meter_color(level);
+
+ let bar: String = "█".repeat(filled) + &"·".repeat(bar_cells.saturating_sub(filled));
+ let cell = Line::from(vec![
+ Span::styled(format!("{:>2} ", i + 1), Style::default().fg(theme.fg_dim)),
+ Span::styled(bar, Style::default().fg(color)),
+ Span::raw(" "),
+ ]);
+
+ let row = i % rows_per_col;
+ // append this meter's cell to its row line
+ let mut spans = lines[row].spans.clone();
+ spans.extend(cell.spans);
+ lines[row] = Line::from(spans);
+ }
+
+ f.render_widget(ratatui::widgets::Paragraph::new(lines), inner);
+}
diff --git a/valentine/src/panels/mixer.rs b/valentine/src/panels/mixer.rs
@@ -0,0 +1,170 @@
+//! The Mixer panel — the crosspoint matrix. Rows are mixer inputs, columns are
+//! mix buses (A, B, C…); each cell is the gain (dB) of that input into that bus.
+//!
+//! Arrow keys / hjkl move the cursor; `+`/`-` nudge the focused crosspoint by
+//! 1 dB and write it to the device; `0` sets unity, `m` sets silence. The grid
+//! scrolls so the cursor stays visible. Amber = above unity, cyan cursor cell.
+
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders, Paragraph};
+
+use scarlett_core::matrix::{MIXER_MAX_DB, MIXER_MIN_DB};
+
+use crate::theme::Theme;
+
+/// Cursor over the crosspoint grid.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct Cursor {
+ /// Mixer input index (row).
+ pub input: usize,
+ /// Mix bus index (column).
+ pub bus: usize,
+}
+
+impl Cursor {
+ pub fn up(&mut self) {
+ self.input = self.input.saturating_sub(1);
+ }
+ pub fn down(&mut self, max_inputs: usize) {
+ if self.input + 1 < max_inputs {
+ self.input += 1;
+ }
+ }
+ pub fn left(&mut self) {
+ self.bus = self.bus.saturating_sub(1);
+ }
+ pub fn right(&mut self, max_buses: usize) {
+ if self.bus + 1 < max_buses {
+ self.bus += 1;
+ }
+ }
+}
+
+/// Format a dB value compactly for a 5-char cell (e.g. " 0.0", "-12", "-80").
+fn fmt_db(db: f32) -> String {
+ if db <= MIXER_MIN_DB + 0.05 {
+ " -∞".to_string()
+ } else if db.abs() < 0.05 {
+ " 0.0".to_string()
+ } else {
+ format!("{db:>4.0}")
+ }
+}
+
+/// Mix bus label: A, B, … then AA, AB if it ever exceeds 26.
+fn bus_label(i: usize) -> String {
+ if i < 26 {
+ ((b'A' + i as u8) as char).to_string()
+ } else {
+ format!("{}", i + 1)
+ }
+}
+
+/// `grid[bus][input]` of dB values.
+pub fn render(
+ f: &mut Frame,
+ area: Rect,
+ theme: &Theme,
+ grid: &[Vec<f32>],
+ cursor: Cursor,
+ focused: bool,
+) {
+ let border = if focused { theme.border_focus } else { theme.border };
+ let block = Block::default()
+ .title(Span::styled(" Mixer ", Style::default().fg(theme.accent)))
+ .borders(Borders::ALL)
+ .border_type(ratatui::widgets::BorderType::Rounded)
+ .border_style(Style::default().fg(border));
+ let inner = block.inner(area);
+ f.render_widget(block, area);
+
+ if grid.is_empty() {
+ f.render_widget(
+ Paragraph::new(Span::styled(
+ "loading mixer…",
+ Style::default().fg(theme.fg_dim),
+ )),
+ inner,
+ );
+ return;
+ }
+
+ let buses = grid.len();
+ let inputs = grid.first().map(|b| b.len()).unwrap_or(0);
+
+ // How many rows/cols fit (each cell is 5 cols wide; first col is a 4-wide label).
+ let label_w = 4usize;
+ let cell_w = 5usize;
+ let visible_cols = ((inner.width as usize).saturating_sub(label_w) / cell_w).max(1);
+ let visible_rows = (inner.height as usize).saturating_sub(2).max(1); // header+help
+
+ // Scroll offsets so the cursor stays on screen.
+ let col_start = cursor.bus.saturating_sub(visible_cols - 1).min(buses.saturating_sub(1));
+ let col_start = col_start.min(buses.saturating_sub(visible_cols).max(0));
+ let row_start = cursor.input.saturating_sub(visible_rows - 1);
+ let row_start = row_start.min(inputs.saturating_sub(visible_rows).max(0));
+
+ let col_end = (col_start + visible_cols).min(buses);
+ let row_end = (row_start + visible_rows).min(inputs);
+
+ let mut lines: Vec<Line> = Vec::new();
+
+ // Header: bus labels.
+ let mut header = vec![Span::styled(format!("{:<width$}", "", width = label_w), Style::default())];
+ for bus in col_start..col_end {
+ let style = if bus == cursor.bus {
+ Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(theme.fg_dim)
+ };
+ header.push(Span::styled(format!("{:>width$}", bus_label(bus), width = cell_w), style));
+ }
+ lines.push(Line::from(header));
+
+ // Rows.
+ for input in row_start..row_end {
+ let row_label_style = if input == cursor.input {
+ Style::default().fg(theme.accent)
+ } else {
+ Style::default().fg(theme.fg_dim)
+ };
+ let mut row = vec![Span::styled(
+ format!("{:<width$}", format!("{:>2}", input + 1), width = label_w),
+ row_label_style,
+ )];
+ for bus in col_start..col_end {
+ let db = grid[bus].get(input).copied().unwrap_or(MIXER_MIN_DB);
+ let here = focused && bus == cursor.bus && input == cursor.input;
+ let mut style = if db > 0.05 {
+ Style::default().fg(theme.armed) // above unity = amber
+ } else if db <= MIXER_MIN_DB + 0.05 {
+ Style::default().fg(theme.fg_dim) // silent
+ } else {
+ Style::default().fg(theme.fg)
+ };
+ if here {
+ style = style.bg(theme.bg_selected).add_modifier(Modifier::BOLD);
+ }
+ row.push(Span::styled(format!("{:>width$}", fmt_db(db), width = cell_w), style));
+ }
+ lines.push(Line::from(row));
+ }
+
+ lines.push(Line::from(Span::styled(
+ format!(
+ "↑↓←→ move +/- 1dB 0 unity m mute [in {}/{} bus {}/{}]",
+ cursor.input + 1,
+ inputs,
+ bus_label(cursor.bus),
+ buses
+ ),
+ Style::default().fg(theme.fg_dim),
+ )));
+
+ f.render_widget(Paragraph::new(lines), inner);
+}
+
+/// Clamp helper used by main when nudging a cell.
+pub fn clamp_db(db: f32) -> f32 {
+ db.clamp(MIXER_MIN_DB, MIXER_MAX_DB)
+}
diff --git a/valentine/src/panels/mod.rs b/valentine/src/panels/mod.rs
@@ -0,0 +1,9 @@
+//! Feature panels. Each tab is a panel module; Phase 3 fills them in one by one.
+//! `inputs` is live; the rest render a "coming soon" placeholder for now.
+
+pub mod clock;
+pub mod inputs;
+pub mod meters;
+pub mod mixer;
+pub mod monitor;
+pub mod routing;
diff --git a/valentine/src/panels/monitor.rs b/valentine/src/panels/monitor.rs
@@ -0,0 +1,147 @@
+//! The Monitor panel — master level, Mute, and Dim for the monitor outputs.
+//!
+//! Master volume on the 18i20 g3 is hardware-controlled (the big knob), so it's
+//! shown read-only as a dB bar. Mute and Dim are software-toggleable buttons
+//! (Dim = −18 dB). Amber = engaged.
+
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders, Gauge, Paragraph};
+
+use scarlett_core::controls::{MonitorButton, MonitorState, VOLUME_BIAS};
+
+use crate::theme::Theme;
+
+/// Toggleable rows on the monitor panel.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Row {
+ Mute,
+ Dim,
+}
+
+impl Row {
+ pub const ALL: [Row; 2] = [Row::Mute, Row::Dim];
+
+ pub fn button(self) -> MonitorButton {
+ match self {
+ Row::Mute => MonitorButton::Mute,
+ Row::Dim => MonitorButton::Dim,
+ }
+ }
+
+ fn label(self) -> &'static str {
+ match self {
+ Row::Mute => "MUTE",
+ Row::Dim => "DIM (−18 dB)",
+ }
+ }
+}
+
+/// Cursor over the monitor toggles.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct Cursor {
+ pub row: usize,
+}
+
+impl Cursor {
+ pub fn up(&mut self) {
+ self.row = self.row.saturating_sub(1);
+ }
+ pub fn down(&mut self) {
+ if self.row + 1 < Row::ALL.len() {
+ self.row += 1;
+ }
+ }
+ pub fn current(&self) -> Row {
+ Row::ALL[self.row]
+ }
+}
+
+fn row_on(state: &MonitorState, row: Row) -> bool {
+ match row {
+ Row::Mute => state.mute,
+ Row::Dim => state.dim,
+ }
+}
+
+pub fn render(
+ f: &mut Frame,
+ area: Rect,
+ theme: &Theme,
+ state: &MonitorState,
+ cursor: Cursor,
+ focused: bool,
+) {
+ let border = if focused { theme.border_focus } else { theme.border };
+ let block = Block::default()
+ .title(Span::styled(" Monitor ", Style::default().fg(theme.accent)))
+ .borders(Borders::ALL)
+ .border_type(ratatui::widgets::BorderType::Rounded)
+ .border_style(Style::default().fg(border));
+ let inner = block.inner(area);
+ f.render_widget(block, area);
+
+ let rows = Layout::vertical([
+ Constraint::Length(1), // label
+ Constraint::Length(1), // gauge
+ Constraint::Length(1), // spacer
+ Constraint::Length(1), // mute
+ Constraint::Length(1), // dim
+ Constraint::Min(0), // help
+ ])
+ .split(inner);
+
+ // Master level (read-only): bias so 0 dB = full, -127 dB = empty.
+ let db = state.master_db;
+ let ratio = ((db + VOLUME_BIAS) as f64 / VOLUME_BIAS as f64).clamp(0.0, 1.0);
+ f.render_widget(
+ Paragraph::new(Span::styled(
+ format!("Master level (hardware knob): {db} dB"),
+ Style::default().fg(theme.fg_dim),
+ )),
+ rows[0],
+ );
+ let gauge_color = if state.mute { theme.danger } else { theme.good };
+ f.render_widget(
+ Gauge::default()
+ .gauge_style(Style::default().fg(gauge_color).bg(theme.bg_elevated))
+ .ratio(ratio)
+ .label(format!("{} dB", db)),
+ rows[1],
+ );
+
+ // Toggle rows.
+ for (i, row) in Row::ALL.iter().enumerate() {
+ let row = *row;
+ let on = row_on(state, row);
+ let here = focused && cursor.row == i;
+ let marker = if on { "● ON " } else { " · " };
+ let mut style = if on {
+ Style::default().fg(theme.armed).add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(theme.fg_dim)
+ };
+ if here {
+ style = style.bg(theme.bg_selected);
+ }
+ let line = Line::from(vec![
+ Span::styled(format!(" {marker} "), style),
+ Span::styled(
+ row.label(),
+ if here {
+ Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(theme.fg)
+ },
+ ),
+ ]);
+ f.render_widget(Paragraph::new(line), rows[3 + i]);
+ }
+
+ f.render_widget(
+ Paragraph::new(Span::styled(
+ "↑↓ select space/enter toggle",
+ Style::default().fg(theme.fg_dim),
+ )),
+ rows[5],
+ );
+}
diff --git a/valentine/src/panels/routing.rs b/valentine/src/panels/routing.rs
@@ -0,0 +1,103 @@
+//! The Routing panel — shows the device's signal routing: which source feeds each
+//! physical/virtual output (sink), with human names decoded from the hardware IDs.
+//!
+//! This is **read-only for now**. Editing the routing requires the kernel's
+//! per-sample-rate-band mux-table write semantics, which the core's `set_mux`
+//! doesn't yet replicate; that needs hardware verification before being exposed.
+//! Until then this is a faithful view of the current routing.
+
+use ratatui::prelude::*;
+use ratatui::widgets::{Block, Borders, Paragraph};
+
+use crate::theme::Theme;
+
+/// Scroll offset (which sink row is at the top).
+#[derive(Debug, Clone, Copy, Default)]
+pub struct Cursor {
+ pub row: usize,
+}
+
+impl Cursor {
+ pub fn up(&mut self) {
+ self.row = self.row.saturating_sub(1);
+ }
+ pub fn down(&mut self, max: usize) {
+ if self.row + 1 < max {
+ self.row += 1;
+ }
+ }
+}
+
+/// `routes` is the decoded `(sink, source)` list from `Scarlett::read_routing`.
+pub fn render(
+ f: &mut Frame,
+ area: Rect,
+ theme: &Theme,
+ routes: &[(String, String)],
+ cursor: Cursor,
+ focused: bool,
+) {
+ let border = if focused { theme.border_focus } else { theme.border };
+ let block = Block::default()
+ .title(Span::styled(" Routing ", Style::default().fg(theme.accent)))
+ .borders(Borders::ALL)
+ .border_type(ratatui::widgets::BorderType::Rounded)
+ .border_style(Style::default().fg(border));
+ let inner = block.inner(area);
+ f.render_widget(block, area);
+
+ if routes.is_empty() {
+ f.render_widget(
+ Paragraph::new(Span::styled(
+ "reading routing… (if this stays empty, routing read needs a hardware check)",
+ Style::default().fg(theme.fg_dim),
+ )),
+ inner,
+ );
+ return;
+ }
+
+ let visible = (inner.height as usize).saturating_sub(2).max(1);
+ let start = cursor.row.saturating_sub(visible - 1).min(routes.len().saturating_sub(visible).max(0));
+ let end = (start + visible).min(routes.len());
+
+ let mut lines: Vec<Line> = Vec::new();
+ lines.push(Line::from(Span::styled(
+ format!("{:<22} {}", "Output (sink)", "Source"),
+ Style::default().fg(theme.fg_dim).add_modifier(Modifier::BOLD),
+ )));
+
+ for (i, (sink, source)) in routes.iter().enumerate().take(end).skip(start) {
+ let here = focused && i == cursor.row;
+ let off = source == "Off";
+ let src_style = if off {
+ Style::default().fg(theme.fg_dim)
+ } else {
+ Style::default().fg(theme.armed) // a live route = amber
+ };
+ let sink_style = if here {
+ Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(theme.fg)
+ };
+ let arrow = if off { " · " } else { " ← " };
+ let mut spans = vec![
+ Span::styled(format!("{sink:<22}"), sink_style),
+ Span::styled(arrow, Style::default().fg(theme.fg_dim)),
+ Span::styled(source.clone(), src_style),
+ ];
+ if here {
+ spans.insert(0, Span::styled("▸", Style::default().fg(theme.accent)));
+ } else {
+ spans.insert(0, Span::raw(" "));
+ }
+ lines.push(Line::from(spans));
+ }
+
+ lines.push(Line::from(Span::styled(
+ "↑↓ scroll (read-only — editing pending hardware check)",
+ Style::default().fg(theme.fg_dim),
+ )));
+
+ f.render_widget(Paragraph::new(lines), inner);
+}
diff --git a/valentine/src/theme.rs b/valentine/src/theme.rs
@@ -0,0 +1,147 @@
+//! Theming. The palette is data, not code.
+//!
+//! Valentine compiles in **Ember** (`themes/default.toml`, a tasteful red/magenta
+//! palette) as the default everyone gets, and loads an override from
+//! `~/.config/valentine/theme.toml` if present. Keeping the palette in a swappable
+//! file means a personal palette (e.g. Navi) lives only in the user's config and
+//! never has to ship in the source — clean to open-source.
+
+use ratatui::style::Color;
+use serde::Deserialize;
+
+/// The compiled-in default (Ember). Parsed once at startup.
+const DEFAULT_THEME_TOML: &str = include_str!("../../themes/default.toml");
+
+/// A hex color string from the theme file, e.g. "#ff5fa2".
+#[derive(Debug, Clone, Deserialize)]
+struct Hex(String);
+
+impl Hex {
+ fn color(&self) -> Color {
+ let s = self.0.trim_start_matches('#');
+ let parse = |i: usize| u8::from_str_radix(&s[i..i + 2], 16).unwrap_or(0);
+ if s.len() == 6 {
+ Color::Rgb(parse(0), parse(2), parse(4))
+ } else {
+ Color::Reset
+ }
+ }
+}
+
+/// Raw theme as read from TOML (every field a hex string).
+#[derive(Debug, Clone, Deserialize)]
+struct RawTheme {
+ bg: Hex,
+ bg_elevated: Hex,
+ bg_selected: Hex,
+ border: Hex,
+ border_focus: Hex,
+ fg: Hex,
+ fg_dim: Hex,
+ accent: Hex,
+ armed: Hex,
+ good: Hex,
+ warn: Hex,
+ danger: Hex,
+ meter_low: Hex,
+ meter_mid: Hex,
+ meter_high: Hex,
+}
+
+/// Resolved palette, ready for use in widgets.
+#[derive(Debug, Clone, Copy)]
+pub struct Theme {
+ pub bg: Color,
+ pub bg_elevated: Color,
+ pub bg_selected: Color,
+ pub border: Color,
+ pub border_focus: Color,
+ pub fg: Color,
+ pub fg_dim: Color,
+ pub accent: Color,
+ pub armed: Color,
+ pub good: Color,
+ pub warn: Color,
+ pub danger: Color,
+ pub meter_low: Color,
+ pub meter_mid: Color,
+ pub meter_high: Color,
+}
+
+impl From<RawTheme> for Theme {
+ fn from(r: RawTheme) -> Self {
+ Theme {
+ bg: r.bg.color(),
+ bg_elevated: r.bg_elevated.color(),
+ bg_selected: r.bg_selected.color(),
+ border: r.border.color(),
+ border_focus: r.border_focus.color(),
+ fg: r.fg.color(),
+ fg_dim: r.fg_dim.color(),
+ accent: r.accent.color(),
+ armed: r.armed.color(),
+ good: r.good.color(),
+ warn: r.warn.color(),
+ danger: r.danger.color(),
+ meter_low: r.meter_low.color(),
+ meter_mid: r.meter_mid.color(),
+ meter_high: r.meter_high.color(),
+ }
+ }
+}
+
+impl Theme {
+ /// Load the user override if present, else the bundled Ember default.
+ pub fn load() -> Self {
+ if let Some(dir) = dirs_next::config_dir() {
+ let path = dir.join("valentine").join("theme.toml");
+ if let Ok(text) = std::fs::read_to_string(&path) {
+ if let Ok(raw) = toml::from_str::<RawTheme>(&text) {
+ return raw.into();
+ }
+ }
+ }
+ Self::default()
+ }
+
+ /// Pick a meter color for a 0.0..=1.0 normalized level.
+ pub fn meter_color(&self, level: f32) -> Color {
+ if level >= 0.95 {
+ self.meter_high
+ } else if level >= 0.70 {
+ self.meter_mid
+ } else {
+ self.meter_low
+ }
+ }
+}
+
+impl Default for Theme {
+ fn default() -> Self {
+ toml::from_str::<RawTheme>(DEFAULT_THEME_TOML)
+ .expect("bundled default.toml must parse")
+ .into()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn bundled_ember_theme_parses() {
+ let t = Theme::default();
+ assert_eq!(t.accent, Color::Rgb(0xff, 0x5f, 0xa2)); // rose
+ assert_eq!(t.armed, Color::Rgb(0xff, 0x8a, 0x5b)); // coral ember
+ assert_eq!(t.bg, Color::Rgb(0x17, 0x0e, 0x15)); // plum-black
+ assert_eq!(t.danger, Color::Rgb(0xff, 0x3b, 0x5c)); // crimson
+ }
+
+ #[test]
+ fn meter_color_thresholds() {
+ let t = Theme::default();
+ assert_eq!(t.meter_color(0.1), t.meter_low);
+ assert_eq!(t.meter_color(0.8), t.meter_mid);
+ assert_eq!(t.meter_color(0.99), t.meter_high);
+ }
+}