valentine

Terminal control panel for the Focusrite Scarlett 18i20 — a from-scratch replacement for Focusrite Control.
Log | Files | Refs | README | LICENSE

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:
A.gitignore | 3+++
ACargo.lock | 881+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACargo.toml | 19+++++++++++++++++++
AREADME.md | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascarlett-core/Cargo.toml | 17+++++++++++++++++
Ascarlett-core/src/controls.rs | 289++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascarlett-core/src/lib.rs | 33+++++++++++++++++++++++++++++++++
Ascarlett-core/src/matrix.rs | 328+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascarlett-core/src/model.rs | 225+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascarlett-core/src/packet.rs | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascarlett-core/src/ports.rs | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascarlett-core/src/preset.rs | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascarlett-core/src/protocol.rs | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascarlett-core/src/transport.rs | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aspike/Cargo.toml | 28++++++++++++++++++++++++++++
Aspike/src/bin/hwcheck.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aspike/src/main.rs | 291++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Athemes/default.toml | 31+++++++++++++++++++++++++++++++
Avalentine/Cargo.toml | 20++++++++++++++++++++
Avalentine/src/main.rs | 800+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Avalentine/src/panels/clock.rs | 47+++++++++++++++++++++++++++++++++++++++++++++++
Avalentine/src/panels/inputs.rs | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Avalentine/src/panels/meters.rs | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Avalentine/src/panels/mixer.rs | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Avalentine/src/panels/mod.rs | 9+++++++++
Avalentine/src/panels/monitor.rs | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Avalentine/src/panels/routing.rs | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Avalentine/src/theme.rs | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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); + } +}