diff --git a/.claude/skills/screenshot/SKILL.md b/.claude/skills/screenshot/SKILL.md new file mode 100644 index 0000000..cbc3374 --- /dev/null +++ b/.claude/skills/screenshot/SKILL.md @@ -0,0 +1,122 @@ +--- +name: screenshot +description: Build and launch Halo with a real track, then capture the window with screencapture for UI inspection and spacing work. Use when Rob asks to run the app, screenshot it, or check/tune UI layout and element spacing. +--- + +# Launching Halo and screenshotting the window + +Launches the app with a real track loaded on deck A, captures the window as a +PNG, and reads it back to inspect layout/spacing. No app-side screenshot +support exists — capture is external via macOS `screencapture`. + +## Track + +Always use the real music track so the waveforms look representative: + +``` +../timestretch-rs/benchmarks/audio/public-corpus/01-Interplanetary_Criminal-Saucers.mp3 +``` + +`../timestretch-rs` is always present (it's a path dependency in `Cargo.toml`), +and the track's `.tsanalysis.json` sidecar is cached next to it, so load and +analysis are fast. Do NOT use synthetic test tones — flat waveforms make +spacing judgments harder. + +## Procedure + +All paths relative to the repo root. `$SCRATCH` = the session scratchpad. + +1. **Build & launch** with an isolated DB so scripted runs never touch the real + library at `~/Library/Application Support/Halo/halo.db`: + + ```bash + cargo build --release + HALO_DB="$SCRATCH/halo.db" RUST_LOG=info target/release/halo \ + ../timestretch-rs/benchmarks/audio/public-corpus/01-Interplanetary_Criminal-Saucers.mp3 \ + > "$SCRATCH/app.log" 2>&1 & + sleep 4 + ``` + + argv[1] auto-loads the track onto deck A. For a mid-playback capture add + `HALO_AUTOPLAY=1` (also available: `HALO_PITCH=`, `HALO_LOOP=1`, + `HALO_IMPORT=`). + +2. **Compile the UI helper** from the copy kept in this skill directory + (`uidrive.swift`, subcommands: `winid ` / `list` / `click ` / + `drag [holdBeforeMs] [holdAfterMs]`): + + ```bash + swiftc -O -o "$SCRATCH/uidrive" .claude/skills/screenshot/uidrive.swift + ``` + +3. **Find the window** (and, if you'll drive the pointer, safety-check first — + CGEvent clicks land on whatever is topmost). Bring Halo frontmost, dump the + layer-0 window list, and confirm the Halo window is first at the expected + position. If another window is on top or Rob is clearly active, STOP and + hand over to him. + + ```bash + osascript -e 'tell application "System Events" to set frontmost of first process whose name contains "halo" to true' + "$SCRATCH/uidrive" winid halo # -> "windowID x y w h" (screen points) + "$SCRATCH/uidrive" list | head -5 + ``` + + Default window is 1200×700 points (min 900×550), but eframe's + `persistence` feature restores the size/position from the last run — + always take the actual geometry from `winid` output rather than assuming + the default. To test a specific size, resize with AppleScript first: + `osascript -e 'tell application "System Events" to set size of front window of (first process whose name contains "halo") to {1200, 700}'`. + +4. **Capture**: + + ```bash + screencapture -x -o -l "$SCRATCH/shot.png" + ``` + + `-o` (no shadow) gives an exact 2× point→pixel mapping; without it the + shadow margin breaks coordinates and adds transparent padding. Expect + 2400×1400 pixels for the default window. + +5. **Inspect** by reading the PNG with the Read tool. For spacing work: + image pixels are 2× egui points — measure in the image and divide by 2. + Click targets for `uidrive` are screen points: window origin (from + `winid`) + in-window point coords. Don't rely on hardcoded coordinates + from earlier sessions — the layout is exactly what's being iterated on, + so re-derive targets from the current screenshot each time. + + When interacting mid-playback, stage everything in ONE bash command so + tool-call gaps don't drift the transport: + + ```bash + "$SCRATCH/uidrive" click && sleep 1 && \ + screencapture -x -o -l "$SCRATCH/shot2.png" + ``` + + **Synthetic input gotcha**: several Halo controls (hot cues, CUE, the + shortcut keys) detect press/release *edges* by sampling held state per + frame — instantaneous synthetic taps land between frames and vanish. + Plain `uidrive click` and `osascript keystroke` therefore often do + nothing. Make presses span frames instead: + + ```bash + # press-and-hold "click": drag in place with hold times + "$SCRATCH/uidrive" drag 30 150 150 + # held keypress + osascript -e 'tell application "System Events" to key down "v"'; sleep 0.3 + osascript -e 'tell application "System Events" to key up "v"' + ``` + + And re-verify the Halo window is still topmost (`uidrive list`) + **immediately before every batch of pointer input**, not just at + launch — if focus has moved, presses land in whatever app is now on + top. When that happens, stop driving and hand the check to Rob. + +6. **Clean up** (pattern must be precise — plain `halo` is too greedy): + + ```bash + pkill -f 'target/release/halo' + grep -ci 'underrun\|error' "$SCRATCH/app.log" # expect 0 + ``` + +For iterating on spacing: edit → `cargo build --release` → relaunch → recapture, +comparing successive PNGs in `$SCRATCH`. diff --git a/.claude/skills/screenshot/uidrive.swift b/.claude/skills/screenshot/uidrive.swift new file mode 100644 index 0000000..23925fb --- /dev/null +++ b/.claude/skills/screenshot/uidrive.swift @@ -0,0 +1,81 @@ +import Cocoa +import CoreGraphics + +func layer0Windows() -> [[String: Any]] { + let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], + kCGNullWindowID) as! [[String: Any]] + return list.filter { ($0[kCGWindowLayer as String] as? Int ?? -1) == 0 } +} + +func bounds(_ w: [String: Any]) -> CGRect { + let b = w[kCGWindowBounds as String] as! [String: CGFloat] + return CGRect(x: b["X"]!, y: b["Y"]!, width: b["Width"]!, height: b["Height"]!) +} + +func post(_ type: CGEventType, _ pt: CGPoint, _ button: CGMouseButton = .left) { + CGEvent(mouseEventSource: nil, mouseType: type, + mouseCursorPosition: pt, mouseButton: button)?.post(tap: .cghidEventTap) +} + +let args = CommandLine.arguments +guard args.count >= 2 else { + fputs("usage: uidrive winid | list | click | drag [holdBeforeMs] [holdAfterMs]\n", stderr) + exit(2) +} + +switch args[1] { +case "winid": + let name = args[2].lowercased() + for w in layer0Windows() { + let owner = (w[kCGWindowOwnerName as String] as? String ?? "").lowercased() + if owner.contains(name) { + let id = w[kCGWindowNumber as String] as! Int + let r = bounds(w) + print("\(id) \(Int(r.origin.x)) \(Int(r.origin.y)) \(Int(r.width)) \(Int(r.height))") + exit(0) + } + } + fputs("window not found\n", stderr) + exit(1) + +case "list": + for w in layer0Windows() { + let owner = w[kCGWindowOwnerName as String] as? String ?? "?" + let title = w[kCGWindowName as String] as? String ?? "" + let id = w[kCGWindowNumber as String] as! Int + let r = bounds(w) + print("\(id)\t\(owner)\t\(title)\t\(Int(r.origin.x)) \(Int(r.origin.y)) \(Int(r.width)) \(Int(r.height))") + } + +case "click": + let pt = CGPoint(x: Double(args[2])!, y: Double(args[3])!) + post(.mouseMoved, pt) + usleep(50_000) + post(.leftMouseDown, pt) + usleep(60_000) + post(.leftMouseUp, pt) + +case "drag": + let p1 = CGPoint(x: Double(args[2])!, y: Double(args[3])!) + let p2 = CGPoint(x: Double(args[4])!, y: Double(args[5])!) + let ms = Double(args[6])! + let holdBefore = args.count > 7 ? Double(args[7])! : 0 + let holdAfter = args.count > 8 ? Double(args[8])! : 0 + post(.mouseMoved, p1) + usleep(50_000) + post(.leftMouseDown, p1) + if holdBefore > 0 { usleep(useconds_t(holdBefore * 1000)) } + let steps = max(Int(ms / 8), 2) + for i in 1...steps { + let t = Double(i) / Double(steps) + let pt = CGPoint(x: p1.x + (p2.x - p1.x) * t, y: p1.y + (p2.y - p1.y) * t) + post(.leftMouseDragged, pt) + usleep(useconds_t((ms / Double(steps)) * 1000)) + } + if holdAfter > 0 { usleep(useconds_t(holdAfter * 1000)) } + post(.leftMouseUp, p2) + +default: + fputs("unknown subcommand \(args[1])\n", stderr) + exit(2) +} diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index c5d42fa..4782fa6 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -19,6 +19,21 @@ jobs: - name: Check out code uses: actions/checkout@v6 + # crates/halo depends on ../../../timestretch-rs as a sibling path + # dependency (it evolves alongside halo, unpublished-version-ahead of + # crates.io). actions/checkout refuses a `path` outside the workspace, + # so check it out into a subdirectory and move it up a level to land + # where the relative path expects it, same as on a local machine where + # both repos live side by side. + - name: Check out timestretch-rs (sibling path dependency) + uses: actions/checkout@v6 + with: + repository: robmorgan/timestretch-rs + path: timestretch-rs-checkout + + - name: Move timestretch-rs alongside this checkout + run: mv timestretch-rs-checkout ../timestretch-rs + - name: Install Rust toolchain (Nightly for fmt) uses: actions-rs/toolchain@v1 with: @@ -68,13 +83,28 @@ jobs: - name: Check out code uses: actions/checkout@v6 + - name: Check out timestretch-rs (sibling path dependency) + uses: actions/checkout@v6 + with: + repository: robmorgan/timestretch-rs + path: timestretch-rs-checkout + + - name: Move timestretch-rs alongside this checkout + run: mv timestretch-rs-checkout ../timestretch-rs + - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y \ libasound2-dev \ libjack-dev \ - libpulse-dev + libpulse-dev \ + libx11-dev \ + libxrandr-dev \ + libxi-dev \ + libxcursor-dev \ + libxkbcommon-dev \ + libwayland-dev - name: Install Rust toolchain (Nightly for fmt) uses: actions-rs/toolchain@v1 diff --git a/Cargo.lock b/Cargo.lock index c5d139d..8156a37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,49 +14,54 @@ dependencies = [ [[package]] name = "ab_glyph_rasterizer" -version = "0.1.8" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71b1793ee61086797f5c80b6efa2b8ffa6d5dd703f118545808a7f2e27f7046" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "accesskit" -version = "0.21.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" +checksum = "d3d3b8f9bae46a948369bc4a03e815d4ed6d616bd00de4051133a5019dc31c5a" +dependencies = [ + "enumn", + "serde", +] [[package]] name = "accesskit_atspi_common" -version = "0.14.1" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29f73a9b855b6f4af4962a94553ef0c092b80cf5e17038724d5e30945d036f69" +checksum = "7c5dd55e6e94949498698daf4d48fb5659e824d7abec0d394089656ceaf99d4f" dependencies = [ "accesskit", "accesskit_consumer", "atspi-common", "serde", "thiserror 1.0.69", - "zvariant", + "zvariant 4.2.0", ] [[package]] name = "accesskit_consumer" -version = "0.30.1" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd06f5fea9819250fffd4debf926709f3593ac22f8c1541a2573e5ee0ca01cd" +checksum = "f47983a1084940ba9a39c077a8c63e55c619388be5476ac04c804cfbd1e63459" dependencies = [ "accesskit", - "hashbrown 0.15.3", + "hashbrown 0.15.5", + "immutable-chunkmap", ] [[package]] name = "accesskit_macos" -version = "0.22.1" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fbaf15815f39084e0cb24950c232f0e3634702c2dfbf182ae3b4919a4a1d45" +checksum = "7329821f3bd1101e03a7d2e03bd339e3ac0dc64c70b4c9f9ae1949e3ba8dece1" dependencies = [ "accesskit", "accesskit_consumer", - "hashbrown 0.15.3", + "hashbrown 0.15.5", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -64,9 +69,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.17.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64926a930368d52d95422b822ede15014c04536cabaa2394f99567a1f4788dc6" +checksum = "fcee751cc20d88678c33edaf9c07e8b693cd02819fe89053776f5313492273f5" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -77,28 +82,29 @@ dependencies = [ "futures-lite", "futures-util", "serde", - "zbus", + "zbus 4.4.0", ] [[package]] name = "accesskit_windows" -version = "0.29.1" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "792991159fa9ba57459de59e12e918bb90c5346fea7d40ac1a11f8632b41e63a" +checksum = "24fcd5d23d70670992b823e735e859374d694a3d12bfd8dd32bd3bd8bedb5d81" dependencies = [ "accesskit", "accesskit_consumer", - "hashbrown 0.15.3", + "hashbrown 0.15.5", + "paste", "static_assertions", - "windows 0.61.3", - "windows-core 0.61.2", + "windows 0.58.0", + "windows-core 0.58.0", ] [[package]] name = "accesskit_winit" -version = "0.29.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd9db0ea66997e3f4eae4a5f2c6b6486cf206642639ee629dbbb860ace1dec87" +checksum = "6a6a48dad5530b6deb9fc7a52cc6c3bf72cdd9eb8157ac9d32d69f2427a5e879" dependencies = [ "accesskit", "accesskit_macos", @@ -110,9 +116,9 @@ dependencies = [ [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "ahash" @@ -121,17 +127,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom 0.3.2", + "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -143,19 +150,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" dependencies = [ "alsa-sys", - "bitflags 2.9.4", - "cfg-if", - "libc", -] - -[[package]] -name = "alsa" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" -dependencies = [ - "alsa-sys", - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg-if", "libc", ] @@ -172,23 +167,21 @@ dependencies = [ [[package]] name = "android-activity" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.9.4", + "bitflags 2.13.1", "cc", - "cesu8", - "jni", - "jni-sys", + "jni 0.22.4", "libc", "log", - "ndk", + "ndk 0.9.0", "ndk-context", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "num_enum", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] @@ -208,9 +201,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.18" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -223,45 +216,39 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.7" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "once_cell", - "windows-sys 0.59.0", + "once_cell_polyfill", + "windows-sys 0.61.2", ] -[[package]] -name = "anyhow" -version = "1.0.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" - [[package]] name = "arboard" version = "3.6.1" @@ -271,8 +258,8 @@ dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.3", - "objc2-app-kit 0.3.1", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -283,16 +270,19 @@ dependencies = [ ] [[package]] -name = "arrayref" -version = "0.3.9" +name = "arc-swap" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "artnet_protocol" @@ -300,7 +290,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "686b971a57c02682e07fdb0866b09aad7df7054d9f3c3bb62a5eaa2092621367" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "byteorder", ] @@ -319,6 +309,28 @@ dependencies = [ "libloading", ] +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.5", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus 5.18.0", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -333,9 +345,9 @@ dependencies = [ [[package]] name = "async-channel" -version = "2.3.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ "concurrent-queue", "event-listener-strategy", @@ -345,9 +357,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb812ffb58524bdd10860d7d974e2f01cc0950c2438a74ee5ec2e2280c6c4ffa" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -359,9 +371,9 @@ dependencies = [ [[package]] name = "async-fs" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" dependencies = [ "async-lock", "blocking", @@ -370,39 +382,49 @@ dependencies = [ [[package]] name = "async-io" -version = "2.4.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ - "async-lock", + "autocfg", "cfg-if", "concurrent-queue", "futures-io", "futures-lite", "parking", "polling", - "rustix 0.38.44", + "rustix 1.1.4", "slab", - "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "async-lock" -version = "3.4.0" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ "event-listener", "event-listener-strategy", "pin-project-lite", ] +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + [[package]] name = "async-process" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ "async-channel", "async-io", @@ -413,8 +435,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 0.38.44", - "tracing", + "rustix 1.1.4", ] [[package]] @@ -430,9 +451,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.10" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -440,10 +461,10 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 0.38.44", + "rustix 1.1.4", "signal-hook-registry", "slab", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -471,9 +492,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "atspi" -version = "0.25.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83247582e7508838caf5f316c00791eee0e15c0bf743e6880585b867e16815c" +checksum = "be534b16650e35237bb1ed189ba2aab86ce65e88cc84c66f4935ba38575cecbf" dependencies = [ "atspi-common", "atspi-connection", @@ -482,48 +503,55 @@ dependencies = [ [[package]] name = "atspi-common" -version = "0.9.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33dfc05e7cdf90988a197803bf24f5788f94f7c94a69efa95683e8ffe76cfdfb" +checksum = "1909ed2dc01d0a17505d89311d192518507e8a056a48148e3598fef5e7bb6ba7" dependencies = [ "enumflags2", "serde", "static_assertions", - "zbus", + "zbus 4.4.0", "zbus-lockstep", "zbus-lockstep-macros", - "zbus_names", - "zvariant", + "zbus_names 3.0.0", + "zvariant 4.2.0", ] [[package]] name = "atspi-connection" -version = "0.9.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4193d51303d8332304056ae0004714256b46b6635a5c556109b319c0d3784938" +checksum = "430c5960624a4baaa511c9c0fcc2218e3b58f5dbcc47e6190cafee344b873333" dependencies = [ "atspi-common", "atspi-proxies", "futures-lite", - "zbus", + "zbus 4.4.0", ] [[package]] name = "atspi-proxies" -version = "0.9.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2eebcb9e7e76f26d0bcfd6f0295e1cd1e6f33bedbc5698a971db8dc43d7751c" +checksum = "a5e6c5de3e524cf967569722446bcd458d5032348554d9a17d7d72b041ab7496" dependencies = [ "atspi-common", "serde", - "zbus", + "zbus 4.4.0", + "zvariant 4.2.0", ] [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" [[package]] name = "bindgen" @@ -531,17 +559,15 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools", - "log", - "prettyplease", "proc-macro2", "quote", "regex", - "rustc-hash 2.1.1", - "shlex", + "rustc-hash 2.1.3", + "shlex 1.3.0", "syn", ] @@ -568,9 +594,12 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "block" @@ -578,6 +607,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -589,18 +627,18 @@ dependencies = [ [[package]] name = "block2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] name = "blocking" -version = "1.6.1" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ "async-channel", "async-task", @@ -611,24 +649,24 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.17.0" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.10.2" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" dependencies = [ "proc-macro2", "quote", @@ -649,9 +687,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.10.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "calloop" @@ -659,7 +697,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "log", "polling", "rustix 0.38.44", @@ -667,28 +705,53 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + [[package]] name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "calloop", + "calloop 0.13.0", "rustix 0.38.44", "wayland-backend", "wayland-client", ] +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cc" -version = "1.2.55" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -708,15 +771,15 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "cgl" @@ -727,30 +790,6 @@ dependencies = [ "libc", ] -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures", - "rand_core", -] - -[[package]] -name = "chrono" -version = "0.4.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link 0.2.1", -] - [[package]] name = "clang-sys" version = "1.8.1" @@ -762,80 +801,30 @@ dependencies = [ "libloading", ] -[[package]] -name = "clap" -version = "4.5.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" - [[package]] name = "clipboard-win" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" dependencies = [ "error-code", ] -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - [[package]] name = "codespan-reporting" -version = "0.12.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" dependencies = [ - "serde", "termcolor", "unicode-width", ] [[package]] name = "colorchoice" -version = "1.0.3" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" @@ -856,15 +845,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "convert_case" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -877,9 +857,9 @@ dependencies = [ [[package]] name = "core-foundation" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -899,7 +879,7 @@ checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-graphics-types 0.1.3", + "core-graphics-types", "foreign-types", "libc", ] @@ -915,170 +895,94 @@ dependencies = [ "libc", ] -[[package]] -name = "core-graphics-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" -dependencies = [ - "bitflags 2.9.4", - "core-foundation 0.10.0", - "libc", -] - [[package]] name = "coreaudio-rs" -version = "0.13.0" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" dependencies = [ "bitflags 1.3.2", - "libc", - "objc2-audio-toolbox", - "objc2-core-audio", - "objc2-core-audio-types", - "objc2-core-foundation", -] - -[[package]] -name = "coremidi" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964eb3e10ea8b0d29c797086aab3ca730f75e06dced0cb980642fd274a5cca30" -dependencies = [ - "block", - "core-foundation 0.9.4", "core-foundation-sys", - "coremidi-sys", + "coreaudio-sys", ] [[package]] -name = "coremidi-sys" -version = "3.1.1" +name = "coreaudio-sys" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709d142e542467e028d5dc5f0374392339ab7dead0c48c129504de2ccd667e1b" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" dependencies = [ - "core-foundation-sys", + "bindgen", ] [[package]] name = "cpal" -version = "0.16.0" +version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" dependencies = [ - "alsa 0.9.1", + "alsa", + "core-foundation-sys", "coreaudio-rs", "dasp_sample", - "jni", + "jni 0.21.1", "js-sys", "libc", - "mach2 0.4.2", - "ndk", + "mach2", + "ndk 0.8.0", "ndk-context", - "num-derive", - "num-traits", - "objc2-audio-toolbox", - "objc2-core-audio", - "objc2-core-audio-types", + "oboe", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "windows 0.54.0", ] -[[package]] -name = "cpal" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb" -dependencies = [ - "alsa 0.10.0", - "coreaudio-rs", - "dasp_sample", - "jni", - "js-sys", - "libc", - "mach2 0.5.0", - "ndk", - "ndk-context", - "num-derive", - "num-traits", - "objc2 0.6.3", - "objc2-audio-toolbox", - "objc2-avf-audio", - "objc2-core-audio", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows 0.61.3", -] - [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] -name = "crossterm" -version = "0.29.0" +name = "crunchy" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags 2.9.4", - "crossterm_winapi", - "derive_more", - "document-features", - "mio", - "parking_lot", - "rustix 1.1.3", - "signal-hook", - "signal-hook-mio", - "winapi", -] +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] -name = "crossterm_winapi" -version = "0.9.1" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "winapi", + "generic-array", + "typenum", ] -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "cursor-icon" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a6ac251f4a2aca6b3f91340350eab87ae57c3f127ffeb585e92bd336717991" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] name = "dasp_sample" @@ -1087,45 +991,71 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] -name = "derive_more" -version = "2.0.1" +name = "data-encoding" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" -dependencies = [ - "derive_more-impl", -] +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] -name = "derive_more-impl" -version = "2.0.1" +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" dependencies = [ - "convert_case", + "defmt-parser", "proc-macro2", "quote", "syn", ] +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" -version = "6.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" -version = "0.5.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -1136,21 +1066,21 @@ checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.9.4", - "block2 0.6.1", + "bitflags 2.13.1", + "block2 0.6.2", "libc", - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1159,18 +1089,18 @@ dependencies = [ [[package]] name = "dlib" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ "libloading", ] [[package]] name = "document-features" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ "litrs", ] @@ -1183,25 +1113,26 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "dpi" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f25c0e292a7ca6d6498557ff1df68f32c99850012b6ea401cf8daf771f22ff53" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "ecolor" -version = "0.33.3" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" +checksum = "bc4feb366740ded31a004a0e4452fbf84e80ef432ecf8314c485210229672fd1" dependencies = [ "bytemuck", "emath", + "serde", ] [[package]] name = "eframe" -version = "0.33.3" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6" +checksum = "d0dfe0859f3fb1bc6424c57d41e10e9093fe938f426b691e42272c2f336d915c" dependencies = [ "ahash", "bytemuck", @@ -1210,9 +1141,9 @@ dependencies = [ "egui-wgpu", "egui-winit", "egui_glow", - "glow", "glutin", "glutin-winit", + "home", "image", "js-sys", "log", @@ -1221,40 +1152,45 @@ dependencies = [ "objc2-foundation 0.2.2", "parking_lot", "percent-encoding", + "pollster", "profiling", "raw-window-handle", + "ron", + "serde", "static_assertions", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "web-time", - "windows-sys 0.61.2", + "wgpu", + "winapi", + "windows-sys 0.59.0", "winit", ] [[package]] name = "egui" -version = "0.33.3" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" +checksum = "25dd34cec49ab55d85ebf70139cb1ccd29c977ef6b6ba4fe85489d6877ee9ef3" dependencies = [ "accesskit", "ahash", - "bitflags 2.9.4", + "bitflags 2.13.1", "emath", "epaint", "log", "nohash-hasher", "profiling", - "smallvec", - "unicode-segmentation", + "ron", + "serde", ] [[package]] name = "egui-wgpu" -version = "0.33.3" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236" +checksum = "d319dfef570f699b6e9114e235e862a2ddcf75f0d1a061de9e1328d92146d820" dependencies = [ "ahash", "bytemuck", @@ -1263,7 +1199,7 @@ dependencies = [ "epaint", "log", "profiling", - "thiserror 2.0.17", + "thiserror 1.0.69", "type-map", "web-time", "wgpu", @@ -1272,20 +1208,19 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.33.3" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec6687e5bb551702f4ad10ac428bab12acf9d53047ebb1082d4a0ed8c6251a29" +checksum = "7d9dfbb78fe4eb9c3a39ad528b90ee5915c252e77bbab9d4ebc576541ab67e13" dependencies = [ "accesskit_winit", + "ahash", "arboard", "bytemuck", "egui", "log", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-ui-kit", "profiling", "raw-window-handle", + "serde", "smithay-clipboard", "web-time", "webbrowser", @@ -1293,46 +1228,50 @@ dependencies = [ ] [[package]] -name = "egui_glow" -version = "0.33.3" +name = "egui_extras" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb" +checksum = "624659a2e972a46f4d5f646557906c55f1cd5a0836eddbe610fdf1afba1b4226" dependencies = [ - "bytemuck", + "ahash", "egui", - "glow", + "enum-map", "log", - "memoffset", "profiling", - "wasm-bindgen", - "web-sys", - "winit", ] [[package]] -name = "egui_plot" -version = "0.34.0" +name = "egui_glow" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33233ffc010fd450381805bbbebecbbb82f077de7712ddc439f0b20effd42db7" +checksum = "910906e3f042ea6d2378ec12a6fd07698e14ddae68aed2d819ffe944a73aab9e" dependencies = [ "ahash", + "bytemuck", "egui", - "emath", + "glow", + "log", + "memoffset", + "profiling", + "wasm-bindgen", + "web-sys", + "winit", ] [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "emath" -version = "0.33.3" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" +checksum = "9e4cadcff7a5353ba72b7fea76bf2122b5ebdbc68e8155aa56dfdea90083fe1b" dependencies = [ "bytemuck", + "serde", ] [[package]] @@ -1346,15 +1285,36 @@ dependencies = [ [[package]] name = "endi" -version = "1.1.0" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", + "serde", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "enumflags2" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2f4b465f5318854c6f8dd686ede6c0a9dc67d4b1ac241cf0eb51521a309147" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ "enumflags2_derive", "serde", @@ -1362,20 +1322,54 @@ dependencies = [ [[package]] name = "enumflags2_derive" -version = "0.7.11" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "enumn" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "epaint" -version = "0.33.3" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" +checksum = "41fcc0f5a7c613afd2dee5e4b30c3e6acafb8ad6f0edb06068811f708a67c562" dependencies = [ "ab_glyph", "ahash", @@ -1387,13 +1381,14 @@ dependencies = [ "nohash-hasher", "parking_lot", "profiling", + "serde", ] [[package]] name = "epaint_default_fonts" -version = "0.33.3" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" +checksum = "fc7e7a64c02cf7a5b51e745a9e45f60660a286f151c238b9d397b3e923f5082f" [[package]] name = "equivalent" @@ -1403,25 +1398,25 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.11" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "error-code" -version = "3.3.1" +version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d9305ccc6942a704f4335694ecd3de2ea531b114ac2d51f5f843750787a92f" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] name = "event-listener" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -1444,11 +1439,29 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" @@ -1467,9 +1480,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.1" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -1481,12 +1494,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "foreign-types" version = "0.5.0" @@ -1516,30 +1523,39 @@ checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ "fastrand", "futures-core", @@ -1550,80 +1566,95 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", - "pin-utils", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" -version = "0.4.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "libc", - "windows-targets 0.48.5", + "rustix 1.1.4", + "windows-link", ] [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", ] [[package]] name = "getrandom" -version = "0.3.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "r-efi 5.3.0", + "wasip2", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", - "rand_core", - "wasip2", - "wasip3", + "r-efi 6.0.0", ] [[package]] @@ -1639,9 +1670,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "glow" @@ -1661,7 +1692,7 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg_aliases", "cgl", "dispatch2", @@ -1669,8 +1700,8 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.3", - "objc2-app-kit 0.3.1", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", "once_cell", @@ -1723,33 +1754,21 @@ dependencies = [ [[package]] name = "gpu-alloc" -version = "0.6.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "gpu-alloc-types", ] [[package]] name = "gpu-alloc-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" -dependencies = [ - "bitflags 2.9.4", -] - -[[package]] -name = "gpu-allocator" -version = "0.27.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" dependencies = [ - "log", - "presser", - "thiserror 1.0.69", - "windows 0.58.0", + "bitflags 2.13.1", ] [[package]] @@ -1758,9 +1777,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "gpu-descriptor-types", - "hashbrown 0.15.3", + "hashbrown 0.15.5", ] [[package]] @@ -1769,104 +1788,83 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", ] [[package]] name = "half" -version = "2.6.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", - "num-traits", + "zerocopy", ] [[package]] name = "halo" version = "0.1.0" dependencies = [ - "anyhow", - "artnet_protocol", - "clap", - "crossterm", + "cpal", + "dirs", "eframe", - "halo-core", - "halo-fixtures", - "halo-ui", + "egui_extras", + "env_logger", + "halo-light", + "image", + "libc", + "lofty", "log", - "midir", - "parking_lot", "rfd", - "rodio", - "rusty_link", - "tokio", -] - -[[package]] -name = "halo-core" -version = "0.1.0" -dependencies = [ - "anyhow", - "artnet_protocol", - "async-trait", - "chrono", - "cpal 0.17.1", - "crossterm", - "dirs", - "halo-fixtures", - "log", - "midir", - "parking_lot", - "rodio", - "rusty_link", + "rusqlite", "serde", "serde_json", "symphonia", - "tempfile", - "tokio", + "timestretch", ] [[package]] -name = "halo-fixtures" +name = "halo-light" version = "0.1.0" dependencies = [ + "artnet_protocol", + "log", "serde", "serde_json", ] [[package]] -name = "halo-ui" -version = "0.1.0" +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "chrono", - "eframe", - "egui_plot", - "halo-core", - "halo-fixtures", - "parking_lot", - "rand", - "rfd", - "tokio", + "ahash", ] [[package]] name = "hashbrown" -version = "0.15.3" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash 0.1.5", + "foldhash", ] [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "foldhash 0.2.0", + "hashbrown 0.14.5", ] [[package]] @@ -1877,9 +1875,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.4.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hex" @@ -1894,46 +1892,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" [[package]] -name = "iana-time-zone" -version = "0.1.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core 0.61.2", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "home" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ - "cc", + "windows-sys 0.61.2", ] [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", + "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1942,110 +1927,66 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr", + "icu_locale_core", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -2054,9 +1995,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -2064,33 +2005,44 @@ dependencies = [ [[package]] name = "image" -version = "0.25.6" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "moxcms", "num-traits", "png", "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "immutable-chunkmap" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45b30d0eb3d4282f694894b75bc50807ab6e3203436681cc235fa364acf7f5e1" +dependencies = [ + "arrayvec", ] [[package]] name = "indexmap" -version = "2.9.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.15.3", - "serde", + "hashbrown 0.17.1", ] [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" @@ -2103,9 +2055,34 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "jni" @@ -2116,7 +2093,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -2124,34 +2101,81 @@ dependencies = [ ] [[package]] -name = "jni-sys" -version = "0.3.0" +name = "jni" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] [[package]] -name = "jobserver" -version = "0.1.33" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "getrandom 0.3.2", - "libc", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", ] [[package]] -name = "jpeg-decoder" +name = "jni-sys" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -2178,43 +2202,43 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" -version = "0.2.180" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libloading" -version = "0.8.6" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-link", ] [[package]] -name = "libm" -version = "0.2.15" +name = "libredox" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.0", +] [[package]] -name = "libredox" -version = "0.1.3" +name = "libsqlite3-sys" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ - "bitflags 2.9.4", - "libc", - "redox_syscall 0.5.11", + "cc", + "pkg-config", + "vcpkg", ] [[package]] @@ -2225,21 +2249,21 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.7.5" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "litrs" -version = "0.4.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" @@ -2251,25 +2275,42 @@ dependencies = [ ] [[package]] -name = "log" -version = "0.4.29" +name = "lofty" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] [[package]] -name = "mach2" -version = "0.4.2" +name = "lofty_attr" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709" +checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" dependencies = [ - "libc", + "proc-macro2", + "quote", + "syn", ] +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "mach2" -version = "0.5.0" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" dependencies = [ "libc", ] @@ -2285,15 +2326,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.5" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -2309,36 +2350,19 @@ dependencies = [ [[package]] name = "metal" -version = "0.32.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block", - "core-graphics-types 0.2.0", + "core-graphics-types", "foreign-types", "log", "objc", "paste", ] -[[package]] -name = "midir" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73f8737248ad37b88291a2108d9df5f991dc8555103597d586b5a29d4d703c0" -dependencies = [ - "alsa 0.9.1", - "bitflags 1.3.2", - "coremidi", - "js-sys", - "libc", - "parking_lot", - "wasm-bindgen", - "web-sys", - "windows 0.56.0", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2347,50 +2371,58 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", "simd-adler32", ] [[package]] -name = "mio" -version = "1.0.3" +name = "moxcms" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ - "libc", - "log", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "num-traits", + "pxfm", ] [[package]] name = "naga" -version = "27.0.0" +version = "24.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12b2e757b11b47345d44e7760e45458339bc490463d9548cd8651c53ae523153" +checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e" dependencies = [ "arrayvec", "bit-set", - "bitflags 2.9.4", - "cfg-if", + "bitflags 2.13.1", "cfg_aliases", "codespan-reporting", - "half", - "hashbrown 0.16.0", "hexf-parse", "indexmap", - "libm", "log", - "num-traits", - "once_cell", "rustc-hash 1.1.0", "spirv", - "thiserror 2.0.17", - "unicode-ident", + "strum", + "termcolor", + "thiserror 2.0.18", + "unicode-xid", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", ] [[package]] @@ -2399,10 +2431,10 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.9.4", - "jni-sys", + "bitflags 2.13.1", + "jni-sys 0.3.1", "log", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -2414,13 +2446,22 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys", + "jni-sys 0.3.1", ] [[package]] @@ -2429,7 +2470,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -2453,12 +2494,11 @@ dependencies = [ ] [[package]] -name = "num-bigint" +name = "num-complex" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ - "num-integer", "num-traits", ] @@ -2482,17 +2522,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -2500,23 +2529,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] name = "num_enum" -version = "0.7.3" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", + "rustversion", ] [[package]] name = "num_enum_derive" -version = "0.7.3" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2551,9 +2580,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -2564,7 +2593,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -2576,40 +2605,15 @@ dependencies = [ [[package]] name = "objc2-app-kit" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" -dependencies = [ - "bitflags 2.9.4", - "block2 0.6.1", - "objc2 0.6.3", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-audio-toolbox" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.9.4", - "libc", - "objc2 0.6.3", - "objc2-core-audio", - "objc2-core-audio-types", + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", "objc2-core-foundation", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-avf-audio" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" -dependencies = [ - "objc2 0.6.3", + "objc2-core-graphics", "objc2-foundation 0.3.2", ] @@ -2619,7 +2623,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -2637,36 +2641,13 @@ dependencies = [ "objc2-foundation 0.2.2", ] -[[package]] -name = "objc2-core-audio" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" -dependencies = [ - "dispatch2", - "objc2 0.6.3", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-core-audio-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" -dependencies = [ - "bitflags 2.9.4", - "objc2 0.6.3", -] - [[package]] name = "objc2-core-data" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2678,22 +2659,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.9.4", - "block2 0.6.1", + "bitflags 2.13.1", "dispatch2", - "libc", - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] name = "objc2-core-graphics" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "dispatch2", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", "objc2-io-surface", ] @@ -2734,7 +2713,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "dispatch", "libc", @@ -2747,21 +2726,19 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.9.4", - "block2 0.6.1", - "libc", - "objc2 0.6.3", + "bitflags 2.13.1", + "objc2 0.6.4", "objc2-core-foundation", ] [[package]] name = "objc2-io-surface" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.9.4", - "objc2 0.6.3", + "bitflags 2.13.1", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -2783,7 +2760,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2795,7 +2772,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2818,7 +2795,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", @@ -2850,18 +2827,56 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", ] +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni 0.21.1", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "ogg_pager" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216" +dependencies = [ + "byteorder", +] + [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "option-ext" @@ -2871,10 +2886,11 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orbclient" -version = "0.3.48" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba0b26cec2e24f08ed8bb31519a9333140a6599b867dac464bb150bdb796fd43" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" dependencies = [ + "libc", "libredox", ] @@ -2899,9 +2915,9 @@ dependencies = [ [[package]] name = "owned_ttf_parser" -version = "0.25.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec719bbf3b2a81c109a4e20b1f129b5566b7dce654bc3872f6a05abf82b2c4" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" dependencies = [ "ttf-parser", ] @@ -2930,9 +2946,9 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.11", + "redox_syscall 0.5.18", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2949,18 +2965,18 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", @@ -2969,21 +2985,15 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -2992,17 +3002,23 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "png" -version = "0.17.16" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -3011,17 +3027,16 @@ dependencies = [ [[package]] name = "polling" -version = "3.7.4" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 0.38.44", - "tracing", - "windows-sys 0.59.0", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -3032,64 +3047,87 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] [[package]] -name = "presser" -version = "0.3.1" +name = "potential_utf" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] [[package]] -name = "prettyplease" -version = "0.2.32" +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "664ec5419c51e34154eec046ebcba56312d5a2fc3b09a06da188e1ad21afadf6" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "proc-macro2", - "syn", + "zerocopy", +] + +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", ] [[package]] name = "proc-macro-crate" -version = "3.3.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "profiling" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.36.2" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" dependencies = [ "memchr", "serde", @@ -3097,50 +3135,92 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.37.5" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.10.0" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ - "chacha20", - "getrandom 0.4.1", - "rand_core", + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] name = "rand_core" -version = "0.10.0" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] -name = "range-alloc" -version = "0.1.4" +name = "rand_core" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] [[package]] name = "raw-window-handle" @@ -3159,29 +3239,38 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.11" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags 2.13.1", ] [[package]] name = "redox_users" -version = "0.5.0" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", - "thiserror 2.0.17", + "thiserror 1.0.69", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3191,9 +3280,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3202,9 +3291,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "renderdoc-sys" @@ -3214,41 +3303,52 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "rfd" -version = "0.17.2" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" dependencies = [ - "block2 0.6.1", + "ashpd", + "block2 0.6.2", "dispatch2", "js-sys", - "libc", "log", - "objc2 0.6.3", - "objc2-app-kit 0.3.1", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", - "percent-encoding", "pollster", "raw-window-handle", + "urlencoding", "wasm-bindgen", "wasm-bindgen-futures", - "wayland-backend", - "wayland-client", - "wayland-protocols", "web-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] -name = "rodio" -version = "0.21.1" +name = "ron" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40ecf59e742e03336be6a3d53755e789fd05a059fa22dfa0ed624722319e183" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ - "cpal 0.16.0", - "dasp_sample", - "num-rational", - "symphonia", + "base64", + "bitflags 2.13.1", + "serde", + "serde_derive", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", ] [[package]] @@ -3259,9 +3359,32 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] [[package]] name = "rustix" @@ -3269,7 +3392,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3278,32 +3401,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" - -[[package]] -name = "rusty_link" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "431fec7d301f2c3668244fe013938073ef33eb39615696bfd659111d4b4d1089" -dependencies = [ - "bindgen", - "cmake", -] +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "same-file" @@ -3326,24 +3439,11 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sctk-adwaita" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" -dependencies = [ - "ab_glyph", - "log", - "memmap2", - "smithay-client-toolkit", - "tiny-skia", -] - [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -3377,9 +3477,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -3399,6 +3499,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3406,64 +3517,63 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] -name = "signal-hook" -version = "0.3.17" +name = "shlex" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" -dependencies = [ - "libc", - "signal-hook-registry", -] +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] -name = "signal-hook-mio" -version = "0.2.4" +name = "signal-hook-registry" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", - "mio", - "signal-hook", ] [[package]] -name = "signal-hook-registry" -version = "1.4.5" +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ - "libc", + "rustc_version", + "simdutf8", ] [[package]] -name = "simd-adler32" -version = "0.3.7" +name = "simdutf8" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "slab" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slotmap" -version = "1.0.7" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" dependencies = [ "version_check", ] [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "smithay-client-toolkit" @@ -3471,9 +3581,9 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.9.4", - "calloop", - "calloop-wayland-source", + "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", "cursor-icon", "libc", "log", @@ -3491,33 +3601,50 @@ dependencies = [ ] [[package]] -name = "smithay-clipboard" -version = "0.7.2" +name = "smithay-client-toolkit" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc8216eec463674a0e90f29e0ae41a4db573ec5b56b1c6c1c71615d249b6d846" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", "libc", - "smithay-client-toolkit", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.18", "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", ] [[package]] -name = "smol_str" -version = "0.2.2" +name = "smithay-clipboard" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" dependencies = [ - "serde", + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", ] [[package]] -name = "socket2" -version = "0.6.0" +name = "smol_str" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" dependencies = [ - "libc", - "windows-sys 0.59.0", + "serde", ] [[package]] @@ -3526,14 +3653,14 @@ version = "0.3.0+sdk-1.3.268.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", ] [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "static_assertions" @@ -3542,16 +3669,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] -name = "strict-num" -version = "0.1.1" +name = "strength_reduce" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" [[package]] -name = "strsim" -version = "0.11.1" +name = "strum" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +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 = "symphonia" @@ -3562,12 +3705,10 @@ dependencies = [ "lazy_static", "symphonia-bundle-flac", "symphonia-bundle-mp3", - "symphonia-codec-aac", "symphonia-codec-adpcm", "symphonia-codec-pcm", "symphonia-codec-vorbis", "symphonia-core", - "symphonia-format-isomp4", "symphonia-format-mkv", "symphonia-format-ogg", "symphonia-format-riff", @@ -3598,17 +3739,6 @@ dependencies = [ "symphonia-metadata", ] -[[package]] -name = "symphonia-codec-aac" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" -dependencies = [ - "lazy_static", - "log", - "symphonia-core", -] - [[package]] name = "symphonia-codec-adpcm" version = "0.5.5" @@ -3653,19 +3783,6 @@ dependencies = [ "log", ] -[[package]] -name = "symphonia-format-isomp4" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" -dependencies = [ - "encoding_rs", - "log", - "symphonia-core", - "symphonia-metadata", - "symphonia-utils-xiph", -] - [[package]] name = "symphonia-format-mkv" version = "0.5.5" @@ -3727,9 +3844,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.101" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -3749,14 +3866,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.2", + "getrandom 0.4.3", "once_cell", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -3780,11 +3897,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -3800,9 +3917,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -3811,101 +3928,75 @@ dependencies = [ [[package]] name = "tiff" -version = "0.9.1" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ + "fax", "flate2", - "jpeg-decoder", + "half", + "quick-error", "weezl", + "zune-jpeg", ] [[package]] -name = "tiny-skia" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" -dependencies = [ - "arrayref", - "arrayvec", - "bytemuck", - "cfg-if", - "log", - "tiny-skia-path", -] - -[[package]] -name = "tiny-skia-path" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +name = "timestretch" +version = "0.8.0" dependencies = [ - "arrayref", - "bytemuck", - "strict-num", + "arc-swap", + "rustfft", + "serde", + "serde_json", ] [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", ] [[package]] -name = "tokio" -version = "1.49.0" +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", + "serde_core", ] [[package]] -name = "tokio-macros" -version = "2.6.0" +name = "toml_edit" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ - "proc-macro2", - "quote", - "syn", + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", ] [[package]] -name = "toml_datetime" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" - -[[package]] -name = "toml_edit" -version = "0.22.26" +name = "toml_parser" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "indexmap", - "toml_datetime", "winnow", ] [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -3913,9 +4004,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -3924,13 +4015,23 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -3943,31 +4044,37 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" dependencies = [ - "rustc-hash 2.1.1", + "rustc-hash 2.1.3", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "winapi", + "windows-sys 0.61.2", ] [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -3983,20 +4090,22 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", + "serde_derive", ] [[package]] -name = "utf16_iter" -version = "1.0.5" +name = "urlencoding" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "utf8_iter" @@ -4010,6 +4119,23 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -4028,81 +4154,47 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - -[[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" -dependencies = [ - "wit-bindgen-rt", -] +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4110,69 +4202,35 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.9.4", - "hashbrown 0.15.3", - "indexmap", - "semver", -] - [[package]] name = "wayland-backend" -version = "0.3.10" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe770181423e5fc79d3e2a7f4410b7799d5aab1de4372853de3c6aa13ca24121" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", - "rustix 0.38.44", + "rustix 1.1.4", "scoped-tls", "smallvec", "wayland-sys", @@ -4180,12 +4238,12 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.10" +version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978fa7c67b0847dbd6a9f350ca2569174974cd4082737054dbb7fbb79d7d9a61" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags 2.9.4", - "rustix 0.38.44", + "bitflags 2.13.1", + "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] @@ -4196,41 +4254,67 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cursor-icon", "wayland-backend", ] [[package]] name = "wayland-cursor" -version = "0.31.10" +version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65317158dec28d00416cb16705934070aef4f8393353d41126c54264ae0f182" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 0.38.44", + "rustix 1.1.4", "wayland-client", "xcursor", ] [[package]] name = "wayland-protocols" -version = "0.32.8" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "779075454e1e9a521794fed15886323ea0feda3f8b0fc1390f5398141310422a" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", ] +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + [[package]] name = "wayland-protocols-plasma" -version = "0.3.8" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd38cdad69b56ace413c6bcc1fbf5acc5e2ef4af9d5f8f1f9570c0c83eae175" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -4239,11 +4323,11 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.8" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cb6cdc73399c0e06504c437fe3cf886f25568dd5454473d565085b36d6a8bbf" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -4252,20 +4336,20 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.6" +version = "0.31.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "896fdafd5d28145fce7958917d69f2fd44469b1d4e861cb5961bcbeebc6d1484" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", - "quick-xml 0.37.5", + "quick-xml 0.39.4", "quote", ] [[package]] name = "wayland-sys" -version = "0.31.6" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbcebb399c77d5aa9fa5db874806ee7b4eba4e73650948e8f93963f128896615" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", @@ -4275,9 +4359,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -4295,15 +4379,15 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.0.6" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" dependencies = [ - "core-foundation 0.10.0", - "jni", + "core-foundation 0.10.1", + "jni 0.22.4", "log", "ndk-context", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", "url", "web-sys", @@ -4311,27 +4395,24 @@ dependencies = [ [[package]] name = "weezl" -version = "0.1.8" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "wgpu" -version = "27.0.1" +version = "24.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353" dependencies = [ "arrayvec", - "bitflags 2.9.4", - "cfg-if", + "bitflags 2.13.1", "cfg_aliases", "document-features", - "hashbrown 0.16.0", "js-sys", "log", "naga", "parking_lot", - "portable-atomic", "profiling", "raw-window-handle", "smallvec", @@ -4346,85 +4427,47 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "27.0.1" +version = "24.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3d654c0b6c6335edfca18c11bdaed964def641b8e9997d3a495a2ff4077c922" +checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" dependencies = [ "arrayvec", - "bit-set", "bit-vec", - "bitflags 2.9.4", - "bytemuck", + "bitflags 2.13.1", "cfg_aliases", "document-features", - "hashbrown 0.16.0", "indexmap", "log", "naga", "once_cell", "parking_lot", - "portable-atomic", "profiling", "raw-window-handle", "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.17", - "wgpu-core-deps-apple", - "wgpu-core-deps-emscripten", - "wgpu-core-deps-windows-linux-android", + "thiserror 2.0.18", "wgpu-hal", "wgpu-types", ] -[[package]] -name = "wgpu-core-deps-apple" -version = "27.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-emscripten" -version = "27.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-windows-linux-android" -version = "27.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" -dependencies = [ - "wgpu-hal", -] - [[package]] name = "wgpu-hal" -version = "27.0.2" +version = "24.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2618a2d6b8a5964ecc1ac32a5db56cb3b1e518725fcd773fd9a782e023453f2b" +checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259" dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", - "bitflags 2.9.4", + "bitflags 2.13.1", "block", "bytemuck", - "cfg-if", "cfg_aliases", - "core-graphics-types 0.2.0", + "core-graphics-types", "glow", "glutin_wgl_sys", "gpu-alloc", - "gpu-allocator", "gpu-descriptor", - "hashbrown 0.16.0", "js-sys", "khronos-egl", "libc", @@ -4432,37 +4475,32 @@ dependencies = [ "log", "metal", "naga", - "ndk-sys", + "ndk-sys 0.5.0+25.2.9519653", "objc", "once_cell", "ordered-float", "parking_lot", - "portable-atomic", - "portable-atomic-util", "profiling", - "range-alloc", "raw-window-handle", "renderdoc-sys", + "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "wasm-bindgen", "web-sys", "wgpu-types", "windows 0.58.0", - "windows-core 0.58.0", ] [[package]] name = "wgpu-types" -version = "27.0.1" +version = "24.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" dependencies = [ - "bitflags 2.9.4", - "bytemuck", + "bitflags 2.13.1", "js-sys", "log", - "thiserror 2.0.17", "web-sys", ] @@ -4484,179 +4522,67 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", -] - -[[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" -version = "0.54.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" -dependencies = [ - "windows-core 0.54.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" -dependencies = [ - "windows-core 0.56.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" -dependencies = [ - "windows-core 0.58.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections", - "windows-core 0.61.2", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.54.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" -dependencies = [ - "windows-result 0.1.2", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" -dependencies = [ - "windows-implement 0.56.0", - "windows-interface 0.56.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" -dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement 0.60.0", - "windows-interface 0.59.1", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-sys 0.61.2", ] [[package]] -name = "windows-future" -version = "0.2.1" +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading", -] +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows-implement" -version = "0.56.0" +name = "windows" +version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-core 0.54.0", + "windows-targets 0.52.6", ] [[package]] -name = "windows-implement" +name = "windows" version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-core 0.58.0", + "windows-targets 0.52.6", ] [[package]] -name = "windows-implement" -version = "0.60.0" +name = "windows-core" +version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-result 0.1.2", + "windows-targets 0.52.6", ] [[package]] -name = "windows-interface" -version = "0.56.0" +name = "windows-core" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-implement", + "windows-interface", + "windows-result 0.2.0", + "windows-strings", + "windows-targets 0.52.6", ] [[package]] -name = "windows-interface" +name = "windows-implement" version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", @@ -4665,37 +4591,21 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", "syn", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - [[package]] name = "windows-result" version = "0.1.2" @@ -4714,15 +4624,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-strings" version = "0.1.0" @@ -4734,21 +4635,21 @@ dependencies = [ ] [[package]] -name = "windows-strings" -version = "0.4.2" +name = "windows-sys" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-link 0.1.3", + "windows-targets 0.42.2", ] [[package]] name = "windows-sys" -version = "0.45.0" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets 0.42.2", + "windows-targets 0.48.5", ] [[package]] @@ -4784,7 +4685,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -4839,7 +4740,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -4850,15 +4751,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -5041,17 +4933,17 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" -version = "0.30.12" +version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66d4b9ed69c4009f6321f762d6e61ad8a2389cd431b97cb1e146812e9e6c732" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.9.4", + "bitflags 2.13.1", "block2 0.5.1", "bytemuck", - "calloop", + "calloop 0.13.0", "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", @@ -5061,7 +4953,7 @@ dependencies = [ "js-sys", "libc", "memmap2", - "ndk", + "ndk 0.9.0", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -5072,8 +4964,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", - "sctk-adwaita", - "smithay-client-toolkit", + "smithay-client-toolkit 0.19.2", "smol_str", "tracing", "unicode-segmentation", @@ -5093,121 +4984,24 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.7" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb8234a863ea0e8cd7284fcdd4f145233eb00fee02bbdd9861aec44e6477bc5" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rt" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.4", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.9.4", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "write16" -version = "1.0.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "x11-dl" @@ -5222,30 +5016,30 @@ dependencies = [ [[package]] name = "x11rb" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", "libloading", "once_cell", - "rustix 0.38.44", + "rustix 1.1.4", "x11rb-protocol", ] [[package]] name = "x11rb-protocol" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "xcursor" -version = "0.3.8" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef33da6b1660b4ddbfb3aef0ade110c8b8a781a3b6382fa5f2b5b040fd55f61" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" [[package]] name = "xdg-home" @@ -5263,7 +5057,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "dlib", "log", "once_cell", @@ -5278,17 +5072,16 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xml-rs" -version = "0.8.26" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62ce76d9b56901b19a74f19431b0d8b3bc7ca4ad685a746dfd78ca8f4fc6bda" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "yoke" -version = "0.7.5" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -5296,9 +5089,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.7.5" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -5308,9 +5101,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.5.0" +version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59c333f648ea1b647bc95dc1d34807c8e25ed7a6feff3394034dc4776054b236" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" dependencies = [ "async-broadcast", "async-executor", @@ -5325,101 +5118,161 @@ dependencies = [ "enumflags2", "event-listener", "futures-core", - "futures-lite", + "futures-sink", + "futures-util", "hex", "nix", "ordered-stream", + "rand 0.8.7", "serde", "serde_repr", + "sha1", "static_assertions", "tracing", "uds_windows", - "windows-sys 0.59.0", - "winnow", + "windows-sys 0.52.0", "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros 5.18.0", + "zbus_names 4.3.4", + "zvariant 5.13.1", ] [[package]] name = "zbus-lockstep" -version = "0.5.1" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29e96e38ded30eeab90b6ba88cb888d70aef4e7489b6cd212c5e5b5ec38045b6" +checksum = "4ca2c5dceb099bddaade154055c926bb8ae507a18756ba1d8963fd7b51d8ed1d" dependencies = [ "zbus_xml", - "zvariant", + "zvariant 4.2.0", ] [[package]] name = "zbus-lockstep-macros" -version = "0.5.1" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6821851fa840b708b4cbbaf6241868cabc85a2dc22f426361b0292bfc0b836" +checksum = "709ab20fc57cb22af85be7b360239563209258430bccf38d8b979c5a2ae3ecce" dependencies = [ "proc-macro2", "quote", "syn", "zbus-lockstep", "zbus_xml", - "zvariant", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils 2.1.0", ] [[package]] name = "zbus_macros" -version = "5.5.0" +version = "5.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f325ad10eb0d0a3eb060203494c3b7ec3162a01a59db75d2deee100339709fc0" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", "syn", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.4", + "zvariant 5.13.1", + "zvariant_utils 3.5.0", ] [[package]] name = "zbus_names" -version = "4.2.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", "winnow", - "zvariant", + "zvariant 5.13.1", ] [[package]] name = "zbus_xml" -version = "5.0.2" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589e9a02bfafb9754bb2340a9e3b38f389772684c63d9637e76b1870377bec29" +checksum = "ab3f374552b954f6abb4bd6ce979e6c9b38fb9d0cd7cc68a7d796e70c9f3a233" dependencies = [ - "quick-xml 0.36.2", + "quick-xml 0.30.0", "serde", "static_assertions", - "zbus_names", - "zvariant", + "zbus_names 3.0.0", + "zvariant 4.2.0", ] [[package]] name = "zerocopy" -version = "0.8.25" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.25" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", @@ -5428,18 +5281,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -5447,11 +5300,22 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + [[package]] name = "zerovec" -version = "0.10.4" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -5460,9 +5324,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -5471,48 +5335,99 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.20" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] [[package]] name = "zvariant" -version = "5.4.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2df9ee044893fcffbdc25de30546edef3e32341466811ca18421e3cd6c5a3ac" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" dependencies = [ "endi", "enumflags2", "serde", "static_assertions", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", "winnow", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.13.1", + "zvariant_utils 3.5.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils 2.1.0", ] [[package]] name = "zvariant_derive" -version = "5.4.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74170caa85b8b84cc4935f2d56a57c7a15ea6185ccdd7eadb57e6edd90f94b2f" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", "syn", - "zvariant_utils", + "zvariant_utils 3.5.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] name = "zvariant_utils" -version = "3.2.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16edfee43e5d7b553b77872d99bc36afdda75c223ca7ad5e3fbecd82ca5fc34" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" dependencies = [ "proc-macro2", "quote", "serde", - "static_assertions", "syn", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index dd4ce8f..ead1052 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,13 @@ [workspace] -members = ["crates/core", "crates/fixtures", "crates/halo", "crates/ui"] -default-members = ["crates/halo"] -resolver = "2" +resolver = "3" +members = ["crates/halo", "crates/halo-light"] + +[workspace.dependencies] +halo-light = { path = "crates/halo-light" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +log = "0.4" + +[profile.release] +opt-level = 2 +lto = "thin" diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..d91d081 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,259 @@ +# Halo — Roadmap + +Halo is a 2-deck DJ app for macOS, written in Rust. It uses [`timestretch`](https://github.com/robmorgan/timestretch-rs) for real-time tempo/pitch control and beat analysis, egui/eframe (wgpu backend) for the UI, cpal for audio output, and symphonia for decoding. + +The `timestretch-rs` desktop reference app (`timestretch-rs/desktop/`) is a proven single-deck implementation of most of the hard wiring — decoder, audio callback, feed thread, waveform rendering, gapless loops, scrub. Halo's early phases are largely about porting those patterns into a two-deck architecture, then building the DJ-specific features (mixer, EQ, sync, hot cues, library) on top. + +## Ground rules + +- **`timestretch` is referenced by local path** so the crate can evolve alongside Halo without publishing: + + ```toml + timestretch = { path = "../timestretch-rs" } + ``` + +- **All audio is `f32` interleaved stereo.** The audio callback is allocation-free and lock-free; UI ↔ audio communication uses atomics and the engine's wait-free control mailbox, with a mutex only for cold UI state. +- **Musical key is read from file tags only** (Mixed In Key / Rekordbox style tags). No in-app key detection before 1.0. + +## Architecture overview + +Three kinds of threads, mirroring the `timestretch` engine's controller/processor/source split: + +``` +UI thread (egui, ~30fps while playing) + │ atomics + engine control mailbox (lock-free) + ▼ +Feed/control thread × 2 (one per deck) + keeps engine source ring fed, handles seeks (warm-start), + loops (JumpMap re-anchor), publishes playhead + │ lock-free source ring + ▼ +Audio callback thread (cpal, owns everything below) + Deck A EngineProcessor ─ gain ─ EQ ─ filter ─ fader ┐ + ├─ crossfader ─ sum ─ output + Deck B EngineProcessor ─ gain ─ EQ ─ filter ─ fader ┘ +``` + +One `timestretch::Engine` per deck. The audio callback owns both `EngineProcessor`s and the mixer chain; per-deck DSP (gain, EQ, filter) runs after the engine, before the crossfader sum. + +--- + +## Phase 0 — Foundation + +**Goal:** a running app shell that can decode a file and play it. + +- Cargo binary crate, `timestretch` by local path. +- Dependencies: `eframe` (default-features off; `accesskit`, `default_fonts`, `wgpu` — the wgpu backend is a deliberate CPU-efficiency choice on macOS), `cpal`, `symphonia` (mp3/flac/ogg/wav/pcm/vorbis), `rfd`, `log`/`env_logger`. +- Port `desktop/src/decoder.rs` → decode any supported file to interleaved stereo `f32`. +- Single cpal output stream with a stub callback; error handling that never panics in the callback. +- App shell: window, dark CDJ-style theme, file-open dialog. + +**Milestone:** open a file, hear raw (un-stretched) playback. + +## Phase 1 — Dual-deck core + mixer basics + +**Goal:** two independent decks mixed to one output — the audio-path skeleton every later phase builds on. + +- `Deck` struct ×2, each owning a `timestretch::Engine` (`EngineController` / `EngineProcessor` / `SourceProducer`). Port the feed-thread pattern from `desktop/src/deck.rs`: source ring top-up, warm-start seeks (mute → reset → preroll → `warm_start`), EOF handling, playhead publishing. +- Mixer stage in the audio callback: per-deck gain (trim) and channel fader, constant-power crossfader, sum to output. +- Transport per deck: play/pause and CDJ-style cue (set cue while paused, press to return + hold-to-preview, release returns to cue). +- **Basic CPU meter** — callback processing time ÷ buffer duration, published via atomic. Built now, deliberately early: it's the tool that validates the DSP budget for every phase after this. + +**Milestone:** load a track on each deck and mix between them by ear with volume + crossfader. + +## Phase 2 — Analysis + rich waveform display + +**Goal:** the full CDJ-style deck display. + +- On load: `timestretch::detect_beat_grid_buffer` for an instant BPM readout, then background `analyze_for_dj` → `PreAnalysisArtifact` cached to a sidecar (port `spawn_pre_analysis` / `sidecar_path` from `desktop/src/app.rs`; the artifact also feeds the engine to improve stretch quality). +- Port `desktop/src/waveform/`: 3-band RGB peaks pyramid (`peaks.rs`), full-track overview strip as GPU texture (`overview.rs`), centered-playhead zoomed scrolling waveform with beat/bar/phrase marks (`zoomed.rs`, `mod.rs`), audible scrub gesture (`scrub.rs`). +- Track artwork from file tags via `lofty`, displayed in the deck header. +- Elapsed + remaining time readouts; **prominent BPM display** = detected BPM × current tempo rate, updating live. + +**Milestone:** both decks show artwork, zoomed waveform with beat grid, overview strip with playhead, elapsed/remaining, and live BPM. + +## Phase 3 — EQ + filter (completing the channel strip) + +**Goal:** full per-deck mixer DSP. Independent of Phase 2 — can overlap with it. + +- 3-band isolator EQ per deck (low / mid / high, full-kill at minimum). Build on the Linkwitz-Riley crossover math already in `timestretch` (`src/core/crossover.rs` — currently internal; expose it `pub` in the crate, which the local path dependency makes trivial) or implement biquads in Halo if keeping the crate's API surface clean is preferred. +- LP/HP filter per deck: mode toggle (low-pass / high-pass) + cutoff knob, gentle resonance. +- Final DSP chain order: engine → gain → EQ → filter → fader → crossfader → sum. Smooth all parameter changes (per-block ramps) to avoid zipper noise. +- Verify CPU headroom with the Phase 1 meter. + +**Milestone:** EQ-kill mixing and filter sweeps on both decks, no clicks or zipper noise. + +## Phase 4 — Tempo, pitch & sync + +**Goal:** beatmatching — manual and one-button. + +- Tempo slider per deck with range selector (±8 / ±16 / ±50%), mapped to `EngineController::set_tempo_rate`. Live BPM readout follows. +- Keylock toggle per deck (`set_keylock` — the engine crossfades profiles click-free). +- Pitch bend / nudge buttons (temporary rate offset while held). +- **Sync/Master:** designate a master deck; sync matches the other deck's BPM from the beat grids (Phase 2) and phase-aligns the nearest beat using `set_tempo_rate_at` for sample-accurate correction. Bar-aware alignment using downbeats where confidence allows. + +**Milestone:** press Sync and the decks lock in phase; manual beatmatching works with slider + nudge. + +## Phase 5 — Performance features: hot cues + loops + +**Goal:** performance-ready decks. + +- **Hot cues**, 8 per deck: Normal mode (empty slot = set, occupied = jump; delete via modifier) and **Gated mode** (plays from the cue while held, stops on release). Optional quantize snaps stored/triggered cues to the beat grid (`BeatGrid::snap_to_grid`). +- **Loops:** manual loop in/out; **4-beat quantized loop** button snapped to the grid; halve/double loop-size controls covering **1/16 beat up to 16 beats** (`snap_to_subdivision` for sub-beat sizes). Port the reference app's grid-quantized autoloop ladder and gapless `JumpMap` wrap (no engine reset across the loop seam). +- Loop + hot cue state survives seeks and tempo changes; active loop drawn on both waveforms. + +**Milestone:** finger-drum hot cues in both modes; set, resize (1/16→16 beats), and exit loops seamlessly. + +## Phase 6 — Library + track browser + +**Goal:** prepare and play a full set without a file dialog. + +- SQLite library via `rusqlite`: tracks table (path, tags, duration, BPM, key, artwork ref), playlist tree (folders + playlists), analysis cache keyed by the artifact's content hash. One-time import of existing `.halo.tsanalysis.json` sidecars, after which the DB is the only analysis cache (no new sidecars written). +- Add a small `PreAnalysisArtifact::resample_to(rate)` helper to the `timestretch` crate (easy via the local path dependency): analysis runs **once at the file's native sample rate** and is rescaled to the engine/device rate on load. This keeps one analysis row per track instead of duplicates per output-device rate, and stops a device switch (48 kHz interface → 44.1 kHz headphones) from invalidating the cache. +- Import: add folders, scan, read tags with `lofty` (title / artist / album / **key** / artwork), queue background analysis for BPM + beat grid. +- Browser UI: left tree panel (playlist folders → playlists), right table with sortable columns (title, artist, BPM, key, duration, date added), search box, drag or button to load a track to a deck. + +**Milestone:** import a music folder, build a playlist, sort by BPM/key, and load tracks to decks from the browser. + +## Phase 7 — Polish + +- CPU meter promoted from dev readout to a proper always-visible indicator (audio-callback load + process CPU). +- Audio device selection and buffer-size/latency settings. +- Soft limiter on the master output. +- Keyboard shortcuts for transport, cues, loops, and browser navigation. +- macOS app bundle + icon; persistence of UI/mixer state between sessions. + +**Milestone:** a build you'd hand to another DJ. + +--- + +## Lighting & FX + +Halo drives show lighting alongside the decks. Current state (branch +`mixer-deck-ui-overhaul`): per-deck trigger lanes (Lighting / Pixels / FX) +under the waveforms; editable per-track cues persisted in the library +(`lighting_cues` table, seconds-based JSON); Prepare/Perform views with an +independent audition player and a direct-manipulation cue editor; a +console-style programmer override layer resolved per lane +(Programmer > track cues > off, `programmer::resolve()` as the single +source of truth) with STORE-from-live; and the programmer surface — +fixture grid over a simulated rig, group selects, five parameter views +(Intensity / Color / Position / Beam / Pixel FX) with beat-synced effect +panels. The fixture engine core has landed: the default rig is patched +from real library profiles (auto-addressed across 5 universes), +`output::render()` flattens resolved lanes + programmer params into +per-universe DMX frames, and a 44 Hz engine thread sends them over +Art-Net independently of the UI (playhead read live from the deck +atomics, so cues keep firing through UI stalls). The PATCH footer tab +edits the rig live (profile/universe/address/grid, conflict detection, +add/unpatch/reset) and persists it to the library DB; the settings +window picks broadcast vs unicast-to-node, also persisted. **Phase L1 is +functionally complete** pending hardware validation; per-fixture +pan/tilt limits in the patch sheet are a small leftover. + +### Phase L1 — Fixture engine + +**Goal:** the programmer stops being a mockup — selection and values +drive real per-fixture output. + +The previous console (`../halo-old`, tokio-based) already has proven +implementations of the two hard pieces — the fixture library and Art-Net +output — and both are synchronous underneath: the tokio in halo-old is +orchestration scaffolding (`AsyncModule` / `ModuleManager`) around sync +domain code. The import strategy is to take the domain code and leave the +scaffolding; Halo's existing worker pattern (`std::thread` + `mpsc`, +drained per-frame) replaces it. **No tokio dependency.** + +**Step 1 — import the fixture library** +(`halo-old/crates/fixtures`, ~650 lines, serde-only deps): + +- Port nearly verbatim as `halo-light`'s `fixture_library.rs`: `FixtureProfile` + (manufacturer/model + channel layout), `FixtureLibrary` (profile + registry — hardcoded profiles for now, disk-loaded later, as halo-old + already noted), `Fixture` (id, profile ref, universe, start address, + live channel values), `Channel` / `ChannelType` (including indexed + `PixelRed(n)`/`PixelGreen(n)`/`PixelBlue(n)` for pixel bars), and + `PanTiltLimits`. +- Merge with the existing `fixture.rs` simulation rather than replacing + it: the new grid/selection types (`FixtureKind`, groups, grid + position) stay as the UI layer; each grid fixture gains a patched + `Fixture` + profile behind it. `default_rig()` becomes a default patch + built from real profiles instead of a mockup. +- Real patching UI: assign profile, universe + start address, grid + position; persisted in the library DB (profiles by id; channel values + are `#[serde(skip)]` and rebuilt from the profile on load, as in + halo-old). + +**Step 2 — per-fixture output state:** + +- Output computed from cues + programmer; the grid's selection becomes + the target for applied values and effects (`EffectConfig` evaluated + per fixture with Step/Wave distribution). `resolve()` stays the single + merge point and now yields per-fixture channel values, flattened into + per-universe `[u8; 512]` frames. +- PREVIEW (blind) and HIGHLIGHT gain real semantics. + +**Step 3 — import Art-Net output** +(`halo-old/crates/core/src/artnet`, ~225 lines): + +- Decision resolved: **Art-Net** (imported and proven against hardware); + sACN drops to the backlog. +- `artnet.rs` + `network_config.rs` are already sync + (`std::net::UdpSocket` via the `artnet_protocol` crate; broadcast and + unicast modes, multiple destinations) — port into `halo-light`. The + `DmxModule` async wrapper does **not** come along. +- New dedicated DMX output thread (same shape as the analysis worker): + tick at 44 Hz, snapshot the shared per-universe frames from Step 2, + blocking UDP send to each destination. A 512-byte send is + microseconds; a plain thread holds the frame rate fine. +- Add `artnet_protocol = "0.4"` to dependencies; persist network config + (destinations, broadcast/unicast) with app settings and expose it in a + settings panel. + +**Milestone:** latch a look in the programmer and real fixtures respond +over Art-Net; track cues fire the rig from the active deck. + +### Phase L2 — Overlapping cues + +Today `CueSet` enforces **non-overlap per lane** by construction (the +editor clamps drags, `insert` truncates into free gaps, the loader drops +overlaps), and `active_at()` returns the single cue under the playhead. +Starting a new cue while an old one plays is inexpressible. Evolve in +three steps, each contained in `CueSet` + `resolve()` (the painters and +persistence shell don't change; bump `CueFile.version` as fields grow): + +1. **Per-cue fade in/out** — crossfade the outgoing cue into the incoming + one inside `resolve()`. Cues stay non-overlapping in the data; this + covers the common "new look starts while the old is still visible" + case with minimal machinery. +2. **Per-fixture cue targets** — cues carry a fixture selection; relax + the invariant from "no time overlap per lane" to "no overlap per + fixture", so cues that touch disjoint fixtures may overlap freely + (wash look running while a spot chase fires over it). +3. **HTP/LTP merging** — true same-fixture overlap resolved by console + convention: highest-takes-precedence for intensity, + latest-takes-precedence for color/position/beam. `resolve()` remains + the one merge point. + +**Milestone:** draw two overlapping cues targeting different fixtures and +both play; same-fixture overlaps merge HTP/LTP with clean fades. + +--- + +## Backlog (post-1.0) + +- In-app musical key detection (chromagram-based — a natural fit for the `timestretch` crate). +- Headphone cue / split output (needs a second output or multi-channel device routing). +- Session recording to disk. +- MIDI controller mapping. +- Rekordbox library import. +- sACN (E1.31) output alongside Art-Net. +- Fixture profiles loaded from disk (user-editable library) instead of the built-in registry. + +## Sequencing rationale + +1. **Audio-path correctness first** (Phases 0–1): everything else hangs off a solid two-deck, lock-free audio skeleton. +2. **Analysis before anything that needs the grid** (Phase 2 before 4 and 5): sync, quantized loops, and quantized cues all consume the beat grid. +3. **EQ/filter is dependency-free DSP** (Phase 3): only needs the mixer chain, so it can overlap with Phase 2. +4. **Library last** (Phase 6): file dialogs are good enough until the performance features exist; the browser then lands with analysis and tag reading already proven. +5. **CPU meter early** (Phase 1): it's the instrument for keeping every later DSP addition inside budget. diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml deleted file mode 100644 index 240ae19..0000000 --- a/crates/core/Cargo.toml +++ /dev/null @@ -1,33 +0,0 @@ -[package] -name = "halo-core" -version = "0.1.0" -authors = ["Rob Morgan "] -edition = "2021" - -[dependencies] -halo-fixtures = { path = "../fixtures" } -rusty_link = "0.4.8" -artnet_protocol = "0.4.4" -anyhow = "1.0.101" -log = "0.4.29" -crossterm = "0.29.0" -midir = "0.10.3" -parking_lot = "0.12.5" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -dirs = "6.0" -chrono = "0.4" -rodio = "0.21.1" -cpal = "0.17" -tokio = { version = "1.49.0", features = ["full"] } -async-trait = "0.1" -symphonia = { version = "0.5", features = [ - "mp3", - "aac", - "flac", - "wav", - "vorbis", -] } - -[dev-dependencies] -tempfile = "3.24" diff --git a/crates/core/src/ableton_link.rs b/crates/core/src/ableton_link.rs deleted file mode 100644 index cca3db6..0000000 --- a/crates/core/src/ableton_link.rs +++ /dev/null @@ -1,135 +0,0 @@ -use std::sync::Arc; - -use rusty_link::{AblLink, SessionState}; -use tokio::sync::Mutex; - -/// A thread-safe wrapper for Ableton Link using tokio::sync::Mutex -/// -/// This manager provides async-safe access to the Ableton Link functionality, -/// allowing multiple async tasks to safely interact with the Link session. -/// The underlying AblLink instance is wrapped in an Arc> for -/// thread-safe concurrent access. -pub struct AbletonLinkManager { - link: Option>>, - session_state: SessionState, - is_enabled: bool, - num_peers: u64, -} - -impl AbletonLinkManager { - pub fn new() -> Self { - Self { - link: None, - session_state: SessionState::new(), - is_enabled: false, - num_peers: 0, - } - } - - pub async fn enable(&mut self) -> Result<(), String> { - if self.is_enabled { - return Ok(()); - } - - // AblLink::new() doesn't return a Result, it just takes a BPM parameter - let link = AblLink::new(120.0); - let link_arc = Arc::new(Mutex::new(link)); - self.link = Some(link_arc); - self.is_enabled = true; - log::info!("Ableton Link enabled"); - Ok(()) - } - - pub fn disable(&mut self) { - self.link = None; - self.is_enabled = false; - self.num_peers = 0; - log::info!("Ableton Link disabled"); - } - - pub fn is_enabled(&self) -> bool { - self.is_enabled - } - - pub fn num_peers(&self) -> u64 { - self.num_peers - } - - pub async fn update(&mut self) -> Option<(f64, f64)> { - if !self.is_enabled { - return None; - } - - if let Some(link_arc) = &self.link { - let link = link_arc.lock().await; - - // Update the session state - link.capture_app_session_state(&mut self.session_state); - - // Get the number of peers - self.num_peers = link.num_peers() as u64; - - // Get tempo and beat time - let tempo = self.session_state.tempo(); - let clock_micros = link.clock_micros(); - let beat_time = self.session_state.beat_at_time(clock_micros, 4.0); // 4/4 time signature - - // Update the session state with our current state - link.commit_app_session_state(&self.session_state); - - Some((tempo, beat_time)) - } else { - None - } - } - - pub async fn set_tempo(&mut self, tempo: f64) -> Result<(), String> { - if !self.is_enabled { - return Err("Ableton Link is not enabled".to_string()); - } - - if let Some(link_arc) = &self.link { - let link = link_arc.lock().await; - let clock_micros = link.clock_micros(); - self.session_state.set_tempo(tempo, clock_micros); - link.commit_app_session_state(&self.session_state); - log::info!("Set Ableton Link tempo to {} BPM", tempo); - Ok(()) - } else { - Err("Link not initialized".to_string()) - } - } - - pub async fn enable_start_stop_sync(&mut self, enable: bool) -> Result<(), String> { - if !self.is_enabled { - return Err("Ableton Link is not enabled".to_string()); - } - - if let Some(link_arc) = &self.link { - let link = link_arc.lock().await; - link.enable_start_stop_sync(enable); - log::info!( - "Ableton Link start/stop sync {}", - if enable { "enabled" } else { "disabled" } - ); - Ok(()) - } else { - Err("Link not initialized".to_string()) - } - } - - pub async fn is_playing(&self) -> bool { - if let Some(link_arc) = &self.link { - let link = link_arc.lock().await; - link.is_enabled() && self.session_state.is_playing() - } else { - false - } - } -} - -impl Default for AbletonLinkManager { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/core/src/artnet/artnet.rs b/crates/core/src/artnet/artnet.rs deleted file mode 100644 index 0f08a00..0000000 --- a/crates/core/src/artnet/artnet.rs +++ /dev/null @@ -1,92 +0,0 @@ -use std::net::{SocketAddr, ToSocketAddrs, UdpSocket}; -use std::time::SystemTime; - -use artnet_protocol::{ArtCommand, Output}; -use log::debug; - -// The IP of the device running this SW -const DEVICE_IP: &str = "0.0.0.0"; - -const ART_NET_CONTROLLER_IP: &str = "255.255.255.255"; // Broadcast + Capture - //const ART_NET_CONTROLLER_IP: &str = "10.8.45.80"; // ODE MK2 -const CHANNELS_PER_UNIVERSE: u16 = 512; - -pub struct ArtNet { - socket: UdpSocket, - destination: SocketAddr, - channels: Vec, - last_sent: Option, - mode: ArtNetMode, -} - -#[derive(Clone, Debug)] -pub enum ArtNetMode { - Broadcast, - /// Specify from (interface) + to (destination) addresses - Unicast(SocketAddr, SocketAddr), -} - -impl ArtNet { - pub fn new(mode: ArtNetMode) -> Result { - let channels = Vec::with_capacity(CHANNELS_PER_UNIVERSE as usize); - - match mode { - ArtNetMode::Broadcast => { - // Use port 0 to let OS assign an ephemeral port, allowing multiple broadcast - // sockets - let socket = UdpSocket::bind((String::from("0.0.0.0"), 0))?; - let broadcast_addr = (ART_NET_CONTROLLER_IP, 6454) - .to_socket_addrs()? - .next() - .unwrap(); - socket.set_broadcast(true).unwrap(); - debug!( - "Broadcast mode set up OK on local port {}", - socket.local_addr()?.port() - ); - Ok(ArtNet { - socket, - destination: broadcast_addr, - channels, - last_sent: None, - mode: mode.clone(), - }) - } - - ArtNetMode::Unicast(src, destination) => { - debug!( - "Will connect from interface {} to destination {}", - &src, &destination - ); - // Use ephemeral port for sending, only use src IP to select interface - let bind_addr = SocketAddr::new(src.ip(), 0); - let socket = UdpSocket::bind(bind_addr)?; - - socket.set_broadcast(false)?; - debug!( - "Unicast mode set up OK on local port {}", - socket.local_addr()?.port() - ); - Ok(ArtNet { - socket, - destination, - channels, - last_sent: None, - mode: mode.clone(), - }) - } - } - } - - pub fn send_data(&self, universe: u8, dmx: Vec) { - let command = ArtCommand::Output(Output { - // length: dmx.len() as u16, - port_address: universe.into(), - data: dmx.into(), - ..Output::default() - }); - - let bytes = command.write_to_buffer().unwrap(); - self.socket.send_to(&bytes, self.destination).unwrap(); - } -} diff --git a/crates/core/src/artnet/mod.rs b/crates/core/src/artnet/mod.rs deleted file mode 100644 index b50708d..0000000 --- a/crates/core/src/artnet/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod artnet; -pub mod network_config; diff --git a/crates/core/src/artnet/network_config.rs b/crates/core/src/artnet/network_config.rs deleted file mode 100644 index a249d8e..0000000 --- a/crates/core/src/artnet/network_config.rs +++ /dev/null @@ -1,131 +0,0 @@ -use std::collections::HashMap; -use std::net::{IpAddr, SocketAddr}; - -use super::artnet::ArtNetMode; - -#[derive(Clone)] -pub struct NetworkConfig { - pub destinations: Vec, - pub universe_routing: HashMap, // universe -> destination index - pub port: u16, -} - -#[derive(Clone, Debug)] -pub struct ArtNetDestination { - pub name: String, - pub mode: ArtNetMode, -} - -impl NetworkConfig { - // Legacy constructor for backward compatibility - pub fn new( - source_ip: IpAddr, - dest_ip: Option, - artnet_port: u16, - broadcast: bool, - ) -> Self { - let mode = if broadcast { - ArtNetMode::Broadcast - } else { - match dest_ip { - Some(ip) => ArtNetMode::Unicast( - SocketAddr::new(source_ip, artnet_port), - SocketAddr::new(ip, artnet_port), - ), - None => ArtNetMode::Broadcast, - } - }; - - let destination = ArtNetDestination { - name: "default".to_string(), - mode, - }; - - // Default: route universe 1 to the single destination - let mut universe_routing = HashMap::new(); - universe_routing.insert(1, 0); - - NetworkConfig { - destinations: vec![destination], - universe_routing, - port: artnet_port, - } - } - - // New constructor for multi-destination setup - pub fn new_multi_destination( - destinations: Vec, - universe_routing: HashMap, - artnet_port: u16, - ) -> Self { - NetworkConfig { - destinations, - universe_routing, - port: artnet_port, - } - } - - // Add a destination and return its index - pub fn add_destination(&mut self, destination: ArtNetDestination) -> usize { - self.destinations.push(destination); - self.destinations.len() - 1 - } - - // Route a universe to a specific destination - pub fn route_universe(&mut self, universe: u8, destination_index: usize) { - if destination_index < self.destinations.len() { - self.universe_routing.insert(universe, destination_index); - } - } - - // Get destination index for a universe (returns None if not routed) - pub fn get_destination_for_universe(&self, universe: u8) -> Option { - self.universe_routing.get(&universe).copied() - } - - // Legacy compatibility methods - pub fn get_destination(&self) -> String { - if self.destinations.is_empty() { - return "No destinations configured".to_string(); - } - - let mut result = String::new(); - for (i, dest) in self.destinations.iter().enumerate() { - if i > 0 { - result.push_str(", "); - } - result.push_str(&format!( - "{}: {}", - dest.name, - self.get_destination_string(&dest.mode) - )); - } - result - } - - pub fn get_mode_string(&self) -> &str { - if self.destinations.is_empty() { - return "none"; - } - // Return the mode of the first destination for backward compatibility - match &self.destinations[0].mode { - ArtNetMode::Unicast(_, _) => "multi-unicast", - ArtNetMode::Broadcast => "multi-broadcast", - } - } - - fn get_destination_string(&self, mode: &ArtNetMode) -> String { - match mode { - ArtNetMode::Unicast(src, destination) => { - format!( - "{}:{} -> {}:{}", - src.ip(), - self.port, - destination.ip(), - self.port - ) - } - ArtNetMode::Broadcast => format!("255.255.255.255:{}", self.port), - } - } -} diff --git a/crates/core/src/audio/audio_player.rs b/crates/core/src/audio/audio_player.rs deleted file mode 100644 index 3400416..0000000 --- a/crates/core/src/audio/audio_player.rs +++ /dev/null @@ -1,110 +0,0 @@ -use std::fs::File; -use std::io::BufReader; -use std::path::Path; -use std::time::Duration; - -use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink}; - -pub struct AudioPlayer { - _stream_handle: OutputStream, - sink: Option, - current_file: Option, - volume: f32, -} - -impl AudioPlayer { - pub fn new() -> Result { - let stream_handle = OutputStreamBuilder::open_default_stream() - .map_err(|e| format!("Failed to open audio output stream: {}", e))?; - - Ok(AudioPlayer { - _stream_handle: stream_handle, - sink: None, - current_file: None, - volume: 1.0, - }) - } - - pub fn load_file>(&mut self, path: P) -> Result<(), String> { - let path_str = path.as_ref().to_string_lossy().to_string(); - - // Create a new sink - let sink = Sink::connect_new(self._stream_handle.mixer()); - - // Open the audio file - let file = File::open(&path).map_err(|e| format!("Failed to open audio file: {}", e))?; - let reader = BufReader::new(file); - - // Decode the audio file - let source = - Decoder::new(reader).map_err(|e| format!("Failed to decode audio file: {}", e))?; - - // Add the source to the sink - sink.append(source); - sink.set_volume(self.volume); - sink.pause(); - - // Store the sink and current file - self.sink = Some(sink); - self.current_file = Some(path_str); - - Ok(()) - } - - pub fn play(&self) -> Result<(), String> { - if let Some(sink) = &self.sink { - sink.play(); - Ok(()) - } else { - Err("No audio file loaded".to_string()) - } - } - - pub fn pause(&self) -> Result<(), String> { - if let Some(sink) = &self.sink { - sink.pause(); - Ok(()) - } else { - Err("No audio file loaded".to_string()) - } - } - - pub fn stop(&self) -> Result<(), String> { - if let Some(sink) = &self.sink { - sink.stop(); - Ok(()) - } else { - Err("No audio file loaded".to_string()) - } - } - - pub fn set_volume(&mut self, volume: f32) { - self.volume = volume.clamp(0.0, 1.0); - if let Some(sink) = &self.sink { - sink.set_volume(self.volume); - } - } - - pub fn is_playing(&self) -> bool { - if let Some(sink) = &self.sink { - !sink.is_paused() && !sink.empty() - } else { - false - } - } - - pub fn seek(&self, _position: Duration) -> Result<(), String> { - if let Some(_sink) = &self.sink { - // Rodio doesn't support seeking directly, so we'd need to - // implement a custom solution for this, potentially by - // reloading the file and skipping to position - Err("Seeking not implemented yet".to_string()) - } else { - Err("No audio file loaded".to_string()) - } - } - - pub fn get_current_file(&self) -> Option<&String> { - self.current_file.as_ref() - } -} diff --git a/crates/core/src/audio/device_enumerator.rs b/crates/core/src/audio/device_enumerator.rs deleted file mode 100644 index 5625692..0000000 --- a/crates/core/src/audio/device_enumerator.rs +++ /dev/null @@ -1,49 +0,0 @@ -use cpal::traits::{DeviceTrait, HostTrait}; -use serde::{Deserialize, Serialize}; - -/// Information about an audio device -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AudioDeviceInfo { - pub name: String, - pub is_default: bool, -} - -/// Enumerate all available audio output devices -pub fn enumerate_audio_devices() -> Result, String> { - let host = cpal::default_host(); - - // Get the default output device name - let default_device_name = host.default_output_device().and_then(|d| d.name().ok()); - - // Enumerate all output devices - let devices = host - .output_devices() - .map_err(|e| format!("Failed to enumerate audio devices: {e}"))?; - - let mut device_list = Vec::new(); - - for device in devices { - if let Ok(name) = device.name() { - let is_default = default_device_name.as_ref() == Some(&name); - device_list.push(AudioDeviceInfo { name, is_default }); - } - } - - // If no devices found, add a fallback - if device_list.is_empty() { - device_list.push(AudioDeviceInfo { - name: "Default".to_string(), - is_default: true, - }); - } - - Ok(device_list) -} - -/// Get the default audio device name -pub fn get_default_audio_device() -> String { - let host = cpal::default_host(); - host.default_output_device() - .and_then(|d| d.name().ok()) - .unwrap_or_else(|| "Default".to_string()) -} diff --git a/crates/core/src/audio/mod.rs b/crates/core/src/audio/mod.rs deleted file mode 100644 index 9affb74..0000000 --- a/crates/core/src/audio/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod audio_player; -pub mod device_enumerator; -pub mod waveform; diff --git a/crates/core/src/audio/waveform.rs b/crates/core/src/audio/waveform.rs deleted file mode 100644 index a299a7a..0000000 --- a/crates/core/src/audio/waveform.rs +++ /dev/null @@ -1,286 +0,0 @@ -use std::path::Path; - -use symphonia::core::audio::{AudioBufferRef, Signal}; -use symphonia::core::codecs::DecoderOptions; -use symphonia::core::formats::FormatOptions; -use symphonia::core::io::MediaSourceStream; -use symphonia::core::meta::MetadataOptions; -use symphonia::core::probe::Hint; - -#[derive(Debug, Clone)] -pub struct WaveformData { - pub samples: Vec, - pub duration_seconds: f64, - pub sample_rate: u32, - pub bpm: Option, -} - -impl WaveformData { - pub fn new( - samples: Vec, - duration_seconds: f64, - sample_rate: u32, - bpm: Option, - ) -> Self { - Self { - samples, - duration_seconds, - sample_rate, - bpm, - } - } -} - -pub fn analyze_audio_file>(path: P) -> Result { - let path = path.as_ref(); - - // Create a media source from the file - let file = std::fs::File::open(path).map_err(|e| format!("Failed to open audio file: {e}"))?; - let mss = MediaSourceStream::new(Box::new(file), Default::default()); - - // Create a probe hint using the file's extension - let mut hint = Hint::new(); - if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) { - hint.with_extension(extension); - } - - // Use the default options for metadata and format readers - let meta_opts: MetadataOptions = Default::default(); - let fmt_opts: FormatOptions = Default::default(); - - // Probe the media source - let probed = symphonia::default::get_probe() - .format(&hint, mss, &fmt_opts, &meta_opts) - .map_err(|e| format!("Failed to probe audio file: {e}"))?; - - let mut format = probed.format; - let _metadata = probed.metadata; - - // Get the default track - let track = format - .tracks() - .iter() - .find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL) - .ok_or("No supported audio tracks found")?; - - // Create a decoder for the track - let decoder_opts: DecoderOptions = Default::default(); - let mut decoder = symphonia::default::get_codecs() - .make(&track.codec_params, &decoder_opts) - .map_err(|e| format!("Failed to create decoder: {e}"))?; - - // Get track info - let track_id = track.id; - let sample_rate = track.codec_params.sample_rate.unwrap_or(44100); - let duration = track - .codec_params - .n_frames - .map(|frames| frames as f64 / sample_rate as f64); - - // Extract BPM from metadata if available - let bpm = None; - // Skip BPM extraction for now - metadata API is complex - // TODO: Implement proper BPM extraction from metadata - - // Read and decode audio data - let mut samples = Vec::new(); - let mut decoded_samples = 0; - let target_samples = 2000; // Target number of samples for visualization - - loop { - let packet = match format.next_packet() { - Ok(packet) => packet, - Err(symphonia::core::errors::Error::ResetRequired) => { - // The track list has changed and the user must select a new track - return Err("Track list changed during decoding".to_string()); - } - Err(symphonia::core::errors::Error::IoError(_)) => { - // The packet is likely corrupted - continue; - } - Err(_) => break, // End of stream or other error - }; - - // If the packet does not belong to the selected track, skip it - if packet.track_id() != track_id { - continue; - } - - // Decode the packet - let audio_buf = match decoder.decode(&packet) { - Ok(audio_buf) => audio_buf, - Err(symphonia::core::errors::Error::IoError(_)) => { - // The packet is likely corrupted - continue; - } - Err(_) => break, - }; - - // Convert the decoded audio buffer to a vector of samples - let audio_samples = match audio_buf { - AudioBufferRef::F32(buf) => { - // For stereo, mix down to mono by averaging channels - if buf.spec().channels.count() > 1 { - buf.chan(0) - .iter() - .zip(buf.chan(1).iter()) - .map(|(l, r)| (l + r) / 2.0) - .collect::>() - } else { - buf.chan(0).to_vec() - } - } - AudioBufferRef::U8(buf) => { - // Convert u8 to f32 - if buf.spec().channels.count() > 1 { - buf.chan(0) - .iter() - .zip(buf.chan(1).iter()) - .map(|(l, r)| ((*l as f32 + *r as f32) / 2.0 - 128.0) / 128.0) - .collect::>() - } else { - buf.chan(0) - .iter() - .map(|&s| (s as f32 - 128.0) / 128.0) - .collect::>() - } - } - AudioBufferRef::U16(buf) => { - // Convert u16 to f32 - if buf.spec().channels.count() > 1 { - buf.chan(0) - .iter() - .zip(buf.chan(1).iter()) - .map(|(l, r)| ((*l as f32 + *r as f32) / 2.0 - 32768.0) / 32768.0) - .collect::>() - } else { - buf.chan(0) - .iter() - .map(|&s| (s as f32 - 32768.0) / 32768.0) - .collect::>() - } - } - AudioBufferRef::U24(_buf) => { - // Skip u24 for now - complex conversion - Vec::new() - } - AudioBufferRef::U32(buf) => { - // Convert u32 to f32 - if buf.spec().channels.count() > 1 { - buf.chan(0) - .iter() - .zip(buf.chan(1).iter()) - .map(|(l, r)| { - let l_f32 = (*l as f32 - 2147483648.0) / 2147483648.0; - let r_f32 = (*r as f32 - 2147483648.0) / 2147483648.0; - (l_f32 + r_f32) / 2.0 - }) - .collect::>() - } else { - buf.chan(0) - .iter() - .map(|&s| (s as f32 - 2147483648.0) / 2147483648.0) - .collect::>() - } - } - AudioBufferRef::S8(buf) => { - // Convert s8 to f32 - if buf.spec().channels.count() > 1 { - buf.chan(0) - .iter() - .zip(buf.chan(1).iter()) - .map(|(l, r)| (*l as f32 + *r as f32) / 2.0 / 128.0) - .collect::>() - } else { - buf.chan(0) - .iter() - .map(|&s| s as f32 / 128.0) - .collect::>() - } - } - AudioBufferRef::S16(buf) => { - // Convert s16 to f32 - if buf.spec().channels.count() > 1 { - buf.chan(0) - .iter() - .zip(buf.chan(1).iter()) - .map(|(l, r)| (*l as f32 + *r as f32) / 2.0 / 32768.0) - .collect::>() - } else { - buf.chan(0) - .iter() - .map(|&s| s as f32 / 32768.0) - .collect::>() - } - } - AudioBufferRef::S24(_buf) => { - // Skip s24 for now - complex conversion - Vec::new() - } - AudioBufferRef::S32(buf) => { - // Convert s32 to f32 - if buf.spec().channels.count() > 1 { - buf.chan(0) - .iter() - .zip(buf.chan(1).iter()) - .map(|(l, r)| { - let l_f32 = *l as f32 / 2147483648.0; - let r_f32 = *r as f32 / 2147483648.0; - (l_f32 + r_f32) / 2.0 - }) - .collect::>() - } else { - buf.chan(0) - .iter() - .map(|&s| s as f32 / 2147483648.0) - .collect::>() - } - } - AudioBufferRef::F64(buf) => { - // Convert f64 to f32 - if buf.spec().channels.count() > 1 { - buf.chan(0) - .iter() - .zip(buf.chan(1).iter()) - .map(|(l, r)| (l + r) as f32 / 2.0) - .collect::>() - } else { - buf.chan(0).iter().map(|&s| s as f32).collect::>() - } - } - }; - - let sample_count = audio_samples.len(); - samples.extend(audio_samples); - decoded_samples += sample_count; - - // If we have enough samples, break - if decoded_samples >= target_samples * 10 { - break; - } - } - - // Downsample to target number of samples - let downsampled = if samples.len() > target_samples { - let step = samples.len() / target_samples; - samples - .chunks(step) - .map(|chunk| { - // Calculate RMS (root mean square) for each chunk - let sum_squares: f32 = chunk.iter().map(|&s| s * s).sum(); - (sum_squares / chunk.len() as f32).sqrt() - }) - .collect() - } else { - samples - }; - - let duration_seconds = duration.unwrap_or(0.0); - - Ok(WaveformData::new( - downsampled, - duration_seconds, - sample_rate, - bpm, - )) -} diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs deleted file mode 100644 index 25c337e..0000000 --- a/crates/core/src/config.rs +++ /dev/null @@ -1,454 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; - -use crate::Settings; - -/// Configuration manager for Halo settings -/// Provides a layered configuration system that separates schema, available options, and persisted -/// values Configuration is stored in config.json in the repository root by default -pub struct ConfigManager { - config_path: PathBuf, - settings: Settings, -} - -/// Available configuration options with validation -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConfigSchema { - pub general: GeneralConfigSchema, - pub audio: AudioConfigSchema, - pub midi: MidiConfigSchema, - pub output: OutputConfigSchema, - pub fixture: FixtureConfigSchema, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeneralConfigSchema { - pub target_fps: ConfigOption, - pub enable_autosave: ConfigOption, - pub autosave_interval_secs: ConfigOption, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AudioConfigSchema { - pub audio_device: ConfigOption, - pub audio_buffer_size: ConfigOption, - pub audio_sample_rate: ConfigOption, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MidiConfigSchema { - pub midi_enabled: ConfigOption, - pub midi_device: ConfigOption, - pub midi_channel: ConfigOption, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OutputConfigSchema { - pub dmx_enabled: ConfigOption, - pub dmx_broadcast: ConfigOption, - pub dmx_source_ip: ConfigOption, - pub dmx_dest_ip: ConfigOption, - pub dmx_port: ConfigOption, - pub wled_enabled: ConfigOption, - pub wled_ip: ConfigOption, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FixtureConfigSchema { - pub enable_pan_tilt_limits: ConfigOption, -} - -/// Configuration option with validation and available choices -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConfigOption { - pub default: T, - pub valid_range: Option<(T, T)>, - pub valid_choices: Option>, - pub description: String, - pub requires_restart: bool, -} - -/// Persisted configuration file format -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConfigFile { - pub version: String, - pub settings: Settings, - pub created_at: String, - pub modified_at: String, -} - -impl ConfigManager { - /// Create a new configuration manager - /// If no path is provided, defaults to 'config.json' in the current working directory - pub fn new(config_path: Option) -> Self { - let config_path = config_path.unwrap_or_else(|| { - // Default to config.json in the repository root - PathBuf::from("config.json") - }); - - Self { - config_path, - settings: Settings::default(), - } - } - - /// Load settings from configuration file - /// Returns default settings if file doesn't exist or is invalid - pub fn load(&mut self) -> Result { - if !self.config_path.exists() { - // Create default config file - self.save()?; - return Ok(self.settings.clone()); - } - - let content = fs::read_to_string(&self.config_path) - .map_err(|e| ConfigError::ReadError(e.to_string()))?; - - let config_file: ConfigFile = - serde_json::from_str(&content).map_err(|e| ConfigError::ParseError(e.to_string()))?; - - // Validate version compatibility - if config_file.version != env!("CARGO_PKG_VERSION") { - eprintln!( - "Warning: Config file version {} doesn't match application version {}. Using defaults for new settings.", - config_file.version, - env!("CARGO_PKG_VERSION") - ); - } - - self.settings = config_file.settings; - Ok(self.settings.clone()) - } - - /// Save current settings to configuration file - pub fn save(&self) -> Result<(), ConfigError> { - // Ensure config directory exists (if config is in a subdirectory) - if let Some(parent) = self.config_path.parent() { - if parent != std::path::Path::new("") && parent != std::path::Path::new(".") { - fs::create_dir_all(parent).map_err(|e| ConfigError::WriteError(e.to_string()))?; - } - } - - let config_file = ConfigFile { - version: env!("CARGO_PKG_VERSION").to_string(), - settings: self.settings.clone(), - created_at: chrono::Utc::now().to_rfc3339(), - modified_at: chrono::Utc::now().to_rfc3339(), - }; - - let content = serde_json::to_string_pretty(&config_file) - .map_err(|e| ConfigError::SerializeError(e.to_string()))?; - - fs::write(&self.config_path, content) - .map_err(|e| ConfigError::WriteError(e.to_string()))?; - - Ok(()) - } - - /// Update settings and save to file - pub fn update_settings(&mut self, settings: Settings) -> Result<(), ConfigError> { - self.settings = settings; - self.save() - } - - /// Get current settings - pub fn settings(&self) -> &Settings { - &self.settings - } - - /// Get configuration file path - pub fn config_path(&self) -> &Path { - &self.config_path - } - - /// Get configuration schema with available options - pub fn schema() -> ConfigSchema { - ConfigSchema { - general: GeneralConfigSchema { - target_fps: ConfigOption { - default: 60, - valid_range: Some((30, 120)), - valid_choices: None, - description: "UI refresh rate in frames per second".to_string(), - requires_restart: false, - }, - enable_autosave: ConfigOption { - default: false, - valid_range: None, - valid_choices: None, - description: "Automatically save show files at regular intervals".to_string(), - requires_restart: false, - }, - autosave_interval_secs: ConfigOption { - default: 300, - valid_range: Some((60, 3600)), - valid_choices: None, - description: "Autosave interval in seconds".to_string(), - requires_restart: false, - }, - }, - audio: AudioConfigSchema { - audio_device: ConfigOption { - default: "Default".to_string(), - valid_range: None, - valid_choices: None, // Will be populated from system enumeration - description: "Audio output device for playback".to_string(), - requires_restart: true, - }, - audio_buffer_size: ConfigOption { - default: 512, - valid_range: None, - valid_choices: Some(vec![128, 256, 512, 1024, 2048]), - description: "Audio buffer size in samples".to_string(), - requires_restart: true, - }, - audio_sample_rate: ConfigOption { - default: 48000, - valid_range: None, - valid_choices: Some(vec![44100, 48000, 96000]), - description: "Audio sample rate in Hz".to_string(), - requires_restart: true, - }, - }, - midi: MidiConfigSchema { - midi_enabled: ConfigOption { - default: false, - valid_range: None, - valid_choices: None, - description: "Enable MIDI input for live control".to_string(), - requires_restart: true, - }, - midi_device: ConfigOption { - default: "None".to_string(), - valid_range: None, - valid_choices: None, // Will be populated from system enumeration - description: "MIDI input device".to_string(), - requires_restart: true, - }, - midi_channel: ConfigOption { - default: 1, - valid_range: Some((1, 16)), - valid_choices: None, - description: "MIDI channel for input (1-16)".to_string(), - requires_restart: true, - }, - }, - output: OutputConfigSchema { - dmx_enabled: ConfigOption { - default: true, - valid_range: None, - valid_choices: None, - description: "Enable DMX output via Art-Net".to_string(), - requires_restart: true, - }, - dmx_broadcast: ConfigOption { - default: false, - valid_range: None, - valid_choices: None, - description: "Use broadcast mode for Art-Net (vs unicast)".to_string(), - requires_restart: true, - }, - dmx_source_ip: ConfigOption { - default: "192.168.1.100".to_string(), - valid_range: None, - valid_choices: None, - description: "Source IP address for Art-Net output".to_string(), - requires_restart: true, - }, - dmx_dest_ip: ConfigOption { - default: "192.168.1.200".to_string(), - valid_range: None, - valid_choices: None, - description: "Destination IP address for Art-Net unicast".to_string(), - requires_restart: true, - }, - dmx_port: ConfigOption { - default: 6454, - valid_range: Some((1024, 65535)), - valid_choices: None, - description: "UDP port for Art-Net output".to_string(), - requires_restart: true, - }, - wled_enabled: ConfigOption { - default: false, - valid_range: None, - valid_choices: None, - description: "Enable WLED protocol support".to_string(), - requires_restart: true, - }, - wled_ip: ConfigOption { - default: "192.168.1.50".to_string(), - valid_range: None, - valid_choices: None, - description: "IP address of WLED device".to_string(), - requires_restart: true, - }, - }, - fixture: FixtureConfigSchema { - enable_pan_tilt_limits: ConfigOption { - default: true, - valid_range: None, - valid_choices: None, - description: "Enable pan/tilt limiting for moving heads".to_string(), - requires_restart: false, - }, - }, - } - } - - /// Validate settings against schema - pub fn validate_settings(settings: &Settings) -> Result<(), Vec> { - let mut errors = Vec::new(); - let schema = Self::schema(); - - // Validate general settings - if let Some((min, max)) = schema.general.target_fps.valid_range { - if settings.target_fps < min || settings.target_fps > max { - errors.push(format!("target_fps must be between {} and {}", min, max)); - } - } - - if let Some((min, max)) = schema.general.autosave_interval_secs.valid_range { - if settings.autosave_interval_secs < min || settings.autosave_interval_secs > max { - errors.push(format!( - "autosave_interval_secs must be between {} and {}", - min, max - )); - } - } - - // Validate audio settings - if let Some(choices) = &schema.audio.audio_buffer_size.valid_choices { - if !choices.contains(&settings.audio_buffer_size) { - errors.push(format!("audio_buffer_size must be one of: {:?}", choices)); - } - } - - if let Some(choices) = &schema.audio.audio_sample_rate.valid_choices { - if !choices.contains(&settings.audio_sample_rate) { - errors.push(format!("audio_sample_rate must be one of: {:?}", choices)); - } - } - - // Validate MIDI settings - if let Some((min, max)) = schema.midi.midi_channel.valid_range { - if settings.midi_channel < min || settings.midi_channel > max { - errors.push(format!("midi_channel must be between {} and {}", min, max)); - } - } - - // Validate output settings - if let Some((min, max)) = schema.output.dmx_port.valid_range { - if settings.dmx_port < min || settings.dmx_port > max { - errors.push(format!("dmx_port must be between {} and {}", min, max)); - } - } - - if errors.is_empty() { - Ok(()) - } else { - Err(errors) - } - } - - /// Reset settings to defaults - pub fn reset_to_defaults(&mut self) -> Result<(), ConfigError> { - self.settings = Settings::default(); - self.save() - } -} - -/// Configuration error types -#[derive(Debug)] -pub enum ConfigError { - ReadError(String), - WriteError(String), - ParseError(String), - SerializeError(String), - ValidationError(Vec), -} - -impl std::fmt::Display for ConfigError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ConfigError::ReadError(msg) => write!(f, "Failed to read config file: {}", msg), - ConfigError::WriteError(msg) => write!(f, "Failed to write config file: {}", msg), - ConfigError::ParseError(msg) => write!(f, "Failed to parse config file: {}", msg), - ConfigError::SerializeError(msg) => write!(f, "Failed to serialize config: {}", msg), - ConfigError::ValidationError(errors) => { - write!(f, "Config validation errors: {}", errors.join(", ")) - } - } - } -} - -impl std::error::Error for ConfigError {} - -#[cfg(test)] -mod tests { - use tempfile::TempDir; - - use super::*; - - #[test] - fn test_config_manager_new() { - let temp_dir = TempDir::new().unwrap(); - let config_path = temp_dir.path().join("test_config.json"); - - let manager = ConfigManager::new(Some(config_path.clone())); - assert_eq!(manager.config_path(), config_path); - assert_eq!(manager.settings(), &Settings::default()); - } - - #[test] - fn test_save_and_load() { - let temp_dir = TempDir::new().unwrap(); - let config_path = temp_dir.path().join("test_config.json"); - - let mut manager = ConfigManager::new(Some(config_path.clone())); - - // Modify settings - let mut settings = Settings::default(); - settings.target_fps = 90; - settings.audio_device = "Test Device".to_string(); - - // Save settings - manager.update_settings(settings.clone()).unwrap(); - - // Load into new manager - let mut manager2 = ConfigManager::new(Some(config_path)); - let loaded_settings = manager2.load().unwrap(); - - assert_eq!(loaded_settings.target_fps, 90); - assert_eq!(loaded_settings.audio_device, "Test Device"); - } - - #[test] - fn test_validation() { - let mut settings = Settings::default(); - - // Valid settings should pass - assert!(ConfigManager::validate_settings(&settings).is_ok()); - - // Invalid settings should fail - settings.target_fps = 200; // Outside valid range - assert!(ConfigManager::validate_settings(&settings).is_err()); - - settings.target_fps = 60; // Back to valid - settings.midi_channel = 20; // Outside valid range - assert!(ConfigManager::validate_settings(&settings).is_err()); - } - - #[test] - fn test_schema_completeness() { - let schema = ConfigManager::schema(); - - // Ensure all settings have corresponding schema entries - assert!(schema.general.target_fps.default > 0); - assert!(!schema.audio.audio_device.description.is_empty()); - assert!(schema.midi.midi_channel.valid_range.is_some()); - assert!(schema.output.dmx_port.valid_range.is_some()); - } -} diff --git a/crates/core/src/console.rs b/crates/core/src/console.rs deleted file mode 100644 index 8adb8f9..0000000 --- a/crates/core/src/console.rs +++ /dev/null @@ -1,2160 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use halo_fixtures::{Fixture, FixtureLibrary}; -use tokio::sync::{mpsc, Mutex, RwLock}; -use tokio::task::JoinHandle; - -use crate::artnet::network_config::NetworkConfig; -use crate::audio::device_enumerator; -use crate::cue::cue::Cue; -use crate::cue::cue_manager::{CueManager, PlaybackState}; -use crate::messages::{ConsoleCommand, ConsoleEvent, Settings}; -use crate::midi::midi::{MidiMessage, MidiOverride}; -use crate::modules::{ - AudioModule, DmxModule, MidiModule, ModuleEvent, ModuleId, ModuleManager, ModuleMessage, - SmpteModule, -}; -use crate::pixel::PixelEngine; -use crate::programmer::Programmer; -use crate::rhythm::rhythm::RhythmState; -use crate::show::show_manager::ShowManager; -use crate::timecode::timecode::TimeCode; -use crate::tracking_state::TrackingState; -use crate::{AbletonLinkManager, CueList}; - -pub struct LightingConsole { - // Core components - show_name: String, - tempo: f64, - fixture_library: FixtureLibrary, - pub fixtures: Arc>>, - pub cue_manager: Arc>, - pub programmer: Arc>, - pub show_manager: Arc>, - - // Async module system - module_manager: ModuleManager, - message_handler: Option>, - message_rx: Option>, - - // MIDI overrides - midi_overrides: HashMap, - active_overrides: HashMap, - - // Rhythm state - rhythm_state: Arc>, - - // Ableton Link integration - link_manager: Arc>, - - // Settings - settings: Arc>, - - // Pixel engine - pixel_engine: Arc>, - - // Tracking state for tracking console behavior - tracking_state: Arc>, - - // System state - is_running: bool, - - // Internal timing for rhythm state when Link is not active - last_update_time: std::time::Instant, - accumulated_beats: f64, -} - -impl LightingConsole { - pub fn new(bpm: f64, network_config: NetworkConfig) -> Result { - Self::new_with_settings(bpm, network_config, Settings::default()) - } - - pub fn new_with_settings( - bpm: f64, - network_config: NetworkConfig, - settings: Settings, - ) -> Result { - let mut module_manager = ModuleManager::new(); - - // Register async modules - module_manager.register_module(Box::new(DmxModule::new(network_config))); - module_manager.register_module(Box::new(AudioModule::new())); - module_manager.register_module(Box::new(SmpteModule::new(30))); // 30fps default - - // Only register MIDI module if enabled and device is not "None" - if settings.midi_enabled && settings.midi_device != "None" { - module_manager.register_module(Box::new(MidiModule::new(settings.midi_device.clone()))); - } - - let show_manager = ShowManager::new()?; - - Ok(Self { - show_name: "Untitled Show".to_string(), - tempo: bpm, - fixture_library: FixtureLibrary::new(), - fixtures: Arc::new(RwLock::new(Vec::new())), - cue_manager: Arc::new(RwLock::new(CueManager::new(Vec::new()))), - programmer: Arc::new(RwLock::new(Programmer::new())), - show_manager: Arc::new(RwLock::new(show_manager)), - module_manager, - message_handler: None, - message_rx: None, - midi_overrides: HashMap::new(), - active_overrides: HashMap::new(), - rhythm_state: Arc::new(RwLock::new(RhythmState { - beat_phase: 0.0, - bar_phase: 0.0, - phrase_phase: 0.0, - beats_per_bar: 4, - bars_per_phrase: 4, - last_tap_time: None, - tap_count: 0, - })), - link_manager: Arc::new(Mutex::new(AbletonLinkManager::new())), - settings: Arc::new(RwLock::new(settings)), - pixel_engine: Arc::new(RwLock::new(PixelEngine::new())), - tracking_state: Arc::new(RwLock::new(TrackingState::new())), - is_running: false, - last_update_time: std::time::Instant::now(), - accumulated_beats: 0.0, - }) - } - - /// Initialize the async console and all modules - pub async fn initialize(&mut self) -> Result<(), anyhow::Error> { - log::info!("Initializing async lighting console..."); - - // Initialize all modules - self.module_manager - .initialize() - .await - .map_err(|e| anyhow::anyhow!("Module initialization failed: {}", e))?; - - // Start all modules - self.module_manager - .start() - .await - .map_err(|e| anyhow::anyhow!("Module start failed: {}", e))?; - - // Store message receiver for main loop processing - if let Some(message_rx) = self.module_manager.take_message_receiver() { - self.message_rx = Some(message_rx); - } - - self.is_running = true; - log::info!("Async lighting console initialized successfully"); - Ok(()) - } - - async fn handle_midi_input( - midi_msg: MidiMessage, - _rhythm_state: &Arc>, - cue_manager: &Arc>, - ) { - match midi_msg { - MidiMessage::Clock => { - // Handle MIDI clock for tempo sync - log::debug!("MIDI Clock received"); - } - MidiMessage::NoteOn(note, velocity) => { - log::info!("MIDI Note On: {} velocity: {}", note, velocity); - // Handle MIDI note on for cue triggers, etc. - } - MidiMessage::NoteOff(note) => { - log::info!("MIDI Note Off: {}", note); - // Handle MIDI note off - } - MidiMessage::ControlChange(cc, value) => { - log::info!("MIDI CC: {} value: {}", cc, value); - - // Handle specific control changes - match cc { - 116 if value > 64 => { - // Go button - let mut cue_mgr = cue_manager.write().await; - if let Err(e) = cue_mgr.go() { - log::error!("Error advancing cue: {}", e); - } - } - 22 => { - // BPM control - let bpm = 60.0 + (value as f64 / 127.0) * (187.0 - 60.0); - log::info!("Setting BPM to {}", bpm); - // Update tempo via rhythm state - } - _ => {} - } - } - } - } - - /// Main update loop - call this regularly to process lighting data - pub async fn update(&mut self) -> Result)>, anyhow::Error> { - // Update timing for rhythm state - let now = std::time::Instant::now(); - let delta_time = now.duration_since(self.last_update_time).as_secs_f64(); - self.last_update_time = now; - - // Update Ableton Link state - let link_updated = { - let mut link_manager = self.link_manager.lock().await; - if let Some((tempo, beat_time)) = link_manager.update().await { - self.tempo = tempo; - self.accumulated_beats = beat_time; - self.update_rhythm_state(beat_time).await; - true - } else { - false - } - }; - - // If Link didn't update, advance rhythm state based on internal tempo - if !link_updated { - let beats_per_second = self.tempo / 60.0; - self.accumulated_beats += delta_time * beats_per_second; - self.update_rhythm_state(self.accumulated_beats).await; - } - - // Process current cue if playing - update tracking state - { - let cue_manager = self.cue_manager.read().await; - if cue_manager.get_playback_state() == PlaybackState::Playing { - if let Some(current_cue) = cue_manager.get_current_cue() { - // Update tracking state with current cue - self.update_tracking_state(current_cue.clone()).await; - } - } - } - - // Apply accumulated tracking state to fixtures - self.apply_tracking_state().await; - - // Apply programmer values (highest priority) - self.apply_programmer_values().await; - - // Generate and send DMX data - let pixel_data = self.send_dmx_data().await?; - - // Update cue manager - { - let mut cue_manager = self.cue_manager.write().await; - cue_manager.update(); - } - - Ok(pixel_data) - } - - async fn update_rhythm_state(&self, beat_time: f64) { - let mut rhythm = self.rhythm_state.write().await; - rhythm.beat_phase = beat_time.fract(); - rhythm.bar_phase = (beat_time / rhythm.beats_per_bar as f64).fract(); - rhythm.phrase_phase = - (beat_time / (rhythm.beats_per_bar * rhythm.bars_per_phrase) as f64).fract(); - } - - /// Update rhythm state based on internal time when Link isn't available - async fn update_internal_rhythm(&mut self) { - let now = Instant::now(); - let elapsed = now.duration_since(self.last_update_time).as_secs_f64(); - self.last_update_time = now; - - // Protect against large time jumps (lag spikes, window focus loss, etc.) - // Cap elapsed time to 100ms to prevent discontinuities - let elapsed = elapsed.min(0.1); - - // Calculate how many beats have passed based on tempo - let beats_per_second = self.tempo / 60.0; - let beats_elapsed = elapsed * beats_per_second; - - // Accumulate beats - self.accumulated_beats += beats_elapsed; - - // Update rhythm state - self.update_rhythm_state(self.accumulated_beats).await; - } - - /// Update tracking state with current cue - async fn update_tracking_state(&self, cue: crate::cue::cue::Cue) { - let mut tracking_state = self.tracking_state.write().await; - - if cue.is_blocking { - // Blocking cue: clear state and apply this cue - tracking_state.apply_blocking_cue(&cue); - } else { - // Non-blocking cue: merge into tracking state - tracking_state.apply_cue(&cue); - } - } - - /// Apply accumulated tracking state to fixtures - async fn apply_tracking_state(&self) { - let tracking_state = self.tracking_state.read().await; - let mut fixtures = self.fixtures.write().await; - - // Apply static values from tracking state - for value in tracking_state.get_static_values() { - if let Some(fixture) = fixtures.iter_mut().find(|f| f.id == value.fixture_id) { - fixture.set_channel_value(&value.channel_type, value.value); - } - } - - // Release fixtures lock before processing effects - drop(fixtures); - - // Apply effects from tracking state - self.apply_effects().await; - - // Apply pixel effects from tracking state - let pixel_effects = tracking_state.get_pixel_effects(); - if !pixel_effects.is_empty() { - let mut pixel_engine = self.pixel_engine.write().await; - let pixel_effect_data: Vec<_> = pixel_effects - .iter() - .map(|pm| { - ( - pm.name.clone(), - pm.fixture_ids.clone(), - pm.effect.clone(), - pm.distribution.clone(), - ) - }) - .collect(); - pixel_engine.set_effects(pixel_effect_data); - } - } - - /// Apply effects from tracking state to fixtures - async fn apply_effects(&self) { - let tracking_state = self.tracking_state.read().await; - let effects = tracking_state.get_effects(); - let rhythm_state = self.rhythm_state.read().await; - let mut fixtures = self.fixtures.write().await; - - for effect_mapping in effects { - // Calculate effect phase based on rhythm state - let phase = crate::effect::effect::get_effect_phase( - &rhythm_state, - &effect_mapping.effect.params, - ); - - // Apply the effect to get normalized value (0.0 to 1.0) - let normalized_value = effect_mapping.effect.apply(phase); - - // Scale to min/max range - let min = effect_mapping.effect.min as f64; - let max = effect_mapping.effect.max as f64; - let scaled_value = (min + (max - min) * normalized_value) as u8; - - // Apply effect to fixtures based on distribution - match &effect_mapping.distribution { - crate::EffectDistribution::All => { - // Apply same value to all fixtures - for fixture_id in &effect_mapping.fixture_ids { - if let Some(fixture) = fixtures.iter_mut().find(|f| f.id == *fixture_id) { - for channel_type in &effect_mapping.channel_types { - fixture.set_channel_value(channel_type, scaled_value); - } - } - } - } - crate::EffectDistribution::Step(step_size) => { - // Apply effect with step distribution - for (idx, fixture_id) in effect_mapping.fixture_ids.iter().enumerate() { - let step_phase = (phase + (idx / step_size) as f64) % 1.0; - let step_normalized = effect_mapping.effect.apply(step_phase); - let step_value = (min + (max - min) * step_normalized) as u8; - - if let Some(fixture) = fixtures.iter_mut().find(|f| f.id == *fixture_id) { - for channel_type in &effect_mapping.channel_types { - fixture.set_channel_value(channel_type, step_value); - } - } - } - } - crate::EffectDistribution::Wave(phase_offset) => { - // Apply effect with wave distribution (phase offset per fixture) - for (idx, fixture_id) in effect_mapping.fixture_ids.iter().enumerate() { - let wave_phase = (phase + idx as f64 * phase_offset) % 1.0; - let wave_normalized = effect_mapping.effect.apply(wave_phase); - let wave_value = (min + (max - min) * wave_normalized) as u8; - - if let Some(fixture) = fixtures.iter_mut().find(|f| f.id == *fixture_id) { - for channel_type in &effect_mapping.channel_types { - fixture.set_channel_value(channel_type, wave_value); - } - } - } - } - } - } - } - - async fn apply_programmer_values(&self) { - let programmer = self.programmer.read().await; - if programmer.get_preview_mode() { - let values = programmer.get_values(); - let mut fixtures = self.fixtures.write().await; - - for value in values { - if let Some(fixture) = fixtures.iter_mut().find(|f| f.id == value.fixture_id) { - fixture.set_channel_value(&value.channel_type, value.value); - } - } - } - } - - async fn send_dmx_data(&self) -> Result)>, anyhow::Error> { - let fixtures = self.fixtures.read().await; - - // Render pixel fixtures first - let pixel_engine = self.pixel_engine.read().await; - let rhythm_state = self.rhythm_state.read().await; - let mut universe_data = pixel_engine.render(&fixtures, &rhythm_state); - - // Merge regular fixtures into universe buffers - for fixture in fixtures.iter() { - if fixture.profile.fixture_type != halo_fixtures::FixtureType::PixelBar { - // Get or create universe buffer - let universe_buffer = universe_data - .entry(fixture.universe) - .or_insert_with(|| vec![0; 512]); - - let start_channel = (fixture.start_address - 1) as usize; - let fixture_data = fixture.get_dmx_values(); - let end_channel = (start_channel + fixture_data.len()).min(512); - - universe_buffer[start_channel..end_channel].copy_from_slice(&fixture_data); - } - } - - // Extract pixel data for visualization before sending - let mut pixel_data = Vec::new(); - for fixture in fixtures.iter() { - if fixture.profile.fixture_type == halo_fixtures::FixtureType::PixelBar { - let universe = pixel_engine.get_fixture_universe(fixture.id, fixture.universe); - if let Some(universe_buffer) = universe_data.get(&universe) { - let start_idx = (fixture.start_address - 1) as usize; - let pixel_count = fixture.channels.len() / 3; - let mut pixels = Vec::new(); - - for pixel_idx in 0..pixel_count { - let base = start_idx + pixel_idx * 3; - if base + 2 < universe_buffer.len() { - let r = universe_buffer[base]; - let g = universe_buffer[base + 1]; - let b = universe_buffer[base + 2]; - pixels.push((r, g, b)); - } - } - - if !pixels.is_empty() { - pixel_data.push((fixture.id, pixels)); - } - } - } - } - - // Send all universes to DMX module - for (universe, data) in universe_data { - self.module_manager - .send_to_module(ModuleId::Dmx, ModuleEvent::DmxOutput(universe, data)) - .await - .map_err(|e| anyhow::anyhow!(e))?; - } - - Ok(pixel_data) - } - - /// Load fixture library - pub fn load_fixture_library(&mut self) { - self.fixture_library = FixtureLibrary::new(); - } - - /// Convert a channel name string to a ChannelType - fn channel_string_to_type(channel: &str) -> halo_fixtures::ChannelType { - use halo_fixtures::ChannelType; - - match channel.to_lowercase().as_str() { - "dimmer" => ChannelType::Dimmer, - "color" => ChannelType::Color, - "gobo" => ChannelType::Gobo, - "red" => ChannelType::Red, - "green" => ChannelType::Green, - "blue" => ChannelType::Blue, - "white" => ChannelType::White, - "amber" => ChannelType::Amber, - "uv" => ChannelType::UV, - "strobe" => ChannelType::Strobe, - "pan" => ChannelType::Pan, - "tilt" => ChannelType::Tilt, - "tiltspeed" | "tilt_speed" => ChannelType::TiltSpeed, - "beam" => ChannelType::Beam, - "focus" => ChannelType::Focus, - "zoom" => ChannelType::Zoom, - "function" => ChannelType::Function, - "functionspeed" | "function_speed" => ChannelType::FunctionSpeed, - "gobo_rotation" | "gobo_rot" => ChannelType::Other("gobo_rotation".to_string()), - "gobo_selection" | "gobo_sel" => ChannelType::Other("gobo_selection".to_string()), - _ => ChannelType::Other(channel.to_string()), - } - } - - /// Patch a fixture - pub async fn patch_fixture( - &mut self, - name: &str, - profile_name: &str, - universe: u8, - address: u16, - ) -> Result { - let profile = self - .fixture_library - .profiles - .get(profile_name) - .ok_or_else(|| format!("Profile {} not found", profile_name))?; - - let mut fixtures = self.fixtures.write().await; - // Find the next available ID by getting max ID + 1, or 0 if no fixtures exist - let id = fixtures - .iter() - .map(|f| f.id) - .max() - .map(|max| max + 1) - .unwrap_or(0); - - let fixture = Fixture { - id, - name: name.to_string(), - profile_id: profile.id.clone(), - profile: profile.clone(), - channels: profile.channel_layout.clone(), - universe, - start_address: address, - pan_tilt_limits: None, - }; - - fixtures.push(fixture); - Ok(id) - } - - /// Update an existing fixture - pub async fn update_fixture( - &mut self, - fixture_id: usize, - name: String, - universe: u8, - address: u16, - ) -> Result { - let mut fixtures = self.fixtures.write().await; - let fixture = fixtures - .iter_mut() - .find(|f| f.id == fixture_id) - .ok_or_else(|| format!("Fixture {fixture_id} not found"))?; - - fixture.name = name; - fixture.universe = universe; - fixture.start_address = address; - - Ok(fixture.clone()) - } - - /// Remove a fixture - pub async fn unpatch_fixture(&mut self, fixture_id: usize) -> Result<(), String> { - let mut fixtures = self.fixtures.write().await; - - // Find if fixture exists - if !fixtures.iter().any(|f| f.id == fixture_id) { - return Err(format!("Fixture {fixture_id} not found")); - } - - // Remove the fixture by ID - fixtures.retain(|f| f.id != fixture_id); - - log::info!("Unpatched fixture {fixture_id}"); - Ok(()) - } - - /// Set cue lists - pub async fn set_cue_lists(&self, cue_lists: Vec) { - let mut cue_manager = self.cue_manager.write().await; - cue_manager.set_cue_lists(cue_lists); - } - - /// Shutdown the async console - pub async fn shutdown(&mut self) -> Result<(), anyhow::Error> { - if !self.is_running { - return Ok(()); - } - - log::info!("Shutting down async lighting console..."); - - // Shutdown module manager - self.module_manager - .shutdown() - .await - .map_err(|e| anyhow::anyhow!("Module shutdown failed: {}", e))?; - - // Cancel message handler - if let Some(handle) = self.message_handler.take() { - handle.abort(); - } - - self.is_running = false; - log::info!("Async lighting console shutdown complete"); - Ok(()) - } - - pub fn is_running(&self) -> bool { - self.is_running - } - - /// Enable Ableton Link - pub async fn enable_ableton_link(&mut self) -> Result<(), anyhow::Error> { - { - let mut link_manager = self.link_manager.lock().await; - link_manager - .enable() - .await - .map_err(|e| anyhow::anyhow!("Failed to enable Ableton Link: {}", e))?; - - // Enable start/stop sync - link_manager - .enable_start_stop_sync(true) - .await - .map_err(|e| anyhow::anyhow!("Failed to enable start/stop sync: {}", e))?; - } - - log::info!("Ableton Link enabled and synchronized"); - Ok(()) - } - - /// Disable Ableton Link - pub async fn disable_ableton_link(&mut self) { - let mut link_manager = self.link_manager.lock().await; - link_manager.disable(); - log::info!("Ableton Link disabled"); - } - - /// Check if Ableton Link is enabled - pub async fn is_ableton_link_enabled(&self) -> bool { - let link_manager = self.link_manager.lock().await; - link_manager.is_enabled() - } - - /// Get the number of Ableton Link peers - pub async fn get_ableton_link_peers(&self) -> u64 { - let link_manager = self.link_manager.lock().await; - link_manager.num_peers() - } - - /// Set the BPM/tempo - pub async fn set_bpm(&mut self, bpm: f64) -> Result<(), anyhow::Error> { - // Set the tempo using ableton's boundary - let bounded_bpm = bpm.min(999.0).max(20.0); - self.tempo = bounded_bpm; - - // Update Ableton Link tempo if enabled - { - let link_manager = self.link_manager.lock().await; - if link_manager.is_enabled() { - drop(link_manager); // Release lock before async call - let mut link_manager = self.link_manager.lock().await; - if let Err(e) = link_manager.set_tempo(bounded_bpm).await { - log::warn!("Failed to set Ableton Link tempo: {}", e); - } - } - } - - Ok(()) - } - - /// Add a new MIDI override configuration - pub fn add_midi_override(&mut self, note: u8, override_config: MidiOverride) { - self.midi_overrides.insert(note, override_config); - self.active_overrides.insert(note, (false, 0)); - } - - /// Create a new show - pub async fn new_show(&mut self, name: String) -> Result<(), anyhow::Error> { - let _ = self.show_manager.write().await.new_show(name); - Ok(()) - } - - /// Reload the current show - pub async fn reload_show(&mut self) -> Result<(), anyhow::Error> { - let current_path = { - let show_manager = self.show_manager.read().await; - show_manager.get_current_path() - }; - if let Some(current_path) = current_path { - let _ = self.load_show(¤t_path).await; - } - Ok(()) - } - - /// Save the current show - pub async fn save_show(&mut self) -> Result { - let result = self - .show_manager - .write() - .await - .save_show(&self.get_show().await.clone())?; - Ok(result) - } - - /// Save the show with a new name and path - pub async fn save_show_as( - &mut self, - name: String, - path: std::path::PathBuf, - ) -> Result { - self.show_name = name; - let result = self - .show_manager - .write() - .await - .save_show_as(&self.get_show().await.clone(), path)?; - Ok(result) - } - - /// Load a show from a path - pub async fn load_show(&mut self, path: &std::path::Path) -> Result<(), anyhow::Error> { - // Validate that the file exists - if !path.exists() { - return Err(anyhow::anyhow!("Show file not found: {}", path.display())); - } - - // Load the show from the file - let show = self - .show_manager - .write() - .await - .load_show(path) - .map_err(|e| anyhow::anyhow!("Failed to load show file '{}': {}", path.display(), e))?; - - log::info!( - "Loaded show '{}' with {} fixtures and {} cue lists", - show.name, - show.fixtures.len(), - show.cue_lists.len() - ); - - // Clear current fixtures and cue lists before loading - { - let mut fixtures = self.fixtures.write().await; - fixtures.clear(); - } - - // Track missing profiles for better error reporting - let mut missing_profiles = Vec::new(); - - // For each fixture in the loaded show - for mut fixture in show.fixtures { - // Preserve the original fixture ID - let fixture_id = fixture.id; - let fixture_name = fixture.name.clone(); - let profile_id = fixture.profile_id.clone(); - - // Look up the profile by ID in the fixture library - if let Some(profile) = self.fixture_library.profiles.get(&profile_id) { - // Set the profile field with the one from the library - fixture.profile = profile.clone(); - fixture.channels = profile.channel_layout.clone(); - - // Ensure the fixture keeps its original ID to maintain cue references - fixture.id = fixture_id; - let mut fixtures = self.fixtures.write().await; - fixtures.push(fixture); - log::debug!( - "Loaded fixture '{}' with profile '{}'", - fixture_name, - profile_id - ); - } else { - missing_profiles.push(format!( - " - Fixture '{}' (ID: {}) requires profile '{}'", - fixture_name, fixture_id, profile_id - )); - } - } - - // If any profiles are missing, return a detailed error - if !missing_profiles.is_empty() { - return Err(anyhow::anyhow!( - "Failed to load show '{}': {} fixture profile(s) not found in library:\n{}", - path.display(), - missing_profiles.len(), - missing_profiles.join("\n") - )); - } - - // After all fixtures are loaded with their original IDs, set the cue lists - self.set_cue_lists(show.cue_lists).await; - self.show_name = show.name.clone(); - - log::info!("Successfully loaded show '{}'", show.name); - - // Enable sequential packing for pixel bars - { - let fixtures = self.fixtures.read().await; - let mut pixel_engine = self.pixel_engine.write().await; - pixel_engine.enable_sequential_packing(&fixtures); - } - - // Settings are now loaded separately from config file, not from show - - Ok(()) - } - - /// Get the current show - pub async fn get_show(&self) -> crate::show::show::Show { - let fixtures = self.fixtures.read().await; - let cue_lists = self.cue_manager.read().await.get_cue_lists().clone(); - let mut show = crate::show::show::Show::new(self.show_name.clone()); - show.fixtures = fixtures.clone(); - show.cue_lists = cue_lists; - show.modified_at = std::time::SystemTime::now(); - show - } - - /// Play audio file through audio module - pub async fn play_audio(&self, file_path: String) -> Result<(), anyhow::Error> { - self.module_manager - .send_to_module(ModuleId::Audio, ModuleEvent::AudioPlay { file_path }) - .await - .map_err(|e| anyhow::anyhow!(e))?; - Ok(()) - } - - /// Set audio volume - pub async fn set_audio_volume(&self, volume: f32) -> Result<(), anyhow::Error> { - self.module_manager - .send_to_module(ModuleId::Audio, ModuleEvent::AudioSetVolume(volume)) - .await - .map_err(|e| anyhow::anyhow!(e))?; - Ok(()) - } - - /// Process a command from the UI - pub async fn process_command( - &mut self, - command: ConsoleCommand, - event_tx: &mpsc::UnboundedSender, - ) -> Result<(), anyhow::Error> { - use ConsoleCommand::*; - - log::debug!("Processing command: {:?}", command); - - match command { - Initialize => { - log::info!("Processing Initialize command"); - self.initialize().await?; - let _ = event_tx.send(ConsoleEvent::Initialized); - } - Shutdown => { - log::info!("Processing Shutdown command"); - self.shutdown().await?; - let _ = event_tx.send(ConsoleEvent::ShutdownComplete); - } - Update => { - self.update().await?; - } - - // Show management - NewShow { name } => { - self.new_show(name.clone()).await?; - let _ = event_tx.send(ConsoleEvent::ShowCreated { name }); - } - LoadShow { path } => { - log::info!("Processing LoadShow command for path: {:?}", path); - match self.load_show(&path).await { - Ok(_) => { - let show = self.get_show().await; - let settings = self.settings.read().await.clone(); - let _ = event_tx.send(ConsoleEvent::ShowLoaded { show }); - let _ = event_tx.send(ConsoleEvent::CurrentSettings { settings }); - log::info!("LoadShow command completed successfully"); - } - Err(e) => { - let error_message = format!("Failed to load show: {}", e); - log::error!("{}", error_message); - let _ = event_tx.send(ConsoleEvent::Error { - message: error_message, - }); - } - } - } - SaveShow => { - let path = self.save_show().await?; - let _ = event_tx.send(ConsoleEvent::ShowSaved { path }); - } - SaveShowAs { name, path } => { - let saved_path = self.save_show_as(name, path).await?; - let _ = event_tx.send(ConsoleEvent::ShowSaved { path: saved_path }); - } - ReloadShow => match self.reload_show().await { - Ok(_) => { - let show = self.get_show().await; - let settings = self.settings.read().await.clone(); - let _ = event_tx.send(ConsoleEvent::ShowLoaded { show }); - let _ = event_tx.send(ConsoleEvent::CurrentSettings { settings }); - log::info!("ReloadShow command completed successfully"); - } - Err(e) => { - let error_message = format!("Failed to reload show: {}", e); - log::error!("{}", error_message); - let _ = event_tx.send(ConsoleEvent::Error { - message: error_message, - }); - } - }, - - // Fixture management - PatchFixture { - name, - profile_name, - universe, - address, - } => { - let fixture_id = self - .patch_fixture(&name, &profile_name, universe, address) - .await - .map_err(|e| anyhow::anyhow!(e))?; - let fixtures = self.fixtures.read().await; - if let Some(fixture) = fixtures.iter().find(|f| f.id == fixture_id) { - let _ = event_tx.send(ConsoleEvent::FixturePatched { - fixture_id, - fixture: fixture.clone(), - }); - } - } - UnpatchFixture { fixture_id } => match self.unpatch_fixture(fixture_id).await { - Ok(_) => { - let _ = event_tx.send(ConsoleEvent::FixtureUnpatched { fixture_id }); - } - Err(e) => { - log::error!("Failed to unpatch fixture: {e}"); - let _ = event_tx.send(ConsoleEvent::Error { - message: format!("Failed to unpatch fixture: {e}"), - }); - } - }, - UpdateFixture { - fixture_id, - name, - universe, - address, - } => { - let fixture = self - .update_fixture(fixture_id, name, universe, address) - .await - .map_err(|e| anyhow::anyhow!(e))?; - let _ = event_tx.send(ConsoleEvent::FixtureUpdated { - fixture_id, - fixture, - }); - } - UpdateFixtureChannels { - fixture_id, - channel_values, - } => { - // TODO: Implement fixture channel update - let _ = event_tx.send(ConsoleEvent::FixtureValuesChanged { - fixture_id, - values: channel_values, - }); - } - SetPanTiltLimits { - fixture_id, - pan_min, - pan_max, - tilt_min, - tilt_max, - } => { - let mut fixtures = self.fixtures.write().await; - if let Some(fixture) = fixtures.iter_mut().find(|f| f.id == fixture_id) { - fixture.set_pan_tilt_limits(halo_fixtures::PanTiltLimits { - pan_min, - pan_max, - tilt_min, - tilt_max, - }); - log::info!("Set pan/tilt limits for fixture {fixture_id}: pan({pan_min}-{pan_max}), tilt({tilt_min}-{tilt_max})"); - } - } - ClearPanTiltLimits { fixture_id } => { - let mut fixtures = self.fixtures.write().await; - if let Some(fixture) = fixtures.iter_mut().find(|f| f.id == fixture_id) { - fixture.clear_pan_tilt_limits(); - log::info!("Cleared pan/tilt limits for fixture {fixture_id}"); - } - } - - // Cue management - SetCueLists { cue_lists } => { - self.set_cue_lists(cue_lists.clone()).await; - let _ = event_tx.send(ConsoleEvent::CueListsUpdated { cue_lists }); - } - UpdateCue { - list_index, - cue_index, - name, - fade_time, - timecode, - is_blocking, - } => { - let result = self.cue_manager.write().await.update_cue( - list_index, - cue_index, - name, - fade_time, - timecode, - is_blocking, - ); - match result { - Ok(_) => { - let cue_lists = self.cue_manager.read().await.get_cue_lists(); - let _ = event_tx.send(ConsoleEvent::CueListsUpdated { cue_lists }); - } - Err(e) => { - let _ = event_tx.send(ConsoleEvent::Error { - message: format!("Failed to update cue: {}", e), - }); - } - } - } - DeleteCue { - list_index, - cue_index, - } => { - let result = self - .cue_manager - .write() - .await - .remove_cue(list_index, cue_index); - match result { - Ok(_) => { - let cue_lists = self.cue_manager.read().await.get_cue_lists(); - let _ = event_tx.send(ConsoleEvent::CueListsUpdated { cue_lists }); - } - Err(e) => { - let _ = event_tx.send(ConsoleEvent::Error { - message: format!("Failed to delete cue: {}", e), - }); - } - } - } - DeleteCueList { list_index } => { - let result = self.cue_manager.write().await.remove_cue_list(list_index); - match result { - Ok(_) => { - let cue_lists = self.cue_manager.read().await.get_cue_lists(); - let _ = event_tx.send(ConsoleEvent::CueListsUpdated { cue_lists }); - } - Err(e) => { - let _ = event_tx.send(ConsoleEvent::Error { - message: format!("Failed to delete cue list: {}", e), - }); - } - } - } - SetCueListAudioFile { - list_index, - audio_file, - } => { - let result = if let Some(file_path) = &audio_file { - self.cue_manager - .write() - .await - .set_audio_file(list_index, file_path.clone()) - } else { - // Clear the audio file - self.cue_manager - .write() - .await - .set_audio_file(list_index, String::new()) - }; - match result { - Ok(_) => { - let cue_lists = self.cue_manager.read().await.get_cue_lists(); - let _ = event_tx.send(ConsoleEvent::CueListsUpdated { cue_lists }); - } - Err(e) => { - let _ = event_tx.send(ConsoleEvent::Error { - message: format!("Failed to set audio file: {}", e), - }); - } - } - } - AddCue { - list_index, - name, - fade_time, - timecode, - is_blocking, - } => { - let cue = Cue { - id: 0, // Will be set by the cue manager - name, - fade_time: Duration::from_secs_f64(fade_time), - timecode, - static_values: Vec::new(), - effects: Vec::new(), - pixel_effects: Vec::new(), - is_blocking, - }; - let result = self.cue_manager.write().await.add_cue(list_index, cue); - match result { - Ok(_) => { - let cue_lists = self.cue_manager.read().await.get_cue_lists(); - let _ = event_tx.send(ConsoleEvent::CueListsUpdated { cue_lists }); - } - Err(e) => { - let _ = event_tx.send(ConsoleEvent::Error { - message: format!("Failed to add cue: {}", e), - }); - } - } - } - PlayCue { - list_index, - cue_index, - } => { - let _ = self - .cue_manager - .write() - .await - .go_to_cue(list_index, cue_index); - let _ = event_tx.send(ConsoleEvent::CueStarted { - list_index, - cue_index, - }); - } - StopCue { list_index } => { - let _ = self.cue_manager.write().await.stop(); - let _ = event_tx.send(ConsoleEvent::CueStopped { list_index }); - } - PauseCue { list_index: _ } => { - let _ = self.cue_manager.write().await.hold(); - let state = self.cue_manager.read().await.get_playback_state(); - let _ = event_tx.send(ConsoleEvent::PlaybackStateChanged { state }); - } - ResumeCue { list_index: _ } => { - let _ = self.cue_manager.write().await.go(); - let state = self.cue_manager.read().await.get_playback_state(); - let _ = event_tx.send(ConsoleEvent::PlaybackStateChanged { state }); - } - GoToCue { - list_index, - cue_index, - } => { - let _ = self - .cue_manager - .write() - .await - .go_to_cue(list_index, cue_index); - let _ = event_tx.send(ConsoleEvent::CueStarted { - list_index, - cue_index, - }); - // Send current cue update - let cue_manager = self.cue_manager.read().await; - let current_cue_index = cue_manager.get_current_cue_idx().unwrap_or(0); - let progress = cue_manager.get_current_cue_progress(); - let _ = event_tx.send(ConsoleEvent::CurrentCueChanged { - cue_index: current_cue_index, - progress, - }); - } - NextCue { list_index: _ } => { - let _ = self.cue_manager.write().await.go_to_next_cue(); - // Send current cue update - let cue_manager = self.cue_manager.read().await; - let cue_index = cue_manager.get_current_cue_idx().unwrap_or(0); - let progress = cue_manager.get_current_cue_progress(); - let _ = event_tx.send(ConsoleEvent::CurrentCueChanged { - cue_index, - progress, - }); - } - PrevCue { list_index: _ } => { - let _ = self.cue_manager.write().await.go_to_previous_cue(); - // Send current cue update - let cue_manager = self.cue_manager.read().await; - let cue_index = cue_manager.get_current_cue_idx().unwrap_or(0); - let progress = cue_manager.get_current_cue_progress(); - let _ = event_tx.send(ConsoleEvent::CurrentCueChanged { - cue_index, - progress, - }); - } - SelectNextCueList => { - let mut cue_manager = self.cue_manager.write().await; - if let Err(err) = cue_manager.select_next_cue_list() { - log::warn!("Error selecting next cue list: {}", err); - } else { - let current_index = cue_manager.get_current_cue_list_idx(); - let _ = event_tx.send(ConsoleEvent::CueListSelected { - list_index: current_index, - }); - } - } - SelectPreviousCueList => { - let mut cue_manager = self.cue_manager.write().await; - if let Err(err) = cue_manager.select_previous_cue_list() { - log::warn!("Error selecting previous cue list: {}", err); - } else { - let current_index = cue_manager.get_current_cue_list_idx(); - let _ = event_tx.send(ConsoleEvent::CueListSelected { - list_index: current_index, - }); - } - } - - // Playback control - Play => { - println!("Console received Play command"); - log::info!("Console received Play command"); - let _ = self.cue_manager.write().await.go(); - let state = self.cue_manager.read().await.get_playback_state(); - let _ = event_tx.send(ConsoleEvent::PlaybackStateChanged { state }); - - // Check if current cuelist has an audio file and play it - let cue_manager = self.cue_manager.read().await; - if let Some(current_cue_list) = cue_manager.get_current_cue_list() { - println!("Current cuelist: {}", current_cue_list.name); - log::info!("Current cuelist: {}", current_cue_list.name); - if let Some(audio_file) = ¤t_cue_list.audio_file { - println!("Found audio file for cuelist: {}", audio_file); - log::info!("Found audio file for cuelist: {}", audio_file); - - // Analyze waveform for timeline visualization - if let Ok(waveform_data) = - crate::audio::waveform::analyze_audio_file(audio_file) - { - let _ = event_tx.send(ConsoleEvent::WaveformAnalyzed { - waveform_data: waveform_data.clone(), - duration: waveform_data.duration_seconds, - bpm: waveform_data.bpm, - }); - log::info!("Waveform analysis completed for: {}", audio_file); - } else { - log::warn!("Failed to analyze waveform for: {}", audio_file); - } - - if let Err(e) = self - .module_manager - .send_to_module( - ModuleId::Audio, - ModuleEvent::AudioPlay { - file_path: audio_file.clone(), - }, - ) - .await - { - println!("ERROR: Failed to play audio file {}: {}", audio_file, e); - log::error!("Failed to play audio file {}: {}", audio_file, e); - } else { - println!("Successfully sent audio play command for: {}", audio_file); - log::info!("Successfully sent audio play command for: {}", audio_file); - } - } else { - println!( - "No audio file found for current cuelist: {}", - current_cue_list.name - ); - log::info!( - "No audio file found for current cuelist: {}", - current_cue_list.name - ); - } - } else { - println!("No current cuelist found"); - log::warn!("No current cuelist found"); - } - } - Stop => { - let _ = self.cue_manager.write().await.stop(); - let state = self.cue_manager.read().await.get_playback_state(); - let _ = event_tx.send(ConsoleEvent::PlaybackStateChanged { state }); - - // Clear tracking state when stopping - self.tracking_state.write().await.clear(); - - // Stop audio playback when stopping the cuelist - if let Err(e) = self - .module_manager - .send_to_module(ModuleId::Audio, ModuleEvent::AudioStop) - .await - { - log::error!("Failed to stop audio: {}", e); - } - } - Pause => { - let _ = self.cue_manager.write().await.hold(); - let state = self.cue_manager.read().await.get_playback_state(); - let _ = event_tx.send(ConsoleEvent::PlaybackStateChanged { state }); - - // Pause audio playback when pausing the cuelist - if let Err(e) = self - .module_manager - .send_to_module(ModuleId::Audio, ModuleEvent::AudioPause) - .await - { - log::error!("Failed to pause audio: {}", e); - } - } - Resume => { - let _ = self.cue_manager.write().await.go(); - let state = self.cue_manager.read().await.get_playback_state(); - let _ = event_tx.send(ConsoleEvent::PlaybackStateChanged { state }); - - // Resume audio playback when resuming the cuelist - if let Err(e) = self - .module_manager - .send_to_module(ModuleId::Audio, ModuleEvent::AudioResume) - .await - { - log::error!("Failed to resume audio: {}", e); - } - } - SetPlaybackRate { rate: _ } => { - // TODO: Implement playback rate control - } - - // Tempo and timing - SetBpm { bpm } => { - if let Err(e) = self.set_bpm(bpm).await { - log::error!("Failed to set BPM: {}", e); - } - let _ = event_tx.send(ConsoleEvent::BpmChanged { bpm: self.tempo }); - } - TapTempo => { - // TODO: Implement tap tempo - let bpm = self.tempo; - let _ = event_tx.send(ConsoleEvent::BpmChanged { bpm }); - } - SetTimecode { timecode } => { - self.cue_manager.write().await.current_timecode = Some(timecode); - let _ = event_tx.send(ConsoleEvent::TimecodeUpdated { timecode }); - } - SeekAudio { position_seconds } => { - // Send seek command to audio module - if let Err(e) = self - .module_manager - .send_to_module(ModuleId::Audio, ModuleEvent::AudioSeek { position_seconds }) - .await - { - log::error!("Failed to seek audio: {}", e); - let _ = event_tx.send(ConsoleEvent::Error { - message: format!("Failed to seek audio: {}", e), - }); - } else { - // Update cue manager timing to reflect new position - let mut cue_manager = self.cue_manager.write().await; - - // Update show elapsed time to the seek position - cue_manager.show_elapsed_time = position_seconds; - - // Adjust show start time so that elapsed time calculation reflects the new - // position - if cue_manager.show_start_time.is_some() { - let now = std::time::Instant::now(); - let adjusted_start_time = - now - std::time::Duration::from_secs_f64(position_seconds); - cue_manager.show_start_time = Some(adjusted_start_time); - } - - // Update timecode to reflect new position - let new_timecode = TimeCode::from_seconds(position_seconds, 30); - cue_manager.current_timecode = Some(new_timecode); - - // Check if we need to jump to a different cue based on the new timecode - if let Some(target_cue_idx) = cue_manager.find_cue_by_timecode(&new_timecode) { - if target_cue_idx != cue_manager.get_current_cue_index() { - if let Err(e) = cue_manager.jump_to_cue(target_cue_idx) { - log::warn!("Failed to jump to cue {}: {}", target_cue_idx, e); - } else { - log::info!("Seek triggered cue jump to cue {}", target_cue_idx); - } - } - } - - let _ = event_tx.send(ConsoleEvent::TimecodeUpdated { - timecode: new_timecode, - }); - } - } - - // MIDI - AddMidiOverride { - note, - override_config, - } => { - self.add_midi_override(note, override_config); - let _ = event_tx.send(ConsoleEvent::MidiOverrideAdded { note }); - } - RemoveMidiOverride { note } => { - self.midi_overrides.remove(¬e); - let _ = event_tx.send(ConsoleEvent::MidiOverrideRemoved { note }); - } - ProcessMidiMessage { message } => { - // TODO: Process MIDI message - let _ = event_tx.send(ConsoleEvent::MidiMessageReceived { message }); - } - - // Audio - PlayAudio { file_path } => { - self.play_audio(file_path.clone()).await?; - let _ = event_tx.send(ConsoleEvent::AudioStarted { file_path }); - } - StopAudio => { - // TODO: Implement stop_audio method - let _ = event_tx.send(ConsoleEvent::AudioStopped); - } - SetAudioVolume { volume } => { - self.set_audio_volume(volume).await?; - let _ = event_tx.send(ConsoleEvent::AudioVolumeChanged { volume }); - } - - // Effects - ApplyEffect { - fixture_ids: _, - channel_type: _, - effect_type: _, - frequency: _, - amplitude: _, - offset: _, - } => { - // TODO: Implement apply_effect method - } - ClearEffect { - fixture_ids: _, - channel_type: _, - } => { - // TODO: Implement clear_effect method - } - - // Programmer - SetProgrammerValue { - fixture_id, - channel, - value, - } => { - // Convert channel string to ChannelType - let channel_type = Self::channel_string_to_type(&channel); - self.programmer - .write() - .await - .add_value(fixture_id, channel_type, value); - - // Send updated programmer values to UI - let programmer = self.programmer.read().await; - let values: Vec<(usize, String, u8)> = programmer - .get_values() - .iter() - .map(|v| (v.fixture_id, v.channel_type.to_string(), v.value)) - .collect(); - drop(programmer); - - let _ = event_tx.send(ConsoleEvent::ProgrammerValuesUpdated { values }); - } - SetProgrammerPreviewMode { preview_mode } => { - self.programmer.write().await.set_preview_mode(preview_mode); - let programmer = self.programmer.read().await; - let selected_fixtures = programmer.get_selected_fixtures().clone(); - let _ = event_tx.send(ConsoleEvent::ProgrammerStateUpdated { - preview_mode: programmer.get_preview_mode(), - selected_fixtures, - }); - } - SetSelectedFixtures { fixture_ids } => { - self.programmer - .write() - .await - .set_selected_fixtures(fixture_ids.clone()); - let programmer = self.programmer.read().await; - let _ = event_tx.send(ConsoleEvent::ProgrammerStateUpdated { - preview_mode: programmer.get_preview_mode(), - selected_fixtures: fixture_ids, - }); - } - AddSelectedFixture { fixture_id } => { - self.programmer - .write() - .await - .add_selected_fixture(fixture_id); - let programmer = self.programmer.read().await; - let selected_fixtures = programmer.get_selected_fixtures().clone(); - let _ = event_tx.send(ConsoleEvent::ProgrammerStateUpdated { - preview_mode: programmer.get_preview_mode(), - selected_fixtures, - }); - } - RemoveSelectedFixture { fixture_id } => { - self.programmer - .write() - .await - .remove_selected_fixture(fixture_id); - let programmer = self.programmer.read().await; - let selected_fixtures = programmer.get_selected_fixtures().clone(); - let _ = event_tx.send(ConsoleEvent::ProgrammerStateUpdated { - preview_mode: programmer.get_preview_mode(), - selected_fixtures, - }); - } - ClearSelectedFixtures => { - self.programmer.write().await.clear_selected_fixtures(); - let programmer = self.programmer.read().await; - let _ = event_tx.send(ConsoleEvent::ProgrammerStateUpdated { - preview_mode: programmer.get_preview_mode(), - selected_fixtures: Vec::new(), - }); - } - ClearProgrammer => { - self.programmer.write().await.clear(); - - // Send empty programmer values to UI - let _ = event_tx.send(ConsoleEvent::ProgrammerValuesUpdated { values: Vec::new() }); - } - RecordProgrammerToCue { - cue_name, - list_index: _, - } => { - // TODO: Implement record_programmer_to_cue method - println!("Recording programmer to cue: {}", cue_name); - } - ApplyProgrammerEffect { - fixture_ids, - channel_types, - effect_type, - waveform, - interval, - ratio, - phase, - distribution, - step_value, - wave_offset, - } => { - // Convert string channel types to ChannelType enum - let channel_types_enum: Vec = channel_types - .iter() - .map(|s| Self::channel_string_to_type(s)) - .collect(); - - // Convert UI parameters to effect parameters - let interval_enum = match interval { - 0 => crate::Interval::Beat, - 1 => crate::Interval::Bar, - 2 => crate::Interval::Phrase, - _ => crate::Interval::Beat, - }; - - let distribution_enum = match distribution { - 0 => crate::EffectDistribution::All, - 1 => crate::EffectDistribution::Step(step_value.unwrap_or(1)), - 2 => crate::EffectDistribution::Wave(wave_offset.unwrap_or(0.0) as f64), - _ => crate::EffectDistribution::All, - }; - - // Create the effect - let effect = crate::Effect { - effect_type, - min: 0, - max: 255, - amplitude: 1.0, - frequency: 1.0, - offset: 0.0, - params: crate::EffectParams { - interval: interval_enum, - interval_ratio: ratio as f64, - phase: phase as f64, - }, - }; - - // Create effect mapping - let effect_mapping = crate::EffectMapping { - name: format!("Programmer_{}_{}", effect_type.as_str(), fixture_ids.len()), - effect, - fixture_ids, - channel_types: channel_types_enum, - distribution: distribution_enum, - release: crate::EffectRelease::Hold, - }; - - // Add to tracking state - let mut tracking_state = self.tracking_state.write().await; - tracking_state.add_effect(effect_mapping); - } - - // Query commands - QueryFixtures => { - let fixtures = self.fixtures.read().await.clone(); - let _ = event_tx.send(ConsoleEvent::FixturesList { fixtures }); - } - QueryCueLists => { - let cue_lists = self.cue_manager.read().await.get_cue_lists().clone(); - let _ = event_tx.send(ConsoleEvent::CueListsList { cue_lists }); - } - QueryCurrentCueListIndex => { - let index = self.cue_manager.read().await.get_current_cue_list_idx(); - let _ = event_tx.send(ConsoleEvent::CurrentCueListIndex { index }); - } - QueryCurrentCue => { - let cue_manager = self.cue_manager.read().await; - let cue_index = cue_manager.get_current_cue_idx().unwrap_or(0); - let progress = cue_manager.get_current_cue_progress(); - let _ = event_tx.send(ConsoleEvent::CurrentCue { - cue_index, - progress, - }); - } - QueryPlaybackState => { - let state = self.cue_manager.read().await.get_playback_state(); - let _ = event_tx.send(ConsoleEvent::CurrentPlaybackState { state }); - } - QueryRhythmState => { - let rhythm_guard = self.rhythm_state.read().await; - let state = RhythmState { - beat_phase: rhythm_guard.beat_phase, - bar_phase: rhythm_guard.bar_phase, - phrase_phase: rhythm_guard.phrase_phase, - beats_per_bar: rhythm_guard.beats_per_bar, - bars_per_phrase: rhythm_guard.bars_per_phrase, - last_tap_time: rhythm_guard.last_tap_time, - tap_count: rhythm_guard.tap_count, - }; - let _ = event_tx.send(ConsoleEvent::CurrentRhythmState { state }); - } - QueryShow => { - let show = self.get_show().await; - let _ = event_tx.send(ConsoleEvent::CurrentShow { show }); - } - QueryLinkState => { - let enabled = self.is_ableton_link_enabled().await; - let num_peers = self.get_ableton_link_peers().await; - let _ = event_tx.send(ConsoleEvent::LinkStateChanged { enabled, num_peers }); - } - QueryFixtureLibrary => { - let profiles: Vec<(String, String)> = self - .fixture_library - .profiles - .iter() - .map(|(id, profile)| (id.clone(), profile.to_string())) - .collect(); - let _ = event_tx.send(ConsoleEvent::FixtureLibraryList { profiles }); - } - EnableAbletonLink => { - if let Err(e) = self.enable_ableton_link().await { - let _ = event_tx.send(ConsoleEvent::Error { - message: format!("Failed to enable Ableton Link: {}", e), - }); - } else { - let enabled = self.is_ableton_link_enabled().await; - let num_peers = self.get_ableton_link_peers().await; - let _ = event_tx.send(ConsoleEvent::LinkStateChanged { enabled, num_peers }); - } - } - DisableAbletonLink => { - self.disable_ableton_link().await; - let enabled = self.is_ableton_link_enabled().await; - let num_peers = self.get_ableton_link_peers().await; - let _ = event_tx.send(ConsoleEvent::LinkStateChanged { enabled, num_peers }); - } - - // Settings management - UpdateSettings { settings } => { - log::info!("Updating settings"); - *self.settings.write().await = settings.clone(); - let _ = event_tx.send(ConsoleEvent::SettingsUpdated { settings }); - } - QuerySettings => { - let settings = self.settings.read().await.clone(); - let _ = event_tx.send(ConsoleEvent::CurrentSettings { settings }); - } - QueryAudioDevices => match device_enumerator::enumerate_audio_devices() { - Ok(devices) => { - log::info!("Found {} audio devices", devices.len()); - let _ = event_tx.send(ConsoleEvent::AudioDevicesList { devices }); - } - Err(e) => { - log::error!("Failed to enumerate audio devices: {}", e); - let _ = event_tx.send(ConsoleEvent::Error { - message: format!("Failed to enumerate audio devices: {e}"), - }); - } - }, - - // Pixel engine commands - ConfigurePixelEngine { - enabled, - universe_mapping, - } => { - log::info!("Configuring pixel engine: enabled={}", enabled); - let mut pixel_engine = self.pixel_engine.write().await; - pixel_engine.set_enabled(enabled); - pixel_engine.clear_universe_mappings(); - for (fixture_id, universe) in universe_mapping { - pixel_engine.set_fixture_universe(fixture_id, universe); - } - } - AddPixelEffect { - name, - fixture_ids, - effect, - distribution, - } => { - log::info!("Adding pixel effect: {}", name); - let mut pixel_engine = self.pixel_engine.write().await; - pixel_engine.add_effect(name, fixture_ids, effect, distribution); - } - RemovePixelEffect { name } => { - log::info!("Removing pixel effect: {}", name); - let mut pixel_engine = self.pixel_engine.write().await; - pixel_engine.remove_effect(&name); - } - ClearPixelEffects => { - log::info!("Clearing all pixel effects"); - let mut pixel_engine = self.pixel_engine.write().await; - pixel_engine.clear_effects(); - } - } - - Ok(()) - } - - /// Run the console with channel-based communication - pub async fn run_with_channels( - mut self, - mut command_rx: mpsc::UnboundedReceiver, - event_tx: mpsc::UnboundedSender, - ) -> Result<(), anyhow::Error> { - log::info!("Console run_with_channels starting..."); - - // Start the update loop - let mut update_interval = tokio::time::interval(std::time::Duration::from_millis(23)); // ~44Hz - log::info!("Starting console main loop..."); - - loop { - tokio::select! { - // Process commands from UI - Some(command) = command_rx.recv() => { - log::debug!("Received command: {:?}", command); - - if let ConsoleCommand::Shutdown = command { - log::info!("Received shutdown command"); - self.shutdown().await?; - let _ = event_tx.send(ConsoleEvent::ShutdownComplete); - break; - } - - if let Err(e) = self.process_command(command, &event_tx).await { - log::error!("Command processing error: {}", e); - let _ = event_tx.send(ConsoleEvent::Error { - message: format!("Command processing error: {}", e) - }); - } - } - - // Regular update tick - _ = update_interval.tick() => { - let pixel_data = match self.update().await { - Ok(data) => data, - Err(e) => { - log::error!("Update error: {}", e); - Vec::new() - } - }; - - // Always send pixel data update for smooth animation and proper clearing - let _ = event_tx.send(ConsoleEvent::PixelDataUpdated { pixel_data }); - - // Send periodic state updates - if let Some(timecode) = self.cue_manager.read().await.current_timecode { - let _ = event_tx.send(ConsoleEvent::TimecodeUpdated { timecode }); - } - - // Send current cue information - let cue_manager = self.cue_manager.read().await; - let cue_index = cue_manager.get_current_cue_idx().unwrap_or(0); - let progress = cue_manager.get_current_cue_progress(); - let _ = event_tx.send(ConsoleEvent::CurrentCueChanged { cue_index, progress }); - - let rhythm_guard = self.rhythm_state.read().await; - let rhythm_state = RhythmState { - beat_phase: rhythm_guard.beat_phase, - bar_phase: rhythm_guard.bar_phase, - phrase_phase: rhythm_guard.phrase_phase, - beats_per_bar: rhythm_guard.beats_per_bar, - bars_per_phrase: rhythm_guard.bars_per_phrase, - last_tap_time: rhythm_guard.last_tap_time, - tap_count: rhythm_guard.tap_count, - }; - let _ = event_tx.send(ConsoleEvent::RhythmStateUpdated { state: rhythm_state }); - - // Send tracking state information - let tracking_state = self.tracking_state.read().await; - let active_effect_count = tracking_state.active_effect_count(); - let _ = event_tx.send(ConsoleEvent::TrackingStateUpdated { active_effect_count }); - } - - // Process module messages (if available) - Some(message) = async { - if let Some(rx) = self.message_rx.as_mut() { - rx.recv().await - } else { - // Return a future that never resolves if no receiver - std::future::pending().await - } - } => { - match message { - ModuleMessage::Event(event) => { - match event { - ModuleEvent::MidiInput(midi_msg) => { - Self::handle_midi_input(midi_msg, &self.rhythm_state, &self.cue_manager).await; - } - _ => { - // Handle other inter-module events as needed - } - } - } - ModuleMessage::Status(status) => { - log::info!("Module status: {}", status); - } - ModuleMessage::Error(error) => { - log::error!("Module error: {}", error); - // Send error to UI - let _ = event_tx.send(ConsoleEvent::Error { message: error }); - } - } - } - } - } - - log::info!("Console run_with_channels completed"); - Ok(()) - } -} - -/// Synchronous wrapper around the async LightingConsole for UI compatibility -pub struct SyncLightingConsole { - inner: Arc>, - runtime: tokio::runtime::Runtime, -} - -impl SyncLightingConsole { - pub fn new(bpm: f64, network_config: NetworkConfig) -> Result { - let runtime = tokio::runtime::Runtime::new()?; - let inner = runtime.block_on(async { - let mut console = LightingConsole::new(bpm, network_config)?; - console.initialize().await?; - Ok::<_, anyhow::Error>(Arc::new(Mutex::new(console))) - })?; - - Ok(Self { inner, runtime }) - } - - pub fn load_fixture_library(&mut self) { - // This is a no-op in the async version as fixture library is loaded on demand - } - - pub fn patch_fixture( - &mut self, - name: &str, - profile_name: &str, - universe: u8, - address: u16, - ) -> Result { - self.runtime.block_on(async { - let mut console = self.inner.lock().await; - console - .patch_fixture(name, profile_name, universe, address) - .await - }) - } - - pub fn set_cue_lists(&mut self, cue_lists: Vec) { - self.runtime.block_on(async { - let console = self.inner.lock().await; - console.set_cue_lists(cue_lists).await; - }); - } - - pub fn set_bpm(&mut self, bpm: f64) { - self.runtime.block_on(async { - let mut console = self.inner.lock().await; - if let Err(e) = console.set_bpm(bpm).await { - log::error!("Failed to set BPM: {}", e); - } - }); - } - - pub fn add_midi_override(&mut self, note: u8, override_config: MidiOverride) { - self.runtime.block_on(async { - let mut console = self.inner.lock().await; - console.add_midi_override(note, override_config); - }); - } - - pub fn new_show(&mut self, name: String) -> Result<(), anyhow::Error> { - self.runtime.block_on(async { - let mut console = self.inner.lock().await; - console.new_show(name).await - }) - } - - pub fn reload_show(&mut self) -> Result<(), anyhow::Error> { - self.runtime.block_on(async { - let mut console = self.inner.lock().await; - console.reload_show().await - }) - } - - pub fn save_show(&mut self) -> Result { - self.runtime.block_on(async { - let mut console = self.inner.lock().await; - console.save_show().await - }) - } - - pub fn save_show_as( - &mut self, - name: String, - path: std::path::PathBuf, - ) -> Result { - self.runtime.block_on(async { - let mut console = self.inner.lock().await; - console.save_show_as(name, path).await - }) - } - - pub fn load_show(&mut self, path: &std::path::Path) -> Result<(), anyhow::Error> { - self.runtime.block_on(async { - let mut console = self.inner.lock().await; - console.load_show(path).await - }) - } - - pub fn get_show(&self) -> crate::show::show::Show { - self.runtime.block_on(async { - let console = self.inner.lock().await; - console.get_show().await - }) - } - - pub fn update(&mut self) { - self.runtime.block_on(async { - let mut console = self.inner.lock().await; - if let Err(e) = console.update().await { - log::error!("Error updating console: {}", e); - } - }); - } - - pub fn render(&self) { - // Rendering is handled internally by the async console - } - - // Getters for UI access - pub fn fixtures(&self) -> Vec { - self.runtime.block_on(async { - let console = self.inner.lock().await; - let fixtures = console.fixtures.read().await; - fixtures.clone() - }) - } - - pub fn cue_manager(&self) -> CueManager { - self.runtime.block_on(async { - let console = self.inner.lock().await; - let cue_manager = console.cue_manager.read().await; - cue_manager.clone() - }) - } - - pub fn show_manager(&self) -> ShowManager { - self.runtime.block_on(async { - let console = self.inner.lock().await; - let show_manager = console.show_manager.read().await; - show_manager.clone() - }) - } - - pub fn programmer(&self) -> Programmer { - self.runtime.block_on(async { - let console = self.inner.lock().await; - let programmer = console.programmer.read().await; - programmer.clone() - }) - } - - pub fn record_cue(&mut self, name: String, fade_time: f64) -> Result<(), anyhow::Error> { - self.runtime.block_on(async { - let console = self.inner.lock().await; - let programmer = console.programmer.read().await; - let values = programmer.get_values().clone(); - drop(programmer); - - let mut cue_manager = console.cue_manager.write().await; - // For now, add to the first cue list (index 0) - // TODO: Allow specifying which cue list to add to - if cue_manager.get_cue_lists().is_empty() { - cue_manager.add_cue_list(crate::CueList { - name: "Main".to_string(), - cues: vec![], - audio_file: None, - }); - } - - let cue = crate::Cue { - id: 0, // Will be assigned by the cue manager - name, - fade_time: std::time::Duration::from_secs_f64(fade_time), - static_values: values, - effects: vec![], - pixel_effects: vec![], - timecode: None, - is_blocking: false, - }; - - cue_manager - .add_cue(0, cue) - .map(|_| ()) - .map_err(|e| anyhow::anyhow!("Failed to add cue: {}", e)) - }) - } - - pub fn set_programmer_preview_mode(&mut self, preview_mode: bool) { - self.runtime.block_on(async { - let console = self.inner.lock().await; - let mut programmer = console.programmer.write().await; - programmer.set_preview_mode(preview_mode); - }); - } - - pub fn clear_programmer(&mut self) { - self.runtime.block_on(async { - let console = self.inner.lock().await; - let mut programmer = console.programmer.write().await; - programmer.clear(); - }); - } - - pub fn add_programmer_effect(&mut self, effect: crate::EffectMapping) { - self.runtime.block_on(async { - let console = self.inner.lock().await; - let mut programmer = console.programmer.write().await; - programmer.add_effect(effect); - }); - } - - pub fn get_programmer_values(&self) -> Vec { - self.runtime.block_on(async { - let console = self.inner.lock().await; - let programmer = console.programmer.read().await; - programmer.get_values().clone() - }) - } - - pub fn get_programmer_effects(&self) -> Vec { - self.runtime.block_on(async { - let console = self.inner.lock().await; - let programmer = console.programmer.read().await; - programmer.get_effects().clone() - }) - } - - pub fn is_running(&self) -> bool { - self.runtime.block_on(async { - let console = self.inner.lock().await; - console.is_running() - }) - } - - /// Enable Ableton Link - pub fn enable_ableton_link(&mut self) -> Result<(), anyhow::Error> { - self.runtime.block_on(async { - let mut console = self.inner.lock().await; - console.enable_ableton_link().await - }) - } - - /// Disable Ableton Link - pub fn disable_ableton_link(&mut self) { - self.runtime.block_on(async { - let mut console = self.inner.lock().await; - console.disable_ableton_link().await; - }); - } - - /// Check if Ableton Link is enabled - pub fn is_ableton_link_enabled(&self) -> bool { - self.runtime.block_on(async { - let console = self.inner.lock().await; - console.is_ableton_link_enabled().await - }) - } - - /// Get the number of Ableton Link peers - pub fn get_ableton_link_peers(&self) -> u64 { - self.runtime.block_on(async { - let console = self.inner.lock().await; - console.get_ableton_link_peers().await - }) - } - - /// Apply master fader to fixtures - pub fn apply_master_fader(&mut self, master_value: f32) { - self.runtime.block_on(async { - let console = self.inner.lock().await; - let mut fixtures = console.fixtures.write().await; - - for fixture in fixtures.iter_mut() { - for channel in &mut fixture.channels { - if let halo_fixtures::ChannelType::Dimmer = channel.channel_type { - // Scale the channel value by the master value - // Note: We apply the square of the fader value for a more natural feel - let scaled_value = (channel.value as f32 * master_value.powi(2)) as u8; - channel.value = scaled_value; - } - } - } - }); - } - - /// Apply smoke fader to fixtures - pub fn apply_smoke_fader(&mut self, smoke_value: f32) { - self.runtime.block_on(async { - let console = self.inner.lock().await; - let mut fixtures = console.fixtures.write().await; - - for fixture in fixtures.iter_mut() { - if fixture.name.to_lowercase().contains("smoke") { - for channel in &mut fixture.channels { - if let halo_fixtures::ChannelType::Other(ref name) = channel.channel_type { - if name == "Smoke" { - let scaled_value = - (channel.value as f32 * smoke_value.powi(2)) as u8; - channel.value = scaled_value; - } - } - } - } - } - }); - } -} - -impl Drop for SyncLightingConsole { - fn drop(&mut self) { - // Ensure module manager is properly shut down - self.runtime.block_on(async { - let mut console = self.inner.lock().await; - if let Err(e) = console.module_manager.shutdown().await { - log::error!("Error shutting down module manager during drop: {}", e); - } - }); - } -} diff --git a/crates/core/src/cue/cue.rs b/crates/core/src/cue/cue.rs deleted file mode 100644 index 39263a4..0000000 --- a/crates/core/src/cue/cue.rs +++ /dev/null @@ -1,122 +0,0 @@ -use std::time::Duration; - -use halo_fixtures::ChannelType; -use serde::{Deserialize, Serialize}; - -use crate::{Effect, EffectRelease, PixelEffect}; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct CueList { - pub name: String, - pub cues: Vec, - pub audio_file: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Cue { - pub id: usize, - pub name: String, - // Time to fade to the new values - pub fade_time: Duration, - // TODO - Wait before starting the fade - //pub delay_time: Duration, - pub static_values: Vec, - pub effects: Vec, - pub pixel_effects: Vec, - pub timecode: Option, - // A blocking cue prevents level changes from tracking through it and successive cues. - pub is_blocking: bool, -} - -impl Default for Cue { - fn default() -> Self { - Self { - id: 0, - name: "".to_string(), - fade_time: Duration::ZERO, - //delay_time: Duration::ZERO, - timecode: None, - static_values: vec![], - effects: vec![], - pixel_effects: vec![], - is_blocking: false, - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct StaticValue { - pub fixture_id: usize, - pub channel_type: ChannelType, - pub value: u8, -} - -#[derive(Clone, Debug, Serialize)] -pub struct EffectMapping { - pub name: String, - pub effect: Effect, - pub fixture_ids: Vec, - pub channel_types: Vec, - pub distribution: EffectDistribution, - #[serde(default)] - pub release: EffectRelease, -} - -impl<'de> Deserialize<'de> for EffectMapping { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - struct EffectMappingHelper { - name: String, - effect: Effect, - fixture_ids: Vec, - #[serde(flatten)] - channel_data: ChannelData, - distribution: EffectDistribution, - #[serde(default)] - release: EffectRelease, - } - - #[derive(Deserialize)] - #[serde(untagged)] - enum ChannelData { - Old { channel_type: ChannelType }, - New { channel_types: Vec }, - } - - let helper = EffectMappingHelper::deserialize(deserializer)?; - - let channel_types = match helper.channel_data { - ChannelData::Old { channel_type } => vec![channel_type], - ChannelData::New { channel_types } => channel_types, - }; - - Ok(EffectMapping { - name: helper.name, - effect: helper.effect, - fixture_ids: helper.fixture_ids, - channel_types, - distribution: helper.distribution, - release: helper.release, - }) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum EffectDistribution { - All, - Step(usize), - Wave(f64), // Phase offset between fixtures -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct PixelEffectMapping { - pub name: String, - pub effect: PixelEffect, - pub fixture_ids: Vec, - pub distribution: EffectDistribution, - #[serde(default)] - pub release: EffectRelease, -} diff --git a/crates/core/src/cue/cue_manager.rs b/crates/core/src/cue/cue_manager.rs deleted file mode 100644 index 5be3b72..0000000 --- a/crates/core/src/cue/cue_manager.rs +++ /dev/null @@ -1,516 +0,0 @@ -use std::time::{Duration, Instant}; - -use crate::{Cue, CueList, EffectMapping, PixelEffectMapping, StaticValue, TimeCode}; - -#[derive(Clone, Copy, PartialEq, Debug, Default)] -pub enum PlaybackState { - #[default] - Stopped, - Playing, - Holding, -} - -pub struct CueManager { - cue_lists: Vec, - current_cue_list: usize, - current_cue: usize, - playback_state: PlaybackState, - /// Show start time - pub show_start_time: Option, - /// Show elapsed time in seconds - pub show_elapsed_time: f64, - /// Current timecode - pub current_timecode: Option, - /// Current Cue start time reference point - current_cue_start_time: Option, - /// Current elapsed time in seconds - current_cue_elapsed_time: f64, - /// Last update time - pub last_update: Instant, - /// Original start time marker for resume - original_start_time: Option, - /// Current cue progress - progress: f32, - // audio_player: Option, // Removed - using audio module instead -} - -impl CueManager { - pub fn new(cue_lists: Vec) -> Self { - CueManager { - cue_lists, - current_cue_list: 0, - current_cue: 0, - playback_state: PlaybackState::Stopped, - show_start_time: None, - show_elapsed_time: 0.0, - current_timecode: None, - current_cue_start_time: None, - current_cue_elapsed_time: 0.0, - last_update: Instant::now(), - original_start_time: None, - progress: 0.0, - } - } - - pub fn update(&mut self) { - if self.playback_state != PlaybackState::Playing { - return; - } - - let now = Instant::now(); - - // Show Elapsed Time - if let Some(show_start_time) = self.show_start_time { - self.show_elapsed_time = show_start_time.elapsed().as_secs_f64(); - } - - // Cue Elapsed Time - if let Some(cue_start_time) = self.current_cue_start_time { - self.current_cue_elapsed_time = cue_start_time.elapsed().as_secs_f64(); - } - - self.update_timecode(); - - // Check if we need to advance to the next cue based on timecode - if let Some(current_tc) = &self.current_timecode { - if let Some((next_cue_idx, next_cue_tc)) = self.get_next_timecode_cue() { - // If current time has reached or passed the next cue's timecode - if current_tc.to_seconds() >= next_cue_tc.to_seconds() { - // Go to the specific cue - let _ = self.go_to_cue(self.current_cue_list, next_cue_idx); - } - } - } - - // Calculate cue progress for visual feedback - if let Some(current_cue) = self.get_current_cue() { - if current_cue.fade_time.as_secs_f64() > 0.0 { - self.progress = (self.current_cue_elapsed_time - / current_cue.fade_time.as_secs_f64()) - .min(1.0) as f32; - } else { - self.progress = 1.0; - } - } - - self.last_update = now; - } - - pub fn update_timecode(&mut self) { - // Using 30fps as default - self.current_timecode = Some(TimeCode::from_seconds(self.show_elapsed_time, 30)); - } - - pub fn set_cue_lists(&mut self, cue_lists: Vec) { - self.cue_lists = cue_lists; - } - - pub fn add_cue_list(&mut self, cue_list: CueList) -> usize { - self.cue_lists.push(cue_list); - self.cue_lists.len() - 1 // Return the index of the new cue list - } - - pub fn get_cue_lists(&self) -> Vec { - self.cue_lists.clone() - } - - pub fn get_cue_list(&self, index: usize) -> Option<&CueList> { - self.cue_lists.get(index) - } - - pub fn get_cue_list_mut(&mut self, index: usize) -> Option<&mut CueList> { - self.cue_lists.get_mut(index) - } - - pub fn get_current_cue_list(&self) -> Option<&CueList> { - self.cue_lists.get(self.current_cue_list) - } - - pub fn get_current_cue_list_idx(&self) -> usize { - self.current_cue_list - } - - pub fn remove_cue_list(&mut self, index: usize) -> Result { - if index < self.cue_lists.len() { - Ok(self.cue_lists.remove(index)) - } else { - Err("Cue list index out of bounds".to_string()) - } - } - - pub fn set_audio_file(&mut self, cue_list_idx: usize, path: String) -> Result<(), String> { - if let Some(cue_list) = self.cue_lists.get_mut(cue_list_idx) { - cue_list.audio_file = Some(path.clone()); - Ok(()) - } else { - Err("Invalid cue list index".to_string()) - } - } - - // Cue Management - pub fn add_cue(&mut self, cue_list_idx: usize, cue: Cue) -> Result { - if cue_list_idx >= self.cue_lists.len() { - return Err("Invalid cue list index".to_string()); - } - - self.cue_lists[cue_list_idx].cues.push(cue); - let cue_idx = self.cue_lists[cue_list_idx].cues.len() - 1; - - Ok(cue_idx) - } - - pub fn get_cue(&self, cue_idx: usize) -> Option<&Cue> { - self.cue_lists[self.current_cue_list].cues.get(cue_idx) - } - - pub fn get_current_cue_idx(&self) -> Option { - if self.current_cue_list >= self.cue_lists.len() { - return None; - } - - Some(self.current_cue) - } - - pub fn is_cue_active(&self, cue_id: usize) -> bool { - self.cue_lists[self.current_cue_list].cues[self.current_cue].id == cue_id - } - - pub fn get_current_cue_progress(&self) -> f32 { - self.progress - } - - pub fn get_cue_mut(&mut self, cue_idx: usize) -> Option<&mut Cue> { - self.cue_lists[self.current_cue_list].cues.get_mut(cue_idx) - } - - pub fn update_cue( - &mut self, - cue_list_idx: usize, - cue_idx: usize, - name: String, - fade_time: f64, - timecode: Option, - is_blocking: bool, - ) -> Result<(), String> { - if cue_list_idx >= self.cue_lists.len() { - return Err("Invalid cue list index".to_string()); - } - - let cue_list = &mut self.cue_lists[cue_list_idx]; - if let Some(cue) = cue_list.cues.get_mut(cue_idx) { - cue.name = name; - cue.fade_time = Duration::from_secs_f64(fade_time); - cue.timecode = timecode; - cue.is_blocking = is_blocking; - Ok(()) - } else { - Err("Invalid cue index".to_string()) - } - } - - pub fn remove_cue(&mut self, cue_list_idx: usize, cue_idx: usize) -> Result<(), String> { - if cue_list_idx >= self.cue_lists.len() { - return Err("Invalid cue list index".to_string()); - } - - // Remove the cue index from the cue list - let cue_list = &mut self.cue_lists[cue_list_idx]; - if cue_idx < cue_list.cues.len() { - cue_list.cues.remove(cue_idx); - Ok(()) - } else { - Err("Invalid cue index".to_string()) - } - } - - // Cue Playback Control - - /// Selects the previous cue list if available - pub fn select_previous_cue_list(&mut self) -> Result<(), String> { - if self.current_cue_list > 0 { - self.current_cue_list = self.current_cue_list - 1; - Ok(()) - } else if !self.cue_lists.is_empty() { - // Wrap around to the last cue list - self.current_cue_list = self.cue_lists.len() - 1; - Ok(()) - } else { - Err("No cue lists available".to_string()) - } - } - - /// Selects the next cue list if available - pub fn select_next_cue_list(&mut self) -> Result<(), String> { - if self.current_cue_list + 1 < self.cue_lists.len() { - self.current_cue_list = self.current_cue_list + 1; - Ok(()) - } else if !self.cue_lists.is_empty() { - // Wrap around to the first cue list - self.current_cue_list = 0; - Ok(()) - } else { - Err("No cue lists available".to_string()) - } - } - - // Gets the next cue with a timecode after the current cue - fn get_next_timecode_cue(&self) -> Option<(usize, TimeCode)> { - if self.current_cue_list >= self.cue_lists.len() { - return None; - } - - let cue_list = &self.cue_lists[self.current_cue_list]; - - // Look for the next cue with a timecode after the current cue - for (index, cue) in cue_list.cues.iter().enumerate().skip(self.current_cue + 1) { - if let Some(tc_str) = &cue.timecode { - let mut parsed_tc = TimeCode::default(); - if parsed_tc.from_string(tc_str).is_ok() { - return Some((index, parsed_tc)); - } - } - } - - None - } - - pub fn go(&mut self) -> Result<&Cue, String> { - // Audio playback is now handled by the audio module - self.go_to_next_cue() - } - - pub fn hold(&mut self) -> Result<&Cue, String> { - self.playback_state = PlaybackState::Holding; - self.get_current_cue() - .ok_or_else(|| "No current cue".to_string()) - } - - pub fn stop(&mut self) -> Result<&Cue, String> { - // Audio stop is now handled by the audio module - self.playback_state = PlaybackState::Stopped; - self.progress = 0.0; - self.show_elapsed_time = 0.0; - self.current_cue_elapsed_time = 0.0; - self.current_cue_start_time = None; - self.original_start_time = None; - self.current_cue = 0; - self.update_timecode(); - self.get_current_cue() - .ok_or_else(|| "No current cue".to_string()) - } - - pub fn go_to_next_cue(&mut self) -> Result<&Cue, String> { - if self.current_cue_list >= self.cue_lists.len() { - return Err("Invalid cue list index".to_string()); - } - - let cue_list = &self.cue_lists[self.current_cue_list]; - if self.current_cue + 1 >= cue_list.cues.len() { - return Err("No next cue".to_string()); - } - - self.progress = 0.0; - self.current_cue += 1; - self.show_start_time = Some(Instant::now()); - self.current_cue_start_time = Some(Instant::now()); - self.original_start_time = self.current_cue_start_time; - self.last_update = Instant::now(); - self.playback_state = PlaybackState::Playing; - - self.get_current_cue() - .ok_or_else(|| "No current cue".to_string()) - } - - pub fn go_to_previous_cue(&mut self) -> Result<&Cue, String> { - if self.current_cue_list >= self.cue_lists.len() { - return Err("Invalid cue list index".to_string()); - } - - let cue_list = &self.cue_lists[self.current_cue_list]; - if cue_list.cues.is_empty() { - return Err("No previous cue".to_string()); - } - - if self.current_cue > 0 { - self.current_cue -= 1; - self.playback_state = PlaybackState::Playing; - self.get_current_cue() - .ok_or_else(|| "No current cue".to_string()) - } else { - Err("Already at first cue".to_string()) - } - } - - pub fn go_to_cue(&mut self, cue_list_idx: usize, cue_idx: usize) -> Result<&Cue, String> { - if cue_list_idx >= self.cue_lists.len() { - return Err("Invalid cue list index".to_string()); - } - - let cue_list = &self.cue_lists[cue_list_idx]; - if cue_idx >= cue_list.cues.len() { - return Err("Invalid cue index".to_string()); - } - - self.current_cue_list = cue_list_idx; - self.current_cue = cue_idx; - self.current_cue_start_time = Some(Instant::now()); - self.original_start_time = self.current_cue_start_time; - self.last_update = Instant::now(); - self.playback_state = PlaybackState::Playing; - - self.get_current_cue() - .ok_or_else(|| "No current cue".to_string()) - } - - pub fn get_current_cue(&self) -> Option<&Cue> { - if self.current_cue_list >= self.cue_lists.len() { - return None; - } - - let cue_list = &self.cue_lists[self.current_cue_list]; - if self.current_cue >= cue_list.cues.len() { - return None; - } - - cue_list.cues.get(self.current_cue) - } - - pub fn get_current_cues(&self) -> Vec<&Cue> { - if self.current_cue_list >= self.cue_lists.len() { - return vec![]; - } - - let cue_list = &self.cue_lists[self.current_cue_list]; - if self.current_cue >= cue_list.cues.len() { - return vec![]; - } - - cue_list.cues.iter().collect() - } - - pub fn get_next_cue_id(&self) -> Option { - let cue_list = self.get_current_cue_list()?; - - if cue_list.cues.is_empty() { - return Some(1); - } - - // Find the maximum ID in the current cue list - let max_id = cue_list.cues.iter().map(|cue| cue.id).max().unwrap_or(0); - - Some(max_id + 1) - } - - pub fn get_playback_state(&self) -> PlaybackState { - self.playback_state - } - - // Audio Playback Control - now handled by audio module - - // Cue Management - - pub fn record( - &mut self, - cue_name: String, - cue_list_idx: usize, - fade_time: f32, - values: Vec, - effects: Vec, - pixel_effects: Vec, - ) { - if let Some(id) = self.get_next_cue_id() { - self.cue_lists[cue_list_idx].cues.push(Cue { - id, - name: cue_name, - fade_time: Duration::from_secs_f32(fade_time), - static_values: values, - effects, - pixel_effects, - timecode: None, - is_blocking: false, - }); - } - } - - /// Find the appropriate cue index based on a timecode position - /// Returns the index of the cue that should be active at the given timecode - pub fn find_cue_by_timecode(&self, timecode: &TimeCode) -> Option { - let cue_list = self.get_current_cue_list()?; - let target_seconds = timecode.to_seconds(); - - // Find the last cue whose timecode is <= the target timecode - let mut best_cue_idx = None; - let mut best_timecode_seconds = 0.0; - - for (idx, cue) in cue_list.cues.iter().enumerate() { - if let Some(cue_timecode_str) = &cue.timecode { - let mut cue_timecode = TimeCode::default(); - if cue_timecode.from_string(cue_timecode_str).is_ok() { - let cue_seconds = cue_timecode.to_seconds(); - if cue_seconds <= target_seconds && cue_seconds >= best_timecode_seconds { - best_cue_idx = Some(idx); - best_timecode_seconds = cue_seconds; - } - } - } - } - - best_cue_idx - } - - /// Jump to a specific cue by index - pub fn jump_to_cue(&mut self, cue_index: usize) -> Result<(), String> { - // Check bounds first - if self.current_cue_list >= self.cue_lists.len() { - return Err("No current cue list".to_string()); - } - - let cue_list = &self.cue_lists[self.current_cue_list]; - if cue_index >= cue_list.cues.len() { - return Err(format!( - "Cue index {} out of range (max: {})", - cue_index, - cue_list.cues.len() - 1 - )); - } - - // Update current cue index - self.current_cue = cue_index; - - // Reset cue timing - self.current_cue_start_time = Some(Instant::now()); - self.current_cue_elapsed_time = 0.0; - self.progress = 0.0; - - log::info!( - "Jumped to cue {}: {}", - cue_index, - cue_list.cues[cue_index].name - ); - Ok(()) - } - - /// Get the current cue index (public accessor) - pub fn get_current_cue_index(&self) -> usize { - self.current_cue - } -} - -impl Clone for CueManager { - fn clone(&self) -> Self { - Self { - cue_lists: self.cue_lists.clone(), - current_cue_list: self.current_cue_list, - current_cue: self.current_cue, - playback_state: self.playback_state, - show_start_time: self.show_start_time, - show_elapsed_time: self.show_elapsed_time, - current_timecode: self.current_timecode.clone(), - current_cue_start_time: self.current_cue_start_time, - current_cue_elapsed_time: self.current_cue_elapsed_time, - last_update: self.last_update, - original_start_time: self.original_start_time, - progress: self.progress, - } - } -} diff --git a/crates/core/src/cue/cue_resolver.rs b/crates/core/src/cue/cue_resolver.rs deleted file mode 100644 index dac8db9..0000000 --- a/crates/core/src/cue/cue_resolver.rs +++ /dev/null @@ -1,227 +0,0 @@ -use halo_fixtures::ChannelType; - -use crate::{ - Cue, EffectDistribution, EffectMapping, FixtureGroup, PixelEffectMapping, Preset, - PresetLibrary, StaticValue, -}; - -/// Resolves cue preset references into concrete static values and effects -pub struct CueResolver<'a> { - preset_library: &'a PresetLibrary, - fixture_groups: &'a [FixtureGroup], -} - -impl<'a> CueResolver<'a> { - pub fn new(preset_library: &'a PresetLibrary, fixture_groups: &'a [FixtureGroup]) -> Self { - Self { - preset_library, - fixture_groups, - } - } - - /// Resolve all preset references in a cue to static values and effects - pub fn resolve_cue(&self, cue: &Cue) -> ResolvedCue { - let mut static_values = Vec::new(); - let mut effects = Vec::new(); - let mut pixel_effects = Vec::new(); - - // Process each preset reference - for preset_ref in &cue.preset_references { - if let Some(preset) = self - .preset_library - .get_preset(&preset_ref.preset_type, preset_ref.preset_id) - { - let resolved = self.resolve_preset_reference(preset_ref, &preset); - static_values.extend(resolved.static_values); - effects.extend(resolved.effects); - pixel_effects.extend(resolved.pixel_effects); - } - } - - // Add direct static values (these take precedence over preset values) - static_values.extend(cue.static_values.clone()); - - // Add direct effects - effects.extend(cue.effects.clone()); - - // Add direct pixel effects - pixel_effects.extend(cue.pixel_effects.clone()); - - // Deduplicate static values - last write wins for same fixture/channel - static_values = Self::deduplicate_static_values(static_values); - - ResolvedCue { - static_values, - effects, - pixel_effects, - } - } - - /// Resolve a single preset reference - fn resolve_preset_reference( - &self, - preset_ref: &crate::cue::cue::PresetReference, - preset: &Preset, - ) -> ResolvedCue { - let mut static_values = Vec::new(); - let mut effects = Vec::new(); - let mut pixel_effects = Vec::new(); - - // Get the fixtures to apply this preset to - let target_fixtures = self.get_target_fixtures(preset, preset_ref.fixture_group_id); - - // Resolve based on preset type - match preset { - Preset::Color(color_preset) => { - for fixture_id in &target_fixtures { - for color_value in &color_preset.values { - static_values.push(StaticValue { - fixture_id: *fixture_id, - channel_type: color_value.channel_type.clone(), - value: color_value.value, - }); - } - } - } - Preset::Position(pos_preset) => { - for fixture_id in &target_fixtures { - if let Some(pan) = pos_preset.pan { - static_values.push(StaticValue { - fixture_id: *fixture_id, - channel_type: ChannelType::Pan, - value: pan, - }); - } - if let Some(tilt) = pos_preset.tilt { - static_values.push(StaticValue { - fixture_id: *fixture_id, - channel_type: ChannelType::Tilt, - value: tilt, - }); - } - } - } - Preset::Intensity(intensity_preset) => { - for fixture_id in &target_fixtures { - static_values.push(StaticValue { - fixture_id: *fixture_id, - channel_type: ChannelType::Dimmer, - value: intensity_preset.dimmer, - }); - } - } - Preset::Beam(beam_preset) => { - for fixture_id in &target_fixtures { - for beam_value in &beam_preset.values { - static_values.push(StaticValue { - fixture_id: *fixture_id, - channel_type: beam_value.channel_type.clone(), - value: beam_value.value, - }); - } - } - } - Preset::Effect(effect_preset) => { - // For effect presets, create effect mappings for target fixtures - match &effect_preset.effect { - crate::preset::preset::EffectPresetType::Standard(effect) => { - // Get all relevant channel types from the effect - // For now, we'll apply to Dimmer as a default - // This could be expanded based on effect configuration - effects.push(EffectMapping { - name: format!("Preset: {}", effect_preset.name), - effect: effect.clone(), - fixture_ids: target_fixtures.clone(), - channel_types: vec![ChannelType::Dimmer], - distribution: EffectDistribution::All, - release: crate::EffectRelease::Hold, - }); - } - crate::preset::preset::EffectPresetType::Pixel(pixel_effect) => { - pixel_effects.push(PixelEffectMapping { - name: format!("Preset: {}", effect_preset.name), - effect: pixel_effect.clone(), - fixture_ids: target_fixtures.clone(), - distribution: EffectDistribution::All, - release: crate::EffectRelease::Hold, - }); - } - } - } - } - - // Apply overrides - for override_val in &preset_ref.overrides { - // Find and replace the static value for this fixture/channel - if let Some(existing) = static_values.iter_mut().find(|sv| { - sv.fixture_id == override_val.fixture_id - && sv.channel_type == override_val.channel_type - }) { - existing.value = override_val.value; - } else { - // Add the override if it doesn't exist - static_values.push(override_val.clone()); - } - } - - ResolvedCue { - static_values, - effects, - pixel_effects, - } - } - - /// Get the target fixtures for a preset, considering fixture groups and optional restrictions - fn get_target_fixtures(&self, preset: &Preset, filter_group_id: Option) -> Vec { - let mut fixtures = Vec::new(); - - let preset_groups = preset.fixture_groups(); - - for &group_id in preset_groups { - // If filter_group_id is specified and doesn't match, skip this group - if let Some(filter_id) = filter_group_id { - if filter_id != group_id { - continue; - } - } - - // Find the fixture group and add its fixtures - if let Some(group) = self.fixture_groups.iter().find(|g| g.id == group_id) { - fixtures.extend_from_slice(&group.fixture_ids); - } - } - - // Deduplicate fixtures - fixtures.sort_unstable(); - fixtures.dedup(); - fixtures - } - - /// Deduplicate static values - last write wins for the same fixture/channel combination - fn deduplicate_static_values(values: Vec) -> Vec { - let mut result = Vec::new(); - - for value in values { - // Find if we already have this fixture/channel combination - if let Some(existing) = result.iter_mut().find(|v: &&mut StaticValue| { - v.fixture_id == value.fixture_id && v.channel_type == value.channel_type - }) { - // Update the existing value (last write wins) - existing.value = value.value; - } else { - // Add new value - result.push(value); - } - } - - result - } -} - -/// A cue with all preset references resolved to concrete values -#[derive(Clone, Debug)] -pub struct ResolvedCue { - pub static_values: Vec, - pub effects: Vec, - pub pixel_effects: Vec, -} diff --git a/crates/core/src/cue/mod.rs b/crates/core/src/cue/mod.rs deleted file mode 100644 index f186b1b..0000000 --- a/crates/core/src/cue/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod cue; -pub mod cue_manager; diff --git a/crates/core/src/dmx.rs b/crates/core/src/dmx.rs deleted file mode 100644 index 51b77c1..0000000 --- a/crates/core/src/dmx.rs +++ /dev/null @@ -1,3 +0,0 @@ -trait DMXWriter { - fn write_dmx(&self, data: &[u8]); -} diff --git a/crates/core/src/effect/effect.rs b/crates/core/src/effect/effect.rs deleted file mode 100644 index cd09dd7..0000000 --- a/crates/core/src/effect/effect.rs +++ /dev/null @@ -1,139 +0,0 @@ -use std::f64::consts::PI; -use std::time::Duration; - -use serde::{Deserialize, Serialize}; - -use crate::{Interval, RhythmState}; - -/// Effect release behavior - controls what happens to effects when cues change -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum EffectRelease { - /// Continue running indefinitely (default for tracking consoles) - Hold, - /// Remove when cue changes - Remove, - /// Fade out over time (future enhancement) - FadeOut(Duration), -} - -impl Default for EffectRelease { - fn default() -> Self { - EffectRelease::Hold - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Effect { - pub effect_type: EffectType, - pub min: u8, - pub max: u8, - pub amplitude: f32, - pub frequency: f32, - pub offset: f32, - pub params: EffectParams, - // pub value: f64, - // pub loop: bool, - // pub paused: bool, -} - -impl Effect { - // Takes a phase (0.0 to 1.0) and returns a value (0.0 to 1.0) - pub fn apply(&self, phase: f64) -> f64 { - // Apply based on the effect type - let apply_fn = match self.effect_type { - EffectType::Sine => sine_effect, - EffectType::Square => square_effect, - EffectType::Sawtooth => sawtooth_effect, - EffectType::Triangle => |phase| { - if phase < 0.5 { - phase * 2.0 - } else { - 2.0 - phase * 2.0 - } - }, - _ => sine_effect, // Default - }; - (apply_fn)(phase) - } -} - -impl Default for Effect { - fn default() -> Self { - Self { - effect_type: EffectType::Sine, - min: 0, - max: 255, - amplitude: 1.0, - frequency: 1.0, - offset: 0.0, - params: EffectParams::default(), - } - } -} - -// Effect types -#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy, Serialize, Deserialize)] -pub enum EffectType { - Sine, - Sawtooth, - Square, - Triangle, - Pulse, - Random, -} - -impl EffectType { - pub fn as_str(&self) -> &'static str { - match self { - EffectType::Sine => "Sine", - EffectType::Sawtooth => "Sawtooth", - EffectType::Square => "Square", - EffectType::Triangle => "Triangle", - EffectType::Pulse => "Pulse", - EffectType::Random => "Random", - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct EffectParams { - pub interval: Interval, - pub interval_ratio: f64, - pub phase: f64, -} - -impl Default for EffectParams { - fn default() -> Self { - EffectParams { - interval: Interval::Beat, - interval_ratio: 1.0, - phase: 0.0, - } - } -} - -pub fn get_effect_phase(rhythm: &RhythmState, params: &EffectParams) -> f64 { - let base_phase = match params.interval { - Interval::Beat => rhythm.beat_phase, - Interval::Bar => rhythm.bar_phase, - Interval::Phrase => rhythm.phrase_phase, - }; - - (base_phase * params.interval_ratio + params.phase) % 1.0 -} - -pub fn sine_effect(phase: f64) -> f64 { - (phase * 2.0 * PI).sin() * 0.5 + 0.5 -} - -pub fn square_effect(phase: f64) -> f64 { - if phase < 0.5 { - 1.0 - } else { - 0.0 - } -} - -pub fn sawtooth_effect(phase: f64) -> f64 { - phase -} diff --git a/crates/core/src/effect/mod.rs b/crates/core/src/effect/mod.rs deleted file mode 100644 index cc2a9a6..0000000 --- a/crates/core/src/effect/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub(crate) mod effect; - -pub use effect::EffectRelease; diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs deleted file mode 100644 index c7faca5..0000000 --- a/crates/core/src/lib.rs +++ /dev/null @@ -1,46 +0,0 @@ -pub use ableton_link::AbletonLinkManager; -pub use artnet::artnet::ArtNetMode; -pub use artnet::network_config::{ArtNetDestination, NetworkConfig}; -pub use audio::audio_player::AudioPlayer; -pub use audio::device_enumerator::{enumerate_audio_devices, AudioDeviceInfo}; -pub use config::{ConfigError, ConfigManager, ConfigSchema}; -pub use console::{LightingConsole, SyncLightingConsole}; -pub use cue::cue::{ - Cue, CueList, EffectDistribution, EffectMapping, PixelEffectMapping, StaticValue, -}; -pub use cue::cue_manager::{CueManager, PlaybackState}; -pub use effect::effect::{ - sawtooth_effect, sine_effect, square_effect, Effect, EffectParams, EffectType, -}; -pub use effect::EffectRelease; -pub use messages::{ConsoleCommand, ConsoleEvent, Settings}; -pub use midi::midi::{MidiAction, MidiMessage, MidiOverride}; -// Async module system exports -pub use modules::{ - AsyncModule, AudioModule, DmxModule, MidiModule, ModuleEvent, ModuleId, ModuleManager, - ModuleMessage, SmpteModule, -}; -pub use pixel::{PixelEffect, PixelEffectParams, PixelEffectScope, PixelEffectType, PixelEngine}; -pub use rhythm::rhythm::{Interval, RhythmState}; -pub use show::show::Show; -pub use show::show_manager::ShowManager; -pub use timecode::timecode::TimeCode; -pub use tracking_state::TrackingState; - -mod ableton_link; -mod artnet; -pub mod audio; -mod config; -mod console; - -mod cue; -mod effect; -pub mod messages; -mod midi; -mod modules; -mod pixel; -mod programmer; -mod rhythm; -mod show; -mod timecode; -mod tracking_state; diff --git a/crates/core/src/messages.rs b/crates/core/src/messages.rs deleted file mode 100644 index 5a7ea6d..0000000 --- a/crates/core/src/messages.rs +++ /dev/null @@ -1,491 +0,0 @@ -use std::path::PathBuf; - -use halo_fixtures::Fixture; -use serde::{Deserialize, Serialize}; - -use crate::audio::device_enumerator::AudioDeviceInfo; -use crate::{CueList, EffectType, MidiOverride, PlaybackState, RhythmState, Show, TimeCode}; - -/// Commands sent from UI to Console -#[derive(Debug, Clone)] -pub enum ConsoleCommand { - // System commands - Initialize, - Shutdown, - Update, - - // Show management - NewShow { - name: String, - }, - LoadShow { - path: PathBuf, - }, - SaveShow, - SaveShowAs { - name: String, - path: PathBuf, - }, - ReloadShow, - - // Fixture management - PatchFixture { - name: String, - profile_name: String, - universe: u8, - address: u16, - }, - UnpatchFixture { - fixture_id: usize, - }, - UpdateFixture { - fixture_id: usize, - name: String, - universe: u8, - address: u16, - }, - UpdateFixtureChannels { - fixture_id: usize, - channel_values: Vec<(String, u8)>, - }, - SetPanTiltLimits { - fixture_id: usize, - pan_min: u8, - pan_max: u8, - tilt_min: u8, - tilt_max: u8, - }, - ClearPanTiltLimits { - fixture_id: usize, - }, - - // Cue management - SetCueLists { - cue_lists: Vec, - }, - UpdateCue { - list_index: usize, - cue_index: usize, - name: String, - fade_time: f64, - timecode: Option, - is_blocking: bool, - }, - DeleteCue { - list_index: usize, - cue_index: usize, - }, - DeleteCueList { - list_index: usize, - }, - SetCueListAudioFile { - list_index: usize, - audio_file: Option, - }, - AddCue { - list_index: usize, - name: String, - fade_time: f64, - timecode: Option, - is_blocking: bool, - }, - PlayCue { - list_index: usize, - cue_index: usize, - }, - StopCue { - list_index: usize, - }, - PauseCue { - list_index: usize, - }, - ResumeCue { - list_index: usize, - }, - GoToCue { - list_index: usize, - cue_index: usize, - }, - NextCue { - list_index: usize, - }, - PrevCue { - list_index: usize, - }, - SelectNextCueList, - SelectPreviousCueList, - - // Playback control - Play, - Stop, - Pause, - Resume, - SetPlaybackRate { - rate: f64, - }, - - // Tempo and timing - SetBpm { - bpm: f64, - }, - TapTempo, - SetTimecode { - timecode: TimeCode, - }, - SeekAudio { - position_seconds: f64, - }, - - // MIDI - AddMidiOverride { - note: u8, - override_config: MidiOverride, - }, - RemoveMidiOverride { - note: u8, - }, - ProcessMidiMessage { - message: Vec, - }, - - // Audio - PlayAudio { - file_path: String, - }, - StopAudio, - SetAudioVolume { - volume: f32, - }, - - // Ableton Link - EnableAbletonLink, - DisableAbletonLink, - - // Effects - ApplyEffect { - fixture_ids: Vec, - channel_type: String, - effect_type: EffectType, - frequency: f32, - amplitude: f32, - offset: f32, - }, - ClearEffect { - fixture_ids: Vec, - channel_type: String, - }, - - // Programmer - SetProgrammerValue { - fixture_id: usize, - channel: String, - value: u8, - }, - SetProgrammerPreviewMode { - preview_mode: bool, - }, - SetSelectedFixtures { - fixture_ids: Vec, - }, - AddSelectedFixture { - fixture_id: usize, - }, - RemoveSelectedFixture { - fixture_id: usize, - }, - ClearSelectedFixtures, - RecordProgrammerToCue { - cue_name: String, - list_index: Option, - }, - ClearProgrammer, - ApplyProgrammerEffect { - fixture_ids: Vec, - channel_types: Vec, - effect_type: EffectType, - waveform: u8, - interval: u8, - ratio: f32, - phase: f32, - distribution: u8, - step_value: Option, - wave_offset: Option, - }, - - // Settings commands - UpdateSettings { - settings: Settings, - }, - QuerySettings, - QueryAudioDevices, - - // Pixel engine commands - ConfigurePixelEngine { - enabled: bool, - universe_mapping: std::collections::HashMap, - }, - AddPixelEffect { - name: String, - fixture_ids: Vec, - effect: crate::pixel::PixelEffect, - distribution: crate::EffectDistribution, - }, - RemovePixelEffect { - name: String, - }, - ClearPixelEffects, - - // Query commands (request state) - QueryFixtures, - QueryCueLists, - QueryCurrentCueListIndex, - QueryCurrentCue, - QueryPlaybackState, - QueryRhythmState, - QueryShow, - QueryLinkState, - QueryFixtureLibrary, -} - -/// Settings configuration -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct Settings { - // General settings - pub target_fps: u32, - pub enable_autosave: bool, - pub autosave_interval_secs: u32, - - // Audio settings - pub audio_device: String, - pub audio_buffer_size: u32, - pub audio_sample_rate: u32, - - // MIDI settings - pub midi_enabled: bool, - pub midi_device: String, - pub midi_channel: u8, - - // Output settings (DMX/Art-Net) - pub dmx_enabled: bool, - pub dmx_broadcast: bool, - pub dmx_source_ip: String, - pub dmx_dest_ip: String, - pub dmx_port: u16, - pub wled_enabled: bool, - pub wled_ip: String, - - // Pixel engine settings - pub pixel_engine_enabled: bool, - pub pixel_engine_fps: f64, - pub pixel_universe_mapping: std::collections::HashMap, - - // Fixture settings - pub enable_pan_tilt_limits: bool, -} - -impl Default for Settings { - fn default() -> Self { - Self { - // General defaults - target_fps: 60, - enable_autosave: false, - autosave_interval_secs: 300, - - // Audio defaults - audio_device: "Default".to_string(), - audio_buffer_size: 512, - audio_sample_rate: 48000, - - // MIDI defaults - midi_enabled: false, - midi_device: "None".to_string(), - midi_channel: 1, - - // Output defaults - dmx_enabled: true, - dmx_broadcast: false, - dmx_source_ip: "192.168.1.100".to_string(), - dmx_dest_ip: "192.168.1.200".to_string(), - dmx_port: 6454, - wled_enabled: false, - wled_ip: "192.168.1.50".to_string(), - - // Pixel engine defaults - pixel_engine_enabled: false, - pixel_engine_fps: 44.0, - pixel_universe_mapping: std::collections::HashMap::new(), - - // Fixture defaults - enable_pan_tilt_limits: true, - } - } -} - -/// Events sent from Console to UI -#[derive(Debug, Clone)] -pub enum ConsoleEvent { - // System events - Initialized, - ShutdownComplete, - Error { - message: String, - }, - - // State updates - FixturesUpdated { - fixtures: Vec, - }, - CueListsUpdated { - cue_lists: Vec, - }, - PlaybackStateChanged { - state: PlaybackState, - }, - RhythmStateUpdated { - state: RhythmState, - }, - TrackingStateUpdated { - active_effect_count: usize, - }, - TimecodeUpdated { - timecode: TimeCode, - }, - BpmChanged { - bpm: f64, - }, - - // Show events - ShowLoaded { - show: Show, - }, - ShowSaved { - path: PathBuf, - }, - ShowCreated { - name: String, - }, - - // Fixture events - FixturePatched { - fixture_id: usize, - fixture: Fixture, - }, - FixtureUnpatched { - fixture_id: usize, - }, - FixtureUpdated { - fixture_id: usize, - fixture: Fixture, - }, - FixtureValuesChanged { - fixture_id: usize, - values: Vec<(String, u8)>, - }, - - // Cue events - CueStarted { - list_index: usize, - cue_index: usize, - }, - CueStopped { - list_index: usize, - }, - CueCompleted { - list_index: usize, - cue_index: usize, - }, - CueListCompleted { - list_index: usize, - }, - CueListSelected { - list_index: usize, - }, - CurrentCueChanged { - cue_index: usize, - progress: f32, - }, - - // MIDI events - MidiOverrideAdded { - note: u8, - }, - MidiOverrideRemoved { - note: u8, - }, - MidiMessageReceived { - message: Vec, - }, - - // Audio events - AudioStarted { - file_path: String, - }, - AudioStopped, - AudioVolumeChanged { - volume: f32, - }, - - // Link events - LinkStateChanged { - enabled: bool, - num_peers: u64, - }, - - // Programmer events - ProgrammerStateUpdated { - preview_mode: bool, - selected_fixtures: Vec, - }, - ProgrammerValuesUpdated { - values: Vec<(usize, String, u8)>, // (fixture_id, channel, value) - }, - ProgrammerEffectsUpdated { - effects: Vec<(String, EffectType, Vec)>, // (name, effect_type, fixture_ids) - }, - - // Response to queries - FixturesList { - fixtures: Vec, - }, - CueListsList { - cue_lists: Vec, - }, - CurrentCueListIndex { - index: usize, - }, - CurrentCue { - cue_index: usize, - progress: f32, - }, - CurrentPlaybackState { - state: PlaybackState, - }, - CurrentRhythmState { - state: RhythmState, - }, - CurrentShow { - show: Show, - }, - - // Settings events - SettingsUpdated { - settings: Settings, - }, - CurrentSettings { - settings: Settings, - }, - AudioDevicesList { - devices: Vec, - }, - WaveformAnalyzed { - waveform_data: crate::audio::waveform::WaveformData, - duration: f64, - bpm: Option, - }, - FixtureLibraryList { - profiles: Vec<(String, String)>, // (id, display_name) - }, - PixelDataUpdated { - pixel_data: Vec<(usize, Vec<(u8, u8, u8)>)>, // (fixture_id, pixels_rgb) - }, -} diff --git a/crates/core/src/midi/midi.rs b/crates/core/src/midi/midi.rs deleted file mode 100644 index ed02aec..0000000 --- a/crates/core/src/midi/midi.rs +++ /dev/null @@ -1,22 +0,0 @@ -use crate::StaticValue; - -#[derive(Debug, Clone)] -pub enum MidiAction { - StaticValues(Vec), - TriggerCue(String), // Cue name to trigger -} - -// Represent a MIDI override (could be from keys, pads, or controls) -#[derive(Debug, Clone)] -pub struct MidiOverride { - pub action: MidiAction, -} - -// MIDI message types we care about -#[derive(Debug, Clone)] -pub enum MidiMessage { - NoteOn(u8, u8), // (note, velocity) - NoteOff(u8), // note - ControlChange(u8, u8), // (controller number, value) - Clock, // MIDI clock messages -} diff --git a/crates/core/src/midi/mod.rs b/crates/core/src/midi/mod.rs deleted file mode 100644 index 419b825..0000000 --- a/crates/core/src/midi/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod midi; diff --git a/crates/core/src/modules/audio_module.rs b/crates/core/src/modules/audio_module.rs deleted file mode 100644 index 0d745ba..0000000 --- a/crates/core/src/modules/audio_module.rs +++ /dev/null @@ -1,523 +0,0 @@ -use std::collections::HashMap; -use std::fs::File; -use std::path::PathBuf; -use std::{io, thread}; - -use async_trait::async_trait; -use rodio::{Decoder, OutputStreamBuilder, Sink}; -use tokio::sync::{mpsc, oneshot}; - -use super::traits::{AsyncModule, ModuleEvent, ModuleId, ModuleMessage}; - -/// Commands that can be sent to the audio thread -#[derive(Debug)] -enum AudioCommand { - /// Load and play an audio file - Play { - file_path: PathBuf, - response: oneshot::Sender>, - }, - /// Stop playback - Stop { - response: oneshot::Sender>, - }, - /// Pause playback - Pause { - response: oneshot::Sender>, - }, - /// Resume playback - Resume { - response: oneshot::Sender>, - }, - /// Set volume (0.0 to 1.0) - SetVolume { - volume: f32, - response: oneshot::Sender<()>, - }, - /// Query playback status - #[allow(dead_code)] - GetStatus { - response: oneshot::Sender, - }, - /// Seek to a specific position - Seek { - position_seconds: f64, - response: oneshot::Sender>, - }, - /// Shutdown the audio thread - Shutdown, -} - -/// Current status of the audio player -#[allow(dead_code)] -#[derive(Debug, Clone)] -struct AudioStatus { - current_file: Option, - is_playing: bool, - is_paused: bool, - volume: f32, -} - -pub struct AudioModule { - /// Channel to send commands to the audio thread - command_tx: Option>, - /// Handle to the audio thread - thread_handle: Option>, - /// Cached status for quick access - cached_status: HashMap, -} - -impl AudioModule { - pub fn new() -> Self { - Self { - command_tx: None, - thread_handle: None, - cached_status: HashMap::new(), - } - } - - /// Send a command to the audio thread and wait for response - async fn send_command(&self, command: AudioCommand) -> Result<(), String> { - if let Some(tx) = &self.command_tx { - tx.send(command) - .await - .map_err(|_| "Audio thread has stopped".to_string())?; - Ok(()) - } else { - Err("Audio module not initialized".to_string()) - } - } - - /// Play an audio file - async fn play_file(&mut self, file_path: PathBuf) -> Result<(), String> { - let (response_tx, response_rx) = oneshot::channel(); - self.send_command(AudioCommand::Play { - file_path, - response: response_tx, - }) - .await?; - - response_rx - .await - .map_err(|_| "Audio thread did not respond".to_string())? - } - - /// Stop playback - async fn stop(&mut self) -> Result<(), String> { - let (response_tx, response_rx) = oneshot::channel(); - self.send_command(AudioCommand::Stop { - response: response_tx, - }) - .await?; - - response_rx - .await - .map_err(|_| "Audio thread did not respond".to_string())? - } - - /// Pause playback - async fn pause(&mut self) -> Result<(), String> { - let (response_tx, response_rx) = oneshot::channel(); - self.send_command(AudioCommand::Pause { - response: response_tx, - }) - .await?; - - response_rx - .await - .map_err(|_| "Audio thread did not respond".to_string())? - } - - /// Resume playback - async fn resume(&mut self) -> Result<(), String> { - let (response_tx, response_rx) = oneshot::channel(); - self.send_command(AudioCommand::Resume { - response: response_tx, - }) - .await?; - - response_rx - .await - .map_err(|_| "Audio thread did not respond".to_string())? - } - - /// Set volume (0.0 to 1.0) - async fn set_volume(&mut self, volume: f32) -> Result<(), String> { - let (response_tx, response_rx) = oneshot::channel(); - self.send_command(AudioCommand::SetVolume { - volume, - response: response_tx, - }) - .await?; - - response_rx - .await - .map_err(|_| "Audio thread did not respond".to_string())?; - Ok(()) - } - - /// Get current status from the audio thread - #[allow(dead_code)] - async fn get_status(&self) -> Result { - let (response_tx, response_rx) = oneshot::channel(); - self.send_command(AudioCommand::GetStatus { - response: response_tx, - }) - .await?; - - response_rx - .await - .map_err(|_| "Audio thread did not respond".to_string()) - } - - /// Seek to a specific position - async fn seek(&mut self, position_seconds: f64) -> Result<(), String> { - let (response_tx, response_rx) = oneshot::channel(); - self.send_command(AudioCommand::Seek { - position_seconds, - response: response_tx, - }) - .await?; - - response_rx - .await - .map_err(|_| "Audio thread did not respond".to_string())? - } -} - -/// The audio thread worker that handles all rodio operations -fn audio_thread_worker(mut command_rx: mpsc::Receiver) { - log::info!("Audio thread starting"); - - // Create the OutputStream - this must live for the entire thread lifetime - let stream_handle = match OutputStreamBuilder::open_default_stream() { - Ok(handle) => { - log::info!("Successfully created audio output stream"); - handle - } - Err(e) => { - log::error!("Failed to create audio output stream: {e}"); - return; - } - }; - - // Audio state - let mut sink: Option = None; - let mut current_file: Option = None; - let mut volume: f32 = 1.0; - - // Process commands - while let Some(command) = command_rx.blocking_recv() { - match command { - AudioCommand::Play { - file_path, - response, - } => { - log::info!("Audio thread: Loading file: {file_path:?}"); - - let result = (|| -> Result<(), String> { - // Create a new sink - let new_sink = Sink::connect_new(stream_handle.mixer()); - - // Open the audio file - let file = File::open(&file_path) - .map_err(|e| format!("Failed to open audio file: {e}"))?; - - // Create decoder using try_from for seeking support - let source = Decoder::try_from(file) - .map_err(|e| format!("Failed to decode audio file: {e}"))?; - - // Add source to sink and configure - new_sink.append(source); - new_sink.set_volume(volume); - new_sink.play(); // Start playing immediately - - // Update state - sink = Some(new_sink); - current_file = Some(file_path.to_string_lossy().to_string()); - - log::info!("Audio thread: File loaded and playing with seeking support"); - Ok(()) - })(); - - let _ = response.send(result); - } - - AudioCommand::Stop { response } => { - if let Some(s) = &sink { - s.stop(); - sink = None; - current_file = None; - log::info!("Audio thread: Stopped playback"); - } else { - log::info!("Audio thread: Stop requested but no audio file loaded"); - } - let _ = response.send(Ok(())); - } - - AudioCommand::Pause { response } => { - let result = if let Some(s) = &sink { - s.pause(); - log::info!("Audio thread: Paused playback"); - Ok(()) - } else { - Err("No audio file loaded".to_string()) - }; - let _ = response.send(result); - } - - AudioCommand::Resume { response } => { - let result = if let Some(s) = &sink { - s.play(); - log::info!("Audio thread: Resumed playback"); - Ok(()) - } else { - Err("No audio file loaded".to_string()) - }; - let _ = response.send(result); - } - - AudioCommand::SetVolume { - volume: vol, - response, - } => { - volume = vol.clamp(0.0, 1.0); - if let Some(s) = &sink { - s.set_volume(volume); - } - log::info!("Audio thread: Set volume to {volume}"); - let _ = response.send(()); - } - - AudioCommand::GetStatus { response } => { - let status = AudioStatus { - current_file: current_file.clone(), - is_playing: sink - .as_ref() - .map(|s| !s.is_paused() && !s.empty()) - .unwrap_or(false), - is_paused: sink.as_ref().map(|s| s.is_paused()).unwrap_or(false), - volume, - }; - let _ = response.send(status); - } - - AudioCommand::Seek { - position_seconds, - response, - } => { - let result = if let Some(s) = &sink { - let position = std::time::Duration::from_secs_f64(position_seconds); - match s.try_seek(position) { - Ok(_) => { - log::info!("Audio thread: Seeked to {position_seconds}s"); - Ok(()) - } - Err(e) => { - log::warn!("Audio thread: Seek failed: {e}"); - Err(format!("Seek failed: {e}")) - } - } - } else { - Err("No audio file loaded".to_string()) - }; - let _ = response.send(result); - } - - AudioCommand::Shutdown => { - log::info!("Audio thread: Received shutdown command"); - if let Some(s) = sink.take() { - s.stop(); - } - break; - } - } - } - - log::info!("Audio thread shutting down"); -} - -#[async_trait] -impl AsyncModule for AudioModule { - fn id(&self) -> ModuleId { - ModuleId::Audio - } - - async fn initialize(&mut self) -> Result<(), Box> { - log::info!("Initializing Audio module"); - println!("Initializing Audio module"); - - // Create channel for communication with the audio thread - let (command_tx, command_rx) = mpsc::channel::(32); - - // Spawn the dedicated audio thread (NOT a Tokio task) - let thread_handle = thread::Builder::new() - .name("audio-worker".to_string()) - .spawn(move || { - audio_thread_worker(command_rx); - }) - .map_err(|e| format!("Failed to spawn audio thread: {e}"))?; - - self.command_tx = Some(command_tx); - self.thread_handle = Some(thread_handle); - - // Initialize cached status - self.cached_status - .insert("status".to_string(), "initialized".to_string()); - self.cached_status - .insert("playback_state".to_string(), "idle".to_string()); - self.cached_status - .insert("volume".to_string(), "1.00".to_string()); - - log::info!("Audio module initialized successfully with dedicated thread"); - Ok(()) - } - - async fn run( - &mut self, - mut rx: mpsc::Receiver, - tx: mpsc::Sender, - ) -> Result<(), Box> { - log::info!("Audio module started"); - - let _ = tx - .send(ModuleMessage::Status("Audio module running".to_string())) - .await; - - while let Some(event) = rx.recv().await { - match event { - ModuleEvent::AudioPlay { file_path } => { - log::info!("Audio module received AudioPlay event for file: {file_path}"); - - if file_path.is_empty() { - log::warn!("AudioPlay received with empty file path"); - let _ = tx - .send(ModuleMessage::Error("Empty file path provided".to_string())) - .await; - continue; - } - - log::info!("Loading and playing audio file: {file_path}"); - match self.play_file(PathBuf::from(&file_path)).await { - Ok(_) => { - log::info!("Audio file loaded and playing successfully"); - let _ = tx - .send(ModuleMessage::Status(format!("Playing audio: {file_path}"))) - .await; - } - Err(e) => { - let error_msg = format!("Failed to play audio file: {e}"); - log::error!("{error_msg}"); - let _ = tx.send(ModuleMessage::Error(error_msg)).await; - } - } - } - - ModuleEvent::AudioPause => { - if let Err(e) = self.pause().await { - let error_msg = format!("Failed to pause audio: {e}"); - log::error!("{error_msg}"); - let _ = tx.send(ModuleMessage::Error(error_msg)).await; - } else { - let _ = tx - .send(ModuleMessage::Status("Audio paused".to_string())) - .await; - } - } - - ModuleEvent::AudioResume => { - if let Err(e) = self.resume().await { - let error_msg = format!("Failed to resume audio: {e}"); - log::error!("{error_msg}"); - let _ = tx.send(ModuleMessage::Error(error_msg)).await; - } else { - let _ = tx - .send(ModuleMessage::Status("Audio resumed".to_string())) - .await; - } - } - - ModuleEvent::AudioStop => { - if let Err(e) = self.stop().await { - let error_msg = format!("Failed to stop audio: {e}"); - log::error!("{error_msg}"); - let _ = tx.send(ModuleMessage::Error(error_msg)).await; - } else { - let _ = tx - .send(ModuleMessage::Status("Audio stopped".to_string())) - .await; - } - } - - ModuleEvent::AudioSetVolume(volume) => { - if let Err(e) = self.set_volume(volume).await { - log::error!("Failed to set volume: {e}"); - } else { - let _ = tx - .send(ModuleMessage::Status(format!("Volume set to {volume:.2}"))) - .await; - } - } - - ModuleEvent::AudioSeek { position_seconds } => { - if let Err(e) = self.seek(position_seconds).await { - let error_msg = format!("Failed to seek audio: {e}"); - log::error!("{error_msg}"); - let _ = tx.send(ModuleMessage::Error(error_msg)).await; - } else { - let _ = tx - .send(ModuleMessage::Status(format!( - "Seeked to {position_seconds:.2}s" - ))) - .await; - } - } - - ModuleEvent::Shutdown => { - log::info!("Audio module received shutdown signal"); - break; - } - - _ => { - // Audio module ignores other events - } - } - } - - log::info!("Audio module shutting down"); - Ok(()) - } - - async fn shutdown(&mut self) -> Result<(), Box> { - log::info!("Audio module shutting down"); - - // Send shutdown command to the audio thread - if let Some(tx) = self.command_tx.take() { - let _ = tx.send(AudioCommand::Shutdown).await; - // Give the thread a moment to process the shutdown - drop(tx); - } - - // Wait for the audio thread to finish - if let Some(handle) = self.thread_handle.take() { - // Join with a reasonable timeout using tokio::task::spawn_blocking - tokio::task::spawn_blocking(move || { - if let Err(e) = handle.join() { - log::error!("Audio thread panicked during shutdown: {e:?}"); - } - }) - .await?; - } - - self.cached_status - .insert("status".to_string(), "shutdown".to_string()); - log::info!("Audio module shutdown complete"); - Ok(()) - } - - fn status(&self) -> HashMap { - // Return cached status - querying the thread would be async - // For real-time status, call get_status() from async context - self.cached_status.clone() - } -} diff --git a/crates/core/src/modules/dmx_module.rs b/crates/core/src/modules/dmx_module.rs deleted file mode 100644 index 777b022..0000000 --- a/crates/core/src/modules/dmx_module.rs +++ /dev/null @@ -1,192 +0,0 @@ -use std::collections::HashMap; - -use async_trait::async_trait; -use tokio::sync::mpsc; -use tokio::time::{interval, Duration, Instant}; - -use super::traits::{AsyncModule, ModuleEvent, ModuleId, ModuleMessage}; -use crate::artnet::artnet::ArtNet; -use crate::artnet::network_config::NetworkConfig; - -pub struct DmxModule { - artnet_connections: Vec>, // Multiple ArtNet instances - network_config: NetworkConfig, - last_frame_time: Option, - frames_sent: u64, - target_fps: f64, - status: HashMap, -} - -impl DmxModule { - pub fn new(network_config: NetworkConfig) -> Self { - let num_destinations = network_config.destinations.len(); - let mut artnet_connections = Vec::new(); - for _ in 0..num_destinations { - artnet_connections.push(None); - } - - Self { - artnet_connections, - network_config, - last_frame_time: None, - frames_sent: 0, - target_fps: 44.0, // DMX standard 44Hz - status: HashMap::new(), - } - } - - pub fn set_target_fps(&mut self, fps: f64) { - self.target_fps = fps; - } -} - -#[async_trait] -impl AsyncModule for DmxModule { - fn id(&self) -> ModuleId { - ModuleId::Dmx - } - - async fn initialize(&mut self) -> Result<(), Box> { - log::info!( - "Initializing DMX module with {} destinations", - self.network_config.destinations.len() - ); - - // Initialize ArtNet connections for each destination - for (i, destination) in self.network_config.destinations.iter().enumerate() { - log::info!( - "Setting up ArtNet connection {} for destination: {}", - i, - destination.name - ); - - let artnet = ArtNet::new(destination.mode.clone())?; - self.artnet_connections[i] = Some(artnet); - } - - self.status.insert( - "mode".to_string(), - self.network_config.get_mode_string().to_string(), - ); - self.status.insert( - "destinations".to_string(), - format!("{}", self.network_config.destinations.len()), - ); - self.status.insert( - "destination_info".to_string(), - self.network_config.get_destination(), - ); - self.status - .insert("status".to_string(), "initialized".to_string()); - - Ok(()) - } - - async fn run( - &mut self, - mut rx: mpsc::Receiver, - tx: mpsc::Sender, - ) -> Result<(), Box> { - // Validate all ArtNet connections are initialized - for (i, conn) in self.artnet_connections.iter().enumerate() { - if conn.is_none() { - return Err(format!("ArtNet connection {} not initialized", i).into()); - } - } - - // Create interval for DMX output timing - let frame_duration = Duration::from_secs_f64(1.0 / self.target_fps); - let mut frame_interval = interval(frame_duration); - - let mut last_dmx_data: HashMap> = HashMap::new(); - let mut shutdown = false; - - log::info!( - "DMX module started with {} destinations, running at {}Hz", - self.artnet_connections.len(), - self.target_fps - ); - - // Send initial status - let _ = tx - .send(ModuleMessage::Status(format!( - "DMX module running at {}Hz with {} destinations", - self.target_fps, - self.artnet_connections.len() - ))) - .await; - - while !shutdown { - tokio::select! { - // Handle incoming events - Some(event) = rx.recv() => { - match event { - ModuleEvent::DmxOutput(universe, data) => { - last_dmx_data.insert(universe, data); - } - ModuleEvent::Shutdown => { - log::info!("DMX module received shutdown signal"); - shutdown = true; - break; - } - _ => { - // DMX module only handles DMX output events - } - } - } - - // Send DMX data at regular intervals - _ = frame_interval.tick() => { - let now = Instant::now(); - - // Send each universe to its routed destination - for (universe, data) in &last_dmx_data { - if let Some(dest_index) = self.network_config.get_destination_for_universe(*universe) { - if let Some(Some(artnet)) = self.artnet_connections.get(dest_index) { - artnet.send_data(*universe, data.clone()); - } else { - log::warn!("No ArtNet connection found for destination index {}", dest_index); - } - } else { - log::warn!("No destination routing configured for universe {}", universe); - } - } - - self.frames_sent += 1; - self.last_frame_time = Some(now); - - // Update status periodically - if self.frames_sent % (self.target_fps as u64 * 5) == 0 { // Every 5 seconds - self.status.insert("frames_sent".to_string(), self.frames_sent.to_string()); - self.status.insert("fps".to_string(), format!("{:.1}", self.target_fps)); - self.status.insert("universes".to_string(), last_dmx_data.len().to_string()); - - let _ = tx.send(ModuleMessage::Status(format!( - "DMX: {} frames sent, {} universes active across {} destinations", - self.frames_sent, - last_dmx_data.len(), - self.artnet_connections.len() - ))).await; - } - } - } - } - - log::info!( - "DMX module shutting down after sending {} frames", - self.frames_sent - ); - Ok(()) - } - - async fn shutdown(&mut self) -> Result<(), Box> { - self.status - .insert("status".to_string(), "shutdown".to_string()); - log::info!("DMX module shutdown complete"); - Ok(()) - } - - fn status(&self) -> HashMap { - self.status.clone() - } -} diff --git a/crates/core/src/modules/midi_module.rs b/crates/core/src/modules/midi_module.rs deleted file mode 100644 index 1f03818..0000000 --- a/crates/core/src/modules/midi_module.rs +++ /dev/null @@ -1,204 +0,0 @@ -use std::collections::HashMap; - -use async_trait::async_trait; -use midir::{MidiInput, MidiInputConnection, MidiOutput, MidiOutputConnection}; -use tokio::sync::mpsc; - -use super::traits::{AsyncModule, ModuleEvent, ModuleId, ModuleMessage}; -use crate::midi::midi::MidiMessage; - -pub struct MidiModule { - device_name: String, - midi_sender: Option>, - status: HashMap, -} - -impl MidiModule { - pub fn new(device_name: String) -> Self { - Self { - device_name, - midi_sender: None, - status: HashMap::new(), - } - } - - fn connect_midi( - &mut self, - tx: mpsc::Sender, - ) -> Result< - (MidiInputConnection<()>, MidiOutputConnection), - Box, - > { - let midi_in = MidiInput::new("halo_async_controller")?; - let midi_out = MidiOutput::new("halo_async_controller")?; - - // Find the device port for input - let in_port = midi_in - .ports() - .into_iter() - .find(|port| { - midi_in - .port_name(port) - .map(|name| name.contains(&self.device_name)) - .unwrap_or(false) - }) - .ok_or_else(|| format!("{} input not found", self.device_name))?; - - let tx_clone = tx.clone(); - let connection = midi_in - .connect( - &in_port, - "async-midi-input", - move |_timestamp, message, _| { - if message.len() >= 3 { - let midi_msg = match message[0] & 0xF0 { - 0xF8 => Some(MidiMessage::Clock), - 0x90 => { - // Note On - if message[2] > 0 { - Some(MidiMessage::NoteOn(message[1], message[2])) - } else { - Some(MidiMessage::NoteOff(message[1])) - } - } - 0x80 => Some(MidiMessage::NoteOff(message[1])), - 0xB0 => Some(MidiMessage::ControlChange(message[1], message[2])), - _ => None, - }; - - if let Some(midi_msg) = midi_msg { - let event = ModuleEvent::MidiInput(midi_msg); - - // Since we're in a callback, we need to use try_send - // to avoid blocking if the channel is full - if let Err(e) = tx_clone.try_send(ModuleMessage::Event(event)) { - log::warn!("Failed to send MIDI message: {}", e); - } - } - } - }, - (), - ) - .map_err(|_| "Failed to connect MIDI input")?; - - // Find the device port for output - let out_port = midi_out - .ports() - .into_iter() - .find(|port| { - midi_out - .port_name(port) - .map(|name| name.contains(&self.device_name)) - .unwrap_or(false) - }) - .ok_or_else(|| format!("{} output not found", self.device_name))?; - - let output_connection = midi_out - .connect(&out_port, "async-midi-output") - .map_err(|_| "Failed to connect MIDI output")?; - - self.midi_sender = Some(tx); - - self.status - .insert("input_connected".to_string(), "true".to_string()); - self.status - .insert("output_connected".to_string(), "true".to_string()); - self.status - .insert("device".to_string(), self.device_name.clone()); - - Ok((connection, output_connection)) - } -} - -#[async_trait] -impl AsyncModule for MidiModule { - fn id(&self) -> ModuleId { - ModuleId::Midi - } - - async fn initialize(&mut self) -> Result<(), Box> { - log::info!("Initializing MIDI module for device: {}", self.device_name); - - self.status - .insert("device_name".to_string(), self.device_name.clone()); - self.status - .insert("status".to_string(), "initialized".to_string()); - self.status - .insert("input_connected".to_string(), "false".to_string()); - self.status - .insert("output_connected".to_string(), "false".to_string()); - - Ok(()) - } - - async fn run( - &mut self, - mut rx: mpsc::Receiver, - tx: mpsc::Sender, - ) -> Result<(), Box> { - log::info!("MIDI module starting for device: {}", self.device_name); - - // Connect to MIDI device - let _input_conn; - let _output_conn; - match self.connect_midi(tx.clone()) { - Ok((input, output)) => { - _input_conn = input; - _output_conn = output; - log::info!("MIDI device '{}' connected successfully", self.device_name); - let _ = tx - .send(ModuleMessage::Status(format!( - "MIDI device '{}' connected", - self.device_name - ))) - .await; - } - Err(e) => { - let error_msg = format!( - "Failed to connect MIDI device '{}': {}", - self.device_name, e - ); - log::error!("{}", error_msg); - let _ = tx.send(ModuleMessage::Error(error_msg)).await; - - // Continue running even if MIDI connection fails - // This allows the system to run without MIDI hardware - } - } - - // Main event loop - while let Some(event) = rx.recv().await { - match event { - ModuleEvent::Shutdown => { - log::info!("MIDI module received shutdown signal"); - break; - } - _ => { - // MIDI module primarily handles input via the callback - // Other events are ignored for now, but could be extended - // to handle MIDI output commands in the future - } - } - } - - log::info!("MIDI module shutting down"); - Ok(()) - } - - async fn shutdown(&mut self) -> Result<(), Box> { - // Connections are automatically dropped when they go out of scope in the run() method - self.status - .insert("status".to_string(), "shutdown".to_string()); - self.status - .insert("input_connected".to_string(), "false".to_string()); - self.status - .insert("output_connected".to_string(), "false".to_string()); - - log::info!("MIDI module shutdown complete"); - Ok(()) - } - - fn status(&self) -> HashMap { - self.status.clone() - } -} diff --git a/crates/core/src/modules/mod.rs b/crates/core/src/modules/mod.rs deleted file mode 100644 index e114c93..0000000 --- a/crates/core/src/modules/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub mod audio_module; -pub mod dmx_module; -pub mod midi_module; -pub mod module_manager; -pub mod smpte_module; -pub mod traits; - -// Re-export for convenience -pub use audio_module::AudioModule; -pub use dmx_module::DmxModule; -pub use midi_module::MidiModule; -pub use module_manager::ModuleManager; -pub use smpte_module::SmpteModule; -pub use traits::{AsyncModule, ModuleEvent, ModuleId, ModuleMessage}; diff --git a/crates/core/src/modules/module_manager.rs b/crates/core/src/modules/module_manager.rs deleted file mode 100644 index e7e7f78..0000000 --- a/crates/core/src/modules/module_manager.rs +++ /dev/null @@ -1,156 +0,0 @@ -use std::collections::HashMap; - -use tokio::sync::mpsc; -use tokio::task::JoinHandle; - -use super::traits::{AsyncModule, ModuleEvent, ModuleId, ModuleMessage}; - -pub struct ModuleManager { - modules: HashMap>, - module_handles: HashMap>, - module_senders: HashMap>, - message_receiver: Option>, - message_sender: mpsc::Sender, - running: bool, -} - -impl ModuleManager { - pub fn new() -> Self { - let (message_sender, message_receiver) = mpsc::channel(1000); - - Self { - modules: HashMap::new(), - module_handles: HashMap::new(), - module_senders: HashMap::new(), - message_receiver: Some(message_receiver), - message_sender, - running: false, - } - } - - /// Register a new module with the manager - pub fn register_module(&mut self, module: Box) { - let id = module.id(); - self.modules.insert(id, module); - } - - /// Initialize all registered modules - pub async fn initialize(&mut self) -> Result<(), Box> { - for (id, module) in &mut self.modules { - match module.initialize().await { - Ok(_) => log::info!("Module {:?} initialized successfully", id), - Err(e) => { - log::error!("Failed to initialize module {:?}: {}", id, e); - let error_message = format!("{:?}Module error: {}", id, e); - return Err(error_message.into()); - } - } - } - Ok(()) - } - - /// Start all modules and begin the main coordination loop - pub async fn start(&mut self) -> Result<(), Box> { - if self.running { - return Err("Module manager is already running".into()); - } - - // Start each module in its own async task - let modules_to_start = std::mem::take(&mut self.modules); - - for (id, mut module) in modules_to_start { - let (event_tx, event_rx) = mpsc::channel(1000); - let message_tx = self.message_sender.clone(); - let module_id = id.clone(); - - let handle = tokio::spawn(async move { - if let Err(e) = module.run(event_rx, message_tx.clone()).await { - let _ = message_tx - .send(ModuleMessage::Error(format!( - "Module {:?} error: {}", - module_id, e - ))) - .await; - } - }); - - self.module_handles.insert(id.clone(), handle); - self.module_senders.insert(id, event_tx); - } - - self.running = true; - Ok(()) - } - - /// Send an event to a specific module - pub async fn send_to_module( - &self, - module_id: ModuleId, - event: ModuleEvent, - ) -> Result<(), String> { - if let Some(sender) = self.module_senders.get(&module_id) { - sender - .send(event) - .await - .map_err(|e| format!("Failed to send event to module {:?}: {}", module_id, e))?; - Ok(()) - } else { - Err(format!("Module {:?} not found", module_id)) - } - } - - /// Broadcast an event to all modules - pub async fn broadcast_event(&self, event: ModuleEvent) { - for (id, sender) in &self.module_senders { - if let Err(e) = sender.send(event.clone()).await { - log::warn!("Failed to broadcast event to module {:?}: {}", id, e); - } - } - } - - /// Get the message receiver (should only be called once) - pub fn take_message_receiver(&mut self) -> Option> { - self.message_receiver.take() - } - - /// Shutdown all modules gracefully - pub async fn shutdown(&mut self) -> Result<(), Box> { - if !self.running { - return Ok(()); - } - - log::info!("Shutting down module manager..."); - - // Send shutdown event to all modules - self.broadcast_event(ModuleEvent::Shutdown).await; - - // Wait for all module handles to complete - for (id, handle) in std::mem::take(&mut self.module_handles) { - log::info!("Waiting for module {:?} to shutdown...", id); - if let Err(e) = handle.await { - log::error!("Module {:?} shutdown error: {}", id, e); - } - } - - // Clear the senders map as well - self.module_senders.clear(); - - self.running = false; - log::info!("Module manager shutdown complete"); - Ok(()) - } - - /// Check if the manager is running - pub fn is_running(&self) -> bool { - self.running - } - - /// Get status of all modules - pub fn get_status(&self) -> HashMap> { - let mut status = HashMap::new(); - for (id, module) in &self.modules { - status.insert(id.clone(), module.status()); - } - status - } -} diff --git a/crates/core/src/modules/smpte_module.rs b/crates/core/src/modules/smpte_module.rs deleted file mode 100644 index e262ac5..0000000 --- a/crates/core/src/modules/smpte_module.rs +++ /dev/null @@ -1,207 +0,0 @@ -use std::collections::HashMap; - -use async_trait::async_trait; -use tokio::sync::mpsc; -use tokio::time::{interval, Duration, Instant}; - -use super::traits::{AsyncModule, ModuleEvent, ModuleId, ModuleMessage}; -use crate::timecode::timecode::TimeCode; - -pub struct SmpteModule { - internal_timecode: TimeCode, - external_timecode: Option, - frame_rate: u8, - is_internal_source: bool, - is_running: bool, - last_update: Instant, - status: HashMap, -} - -impl SmpteModule { - pub fn new(frame_rate: u8) -> Self { - Self { - internal_timecode: TimeCode::default(), - external_timecode: None, - frame_rate, - is_internal_source: true, - is_running: false, - last_update: Instant::now(), - status: HashMap::new(), - } - } - - pub fn set_frame_rate(&mut self, frame_rate: u8) { - self.frame_rate = frame_rate; - self.internal_timecode.set_frame_rate(frame_rate); - } - - pub fn use_external_source(&mut self, use_external: bool) { - self.is_internal_source = !use_external; - self.status.insert( - "source".to_string(), - if self.is_internal_source { - "internal" - } else { - "external" - } - .to_string(), - ); - } - - pub fn get_current_timecode(&self) -> TimeCode { - if self.is_internal_source { - self.internal_timecode - } else { - self.external_timecode.unwrap_or(self.internal_timecode) - } - } - - async fn update_internal_timecode(&mut self) { - if self.is_internal_source && self.is_running { - let now = Instant::now(); - let elapsed = now.duration_since(self.last_update); - - // Update at the configured frame rate - let frame_duration = Duration::from_millis(1000 / self.frame_rate as u64); - if elapsed >= frame_duration { - self.internal_timecode.update(); - self.last_update = now; - - // Update status - self.status - .insert("timecode".to_string(), self.internal_timecode.to_string()); - } - } - } - - pub fn start(&mut self) { - self.is_running = true; - self.last_update = Instant::now(); - self.status - .insert("playback_state".to_string(), "running".to_string()); - } - - pub fn stop(&mut self) { - self.is_running = false; - self.status - .insert("playback_state".to_string(), "stopped".to_string()); - } - - pub fn reset(&mut self) { - self.internal_timecode.reset(); - self.last_update = Instant::now(); - self.status - .insert("timecode".to_string(), self.internal_timecode.to_string()); - } -} - -#[async_trait] -impl AsyncModule for SmpteModule { - fn id(&self) -> ModuleId { - ModuleId::Smpte - } - - async fn initialize(&mut self) -> Result<(), Box> { - log::info!("Initializing SMPTE module at {}fps", self.frame_rate); - - self.internal_timecode.set_frame_rate(self.frame_rate); - - self.status - .insert("frame_rate".to_string(), self.frame_rate.to_string()); - self.status.insert( - "source".to_string(), - if self.is_internal_source { - "internal" - } else { - "external" - } - .to_string(), - ); - self.status - .insert("status".to_string(), "initialized".to_string()); - self.status - .insert("playback_state".to_string(), "stopped".to_string()); - self.status - .insert("timecode".to_string(), self.internal_timecode.to_string()); - - Ok(()) - } - - async fn run( - &mut self, - mut rx: mpsc::Receiver, - tx: mpsc::Sender, - ) -> Result<(), Box> { - log::info!("SMPTE module started at {}fps", self.frame_rate); - - let _ = tx - .send(ModuleMessage::Status("SMPTE module running".to_string())) - .await; - - // Create interval for internal timecode updates - let frame_duration = Duration::from_millis(1000 / self.frame_rate as u64); - let mut update_interval = interval(frame_duration); - - // Status reporting interval (every second) - let mut status_interval = interval(Duration::from_secs(1)); - - let mut shutdown = false; - - while !shutdown { - tokio::select! { - // Handle incoming events - Some(event) = rx.recv() => { - match event { - ModuleEvent::SmpteSync { timecode } => { - if !self.is_internal_source { - self.external_timecode = Some(timecode); - self.status.insert("timecode".to_string(), timecode.to_string()); - } - } - ModuleEvent::Shutdown => { - log::info!("SMPTE module received shutdown signal"); - shutdown = true; - break; - } - _ => { - // SMPTE module only handles sync events - } - } - } - - // Update internal timecode at frame rate - _ = update_interval.tick() => { - self.update_internal_timecode().await; - } - - // Send periodic status updates - _ = status_interval.tick() => { - let current_tc = self.get_current_timecode(); - self.status.insert("timecode".to_string(), current_tc.to_string()); - - let _ = tx.send(ModuleMessage::Status(format!( - "SMPTE: {} ({}fps, {} source)", - current_tc.to_string(), - self.frame_rate, - if self.is_internal_source { "internal" } else { "external" } - ))).await; - } - } - } - - log::info!("SMPTE module shutting down"); - Ok(()) - } - - async fn shutdown(&mut self) -> Result<(), Box> { - self.stop(); - self.status - .insert("status".to_string(), "shutdown".to_string()); - log::info!("SMPTE module shutdown complete"); - Ok(()) - } - - fn status(&self) -> HashMap { - self.status.clone() - } -} diff --git a/crates/core/src/modules/traits.rs b/crates/core/src/modules/traits.rs deleted file mode 100644 index 2597743..0000000 --- a/crates/core/src/modules/traits.rs +++ /dev/null @@ -1,70 +0,0 @@ -use std::collections::HashMap; - -use async_trait::async_trait; -use tokio::sync::mpsc; - -/// Unique identifier for each module type -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum ModuleId { - Audio, - Dmx, - Smpte, - Midi, -} - -/// Events that can be sent between modules -#[derive(Debug, Clone)] -pub enum ModuleEvent { - /// DMX data to output (universe, data) - DmxOutput(u8, Vec), - /// Audio playback command - AudioPlay { - file_path: String, - }, - AudioPause, - AudioResume, - AudioStop, - AudioSetVolume(f32), - AudioSeek { - position_seconds: f64, - }, - /// SMPTE timecode sync - SmpteSync { - timecode: crate::timecode::timecode::TimeCode, - }, - /// MIDI input events - MidiInput(crate::midi::midi::MidiMessage), - /// System events - Shutdown, -} - -/// Messages passed between modules and the module manager -#[derive(Debug)] -pub enum ModuleMessage { - Event(ModuleEvent), - Status(String), - Error(String), -} - -/// Trait that all async modules must implement -#[async_trait] -pub trait AsyncModule: Send + Sync { - /// Get the unique identifier for this module - fn id(&self) -> ModuleId; - - /// Initialize the module (called once at startup) - async fn initialize(&mut self) -> Result<(), Box>; - - /// Start the module's main loop - async fn run( - &mut self, - mut rx: mpsc::Receiver, - tx: mpsc::Sender, - ) -> Result<(), Box>; - - /// Shutdown the module gracefully - async fn shutdown(&mut self) -> Result<(), Box>; - - /// Get the module's status - fn status(&self) -> HashMap; -} diff --git a/crates/core/src/pixel/mod.rs b/crates/core/src/pixel/mod.rs deleted file mode 100644 index 3a8e9c0..0000000 --- a/crates/core/src/pixel/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub use pixel_effects::{PixelEffect, PixelEffectParams, PixelEffectScope, PixelEffectType}; -pub use pixel_engine::PixelEngine; - -mod pixel_effects; -mod pixel_engine; diff --git a/crates/core/src/pixel/pixel_effects.rs b/crates/core/src/pixel/pixel_effects.rs deleted file mode 100644 index aeb5da0..0000000 --- a/crates/core/src/pixel/pixel_effects.rs +++ /dev/null @@ -1,237 +0,0 @@ -use std::f64::consts::PI; - -use serde::{Deserialize, Serialize}; - -use crate::{Interval, RhythmState}; - -/// Pixel-specific effect types -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum PixelEffectType { - Chase, - Wave, - Strobe, - ColorCycle, -} - -impl PixelEffectType { - pub fn as_str(&self) -> &'static str { - match self { - PixelEffectType::Chase => "Chase", - PixelEffectType::Wave => "Wave", - PixelEffectType::Strobe => "Strobe", - PixelEffectType::ColorCycle => "ColorCycle", - } - } - - pub fn all() -> Vec { - vec![ - PixelEffectType::Chase, - PixelEffectType::Wave, - PixelEffectType::Strobe, - PixelEffectType::ColorCycle, - ] - } -} - -/// Scope of pixel effect application -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum PixelEffectScope { - /// Apply effect to all pixels in bar uniformly - Bar, - /// Apply effect to individual pixels - Individual, -} - -/// Pixel effect parameters -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PixelEffectParams { - pub interval: Interval, - pub interval_ratio: f64, - pub phase: f64, - pub speed: f64, -} - -impl Default for PixelEffectParams { - fn default() -> Self { - PixelEffectParams { - interval: Interval::Beat, - interval_ratio: 1.0, - phase: 0.0, - speed: 1.0, - } - } -} - -/// Complete pixel effect definition -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PixelEffect { - pub effect_type: PixelEffectType, - pub scope: PixelEffectScope, - pub color: (u8, u8, u8), - pub params: PixelEffectParams, -} - -impl Default for PixelEffect { - fn default() -> Self { - Self { - effect_type: PixelEffectType::Chase, - scope: PixelEffectScope::Individual, - color: (255, 255, 255), - params: PixelEffectParams::default(), - } - } -} - -impl PixelEffect { - /// Render effect for a single pixel at given position - /// position: 0.0 to 1.0 representing position in bar (0 = start, 1 = end) - /// phase: 0.0 to 1.0 representing effect phase from rhythm - /// Returns RGB tuple - pub fn render_pixel(&self, position: f64, phase: f64) -> (u8, u8, u8) { - // ColorCycle needs special handling - it generates colors dynamically - if self.effect_type == PixelEffectType::ColorCycle { - return self.render_color_cycle(position, phase); - } - - let intensity = match self.scope { - PixelEffectScope::Bar => { - // All pixels get same intensity based on phase - self.calculate_intensity(phase) - } - PixelEffectScope::Individual => { - // Each pixel gets intensity based on its position and phase - self.calculate_intensity_individual(position, phase) - } - }; - - // Apply intensity to color - ( - ((self.color.0 as f64 * intensity) as u8), - ((self.color.1 as f64 * intensity) as u8), - ((self.color.2 as f64 * intensity) as u8), - ) - } - - /// Render color cycle effect with actual color changes - fn render_color_cycle(&self, position: f64, phase: f64) -> (u8, u8, u8) { - // Neon purple and electric blue - let neon_purple = (191.0, 0.0, 255.0); // RGB - let electric_blue = (125.0, 249.0, 255.0); // RGB - - let t = match self.scope { - PixelEffectScope::Bar => { - // All pixels cycle through colors together based on phase - // Use sine wave for smooth alternation - (phase * std::f64::consts::PI * 2.0).sin() * 0.5 + 0.5 - } - PixelEffectScope::Individual => { - // Each pixel has different color based on position + phase - let combined = (position + phase) % 1.0; - (combined * std::f64::consts::PI * 2.0).sin() * 0.5 + 0.5 - } - }; - - // Interpolate between neon purple and electric blue - let r = (neon_purple.0 * (1.0 - t) + electric_blue.0 * t) as u8; - let g = (neon_purple.1 * (1.0 - t) + electric_blue.1 * t) as u8; - let b = (neon_purple.2 * (1.0 - t) + electric_blue.2 * t) as u8; - - (r, g, b) - } - - fn calculate_intensity(&self, phase: f64) -> f64 { - match self.effect_type { - PixelEffectType::Chase => { - // Simple on/off based on phase - if phase < 0.5 { - 1.0 - } else { - 0.0 - } - } - PixelEffectType::Wave => { - // Sine wave - (phase * 2.0 * PI).sin() * 0.5 + 0.5 - } - PixelEffectType::Strobe => { - // Fast on/off - if (phase * 10.0) % 1.0 < 0.5 { - 1.0 - } else { - 0.0 - } - } - PixelEffectType::ColorCycle => { - // Always on for color cycle (color changes in bar mode) - 1.0 - } - } - } - - fn calculate_intensity_individual(&self, position: f64, phase: f64) -> f64 { - match self.effect_type { - PixelEffectType::Chase => { - // Chase effect: light travels down the bar - let chase_pos = phase; - let distance = (position - chase_pos).abs(); - if distance < 0.1 { - 1.0 - } else { - 0.0 - } - } - PixelEffectType::Wave => { - // Wave effect: sine wave travels down the bar - let wave_phase = phase + position; - (wave_phase * 2.0 * PI).sin() * 0.5 + 0.5 - } - PixelEffectType::Strobe => { - // All pixels strobe together in individual mode - if (phase * 10.0) % 1.0 < 0.5 { - 1.0 - } else { - 0.0 - } - } - PixelEffectType::ColorCycle => { - // Always full intensity for color cycle (color changes, not intensity) - 1.0 - } - } - } - - /// Get effect phase from rhythm state - pub fn get_phase(&self, rhythm: &RhythmState) -> f64 { - let base_phase = match self.params.interval { - Interval::Beat => rhythm.beat_phase, - Interval::Bar => rhythm.bar_phase, - Interval::Phrase => rhythm.phrase_phase, - }; - - // Calculate phase and ensure it's in valid range [0.0, 1.0) - let phase = - (base_phase * self.params.interval_ratio * self.params.speed + self.params.phase) % 1.0; - - // Clamp to prevent any floating point edge cases - phase.max(0.0).min(0.9999999) - } -} - -/// Apply distribution to pixel effects across multiple fixtures -#[allow(dead_code)] -pub fn apply_pixel_distribution( - _effect: &PixelEffect, - fixture_index: usize, - total_fixtures: usize, - base_phase: f64, -) -> f64 { - // For pixel effects, we can apply distribution to offset the phase - // This makes effects spread across multiple pixel bars - match total_fixtures { - 0 | 1 => base_phase, - _ => { - let fixture_offset = fixture_index as f64 / total_fixtures as f64; - (base_phase + fixture_offset) % 1.0 - } - } -} diff --git a/crates/core/src/pixel/pixel_engine.rs b/crates/core/src/pixel/pixel_engine.rs deleted file mode 100644 index f18fe75..0000000 --- a/crates/core/src/pixel/pixel_engine.rs +++ /dev/null @@ -1,421 +0,0 @@ -use std::collections::HashMap; - -use halo_fixtures::{Fixture, FixtureType}; - -use super::pixel_effects::PixelEffect; -use crate::rhythm::rhythm::RhythmState; -use crate::EffectDistribution; - -/// Global pixel engine managing all pixel bar fixtures -pub struct PixelEngine { - /// Configuration - enabled: bool, - /// Mapping of fixture ID to universe - universe_mapping: HashMap, - /// Active pixel effects mapped by a unique key - active_effects: HashMap, PixelEffect, EffectDistribution)>, - /// Sequential packing mode enabled - sequential_packing: bool, - /// Fixture mapping: fixture_id -> (universe, start_address, channels_needed) - fixture_mapping: HashMap, -} - -impl PixelEngine { - pub fn new() -> Self { - Self { - enabled: true, - universe_mapping: HashMap::new(), - active_effects: HashMap::new(), - sequential_packing: false, - fixture_mapping: HashMap::new(), - } - } - - pub fn is_enabled(&self) -> bool { - self.enabled - } - - pub fn set_enabled(&mut self, enabled: bool) { - self.enabled = enabled; - } - - /// Set universe mapping for a fixture - pub fn set_fixture_universe(&mut self, fixture_id: usize, universe: u8) { - self.universe_mapping.insert(fixture_id, universe); - } - - /// Get universe for a fixture (falls back to fixture's own universe if not mapped) - pub fn get_fixture_universe(&self, fixture_id: usize, default_universe: u8) -> u8 { - *self - .universe_mapping - .get(&fixture_id) - .unwrap_or(&default_universe) - } - - /// Clear all universe mappings - pub fn clear_universe_mappings(&mut self) { - self.universe_mapping.clear(); - } - - /// Enable sequential packing mode and calculate fixture mappings - pub fn enable_sequential_packing(&mut self, fixtures: &[Fixture]) { - self.sequential_packing = true; - self.fixture_mapping = self.calculate_sequential_mapping(fixtures); - - log::info!( - "Sequential packing enabled for {} pixel fixtures", - self.fixture_mapping.len() - ); - for (fixture_id, (universe, start_address, channels)) in &self.fixture_mapping { - log::info!( - " Fixture {}: Universe {}, Address {}-{} ({} channels)", - fixture_id, - universe, - start_address, - start_address + *channels as u16 - 1, - channels - ); - } - } - - /// Disable sequential packing mode - pub fn disable_sequential_packing(&mut self) { - self.sequential_packing = false; - self.fixture_mapping.clear(); - log::info!("Sequential packing disabled"); - } - - /// Calculate sequential mapping for pixel bar fixtures - /// Returns: HashMap - /// Ensures all addresses are pixel-aligned (address-1 must be divisible by 3) - fn calculate_sequential_mapping( - &self, - fixtures: &[Fixture], - ) -> HashMap { - let mut mapping = HashMap::new(); - - // Find all pixel bar fixtures sorted by ID - let mut pixel_fixtures: Vec<&Fixture> = fixtures - .iter() - .filter(|f| f.profile.fixture_type == FixtureType::PixelBar) - .collect(); - pixel_fixtures.sort_by_key(|f| f.id); - - let mut current_universe: u8 = 1; - let mut current_address: u16 = 1; - - for fixture in pixel_fixtures { - let pixel_count = self.get_pixel_count_from_channels(&fixture.channels); - if pixel_count == 0 { - continue; - } - - let channels_needed = pixel_count * 3; // RGB per pixel - - mapping.insert( - fixture.id, - (current_universe, current_address, channels_needed), - ); - - // Update address for next fixture, handling universe overflow with pixel alignment - let next_address = current_address + channels_needed as u16; - - if next_address > 512 { - // Calculate how many channels actually fit in current universe - let available_in_universe = 512 - current_address + 1; - - // Only complete pixels (groups of 3 channels) can be written - let complete_pixels_in_universe = available_in_universe / 3; - let channels_written = complete_pixels_in_universe * 3; - - // Remaining channels go to next universe - let remaining_channels = channels_needed as u16 - channels_written; - - // Next fixture starts after the spillover - current_universe += 1; - current_address = 1 + remaining_channels; - } else { - current_address = next_address; - } - } - - mapping - } - - /// Set active pixel effects from effect mappings - pub fn set_effects( - &mut self, - effects: Vec<(String, Vec, PixelEffect, EffectDistribution)>, - ) { - self.active_effects.clear(); - for (name, fixture_ids, effect, distribution) in effects { - self.active_effects - .insert(name, (fixture_ids, effect, distribution)); - } - } - - /// Add a single pixel effect - pub fn add_effect( - &mut self, - name: String, - fixture_ids: Vec, - effect: PixelEffect, - distribution: EffectDistribution, - ) { - self.active_effects - .insert(name, (fixture_ids, effect, distribution)); - } - - /// Remove a pixel effect by name - pub fn remove_effect(&mut self, name: &str) { - self.active_effects.remove(name); - } - - /// Clear all active effects - pub fn clear_effects(&mut self) { - self.active_effects.clear(); - } - - /// Render all pixel fixtures and return DMX data per universe - pub fn render(&self, fixtures: &[Fixture], rhythm_state: &RhythmState) -> HashMap> { - if !self.enabled { - return HashMap::new(); - } - - let mut universe_data: HashMap> = HashMap::new(); - - // Find all pixel bar fixtures - let pixel_fixtures: Vec<&Fixture> = fixtures - .iter() - .filter(|f| f.profile.fixture_type == FixtureType::PixelBar) - .collect(); - - if pixel_fixtures.is_empty() { - return universe_data; - } - - // Render each pixel fixture - for fixture in pixel_fixtures { - let pixel_count = self.get_pixel_count_from_channels(&fixture.channels); - if pixel_count == 0 { - continue; - } - - // Calculate RGB values for each pixel - let pixel_data = self.render_fixture(fixture, pixel_count, rhythm_state); - let channels_needed = pixel_count * 3; // RGB per pixel - - // Determine universe and start address (use sequential mapping if enabled) - let (start_universe, start_address) = if self.sequential_packing { - if let Some((universe, address, _)) = self.fixture_mapping.get(&fixture.id) { - (*universe, *address) - } else { - // Fallback if fixture not in mapping - ( - self.get_fixture_universe(fixture.id, fixture.universe), - fixture.start_address, - ) - } - } else { - ( - self.get_fixture_universe(fixture.id, fixture.universe), - fixture.start_address, - ) - }; - - log::info!( - "Pixel Engine - Fixture {} ({}): pixel_count={}, channels.len()={}, start_address={}, universe={}, channels_needed={}", - fixture.id, - fixture.name, - pixel_count, - fixture.channels.len(), - start_address, - start_universe, - channels_needed - ); - - // Write pixel data with spillover support - self.write_with_spillover( - &mut universe_data, - &pixel_data, - start_universe, - start_address, - channels_needed, - fixture.id, - &fixture.name, - ); - } - - universe_data - } - - /// Render a single pixel fixture - fn render_fixture( - &self, - fixture: &Fixture, - pixel_count: usize, - rhythm_state: &RhythmState, - ) -> Vec { - let mut pixel_data = vec![0u8; pixel_count * 3]; // RGB per pixel - - // Find effects that apply to this fixture - let applicable_effects: Vec<(&PixelEffect, &EffectDistribution, usize, usize)> = self - .active_effects - .values() - .filter_map(|(fixture_ids, effect, distribution)| { - fixture_ids - .iter() - .position(|&id| id == fixture.id) - .map(|idx| (effect, distribution, idx, fixture_ids.len())) - }) - .collect(); - - if applicable_effects.is_empty() { - // No effects, return black (all zeros) - return pixel_data; - } - - // Render each pixel - for pixel_idx in 0..pixel_count { - let position = (pixel_idx as f64 + 0.5) / pixel_count as f64; - let mut r = 0u16; - let mut g = 0u16; - let mut b = 0u16; - - // Accumulate all applicable effects - for (effect, distribution, fixture_idx, _total_fixtures) in &applicable_effects { - let base_phase = effect.get_phase(rhythm_state); - - // Apply distribution to offset phase across fixtures - let phase = match distribution { - EffectDistribution::All => base_phase, - EffectDistribution::Step(step) => { - let step_offset = (fixture_idx % step) as f64 / (*step).max(1) as f64; - (base_phase + step_offset) % 1.0 - } - EffectDistribution::Wave(offset) => { - let wave_offset = *fixture_idx as f64 * offset; - (base_phase + wave_offset) % 1.0 - } - }; - - let (pr, pg, pb) = effect.render_pixel(position, phase); - r += pr as u16; - g += pg as u16; - b += pb as u16; - } - - // Clamp to 255 - let base = pixel_idx * 3; - pixel_data[base] = r.min(255) as u8; - pixel_data[base + 1] = g.min(255) as u8; - pixel_data[base + 2] = b.min(255) as u8; - } - - pixel_data - } - - /// Write pixel data with automatic spillover across universe boundaries - /// Ensures splits happen only on pixel boundaries (multiples of 3 channels) - fn write_with_spillover( - &self, - universe_data: &mut HashMap>, - pixel_data: &[u8], - start_universe: u8, - start_address: u16, - channels_needed: usize, - fixture_id: usize, - fixture_name: &str, - ) { - let mut remaining_channels = channels_needed; - let mut source_offset = 0; - let mut current_universe = start_universe; - let mut current_address = start_address; - - while remaining_channels > 0 { - // Calculate how many channels we can write in the current universe - let available_in_universe = (512 - current_address as usize + 1).min(512); - let mut to_write = remaining_channels.min(available_in_universe); - - // CRITICAL: Ensure we only split on pixel boundaries (RGB = 3 channels) - // If we would split in the middle of a pixel, write fewer channels - if to_write < remaining_channels { - // We're going to split - make sure it's on a pixel boundary - to_write = (to_write / 3) * 3; - - // If we can't write any complete pixels, something is wrong with addressing - if to_write == 0 { - log::error!( - "Pixel fixture {} ({}) cannot write complete pixels: only {} channels available at Universe {} address {}", - fixture_id, - fixture_name, - available_in_universe, - current_universe, - current_address - ); - break; - } - } - - // Initialize universe buffer if needed - let universe_buffer = universe_data - .entry(current_universe) - .or_insert_with(|| vec![0; 512]); - - // Write channels to this universe - let start_idx = (current_address - 1) as usize; // DMX addresses are 1-based - let end_idx = start_idx + to_write; - - if end_idx <= 512 { - universe_buffer[start_idx..end_idx] - .copy_from_slice(&pixel_data[source_offset..source_offset + to_write]); - - if remaining_channels > to_write { - log::info!( - " Fixture {} ({}): Wrote {} channels ({} pixels) to Universe {} (addresses {}-{}), {} channels remaining", - fixture_id, - fixture_name, - to_write, - to_write / 3, - current_universe, - current_address, - current_address + to_write as u16 - 1, - remaining_channels - to_write - ); - } - } else { - log::error!( - "Pixel fixture {} ({}) write overflow: trying to write to {}-{} in universe {}", - fixture_id, - fixture_name, - start_idx, - end_idx, - current_universe - ); - } - - // Update for next iteration - remaining_channels -= to_write; - source_offset += to_write; - current_universe += 1; - current_address = 1; // Next universe starts at address 1 - } - } - - /// Extract pixel count from channel layout - /// Assumes RGB layout: 3 channels per pixel - fn get_pixel_count_from_channels(&self, channels: &[halo_fixtures::Channel]) -> usize { - channels.len() / 3 - } - - /// Get current universe mapping - pub fn get_universe_mapping(&self) -> &HashMap { - &self.universe_mapping - } -} - -impl Default for PixelEngine { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/core/src/preset/preset.rs b/crates/core/src/preset/preset.rs deleted file mode 100644 index 046bfdc..0000000 --- a/crates/core/src/preset/preset.rs +++ /dev/null @@ -1,234 +0,0 @@ -use halo_fixtures::ChannelType; -use serde::{Deserialize, Serialize}; - -use crate::{Effect, PixelEffect}; - -/// Represents different types of presets -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub enum PresetType { - Color, - Position, - Intensity, - Beam, - Effect, -} - -/// A generic preset that can be one of several types -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum Preset { - Color(ColorPreset), - Position(PositionPreset), - Intensity(IntensityPreset), - Beam(BeamPreset), - Effect(EffectPreset), -} - -impl Preset { - pub fn id(&self) -> usize { - match self { - Preset::Color(p) => p.id, - Preset::Position(p) => p.id, - Preset::Intensity(p) => p.id, - Preset::Beam(p) => p.id, - Preset::Effect(p) => p.id, - } - } - - pub fn name(&self) -> &str { - match self { - Preset::Color(p) => &p.name, - Preset::Position(p) => &p.name, - Preset::Intensity(p) => &p.name, - Preset::Beam(p) => &p.name, - Preset::Effect(p) => &p.name, - } - } - - pub fn preset_type(&self) -> PresetType { - match self { - Preset::Color(_) => PresetType::Color, - Preset::Position(_) => PresetType::Position, - Preset::Intensity(_) => PresetType::Intensity, - Preset::Beam(_) => PresetType::Beam, - Preset::Effect(_) => PresetType::Effect, - } - } - - pub fn fixture_groups(&self) -> &[usize] { - match self { - Preset::Color(p) => &p.fixture_groups, - Preset::Position(p) => &p.fixture_groups, - Preset::Intensity(p) => &p.fixture_groups, - Preset::Beam(p) => &p.fixture_groups, - Preset::Effect(p) => &p.fixture_groups, - } - } -} - -/// A preset for color values (RGB, RGBW, color wheels, etc.) -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct ColorPreset { - pub id: usize, - pub name: String, - pub fixture_groups: Vec, - pub values: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct ColorValue { - pub channel_type: ChannelType, - pub value: u8, -} - -impl ColorPreset { - pub fn new(id: usize, name: String, fixture_groups: Vec) -> Self { - Self { - id, - name, - fixture_groups, - values: Vec::new(), - } - } - - pub fn add_value(&mut self, channel_type: ChannelType, value: u8) { - // Remove existing value for this channel type - self.values.retain(|v| v.channel_type != channel_type); - self.values.push(ColorValue { - channel_type, - value, - }); - } -} - -/// A preset for position values (Pan, Tilt) -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct PositionPreset { - pub id: usize, - pub name: String, - pub fixture_groups: Vec, - pub pan: Option, - pub tilt: Option, -} - -impl PositionPreset { - pub fn new(id: usize, name: String, fixture_groups: Vec) -> Self { - Self { - id, - name, - fixture_groups, - pan: None, - tilt: None, - } - } - - pub fn with_pan(mut self, pan: u8) -> Self { - self.pan = Some(pan); - self - } - - pub fn with_tilt(mut self, tilt: u8) -> Self { - self.tilt = Some(tilt); - self - } -} - -/// A preset for intensity values (Dimmer) -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct IntensityPreset { - pub id: usize, - pub name: String, - pub fixture_groups: Vec, - pub dimmer: u8, -} - -impl IntensityPreset { - pub fn new(id: usize, name: String, fixture_groups: Vec, dimmer: u8) -> Self { - Self { - id, - name, - fixture_groups, - dimmer, - } - } -} - -/// A preset for beam attributes (Focus, Zoom, Iris, Gobo, Prism, etc.) -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct BeamPreset { - pub id: usize, - pub name: String, - pub fixture_groups: Vec, - pub values: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct BeamValue { - pub channel_type: ChannelType, - pub value: u8, -} - -impl BeamPreset { - pub fn new(id: usize, name: String, fixture_groups: Vec) -> Self { - Self { - id, - name, - fixture_groups, - values: Vec::new(), - } - } - - pub fn add_value(&mut self, channel_type: ChannelType, value: u8) { - // Remove existing value for this channel type - self.values.retain(|v| v.channel_type != channel_type); - self.values.push(BeamValue { - channel_type, - value, - }); - } -} - -/// A preset for effects -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct EffectPreset { - pub id: usize, - pub name: String, - pub fixture_groups: Vec, - pub effect: EffectPresetType, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum EffectPresetType { - Standard(Effect), - Pixel(PixelEffect), -} - -impl EffectPreset { - pub fn new_standard( - id: usize, - name: String, - fixture_groups: Vec, - effect: Effect, - ) -> Self { - Self { - id, - name, - fixture_groups, - effect: EffectPresetType::Standard(effect), - } - } - - pub fn new_pixel( - id: usize, - name: String, - fixture_groups: Vec, - effect: PixelEffect, - ) -> Self { - Self { - id, - name, - fixture_groups, - effect: EffectPresetType::Pixel(effect), - } - } -} diff --git a/crates/core/src/preset/preset_library.rs b/crates/core/src/preset/preset_library.rs deleted file mode 100644 index b849533..0000000 --- a/crates/core/src/preset/preset_library.rs +++ /dev/null @@ -1,202 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use super::preset::{ - BeamPreset, ColorPreset, EffectPreset, IntensityPreset, PositionPreset, Preset, PresetType, -}; - -/// Central library for managing all presets in a show -#[derive(Clone, Debug, Serialize, Deserialize, Default)] -pub struct PresetLibrary { - #[serde(default)] - pub color: Vec, - #[serde(default)] - pub position: Vec, - #[serde(default)] - pub intensity: Vec, - #[serde(default)] - pub beam: Vec, - #[serde(default)] - pub effect: Vec, -} - -impl PresetLibrary { - pub fn new() -> Self { - Self::default() - } - - /// Add a preset to the library - pub fn add_preset(&mut self, preset: Preset) { - match preset { - Preset::Color(p) => self.color.push(p), - Preset::Position(p) => self.position.push(p), - Preset::Intensity(p) => self.intensity.push(p), - Preset::Beam(p) => self.beam.push(p), - Preset::Effect(p) => self.effect.push(p), - } - } - - /// Get a preset by ID and type - pub fn get_preset(&self, preset_type: &PresetType, id: usize) -> Option { - match preset_type { - PresetType::Color => self - .color - .iter() - .find(|p| p.id == id) - .cloned() - .map(Preset::Color), - PresetType::Position => self - .position - .iter() - .find(|p| p.id == id) - .cloned() - .map(Preset::Position), - PresetType::Intensity => self - .intensity - .iter() - .find(|p| p.id == id) - .cloned() - .map(Preset::Intensity), - PresetType::Beam => self - .beam - .iter() - .find(|p| p.id == id) - .cloned() - .map(Preset::Beam), - PresetType::Effect => self - .effect - .iter() - .find(|p| p.id == id) - .cloned() - .map(Preset::Effect), - } - } - - /// Update an existing preset - pub fn update_preset(&mut self, preset: Preset) -> bool { - match preset { - Preset::Color(new_preset) => { - if let Some(existing) = self.color.iter_mut().find(|p| p.id == new_preset.id) { - *existing = new_preset; - true - } else { - false - } - } - Preset::Position(new_preset) => { - if let Some(existing) = self.position.iter_mut().find(|p| p.id == new_preset.id) { - *existing = new_preset; - true - } else { - false - } - } - Preset::Intensity(new_preset) => { - if let Some(existing) = self.intensity.iter_mut().find(|p| p.id == new_preset.id) { - *existing = new_preset; - true - } else { - false - } - } - Preset::Beam(new_preset) => { - if let Some(existing) = self.beam.iter_mut().find(|p| p.id == new_preset.id) { - *existing = new_preset; - true - } else { - false - } - } - Preset::Effect(new_preset) => { - if let Some(existing) = self.effect.iter_mut().find(|p| p.id == new_preset.id) { - *existing = new_preset; - true - } else { - false - } - } - } - } - - /// Delete a preset by ID and type - pub fn delete_preset(&mut self, preset_type: &PresetType, id: usize) -> bool { - match preset_type { - PresetType::Color => { - let len_before = self.color.len(); - self.color.retain(|p| p.id != id); - self.color.len() < len_before - } - PresetType::Position => { - let len_before = self.position.len(); - self.position.retain(|p| p.id != id); - self.position.len() < len_before - } - PresetType::Intensity => { - let len_before = self.intensity.len(); - self.intensity.retain(|p| p.id != id); - self.intensity.len() < len_before - } - PresetType::Beam => { - let len_before = self.beam.len(); - self.beam.retain(|p| p.id != id); - self.beam.len() < len_before - } - PresetType::Effect => { - let len_before = self.effect.len(); - self.effect.retain(|p| p.id != id); - self.effect.len() < len_before - } - } - } - - /// Get all presets of a specific type - pub fn get_presets_by_type(&self, preset_type: &PresetType) -> Vec { - match preset_type { - PresetType::Color => self.color.iter().cloned().map(Preset::Color).collect(), - PresetType::Position => self - .position - .iter() - .cloned() - .map(Preset::Position) - .collect(), - PresetType::Intensity => self - .intensity - .iter() - .cloned() - .map(Preset::Intensity) - .collect(), - PresetType::Beam => self.beam.iter().cloned().map(Preset::Beam).collect(), - PresetType::Effect => self.effect.iter().cloned().map(Preset::Effect).collect(), - } - } - - /// Get all presets - pub fn get_all_presets(&self) -> Vec { - let mut presets = Vec::new(); - presets.extend(self.color.iter().cloned().map(Preset::Color)); - presets.extend(self.position.iter().cloned().map(Preset::Position)); - presets.extend(self.intensity.iter().cloned().map(Preset::Intensity)); - presets.extend(self.beam.iter().cloned().map(Preset::Beam)); - presets.extend(self.effect.iter().cloned().map(Preset::Effect)); - presets - } - - /// Get next available ID for a preset type - pub fn next_id(&self, preset_type: &PresetType) -> usize { - let max_id = match preset_type { - PresetType::Color => self.color.iter().map(|p| p.id).max().unwrap_or(0), - PresetType::Position => self.position.iter().map(|p| p.id).max().unwrap_or(0), - PresetType::Intensity => self.intensity.iter().map(|p| p.id).max().unwrap_or(0), - PresetType::Beam => self.beam.iter().map(|p| p.id).max().unwrap_or(0), - PresetType::Effect => self.effect.iter().map(|p| p.id).max().unwrap_or(0), - }; - max_id + 1 - } - - /// Get presets that apply to a specific fixture group - pub fn get_presets_for_group(&self, group_id: usize) -> Vec { - self.get_all_presets() - .into_iter() - .filter(|preset| preset.fixture_groups().contains(&group_id)) - .collect() - } -} diff --git a/crates/core/src/programmer.rs b/crates/core/src/programmer.rs deleted file mode 100644 index dcacf50..0000000 --- a/crates/core/src/programmer.rs +++ /dev/null @@ -1,92 +0,0 @@ -use halo_fixtures::ChannelType; - -use crate::{EffectMapping, StaticValue}; - -#[derive(Clone)] -pub struct Programmer { - values: Vec, - effects: Vec, - preview_mode: bool, - collapsed: bool, - selected_fixtures: Vec, -} - -impl Programmer { - pub fn new() -> Self { - Self { - values: Vec::new(), - effects: Vec::new(), - preview_mode: false, - collapsed: false, - selected_fixtures: Vec::new(), - } - } - - pub fn add_value(&mut self, fixture_id: usize, channel_type: ChannelType, value: u8) { - // Remove any existing value for this fixture/channel combination - self.values - .retain(|v| !(v.fixture_id == fixture_id && v.channel_type == channel_type)); - - // Add the new value - self.values.push(StaticValue { - fixture_id, - channel_type, - value, - }); - } - - pub fn get_values(&self) -> &Vec { - &self.values - } - - pub fn add_effect(&mut self, effect: EffectMapping) { - self.effects.push(effect); - } - - pub fn get_effects(&self) -> &Vec { - &self.effects - } - - pub fn set_preview_mode(&mut self, preview_mode: bool) { - self.preview_mode = preview_mode; - } - - pub fn get_preview_mode(&self) -> bool { - self.preview_mode - } - - pub fn clear(&mut self) { - self.values.clear(); - self.effects.clear(); - } - - pub fn set_collapsed(&mut self, collapsed: bool) { - self.collapsed = collapsed; - } - - pub fn get_collapsed(&self) -> bool { - self.collapsed - } - - pub fn set_selected_fixtures(&mut self, fixtures: Vec) { - self.selected_fixtures = fixtures; - } - - pub fn add_selected_fixture(&mut self, fixture_id: usize) { - if !self.selected_fixtures.contains(&fixture_id) { - self.selected_fixtures.push(fixture_id); - } - } - - pub fn remove_selected_fixture(&mut self, fixture_id: usize) { - self.selected_fixtures.retain(|&id| id != fixture_id); - } - - pub fn clear_selected_fixtures(&mut self) { - self.selected_fixtures.clear(); - } - - pub fn get_selected_fixtures(&self) -> &Vec { - &self.selected_fixtures - } -} diff --git a/crates/core/src/rhythm/mod.rs b/crates/core/src/rhythm/mod.rs deleted file mode 100644 index 3f910ac..0000000 --- a/crates/core/src/rhythm/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod rhythm; diff --git a/crates/core/src/rhythm/rhythm.rs b/crates/core/src/rhythm/rhythm.rs deleted file mode 100644 index ce05e7b..0000000 --- a/crates/core/src/rhythm/rhythm.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::time::Instant; - -use serde::{Deserialize, Serialize}; - -// Assuming we have access to these from our rhythm engine -#[derive(Debug, Clone)] -pub struct RhythmState { - pub beat_phase: f64, // 0.0 to 1.0, resets each beat - pub bar_phase: f64, // 0.0 to 1.0, resets each bar - pub phrase_phase: f64, // 0.0 to 1.0, resets each phrase - pub beats_per_bar: u32, - pub bars_per_phrase: u32, - pub last_tap_time: Option, - pub tap_count: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum Interval { - Beat, - Bar, - Phrase, -} diff --git a/crates/core/src/show/mod.rs b/crates/core/src/show/mod.rs deleted file mode 100644 index 9aaab1b..0000000 --- a/crates/core/src/show/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod show; -pub mod show_manager; diff --git a/crates/core/src/show/show.rs b/crates/core/src/show/show.rs deleted file mode 100644 index ee1f462..0000000 --- a/crates/core/src/show/show.rs +++ /dev/null @@ -1,30 +0,0 @@ -use std::time::SystemTime; - -use halo_fixtures::Fixture; -use serde::{Deserialize, Serialize}; - -use crate::CueList; - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct Show { - pub name: String, - pub created_at: SystemTime, - pub modified_at: SystemTime, - pub fixtures: Vec, - pub cue_lists: Vec, - pub version: String, // Schema version for future compatibility -} - -impl Show { - pub fn new(name: String) -> Self { - let now = SystemTime::now(); - Self { - name, - created_at: now, - modified_at: now, - fixtures: Vec::new(), - cue_lists: Vec::new(), - version: env!("CARGO_PKG_VERSION").to_string(), - } - } -} diff --git a/crates/core/src/show/show_manager.rs b/crates/core/src/show/show_manager.rs deleted file mode 100644 index d414074..0000000 --- a/crates/core/src/show/show_manager.rs +++ /dev/null @@ -1,103 +0,0 @@ -use std::fs::{self, File}; -use std::path::{Path, PathBuf}; - -use anyhow::Result; -use serde_json::{from_reader, to_writer_pretty}; - -use super::show::Show; - -pub struct ShowManager { - shows_directory: PathBuf, - current_show: Option, - current_path: Option, -} - -impl ShowManager { - pub fn new() -> Result { - // Get the current working directory - let shows_dir = std::env::current_dir()?; - - Ok(Self { - shows_directory: shows_dir, - current_show: None, - current_path: None, - }) - } - - pub fn new_show(&mut self, name: String) -> Show { - let show = Show::new(name); - self.current_show = Some(show.clone()); - self.current_path = None; - show - } - - pub fn get_current_path(&self) -> Option { - self.current_path.clone() - } - - pub fn save_show(&mut self, show: &Show) -> Result { - let path = if let Some(path) = &self.current_path { - path.clone() - } else { - // Create a new file path based on show name - let sanitized_name = show.name.replace(" ", "_").to_lowercase(); - self.shows_directory - .join(format!("{}.json", sanitized_name)) - }; - - // Save to disk - let file = File::create(&path)?; - to_writer_pretty(file, &show)?; - - self.current_show = Some(show.clone()); - self.current_path = Some(path.clone()); - - Ok(path) - } - - pub fn save_show_as(&mut self, show: &Show, path: PathBuf) -> Result { - let file = File::create(&path)?; - to_writer_pretty(file, &show)?; - - self.current_show = Some(show.clone()); - self.current_path = Some(path.clone()); - - Ok(path) - } - - pub fn load_show(&mut self, path: &Path) -> Result { - let file = File::open(path)?; - let show: Show = from_reader(file)?; - - self.current_show = Some(show.clone()); - self.current_path = Some(path.to_path_buf()); - - Ok(show) - } - - pub fn list_shows(&self) -> Result> { - let entries = fs::read_dir(&self.shows_directory)?; - - let mut shows = Vec::new(); - for entry in entries { - let entry = entry?; - let path = entry.path(); - - if path.is_file() && path.extension().map_or(false, |ext| ext == "json") { - shows.push(path); - } - } - - Ok(shows) - } -} - -impl Clone for ShowManager { - fn clone(&self) -> Self { - Self { - shows_directory: self.shows_directory.clone(), - current_show: self.current_show.clone(), - current_path: self.current_path.clone(), - } - } -} diff --git a/crates/core/src/timecode/mod.rs b/crates/core/src/timecode/mod.rs deleted file mode 100644 index a9e6f33..0000000 --- a/crates/core/src/timecode/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod timecode; diff --git a/crates/core/src/timecode/timecode.rs b/crates/core/src/timecode/timecode.rs deleted file mode 100644 index 14ef11c..0000000 --- a/crates/core/src/timecode/timecode.rs +++ /dev/null @@ -1,108 +0,0 @@ -use std::time::{Duration, Instant}; - -#[derive(Clone, Debug, Copy)] -pub struct TimeCode { - pub hours: u8, - pub minutes: u8, - pub seconds: u8, - pub frames: u8, - pub frame_rate: u8, - last_update: Instant, -} - -impl Default for TimeCode { - fn default() -> Self { - Self { - hours: 0, - minutes: 0, - seconds: 0, - frames: 0, - frame_rate: 30, // Default to 30fps - last_update: Instant::now(), - } - } -} - -impl TimeCode { - pub fn update(&mut self) { - let now = Instant::now(); - let elapsed = now.duration_since(self.last_update); - - // Only update at the configured frame rate - if elapsed > Duration::from_millis(1000 / self.frame_rate as u64) { - self.last_update = now; - - // Update timecode - self.frames += 1; - if self.frames >= self.frame_rate { - self.frames = 0; - self.seconds += 1; - } - if self.seconds >= 60 { - self.seconds = 0; - self.minutes += 1; - } - if self.minutes >= 60 { - self.minutes = 0; - self.hours += 1; - } - } - } - - pub fn reset(&mut self) { - self.hours = 0; - self.minutes = 0; - self.seconds = 0; - self.frames = 0; - self.last_update = Instant::now(); - } - - pub fn set_frame_rate(&mut self, frame_rate: u8) { - self.frame_rate = frame_rate; - } - - /// Create a timecode from seconds - pub fn from_seconds(total_seconds: f64, frame_rate: u8) -> Self { - let hours = (total_seconds / 3600.0) as u8; - let minutes = ((total_seconds % 3600.0) / 60.0) as u8; - let seconds = (total_seconds % 60.0) as u8; - let frames = ((total_seconds % 1.0) * frame_rate as f64) as u8; - - Self { - hours, - minutes, - seconds, - frames, - frame_rate, - last_update: Instant::now(), - } - } - - pub fn to_seconds(&self) -> f64 { - self.hours as f64 * 3600.0 - + self.minutes as f64 * 60.0 - + self.seconds as f64 - + self.frames as f64 / self.frame_rate as f64 - } - - pub fn from_string(&mut self, timecode: &str) -> Result<(), String> { - let parts: Vec<&str> = timecode.split(':').collect(); - if parts.len() < 4 { - return Err("Invalid timecode format. Expected HH:MM:SS:FF".to_string()); - } - - self.hours = parts[0].parse().map_err(|_| "Invalid hours")?; - self.minutes = parts[1].parse().map_err(|_| "Invalid minutes")?; - self.seconds = parts[2].parse().map_err(|_| "Invalid seconds")?; - self.frames = parts[3].parse().map_err(|_| "Invalid frames")?; - - Ok(()) - } - - pub fn to_string(&self) -> String { - format!( - "{:02}:{:02}:{:02}:{:02}", - self.hours, self.minutes, self.seconds, self.frames - ) - } -} diff --git a/crates/core/src/tracking_state.rs b/crates/core/src/tracking_state.rs deleted file mode 100644 index 9987444..0000000 --- a/crates/core/src/tracking_state.rs +++ /dev/null @@ -1,114 +0,0 @@ -use std::collections::HashMap; - -use crate::{Cue, EffectMapping, PixelEffectMapping, StaticValue}; - -/// Manages accumulated tracking state for a tracking console -/// Values and effects persist across cues until explicitly changed or cleared by blocking cues -#[derive(Clone)] -pub struct TrackingState { - /// Accumulated fixture channel values - accumulated_values: Vec, - /// Active effects that continue to run - active_effects: HashMap, - /// Active pixel effects that continue to run - active_pixel_effects: HashMap, -} - -impl TrackingState { - /// Create a new empty tracking state - pub fn new() -> Self { - Self { - accumulated_values: Vec::new(), - active_effects: HashMap::new(), - active_pixel_effects: HashMap::new(), - } - } - - /// Apply a cue to the tracking state (merges values and effects) - pub fn apply_cue(&mut self, cue: &Cue) { - // Merge static values into accumulated state - for value in &cue.static_values { - // Find and update existing value or add new one - if let Some(existing) = self - .accumulated_values - .iter_mut() - .find(|v| v.fixture_id == value.fixture_id && v.channel_type == value.channel_type) - { - existing.value = value.value; - } else { - self.accumulated_values.push(value.clone()); - } - } - - // Process effects based on release behavior - for effect_mapping in &cue.effects { - // Add or update the effect in tracking state - self.active_effects - .insert(effect_mapping.name.clone(), effect_mapping.clone()); - } - - // Process pixel effects based on release behavior - for pixel_effect_mapping in &cue.pixel_effects { - // Add or update the pixel effect in tracking state - self.active_pixel_effects.insert( - pixel_effect_mapping.name.clone(), - pixel_effect_mapping.clone(), - ); - } - } - - /// Apply a blocking cue (clears tracking state, then applies the cue) - pub fn apply_blocking_cue(&mut self, cue: &Cue) { - // Clear all tracking state - self.clear(); - - // Apply the blocking cue's values - self.apply_cue(cue); - } - - /// Get all tracked static values for rendering - pub fn get_static_values(&self) -> Vec { - self.accumulated_values.clone() - } - - /// Get all active effects - pub fn get_effects(&self) -> Vec { - self.active_effects.values().cloned().collect() - } - - /// Get all active pixel effects - pub fn get_pixel_effects(&self) -> Vec { - self.active_pixel_effects.values().cloned().collect() - } - - /// Clear all tracking state - pub fn clear(&mut self) { - self.accumulated_values.clear(); - self.active_effects.clear(); - self.active_pixel_effects.clear(); - } - - /// Check if tracking state is empty - pub fn is_empty(&self) -> bool { - self.accumulated_values.is_empty() - && self.active_effects.is_empty() - && self.active_pixel_effects.is_empty() - } - - /// Get the number of active effects - pub fn active_effect_count(&self) -> usize { - self.active_effects.len() + self.active_pixel_effects.len() - } - - /// Add or update an effect in the tracking state - pub fn add_effect(&mut self, effect_mapping: EffectMapping) { - self.active_effects - .insert(effect_mapping.name.clone(), effect_mapping); - } -} - -impl Default for TrackingState { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/fixtures/Cargo.toml b/crates/fixtures/Cargo.toml deleted file mode 100644 index 0fc2024..0000000 --- a/crates/fixtures/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "halo-fixtures" -version = "0.1.0" -authors = ["Rob Morgan "] -edition = "2021" - -[dependencies] -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" diff --git a/crates/fixtures/src/fixture_library.rs b/crates/fixtures/src/fixture_library.rs deleted file mode 100644 index 473b23b..0000000 --- a/crates/fixtures/src/fixture_library.rs +++ /dev/null @@ -1,535 +0,0 @@ -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -use crate::{channel_layout, FixtureType}; - -#[derive(Clone, Debug, Default)] -pub struct FixtureProfile { - pub id: String, - pub fixture_type: FixtureType, - pub manufacturer: String, - pub model: String, - pub channel_layout: Vec, -} - -impl std::fmt::Display for FixtureProfile { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} {}", self.manufacturer, self.model) - } -} - -#[derive(Clone, Debug, Default)] -pub struct FixtureLibrary { - pub profiles: HashMap, -} - -impl FixtureLibrary { - pub fn new() -> Self { - let mut profiles = HashMap::new(); - - // Define all fixture profiles. Note in the future we'll load these from disk. - profiles.insert( - "shehds-rgbw-par".to_string(), - FixtureProfile { - id: "shehds-rgbw-par".to_string(), - fixture_type: FixtureType::PAR, - manufacturer: "Shehds".to_string(), - model: "LED Flat PAR 12x3W RGBW".to_string(), - channel_layout: vec![ - Channel { - name: "Dimmer".to_string(), - channel_type: ChannelType::Dimmer, - value: 0, - }, - Channel { - name: "Red".to_string(), - channel_type: ChannelType::Red, - value: 0, - }, - Channel { - name: "Green".to_string(), - channel_type: ChannelType::Green, - value: 0, - }, - Channel { - name: "Blue".to_string(), - channel_type: ChannelType::Blue, - value: 0, - }, - Channel { - name: "White".to_string(), - channel_type: ChannelType::White, - value: 0, - }, - Channel { - name: "Strobe".to_string(), - channel_type: ChannelType::Strobe, - value: 0, - }, - Channel { - name: "Program".to_string(), - channel_type: ChannelType::Other("Program".to_string()), - value: 0, - }, - Channel { - name: "Function".to_string(), - channel_type: ChannelType::Other("Function".to_string()), - value: 0, - }, - ], - }, - ); - - profiles.insert( - "shehds-led-spot-60w".to_string(), - FixtureProfile { - id: "shehds-led-spot-60w".to_string(), - fixture_type: FixtureType::MovingHead, - manufacturer: "Shehds".to_string(), - model: "LED Spot 60W Lighting".to_string(), - channel_layout: vec![ - Channel { - name: "Pan".to_string(), - channel_type: ChannelType::Pan, - value: 0, - }, - Channel { - name: "Tilt".to_string(), - channel_type: ChannelType::Tilt, - value: 0, - }, - Channel { - name: "Color".to_string(), - channel_type: ChannelType::Color, - value: 0, - }, - Channel { - name: "Gobo".to_string(), - channel_type: ChannelType::Gobo, - value: 0, - }, - Channel { - name: "Strobe".to_string(), - channel_type: ChannelType::Strobe, - value: 0, - }, - Channel { - name: "Dimmer".to_string(), - channel_type: ChannelType::Dimmer, - value: 0, - }, - Channel { - name: "Speed".to_string(), - channel_type: ChannelType::Other("Speed".to_string()), - value: 0, - }, - Channel { - name: "Auto".to_string(), - channel_type: ChannelType::Other("Auto".to_string()), - value: 0, - }, - Channel { - name: "Reset".to_string(), - channel_type: ChannelType::Other("Reset".to_string()), - value: 0, - }, - ], - }, - ); - - profiles.insert( - "shehds-led-wash-7x18w-rgbwa-uv".to_string(), - FixtureProfile { - id: "shehds-led-wash-7x18w-rgbwa-uv".to_string(), - fixture_type: FixtureType::Wash, - manufacturer: "Shehds".to_string(), - model: "LED Wash 7x18W RGBWA+UV".to_string(), - channel_layout: vec![ - Channel { - name: "Pan".to_string(), - channel_type: ChannelType::Pan, - value: 0, - }, - Channel { - name: "Tilt".to_string(), - channel_type: ChannelType::Tilt, - value: 0, - }, - Channel { - name: "Dimmer".to_string(), - channel_type: ChannelType::Dimmer, - value: 0, - }, - Channel { - name: "Red".to_string(), - channel_type: ChannelType::Red, - value: 0, - }, - Channel { - name: "Green".to_string(), - channel_type: ChannelType::Green, - value: 0, - }, - Channel { - name: "Blue".to_string(), - channel_type: ChannelType::Blue, - value: 0, - }, - Channel { - name: "White".to_string(), - channel_type: ChannelType::White, - value: 0, - }, - Channel { - name: "Amber".to_string(), - channel_type: ChannelType::Amber, - value: 0, - }, - Channel { - name: "UV".to_string(), - channel_type: ChannelType::UV, - value: 0, - }, - Channel { - name: "Function".to_string(), - // TODO - I think this is XY speed? Check the manual and update accordingly. - channel_type: ChannelType::Other("Function".to_string()), - value: 0, - }, - ], - }, - ); - - profiles.insert( - "shehds-mini-led-pinspot-10w".to_string(), - FixtureProfile { - id: "shehds-mini-led-pinspot-10w".to_string(), - fixture_type: FixtureType::Pinspot, - manufacturer: "Shehds".to_string(), - model: "Mini LED Pinspot 10W".to_string(), - channel_layout: channel_layout![ - ("Dimmer", ChannelType::Dimmer), - ("Red", ChannelType::Red), - ("Green", ChannelType::Green), - ("Blue", ChannelType::Blue), - ("White", ChannelType::White), - ("Strobe", ChannelType::Strobe), - // 0-50: no effect - // 51-100: color selection mode - // 101-150: Jump mode - // 151-200: Gradient mode - // 201-250: Automatic mode - // 251-255: Voice control mode - ("Function", ChannelType::Other("Function".to_string())), - // From slow to fast - ("Speed", ChannelType::Other("FunctionSpeed".to_string())), - ], - }, - ); - - profiles.insert( - "dl-geyser-1000-led-smoke-machine-1000w-3x9w-rgb".to_string(), - FixtureProfile { - id: "dl-geyser-1000-led-smoke-machine-1000w-3x9w-rgb".to_string(), - fixture_type: FixtureType::Smoke, - manufacturer: "DL Geyser".to_string(), - model: "1000 LED Smoke Machine".to_string(), - channel_layout: vec![ - Channel { - name: "Smoke".to_string(), - channel_type: ChannelType::Other("Smoke".to_string()), - value: 0, - }, - Channel { - name: "Red".to_string(), - channel_type: ChannelType::Red, - value: 0, - }, - Channel { - name: "Green".to_string(), - channel_type: ChannelType::Green, - value: 0, - }, - Channel { - name: "Blue".to_string(), - channel_type: ChannelType::Blue, - value: 0, - }, - Channel { - name: "Strobe".to_string(), - channel_type: ChannelType::Strobe, - value: 0, - }, - Channel { - name: "Effect".to_string(), - // LED Effect - // - 0-50: Off - // - 51-100: Jump - // - 101-200: Gradient - // - 201-255: Color Strobe - channel_type: ChannelType::Other("Function".to_string()), - value: 0, - }, - Channel { - // Works with the Effect channel - name: "Speed".to_string(), - channel_type: ChannelType::Other("FunctionSpeed".to_string()), - value: 0, - }, - ], - }, - ); - - profiles.insert( - "shehds-led-bar-beam-8x12w".to_string(), - FixtureProfile { - id: "shehds-led-bar-beam-8x12w".to_string(), - fixture_type: FixtureType::Beam, - manufacturer: "Shehds".to_string(), - model: "LED Bar Beam 8x12W".to_string(), - channel_layout: channel_layout![ - ("Tilt", ChannelType::Tilt), - ("Tilt Speed", ChannelType::TiltSpeed), - // 0-50: no effect - // 51-100: color selection mode - // 101-150: Jump mode - // 151-200: Gradient mode - // 201-250: Automatic mode - // 251-255: Voice control mode - // 0-20: DMX 10 Channel control. - // 21-70: Transition. - // 71-120: Gradual change. - // 121-170: Clock change. - // 171-220: Run change. - // 221-240: Sound 1 mode. - // 241-255: Sound 2 mode. - ("Function", ChannelType::Function), - // From slow to fast - ("Speed", ChannelType::FunctionSpeed), - ("Dimmer", ChannelType::Dimmer), - ("Red", ChannelType::Red), - ("Green", ChannelType::Green), - ("Blue", ChannelType::Blue), - ("White", ChannelType::White), - ], - }, - ); - - // 1 Intensity Master Dimmer 100% - // 2 Intensity RGB RGB Shutter 0% - // 3 Effects RGB RGB FX No Effect - // 4 Effects RGB RGB FX Spd Speed 0% - // 5 Effects RGB RGB FX Colour Default - // 9 Colour RGB Red 100% 100% 0% - // 10 Colour RGB Green 100% 100% 0% - // 11 Colour RGB Blue 100% 0% 90% - // 6 Intensity White White Shutter 0% - // 7 Effects White White FX No Effect - // 8 Effects White White FX Spd 50% - // 12 Intensity White Dimmer 100% - - // https://personalities.avolites.com/?mainPage=Main.asp&LightName=LED+RGBW+4in1+48+Partition+Strobe+Light&Manufacturer=Unknown - // 12-channel variant - // profiles.insert( - // "hyulights-led-rgbw-4in1-48-partition-strobe".to_string(), - // FixtureProfile { - // id: "hyulights-led-rgbw-4in1-48-partition-strobe".to_string(), - // fixture_type: FixtureType::LEDBar, - // manufacturer: "Hyulights".to_string(), - // model: "200W LED RGBW 4in1 48 Partition Strobe Light".to_string(), - // channel_layout: channel_layout![ - // ("Dimmer", ChannelType::Dimmer), - // ("RGB Strobe", ChannelType::Other("RGBStrobe".to_string())), - // ("Effect FX", ChannelType::Other("Function".to_string())), - // ( - // "Effect FX Speed", - // ChannelType::Other("FunctionSpeed".to_string()) - // ), - // ("Color", ChannelType::Color), - // ("Strobe", ChannelType::Strobe), - // ("White FX", ChannelType::Other("WhiteFunction".to_string())), - // ( - // "White FX Speed", - // ChannelType::Other("WhiteFunctionSpeed".to_string()) - // ), - // ("Red", ChannelType::Red), - // ("Green", ChannelType::Green), - // ("Blue", ChannelType::Blue), - // ("White", ChannelType::White), - // ], - // }, - // ); - - // 6-channel variant - profiles.insert( - "hyulights-led-rgbw-4in1-48-partition-strobe".to_string(), - FixtureProfile { - id: "hyulights-led-rgbw-4in1-48-partition-strobe".to_string(), - fixture_type: FixtureType::LEDBar, - manufacturer: "Hyulights".to_string(), - model: "200W LED RGBW 4in1 48 Partition Strobe Light".to_string(), - channel_layout: channel_layout![ - ("Dimmer", ChannelType::Dimmer), - ("Strobe", ChannelType::Strobe), - ("Red", ChannelType::Red), - ("Green", ChannelType::Green), - ("Blue", ChannelType::Blue), - ("White", ChannelType::White), - ], - }, - ); - - profiles.insert( - "hyulights-led-rgbw-par".to_string(), - FixtureProfile { - id: "hyulights-led-rgbw-par".to_string(), - fixture_type: FixtureType::PAR, - manufacturer: "Hyulights".to_string(), - model: "LED RGBW PAR Light".to_string(), - channel_layout: channel_layout![ - ("Dimmer", ChannelType::Dimmer), - ("Red", ChannelType::Red), - ("Green", ChannelType::Green), - ("Blue", ChannelType::Blue), - ("White", ChannelType::White), - ("Strobe", ChannelType::Strobe), - ("Function", ChannelType::Function), - ("Function Speed", ChannelType::FunctionSpeed), - ], - }, - ); - - // Pixel Bar Fixtures - profiles.insert( - "generic-rgb-pixel-bar-30".to_string(), - FixtureProfile { - id: "generic-rgb-pixel-bar-30".to_string(), - fixture_type: FixtureType::PixelBar, - manufacturer: "Generic".to_string(), - model: "RGB Pixel Bar 30 Pixels".to_string(), - channel_layout: Self::create_pixel_bar_channels(30), - }, - ); - - profiles.insert( - "generic-rgb-pixel-bar-60".to_string(), - FixtureProfile { - id: "generic-rgb-pixel-bar-60".to_string(), - fixture_type: FixtureType::PixelBar, - manufacturer: "Generic".to_string(), - model: "RGB Pixel Bar 60 Pixels".to_string(), - channel_layout: Self::create_pixel_bar_channels(60), - }, - ); - - profiles.insert( - "generic-rgb-pixel-bar-144".to_string(), - FixtureProfile { - id: "generic-rgb-pixel-bar-144".to_string(), - fixture_type: FixtureType::PixelBar, - manufacturer: "Generic".to_string(), - model: "RGB Pixel Bar 144 Pixels".to_string(), - channel_layout: Self::create_pixel_bar_channels(144), - }, - ); - - profiles.insert( - "clen-led-pixel-bar-64".to_string(), - FixtureProfile { - id: "clen-led-pixel-bar-64".to_string(), - fixture_type: FixtureType::PixelBar, - manufacturer: "Clen".to_string(), - model: "LED Pixel Bar 64 Pixels RGB".to_string(), - channel_layout: Self::create_pixel_bar_channels(64), - }, - ); - - FixtureLibrary { profiles } - } - - /// Create channel layout for a pixel bar with given number of pixels - fn create_pixel_bar_channels(pixel_count: usize) -> Vec { - let mut channels = Vec::with_capacity(pixel_count * 3); - for i in 0..pixel_count { - channels.push(Channel { - name: format!("Pixel {} Red", i + 1), - channel_type: ChannelType::PixelRed(i), - value: 0, - }); - channels.push(Channel { - name: format!("Pixel {} Green", i + 1), - channel_type: ChannelType::PixelGreen(i), - value: 0, - }); - channels.push(Channel { - name: format!("Pixel {} Blue", i + 1), - channel_type: ChannelType::PixelBlue(i), - value: 0, - }); - } - channels - } -} - -#[derive(Clone, Debug)] -pub struct Channel { - pub name: String, - pub channel_type: ChannelType, - pub value: u8, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub enum ChannelType { - Dimmer, - Color, - Gobo, - Red, - Green, - Blue, - White, - Amber, - UV, - Strobe, - Pan, - Tilt, - TiltSpeed, - Beam, - Focus, - Zoom, - Function, - FunctionSpeed, - PixelRed(usize), - PixelGreen(usize), - PixelBlue(usize), - Other(String), -} - -impl std::fmt::Display for ChannelType { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - ChannelType::Dimmer => write!(f, "Dimmer"), - ChannelType::Color => write!(f, "Color"), - ChannelType::Gobo => write!(f, "Gobo"), - ChannelType::Red => write!(f, "Red"), - ChannelType::Green => write!(f, "Green"), - ChannelType::Blue => write!(f, "Blue"), - ChannelType::White => write!(f, "White"), - ChannelType::Amber => write!(f, "Amber"), - ChannelType::UV => write!(f, "UV"), - ChannelType::Strobe => write!(f, "Strobe"), - ChannelType::Pan => write!(f, "Pan"), - ChannelType::Tilt => write!(f, "Tilt"), - ChannelType::TiltSpeed => write!(f, "TiltSpeed"), - ChannelType::Beam => write!(f, "Beam"), - ChannelType::Focus => write!(f, "Focus"), - ChannelType::Zoom => write!(f, "Zoom"), - ChannelType::Function => write!(f, "Function"), - ChannelType::FunctionSpeed => write!(f, "FunctionSpeed"), - ChannelType::PixelRed(idx) => write!(f, "PixelRed({})", idx), - ChannelType::PixelGreen(idx) => write!(f, "PixelGreen({})", idx), - ChannelType::PixelBlue(idx) => write!(f, "PixelBlue({})", idx), - ChannelType::Other(s) => write!(f, "Other({})", s), - } - } -} diff --git a/crates/fixtures/src/lib.rs b/crates/fixtures/src/lib.rs deleted file mode 100644 index f3d4b4e..0000000 --- a/crates/fixtures/src/lib.rs +++ /dev/null @@ -1,118 +0,0 @@ -pub use fixture_library::{Channel, ChannelType, FixtureLibrary, FixtureProfile}; -use serde::{Deserialize, Serialize}; - -mod fixture_library; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct PanTiltLimits { - pub pan_min: u8, - pub pan_max: u8, - pub tilt_min: u8, - pub tilt_max: u8, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Fixture { - pub id: usize, - pub name: String, - pub profile_id: String, - #[serde(skip)] - pub profile: FixtureProfile, - #[serde(skip)] // Channels are copied from the profile during initialization - pub channels: Vec, - pub universe: u8, - pub start_address: u16, - #[serde(default)] - pub pan_tilt_limits: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)] -pub enum FixtureType { - #[default] - MovingHead, - PAR, - Wash, - Beam, - LEDBar, - Pinspot, - Smoke, - PixelBar, -} - -impl Fixture { - pub fn new( - id: usize, - name: &str, - profile: FixtureProfile, - channels: Vec, - universe: u8, - start_address: u16, - ) -> Self { - Fixture { - id, - name: name.to_string(), - profile_id: profile.id.clone(), - profile: profile.clone(), - channels, - universe, - start_address, - pan_tilt_limits: None, - } - } - - pub fn set_channel_value(&mut self, channel_type: &ChannelType, value: u8) { - if let Some(channel) = self - .channels - .iter_mut() - .find(|c| c.channel_type == *channel_type) - { - // Apply pan/tilt limits if they exist - let clamped_value = if let Some(limits) = &self.pan_tilt_limits { - match channel_type { - ChannelType::Pan => value.clamp(limits.pan_min, limits.pan_max), - ChannelType::Tilt => value.clamp(limits.tilt_min, limits.tilt_max), - _ => value, - } - } else { - value - }; - - channel.value = clamped_value; - } - } - - pub fn get_dmx_values(&self) -> Vec { - let mut values = Vec::new(); - for channel in &self.channels { - values.push(channel.value); - } - values - } - - pub fn set_pan_tilt_limits(&mut self, limits: PanTiltLimits) { - self.pan_tilt_limits = Some(limits); - } - - pub fn clear_pan_tilt_limits(&mut self) { - self.pan_tilt_limits = None; - } - - pub fn get_pan_tilt_limits(&self) -> Option<&PanTiltLimits> { - self.pan_tilt_limits.as_ref() - } -} - -#[macro_export] -macro_rules! channel_layout { - ($(($name:expr, $type:expr)),* $(,)?) => { - vec![ - $( - Channel { - name: $name.to_string(), - channel_type: $type, - value: 0, - }, - )* - ] - }; -} diff --git a/crates/halo-light/Cargo.toml b/crates/halo-light/Cargo.toml new file mode 100644 index 0000000..ed7f3e5 --- /dev/null +++ b/crates/halo-light/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "halo-light" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +artnet_protocol = "0.4.4" +log = { workspace = true } +serde = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/halo-light/src/artnet.rs b/crates/halo-light/src/artnet.rs new file mode 100644 index 0000000..4d7e8bc --- /dev/null +++ b/crates/halo-light/src/artnet.rs @@ -0,0 +1,202 @@ +//! Art-Net DMX output, ported from halo-old's `crates/core/src/artnet`. +//! +//! Synchronous by design: a 512-byte UDP send is microseconds, so the +//! DMX engine thread calls [`ArtNet::send`] directly from its 44 Hz tick. +//! Errors are returned (never panicked) so a mid-show network hiccup +//! degrades to a dropped frame, not a crash. + +use std::collections::HashMap; +use std::io; +use std::net::{SocketAddr, ToSocketAddrs, UdpSocket}; + +use artnet_protocol::{ArtCommand, Output}; +use log::debug; +use serde::{Deserialize, Serialize}; + +/// The standard Art-Net UDP port. +pub const ARTNET_PORT: u16 = 6454; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum ArtNetMode { + Broadcast, + /// From (interface) + to (destination) addresses. + Unicast(SocketAddr, SocketAddr), +} + +/// One open Art-Net socket aimed at a destination. +pub struct ArtNet { + socket: UdpSocket, + destination: SocketAddr, + pub mode: ArtNetMode, +} + +impl ArtNet { + pub fn new(mode: ArtNetMode) -> io::Result { + match mode { + ArtNetMode::Broadcast => { + // Ephemeral local port so multiple broadcast sockets can + // coexist (one per destination). + let socket = UdpSocket::bind(("0.0.0.0", 0))?; + socket.set_broadcast(true)?; + let destination = ("255.255.255.255", ARTNET_PORT) + .to_socket_addrs()? + .next() + .expect("broadcast addr always resolves"); + debug!( + "Art-Net broadcast ready on local port {}", + socket.local_addr()?.port() + ); + Ok(ArtNet { + socket, + destination, + mode, + }) + } + ArtNetMode::Unicast(src, destination) => { + // Bind to the source IP (interface selection) with an + // ephemeral port. + let socket = UdpSocket::bind(SocketAddr::new(src.ip(), 0))?; + socket.set_broadcast(false)?; + debug!( + "Art-Net unicast {} -> {} ready on local port {}", + src.ip(), + destination, + socket.local_addr()?.port() + ); + Ok(ArtNet { + socket, + destination, + mode, + }) + } + } + } + + /// Send one universe's channel data as an ArtDmx packet. + pub fn send(&self, universe: u8, dmx: &[u8]) -> io::Result<()> { + let command = ArtCommand::Output(Output { + port_address: universe.into(), + data: dmx.to_vec().into(), + ..Output::default() + }); + let bytes = command + .write_to_buffer() + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + self.socket.send_to(&bytes, self.destination)?; + Ok(()) + } +} + +/// A named place to send Art-Net (a node, or the broadcast domain). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ArtNetDestination { + pub name: String, + pub mode: ArtNetMode, +} + +/// Where each universe goes. Persisted with app settings. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct NetworkConfig { + pub destinations: Vec, + /// universe -> index into `destinations`. + pub universe_routing: HashMap, +} + +impl NetworkConfig { + /// Single-destination config routing universe 1, the common case. + pub fn single(name: &str, mode: ArtNetMode) -> Self { + NetworkConfig { + destinations: vec![ArtNetDestination { + name: name.to_string(), + mode, + }], + universe_routing: HashMap::from([(1, 0)]), + } + } + + /// Add a destination and return its index. + pub fn add_destination(&mut self, destination: ArtNetDestination) -> usize { + self.destinations.push(destination); + self.destinations.len() - 1 + } + + /// Route a universe to a destination by index; out-of-range indices + /// are ignored. + pub fn route_universe(&mut self, universe: u8, destination_index: usize) { + if destination_index < self.destinations.len() { + self.universe_routing.insert(universe, destination_index); + } + } + + pub fn destination_for_universe(&self, universe: u8) -> Option { + self.universe_routing.get(&universe).copied() + } + + /// Open a socket per destination. Returns the connections in + /// destination order so `universe_routing` indices line up. + pub fn connect(&self) -> io::Result> { + self.destinations + .iter() + .map(|d| ArtNet::new(d.mode.clone())) + .collect() + } + + /// Human-readable summary for the settings panel. + pub fn summary(&self) -> String { + if self.destinations.is_empty() { + return "no destinations configured".to_string(); + } + self.destinations + .iter() + .map(|d| match &d.mode { + ArtNetMode::Broadcast => { + format!("{}: 255.255.255.255:{ARTNET_PORT}", d.name) + } + ArtNetMode::Unicast(src, dst) => { + format!("{}: {} -> {}", d.name, src.ip(), dst) + } + }) + .collect::>() + .join(", ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn routing_maps_universes_to_destinations() { + let mut config = NetworkConfig::single("main", ArtNetMode::Broadcast); + let ode = config.add_destination(ArtNetDestination { + name: "ode-mk2".to_string(), + mode: ArtNetMode::Unicast( + "10.8.45.1:6454".parse::().unwrap(), + "10.8.45.80:6454".parse::().unwrap(), + ), + }); + config.route_universe(2, ode); + config.route_universe(9, 99); // out of range: ignored + + assert_eq!(config.destination_for_universe(1), Some(0)); + assert_eq!(config.destination_for_universe(2), Some(ode)); + assert_eq!(config.destination_for_universe(9), None); + } + + #[test] + fn network_config_roundtrips_json() { + let config = NetworkConfig::single("main", ArtNetMode::Broadcast); + let json = serde_json::to_string(&config).unwrap(); + let parsed: NetworkConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.destinations.len(), 1); + assert_eq!(parsed.destination_for_universe(1), Some(0)); + } + + #[test] + fn broadcast_socket_binds_and_frames_a_universe() { + // Socket setup only — no packets are sent from tests. + let artnet = ArtNet::new(ArtNetMode::Broadcast).expect("bind broadcast socket"); + assert!(matches!(artnet.mode, ArtNetMode::Broadcast)); + assert_eq!(artnet.destination.port(), ARTNET_PORT); + } +} diff --git a/crates/halo-light/src/cues.rs b/crates/halo-light/src/cues.rs new file mode 100644 index 0000000..7969198 --- /dev/null +++ b/crates/halo-light/src/cues.rs @@ -0,0 +1,347 @@ +//! Editable lighting/pixels/FX cues for a track. +//! +//! `CueSet` is the runtime model (frames, painter-friendly windowed +//! queries, mutation with per-lane sort + non-overlap invariants); +//! `CueFile` is the persisted JSON form stored in the library, in seconds +//! so cues survive device sample-rate changes. + +use std::collections::HashSet; + +/// The three trigger lanes drawn under the zoomed waveform, top to bottom. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum Lane { + Lighting = 0, + Pixels = 1, + Fx = 2, +} + +pub const LANE_COUNT: usize = 3; +pub const ALL_LANES: [Lane; LANE_COUNT] = [Lane::Lighting, Lane::Pixels, Lane::Fx]; + +/// One effect firing: a bar on a lane from `start_frame` for +/// `duration_frames` source frames. Ids are runtime-only (selection +/// handles for the editor) and are not persisted. +#[derive(Debug, Clone, Copy)] +pub struct Cue { + pub id: u64, + pub start_frame: f64, + pub duration_frames: f64, + /// 0..=1; drives bar alpha and, later, the rig level. + pub intensity: f32, +} + +impl Cue { + pub fn end_frame(&self) -> f64 { + self.start_frame + self.duration_frames + } +} + +/// Smallest representable cue, in frames — guards against degenerate +/// zero-width cues from clamping; musical minimums are enforced by the +/// editor. +const MIN_DUR_FRAMES: f64 = 1.0; + +/// All cues for a track. Invariant per lane: sorted by start frame, +/// non-overlapping. Mutators clamp rather than reject, so editor drags +/// slide a cue until it butts its neighbor. +#[derive(Debug, Clone, Default)] +pub struct CueSet { + lanes: [Vec; LANE_COUNT], + /// Per-lane max duration, to widen the visibility window cheaply. + max_duration: [f64; LANE_COUNT], + next_id: u64, +} + +impl CueSet { + pub fn empty() -> Self { + Self::default() + } + + /// Cues possibly overlapping `[start, end)`: a binary search on start + /// frame, widened by the lane's max duration (same idea as + /// `GridMarks::visible_range`). + pub fn visible(&self, lane: Lane, start: f64, end: f64) -> &[Cue] { + let l = lane as usize; + let v = &self.lanes[l]; + let lo = v.partition_point(|c| c.start_frame < start - self.max_duration[l]); + let hi = v.partition_point(|c| c.start_frame < end); + &v[lo..hi] + } + + /// The cue covering `frame` on `lane`, if any. + pub fn active_at(&self, lane: Lane, frame: f64) -> Option<&Cue> { + self.visible(lane, frame, frame + 1.0) + .iter() + .find(|c| c.start_frame <= frame && frame < c.end_frame()) + } + + pub fn find(&self, id: u64) -> Option<(Lane, Cue)> { + for lane in ALL_LANES { + if let Some(c) = self.lanes[lane as usize].iter().find(|c| c.id == id) { + return Some((lane, *c)); + } + } + None + } + + fn rescan_lane(&mut self, lane: Lane) { + let l = lane as usize; + self.lanes[l].sort_by(|a, b| a.start_frame.total_cmp(&b.start_frame)); + self.max_duration[l] = self.lanes[l] + .iter() + .map(|c| c.duration_frames) + .fold(0.0, f64::max); + } + + /// Insert a cue, truncated into the free gap around `start`; `None` + /// when there is no usable gap. + pub fn insert(&mut self, lane: Lane, start: f64, dur: f64, intensity: f32) -> Option { + let l = lane as usize; + let mut start = start.max(0.0); + let idx = self.lanes[l].partition_point(|c| c.start_frame < start); + if let Some(prev) = idx.checked_sub(1).map(|i| &self.lanes[l][i]) { + start = start.max(prev.end_frame()); + } + let mut end = start + dur.max(MIN_DUR_FRAMES); + if let Some(next) = self.lanes[l].get(idx) { + end = end.min(next.start_frame); + } + if end - start < MIN_DUR_FRAMES { + return None; + } + let id = self.next_id; + self.next_id += 1; + self.lanes[l].push(Cue { + id, + start_frame: start, + duration_frames: end - start, + intensity: intensity.clamp(0.0, 1.0), + }); + self.rescan_lane(lane); + Some(id) + } + + /// Neighbor bounds of cue `pos` in `lane`: (min start, max end). + fn gap_around(&self, lane: Lane, pos: usize) -> (f64, f64) { + let v = &self.lanes[lane as usize]; + let lo = pos.checked_sub(1).map_or(0.0, |i| v[i].end_frame()); + let hi = v.get(pos + 1).map_or(f64::INFINITY, |c| c.start_frame); + (lo, hi) + } + + fn position_of(&self, id: u64) -> Option<(Lane, usize)> { + for lane in ALL_LANES { + if let Some(i) = self.lanes[lane as usize].iter().position(|c| c.id == id) { + return Some((lane, i)); + } + } + None + } + + /// Move a cue, keeping its duration; clamped between its neighbors. + pub fn move_cue(&mut self, id: u64, new_start: f64) { + let Some((lane, i)) = self.position_of(id) else { + return; + }; + // Moving can pass over neighbors only by removing + reinserting; + // v1 clamps within the current gap, which reads as the cue + // butting its neighbor mid-drag. + let (lo, hi) = self.gap_around(lane, i); + let cue = &mut self.lanes[lane as usize][i]; + let max_start = (hi - cue.duration_frames).max(lo); + cue.start_frame = new_start.clamp(lo.max(0.0), max_start.max(0.0)); + self.rescan_lane(lane); + } + + /// Resize a cue to `[new_start, new_end)`, clamped to its neighbors. + pub fn resize(&mut self, id: u64, new_start: f64, new_end: f64) { + let Some((lane, i)) = self.position_of(id) else { + return; + }; + let (lo, hi) = self.gap_around(lane, i); + let cue = &mut self.lanes[lane as usize][i]; + let start = new_start.clamp(lo.max(0.0), hi - MIN_DUR_FRAMES); + let end = new_end.clamp(start + MIN_DUR_FRAMES, hi); + cue.start_frame = start; + cue.duration_frames = end - start; + self.rescan_lane(lane); + } + + pub fn set_intensity(&mut self, id: u64, v: f32) { + if let Some((lane, i)) = self.position_of(id) { + self.lanes[lane as usize][i].intensity = v.clamp(0.0, 1.0); + } + } + + pub fn remove(&mut self, ids: &HashSet) { + for lane in ALL_LANES { + self.lanes[lane as usize].retain(|c| !ids.contains(&c.id)); + self.rescan_lane(lane); + } + } + + pub fn clear_lane(&mut self, lane: Lane) { + self.lanes[lane as usize].clear(); + self.max_duration[lane as usize] = 0.0; + } + + /// Persisted form, in seconds. + pub fn to_file(&self, sample_rate: u32) -> CueFile { + let sr = sample_rate.max(1) as f64; + CueFile { + version: 1, + lanes: ALL_LANES.map(|lane| { + self.lanes[lane as usize] + .iter() + .map(|c| CueJson { + start: c.start_frame / sr, + dur: c.duration_frames / sr, + intensity: c.intensity, + }) + .collect() + }), + } + } + + /// Rebuild from the persisted form at the current device rate. Fresh + /// ids; defensively re-sorts and drops any overlapping cues. + pub fn from_file(file: &CueFile, sample_rate: u32) -> Self { + let sr = sample_rate.max(1) as f64; + let mut set = Self::empty(); + for lane in ALL_LANES { + let mut sorted: Vec<&CueJson> = file.lanes[lane as usize].iter().collect(); + sorted.sort_by(|a, b| a.start.total_cmp(&b.start)); + for c in sorted { + set.insert(lane, c.start * sr, c.dur * sr, c.intensity); + } + } + set + } +} + +/// JSON blob stored per track in the library's `lighting_cues` table. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct CueFile { + pub version: u32, + pub lanes: [Vec; LANE_COUNT], +} + +/// One persisted cue, in seconds. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct CueJson { + pub start: f64, + pub dur: f64, + pub intensity: f32, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn set_with(cues: &[(f64, f64)]) -> CueSet { + let mut s = CueSet::empty(); + for &(start, dur) in cues { + s.insert(Lane::Lighting, start, dur, 0.8).unwrap(); + } + s + } + + fn spans(s: &CueSet, lane: Lane) -> Vec<(f64, f64)> { + s.visible(lane, f64::MIN, f64::MAX) + .iter() + .map(|c| (c.start_frame, c.duration_frames)) + .collect() + } + + #[test] + fn insert_truncates_into_gap() { + let mut s = set_with(&[(0.0, 100.0), (300.0, 100.0)]); + // Requested span overlaps both neighbors: clamped to [100, 300). + let id = s.insert(Lane::Lighting, 50.0, 500.0, 1.0).unwrap(); + let (_, cue) = s.find(id).unwrap(); + assert_eq!((cue.start_frame, cue.end_frame()), (100.0, 300.0)); + // A fully-occupied gap rejects the insert. + assert!(s.insert(Lane::Lighting, 150.0, 10.0, 1.0).is_none()); + } + + #[test] + fn move_clamps_between_neighbors() { + let mut s = set_with(&[(0.0, 100.0), (200.0, 100.0), (500.0, 100.0)]); + let mid = s.active_at(Lane::Lighting, 250.0).unwrap().id; + s.move_cue(mid, 0.0); // butts the left neighbor + assert_eq!(spans(&s, Lane::Lighting)[1].0, 100.0); + s.move_cue(mid, 1_000.0); // butts the right neighbor + assert_eq!(spans(&s, Lane::Lighting)[1].0, 400.0); + } + + #[test] + fn resize_clamps_and_keeps_min_duration() { + let mut s = set_with(&[(0.0, 100.0), (200.0, 100.0), (500.0, 100.0)]); + let mid = s.active_at(Lane::Lighting, 250.0).unwrap().id; + s.resize(mid, 50.0, 600.0); // both edges hit neighbors + let (_, cue) = s.find(mid).unwrap(); + assert_eq!((cue.start_frame, cue.end_frame()), (100.0, 500.0)); + s.resize(mid, 300.0, 300.0); // collapses to the minimum, not zero + let (_, cue) = s.find(mid).unwrap(); + assert!(cue.duration_frames >= 1.0); + } + + #[test] + fn remove_and_active_at() { + let mut s = set_with(&[(0.0, 100.0), (200.0, 100.0)]); + assert!(s.active_at(Lane::Lighting, 50.0).is_some()); + assert!(s.active_at(Lane::Lighting, 150.0).is_none()); + let first = s.active_at(Lane::Lighting, 50.0).unwrap().id; + s.remove(&HashSet::from([first])); + assert!(s.active_at(Lane::Lighting, 50.0).is_none()); + assert_eq!(spans(&s, Lane::Lighting).len(), 1); + } + + #[test] + fn file_round_trip_across_sample_rates() { + let mut s = CueSet::empty(); + s.insert(Lane::Lighting, 44_100.0, 88_200.0, 0.9); + s.insert(Lane::Pixels, 22_050.0, 11_025.0, 0.5); + s.insert(Lane::Fx, 0.0, 44_100.0, 1.0); + let file = s.to_file(44_100); + // Reload at a different device rate: times in seconds are stable. + let reloaded = CueSet::from_file(&file, 48_000); + let cue = reloaded.active_at(Lane::Lighting, 1.5 * 48_000.0).unwrap(); + assert!((cue.start_frame - 48_000.0).abs() < 1e-6); + assert!((cue.duration_frames - 96_000.0).abs() < 1e-6); + assert!((cue.intensity - 0.9).abs() < 1e-6); + // JSON round-trip too (what the library stores). + let json = serde_json::to_string(&file).unwrap(); + let parsed: CueFile = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.lanes[0].len(), 1); + assert_eq!(parsed.version, 1); + } + + #[test] + fn from_file_drops_overlaps_defensively() { + let file = CueFile { + version: 1, + lanes: [ + vec![ + CueJson { + start: 0.0, + dur: 2.0, + intensity: 1.0, + }, + CueJson { + start: 1.0, // overlaps the first + dur: 2.0, + intensity: 1.0, + }, + ], + Vec::new(), + Vec::new(), + ], + }; + let s = CueSet::from_file(&file, 100); + let got = spans(&s, Lane::Lighting); + // Second cue is truncated into the remaining gap, not overlapping. + assert_eq!(got.len(), 2); + assert!(got[0].0 + got[0].1 <= got[1].0 + 1e-9); + } +} diff --git a/crates/halo-light/src/fixture.rs b/crates/halo-light/src/fixture.rs new file mode 100644 index 0000000..f9b50ef --- /dev/null +++ b/crates/halo-light/src/fixture.rs @@ -0,0 +1,397 @@ +//! Fixture rig model: the programmer's selection grid, with each grid +//! fixture patched to a real profile at a universe + start address. +//! [`default_rig`] builds a plausible club rig from library profiles +//! with auto-assigned addresses; a patching UI will replace it. + +use crate::cues::Lane; +use crate::fixture_library::FixtureLibrary; + +/// sRGB color, UI-toolkit-free; the egui layer converts at the edge. +pub type Rgb = [u8; 3]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum FixtureKind { + Spot, + Wash, + Par, + Strobe, + Smoke, + Pyro, + PixelBar, +} + +pub const ALL_KINDS: [FixtureKind; 7] = [ + FixtureKind::Spot, + FixtureKind::Wash, + FixtureKind::Par, + FixtureKind::Strobe, + FixtureKind::Smoke, + FixtureKind::Pyro, + FixtureKind::PixelBar, +]; + +impl FixtureKind { + /// Which trigger lane drives this kind. + pub fn lane(self) -> Lane { + match self { + Self::Spot | Self::Wash | Self::Par | Self::Strobe => Lane::Lighting, + Self::PixelBar => Lane::Pixels, + Self::Smoke | Self::Pyro => Lane::Fx, + } + } + + /// Group-select button label. + pub fn group_label(self) -> &'static str { + match self { + Self::Spot => "SPOTS", + Self::Wash => "WASHES", + Self::Par => "PARS", + Self::Strobe => "STROBES", + Self::Smoke => "SMOKE", + Self::Pyro => "PYRO", + Self::PixelBar => "PIXELS", + } + } + + /// Library profile backing this kind in the default rig. + pub fn default_profile_id(self) -> &'static str { + match self { + Self::Spot => "shehds-led-spot-60w", + Self::Wash => "shehds-led-wash-7x18w-rgbwa-uv", + Self::Par => "shehds-rgbw-par", + Self::Strobe => "hyulights-led-rgbw-4in1-48-partition-strobe", + Self::Smoke => "dl-geyser-1000-led-smoke-machine-1000w-3x9w-rgb", + Self::Pyro => "generic-pyro-igniter", + Self::PixelBar => "clen-led-pixel-bar-64", + } + } + + /// Cell-label prefix ("S1", "PB3", …). + pub fn short(self) -> &'static str { + match self { + Self::Spot => "S", + Self::Wash => "W", + Self::Par => "P", + Self::Strobe => "ST", + Self::Smoke => "SM", + Self::Pyro => "PY", + Self::PixelBar => "PB", + } + } + + /// Family hue for grid cells: the lighting kinds stay in the blue + /// family of the Lighting lane, pixel bars take the lane pink, and + /// smoke/pyro read literally (grey smoke, flame orange). + pub fn color(self) -> Rgb { + match self { + Self::Spot => [80, 165, 255], + Self::Wash => [140, 120, 255], + Self::Par => [70, 130, 215], + Self::Strobe => [225, 235, 250], + Self::Smoke => [150, 160, 172], + Self::Pyro => [255, 115, 60], + Self::PixelBar => [240, 95, 175], + } + } +} + +/// One patched fixture: positioned on the selection grid so the layout +/// mirrors the physical rig, and addressed on the wire via its profile. +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct Fixture { + pub id: u32, + pub kind: FixtureKind, + pub label: String, + pub col: u8, + pub row: u8, + /// Key into the [`FixtureLibrary`]. + pub profile_id: String, + pub universe: u8, + /// 1-based DMX start address. + pub start_address: u16, +} + +#[derive(Clone)] +pub struct Rig { + fixtures: Vec, +} + +impl Rig { + pub fn iter(&self) -> impl Iterator { + self.fixtures.iter() + } + + pub fn ids_of_kind(&self, kind: FixtureKind) -> impl Iterator + '_ { + self.fixtures + .iter() + .filter(move |f| f.kind == kind) + .map(|f| f.id) + } + + pub fn ids(&self) -> impl Iterator + '_ { + self.fixtures.iter().map(|f| f.id) + } + + /// Grid extent as (cols, rows). + pub fn extent(&self) -> (u8, u8) { + let cols = self.fixtures.iter().map(|f| f.col + 1).max().unwrap_or(0); + let rows = self.fixtures.iter().map(|f| f.row + 1).max().unwrap_or(0); + (cols, rows) + } + + pub fn from_fixtures(fixtures: Vec) -> Self { + Rig { fixtures } + } + + /// Direct access for the patch editor. + pub fn fixtures_mut(&mut self) -> &mut Vec { + &mut self.fixtures + } + + pub fn next_id(&self) -> u32 { + self.fixtures.iter().map(|f| f.id).max().unwrap_or(0) + 1 + } + + /// Ids of fixtures whose patch is invalid: unknown profile, footprint + /// spilling past channel 512, or overlapping another fixture on the + /// same universe. + pub fn conflicts(&self, library: &FixtureLibrary) -> std::collections::HashSet { + let mut bad = std::collections::HashSet::new(); + // (universe, start, end-inclusive, id) for overlap sweeping. + let mut spans: Vec<(u8, u16, u16, u32)> = Vec::new(); + for f in &self.fixtures { + let Some(profile) = library.get(&f.profile_id) else { + bad.insert(f.id); + continue; + }; + let fp = profile.footprint() as u16; + if f.start_address < 1 || u32::from(f.start_address) + u32::from(fp) - 1 > 512 { + bad.insert(f.id); + continue; + } + spans.push((f.universe, f.start_address, f.start_address + fp - 1, f.id)); + } + spans.sort_unstable(); + for pair in spans.windows(2) { + let (u_a, _, end_a, id_a) = pair[0]; + let (u_b, start_b, _, id_b) = pair[1]; + if u_a == u_b && start_b <= end_a { + bad.insert(id_a); + bad.insert(id_b); + } + } + bad + } +} + +/// Persisted patch form (JSON in the library DB), mirroring `CueFile`. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct RigFile { + pub version: u32, + pub fixtures: Vec, +} + +impl RigFile { + pub fn from_rig(rig: &Rig) -> Self { + RigFile { + version: 1, + fixtures: rig.fixtures.clone(), + } + } + + pub fn into_rig(self) -> Rig { + Rig { + fixtures: self.fixtures, + } + } +} + +/// Default patch, arranged like a stage (top row = truss, bottom = +/// floor), addressed from real library profiles: +/// +/// ```text +/// row 0 S1 S2 W1 W2 W3 W4 S3 S4 4 spots flanking 4 washes +/// row 1 P1 P2 P3 P4 P5 P6 ST1 ST2 6 PARs + 2 strobes +/// row 2 PB1 .. PB8 8 pixel bars +/// row 3 SM1 SM2 PY1 PY2 smoke + pyro on the floor +/// ``` +/// +/// Conventionals pack sequentially into universe 1; the 192-channel +/// pixel bars go two per universe starting at universe 2. +pub fn default_rig(library: &FixtureLibrary) -> Rig { + let mut fixtures = Vec::new(); + let mut next_id = 0u32; + let mut counts = std::collections::HashMap::new(); + let mut next_addr_u1: u16 = 1; + let mut pixel_bars_placed: u8 = 0; + let mut place = |kind: FixtureKind, col: u8, row: u8| { + let n = counts.entry(kind).or_insert(0u32); + *n += 1; + let profile_id = kind.default_profile_id(); + let footprint = library + .get(profile_id) + .expect("default rig uses a library profile") + .footprint() as u16; + let (universe, start_address) = if kind == FixtureKind::PixelBar { + let universe = 2 + pixel_bars_placed / 2; + let addr = 1 + (pixel_bars_placed % 2) as u16 * footprint; + pixel_bars_placed += 1; + (universe, addr) + } else { + let addr = next_addr_u1; + next_addr_u1 += footprint; + (1, addr) + }; + fixtures.push(Fixture { + id: { + next_id += 1; + next_id + }, + kind, + label: format!("{}{n}", kind.short()), + col, + row, + profile_id: profile_id.to_string(), + universe, + start_address, + }); + }; + + use FixtureKind::*; + for (col, kind) in [Spot, Spot, Wash, Wash, Wash, Wash, Spot, Spot] + .into_iter() + .enumerate() + { + place(kind, col as u8, 0); + } + for col in 0..6 { + place(Par, col, 1); + } + place(Strobe, 6, 1); + place(Strobe, 7, 1); + for col in 0..8 { + place(PixelBar, col, 2); + } + place(Smoke, 0, 3); + place(Smoke, 1, 3); + place(Pyro, 6, 3); + place(Pyro, 7, 3); + + Rig { fixtures } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + + #[test] + fn default_rig_is_well_formed() { + let rig = default_rig(&FixtureLibrary::new()); + let ids: HashSet = rig.ids().collect(); + assert_eq!(ids.len(), rig.iter().count(), "ids must be unique"); + let cells: HashSet<(u8, u8)> = rig.iter().map(|f| (f.col, f.row)).collect(); + assert_eq!(cells.len(), rig.iter().count(), "grid cells must be unique"); + assert_eq!(rig.ids_of_kind(FixtureKind::Spot).count(), 4); + assert_eq!(rig.ids_of_kind(FixtureKind::Wash).count(), 4); + assert_eq!(rig.ids_of_kind(FixtureKind::Par).count(), 6); + assert_eq!(rig.ids_of_kind(FixtureKind::Strobe).count(), 2); + assert_eq!(rig.ids_of_kind(FixtureKind::PixelBar).count(), 8); + assert_eq!(rig.ids_of_kind(FixtureKind::Smoke).count(), 2); + assert_eq!(rig.ids_of_kind(FixtureKind::Pyro).count(), 2); + assert_eq!(rig.extent(), (8, 4)); + } + + #[test] + fn default_rig_patch_is_valid_dmx() { + let library = FixtureLibrary::new(); + let rig = default_rig(&library); + // Every fixture's footprint fits its universe, and no two + // fixtures overlap on the wire. + let mut occupied: HashSet<(u8, u16)> = HashSet::new(); + for f in rig.iter() { + let profile = library.get(&f.profile_id).expect("profile exists"); + let footprint = profile.footprint() as u16; + assert!(f.start_address >= 1, "{}: addresses are 1-based", f.label); + assert!( + f.start_address + footprint - 1 <= 512, + "{}: footprint spills past the universe", + f.label + ); + for addr in f.start_address..f.start_address + footprint { + assert!( + occupied.insert((f.universe, addr)), + "{}: address {}:{} double-patched", + f.label, + f.universe, + addr + ); + } + } + } + + #[test] + fn rig_roundtrips_through_rigfile_json() { + let library = FixtureLibrary::new(); + let rig = default_rig(&library); + let json = serde_json::to_string(&RigFile::from_rig(&rig)).unwrap(); + let parsed: RigFile = serde_json::from_str(&json).unwrap(); + let restored = parsed.into_rig(); + assert_eq!(restored.iter().count(), rig.iter().count()); + let f = restored.iter().find(|f| f.label == "PB3").unwrap(); + assert_eq!(f.profile_id, "clen-led-pixel-bar-64"); + assert_eq!((f.universe, f.start_address), (3, 1)); + } + + #[test] + fn conflicts_flag_overlap_spill_and_unknown_profile() { + let library = FixtureLibrary::new(); + let mut rig = default_rig(&library); + assert!(rig.conflicts(&library).is_empty(), "default patch is clean"); + + // P2 moved onto P1's footprint: both flagged. + let (p1, p2) = { + let a = rig.iter().find(|f| f.label == "P1").unwrap(); + let b = rig.iter().find(|f| f.label == "P2").unwrap(); + (a.id, b.id) + }; + let p1_addr = rig.iter().find(|f| f.id == p1).unwrap().start_address; + rig.fixtures_mut() + .iter_mut() + .find(|f| f.id == p2) + .unwrap() + .start_address = p1_addr + 1; + let bad = rig.conflicts(&library); + assert!(bad.contains(&p1) && bad.contains(&p2)); + + // A pixel bar pushed past channel 512: flagged alone. + let mut rig = default_rig(&library); + let pb = rig.iter().find(|f| f.label == "PB1").unwrap().id; + rig.fixtures_mut() + .iter_mut() + .find(|f| f.id == pb) + .unwrap() + .start_address = 400; + assert_eq!(rig.conflicts(&library), HashSet::from([pb])); + + // Unknown profile: flagged. + let mut rig = default_rig(&library); + let s1 = rig.iter().find(|f| f.label == "S1").unwrap().id; + rig.fixtures_mut() + .iter_mut() + .find(|f| f.id == s1) + .unwrap() + .profile_id = "no-such-profile".to_string(); + assert!(rig.conflicts(&library).contains(&s1)); + } + + #[test] + fn every_kind_maps_to_a_lane_and_group() { + for kind in ALL_KINDS { + let _ = kind.lane(); + assert!(!kind.group_label().is_empty()); + assert!(!kind.short().is_empty()); + } + } +} diff --git a/crates/halo-light/src/fixture_library.rs b/crates/halo-light/src/fixture_library.rs new file mode 100644 index 0000000..c9e5675 --- /dev/null +++ b/crates/halo-light/src/fixture_library.rs @@ -0,0 +1,391 @@ +//! Fixture profiles and DMX patching, ported from halo-old's +//! `crates/fixtures`. +//! +//! A [`FixtureProfile`] is a pure template: the ordered channel layout a +//! fixture presents on the wire. Runtime channel values deliberately live +//! elsewhere (the resolve/output layer) — profiles and patches only +//! describe the rig. Profiles are hardcoded for now; loading from disk is +//! on the backlog. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// What a profile physically is, for grouping and defaulting in the +/// patch UI. Distinct from `fixture::FixtureKind`, which is the +/// programmer's grid grouping; the rig merge maps between them. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum FixtureType { + #[default] + MovingHead, + Par, + Wash, + Beam, + LedBar, + Pinspot, + Smoke, + Pyro, + PixelBar, +} + +/// One DMX channel in a profile's layout (template only — no value). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Channel { + pub name: String, + pub channel_type: ChannelType, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum ChannelType { + Dimmer, + Color, + Gobo, + Red, + Green, + Blue, + White, + Amber, + Uv, + Strobe, + Pan, + Tilt, + TiltSpeed, + Beam, + Focus, + Zoom, + Function, + FunctionSpeed, + /// Per-pixel color channels for pixel bars, 0-indexed. + PixelRed(usize), + PixelGreen(usize), + PixelBlue(usize), + Other(String), +} + +#[derive(Clone, Debug, Default)] +pub struct FixtureProfile { + pub id: String, + pub fixture_type: FixtureType, + pub manufacturer: String, + pub model: String, + pub channel_layout: Vec, +} + +impl FixtureProfile { + /// Number of consecutive DMX addresses the fixture occupies. + pub fn footprint(&self) -> usize { + self.channel_layout.len() + } + + /// Offset of the first channel of `channel_type` within the + /// footprint, if the profile has one. + pub fn channel_offset(&self, channel_type: &ChannelType) -> Option { + self.channel_layout + .iter() + .position(|c| c.channel_type == *channel_type) + } +} + +impl std::fmt::Display for FixtureProfile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} {}", self.manufacturer, self.model) + } +} + +/// Soft limits applied to pan/tilt values so a fixture can't be driven +/// into truss or walls. Applied by the output layer, not stored here. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PanTiltLimits { + pub pan_min: u8, + pub pan_max: u8, + pub tilt_min: u8, + pub tilt_max: u8, +} + +impl PanTiltLimits { + /// Clamp a value for the given channel; non-pan/tilt channels pass + /// through untouched. + pub fn clamp(&self, channel_type: &ChannelType, value: u8) -> u8 { + match channel_type { + ChannelType::Pan => value.clamp(self.pan_min, self.pan_max), + ChannelType::Tilt => value.clamp(self.tilt_min, self.tilt_max), + _ => value, + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct FixtureLibrary { + pub profiles: HashMap, +} + +fn ch(name: &str, channel_type: ChannelType) -> Channel { + Channel { + name: name.to_string(), + channel_type, + } +} + +impl FixtureLibrary { + pub fn new() -> Self { + let mut profiles = HashMap::new(); + let mut add = |profile: FixtureProfile| { + profiles.insert(profile.id.clone(), profile); + }; + + add(FixtureProfile { + id: "shehds-rgbw-par".to_string(), + fixture_type: FixtureType::Par, + manufacturer: "Shehds".to_string(), + model: "LED Flat PAR 12x3W RGBW".to_string(), + channel_layout: vec![ + ch("Dimmer", ChannelType::Dimmer), + ch("Red", ChannelType::Red), + ch("Green", ChannelType::Green), + ch("Blue", ChannelType::Blue), + ch("White", ChannelType::White), + ch("Strobe", ChannelType::Strobe), + ch("Program", ChannelType::Other("Program".to_string())), + ch("Function", ChannelType::Other("Function".to_string())), + ], + }); + + add(FixtureProfile { + id: "shehds-led-spot-60w".to_string(), + fixture_type: FixtureType::MovingHead, + manufacturer: "Shehds".to_string(), + model: "LED Spot 60W Lighting".to_string(), + channel_layout: vec![ + ch("Pan", ChannelType::Pan), + ch("Tilt", ChannelType::Tilt), + ch("Color", ChannelType::Color), + ch("Gobo", ChannelType::Gobo), + ch("Strobe", ChannelType::Strobe), + ch("Dimmer", ChannelType::Dimmer), + ch("Speed", ChannelType::Other("Speed".to_string())), + ch("Auto", ChannelType::Other("Auto".to_string())), + ch("Reset", ChannelType::Other("Reset".to_string())), + ], + }); + + add(FixtureProfile { + id: "shehds-led-wash-7x18w-rgbwa-uv".to_string(), + fixture_type: FixtureType::Wash, + manufacturer: "Shehds".to_string(), + model: "LED Wash 7x18W RGBWA+UV".to_string(), + channel_layout: vec![ + ch("Pan", ChannelType::Pan), + ch("Tilt", ChannelType::Tilt), + ch("Dimmer", ChannelType::Dimmer), + ch("Red", ChannelType::Red), + ch("Green", ChannelType::Green), + ch("Blue", ChannelType::Blue), + ch("White", ChannelType::White), + ch("Amber", ChannelType::Amber), + ch("UV", ChannelType::Uv), + // TODO(halo-old): possibly XY speed — check the manual. + ch("Function", ChannelType::Other("Function".to_string())), + ], + }); + + add(FixtureProfile { + id: "shehds-mini-led-pinspot-10w".to_string(), + fixture_type: FixtureType::Pinspot, + manufacturer: "Shehds".to_string(), + model: "Mini LED Pinspot 10W".to_string(), + channel_layout: vec![ + ch("Dimmer", ChannelType::Dimmer), + ch("Red", ChannelType::Red), + ch("Green", ChannelType::Green), + ch("Blue", ChannelType::Blue), + ch("White", ChannelType::White), + ch("Strobe", ChannelType::Strobe), + // 0-50 none | 51-100 color select | 101-150 jump | + // 151-200 gradient | 201-250 auto | 251-255 sound + ch("Function", ChannelType::Other("Function".to_string())), + // Slow → fast, paired with Function. + ch("Speed", ChannelType::Other("FunctionSpeed".to_string())), + ], + }); + + add(FixtureProfile { + id: "dl-geyser-1000-led-smoke-machine-1000w-3x9w-rgb".to_string(), + fixture_type: FixtureType::Smoke, + manufacturer: "DL Geyser".to_string(), + model: "1000 LED Smoke Machine".to_string(), + channel_layout: vec![ + ch("Smoke", ChannelType::Other("Smoke".to_string())), + ch("Red", ChannelType::Red), + ch("Green", ChannelType::Green), + ch("Blue", ChannelType::Blue), + ch("Strobe", ChannelType::Strobe), + // LED effect: 0-50 off | 51-100 jump | 101-200 gradient | + // 201-255 color strobe + ch("Effect", ChannelType::Other("Function".to_string())), + // Paired with Effect. + ch("Speed", ChannelType::Other("FunctionSpeed".to_string())), + ], + }); + + add(FixtureProfile { + id: "shehds-led-bar-beam-8x12w".to_string(), + fixture_type: FixtureType::Beam, + manufacturer: "Shehds".to_string(), + model: "LED Bar Beam 8x12W".to_string(), + channel_layout: vec![ + ch("Tilt", ChannelType::Tilt), + ch("Tilt Speed", ChannelType::TiltSpeed), + // 0-20 DMX 10ch | 21-70 transition | 71-120 gradual | + // 121-170 clock | 171-220 run | 221-240 sound 1 | + // 241-255 sound 2 + ch("Function", ChannelType::Function), + ch("Speed", ChannelType::FunctionSpeed), + ch("Dimmer", ChannelType::Dimmer), + ch("Red", ChannelType::Red), + ch("Green", ChannelType::Green), + ch("Blue", ChannelType::Blue), + ch("White", ChannelType::White), + ], + }); + + // 6-channel mode (the 12-channel variant adds split RGB/White + // shutter + FX banks; add it as a second profile when needed — + // see https://personalities.avolites.com "LED RGBW 4in1 48 + // Partition Strobe Light"). + add(FixtureProfile { + id: "hyulights-led-rgbw-4in1-48-partition-strobe".to_string(), + fixture_type: FixtureType::LedBar, + manufacturer: "Hyulights".to_string(), + model: "200W LED RGBW 4in1 48 Partition Strobe Light".to_string(), + channel_layout: vec![ + ch("Dimmer", ChannelType::Dimmer), + ch("Strobe", ChannelType::Strobe), + ch("Red", ChannelType::Red), + ch("Green", ChannelType::Green), + ch("Blue", ChannelType::Blue), + ch("White", ChannelType::White), + ], + }); + + add(FixtureProfile { + id: "hyulights-led-rgbw-par".to_string(), + fixture_type: FixtureType::Par, + manufacturer: "Hyulights".to_string(), + model: "LED RGBW PAR Light".to_string(), + channel_layout: vec![ + ch("Dimmer", ChannelType::Dimmer), + ch("Red", ChannelType::Red), + ch("Green", ChannelType::Green), + ch("Blue", ChannelType::Blue), + ch("White", ChannelType::White), + ch("Strobe", ChannelType::Strobe), + ch("Function", ChannelType::Function), + ch("Function Speed", ChannelType::FunctionSpeed), + ], + }); + + for (id, model, pixels) in [ + ("generic-rgb-pixel-bar-30", "RGB Pixel Bar 30 Pixels", 30), + ("generic-rgb-pixel-bar-60", "RGB Pixel Bar 60 Pixels", 60), + ("generic-rgb-pixel-bar-144", "RGB Pixel Bar 144 Pixels", 144), + ] { + add(FixtureProfile { + id: id.to_string(), + fixture_type: FixtureType::PixelBar, + manufacturer: "Generic".to_string(), + model: model.to_string(), + channel_layout: pixel_bar_channels(pixels), + }); + } + add(FixtureProfile { + id: "clen-led-pixel-bar-64".to_string(), + fixture_type: FixtureType::PixelBar, + manufacturer: "Clen".to_string(), + model: "LED Pixel Bar 64 Pixels RGB".to_string(), + channel_layout: pixel_bar_channels(64), + }); + + // Not from halo-old: the default rig carries pyro units and needs + // a profile for them. Standard 2ch DMX igniter convention — + // Safety must be held high before Fire triggers. + add(FixtureProfile { + id: "generic-pyro-igniter".to_string(), + fixture_type: FixtureType::Pyro, + manufacturer: "Generic".to_string(), + model: "DMX Pyro Igniter 2ch".to_string(), + channel_layout: vec![ + ch("Safety", ChannelType::Other("Safety".to_string())), + ch("Fire", ChannelType::Other("Fire".to_string())), + ], + }); + + FixtureLibrary { profiles } + } + + pub fn get(&self, profile_id: &str) -> Option<&FixtureProfile> { + self.profiles.get(profile_id) + } +} + +/// RGB-per-pixel layout for a pixel bar. +fn pixel_bar_channels(pixel_count: usize) -> Vec { + let mut channels = Vec::with_capacity(pixel_count * 3); + for i in 0..pixel_count { + channels.push(ch( + &format!("Pixel {} Red", i + 1), + ChannelType::PixelRed(i), + )); + channels.push(ch( + &format!("Pixel {} Green", i + 1), + ChannelType::PixelGreen(i), + )); + channels.push(ch( + &format!("Pixel {} Blue", i + 1), + ChannelType::PixelBlue(i), + )); + } + channels +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn library_profiles_are_well_formed() { + let lib = FixtureLibrary::new(); + assert!(!lib.profiles.is_empty()); + for (key, profile) in &lib.profiles { + assert_eq!(key, &profile.id, "map key must match profile id"); + assert!(profile.footprint() > 0, "{key}: empty channel layout"); + assert!( + profile.footprint() <= 512, + "{key}: footprint exceeds a universe" + ); + } + } + + #[test] + fn pixel_bar_layout_is_rgb_per_pixel() { + let lib = FixtureLibrary::new(); + let bar = lib.get("clen-led-pixel-bar-64").unwrap(); + assert_eq!(bar.footprint(), 64 * 3); + assert_eq!( + bar.channel_offset(&ChannelType::PixelBlue(63)), + Some(64 * 3 - 1) + ); + } + + #[test] + fn pan_tilt_limits_clamp_only_pan_tilt() { + let limits = PanTiltLimits { + pan_min: 10, + pan_max: 200, + tilt_min: 0, + tilt_max: 128, + }; + assert_eq!(limits.clamp(&ChannelType::Pan, 255), 200); + assert_eq!(limits.clamp(&ChannelType::Tilt, 255), 128); + assert_eq!(limits.clamp(&ChannelType::Dimmer, 255), 255); + } +} diff --git a/crates/halo-light/src/lib.rs b/crates/halo-light/src/lib.rs new file mode 100644 index 0000000..218a904 --- /dev/null +++ b/crates/halo-light/src/lib.rs @@ -0,0 +1,12 @@ +//! Halo's lighting domain: cue data, the fixture rig, and the programmer +//! override layer. Deliberately UI- and audio-free — the egui painters and +//! the (future) DMX engine thread are both consumers of this crate. +//! `programmer::resolve()` is the single merge point, a pure function of +//! (cue set, programmer state, playhead). + +pub mod artnet; +pub mod cues; +pub mod fixture; +pub mod fixture_library; +pub mod output; +pub mod programmer; diff --git a/crates/halo-light/src/output.rs b/crates/halo-light/src/output.rs new file mode 100644 index 0000000..2c8a966 --- /dev/null +++ b/crates/halo-light/src/output.rs @@ -0,0 +1,579 @@ +//! Per-fixture DMX output: flattens [`resolve`](crate::programmer::resolve)'d +//! lane levels + programmer parameters into per-universe channel frames. +//! +//! [`render`] is pure — (rig, params, lane outputs, beat time) in, frames +//! out — so the DMX engine thread and any future preview visualizer get +//! identical results. Frames explicitly zero dark fixtures: a rendered +//! universe always ships all 512 channels, so "off" is sent, not held. +//! +//! Value semantics (v1, until Phase L2's per-fixture programming): +//! - A fixture's intensity is its lane's resolved level × the programmer dimmer. Fixtures without a +//! dimmer channel fold intensity into their color channels. +//! - Palette (RGBW), position, gobo, and strobe apply rig-wide; *effects* run across the fixture +//! selection (or the whole rig when nothing is selected), with Step/Wave distribution by ordinal +//! in that cohort. +//! - HIGHLIGHT snaps the selection to open white. PREVIEW (blind) renders as if the programmer +//! params were untouched defaults. + +use std::collections::{HashMap, HashSet}; + +use crate::cues::LANE_COUNT; +use crate::fixture::{FixtureKind, Rig}; +use crate::fixture_library::{ChannelType, FixtureLibrary, FixtureProfile}; +use crate::programmer::{ + COLOR_PRESETS, Distribution, EffectConfig, LaneOutput, PanTiltTarget, ProgrammerParams, + effect_value, +}; + +pub const UNIVERSE_CHANNELS: usize = 512; +pub type UniverseFrame = [u8; UNIVERSE_CHANNELS]; + +fn u8_of(v: f32) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +/// Phase offset (in cycles) for the fixture at ordinal `i` of a cohort. +fn dist_offset(d: Distribution, i: usize) -> f64 { + match d { + Distribution::All => 0.0, + Distribution::Step(s) if s > 0 => f64::from(i as u32 % s) / f64::from(s), + Distribution::Step(_) => 0.0, + Distribution::Wave(deg) => i as f64 * f64::from(deg) / 360.0, + } +} + +/// Evaluate an applied effect for cohort ordinal `i` at musical time +/// `beat_t` (in beats). Returns None when the effect isn't applied or the +/// fixture isn't in the cohort. +fn effect_at(cfg: &EffectConfig, beat_t: f64, ord: Option) -> Option { + if !cfg.applied { + return None; + } + let i = ord?; + let t = beat_t / cfg.interval.beats() - dist_offset(cfg.distribution, i); + Some(effect_value(cfg, t)) +} + +/// Render one instant of the rig into per-universe DMX frames. +/// +/// `beat_t` is musical time in beats (fractional), the same clock the +/// programmer's effect previews run on. +pub fn render( + rig: &Rig, + library: &FixtureLibrary, + lanes: &[LaneOutput; LANE_COUNT], + params: &ProgrammerParams, + selection: &HashSet, + beat_t: f64, +) -> HashMap { + // PREVIEW (blind): programmer values stay in the editor. Highlight + // remains live — it's an identification tool, not a look. + let defaults; + let p = if params.preview { + defaults = ProgrammerParams::default(); + &defaults + } else { + params + }; + + // Effect cohort: the selection, or everything when nothing is + // selected. Ordinals follow rig order. + let cohort: Vec = rig + .iter() + .filter(|f| selection.is_empty() || selection.contains(&f.id)) + .map(|f| f.id) + .collect(); + + let mut universes: HashMap = HashMap::new(); + for f in rig.iter() { + let Some(profile) = library.get(&f.profile_id) else { + continue; + }; + let footprint = profile.footprint(); + let base = (f.start_address.max(1) - 1) as usize; + if base + footprint > UNIVERSE_CHANNELS { + continue; + } + let frame = universes + .entry(f.universe) + .or_insert([0u8; UNIVERSE_CHANNELS]); + let slot = &mut frame[base..base + footprint]; + + if params.highlight && selection.contains(&f.id) { + render_highlight(profile, slot); + continue; + } + + let level = lanes[f.kind.lane() as usize].level.clamp(0.0, 1.0); + let ord = cohort.iter().position(|&id| id == f.id); + if f.kind == FixtureKind::PixelBar { + render_pixel_bar(profile, slot, p, level, beat_t); + } else { + render_conventional(profile, slot, p, level, beat_t, ord); + } + } + universes +} + +/// Open white at full for fixture identification. +fn render_highlight(profile: &FixtureProfile, slot: &mut [u8]) { + for (i, ch) in profile.channel_layout.iter().enumerate() { + slot[i] = match ch.channel_type { + ChannelType::Dimmer + | ChannelType::Red + | ChannelType::Green + | ChannelType::Blue + | ChannelType::White + | ChannelType::PixelRed(_) + | ChannelType::PixelGreen(_) + | ChannelType::PixelBlue(_) => 255, + _ => 0, + }; + } +} + +fn render_conventional( + profile: &FixtureProfile, + slot: &mut [u8], + p: &ProgrammerParams, + level: f32, + beat_t: f64, + ord: Option, +) { + let mut dim = level * (p.intensity.dimmer / 100.0); + if let Some(fx) = effect_at(&p.intensity.effect, beat_t, ord) { + dim *= fx; + } + + let mut rgbw = p.color.rgbw.map(|v| v / 100.0); + if let Some(fx) = effect_at(&p.color.effect, beat_t, ord) { + for c in rgbw.iter_mut() { + *c *= fx; + } + } + + let pos_fx = effect_at(&p.position.effect, beat_t, ord); + let axis = |deg: f32, driven: bool| -> u8 { + let base = deg / 360.0; + match pos_fx { + // ±1/8 of travel swing around the base position. + Some(fx) if driven => u8_of(base + (fx - 0.5) * 0.25), + _ => u8_of(base), + } + }; + let pan_driven = matches!(p.position.target, PanTiltTarget::Both | PanTiltTarget::Pan); + let tilt_driven = matches!(p.position.target, PanTiltTarget::Both | PanTiltTarget::Tilt); + + // Fixtures without a dimmer channel carry intensity in their color + // channels instead. + let color_scale = if profile.channel_offset(&ChannelType::Dimmer).is_some() { + 1.0 + } else { + dim + }; + + for (i, ch) in profile.channel_layout.iter().enumerate() { + slot[i] = match &ch.channel_type { + ChannelType::Dimmer => u8_of(dim), + ChannelType::Red => u8_of(rgbw[0] * color_scale), + ChannelType::Green => u8_of(rgbw[1] * color_scale), + ChannelType::Blue => u8_of(rgbw[2] * color_scale), + ChannelType::White => u8_of(rgbw[3] * color_scale), + ChannelType::Strobe => u8_of(p.intensity.strobe / 100.0), + ChannelType::Pan => axis(p.position.pan, pan_driven), + ChannelType::Tilt => axis(p.position.tilt, tilt_driven), + // 8 gobo slots spread across the wheel's DMX range. + ChannelType::Gobo => p.beam.gobo.saturating_sub(1).min(7) * 32, + ChannelType::Other(name) => match name.as_str() { + // The FX lane level drives smoke output directly. + "Smoke" => u8_of(dim), + // Igniter convention: Safety arms with any level, Fire + // needs the lane driven hard (a deliberate cue at ≥ 0.9). + "Safety" => { + if level > 0.0 { + 255 + } else { + 0 + } + } + "Fire" => { + if level >= 0.9 { + 255 + } else { + 0 + } + } + _ => 0, + }, + _ => 0, + }; + } +} + +/// Fold a `[r, g, b, w]` percent preset into RGB in 0..=1. +fn preset_rgb(preset: [f32; 4]) -> [f32; 3] { + let w = preset[3] / 100.0; + [ + (preset[0] / 100.0 + w).min(1.0), + (preset[1] / 100.0 + w).min(1.0), + (preset[2] / 100.0 + w).min(1.0), + ] +} + +/// h in 0..=1 → RGB at full saturation/value (rainbow effect). +fn hue_rgb(h: f32) -> [f32; 3] { + let h6 = h.rem_euclid(1.0) * 6.0; + let x = 1.0 - (h6.rem_euclid(2.0) - 1.0).abs(); + match h6 as u32 { + 0 => [1.0, x, 0.0], + 1 => [x, 1.0, 0.0], + 2 => [0.0, 1.0, x], + 3 => [0.0, x, 1.0], + 4 => [x, 0.0, 1.0], + _ => [1.0, 0.0, x], + } +} + +fn render_pixel_bar( + profile: &FixtureProfile, + slot: &mut [u8], + p: &ProgrammerParams, + level: f32, + beat_t: f64, +) { + let n = profile.footprint() / 3; + if n == 0 || level <= 0.0 { + return; // slot is already zeroed + } + let base_rgb = preset_rgb(COLOR_PRESETS[p.pixel.color.min(COLOR_PRESETS.len() - 1)].1); + let frac = |x: f64| x.rem_euclid(1.0); + + for i in 0..n { + let fi = i as f32; + let nf = n as f32; + // (intensity, optional color override) per pixel. + let (v, over): (f32, Option<[f32; 3]>) = match p.pixel.effect { + // Chase L-R: a window sweeping once per beat. + 0 => { + let pos = frac(beat_t) as f32 * nf; + let w = (nf / 8.0).max(1.0); + ((1.0 - (fi - pos).abs() / w).max(0.0), None) + } + // Bounce: the window ping-pongs over a 2-beat cycle. + 1 => { + let ph = frac(beat_t / 2.0) as f32; + let tri = if ph < 0.5 { ph * 2.0 } else { 2.0 - ph * 2.0 }; + let pos = tri * (nf - 1.0); + let w = (nf / 8.0).max(1.0); + ((1.0 - (fi - pos).abs() / w).max(0.0), None) + } + // Rainbow: hue wheel across the bar, drifting one revolution + // every 4 beats. + 2 => (1.0, Some(hue_rgb(fi / nf - frac(beat_t / 4.0) as f32))), + // Sparkle: ~1 in 4 pixels re-rolled every quarter beat. + 3 => { + let tick = (beat_t * 4.0).floor() as u64; + let h = (i as u64) + .wrapping_mul(0x9E37_79B9_7F4A_7C15) + .wrapping_add(tick.wrapping_mul(0xC2B2_AE3D_27D4_EB4F)); + (if (h >> 33) & 3 == 0 { 1.0 } else { 0.0 }, None) + } + // VU meter: lane level fills the bar from the left. + 4 => (if fi < level * nf { 1.0 } else { 0.0 }, None), + // Breathe: everything swells over 2 beats. + 5 => { + let ph = frac(beat_t / 2.0) as f32; + (0.5 - 0.5 * (ph * std::f32::consts::TAU).cos(), None) + } + // Strobe all: a hard flash on each beat. + 6 => (if frac(beat_t) < 0.15 { 1.0 } else { 0.0 }, None), + // Theater: odd/even pixels alternate every half beat. + _ => { + let step = (beat_t * 2.0).floor() as usize; + (if i % 2 == step % 2 { 1.0 } else { 0.0 }, None) + } + }; + + let rgb = over.unwrap_or(base_rgb); + let scale = v * level; + slot[i * 3] = u8_of(rgb[0] * scale); + slot[i * 3 + 1] = u8_of(rgb[1] * scale); + slot[i * 3 + 2] = u8_of(rgb[2] * scale); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixture::default_rig; + use crate::programmer::{LaneOutput, LaneSource, Waveform}; + + fn lanes(lighting: f32, pixels: f32, fx: f32) -> [LaneOutput; LANE_COUNT] { + [lighting, pixels, fx].map(|level| LaneOutput { + source: if level > 0.0 { + LaneSource::Track + } else { + LaneSource::Off + }, + level, + }) + } + + fn setup() -> (Rig, FixtureLibrary) { + let library = FixtureLibrary::new(); + (default_rig(&library), library) + } + + /// Channel value for a fixture found by label, by channel type. + fn value_of( + rig: &Rig, + library: &FixtureLibrary, + frames: &HashMap, + label: &str, + ch: &ChannelType, + ) -> u8 { + let f = rig.iter().find(|f| f.label == label).expect("fixture"); + let profile = library.get(&f.profile_id).unwrap(); + let off = profile.channel_offset(ch).expect("channel"); + frames[&f.universe][(f.start_address - 1) as usize + off] + } + + #[test] + fn dark_rig_closes_every_dimmer() { + let (rig, library) = setup(); + let frames = render( + &rig, + &library, + &lanes(0.0, 0.0, 0.0), + &ProgrammerParams::default(), + &HashSet::new(), + 0.0, + ); + // All patched universes ship frames (1 conventional + 4 pixel). + assert_eq!(frames.len(), 5); + // Console-style dark: dimmer channels closed (palette may sit on + // color channels behind them); fixtures without a dimmer — smoke, + // pyro, pixels — are all-zero. + for f in rig.iter() { + let profile = library.get(&f.profile_id).unwrap(); + let base = (f.start_address - 1) as usize; + let slot = &frames[&f.universe][base..base + profile.footprint()]; + match profile.channel_offset(&ChannelType::Dimmer) { + Some(off) => assert_eq!(slot[off], 0, "{}: dimmer open", f.label), + None => assert!( + slot.iter().all(|&v| v == 0), + "{}: dimmerless fixture not dark", + f.label + ), + } + } + } + + #[test] + fn lighting_lane_drives_par_dimmer_and_default_white() { + let (rig, library) = setup(); + let frames = render( + &rig, + &library, + &lanes(0.8, 0.0, 0.0), + &ProgrammerParams::default(), + &HashSet::new(), + 0.0, + ); + assert_eq!( + value_of(&rig, &library, &frames, "P1", &ChannelType::Dimmer), + u8_of(0.8) + ); + // Default palette is RGB full, W off. + assert_eq!( + value_of(&rig, &library, &frames, "P1", &ChannelType::Red), + 255 + ); + assert_eq!( + value_of(&rig, &library, &frames, "P1", &ChannelType::White), + 0 + ); + } + + #[test] + fn fx_lane_drives_smoke_and_gates_pyro() { + let (rig, library) = setup(); + let smoke_ch = ChannelType::Other("Smoke".to_string()); + let fire_ch = ChannelType::Other("Fire".to_string()); + let safety_ch = ChannelType::Other("Safety".to_string()); + + let half = render( + &rig, + &library, + &lanes(0.0, 0.0, 0.5), + &ProgrammerParams::default(), + &HashSet::new(), + 0.0, + ); + assert_eq!( + value_of(&rig, &library, &half, "SM1", &smoke_ch), + u8_of(0.5) + ); + assert_eq!(value_of(&rig, &library, &half, "PY1", &safety_ch), 255); + assert_eq!( + value_of(&rig, &library, &half, "PY1", &fire_ch), + 0, + "half level must not fire pyro" + ); + + let full = render( + &rig, + &library, + &lanes(0.0, 0.0, 1.0), + &ProgrammerParams::default(), + &HashSet::new(), + 0.0, + ); + assert_eq!(value_of(&rig, &library, &full, "PY1", &fire_ch), 255); + } + + #[test] + fn highlight_snaps_selection_to_open_white() { + let (rig, library) = setup(); + let mut params = ProgrammerParams::default(); + params.highlight = true; + let p1 = rig.iter().find(|f| f.label == "P1").unwrap().id; + let selection = HashSet::from([p1]); + let frames = render( + &rig, + &library, + &lanes(0.0, 0.0, 0.0), + ¶ms, + &selection, + 0.0, + ); + assert_eq!( + value_of(&rig, &library, &frames, "P1", &ChannelType::Dimmer), + 255 + ); + assert_eq!( + value_of(&rig, &library, &frames, "P1", &ChannelType::White), + 255 + ); + // Unselected neighbor stays dark. + assert_eq!( + value_of(&rig, &library, &frames, "P2", &ChannelType::Dimmer), + 0 + ); + } + + #[test] + fn preview_blinds_programmer_values() { + let (rig, library) = setup(); + let mut params = ProgrammerParams::default(); + params.intensity.dimmer = 0.0; // would black out the rig... + params.preview = true; // ...but blind keeps it in the editor + let frames = render( + &rig, + &library, + &lanes(1.0, 0.0, 0.0), + ¶ms, + &HashSet::new(), + 0.0, + ); + assert_eq!( + value_of(&rig, &library, &frames, "P1", &ChannelType::Dimmer), + 255 + ); + } + + #[test] + fn step_distribution_splits_the_cohort() { + let (rig, library) = setup(); + let mut params = ProgrammerParams::default(); + params.intensity.effect.applied = true; + params.intensity.effect.waveform = Waveform::Square; + params.intensity.effect.distribution = Distribution::Step(2); + let ids: Vec = rig + .iter() + .filter(|f| f.label == "P1" || f.label == "P2") + .map(|f| f.id) + .collect(); + let selection: HashSet = ids.into_iter().collect(); + let frames = render( + &rig, + &library, + &lanes(1.0, 0.0, 0.0), + ¶ms, + &selection, + 0.25, + ); + let a = value_of(&rig, &library, &frames, "P1", &ChannelType::Dimmer); + let b = value_of(&rig, &library, &frames, "P2", &ChannelType::Dimmer); + assert_eq!( + (a, b), + (255, 0), + "square wave at Step(2) puts the halves in antiphase" + ); + // Fixtures outside the selection are untouched by the effect. + assert_eq!( + value_of(&rig, &library, &frames, "P3", &ChannelType::Dimmer), + 255 + ); + } + + #[test] + fn pixel_bar_scales_with_lane_and_chases() { + let (rig, library) = setup(); + let params = ProgrammerParams::default(); + let dark = render( + &rig, + &library, + &lanes(0.0, 0.0, 0.0), + ¶ms, + &HashSet::new(), + 0.0, + ); + let pb1 = rig.iter().find(|f| f.label == "PB1").unwrap(); + let frame = &dark[&pb1.universe]; + assert!(frame.iter().all(|&v| v == 0), "pixels dark with lane off"); + + let lit = render( + &rig, + &library, + &lanes(0.0, 1.0, 0.0), + ¶ms, + &HashSet::new(), + 0.25, + ); + let profile = library.get(&pb1.profile_id).unwrap(); + let base = (pb1.start_address - 1) as usize; + let slot = &lit[&pb1.universe][base..base + profile.footprint()]; + let lit_pixels = slot + .chunks(3) + .filter(|px| px.iter().any(|&v| v > 0)) + .count(); + let n = profile.footprint() / 3; + assert!(lit_pixels > 0, "chase lights a window"); + assert!(lit_pixels < n, "chase is a window, not the whole bar"); + } + + #[test] + fn position_maps_degrees_and_swings_with_effect() { + let (rig, library) = setup(); + let mut params = ProgrammerParams::default(); + params.position.pan = 0.0; + params.position.tilt = 360.0; + let frames = render( + &rig, + &library, + &lanes(1.0, 0.0, 0.0), + ¶ms, + &HashSet::new(), + 0.0, + ); + assert_eq!( + value_of(&rig, &library, &frames, "S1", &ChannelType::Pan), + 0 + ); + assert_eq!( + value_of(&rig, &library, &frames, "S1", &ChannelType::Tilt), + 255 + ); + } +} diff --git a/crates/halo-light/src/programmer.rs b/crates/halo-light/src/programmer.rs new file mode 100644 index 0000000..f93caf0 --- /dev/null +++ b/crates/halo-light/src/programmer.rs @@ -0,0 +1,445 @@ +//! The lighting programmer: a live manual-override layer that sits above +//! the track-cue layer, console-style. When a lane's override is active +//! (latched ON or a held FLASH), the programmer owns that lane's output; +//! CLEAR releases every latch and the track cues take back over. +//! +//! [`resolve`] is the single source of truth for "what is the rig doing +//! right now" — every indicator (toolbar LEDs, lane-strip tints, hollow +//! cue bars) must derive from its output so provenance stays consistent. + +use crate::cues::{ALL_LANES, CueSet, LANE_COUNT}; + +/// One lane's manual override state. +#[derive(Clone)] +pub struct LaneOverride { + /// Latched ON until CLEAR. + pub latched: bool, + /// Momentary: active only while the FLASH button/key is held. + pub flash_held: bool, + /// Level the programmer drives the lane at while active. + pub intensity: f32, +} + +impl Default for LaneOverride { + fn default() -> Self { + Self { + latched: false, + flash_held: false, + intensity: 1.0, + } + } +} + +impl LaneOverride { + pub fn active(&self) -> bool { + self.latched || self.flash_held + } +} + +pub type Programmer = [LaneOverride; LANE_COUNT]; + +/// Where a lane's current output comes from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LaneSource { + /// Manual override — highest priority. + Programmer, + /// A cue from the active lighting deck's track. + Track, + Off, +} + +#[derive(Debug, Clone, Copy)] +pub struct LaneOutput { + pub source: LaneSource, + pub level: f32, +} + +/// Resolve the lighting output priority stack for one instant: +/// Programmer > track cues at `playhead` > off. +pub fn resolve( + prog: &Programmer, + track_cues: Option<&CueSet>, + playhead: f64, +) -> [LaneOutput; LANE_COUNT] { + ALL_LANES.map(|lane| { + let o = &prog[lane as usize]; + if o.active() { + return LaneOutput { + source: LaneSource::Programmer, + level: o.intensity.clamp(0.0, 1.0), + }; + } + if let Some(cue) = track_cues.and_then(|c| c.active_at(lane, playhead)) { + return LaneOutput { + source: LaneSource::Track, + level: cue.intensity.clamp(0.0, 1.0), + }; + } + LaneOutput { + source: LaneSource::Off, + level: 0.0, + } + }) +} + +/// Release every latch (held FLASH keys release themselves on key-up). +pub fn clear(prog: &mut Programmer) { + for o in prog.iter_mut() { + o.latched = false; + } +} + +/// Whether any lane is latched (drives the CLEAR button's armed styling). +pub fn any_latched(prog: &Programmer) -> bool { + prog.iter().any(|o| o.latched) +} + +// --------------------------------------------------------------------------- +// Parameter views + per-parameter effects. +// +// MOCKUP STATE for now: values and effect configs are real, interactive +// state, but nothing drives fixture output until the fixture engine lands. +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ParamView { + #[default] + Intensity, + Color, + Position, + Beam, + PixelFx, +} + +pub const ALL_VIEWS: [(ParamView, &str); 5] = [ + (ParamView::Intensity, "INTENSITY"), + (ParamView::Color, "COLOR"), + (ParamView::Position, "POSITION"), + (ParamView::Beam, "BEAM"), + (ParamView::PixelFx, "PIXEL FX"), +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Waveform { + Sine, + Square, + Sawtooth, + Triangle, +} + +pub const ALL_WAVEFORMS: [(Waveform, &str); 4] = [ + (Waveform::Sine, "SINE"), + (Waveform::Square, "SQR"), + (Waveform::Sawtooth, "SAW"), + (Waveform::Triangle, "TRI"), +]; + +/// Musical span one effect cycle rides on (at ratio 1.0). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Interval { + Beat, + Bar, + Phrase, +} + +pub const ALL_INTERVALS: [(Interval, &str); 3] = [ + (Interval::Beat, "BEAT"), + (Interval::Bar, "BAR"), + (Interval::Phrase, "PHRASE"), +]; + +impl Interval { + /// Length in beats (4/4; 16-bar phrases, matching the deck grids). + pub fn beats(self) -> f64 { + match self { + Self::Beat => 1.0, + Self::Bar => 4.0, + Self::Phrase => 64.0, + } + } +} + +/// How the effect spreads across the selected fixtures. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Distribution { + /// Every fixture in phase. + All, + /// Fixtures grouped into n steps. + Step(u32), + /// Phase offset (degrees) per fixture, a travelling wave. + Wave(u32), +} + +#[derive(Debug, Clone)] +pub struct EffectConfig { + pub waveform: Waveform, + pub interval: Interval, + /// Cycles per interval, 0.00..=2.00. + pub ratio: f32, + /// Phase offset in degrees, 0..=360. + pub phase_deg: f32, + pub distribution: Distribution, + /// Latched by the APPLY button. + pub applied: bool, +} + +impl Default for EffectConfig { + fn default() -> Self { + Self { + waveform: Waveform::Sine, + interval: Interval::Beat, + ratio: 1.0, + phase_deg: 0.0, + distribution: Distribution::All, + applied: false, + } + } +} + +/// Normalized effect value in 0..=1 at musical time `t`, measured in +/// intervals (so `t` advances by 1.0 per beat/bar/phrase as configured). +pub fn effect_value(cfg: &EffectConfig, t: f64) -> f32 { + let cycles = t * cfg.ratio as f64 + cfg.phase_deg as f64 / 360.0; + let phase = (cycles.rem_euclid(1.0)) as f32; + match cfg.waveform { + Waveform::Sine => 0.5 - 0.5 * (phase * std::f32::consts::TAU).cos(), + Waveform::Square => { + if phase < 0.5 { + 1.0 + } else { + 0.0 + } + } + Waveform::Sawtooth => phase, + Waveform::Triangle => { + if phase < 0.5 { + phase * 2.0 + } else { + 2.0 - phase * 2.0 + } + } + } +} + +#[derive(Clone)] +pub struct IntensityParams { + /// Percent, 0..=100. + pub dimmer: f32, + pub strobe: f32, + pub effect: EffectConfig, +} + +impl Default for IntensityParams { + fn default() -> Self { + Self { + dimmer: 100.0, + strobe: 0.0, + effect: EffectConfig::default(), + } + } +} + +#[derive(Clone)] +pub struct ColorParams { + /// R/G/B/W percent, 0..=100. + pub rgbw: [f32; 4], + pub effect: EffectConfig, +} + +impl Default for ColorParams { + fn default() -> Self { + Self { + rgbw: [100.0, 100.0, 100.0, 0.0], + effect: EffectConfig::default(), + } + } +} + +/// Which axes a position effect drives. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PanTiltTarget { + #[default] + Both, + Pan, + Tilt, +} + +#[derive(Clone)] +pub struct PositionParams { + /// Degrees, 0..=360. + pub pan: f32, + pub tilt: f32, + pub target: PanTiltTarget, + pub effect: EffectConfig, +} + +impl Default for PositionParams { + fn default() -> Self { + Self { + pan: 180.0, + tilt: 180.0, + target: PanTiltTarget::default(), + effect: EffectConfig::default(), + } + } +} + +#[derive(Clone)] +pub struct BeamParams { + /// Selected gobo, 1..=8. + pub gobo: u8, + pub effect: EffectConfig, +} + +impl Default for BeamParams { + fn default() -> Self { + Self { + gobo: 1, + effect: EffectConfig::default(), + } + } +} + +/// Placeholder pixel-bar effects until the fixture engine defines real ones. +pub const PIXEL_EFFECTS: [&str; 8] = [ + "Chase L-R", + "Bounce", + "Rainbow", + "Sparkle", + "VU Meter", + "Breathe", + "Strobe All", + "Theater", +]; + +/// Placeholder color presets `(name, [r, g, b, w])` in percent, shared by +/// the Color view's preset buttons and the Pixel FX color picker. +pub const COLOR_PRESETS: [(&str, [f32; 4]); 10] = [ + ("White", [0.0, 0.0, 0.0, 100.0]), + ("Red", [100.0, 0.0, 0.0, 0.0]), + ("Orange", [100.0, 40.0, 0.0, 0.0]), + ("Yellow", [100.0, 85.0, 0.0, 0.0]), + ("Green", [0.0, 100.0, 0.0, 0.0]), + ("Cyan", [0.0, 90.0, 100.0, 0.0]), + ("Blue", [0.0, 0.0, 100.0, 0.0]), + ("Magenta", [100.0, 0.0, 100.0, 0.0]), + ("Pink", [100.0, 25.0, 55.0, 10.0]), + ("UV", [45.0, 0.0, 100.0, 0.0]), +]; + +#[derive(Clone, Default)] +pub struct PixelFxParams { + /// Index into [`PIXEL_EFFECTS`]. + pub effect: usize, + /// Index into [`COLOR_PRESETS`]. + pub color: usize, +} + +/// All parameter-view state for the programmer surface. +#[derive(Clone, Default)] +pub struct ProgrammerParams { + pub view: ParamView, + pub intensity: IntensityParams, + pub color: ColorParams, + pub position: PositionParams, + pub beam: BeamParams, + pub pixel: PixelFxParams, + /// Blind mode: view programmer values without sending them to the rig. + pub preview: bool, + /// Snap the selected fixtures to full white for identification. + pub highlight: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cues::Lane; + + #[test] + fn programmer_beats_track_beats_off() { + let mut cues = CueSet::empty(); + cues.insert(Lane::Lighting, 0.0, 100.0, 0.7); + + let mut prog = Programmer::default(); + // Track cue wins while the programmer is idle. + let out = resolve(&prog, Some(&cues), 50.0); + assert_eq!(out[0].source, LaneSource::Track); + assert!((out[0].level - 0.7).abs() < 1e-6); + // Off past the cue. + assert_eq!( + resolve(&prog, Some(&cues), 200.0)[0].source, + LaneSource::Off + ); + // Latching takes the lane over at the programmer's intensity. + prog[0].latched = true; + prog[0].intensity = 0.4; + let out = resolve(&prog, Some(&cues), 50.0); + assert_eq!(out[0].source, LaneSource::Programmer); + assert!((out[0].level - 0.4).abs() < 1e-6); + // Other lanes are untouched. + assert_eq!(out[1].source, LaneSource::Off); + // CLEAR hands back to the track. + clear(&mut prog); + assert_eq!( + resolve(&prog, Some(&cues), 50.0)[0].source, + LaneSource::Track + ); + } + + #[test] + fn effect_value_waveform_shapes() { + let mut cfg = EffectConfig::default(); // sine, ratio 1, phase 0 + assert!((effect_value(&cfg, 0.0) - 0.0).abs() < 1e-6); + assert!((effect_value(&cfg, 0.25) - 0.5).abs() < 1e-6); + assert!((effect_value(&cfg, 0.5) - 1.0).abs() < 1e-6); + + cfg.waveform = Waveform::Square; + assert_eq!(effect_value(&cfg, 0.1), 1.0); + assert_eq!(effect_value(&cfg, 0.6), 0.0); + + cfg.waveform = Waveform::Sawtooth; + assert!((effect_value(&cfg, 0.75) - 0.75).abs() < 1e-6); + + cfg.waveform = Waveform::Triangle; + assert!((effect_value(&cfg, 0.25) - 0.5).abs() < 1e-6); + assert!((effect_value(&cfg, 0.5) - 1.0).abs() < 1e-6); + assert!((effect_value(&cfg, 0.75) - 0.5).abs() < 1e-6); + } + + #[test] + fn effect_value_ratio_and_phase() { + let mut cfg = EffectConfig { + waveform: Waveform::Sawtooth, + ..Default::default() + }; + // Ratio 0: frozen at the phase offset. + cfg.ratio = 0.0; + cfg.phase_deg = 90.0; + assert!((effect_value(&cfg, 0.0) - 0.25).abs() < 1e-6); + assert!((effect_value(&cfg, 5.3) - 0.25).abs() < 1e-6); + // Ratio 2: two cycles per interval. + cfg.ratio = 2.0; + cfg.phase_deg = 0.0; + assert!((effect_value(&cfg, 0.25) - 0.5).abs() < 1e-6); + // Always normalized. + for wf in ALL_WAVEFORMS.map(|(w, _)| w) { + cfg.waveform = wf; + for i in 0..40 { + let v = effect_value(&cfg, i as f64 * 0.173 - 3.0); + assert!((0.0..=1.0).contains(&v), "{wf:?} out of range: {v}"); + } + } + } + + #[test] + fn flash_is_momentary_and_survives_clear() { + let mut prog = Programmer::default(); + prog[2].flash_held = true; + assert_eq!(resolve(&prog, None, 0.0)[2].source, LaneSource::Programmer); + // CLEAR only drops latches; a held flash stays until key-up. + clear(&mut prog); + assert_eq!(resolve(&prog, None, 0.0)[2].source, LaneSource::Programmer); + prog[2].flash_held = false; + assert_eq!(resolve(&prog, None, 0.0)[2].source, LaneSource::Off); + } +} diff --git a/crates/halo/Cargo.toml b/crates/halo/Cargo.toml index 533aaf0..c2709b0 100644 --- a/crates/halo/Cargo.toml +++ b/crates/halo/Cargo.toml @@ -1,33 +1,44 @@ -# [lib] -# name = "halo" -# path = "src/lib.rs" -# bench = false - -[[bin]] -name = "halo" -path = "src/main.rs" -bench = false - [package] name = "halo" -description = "Realtime lighting console for live performances and precise automation." version = "0.1.0" -authors = ["Rob Morgan "] -edition = "2021" +edition = "2024" +publish = false [dependencies] -halo-core = { path = "../core" } -halo-ui = { path = "../ui" } -halo-fixtures = { path = "../fixtures" } -rusty_link = "0.4.8" -artnet_protocol = "0.4.4" -anyhow = "1.0.101" -log = "0.4.29" -midir = "0.10.3" -clap = { version = "4.5.57", features = ["derive"] } -crossterm = "0.29.0" -eframe = "0.33.3" -parking_lot = "0.12.5" -rfd = "0.17.2" -tokio = { version = "1.49.0", features = ["full"] } -rodio = "0.21.1" +halo-light = { workspace = true } +# Local path so the crate can evolve alongside Halo without publishing. +timestretch = { path = "../../../timestretch-rs" } +# wgpu backend (native Metal on macOS): the default glow backend presents +# through CGLFlushDrawable / OpenGL-on-Metal, which profiled as the single +# biggest CPU cost of the timestretch reference app. +eframe = { version = "0.31", default-features = false, features = [ + "accesskit", + "default_fonts", + "persistence", + "wgpu", + # winit refuses to build on Linux at all without one of these — they're + # part of eframe's own `default` feature set, which is otherwise off here. + "wayland", + "x11", +] } +cpal = "0.15" +symphonia = { version = "0.5", features = ["mp3", "flac", "ogg", "wav", "pcm", "vorbis"] } +egui_extras = { version = "0.31", default-features = false } +rusqlite = { version = "0.32", features = ["bundled"] } +serde = { workspace = true } +serde_json = { workspace = true } +dirs = "5" +libc = "0.2" +rfd = "0.15" +lofty = "0.22" +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } +log = { workspace = true } +env_logger = "0.11" + +# `cargo install cargo-bundle && cargo bundle --release` builds Halo.app. +[package.metadata.bundle] +name = "Halo" +identifier = "com.robmorgan.halo" +category = "public.app-category.music" +short_description = "Two-deck DJ app powered by the timestretch engine" +osx_minimum_system_version = "12.0" diff --git a/crates/halo/src/app.rs b/crates/halo/src/app.rs new file mode 100644 index 0000000..0a4ad59 --- /dev/null +++ b/crates/halo/src/app.rs @@ -0,0 +1,4208 @@ +use std::path::{Path, PathBuf}; +use std::sync::atomic::Ordering; +use std::sync::{Arc, mpsc}; + +use eframe::egui; +use halo_light::artnet::{ARTNET_PORT, ArtNetMode, NetworkConfig}; +use halo_light::cues::{ALL_LANES, CueSet, LANE_COUNT, Lane}; +use halo_light::fixture::{ALL_KINDS, Rig, RigFile, default_rig}; +use halo_light::fixture_library::FixtureLibrary; +use halo_light::programmer::{ + self, LaneOutput, LaneSource, ParamView, Programmer, ProgrammerParams, +}; +use timestretch::{AudioBuffer, BeatGrid, Channels, PreAnalysisArtifact}; + +use crate::audio::{AudioOutput, AudioSettings, DeckAudio, list_output_devices}; +use crate::deck::Deck; +use crate::decoder::decode_file; +use crate::dmx; +use crate::fader::{Fader, Notches}; +use crate::knob::{Knob, KnobArc}; +use crate::library::{Library, PlaylistRow, SortColumn, TrackRow}; +use crate::programmer_ui::{ProgrammerCtx, programmer_panel}; +use crate::show::simulate_show; +use crate::state::{MixerShared, ScrubPhase, Transport}; +use crate::waveform::{ + BandPeaks, EditorInteraction, GridMarks, LanesEditorParams, LanesParams, OverviewParams, + OverviewTexture, ScrubGesture, ZoomSpan, ZoomedParams, lanes_editor, paint_beat_counter, + paint_lanes, paint_overview, paint_zoomed, +}; +use crate::worker::{WorkerEvent, spawn_analysis_worker, spawn_folder_import}; + +/// Halo accent color (amber, CDJ-style). +const ACCENT: egui::Color32 = egui::Color32::from_rgb(255, 170, 40); +const DECK_NAMES: [&str; 2] = ["A", "B"]; +/// Longest edge of the artwork texture. +const ARTWORK_MAX_PX: u32 = 256; +/// Artwork display size in the deck header. +const ARTWORK_SIZE: f32 = 56.0; + +/// Everything the decode thread produces for a track load. +struct LoadedData { + title: String, + artist: Option, + key: Option, + track_id: Option, + artwork: Option, + /// Interleaved stereo, resampled to the device rate. + samples: Arc>, + peaks: BandPeaks, + grid: BeatGrid, + /// Library analysis artifact, already rescaled to the device rate. + artifact: Option>, +} + +type DecodeResult = Result; + +struct DeckUi { + deck: Deck, + decode_rx: Option>, + /// Library row of the loaded track (None only when the library is + /// unavailable). + track_id: Option, + key: Option, + /// Artifact that landed while the deck was playing; applied at the next + /// non-playing moment. + pending_artifact: Option>, + peaks: Option, + marks: GridMarks, + /// Lighting/pixels/FX cues for the lane strip, loaded from the + /// library on track load (empty until authored in Prepare). + cues: CueSet, + bpm: f64, + overview: Option, + artwork: Option, + title: String, + artist: Option, + zoom: ZoomSpan, + /// Pointer-implied platter position while the zoomed waveform is + /// dragged (None = not dragging); published to the audio callback's + /// scrub voice as the chase target. + scrub_pos: Option, + /// Last consumed scrub-landing sequence number; each newly published + /// landing fires one parallel engine warm-start seek. + landing_seq_seen: u64, + /// Previous frame's cue-button-held state, for press/release edges. + cue_was_down: bool, + /// True while the cue button is previewing (play-from-cue while held). + cue_previewing: bool, + /// Tempo slider value in percent, within ±`pitch_range`. + pitch_percent: f32, + /// Tempo slider range in percent (8 / 16 / 50). + pitch_range: f32, + keylock: bool, + /// Following the master deck's tempo + beat phase. + synced: bool, + /// Momentary pitch-bend factor from the held nudge buttons (1.0 = none). + bend: f32, + /// Smoothed beat-phase error vs the master (beats, ±0.5), tracked only + /// while synced and both decks play. Filters playhead-publish jitter so + /// the sync PLL doesn't chase phantom errors. + phase_err: Option, + /// Hot cue slots (source frames). + hot_cues: [Option; 8], + hotcue_was_down: [bool; 8], + /// Gated hot-cue mode: play from the cue while held, pause on release. + gated: bool, + /// Slot currently held in gated mode. + gated_held: Option, + /// Quantize hot cues and loop points to the beat grid. + quantize: bool, + /// Header time readout shows remaining (true) or elapsed (false). + show_remaining: bool, + /// Staged loop-in point awaiting loop-out. + loop_in_staged: Option, + /// Active/last loop length in beats (resize anchor). + loop_beats: f64, + /// Restore the playhead to this track fraction after the next load + /// (used when an audio-device change forces a reload). + pending_seek_frac: Option, +} + +impl DeckUi { + fn new() -> Self { + Self { + deck: Deck::new(), + decode_rx: None, + track_id: None, + key: None, + pending_artifact: None, + peaks: None, + marks: GridMarks::empty(), + cues: CueSet::empty(), + bpm: 0.0, + overview: None, + artwork: None, + title: String::new(), + artist: None, + zoom: ZoomSpan::default(), + scrub_pos: None, + landing_seq_seen: 0, + cue_was_down: false, + cue_previewing: false, + pitch_percent: 0.0, + pitch_range: 8.0, + keylock: true, + synced: false, + bend: 1.0, + phase_err: None, + hot_cues: [None; 8], + hotcue_was_down: [false; 8], + gated: false, + gated_held: None, + quantize: true, + show_remaining: false, + loop_in_staged: None, + loop_beats: 4.0, + pending_seek_frac: None, + } + } + + fn playhead(&self) -> usize { + self.deck + .shared + .playhead_frames() + .min(self.deck.shared.total()) + } + + fn toggle_play(&mut self) { + if self.deck.track.is_none() { + return; + } + let shared = &self.deck.shared; + self.cue_previewing = false; + match shared.transport() { + Transport::Playing => shared.set_transport(Transport::Paused), + _ => { + let total = shared.total(); + if total > 0 && shared.playhead_frames() >= total { + request_seek_guarded(shared, 0); + } + shared.set_transport(Transport::Playing); + } + } + } + + /// CDJ cue, press edge: playing = return to cue and pause; paused = set + /// the cue here and preview while held. + fn cue_press(&mut self) { + if self.deck.track.is_none() { + return; + } + let playhead = self.playhead(); + let shared = &self.deck.shared; + if shared.transport() == Transport::Playing { + if !self.cue_previewing { + shared.set_transport(Transport::Paused); + request_seek_guarded(shared, shared.cue_point.load(Ordering::Relaxed) as usize); + } + } else { + shared.cue_point.store(playhead as u64, Ordering::Relaxed); + self.cue_previewing = true; + shared.set_transport(Transport::Playing); + } + } + + /// CDJ cue, release edge: a preview ends back at the cue point, paused. + fn cue_release(&mut self) { + if self.cue_previewing { + self.cue_previewing = false; + let shared = &self.deck.shared; + shared.set_transport(Transport::Paused); + request_seek_guarded(shared, shared.cue_point.load(Ordering::Relaxed) as usize); + } + } + + /// Hot cue press: empty slot stores the (quantized) playhead, occupied + /// slot jumps and plays. Returns true when it jumped (the button path + /// uses this to engage gated mode). + fn hot_cue_press(&mut self, slot: usize) -> bool { + if self.deck.track.is_none() { + return false; + } + let playhead = self.playhead(); + match self.hot_cues[slot] { + None => { + self.hot_cues[slot] = Some(quantize_frame(&self.marks, self.quantize, playhead)); + false + } + Some(frame) => { + let shared = &self.deck.shared; + request_seek_guarded(shared, frame); + shared.set_transport(Transport::Playing); + self.cue_previewing = false; + true + } + } + } + + /// Quantized 4-beat autoloop at the current position. + fn autoloop_4(&mut self) { + if self.deck.track.is_none() || !self.marks.is_usable() { + return; + } + let start = quantize_frame(&self.marks, true, self.playhead()); + let end = loop_end_for(&self.marks, start, 4.0); + if end > start { + self.deck.shared.set_loop(Some((start, end))); + self.loop_beats = 4.0; + self.loop_in_staged = None; + } + } + + fn exit_loop(&mut self) { + self.deck.shared.set_loop(None); + } +} + +/// UI state of the track browser. +struct BrowserState { + rows: Vec, + playlists: Vec, + /// Selected playlist (None = whole library). + selected: Option, + search: String, + sort: SortColumn, + ascending: bool, + /// Re-query the DB on the next frame. + dirty: bool, + /// Playlist being renamed inline: (id, buffer). + rename: Option<(i64, String)>, +} + +impl BrowserState { + fn new() -> Self { + Self { + rows: Vec::new(), + playlists: Vec::new(), + selected: None, + search: String::new(), + sort: SortColumn::Title, + ascending: true, + dirty: true, + rename: None, + } + } +} + +/// State restored between sessions. +/// +/// New fields MUST carry `#[serde(default)]`: the stored blob is decoded +/// with `unwrap_or_default()`, so a missing field would otherwise reset +/// every setting on upgrade. +#[derive(serde::Serialize, serde::Deserialize, Default)] +struct Persisted { + master_volume: f32, + crossfader: f32, + trims: [f32; 2], + keylocks: [bool; 2], + pitch_ranges: [f32; 2], + quantize: [bool; 2], + gated: [bool; 2], + sort: Option, + ascending: bool, + device_name: Option, + buffer_size: Option, + #[serde(default)] + view: View, + #[serde(default)] + audition_volume: f32, + /// Inverted so the missing-field default (false) means snap ON. + #[serde(default)] + snap_off: bool, + #[serde(default)] + footer_tab: FooterTab, +} + +/// Which pane the bottom slide-up footer shows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +enum FooterTab { + #[default] + Library, + Programmer, +} + +/// Persisted Art-Net destination choice. Lives in the library DB (show +/// config, tied to the venue), not eframe storage (UI state, tied to the +/// machine). +#[derive(Clone, Default, serde::Serialize, serde::Deserialize)] +struct ArtNetSettings { + /// Node IP for unicast; `None` broadcasts. + unicast_ip: Option, +} + +/// Art-Net config for the current rig: one destination (broadcast, or +/// unicast when the settings name a node IP) with every rig universe +/// routed to it. +fn build_net(settings: &ArtNetSettings, rig: &Rig) -> std::sync::Arc { + let mode = match settings + .unicast_ip + .as_deref() + .and_then(|ip| ip.parse::().ok()) + { + Some(ip) => ArtNetMode::Unicast( + "0.0.0.0:0".parse().unwrap(), + std::net::SocketAddr::new(ip, ARTNET_PORT), + ), + None => ArtNetMode::Broadcast, + }; + let mut net = NetworkConfig::single("output", mode); + for f in rig.iter() { + net.route_universe(f.universe, 0); + } + std::sync::Arc::new(net) +} + +/// Which top-level screen is showing. Purely a UI concern: the engine +/// (audio, decks, lighting) is untouched by the view, so switching never +/// interrupts playback. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +enum View { + #[default] + Perform, + Prepare, + /// Rig setup: the full-window patch sheet. + Patch, +} + +/// Default audition player volume (its channel bypasses the crossfader, +/// so the fader alone sets its level). +const AUDITION_VOLUME_DEFAULT: f32 = 0.85; + +/// Prepare-view state: an independent audition player (a full third deck +/// mixed straight to master) whose loaded track is the cue-editing target, +/// plus the lane editor's selection and drag state. +struct PrepareState { + audition: DeckUi, + selection: std::collections::HashSet, + interaction: EditorInteraction, + /// Snap editor gestures to the beat grid. + snap: bool, +} + +impl PrepareState { + fn new() -> Self { + let audition = DeckUi::new(); + audition.deck.shared.fader.store(AUDITION_VOLUME_DEFAULT); + Self { + audition, + selection: std::collections::HashSet::new(), + interaction: EditorInteraction::default(), + snap: true, + } + } +} + +/// One clipboard cue; offsets are relative to the earliest copied cue and +/// in seconds, so pastes land correctly on any track at any device rate. +struct ClipCue { + lane: Lane, + offset_secs: f64, + dur_secs: f64, + intensity: f32, +} + +const PERSIST_KEY: &str = "halo"; + +pub struct HaloApp { + audio: Option, + mixer: Arc, + decks: [DeckUi; 2], + /// Index of the tempo-master deck. + master: usize, + /// Index of the deck driving the lighting rig. + lighting_deck: usize, + view: View, + prepare: PrepareState, + /// Cue clipboard (survives track switches → cross-track paste). + cue_clipboard: Vec, + /// Live manual-override layer; beats the active deck's track cues. + programmer: Programmer, + /// Which pane the footer shows (library browser or programmer). + footer_tab: FooterTab, + /// The patched rig (simulated default until real patching exists). + /// Shared with the DMX engine thread via snapshots; Arc so publishing + /// is a pointer bump, not a rig clone. + rig: std::sync::Arc, + /// Snapshot slot the DMX engine thread renders from. + dmx: dmx::DmxShared, + fixture_library: FixtureLibrary, + /// Current Art-Net config. Replaced wholesale (new Arc) on any + /// settings or rig-universe change — the engine rebuilds its sockets + /// when the pointer changes. + net: std::sync::Arc, + artnet: ArtNetSettings, + /// Settings-window edit buffer for the unicast node IP. + artnet_ip_edit: String, + /// Staged patch-sheet edits: the live rig, DMX output, and programmer + /// keep the last-applied patch until APPLY commits this draft to the + /// show file. `None` = sheet is clean. In-memory only. + patch_draft: Option, + /// Fixtures the programmer's grid has selected (empty = whole lanes). + fixture_selection: std::collections::HashSet, + /// Parameter-view values + effect configs (mockup state). + programmer_params: ProgrammerParams, + /// Keyboard FLASH state (Z/X/C), merged with the on-screen buttons. + flash_key: [bool; LANE_COUNT], + library: Option, + browser: BrowserState, + /// Wakes the analysis worker after imports. + wake_tx: mpsc::Sender<()>, + events_rx: mpsc::Receiver, + /// Prototype sender for the events channel (folder imports clone it). + event_tx: mpsc::Sender, + audio_settings: AudioSettings, + settings_open: bool, + available_devices: Vec, + /// Per-deck previous key-down states for shortcut edge detection. + kb_prev: [[bool; 8]; 2], + /// Process CPU sampling: (last wall instant, last cpu seconds, percent). + cpu_sample: (std::time::Instant, f64, f32), + status: String, + /// Absolute x of the mixer column's center, captured while rendering the + /// central panel and read a frame later by the toolbar to center the + /// master-BPM readout over the mixer (0 = not measured yet). + mixer_center_x: f32, +} + +impl HaloApp { + fn deck_audio(deck_ui: &DeckUi) -> DeckAudio { + DeckAudio { + shared: deck_ui.deck.shared.clone(), + slot: deck_ui.deck.processor_slot.clone(), + retired: deck_ui.deck.retired_slot.clone(), + scratch_source: deck_ui.deck.scratch_source.clone(), + scratch_retired: deck_ui.deck.scratch_retired.clone(), + reset_request: deck_ui.deck.reset_request.clone(), + } + } + + pub fn new(cc: &eframe::CreationContext<'_>, initial_file: Option) -> Self { + apply_theme(&cc.egui_ctx); + + let persisted: Persisted = cc + .storage + .and_then(|s| eframe::get_value(s, PERSIST_KEY)) + .unwrap_or_default(); + + let mixer = Arc::new(MixerShared::new()); + let mut decks = [DeckUi::new(), DeckUi::new()]; + let mut prepare = PrepareState::new(); + + // Restore session state before the audio stream starts. + if persisted.master_volume > 0.0 { + mixer.master.store(persisted.master_volume); + mixer.crossfader.store(persisted.crossfader.clamp(0.0, 1.0)); + for (i, deck_ui) in decks.iter_mut().enumerate() { + deck_ui.deck.shared.trim.store(persisted.trims[i]); + deck_ui.keylock = persisted.keylocks[i]; + deck_ui.pitch_range = persisted.pitch_ranges[i].max(8.0); + deck_ui.quantize = persisted.quantize[i]; + deck_ui.gated = persisted.gated[i]; + } + } + if persisted.audition_volume > 0.0 { + prepare + .audition + .deck + .shared + .fader + .store(persisted.audition_volume.clamp(0.0, 1.0)); + } + prepare.snap = !persisted.snap_off; + let audio_settings = AudioSettings { + device_name: persisted.device_name, + buffer_size: persisted.buffer_size, + }; + + let audio = match AudioOutput::new( + [ + Self::deck_audio(&decks[0]), + Self::deck_audio(&decks[1]), + Self::deck_audio(&prepare.audition), + ], + mixer.clone(), + &audio_settings, + ) { + Ok(a) => Some(a), + Err(e) => { + log::error!("{e}"); + None + } + }; + + let status = match &audio { + Some(a) => format!("Output: {} @ {} Hz", a.device_name, a.sample_rate), + None => "No audio output".to_string(), + }; + + let db_path = Library::default_path(); + let library = match Library::open(&db_path) { + Ok(l) => Some(l), + Err(e) => { + log::error!("library: {e}"); + None + } + }; + let (wake_tx, wake_rx) = mpsc::channel(); + let (event_tx, events_rx) = mpsc::channel(); + if library.is_some() { + spawn_analysis_worker(db_path, wake_rx, event_tx.clone()); + } + + let mut browser = BrowserState::new(); + if let Some(sort) = persisted.sort { + browser.sort = sort; + browser.ascending = persisted.ascending; + } + + // Rig patch + Art-Net settings from the library DB, falling back + // to the default rig / broadcast when absent or unparsable. + let fixture_library = FixtureLibrary::new(); + let rig = std::sync::Arc::new( + library + .as_ref() + .and_then(|l| l.setting("rig_patch").ok().flatten()) + .and_then(|json| serde_json::from_str::(&json).ok()) + .map(RigFile::into_rig) + .unwrap_or_else(|| default_rig(&fixture_library)), + ); + let artnet: ArtNetSettings = library + .as_ref() + .and_then(|l| l.setting("artnet").ok().flatten()) + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default(); + let artnet_ip_edit = artnet + .unicast_ip + .clone() + .unwrap_or_else(|| "10.0.0.10".to_string()); + let net = build_net(&artnet, &rig); + let dmx = dmx::spawn_dmx_engine(); + + let mut app = Self { + audio, + mixer, + decks, + master: 0, + lighting_deck: 0, + view: persisted.view, + prepare, + cue_clipboard: Vec::new(), + programmer: Programmer::default(), + footer_tab: persisted.footer_tab, + rig, + dmx, + fixture_library, + net, + artnet, + artnet_ip_edit, + patch_draft: None, + fixture_selection: std::collections::HashSet::new(), + programmer_params: ProgrammerParams::default(), + flash_key: [false; LANE_COUNT], + library, + browser, + wake_tx, + events_rx, + event_tx, + audio_settings, + settings_open: false, + available_devices: Vec::new(), + kb_prev: [[false; 8]; 2], + cpu_sample: (std::time::Instant::now(), process_cpu_secs(), 0.0), + status, + mixer_center_x: 0.0, + }; + // Dev smoke-test hook: import a folder at startup. + if let Some(dir) = std::env::var_os("HALO_IMPORT") { + app.import_folder(PathBuf::from(dir)); + } + if let Some(path) = initial_file { + // Dev smoke-test hook: also open the same track in the Prepare + // audition player (import is idempotent). + if std::env::var_os("HALO_AUDITION").is_some() + && let Some(lib) = &app.library + && let Ok(id) = lib.import_file(&path) + { + app.load_audition(id); + } + app.import_and_load(0, path); + } + // Dev smoke-test hook: latch programmer lanes (comma-separated + // indices) with the footer on the programmer tab. + if let Ok(l) = std::env::var("HALO_LATCH") { + for idx in l.split(',').filter_map(|s| s.trim().parse::().ok()) { + if let Some(o) = app.programmer.get_mut(idx) { + o.latched = true; + } + } + app.footer_tab = FooterTab::Programmer; + } + // Dev smoke-test hook: preselect a fixture group ("all" or a + // group label like "pixels") in the programmer grid. + if let Ok(sel) = std::env::var("HALO_FIXSEL") { + app.fixture_selection = if sel.eq_ignore_ascii_case("all") { + app.rig.ids().collect() + } else { + ALL_KINDS + .iter() + .find(|k| k.group_label().eq_ignore_ascii_case(&sel)) + .map(|&k| app.rig.ids_of_kind(k).collect()) + .unwrap_or_default() + }; + app.footer_tab = FooterTab::Programmer; + } + // Dev smoke-test hook: open a specific programmer parameter view. + if let Ok(v) = std::env::var("HALO_PVIEW") { + app.programmer_params.view = match v.to_ascii_lowercase().as_str() { + "color" => ParamView::Color, + "position" => ParamView::Position, + "beam" => ParamView::Beam, + "pixel" => ParamView::PixelFx, + _ => ParamView::Intensity, + }; + app.footer_tab = FooterTab::Programmer; + } + // Dev smoke-test hook: start in a specific view. + if let Ok(v) = std::env::var("HALO_VIEW") { + app.view = if v.eq_ignore_ascii_case("prepare") { + View::Prepare + } else if v.eq_ignore_ascii_case("patch") { + View::Patch + } else { + View::Perform + }; + } + app + } + + fn import_folder(&mut self, dir: PathBuf) { + if self.library.is_none() { + return; + } + self.status = format!("Importing {}…", dir.display()); + spawn_folder_import( + Library::default_path(), + dir, + self.wake_tx.clone(), + self.event_tx.clone(), + ); + } + + fn device_rate(&self) -> u32 { + self.audio.as_ref().map(|a| a.sample_rate).unwrap_or(44_100) + } + + /// File picked via dialog / CLI: register it in the library first so it + /// gains analysis + browser presence, then load. + fn import_and_load(&mut self, deck_idx: usize, path: PathBuf) { + let mut track_id = None; + if let Some(lib) = &self.library { + match lib.import_file(&path) { + Ok(id) => track_id = Some(id), + Err(e) => log::warn!("import {}: {e}", path.display()), + } + } + if track_id.is_some() { + let _ = self.wake_tx.send(()); + self.browser.dirty = true; + } + self.start_decode(deck_idx, path, track_id); + } + + /// Load a library row onto a deck. + fn load_track_row(&mut self, deck_idx: usize, track_id: i64) { + let row = self + .library + .as_ref() + .and_then(|lib| lib.track(track_id).ok().flatten()); + match row { + Some(row) => self.start_decode(deck_idx, row.path, Some(track_id)), + None => self.status = "Track not found in library".to_string(), + } + } + + fn start_decode(&mut self, deck_idx: usize, path: PathBuf, track_id: Option) { + self.status = format!("Deck {}: loading {}…", DECK_NAMES[deck_idx], path.display()); + self.decks[deck_idx].decode_rx = Some(self.spawn_decode(path, track_id)); + } + + /// Load a library row into the Prepare view's audition player. + fn load_audition(&mut self, track_id: i64) { + let row = self + .library + .as_ref() + .and_then(|lib| lib.track(track_id).ok().flatten()); + match row { + Some(row) => { + self.status = format!("Prepare: loading {}…", row.path.display()); + self.prepare.audition.decode_rx = Some(self.spawn_decode(row.path, Some(track_id))); + } + None => self.status = "Track not found in library".to_string(), + } + } + + fn spawn_decode(&self, path: PathBuf, track_id: Option) -> mpsc::Receiver { + let device_rate = self.device_rate(); + // Stored analysis (native rate) and key come from the library; both + // queries are cheap enough for the UI thread. + let (artifact, key) = match (&self.library, track_id) { + (Some(lib), Some(id)) => ( + lib.analysis(id).ok().flatten(), + lib.track(id).ok().flatten().and_then(|r| r.key), + ), + _ => (None, None), + }; + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(load_track_data(&path, device_rate, track_id, key, artifact)); + }); + rx + } + + /// Handle finished decode threads: install the track on its deck (A, B, + /// or the Prepare audition player) and kick off background pre-analysis. + fn poll_decodes(&mut self, ctx: &egui::Context) { + let device_rate = self.device_rate(); + for (i, deck_ui) in self.decks.iter_mut().enumerate() { + if let Some(status) = Self::poll_deck_decode( + deck_ui, + DECK_NAMES[i], + ctx, + device_rate, + self.library.as_ref(), + &self.wake_tx, + ) { + self.status = status; + } + } + if let Some(status) = Self::poll_deck_decode( + &mut self.prepare.audition, + "PREP", + ctx, + device_rate, + self.library.as_ref(), + &self.wake_tx, + ) { + self.status = status; + } + } + + /// One deck's decode-completion handling; returns a status line when a + /// load finished (or failed). + fn poll_deck_decode( + deck_ui: &mut DeckUi, + label: &str, + ctx: &egui::Context, + device_rate: u32, + library: Option<&Library>, + wake_tx: &mpsc::Sender<()>, + ) -> Option { + // Drop sample Arcs the callback retired, off the audio thread. + if let Ok(mut retired) = deck_ui.deck.scratch_retired.try_lock() { + retired.take(); + } + let rx = deck_ui.decode_rx.as_ref()?; + let Ok(result) = rx.try_recv() else { + return None; + }; + deck_ui.decode_rx = None; + match result { + Ok(data) => { + match deck_ui.deck.load( + data.title.clone(), + data.samples.clone(), + device_rate, + data.artifact.clone(), + ) { + Ok(()) => { + deck_ui.overview = Some(OverviewTexture::from_peaks(ctx, &data.peaks)); + deck_ui.artwork = data.artwork.map(|img| { + ctx.load_texture( + format!("artwork_{label}"), + img, + egui::TextureOptions::LINEAR, + ) + }); + deck_ui.peaks = Some(data.peaks); + deck_ui.marks = GridMarks::from_grid(&data.grid); + deck_ui.cues = data + .track_id + .and_then(|id| library?.cues(id).ok().flatten()) + .map(|f| CueSet::from_file(&f, device_rate)) + .unwrap_or_else(CueSet::empty); + deck_ui.bpm = data.grid.bpm; + deck_ui.title = data.title; + deck_ui.artist = data.artist; + deck_ui.key = data.key; + deck_ui.track_id = data.track_id; + deck_ui.pending_artifact = None; + deck_ui.scrub_pos = None; + deck_ui.hot_cues = [None; 8]; + deck_ui.gated_held = None; + deck_ui.loop_in_staged = None; + deck_ui.loop_beats = 4.0; + // No stored analysis yet: the worker will send + // an Analyzed event when it lands. + if data.artifact.is_none() && data.track_id.is_some() { + let _ = wake_tx.send(()); + } + // Device-change reload: restore the playhead. + if let Some(frac) = deck_ui.pending_seek_frac.take() { + let total = deck_ui.deck.shared.total(); + deck_ui + .deck + .shared + .request_seek((frac * total as f64) as usize); + } + // Dev smoke-test hooks: start playback + // immediately, optionally at a tempo offset + // and/or with a 4-beat loop engaged. + if std::env::var_os("HALO_AUTOPLAY").is_some() { + if let Ok(p) = std::env::var("HALO_PITCH") { + deck_ui.pitch_percent = p.parse().unwrap_or(0.0); + } + if std::env::var_os("HALO_LOOP").is_some() && deck_ui.marks.is_usable() + { + let start = quantize_frame(&deck_ui.marks, true, 0); + let end = loop_end_for(&deck_ui.marks, start, 4.0); + deck_ui.deck.shared.set_loop(Some((start, end))); + } + deck_ui.deck.shared.set_transport(Transport::Playing); + } + Some(format!( + "Deck {label}: {} ({:.1} BPM)", + deck_ui.title, deck_ui.bpm + )) + } + Err(e) => Some(format!("Deck {label}: {e}")), + } + } + Err(e) => Some(format!("Deck {label}: load failed: {e}")), + } + } + + /// Drain analysis-worker events and apply pending artifacts. A freshly + /// analyzed track that's sitting on a deck gets its display grid/BPM + /// immediately; the engine upgrade waits for the next moment the deck + /// isn't playing (rebuilding a live engine would audibly interrupt it). + fn poll_worker_events(&mut self) { + let device_rate = self.device_rate(); + while let Ok(event) = self.events_rx.try_recv() { + match event { + WorkerEvent::Analyzed(id) => { + self.browser.dirty = true; + for deck_ui in self.decks.iter_mut() { + if deck_ui.track_id == Some(id) + && deck_ui.deck.pre_analysis.is_none() + && let Some(lib) = &self.library + && let Ok(Some(native)) = lib.analysis(id) + { + let resampled = native.resample_to(device_rate); + deck_ui.marks = GridMarks::from_grid(&grid_from_artifact(&resampled)); + deck_ui.bpm = resampled.bpm; + deck_ui.pending_artifact = Some(Arc::new(resampled)); + } + } + if let Some(lib) = &self.library + && let Ok(n) = lib.unanalyzed_count() + { + self.status = if n > 0 { + format!("Analyzing… {n} track(s) remaining") + } else { + "Analysis complete".to_string() + }; + } + } + WorkerEvent::Imported(n) => { + self.browser.dirty = true; + self.status = format!("Imported {n} audio file(s)"); + } + } + } + + for deck_ui in self.decks.iter_mut() { + if deck_ui.pending_artifact.is_some() + && deck_ui.deck.shared.transport() != Transport::Playing + { + let artifact = deck_ui.pending_artifact.take().unwrap(); + if let Err(e) = deck_ui.deck.apply_pre_analysis(artifact, device_rate) { + log::error!("apply_pre_analysis: {e}"); + } + } + } + } + + /// Recompute and publish each deck's tempo rate + keylock. + /// + /// A synced deck follows the master's effective BPM and writes the + /// matching pitch back to its own slider (expanding the range if it + /// doesn't fit) so the control shows the real tempo; while both decks + /// run, a gentle proportional rate correction (smoothed error, capped + /// at ±1.5%) chases the master's beat phase — a continuous PLL, like a + /// DJ riding the platter, so the lock survives grid drift and seeks. + /// The PLL nudge is deliberately left out of the displayed pitch to + /// keep the slider steady. Pitch-bend multiplies on top either way. + /// Global/master BPM for the header readout: the pitch-adjusted BPM of the + /// deck driving the mix. A deck is "live" if it's playing with its channel + /// fader up (> 50%); the live deck wins, the master deck breaks ties (both + /// live) and is the fallback (neither live). Excludes momentary bend so the + /// clock reads steady. + /// Deck the master BPM (and the lighting rig) is sourced from: the live + /// deck (playing with fader > 50%), the master deck breaking ties / as the + /// fallback. + fn master_source(&self) -> usize { + let live = |i: usize| { + let d = &self.decks[i]; + d.deck.shared.transport() == Transport::Playing && d.deck.shared.fader.load() > 0.5 + }; + match (live(0), live(1)) { + (true, false) => 0, + (false, true) => 1, + _ => self.master, + } + } + + fn global_bpm(&self) -> f64 { + let d = &self.decks[self.master_source()]; + d.bpm * (1.0 + d.pitch_percent as f64 / 100.0) + } + + fn update_tempo(&mut self, dt: f64) { + // EMA weight for a ~150 ms error-smoothing time constant at the + // actual frame rate. + let alpha = 1.0 - (-dt / 0.15).exp(); + let master = self.master; + let master_base = self.decks[master].bpm; + let master_rate = 1.0 + self.decks[master].pitch_percent as f64 / 100.0; + let master_playing = self.decks[master].deck.shared.transport() == Transport::Playing; + let master_phase = beat_phase( + &self.decks[master].marks, + self.decks[master].deck.shared.playhead_frames() as f64, + ); + + for i in 0..2 { + let d = &mut self.decks[i]; + let manual = 1.0 + d.pitch_percent as f64 / 100.0; + let mut rate = if d.synced && i != master && d.bpm > 0.0 && master_base > 0.0 { + let mut r = master_base * master_rate / d.bpm; + let sync_pct = ((r - 1.0) * 100.0) as f32; + d.pitch_range = d.pitch_range.max(range_for_pitch(sync_pct)); + d.pitch_percent = sync_pct.clamp(-50.0, 50.0); + // A scrub (grab or glide) freezes or overrides the deck's + // playhead — suspend the phase chase so it doesn't poison + // the smoothed error; the PLL re-acquires once it ends. + if master_playing + && d.deck.shared.scrub.phase() == ScrubPhase::Idle + && d.deck.shared.transport() == Transport::Playing + && let Some(mp) = master_phase + && let Some(dp) = beat_phase(&d.marks, d.deck.shared.playhead_frames() as f64) + { + let err = smooth_phase_err(d.phase_err, wrap_phase_err(mp, dp), alpha); + d.phase_err = Some(err); + r *= 1.0 + phase_correction(err); + } else { + d.phase_err = None; + } + r + } else { + d.phase_err = None; + manual + }; + rate *= d.bend as f64; + d.deck.shared.tempo_rate.store(rate.clamp(0.25, 4.0) as f32); + d.deck.shared.keylock.store(d.keylock, Ordering::Relaxed); + } + } + + /// Tear down and rebuild the output stream with the current settings. + /// The old callback owned the deck processors, so loaded decks always + /// reload afterwards (restoring their playhead position). + fn rebuild_audio(&mut self) { + self.audio = None; + match AudioOutput::new( + [ + Self::deck_audio(&self.decks[0]), + Self::deck_audio(&self.decks[1]), + Self::deck_audio(&self.prepare.audition), + ], + self.mixer.clone(), + &self.audio_settings, + ) { + Ok(a) => { + self.status = format!("Output: {} @ {} Hz", a.device_name, a.sample_rate); + self.audio = Some(a); + } + Err(e) => { + self.status = format!("Audio error: {e}"); + return; + } + } + for i in 0..2 { + if let Some(id) = self.decks[i].track_id { + let shared = &self.decks[i].deck.shared; + shared.set_transport(Transport::Stopped); + let total = shared.total().max(1); + self.decks[i].pending_seek_frac = + Some(shared.playhead_frames() as f64 / total as f64); + self.load_track_row(i, id); + } + } + if let Some(id) = self.prepare.audition.track_id { + let shared = &self.prepare.audition.deck.shared; + shared.set_transport(Transport::Stopped); + let total = shared.total().max(1); + self.prepare.audition.pending_seek_frac = + Some(shared.playhead_frames() as f64 / total as f64); + self.load_audition(id); + } + } + + /// Keyboard shortcuts: deck A = Q (play) W (cue) E (4-beat loop) + /// R (exit loop) 1–4 (hot cues); deck B = P O I U 7–0. Suppressed while + /// a text field has focus. + fn handle_shortcuts(&mut self, ctx: &egui::Context) { + if ctx.wants_keyboard_input() { + // Don't leave a FLASH stuck on when focus moves to a text field. + self.flash_key = [false; LANE_COUNT]; + for o in self.programmer.iter_mut() { + o.flash_held = false; + } + return; + } + use egui::Key; + // V toggles Perform ↔ Prepare; the engine is untouched by the view. + // Patch is a setup screen, not part of the performance flip — V + // just returns to Perform from there. + if ctx.input(|i| i.key_pressed(Key::V)) { + self.view = match self.view { + View::Perform => View::Prepare, + View::Prepare | View::Patch => View::Perform, + }; + } + // Programmer: Z/X/C = momentary FLASH per lane (suppressed under + // ⌘ so ⌘C copy doesn't fire the FX lane); Esc = CLEAR, except in + // Prepare where a non-empty editor selection clears first. + const FLASH_KEYS: [Key; LANE_COUNT] = [Key::Z, Key::X, Key::C]; + self.flash_key = ctx.input(|i| { + if i.modifiers.command { + [false; LANE_COUNT] + } else { + FLASH_KEYS.map(|k| i.key_down(k)) + } + }); + for (i, o) in self.programmer.iter_mut().enumerate() { + o.flash_held = self.flash_key[i]; + } + if ctx.input(|i| i.key_pressed(Key::Escape)) { + if self.view == View::Prepare && !self.prepare.selection.is_empty() { + self.prepare.selection.clear(); + } else { + programmer::clear(&mut self.programmer); + } + } + if self.view == View::Prepare { + self.prepare_editor_keys(ctx); + } + const KEYS: [[Key; 8]; 2] = [ + [ + Key::Q, + Key::W, + Key::E, + Key::R, + Key::Num1, + Key::Num2, + Key::Num3, + Key::Num4, + ], + [ + Key::P, + Key::O, + Key::I, + Key::U, + Key::Num7, + Key::Num8, + Key::Num9, + Key::Num0, + ], + ]; + let down = ctx.input(|i| KEYS.map(|deck| deck.map(|k| i.key_down(k)))); + for d in 0..2 { + let prev = self.kb_prev[d]; + let now = down[d]; + let deck_ui = &mut self.decks[d]; + if now[0] && !prev[0] { + deck_ui.toggle_play(); + } + if now[1] && !prev[1] { + deck_ui.cue_press(); + } + if !now[1] && prev[1] { + deck_ui.cue_release(); + } + if now[2] && !prev[2] { + deck_ui.autoloop_4(); + } + if now[3] && !prev[3] { + deck_ui.exit_loop(); + } + for slot in 0..4 { + if now[4 + slot] && !prev[4 + slot] { + deck_ui.hot_cue_press(slot); + } + } + self.kb_prev[d] = now; + } + } + + /// Prepare-view editor keys: Delete removes the selection, ⌘C/⌘V copy + /// and paste (across tracks — the clipboard is app-level). Esc is + /// handled by the caller, layered with programmer CLEAR. + fn prepare_editor_keys(&mut self, ctx: &egui::Context) { + use egui::Key; + let (del, copy, paste) = ctx.input(|i| { + ( + i.key_pressed(Key::Delete) || i.key_pressed(Key::Backspace), + i.modifiers.command && i.key_pressed(Key::C), + i.modifiers.command && i.key_pressed(Key::V), + ) + }); + if !(del || copy || paste) { + return; + } + let sr = self.device_rate().max(1) as f64; + let mut mutated = false; + let PrepareState { + audition, + selection, + snap, + .. + } = &mut self.prepare; + let track_id = audition.track_id; + + if copy && !selection.is_empty() { + let mut items: Vec<(Lane, f64, f64, f32)> = selection + .iter() + .filter_map(|&id| { + audition + .cues + .find(id) + .map(|(l, c)| (l, c.start_frame, c.duration_frames, c.intensity)) + }) + .collect(); + items.sort_by(|a, b| a.1.total_cmp(&b.1)); + if let Some(&(_, first, _, _)) = items.first() { + self.cue_clipboard = items + .iter() + .map(|&(lane, start, dur, intensity)| ClipCue { + lane, + offset_secs: (start - first) / sr, + dur_secs: dur / sr, + intensity, + }) + .collect(); + } + } + if paste && !self.cue_clipboard.is_empty() && audition.deck.track.is_some() { + let playhead = audition.deck.shared.playhead_frames() as f64; + let base = crate::waveform::snap_frame(&audition.marks, *snap, playhead); + selection.clear(); + for clip in &self.cue_clipboard { + if let Some(id) = audition.cues.insert( + clip.lane, + base + clip.offset_secs * sr, + clip.dur_secs * sr, + clip.intensity, + ) { + selection.insert(id); + } + } + mutated = true; + } + if del && !selection.is_empty() { + audition.cues.remove(selection); + selection.clear(); + mutated = true; + } + + let dirty = if mutated { + Some(audition.cues.clone()) + } else { + None + }; + if let (Some(cues), Some(id)) = (dirty, track_id) { + self.commit_cues(id, &cues); + } + } + + /// Publish the cold lighting inputs to the DMX engine thread. The + /// thread reads the playhead atomics itself; the beat reference lets + /// it extrapolate musical time between publishes so effects stay + /// beat-locked through UI stalls. + fn publish_dmx(&self, ctx: &egui::Context) { + let d = &self.decks[self.lighting_deck]; + let playhead = d.playhead() as f64; + let beat_ref = match ( + d.marks.beat_at_or_before(playhead), + beat_phase(&d.marks, playhead), + ) { + (Some(i), Some(ph)) => dmx::BeatRef { + beat_t: i as f64 + ph, + playhead, + frames_per_beat: d.marks.median_beat_frames(), + }, + _ => { + // No grid: wall-clock at the master BPM, matching the + // programmer's effect previews. Advances per publish only. + let bpm = { + let b = self.global_bpm(); + if b > 0.0 { b } else { 120.0 } + }; + dmx::BeatRef { + beat_t: ctx.input(|i| i.time) * bpm / 60.0, + playhead, + frames_per_beat: 0.0, + } + } + }; + *self.dmx.lock().unwrap() = Some(dmx::DmxSnapshot { + rig: self.rig.clone(), + cues: d.deck.track.is_some().then(|| d.cues.clone()), + overrides: self.programmer.clone(), + params: self.programmer_params.clone(), + selection: self.fixture_selection.clone(), + deck: d.deck.shared.clone(), + beat_ref, + net: self.net.clone(), + }); + } + + /// The PATCH view: console-style patch sheet, staged. Edits build a + /// draft; the live rig, DMX output, and programmer keep the + /// last-applied patch until APPLY swaps the draft in and persists it + /// to the show file. REVERT discards the draft. + fn patch_ui(&mut self, ui: &mut egui::Ui) { + const WARN: egui::Color32 = egui::Color32::from_rgb(230, 90, 70); + let mut work = self + .patch_draft + .clone() + .unwrap_or_else(|| (*self.rig).clone()); + let dirty = self.patch_draft.is_some(); + let conflicts = work.conflicts(&self.fixture_library); + // Stable, name-sorted profile list for the combos. + let mut profiles: Vec<(String, String)> = self + .fixture_library + .profiles + .iter() + .map(|(id, p)| (id.clone(), p.to_string())) + .collect(); + profiles.sort_by(|a, b| a.1.cmp(&b.1)); + + let mut changed = false; + let mut remove: Option = None; + let mut do_apply = false; + let mut do_revert = false; + { + let fl = &self.fixture_library; + let rig = &mut work; + ui.horizontal(|ui| { + ui.menu_button("+ ADD FIXTURE", |ui| { + for kind in ALL_KINDS { + if ui.button(kind.group_label()).clicked() { + let id = rig.next_id(); + let count = rig.iter().filter(|f| f.kind == kind).count(); + let row = rig.extent().1; + // First free address on universe 1. + let addr = rig + .iter() + .filter(|f| f.universe == 1) + .filter_map(|f| { + fl.get(&f.profile_id) + .map(|p| f.start_address + p.footprint() as u16) + }) + .max() + .unwrap_or(1); + rig.fixtures_mut().push(halo_light::fixture::Fixture { + id, + kind, + label: format!("{}{}", kind.short(), count + 1), + col: 0, + row, + profile_id: kind.default_profile_id().to_string(), + universe: 1, + start_address: addr.min(512), + }); + changed = true; + ui.close_menu(); + } + } + }); + if ui.button("RESET TO DEFAULT RIG").clicked() { + *rig = default_rig(fl); + changed = true; + } + if !conflicts.is_empty() { + ui.colored_label( + WARN, + format!("{} fixtures with patch conflicts", conflicts.len()), + ); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let can_apply = dirty && conflicts.is_empty(); + let apply = ui.add_enabled( + can_apply, + egui::Button::new(egui::RichText::new("APPLY").strong()), + ); + let apply = if dirty && !conflicts.is_empty() { + apply.on_disabled_hover_text("Resolve patch conflicts first") + } else { + apply.on_disabled_hover_text("No unapplied changes") + }; + if apply.clicked() { + do_apply = true; + } + if ui + .add_enabled(dirty, egui::Button::new("REVERT")) + .on_hover_text("Discard the draft; keep the applied patch") + .clicked() + { + do_revert = true; + } + if dirty { + ui.colored_label( + egui::Color32::from_rgb(240, 200, 90), + "● unapplied changes", + ); + } + }); + }); + ui.add_space(4.0); + + egui::ScrollArea::vertical().show(ui, |ui| { + egui::Grid::new("patch_sheet") + .striped(true) + .min_col_width(44.0) + .show(ui, |ui| { + for h in [ + "FIXTURE", "KIND", "PROFILE", "UNIV", "ADDR", "COL", "ROW", "", + ] { + ui.label(egui::RichText::new(h).weak().size(10.0)); + } + ui.end_row(); + for f in rig.fixtures_mut().iter_mut() { + let bad = conflicts.contains(&f.id); + if bad { + ui.colored_label(WARN, format!("⚠ {}", f.label)); + } else { + ui.label(&f.label); + } + egui::ComboBox::from_id_salt(("patch-kind", f.id)) + .selected_text(f.kind.group_label()) + .show_ui(ui, |ui| { + for kind in ALL_KINDS { + if ui + .selectable_label(f.kind == kind, kind.group_label()) + .clicked() + && f.kind != kind + { + f.kind = kind; + changed = true; + } + } + }); + let profile_text = fl + .get(&f.profile_id) + .map(|p| p.to_string()) + .unwrap_or_else(|| format!("? {}", f.profile_id)); + egui::ComboBox::from_id_salt(("patch-profile", f.id)) + .selected_text(profile_text) + .width(230.0) + .show_ui(ui, |ui| { + for (pid, name) in &profiles { + if ui.selectable_label(&f.profile_id == pid, name).clicked() + && &f.profile_id != pid + { + f.profile_id = pid.clone(); + changed = true; + } + } + }); + changed |= ui + .add(egui::DragValue::new(&mut f.universe).range(1..=32)) + .changed(); + changed |= ui + .add(egui::DragValue::new(&mut f.start_address).range(1..=512)) + .changed(); + changed |= ui + .add(egui::DragValue::new(&mut f.col).range(0..=15)) + .changed(); + changed |= ui + .add(egui::DragValue::new(&mut f.row).range(0..=15)) + .changed(); + if ui.small_button("✕").on_hover_text("Unpatch").clicked() { + remove = Some(f.id); + } + ui.end_row(); + } + }); + }); + + if let Some(id) = remove { + rig.fixtures_mut().retain(|f| f.id != id); + changed = true; + } + } + if do_revert { + self.patch_draft = None; + } else if do_apply { + // Commit: swap the draft in, then the same live-update path + // instant edits used to take — selection may reference + // unpatched ids, routing may cover new universes. + self.patch_draft = None; + self.rig = std::sync::Arc::new(work); + let rig = std::sync::Arc::clone(&self.rig); + self.fixture_selection + .retain(|id| rig.iter().any(|f| f.id == *id)); + self.net = build_net(&self.artnet, &rig); + self.save_rig(); + } else if changed || dirty { + self.patch_draft = Some(work); + } + } + + /// Persist the current rig patch to the library DB. + fn save_rig(&self) { + let Some(lib) = &self.library else { return }; + match serde_json::to_string(&RigFile::from_rig(&self.rig)) { + Ok(json) => { + if let Err(e) = lib.store_setting("rig_patch", &json) { + log::warn!("save rig patch: {e}"); + } + } + Err(e) => log::warn!("serialize rig patch: {e}"), + } + } + + /// Persist the Art-Net settings and rebuild the engine's config. + fn apply_artnet(&mut self) { + self.net = build_net(&self.artnet, &self.rig); + if let Some(lib) = &self.library { + match serde_json::to_string(&self.artnet) { + Ok(json) => { + if let Err(e) = lib.store_setting("artnet", &json) { + log::warn!("save artnet settings: {e}"); + } + } + Err(e) => log::warn!("serialize artnet settings: {e}"), + } + } + } + + /// The footer's Programmer tab: group-select row, the fixture grid + /// (select fixtures → apply values, console-style; value application + /// comes with the fixture engine), and the lane override controls. + /// Returns true when STORE was pressed. + fn programmer_ui(&mut self, ui: &mut egui::Ui, outputs: &[LaneOutput; LANE_COUNT]) -> bool { + let deck = &self.decks[self.lighting_deck]; + let can_store = deck.track_id.is_some() && deck.marks.is_usable(); + let deck_name = DECK_NAMES[self.lighting_deck]; + + // Musical time in beats for the effect previews: the lighting + // deck's grid when available, wall-clock at the master BPM + // otherwise (so previews always animate). + let playhead = deck.playhead() as f64; + let beat_t = match ( + deck.marks.beat_at_or_before(playhead), + beat_phase(&deck.marks, playhead), + ) { + (Some(i), Some(ph)) => i as f64 + ph, + _ => { + let bpm = { + let b = self.global_bpm(); + if b > 0.0 { b } else { 120.0 } + }; + ui.input(|i| i.time) * bpm / 60.0 + } + }; + + let mut cx = ProgrammerCtx { + rig: &self.rig, + selection: &mut self.fixture_selection, + overrides: &mut self.programmer, + params: &mut self.programmer_params, + outputs, + can_store, + deck_name, + beat_t, + }; + programmer_panel(ui, &mut cx) + } + + /// STORE: write every active programmer lane into the lighting deck's + /// track as a cue at the current bar (1 bar for Lighting/Pixels — they + /// read as states — 1 beat for FX hits), then persist + sync. + fn store_programmer(&mut self) { + let deck = &self.decks[self.lighting_deck]; + let Some(track_id) = deck.track_id else { + return; + }; + let Some(start) = deck.marks.bar_start(deck.playhead() as f64) else { + return; + }; + let beat = deck.marks.median_beat_frames(); + if beat <= 0.0 { + return; + } + let mut cues = deck.cues.clone(); + let mut stored = 0; + for lane in ALL_LANES { + let o = &self.programmer[lane as usize]; + if o.active() + && cues + .insert( + lane, + start, + match lane { + Lane::Fx => beat, + _ => 4.0 * beat, + }, + o.intensity, + ) + .is_some() + { + stored += 1; + } + } + if stored > 0 { + let bar = deck.marks.bar_beat(start).map(|(b, _)| b).unwrap_or(0); + self.commit_cues(track_id, &cues); + self.status = format!("Stored {stored} cue(s) at bar {bar}"); + } + } + + /// Sample process-wide CPU roughly once a second. + fn update_cpu(&mut self) { + let (last_at, last_cpu, _) = self.cpu_sample; + let elapsed = last_at.elapsed().as_secs_f64(); + if elapsed >= 1.0 { + let cpu_now = process_cpu_secs(); + let pct = ((cpu_now - last_cpu) / elapsed * 100.0) as f32; + self.cpu_sample = (std::time::Instant::now(), cpu_now, pct.max(0.0)); + } + } + + fn settings_window(&mut self, ctx: &egui::Context) { + if !self.settings_open { + return; + } + let mut open = true; + let mut apply = false; + egui::Window::new("Settings") + .open(&mut open) + .resizable(false) + .show(ctx, |ui| { + ui.label(egui::RichText::new("Audio output").strong()); + let current = self + .audio_settings + .device_name + .clone() + .unwrap_or_else(|| "System default".to_string()); + egui::ComboBox::from_label("Device") + .selected_text(current) + .show_ui(ui, |ui| { + if ui + .selectable_label( + self.audio_settings.device_name.is_none(), + "System default", + ) + .clicked() + { + self.audio_settings.device_name = None; + } + for name in &self.available_devices { + if ui + .selectable_label( + self.audio_settings.device_name.as_deref() == Some(name), + name, + ) + .clicked() + { + self.audio_settings.device_name = Some(name.clone()); + } + } + }); + + let buffer_label = match self.audio_settings.buffer_size { + Some(n) => format!("{n} frames"), + None => "Default".to_string(), + }; + egui::ComboBox::from_label("Buffer size") + .selected_text(buffer_label) + .show_ui(ui, |ui| { + if ui + .selectable_label(self.audio_settings.buffer_size.is_none(), "Default") + .clicked() + { + self.audio_settings.buffer_size = None; + } + for n in [64u32, 128, 256, 512, 1024, 2048] { + if ui + .selectable_label( + self.audio_settings.buffer_size == Some(n), + format!("{n} frames"), + ) + .clicked() + { + self.audio_settings.buffer_size = Some(n); + } + } + }); + + ui.add_space(6.0); + ui.label( + egui::RichText::new("Applying restarts the audio stream and reloads decks.") + .weak() + .size(11.0), + ); + if ui.button("Apply").clicked() { + apply = true; + } + + ui.add_space(12.0); + ui.separator(); + ui.label(egui::RichText::new("Art-Net output").strong()); + let was_broadcast = self.artnet.unicast_ip.is_none(); + let mut artnet_dirty = false; + ui.horizontal(|ui| { + if ui.selectable_label(was_broadcast, "Broadcast").clicked() && !was_broadcast { + self.artnet.unicast_ip = None; + artnet_dirty = true; + } + if ui.selectable_label(!was_broadcast, "Unicast").clicked() && was_broadcast { + self.artnet.unicast_ip = Some(self.artnet_ip_edit.clone()); + artnet_dirty = true; + } + }); + if self.artnet.unicast_ip.is_some() { + ui.horizontal(|ui| { + ui.label("Node IP"); + let resp = ui.text_edit_singleline(&mut self.artnet_ip_edit); + let valid = self.artnet_ip_edit.parse::().is_ok(); + if !valid { + ui.colored_label( + egui::Color32::from_rgb(230, 90, 70), + "invalid address", + ); + } else if resp.lost_focus() + && self.artnet.unicast_ip.as_deref() + != Some(self.artnet_ip_edit.as_str()) + { + self.artnet.unicast_ip = Some(self.artnet_ip_edit.clone()); + artnet_dirty = true; + } + }); + } + ui.label(egui::RichText::new(self.net.summary()).weak().size(11.0)); + if artnet_dirty { + self.apply_artnet(); + } + }); + self.settings_open = open; + if apply { + self.rebuild_audio(); + } + } + + fn refresh_browser(&mut self) { + if !self.browser.dirty { + return; + } + self.browser.dirty = false; + if let Some(lib) = &self.library { + self.browser.playlists = lib.playlists().unwrap_or_default(); + self.browser.rows = lib + .tracks( + self.browser.selected, + &self.browser.search, + self.browser.sort, + self.browser.ascending, + ) + .unwrap_or_default(); + } + } + + /// The bottom slide-up footer, home of both the library browser and + /// the programmer, toggled by tabs so the programmer lives in one + /// consistent place across views. Returns true when STORE was pressed. + fn footer_panel(&mut self, ctx: &egui::Context, lighting: &[LaneOutput; LANE_COUNT]) -> bool { + let mut actions: Vec = Vec::new(); + let mut store = false; + egui::TopBottomPanel::bottom("browser") + .resizable(true) + .default_height(300.0) + .min_height(140.0) + .show(ctx, |ui| { + ui.add_space(4.0); + ui.horizontal(|ui| { + for (tab, label) in [ + (FooterTab::Library, "LIBRARY"), + (FooterTab::Programmer, "PROGRAMMER"), + ] { + if ui + .selectable_label( + self.footer_tab == tab, + egui::RichText::new(label).size(11.0), + ) + .clicked() + { + self.footer_tab = tab; + } + } + lighting_leds(ui, lighting); + }); + ui.add_space(4.0); + match self.footer_tab { + FooterTab::Library => { + if self.library.is_none() { + ui.centered_and_justified(|ui| { + ui.label(egui::RichText::new("Library unavailable").weak()); + }); + return; + } + let view = self.view; + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(190.0); + playlist_tree(ui, &mut self.browser, &mut actions); + }); + ui.separator(); + ui.vertical(|ui| { + track_table(ui, &mut self.browser, &mut actions, view); + }); + }); + } + FooterTab::Programmer => { + ui.add_space(4.0); + store = self.programmer_ui(ui, lighting); + } + } + }); + + for action in actions { + self.apply_browser_action(action); + } + store + } + + /// Single write path for every cue mutation: persist to the library, + /// then propagate clones to every player holding the same track — this + /// is what keeps a performance deck's lane strip live-updating while + /// the same track is edited in Prepare. + fn commit_cues(&mut self, track_id: i64, cues: &CueSet) { + if let Some(lib) = &self.library + && let Err(e) = lib.store_cues(track_id, &cues.to_file(self.device_rate())) + { + log::error!("store cues: {e}"); + } + for d in &mut self.decks { + if d.track_id == Some(track_id) { + d.cues = cues.clone(); + } + } + if self.prepare.audition.track_id == Some(track_id) { + self.prepare.audition.cues = cues.clone(); + } + } + + /// The Prepare view's central panel: audition transport plus the same + /// waveform stack as a deck, with the direct-manipulation cue-lane + /// editor in the middle. + fn prepare_panel(&mut self, ui: &mut egui::Ui) { + let sample_rate = self.device_rate(); + let has_audio = self.audio.is_some(); + let PrepareState { + audition, + selection, + interaction, + snap, + } = &mut self.prepare; + let shared = audition.deck.shared.clone(); + let has_track = audition.deck.track.is_some(); + let total = shared.total(); + // Scrub-aware position, same as deck_panel. + let playhead = if let Some(pos) = audition.scrub_pos { + pos.clamp(0.0, total as f64) as usize + } else if shared.scrub.phase() == ScrubPhase::Settling { + shared.scrub.voice_frame().clamp(0.0, total as f64) as usize + } else { + shared.playhead_frames().min(total) + }; + let playing = shared.transport() == Transport::Playing; + + ui.add_space(8.0); + ui.horizontal(|ui| { + if has_track { + ui.vertical(|ui| { + ui.label(egui::RichText::new(&audition.title).strong().size(16.0)); + ui.label(egui::RichText::new(audition.artist.as_deref().unwrap_or("—")).weak()); + }); + } else { + ui.label( + egui::RichText::new( + "Load a track from the library below (EDIT or double-click) \ + to author its lighting cues", + ) + .weak(), + ); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if audition.bpm > 0.0 { + ui.label( + egui::RichText::new(format!("{:.1} BPM", audition.bpm)) + .monospace() + .color(ACCENT), + ); + ui.add_space(10.0); + } + ui.label( + egui::RichText::new(format_time(playhead, sample_rate)) + .monospace() + .size(16.0), + ); + }); + }); + ui.add_space(6.0); + + // Transport + audition volume (this channel bypasses the + // crossfader; the fader alone is its level). + ui.add_enabled_ui(has_track, |ui| { + ui.horizontal(|ui| { + let play_label = if playing { "⏸" } else { "▶" }; + if ui + .add_sized( + [50.0, 30.0], + egui::Button::new( + egui::RichText::new(play_label) + .size(16.0) + .color(egui::Color32::from_rgb(90, 220, 120)), + ) + .fill(egui::Color32::from_rgb(32, 56, 40)), + ) + .clicked() + { + audition.toggle_play(); + } + let cue_resp = ui.add_sized( + [50.0, 30.0], + egui::Button::new( + egui::RichText::new("CUE") + .size(13.0) + .color(egui::Color32::from_rgb(255, 215, 70)), + ) + .fill(egui::Color32::from_rgb(58, 50, 26)), + ); + let cue_down = cue_resp.is_pointer_button_down_on(); + let pressed = cue_down && !audition.cue_was_down; + let released = !cue_down && audition.cue_was_down; + audition.cue_was_down = cue_down; + if pressed { + audition.cue_press(); + } + if released { + audition.cue_release(); + } + ui.separator(); + let mut vol = shared.fader.load(); + if ui + .add( + egui::Slider::new(&mut vol, 0.0..=1.0) + .show_value(false) + .text("VOL"), + ) + .on_hover_text("Audition volume (independent of the crossfader)") + .changed() + { + shared.fader.store(vol); + } + }); + }); + ui.add_space(8.0); + + // Waveform stack — same painters as a deck, full width. + let loop_region = shared.loop_region(); + let display_pos = playhead as f64; + let gesture = paint_zoomed( + ui, + ZoomedParams { + peaks: audition.peaks.as_ref(), + marks: &audition.marks, + position_frames: display_pos, + total_frames: total, + sample_rate, + loop_region, + loop_in: audition.loop_in_staged, + }, + &mut audition.zoom, + ); + handle_scrub_gesture(audition, gesture, has_track, has_audio, playing, playhead); + + ui.add_space(2.0); + let mut mutated = lanes_editor( + ui, + LanesEditorParams { + marks: &audition.marks, + position_frames: display_pos, + total_frames: total, + sample_rate, + snap: *snap, + }, + &audition.zoom, + &mut audition.cues, + selection, + interaction, + ); + + // Inspector row: snap, selection tools, generate/clear. + ui.add_space(4.0); + ui.add_enabled_ui(has_track, |ui| { + ui.horizontal(|ui| { + if ui + .selectable_label(*snap, "SNAP") + .on_hover_text("Snap edits to the beat grid") + .clicked() + { + *snap = !*snap; + } + ui.separator(); + let count = selection.len(); + if count == 0 { + ui.label( + egui::RichText::new( + "Drag in a lane to draw a cue · drag edges to resize · ⌘-drag to \ + rubber-band select", + ) + .weak() + .size(11.0), + ); + } else { + ui.label( + egui::RichText::new(format!("{count} selected")) + .size(11.0) + .strong(), + ); + let mut intensity = selection + .iter() + .next() + .and_then(|&id| audition.cues.find(id)) + .map(|(_, c)| c.intensity) + .unwrap_or(1.0); + let resp = ui + .add( + egui::Slider::new(&mut intensity, 0.0..=1.0) + .show_value(false) + .text("INT"), + ) + .on_hover_text("Intensity of the selected cue(s)"); + if resp.changed() { + for &id in selection.iter() { + audition.cues.set_intensity(id, intensity); + } + } + if resp.drag_stopped() { + mutated = true; + } + if ui.button("Delete").clicked() { + audition.cues.remove(selection); + selection.clear(); + mutated = true; + } + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.menu_button("Clear ▾", |ui| { + for (lane, name) in [ + (Lane::Lighting, "Lighting"), + (Lane::Pixels, "Pixels"), + (Lane::Fx, "FX"), + ] { + if ui.button(name).clicked() { + audition.cues.clear_lane(lane); + mutated = true; + ui.close_menu(); + } + } + if ui.button("All lanes").clicked() { + audition.cues = CueSet::empty(); + selection.clear(); + mutated = true; + ui.close_menu(); + } + }); + if ui + .button("Generate demo cues") + .on_hover_text("Seed the track with simulated beat-aligned cues, then edit") + .clicked() + { + audition.cues = simulate_show( + &audition.marks, + total, + sample_rate, + audition.track_id.unwrap_or(1) as u64, + ); + selection.clear(); + mutated = true; + } + }); + }); + }); + ui.add_space(4.0); + + if let Some(frac) = paint_overview( + ui, + OverviewParams { + texture: audition.overview.as_ref(), + progress: if total > 0 { + playhead as f32 / total as f32 + } else { + 0.0 + }, + total_frames: total, + loop_region, + loop_in: audition.loop_in_staged, + hot_cues: &[], + }, + ) && has_track + { + request_seek_guarded(&shared, (frac as f64 * total as f64) as usize); + } + + ui.add_space(2.0); + ui.horizontal(|ui| { + paint_beat_counter(ui, &audition.marks, display_pos); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("+").clicked() { + audition.zoom.zoom_in(); + } + ui.label( + egui::RichText::new(audition.zoom.label(audition.marks.is_usable())) + .weak() + .size(11.0), + ); + if ui.small_button("−").clicked() { + audition.zoom.zoom_out(); + } + }); + }); + + // Drag-and-drop target: the whole editor area accepts library + // tracks (mirrors the deck columns in Perform). + let mut dropped: Option = None; + if egui::DragAndDrop::has_payload_of_type::(ui.ctx()) { + let rect = ui.min_rect(); + if ui.rect_contains_pointer(rect) { + ui.painter().rect_stroke( + rect.expand(2.0), + 6.0, + egui::Stroke::new(2.0, ACCENT), + egui::StrokeKind::Inside, + ); + if ui.input(|i| i.pointer.any_released()) + && let Some(drag) = egui::DragAndDrop::take_payload::(ui.ctx()) + { + dropped = Some(drag.track_id); + } + } + } + + // Autosave: every completed mutation lands in the library and + // propagates to any deck holding the same track. + let track_id = audition.track_id; + let dirty = if mutated { + Some(audition.cues.clone()) + } else { + None + }; + if let (Some(cues), Some(id)) = (dirty, track_id) { + self.commit_cues(id, &cues); + } + if let Some(id) = dropped { + self.load_audition(id); + } + } + + fn apply_browser_action(&mut self, action: BrowserAction) { + let Some(lib) = &self.library else { return }; + match action { + BrowserAction::LoadDeck(deck, track) => { + self.load_track_row(deck, track); + return; + } + BrowserAction::LoadAudition(track) => { + self.load_audition(track); + return; + } + BrowserAction::SelectPlaylist(p) => self.browser.selected = p, + BrowserAction::SortBy(col) => { + if self.browser.sort == col { + self.browser.ascending = !self.browser.ascending; + } else { + self.browser.sort = col; + self.browser.ascending = true; + } + } + BrowserAction::SearchChanged => {} + BrowserAction::NewPlaylist(is_folder) => { + let base = if is_folder { + "New Folder" + } else { + "New Playlist" + }; + let name = format!("{base} {}", self.browser.playlists.len() + 1); + let parent = self + .browser + .selected + .and_then(|id| self.browser.playlists.iter().find(|p| p.id == id)) + .map(|p| if p.is_folder { Some(p.id) } else { p.parent_id }) + .unwrap_or(None); + if let Err(e) = lib.create_playlist(&name, parent, is_folder) { + log::error!("{e}"); + } + } + BrowserAction::CommitRename(id, name) => { + if let Err(e) = lib.rename_playlist(id, &name) { + log::error!("{e}"); + } + self.browser.rename = None; + } + BrowserAction::DeletePlaylist(id) => { + if let Err(e) = lib.delete_playlist(id) { + log::error!("{e}"); + } + if self.browser.selected == Some(id) { + self.browser.selected = None; + } + } + BrowserAction::AddToPlaylist(playlist, track) => { + if let Err(e) = lib.add_to_playlist(playlist, track) { + log::error!("{e}"); + } + } + BrowserAction::RemoveFromPlaylist(playlist, track) => { + if let Err(e) = lib.remove_from_playlist(playlist, track) { + log::error!("{e}"); + } + } + BrowserAction::ImportFolder(dir) => { + self.import_folder(dir); + return; + } + } + self.browser.dirty = true; + } + + fn any_active(&self) -> bool { + self.decks + .iter() + .chain(std::iter::once(&self.prepare.audition)) + .any(|d| { + d.deck.shared.transport() == Transport::Playing + || d.decode_rx.is_some() + // A scrub glide animates the playhead even while paused, + // so it keeps the repaint loop alive too. + || d.deck.shared.scrub.phase() != ScrubPhase::Idle + }) + } +} + +impl eframe::App for HaloApp { + fn save(&mut self, storage: &mut dyn eframe::Storage) { + eframe::set_value( + storage, + PERSIST_KEY, + &Persisted { + master_volume: self.mixer.master.load(), + crossfader: self.mixer.crossfader.load(), + trims: [ + self.decks[0].deck.shared.trim.load(), + self.decks[1].deck.shared.trim.load(), + ], + keylocks: [self.decks[0].keylock, self.decks[1].keylock], + pitch_ranges: [self.decks[0].pitch_range, self.decks[1].pitch_range], + quantize: [self.decks[0].quantize, self.decks[1].quantize], + gated: [self.decks[0].gated, self.decks[1].gated], + sort: Some(self.browser.sort), + ascending: self.browser.ascending, + device_name: self.audio_settings.device_name.clone(), + buffer_size: self.audio_settings.buffer_size, + view: self.view, + audition_volume: self.prepare.audition.deck.shared.fader.load(), + snap_off: !self.prepare.snap, + footer_tab: self.footer_tab, + }, + ); + } + + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Scrub glide bookkeeping: each newly published landing fires the + // parallel engine warm-start, so the engine is primed at the + // predicted frame by the time the glide hands back to it. + for deck_ui in &mut self.decks { + let shared = &deck_ui.deck.shared; + let (landing_seq, landing) = shared.scrub.landing(); + if landing_seq != deck_ui.landing_seq_seen { + deck_ui.landing_seq_seen = landing_seq; + request_seek_guarded(shared, landing as usize); + } + } + self.poll_decodes(ctx); + self.poll_worker_events(); + self.refresh_browser(); + self.handle_shortcuts(ctx); + self.update_tempo(ctx.input(|i| i.stable_dt).min(0.1) as f64); + // Lighting auto-follows the deck the master BPM is sourced from. + self.lighting_deck = self.master_source(); + self.update_cpu(); + + if self.any_active() { + ctx.request_repaint_after(std::time::Duration::from_millis(33)); + } else if self.footer_tab == FooterTab::Programmer { + // Only the effect preview is animating: a slower tick keeps + // the dot moving without paying the full-window 30 fps + // repaint cost (which re-tessellates the deck waveforms and + // was burning ~20% CPU on idle decks). + ctx.request_repaint_after(std::time::Duration::from_millis(80)); + } else { + // Idle tick so worker events (analysis, imports) still land. + ctx.request_repaint_after(std::time::Duration::from_millis(500)); + } + + // Resolve the lighting output stack once per frame — every + // indicator (toolbar LEDs, lane tints, hollow bars) derives from + // this one result so provenance stays consistent. + let lighting = { + let d = &self.decks[self.lighting_deck]; + let cues = d.deck.track.is_some().then_some(&d.cues); + programmer::resolve(&self.programmer, cues, d.playhead() as f64) + }; + // Same inputs, other consumer: the DMX engine thread re-resolves + // on its own clock between UI frames. + self.publish_dmx(ctx); + + let global_bpm = self.global_bpm(); + let master_src = self.master_source(); + let mixer_cx = self.mixer_center_x; + egui::TopBottomPanel::top("toolbar").show(ctx, |ui| { + ui.add_space(6.0); + let toolbar_row = ui + .horizontal(|ui| { + ui.heading(egui::RichText::new("HALO").color(ACCENT).strong()); + ui.add_space(10.0); + for (v, label) in [ + (View::Perform, "PERFORM"), + (View::Prepare, "PREPARE"), + (View::Patch, "PATCH"), + ] { + if ui + .selectable_label(self.view == v, egui::RichText::new(label).size(11.0)) + .on_hover_text("Switch view (V) — playback is unaffected") + .clicked() + { + self.view = v; + } + } + // The audition player keeps playing across view + // switches; outside Prepare it has no other UI, so + // surface it with a pause chip. + if self.view != View::Prepare + && self.prepare.audition.deck.shared.transport() == Transport::Playing + && ui + .button(egui::RichText::new("AUDITION ▶").size(11.0).color(ACCENT)) + .on_hover_text( + "The Prepare audition player is running — click to pause", + ) + .clicked() + { + self.prepare.audition.toggle_play(); + } + ui.separator(); + lighting_leds(ui, &lighting); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("⚙").on_hover_text("Audio settings").clicked() { + self.settings_open = !self.settings_open; + if self.settings_open { + self.available_devices = list_output_devices(); + } + } + ui.separator(); + cpu_meter(ui, self.mixer.cpu_load.load(), self.cpu_sample.2); + ui.separator(); + // Right-to-left layout: the output bar lands between + // the DSP cluster and the master knob, i.e. just to + // the knob's right. + master_level_meter(ui, self.mixer.master_meter.load()); + ui.add_space(6.0); + let mut master = self.mixer.master.load(); + if ui + .add( + Knob::new(&mut master, 0.0..=1.0, ACCENT) + .arc(KnobArc::Unipolar) + .default_value(1.0) + .diameter(24.0), + ) + .on_hover_text(format!("Master: {:.0}%", master * 100.0)) + .changed() + { + self.mixer.master.store(master); + } + ui.label(egui::RichText::new("Master").weak().size(11.0)); + }); + }) + .response + .rect; + ui.add_space(6.0); + + // Global/master BPM, centered over the mixer. Uses the mixer's + // actual center-x captured last frame in the central panel (egui + // rects are absolute screen coords); falls back to the panel + // mid-x until measured. Painted, not laid out, to dodge egui's + // horizontal-centering quirks. + let cx = if mixer_cx > 0.0 { + mixer_cx + } else { + ui.max_rect().center().x + }; + let cy = toolbar_row.center().y; + let bpm_text = if global_bpm > 0.0 { + format!("{global_bpm:.1}") + } else { + "—".to_string() + }; + // Boxed readout: "A MASTER BPM / value B". A/B light blue for + // the deck the master BPM (and lighting rig) is sourced from — + // automatic, not clickable. + let border = ui.visuals().widgets.noninteractive.bg_stroke.color; + let dim = ui.visuals().weak_text_color().gamma_multiply(0.6); + let lit = crate::waveform::palette::LANE_LIGHTING; + let box_rect = + egui::Rect::from_center_size(egui::pos2(cx, cy), egui::vec2(132.0, 30.0)); + let painter = ui.painter(); + painter.rect_stroke( + box_rect, + 4.0, + egui::Stroke::new(1.0, border), + egui::StrokeKind::Outside, + ); + painter.text( + egui::pos2(cx, box_rect.top() + 7.0), + egui::Align2::CENTER_CENTER, + "MASTER BPM", + egui::FontId::proportional(8.0), + dim, + ); + let vy = box_rect.top() + 20.0; + painter.text( + egui::pos2(box_rect.left() + 13.0, vy), + egui::Align2::CENTER_CENTER, + "A", + egui::FontId::proportional(13.0), + if master_src == 0 { lit } else { dim }, + ); + painter.text( + egui::pos2(cx, vy), + egui::Align2::CENTER_CENTER, + bpm_text, + egui::FontId::monospace(18.0), + egui::Color32::WHITE, + ); + painter.text( + egui::pos2(box_rect.right() - 13.0, vy), + egui::Align2::CENTER_CENTER, + "B", + egui::FontId::proportional(13.0), + if master_src == 1 { lit } else { dim }, + ); + }); + + self.settings_window(ctx); + + egui::TopBottomPanel::bottom("status").show(ctx, |ui| { + ui.add_space(4.0); + ui.label(egui::RichText::new(&self.status).weak()); + ui.add_space(4.0); + }); + + // The patch sheet gets the whole window: no slide-up footer there. + if self.view != View::Patch && self.footer_panel(ctx, &lighting) { + self.store_programmer(); + } + + if self.view == View::Prepare { + egui::CentralPanel::default().show(ctx, |ui| { + self.prepare_panel(ui); + }); + return; + } + + if self.view == View::Patch { + egui::CentralPanel::default().show(ctx, |ui| { + ui.add_space(6.0); + self.patch_ui(ui); + }); + return; + } + + egui::CentralPanel::default().show(ctx, |ui| { + let full_width = ui.available_width(); + // Sized to just fit the mixer content (119 px strip cluster) with + // ~5 px breathing room each side, rather than a wide centered panel. + let mixer_width = 130.0; + // The row holds 5 children (deck | sep | mixer | sep | deck): + // 4 item-spacing gaps plus 2 separators (6 pt each in egui). + let spacing = ui.spacing().item_spacing.x; + let chrome = 4.0 * spacing + 2.0 * 6.0; + let deck_width = ((full_width - mixer_width - chrome) / 2.0) + .floor() + .max(280.0); + + let sample_rate = self.device_rate(); + let master = self.master; + let lighting_deck = self.lighting_deck; + let has_audio = self.audio.is_some(); + let mut responses: [DeckPanelResponse; 2] = Default::default(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(deck_width); + responses[0] = deck_panel( + ui, + &mut self.decks[0], + 0, + sample_rate, + master == 0, + (lighting_deck == 0).then_some(&lighting), + has_audio, + ); + }); + ui.separator(); + let mixer_rect = ui + .vertical(|ui| { + ui.set_width(mixer_width); + mixer_panel(ui, &self.mixer, &self.decks); + }) + .response + .rect; + self.mixer_center_x = mixer_rect.center().x; + ui.separator(); + ui.vertical(|ui| { + ui.set_width(deck_width); + responses[1] = deck_panel( + ui, + &mut self.decks[1], + 1, + sample_rate, + master == 1, + (lighting_deck == 1).then_some(&lighting), + has_audio, + ); + }); + }); + for (i, resp) in responses.into_iter().enumerate() { + if resp.master_clicked { + self.master = i; + // The master leads; it can't also follow. + self.decks[i].synced = false; + } + // Engaging sync jumps straight onto the master's beat (at + // most half a beat, the short way); the PLL holds the lock + // from there. + if resp.sync_engaged && i != self.master { + let m = &self.decks[self.master]; + let master_phase = beat_phase(&m.marks, m.playhead() as f64); + if let Some(mp) = master_phase { + let d = &self.decks[i]; + if let Some(target) = align_target_frame( + &d.marks, + d.playhead() as f64, + mp, + d.deck.shared.total(), + ) { + request_seek_guarded(&d.deck.shared, target); + } + } + // The jump invalidates any smoothed error history. + self.decks[i].phase_err = None; + } + if let Some(path) = resp.load_path { + self.import_and_load(i, path); + } + if let Some(track_id) = resp.load_track_id { + self.load_track_row(i, track_id); + } + } + }); + } +} + +/// Decode + resample + tag-read, all off the UI thread. With a stored +/// library artifact the beat grid comes straight from it (rescaled to the +/// device rate); otherwise a quick detection fills in until the analysis +/// worker delivers the real thing. +fn load_track_data( + path: &Path, + device_rate: u32, + track_id: Option, + key: Option, + stored: Option, +) -> DecodeResult { + let decoded = decode_file(path)?; + let buffer = AudioBuffer::new(decoded.samples, decoded.sample_rate, Channels::Stereo) + .resample(device_rate); + + let peaks = BandPeaks::compute(&buffer.data, 2, device_rate); + let (grid, artifact) = match stored { + Some(native) => { + let resampled = native.resample_to(device_rate); + (grid_from_artifact(&resampled), Some(Arc::new(resampled))) + } + None => { + let grid = timestretch::detect_beat_grid_buffer(&buffer); + log::info!( + "Quick BPM: {:.1} ({} beats, confidence {:.2})", + grid.bpm, + grid.beats.len(), + grid.confidence + ); + (grid, None) + } + }; + + let (title, artist, tag_key, artwork) = read_tags(path); + let title = title.unwrap_or_else(|| { + path.file_stem() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) + }); + + Ok(LoadedData { + title, + artist, + key: key.or(tag_key), + track_id, + artwork, + samples: Arc::new(buffer.into_data()), + peaks, + grid, + artifact, + }) +} + +/// Display beat grid from a stored analysis artifact (same rate domain). +fn grid_from_artifact(artifact: &PreAnalysisArtifact) -> BeatGrid { + let mut grid = BeatGrid::empty(artifact.sample_rate); + grid.beats = if !artifact.beat_positions_fractional.is_empty() { + artifact.beat_positions_fractional.clone() + } else { + artifact.beat_positions.iter().map(|&p| p as f64).collect() + }; + grid.downbeats = artifact.downbeat_beat_indices.clone(); + grid.bpm = artifact.bpm; + grid.confidence = artifact.confidence; + grid +} + +/// Best-effort tag read: title, artist, musical key, and embedded artwork. +fn read_tags( + path: &Path, +) -> ( + Option, + Option, + Option, + Option, +) { + use lofty::prelude::*; + + let tagged = match lofty::probe::Probe::open(path).and_then(|p| p.read()) { + Ok(t) => t, + Err(e) => { + log::info!("No readable tags in {}: {e}", path.display()); + return (None, None, None, None); + } + }; + let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) else { + return (None, None, None, None); + }; + + let title = tag.title().map(|s| s.into_owned()); + let artist = tag.artist().map(|s| s.into_owned()); + let key = tag + .get_string(&lofty::tag::ItemKey::InitialKey) + .map(|s| s.to_string()); + let artwork = + tag.pictures() + .first() + .and_then(|pic| match image::load_from_memory(pic.data()) { + Ok(img) => { + let thumb = img.thumbnail(ARTWORK_MAX_PX, ARTWORK_MAX_PX).to_rgba8(); + let size = [thumb.width() as usize, thumb.height() as usize]; + Some(egui::ColorImage::from_rgba_unmultiplied( + size, + thumb.as_raw(), + )) + } + Err(e) => { + log::info!("Could not decode artwork: {e}"); + None + } + }); + (title, artist, key, artwork) +} + +/// Drag-and-drop payload for a library row dragged from the browser. +#[derive(Clone)] +struct DragTrack { + track_id: i64, + title: String, // carried so the drag preview needs no library lookup +} + +enum BrowserAction { + LoadDeck(usize, i64), + /// Load into the Prepare view's audition player for cue editing. + LoadAudition(i64), + SelectPlaylist(Option), + SortBy(SortColumn), + SearchChanged, + NewPlaylist(bool), + CommitRename(i64, String), + DeletePlaylist(i64), + AddToPlaylist(i64, i64), + RemoveFromPlaylist(i64, i64), + ImportFolder(PathBuf), +} + +/// Left side of the browser: Library root + playlist tree with inline +/// rename and context menus. +fn playlist_tree(ui: &mut egui::Ui, browser: &mut BrowserState, actions: &mut Vec) { + if ui + .selectable_label(browser.selected.is_none(), "🗄 Library") + .clicked() + { + actions.push(BrowserAction::SelectPlaylist(None)); + } + + // Folders first, then root-level playlists. + let folders: Vec = browser + .playlists + .iter() + .filter(|p| p.is_folder) + .cloned() + .collect(); + let playlists: Vec = browser + .playlists + .iter() + .filter(|p| !p.is_folder) + .cloned() + .collect(); + + for folder in &folders { + egui::CollapsingHeader::new(format!("🗀 {}", folder.name)) + .id_salt(folder.id) + .default_open(true) + .show(ui, |ui| { + for pl in playlists.iter().filter(|p| p.parent_id == Some(folder.id)) { + playlist_row(ui, browser, pl, actions); + } + }) + .header_response + .context_menu(|ui| { + if ui.button("Delete folder").clicked() { + actions.push(BrowserAction::DeletePlaylist(folder.id)); + ui.close_menu(); + } + }); + } + for pl in playlists + .iter() + .filter(|p| p.parent_id.is_none() || !folders.iter().any(|f| Some(f.id) == p.parent_id)) + { + playlist_row(ui, browser, pl, actions); + } + + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.small_button("+ List").clicked() { + actions.push(BrowserAction::NewPlaylist(false)); + } + if ui.small_button("+ Folder").clicked() { + actions.push(BrowserAction::NewPlaylist(true)); + } + }); + if ui.small_button("⬇ Import folder…").clicked() + && let Some(dir) = rfd::FileDialog::new().pick_folder() + { + actions.push(BrowserAction::ImportFolder(dir)); + } +} + +fn playlist_row( + ui: &mut egui::Ui, + browser: &mut BrowserState, + pl: &PlaylistRow, + actions: &mut Vec, +) { + // Inline rename in progress? + if let Some((rename_id, buffer)) = &mut browser.rename + && *rename_id == pl.id + { + let resp = ui.text_edit_singleline(buffer); + if resp.lost_focus() { + if ui.input(|i| i.key_pressed(egui::Key::Enter)) && !buffer.is_empty() { + actions.push(BrowserAction::CommitRename(pl.id, buffer.clone())); + } else { + browser.rename = None; + } + } + return; + } + + let resp = ui.selectable_label(browser.selected == Some(pl.id), format!("♪ {}", pl.name)); + if resp.clicked() { + actions.push(BrowserAction::SelectPlaylist(Some(pl.id))); + } + resp.context_menu(|ui| { + if ui.button("Rename").clicked() { + browser.rename = Some((pl.id, pl.name.clone())); + ui.close_menu(); + } + if ui.button("Delete").clicked() { + actions.push(BrowserAction::DeletePlaylist(pl.id)); + ui.close_menu(); + } + }); +} + +/// Right side of the browser: search box + sortable track table. +fn track_table( + ui: &mut egui::Ui, + browser: &mut BrowserState, + actions: &mut Vec, + view: View, +) { + ui.horizontal(|ui| { + ui.label("🔍"); + if ui + .add( + egui::TextEdit::singleline(&mut browser.search) + .hint_text("Search title, artist, album") + .desired_width(240.0), + ) + .changed() + { + actions.push(BrowserAction::SearchChanged); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + egui::RichText::new(format!("{} tracks", browser.rows.len())) + .weak() + .size(11.0), + ); + }); + }); + ui.add_space(4.0); + + use egui_extras::{Column, TableBuilder}; + let header = |ui: &mut egui::Ui, + label: &str, + col: SortColumn, + browser: &BrowserState, + actions: &mut Vec| { + let arrow = if browser.sort == col { + if browser.ascending { " ▲" } else { " ▼" } + } else { + "" + }; + if ui + .add( + egui::Label::new(egui::RichText::new(format!("{label}{arrow}")).strong()) + .sense(egui::Sense::click()), + ) + .clicked() + { + actions.push(BrowserAction::SortBy(col)); + } + }; + + let playlists: Vec<(i64, String)> = browser + .playlists + .iter() + .filter(|p| !p.is_folder) + .map(|p| (p.id, p.name.clone())) + .collect(); + let selected_playlist = browser.selected; + + // Text selection in cells would show an I-beam cursor and steal drags + // from the row; the whole row must act as one draggable unit. + ui.style_mut().interaction.selectable_labels = false; + + TableBuilder::new(ui) + .striped(true) + .sense(egui::Sense::click_and_drag()) + .column(Column::exact(58.0)) // load buttons + .column(Column::remainder().at_least(140.0)) // title + .column(Column::initial(140.0).at_least(80.0)) // artist + .column(Column::initial(120.0).at_least(60.0)) // album + .column(Column::exact(52.0)) // bpm + .column(Column::exact(44.0)) // key + .column(Column::exact(48.0)) // time + .header(20.0, |mut h| { + h.col(|_| {}); + h.col(|ui| header(ui, "Title", SortColumn::Title, browser, actions)); + h.col(|ui| header(ui, "Artist", SortColumn::Artist, browser, actions)); + h.col(|ui| header(ui, "Album", SortColumn::Album, browser, actions)); + h.col(|ui| header(ui, "BPM", SortColumn::Bpm, browser, actions)); + h.col(|ui| header(ui, "Key", SortColumn::Key, browser, actions)); + h.col(|ui| header(ui, "Time", SortColumn::Duration, browser, actions)); + }) + .body(|body| { + body.rows(20.0, browser.rows.len(), |mut row| { + let track = &browser.rows[row.index()]; + row.col(|ui| { + ui.horizontal(|ui| match view { + View::Perform | View::Patch => { + if ui + .small_button("A") + .on_hover_text("Load to deck A") + .clicked() + { + actions.push(BrowserAction::LoadDeck(0, track.id)); + } + if ui + .small_button("B") + .on_hover_text("Load to deck B") + .clicked() + { + actions.push(BrowserAction::LoadDeck(1, track.id)); + } + } + View::Prepare => { + if ui + .small_button("EDIT") + .on_hover_text("Open in the cue editor") + .clicked() + { + actions.push(BrowserAction::LoadAudition(track.id)); + } + } + }); + }); + row.col(|ui| { + ui.label(&track.title).context_menu(|ui| { + ui.menu_button("Add to playlist", |ui| { + for (id, name) in &playlists { + if ui.button(name).clicked() { + actions.push(BrowserAction::AddToPlaylist(*id, track.id)); + ui.close_menu(); + } + } + if playlists.is_empty() { + ui.label(egui::RichText::new("No playlists").weak()); + } + }); + if let Some(pl) = selected_playlist + && ui.button("Remove from this playlist").clicked() + { + actions.push(BrowserAction::RemoveFromPlaylist(pl, track.id)); + ui.close_menu(); + } + }); + }); + row.col(|ui| { + ui.label(track.artist.as_deref().unwrap_or("—")); + }); + row.col(|ui| { + ui.label(track.album.as_deref().unwrap_or("—")); + }); + row.col(|ui| { + ui.label( + egui::RichText::new( + track + .bpm + .map(|b| format!("{b:.1}")) + .unwrap_or_else(|| "—".to_string()), + ) + .monospace(), + ); + }); + row.col(|ui| { + ui.label(track.key.as_deref().unwrap_or("—")); + }); + row.col(|ui| { + ui.label( + egui::RichText::new( + track + .duration_secs + .map(|s| format!("{}:{:02}", s as u64 / 60, s as u64 % 60)) + .unwrap_or_else(|| "—".to_string()), + ) + .monospace(), + ); + }); + let row_resp = row.response(); + if view == View::Prepare && row_resp.double_clicked() { + actions.push(BrowserAction::LoadAudition(track.id)); + } + // Rows drag in both views: onto a deck in Perform, onto + // the cue editor in Prepare. + if row_resp.drag_started() { + egui::DragAndDrop::set_payload( + &row_resp.ctx, + DragTrack { + track_id: track.id, + title: track.title.clone(), + }, + ); + } + }); + }); + + // Floating chip that follows the cursor while a track is being dragged. + if let Some(drag) = egui::DragAndDrop::payload::(ui.ctx()) + && let Some(pos) = ui.ctx().pointer_interact_pos() + { + egui::Area::new(egui::Id::new("track_drag_preview")) + .order(egui::Order::Tooltip) + .fixed_pos(pos + egui::vec2(14.0, 10.0)) + .interactable(false) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.label(format!("♪ {}", drag.title)); + }); + }); + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } +} + +#[derive(Default)] +struct DeckPanelResponse { + load_path: Option, + load_track_id: Option, + master_clicked: bool, + sync_engaged: bool, +} + +/// Three always-visible dots answering "what is the rig doing right now": +/// lane color at output level; a white ring marks a programmer override +/// (vs a solid track cue). +fn lighting_leds(ui: &mut egui::Ui, outputs: &[LaneOutput; LANE_COUNT]) { + let (rect, resp) = ui.allocate_exact_size(egui::vec2(52.0, 16.0), egui::Sense::hover()); + resp.on_hover_text("Lighting output: solid = track cue, ring = programmer override"); + let painter = ui.painter(); + for (i, out) in outputs.iter().enumerate() { + let (_, _, color) = crate::waveform::LANES[i]; + let c = egui::pos2(rect.left() + 8.0 + i as f32 * 18.0, rect.center().y); + painter.circle_filled(c, 5.0, color.gamma_multiply(0.15 + 0.85 * out.level)); + if out.source == LaneSource::Programmer { + painter.circle_stroke(c, 6.5, egui::Stroke::new(1.5_f32, egui::Color32::WHITE)); + } + } +} + +/// Apply a zoomed-waveform drag gesture to a deck's scrub state: grab the +/// platter, chase the hand while dragging, release into a momentum glide +/// (shared by the performance decks and the Prepare audition player). +fn handle_scrub_gesture( + deck_ui: &mut DeckUi, + gesture: Option, + has_track: bool, + has_audio: bool, + playing: bool, + playhead: usize, +) { + let shared = deck_ui.deck.shared.clone(); + let total = shared.total(); + match gesture { + Some(ScrubGesture::Grab) => { + if has_track && total > 0 { + // `playhead` is scrub-aware at the caller, so re-grabbing a + // mid-glide platter continues from the voice's gliding + // position, not the stale engine playhead. + deck_ui.scrub_pos = Some(playhead as f64); + shared.scrub.begin(playhead as f64); + } + } + Some(ScrubGesture::Drag(delta)) => { + if let Some(pos) = deck_ui.scrub_pos { + let target = (pos + delta).clamp(0.0, total.saturating_sub(1) as f64); + shared.scrub.update_target(target); + deck_ui.scrub_pos = Some(target); + } + } + Some(ScrubGesture::Release) => { + if let Some(frame) = deck_ui.scrub_pos.take() { + if has_audio { + // Momentum glide: the audio callback eases the voice + // toward play speed (or rest), predicts the landing, + // and the landing consumer in `update` warm-starts the + // engine there in parallel. + let rate = if playing { + shared.tempo_rate.load() as f64 + } else { + 0.0 + }; + shared.scrub.release(rate); + } else { + // No audio stream to render a glide — land instantly. + shared.scrub.cancel(); + request_seek_guarded(&shared, frame as usize); + } + } + } + None => {} + } +} + +/// Renders one deck column. +fn deck_panel( + ui: &mut egui::Ui, + deck_ui: &mut DeckUi, + _idx: usize, + sample_rate: u32, + is_master: bool, + // Some exactly when this deck drives the lighting rig. + lighting_outputs: Option<&[LaneOutput; LANE_COUNT]>, + has_audio: bool, +) -> DeckPanelResponse { + let is_lighting = lighting_outputs.is_some(); + let mut response = DeckPanelResponse::default(); + + ui.add_space(8.0); + + let shared = deck_ui.deck.shared.clone(); + let has_track = deck_ui.deck.track.is_some(); + let total = shared.total(); + // Scrub-aware position: during a drag the UI owns the displayed + // position (the hand target); during the release glide the audio + // callback's voice does. Everything downstream — waveform, overview, + // time readouts, quantized cues — follows the platter. + let playhead = if let Some(pos) = deck_ui.scrub_pos { + pos.clamp(0.0, total as f64) as usize + } else if shared.scrub.phase() == ScrubPhase::Settling { + shared.scrub.voice_frame().clamp(0.0, total as f64) as usize + } else { + shared.playhead_frames().min(total) + }; + let transport = shared.transport(); + let playing = transport == Transport::Playing; + // Display rate: slider pitch × bend × throw momentum, without the + // sync PLL's micro corrections — those are meant to be inaudible and + // invisible, and showing them makes a locked deck look like it's + // hunting. The throw is shown deliberately: the BPM dipping and + // settling is the platter feedback. + let tempo_rate = (1.0 + deck_ui.pitch_percent as f64 / 100.0) * deck_ui.bend as f64; + + // Header: artwork | title/artist | (big white time + big amber BPM). + ui.horizontal(|ui| { + let art_size = egui::vec2(ARTWORK_SIZE, ARTWORK_SIZE); + match &deck_ui.artwork { + Some(tex) => { + ui.add( + egui::Image::new((tex.id(), art_size)) + .corner_radius(4.0) + .fit_to_exact_size(art_size), + ); + } + None => { + let (rect, _) = ui.allocate_exact_size(art_size, egui::Sense::hover()); + ui.painter() + .rect_filled(rect, 4.0, egui::Color32::from_rgb(28, 28, 32)); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + "♪", + egui::FontId::proportional(24.0), + egui::Color32::from_rgb(80, 80, 90), + ); + } + } + ui.add_space(4.0); + // Title/artist, width-constrained and truncated so a long name can't + // grow into (and overlap) the right-aligned time + BPM readouts. + let title_w = (ui.available_width() - 200.0).max(60.0); + ui.allocate_ui_with_layout( + egui::vec2(title_w, ARTWORK_SIZE), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.add_space(4.0); + if has_track { + ui.add( + egui::Label::new(egui::RichText::new(&deck_ui.title).strong().size(13.0)) + .truncate(), + ); + if let Some(artist) = &deck_ui.artist { + ui.add(egui::Label::new(egui::RichText::new(artist).weak()).truncate()); + } + if let Some(key) = &deck_ui.key { + ui.label( + egui::RichText::new(key) + .color(egui::Color32::from_rgb(120, 190, 255)) + .size(11.0), + ); + } + } else { + ui.label(egui::RichText::new("No track loaded").weak().size(13.0)); + } + }, + ); + // Right side: the inner right-to-left row fills the remaining width and + // right-aligns, so the amber BPM sits at the far right with the white + // time just to its left. The box + "BPM"/key labels are painted around + // the value afterwards (a Frame would inherit the width-filling layout + // and stretch). + let mut bpm_rect = None; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.vertical(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(8.0); // right margin for the painted box + // Reserve a fixed-width slot sized to the widest value + // ("888.8") so the box — and the controls to its left — + // never shift with the number of BPM digits. The value is + // painted right-aligned into this slot below. + let value_w = ui.fonts(|f| { + f.layout_no_wrap( + "888.8".to_owned(), + egui::FontId::monospace(20.0), + egui::Color32::WHITE, + ) + .size() + .x + }); + let (slot, _) = + ui.allocate_exact_size(egui::vec2(value_w, 24.0), egui::Sense::hover()); + bpm_rect = Some(slot); + ui.add_space(16.0); + // Toggle button at a fixed spot just left of the BPM; the + // time to its left grows with elapsed/remaining width. + if ui + .small_button("⏱") + .on_hover_text("Toggle elapsed / remaining") + .clicked() + { + deck_ui.show_remaining = !deck_ui.show_remaining; + } + ui.add_space(6.0); + let time_text = if deck_ui.show_remaining { + format!( + "-{}", + format_time(total.saturating_sub(playhead), sample_rate) + ) + } else { + format_time(playhead, sample_rate) + }; + ui.label( + egui::RichText::new(time_text) + .color(egui::Color32::WHITE) + .strong() + .monospace() + .size(20.0), + ); + }); + }); + }); + // Box around the BPM value, with the "BPM" label at its top-left corner + // (and the key, if any, at the top-right). + if let Some(r) = bpm_rect { + let box_rect = egui::Rect::from_min_max( + r.min - egui::vec2(6.0, 15.0), + r.max + egui::vec2(6.0, 4.0), + ); + let border = ui.visuals().widgets.noninteractive.bg_stroke.color; + let label = ui.visuals().weak_text_color(); + let painter = ui.painter(); + painter.rect_stroke( + box_rect, + 4.0, + egui::Stroke::new(1.0, border), + egui::StrokeKind::Outside, + ); + // Value, right-aligned within its fixed slot. + let bpm_text = if deck_ui.bpm > 0.0 && has_track { + format!("{:.1}", deck_ui.bpm * tempo_rate) + } else { + "0.0".to_string() + }; + painter.text( + egui::pos2(r.max.x, r.center().y), + egui::Align2::RIGHT_CENTER, + bpm_text, + egui::FontId::monospace(20.0), + ACCENT, + ); + painter.text( + box_rect.min + egui::vec2(6.0, 2.0), + egui::Align2::LEFT_TOP, + "BPM", + egui::FontId::proportional(10.0), + label, + ); + } + }); + ui.add_space(6.0); + + // Waveform on the left; tempo fader + KEY / Master-Sync box carve out a + // fixed column on the right (like a hardware deck's pitch strip). + let loop_region = shared.loop_region(); + ui.horizontal(|ui| { + const RIGHT_W: f32 = 60.0; + let total_w = ui.available_width(); + let wave = ui.vertical(|ui| { + ui.set_width((total_w - RIGHT_W - 8.0).max(200.0)); + + // Zoomed scrolling waveform. Dragging grabs the platter: the + // audio callback's varispeed voice audibly chases the hand + // (both directions); the drop releases the momentum into a + // glide that eases back to play speed (or rest), handing off + // to a warm-started engine at the predicted landing. + let display_pos = playhead as f64; + let gesture = paint_zoomed( + ui, + ZoomedParams { + peaks: deck_ui.peaks.as_ref(), + marks: &deck_ui.marks, + position_frames: display_pos, + total_frames: total, + sample_rate, + loop_region, + loop_in: deck_ui.loop_in_staged, + }, + &mut deck_ui.zoom, + ); + handle_scrub_gesture(deck_ui, gesture, has_track, has_audio, playing, playhead); + + // Lighting / Pixels / FX trigger lanes, scrolling in lockstep + // with the zoomed view above. + ui.add_space(2.0); + paint_lanes( + ui, + LanesParams { + cues: &deck_ui.cues, + marks: &deck_ui.marks, + position_frames: display_pos, + total_frames: total, + sample_rate, + lighting_active: is_lighting, + outputs: lighting_outputs, + }, + &deck_ui.zoom, + ); + ui.add_space(4.0); + + // Full-track overview with click-to-seek. + if let Some(frac) = paint_overview( + ui, + OverviewParams { + texture: deck_ui.overview.as_ref(), + progress: if total > 0 { + playhead as f32 / total as f32 + } else { + 0.0 + }, + total_frames: total, + loop_region, + loop_in: deck_ui.loop_in_staged, + hot_cues: &deck_ui.hot_cues, + }, + ) && has_track + { + request_seek_guarded(&shared, (frac as f64 * total as f64) as usize); + } + + // Beat counter | zoom | time readouts. + ui.add_space(2.0); + ui.horizontal(|ui| { + paint_beat_counter(ui, &deck_ui.marks, display_pos); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("+").clicked() { + deck_ui.zoom.zoom_in(); + } + ui.label( + egui::RichText::new(deck_ui.zoom.label(deck_ui.marks.is_usable())) + .weak() + .size(11.0), + ); + if ui.small_button("−").clicked() { + deck_ui.zoom.zoom_out(); + } + }); + }); + }); + let wave_h = wave.response.rect.height(); + ui.vertical(|ui| { + ui.set_width(RIGHT_W); + ui.add_enabled_ui(has_track, |ui| { + deck_tempo_column(ui, deck_ui, is_master, wave_h, &mut response); + }); + }); + }); + + ui.add_space(8.0); + ui.add_enabled_ui(has_track, |ui| { + // Row 1: transport (play / cue / pitch bend) then the loop controls, + // all at one consistent height. + ui.horizontal(|ui| { + // PLAY/PAUSE — green accents. + let play_label = if playing { "⏸" } else { "▶" }; + if ui + .add_sized( + [50.0, 36.0], + egui::Button::new( + egui::RichText::new(play_label) + .size(18.0) + .color(egui::Color32::from_rgb(90, 220, 120)), + ) + .fill(egui::Color32::from_rgb(32, 56, 40)), + ) + .clicked() + { + deck_ui.toggle_play(); + } + + // CUE — CDJ semantics on press/release edges; yellow accents. + let cue_resp = ui.add_sized( + [50.0, 36.0], + egui::Button::new( + egui::RichText::new("CUE") + .size(15.0) + .color(egui::Color32::from_rgb(255, 215, 70)), + ) + .fill(egui::Color32::from_rgb(58, 50, 26)), + ); + let cue_down = cue_resp.is_pointer_button_down_on(); + let pressed = cue_down && !deck_ui.cue_was_down; + let released = !cue_down && deck_ui.cue_was_down; + deck_ui.cue_was_down = cue_down; + + if pressed { + deck_ui.cue_press(); + } + if released { + deck_ui.cue_release(); + } + + ui.separator(); + + // Pitch bend: momentary ±4% while held. + let bend_minus = ui + .add_sized([28.0, 36.0], egui::Button::new("−")) + .is_pointer_button_down_on(); + let bend_plus = ui + .add_sized([28.0, 36.0], egui::Button::new("+")) + .is_pointer_button_down_on(); + deck_ui.bend = if bend_minus { + 0.96 + } else if bend_plus { + 1.04 + } else { + 1.0 + }; + if bend_minus || bend_plus { + ui.ctx().request_repaint(); + } + + ui.separator(); + + // Loops: manual in/out, 4-beat quantized autoloop, halve/double + // between 1/4 and 16 beats (gapless feed-thread re-anchor). Fixed + // height, text-tight width so the row stays compact. + let has_loop = loop_region.is_some(); + let grid_ok = deck_ui.marks.is_usable(); + let btn = + |txt: &str| egui::Button::new(txt.to_string()).min_size(egui::vec2(0.0, 36.0)); + + if ui.add(btn("IN")).clicked() && has_track { + deck_ui.loop_in_staged = + Some(quantize_frame(&deck_ui.marks, deck_ui.quantize, playhead)); + } + if ui.add(btn("OUT")).clicked() + && has_track + && let Some(start) = deck_ui.loop_in_staged + { + let end = quantize_frame(&deck_ui.marks, deck_ui.quantize, playhead); + if end > start { + shared.set_loop(Some((start, end))); + let median = deck_ui.marks.median_beat_frames(); + deck_ui.loop_beats = if median > 0.0 { + ((end - start) as f64 / median).clamp(0.25, 64.0) + } else { + 4.0 + }; + deck_ui.loop_in_staged = None; + } + } + + ui.separator(); + if ui + .add_enabled(grid_ok && has_track, btn("4 BEAT")) + .clicked() + { + deck_ui.autoloop_4(); + } + + ui.separator(); + if ui.add_enabled(has_loop, btn("÷2")).clicked() + && let Some((start, _)) = loop_region + { + deck_ui.loop_beats = (deck_ui.loop_beats / 2.0).max(0.0625); + let end = loop_end_for(&deck_ui.marks, start, deck_ui.loop_beats); + shared.set_loop(Some((start, end.max(start + 1)))); + } + ui.label( + egui::RichText::new(if has_loop { + format_beats(deck_ui.loop_beats) + } else { + "—".to_string() + }) + .monospace() + .size(12.0), + ); + if ui.add_enabled(has_loop, btn("×2")).clicked() + && let Some((start, _)) = loop_region + { + deck_ui.loop_beats = (deck_ui.loop_beats * 2.0).min(16.0); + let end = loop_end_for(&deck_ui.marks, start, deck_ui.loop_beats); + shared.set_loop(Some((start, end.max(start + 1)))); + } + + ui.separator(); + if ui.add_enabled(has_loop, btn("EXIT")).clicked() { + shared.set_loop(None); + } + }); + + // Row 2: hot-cue pads (8) + GATE / Q. Normal mode: empty = set at the + // (quantized) playhead, occupied = jump and play, right-click deletes. + // Gated mode: plays from the cue while held, pauses on release. + ui.add_space(6.0); + ui.horizontal(|ui| { + // The pads shrink when the deck column is narrow so the row + // (8 pads + separator + GATE/Q, ~88 pt of tail) never widens + // the panel — at the minimum window size that overflow would + // push deck B past the right edge. + let gap = ui.spacing().item_spacing.x; + let pad_w = ((ui.available_width() - 88.0 - 7.0 * gap) / 8.0).clamp(24.0, 40.0); + for i in 0..8 { + let set = deck_ui.hot_cues[i].is_some(); + let mut button = + egui::Button::new(egui::RichText::new(format!("{}", i + 1)).size(13.0).color( + if set { + egui::Color32::BLACK + } else { + egui::Color32::from_rgb(140, 140, 150) + }, + )); + if set { + button = button.fill(ACCENT); + } + let resp = ui.add_sized([pad_w, 30.0], button); + let down = resp.is_pointer_button_down_on(); + let pressed = down && !deck_ui.hotcue_was_down[i]; + deck_ui.hotcue_was_down[i] = down; + + if resp.secondary_clicked() { + deck_ui.hot_cues[i] = None; + continue; + } + if pressed && deck_ui.hot_cue_press(i) && deck_ui.gated { + deck_ui.gated_held = Some(i); + } + } + // Gated release: the held slot's button is no longer down. + if let Some(held) = deck_ui.gated_held + && !deck_ui.hotcue_was_down[held] + { + deck_ui.gated_held = None; + shared.set_transport(Transport::Paused); + } + + ui.separator(); + ui.toggle_value(&mut deck_ui.gated, "GATE") + .on_hover_text("Gated hot cues: play while held, stop on release"); + ui.toggle_value(&mut deck_ui.quantize, "Q") + .on_hover_text("Quantize hot cues and loops to the beat grid"); + }); + }); + + // Drag-and-drop target: the whole deck column accepts library tracks. + if egui::DragAndDrop::has_payload_of_type::(ui.ctx()) { + let rect = ui.min_rect(); + if ui.rect_contains_pointer(rect) { + ui.painter().rect_stroke( + rect.expand(2.0), + 6.0, + egui::Stroke::new(2.0, ACCENT), + egui::StrokeKind::Inside, + ); + if ui.input(|i| i.pointer.any_released()) + && let Some(drag) = egui::DragAndDrop::take_payload::(ui.ctx()) + { + response.load_track_id = Some(drag.track_id); + } + } + } + + response +} + +/// Right-of-waveform pitch strip: KEY toggle and a Master/Sync box stacked on +/// top, then a vertical tempo fader that fills the remaining waveform height +/// with a `%` readout and the ±range button. `height` is the waveform height, +/// used to size the fader so the column spans it. +fn deck_tempo_column( + ui: &mut egui::Ui, + deck_ui: &mut DeckUi, + is_master: bool, + height: f32, + response: &mut DeckPanelResponse, +) { + let full = ui.available_width(); + + // KEY + Master/Sync box stacked on top; measure their height so the fader + // below can fill the rest of the waveform's height. + let top = ui.scope(|ui| { + if ui + .add_sized( + [full, 22.0], + egui::SelectableLabel::new(deck_ui.keylock, "KEY"), + ) + .on_hover_text("Keylock: keep pitch constant while tempo changes") + .clicked() + { + deck_ui.keylock = !deck_ui.keylock; + } + ui.add_space(4.0); + // Master/Sync box: mutually exclusive, at most one lit (master deck + // shows MASTER with SYNC disabled; a follower shows SYNC; neither lit + // = independent). + // Tightened margin + 10 pt text so MASTER fits the narrow strip + // unwrapped; SYNC matches for consistency. + egui::Frame::group(ui.style()) + .inner_margin(4.0) + .show(ui, |ui| { + let w = ui.available_width(); + if ui + .add_sized( + [w, 20.0], + egui::SelectableLabel::new( + is_master, + egui::RichText::new("MASTER").size(10.0), + ), + ) + .on_hover_text("Make this deck the tempo reference") + .clicked() + && !is_master + { + response.master_clicked = true; + } + let sync = ui + .add_enabled_ui(!is_master, |ui| { + ui.add_sized( + [w, 20.0], + egui::SelectableLabel::new( + deck_ui.synced, + egui::RichText::new("SYNC").size(10.0), + ), + ) + .on_hover_text("Follow the master deck's tempo and beat phase") + }) + .inner; + if sync.clicked() { + deck_ui.synced = !deck_ui.synced; + if deck_ui.synced { + response.sync_engaged = true; + } + } + }); + }); + let used = top.response.rect.height(); + let fader_h = (height - used - 48.0).max(80.0); + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new(format!("{:+.1}%", deck_ui.pitch_percent)) + .monospace() + .size(11.0), + ); + let range = deck_ui.pitch_range; + let mut pct = deck_ui.pitch_percent; + // Absolute-position fader: `.changed()` only fires on a real grab, so + // touching it hands control back and drops sync (matching the old + // slider). While synced, update_tempo keeps writing pitch_percent and + // the fader just displays it. + if ui + .add( + Fader::new(&mut pct, -range..=range, ACCENT) + .vertical(true) + .size([24.0, fader_h]) + .notches(Notches::Even(10)) + .default_value(0.0), + ) + .changed() + { + deck_ui.pitch_percent = pct; + deck_ui.synced = false; + } + if ui + .add_sized([full, 20.0], egui::Button::new(format!("±{:.0}", range))) + .on_hover_text("Tempo range") + .clicked() + { + deck_ui.pitch_range = match deck_ui.pitch_range as u32 { + 8 => 16.0, + 16 => 50.0, + _ => 8.0, + }; + deck_ui.pitch_percent = deck_ui + .pitch_percent + .clamp(-deck_ui.pitch_range, deck_ui.pitch_range); + } + }); +} + +/// One deck's knob strip (Trim/Hi/Mid/Low/Filter) with the volume fader +/// centered beneath it, in a fixed-width column so the knobs land directly +/// over the fader. Returns the column rect (used to size the level meter). +fn deck_channel_strip(ui: &mut egui::Ui, shared: &crate::state::DeckShared) -> egui::Rect { + ui.scope(|ui| { + ui.set_width(44.0); + ui.vertical_centered(|ui| { + // Trim + isolator EQ: all bipolar knobs centered at unity (1.0), + // range 0..2 (kill hard left, +6 dB hard right). + for (label, atom) in [ + ("Trim", &shared.trim), + ("Hi", &shared.eq_high), + ("Mid", &shared.eq_mid), + ("Low", &shared.eq_low), + ] { + ui.add_space(2.0); + ui.label(egui::RichText::new(label).weak().size(11.0)); + let mut v = atom.load(); + if ui + .add( + Knob::new(&mut v, 0.0..=2.0, ACCENT) + .arc(KnobArc::Bipolar { center: 1.0 }) + .default_value(1.0), + ) + .on_hover_text(format!("{label}: {:+.1} dB", 20.0 * v.max(1e-4).log10())) + .changed() + { + atom.store(v); + } + } + + // Filter: one bipolar knob. Center (12 o'clock) = off; twist left = + // low-pass sweeping closed, right = high-pass sweeping closed. Rides + // a synthetic position t in [-1, 1] mapped to (mode, cutoff). + ui.add_space(4.0); + ui.label(egui::RichText::new("Filter").weak().size(11.0)); + let mut t = filter_pos(shared.filter_mode_u8(), shared.filter_cutoff.load()); + if ui + .add( + Knob::new(&mut t, -1.0..=1.0, ACCENT) + .arc(KnobArc::Bipolar { center: 0.0 }) + .default_value(0.0), + ) + .on_hover_text(filter_hint(t)) + .changed() + { + let (mode, cutoff) = filter_params(t); + if shared.filter_mode_u8() != mode { + shared.set_filter_mode(mode); + } + shared.filter_cutoff.store(cutoff); + } + + ui.add_space(6.0); + let mut fader = shared.fader.load(); + if ui + .add( + Fader::new(&mut fader, 0.0..=1.0, ACCENT) + .vertical(true) + .size([24.0, 120.0]) + .notches(Notches::Even(10)) + .default_value(1.0), + ) + .changed() + { + shared.fader.store(fader); + } + }); + }) + .response + .rect +} + +fn mixer_panel(ui: &mut egui::Ui, mixer: &MixerShared, decks: &[DeckUi; 2]) { + ui.add_space(8.0); + + // Two channel strips packed toward the center, with the pair of level + // meters between them (classic DJ-mixer layout). `vertical_centered` won't + // center a multi-widget horizontal row (egui seeds it at full width), so + // pad-center it to the panel mid-line — the same axis the crossfader uses. + const STRIP_W: f32 = 44.0; + // 44 + 8 + 6 + 3 + 6 + 8 + 44 + const CLUSTER_W: f32 = STRIP_W + 8.0 + 6.0 + 3.0 + 6.0 + 8.0 + STRIP_W; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.add_space(((ui.available_width() - CLUSTER_W) * 0.5).max(0.0)); + let a = deck_channel_strip(ui, &decks[0].deck.shared); + ui.add_space(8.0); + deck_level_meter(ui, decks[0].deck.shared.meter.load(), a.height()); + ui.add_space(3.0); + deck_level_meter(ui, decks[1].deck.shared.meter.load(), a.height()); + ui.add_space(8.0); + deck_channel_strip(ui, &decks[1].deck.shared); + }); + + ui.add_space(12.0); + ui.vertical_centered(|ui| { + let mut xf = mixer.crossfader.load(); + if ui + .add( + Fader::new(&mut xf, 0.0..=1.0, ACCENT) + .vertical(false) + .size([120.0, 24.0]) + .notches(Notches::Center) + .center_fill(0.5) + .default_value(0.5), + ) + .changed() + { + mixer.crossfader.store(xf); + } + }); +} + +/// Reconstruct the bipolar filter knob position `t` in [-1, 1] from the +/// stored (mode, cutoff): 0 at center (off / fully open), -1 at hard left +/// (low-pass fully closed), +1 at hard right (high-pass fully closed). +fn filter_pos(mode: u8, cutoff: f32) -> f32 { + match mode { + 1 => cutoff - 1.0, // LowPass: open (1.0) → 0, closed (0.0) → -1 + 2 => cutoff, // HighPass: open (0.0) → 0, closed (1.0) → +1 + _ => 0.0, // Off + } +} + +/// Decompose the filter knob position `t` back into (filter_mode, cutoff). +/// A small center detent snaps to Off so 12 o'clock is easy to hit. +fn filter_params(t: f32) -> (u8, f32) { + const EPS: f32 = 0.02; + if t < -EPS { + (1, 1.0 + t) // LowPass, cutoff 1.0 (open) → 0.0 (closed) + } else if t > EPS { + (2, t) // HighPass, cutoff 0.0 (open) → 1.0 (closed) + } else { + (0, 0.0) // Off + } +} + +/// Hover text for the filter knob: mode plus cutoff frequency. +fn filter_hint(t: f32) -> String { + let (mode, cutoff) = filter_params(t); + match mode { + 1 => format!("Low-pass: {:.0} Hz", crate::dsp::filter_cutoff_hz(cutoff)), + 2 => format!("High-pass: {:.0} Hz", crate::dsp::filter_cutoff_hz(cutoff)), + _ => "Filter: off".to_string(), + } +} + +/// Thin vertical channel meter: a dim track with a green bar rising from the +/// bottom to `level` (0..1, linear pre-fader / post-trim peak published by +/// the audio callback — the track's level regardless of fader position). +fn deck_level_meter(ui: &mut egui::Ui, level: f32, height: f32) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(6.0, height), egui::Sense::hover()); + if !ui.is_rect_visible(rect) { + return; + } + let painter = ui.painter(); + painter.rect_filled(rect, 1.0, ui.visuals().extreme_bg_color); + let h = level.clamp(0.0, 1.0) * height; + if h > 0.0 { + let fill = egui::Rect::from_min_max( + egui::pos2(rect.left(), rect.bottom() - h), + rect.right_bottom(), + ); + painter.rect_filled(fill, 1.0, egui::Color32::from_rgb(64, 210, 96)); + } +} + +/// Toolbar master output meter, styled like the DSP load bar: the summed +/// stream level measured post-master / post-limiter, so pulling the master +/// knob down visibly limits it. Red = pinned near the ceiling (limiting). +fn master_level_meter(ui: &mut egui::Ui, level: f32) { + let color = if level < 0.7 { + egui::Color32::from_rgb(110, 200, 110) + } else if level < 0.95 { + ACCENT + } else { + egui::Color32::from_rgb(230, 80, 80) + }; + let (rect, response) = ui.allocate_exact_size(egui::vec2(64.0, 10.0), egui::Sense::hover()); + response.on_hover_text(format!("Output: {:.0}%", level.clamp(0.0, 1.0) * 100.0)); + ui.painter() + .rect_filled(rect, 2.0, egui::Color32::from_rgb(30, 30, 34)); + let fill = rect.width() * level.clamp(0.0, 1.0); + if fill > 0.0 { + ui.painter().rect_filled( + egui::Rect::from_min_size(rect.min, egui::vec2(fill, rect.height())), + 2.0, + color, + ); + } +} + +/// Toolbar meter: DSP = audio-callback load (render time ÷ buffer time, +/// the number that matters for dropouts) with a bar; CPU = whole-process +/// usage. +fn cpu_meter(ui: &mut egui::Ui, dsp_load: f32, process_pct: f32) { + let dsp_pct = (dsp_load * 100.0).clamp(0.0, 999.0); + let color = if dsp_pct < 50.0 { + egui::Color32::from_rgb(110, 200, 110) + } else if dsp_pct < 80.0 { + ACCENT + } else { + egui::Color32::from_rgb(230, 80, 80) + }; + // Right-to-left layout: process CPU, then the DSP bar + label. + ui.label( + egui::RichText::new(format!("CPU {process_pct:>4.1}%")) + .weak() + .monospace(), + ); + ui.separator(); + ui.label( + egui::RichText::new(format!("DSP {dsp_pct:>4.1}%")) + .color(color) + .monospace(), + ); + let (rect, _) = ui.allocate_exact_size(egui::vec2(64.0, 10.0), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, 2.0, egui::Color32::from_rgb(30, 30, 34)); + let fill = rect.width() * (dsp_load.clamp(0.0, 1.0)); + ui.painter().rect_filled( + egui::Rect::from_min_size(rect.min, egui::vec2(fill, rect.height())), + 2.0, + color, + ); +} + +/// Cumulative user+system CPU time of this process, in seconds. +fn process_cpu_secs() -> f64 { + unsafe { + let mut usage: libc::rusage = std::mem::zeroed(); + if libc::getrusage(libc::RUSAGE_SELF, &mut usage) == 0 { + let secs = |tv: libc::timeval| tv.tv_sec as f64 + tv.tv_usec as f64 * 1e-6; + secs(usage.ru_utime) + secs(usage.ru_stime) + } else { + 0.0 + } + } +} + +fn apply_theme(ctx: &egui::Context) { + // Pin to dark regardless of the OS appearance setting; set_visuals only + // styles the active theme, so following the system would fall back to + // egui's stock light visuals. + ctx.set_theme(egui::ThemePreference::Dark); + let mut visuals = egui::Visuals::dark(); + visuals.panel_fill = egui::Color32::from_rgb(16, 16, 18); + visuals.window_fill = egui::Color32::from_rgb(16, 16, 18); + visuals.extreme_bg_color = egui::Color32::from_rgb(8, 8, 10); + visuals.selection.bg_fill = ACCENT.linear_multiply(0.4); + visuals.slider_trailing_fill = true; + ctx.set_visuals(visuals); +} + +/// Seek, exiting any active loop the target falls outside of (otherwise +/// the feed thread would immediately wrap the playhead back in). +fn request_seek_guarded(shared: &crate::state::DeckShared, target: usize) { + if let Some((start, end)) = shared.loop_region() + && (target < start || target >= end) + { + shared.set_loop(None); + } + shared.request_seek(target); +} + +/// Snap a frame to the nearest grid beat when quantize is on (and a grid +/// exists); otherwise pass it through. +fn quantize_frame(marks: &GridMarks, quantize: bool, frame: usize) -> usize { + if !quantize || !marks.is_usable() { + return frame; + } + let f = frame as f64; + let Some(i) = marks.beat_at_or_before(f) else { + // Before the first beat: snap forward to it. + return marks.frame(0) as usize; + }; + let a = marks.frame(i); + let b = if i + 1 < marks.len() { + marks.frame(i + 1) + } else { + a + }; + if f - a <= b - f { + a as usize + } else { + b as usize + } +} + +/// Loop end for `beats` beats starting at `start`: exact grid frames for +/// whole-beat lengths inside the grid, median beat interval otherwise. +fn loop_end_for(marks: &GridMarks, start: usize, beats: f64) -> usize { + if marks.is_usable() { + let whole = beats.fract() == 0.0; + if whole && let Some(i) = marks.beat_at_or_before(start as f64) { + let target = i + beats as usize; + // Only use the grid when start sits on beat i exactly (it does + // for quantized loops) and the grid reaches far enough. + if (marks.frame(i) - start as f64).abs() < 1.0 && target < marks.len() { + return marks.frame(target) as usize; + } + } + let median = marks.median_beat_frames(); + if median > 0.0 { + return start + (beats * median) as usize; + } + } + start +} + +/// "1/16", "1/4", "4", "3.7" — beat count for the loop-length readout. +fn format_beats(beats: f64) -> String { + for (value, label) in [ + (0.0625, "1/16"), + (0.125, "1/8"), + (0.25, "1/4"), + (0.5, "1/2"), + ] { + if (beats - value).abs() < 1e-9 { + return label.to_string(); + } + } + if beats.fract() == 0.0 { + format!("{beats:.0}") + } else { + format!("{beats:.1}") + } +} + +/// Fractional position within the current beat (0..1), from the display +/// beat grid. +fn beat_phase(marks: &GridMarks, frame: f64) -> Option { + if !marks.is_usable() { + return None; + } + let i = marks.beat_at_or_before(frame)?; + if i + 1 >= marks.len() { + return None; + } + let (a, b) = (marks.frame(i), marks.frame(i + 1)); + if b <= a { + return None; + } + Some(((frame - a) / (b - a)).clamp(0.0, 1.0)) +} + +/// Smallest standard pitch range (8/16/50) that fits `pct`; saturates at 50. +fn range_for_pitch(pct: f32) -> f32 { + let a = pct.abs(); + if a <= 8.0 { + 8.0 + } else if a <= 16.0 { + 16.0 + } else { + 50.0 + } +} + +/// Seek target that puts the deck's beat phase at `master_phase`, taking +/// the nearest alignment (offset wrapped to ±half a beat) using the deck's +/// local beat length at the playhead. None without a usable grid reading. +fn align_target_frame( + marks: &GridMarks, + playhead: f64, + master_phase: f64, + total: usize, +) -> Option { + let dp = beat_phase(marks, playhead)?; + let i = marks.beat_at_or_before(playhead)?; + // beat_phase succeeding proves i + 1 is in range and the interval > 0. + let beat_len = marks.frame(i + 1) - marks.frame(i); + let mut off = master_phase - dp; + off -= off.round(); + let target = playhead + off * beat_len; + Some((target.round().max(0.0) as usize).min(total.saturating_sub(1))) +} + +/// Beat-phase error in beats, wrapped to ±half a beat so the deck always +/// takes the short way round. +fn wrap_phase_err(master_phase: f64, deck_phase: f64) -> f64 { + let mut err = master_phase - deck_phase; + err -= err.round(); + err +} + +/// Low-pass the phase error across frames. Playheads are published in +/// audio-callback quanta on independent threads, so a single reading can be +/// off by a couple hundredths of a beat; the EMA averages that phantom +/// error away while real drift passes through. A jump bigger than a quarter +/// beat (seek, loop wrap, grid seam) restarts the filter instead of slewing +/// through stale history. +fn smooth_phase_err(prev: Option, raw: f64, alpha: f64) -> f64 { + match prev { + Some(p) if (raw - p).abs() <= 0.25 => p + (raw - p) * alpha, + _ => raw, + } +} + +/// Proportional rate correction toward the master's beat phase, from an +/// already-smoothed error. Soft-knee deadband: corrections ramp from zero +/// past 0.02 beats (no step at the threshold), capped at an inaudible +/// ±1.5% — the align-on-engage seek means the PLL only ever counters slow +/// drift, never performs big chases. +fn phase_correction(err: f64) -> f64 { + let past_knee = err.abs() - 0.02; + if past_knee <= 0.0 { + return 0.0; + } + (err.signum() * past_knee * 0.3).clamp(-0.015, 0.015) +} + +/// `m:ss.t` for a frame count at the given sample rate. +pub fn format_time(frames: usize, sample_rate: u32) -> String { + let secs = frames as f64 / sample_rate.max(1) as f64; + let m = (secs / 60.0) as u64; + let s = secs % 60.0; + format!("{m}:{s:04.1}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phase_err_takes_short_way_round() { + // Master just past a beat (0.1), deck late in its beat (0.9): the + // short way is forward +0.2 beats, so the deck speeds up. + assert!((wrap_phase_err(0.1, 0.9) - 0.2).abs() < 1e-9); + assert!(phase_correction(wrap_phase_err(0.1, 0.9)) > 0.0); + // Mirror case slows down. + assert!(phase_correction(wrap_phase_err(0.9, 0.1)) < 0.0); + } + + #[test] + fn phase_correction_knee_and_clamp() { + // Inside the knee: no correction. + assert_eq!(phase_correction(0.005), 0.0); + assert_eq!(phase_correction(-0.02), 0.0); + // Ramps from zero past the knee — no step at the threshold. + let c = phase_correction(0.03); + assert!((c - 0.003).abs() < 1e-9); + // Large errors cap at an inaudible ±1.5%. + assert_eq!(phase_correction(0.5), 0.015); + assert_eq!(phase_correction(-0.5), -0.015); + } + + #[test] + fn phase_err_smoothing_filters_jitter_but_resets_on_jumps() { + // First reading passes straight through. + assert_eq!(smooth_phase_err(None, 0.04, 0.1), 0.04); + // Small readings blend toward the new value. + let s = smooth_phase_err(Some(0.0), 0.04, 0.1); + assert!((s - 0.004).abs() < 1e-9); + // A jump past a quarter beat restarts the filter. + assert_eq!(smooth_phase_err(Some(0.0), 0.4, 0.1), 0.4); + } + + fn grid_100(sr: u32, n: usize) -> GridMarks { + let mut grid = timestretch::BeatGrid::empty(sr); + grid.beats = (0..n).map(|i| i as f64 * 100.0).collect(); + GridMarks::from_grid(&grid) + } + + #[test] + fn quantize_snaps_to_nearest_beat() { + let marks = grid_100(48_000, 8); + assert_eq!(quantize_frame(&marks, true, 140), 100); + assert_eq!(quantize_frame(&marks, true, 160), 200); + assert_eq!(quantize_frame(&marks, true, 150), 100); // ties go early + assert_eq!(quantize_frame(&marks, false, 140), 140); + } + + #[test] + fn loop_end_uses_grid_for_whole_beats() { + let marks = grid_100(48_000, 16); + assert_eq!(loop_end_for(&marks, 200, 4.0), 600); + // Fractional beats fall back to the median interval. + assert_eq!(loop_end_for(&marks, 200, 0.5), 250); + } + + #[test] + fn beats_format_fractions() { + assert_eq!(format_beats(0.0625), "1/16"); + assert_eq!(format_beats(0.125), "1/8"); + assert_eq!(format_beats(0.25), "1/4"); + assert_eq!(format_beats(0.5), "1/2"); + assert_eq!(format_beats(4.0), "4"); + } + + #[test] + fn halving_ladder_floors_at_sixteenth() { + let mut beats = 4.0f64; + for _ in 0..10 { + beats = (beats / 2.0).max(0.0625); + } + assert_eq!(beats, 0.0625); + assert_eq!(format_beats(beats), "1/16"); + } + + #[test] + fn pitch_range_expands_to_fit() { + assert_eq!(range_for_pitch(5.0), 8.0); + assert_eq!(range_for_pitch(-8.0), 8.0); + assert_eq!(range_for_pitch(8.1), 16.0); + assert_eq!(range_for_pitch(16.0), 16.0); + assert_eq!(range_for_pitch(-20.0), 50.0); + assert_eq!(range_for_pitch(74.0), 50.0); // saturates + } + + #[test] + fn align_seeks_nearest_beat_offset() { + let marks = grid_100(48_000, 8); + // Already in phase: no movement. + assert_eq!(align_target_frame(&marks, 250.0, 0.5, 800), Some(250)); + // Deck late (0.9), master early (0.1): forward to the next beat's + // 0.1, not back a near-full beat. + assert_eq!(align_target_frame(&marks, 290.0, 0.1, 800), Some(310)); + // Mirror case wraps backwards. + assert_eq!(align_target_frame(&marks, 210.0, 0.9, 800), Some(190)); + // Target clamped inside the track. + assert_eq!(align_target_frame(&marks, 640.0, 0.8, 660), Some(659)); + // No grid: no seek. + assert_eq!( + align_target_frame(&GridMarks::empty(), 250.0, 0.5, 800), + None + ); + } + + #[test] + fn beat_phase_interpolates_between_beats() { + let mut grid = timestretch::BeatGrid::empty(48_000); + grid.beats = vec![0.0, 100.0, 200.0, 300.0]; + let marks = GridMarks::from_grid(&grid); + assert_eq!(beat_phase(&marks, 150.0), Some(0.5)); + assert_eq!(beat_phase(&marks, 100.0), Some(0.0)); + // Past the last interval or before the grid: no reading. + assert_eq!(beat_phase(&marks, 350.0), None); + assert_eq!(beat_phase(&marks, -1.0), None); + } +} diff --git a/crates/halo/src/audio.rs b/crates/halo/src/audio.rs new file mode 100644 index 0000000..d178acb --- /dev/null +++ b/crates/halo/src/audio.rs @@ -0,0 +1,392 @@ +//! The single cpal output stream and the mixer callback. +//! +//! The stream runs at the device's default sample rate for the whole +//! session; tracks are resampled to that rate at load time, so the per-deck +//! engines are never rebuilt for rate reasons. The callback owns each +//! deck's [`EngineProcessor`] (adopted from lock-free hand-off slots), +//! renders each live deck into a scratch buffer, and sums them through +//! trim × fader × constant-power crossfader × master with per-sample gain +//! smoothing. Channels 0/1 are performance decks A/B on the crossfader; +//! channel 2 is the Prepare view's audition player, which bypasses the +//! crossfader (its fader alone is its volume). + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Instant; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{SampleRate, Stream, StreamConfig}; +use timestretch::engine::EngineProcessor; + +use crate::deck::{ProcessorSlot, SampleSlot}; +use crate::dsp::{ChannelStrip, FilterMode, Limiter, StripParams}; +use crate::scrub::ScrubVoice; +use crate::state::{DeckShared, MixerShared, ScrubPhase}; + +/// Gain smoothing time constant in seconds (anti-zipper). +const GAIN_SMOOTH_SECS: f32 = 0.005; +/// Engine ↔ scrub-voice crossfade time constant in seconds. +const SCRUB_MIX_SECS: f32 = 0.005; +/// EMA weight for the CPU load meter. +const CPU_EMA_ALPHA: f32 = 0.1; +/// Release time constant for the per-deck level meter (fast attack, slow +/// release so the bar falls smoothly rather than flickering). +const METER_RELEASE_SECS: f32 = 0.3; + +/// Everything the callback needs from one deck. +pub struct DeckAudio { + pub shared: Arc, + pub slot: ProcessorSlot, + pub retired: ProcessorSlot, + /// Raw track samples for the scratch reader (bypasses the engine). + pub scratch_source: SampleSlot, + /// Old sample Arcs handed back so the track buffer never drops here. + pub scratch_retired: SampleSlot, + pub reset_request: Arc, +} + +/// User-selectable output configuration (persisted between sessions). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct AudioSettings { + /// Output device name (None = system default). + pub device_name: Option, + /// Requested buffer size in frames (None = device default). + pub buffer_size: Option, +} + +/// Names of the available output devices. +pub fn list_output_devices() -> Vec { + use cpal::traits::HostTrait; + let host = cpal::default_host(); + host.output_devices() + .map(|devices| devices.filter_map(|d| d.name().ok()).collect()) + .unwrap_or_default() +} + +pub struct AudioOutput { + _stream: Stream, + pub sample_rate: u32, + pub device_name: String, +} + +impl AudioOutput { + pub fn new( + decks: [DeckAudio; 3], + mixer: Arc, + settings: &AudioSettings, + ) -> Result { + let host = cpal::default_host(); + let device = match &settings.device_name { + Some(wanted) => host + .output_devices() + .ok() + .and_then(|mut devices| devices.find(|d| d.name().is_ok_and(|n| &n == wanted))) + .or_else(|| { + log::warn!("Output device {wanted:?} not found, using default"); + host.default_output_device() + }), + None => host.default_output_device(), + } + .ok_or_else(|| "No audio output device found".to_string())?; + let device_name = device.name().unwrap_or_else(|_| "Unknown".to_string()); + + let default_config = device + .default_output_config() + .map_err(|e| format!("Failed to get default output config: {e}"))?; + let sample_rate = default_config.sample_rate().0; + + let config = StreamConfig { + channels: 2, + sample_rate: SampleRate(sample_rate), + buffer_size: match settings.buffer_size { + Some(frames) => cpal::BufferSize::Fixed(frames), + None => cpal::BufferSize::Default, + }, + }; + + let gain_alpha = 1.0 - (-1.0 / (GAIN_SMOOTH_SECS * sample_rate as f32)).exp(); + let mut procs: [Option; 3] = [None, None, None]; + let mut limiter = Limiter::new(sample_rate); + let mut strips: [ChannelStrip; 3] = std::array::from_fn(|_| ChannelStrip::new(sample_rate)); + let mut was_rendering = [false; 3]; + // Deck gain is split around the meter tap: `pre_gains` (trim) is + // applied to the metered signal, `gains` (fader × crossfader, + // gated by audibility) after it — so the channel meters read + // post-trim, pre-fader, like a DJ mixer. + let mut pre_gains: [f32; 3] = [0.0; 3]; + let mut gains: [f32; 3] = [0.0; 3]; + let mut meters: [f32; 3] = [0.0; 3]; + let mut master_meter = 0.0f32; + let mut scratch: Vec = vec![0.0; 16_384]; + // Scrub state: per-deck varispeed voice (raw-sample snapshot, its + // own channel strip) and the engine↔voice crossfade mix. + let mix_alpha = 1.0 - (-1.0 / (SCRUB_MIX_SECS * sample_rate as f32)).exp(); + let mut voices: [ScrubVoice; 3] = std::array::from_fn(|_| ScrubVoice::new(sample_rate)); + let mut scr_srcs: [Option>>; 3] = [None, None, None]; + let mut scrub_mix: [f32; 3] = [0.0; 3]; + let mut prev_phase = [ScrubPhase::Idle; 3]; + let mut scr_strips: [ChannelStrip; 3] = + std::array::from_fn(|_| ChannelStrip::new(sample_rate)); + let mut scr_buf: Vec = vec![0.0; 16_384]; + + let stream = device + .build_output_stream( + &config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + let t0 = Instant::now(); + + data.fill(0.0); + if scratch.len() < data.len() { + scratch.resize(data.len(), 0.0); + } + if scr_buf.len() < data.len() { + scr_buf.resize(data.len(), 0.0); + } + + let xf = mixer.crossfader.load().clamp(0.0, 1.0); + let master = mixer.master.load(); + // Decks A/B ride the constant-power crossfader; the + // audition channel bypasses it. + let xfade_gains = [ + (xf * std::f32::consts::FRAC_PI_2).cos(), + (xf * std::f32::consts::FRAC_PI_2).sin(), + 1.0, + ]; + + // Real time this buffer represents; drives both the meter + // release ballistics and the CPU-load figure below. + let budget = (data.len() / 2) as f32 / sample_rate as f32; + let meter_release = 1.0 - (-budget / METER_RELEASE_SECS).exp(); + + for (i, deck) in decks.iter().enumerate() { + // Adopt a newly loaded processor; retire the old one + // so it drops off the audio thread. try_lock only — + // if the UI holds either slot this block, skip. + if let Ok(mut slot) = deck.slot.try_lock() + && slot.is_some() + && let Ok(mut retired) = deck.retired.try_lock() + { + *retired = std::mem::replace(&mut procs[i], slot.take()); + } + + // Acknowledge a pending warm-start reset before + // anything else so seeks work while muted. + if deck.reset_request.load(Ordering::Acquire) { + if let Some(p) = &mut procs[i] { + p.reset(); + } + deck.reset_request.store(false, Ordering::Release); + } + + // Adopt/refresh the raw-sample snapshot for the + // scrub voice; retire a stale Arc so the track + // buffer never deallocates on the audio thread. + if let Ok(src) = deck.scratch_source.try_lock() { + let differs = match (&scr_srcs[i], &*src) { + (Some(a), Some(b)) => !Arc::ptr_eq(a, b), + (a, b) => a.is_some() != b.is_some(), + }; + if differs && let Ok(mut retired) = deck.scratch_retired.try_lock() { + *retired = std::mem::replace(&mut scr_srcs[i], src.clone()); + } + } + let source: &[f32] = scr_srcs[i].as_ref().map_or(&[], |s| s.as_slice()); + + // Scrub phase edges: seed on a fresh engage; start + // the release glide (and publish its predicted + // landing for the UI's parallel engine warm-start) + // on entry to Settling. A press+release inside one + // block arrives as Idle → Settling — seed first, + // then glide. + let phase = deck.shared.scrub.phase(); + if prev_phase[i] == ScrubPhase::Idle && phase != ScrubPhase::Idle { + voices[i].seed(deck.shared.scrub.target()); + scr_strips[i].reset(); + } + if prev_phase[i] != ScrubPhase::Settling && phase == ScrubPhase::Settling { + let landing = voices[i] + .begin_settle(deck.shared.scrub.settle_rate_target(), source); + deck.shared.scrub.publish_landing(landing); + } + prev_phase[i] = phase; + let scrubbing = phase != ScrubPhase::Idle; + + let live = deck.shared.stream_active.load(Ordering::Relaxed); + // Engine path. Keep consuming through the short + // fade-out after pause so the ramp lands on real + // audio, not an instant cut; once faded, freeze the + // engine (its state survives pause; a seek resets it + // anyway). While the scrub voice fully owns the mix + // the engine is likewise left unconsumed (frozen) — + // its state is discarded by the release-time + // warm-start seek. + let rendering = (live || gains[i] > 1e-4) && scrub_mix[i] < 1.0; + let buf = &mut scratch[..data.len()]; + if rendering { + if let Some(p) = &mut procs[i] { + p.process(buf); + } else { + buf.fill(0.0); + } + // Channel strip: isolator EQ then LP/HP filter. + if !was_rendering[i] { + strips[i].reset(); + } + strips[i].process( + buf, + StripParams { + eq: [ + deck.shared.eq_low.load(), + deck.shared.eq_mid.load(), + deck.shared.eq_high.load(), + ], + filter_mode: FilterMode::from_u8(deck.shared.filter_mode_u8()), + cutoff: deck.shared.filter_cutoff.load(), + }, + ); + } else { + buf.fill(0.0); + } + was_rendering[i] = rendering; + + // Scrub voice: chases the hand while `Active`, then + // glides its momentum through `Settling` and the + // post-settle mix ramp-out (past the landing it holds + // the settle rate, time-aligned with the engine + // warm-started there). Its own strip keeps EQ/filter + // applying to scratch audio. + let vbuf = &mut scr_buf[..data.len()]; + if scrubbing || scrub_mix[i] > 0.0 { + match phase { + ScrubPhase::Active => { + voices[i].render(deck.shared.scrub.target(), source, vbuf); + } + ScrubPhase::Settling | ScrubPhase::Idle => { + if voices[i].render_settle(source, vbuf) + && phase == ScrubPhase::Settling + { + deck.shared.scrub.finish_settle(); + prev_phase[i] = ScrubPhase::Idle; + } + } + } + deck.shared.scrub.publish_voice_frame(voices[i].position()); + scr_strips[i].process( + vbuf, + StripParams { + eq: [ + deck.shared.eq_low.load(), + deck.shared.eq_mid.load(), + deck.shared.eq_high.load(), + ], + filter_mode: FilterMode::from_u8(deck.shared.filter_mode_u8()), + cutoff: deck.shared.filter_cutoff.load(), + }, + ); + } else { + vbuf.fill(0.0); + } + + // Blend engine ↔ voice per frame and apply the deck + // gain in two stages: trim first (the meter reads + // the post-trim signal), fader × crossfader after, + // gated by audibility — it stays up during a + // paused-deck scrub (`scrubbing`) so the voice is + // audible even though the engine is muted. + let target_pre = deck.shared.trim.load(); + let target_gain = if live || scrubbing || scrub_mix[i] > 0.0 { + deck.shared.fader.load() * xfade_gains[i] + } else { + 0.0 + }; + let mut g_pre = pre_gains[i]; + let mut g = gains[i]; + let mut mix = scrub_mix[i]; + let mix_target: f32 = if scrubbing { 1.0 } else { 0.0 }; + let mut peak = 0.0f32; + for ((out, e), v) in data + .chunks_exact_mut(2) + .zip(buf.chunks_exact(2)) + .zip(vbuf.chunks_exact(2)) + { + g_pre += (target_pre - g_pre) * gain_alpha; + g += (target_gain - g) * gain_alpha; + mix += (mix_target - mix) * mix_alpha; + let pl = (e[0] * (1.0 - mix) + v[0] * mix) * g_pre; + let pr = (e[1] * (1.0 - mix) + v[1] * mix) * g_pre; + out[0] += pl * g; + out[1] += pr * g; + peak = peak.max(pl.abs()).max(pr.abs()); + } + pre_gains[i] = g_pre; + gains[i] = if !live && !scrubbing && g < 1e-4 { + 0.0 + } else { + g + }; + // Snap the asymptotic one-pole at the rails: without + // this, mix stalls one ulp below 1.0 and the engine + // keeps being consumed (unfed, at inaudible gain) for + // the whole drag, draining its ring into underruns. + if scrubbing && mix > 0.999 { + mix = 1.0; + } else if !scrubbing && mix < 1e-4 { + mix = 0.0; + } + scrub_mix[i] = mix; + + // Pre-fader channel meter: instant attack, slow release. + let m = &mut meters[i]; + *m = if peak >= *m { + peak + } else { + *m + (peak - *m) * meter_release + }; + deck.shared.meter.store(*m); + } + + for s in data.iter_mut() { + *s *= master; + } + limiter.process(data); + + // Master output meter: peak of what actually leaves the + // stream (post-master, post-limiter), same ballistics as + // the channel meters. + let mut out_peak = 0.0f32; + for &s in data.iter() { + out_peak = out_peak.max(s.abs()); + } + master_meter = if out_peak >= master_meter { + out_peak + } else { + master_meter + (out_peak - master_meter) * meter_release + }; + mixer.master_meter.store(master_meter); + + // CPU load: render time over the real time this buffer + // represents (`budget`, computed above), EMA-smoothed. + if budget > 0.0 { + let load = t0.elapsed().as_secs_f32() / budget; + let old = mixer.cpu_load.load(); + mixer.cpu_load.store(old + (load - old) * CPU_EMA_ALPHA); + } + }, + move |err| { + log::error!("Audio output error: {err}"); + }, + None, + ) + .map_err(|e| format!("Failed to build output stream: {e}"))?; + + stream + .play() + .map_err(|e| format!("Failed to start audio stream: {e}"))?; + + Ok(AudioOutput { + _stream: stream, + sample_rate, + device_name, + }) + } +} diff --git a/crates/halo/src/deck.rs b/crates/halo/src/deck.rs new file mode 100644 index 0000000..d2a6abb --- /dev/null +++ b/crates/halo/src/deck.rs @@ -0,0 +1,464 @@ +//! Per-deck engine ownership and the feed/control thread. +//! +//! The audio callback owns each deck's +//! [`EngineProcessor`](timestretch::engine::EngineProcessor) (handed over +//! through a lock-free slot); this thread keeps the engine's source ring +//! topped up and publishes the playhead. Seeks feed the preroll preceding +//! the target and request warm-start priming, exactly as in the timestretch +//! desktop reference deck. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use timestretch::PreAnalysisArtifact; +use timestretch::engine::{ + Engine, EngineConfig, EngineController, EngineProcessor, EngineProfile, SourceProducer, +}; + +use crate::state::{DeckShared, ScrubPhase, StopFlag, Transport}; + +pub const CHANNELS: usize = 2; +/// Interleaved samples pushed per feed batch. +const FEED_BATCH_SAMPLES: usize = 2048 * CHANNELS; +/// Ring occupancy (frames) the feeder tops up to. +const TARGET_OCCUPANCY_FRAMES: usize = 16_384; +/// Occupancy (frames) required before output unmutes after start/seek. +const PREROLL_FRAMES: usize = 4_096; + +/// Hand-off slot for moving an `EngineProcessor` into the audio callback. +/// The callback `try_lock`s it each block and adopts any waiting processor. +pub type ProcessorSlot = Arc>>; + +/// Hand-off slot for the raw interleaved track samples the audio callback +/// reads directly during a scratch (bypassing the engine). Same discipline +/// as [`ProcessorSlot`]: the callback only ever `try_lock`s. +pub type SampleSlot = Arc>>>>; + +/// One source-timeline discontinuity: at cumulative consumed frame +/// `anchor`, playback continued from source frame `target`. +#[derive(Debug, Clone, Copy)] +struct Jump { + anchor: f64, + target: f64, +} + +/// Maps the engine's cumulative consumed-source position to an absolute +/// source frame across feed-cursor jumps (loop wraps, seeks). Ported from +/// the timestretch desktop reference deck. +#[derive(Debug)] +struct JumpMap { + jumps: Vec, +} + +impl JumpMap { + fn starting_at(source_frame: f64) -> Self { + Self { + jumps: vec![Jump { + anchor: 0.0, + target: source_frame, + }], + } + } + + fn record(&mut self, anchor: f64, target: f64) { + self.jumps.push(Jump { anchor, target }); + } + + fn map(&self, cumulative: f64) -> f64 { + let jump = self + .jumps + .iter() + .rev() + .find(|j| j.anchor <= cumulative) + .or(self.jumps.first()) + .copied() + .unwrap_or(Jump { + anchor: 0.0, + target: 0.0, + }); + jump.target + (cumulative - jump.anchor) + } + + /// Drops jumps well behind the playhead, keeping one anchor. + fn prune(&mut self, cumulative: f64) { + while self.jumps.len() >= 2 && self.jumps[1].anchor <= cumulative { + self.jumps.remove(0); + } + } +} + +pub struct Track { + #[allow(dead_code)] // browser/library metadata from Phase 6 + pub name: String, + /// Kept for waveform rendering and analysis from Phase 2 on. + #[allow(dead_code)] + pub samples: Arc>, + #[allow(dead_code)] + pub num_frames: usize, +} + +/// UI-side deck object. Owns the feed thread and the engine controller; +/// the processor lives in the audio callback. +pub struct Deck { + pub shared: Arc, + pub processor_slot: ProcessorSlot, + /// Old processors handed back by the callback so they drop off the + /// audio thread. + pub retired_slot: ProcessorSlot, + /// Current track samples for the callback's scratch reader. + pub scratch_source: SampleSlot, + /// Old sample Arcs handed back by the callback so the track buffer + /// never deallocates on the audio thread. + pub scratch_retired: SampleSlot, + pub reset_request: Arc, + pub track: Option, + /// Offline analysis artifact for the loaded track, once it has landed + /// (steers the engine's transient handling at non-unity tempo). + pub pre_analysis: Option>, + feed_stop: Option>, + feed_handle: Option>, +} + +impl Deck { + pub fn new() -> Self { + Self { + shared: Arc::new(DeckShared::new()), + processor_slot: Arc::new(Mutex::new(None)), + retired_slot: Arc::new(Mutex::new(None)), + scratch_source: Arc::new(Mutex::new(None)), + scratch_retired: Arc::new(Mutex::new(None)), + reset_request: Arc::new(AtomicBool::new(false)), + track: None, + pre_analysis: None, + feed_stop: None, + feed_handle: None, + } + } + + /// Load a track (already decoded and resampled to the device rate), + /// with its analysis artifact (already rescaled to the device rate) + /// when the library has one. Builds a fresh engine, hands its processor + /// to the audio callback, and starts the feed thread. + pub fn load( + &mut self, + name: String, + samples: Arc>, + device_sample_rate: u32, + pre_analysis: Option>, + ) -> Result<(), String> { + let num_frames = samples.len() / CHANNELS; + self.shared.set_transport(Transport::Stopped); + self.shared.playhead.store(0, Ordering::Relaxed); + self.shared.cue_point.store(0, Ordering::Relaxed); + self.shared.set_loop(None); + self.shared + .total_frames + .store(num_frames as u64, Ordering::Relaxed); + // End any in-flight scrub and hand the new samples to the + // callback; drain retired Arcs here on the UI thread. + self.shared.scrub.cancel(); + *self.scratch_source.lock().unwrap() = Some(samples.clone()); + self.scratch_retired.lock().unwrap().take(); + + self.pre_analysis = pre_analysis; + self.start_engine(samples.clone(), device_sample_rate)?; + + self.track = Some(Track { + name, + samples, + num_frames, + }); + Ok(()) + } + + /// Rebuild the engine with the freshly landed analysis artifact, + /// preserving playhead and cue. Only call while the deck is not + /// playing — the rebuild swaps the processor out from under the + /// callback. + pub fn apply_pre_analysis( + &mut self, + artifact: Arc, + device_sample_rate: u32, + ) -> Result<(), String> { + let Some(track) = &self.track else { + return Ok(()); + }; + let samples = track.samples.clone(); + let playhead = self.shared.playhead_frames(); + self.pre_analysis = Some(artifact); + self.start_engine(samples, device_sample_rate)?; + if playhead > 0 { + self.shared.request_seek(playhead); + } + Ok(()) + } + + /// (Re)build the engine and feed thread for `samples`, using the deck's + /// current `pre_analysis` if any. + fn start_engine( + &mut self, + samples: Arc>, + device_sample_rate: u32, + ) -> Result<(), String> { + self.stop_feed_thread(); + // Drop any processor retired by a previous engine. + *self.retired_slot.lock().unwrap() = None; + self.shared.stream_active.store(false, Ordering::Relaxed); + self.shared.take_seek(); + + let config = EngineConfig { + sample_rate: device_sample_rate, + channels: 2, + profile: EngineProfile::Keylock, + initial_tempo_rate: 1.0, + max_block_frames: 2048, + source_capacity_frames: 65_536, + pre_analysis: self.pre_analysis.clone(), + }; + let handles = Engine::build(config).map_err(|e| format!("Engine error: {e}"))?; + let warm_start_preroll = handles.processor.warm_start_preroll_frames(); + + self.reset_request.store(false, Ordering::Relaxed); + *self.processor_slot.lock().unwrap() = Some(handles.processor); + + let stop_flag = Arc::new(StopFlag::new()); + let handle = start_feed_thread( + self.shared.clone(), + samples, + handles.source, + handles.controller, + self.reset_request.clone(), + stop_flag.clone(), + warm_start_preroll, + ); + self.feed_stop = Some(stop_flag); + self.feed_handle = Some(handle); + Ok(()) + } + + fn stop_feed_thread(&mut self) { + if let Some(flag) = self.feed_stop.take() { + flag.set(); + } + if let Some(handle) = self.feed_handle.take() { + let _ = handle.join(); + } + } +} + +impl Drop for Deck { + fn drop(&mut self) { + self.stop_feed_thread(); + } +} + +/// Feed/control thread: tops up the engine's source ring, executes +/// warm-start seeks, publishes the playhead, and gates `stream_active`. +#[allow(clippy::too_many_arguments)] +fn start_feed_thread( + shared: Arc, + source_audio: Arc>, + mut source: SourceProducer, + controller: EngineController, + reset_request: Arc, + stop_flag: Arc, + warm_start_preroll: usize, +) -> thread::JoinHandle<()> { + thread::spawn(move || { + let total_frames = source_audio.len() / CHANNELS; + // Interleaved read offset into the source. + let mut cursor: usize = 0; + // Maps consumed-source position to absolute source frames across + // seeks and loop wraps. + let mut jumps = JumpMap::starting_at(0.0); + // Frames fed to the engine since the last reset (jump anchors). + let mut fed_frames: f64 = 0.0; + let mut finished = false; + let mut prerolled = false; + let mut last_underruns = 0u64; + let mut last_rate = f64::NAN; + let mut last_keylock: Option = None; + + shared.stream_active.store(false, Ordering::Relaxed); + // Anchor the artifact timeline: the first pushed frame is track 0. + source.set_track_position(0); + + loop { + if stop_flag.is_set() { + break; + } + + // Forward tempo and keylock on change. The engine's mailbox is + // wait-free and clamps values; the epsilon just avoids spamming + // identical events every 2 ms. + let rate = (shared.tempo_rate.load() as f64).clamp(0.25, 4.0); + if last_rate.is_nan() || (rate - last_rate).abs() > 1e-6 { + controller.set_tempo_rate(rate); + last_rate = rate; + } + let keylock = shared.keylock.load(Ordering::Relaxed); + if last_keylock != Some(keylock) { + controller.set_keylock(keylock); + last_keylock = Some(keylock); + } + + if let Some(seek_frame) = shared.take_seek() { + // Warm-start seek: mute, have the audio callback reset the + // engine (which discards in-flight source), then feed the + // preroll PRECEDING the target and request priming — the + // graph runs the history through and resumes converged. + shared.stream_active.store(false, Ordering::Relaxed); + prerolled = false; + reset_request.store(true, Ordering::Release); + let mut spins = 0; + while reset_request.load(Ordering::Acquire) && spins < 500 { + thread::sleep(Duration::from_millis(1)); + spins += 1; + } + let target = seek_frame.min(total_frames); + let preroll = warm_start_preroll.min(target); + let feed_from = target - preroll; + cursor = feed_from * CHANNELS; + fed_frames = 0.0; + jumps = JumpMap::starting_at(feed_from as f64); + source.set_track_position(feed_from as u64); + controller.warm_start(preroll as u32); + finished = false; + shared.playhead.store(target as u64, Ordering::Relaxed); + } + + if shared.transport() != Transport::Playing { + shared.stream_active.store(false, Ordering::Relaxed); + thread::sleep(Duration::from_millis(10)); + continue; + } + + // Audible scrub: while the pointer holds the platter (`Active`) + // the audio callback plays its own varispeed voice and leaves + // the engine unconsumed, and the UI owns the displayed position + // — don't feed, don't publish a stale engine playhead, don't + // drive EOF logic. During the release glide (`Settling`) the + // loop must keep running so the landing seek (handled above) + // resets, feeds preroll, and primes the engine in parallel with + // the glide — only the playhead publish stays yielded. + let scrub_phase = shared.scrub.phase(); + if scrub_phase == ScrubPhase::Active { + thread::sleep(Duration::from_millis(10)); + continue; + } + + // Loop wrap: jump the feed cursor and re-anchor the timeline. + // The engine streams straight across the seam — no reset. + let loop_region = shared.loop_region(); + if let Some((loop_start, loop_end)) = loop_region + && cursor >= loop_end * CHANNELS + { + cursor = loop_start * CHANNELS; + jumps.record(fed_frames, loop_start as f64); + source.set_track_position(loop_start as u64); + finished = false; + } + + // End of stream: flush the resampler lookahead once, then stop + // the transport when the buffered tail has drained. + if cursor >= source_audio.len() && loop_region.is_none() { + if !finished { + finished = source.finish(); + } else if source.occupied_frames() == 0 { + thread::sleep(Duration::from_millis(100)); + shared.stream_active.store(false, Ordering::Relaxed); + shared.set_transport(Transport::Stopped); + shared + .playhead + .store(total_frames as u64, Ordering::Relaxed); + continue; + } + } else if source.occupied_frames() < TARGET_OCCUPANCY_FRAMES { + // Top up the ring, clamping each batch to the loop end (the + // wrap above fires on the next iteration) and to EOF. + let mut end = (cursor + FEED_BATCH_SAMPLES).min(source_audio.len()); + if let Some((_, loop_end)) = loop_region { + let loop_end = loop_end * CHANNELS; + if cursor < loop_end { + end = end.min(loop_end); + } + } + if end > cursor { + let accepted = source.push(&source_audio[cursor..end]); + cursor += accepted * CHANNELS; + fed_frames += accepted as f64; + } + } + + if !prerolled + && (source.occupied_frames() >= PREROLL_FRAMES || cursor >= source_audio.len()) + { + prerolled = true; + } + shared.stream_active.store(prerolled, Ordering::Relaxed); + + // Playhead: map the engine's cumulative consumed-source position + // through the jump timeline to an absolute source frame. The + // glide display belongs to the scrub voice, so don't fight it. + let consumed = controller.source_position(); + jumps.prune(consumed); + let playhead = jumps.map(consumed).clamp(0.0, total_frames as f64); + if scrub_phase == ScrubPhase::Idle { + shared.playhead.store(playhead as u64, Ordering::Relaxed); + } + + let underruns = controller.underrun_frames(); + if underruns > last_underruns && !finished { + log::warn!( + "deck: {} underrun frames (total {underruns})", + underruns - last_underruns + ); + last_underruns = underruns; + } + + thread::sleep(Duration::from_millis(2)); + } + + shared.stream_active.store(false, Ordering::Relaxed); + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jump_map_identity_without_jumps() { + let map = JumpMap::starting_at(0.0); + assert_eq!(map.map(0.0), 0.0); + assert_eq!(map.map(1234.5), 1234.5); + } + + #[test] + fn jump_map_seek_start_offsets_position() { + let map = JumpMap::starting_at(44_100.0); + assert_eq!(map.map(0.0), 44_100.0); + assert_eq!(map.map(100.0), 44_200.0); + } + + #[test] + fn jump_map_loop_wrap_re_anchors() { + // Fed 1000 frames, then wrapped back to source frame 200. + let mut map = JumpMap::starting_at(0.0); + map.record(1000.0, 200.0); + assert_eq!(map.map(999.0), 999.0); // pre-wrap audio still playing + assert_eq!(map.map(1000.0), 200.0); // seam + assert_eq!(map.map(1300.0), 500.0); // inside the loop + } + + #[test] + fn jump_map_prune_keeps_active_anchor() { + let mut map = JumpMap::starting_at(0.0); + map.record(1000.0, 200.0); + map.record(2000.0, 200.0); + map.prune(2500.0); + assert_eq!(map.map(2500.0), 700.0); + } +} diff --git a/crates/halo/src/decoder.rs b/crates/halo/src/decoder.rs new file mode 100644 index 0000000..775c8c1 --- /dev/null +++ b/crates/halo/src/decoder.rs @@ -0,0 +1,151 @@ +use std::fs::File; +use std::path::Path; + +use symphonia::core::audio::{AudioBufferRef, Signal}; +use symphonia::core::codecs::DecoderOptions; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +/// Decoded audio data. +pub struct DecodedAudio { + /// Interleaved stereo f32 samples. + pub samples: Vec, + pub sample_rate: u32, + #[allow(dead_code)] // read from Phase 1 (deck wiring) onward + pub channels: u32, + /// Total frames (samples per channel). + #[allow(dead_code)] + pub num_frames: usize, +} + +/// Decode an audio file to interleaved stereo f32 samples. +pub fn decode_file(path: &Path) -> Result { + let file = File::open(path).map_err(|e| format!("Failed to open file: {e}"))?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + let probed = symphonia::default::get_probe() + .format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .map_err(|e| format!("Failed to probe format: {e}"))?; + + let mut format = probed.format; + + let track = format + .default_track() + .ok_or_else(|| "No audio track found".to_string())?; + + let track_id = track.id; + let codec_params = track.codec_params.clone(); + let sample_rate = codec_params.sample_rate.ok_or("Unknown sample rate")?; + let src_channels = codec_params.channels.map(|c| c.count() as u32).unwrap_or(2); + + let mut decoder = symphonia::default::get_codecs() + .make(&codec_params, &DecoderOptions::default()) + .map_err(|e| format!("Failed to create decoder: {e}"))?; + + let mut all_samples: Vec = Vec::new(); + + loop { + let packet = match format.next_packet() { + Ok(p) => p, + Err(symphonia::core::errors::Error::IoError(ref e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => + { + break; + } + Err(e) => return Err(format!("Error reading packet: {e}")), + }; + + if packet.track_id() != track_id { + continue; + } + + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + Err(symphonia::core::errors::Error::DecodeError(_)) => continue, + Err(e) => return Err(format!("Decode error: {e}")), + }; + + append_samples(&decoded, src_channels, &mut all_samples); + } + + // If mono, convert to interleaved stereo + let (samples, out_channels) = if src_channels == 1 { + let stereo: Vec = all_samples.iter().flat_map(|&s| [s, s]).collect(); + (stereo, 2) + } else { + (all_samples, src_channels.min(2)) + }; + + let num_frames = samples.len() / out_channels as usize; + + Ok(DecodedAudio { + samples, + sample_rate, + channels: out_channels, + num_frames, + }) +} + +fn append_samples(buf: &AudioBufferRef, src_channels: u32, out: &mut Vec) { + match buf { + AudioBufferRef::F32(b) => { + let frames = b.frames(); + let chans = b.spec().channels.count().min(2); + for f in 0..frames { + for c in 0..chans { + out.push(*b.chan(c).get(f).unwrap_or(&0.0)); + } + // If source is mono, duplicate for stereo output handled later + if chans == 1 && src_channels == 1 { + // mono samples go in as mono; stereo conversion happens in decode_file + } + } + } + AudioBufferRef::S16(b) => { + let frames = b.frames(); + let chans = b.spec().channels.count().min(2); + for f in 0..frames { + for c in 0..chans { + let sample = *b.chan(c).get(f).unwrap_or(&0); + out.push(sample as f32 / 32768.0); + } + } + } + AudioBufferRef::S32(b) => { + let frames = b.frames(); + let chans = b.spec().channels.count().min(2); + for f in 0..frames { + for c in 0..chans { + let sample = *b.chan(c).get(f).unwrap_or(&0); + out.push(sample as f32 / 2_147_483_648.0); + } + } + } + AudioBufferRef::U8(b) => { + let frames = b.frames(); + let chans = b.spec().channels.count().min(2); + for f in 0..frames { + for c in 0..chans { + let sample = *b.chan(c).get(f).unwrap_or(&128); + out.push((sample as f32 - 128.0) / 128.0); + } + } + } + _ => { + // For other formats, try to get F32 data + log::warn!("Unsupported sample format, skipping packet"); + } + } +} diff --git a/crates/halo/src/dmx.rs b/crates/halo/src/dmx.rs new file mode 100644 index 0000000..0b288dc --- /dev/null +++ b/crates/halo/src/dmx.rs @@ -0,0 +1,185 @@ +//! The DMX engine thread: renders the lighting stack to Art-Net on its +//! own 44 Hz clock, independent of the UI. +//! +//! The UI publishes a [`DmxSnapshot`] of the cold inputs (cues, +//! programmer state, selection) once per frame; the thread reads the +//! *live* playhead from the deck's atomics every tick, so track cues keep +//! firing at full rate through UI stalls (live window resize, modal file +//! dialogs, an occluded window). A stalled UI freezes programmer *edits*, +//! never playback. The thread holds the last snapshot, so the rig keeps +//! its look — and keeps receiving frames — even when the UI goes quiet. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use halo_light::artnet::NetworkConfig; +use halo_light::cues::{CueSet, LANE_COUNT}; +use halo_light::fixture::Rig; +use halo_light::fixture_library::FixtureLibrary; +use halo_light::output::render; +use halo_light::programmer::{LaneOverride, ProgrammerParams, resolve}; + +use crate::state::DeckShared; + +/// DMX refresh rate (full-universe Art-Net refresh convention). +pub const DMX_FPS: f64 = 44.0; + +/// Linear beat map published by the UI: the thread extrapolates musical +/// time from the live playhead so effects stay beat-locked between UI +/// frames. `frames_per_beat <= 0` means no usable grid — hold `beat_t`. +#[derive(Clone, Copy)] +pub struct BeatRef { + pub beat_t: f64, + pub playhead: f64, + pub frames_per_beat: f64, +} + +/// Everything the engine needs except the playhead, which it reads live. +#[derive(Clone)] +pub struct DmxSnapshot { + pub rig: Arc, + pub cues: Option, + pub overrides: [LaneOverride; LANE_COUNT], + pub params: ProgrammerParams, + pub selection: HashSet, + /// The lighting deck's shared state (playhead atomics). + pub deck: Arc, + pub beat_ref: BeatRef, + /// Destinations + universe routing. Publish a *new* Arc to make the + /// engine rebuild its sockets (it compares pointers, not contents). + pub net: Arc, +} + +pub type DmxShared = Arc>>; + +/// Spawn the engine. Sockets open lazily from the first snapshot's +/// network config and rebuild whenever a new config Arc is published. +/// The thread runs for the life of the process. +pub fn spawn_dmx_engine() -> DmxShared { + let shared: DmxShared = Arc::new(Mutex::new(None)); + let out = Arc::clone(&shared); + thread::spawn(move || { + let library = FixtureLibrary::new(); + let mut current_net: Option> = None; + let mut connections = Vec::new(); + + let tick = Duration::from_secs_f64(1.0 / DMX_FPS); + let mut next = Instant::now() + tick; + let mut send_errors: u64 = 0; + loop { + if let Some(wait) = next.checked_duration_since(Instant::now()) { + thread::sleep(wait); + } + // Fixed cadence, but re-anchor rather than burst after a stall. + next += tick; + if next < Instant::now() { + next = Instant::now() + tick; + } + + let Some(snap) = shared.lock().unwrap().clone() else { + continue; + }; + if current_net + .as_ref() + .is_none_or(|n| !Arc::ptr_eq(n, &snap.net)) + { + connections = match snap.net.connect() { + Ok(c) => { + log::info!("Art-Net up at {DMX_FPS} Hz: {}", snap.net.summary()); + c + } + Err(e) => { + log::warn!("Art-Net socket setup failed ({e}); output paused"); + Vec::new() + } + }; + current_net = Some(Arc::clone(&snap.net)); + } + let playhead = snap.deck.playhead_frames() as f64; + let lanes = resolve(&snap.overrides, snap.cues.as_ref(), playhead); + let beat_t = if snap.beat_ref.frames_per_beat > 0.0 { + snap.beat_ref.beat_t + + (playhead - snap.beat_ref.playhead) / snap.beat_ref.frames_per_beat + } else { + snap.beat_ref.beat_t + }; + + let frames = render( + &snap.rig, + &library, + &lanes, + &snap.params, + &snap.selection, + beat_t, + ); + for (universe, frame) in &frames { + let Some(dest) = snap.net.destination_for_universe(*universe) else { + continue; + }; + let Some(conn) = connections.get(dest) else { + continue; + }; + if let Err(e) = conn.send(*universe, frame) { + send_errors += 1; + // Log the 1st, 2nd, 4th, 8th... occurrence, not all 44/s. + if send_errors.is_power_of_two() { + log::debug!("Art-Net send error #{send_errors}: {e}"); + } + } + } + } + }); + out +} + +#[cfg(test)] +mod tests { + use std::net::{SocketAddr, UdpSocket}; + + use halo_light::artnet::ArtNetMode; + use halo_light::fixture::default_rig; + + use super::*; + + /// End-to-end: real engine thread → resolve → render → Art-Net UDP, + /// received by a local listener standing in for a node. + #[test] + fn engine_delivers_artnet_frames_to_a_destination() { + let listener = UdpSocket::bind("127.0.0.1:0").expect("bind listener"); + listener + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let dst = listener.local_addr().unwrap(); + let src: SocketAddr = "127.0.0.1:0".parse().unwrap(); + let mut net = NetworkConfig::single("test-node", ArtNetMode::Unicast(src, dst)); + net.route_universe(1, 0); + + let shared = spawn_dmx_engine(); + let library = FixtureLibrary::new(); + *shared.lock().unwrap() = Some(DmxSnapshot { + rig: Arc::new(default_rig(&library)), + cues: None, + overrides: Default::default(), + params: ProgrammerParams::default(), + selection: HashSet::new(), + deck: Arc::new(DeckShared::new()), + beat_ref: BeatRef { + beat_t: 0.0, + playhead: 0.0, + frames_per_beat: 0.0, + }, + net: Arc::new(net), + }); + + let mut buf = [0u8; 1024]; + let (len, from) = listener + .recv_from(&mut buf) + .expect("engine should send a frame within the timeout"); + assert_eq!(&buf[..8], b"Art-Net\0", "packet id from {from}"); + // ArtDmx: 18-byte header + 512 channels. + assert_eq!(len, 18 + 512); + assert_eq!(u16::from_be_bytes([buf[9], buf[8]]), 0x5000, "OpDmx"); + } +} diff --git a/crates/halo/src/dsp.rs b/crates/halo/src/dsp.rs new file mode 100644 index 0000000..fd17d4d --- /dev/null +++ b/crates/halo/src/dsp.rs @@ -0,0 +1,455 @@ +//! Per-deck channel-strip DSP: 3-band isolator EQ and a resonant LP/HP +//! filter, both running inside the audio callback (allocation-free, +//! per-sample gain smoothing against zipper noise). + +use timestretch::core::crossover::LR4Crossover; + +/// Low/mid crossover of the isolator EQ. +const EQ_LOW_HZ: f64 = 250.0; +/// Mid/high crossover of the isolator EQ. +const EQ_HIGH_HZ: f64 = 2_600.0; +/// Gain smoothing time constant in seconds. +const SMOOTH_SECS: f32 = 0.005; +/// Frames per filter-coefficient update during cutoff sweeps. +const FILTER_SUBBLOCK: usize = 64; +/// DJ filter resonance (slightly above Butterworth for a gentle sweep bump). +const FILTER_Q: f64 = 1.05; +/// Cutoff sweep range, mapped log₂ from the normalized 0..1 knob. +const FILTER_MIN_HZ: f64 = 20.0; +const FILTER_MAX_HZ: f64 = 20_000.0; + +/// Map a normalized cutoff (0..1) to Hz, log-scaled 20 Hz → 20 kHz. Shared +/// by the filter DSP and the UI (knob hover readout). +pub fn filter_cutoff_hz(normalized: f32) -> f64 { + FILTER_MIN_HZ * (FILTER_MAX_HZ / FILTER_MIN_HZ).powf(normalized.clamp(0.0, 1.0) as f64) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FilterMode { + Off, + LowPass, + HighPass, +} + +impl FilterMode { + pub fn from_u8(v: u8) -> Self { + match v { + 1 => FilterMode::LowPass, + 2 => FilterMode::HighPass, + _ => FilterMode::Off, + } + } +} + +/// Target parameters for one block, read from the deck's atomics. +#[derive(Debug, Clone, Copy)] +pub struct StripParams { + /// Band gains, linear: 0 = kill, 1 = unity, 2 = +6 dB. + pub eq: [f32; 3], + pub filter_mode: FilterMode, + /// Normalized cutoff 0..1 (log-mapped 20 Hz → 20 kHz). + pub cutoff: f32, +} + +/// 3-band isolator: two LR4 crossovers per channel (bands re-sum to a true +/// allpass at unity), per-sample smoothed band gains. +struct IsolatorEq { + /// Per channel: (low/mid split, mid/high split). + xovers: [(LR4Crossover, LR4Crossover); 2], + gains: [f32; 3], + alpha: f32, +} + +impl IsolatorEq { + fn new(sample_rate: u32) -> Self { + let make = || { + ( + LR4Crossover::new(EQ_LOW_HZ, sample_rate), + LR4Crossover::new(EQ_HIGH_HZ, sample_rate), + ) + }; + Self { + xovers: [make(), make()], + gains: [1.0; 3], + alpha: 1.0 - (-1.0 / (SMOOTH_SECS * sample_rate as f32)).exp(), + } + } + + fn process(&mut self, buf: &mut [f32], targets: [f32; 3]) { + for frame in buf.chunks_exact_mut(2) { + for (g, t) in self.gains.iter_mut().zip(targets) { + *g += (t - *g) * self.alpha; + } + for (ch, sample) in frame.iter_mut().enumerate() { + let (low_mid, mid_high) = &mut self.xovers[ch]; + let (low, upper) = low_mid.process_sample(*sample); + let (mid, high) = mid_high.process_sample(upper); + *sample = low * self.gains[0] + mid * self.gains[1] + high * self.gains[2]; + } + } + } + + fn reset(&mut self) { + for (a, b) in &mut self.xovers { + a.reset(); + b.reset(); + } + } +} + +/// RBJ biquad, Direct Form I, per-channel state with shared coefficients. +struct Biquad { + b0: f64, + b1: f64, + b2: f64, + a1: f64, + a2: f64, + /// (x1, x2, y1, y2) per channel. + state: [[f64; 4]; 2], +} + +impl Biquad { + fn identity() -> Self { + Self { + b0: 1.0, + b1: 0.0, + b2: 0.0, + a1: 0.0, + a2: 0.0, + state: [[0.0; 4]; 2], + } + } + + fn set_lowpass(&mut self, freq: f64, sample_rate: f64, q: f64) { + let w0 = std::f64::consts::TAU * (freq / sample_rate).min(0.49); + let (sin_w0, cos_w0) = w0.sin_cos(); + let alpha = sin_w0 / (2.0 * q); + let a0 = 1.0 + alpha; + self.b0 = (1.0 - cos_w0) / 2.0 / a0; + self.b1 = (1.0 - cos_w0) / a0; + self.b2 = self.b0; + self.a1 = -2.0 * cos_w0 / a0; + self.a2 = (1.0 - alpha) / a0; + } + + fn set_highpass(&mut self, freq: f64, sample_rate: f64, q: f64) { + let w0 = std::f64::consts::TAU * (freq / sample_rate).min(0.49); + let (sin_w0, cos_w0) = w0.sin_cos(); + let alpha = sin_w0 / (2.0 * q); + let a0 = 1.0 + alpha; + self.b0 = (1.0 + cos_w0) / 2.0 / a0; + self.b1 = -(1.0 + cos_w0) / a0; + self.b2 = self.b0; + self.a1 = -2.0 * cos_w0 / a0; + self.a2 = (1.0 - alpha) / a0; + } + + #[inline] + fn process_sample(&mut self, ch: usize, x: f64) -> f64 { + let s = &mut self.state[ch]; + let y = self.b0 * x + self.b1 * s[0] + self.b2 * s[1] - self.a1 * s[2] - self.a2 * s[3]; + s[1] = s[0]; + s[0] = x; + s[3] = s[2]; + s[2] = y; + y + } + + fn reset(&mut self) { + self.state = [[0.0; 4]; 2]; + } +} + +/// Sweepable LP/HP DJ filter: one resonant biquad per channel pair, with the +/// cutoff smoothed and coefficients refreshed every [`FILTER_SUBBLOCK`] +/// frames so sweeps stay zipper-free. +struct DjFilter { + biquad: Biquad, + mode: FilterMode, + /// Smoothed normalized cutoff. + cutoff: f32, + alpha: f32, + sample_rate: f64, +} + +impl DjFilter { + fn new(sample_rate: u32) -> Self { + Self { + biquad: Biquad::identity(), + mode: FilterMode::Off, + cutoff: 1.0, + // Smoothing steps happen once per sub-block, not per sample. + alpha: 1.0 - (-(FILTER_SUBBLOCK as f32) / (SMOOTH_SECS * sample_rate as f32)).exp(), + sample_rate: sample_rate as f64, + } + } + + fn process(&mut self, buf: &mut [f32], mode: FilterMode, target_cutoff: f32) { + if mode != self.mode { + // Mode flips restart the filter cleanly at the new response. + self.mode = mode; + self.cutoff = target_cutoff; + self.biquad.reset(); + } + if self.mode == FilterMode::Off { + return; + } + + for block in buf.chunks_mut(FILTER_SUBBLOCK * 2) { + self.cutoff += (target_cutoff - self.cutoff) * self.alpha; + let hz = filter_cutoff_hz(self.cutoff); + match self.mode { + FilterMode::LowPass => self.biquad.set_lowpass(hz, self.sample_rate, FILTER_Q), + FilterMode::HighPass => self.biquad.set_highpass(hz, self.sample_rate, FILTER_Q), + FilterMode::Off => unreachable!(), + } + for frame in block.chunks_exact_mut(2) { + for (ch, sample) in frame.iter_mut().enumerate() { + *sample = self.biquad.process_sample(ch, *sample as f64) as f32; + } + } + } + } + + fn reset(&mut self) { + self.biquad.reset(); + } +} + +/// One deck's post-engine DSP chain: isolator EQ then filter. Gains +/// (trim/fader/crossfader) stay in the mixer where they always were — for a +/// linear chain the order doesn't change the result. +pub struct ChannelStrip { + eq: IsolatorEq, + filter: DjFilter, +} + +impl ChannelStrip { + pub fn new(sample_rate: u32) -> Self { + Self { + eq: IsolatorEq::new(sample_rate), + filter: DjFilter::new(sample_rate), + } + } + + pub fn process(&mut self, buf: &mut [f32], params: StripParams) { + self.eq.process(buf, params.eq); + self.filter.process(buf, params.filter_mode, params.cutoff); + } + + /// Clear filter state (e.g. when a deck stops rendering) so stale + /// history can't transient on the next start. + pub fn reset(&mut self) { + self.eq.reset(); + self.filter.reset(); + } +} + +/// Master-bus peak limiter: instantaneous attack, exponential release. +/// Keeps two full-gain decks from hard-clipping into the DAC; a final +/// clamp stays as the safety net for intersample overs. +pub struct Limiter { + envelope: f32, + release_alpha: f32, +} + +/// Limiter ceiling (linear). +const LIMIT_THRESHOLD: f32 = 0.98; +/// Release time constant in seconds. +const LIMIT_RELEASE_SECS: f32 = 0.05; + +impl Limiter { + pub fn new(sample_rate: u32) -> Self { + Self { + envelope: 0.0, + release_alpha: 1.0 - (-1.0 / (LIMIT_RELEASE_SECS * sample_rate as f32)).exp(), + } + } + + pub fn process(&mut self, buf: &mut [f32]) { + for frame in buf.chunks_exact_mut(2) { + let peak = frame[0].abs().max(frame[1].abs()); + if peak > self.envelope { + self.envelope = peak; + } else { + self.envelope += (peak - self.envelope) * self.release_alpha; + } + let gain = if self.envelope > LIMIT_THRESHOLD { + LIMIT_THRESHOLD / self.envelope + } else { + 1.0 + }; + for s in frame.iter_mut() { + *s = (*s * gain).clamp(-1.0, 1.0); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stereo_sine(freq: f64, secs: f64, sample_rate: u32) -> Vec { + let n = (secs * sample_rate as f64) as usize; + let mut out = Vec::with_capacity(n * 2); + for i in 0..n { + let s = (std::f64::consts::TAU * freq * i as f64 / sample_rate as f64).sin() as f32; + out.push(s); + out.push(s); + } + out + } + + fn energy(buf: &[f32], skip_frames: usize) -> f64 { + buf[skip_frames * 2..] + .iter() + .map(|s| (*s as f64).powi(2)) + .sum() + } + + fn run_strip(input: &[f32], params: StripParams, sample_rate: u32) -> Vec { + let mut strip = ChannelStrip::new(sample_rate); + let mut buf = input.to_vec(); + // Feed in callback-sized blocks like the real audio path. + for block in buf.chunks_mut(1024) { + strip.process(block, params); + } + buf + } + + const SR: u32 = 48_000; + /// Frames to skip for filter settling + gain smoothing ramp-in. + const SETTLE: usize = 8_192; + + fn unity() -> StripParams { + StripParams { + eq: [1.0; 3], + filter_mode: FilterMode::Off, + cutoff: 1.0, + } + } + + #[test] + fn unity_strip_is_transparent() { + // The isolator re-sums to an allpass: energy preserved within a dB. + for freq in [60.0, 250.0, 1_000.0, 2_600.0, 8_000.0] { + let input = stereo_sine(freq, 1.0, SR); + let out = run_strip(&input, unity(), SR); + let ratio = energy(&out, SETTLE) / energy(&input, SETTLE); + let db = 10.0 * ratio.log10(); + assert!( + db.abs() < 1.0, + "unity strip changed level at {freq} Hz: {db:+.2} dB" + ); + } + } + + #[test] + fn low_kill_removes_bass_keeps_highs() { + let params = StripParams { + eq: [0.0, 1.0, 1.0], + ..unity() + }; + let bass = run_strip(&stereo_sine(60.0, 1.0, SR), params, SR); + let bass_db = + 10.0 * (energy(&bass, SETTLE) / energy(&stereo_sine(60.0, 1.0, SR), SETTLE)).log10(); + assert!(bass_db < -30.0, "low kill left {bass_db:+.1} dB of 60 Hz"); + + let highs = run_strip(&stereo_sine(8_000.0, 1.0, SR), params, SR); + let highs_db = 10.0 + * (energy(&highs, SETTLE) / energy(&stereo_sine(8_000.0, 1.0, SR), SETTLE)).log10(); + assert!( + highs_db.abs() < 1.0, + "low kill touched 8 kHz: {highs_db:+.2} dB" + ); + } + + #[test] + fn mid_kill_notches_mids() { + let params = StripParams { + eq: [1.0, 0.0, 1.0], + ..unity() + }; + let mids = run_strip(&stereo_sine(1_000.0, 1.0, SR), params, SR); + let db = + 10.0 * (energy(&mids, SETTLE) / energy(&stereo_sine(1_000.0, 1.0, SR), SETTLE)).log10(); + assert!(db < -30.0, "mid kill left {db:+.1} dB of 1 kHz"); + } + + #[test] + fn highpass_removes_bass() { + let params = StripParams { + eq: [1.0; 3], + filter_mode: FilterMode::HighPass, + cutoff: 0.5, // ~630 Hz + }; + let out = run_strip(&stereo_sine(60.0, 1.0, SR), params, SR); + let db = + 10.0 * (energy(&out, SETTLE) / energy(&stereo_sine(60.0, 1.0, SR), SETTLE)).log10(); + assert!(db < -20.0, "highpass left {db:+.1} dB of 60 Hz"); + } + + #[test] + fn lowpass_removes_highs() { + let params = StripParams { + eq: [1.0; 3], + filter_mode: FilterMode::LowPass, + cutoff: 0.5, // ~630 Hz + }; + let out = run_strip(&stereo_sine(8_000.0, 1.0, SR), params, SR); + let db = + 10.0 * (energy(&out, SETTLE) / energy(&stereo_sine(8_000.0, 1.0, SR), SETTLE)).log10(); + assert!(db < -20.0, "lowpass left {db:+.1} dB of 8 kHz"); + } + + #[test] + fn limiter_caps_hot_signal_and_passes_quiet_one() { + let mut limiter = Limiter::new(SR); + // Two full-scale decks summed: 2.0 peak. + let mut hot: Vec = stereo_sine(1_000.0, 0.2, SR) + .iter() + .map(|s| s * 2.0) + .collect(); + limiter.process(&mut hot); + assert!(hot.iter().all(|s| s.abs() <= 1.0)); + // Steady-state output should sit at the ceiling, not squashed below. + let peak_tail = hot[hot.len() / 2..] + .iter() + .fold(0.0f32, |m, s| m.max(s.abs())); + assert!(peak_tail > 0.9, "over-limited: peak {peak_tail}"); + + let mut quiet: Vec = stereo_sine(1_000.0, 0.2, SR) + .iter() + .map(|s| s * 0.5) + .collect(); + let reference = quiet.clone(); + let mut limiter = Limiter::new(SR); + limiter.process(&mut quiet); + for (a, b) in quiet.iter().zip(&reference) { + assert!((a - b).abs() < 1e-6, "limiter touched sub-threshold audio"); + } + } + + #[test] + fn output_stays_finite_through_sweeps_and_mode_flips() { + let input = stereo_sine(440.0, 0.5, SR); + let mut strip = ChannelStrip::new(SR); + let mut buf = input.clone(); + let modes = [ + FilterMode::Off, + FilterMode::LowPass, + FilterMode::HighPass, + FilterMode::LowPass, + ]; + for (i, block) in buf.chunks_mut(512).enumerate() { + let params = StripParams { + eq: [(i % 3) as f32, 1.0, ((i + 1) % 2) as f32], + filter_mode: modes[i % modes.len()], + cutoff: (i as f32 * 0.13) % 1.0, + }; + strip.process(block, params); + } + assert!(buf.iter().all(|s| s.is_finite()), "non-finite DSP output"); + } +} diff --git a/crates/halo/src/fader.rs b/crates/halo/src/fader.rs new file mode 100644 index 0000000..c2769ad --- /dev/null +++ b/crates/halo/src/fader.rs @@ -0,0 +1,234 @@ +//! Bar-style fader widget (physical-mixer look) for the channel volume +//! faders and the crossfader. +//! +//! A rectangular cap rides a grooved track. Channel faders draw evenly +//! spaced notches down the side as a position scale; the crossfader draws a +//! single center notch and tints its cap from grey (center) to the accent +//! color at the extremes. Interaction is absolute positioning — the cap +//! jumps to the pointer, like a real fader — with double-click to reset. +//! +//! Pure UI: callers load an atomic into a local `f32`, pass a `&mut`, and +//! store back on `Response::changed()`, the same pattern the knobs use. + +use std::ops::RangeInclusive; + +use eframe::egui::{self, Color32, Rect, Response, Sense, Ui, Vec2, Widget}; + +/// Thickness of the groove the cap rides in, in points. +const GROOVE: f32 = 4.0; +/// Cap thickness along the travel axis, in points. +const CAP_THICKNESS: f32 = 9.0; +/// How far the cap extends across the travel axis (each side of center). +const CAP_HALF_SPAN: f32 = 10.0; +/// Notch tick length (each side of the groove) and gap from it. +const NOTCH_LEN: f32 = 4.0; +const NOTCH_GAP: f32 = 3.0; + +/// Notch (tick) layout drawn alongside the track. +pub enum Notches { + None, + /// `n` evenly-spaced intervals → n+1 ticks across the travel. (channel) + Even(u32), + /// A single tick at the value center. (crossfader) + Center, +} + +pub struct Fader<'a> { + value: &'a mut f32, + range: RangeInclusive, + vertical: bool, + size: Vec2, + notches: Notches, + default: f32, + accent: Color32, + /// Some(center): draw an accent fill along the groove from `center` to the + /// cap as it moves off center (like the bipolar EQ knobs). + center_fill: Option, +} + +impl<'a> Fader<'a> { + pub fn new(value: &'a mut f32, range: RangeInclusive, accent: Color32) -> Self { + let default = *range.start(); + Self { + value, + range, + vertical: true, + size: Vec2::new(24.0, 120.0), + notches: Notches::None, + default, + accent, + center_fill: None, + } + } + + pub fn vertical(mut self, vertical: bool) -> Self { + self.vertical = vertical; + self + } + + pub fn size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } + + pub fn notches(mut self, notches: Notches) -> Self { + self.notches = notches; + self + } + + pub fn default_value(mut self, default: f32) -> Self { + self.default = default; + self + } + + pub fn center_fill(mut self, center: f32) -> Self { + self.center_fill = Some(center); + self + } + + fn span(&self) -> f32 { + *self.range.end() - *self.range.start() + } + + /// Normalize a value to 0..=1 across the range. + fn norm(&self, v: f32) -> f32 { + ((v - *self.range.start()) / self.span()).clamp(0.0, 1.0) + } +} + +impl Widget for Fader<'_> { + fn ui(self, ui: &mut Ui) -> Response { + let (rect, mut response) = ui.allocate_exact_size(self.size, Sense::click_and_drag()); + + // The cap center travels between these two points; inset from the + // ends by half the cap so it never spills past the track. + let inset = CAP_THICKNESS / 2.0; + let (lo, hi) = if self.vertical { + (rect.bottom() - inset, rect.top() + inset) // norm 0 = bottom + } else { + (rect.left() + inset, rect.right() - inset) // norm 0 = left + }; + + // Absolute positioning: cap follows the pointer along the travel axis. + if (response.dragged() || response.clicked()) + && let Some(pos) = response.interact_pointer_pos() + { + let p = if self.vertical { pos.y } else { pos.x }; + let t = ((p - lo) / (hi - lo)).clamp(0.0, 1.0); + let next = (*self.range.start() + t * self.span()) + .clamp(*self.range.start(), *self.range.end()); + if next != *self.value { + *self.value = next; + response.mark_changed(); + } + } + + if response.double_clicked() && *self.value != self.default { + *self.value = self.default; + response.mark_changed(); + } + + if ui.is_rect_visible(rect) { + let painter = ui.painter(); + let visuals = ui.visuals(); + let center = rect.center(); + let track_col = visuals.weak_text_color().gamma_multiply(0.5); + + // Groove along the travel axis. + let groove = if self.vertical { + Rect::from_center_size(center, Vec2::new(GROOVE, (hi - lo).abs() + CAP_THICKNESS)) + } else { + Rect::from_center_size(center, Vec2::new((hi - lo).abs() + CAP_THICKNESS, GROOVE)) + }; + painter.rect_filled(groove, 2.0, visuals.extreme_bg_color); + + // Notches: short ticks perpendicular to the groove. + let tick = |painter: &egui::Painter, t: f32| { + let p = lo + (hi - lo) * t; + let (a, b, c, d) = if self.vertical { + ( + egui::pos2(center.x - GROOVE / 2.0 - NOTCH_GAP - NOTCH_LEN, p), + egui::pos2(center.x - GROOVE / 2.0 - NOTCH_GAP, p), + egui::pos2(center.x + GROOVE / 2.0 + NOTCH_GAP, p), + egui::pos2(center.x + GROOVE / 2.0 + NOTCH_GAP + NOTCH_LEN, p), + ) + } else { + ( + egui::pos2(p, center.y - GROOVE / 2.0 - NOTCH_GAP - NOTCH_LEN), + egui::pos2(p, center.y - GROOVE / 2.0 - NOTCH_GAP), + egui::pos2(p, center.y + GROOVE / 2.0 + NOTCH_GAP), + egui::pos2(p, center.y + GROOVE / 2.0 + NOTCH_GAP + NOTCH_LEN), + ) + }; + let stroke = egui::Stroke::new(1.0, track_col); + painter.line_segment([a, b], stroke); + painter.line_segment([c, d], stroke); + }; + match self.notches { + Notches::None => {} + Notches::Even(n) => { + for i in 0..=n { + tick(painter, i as f32 / n as f32); + } + } + Notches::Center => tick(painter, 0.5), + } + + let t = self.norm(*self.value); + let p = lo + (hi - lo) * t; + let active = response.hovered() || response.dragged(); + + // Center-origin fill: an accent bar from the center value to the + // cap, showing how far it's pushed off center (like the EQ knobs). + if let Some(c) = self.center_fill { + let pc = lo + (hi - lo) * self.norm(c); + if (p - pc).abs() > 0.5 { + let fill = if self.vertical { + Rect::from_two_pos( + egui::pos2(center.x - GROOVE / 2.0, pc), + egui::pos2(center.x + GROOVE / 2.0, p), + ) + } else { + Rect::from_two_pos( + egui::pos2(pc, center.y - GROOVE / 2.0), + egui::pos2(p, center.y + GROOVE / 2.0), + ) + }; + let fill_col = if active { + self.accent + } else { + self.accent.gamma_multiply(0.9) + }; + painter.rect_filled(fill, 2.0, fill_col); + } + } + + // Cap at the current value (always accent). + let cap = if self.vertical { + Rect::from_center_size( + egui::pos2(center.x, p), + Vec2::new(CAP_HALF_SPAN * 2.0, CAP_THICKNESS), + ) + } else { + Rect::from_center_size( + egui::pos2(p, center.y), + Vec2::new(CAP_THICKNESS, CAP_HALF_SPAN * 2.0), + ) + }; + let cap_col = if active { + self.accent + } else { + self.accent.gamma_multiply(0.9) + }; + painter.rect_filled(cap, 2.0, cap_col); + painter.rect_stroke( + cap, + 2.0, + egui::Stroke::new(1.0, visuals.extreme_bg_color), + egui::StrokeKind::Inside, + ); + } + + response + } +} diff --git a/crates/halo/src/knob.rs b/crates/halo/src/knob.rs new file mode 100644 index 0000000..26f14f8 --- /dev/null +++ b/crates/halo/src/knob.rs @@ -0,0 +1,192 @@ +//! Rotary knob widget (Traktor/Rekordbox style) for the mixer. +//! +//! A knob sweeps 270° — from −135° (≈7:30) to +135° (≈4:30), measured +//! clockwise from 12 o'clock — with a dim background track, an accent +//! value arc that shows how much is applied, and a pointer at the current +//! value. Bipolar knobs fill the arc outward from a center detent (EQ/trim +//! unity); unipolar knobs fill from the min end (master). +//! +//! The widget is pure UI: callers load an atomic into a local `f32`, pass a +//! `&mut` to it, and store back on `Response::changed()` — the same pattern +//! the mixer sliders use. + +use std::f32::consts::PI; +use std::ops::RangeInclusive; + +use eframe::egui::{self, Color32, Response, Sense, Stroke, Ui, Vec2, Widget}; + +/// Total sweep, from `-SWEEP/2` to `+SWEEP/2` about 12 o'clock. +const SWEEP: f32 = 1.5 * PI; // 270° +const HALF_SWEEP: f32 = SWEEP / 2.0; +/// Vertical drag distance (points) to traverse the full range. +const PIXELS_FOR_FULL_TRAVEL: f32 = 200.0; +/// Fine-adjust multiplier while Shift is held. +const FINE: f32 = 0.25; +/// Points from the widget rect edge to the track ring. +const RING_INSET: f32 = 3.0; + +/// How the value arc is drawn. +pub enum KnobArc { + /// Arc grows from the min end of the sweep. (master) + Unipolar, + /// Arc grows from a center detent outward either way. (EQ, trim, filter) + Bipolar { center: f32 }, +} + +pub struct Knob<'a> { + value: &'a mut f32, + range: RangeInclusive, + arc: KnobArc, + /// Double-click reset target. + default: f32, + diameter: f32, + accent: Color32, +} + +impl<'a> Knob<'a> { + pub fn new(value: &'a mut f32, range: RangeInclusive, accent: Color32) -> Self { + let default = *range.start(); + Self { + value, + range, + arc: KnobArc::Unipolar, + default, + diameter: 35.0, + accent, + } + } + + pub fn arc(mut self, arc: KnobArc) -> Self { + self.arc = arc; + self + } + + pub fn default_value(mut self, default: f32) -> Self { + self.default = default; + self + } + + pub fn diameter(mut self, diameter: f32) -> Self { + self.diameter = diameter; + self + } + + fn span(&self) -> f32 { + *self.range.end() - *self.range.start() + } + + /// Normalize a value to `0..=1` across the range. + fn norm(&self, v: f32) -> f32 { + ((v - *self.range.start()) / self.span()).clamp(0.0, 1.0) + } + + /// Sweep angle (radians, clockwise from 12 o'clock) for a value. + fn angle_of(&self, v: f32) -> f32 { + -HALF_SWEEP + self.norm(v) * SWEEP + } +} + +/// Unit direction for a sweep angle (egui y-down: θ=0 → up, +θ → right). +fn dir(angle: f32) -> Vec2 { + Vec2::new(angle.sin(), -angle.cos()) +} + +impl Widget for Knob<'_> { + fn ui(self, ui: &mut Ui) -> Response { + let (rect, mut response) = + ui.allocate_exact_size(Vec2::splat(self.diameter), Sense::click_and_drag()); + + let span = self.span(); + + // Drag: vertical, up = increase. Shift = fine. + if response.dragged() { + let dy = -response.drag_delta().y; + if dy != 0.0 { + let fine = if ui.input(|i| i.modifiers.shift_only()) { + FINE + } else { + 1.0 + }; + let next = (*self.value + dy / PIXELS_FOR_FULL_TRAVEL * span * fine) + .clamp(*self.range.start(), *self.range.end()); + if next != *self.value { + *self.value = next; + response.mark_changed(); + } + } + } + + // Double-click resets to default. + if response.double_clicked() && *self.value != self.default { + *self.value = self.default; + response.mark_changed(); + } + + if ui.is_rect_visible(rect) { + let painter = ui.painter(); + let center = rect.center(); + let radius = self.diameter / 2.0 - RING_INSET; + let visuals = ui.visuals(); + + let track_col = visuals.weak_text_color().gamma_multiply(0.5); + let accent = if response.hovered() || response.dragged() { + self.accent + } else { + self.accent.gamma_multiply(0.85) + }; + + // Background track across the full sweep. + painter.add(egui::Shape::line( + arc_points(center, radius, -HALF_SWEEP, HALF_SWEEP), + Stroke::new(3.0, track_col), + )); + + // Value arc. + let cur = self.angle_of(*self.value); + let from = match self.arc { + KnobArc::Unipolar => -HALF_SWEEP, + KnobArc::Bipolar { center: c } => self.angle_of(c), + }; + if (cur - from).abs() > 1e-4 { + let (a, b) = if from <= cur { + (from, cur) + } else { + (cur, from) + }; + painter.add(egui::Shape::line( + arc_points(center, radius, a, b), + Stroke::new(3.0, accent), + )); + } + + // Body. + painter.circle( + center, + radius - 3.5, + visuals.extreme_bg_color, + Stroke::new(1.0, visuals.widgets.inactive.bg_stroke.color), + ); + + // Pointer from just inside the body to the rim. + let d = dir(cur); + painter.line_segment( + [center + d * (radius * 0.35), center + d * (radius - 2.0)], + Stroke::new(2.0, accent), + ); + } + + response + } +} + +/// Polyline approximating the arc from `a0` to `a1` (radians) at `radius`. +fn arc_points(center: egui::Pos2, radius: f32, a0: f32, a1: f32) -> Vec { + // ~one segment per 6° keeps the curve smooth at these sizes. + let steps = (((a1 - a0).abs() / (PI / 30.0)).ceil() as usize).max(1); + (0..=steps) + .map(|i| { + let a = a0 + (a1 - a0) * (i as f32 / steps as f32); + center + dir(a) * radius + }) + .collect() +} diff --git a/crates/halo/src/library.rs b/crates/halo/src/library.rs new file mode 100644 index 0000000..7699ff6 --- /dev/null +++ b/crates/halo/src/library.rs @@ -0,0 +1,708 @@ +//! SQLite track library: imported tracks with tag metadata, the playlist +//! tree, and the analysis cache (one `PreAnalysisArtifact` per track, stored +//! at the file's native sample rate and rescaled per device on load). +//! +//! `rusqlite::Connection` is not `Sync`, so each thread that touches the DB +//! (UI, analysis worker, folder importer) opens its own `Library`. + +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use rusqlite::{Connection, OptionalExtension, params}; +use timestretch::PreAnalysisArtifact; + +/// File extensions Halo can decode (must stay in sync with the symphonia +/// features in Cargo.toml). +pub const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "ogg", "wav"]; + +#[derive(Debug, Clone)] +pub struct TrackRow { + pub id: i64, + pub path: PathBuf, + pub title: String, + pub artist: Option, + pub album: Option, + pub key: Option, + pub duration_secs: Option, + pub bpm: Option, +} + +#[derive(Debug, Clone)] +pub struct PlaylistRow { + pub id: i64, + pub name: String, + pub parent_id: Option, + pub is_folder: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum SortColumn { + Title, + Artist, + Album, + Bpm, + Key, + Duration, +} + +impl SortColumn { + fn sql(self) -> &'static str { + match self { + SortColumn::Title => "title COLLATE NOCASE", + SortColumn::Artist => "artist COLLATE NOCASE", + SortColumn::Album => "album COLLATE NOCASE", + SortColumn::Bpm => "bpm", + SortColumn::Key => "key COLLATE NOCASE", + SortColumn::Duration => "duration_secs", + } + } +} + +/// Tag metadata for one file, ready to insert. +#[derive(Debug, Clone, Default)] +pub struct TrackMeta { + pub title: Option, + pub artist: Option, + pub album: Option, + pub key: Option, + pub duration_secs: Option, +} + +pub struct Library { + conn: Connection, +} + +impl Library { + /// Default database location; `HALO_DB` overrides it (dev/test hook). + pub fn default_path() -> PathBuf { + if let Some(p) = std::env::var_os("HALO_DB") { + return PathBuf::from(p); + } + dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("Halo") + .join("halo.db") + } + + pub fn open(path: &Path) -> Result { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("create {dir:?}: {e}"))?; + } + let conn = Connection::open(path).map_err(|e| format!("open {path:?}: {e}"))?; + conn.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS tracks ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + artist TEXT, + album TEXT, + key TEXT, + duration_secs REAL, + bpm REAL, + native_sample_rate INTEGER, + analysis_json TEXT, + added_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS playlists ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + parent_id INTEGER REFERENCES playlists(id) ON DELETE CASCADE, + is_folder INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS playlist_tracks ( + playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + PRIMARY KEY (playlist_id, track_id) + ); + CREATE INDEX IF NOT EXISTS idx_tracks_bpm ON tracks(bpm); + CREATE TABLE IF NOT EXISTS lighting_cues ( + track_id INTEGER PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + cues_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + );", + ) + .map_err(|e| format!("schema: {e}"))?; + Ok(Self { conn }) + } + + /// Insert (or find) a track. Existing rows keep their analysis; tags are + /// refreshed. Returns the track id. + pub fn upsert_track(&self, path: &Path, meta: &TrackMeta) -> Result { + let title = meta.title.clone().unwrap_or_else(|| { + path.file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) + }); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + self.conn + .execute( + "INSERT INTO tracks (path, title, artist, album, key, duration_secs, added_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(path) DO UPDATE SET + title = excluded.title, + artist = excluded.artist, + album = excluded.album, + key = excluded.key, + duration_secs = COALESCE(excluded.duration_secs, tracks.duration_secs)", + params![ + path.to_string_lossy(), + title, + meta.artist, + meta.album, + meta.key, + meta.duration_secs, + now + ], + ) + .map_err(|e| format!("upsert track: {e}"))?; + self.conn + .query_row( + "SELECT id FROM tracks WHERE path = ?1", + params![path.to_string_lossy()], + |r| r.get(0), + ) + .map_err(|e| format!("track id: {e}")) + } + + /// Import one audio file: read tags, upsert, and adopt a Phase-2 sidecar + /// as the analysis if the track has none yet (one-time migration; no new + /// sidecars are ever written). + pub fn import_file(&self, path: &Path) -> Result { + let id = self.upsert_track(path, &read_meta(path))?; + if self.analysis_json(id)?.is_none() { + let sidecar = sidecar_path(path); + if sidecar.exists() + && let Ok(artifact) = timestretch::read_preanalysis_json(&sidecar) + { + log::info!("Importing sidecar {}", sidecar.display()); + self.store_analysis(id, &artifact)?; + } + } + Ok(id) + } + + /// Recursively import a folder. Returns the number of audio files seen. + pub fn import_folder(&self, dir: &Path) -> Result { + let mut count = 0; + let entries = std::fs::read_dir(dir).map_err(|e| format!("read {dir:?}: {e}"))?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + count += self.import_folder(&path).unwrap_or(0); + } else if path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| AUDIO_EXTENSIONS.contains(&e.to_lowercase().as_str())) + { + match self.import_file(&path) { + Ok(_) => count += 1, + Err(e) => log::warn!("import {}: {e}", path.display()), + } + } + } + Ok(count) + } + + pub fn store_analysis( + &self, + track_id: i64, + artifact: &PreAnalysisArtifact, + ) -> Result<(), String> { + let json = serde_json::to_string(artifact).map_err(|e| format!("serialize: {e}"))?; + self.conn + .execute( + "UPDATE tracks SET analysis_json = ?1, bpm = ?2, native_sample_rate = ?3 + WHERE id = ?4", + params![json, artifact.bpm, artifact.sample_rate, track_id], + ) + .map_err(|e| format!("store analysis: {e}"))?; + Ok(()) + } + + fn analysis_json(&self, track_id: i64) -> Result, String> { + self.conn + .query_row( + "SELECT analysis_json FROM tracks WHERE id = ?1", + params![track_id], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("analysis: {e}")) + .map(Option::flatten) + } + + /// The stored artifact at its native rate, if analyzed. An empty string + /// is the "analysis failed" marker and reads as `None`. + pub fn analysis(&self, track_id: i64) -> Result, String> { + match self.analysis_json(track_id)? { + Some(json) if !json.is_empty() => serde_json::from_str(&json) + .map(Some) + .map_err(|e| format!("parse analysis: {e}")), + _ => Ok(None), + } + } + + /// Persist a track's lighting cues (JSON, times in seconds). + pub fn store_cues( + &self, + track_id: i64, + file: &halo_light::cues::CueFile, + ) -> Result<(), String> { + let json = serde_json::to_string(file).map_err(|e| format!("serialize cues: {e}"))?; + self.conn + .execute( + "INSERT INTO lighting_cues (track_id, cues_json) VALUES (?1, ?2) + ON CONFLICT(track_id) DO UPDATE SET cues_json = excluded.cues_json", + params![track_id, json], + ) + .map_err(|e| format!("store cues: {e}"))?; + Ok(()) + } + + /// The stored lighting cues for a track, if any were authored. + pub fn cues(&self, track_id: i64) -> Result, String> { + let json: Option = self + .conn + .query_row( + "SELECT cues_json FROM lighting_cues WHERE track_id = ?1", + params![track_id], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("cues: {e}"))?; + match json { + Some(json) => serde_json::from_str(&json) + .map(Some) + .map_err(|e| format!("parse cues: {e}")), + None => Ok(None), + } + } + + /// App-level setting (rig patch, Art-Net config, …), stored as JSON. + pub fn setting(&self, key: &str) -> Result, String> { + self.conn + .query_row( + "SELECT value FROM settings WHERE key = ?1", + params![key], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("setting {key}: {e}")) + } + + pub fn store_setting(&self, key: &str, value: &str) -> Result<(), String> { + self.conn + .execute( + "INSERT INTO settings (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, value], + ) + .map_err(|e| format!("store setting {key}: {e}"))?; + Ok(()) + } + + /// Mark a track as failed analysis (empty JSON) so the queue moves on + /// instead of retrying an undecodable file forever. + pub fn store_analysis_failure(&self, track_id: i64) -> Result<(), String> { + self.conn + .execute( + "UPDATE tracks SET analysis_json = '' WHERE id = ?1", + params![track_id], + ) + .map_err(|e| format!("store failure: {e}"))?; + Ok(()) + } + + /// Tracks still waiting for analysis (for the status readout). + pub fn unanalyzed_count(&self) -> Result { + self.conn + .query_row( + "SELECT COUNT(*) FROM tracks WHERE analysis_json IS NULL", + [], + |r| r.get(0), + ) + .map_err(|e| format!("count: {e}")) + } + + /// Next track without analysis, oldest first. + pub fn next_unanalyzed(&self) -> Result, String> { + self.conn + .query_row( + "SELECT id, path FROM tracks WHERE analysis_json IS NULL + ORDER BY added_at, id LIMIT 1", + [], + |r| Ok((r.get::<_, i64>(0)?, PathBuf::from(r.get::<_, String>(1)?))), + ) + .optional() + .map_err(|e| format!("next unanalyzed: {e}")) + } + + pub fn track(&self, track_id: i64) -> Result, String> { + self.conn + .query_row( + "SELECT id, path, title, artist, album, key, duration_secs, bpm + FROM tracks WHERE id = ?1", + params![track_id], + row_to_track, + ) + .optional() + .map_err(|e| format!("track: {e}")) + } + + /// Tracks matching a search filter, optionally restricted to a playlist, + /// sorted by `sort`. + pub fn tracks( + &self, + playlist: Option, + search: &str, + sort: SortColumn, + ascending: bool, + ) -> Result, String> { + let dir = if ascending { "ASC" } else { "DESC" }; + let base = "SELECT t.id, t.path, t.title, t.artist, t.album, t.key, + t.duration_secs, t.bpm FROM tracks t"; + let (join, where_pl) = match playlist { + Some(_) => ( + " JOIN playlist_tracks pt ON pt.track_id = t.id", + " AND pt.playlist_id = ?2", + ), + None => ("", ""), + }; + let sql = format!( + "{base}{join} WHERE (t.title LIKE ?1 OR t.artist LIKE ?1 OR t.album LIKE ?1){where_pl} + ORDER BY {} {dir} NULLS LAST, t.title COLLATE NOCASE ASC", + sort.sql() + ); + let pattern = format!("%{search}%"); + let mut stmt = self.conn.prepare(&sql).map_err(|e| format!("query: {e}"))?; + let rows = match playlist { + Some(pl) => stmt + .query_map(params![pattern, pl], row_to_track) + .map_err(|e| format!("query: {e}"))? + .collect::, _>>(), + None => stmt + .query_map(params![pattern], row_to_track) + .map_err(|e| format!("query: {e}"))? + .collect::, _>>(), + }; + rows.map_err(|e| format!("rows: {e}")) + } + + // ---- Playlists ---- + + pub fn playlists(&self) -> Result, String> { + let mut stmt = self + .conn + .prepare( + "SELECT id, name, parent_id, is_folder FROM playlists ORDER BY name COLLATE NOCASE", + ) + .map_err(|e| format!("playlists: {e}"))?; + stmt.query_map([], |r| { + Ok(PlaylistRow { + id: r.get(0)?, + name: r.get(1)?, + parent_id: r.get(2)?, + is_folder: r.get::<_, i64>(3)? != 0, + }) + }) + .map_err(|e| format!("playlists: {e}"))? + .collect::, _>>() + .map_err(|e| format!("playlists: {e}")) + } + + pub fn create_playlist( + &self, + name: &str, + parent: Option, + is_folder: bool, + ) -> Result { + self.conn + .execute( + "INSERT INTO playlists (name, parent_id, is_folder) VALUES (?1, ?2, ?3)", + params![name, parent, is_folder as i64], + ) + .map_err(|e| format!("create playlist: {e}"))?; + Ok(self.conn.last_insert_rowid()) + } + + pub fn rename_playlist(&self, id: i64, name: &str) -> Result<(), String> { + self.conn + .execute( + "UPDATE playlists SET name = ?1 WHERE id = ?2", + params![name, id], + ) + .map_err(|e| format!("rename playlist: {e}"))?; + Ok(()) + } + + pub fn delete_playlist(&self, id: i64) -> Result<(), String> { + self.conn + .execute("DELETE FROM playlists WHERE id = ?1", params![id]) + .map_err(|e| format!("delete playlist: {e}"))?; + Ok(()) + } + + pub fn add_to_playlist(&self, playlist: i64, track: i64) -> Result<(), String> { + self.conn + .execute( + "INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) + VALUES (?1, ?2, + (SELECT COALESCE(MAX(position), 0) + 1 FROM playlist_tracks + WHERE playlist_id = ?1))", + params![playlist, track], + ) + .map_err(|e| format!("add to playlist: {e}"))?; + Ok(()) + } + + pub fn remove_from_playlist(&self, playlist: i64, track: i64) -> Result<(), String> { + self.conn + .execute( + "DELETE FROM playlist_tracks WHERE playlist_id = ?1 AND track_id = ?2", + params![playlist, track], + ) + .map_err(|e| format!("remove from playlist: {e}"))?; + Ok(()) + } +} + +fn row_to_track(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(TrackRow { + id: r.get(0)?, + path: PathBuf::from(r.get::<_, String>(1)?), + title: r.get(2)?, + artist: r.get(3)?, + album: r.get(4)?, + key: r.get(5)?, + duration_secs: r.get(6)?, + bpm: r.get(7)?, + }) +} + +/// Best-effort tag read for import (no audio decode; duration comes from +/// the container properties). +pub fn read_meta(path: &Path) -> TrackMeta { + use lofty::prelude::*; + + let Ok(tagged) = lofty::probe::Probe::open(path).and_then(|p| p.read()) else { + return TrackMeta::default(); + }; + let duration_secs = Some(tagged.properties().duration().as_secs_f64()); + let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) else { + return TrackMeta { + duration_secs, + ..Default::default() + }; + }; + TrackMeta { + title: tag.title().map(|s| s.into_owned()), + artist: tag.artist().map(|s| s.into_owned()), + album: tag.album().map(|s| s.into_owned()), + key: tag + .get_string(&lofty::tag::ItemKey::InitialKey) + .map(|s| s.to_string()), + duration_secs, + } +} + +/// Phase-2 sidecar path for a track (read-only legacy cache). +fn sidecar_path(audio_path: &Path) -> PathBuf { + let mut os = audio_path.as_os_str().to_os_string(); + os.push(".halo.tsanalysis.json"); + PathBuf::from(os) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mem_library() -> Library { + let conn = Connection::open_in_memory().unwrap(); + // Reuse the schema by round-tripping through open(): not possible + // in-memory via path, so replicate minimally. + let lib = Library { conn }; + lib.conn + .execute_batch( + "PRAGMA foreign_keys = ON; + CREATE TABLE tracks ( + id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, artist TEXT, album TEXT, key TEXT, + duration_secs REAL, bpm REAL, native_sample_rate INTEGER, + analysis_json TEXT, added_at INTEGER NOT NULL); + CREATE TABLE playlists ( + id INTEGER PRIMARY KEY, name TEXT NOT NULL, + parent_id INTEGER REFERENCES playlists(id) ON DELETE CASCADE, + is_folder INTEGER NOT NULL DEFAULT 0); + CREATE TABLE playlist_tracks ( + playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + PRIMARY KEY (playlist_id, track_id)); + CREATE TABLE lighting_cues ( + track_id INTEGER PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + cues_json TEXT NOT NULL); + CREATE TABLE settings ( + key TEXT PRIMARY KEY, value TEXT NOT NULL);", + ) + .unwrap(); + lib + } + + fn meta(title: &str, artist: &str, bpm: Option) -> (TrackMeta, Option) { + ( + TrackMeta { + title: Some(title.into()), + artist: Some(artist.into()), + ..Default::default() + }, + bpm, + ) + } + + fn insert(lib: &Library, path: &str, title: &str, artist: &str, bpm: Option) -> i64 { + let (m, bpm) = meta(title, artist, bpm); + let id = lib.upsert_track(Path::new(path), &m).unwrap(); + if let Some(bpm) = bpm { + lib.conn + .execute("UPDATE tracks SET bpm = ?1 WHERE id = ?2", params![bpm, id]) + .unwrap(); + } + id + } + + #[test] + fn upsert_is_idempotent_and_refreshes_tags() { + let lib = mem_library(); + let a = insert(&lib, "/x/a.mp3", "One", "AA", None); + let (m2, _) = meta("One (Remix)", "AA", None); + let b = lib.upsert_track(Path::new("/x/a.mp3"), &m2).unwrap(); + assert_eq!(a, b); + let rows = lib.tracks(None, "", SortColumn::Title, true).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].title, "One (Remix)"); + } + + #[test] + fn cues_round_trip() { + let lib = mem_library(); + let id = insert(&lib, "/x/a.mp3", "One", "AA", None); + assert!(lib.cues(id).unwrap().is_none()); + + let mut set = halo_light::cues::CueSet::empty(); + set.insert(halo_light::cues::Lane::Lighting, 44_100.0, 88_200.0, 0.9); + lib.store_cues(id, &set.to_file(44_100)).unwrap(); + let file = lib.cues(id).unwrap().unwrap(); + assert_eq!(file.lanes[0].len(), 1); + assert!((file.lanes[0][0].start - 1.0).abs() < 1e-9); + + // Upsert replaces, not duplicates. + lib.store_cues(id, &halo_light::cues::CueSet::empty().to_file(44_100)) + .unwrap(); + assert!(lib.cues(id).unwrap().unwrap().lanes[0].is_empty()); + } + + #[test] + fn settings_round_trip_and_upsert() { + let lib = mem_library(); + assert!(lib.setting("rig_patch").unwrap().is_none()); + lib.store_setting("rig_patch", "{\"version\":1}").unwrap(); + assert_eq!( + lib.setting("rig_patch").unwrap().as_deref(), + Some("{\"version\":1}") + ); + lib.store_setting("rig_patch", "{\"version\":2}").unwrap(); + assert_eq!( + lib.setting("rig_patch").unwrap().as_deref(), + Some("{\"version\":2}") + ); + } + + #[test] + fn search_and_sort() { + let lib = mem_library(); + insert(&lib, "/x/a.mp3", "Alpha", "Zed", Some(140.0)); + insert(&lib, "/x/b.mp3", "Beta", "Ann", Some(120.0)); + insert(&lib, "/x/c.mp3", "Gamma", "Mid", None); + + let by_bpm = lib.tracks(None, "", SortColumn::Bpm, true).unwrap(); + assert_eq!(by_bpm[0].title, "Beta"); + assert_eq!(by_bpm[1].title, "Alpha"); + // NULL BPM sorts last. + assert_eq!(by_bpm[2].title, "Gamma"); + + let found = lib.tracks(None, "ann", SortColumn::Title, true).unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].title, "Beta"); + } + + #[test] + fn playlists_filter_tracks() { + let lib = mem_library(); + let t1 = insert(&lib, "/x/a.mp3", "Alpha", "A", None); + let t2 = insert(&lib, "/x/b.mp3", "Beta", "B", None); + let pl = lib.create_playlist("Set", None, false).unwrap(); + lib.add_to_playlist(pl, t2).unwrap(); + + let in_pl = lib.tracks(Some(pl), "", SortColumn::Title, true).unwrap(); + assert_eq!(in_pl.len(), 1); + assert_eq!(in_pl[0].id, t2); + + lib.remove_from_playlist(pl, t2).unwrap(); + assert!( + lib.tracks(Some(pl), "", SortColumn::Title, true) + .unwrap() + .is_empty() + ); + let _ = t1; + } + + #[test] + fn analysis_round_trips_and_queue_drains() { + let lib = mem_library(); + let id = insert(&lib, "/x/a.mp3", "Alpha", "A", None); + assert_eq!(lib.next_unanalyzed().unwrap().unwrap().0, id); + + let artifact = PreAnalysisArtifact { + version: 4, + sample_rate: 44_100, + bpm: 128.0, + confidence: 0.9, + beat_positions: vec![0, 22_050], + ..Default::default() + }; + lib.store_analysis(id, &artifact).unwrap(); + assert!(lib.next_unanalyzed().unwrap().is_none()); + + let loaded = lib.analysis(id).unwrap().unwrap(); + assert_eq!(loaded.bpm, 128.0); + assert_eq!(loaded.beat_positions, vec![0, 22_050]); + // BPM column filled for the browser. + assert_eq!(lib.track(id).unwrap().unwrap().bpm, Some(128.0)); + } + + #[test] + fn playlist_tree_and_rename() { + let lib = mem_library(); + let folder = lib.create_playlist("House", None, true).unwrap(); + let pl = lib + .create_playlist("Peak Time", Some(folder), false) + .unwrap(); + lib.rename_playlist(pl, "Warmup").unwrap(); + let all = lib.playlists().unwrap(); + assert_eq!(all.len(), 2); + let renamed = all.iter().find(|p| p.id == pl).unwrap(); + assert_eq!(renamed.name, "Warmup"); + assert_eq!(renamed.parent_id, Some(folder)); + lib.delete_playlist(folder).unwrap(); + assert!(lib.playlists().unwrap().is_empty(), "cascade delete"); + } +} diff --git a/crates/halo/src/main.rs b/crates/halo/src/main.rs index 4e8fa84..996724d 100644 --- a/crates/halo/src/main.rs +++ b/crates/halo/src/main.rs @@ -1,339 +1,37 @@ -use std::collections::HashMap; -use std::net::{IpAddr, SocketAddr}; -use std::time::Duration; - -use anyhow::Result; -use clap::Parser; -use halo_core::{ - ArtNetDestination, ArtNetMode, ConfigManager, ConsoleCommand, ConsoleEvent, LightingConsole, - NetworkConfig, Settings, -}; -use tokio::sync::mpsc; - -/// Lighting Console for live performances with precise automation and control. -#[derive(Parser, Debug)] -#[command(name = "halo")] -#[command(about = "Halo lighting console")] -struct Args { - /// Art-Net Source IP address - #[arg(long, value_parser = parse_ip)] - source_ip: IpAddr, - - /// Art-Net Destination IP address (optional - if not provided, broadcast mode will be used) - /// This is for backward compatibility - use --lighting-dest-ip and --pixel-dest-ip for - /// multi-destination setup - #[arg(long, value_parser = parse_ip)] - dest_ip: Option, - - /// Lighting fixtures destination IP (for Enttec Ode MK2, etc.) - #[arg(long, value_parser = parse_ip)] - lighting_dest_ip: Option, - - /// Pixel fixtures destination IP (for Enttec Octo MK2, etc.) - #[arg(long, value_parser = parse_ip)] - pixel_dest_ip: Option, - - /// Universe for lighting fixtures (default: 1) - #[arg(long, default_value = "1")] - lighting_universe: u8, - - /// Starting universe for pixel fixtures (default: 2) - #[arg(long, default_value = "2")] - pixel_start_universe: u8, - - /// Art-Net port (default: 6454) - #[arg(long, default_value = "6454")] - artnet_port: u16, - - /// Force broadcast mode even if destination IP is provided - #[arg(long, default_value = "false")] - broadcast: bool, - - /// Whether to enable MIDI support - #[arg(short, long)] - enable_midi: bool, - - /// Path to the show JSON file - #[arg(long)] - show_file: Option, -} - -fn parse_ip(s: &str) -> Result { - s.parse().map_err(|e| format!("Invalid IP address: {}", e)) -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let args = Args::parse(); - - // Load configuration before initializing anything else - println!("Loading configuration..."); - let mut config_manager = ConfigManager::new(None); - let settings = match config_manager.load() { - Ok(settings) => { - println!( - "Configuration loaded successfully from: {:?}", - config_manager.config_path() - ); - settings - } - Err(e) => { - println!( - "Warning: Failed to load configuration: {}. Using defaults.", - e - ); - Settings::default() - } +mod app; +mod audio; +mod deck; +mod decoder; +mod dmx; +mod dsp; +mod fader; +mod knob; +mod library; +mod programmer_ui; +mod scrub; +mod show; +mod state; +mod waveform; +mod worker; + +fn main() -> eframe::Result<()> { + env_logger::init(); + + // Optional audio file to load on startup (skips the file dialog). + let initial_file = std::env::args().nth(1).map(std::path::PathBuf::from); + + let options = eframe::NativeOptions { + viewport: eframe::egui::ViewportBuilder::default() + .with_inner_size([1200.0, 700.0]) + .with_min_inner_size([900.0, 550.0]) + .with_maximized(true) + .with_title("Halo"), + ..Default::default() }; - // Apply CLI overrides to settings if provided - let network_config = if args.lighting_dest_ip.is_some() || args.pixel_dest_ip.is_some() { - // Multi-destination setup - let mut destinations = Vec::new(); - let mut universe_routing = HashMap::new(); - - // Add lighting destination if specified - if let Some(lighting_ip) = args.lighting_dest_ip { - let lighting_dest = ArtNetDestination { - name: "lighting".to_string(), - mode: if args.broadcast { - ArtNetMode::Broadcast - } else { - ArtNetMode::Unicast( - SocketAddr::new(args.source_ip, args.artnet_port), - SocketAddr::new(lighting_ip, args.artnet_port), - ) - }, - }; - let lighting_index = destinations.len(); - destinations.push(lighting_dest); - universe_routing.insert(args.lighting_universe, lighting_index); - - println!( - "Lighting destination: {}:{} -> {}:{} (Universe {})", - args.source_ip, - args.artnet_port, - lighting_ip, - args.artnet_port, - args.lighting_universe - ); - } - - // Add pixel destination if specified - if let Some(pixel_ip) = args.pixel_dest_ip { - let pixel_dest = ArtNetDestination { - name: "pixel".to_string(), - mode: if args.broadcast { - ArtNetMode::Broadcast - } else { - ArtNetMode::Unicast( - SocketAddr::new(args.source_ip, args.artnet_port), - SocketAddr::new(pixel_ip, args.artnet_port), - ) - }, - }; - let pixel_index = destinations.len(); - destinations.push(pixel_dest); - - // Route pixel universes starting from pixel_start_universe (typically 2, 3, 4, etc.) - for universe in args.pixel_start_universe..=16 { - // Support up to universe 16 for pixels - universe_routing.insert(universe, pixel_index); - } - - println!( - "Pixel destination: {}:{} -> {}:{} (Universes {} and up)", - args.source_ip, - args.artnet_port, - pixel_ip, - args.artnet_port, - args.pixel_start_universe - ); - } - - if destinations.is_empty() { - // Fallback to single destination if no multi-destination args provided - NetworkConfig::new( - args.source_ip, - args.dest_ip, - args.artnet_port, - args.broadcast, - ) - } else { - NetworkConfig::new_multi_destination(destinations, universe_routing, args.artnet_port) - } - } else { - // Legacy single destination setup - NetworkConfig::new( - args.source_ip, - args.dest_ip, - args.artnet_port, - args.broadcast, - ) - }; - - println!("Configuring Halo with Art-Net settings:"); - // println!("Source IP: {}", network_config.source_ip); - println!("Mode: {}", network_config.get_mode_string()); - println!("Destination: {}", network_config.get_destination()); - println!("Port: {}", network_config.port); - - // Create channels for communication - let (command_tx, command_rx) = mpsc::unbounded_channel::(); - let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); - - // Convert tokio receiver to std receiver for UI - let (ui_event_tx, ui_event_rx) = std::sync::mpsc::channel::(); - - // Spawn a task to forward events from tokio to std channel - let event_forwarder = tokio::spawn(async move { - while let Some(event) = event_rx.recv().await { - if let Err(e) = ui_event_tx.send(event) { - log::error!("Failed to forward event to UI: {}", e); - break; - } - } - log::info!("Event forwarder task completed"); - }); - - // Create the async console with loaded settings - let console = - LightingConsole::new_with_settings(80., network_config.clone(), settings.clone()).unwrap(); - - // // Blue Strobe Fast - // console.add_midi_override( - // 76, - // MidiOverride { - // action: MidiAction::StaticValues(static_values![ - // ("Smoke #1", "Blue", 255), - // ("Smoke #1", "Strobe", 255), - // ]), - // }, - // ); - - // // Red Strobe Medium w/Half Smoke - // console.add_midi_override( - // 77, - // MidiOverride { - // action: MidiAction::StaticValues(static_values![ - // ("Smoke #1", "Smoke", 100), - // ("Smoke #1", "Red", 255), - // ("Smoke #1", "Strobe", 220), - // ]), - // }, - // ); - - // // Blue Strobe Fast w/Full Smoke - // console.add_midi_override( - // 78, - // MidiOverride { - // action: MidiAction::StaticValues(static_values![ - // ("Smoke #1", "Smoke", 255), - // ("Smoke #1", "Blue", 255), - // ("Smoke #1", "Strobe", 255), - // ]), - // }, - // ); - - // // Full Smoke - // console.add_midi_override( - // 71, - // MidiOverride { - // action: MidiAction::StaticValues(static_values![("Smoke #1", "Smoke", 255),]), - // }, - // ); - - //// Cue Overrides - - println!("Starting lighting console..."); - println!("MIDI support: {}", args.enable_midi); - println!("Show file: {:?}", args.show_file); - - // Create a command sender for the initialization task - let init_command_tx = command_tx.clone(); - - // Spawn the console task with channel communication - let console_task = tokio::spawn(async move { - // Run the console with channels - if let Err(e) = console.run_with_channels(command_rx, event_tx).await { - println!("Console error: {}", e); - } - }); - - // Store the show file path for later loading after UI starts - let show_file_path = args.show_file.clone(); - - // Spawn an initialization task to send all the setup commands - let init_task = tokio::spawn(async move { - println!("Starting initialization task..."); - - // Send initialization commands - println!("Sending Initialize command..."); - init_command_tx - .send(ConsoleCommand::Initialize) - .map_err(|e| anyhow::anyhow!("Failed to send Initialize command: {}", e))?; - - // Allow time for initialization - println!("Waiting for initialization..."); - tokio::time::sleep(Duration::from_millis(100)).await; - - println!("Initialization task completed successfully"); - anyhow::Ok(()) - }); - - // Wait for initialization to complete - log::info!("Waiting for initialization to complete..."); - let init_result = init_task.await; - if let Err(e) = init_result { - log::error!("Initialization task join error: {}", e); - return Err(anyhow::anyhow!("Initialization task failed: {}", e)); - } - if let Err(e) = init_result.unwrap() { - log::error!("Initialization task error: {}", e); - return Err(e); - } - log::info!("Initialization completed successfully"); - - // Run the UI with the channels (this will block until UI closes) - log::info!("Starting UI..."); - let show_path = show_file_path.map(std::path::PathBuf::from); - let ui_result = halo_ui::run_ui(command_tx.clone(), ui_event_rx, show_path, config_manager); - log::info!("UI completed"); - - // Send shutdown command - log::info!("Sending shutdown command..."); - command_tx - .send(ConsoleCommand::Shutdown) - .map_err(|e| anyhow::anyhow!("Failed to send Shutdown command: {}", e))?; - - // Wait for console task to finish - log::info!("Waiting for console task to finish..."); - let _ = console_task.await; - - // Wait for event forwarder task to finish - log::info!("Waiting for event forwarder task to finish..."); - let _ = event_forwarder.await; - - // Check UI result - if let Err(e) = ui_result { - log::error!("UI error: {}", e); - } - - log::info!("Application shutting down"); - anyhow::Ok(()) -} - -#[macro_export] -macro_rules! static_values { - ($(($fixture:expr, $channel:expr, $value:expr)),* $(,)?) => { - vec![ - $( - StaticValue { - fixture_id: $fixture, - channel_type: $channel, - value: $value, - }, - )* - ] - }; + eframe::run_native( + "Halo", + options, + Box::new(|cc| Ok(Box::new(app::HaloApp::new(cc, initial_file)))), + ) } diff --git a/crates/halo/src/mod.rs b/crates/halo/src/mod.rs deleted file mode 100644 index 13a50c5..0000000 --- a/crates/halo/src/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -mod effects; -mod fixtures; -mod ui; - -mod ableton_link; -mod artnet; -mod console; -mod cue; -mod effect; -mod fixture; -mod midi; -mod rhythm; -mod ui; -mod visualization; diff --git a/crates/halo/src/programmer_ui.rs b/crates/halo/src/programmer_ui.rs new file mode 100644 index 0000000..f3857bb --- /dev/null +++ b/crates/halo/src/programmer_ui.rs @@ -0,0 +1,845 @@ +//! The programmer surface in the footer: fixture grid + lane overrides on +//! the left, the parameter views (Intensity / Color / Position / Beam / +//! Pixel FX) with their beat-synced effect panels in the middle, and the +//! CLEAR / STORE / PREVIEW / HIGHLIGHT action column on the right. +//! +//! Everything here edits real state, but values and effects are +//! visual-only until the fixture engine consumes them. + +use std::collections::HashSet; + +use eframe::egui; +use halo_light::cues::LANE_COUNT; +use halo_light::fixture::{ALL_KINDS, FixtureKind, Rig}; +use halo_light::programmer::{ + self, ALL_INTERVALS, ALL_VIEWS, ALL_WAVEFORMS, COLOR_PRESETS, Distribution, EffectConfig, + LaneOutput, LaneSource, PIXEL_EFFECTS, PanTiltTarget, ParamView, Programmer, ProgrammerParams, + effect_value, +}; + +use crate::fader::{Fader, Notches}; +use crate::knob::{Knob, KnobArc}; + +/// Everything the programmer panel reads and writes. +pub struct ProgrammerCtx<'a> { + pub rig: &'a Rig, + pub selection: &'a mut HashSet, + pub overrides: &'a mut Programmer, + pub params: &'a mut ProgrammerParams, + pub outputs: &'a [LaneOutput; LANE_COUNT], + pub can_store: bool, + pub deck_name: &'a str, + /// Musical time in beats (beat index + intra-beat phase) driving the + /// effect previews. + pub beat_t: f64, +} + +const ACTION_COL_W: f32 = 96.0; +const FADER_W: f32 = 20.0; +/// Fixed width of the parameter column so the effects panel never shifts +/// when the view changes (sized for the widest view: Color's four faders +/// plus the swatch column). +const PARAMS_W: f32 = 300.0; + +/// Returns true when STORE was pressed. +pub fn programmer_panel(ui: &mut egui::Ui, cx: &mut ProgrammerCtx<'_>) -> bool { + let mut store = false; + ui.horizontal_top(|ui| { + // Left: group selects, rig grid, lane override row — the groups + // row and the view tabs sit on the same top line. + ui.vertical(|ui| { + groups_row(ui, cx.rig, cx.selection); + ui.add_space(6.0); + fixture_grid(ui, cx.rig, cx.selection, cx.outputs); + ui.add_space(6.0); + ui.horizontal(|ui| { + for (i, o) in cx.overrides.iter_mut().enumerate() { + lane_controls(ui, o, i); + ui.add_space(4.0); + } + }); + }); + ui.separator(); + + // Middle: view tabs + the active view + its effect panel. + let center_w = (ui.available_width() - ACTION_COL_W - 20.0).max(300.0); + ui.vertical(|ui| { + ui.set_width(center_w); + ui.horizontal(|ui| { + for (view, label) in ALL_VIEWS { + if ui + .selectable_label( + cx.params.view == view, + egui::RichText::new(label).size(10.0), + ) + .clicked() + { + cx.params.view = view; + } + } + }); + ui.add_space(6.0); + ui.horizontal_top(|ui| { + let beat_t = cx.beat_t; + // Fixed-width parameter column: the effects panel to its + // right stays put across view changes. The pad after it is + // measured, so even a view that overflows the nominal + // width can't push the panel around between frames. + let params_left = ui.cursor().left(); + ui.vertical(|ui| { + ui.set_width(PARAMS_W); + ui.horizontal_top(|ui| match cx.params.view { + ParamView::Intensity => intensity_view(ui, &mut cx.params.intensity), + ParamView::Color => color_view(ui, &mut cx.params.color), + ParamView::Position => position_view(ui, &mut cx.params.position), + ParamView::Beam => beam_view(ui, &mut cx.params.beam), + // Pixel effects are pre-baked patterns; the + // standard effects panel doesn't apply. + ParamView::PixelFx => pixel_view(ui, &mut cx.params.pixel), + }); + }); + let pad = params_left + PARAMS_W + 10.0 - ui.cursor().left(); + if pad > 0.0 { + ui.add_space(pad); + } + let effect = match cx.params.view { + ParamView::Intensity => Some(( + &mut cx.params.intensity.effect, + crate::waveform::palette::LANE_LIGHTING, + )), + ParamView::Color => Some(( + &mut cx.params.color.effect, + egui::Color32::from_rgb(240, 200, 90), + )), + ParamView::Position => Some(( + &mut cx.params.position.effect, + egui::Color32::from_rgb(140, 120, 255), + )), + ParamView::Beam => Some(( + &mut cx.params.beam.effect, + egui::Color32::from_rgb(225, 235, 250), + )), + ParamView::PixelFx => None, + }; + if let Some((cfg, accent)) = effect { + effect_panel(ui, cfg, accent, beat_t); + } + }); + }); + ui.separator(); + + // Right: stacked actions. + store = action_column(ui, cx); + }); + store +} + +/// Group-select row: click selects exactly that kind's fixtures, +/// shift-click unions, ALL selects everything. +fn groups_row(ui: &mut egui::Ui, rig: &Rig, selection: &mut HashSet) { + let shift = ui.input(|i| i.modifiers.shift); + ui.horizontal(|ui| { + let all: HashSet = rig.ids().collect(); + let all_selected = !all.is_empty() && selection.len() == all.len(); + if ui + .selectable_label(all_selected, egui::RichText::new("ALL").size(10.0)) + .clicked() + { + *selection = all; + } + for kind in ALL_KINDS { + let ids: HashSet = rig.ids_of_kind(kind).collect(); + let active = !ids.is_empty() && ids.is_subset(selection); + if ui + .selectable_label(active, egui::RichText::new(kind.group_label()).size(10.0)) + .clicked() + { + if shift { + selection.extend(&ids); + } else { + *selection = ids; + } + } + } + ui.add_space(10.0); + let n = selection.len(); + let caption = if n == 0 { + "no selection = whole lanes".to_string() + } else { + format!("{n} selected") + }; + ui.label(egui::RichText::new(caption).weak().size(9.0)); + }); +} + +/// Fader height that fills the footer space left below the view tabs +/// (the ~34 pt reserve holds the readout + label under each fader). +fn fill_fader_height(ui: &egui::Ui) -> f32 { + (ui.available_height() - 34.0).clamp(140.0, 420.0) +} + +/// Spec for one labeled vertical fader column. +struct FaderCol<'a> { + label: &'a str, + range: std::ops::RangeInclusive, + default: f32, + accent: egui::Color32, + unit: &'a str, + height: f32, +} + +/// One labeled vertical fader with a live value readout. +fn fader_col(ui: &mut egui::Ui, value: &mut f32, spec: FaderCol<'_>) { + ui.vertical(|ui| { + ui.set_width(38.0); + ui.vertical_centered(|ui| { + ui.add( + Fader::new(value, spec.range, spec.accent) + .size((FADER_W, spec.height)) + .notches(Notches::Even(4)) + .default_value(spec.default), + ); + ui.label( + egui::RichText::new(format!("{value:.0}{}", spec.unit)) + .monospace() + .size(9.0), + ); + ui.label(egui::RichText::new(spec.label).weak().size(9.0)); + }); + }); +} + +fn intensity_view(ui: &mut egui::Ui, p: &mut halo_light::programmer::IntensityParams) { + let accent = crate::waveform::palette::LANE_LIGHTING; + let height = fill_fader_height(ui); + for (value, label, default) in [ + (&mut p.dimmer, "DIMMER", 100.0), + (&mut p.strobe, "STROBE", 0.0), + ] { + fader_col( + ui, + value, + FaderCol { + label, + range: 0.0..=100.0, + default, + accent, + unit: "%", + height, + }, + ); + } +} + +fn color_view(ui: &mut egui::Ui, p: &mut halo_light::programmer::ColorParams) { + const ACCENTS: [(usize, &str, egui::Color32); 4] = [ + (0, "R", egui::Color32::from_rgb(235, 70, 60)), + (1, "G", egui::Color32::from_rgb(80, 205, 95)), + (2, "B", egui::Color32::from_rgb(75, 125, 255)), + (3, "W", egui::Color32::from_rgb(235, 235, 240)), + ]; + let height = fill_fader_height(ui); + for (i, label, accent) in ACCENTS { + fader_col( + ui, + &mut p.rgbw[i], + FaderCol { + label, + range: 0.0..=100.0, + default: 0.0, + accent, + unit: "", + height, + }, + ); + } + ui.add_space(6.0); + ui.vertical(|ui| { + // Mixed-color swatch (white adds broadly to all channels). + let mix = |c: f32| (((c + p.rgbw[3] * 0.9) / 100.0).clamp(0.0, 1.0) * 255.0) as u8; + let (rect, _) = ui.allocate_exact_size(egui::vec2(56.0, 22.0), egui::Sense::hover()); + ui.painter().rect_filled( + rect, + 3.0, + egui::Color32::from_rgb(mix(p.rgbw[0]), mix(p.rgbw[1]), mix(p.rgbw[2])), + ); + ui.add_space(4.0); + // Preset swatches set the faders instantly. + egui::Grid::new("color_presets") + .spacing([4.0, 4.0]) + .min_col_width(18.0) + .show(ui, |ui| { + for (i, &(name, rgbw)) in COLOR_PRESETS.iter().enumerate() { + if color_swatch(ui, rgbw, false).on_hover_text(name).clicked() { + p.rgbw = rgbw; + } + if i % 5 == 4 { + ui.end_row(); + } + } + }); + }); +} + +/// Small clickable color swatch for a preset. +fn color_swatch(ui: &mut egui::Ui, rgbw: [f32; 4], selected: bool) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::click()); + let mix = |c: f32| (((c + rgbw[3] * 0.9) / 100.0).clamp(0.0, 1.0) * 255.0) as u8; + ui.painter().rect_filled( + rect, + 3.0, + egui::Color32::from_rgb(mix(rgbw[0]), mix(rgbw[1]), mix(rgbw[2])), + ); + if selected { + ui.painter().rect_stroke( + rect, + 3.0, + egui::Stroke::new(1.5_f32, egui::Color32::WHITE), + egui::StrokeKind::Outside, + ); + } + resp +} + +fn position_view(ui: &mut egui::Ui, p: &mut halo_light::programmer::PositionParams) { + let accent = egui::Color32::from_rgb(140, 120, 255); + let height = fill_fader_height(ui); + for (value, label) in [(&mut p.pan, "PAN"), (&mut p.tilt, "TILT")] { + fader_col( + ui, + value, + FaderCol { + label, + range: 0.0..=360.0, + default: 180.0, + accent, + unit: "°", + height, + }, + ); + } + ui.add_space(6.0); + ui.vertical(|ui| { + // XY pad: pan on x, tilt on y (up = more tilt); the dot drags. + const PAD: f32 = 96.0; + let (rect, resp) = + ui.allocate_exact_size(egui::vec2(PAD, PAD), egui::Sense::click_and_drag()); + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 4.0, egui::Color32::from_rgb(16, 16, 22)); + for f in [0.25, 0.5, 0.75] { + let x = rect.left() + rect.width() * f; + let y = rect.top() + rect.height() * f; + let stroke = egui::Stroke::new( + 1.0_f32, + if f == 0.5 { + egui::Color32::from_rgb(45, 45, 55) + } else { + egui::Color32::from_rgb(28, 28, 36) + }, + ); + painter.line_segment( + [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], + stroke, + ); + painter.line_segment( + [egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)], + stroke, + ); + } + if (resp.dragged() || resp.clicked()) + && let Some(pos) = resp.interact_pointer_pos() + { + p.pan = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0) * 360.0; + p.tilt = (1.0 - (pos.y - rect.top()) / rect.height()).clamp(0.0, 1.0) * 360.0; + } + let dot = egui::pos2( + rect.left() + rect.width() * (p.pan / 360.0), + rect.top() + rect.height() * (1.0 - p.tilt / 360.0), + ); + painter.circle_filled(dot, 4.5, accent); + painter.circle_stroke(dot, 6.0, egui::Stroke::new(1.0_f32, egui::Color32::WHITE)); + + // Which axes the effect drives. + ui.add_space(4.0); + ui.horizontal(|ui| { + for (t, label) in [ + (PanTiltTarget::Both, "BOTH"), + (PanTiltTarget::Pan, "PAN"), + (PanTiltTarget::Tilt, "TILT"), + ] { + if ui + .selectable_label(p.target == t, egui::RichText::new(label).size(9.0)) + .on_hover_text("Apply the effect to these axes") + .clicked() + { + p.target = t; + } + } + }); + }); +} + +fn beam_view(ui: &mut egui::Ui, p: &mut halo_light::programmer::BeamParams) { + ui.vertical(|ui| { + ui.label(egui::RichText::new("GOBO").weak().size(9.0)); + ui.add_space(2.0); + egui::Grid::new("gobo_grid") + .spacing([4.0, 4.0]) + .show(ui, |ui| { + for g in 1..=8u8 { + if ui + .add_sized( + [34.0, 30.0], + egui::SelectableLabel::new( + p.gobo == g, + egui::RichText::new(format!("G{g}")).monospace().size(10.0), + ), + ) + .clicked() + { + p.gobo = g; + } + if g == 4 { + ui.end_row(); + } + } + }); + }); +} + +fn pixel_view(ui: &mut egui::Ui, p: &mut halo_light::programmer::PixelFxParams) { + ui.vertical(|ui| { + ui.label(egui::RichText::new("EFFECT").weak().size(9.0)); + egui::ScrollArea::vertical() + .id_salt("pixel_fx_list") + .max_height(118.0) + .show(ui, |ui| { + for (i, name) in PIXEL_EFFECTS.iter().enumerate() { + if ui + .selectable_label(p.effect == i, egui::RichText::new(*name).size(10.0)) + .clicked() + { + p.effect = i; + } + } + }); + }); + ui.add_space(10.0); + ui.vertical(|ui| { + ui.label(egui::RichText::new("COLOR").weak().size(9.0)); + ui.add_space(2.0); + egui::Grid::new("pixel_colors") + .spacing([4.0, 4.0]) + .min_col_width(18.0) + .show(ui, |ui| { + for (i, &(name, rgbw)) in COLOR_PRESETS.iter().enumerate() { + if color_swatch(ui, rgbw, p.color == i) + .on_hover_text(name) + .clicked() + { + p.color = i; + } + if i % 5 == 4 { + ui.end_row(); + } + } + }); + ui.add_space(6.0); + ui.label( + egui::RichText::new(format!( + "{} · {}", + PIXEL_EFFECTS[p.effect], COLOR_PRESETS[p.color].0 + )) + .weak() + .size(9.0), + ); + }); +} + +/// The shared per-parameter effects panel with a beat-synced preview. +fn effect_panel(ui: &mut egui::Ui, cfg: &mut EffectConfig, accent: egui::Color32, beat_t: f64) { + // The panel is embedded in a horizontal row; force its own content + // back to a vertical stack. + egui::Frame::group(ui.style()) + .inner_margin(6.0) + .show(ui, |ui| { + ui.vertical(|ui| { + ui.set_width(216.0); + ui.label(egui::RichText::new("EFFECT").weak().size(9.0)); + ui.horizontal(|ui| { + for (wf, label) in ALL_WAVEFORMS { + if ui + .selectable_label( + cfg.waveform == wf, + egui::RichText::new(label).size(9.0), + ) + .clicked() + { + cfg.waveform = wf; + } + } + }); + ui.horizontal(|ui| { + for (iv, label) in ALL_INTERVALS { + if ui + .selectable_label( + cfg.interval == iv, + egui::RichText::new(label).size(9.0), + ) + .clicked() + { + cfg.interval = iv; + } + } + }); + ui.spacing_mut().slider_width = 100.0; + ui.horizontal(|ui| { + ui.label(egui::RichText::new("RATIO").weak().size(9.0)); + ui.add( + egui::Slider::new(&mut cfg.ratio, 0.0..=2.0) + .fixed_decimals(2) + .handle_shape(egui::style::HandleShape::Rect { aspect_ratio: 0.5 }), + ); + }); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("PHASE").weak().size(9.0)); + ui.add( + egui::Slider::new(&mut cfg.phase_deg, 0.0..=360.0) + .fixed_decimals(0) + .suffix("°") + .handle_shape(egui::style::HandleShape::Rect { aspect_ratio: 0.5 }), + ); + }); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("DIST").weak().size(9.0)); + let is_all = cfg.distribution == Distribution::All; + if ui + .selectable_label(is_all, egui::RichText::new("ALL").size(9.0)) + .clicked() + { + cfg.distribution = Distribution::All; + } + let is_step = matches!(cfg.distribution, Distribution::Step(_)); + if ui + .selectable_label(is_step, egui::RichText::new("STEP").size(9.0)) + .clicked() + { + cfg.distribution = Distribution::Step(2); + } + let is_wave = matches!(cfg.distribution, Distribution::Wave(_)); + if ui + .selectable_label(is_wave, egui::RichText::new("WAVE").size(9.0)) + .clicked() + { + cfg.distribution = Distribution::Wave(45); + } + match &mut cfg.distribution { + Distribution::Step(n) => { + ui.add(egui::DragValue::new(n).range(1..=32)); + } + Distribution::Wave(offset) => { + ui.add(egui::DragValue::new(offset).range(0..=360).suffix("°")); + } + Distribution::All => {} + } + }); + ui.add_space(4.0); + effect_preview(ui, cfg, accent, beat_t); + ui.add_space(4.0); + let apply = egui::Button::new( + egui::RichText::new(if cfg.applied { "APPLIED" } else { "APPLY" }) + .size(10.0) + .strong() + .color(if cfg.applied { + egui::Color32::from_rgb(15, 15, 18) + } else { + egui::Color32::from_rgb(220, 220, 225) + }), + ) + .fill(if cfg.applied { + accent + } else { + egui::Color32::from_rgb(42, 42, 46) + }); + if ui + .add_sized([ui.available_width(), 20.0], apply) + .on_hover_text("Latch this effect (visual-only until the fixture engine lands)") + .clicked() + { + cfg.applied = !cfg.applied; + } + }); + }); +} + +/// Musical window the preview shows, in intervals. A few cycles at once +/// keeps the "now" dot travelling calmly (once per bar at the default +/// beat interval) instead of whipping across on every beat. +const PREVIEW_SPAN: f64 = 4.0; + +/// A few intervals of the configured waveform with a dot riding the +/// actual musical phase, plus a fainter offset trace hinting Step/Wave +/// spread. +fn effect_preview(ui: &mut egui::Ui, cfg: &EffectConfig, accent: egui::Color32, beat_t: f64) { + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width().min(204.0), 44.0), + egui::Sense::hover(), + ); + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 3.0, egui::Color32::from_rgb(12, 12, 16)); + let mid = rect.center().y; + painter.line_segment( + [egui::pos2(rect.left(), mid), egui::pos2(rect.right(), mid)], + egui::Stroke::new(1.0_f32, egui::Color32::from_rgb(30, 30, 38)), + ); + let inset = rect.shrink2(egui::vec2(3.0, 4.0)); + let y_of = |v: f32| inset.bottom() - v * inset.height(); + let curve = |phase_offset: f64| -> Vec { + (0..=192) + .map(|i| { + let frac = i as f64 / 192.0; + egui::pos2( + inset.left() + inset.width() * frac as f32, + y_of(effect_value(cfg, frac * PREVIEW_SPAN + phase_offset)), + ) + }) + .collect() + }; + // Offset trace first (under the main one) when the effect spreads. + let spread_offset = match cfg.distribution { + Distribution::All => None, + Distribution::Step(n) => Some(1.0 / n.max(2) as f64), + Distribution::Wave(deg) => Some(deg as f64 / 360.0), + }; + if let Some(off) = spread_offset { + painter.add(egui::Shape::line( + curve(off), + egui::Stroke::new(1.0_f32, accent.gamma_multiply(0.3)), + )); + } + painter.add(egui::Shape::line( + curve(0.0), + egui::Stroke::new(1.5_f32, accent), + )); + // The "now" dot, riding the deck's beat grid across the whole window. + let t_now = (beat_t / cfg.interval.beats()).rem_euclid(PREVIEW_SPAN); + let dot = egui::pos2( + inset.left() + inset.width() * (t_now / PREVIEW_SPAN) as f32, + y_of(effect_value(cfg, t_now)), + ); + painter.circle_filled(dot, 3.0, egui::Color32::WHITE); +} + +/// CLEAR / STORE / PREVIEW / HIGHLIGHT, stacked on the panel's right edge. +fn action_column(ui: &mut egui::Ui, cx: &mut ProgrammerCtx<'_>) -> bool { + let mut store = false; + ui.vertical(|ui| { + ui.set_width(ACTION_COL_W); + let armed = programmer::any_latched(cx.overrides); + let clear_btn = egui::Button::new(egui::RichText::new("CLEAR").size(12.0).strong().color( + if armed { + egui::Color32::WHITE + } else { + egui::Color32::from_rgb(200, 200, 205) + }, + )) + .fill(if armed { + egui::Color32::from_rgb(130, 32, 32) + } else { + egui::Color32::from_rgb(42, 42, 46) + }); + if ui + .add_sized([ACTION_COL_W, 26.0], clear_btn) + .on_hover_text("Release all latched lanes (Esc)") + .clicked() + { + programmer::clear(cx.overrides); + } + ui.add_space(4.0); + ui.add_enabled_ui(cx.can_store, |ui| { + if ui + .add_sized( + [ACTION_COL_W, 26.0], + egui::Button::new(egui::RichText::new("STORE").size(12.0)), + ) + .on_hover_text(format!( + "Write the active lanes into deck {}'s track as cues at the current bar", + cx.deck_name + )) + .clicked() + { + store = true; + } + }); + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + for (value, label, hint) in [ + ( + &mut cx.params.preview, + "PREVIEW", + "Blind: edit programmer values without sending them to the rig", + ), + ( + &mut cx.params.highlight, + "HIGHLIGHT", + "Snap the selected fixtures to full white for identification", + ), + ] { + if ui + .add_sized( + [ACTION_COL_W, 22.0], + egui::SelectableLabel::new(*value, egui::RichText::new(label).size(10.0)), + ) + .on_hover_text(hint) + .clicked() + { + *value = !*value; + } + ui.add_space(4.0); + } + ui.add_space(2.0); + ui.label( + egui::RichText::new(format!("out: deck {}", cx.deck_name)) + .weak() + .size(9.0), + ); + }); + store +} + +/// One lane's compact override cluster: colored label, latching ON, +/// momentary FLASH, intensity knob. +fn lane_controls(ui: &mut egui::Ui, o: &mut halo_light::programmer::LaneOverride, i: usize) { + let (_, label, color) = crate::waveform::LANES[i]; + ui.label( + egui::RichText::new(label) + .monospace() + .size(10.0) + .color(if o.active() { + color + } else { + color.gamma_multiply(0.5) + }), + ); + if ui + .selectable_label(o.latched, egui::RichText::new("ON").size(10.0)) + .on_hover_text("Latch this lane on until CLEAR") + .clicked() + { + o.latched = !o.latched; + } + let flash = ui + .add(egui::Button::new(egui::RichText::new("FLASH").size(10.0))) + .on_hover_text("Active while held"); + if flash.is_pointer_button_down_on() { + o.flash_held = true; + } + let mut v = o.intensity; + if ui + .add( + Knob::new(&mut v, 0.0..=1.0, color) + .arc(KnobArc::Unipolar) + .default_value(1.0) + .diameter(18.0), + ) + .on_hover_text(format!("Intensity: {:.0}%", o.intensity * 100.0)) + .changed() + { + o.intensity = v; + } +} + +/// Clickable rig grid, laid out to mirror the stage: cells glow with +/// their lane's current output level and carry the same white-ring +/// programmer-override language as the LEDs and lane strips. Click +/// selects, shift-click toggles, press-drag paints, background click +/// clears. Selection is the target for future per-fixture effects/colors; +/// it is not consumed by the output path yet. +pub fn fixture_grid( + ui: &mut egui::Ui, + rig: &Rig, + selection: &mut HashSet, + outputs: &[LaneOutput; LANE_COUNT], +) { + const CELL_W: f32 = 40.0; + const CELL_H: f32 = 28.0; + const GAP: f32 = 4.0; + let (cols, rows) = rig.extent(); + let size = egui::vec2( + cols as f32 * (CELL_W + GAP) - GAP, + rows as f32 * (CELL_H + GAP) - GAP, + ); + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click_and_drag()); + let painter = ui.painter_at(rect.expand(2.0)); + + let shift = ui.input(|i| i.modifiers.shift); + let pointer = response.interact_pointer_pos(); + let clicked = response.clicked(); + let painting = response.drag_started() || response.dragged(); + let mut background_click = clicked; + + for f in rig.iter() { + let min = + rect.min + egui::vec2(f.col as f32 * (CELL_W + GAP), f.row as f32 * (CELL_H + GAP)); + let cell = egui::Rect::from_min_size(min, egui::vec2(CELL_W, CELL_H)); + let out = outputs[f.kind.lane() as usize]; + let selected = selection.contains(&f.id); + + let alpha = 0.15 + 0.85 * out.level.clamp(0.0, 1.0); + let [r, g, b] = f.kind.color(); + painter.rect_filled( + cell, + 4.0, + egui::Color32::from_rgb(r, g, b).gamma_multiply(alpha), + ); + if out.source == LaneSource::Programmer { + painter.rect_stroke( + cell.shrink(1.5), + 3.0, + egui::Stroke::new(1.0_f32, egui::Color32::WHITE.gamma_multiply(0.55)), + egui::StrokeKind::Inside, + ); + } + if selected { + painter.rect_stroke( + cell, + 4.0, + egui::Stroke::new(1.5_f32, egui::Color32::WHITE), + egui::StrokeKind::Outside, + ); + } + // Bright fills (strobes glowing white, smoke grey) need dark text. + let bright = matches!(f.kind, FixtureKind::Strobe | FixtureKind::Smoke) && alpha > 0.5; + painter.text( + cell.center(), + egui::Align2::CENTER_CENTER, + &f.label, + egui::FontId::monospace(9.0), + if bright { + egui::Color32::from_rgb(20, 20, 24) + } else { + egui::Color32::from_rgb(230, 230, 235) + }, + ); + + if pointer.is_some_and(|p| cell.contains(p)) { + if clicked { + background_click = false; + if shift { + if !selection.remove(&f.id) { + selection.insert(f.id); + } + } else { + selection.clear(); + selection.insert(f.id); + } + } else if painting { + selection.insert(f.id); + } + } + } + if background_click { + selection.clear(); + } +} diff --git a/crates/halo/src/scrub.rs b/crates/halo/src/scrub.rs new file mode 100644 index 0000000..8f74a20 --- /dev/null +++ b/crates/halo/src/scrub.rs @@ -0,0 +1,436 @@ +//! Varispeed scrub voice for CDJ-style audible waveform dragging. +//! +//! While the zoomed waveform is dragged, the audio callback bypasses the +//! engine and renders this voice instead: a linear-interpolated read of the +//! raw decoded source that chases the pointer-implied position with a +//! smoothed rate, so pitch follows hand speed in either direction (the +//! engine itself is forward-only and tempo-clamped, so it can't make this +//! sound). The voice is pure math over slices — no I/O — so it stays +//! unit-testable off the audio thread. + +const CHANNELS: usize = 2; +/// Time (seconds) over which the reader converges on the published target. +const CATCHUP_SECS: f64 = 0.060; +/// One-pole time constant (seconds) smoothing the chase rate; jerky pointer +/// deltas sound like tape, not a zipper. +const RATE_SMOOTH_SECS: f64 = 0.010; +/// Fastest scrub speed in source frames per output frame. +const MAX_RATE: f64 = 32.0; +/// Below this |rate| the voice fades out: a near-stationary read is just a +/// held DC value, and a CDJ in vinyl-hold is silent. +const AUDIBLE_RATE: f64 = 0.02; +/// Gate fade time constant (seconds) as the tape slows to a stop. +const GATE_SMOOTH_SECS: f64 = 0.005; +/// Release-glide time constant (seconds): after the drag drops, the rate +/// eases exponentially toward the settle target (1.0 while playing, 0.0 +/// while paused) — the CDJ vinyl release. ~3x this reaches 95% of the way. +const SETTLE_TAU_SECS: f64 = 0.15; +/// The glide is over once the rate is within this of its target. +const SETTLE_EPS_RATE: f64 = 0.02; +/// Trajectory length cap in frames (safety; ~1.1 s suffices from ±32x). +const MAX_SETTLE_FRAMES: u64 = 48_000 * 5; + +/// Release-glide trajectory: the rate eases toward `rate_target` for +/// exactly `frames_left` more frames (pre-counted at release so the landing +/// position is known in advance). +struct SettleTraj { + rate_target: f64, + frames_left: u64, +} + +/// Variable-rate scrub reader over an interleaved stereo source. +pub struct ScrubVoice { + /// Read position in source frames. + pos: f64, + /// Smoothed advance in source frames per output frame (sign = direction). + rate: f64, + /// Smoothed audibility gate, 0..1. + gate: f64, + catchup_frames: f64, + rate_alpha: f64, + gate_alpha: f64, + settle_alpha: f64, + settle: Option, +} + +impl ScrubVoice { + pub fn new(sample_rate: u32) -> Self { + let sr = sample_rate.max(1) as f64; + Self { + pos: 0.0, + rate: 0.0, + gate: 0.0, + catchup_frames: (CATCHUP_SECS * sr).max(1.0), + rate_alpha: 1.0 - (-1.0 / (RATE_SMOOTH_SECS * sr)).exp(), + gate_alpha: 1.0 - (-1.0 / (GATE_SMOOTH_SECS * sr)).exp(), + settle_alpha: 1.0 - (-1.0 / (SETTLE_TAU_SECS * sr)).exp(), + settle: None, + } + } + + /// Re-anchor the reader at `frame` on scrub engage: no residual motion + /// or gate from a previous gesture. + pub fn seed(&mut self, frame: f64) { + self.pos = frame; + self.rate = 0.0; + self.gate = 0.0; + self.settle = None; + } + + /// Current read position in source frames. + pub fn position(&self) -> f64 { + self.pos + } + + /// Start the release glide easing the current rate toward `rate_target` + /// and return the predicted landing frame. The trajectory is simulated + /// once with the same per-frame arithmetic [`render_settle`] runs, so + /// the voice lands exactly on the returned frame — the engine can be + /// warm-started there in parallel for a seamless handoff. + pub fn begin_settle(&mut self, rate_target: f64, source: &[f32]) -> f64 { + let total_frames = source.len() / CHANNELS; + let max_pos = (total_frames.saturating_sub(1)) as f64; + let mut rate = self.rate; + let mut pos = self.pos.clamp(0.0, max_pos); + let mut n: u64 = 0; + while (rate - rate_target).abs() >= SETTLE_EPS_RATE && n < MAX_SETTLE_FRAMES { + rate += (rate_target - rate) * self.settle_alpha; + pos = (pos + rate).clamp(0.0, max_pos); + n += 1; + // A boundary ends the glide early — but only when still moving + // into it, so a clamped start can ease back off the rail. + if (pos == 0.0 && rate < 0.0) || (pos == max_pos && rate > 0.0) { + break; + } + } + self.settle = Some(SettleTraj { + rate_target, + frames_left: n, + }); + pos + } + + /// Render one glide block into `out` (interleaved stereo, overwritten). + /// Returns `true` once the trajectory is complete. Past the landing the + /// voice keeps playing at the settle rate — the caller's mix ramp fades + /// it against the engine (time-aligned at rate 1.0), so there is no cut. + pub fn render_settle(&mut self, source: &[f32], out: &mut [f32]) -> bool { + let total_frames = source.len() / CHANNELS; + let Some(SettleTraj { + rate_target, + ref mut frames_left, + }) = self.settle + else { + out.fill(0.0); + return true; + }; + if total_frames == 0 { + out.fill(0.0); + return true; + } + let max_pos = (total_frames - 1) as f64; + + for frame in out.chunks_exact_mut(CHANNELS) { + // Same op order as the begin_settle simulation: rate, then + // read at the pre-advance position, then advance + clamp. + self.rate += (rate_target - self.rate) * self.settle_alpha; + let gate_target = (self.rate.abs() / AUDIBLE_RATE).min(1.0); + self.gate += (gate_target - self.gate) * self.gate_alpha; + + let base = self.pos.floor() as usize; + let frac = (self.pos - base as f64) as f32; + let next = (base + 1).min(total_frames - 1); + let gain = self.gate as f32; + for (ch, sample) in frame.iter_mut().enumerate() { + let a = source[base * CHANNELS + ch]; + let b = source[next * CHANNELS + ch]; + *sample = (a + (b - a) * frac) * gain; + } + + self.pos = (self.pos + self.rate).clamp(0.0, max_pos); + *frames_left = frames_left.saturating_sub(1); + } + + self.settle.as_ref().is_none_or(|s| s.frames_left == 0) + } + + /// Render one block into `out` (interleaved stereo, overwritten), + /// chasing `target_frame` through `source`. + pub fn render(&mut self, target_frame: f64, source: &[f32], out: &mut [f32]) { + let total_frames = source.len() / CHANNELS; + if total_frames == 0 { + out.fill(0.0); + return; + } + let max_pos = (total_frames - 1) as f64; + let target = target_frame.clamp(0.0, max_pos); + let target_rate = ((target - self.pos) / self.catchup_frames).clamp(-MAX_RATE, MAX_RATE); + + for frame in out.chunks_exact_mut(CHANNELS) { + self.rate += (target_rate - self.rate) * self.rate_alpha; + let gate_target = (self.rate.abs() / AUDIBLE_RATE).min(1.0); + self.gate += (gate_target - self.gate) * self.gate_alpha; + + let base = self.pos.floor() as usize; + let frac = (self.pos - base as f64) as f32; + let next = (base + 1).min(total_frames - 1); + let gain = self.gate as f32; + for (ch, sample) in frame.iter_mut().enumerate() { + let a = source[base * CHANNELS + ch]; + let b = source[next * CHANNELS + ch]; + *sample = (a + (b - a) * frac) * gain; + } + + self.pos = (self.pos + self.rate).clamp(0.0, max_pos); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SR: u32 = 48_000; + + /// Interleaved stereo source whose left channel is a frame-index ramp + /// and right channel its negation — interpolation errors show up as + /// asymmetry. + fn ramp_source(frames: usize) -> Vec { + let mut s = Vec::with_capacity(frames * CHANNELS); + for i in 0..frames { + s.push(i as f32); + s.push(-(i as f32)); + } + s + } + + fn render_blocks(voice: &mut ScrubVoice, target: f64, source: &[f32], blocks: usize) { + let mut out = vec![0.0f32; 512 * CHANNELS]; + for _ in 0..blocks { + voice.render(target, source, &mut out); + } + } + + #[test] + fn converges_on_forward_target() { + let source = ramp_source(SR as usize * 10); + let mut voice = ScrubVoice::new(SR); + voice.seed(1_000.0); + // One second of audio: far longer than catchup + smoothing. + render_blocks(&mut voice, 50_000.0, &source, SR as usize / 512); + assert!( + (voice.pos - 50_000.0).abs() < 1.0, + "pos {} should have converged on 50000", + voice.pos + ); + } + + #[test] + fn converges_on_reverse_target() { + let source = ramp_source(SR as usize * 10); + let mut voice = ScrubVoice::new(SR); + voice.seed(200_000.0); + render_blocks(&mut voice, 150_000.0, &source, SR as usize / 512); + assert!( + (voice.pos - 150_000.0).abs() < 1.0, + "pos {} should have converged on 150000", + voice.pos + ); + } + + #[test] + fn rate_is_clamped() { + let frames = SR as usize * 100; + let source = ramp_source(frames); + let mut voice = ScrubVoice::new(SR); + voice.seed(0.0); + // Target far beyond what MAX_RATE covers in one block. + let mut out = vec![0.0f32; 512 * CHANNELS]; + voice.render((frames - 1) as f64, &source, &mut out); + assert!(voice.rate.abs() <= MAX_RATE + 1e-9); + assert!(voice.pos <= 512.0 * MAX_RATE); + } + + #[test] + fn position_clamps_at_boundaries() { + let source = ramp_source(1_000); + let mut voice = ScrubVoice::new(SR); + voice.seed(500.0); + render_blocks(&mut voice, -10_000.0, &source, 200); + assert!(voice.pos >= 0.0 && voice.pos < 1e-6, "pos {}", voice.pos); + render_blocks(&mut voice, 10_000.0, &source, 200); + assert!( + voice.pos <= 999.0 && voice.pos > 999.0 - 1e-6, + "pos {}", + voice.pos + ); + } + + #[test] + fn interpolates_ramp_exactly_once_gate_opens() { + let source = ramp_source(SR as usize * 10); + let mut voice = ScrubVoice::new(SR); + voice.seed(10_000.0); + // Settle into a steady forward chase so the gate is fully open. + render_blocks(&mut voice, 100_000.0, &source, 20); + let mut out = vec![0.0f32; 256 * CHANNELS]; + let pos_before = voice.pos; + voice.render(100_000.0, &source, &mut out); + // On a linear ramp, an interpolated read at p returns exactly p. + let expected = (pos_before + voice.rate) as f32; + assert!( + (out[CHANNELS] - expected).abs() < 2.0, + "left {} vs expected ~{expected}", + out[CHANNELS] + ); + for frame in out.chunks_exact(CHANNELS) { + assert!( + (frame[0] + frame[1]).abs() < 1e-3, + "channels should mirror: {} vs {}", + frame[0], + frame[1] + ); + } + } + + #[test] + fn stationary_target_fades_to_silence() { + let source = vec![1.0f32; 10_000 * CHANNELS]; // constant full-scale + let mut voice = ScrubVoice::new(SR); + voice.seed(5_000.0); + // Hold the target at the current position for half a second. + render_blocks(&mut voice, 5_000.0, &source, SR as usize / 2 / 512); + let mut out = vec![1.0f32; 256 * CHANNELS]; + voice.render(5_000.0, &source, &mut out); + for &s in &out { + assert!(s.abs() < 1e-3, "expected silence, got {s}"); + } + } + + #[test] + fn empty_source_outputs_silence() { + let mut voice = ScrubVoice::new(SR); + let mut out = vec![1.0f32; 64 * CHANNELS]; + voice.render(100.0, &[], &mut out); + assert!(out.iter().all(|&s| s == 0.0)); + } + + /// Drive the voice into a steady chase toward a far target so it still + /// carries full momentum (a near target would converge and decay the + /// rate to ~0 before release). + fn voice_with_momentum(source: &[f32], start: f64, target: f64) -> ScrubVoice { + let mut voice = ScrubVoice::new(SR); + voice.seed(start); + render_blocks(&mut voice, target, source, 30); + voice + } + + /// Render settle blocks until done; returns frames rendered until the + /// completion block (inclusive). + fn settle_to_done(voice: &mut ScrubVoice, source: &[f32]) -> usize { + let mut out = vec![0.0f32; 512 * CHANNELS]; + let mut frames = 0; + for _ in 0..2000 { + let done = voice.render_settle(source, &mut out); + frames += 512; + if done { + return frames; + } + } + panic!("settle never completed"); + } + + #[test] + fn settle_lands_exactly_on_prediction() { + let source = ramp_source(SR as usize * 30); + for rate_target in [1.0, 0.0] { + let mut voice = voice_with_momentum(&source, 100_000.0, 1_400_000.0); + let rate_before = voice.rate; + assert!(rate_before > 1.5, "need real momentum, got {rate_before}"); + let landing = voice.begin_settle(rate_target, &source); + let traj_frames = voice.settle.as_ref().unwrap().frames_left; + // Render exactly the trajectory length in odd-sized blocks to + // cross block boundaries. + let mut remaining = traj_frames as usize; + let mut out = vec![0.0f32; 173 * CHANNELS]; + while remaining >= 173 { + voice.render_settle(&source, &mut out); + remaining -= 173; + } + let mut tail = vec![0.0f32; remaining * CHANNELS]; + if remaining > 0 { + voice.render_settle(&source, &mut tail); + } + assert_eq!( + voice.pos, landing, + "voice must land bit-exactly on the predicted frame (rt {rate_target})" + ); + assert!( + (voice.rate - rate_target).abs() < SETTLE_EPS_RATE + 1e-9, + "rate {} should have eased to {rate_target}", + voice.rate + ); + } + } + + #[test] + fn settle_toward_play_overshoots_drop_point() { + let source = ramp_source(SR as usize * 30); + let mut voice = voice_with_momentum(&source, 100_000.0, 1_400_000.0); + let drop_pos = voice.pos; + let landing = voice.begin_settle(1.0, &source); + // Fast forward momentum must carry the landing well past the drop. + assert!( + landing > drop_pos + SR as f64 * 0.05, + "landing {landing} should overshoot drop {drop_pos}" + ); + settle_to_done(&mut voice, &source); + assert!((voice.rate - 1.0).abs() < SETTLE_EPS_RATE + 1e-9); + } + + #[test] + fn spin_up_from_hold_reaches_play_rate() { + let source = ramp_source(SR as usize * 10); + let mut voice = ScrubVoice::new(SR); + voice.seed(200_000.0); // rate 0, as after a stationary hold + let landing = voice.begin_settle(1.0, &source); + assert!(landing > 200_000.0, "spin-up still travels forward"); + settle_to_done(&mut voice, &source); + assert!((voice.rate - 1.0).abs() < SETTLE_EPS_RATE + 1e-9); + // Monotonic rise: never overshoots 1.0 from below. + assert!(voice.rate <= 1.0 + 1e-9); + } + + #[test] + fn settle_clamps_at_track_end() { + let total = SR as usize; // 1s track + let source = ramp_source(total); + let mut voice = voice_with_momentum(&source, 20_000.0, 2_000_000.0); + let landing = voice.begin_settle(1.0, &source); + assert_eq!( + landing, + (total - 1) as f64, + "fling into EOF lands on the last frame" + ); + settle_to_done(&mut voice, &source); + assert_eq!(voice.pos, landing); + } + + #[test] + fn spin_down_fades_to_silence() { + let source = vec![1.0f32; SR as usize * 20 * CHANNELS]; + let mut voice = voice_with_momentum(&source, 100_000.0, 900_000.0); + voice.begin_settle(0.0, &source); + settle_to_done(&mut voice, &source); + // Past the landing the rate keeps easing to 0 and the gate follows + // it down (in the app the mix ramp-out cuts the tail much sooner). + let mut out = vec![1.0f32; 512 * CHANNELS]; + for _ in 0..300 { + voice.render_settle(&source, &mut out); + } + assert!( + out.iter().all(|&s| s.abs() < 1e-2), + "spin-down should end silent" + ); + } +} diff --git a/crates/halo/src/show.rs b/crates/halo/src/show.rs new file mode 100644 index 0000000..3ede563 --- /dev/null +++ b/crates/halo/src/show.rs @@ -0,0 +1,203 @@ +//! Simulated show generator for the deck lane strips. +//! +//! Real cues are authored in Prepare mode and persisted in the library; +//! this generator exists to seed a track with plausible, beat-aligned +//! demo cues (and to exercise the lane painters before a rig exists). + +use halo_light::cues::{CueSet, Lane}; + +use crate::waveform::GridMarks; + +/// splitmix64: a tiny deterministic PRNG so the simulation needs no `rand` +/// dependency and is stable for a given seed. +fn splitmix64(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) +} + +/// Uniform in [0, 1). +fn rand_f32(state: &mut u64) -> f32 { + (splitmix64(state) >> 40) as f32 / (1u64 << 24) as f32 +} + +/// Beat sequence the simulation rules walk: either the track's real grid +/// or a synthesized fallback for un-analyzed tracks. +struct BeatSeq { + frames: Vec, + downbeat: Vec, + phrase_start: Vec, + beat_frames: f64, +} + +impl BeatSeq { + fn from_marks(marks: &GridMarks) -> Self { + let n = marks.len(); + Self { + frames: (0..n).map(|i| marks.frame(i)).collect(), + downbeat: (0..n).map(|i| marks.is_downbeat(i)).collect(), + phrase_start: (0..n).map(|i| marks.is_phrase_start(i)).collect(), + beat_frames: marks.median_beat_frames(), + } + } + + /// 120 BPM 4/4 grid with 16-bar phrases, so lanes still render on + /// tracks without a usable beat grid. + fn synthetic(total_frames: usize, sample_rate: u32) -> Self { + let beat_frames = 0.5 * sample_rate.max(1) as f64; + let n = (total_frames as f64 / beat_frames) as usize; + Self { + frames: (0..n).map(|i| i as f64 * beat_frames).collect(), + downbeat: (0..n).map(|i| i % 4 == 0).collect(), + phrase_start: (0..n).map(|i| i % (4 * 16) == 0).collect(), + beat_frames, + } + } +} + +/// SIMULATED show generator: deterministic per `(grid, seed)`, aligned to +/// phrases and bars so the bars land musically. +pub fn simulate_show( + marks: &GridMarks, + total_frames: usize, + sample_rate: u32, + seed: u64, +) -> CueSet { + if total_frames == 0 { + return CueSet::empty(); + } + let seq = if marks.is_usable() && marks.median_beat_frames() > 0.0 { + BeatSeq::from_marks(marks) + } else { + BeatSeq::synthetic(total_frames, sample_rate) + }; + if seq.frames.len() < 2 { + return CueSet::empty(); + } + + let beat = seq.beat_frames; + let bar = 4.0 * beat; + let mut show = CueSet::empty(); + let mut rng = seed; + + // Lighting: a cue at every phrase start lasting 2 bars, plus 1-bar + // accents on ~40% of mid-phrase bar-group starts (every 4th bar), which + // never collide with the 2-bar phrase cue. + // Pixels: ~half the phrases run a chase — one half-bar hit per bar. + // FX (smoke/pyro): every 4th phrase start, plus ~20% of the others. + let mut phrase_idx: usize = 0; + let mut pixels_active = false; + let mut bars_since_phrase: usize = 0; + let mut seen_phrase = false; + for i in 0..seq.frames.len() { + if !seq.downbeat[i] { + continue; + } + let frame = seq.frames[i]; + if seq.phrase_start[i] { + phrase_idx += if seen_phrase { 1 } else { 0 }; + seen_phrase = true; + bars_since_phrase = 0; + pixels_active = rand_f32(&mut rng) < 0.5; + + show.insert(Lane::Lighting, frame, 2.0 * bar, 0.9); + let fx_hit = phrase_idx.is_multiple_of(4) || rand_f32(&mut rng) < 0.2; + if fx_hit { + show.insert(Lane::Fx, frame, beat, 1.0); + } + } else { + bars_since_phrase += 1; + if bars_since_phrase.is_multiple_of(4) && rand_f32(&mut rng) < 0.4 { + let intensity = 0.4 + 0.4 * rand_f32(&mut rng); + show.insert(Lane::Lighting, frame, bar, intensity); + } + } + if pixels_active && seen_phrase { + let intensity = 0.6 + 0.4 * rand_f32(&mut rng); + show.insert(Lane::Pixels, frame, 0.5 * bar, intensity); + } + } + show +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_marks() -> GridMarks { + // 256 beats at 120 BPM / 44.1 kHz, downbeat every 4th. + let mut grid = timestretch::BeatGrid::empty(44100); + grid.beats = (0..256).map(|i| i as f64 * 22050.0).collect(); + grid.downbeats = (0..64).map(|b| b * 4).collect(); + grid.bpm = 120.0; + GridMarks::from_grid(&grid) + } + + fn collect(show: &CueSet, lane: Lane) -> Vec<(f64, f64)> { + show.visible(lane, f64::MIN, f64::MAX) + .iter() + .map(|c| (c.start_frame, c.duration_frames)) + .collect() + } + + #[test] + fn deterministic_per_seed() { + let marks = test_marks(); + let a = simulate_show(&marks, 256 * 22050, 44100, 7); + let b = simulate_show(&marks, 256 * 22050, 44100, 7); + for lane in [Lane::Lighting, Lane::Pixels, Lane::Fx] { + assert_eq!(collect(&a, lane), collect(&b, lane)); + } + } + + #[test] + fn sorted_and_non_overlapping() { + let marks = test_marks(); + let show = simulate_show(&marks, 256 * 22050, 44100, 42); + for lane in [Lane::Lighting, Lane::Pixels, Lane::Fx] { + let v = collect(&show, lane); + for w in v.windows(2) { + assert!(w[0].0 + w[0].1 <= w[1].0 + 1e-6, "{lane:?}: {w:?}"); + } + } + } + + #[test] + fn lighting_has_phrase_cues() { + let marks = test_marks(); + let show = simulate_show(&marks, 256 * 22050, 44100, 42); + // 256 beats = 64 bars = 4 phrases of 16 bars. + assert!(collect(&show, Lane::Lighting).len() >= 4); + } + + #[test] + fn empty_track_is_empty() { + let show = simulate_show(&GridMarks::empty(), 0, 44100, 1); + for lane in [Lane::Lighting, Lane::Pixels, Lane::Fx] { + assert!(collect(&show, lane).is_empty()); + } + } + + #[test] + fn no_grid_falls_back_to_synthetic() { + // 60 s at 44.1 kHz, no grid: still produces lighting cues. + let show = simulate_show(&GridMarks::empty(), 60 * 44100, 44100, 1); + assert!(!collect(&show, Lane::Lighting).is_empty()); + } + + #[test] + fn visible_windows_by_start_and_duration() { + let marks = test_marks(); + let show = simulate_show(&marks, 256 * 22050, 44100, 42); + let all = collect(&show, Lane::Lighting); + let (start, dur) = all[0]; + // A window starting mid-cue still returns it. + let vis = show.visible(Lane::Lighting, start + dur * 0.5, start + dur); + assert!( + vis.iter() + .any(|c| (c.start_frame - start).abs() < f64::EPSILON) + ); + } +} diff --git a/crates/halo/src/state.rs b/crates/halo/src/state.rs new file mode 100644 index 0000000..8188ef4 --- /dev/null +++ b/crates/halo/src/state.rs @@ -0,0 +1,346 @@ +//! Lock-free state shared between the UI thread, the per-deck feed threads, +//! and the audio callback. Everything here is atomics — the audio path never +//! takes a lock it can block on. + +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering}; + +/// f32 stored as bits in an AtomicU32. +pub struct AtomicF32(AtomicU32); + +impl AtomicF32 { + pub fn new(v: f32) -> Self { + Self(AtomicU32::new(v.to_bits())) + } + + pub fn load(&self) -> f32 { + f32::from_bits(self.0.load(Ordering::Relaxed)) + } + + pub fn store(&self, v: f32) { + self.0.store(v.to_bits(), Ordering::Relaxed); + } +} + +/// f64 stored as bits in an AtomicU64. +pub struct AtomicF64(AtomicU64); + +impl AtomicF64 { + pub fn new(v: f64) -> Self { + Self(AtomicU64::new(v.to_bits())) + } + + pub fn load(&self) -> f64 { + f64::from_bits(self.0.load(Ordering::Relaxed)) + } + + pub fn store(&self, v: f64) { + self.0.store(v.to_bits(), Ordering::Relaxed); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Transport { + Stopped = 0, + Playing = 1, + Paused = 2, +} + +/// Sentinel meaning "no seek requested". +const NO_SEEK: u64 = u64::MAX; + +/// Per-deck state shared across the UI, feed thread, and audio callback. +pub struct DeckShared { + transport: AtomicU8, + /// Playhead in source frames, published by the feed thread. + pub playhead: AtomicU64, + /// Total source frames of the loaded track. + pub total_frames: AtomicU64, + /// Cue point in source frames. + pub cue_point: AtomicU64, + /// Seek request in source frames (NO_SEEK = none). UI writes, feed + /// thread consumes. + seek_request: AtomicU64, + /// True once the feed thread has prerolled the engine's source ring — + /// the audio callback only consumes the deck while this is set. + pub stream_active: AtomicBool, + /// Channel trim (gain), linear. 1.0 = unity. + pub trim: AtomicF32, + /// Channel fader, 0..1. + pub fader: AtomicF32, + /// Pre-fader (post-trim) deck level (linear peak, 0..1), published by + /// the audio callback for the channel meter — shows the track's level + /// regardless of fader/crossfader. Fast attack, slow release. + pub meter: AtomicF32, + /// Isolator EQ band gains, linear 0..2 (0 = kill, 1 = unity). + pub eq_low: AtomicF32, + pub eq_mid: AtomicF32, + pub eq_high: AtomicF32, + /// Filter mode as u8 (see `dsp::FilterMode::from_u8`). + pub filter_mode: AtomicU8, + /// Normalized filter cutoff 0..1 (log-mapped 20 Hz → 20 kHz). + pub filter_cutoff: AtomicF32, + /// Engine tempo rate (playback speed; 1.0 = original). Written by the + /// UI's tempo logic, forwarded to the engine by the feed thread. + pub tempo_rate: AtomicF32, + /// Keylock: pitch stays constant while tempo changes (Tape mode when + /// off — pitch follows tempo). + pub keylock: AtomicBool, + /// Active loop region packed as `start << 32 | end` (frames, u32 each) + /// so the feed thread can never read a torn start/end pair. + /// `u64::MAX` = no loop. + loop_region: AtomicU64, + /// Audible-scrub handshake between the UI, feed thread, and audio + /// callback. + pub scrub: ScrubState, +} + +impl DeckShared { + pub fn new() -> Self { + Self { + transport: AtomicU8::new(Transport::Stopped as u8), + playhead: AtomicU64::new(0), + total_frames: AtomicU64::new(0), + cue_point: AtomicU64::new(0), + seek_request: AtomicU64::new(NO_SEEK), + stream_active: AtomicBool::new(false), + trim: AtomicF32::new(1.0), + fader: AtomicF32::new(1.0), + meter: AtomicF32::new(0.0), + eq_low: AtomicF32::new(1.0), + eq_mid: AtomicF32::new(1.0), + eq_high: AtomicF32::new(1.0), + filter_mode: AtomicU8::new(0), + filter_cutoff: AtomicF32::new(1.0), + tempo_rate: AtomicF32::new(1.0), + keylock: AtomicBool::new(true), + loop_region: AtomicU64::new(u64::MAX), + scrub: ScrubState::new(), + } + } + + pub fn set_loop(&self, region: Option<(usize, usize)>) { + let packed = match region { + Some((start, end)) => ((start as u64) << 32) | (end as u64 & 0xFFFF_FFFF), + None => u64::MAX, + }; + self.loop_region.store(packed, Ordering::Relaxed); + } + + pub fn loop_region(&self) -> Option<(usize, usize)> { + let packed = self.loop_region.load(Ordering::Relaxed); + if packed == u64::MAX { + return None; + } + Some(((packed >> 32) as usize, (packed & 0xFFFF_FFFF) as usize)) + } + + pub fn filter_mode_u8(&self) -> u8 { + self.filter_mode.load(Ordering::Relaxed) + } + + pub fn set_filter_mode(&self, mode: u8) { + self.filter_mode.store(mode, Ordering::Relaxed); + } + + pub fn transport(&self) -> Transport { + match self.transport.load(Ordering::Relaxed) { + 1 => Transport::Playing, + 2 => Transport::Paused, + _ => Transport::Stopped, + } + } + + pub fn set_transport(&self, t: Transport) { + self.transport.store(t as u8, Ordering::Relaxed); + } + + pub fn request_seek(&self, frame: usize) { + self.seek_request.store(frame as u64, Ordering::Relaxed); + } + + pub fn take_seek(&self) -> Option { + let v = self.seek_request.swap(NO_SEEK, Ordering::Relaxed); + (v != NO_SEEK).then_some(v as usize) + } + + pub fn playhead_frames(&self) -> usize { + self.playhead.load(Ordering::Relaxed) as usize + } + + pub fn total(&self) -> usize { + self.total_frames.load(Ordering::Relaxed) as usize + } +} + +/// Where the audible scrub currently stands. Ported from the timestretch +/// desktop reference deck. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScrubPhase { + /// No scrub engaged; the engine owns playback. + Idle = 0, + /// The pointer holds the platter: the callback's varispeed voice + /// chases the published target. + Active = 1, + /// The drag dropped: the voice glides its momentum toward the settle + /// rate, then hands back to the engine at the predicted landing. + Settling = 2, +} + +/// Shared scrub state machine: the UI publishes the pointer-implied source +/// position while the zoomed waveform is dragged; the audio callback chases +/// it with a raw varispeed reader (bypassing the engine), then owns the +/// post-release momentum glide. The feed thread yields the playhead while +/// any phase is engaged and additionally stops feeding while `Active`. +pub struct ScrubState { + /// `ScrubPhase` as u8 (0/1/2). + phase: AtomicU8, + /// Pointer-target source frame (valid while `Active`). + target_frame: AtomicF64, + /// Rate the release glide eases toward: the deck's tempo rate resumes + /// playback speed, 0.0 spins down to rest. + settle_rate_target: AtomicF64, + /// Voice read position, published by the audio callback every rendered + /// block while engaged; the UI displays it during the glide and uses + /// it as the re-grab base. + voice_frame: AtomicF64, + /// Predicted settle landing frame, published by the callback when a + /// glide starts. + landing: AtomicF64, + /// Bumped with each published landing; the UI consumes each sequence + /// number exactly once to fire the engine warm-start seek. + landing_seq: AtomicU64, +} + +impl ScrubState { + pub fn new() -> Self { + Self { + phase: AtomicU8::new(ScrubPhase::Idle as u8), + target_frame: AtomicF64::new(0.0), + settle_rate_target: AtomicF64::new(0.0), + voice_frame: AtomicF64::new(0.0), + landing: AtomicF64::new(0.0), + landing_seq: AtomicU64::new(0), + } + } + + pub fn phase(&self) -> ScrubPhase { + match self.phase.load(Ordering::Acquire) { + 1 => ScrubPhase::Active, + 2 => ScrubPhase::Settling, + _ => ScrubPhase::Idle, + } + } + + /// Engage the scrub at `frame` (the playhead where the drag started, or + /// the gliding voice position on a mid-settle re-grab). The target is + /// published before the phase so the audio callback never sees a stale + /// target on engage. + pub fn begin(&self, frame: f64) { + self.target_frame.store(frame); + self.voice_frame.store(frame); + self.phase + .store(ScrubPhase::Active as u8, Ordering::Release); + } + + pub fn update_target(&self, frame: f64) { + self.target_frame.store(frame); + } + + /// Release the drag into a momentum glide easing toward `rate_target`. + pub fn release(&self, rate_target: f64) { + self.settle_rate_target.store(rate_target); + self.phase + .store(ScrubPhase::Settling as u8, Ordering::Release); + } + + /// Abort the gesture without a glide (no audio stream to render it). + pub fn cancel(&self) { + self.phase.store(ScrubPhase::Idle as u8, Ordering::Release); + } + + /// Callback-side: the glide reached its landing; hand back to the + /// engine. CAS so a simultaneous re-grab (`begin` on the UI thread) + /// wins over the completion. + pub fn finish_settle(&self) { + let _ = self.phase.compare_exchange( + ScrubPhase::Settling as u8, + ScrubPhase::Idle as u8, + Ordering::AcqRel, + Ordering::Relaxed, + ); + } + + pub fn target(&self) -> f64 { + self.target_frame.load() + } + + pub fn settle_rate_target(&self) -> f64 { + self.settle_rate_target.load() + } + + pub fn publish_voice_frame(&self, frame: f64) { + self.voice_frame.store(frame); + } + + pub fn voice_frame(&self) -> f64 { + self.voice_frame.load() + } + + /// Callback-side: publish the predicted glide landing. The frame is + /// stored before the sequence bump so a consumer that sees the new + /// sequence reads the matching landing. + pub fn publish_landing(&self, frame: f64) { + self.landing.store(frame); + self.landing_seq.fetch_add(1, Ordering::Release); + } + + /// `(sequence, landing frame)` of the most recent glide, for the UI to + /// consume once per sequence. + pub fn landing(&self) -> (u64, f64) { + let seq = self.landing_seq.load(Ordering::Acquire); + (seq, self.landing.load()) + } +} + +/// Mixer state shared between the UI and the audio callback. +pub struct MixerShared { + /// Crossfader position 0..1: 0 = full deck A, 1 = full deck B. + pub crossfader: AtomicF32, + /// Master volume, linear. + pub master: AtomicF32, + /// Master output level (linear peak, 0..1), measured post-master and + /// post-limiter — what actually leaves the stream. Published by the + /// audio callback; fast attack, slow release. + pub master_meter: AtomicF32, + /// Audio-callback load: processing time ÷ buffer duration (EMA), + /// published by the callback. + pub cpu_load: AtomicF32, +} + +impl MixerShared { + pub fn new() -> Self { + Self { + crossfader: AtomicF32::new(0.5), + master: AtomicF32::new(1.0), + master_meter: AtomicF32::new(0.0), + cpu_load: AtomicF32::new(0.0), + } + } +} + +/// Flag for signaling a feed thread to stop. +pub struct StopFlag(AtomicBool); + +impl StopFlag { + pub fn new() -> Self { + Self(AtomicBool::new(false)) + } + + pub fn set(&self) { + self.0.store(true, Ordering::Relaxed); + } + + pub fn is_set(&self) -> bool { + self.0.load(Ordering::Relaxed) + } +} diff --git a/crates/halo/src/waveform/counter.rs b/crates/halo/src/waveform/counter.rs new file mode 100644 index 0000000..f59dfb9 --- /dev/null +++ b/crates/halo/src/waveform/counter.rs @@ -0,0 +1,49 @@ +//! CDJ-style beat counter: `BAR n.b` readout plus a 4-segment indicator +//! with the current beat-in-bar lit. Doubles as a live check of the +//! detected grid — wrong downbeats are immediately visible here. + +use eframe::egui; + +use super::{GridMarks, palette}; + +/// Segment size and gap in points. +const SEG_SIZE: egui::Vec2 = egui::vec2(16.0, 10.0); +const SEG_GAP: f32 = 4.0; +const SEGMENTS: u8 = 4; + +/// Paint the readout + segments. Shows placeholders before the first beat; +/// paints nothing without a usable grid. +pub fn paint_beat_counter(ui: &mut egui::Ui, marks: &GridMarks, position_frames: f64) { + if !marks.is_usable() { + return; + } + let bar_beat = marks.bar_beat(position_frames); + + let text = match bar_beat { + Some((bar, beat)) => format!("BAR {bar:>3}.{beat}"), + None => "BAR -.-".to_string(), + }; + ui.label(egui::RichText::new(text).monospace().strong()); + + let width = SEGMENTS as f32 * SEG_SIZE.x + (SEGMENTS - 1) as f32 * SEG_GAP; + let (rect, _) = ui.allocate_exact_size( + egui::vec2(width, SEG_SIZE.y.max(ui.spacing().interact_size.y)), + egui::Sense::hover(), + ); + let painter = ui.painter(); + let top = rect.center().y - SEG_SIZE.y / 2.0; + for seg in 1..=SEGMENTS { + let x = rect.left() + (seg - 1) as f32 * (SEG_SIZE.x + SEG_GAP); + let seg_rect = egui::Rect::from_min_size(egui::pos2(x, top), SEG_SIZE); + if bar_beat.is_some_and(|(_, beat)| beat == seg) { + painter.rect_filled(seg_rect, 2.0, palette::PLAYHEAD); + } else { + painter.rect_stroke( + seg_rect, + 2.0, + egui::Stroke::new(1.0_f32, palette::TEXT_DIM), + egui::StrokeKind::Inside, + ); + } + } +} diff --git a/crates/halo/src/waveform/lanes.rs b/crates/halo/src/waveform/lanes.rs new file mode 100644 index 0000000..a50b1df --- /dev/null +++ b/crates/halo/src/waveform/lanes.rs @@ -0,0 +1,138 @@ +//! Trigger lanes: three rows (Lighting / Pixels / FX) under the zoomed +//! waveform, sharing its frame→x mapping and centered playhead so the +//! bars scroll in lockstep with the audio. + +use eframe::egui; +use halo_light::cues::{CueSet, LANE_COUNT}; +use halo_light::programmer::{LaneOutput, LaneSource}; + +use super::zoomed::ZoomSpan; +use super::{FrameMap, GridMarks, LANES, palette}; + +/// Height of each lane row in points. +const LANE_ROW_H: f32 = 14.0; +/// Full strip height: three rows plus two 1 pt separators. +const STRIP_HEIGHT: f32 = 3.0 * LANE_ROW_H + 2.0; +/// Vertical inset of a trigger bar within its row. +const BAR_INSET_Y: f32 = 2.5; +/// Bars never collapse below this width at wide zooms. +const MIN_BAR_W: f32 = 2.0; +/// Extra dim applied to every lane when the deck isn't driving lighting. +const INACTIVE_DIM: f32 = 0.30; + +pub struct LanesParams<'a> { + pub cues: &'a CueSet, + pub marks: &'a GridMarks, + pub position_frames: f64, + pub total_frames: usize, + pub sample_rate: u32, + /// This deck currently drives the lighting rig; dim everything when + /// false. + pub lighting_active: bool, + /// Resolved lighting output, Some only for the active lighting deck: + /// a programmer-overridden lane tints its row and renders its cue + /// bars hollow ("this would be playing, but you've taken over"). + pub outputs: Option<&'a [LaneOutput; LANE_COUNT]>, +} + +/// Paint the three trigger lanes. +pub fn paint_lanes(ui: &mut egui::Ui, params: LanesParams<'_>, span: &ZoomSpan) { + let desired_size = egui::vec2(ui.available_width(), STRIP_HEIGHT); + let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::hover()); + let rect = response.rect; + let painter = painter.with_clip_rect(rect); + + painter.rect_filled(rect, 4.0, palette::LANE_BG); + + let dim = |color: egui::Color32, alpha: f32| { + let alpha = if params.lighting_active { + alpha + } else { + alpha * INACTIVE_DIM + }; + color.gamma_multiply(alpha) + }; + + let row_rect = |row: usize| { + let top = rect.top() + row as f32 * (LANE_ROW_H + 1.0); + egui::Rect::from_min_size( + egui::pos2(rect.left(), top), + egui::vec2(rect.width(), LANE_ROW_H), + ) + }; + + for row in 1..LANES.len() { + let y = row_rect(row).top() - 0.5; + painter.line_segment( + [egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)], + egui::Stroke::new(1.0_f32, palette::LANE_SEPARATOR), + ); + } + + let loaded = params.total_frames > 0; + + if loaded { + let span_frames = span.span_frames(params.marks, params.sample_rate); + let map = FrameMap::new(rect, params.position_frames, span_frames); + + for (row, &(lane, _, color)) in LANES.iter().enumerate() { + let rr = row_rect(row); + let overridden = params + .outputs + .is_some_and(|o| o[row].source == LaneSource::Programmer); + if overridden { + painter.rect_filled(rr, 0.0, color.gamma_multiply(0.10)); + } + for c in params + .cues + .visible(lane, map.start_frame(), map.end_frame()) + { + let x0 = map.x(c.start_frame); + let x1 = map.x(c.end_frame()).max(x0 + MIN_BAR_W); + let bar = egui::Rect::from_min_max( + egui::pos2(x0, rr.top() + BAR_INSET_Y), + egui::pos2(x1, rr.bottom() - BAR_INSET_Y), + ); + let alpha = 0.55 + 0.45 * c.intensity.clamp(0.0, 1.0); + if overridden { + painter.rect_stroke( + bar, + 2.0, + egui::Stroke::new(1.0_f32, dim(color, alpha)), + egui::StrokeKind::Inside, + ); + } else { + painter.rect_filled(bar, 2.0, dim(color, alpha)); + } + } + } + } + + // Labels over the bars (no reserved gutter, so the mapping stays + // full-width and pixel-identical to the zoomed view above), with a + // backing wash for legibility. + for (row, &(_, label, color)) in LANES.iter().enumerate() { + let rr = row_rect(row); + let galley = painter.layout_no_wrap( + label.to_owned(), + egui::FontId::monospace(8.0), + dim(color, 0.5), + ); + let pos = egui::pos2(rr.left() + 4.0, rr.center().y - galley.size().y / 2.0); + let backing = egui::Rect::from_min_size(pos, galley.size()).expand2(egui::vec2(2.0, 0.0)); + painter.rect_filled(backing, 2.0, palette::LANE_BG.gamma_multiply(0.8)); + painter.galley(pos, galley, color); + } + + // Continue the zoomed view's centered playhead through the strip. + if loaded { + let center_x = rect.center().x; + painter.line_segment( + [ + egui::pos2(center_x, rect.top()), + egui::pos2(center_x, rect.bottom()), + ], + egui::Stroke::new(2.0_f32, palette::PLAYHEAD), + ); + } +} diff --git a/crates/halo/src/waveform/lanes_editor.rs b/crates/halo/src/waveform/lanes_editor.rs new file mode 100644 index 0000000..aaef1d2 --- /dev/null +++ b/crates/halo/src/waveform/lanes_editor.rs @@ -0,0 +1,444 @@ +//! Direct-manipulation editor for a track's cue lanes: tall rows sharing +//! the zoomed view's frame→x mapping, with drag-to-create (snapped to the +//! beat grid), drag-to-move, edge resize, click/shift multi-select, and a +//! Cmd-drag rubber band. All positions are recomputed through the +//! [`FrameMap`] every frame, so editing stays correct while the timeline +//! plays underneath. + +use std::collections::HashSet; + +use eframe::egui; +use halo_light::cues::{ALL_LANES, CueSet}; + +use super::zoomed::ZoomSpan; +use super::{FrameMap, GridMarks, LANES, overlay_plan, palette}; + +/// Editor lane row height in points (~3× the perform strip's rows). +const EDIT_ROW_H: f32 = 44.0; +const STRIP_HEIGHT: f32 = 3.0 * EDIT_ROW_H + 2.0; +/// Pointer distance to a cue edge that counts as a resize grab. +const EDGE_GRAB_PX: f32 = 5.0; +/// Smallest musical cue duration, in beats (0.1 s without a grid). +const MIN_DUR_BEATS: f64 = 0.25; +const BAR_INSET_Y: f32 = 4.0; +const MIN_BAR_W: f32 = 2.0; +/// Intensity of a freshly drawn cue. +const CREATE_INTENSITY: f32 = 0.8; + +pub struct LanesEditorParams<'a> { + pub marks: &'a GridMarks, + pub position_frames: f64, + pub total_frames: usize, + pub sample_rate: u32, + /// Snap creates/moves/resizes to the nearest beat. + pub snap: bool, +} + +/// Drag state carried between frames. +#[derive(Default)] +pub struct EditorInteraction { + drag: Option, +} + +enum DragKind { + /// Draw a new cue: it exists from the first frame and is resized + /// between the anchor and the pointer. + Create { + id: u64, + anchor: f64, + }, + /// Move every selected cue by the pointer delta (original starts are + /// kept so per-frame clamping never accumulates). + Move { + pointer_start: f64, + orig: Vec<(u64, f64)>, + }, + ResizeL { + id: u64, + }, + ResizeR { + id: u64, + }, + RubberBand { + anchor_frame: f64, + anchor_row: usize, + }, +} + +enum Hit { + EdgeL(u64), + EdgeR(u64), + Body(u64), + Empty, +} + +/// Nearest beat when snapping is on (and a grid exists); the raw frame +/// otherwise. Always non-negative. +pub fn snap_frame(marks: &GridMarks, snap: bool, frame: f64) -> f64 { + let frame = frame.max(0.0); + if !snap || !marks.is_usable() { + return frame; + } + match marks.beat_at_or_before(frame) { + Some(i) => { + let a = marks.frame(i); + let b = if i + 1 < marks.len() { + marks.frame(i + 1) + } else { + a + }; + if frame - a <= b - frame { a } else { b } + } + // Before the first beat: the first beat is the only grid point. + None => marks.frame(0).min(frame).max(0.0), + } +} + +/// Paints and edits the lanes in place. Returns `true` when a mutating +/// gesture completed this frame — the caller persists the cue set then. +pub fn lanes_editor( + ui: &mut egui::Ui, + params: LanesEditorParams<'_>, + span: &ZoomSpan, + cues: &mut CueSet, + selection: &mut HashSet, + ix: &mut EditorInteraction, +) -> bool { + let desired_size = egui::vec2(ui.available_width(), STRIP_HEIGHT); + let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::click_and_drag()); + let rect = response.rect; + let painter = painter.with_clip_rect(rect); + + painter.rect_filled(rect, 4.0, palette::LANE_BG); + + let row_rect = |row: usize| { + let top = rect.top() + row as f32 * (EDIT_ROW_H + 1.0); + egui::Rect::from_min_size( + egui::pos2(rect.left(), top), + egui::vec2(rect.width(), EDIT_ROW_H), + ) + }; + for row in 1..LANES.len() { + let y = row_rect(row).top() - 0.5; + painter.line_segment( + [egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)], + egui::Stroke::new(1.0_f32, palette::LANE_SEPARATOR), + ); + } + + let loaded = params.total_frames > 0; + if !loaded { + for (row, &(_, label, color)) in LANES.iter().enumerate() { + let rr = row_rect(row); + painter.text( + egui::pos2(rr.left() + 6.0, rr.top() + 4.0), + egui::Align2::LEFT_TOP, + label, + egui::FontId::monospace(9.0), + color.gamma_multiply(0.4), + ); + } + return false; + } + + let span_frames = span.span_frames(params.marks, params.sample_rate); + let map = FrameMap::new(rect, params.position_frames, span_frames); + let total = params.total_frames as f64; + let frame_at = |x: f32| map.start_frame() + (x - rect.left()) as f64 / map.px_per_frame(); + let snap = |frame: f64| snap_frame(params.marks, params.snap, frame).min(total); + let min_dur = if params.marks.is_usable() && params.marks.median_beat_frames() > 0.0 { + MIN_DUR_BEATS * params.marks.median_beat_frames() + } else { + 0.1 * params.sample_rate.max(1) as f64 + }; + + let row_at = + |y: f32| (((y - rect.top()) / (EDIT_ROW_H + 1.0)).floor() as isize).clamp(0, 2) as usize; + let hit_test = |cues: &CueSet, pos: egui::Pos2| -> (usize, Hit) { + let row = row_at(pos.y); + let lane = ALL_LANES[row]; + // Edges win over bodies; later (topmost-drawn) cues win ties. + let mut hit = Hit::Empty; + for c in cues.visible(lane, map.start_frame(), map.end_frame()) { + let x0 = map.x(c.start_frame); + let x1 = map.x(c.end_frame()).max(x0 + MIN_BAR_W); + if (pos.x - x0).abs() <= EDGE_GRAB_PX { + hit = Hit::EdgeL(c.id); + } else if (pos.x - x1).abs() <= EDGE_GRAB_PX { + hit = Hit::EdgeR(c.id); + } else if pos.x > x0 && pos.x < x1 { + hit = Hit::Body(c.id); + } + } + (row, hit) + }; + + // Hover cursor feedback (only while not mid-drag). + if ix.drag.is_none() + && let Some(pos) = response.hover_pos() + { + match hit_test(cues, pos).1 { + Hit::EdgeL(_) | Hit::EdgeR(_) => { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + Hit::Body(_) => ui.ctx().set_cursor_icon(egui::CursorIcon::Grab), + Hit::Empty => {} + } + } + + let mut committed = false; + let modifiers = ui.input(|i| i.modifiers); + + if response.drag_started() + && let Some(pos) = response.interact_pointer_pos() + { + let (row, hit) = hit_test(cues, pos); + let lane = ALL_LANES[row]; + ix.drag = match hit { + Hit::Body(id) => { + if !selection.contains(&id) { + selection.clear(); + selection.insert(id); + } + let mut orig: Vec<(u64, f64)> = selection + .iter() + .filter_map(|&id| cues.find(id).map(|(_, c)| (id, c.start_frame))) + .collect(); + orig.sort_by(|a, b| a.1.total_cmp(&b.1)); + Some(DragKind::Move { + pointer_start: frame_at(pos.x), + orig, + }) + } + Hit::EdgeL(id) => { + selection.clear(); + selection.insert(id); + Some(DragKind::ResizeL { id }) + } + Hit::EdgeR(id) => { + selection.clear(); + selection.insert(id); + Some(DragKind::ResizeR { id }) + } + Hit::Empty if modifiers.command => Some(DragKind::RubberBand { + anchor_frame: frame_at(pos.x), + anchor_row: row, + }), + Hit::Empty => { + let anchor = snap(frame_at(pos.x)); + cues.insert(lane, anchor, min_dur, CREATE_INTENSITY) + .map(|id| { + selection.clear(); + selection.insert(id); + DragKind::Create { id, anchor } + }) + } + }; + } + + if response.dragged() + && let Some(pos) = response.interact_pointer_pos() + { + match &ix.drag { + Some(DragKind::Create { id, anchor }) => { + let p = snap(frame_at(pos.x)); + let (lo, hi) = if p < *anchor { + (p, *anchor) + } else { + (*anchor, p) + }; + cues.resize(*id, lo, hi.max(lo + min_dur)); + } + Some(DragKind::Move { + pointer_start, + orig, + }) => { + if let Some(&(_, first_start)) = orig.first() { + let raw_delta = frame_at(pos.x) - pointer_start; + let delta = snap(first_start + raw_delta) - first_start; + // Order matters so grouped cues don't clamp against a + // not-yet-moved neighbor: lead with the travel edge. + if delta >= 0.0 { + for &(id, start) in orig.iter().rev() { + cues.move_cue(id, start + delta); + } + } else { + for &(id, start) in orig.iter() { + cues.move_cue(id, start + delta); + } + } + } + } + Some(DragKind::ResizeL { id }) => { + if let Some((_, c)) = cues.find(*id) { + let end = c.end_frame(); + cues.resize(*id, snap(frame_at(pos.x)).min(end - min_dur), end); + } + } + Some(DragKind::ResizeR { id }) => { + if let Some((_, c)) = cues.find(*id) { + let start = c.start_frame; + cues.resize(*id, start, snap(frame_at(pos.x)).max(start + min_dur)); + } + } + Some(DragKind::RubberBand { .. }) | None => {} + } + } + + if response.drag_stopped() { + match ix.drag.take() { + Some(DragKind::RubberBand { + anchor_frame, + anchor_row, + }) => { + if let Some(pos) = response.interact_pointer_pos() { + let f0 = anchor_frame.min(frame_at(pos.x)); + let f1 = anchor_frame.max(frame_at(pos.x)); + let r0 = anchor_row.min(row_at(pos.y)); + let r1 = anchor_row.max(row_at(pos.y)); + if !modifiers.shift { + selection.clear(); + } + for &lane in &ALL_LANES[r0..=r1] { + for c in cues.visible(lane, f0, f1) { + if c.end_frame() > f0 && c.start_frame < f1 { + selection.insert(c.id); + } + } + } + } + } + Some(_) => committed = true, + None => {} + } + } + + if response.clicked() + && let Some(pos) = response.interact_pointer_pos() + { + match hit_test(cues, pos).1 { + Hit::Body(id) | Hit::EdgeL(id) | Hit::EdgeR(id) => { + if modifiers.shift { + if !selection.remove(&id) { + selection.insert(id); + } + } else { + selection.clear(); + selection.insert(id); + } + } + Hit::Empty => selection.clear(), + } + } + + // Drop selection entries whose cues no longer exist. + selection.retain(|&id| cues.find(id).is_some()); + + // --- painting --- + + // Beat/downbeat ticks across the whole strip, density-adaptive. + if params.marks.is_usable() { + let visible = params + .marks + .visible_range(map.start_frame(), map.end_frame()); + let downbeats = visible + .clone() + .filter(|&i| params.marks.is_downbeat(i)) + .count(); + let plan = overlay_plan(rect.width(), visible.len(), downbeats); + let stride = plan.downbeat_stride as u32; + for i in visible { + let is_downbeat = params.marks.is_downbeat(i); + let stroke = if is_downbeat { + let bar = params.marks.bar_number(i); + if bar == 0 || !(bar - 1).is_multiple_of(stride) { + continue; + } + egui::Stroke::new(1.0_f32, palette::TICK_DOWNBEAT.gamma_multiply(0.35)) + } else { + if !plan.draw_beats { + continue; + } + egui::Stroke::new(1.0_f32, palette::TICK_BEAT.gamma_multiply(0.12)) + }; + let x = map.x(params.marks.frame(i)); + painter.line_segment( + [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], + stroke, + ); + } + } + + for (row, &(lane, label, color)) in LANES.iter().enumerate() { + let rr = row_rect(row); + for c in cues.visible(lane, map.start_frame(), map.end_frame()) { + let x0 = map.x(c.start_frame); + let x1 = map.x(c.end_frame()).max(x0 + MIN_BAR_W); + let bar = egui::Rect::from_min_max( + egui::pos2(x0, rr.top() + BAR_INSET_Y), + egui::pos2(x1, rr.bottom() - BAR_INSET_Y), + ); + painter.rect_filled( + bar, + 3.0, + color.gamma_multiply(0.45 + 0.45 * c.intensity.clamp(0.0, 1.0)), + ); + if selection.contains(&c.id) { + painter.rect_stroke( + bar, + 3.0, + egui::Stroke::new(1.5_f32, egui::Color32::WHITE), + egui::StrokeKind::Outside, + ); + } + } + painter.text( + egui::pos2(rr.left() + 6.0, rr.top() + 4.0), + egui::Align2::LEFT_TOP, + label, + egui::FontId::monospace(9.0), + color.gamma_multiply(0.6), + ); + } + + // Live rubber-band rectangle. + if let ( + Some(DragKind::RubberBand { + anchor_frame, + anchor_row, + }), + Some(pos), + ) = (&ix.drag, response.interact_pointer_pos()) + { + let x0 = map.x(*anchor_frame); + let r0 = row_rect(*anchor_row.min(&row_at(pos.y))); + let r1 = row_rect(*anchor_row.max(&row_at(pos.y))); + let band = egui::Rect::from_min_max( + egui::pos2(x0.min(pos.x), r0.top()), + egui::pos2(x0.max(pos.x), r1.bottom()), + ); + painter.rect_filled( + band, + 0.0, + egui::Color32::from_rgba_premultiplied(60, 90, 140, 40), + ); + painter.rect_stroke( + band, + 0.0, + egui::Stroke::new(1.0_f32, egui::Color32::from_rgb(120, 160, 220)), + egui::StrokeKind::Inside, + ); + } + + // Centered playhead, continuing the zoomed view's. + let center_x = rect.center().x; + painter.line_segment( + [ + egui::pos2(center_x, rect.top()), + egui::pos2(center_x, rect.bottom()), + ], + egui::Stroke::new(2.0_f32, palette::PLAYHEAD), + ); + + committed +} diff --git a/crates/halo/src/waveform/mod.rs b/crates/halo/src/waveform/mod.rs new file mode 100644 index 0000000..536c859 --- /dev/null +++ b/crates/halo/src/waveform/mod.rs @@ -0,0 +1,433 @@ +//! CDJ-style deck waveforms: a zoomed scrolling view with a centered +//! playhead, a full-track overview strip, 3-band RGB peak coloring, and a +//! bar.beat counter. Shared grid/palette machinery lives here; the painters +//! live in the submodules. + +mod counter; +mod lanes; +mod lanes_editor; +mod overview; +mod peaks; +mod zoomed; + +pub use counter::paint_beat_counter; +pub use lanes::{LanesParams, paint_lanes}; +pub use lanes_editor::{EditorInteraction, LanesEditorParams, lanes_editor, snap_frame}; +pub use overview::{OverviewParams, OverviewTexture, paint_overview}; +pub use peaks::BandPeaks; +pub use zoomed::{ScrubGesture, ZoomSpan, ZoomedParams, paint_zoomed}; + +/// Label + color per lane, shared by the perform strip, the Prepare +/// editor, and the programmer UI. +pub(crate) const LANES: [(halo_light::cues::Lane, &str, egui::Color32); + halo_light::cues::LANE_COUNT] = [ + ( + halo_light::cues::Lane::Lighting, + "LGT", + palette::LANE_LIGHTING, + ), + (halo_light::cues::Lane::Pixels, "PXL", palette::LANE_PIXELS), + (halo_light::cues::Lane::Fx, "FX", palette::LANE_FX), +]; + +use eframe::egui; + +/// CDJ-3000-flavored palette shared by the deck painters. +pub(crate) mod palette { + use eframe::egui::Color32; + + /// Panel background, near-black. + pub const BACKGROUND: Color32 = Color32::from_rgb(10, 10, 15); + /// Low band (kick/bass): blue. + pub const BAND_LOW: Color32 = Color32::from_rgb(40, 90, 220); + /// Mid band: amber. + pub const BAND_MID: Color32 = Color32::from_rgb(230, 150, 40); + /// High band (hats/air): near-white. + pub const BAND_HIGH: Color32 = Color32::from_rgb(235, 235, 240); + /// Zoomed-view playhead. + pub const PLAYHEAD: Color32 = Color32::from_rgb(230, 40, 40); + /// Overview position cursor. + pub const CURSOR: Color32 = Color32::WHITE; + /// Regular beat tick. + pub const TICK_BEAT: Color32 = Color32::from_rgba_premultiplied(200, 200, 205, 200); + /// Downbeat (bar start) tick. + pub const TICK_DOWNBEAT: Color32 = Color32::from_rgb(230, 40, 40); + /// Phrase-start tick on the overview (every 16 bars). + pub const TICK_PHRASE: Color32 = Color32::WHITE; + /// Loop region fill. + pub const LOOP_FILL: Color32 = Color32::from_rgba_premultiplied(60, 40, 8, 60); + /// Loop in/out boundary lines and staged loop-in marker. + pub const LOOP_EDGE: Color32 = Color32::from_rgb(235, 160, 40); + /// Placeholder / secondary text. + pub const TEXT_DIM: Color32 = Color32::from_rgb(100, 100, 120); + /// Grey multiply tint that dims the played part of the overview. + pub const PLAYED_TINT: Color32 = Color32::from_rgb(110, 110, 118); + /// Trigger-lane strip background, a step above BACKGROUND so bars pop. + pub const LANE_BG: Color32 = Color32::from_rgb(16, 16, 22); + /// Hairline between lane rows. + pub const LANE_SEPARATOR: Color32 = Color32::from_rgb(28, 28, 36); + /// Lighting lane + active-lighting badge: sky blue, deliberately + /// lighter than BAND_LOW so it reads on the darker lane background. + pub const LANE_LIGHTING: Color32 = Color32::from_rgb(80, 165, 255); + /// Pixels lane. + pub const LANE_PIXELS: Color32 = Color32::from_rgb(240, 95, 175); + /// FX (smoke/pyro) lane: green — amber is the accent, red the playhead. + pub const LANE_FX: Color32 = Color32::from_rgb(70, 210, 130); +} + +/// Center-playhead frame→x mapping shared by the zoomed view and the +/// trigger lanes, so both scroll in perfect lockstep. +pub(crate) struct FrameMap { + start_frame: f64, + span_frames: f64, + px_per_frame: f64, + left: f32, +} + +impl FrameMap { + pub fn new(rect: egui::Rect, position_frames: f64, span_frames: f64) -> Self { + let span_frames = span_frames.max(1.0); + Self { + start_frame: position_frames - span_frames / 2.0, + span_frames, + px_per_frame: rect.width() as f64 / span_frames, + left: rect.left(), + } + } + + pub fn x(&self, frame: f64) -> f32 { + self.left + ((frame - self.start_frame) * self.px_per_frame) as f32 + } + + pub fn start_frame(&self) -> f64 { + self.start_frame + } + + pub fn end_frame(&self) -> f64 { + self.start_frame + self.span_frames + } + + pub fn px_per_frame(&self) -> f64 { + self.px_per_frame + } +} + +/// Beats in a bar for the counter/phrase math. The Stage 10 grid carries a +/// 4/4 prior; bars with other beat counts wrap modulo 4 for display. +const BEATS_PER_BAR: usize = 4; +/// Bars per phrase for the overview's emphasized ticks. +const BARS_PER_PHRASE: u32 = 16; + +/// Frame-based beat-grid cache for the painters, built once per track load +/// from the detected [`timestretch::BeatGrid`]. Everything a painter needs +/// per frame is a binary search plus indexed lookups — no per-frame +/// allocation. +pub struct GridMarks { + /// Fractional-sample beat positions, ascending. + frames: Vec, + /// Downbeat flag per beat. + downbeat: Vec, + /// 1-based bar number per beat; 0 for beats before the first downbeat. + bar_of: Vec, + /// 1-based beat-within-bar per beat (1..=4). + beat_in_bar: Vec, + /// Median beat interval in frames (0.0 when fewer than 2 beats). + median_beat_frames: f64, +} + +impl GridMarks { + pub fn empty() -> Self { + Self { + frames: Vec::new(), + downbeat: Vec::new(), + bar_of: Vec::new(), + beat_in_bar: Vec::new(), + median_beat_frames: 0.0, + } + } + + pub fn from_grid(grid: ×tretch::BeatGrid) -> Self { + let frames = grid.beats.clone(); + let mut downbeat = vec![false; frames.len()]; + for &idx in &grid.downbeats { + if let Some(flag) = downbeat.get_mut(idx) { + *flag = true; + } + } + + let first_downbeat = downbeat.iter().position(|&d| d); + let mut bar_of = vec![0u32; frames.len()]; + let mut beat_in_bar = vec![0u8; frames.len()]; + let mut bar = 0u32; + let mut last_downbeat: Option = None; + for i in 0..frames.len() { + if downbeat[i] { + bar += 1; + last_downbeat = Some(i); + } + bar_of[i] = bar; + beat_in_bar[i] = match (last_downbeat, first_downbeat) { + // At or after a downbeat: count forward from it. + (Some(d), _) => ((i - d) % BEATS_PER_BAR + 1) as u8, + // Before the first downbeat: count backward from it, so the + // beat right before a bar start reads as beat 4. + (None, Some(d0)) => { + ((BEATS_PER_BAR - (d0 - i) % BEATS_PER_BAR) % BEATS_PER_BAR + 1) as u8 + } + // No downbeats detected at all: free-running 1..=4. + (None, None) => (i % BEATS_PER_BAR + 1) as u8, + }; + } + + let median_beat_frames = if frames.len() >= 2 { + let mut intervals: Vec = frames.windows(2).map(|w| w[1] - w[0]).collect(); + intervals.sort_by(f64::total_cmp); + intervals[intervals.len() / 2] + } else { + 0.0 + }; + + Self { + frames, + downbeat, + bar_of, + beat_in_bar, + median_beat_frames, + } + } + + pub fn len(&self) -> usize { + self.frames.len() + } + + /// Whether there is enough grid to draw/count against. + pub fn is_usable(&self) -> bool { + self.frames.len() >= 2 + } + + pub fn frame(&self, i: usize) -> f64 { + self.frames[i] + } + + pub fn is_downbeat(&self, i: usize) -> bool { + self.downbeat[i] + } + + /// Whether beat `i` starts a 16-bar phrase (bars 1, 17, 33, …). + pub fn is_phrase_start(&self, i: usize) -> bool { + self.downbeat[i] && self.bar_of[i] % BARS_PER_PHRASE == 1 + } + + /// 1-based bar number of beat `i` (0 before the first downbeat). Tick + /// thinning anchors on this so strided ticks stay phrase-aligned. + pub fn bar_number(&self, i: usize) -> u32 { + self.bar_of[i] + } + + pub fn downbeat_count(&self) -> usize { + self.downbeat.iter().filter(|&&d| d).count() + } + + /// Median beat interval in frames; 0.0 without a usable grid. + pub fn median_beat_frames(&self) -> f64 { + self.median_beat_frames + } + + /// Indices of beats within `[start_frame, end_frame)`. + pub fn visible_range(&self, start_frame: f64, end_frame: f64) -> std::ops::Range { + let lo = self.frames.partition_point(|&f| f < start_frame); + let hi = self.frames.partition_point(|&f| f < end_frame); + lo..hi + } + + /// Index of the last beat at or before `frame`. + pub fn beat_at_or_before(&self, frame: f64) -> Option { + self.frames.partition_point(|&f| f <= frame).checked_sub(1) + } + + /// Frame of the bar start (downbeat) at or before `frame`. + pub fn bar_start(&self, frame: f64) -> Option { + let mut i = self.beat_at_or_before(frame)?; + loop { + if self.downbeat[i] { + return Some(self.frames[i]); + } + i = i.checked_sub(1)?; + } + } + + /// `(bar, beat_in_bar)` at a playback position: bar is 1-based (0 while + /// before the first downbeat), beat is 1..=4. `None` before the first + /// beat or without a usable grid. + pub fn bar_beat(&self, frame: f64) -> Option<(u32, u8)> { + if !self.is_usable() { + return None; + } + let i = self.beat_at_or_before(frame)?; + Some((self.bar_of[i], self.beat_in_bar[i])) + } +} + +/// Minimum pixel spacing between adjacent grid lines before a marker tier +/// is drawn at full density. +pub(crate) const MIN_GRID_SPACING_PX: f32 = 6.0; + +/// How an overlay adapts the grid to the available width: whether +/// individual beats fit, and how many bars each drawn downbeat tick spans +/// (1 = every downbeat; 2/4/8… = thinned when bars are too dense). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct OverlayPlan { + pub draw_beats: bool, + pub downbeat_stride: usize, +} + +/// Chooses the densest tier that keeps adjacent ticks at least +/// [`MIN_GRID_SPACING_PX`] apart. Downbeats never disappear entirely — +/// they thin to every 2^k bars instead, so a full-length track still shows +/// its phrase structure. +pub(crate) fn overlay_plan(width_px: f32, beat_count: usize, downbeat_count: usize) -> OverlayPlan { + let draw_beats = beat_count >= 2 && width_px / beat_count as f32 >= MIN_GRID_SPACING_PX; + let mut downbeat_stride = 1usize; + if downbeat_count > 0 { + let mut spacing = width_px / downbeat_count as f32; + while spacing < MIN_GRID_SPACING_PX && downbeat_stride < (1 << 16) { + downbeat_stride *= 2; + spacing *= 2.0; + } + } + OverlayPlan { + draw_beats, + downbeat_stride, + } +} + +/// Paint the "load a file" placeholder shared by both waveform panels. +pub(crate) fn paint_placeholder(painter: &egui::Painter, rect: egui::Rect) { + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + "Load an audio file to see waveform", + egui::FontId::proportional(14.0), + palette::TEXT_DIM, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overlay_never_empty_on_long_tracks() { + // A 6-minute 123 BPM extended mix in a default-width window: 707 + // beats, 177 downbeats at ~800 px. Beats and per-bar downbeats are + // both too dense (1.1 px / 4.5 px), but thinned bar ticks remain. + let plan = overlay_plan(800.0, 707, 177); + assert!(!plan.draw_beats); + assert_eq!(plan.downbeat_stride, 2, "expected 2-bar tick thinning"); + assert!(800.0 / (177.0 / plan.downbeat_stride as f32) >= MIN_GRID_SPACING_PX); + } + + #[test] + fn overlay_full_density_on_short_loops() { + // A 16-beat loop at any reasonable width: draw everything. + let plan = overlay_plan(800.0, 16, 4); + assert!(plan.draw_beats); + assert_eq!(plan.downbeat_stride, 1); + } + + #[test] + fn overlay_downbeats_only_at_medium_density() { + // ~200 beats at 800 px: beats smear (4 px), bars fit (16 px). + let plan = overlay_plan(800.0, 200, 50); + assert!(!plan.draw_beats); + assert_eq!(plan.downbeat_stride, 1); + } + + #[test] + fn overlay_stride_grows_for_very_long_tracks() { + // A 2-hour DJ mix: 17k beats, 4.3k downbeats at 800 px needs + // 64-bar tick thinning (4300/64 = 67 ticks -> ~12 px). + let plan = overlay_plan(800.0, 17_000, 4_300); + assert!(!plan.draw_beats); + assert_eq!(plan.downbeat_stride, 64); + } + + #[test] + fn overlay_handles_no_downbeats() { + let plan = overlay_plan(800.0, 100, 0); + assert_eq!(plan.downbeat_stride, 1); + } + + /// A grid of 16 beats at one-second intervals (sr 100 for readable + /// numbers), downbeats every 4 beats starting at beat index 2. + fn test_grid() -> GridMarks { + let mut grid = timestretch::BeatGrid::empty(100); + grid.beats = (0..16).map(|i| i as f64 * 100.0).collect(); + grid.downbeats = vec![2, 6, 10, 14]; + grid.bpm = 60.0; + GridMarks::from_grid(&grid) + } + + #[test] + fn bar_beat_counts_within_bars() { + let marks = test_grid(); + // Beat idx 2 is the first downbeat -> bar 1, beat 1. + assert_eq!(marks.bar_beat(200.0), Some((1, 1))); + assert_eq!(marks.bar_beat(300.0), Some((1, 2))); + assert_eq!(marks.bar_beat(599.0), Some((1, 4))); + assert_eq!(marks.bar_beat(600.0), Some((2, 1))); + // Position between beats belongs to the last passed beat. + assert_eq!(marks.bar_beat(250.0), Some((1, 1))); + } + + #[test] + fn bar_beat_before_first_downbeat_counts_backward() { + let marks = test_grid(); + // Beats 0 and 1 precede the first downbeat (bar 0); the beat right + // before a bar start reads as beat 4. + assert_eq!(marks.bar_beat(0.0), Some((0, 3))); + assert_eq!(marks.bar_beat(100.0), Some((0, 4))); + // Before the first beat entirely: no reading. + assert_eq!(marks.bar_beat(-1.0), None); + } + + #[test] + fn bar_beat_without_downbeats_free_runs() { + let mut grid = timestretch::BeatGrid::empty(100); + grid.beats = (0..8).map(|i| i as f64 * 100.0).collect(); + let marks = GridMarks::from_grid(&grid); + assert_eq!(marks.bar_beat(0.0), Some((0, 1))); + assert_eq!(marks.bar_beat(400.0), Some((0, 1))); + assert_eq!(marks.bar_beat(700.0), Some((0, 4))); + } + + #[test] + fn visible_range_is_half_open() { + let marks = test_grid(); + assert_eq!(marks.visible_range(200.0, 600.0), 2..6); + assert_eq!(marks.visible_range(-50.0, 50.0), 0..1); + assert_eq!(marks.visible_range(2000.0, 3000.0), 16..16); + } + + #[test] + fn phrase_starts_on_bar_1_17_33() { + let mut grid = timestretch::BeatGrid::empty(100); + grid.beats = (0..80).map(|i| i as f64 * 100.0).collect(); + grid.downbeats = (0..20).map(|b| b * 4).collect(); + let marks = GridMarks::from_grid(&grid); + let phrase_beats: Vec = (0..marks.len()) + .filter(|&i| marks.is_phrase_start(i)) + .collect(); + // Bars 1 and 17 -> beat indices 0 and 64. + assert_eq!(phrase_beats, vec![0, 64]); + } + + #[test] + fn median_interval_ignores_outliers() { + let mut grid = timestretch::BeatGrid::empty(100); + // Regular 100-frame intervals with one 500-frame gap. + grid.beats = vec![0.0, 100.0, 200.0, 300.0, 800.0, 900.0, 1000.0]; + let marks = GridMarks::from_grid(&grid); + assert_eq!(marks.median_beat_frames(), 100.0); + } +} diff --git a/crates/halo/src/waveform/overview.rs b/crates/halo/src/waveform/overview.rs new file mode 100644 index 0000000..63339de --- /dev/null +++ b/crates/halo/src/waveform/overview.rs @@ -0,0 +1,215 @@ +//! Full-track overview strip, CDJ-3000 style: a bottom-anchored 3-band +//! amplitude silhouette ("side-on" view) with the played portion dimmed, +//! hot cue markers, loop region, position cursor, a hover seek-preview, +//! and click-to-seek. + +use eframe::egui; + +use super::peaks::{BandPeaks, PeakLevel}; +use super::{paint_placeholder, palette}; + +/// Strip height in points. +const STRIP_HEIGHT: f32 = 48.0; +/// Texture height in pixels (2x the strip for retina crispness). +const TEX_HEIGHT: usize = 96; +/// Perceptual lift applied to column heights (amp^gamma): keeps quiet +/// intros/breakdowns visible in the silhouette. 1.0 = linear. +const OVERVIEW_GAMMA: f32 = 0.85; +/// Fraction of the strip height the silhouette may use; the headroom above +/// holds the hot cue markers. +const WAVE_HEIGHT_FRAC: f32 = 0.60; +/// Hot cue marker triangle size in points. +const CUE_MARKER_W: f32 = 8.0; +const CUE_MARKER_H: f32 = 6.0; + +/// The full track pre-rendered once per load from the coarsest pyramid +/// level as a bottom-anchored silhouette (column height = band peak), +/// bands overlaid per column. Drawn twice per frame: full-width with a +/// white tint, then UV-clipped to the playhead with a grey tint that dims +/// the played part (CDJ-style). +pub struct OverviewTexture { + tex: egui::TextureHandle, +} + +impl OverviewTexture { + pub fn from_peaks(ctx: &egui::Context, peaks: &BandPeaks) -> Self { + Self { + tex: ctx.load_texture( + "waveform_overview", + render_level(peaks.coarsest()), + egui::TextureOptions::LINEAR, + ), + } + } +} + +/// Rasterizes a peak level into a transparent-background image, one +/// bottom-anchored column per bucket (CDJ-3000 "side-on" silhouette: +/// height = band peak, everything rises from the baseline). Bands paint +/// in high → mid → low order — low on top — so kick-heavy passages read +/// blue and highs surface only where the lows drop out (CDJ RGB +/// semantics; in a dense master the high band's *peak* is near full scale +/// everywhere and would bury the image if on top). +fn render_level(level: &PeakLevel) -> egui::ColorImage { + let width = level.num_buckets().max(1); + let mut image = egui::ColorImage::new([width, TEX_HEIGHT], egui::Color32::TRANSPARENT); + let band_colors = [palette::BAND_LOW, palette::BAND_MID, palette::BAND_HIGH]; + for x in 0..level.num_buckets() { + for (band, &color) in band_colors.iter().enumerate().rev() { + let pos = level.pos[band][x].clamp(0.0, 1.0); + let neg = level.neg[band][x].clamp(-1.0, 0.0); + let amp = pos.max(-neg).powf(OVERVIEW_GAMMA); + let top_f = (1.0 - amp * WAVE_HEIGHT_FRAC) * TEX_HEIGHT as f32; + let top = top_f.ceil().clamp(0.0, TEX_HEIGHT as f32) as usize; + for y in top..TEX_HEIGHT { + image.pixels[y * width + x] = color; + } + // Anti-aliased top edge: the partial pixel above the solid run + // gets coverage-scaled alpha instead of a hard step. + let coverage = top as f32 - top_f; + if coverage > 0.0 && top > 0 { + image.pixels[(top - 1) * width + x] = color.linear_multiply(coverage); + } + } + } + image +} + +pub struct OverviewParams<'a> { + pub texture: Option<&'a OverviewTexture>, + /// Playback position as a fraction of the track (0..1). + pub progress: f32, + pub total_frames: usize, + pub loop_region: Option<(usize, usize)>, + pub loop_in: Option, + /// Hot cue slots (source frames); markers draw above the wave for each + /// defined slot. Pass `&[]` for players without hot cues. + pub hot_cues: &'a [Option], +} + +/// Paint the overview strip. Returns the click-to-seek target as a track +/// fraction, if clicked. +pub fn paint_overview(ui: &mut egui::Ui, params: OverviewParams<'_>) -> Option { + let desired_size = egui::vec2(ui.available_width(), STRIP_HEIGHT); + let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::click()); + let rect = response.rect; + + painter.rect_filled(rect, 4.0, palette::BACKGROUND); + + let Some(texture) = params.texture else { + paint_placeholder(&painter, rect); + return None; + }; + + let progress = params.progress.clamp(0.0, 1.0); + let cursor_x = rect.left() + rect.width() * progress; + + // Baseline the silhouette sits on, visible through silent passages. + painter.line_segment( + [ + egui::pos2(rect.left(), rect.bottom() - 1.0), + egui::pos2(rect.right(), rect.bottom() - 1.0), + ], + egui::Stroke::new(1.0_f32, egui::Color32::from_rgb(50, 50, 62)), + ); + + // Unplayed across the full width, played dimmed on top (UV-clipped). + painter.image( + texture.tex.id(), + rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + if progress > 0.0 { + painter.image( + texture.tex.id(), + egui::Rect::from_min_max(rect.left_top(), egui::pos2(cursor_x, rect.bottom())), + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(progress, 1.0)), + palette::PLAYED_TINT, + ); + } + + let frac_x = |frame: usize| { + rect.left() + rect.width() * (frame as f32 / params.total_frames.max(1) as f32) + }; + + // Loop region / staged loop-in. + if let Some((start, end)) = params.loop_region { + let (x0, x1) = (frac_x(start), frac_x(end)); + painter.rect_filled( + egui::Rect::from_min_max(egui::pos2(x0, rect.top()), egui::pos2(x1, rect.bottom())), + 0.0, + palette::LOOP_FILL, + ); + for x in [x0, x1] { + painter.line_segment( + [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], + egui::Stroke::new(1.0_f32, palette::LOOP_EDGE), + ); + } + } else if let Some(start) = params.loop_in { + let x = frac_x(start); + painter.line_segment( + [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], + egui::Stroke::new(1.0_f32, palette::LOOP_EDGE), + ); + } + + // Hot cue markers: numbered amber triangles pointing down at the wave + // ceiling, in the headroom the 60% height cap reserves. + if params.total_frames > 0 { + let ceiling_y = rect.bottom() - WAVE_HEIGHT_FRAC * rect.height(); + for (slot, cue) in params.hot_cues.iter().enumerate() { + let Some(frame) = cue else { continue }; + let x = frac_x(*frame); + let tip = egui::pos2(x, ceiling_y - 1.0); + painter.add(egui::Shape::convex_polygon( + vec![ + egui::pos2(x - CUE_MARKER_W / 2.0, tip.y - CUE_MARKER_H), + egui::pos2(x + CUE_MARKER_W / 2.0, tip.y - CUE_MARKER_H), + tip, + ], + palette::LOOP_EDGE, + egui::Stroke::NONE, + )); + painter.text( + egui::pos2(x, tip.y - CUE_MARKER_H - 1.0), + egui::Align2::CENTER_BOTTOM, + format!("{}", slot + 1), + egui::FontId::proportional(8.0), + palette::LOOP_EDGE, + ); + } + } + + // Hover seek-preview: a ghost of the position cursor under the pointer + // shows where a click will land. + let response = response.on_hover_cursor(egui::CursorIcon::PointingHand); + if let Some(hover) = response.hover_pos() { + let x = hover.x.clamp(rect.left(), rect.right()); + painter.line_segment( + [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], + egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 90), + ), + ); + } + + // Position cursor. + painter.line_segment( + [ + egui::pos2(cursor_x, rect.top()), + egui::pos2(cursor_x, rect.bottom()), + ], + egui::Stroke::new(1.0_f32, palette::CURSOR), + ); + + // Click-to-seek. + if response.clicked() + && let Some(pos) = response.interact_pointer_pos() + { + return Some(((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0)); + } + None +} diff --git a/crates/halo/src/waveform/peaks.rs b/crates/halo/src/waveform/peaks.rs new file mode 100644 index 0000000..95eb996 --- /dev/null +++ b/crates/halo/src/waveform/peaks.rs @@ -0,0 +1,294 @@ +//! 3-band multi-resolution waveform peaks. +//! +//! The mono mix is split into low/mid/high bands (two 2nd-order Butterworth +//! crossovers at 200 Hz and 2 kHz) and reduced to per-bucket min/max peaks +//! at a base resolution of 150 buckets/s, with a halving pyramid down to +//! ~1024 buckets so every zoom level can paint at roughly one bucket per +//! pixel without rescanning samples. + +/// Number of frequency bands (low / mid / high). +pub const NUM_BANDS: usize = 3; + +/// Base peak resolution in buckets per second of audio. +const BASE_BUCKETS_PER_SEC: f64 = 150.0; + +/// The pyramid stops halving once a level has at most this many buckets; +/// the coarsest level is what the full-track overview texture rasterizes. +const COARSEST_TARGET_BUCKETS: usize = 1024; + +/// Low/mid crossover frequency in Hz. +const CROSSOVER_LOW_HZ: f64 = 200.0; +/// Mid/high crossover frequency in Hz. +const CROSSOVER_HIGH_HZ: f64 = 2_000.0; + +/// One resolution level: per-band positive/negative peaks per bucket. +pub struct PeakLevel { + /// Buckets per second of audio at this level. + pub buckets_per_sec: f64, + /// Positive peaks, `[band][bucket]`, bands ordered low/mid/high. + pub pos: [Vec; NUM_BANDS], + /// Negative peaks (≤ 0), same layout. + pub neg: [Vec; NUM_BANDS], +} + +impl PeakLevel { + pub fn num_buckets(&self) -> usize { + self.pos[0].len() + } +} + +/// The full pyramid: `levels[0]` is the finest (150 buckets/s), each +/// following level halves the bucket count. +pub struct BandPeaks { + levels: Vec, +} + +/// 2nd-order IIR section, transposed direct form II. +struct Biquad { + b0: f64, + b1: f64, + b2: f64, + a1: f64, + a2: f64, + z1: f64, + z2: f64, +} + +impl Biquad { + /// RBJ Butterworth low-pass (Q = 1/sqrt(2)). + fn lowpass(cutoff_hz: f64, sample_rate: f64) -> Self { + let (b0, b1, b2, a0, a1, a2) = { + let w0 = std::f64::consts::TAU * cutoff_hz / sample_rate; + let alpha = w0.sin() / std::f64::consts::SQRT_2; + let cos_w0 = w0.cos(); + ( + (1.0 - cos_w0) / 2.0, + 1.0 - cos_w0, + (1.0 - cos_w0) / 2.0, + 1.0 + alpha, + -2.0 * cos_w0, + 1.0 - alpha, + ) + }; + Self::normalized(b0, b1, b2, a0, a1, a2) + } + + /// RBJ Butterworth high-pass (Q = 1/sqrt(2)). + fn highpass(cutoff_hz: f64, sample_rate: f64) -> Self { + let (b0, b1, b2, a0, a1, a2) = { + let w0 = std::f64::consts::TAU * cutoff_hz / sample_rate; + let alpha = w0.sin() / std::f64::consts::SQRT_2; + let cos_w0 = w0.cos(); + ( + (1.0 + cos_w0) / 2.0, + -(1.0 + cos_w0), + (1.0 + cos_w0) / 2.0, + 1.0 + alpha, + -2.0 * cos_w0, + 1.0 - alpha, + ) + }; + Self::normalized(b0, b1, b2, a0, a1, a2) + } + + fn normalized(b0: f64, b1: f64, b2: f64, a0: f64, a1: f64, a2: f64) -> Self { + Self { + b0: b0 / a0, + b1: b1 / a0, + b2: b2 / a0, + a1: a1 / a0, + a2: a2 / a0, + z1: 0.0, + z2: 0.0, + } + } + + #[inline] + fn process(&mut self, x: f64) -> f64 { + let y = self.b0 * x + self.z1; + self.z1 = self.b1 * x - self.a1 * y + self.z2; + self.z2 = self.b2 * x - self.a2 * y; + y + } +} + +impl BandPeaks { + /// Compute the pyramid from interleaved samples (mixed to mono for + /// display). One O(n) pass over the samples; offline, so the biquads' + /// phase lag is irrelevant. + pub fn compute(samples: &[f32], channels: usize, sample_rate: u32) -> Self { + let channels = channels.max(1); + let num_frames = samples.len() / channels; + let sr = sample_rate.max(1) as f64; + let num_buckets = ((num_frames as f64 * BASE_BUCKETS_PER_SEC / sr).ceil() as usize).max(1); + + let mut pos: [Vec; NUM_BANDS] = std::array::from_fn(|_| vec![0.0; num_buckets]); + let mut neg: [Vec; NUM_BANDS] = std::array::from_fn(|_| vec![0.0; num_buckets]); + + // Crossover network: low = LP200, high = HP2k, mid = LP2k(HP200). + let mut lp_low = Biquad::lowpass(CROSSOVER_LOW_HZ, sr); + let mut hp_low = Biquad::highpass(CROSSOVER_LOW_HZ, sr); + let mut lp_high = Biquad::lowpass(CROSSOVER_HIGH_HZ, sr); + let mut hp_high = Biquad::highpass(CROSSOVER_HIGH_HZ, sr); + + let inv_channels = 1.0 / channels as f64; + let bucket_scale = BASE_BUCKETS_PER_SEC / sr; + for f in 0..num_frames { + let mut mono = 0.0f64; + for c in 0..channels { + mono += samples[f * channels + c] as f64; + } + mono *= inv_channels; + + let low = lp_low.process(mono); + let above_low = hp_low.process(mono); + let mid = lp_high.process(above_low); + let high = hp_high.process(above_low); + + let bucket = ((f as f64 * bucket_scale) as usize).min(num_buckets - 1); + for (band, sample) in [low, mid, high].into_iter().enumerate() { + let s = sample as f32; + if s > pos[band][bucket] { + pos[band][bucket] = s; + } + if s < neg[band][bucket] { + neg[band][bucket] = s; + } + } + } + + let mut levels = vec![PeakLevel { + buckets_per_sec: BASE_BUCKETS_PER_SEC, + pos, + neg, + }]; + while levels.last().unwrap().num_buckets() > COARSEST_TARGET_BUCKETS { + levels.push(halve(levels.last().unwrap())); + } + Self { levels } + } + + /// The level whose bucket density best matches `px_per_sec`: the + /// finest level whose buckets are at least one pixel wide (largest + /// `buckets_per_sec <= px_per_sec`), so bars never go sub-pixel. When + /// the view is coarser than every level (zoomed way out), the coarsest + /// level is the best available. + pub fn level_for(&self, px_per_sec: f32) -> &PeakLevel { + self.levels + .iter() + .filter(|l| l.buckets_per_sec <= px_per_sec as f64) + .max_by(|a, b| a.buckets_per_sec.total_cmp(&b.buckets_per_sec)) + .unwrap_or_else(|| self.levels.last().unwrap()) + } + + /// The coarsest level (≤ ~1024 buckets), used for the overview texture. + pub fn coarsest(&self) -> &PeakLevel { + self.levels.last().unwrap() + } +} + +/// Pairwise reduction: max of positive peaks, min of negative peaks. +fn halve(level: &PeakLevel) -> PeakLevel { + let n = level.num_buckets().div_ceil(2); + let mut pos: [Vec; NUM_BANDS] = std::array::from_fn(|_| Vec::with_capacity(n)); + let mut neg: [Vec; NUM_BANDS] = std::array::from_fn(|_| Vec::with_capacity(n)); + for band in 0..NUM_BANDS { + for pair in level.pos[band].chunks(2) { + pos[band].push(pair.iter().copied().fold(f32::MIN, f32::max)); + } + for pair in level.neg[band].chunks(2) { + neg[band].push(pair.iter().copied().fold(f32::MAX, f32::min)); + } + } + PeakLevel { + buckets_per_sec: level.buckets_per_sec / 2.0, + pos, + neg, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Interleaved stereo test signal: a 60 Hz sine (low band) plus an + /// 8 kHz sine (high band), 10 s at 44.1 kHz. + fn test_signal(secs: f64) -> Vec { + let sr = 44_100.0; + let n = (secs * sr) as usize; + let mut out = Vec::with_capacity(n * 2); + for i in 0..n { + let t = i as f64 / sr; + let s = (0.8 * (std::f64::consts::TAU * 60.0 * t).sin() + + 0.3 * (std::f64::consts::TAU * 8_000.0 * t).sin()) as f32; + out.push(s); + out.push(s); + } + out + } + + #[test] + fn base_resolution_is_150_per_sec() { + let peaks = BandPeaks::compute(&test_signal(10.0), 2, 44_100); + assert_eq!(peaks.levels[0].num_buckets(), 1500); + assert_eq!(peaks.levels[0].buckets_per_sec, 150.0); + } + + #[test] + fn pyramid_halves_down_to_coarsest_target() { + // 60 s -> 9000 base buckets -> 4500 -> 2250 -> 1125 -> 563. + let peaks = BandPeaks::compute(&test_signal(60.0), 2, 44_100); + let counts: Vec = peaks.levels.iter().map(|l| l.num_buckets()).collect(); + assert_eq!(counts, vec![9000, 4500, 2250, 1125, 563]); + assert!(peaks.coarsest().num_buckets() <= COARSEST_TARGET_BUCKETS); + } + + #[test] + fn halving_preserves_global_extrema() { + let peaks = BandPeaks::compute(&test_signal(30.0), 2, 44_100); + for band in 0..NUM_BANDS { + let global_max = |l: &PeakLevel| l.pos[band].iter().copied().fold(f32::MIN, f32::max); + let global_min = |l: &PeakLevel| l.neg[band].iter().copied().fold(f32::MAX, f32::min); + for pair in peaks.levels.windows(2) { + assert_eq!(global_max(&pair[0]), global_max(&pair[1])); + assert_eq!(global_min(&pair[0]), global_min(&pair[1])); + } + } + } + + #[test] + fn bands_separate_low_and_high_content() { + let peaks = BandPeaks::compute(&test_signal(5.0), 2, 44_100); + let level = &peaks.levels[0]; + // Skip the first buckets (filter settling). + let mid_bucket = level.num_buckets() / 2; + let low = level.pos[0][mid_bucket]; + let mid = level.pos[1][mid_bucket]; + let high = level.pos[2][mid_bucket]; + assert!(low > 0.6, "60 Hz should land in the low band, got {low}"); + assert!(high > 0.2, "8 kHz should land in the high band, got {high}"); + assert!( + mid < 0.15, + "neither test tone is in the mid band, got {mid}" + ); + } + + #[test] + fn level_for_picks_finest_level_with_pixel_wide_buckets() { + let peaks = BandPeaks::compute(&test_signal(60.0), 2, 44_100); + // Levels: 150, 75, 37.5, 18.75, ~9.4 buckets/s. + assert_eq!(peaks.level_for(200.0).buckets_per_sec, 150.0); + assert_eq!(peaks.level_for(150.0).buckets_per_sec, 150.0); + assert_eq!(peaks.level_for(100.0).buckets_per_sec, 75.0); + assert_eq!(peaks.level_for(40.0).buckets_per_sec, 37.5); + // Below the coarsest density: the coarsest level is the closest fit. + assert_eq!(peaks.level_for(1.0).buckets_per_sec, 9.375); + } + + #[test] + fn empty_input_yields_single_bucket() { + let peaks = BandPeaks::compute(&[], 2, 44_100); + assert_eq!(peaks.levels[0].num_buckets(), 1); + assert_eq!(peaks.coarsest().num_buckets(), 1); + } +} diff --git a/crates/halo/src/waveform/zoomed.rs b/crates/halo/src/waveform/zoomed.rs new file mode 100644 index 0000000..d0c312b --- /dev/null +++ b/crates/halo/src/waveform/zoomed.rs @@ -0,0 +1,275 @@ +//! Zoomed scrolling waveform: playhead fixed at horizontal center, the +//! track scrolls underneath. 3-band bars are tessellated per frame from +//! the pyramid level closest to one bucket per pixel (the content moves +//! every frame, so a texture would need constant re-upload; a few thousand +//! rects at the 30 fps repaint cap is cheaper). Beat/downbeat edge ticks, +//! loop overlay, and drag-to-scrub. + +use eframe::egui; + +use super::peaks::BandPeaks; +use super::{FrameMap, GridMarks, overlay_plan, paint_placeholder, palette}; + +/// View height in points. +const VIEW_HEIGHT: f32 = 160.0; +/// Edge tick heights in points. +const TICK_BEAT_PX: f32 = 8.0; +const TICK_DOWNBEAT_PX: f32 = 14.0; +/// Scroll distance (points) per zoom step on wheel/trackpad zoom. +const SCROLL_PER_ZOOM_STEP: f32 = 40.0; + +/// Zoom presets: bars when a grid exists, seconds otherwise. Same index +/// into both tables so toggling grids keeps a comparable span. +const BAR_PRESETS: [f64; 5] = [1.0, 2.0, 4.0, 8.0, 16.0]; +const SEC_PRESETS: [f64; 5] = [2.0, 4.0, 8.0, 16.0, 32.0]; +const DEFAULT_PRESET: usize = 2; + +/// Visible-span state for the zoomed view. +pub struct ZoomSpan { + idx: usize, + /// Accumulated scroll distance toward the next wheel-zoom step. + scroll_accum: f32, +} + +impl Default for ZoomSpan { + fn default() -> Self { + Self { + idx: DEFAULT_PRESET, + scroll_accum: 0.0, + } + } +} + +impl ZoomSpan { + pub fn zoom_in(&mut self) { + self.idx = self.idx.saturating_sub(1); + } + + pub fn zoom_out(&mut self) { + self.idx = (self.idx + 1).min(BAR_PRESETS.len() - 1); + } + + /// Label for the zoom control, e.g. "4 BARS" or "8 s". + pub fn label(&self, has_grid: bool) -> String { + if has_grid { + let bars = BAR_PRESETS[self.idx]; + if bars == 1.0 { + "1 BAR".to_string() + } else { + format!("{bars:.0} BARS") + } + } else { + format!("{:.0} s", SEC_PRESETS[self.idx]) + } + } + + /// Visible span in source frames. + pub(crate) fn span_frames(&self, marks: &GridMarks, sample_rate: u32) -> f64 { + let beat = marks.median_beat_frames(); + if marks.is_usable() && beat > 0.0 { + BAR_PRESETS[self.idx] * 4.0 * beat + } else { + SEC_PRESETS[self.idx] * sample_rate.max(1) as f64 + } + } + + /// Step the zoom from accumulated scroll input; scrolling up zooms in. + fn apply_scroll(&mut self, delta_y: f32) { + self.scroll_accum += delta_y; + while self.scroll_accum >= SCROLL_PER_ZOOM_STEP { + self.zoom_in(); + self.scroll_accum -= SCROLL_PER_ZOOM_STEP; + } + while self.scroll_accum <= -SCROLL_PER_ZOOM_STEP { + self.zoom_out(); + self.scroll_accum += SCROLL_PER_ZOOM_STEP; + } + } +} + +/// Drag lifecycle of the zoomed view, for audible scrubbing. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ScrubGesture { + /// Drag began this frame: grab the platter. + Grab, + /// Pointer moved while dragging: relative scrub distance in source + /// frames (content follows the pointer, so dragging right moves the + /// position backward). + Drag(f64), + /// The drag ended this frame; the scrub voice carries its own + /// momentum, so no velocity payload is needed. + Release, +} + +pub struct ZoomedParams<'a> { + pub peaks: Option<&'a BandPeaks>, + pub marks: &'a GridMarks, + pub position_frames: f64, + pub total_frames: usize, + pub sample_rate: u32, + pub loop_region: Option<(usize, usize)>, + pub loop_in: Option, +} + +/// Paint the zoomed view. Reports the drag lifecycle while the user +/// scrubs: the grab as [`ScrubGesture::Grab`], pointer deltas as +/// [`ScrubGesture::Drag`], and the drop as [`ScrubGesture::Release`]. +pub fn paint_zoomed( + ui: &mut egui::Ui, + params: ZoomedParams<'_>, + span: &mut ZoomSpan, +) -> Option { + let desired_size = egui::vec2(ui.available_width(), VIEW_HEIGHT); + let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::drag()); + let rect = response.rect; + + painter.rect_filled(rect, 4.0, palette::BACKGROUND); + + let (Some(peaks), true) = (params.peaks, params.total_frames > 0) else { + paint_placeholder(&painter, rect); + return None; + }; + + if response.hovered() { + span.apply_scroll(ui.input(|i| i.smooth_scroll_delta.y)); + } + + let span_frames = span.span_frames(params.marks, params.sample_rate); + let map = FrameMap::new(rect, params.position_frames, span_frames); + + // 3-band bars from the pyramid level nearest one bucket per pixel. + let level = peaks.level_for((map.px_per_frame() * params.sample_rate as f64) as f32); + let frames_per_bucket = params.sample_rate as f64 / level.buckets_per_sec; + let first_bucket = (map.start_frame() / frames_per_bucket).floor().max(0.0) as usize; + let last_bucket = + ((map.end_frame() / frames_per_bucket).ceil() as usize).min(level.num_buckets()); + let center_y = rect.center().y; + let half_height = rect.height() * 0.45; + let band_colors = [palette::BAND_LOW, palette::BAND_MID, palette::BAND_HIGH]; + for b in first_bucket..last_bucket { + let x0 = map.x(b as f64 * frames_per_bucket).max(rect.left()); + let x1 = map.x((b + 1) as f64 * frames_per_bucket).min(rect.right()); + if x1 <= x0 { + continue; + } + // Low paints last (on top): see overview::render_level. + for (band, &color) in band_colors.iter().enumerate().rev() { + let pos = level.pos[band][b].clamp(0.0, 1.0); + let neg = level.neg[band][b].clamp(-1.0, 0.0); + if pos == 0.0 && neg == 0.0 { + continue; + } + painter.rect_filled( + egui::Rect::from_min_max( + egui::pos2(x0, center_y - pos * half_height), + egui::pos2(x1, center_y - neg * half_height), + ), + 0.0, + color, + ); + } + } + + // Loop overlay: fill plus full-height boundary lines where in view. + if let Some((start, end)) = params.loop_region { + let x0 = map.x(start as f64); + let x1 = map.x(end as f64); + if x1 > rect.left() && x0 < rect.right() { + painter.rect_filled( + egui::Rect::from_min_max( + egui::pos2(x0.max(rect.left()), rect.top()), + egui::pos2(x1.min(rect.right()), rect.bottom()), + ), + 0.0, + palette::LOOP_FILL, + ); + } + for x in [x0, x1] { + if x >= rect.left() && x <= rect.right() { + painter.line_segment( + [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], + egui::Stroke::new(1.5_f32, palette::LOOP_EDGE), + ); + } + } + } else if let Some(start) = params.loop_in { + let x = map.x(start as f64); + if x >= rect.left() && x <= rect.right() { + painter.line_segment( + [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], + egui::Stroke::new(1.5_f32, palette::LOOP_EDGE), + ); + } + } + + // Beat/downbeat edge ticks (top and bottom), density-adaptive. + if params.marks.is_usable() { + let visible = params + .marks + .visible_range(map.start_frame(), map.end_frame()); + let downbeats = visible + .clone() + .filter(|&i| params.marks.is_downbeat(i)) + .count(); + let plan = overlay_plan(rect.width(), visible.len(), downbeats); + let stride = plan.downbeat_stride as u32; + for i in visible { + let is_downbeat = params.marks.is_downbeat(i); + let (height, stroke) = if is_downbeat { + let bar = params.marks.bar_number(i); + if bar == 0 || !(bar - 1).is_multiple_of(stride) { + continue; + } + ( + TICK_DOWNBEAT_PX, + egui::Stroke::new(2.0_f32, palette::TICK_DOWNBEAT), + ) + } else { + if !plan.draw_beats { + continue; + } + (TICK_BEAT_PX, egui::Stroke::new(1.0_f32, palette::TICK_BEAT)) + }; + let x = map.x(params.marks.frame(i)); + painter.line_segment( + [ + egui::pos2(x, rect.top()), + egui::pos2(x, rect.top() + height), + ], + stroke, + ); + painter.line_segment( + [ + egui::pos2(x, rect.bottom() - height), + egui::pos2(x, rect.bottom()), + ], + stroke, + ); + } + } + + // Fixed centered playhead — the one full-height line in this view. + let center_x = rect.center().x; + painter.line_segment( + [ + egui::pos2(center_x, rect.top()), + egui::pos2(center_x, rect.bottom()), + ], + egui::Stroke::new(2.0_f32, palette::PLAYHEAD), + ); + + // Drag-to-scrub: content follows the pointer. `drag_started` must be + // checked before `dragged` (both are true on the first frame; the + // first-frame delta is ~0 and safely dropped). + if response.drag_started() { + return Some(ScrubGesture::Grab); + } + if response.drag_stopped() { + return Some(ScrubGesture::Release); + } + if response.dragged() { + let dx = response.drag_delta().x; + return Some(ScrubGesture::Drag(-(dx as f64) / map.px_per_frame())); + } + None +} diff --git a/crates/halo/src/worker.rs b/crates/halo/src/worker.rs new file mode 100644 index 0000000..192a632 --- /dev/null +++ b/crates/halo/src/worker.rs @@ -0,0 +1,105 @@ +//! Background library workers: the analysis queue (one track at a time, +//! decoded and analyzed at the file's native rate) and one-shot folder +//! imports. Each worker opens its own SQLite connection. + +use std::path::PathBuf; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use crate::decoder::decode_file; +use crate::library::Library; + +pub enum WorkerEvent { + /// A track's analysis landed in the DB. + Analyzed(i64), + /// A folder import finished with this many audio files seen. + Imported(usize), +} + +/// Long-lived analysis worker: drains the unanalyzed queue, then idles until +/// woken (or polls every few seconds as a fallback). Exits when the wake +/// channel disconnects. +pub fn spawn_analysis_worker( + db_path: PathBuf, + wake_rx: mpsc::Receiver<()>, + event_tx: mpsc::Sender, +) -> thread::JoinHandle<()> { + thread::spawn(move || { + let lib = match Library::open(&db_path) { + Ok(l) => l, + Err(e) => { + log::error!("analysis worker: {e}"); + return; + } + }; + loop { + match lib.next_unanalyzed() { + Ok(Some((id, path))) => { + match analyze_one(&lib, id, &path) { + Ok(()) => { + if event_tx.send(WorkerEvent::Analyzed(id)).is_err() { + return; + } + } + Err(e) => { + log::warn!("analysis of {}: {e}", path.display()); + // Park a failure marker so the queue can't spin + // on an undecodable file. + let _ = lib.store_analysis_failure(id); + } + } + } + Ok(None) => match wake_rx.recv_timeout(Duration::from_secs(5)) { + Ok(()) | Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => return, + }, + Err(e) => { + log::error!("analysis queue: {e}"); + return; + } + } + } + }) +} + +fn analyze_one(lib: &Library, id: i64, path: &std::path::Path) -> Result<(), String> { + let decoded = decode_file(path)?; + let signal = timestretch::downmix_to_mid(&decoded.samples, 2); + let start = std::time::Instant::now(); + let artifact = timestretch::analyze_for_dj(&signal, decoded.sample_rate); + log::info!( + "Analyzed {}: {:.1} BPM, confidence {:.2} ({:.2}s)", + path.display(), + artifact.bpm, + artifact.confidence, + start.elapsed().as_secs_f64() + ); + lib.store_analysis(id, &artifact) +} + +/// One-shot folder import on its own thread; wakes the analysis worker when +/// done. +pub fn spawn_folder_import( + db_path: PathBuf, + dir: PathBuf, + wake_tx: mpsc::Sender<()>, + event_tx: mpsc::Sender, +) { + thread::spawn(move || { + let lib = match Library::open(&db_path) { + Ok(l) => l, + Err(e) => { + log::error!("import worker: {e}"); + return; + } + }; + match lib.import_folder(&dir) { + Ok(n) => { + let _ = event_tx.send(WorkerEvent::Imported(n)); + let _ = wake_tx.send(()); + } + Err(e) => log::error!("import {}: {e}", dir.display()), + } + }); +} diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml deleted file mode 100644 index 07cbb05..0000000 --- a/crates/ui/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "halo-ui" -version = "0.1.0" -authors = ["Rob Morgan "] -edition = "2021" - -[dependencies] -halo-core = { path = "../core" } -halo-fixtures = { path = "../fixtures" } -eframe = "0.33.3" -rand = "0.10.0" -chrono = "0.4.43" -parking_lot = "0.12.5" -egui_plot = "0.34.0" -rfd = "0.17.2" -tokio = { version = "1.49.0", features = ["full"] } diff --git a/crates/ui/assets/Inter-Medium.otf b/crates/ui/assets/Inter-Medium.otf deleted file mode 100644 index ca7bfcd..0000000 Binary files a/crates/ui/assets/Inter-Medium.otf and /dev/null differ diff --git a/crates/ui/assets/Inter-README.txt b/crates/ui/assets/Inter-README.txt deleted file mode 100644 index 3078f19..0000000 --- a/crates/ui/assets/Inter-README.txt +++ /dev/null @@ -1,72 +0,0 @@ -Inter Variable Font -=================== - -This download contains Inter as both a variable font and static fonts. - -Inter is a variable font with these axes: - slnt - wght - -This means all the styles are contained in a single file: - Inter-VariableFont_slnt,wght.ttf - -If your app fully supports variable fonts, you can now pick intermediate styles -that aren’t available as static fonts. Not all apps support variable fonts, and -in those cases you can use the static font files for Inter: - static/Inter-Thin.ttf - static/Inter-ExtraLight.ttf - static/Inter-Light.ttf - static/Inter-Regular.ttf - static/Inter-Medium.ttf - static/Inter-SemiBold.ttf - static/Inter-Bold.ttf - static/Inter-ExtraBold.ttf - static/Inter-Black.ttf - -Get started ------------ - -1. Install the font files you want to use - -2. Use your app's font picker to view the font family and all the -available styles - -Learn more about variable fonts -------------------------------- - - https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts - https://variablefonts.typenetwork.com - https://medium.com/variable-fonts - -In desktop apps - - https://theblog.adobe.com/can-variable-fonts-illustrator-cc - https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts - -Online - - https://developers.google.com/fonts/docs/getting_started - https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide - https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts - -Installing fonts - - MacOS: https://support.apple.com/en-us/HT201749 - Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux - Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows - -Android Apps - - https://developers.google.com/fonts/docs/android - https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts - -License -------- -Please read the full license text (OFL.txt) to understand the permissions, -restrictions and requirements for usage, redistribution, and modification. - -You can use them in your products & projects – print or digital, -commercial or otherwise. - -This isn't legal advice, please consider consulting a lawyer and see the full -license for all details. diff --git a/crates/ui/src/cue.rs b/crates/ui/src/cue.rs deleted file mode 100644 index 991c74e..0000000 --- a/crates/ui/src/cue.rs +++ /dev/null @@ -1,243 +0,0 @@ -use std::time::Duration; - -use eframe::egui; -use halo_core::{ConsoleCommand, PlaybackState}; -use tokio::sync::mpsc; - -use crate::state::ConsoleState; - -/// A panel that shows the list of cues. -#[derive(Default)] -pub struct CuePanel { - playback_state: PlaybackState, - /// Track if we need to scroll to the current cue - needs_scroll_to_current: bool, - /// Track the last cue index to detect changes - last_cue_index: usize, -} - -impl CuePanel { - pub fn render( - &mut self, - ui: &mut eframe::egui::Ui, - state: &ConsoleState, - _console_tx: &mpsc::UnboundedSender, - ) { - // Cue UI with margin - let frame = eframe::egui::Frame::default().inner_margin(10.0); - - frame.show(ui, |ui| { - ui.heading("Cues"); - - let cue_lists = &state.cue_lists; - - if let Some(current_list) = cue_lists.get(state.current_cue_list_index) { - ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.label("Current List:"); - - // Left arrow button - if ui.button("←").clicked() { - let _ = _console_tx.send(ConsoleCommand::SelectPreviousCueList); - } - - ui.strong(egui::RichText::new(¤t_list.name).size(16.0)); - - // Right arrow button - if ui.button("→").clicked() { - let _ = _console_tx.send(ConsoleCommand::SelectNextCueList); - } - }); - - ui.add_space(5.0); - - ui.horizontal(|ui| { - ui.label("Audio:"); - ui.strong( - egui::RichText::new( - ¤t_list - .audio_file - .clone() - .map(|path| { - // Extract just the filename from the path - let filename = std::path::Path::new(&path) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(&path); - - // Truncate to 50 characters if longer - if filename.len() > 50 { - format!("{}...", &filename[..47]) - } else { - filename.to_string() - } - }) - .unwrap_or_else(|| "None".to_string()), - ) - .size(16.0), - ); - }); - }); - } - - // Column headers for cue list - ui.add_space(10.0); - ui.horizontal(|ui| { - ui.add_sized( - [100.0, 20.0], - egui::Label::new(egui::RichText::new("Cue").strong()), - ); - ui.add_sized( - [100.0, 20.0], - egui::Label::new(egui::RichText::new("Timecode").strong()), - ); - ui.add_sized( - [80.0, 20.0], - egui::Label::new(egui::RichText::new("Duration").strong()), - ); - ui.add_sized( - [200.0, 20.0], - egui::Label::new(egui::RichText::new("Progress").strong()), - ); - }); - ui.separator(); - - // Display cues with neat alignment and timecode - if let Some(current_list) = cue_lists.get(state.current_cue_list_index) { - let cues = ¤t_list.cues; - - // Check if we need to scroll to the current cue - let current_cue_changed = self.playback_state != state.playback_state - || (state.playback_state == PlaybackState::Playing - && self.needs_scroll_to_current) - || (state.playback_state == PlaybackState::Playing - && self.last_cue_index != state.current_cue_index); - - if current_cue_changed { - self.needs_scroll_to_current = false; - self.last_cue_index = state.current_cue_index; - } - - // Create scroll area with auto-scroll capability - let mut scroll_area = egui::ScrollArea::vertical(); - - // If we need to scroll to current cue and it's playing, scroll to it - if current_cue_changed && state.playback_state == PlaybackState::Playing { - let target_cue_index = state.current_cue_index; - if target_cue_index < cues.len() { - // Calculate scroll position with some padding to center the cue - let cue_row_height = 22.0; - let visible_height = ui.available_height(); - let target_scroll_offset = - (target_cue_index as f32 * cue_row_height).max(0.0).min( - (target_cue_index as f32 * cue_row_height) - (visible_height / 2.0), - ); - scroll_area = scroll_area.vertical_scroll_offset(target_scroll_offset); - } - } - - scroll_area.show(ui, |ui| { - for (cue_index, cue) in cues.iter().enumerate() { - ui.horizontal(|ui| { - // Check if this is the current active cue - let is_current_cue = cue_index == state.current_cue_index - && state.playback_state == PlaybackState::Playing; - - let active_color = if is_current_cue { - egui::Color32::from_rgb(100, 200, 100) // Green for current cue when - // playing - } else { - ui.style().visuals.text_color() // Default color for all other cues - }; - - // Cue name with fixed width and truncation - ui.scope(|ui| { - ui.style_mut().spacing.item_spacing.x = 0.0; - let truncated_name = if cue.name.len() > 12 { - format!("{}...", &cue.name[..9]) - } else { - cue.name.clone() - }; - ui.add_sized( - [100.0, 20.0], - egui::Label::new( - egui::RichText::new(truncated_name) - .color(active_color) - .strong(), - ), - ); - }); - - // Timecode marker (estimated position in the timeline) - let timecode = if let Some(timecode) = &cue.timecode { - timecode - } else { - &"N/A".to_string() - }; - - ui.add_sized( - [100.0, 20.0], - egui::Label::new( - egui::RichText::new(timecode) - .color(active_color) - .monospace(), - ), - ); - - // Duration with fixed width - ui.add_sized( - [80.0, 20.0], - egui::Label::new( - egui::RichText::new(Self::format_duration(cue.fade_time)) - .color(active_color) - .monospace(), - ), - ); - - // Progress bar - only show progress for the current cue - let progress = if is_current_cue { - state.current_cue_progress - } else { - 0.0 - }; - - ui.add_sized( - [200.0, 20.0], - egui::ProgressBar::new(progress) - .desired_width(200.0) - .desired_height(20.0) - .corner_radius(0.0) - .animate(is_current_cue) - .fill(if is_current_cue { - egui::Color32::from_rgb(75, 2, 245) // Blue for current cue - // progress - } else { - egui::Color32::from_rgb(100, 100, 100) // Gray for inactive - // cues - }), - ); - }); - ui.add_space(2.0); // Spacing between cue rows - } - }); - } - }); - } - - pub fn set_playback_state(&mut self, state: PlaybackState) { - if self.playback_state != state { - self.playback_state = state; - // Trigger auto-scroll when playback state changes to playing - if state == PlaybackState::Playing { - self.needs_scroll_to_current = true; - } - } - } - - fn format_duration(duration: Duration) -> String { - let total_secs = duration.as_secs(); - let minutes = total_secs / 60; - let seconds = total_secs % 60; - format!("{:02}:{:02}", minutes, seconds) - } -} diff --git a/crates/ui/src/cue_editor.rs b/crates/ui/src/cue_editor.rs deleted file mode 100644 index a890a8c..0000000 --- a/crates/ui/src/cue_editor.rs +++ /dev/null @@ -1,463 +0,0 @@ -use eframe::egui; -use halo_core::{ConsoleCommand, CueList}; -use tokio::sync::mpsc; - -use crate::state::ConsoleState; - -pub struct CueEditor { - selected_cue_list_index: Option, - selected_cue_index: Option, - new_cue_list_name: String, - new_cue_name: String, - new_fade_time: f64, - new_timecode: String, - - // Confirmation dialog state - show_delete_cue_dialog: bool, - show_delete_cue_list_dialog: bool, - cue_to_delete: Option<(usize, usize)>, // (list_index, cue_index) - cue_list_to_delete: Option, -} - -impl Default for CueEditor { - fn default() -> Self { - Self { - selected_cue_list_index: Some(0), - selected_cue_index: None, - new_cue_list_name: String::new(), - new_cue_name: String::new(), - new_fade_time: 3.0, - new_timecode: "00:00:00:00".to_string(), - show_delete_cue_dialog: false, - show_delete_cue_list_dialog: false, - cue_to_delete: None, - cue_list_to_delete: None, - } - } -} - -impl CueEditor { - pub fn new() -> Self { - Self::default() - } - - pub fn render( - &mut self, - ctx: &egui::Context, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - ) { - egui::SidePanel::right("right_panel").show(ctx, |ui| { - self.render_cue_lists_panel(ui, state, console_tx); - }); - - egui::CentralPanel::default().show(ctx, |ui| { - self.render_cues_panel(ui, state, console_tx); - }); - - // Render confirmation dialogs - self.render_confirmation_dialogs(ctx, state, console_tx); - } - - fn render_confirmation_dialogs( - &mut self, - ctx: &egui::Context, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - ) { - // Delete cue confirmation dialog - if self.show_delete_cue_dialog { - if let Some((list_idx, cue_idx)) = self.cue_to_delete { - if let Some(cue_list) = state.cue_lists.get(list_idx) { - if let Some(cue) = cue_list.cues.get(cue_idx) { - egui::Window::new("Delete Cue") - .collapsible(false) - .resizable(false) - .show(ctx, |ui| { - ui.label(format!( - "Are you sure you want to delete cue \"{}\"?", - cue.name - )); - ui.label("This action cannot be undone."); - - ui.horizontal(|ui| { - if ui.button("Cancel").clicked() { - self.show_delete_cue_dialog = false; - self.cue_to_delete = None; - } - - if ui.button("Delete").clicked() { - let _ = console_tx.send(ConsoleCommand::DeleteCue { - list_index: list_idx, - cue_index: cue_idx, - }); - self.show_delete_cue_dialog = false; - self.cue_to_delete = None; - } - }); - }); - } - } - } - } - - // Delete cue list confirmation dialog - if self.show_delete_cue_list_dialog { - if let Some(list_idx) = self.cue_list_to_delete { - if let Some(cue_list) = state.cue_lists.get(list_idx) { - egui::Window::new("Delete Cue List") - .collapsible(false) - .resizable(false) - .show(ctx, |ui| { - ui.label(format!( - "Are you sure you want to delete cue list \"{}\"?", - cue_list.name - )); - ui.label(format!( - "This will also delete all {} cues in this list.", - cue_list.cues.len() - )); - ui.label("This action cannot be undone."); - - ui.horizontal(|ui| { - if ui.button("Cancel").clicked() { - self.show_delete_cue_list_dialog = false; - self.cue_list_to_delete = None; - } - - if ui.button("Delete").clicked() { - let _ = console_tx.send(ConsoleCommand::DeleteCueList { - list_index: list_idx, - }); - self.show_delete_cue_list_dialog = false; - self.cue_list_to_delete = None; - - // Reset selection if we deleted the selected cue list - if self.selected_cue_list_index == Some(list_idx) { - self.selected_cue_list_index = None; - self.selected_cue_index = None; - } - } - }); - }); - } - } - } - } - - fn render_cue_lists_panel( - &mut self, - ui: &mut egui::Ui, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - ) { - ui.vertical(|ui| { - ui.heading("Cue Lists"); - - // Add new cue list - ui.horizontal(|ui| { - ui.label("Name:"); - ui.text_edit_singleline(&mut self.new_cue_list_name); - - let name_valid = !self.new_cue_list_name.is_empty(); - if ui - .add_enabled(name_valid, egui::Button::new("Add Cue List")) - .clicked() - { - let _ = console_tx.send(ConsoleCommand::SetCueLists { - cue_lists: vec![CueList { - name: std::mem::take(&mut self.new_cue_list_name), - cues: Vec::new(), - audio_file: None, - }], - }); - } - }); - - ui.separator(); - - // List of cue lists - egui::ScrollArea::vertical().show(ui, |ui| { - let cue_lists = &state.cue_lists; - - for (idx, cue_list) in cue_lists.iter().enumerate() { - let is_selected = self.selected_cue_list_index == Some(idx); - - ui.horizontal(|ui| { - // Fixed width for cue list name - ui.allocate_ui_with_layout( - egui::Vec2::new(ui.available_width() - 30.0, 0.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - if ui.selectable_label(is_selected, &cue_list.name).clicked() { - self.selected_cue_list_index = Some(idx); - self.selected_cue_index = None; // Reset cue selection when - // changing lists - } - }, - ); - - // Fixed width for delete button - ui.allocate_ui_with_layout( - egui::Vec2::new(25.0, 0.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - if ui.button("🗑").clicked() { - self.cue_list_to_delete = Some(idx); - self.show_delete_cue_list_dialog = true; - } - }, - ); - }); - } - }); - - // Audio file section for selected cue list - if let Some(cue_list_idx) = self.selected_cue_list_index { - if let Some(cue_list) = state.cue_lists.get(cue_list_idx) { - ui.separator(); - ui.heading("Audio File"); - - ui.horizontal(|ui| { - if let Some(audio_file) = &cue_list.audio_file { - // Extract filename from path - let filename = std::path::Path::new(audio_file) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(audio_file); - - let label = ui.label(format!("📁 {}", filename)); - label.on_hover_text(audio_file); - } else { - ui.label("No audio file selected"); - } - }); - - ui.horizontal(|ui| { - if ui.button("Browse").clicked() { - // TODO: Implement file picker - ui.label("File picker not yet implemented"); - } - - if ui.button("Clear").clicked() { - let _ = console_tx.send(ConsoleCommand::SetCueListAudioFile { - list_index: cue_list_idx, - audio_file: None, - }); - } - }); - } - } - }); - } - - fn render_cues_panel( - &mut self, - ui: &mut egui::Ui, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - ) { - ui.vertical(|ui| { - ui.heading("Cues"); - - if let Some(cue_list_idx) = self.selected_cue_list_index { - if let Some(cue_list) = state.cue_lists.get(cue_list_idx) { - // Add new cue - ui.horizontal(|ui| { - ui.label("Name:"); - ui.text_edit_singleline(&mut self.new_cue_name); - ui.label("Fade Time:"); - ui.add(egui::DragValue::new(&mut self.new_fade_time).speed(0.1)); - ui.label("Timecode:"); - ui.text_edit_singleline(&mut self.new_timecode); - - let name_valid = !self.new_cue_name.is_empty(); - if ui - .add_enabled(name_valid, egui::Button::new("Add Cue")) - .clicked() - { - let _ = console_tx.send(ConsoleCommand::AddCue { - list_index: cue_list_idx, - name: std::mem::take(&mut self.new_cue_name), - fade_time: self.new_fade_time, - timecode: if self.new_timecode.is_empty() { - None - } else { - Some(std::mem::take(&mut self.new_timecode)) - }, - is_blocking: false, - }); - // Reset the timecode field - self.new_timecode = "00:00:00:00".to_string(); - } - }); - - ui.separator(); - - // Cue table - self.render_cue_table(ui, cue_list, cue_list_idx, console_tx); - } - } else { - ui.label("Please select a cue list from the right panel"); - } - }); - } - - fn render_cue_table( - &mut self, - ui: &mut egui::Ui, - cue_list: &CueList, - cue_list_idx: usize, - console_tx: &mpsc::UnboundedSender, - ) { - egui::ScrollArea::vertical().show(ui, |ui| { - egui::Grid::new("cue_table") - .num_columns(5) - .spacing([20.0, 4.0]) - .show(ui, |ui| { - // Header row with fixed widths - ui.allocate_ui_with_layout( - egui::Vec2::new(300.0, 0.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| ui.label("Name"), - ); - ui.allocate_ui_with_layout( - egui::Vec2::new(100.0, 0.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| ui.label("Fade Time (s)"), - ); - ui.allocate_ui_with_layout( - egui::Vec2::new(120.0, 0.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| ui.label("Timecode"), - ); - ui.allocate_ui_with_layout( - egui::Vec2::new(80.0, 0.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| ui.label("Blocking"), - ); - ui.allocate_ui_with_layout( - egui::Vec2::new(60.0, 0.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| ui.label("Actions"), - ); - ui.end_row(); - - // Cue rows - for (idx, cue) in cue_list.cues.iter().enumerate() { - let mut cue_name = cue.name.clone(); - let mut fade_time = cue.fade_time.as_secs_f64(); - let mut timecode = cue.timecode.clone().unwrap_or_default(); - let mut is_blocking = cue.is_blocking; - - // Name column - lots of space - ui.allocate_ui_with_layout( - egui::Vec2::new(300.0, 0.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - if ui.text_edit_singleline(&mut cue_name).lost_focus() { - if cue_name != cue.name { - let _ = console_tx.send(ConsoleCommand::UpdateCue { - list_index: cue_list_idx, - cue_index: idx, - name: cue_name.clone(), - fade_time, - timecode: if timecode.is_empty() { - None - } else { - Some(timecode.clone()) - }, - is_blocking, - }); - } - } - }, - ); - - // Fade time column - ui.allocate_ui_with_layout( - egui::Vec2::new(100.0, 0.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - if ui - .add(egui::DragValue::new(&mut fade_time).speed(0.1)) - .changed() - { - let _ = console_tx.send(ConsoleCommand::UpdateCue { - list_index: cue_list_idx, - cue_index: idx, - name: cue_name.clone(), - fade_time, - timecode: if timecode.is_empty() { - None - } else { - Some(timecode.clone()) - }, - is_blocking, - }); - } - }, - ); - - // Timecode column - ui.allocate_ui_with_layout( - egui::Vec2::new(120.0, 0.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - if ui.text_edit_singleline(&mut timecode).lost_focus() { - let _ = console_tx.send(ConsoleCommand::UpdateCue { - list_index: cue_list_idx, - cue_index: idx, - name: cue_name.clone(), - fade_time, - timecode: if timecode.is_empty() { - None - } else { - Some(timecode.clone()) - }, - is_blocking, - }); - } - }, - ); - - // Blocking column - ui.allocate_ui_with_layout( - egui::Vec2::new(80.0, 0.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - if ui.checkbox(&mut is_blocking, "").changed() { - let _ = console_tx.send(ConsoleCommand::UpdateCue { - list_index: cue_list_idx, - cue_index: idx, - name: cue_name.clone(), - fade_time, - timecode: if timecode.is_empty() { - None - } else { - Some(timecode.clone()) - }, - is_blocking, - }); - } - }, - ); - - // Actions column - ui.allocate_ui_with_layout( - egui::Vec2::new(60.0, 0.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - if ui.button("🗑").clicked() { - self.cue_to_delete = Some((cue_list_idx, idx)); - self.show_delete_cue_dialog = true; - } - }, - ); - - ui.end_row(); - } - }); - }); - } -} diff --git a/crates/ui/src/fader.rs b/crates/ui/src/fader.rs deleted file mode 100644 index 199e242..0000000 --- a/crates/ui/src/fader.rs +++ /dev/null @@ -1,46 +0,0 @@ -use eframe::egui; -use halo_core::ConsoleCommand; -use tokio::sync::mpsc; - -use crate::state::ConsoleState; - -pub fn render( - ui: &mut eframe::egui::Ui, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, -) { - egui::CentralPanel::default().show(ui.ctx(), |ui| { - ui.vertical(|ui| { - ui.heading("Faders"); - - // Master fader - ui.horizontal(|ui| { - ui.label("Master:"); - let mut master = 100.0; - if ui - .add(egui::Slider::new(&mut master, 0.0..=100.0).text("Master")) - .changed() - { - // TODO: Implement master fader via message passing - } - }); - - ui.separator(); - - // Individual faders - ui.heading("Individual Faders"); - for (idx, (_, fixture)) in state.fixtures.iter().enumerate() { - ui.horizontal(|ui| { - ui.label(format!("{}: {}", idx + 1, fixture.name)); - let mut dimmer = 100.0; - if ui - .add(egui::Slider::new(&mut dimmer, 0.0..=100.0).text("Dimmer")) - .changed() - { - // TODO: Implement individual fader via message passing - } - }); - } - }); - }); -} diff --git a/crates/ui/src/fixture.rs b/crates/ui/src/fixture.rs deleted file mode 100644 index bb8a537..0000000 --- a/crates/ui/src/fixture.rs +++ /dev/null @@ -1,205 +0,0 @@ -use eframe::egui::{self, Color32, CornerRadius, Rect, Stroke, Vec2}; -use halo_core::ConsoleCommand; -use halo_fixtures::FixtureType; -use tokio::sync::mpsc; - -use crate::state::ConsoleState; - -const FIXTURE_TYPE_COLORS: [(FixtureType, Color32); 7] = [ - (FixtureType::MovingHead, Color32::from_rgb(255, 165, 0)), // Orange - (FixtureType::PAR, Color32::from_rgb(0, 255, 255)), // Cyan - (FixtureType::Wash, Color32::from_rgb(255, 0, 255)), // Magenta - (FixtureType::Pinspot, Color32::from_rgb(255, 255, 0)), // Yellow - (FixtureType::LEDBar, Color32::from_rgb(0, 255, 0)), // Green - (FixtureType::PixelBar, Color32::from_rgb(255, 20, 147)), // Deep Pink - (FixtureType::Smoke, Color32::from_rgb(128, 128, 128)), // Gray -]; - -pub fn render( - ui: &mut eframe::egui::Ui, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, -) { - egui::CentralPanel::default().show(ui.ctx(), |ui| { - ui.vertical(|ui| { - ui.heading("Fixtures"); - - // Convert to vector and sort by fixture ID for consistent ordering - let mut fixtures: Vec<_> = state.fixtures.iter().collect(); - fixtures.sort_by_key(|(_, f)| f.id); - - // Fixture grid - egui::ScrollArea::vertical().show(ui, |ui| { - for (idx, (_, fixture)) in fixtures.iter().enumerate() { - ui.horizontal(|ui| { - ui.label(format!("{}: {}", idx + 1, fixture.name)); - ui.label(format!("Profile: {}", fixture.profile_id)); - ui.label(format!("Channels: {}", fixture.channels.len())); - - // Show channel values - ui.label("Values:"); - for (channel_idx, channel) in fixture.channels.iter().enumerate() { - ui.label(format!("{}:{}", channel.name, channel.value)); - } - }); - } - }); - }); - }); -} - -pub fn render_grid( - ui: &mut eframe::egui::Ui, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - height: f32, -) { - let text_color = Color32::from_rgb(255, 255, 255); - let fixture_bg = Color32::from_rgb(30, 30, 30); - let highlight_color = Color32::from_rgb(59, 130, 246); - - // Create a scrollable area for fixtures - egui::ScrollArea::vertical() - .max_height(height) - .show(ui, |ui| { - ui.add_space(8.0); - ui.heading("FIXTURES"); - ui.add_space(4.0); - - // Determine grid layout based on available width - let available_width = ui.available_width(); - let fixture_width = 100.0; - let spacing = 10.0; - let columns = - ((available_width + spacing) / (fixture_width + spacing)).floor() as usize; - let columns = columns.max(1); // At least 1 column - - // Convert to vector and sort by fixture ID for consistent ordering - let mut fixtures: Vec<_> = state.fixtures.iter().collect(); - fixtures.sort_by_key(|(_, f)| f.id); - - // Create a grid layout for fixtures - egui::Grid::new("fixtures_grid") - .num_columns(columns) - .spacing([spacing, spacing]) - .show(ui, |ui| { - for (i, (fixture_id, fixture)) in fixtures.iter().enumerate() { - // Create a fixture button - let fixture_height = if fixture.profile.fixture_type == FixtureType::LEDBar - || fixture.profile.fixture_type == FixtureType::PixelBar - { - 70.0 - } else { - 80.0 - }; - - // Draw fixture background - let rect = ui - .allocate_space(Vec2::new(fixture_width, fixture_height)) - .1; - - // Check if fixture is selected - let is_selected = state.selected_fixtures.contains(&fixture.id); - let border_color = if is_selected { - highlight_color - } else { - Color32::from_gray(70) - }; - let border_width = if is_selected { 2.0 } else { 1.0 }; - - // Draw fixture box - ui.painter() - .rect_filled(rect, CornerRadius::same(4), fixture_bg); - - ui.painter().rect_stroke( - rect, - CornerRadius::same(4), - Stroke::new(border_width, border_color), - egui::StrokeKind::Outside, - ); - - // Handle clicks for fixture selection - let response = ui.interact(rect, ui.id().with(i), egui::Sense::click()); - if response.clicked() { - let fixture_id = fixture.id; - let is_selected = state.selected_fixtures.contains(&fixture_id); - - if is_selected { - // Remove from selection - let _ = console_tx - .send(ConsoleCommand::RemoveSelectedFixture { fixture_id }); - } else { - // Add to selection - let _ = console_tx - .send(ConsoleCommand::AddSelectedFixture { fixture_id }); - } - } - - // Draw color strip at the top of the fixture box - let color_strip_height = 6.0; - let color_strip_rect = Rect::from_min_size( - rect.min, - Vec2::new(rect.width(), color_strip_height), - ); - ui.painter().rect_filled( - color_strip_rect, - CornerRadius::same(4).at_least(4), - get_fixture_type_color(&fixture.profile.fixture_type), - ); - - // Draw fixture name (centered) - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - &fixture.name, - egui::FontId::proportional(14.0), - text_color, - ); - - // Add intensity percentage in bottom right corner - let intensity_value = if let Some(channel) = - fixture.channels.iter().find(|c| { - c.name.to_lowercase().contains("dimmer") - || c.name.to_lowercase().contains("intensity") - }) { - channel.value - } else { - 0 // Default if no dimmer/intensity channel found - }; - - // Format as percentage - let intensity_text = format!( - "{}%", - (intensity_value as f32 / 255.0 * 100.0).round() as u8 - ); - - // Position in bottom right with some padding - let text_pos = rect.right_bottom() - Vec2::new(8.0, 8.0); - ui.painter().text( - text_pos, - egui::Align2::RIGHT_BOTTOM, - &intensity_text, - egui::FontId::proportional(11.0), - if intensity_value > 0 { - highlight_color.linear_multiply(0.9) - } else { - Color32::from_gray(130) // Dimmed when intensity is 0 - }, - ); - - // New row after each column - if (i + 1) % columns == 0 && i < state.fixtures.len() - 1 { - ui.end_row(); - } - } - }); - }); -} - -fn get_fixture_type_color(fixture_type: &FixtureType) -> Color32 { - FIXTURE_TYPE_COLORS - .iter() - .find(|(t, _)| t == fixture_type) - .map(|(_, color)| *color) - .unwrap_or(Color32::WHITE) -} diff --git a/crates/ui/src/footer.rs b/crates/ui/src/footer.rs deleted file mode 100644 index 8eb8c36..0000000 --- a/crates/ui/src/footer.rs +++ /dev/null @@ -1,56 +0,0 @@ -use eframe::egui::{Align, CornerRadius, Direction, Layout, RichText}; -use halo_core::ConsoleCommand; -use tokio::sync::mpsc; - -use crate::utils::theme::Theme; - -pub fn render( - ui: &mut eframe::egui::Ui, - _console_tx: &mpsc::UnboundedSender, - state: &crate::state::ConsoleState, - fps: u32, -) { - let theme = Theme::default(); - let fixture_count = state.fixtures.len(); - let bpm = state.bpm; - let rhythm_state = &state.rhythm_state; - let active_effects_count = state.active_effects_count; - - ui.painter().rect_filled( - ui.available_rect_before_wrap(), - CornerRadius::same(0), - theme.bg_color, - ); - - ui.horizontal(|ui| { - ui.add_space(12.0); - ui.label( - RichText::new(format!("FPS: {}", fps)) - .size(12.0) - .color(theme.text_dim), - ); - - ui.with_layout( - Layout::centered_and_justified(Direction::LeftToRight), - |ui| { - ui.label( - RichText::new(format!( - "{} Fixtures | {} Active Effects | {:.1} BPM | Beat {:.2} | Phase {:.2}", - fixture_count, - active_effects_count, - bpm, - rhythm_state.beat_phase, - rhythm_state.bar_phase - )) - .size(12.0) - .color(theme.text_dim), - ); - }, - ); - - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.add_space(12.0); - ui.label(RichText::new("Halo v0.4").size(12.0).color(theme.text_dim)); - }); - }); -} diff --git a/crates/ui/src/header.rs b/crates/ui/src/header.rs deleted file mode 100644 index 704cdaa..0000000 --- a/crates/ui/src/header.rs +++ /dev/null @@ -1,145 +0,0 @@ -use eframe::egui; -use halo_core::ConsoleCommand; -use tokio::sync::mpsc; - -use crate::settings::SettingsPanel; -use crate::ActiveTab; - -pub fn render( - ui: &mut eframe::egui::Ui, - active_tab: &mut ActiveTab, - console_tx: &mpsc::UnboundedSender, - state: &crate::state::ConsoleState, - settings_panel: &mut SettingsPanel, -) { - ui.menu_button("File", |ui| { - if ui.button("New Show").clicked() { - if let Some(path) = rfd::FileDialog::new().set_title("New Show").save_file() { - let name = path - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - let _ = console_tx.send(ConsoleCommand::NewShow { name }); - } - ui.close(); - } - - if ui.button("Open Show...").clicked() { - if let Some(path) = rfd::FileDialog::new() - .add_filter("Halo Show", &["json"]) - .set_title("Open Show") - .pick_file() - { - let _ = console_tx.send(ConsoleCommand::LoadShow { path }); - } - ui.close(); - } - - if ui.button("Reload Show").clicked() { - let _ = console_tx.send(ConsoleCommand::ReloadShow); - } - - if ui.button("Save Show").clicked() { - let _ = console_tx.send(ConsoleCommand::SaveShow); - ui.close(); - } - - if ui.button("Save Show As...").clicked() { - if let Some(path) = rfd::FileDialog::new() - .add_filter("Halo Show", &["json"]) - .set_title("Save Show As") - .save_file() - { - let name = path - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - let _ = console_tx.send(ConsoleCommand::SaveShowAs { name, path }); - } - ui.close(); - } - - ui.separator(); - - if ui.button("Show Manager").clicked() { - *active_tab = ActiveTab::ShowManager; - ui.close(); - } - - ui.separator(); - - if ui.button("Settings").clicked() { - settings_panel.open(); - ui.close(); - } - - ui.separator(); - - if ui.button("Quit").clicked() { - // TODO - add are you sure? modal. - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); - } - }); - ui.menu_button("View", |ui| { - if ui.button("Patch").clicked() { - *active_tab = ActiveTab::PatchPanel; - } - }); - ui.menu_button("Tools", |ui| { - if ui - .button(if state.link_enabled { - "Disable Ableton Link" - } else { - "Enable Ableton Link" - }) - .clicked() - { - if state.link_enabled { - let _ = console_tx.send(ConsoleCommand::DisableAbletonLink); - } else { - let _ = console_tx.send(ConsoleCommand::EnableAbletonLink); - } - } - if ui.button("MIDI Settings").clicked() { - // TODO: Open MIDI settings - } - if ui.button("DMX Settings").clicked() { - // TODO: Open DMX settings - } - }); - // Tab selector - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui - .selectable_label(matches!(active_tab, ActiveTab::ShowManager), "Shows") - .clicked() - { - *active_tab = ActiveTab::ShowManager; - } - if ui - .selectable_label(matches!(active_tab, ActiveTab::PatchPanel), "Patch") - .clicked() - { - *active_tab = ActiveTab::PatchPanel; - } - if ui - .selectable_label(matches!(active_tab, ActiveTab::CueEditor), "Cue Editor") - .clicked() - { - *active_tab = ActiveTab::CueEditor; - } - if ui - .selectable_label(matches!(active_tab, ActiveTab::Programmer), "Programmer") - .clicked() - { - *active_tab = ActiveTab::Programmer; - } - if ui - .selectable_label(matches!(active_tab, ActiveTab::Dashboard), "Dashboard") - .clicked() - { - *active_tab = ActiveTab::Dashboard; - } - }); -} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs deleted file mode 100644 index 81821d8..0000000 --- a/crates/ui/src/lib.rs +++ /dev/null @@ -1,316 +0,0 @@ -use std::time::{Duration, Instant, SystemTime}; - -use eframe::egui; -use halo_core::{ConfigManager, ConsoleCommand, ConsoleEvent}; -use tokio::sync::mpsc; - -use crate::state::ConsoleState; -mod footer; -mod header; -mod settings; -mod state; -mod utils; - -// Enable all UI modules -mod cue; -mod cue_editor; -mod fader; -mod fixture; -mod master; -mod patch_panel; -mod programmer; -mod session; -mod show_panel; -mod timeline; -mod visualizer; - -pub enum ActiveTab { - Dashboard, - Programmer, - CueEditor, - PatchPanel, - ShowManager, -} - -pub struct HaloApp { - state: ConsoleState, - - // Communication channels - console_tx: mpsc::UnboundedSender, - console_rx: std::sync::mpsc::Receiver, - - last_update: Instant, - last_link_query: Instant, - current_time: SystemTime, - active_tab: ActiveTab, - fps: u32, - - // Track if initial show load has been triggered - initial_show_loaded: bool, - show_file_path: Option, - - // Configuration manager - config_manager: ConfigManager, - - // Component state - maintain state between renders - programmer_state: programmer::ProgrammerState, - cue_editor_state: cue_editor::CueEditor, - patch_panel_state: patch_panel::PatchPanelState, - show_panel_state: show_panel::ShowPanelState, - session_panel_state: session::SessionPanel, - cue_panel_state: cue::CuePanel, - settings_panel: settings::SettingsPanel, - timeline_state: timeline::TimelineState, -} - -impl HaloApp { - fn new( - _cc: &eframe::CreationContext<'_>, - console_tx: mpsc::UnboundedSender, - console_rx: std::sync::mpsc::Receiver, - show_file_path: Option, - config_manager: ConfigManager, - ) -> Self { - // Request initial data from console - let _ = console_tx.send(ConsoleCommand::QueryFixtures); - let _ = console_tx.send(ConsoleCommand::QueryCueLists); - let _ = console_tx.send(ConsoleCommand::QueryCurrentCueListIndex); - let _ = console_tx.send(ConsoleCommand::QueryCurrentCue); - let _ = console_tx.send(ConsoleCommand::QueryPlaybackState); - let _ = console_tx.send(ConsoleCommand::QueryRhythmState); - let _ = console_tx.send(ConsoleCommand::QueryShow); - let _ = console_tx.send(ConsoleCommand::QueryLinkState); - - Self { - state: ConsoleState::default(), - console_tx, - console_rx, - last_update: Instant::now(), - last_link_query: Instant::now(), - current_time: SystemTime::now(), - active_tab: ActiveTab::Dashboard, - fps: 60, - initial_show_loaded: false, - show_file_path, - config_manager, - programmer_state: programmer::ProgrammerState::default(), - cue_editor_state: cue_editor::CueEditor::new(), - patch_panel_state: patch_panel::PatchPanelState::default(), - show_panel_state: show_panel::ShowPanelState::default(), - session_panel_state: session::SessionPanel::default(), - cue_panel_state: cue::CuePanel::default(), - settings_panel: settings::SettingsPanel::new(), - timeline_state: timeline::TimelineState::default(), - } - } - - fn process_engine_updates(&mut self) { - while let Ok(event) = self.console_rx.try_recv() { - self.state.update(event); - } - } - - fn render_error_dialog(&mut self, ctx: &egui::Context) { - if let Some(error) = self.state.last_error.clone() { - egui::Window::new("Error") - .collapsible(false) - .resizable(true) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .show(ctx, |ui| { - ui.set_min_width(400.0); - ui.vertical(|ui| { - ui.add_space(10.0); - ui.label( - egui::RichText::new("⚠") - .size(40.0) - .color(egui::Color32::from_rgb(255, 100, 100)), - ); - ui.add_space(10.0); - - ui.label(egui::RichText::new(&error).color(egui::Color32::WHITE)); - - ui.add_space(20.0); - - if ui.button("OK").clicked() { - self.state.last_error = None; - } - }); - }); - } - } - - fn render_ui(&mut self, ctx: &egui::Context) { - // Header - egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { - egui::MenuBar::new().ui(ui, |ui| { - header::render( - ui, - &mut self.active_tab, - &self.console_tx, - &self.state, - &mut self.settings_panel, - ); - }); - }); - - // Bottom UI - egui::TopBottomPanel::bottom("footer_panel").show(ctx, |ui| { - // Sync programmer state from console state before rendering - self.programmer_state - .set_selected_fixtures(self.state.selected_fixtures.clone()); - self.programmer_state.sync_from_console_state(&self.state); - - // Show programmer panel - programmer::render_compact( - ui, - &self.state, - &self.console_tx, - &mut self.programmer_state, - ); - ui.separator(); - - // Show timeline - timeline::render(ui, &self.state, &mut self.timeline_state, &self.console_tx); - ui.separator(); - - // Show footer status - footer::render(ui, &self.console_tx, &self.state, self.fps); - }); - - match self.active_tab { - ActiveTab::Dashboard => { - egui::SidePanel::right("right_panel") - .frame(egui::Frame::default().fill(egui::Color32::from_gray(20))) - .show(ctx, |ui| { - ui.set_min_width(400.0); - - self.session_panel_state - .render(ui, &self.state, &self.console_tx); - ui.separator(); - - // Update cue panel state and render with auto-scroll - self.cue_panel_state - .set_playback_state(self.state.playback_state); - self.cue_panel_state - .render(ui, &self.state, &self.console_tx); - }); - - egui::CentralPanel::default().show(ctx, |ui| { - ui.horizontal(|ui| { - // Master Panel with the visualizer, overrides and master faders - master::render(ui, &self.state, &self.console_tx); - }); - - // Fixtures Grid - let main_content_height = ui.available_height(); - fixture::render_grid( - ui, - &self.state, - &self.console_tx, - main_content_height - 60.0, - ); - }); - } - ActiveTab::CueEditor => { - self.cue_editor_state - .render(ctx, &self.state, &self.console_tx); - } - ActiveTab::Programmer => { - self.programmer_state - .render_full_view(ctx, &self.state, &self.console_tx); - } - ActiveTab::PatchPanel => { - self.patch_panel_state - .render(ctx, &self.state, &self.console_tx); - } - ActiveTab::ShowManager => { - self.show_panel_state - .render(ctx, &self.state, &self.console_tx); - } - } - - // Render settings panel (modal window) - self.settings_panel - .render(ctx, &self.state, &self.console_tx); - } -} - -impl eframe::App for HaloApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - let now = Instant::now(); - self.last_update = now; - self.current_time = SystemTime::now(); - - // Load show file on first update if provided - if !self.initial_show_loaded { - if let Some(ref path) = self.show_file_path { - println!("Loading show file on UI startup: {}", path.display()); - let _ = self - .console_tx - .send(ConsoleCommand::LoadShow { path: path.clone() }); - } - self.initial_show_loaded = true; - } - - // Process all updates first - self.process_engine_updates(); - - // Periodically query Link state (every 2 seconds) - if now.duration_since(self.last_link_query).as_secs() >= 2 { - let _ = self.console_tx.send(ConsoleCommand::QueryLinkState); - self.last_link_query = now; - } - - // Render UI - self.render_ui(ctx); - - // Render error dialog on top of everything - self.render_error_dialog(ctx); - - // Smart repaint based on playback state or active pixel effects - let has_pixel_fixtures = self - .state - .fixtures - .values() - .any(|f| f.profile.fixture_type == halo_fixtures::FixtureType::PixelBar); - - if matches!(self.state.playback_state, halo_core::PlaybackState::Playing) - || (has_pixel_fixtures && !self.state.pixel_data.is_empty()) - { - ctx.request_repaint(); // Continuous for playing or active pixel effects - } else { - ctx.request_repaint_after(Duration::from_millis(100)); // Slower - } - } -} - -pub fn run_ui( - console_tx: mpsc::UnboundedSender, - console_rx: std::sync::mpsc::Receiver, - show_file_path: Option, - config_manager: ConfigManager, -) -> eframe::Result { - let native_options = eframe::NativeOptions { - viewport: eframe::egui::ViewportBuilder { - title: Some(String::from("Halo")), - app_id: Some(String::from("io.github.robmorgan.halo")), - maximized: Some(true), - ..eframe::egui::ViewportBuilder::default() - }, - ..Default::default() - }; - - eframe::run_native( - "Halo", - native_options, - Box::new(move |cc| { - Ok(Box::new(HaloApp::new( - cc, - console_tx, - console_rx, - show_file_path, - config_manager, - ))) - }), - ) -} diff --git a/crates/ui/src/master.rs b/crates/ui/src/master.rs deleted file mode 100644 index bb81117..0000000 --- a/crates/ui/src/master.rs +++ /dev/null @@ -1,207 +0,0 @@ -use eframe::egui::{self, Color32, Pos2, Rect, Response, Sense, Stroke, Vec2}; -use halo_core::ConsoleCommand; -use tokio::sync::mpsc; - -use crate::state::ConsoleState; -use crate::visualizer; - -// Override button state -#[derive(Clone, Debug)] -pub struct OverrideButton { - pub name: String, - pub color: Color32, - pub is_active: bool, - pub is_momentary: bool, - pub values: Vec<(usize, String, u8)>, // (fixture_id, channel_name, value) -} - -impl OverrideButton { - pub fn new(name: String, color: Color32) -> Self { - Self { - name, - color, - is_active: false, - is_momentary: false, - values: Vec::new(), - } - } -} - -// Master fader state -pub struct MasterFader { - pub name: String, - pub value: f32, // 0.0 to 1.0 - pub color: Color32, - pub is_active: bool, -} - -impl MasterFader { - pub fn new(name: String, value: f32, color: Color32) -> Self { - Self { - name, - value, - color, - is_active: true, - } - } -} - -pub fn render( - ui: &mut eframe::egui::Ui, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, -) { - ui.horizontal(|ui| { - // Visualizer section - ui.vertical(|ui| { - ui.heading("VISUALIZER"); - ui.add_space(5.0); - visualizer::render(ui, state, console_tx); - }); - - // Left side - Overrides section - ui.vertical(|ui| { - ui.heading("OVERRIDES"); - ui.add_space(5.0); - - // Static override buttons (similar to main branch implementation) - ui.horizontal(|ui| { - // Red override - let red_button = draw_override_button(ui, "Red", Color32::RED, false, 120.0, 40.0); - if red_button.clicked() { - // TODO: Send override command - } - - ui.add_space(5.0); - - // Green override - let green_button = - draw_override_button(ui, "Green", Color32::GREEN, false, 120.0, 40.0); - if green_button.clicked() { - // TODO: Send override command - } - - ui.add_space(5.0); - - // Blue override - let blue_button = - draw_override_button(ui, "Blue", Color32::BLUE, false, 120.0, 40.0); - if blue_button.clicked() { - // TODO: Send override command - } - }); - }); - - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - - // Right side - Master faders section - ui.vertical(|ui| { - ui.heading("MASTER"); - ui.add_space(5.0); - - // Stack faders vertically - ui.vertical(|ui| { - // Master fader - draw_master_fader(ui, "Master", 1.0, Color32::from_rgb(150, 150, 150)); - ui.add_space(10.0); - - // Smoke fader - draw_master_fader(ui, "Smoke", 0.75, Color32::from_rgb(100, 100, 100)); - }); - }); - }); -} - -// Draw a single override button -fn draw_override_button( - ui: &mut egui::Ui, - name: &str, - color: Color32, - is_active: bool, - width: f32, - height: f32, -) -> Response { - let (rect, response) = - ui.allocate_exact_size(Vec2::new(width, height), Sense::click_and_drag()); - - // Determine button colors - let (bg_color, text_color, stroke_color) = if is_active { - // Active button colors - (color, Color32::BLACK, Color32::WHITE) - } else { - // Inactive button colors - ( - color.linear_multiply(0.3), // Darker version of the color - Color32::WHITE, - Color32::from_gray(100), - ) - }; - - // Draw button - ui.painter().rect_filled( - rect, 4.0, // rounded corners - bg_color, - ); - - // Draw button outline - ui.painter().rect_stroke( - rect, - 4.0, // rounded corners - Stroke::new(1.0, stroke_color), - egui::StrokeKind::Inside, - ); - - // Draw button text - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - name, - egui::FontId::proportional(14.0), - text_color, - ); - - response -} - -// Draw a single master fader -fn draw_master_fader(ui: &mut egui::Ui, name: &str, mut value: f32, color: Color32) { - ui.vertical(|ui| { - // Fader label with percentage immediately following - ui.label(format!("{} {:.0}%", name, value * 100.0)); - - // Fader slider - let response = ui.add( - egui::Slider::new(&mut value, 0.0..=1.0) - .show_value(false) - .fixed_decimals(2) - .orientation(egui::SliderOrientation::Horizontal), - ); - - // Customize fader appearance with visual feedback - let slider_rect = response.rect; - let track_height = 20.0 * 0.8; - let track_rect = Rect::from_min_size( - Pos2::new( - slider_rect.min.x, - slider_rect.center().y - track_height / 2.0, - ), - Vec2::new(slider_rect.width(), track_height), - ); - - // Draw filled portion - let fill_width = slider_rect.width() * value; - let fill_rect = Rect::from_min_size(track_rect.min, Vec2::new(fill_width, track_height)); - - ui.painter() - .rect_filled(track_rect, 2.0, Color32::from_rgb(40, 40, 40)); - - ui.painter().rect_filled(fill_rect, 2.0, color); - - // Apply fader value changes (TODO: implement via message passing) - if response.changed() { - // TODO: Send master fader command - } - }); -} diff --git a/crates/ui/src/patch_panel.rs b/crates/ui/src/patch_panel.rs deleted file mode 100644 index de903e2..0000000 --- a/crates/ui/src/patch_panel.rs +++ /dev/null @@ -1,383 +0,0 @@ -use std::collections::HashMap; - -use eframe::egui; -use halo_core::ConsoleCommand; -use tokio::sync::mpsc; - -use crate::state::ConsoleState; - -pub struct PatchPanelState { - new_fixture_name: String, - new_fixture_profile: String, - new_fixture_universe: u8, - new_fixture_address: u16, - edit_values: HashMap, - editing_limits_fixture_id: Option, - limit_pan_min: u8, - limit_pan_max: u8, - limit_tilt_min: u8, - limit_tilt_max: u8, - fixture_to_remove: Option, - fixture_to_remove_name: String, -} - -#[derive(Clone)] -struct EditingFixture { - name: String, - universe: u8, - address: u16, -} - -impl Default for PatchPanelState { - fn default() -> Self { - Self { - new_fixture_name: String::new(), - new_fixture_profile: String::new(), - new_fixture_universe: 1, - new_fixture_address: 1, - edit_values: HashMap::new(), - editing_limits_fixture_id: None, - limit_pan_min: 0, - limit_pan_max: 255, - limit_tilt_min: 0, - limit_tilt_max: 255, - fixture_to_remove: None, - fixture_to_remove_name: String::new(), - } - } -} - -impl PatchPanelState { - pub fn render( - &mut self, - ctx: &egui::Context, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - ) { - // Render confirmation modal for fixture removal - if let Some(fixture_id) = self.fixture_to_remove { - egui::Window::new("Remove Fixture") - .collapsible(false) - .resizable(false) - .show(ctx, |ui| { - ui.label(format!( - "Are you sure you want to remove fixture \"{}\"?", - self.fixture_to_remove_name - )); - ui.label("This action cannot be undone."); - - ui.horizontal(|ui| { - if ui.button("Cancel").clicked() { - self.fixture_to_remove = None; - self.fixture_to_remove_name.clear(); - } - - if ui.button("Remove").clicked() { - let _ = console_tx.send(ConsoleCommand::UnpatchFixture { fixture_id }); - self.fixture_to_remove = None; - self.fixture_to_remove_name.clear(); - } - }); - }); - } - - egui::CentralPanel::default().show(ctx, |ui| { - ui.vertical(|ui| { - ui.heading("Patch Panel"); - - // Fixture list - ui.heading("Patched Fixtures"); - - egui::ScrollArea::vertical() - .max_height(400.0) - .show(ui, |ui| { - // Convert to vector and sort by fixture ID for consistent ordering - let mut fixtures: Vec<_> = state.fixtures.iter().collect(); - fixtures.sort_by_key(|(_, f)| f.id); - - // Clean up edit_values for fixtures that no longer exist - let current_fixture_ids: std::collections::HashSet<_> = - fixtures.iter().map(|(_, f)| f.id).collect(); - self.edit_values - .retain(|id, _| current_fixture_ids.contains(id)); - - for (_, fixture) in fixtures { - // Initialize edit values if not present - if !self.edit_values.contains_key(&fixture.id) { - self.edit_values.insert( - fixture.id, - EditingFixture { - name: fixture.name.clone(), - universe: fixture.universe, - address: fixture.start_address, - }, - ); - } - - ui.group(|ui| { - let edit_value = self.edit_values.get_mut(&fixture.id).unwrap(); - - ui.horizontal(|ui| { - ui.add_sized( - [50.0, 20.0], - egui::Label::new(format!("ID {}:", fixture.id)), - ); - - ui.label("Name:"); - ui.add_sized( - [120.0, 20.0], - egui::TextEdit::singleline(&mut edit_value.name), - ); - - ui.label("Profile:"); - ui.add_sized( - [150.0, 20.0], - egui::Label::new(&fixture.profile_id), - ); - - ui.label("Universe:"); - ui.add_sized( - [60.0, 20.0], - egui::DragValue::new(&mut edit_value.universe) - .range(1..=255), - ); - - ui.label("Address:"); - ui.add_sized( - [60.0, 20.0], - egui::DragValue::new(&mut edit_value.address) - .range(1..=512), - ); - - ui.add_sized( - [100.0, 20.0], - egui::Label::new(format!( - "Channels: {}", - fixture.channels.len() - )), - ); - - // Show limits badge if set - if let Some(limits) = &fixture.pan_tilt_limits { - ui.label(format!( - "🔒 P:{}-{} T:{}-{}", - limits.pan_min, - limits.pan_max, - limits.tilt_min, - limits.tilt_max - )); - } - - if ui.button("Limits").clicked() { - // Toggle limit editor for this fixture - if self.editing_limits_fixture_id == Some(fixture.id) { - self.editing_limits_fixture_id = None; - } else { - self.editing_limits_fixture_id = Some(fixture.id); - // Load current limits if they exist - if let Some(limits) = &fixture.pan_tilt_limits { - self.limit_pan_min = limits.pan_min; - self.limit_pan_max = limits.pan_max; - self.limit_tilt_min = limits.tilt_min; - self.limit_tilt_max = limits.tilt_max; - } else { - self.limit_pan_min = 0; - self.limit_pan_max = 255; - self.limit_tilt_min = 0; - self.limit_tilt_max = 255; - } - } - } - - if ui.button("Remove").clicked() { - self.fixture_to_remove = Some(fixture.id); - self.fixture_to_remove_name = fixture.name.clone(); - } - }); - - // Show limit editor if this fixture is being edited - if self.editing_limits_fixture_id == Some(fixture.id) { - ui.indent(format!("limits_editor_{}", fixture.id), |ui| { - ui.horizontal(|ui| { - ui.label("Pan Min:"); - ui.add( - egui::DragValue::new(&mut self.limit_pan_min) - .range(0..=255), - ); - ui.label("Max:"); - ui.add( - egui::DragValue::new(&mut self.limit_pan_max) - .range(0..=255), - ); - }); - ui.horizontal(|ui| { - ui.label("Tilt Min:"); - ui.add( - egui::DragValue::new(&mut self.limit_tilt_min) - .range(0..=255), - ); - ui.label("Max:"); - ui.add( - egui::DragValue::new(&mut self.limit_tilt_max) - .range(0..=255), - ); - }); - ui.horizontal(|ui| { - if ui.button("Apply Limits").clicked() { - let _ = console_tx.send( - ConsoleCommand::SetPanTiltLimits { - fixture_id: fixture.id, - pan_min: self.limit_pan_min, - pan_max: self.limit_pan_max, - tilt_min: self.limit_tilt_min, - tilt_max: self.limit_tilt_max, - }, - ); - self.editing_limits_fixture_id = None; - } - if ui.button("Clear Limits").clicked() { - let _ = console_tx.send( - ConsoleCommand::ClearPanTiltLimits { - fixture_id: fixture.id, - }, - ); - self.editing_limits_fixture_id = None; - } - if ui.button("Cancel").clicked() { - self.editing_limits_fixture_id = None; - } - }); - }); - } - }); - } - }); - - // Global save/cancel buttons - ui.separator(); - - // Check if there are any pending changes - let mut has_changes = false; - let mut fixtures: Vec<_> = state.fixtures.iter().collect(); - fixtures.sort_by_key(|(_, f)| f.id); - - for (_, fixture) in &fixtures { - if let Some(edit_value) = self.edit_values.get(&fixture.id) { - if edit_value.name != fixture.name - || edit_value.universe != fixture.universe - || edit_value.address != fixture.start_address - { - has_changes = true; - break; - } - } - } - - ui.horizontal(|ui| { - if ui - .add_enabled(has_changes, egui::Button::new("Save All Changes")) - .clicked() - { - // Apply all changes - for (_, fixture) in &fixtures { - if let Some(edit_value) = self.edit_values.get(&fixture.id) { - if edit_value.name != fixture.name - || edit_value.universe != fixture.universe - || edit_value.address != fixture.start_address - { - let _ = console_tx.send(ConsoleCommand::UpdateFixture { - fixture_id: fixture.id, - name: edit_value.name.clone(), - universe: edit_value.universe, - address: edit_value.address, - }); - } - } - } - } - - if ui - .add_enabled(has_changes, egui::Button::new("Cancel")) - .clicked() - { - // Reset all edit values to current fixture values - for (_, fixture) in &fixtures { - self.edit_values.insert( - fixture.id, - EditingFixture { - name: fixture.name.clone(), - universe: fixture.universe, - address: fixture.start_address, - }, - ); - } - } - }); - - ui.separator(); - - // Add new fixture - ui.heading("Add Fixture"); - ui.horizontal(|ui| { - ui.label("Name:"); - ui.add( - egui::TextEdit::singleline(&mut self.new_fixture_name).desired_width(120.0), - ); - - ui.label("Profile:"); - // Get sorted list of profiles for dropdown - let mut profile_options: Vec<(String, String)> = state - .fixture_library - .profiles - .iter() - .map(|(id, profile)| (id.clone(), profile.to_string())) - .collect(); - profile_options.sort_by(|a, b| a.1.cmp(&b.1)); - - egui::ComboBox::from_id_salt("fixture_profile_selector") - .selected_text(if self.new_fixture_profile.is_empty() { - "Select a fixture type..." - } else { - // Find the display name for the selected profile - profile_options - .iter() - .find(|(id, _)| id == &self.new_fixture_profile) - .map(|(_, name)| name.as_str()) - .unwrap_or(&self.new_fixture_profile) - }) - .show_ui(ui, |ui| { - for (profile_id, profile_name) in profile_options { - ui.selectable_value( - &mut self.new_fixture_profile, - profile_id.clone(), - profile_name, - ); - } - }); - - ui.label("Universe:"); - ui.add(egui::DragValue::new(&mut self.new_fixture_universe).range(1..=255)); - - ui.label("Address:"); - ui.add(egui::DragValue::new(&mut self.new_fixture_address).range(1..=512)); - - if ui.button("Add").clicked() - && !self.new_fixture_name.is_empty() - && !self.new_fixture_profile.is_empty() - { - let _ = console_tx.send(ConsoleCommand::PatchFixture { - name: self.new_fixture_name.clone(), - profile_name: self.new_fixture_profile.clone(), - universe: self.new_fixture_universe, - address: self.new_fixture_address, - }); - - // Clear the form - self.new_fixture_name.clear(); - self.new_fixture_profile.clear(); - } - }); - }); - }); - } -} diff --git a/crates/ui/src/programmer.rs b/crates/ui/src/programmer.rs deleted file mode 100644 index 0dab1a5..0000000 --- a/crates/ui/src/programmer.rs +++ /dev/null @@ -1,1789 +0,0 @@ -use std::collections::HashMap; -use std::f64::consts::PI; - -use eframe::egui::{self, Color32, Pos2, Rect, Sense, Stroke, Vec2}; -use egui_plot::{Line, Plot, PlotPoints}; -use halo_core::{ - ConsoleCommand, EffectDistribution, EffectType, Interval, PixelEffect, PixelEffectParams, - PixelEffectScope, PixelEffectType, -}; -use halo_fixtures::FixtureType; -use tokio::sync::mpsc; - -use crate::state::ConsoleState; - -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -pub enum ActiveProgrammerTab { - Intensity, - Color, - Position, - Beam, - PixelEffects, -} - -#[derive(Debug, Clone)] -pub struct TabEffectConfig { - pub effect_waveform: u8, - pub effect_interval: u8, - pub effect_ratio: f32, - pub effect_phase: f32, - pub effect_distribution: u8, - pub effect_step_value: usize, - pub effect_wave_offset: f32, - // Channel selection for position effects - pub pan_selected: bool, - pub tilt_selected: bool, -} - -impl Default for TabEffectConfig { - fn default() -> Self { - Self { - effect_waveform: 0, - effect_interval: 0, - effect_ratio: 1.0, - effect_phase: 0.0, - effect_distribution: 0, - effect_step_value: 1, - effect_wave_offset: 0.0, - pan_selected: true, - tilt_selected: true, - } - } -} - -pub struct ProgrammerState { - pub new_cue_name: String, - selected_fixtures: Vec, - params: HashMap, - color_presets: Vec, - active_tab: ActiveProgrammerTab, - tab_effects: HashMap, - preview_mode: bool, - collapsed: bool, - // Pixel effect state - pixel_effect_type: usize, - pixel_effect_scope: usize, - pixel_effect_color: [f32; 3], - // Modal dialog state - show_record_dialog: bool, - record_dialog_cue_name: String, - record_dialog_cue_list_index: usize, -} - -impl Default for ProgrammerState { - fn default() -> Self { - let mut params = HashMap::new(); - - // Initialize default parameter values - params.insert("dimmer".to_string(), 100.0); - params.insert("strobe".to_string(), 0.0); - params.insert("red".to_string(), 255.0); - params.insert("green".to_string(), 127.0); - params.insert("blue".to_string(), 0.0); - params.insert("white".to_string(), 0.0); - params.insert("pan".to_string(), 180.0); - params.insert("tilt".to_string(), 90.0); - params.insert("focus".to_string(), 50.0); - params.insert("zoom".to_string(), 75.0); - params.insert("gobo_rotation".to_string(), 0.0); - params.insert("gobo_selection".to_string(), 2.0); - - // Initialize color presets - let color_presets = vec![ - Color32::from_rgb(255, 0, 0), // Red - Color32::from_rgb(255, 127, 0), // Orange - Color32::from_rgb(255, 255, 0), // Yellow - Color32::from_rgb(0, 255, 0), // Green - Color32::from_rgb(0, 255, 255), // Cyan - Color32::from_rgb(0, 0, 255), // Blue - Color32::from_rgb(139, 0, 255), // Purple - Color32::from_rgb(255, 255, 255), // White - ]; - - // Initialize tab effects - let mut tab_effects = HashMap::new(); - tab_effects.insert(ActiveProgrammerTab::Intensity, TabEffectConfig::default()); - tab_effects.insert(ActiveProgrammerTab::Color, TabEffectConfig::default()); - tab_effects.insert(ActiveProgrammerTab::Position, TabEffectConfig::default()); - tab_effects.insert(ActiveProgrammerTab::Beam, TabEffectConfig::default()); - - Self { - new_cue_name: String::new(), - selected_fixtures: Vec::new(), - params, - color_presets, - active_tab: ActiveProgrammerTab::Intensity, - tab_effects, - preview_mode: false, - collapsed: false, - // Pixel effect defaults - pixel_effect_type: 0, // Chase - pixel_effect_scope: 1, // Individual - pixel_effect_color: [1.0, 1.0, 1.0], // White - // Modal dialog defaults - show_record_dialog: false, - record_dialog_cue_name: String::new(), - record_dialog_cue_list_index: 0, - } - } -} - -impl ProgrammerState { - pub fn new() -> Self { - Self::default() - } - - pub fn toggle_collapsed(&mut self) { - self.collapsed = !self.collapsed; - } - - pub fn is_collapsed(&self) -> bool { - self.collapsed - } - - pub fn set_selected_fixtures(&mut self, fixtures: Vec) { - self.selected_fixtures = fixtures; - } - - pub fn add_selected_fixture(&mut self, fixture_id: usize) { - if !self.selected_fixtures.contains(&fixture_id) { - self.selected_fixtures.push(fixture_id); - } - } - - pub fn remove_selected_fixture(&mut self, fixture_id: usize) { - self.selected_fixtures.retain(|&id| id != fixture_id); - } - - pub fn clear_selected_fixtures(&mut self) { - self.selected_fixtures.clear(); - } - - pub fn get_param(&self, param_name: &str) -> f32 { - *self.params.get(param_name).unwrap_or(&0.0) - } - - pub fn set_param(&mut self, param_name: &str, value: f32) { - if let Some(param) = self.params.get_mut(param_name) { - *param = value; - } - } - - /// Sync programmer state from console state - pub fn sync_from_console_state(&mut self, console_state: &ConsoleState) { - self.preview_mode = console_state.programmer_preview_mode; - } - - // Main rendering function for the programmer panel - pub fn show( - &mut self, - ui: &mut egui::Ui, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - ) { - ui.vertical(|ui| { - // Programmer header with title and action buttons - ui.horizontal(|ui| { - let collapse_icon = if self.collapsed { "▶" } else { "▼" }; - if ui.button(collapse_icon).clicked() { - self.collapsed = !self.collapsed; - } - - ui.heading("PROGRAMMER"); - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("RECORD").clicked() { - // Record the current programmer state to a cue - if !self.new_cue_name.is_empty() { - let _ = console_tx.send(ConsoleCommand::RecordProgrammerToCue { - cue_name: self.new_cue_name.clone(), - list_index: None, - }); - } - } - - if ui.button("CLEAR").clicked() { - // Clear the programmer - let _ = console_tx.send(ConsoleCommand::ClearProgrammer); - } - - if ui.button("HIGHLIGHT").clicked() { - // Highlight function would go here - } - - // If the preview button is toggled on, enter preview mode - if ui - .add(egui::Button::new("PREVIEW").selected(self.preview_mode)) - .clicked() - { - self.preview_mode = !self.preview_mode; - let _ = console_tx.send(ConsoleCommand::SetProgrammerPreviewMode { - preview_mode: self.preview_mode, - }); - } - - ui.label(format!( - "{} fixtures selected", - self.selected_fixtures.len() - )); - }); - }); - - // Only show the rest of the programmer if not collapsed - if !self.collapsed { - // Programmer tabs - ui.horizontal(|ui| { - self.draw_tab_button(ui, "Intensity", ActiveProgrammerTab::Intensity); - self.draw_tab_button(ui, "Color", ActiveProgrammerTab::Color); - self.draw_tab_button(ui, "Position", ActiveProgrammerTab::Position); - self.draw_tab_button(ui, "Beam", ActiveProgrammerTab::Beam); - self.draw_tab_button(ui, "Pixel FX", ActiveProgrammerTab::PixelEffects); - }); - - ui.separator(); - - // Tab content and effects panel - ui.horizontal(|ui| { - ui.vertical(|ui| match self.active_tab { - ActiveProgrammerTab::Intensity => self.show_intensity_tab(ui, console_tx), - ActiveProgrammerTab::Color => self.show_color_tab(ui, console_tx), - ActiveProgrammerTab::Position => self.show_position_tab(ui, console_tx), - ActiveProgrammerTab::Beam => self.show_beam_tab(ui, console_tx), - ActiveProgrammerTab::PixelEffects => { - self.show_pixel_effects_tab(ui, console_tx) - } - }); - ui.set_min_size(Vec2::new(ui.available_width() - 250.0, 0.0)); - - ui.separator(); - - // Effects panel on the right - self.show_effects_panel(ui, state, console_tx); - }); - } else { - // When collapsed, show a compact summary of selected fixtures and active parameters - ui.horizontal(|ui| { - if !self.selected_fixtures.is_empty() { - let active_tab_name = match self.active_tab { - ActiveProgrammerTab::Intensity => "Intensity", - ActiveProgrammerTab::Color => "Color", - ActiveProgrammerTab::Position => "Position", - ActiveProgrammerTab::Beam => "Beam", - ActiveProgrammerTab::PixelEffects => "Pixel FX", - }; - - ui.label(format!( - "{} fixtures | Active tab: {}", - self.selected_fixtures.len(), - active_tab_name - )); - - // Show a few key parameters based on the active tab - match self.active_tab { - ActiveProgrammerTab::Intensity => { - ui.label(format!("Dimmer: {}%", self.get_param("dimmer").round())); - } - ActiveProgrammerTab::Color => { - let r = self.get_param("red").round() as u8; - let g = self.get_param("green").round() as u8; - let b = self.get_param("blue").round() as u8; - let color_preview = Color32::from_rgb(r, g, b); - - ui.label("RGB:"); - ui.painter().rect_filled( - ui.available_rect_before_wrap(), - 4.0, - color_preview, - ); - } - ActiveProgrammerTab::Position => { - ui.label(format!( - "Pan: {}° | Tilt: {}°", - self.get_param("pan").round(), - self.get_param("tilt").round() - )); - } - ActiveProgrammerTab::PixelEffects => { - ui.label("Pixel FX Ready"); - } - _ => {} - } - } else { - ui.label("No fixtures selected"); - } - }); - } - }); - } - - pub fn render( - &mut self, - ctx: &egui::Context, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - ) { - egui::CentralPanel::default().show(ctx, |ui| { - self.show(ui, state, console_tx); - }); - } - - pub fn render_full_view( - &mut self, - ctx: &egui::Context, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - ) { - // Sync all current dashboard programmer values to console - self.sync_all_values_to_console(console_tx); - - // Render the record dialog if needed - self.render_record_dialog(ctx, state, console_tx); - - egui::CentralPanel::default().show(ctx, |ui| { - ui.vertical(|ui| { - // Header area with global controls - ui.horizontal(|ui| { - ui.heading("PROGRAMMER"); - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("CLEAR ALL").clicked() { - // Clear the programmer - let _ = console_tx.send(ConsoleCommand::ClearProgrammer); - } - - if ui.button("RECORD TO CUE").clicked() { - // Open the record dialog - self.show_record_dialog = true; - self.record_dialog_cue_list_index = state.current_cue_list_index; - } - }); - }); - - ui.separator(); - - // Parameter grid - ui.heading("Programmer Values"); - self.render_parameter_grid(ui, state); - - ui.add_space(20.0); - - // Effects summary - self.render_effects_summary(ui, state); - }); - }); - } - - // Helper method to render parameters for a fixture - fn render_fixture_parameters(&self, ui: &mut egui::Ui, values: &[(String, u8)]) { - egui::Grid::new("fixture_params") - .striped(true) - .show(ui, |ui| { - ui.label("Parameter"); - ui.label("Value"); - ui.label("Graphical"); - ui.end_row(); - - for (channel, value) in values { - ui.label(channel); - ui.label(format!("{}", value)); - - // Create a graphical representation based on parameter type - let progress = *value as f32 / 255.0; - - if channel.to_lowercase().contains("red") - || channel.to_lowercase().contains("green") - || channel.to_lowercase().contains("blue") - || channel.to_lowercase().contains("white") - { - let color = if channel.to_lowercase().contains("red") { - Color32::from_rgb(*value, 0, 0) - } else if channel.to_lowercase().contains("green") { - Color32::from_rgb(0, *value, 0) - } else if channel.to_lowercase().contains("blue") { - Color32::from_rgb(0, 0, *value) - } else if channel.to_lowercase().contains("white") { - let v = *value; - Color32::from_rgb(v, v, v) - } else { - Color32::WHITE - }; - - let rect = ui.available_rect_before_wrap().shrink(2.0); - let response = ui.allocate_rect(rect, egui::Sense::hover()); - - ui.painter().rect_filled(response.rect, 4.0, color); - } else { - // For other parameter types, draw a progress bar - let rect = ui.available_rect_before_wrap().shrink(2.0); - let response = ui.allocate_rect(rect, egui::Sense::hover()); - - // Background - ui.painter() - .rect_filled(response.rect, 4.0, Color32::from_gray(30)); - - // Foreground - let filled_width = response.rect.width() * progress; - let filled_rect = egui::Rect::from_min_size( - response.rect.min, - egui::Vec2::new(filled_width, response.rect.height()), - ); - - ui.painter() - .rect_filled(filled_rect, 4.0, Color32::from_rgb(0, 150, 255)); - } - - ui.end_row(); - } - }); - } - - // Helper method to render the parameter grid - fn render_parameter_grid(&self, ui: &mut egui::Ui, state: &ConsoleState) { - if state.programmer_values.is_empty() { - ui.label("No values in programmer"); - return; - } - - // Collect all unique channels and group fixtures by type - let mut channels = std::collections::HashSet::new(); - let mut fixtures_by_type: HashMap> = HashMap::new(); - - for ((fixture_id, channel), _value) in &state.programmer_values { - channels.insert(channel.clone()); - - if let Some(fixture) = state.fixtures.get(&fixture_id.to_string()) { - fixtures_by_type - .entry(fixture.profile.fixture_type.clone()) - .or_insert_with(Vec::new) - .push((fixture.id, fixture.name.clone())); - } - } - - // Sort channels for consistent column order - let mut sorted_channels: Vec = channels.into_iter().collect(); - sorted_channels.sort(); - - // Sort fixtures within each type by ID - for fixtures in fixtures_by_type.values_mut() { - fixtures.sort_by_key(|(id, _)| *id); - } - - // Sort fixture types for consistent display order - let mut sorted_fixture_types: Vec = fixtures_by_type.keys().cloned().collect(); - sorted_fixture_types.sort_by(|a, b| format!("{:?}", a).cmp(&format!("{:?}", b))); - - egui::ScrollArea::both().show(ui, |ui| { - egui::Grid::new("parameter_grid") - .striped(true) - .spacing([10.0, 5.0]) - .show(ui, |ui| { - // Header row - ui.label("Fixture"); - for channel in &sorted_channels { - ui.label(channel); - } - ui.end_row(); - - // Data rows grouped by fixture type - for fixture_type in &sorted_fixture_types { - if let Some(fixtures) = fixtures_by_type.get(fixture_type) { - // Group header - ui.colored_label( - Color32::from_rgb(100, 150, 255), - format!("{:?} ({})", fixture_type, fixtures.len()), - ); - for _ in &sorted_channels { - ui.label(""); // Empty cells for group header - } - ui.end_row(); - - // Fixture rows - for (fixture_id, fixture_name) in fixtures { - ui.label(fixture_name); - - for channel in &sorted_channels { - let value = state - .programmer_values - .get(&(*fixture_id, channel.clone())) - .copied() - .unwrap_or(0); - - self.render_parameter_cell(ui, channel, value); - } - ui.end_row(); - } - } - } - }); - }); - } - - // Helper method to render individual parameter cells - fn render_parameter_cell(&self, ui: &mut egui::Ui, channel: &str, value: u8) { - let cell_size = Vec2::new(60.0, 30.0); - let (rect, _response) = ui.allocate_exact_size(cell_size, Sense::hover()); - - // Determine if this is a color channel - let is_color_channel = channel.to_lowercase().contains("red") - || channel.to_lowercase().contains("green") - || channel.to_lowercase().contains("blue") - || channel.to_lowercase().contains("white") - || channel.to_lowercase().contains("amber") - || channel.to_lowercase().contains("uv"); - - if is_color_channel { - // Render color cell - let color = if channel.to_lowercase().contains("red") { - Color32::from_rgb(value, 0, 0) - } else if channel.to_lowercase().contains("green") { - Color32::from_rgb(0, value, 0) - } else if channel.to_lowercase().contains("blue") { - Color32::from_rgb(0, 0, value) - } else if channel.to_lowercase().contains("white") { - Color32::from_rgb(value, value, value) - } else if channel.to_lowercase().contains("amber") { - Color32::from_rgb(value, (value as f32 * 0.7) as u8, 0) - } else if channel.to_lowercase().contains("uv") { - Color32::from_rgb((value as f32 * 0.3) as u8, 0, value) - } else { - Color32::from_gray(value) - }; - - ui.painter().rect_filled(rect, 4.0, color); - ui.painter().rect_stroke( - rect, - 4.0, - Stroke::new(1.0, Color32::from_gray(100)), - egui::StrokeKind::Inside, - ); - - // Add value text - let text_color = if value > 127 { - Color32::BLACK - } else { - Color32::WHITE - }; - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - value.to_string(), - egui::FontId::proportional(10.0), - text_color, - ); - } else { - // Render progress bar cell - let progress = value as f32 / 255.0; - - // Background - ui.painter().rect_filled(rect, 4.0, Color32::from_gray(30)); - ui.painter().rect_stroke( - rect, - 4.0, - Stroke::new(1.0, Color32::from_gray(100)), - egui::StrokeKind::Inside, - ); - - // Progress fill - let fill_width = rect.width() * progress; - let fill_rect = Rect::from_min_size(rect.min, Vec2::new(fill_width, rect.height())); - ui.painter() - .rect_filled(fill_rect, 4.0, Color32::from_rgb(0, 150, 255)); - - // Value text - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - value.to_string(), - egui::FontId::proportional(10.0), - Color32::WHITE, - ); - } - } - - // Helper method to render effects summary - fn render_effects_summary(&self, ui: &mut egui::Ui, state: &ConsoleState) { - ui.heading("EFFECTS"); - ui.separator(); - - if state.programmer_effects.is_empty() { - ui.label("No effects in programmer"); - return; - } - - for (i, (name, effect_type, fixture_ids)) in state.programmer_effects.iter().enumerate() { - ui.collapsing(format!("Effect #{}: {}", i + 1, name), |ui| { - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.label(format!("Name: {}", name)); - ui.label(format!("Type: {:?}", effect_type)); - ui.label(format!("Fixtures: {} fixtures", fixture_ids.len())); - }); - - // Add a small waveform preview - let plot_height = 80.0; - let plot_width = 150.0; - - Plot::new(format!("effect_plot_{}", i)) - .height(plot_height) - .width(plot_width) - .show_axes([false, false]) - .view_aspect(2.0) - .show(ui, |plot_ui| { - // Generate the waveform for this effect - let n_points = 50; - let mut points = Vec::with_capacity(n_points); - - for i in 0..n_points { - let x = - i as f64 / (n_points - 1) as f64 * 2.0 * std::f64::consts::PI; - - // This is a simplified version - the actual effect could be more - // complex - let y = match effect_type { - EffectType::Sine => x.sin(), - EffectType::Square => { - if (x % (2.0 * std::f64::consts::PI)).sin() >= 0.0 { - 1.0 - } else { - -1.0 - } - } - EffectType::Sawtooth => { - let mut v = (x % (2.0 * std::f64::consts::PI)) - / std::f64::consts::PI - - 1.0; - if v > 1.0 { - v -= 2.0 - }; - v - } - _ => x.sin(), // Default - }; - - points.push([x, y]); - } - - let plot_points = PlotPoints::from(points); - plot_ui.line( - Line::new("", plot_points).color(Color32::from_rgb(100, 200, 255)), - ); - }); - }); - }); - } - } - - // Helper method to render the record dialog - fn render_record_dialog( - &mut self, - ctx: &egui::Context, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - ) { - if self.show_record_dialog { - egui::Window::new("Record to Cue") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .show(ctx, |ui| { - ui.set_min_width(300.0); - ui.vertical(|ui| { - ui.heading("Record Programmer to Cue"); - ui.add_space(10.0); - - ui.label("Cue Name:"); - ui.add( - egui::TextEdit::singleline(&mut self.record_dialog_cue_name) - .hint_text("Enter cue name..."), - ); - - ui.add_space(10.0); - - ui.label("Cue List:"); - egui::ComboBox::from_id_salt("cue_list_selector") - .selected_text( - if self.record_dialog_cue_list_index < state.cue_lists.len() { - &state.cue_lists[self.record_dialog_cue_list_index].name - } else { - "No cue lists available" - }, - ) - .show_ui(ui, |ui| { - for (index, cue_list) in state.cue_lists.iter().enumerate() { - ui.selectable_value( - &mut self.record_dialog_cue_list_index, - index, - &cue_list.name, - ); - } - }); - - ui.add_space(20.0); - - ui.horizontal(|ui| { - if ui.button("Record").clicked() { - if !self.record_dialog_cue_name.is_empty() { - let _ = - console_tx.send(ConsoleCommand::RecordProgrammerToCue { - cue_name: self.record_dialog_cue_name.clone(), - list_index: Some(self.record_dialog_cue_list_index), - }); - self.show_record_dialog = false; - self.record_dialog_cue_name.clear(); - } - } - - if ui.button("Cancel").clicked() { - self.show_record_dialog = false; - self.record_dialog_cue_name.clear(); - } - }); - }); - }); - } - } - - // Helper method to render effect details - fn render_effect_details( - &self, - ui: &mut egui::Ui, - name: &str, - effect_type: EffectType, - fixture_ids: &[usize], - ) { - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.label(format!("Name: {}", name)); - ui.label(format!("Type: {:?}", effect_type)); - ui.label(format!("Fixtures: {} fixtures", fixture_ids.len())); - }); - - // Add a visual preview of the effect - let plot_height = 100.0; - let plot_width = 200.0; - - Plot::new(format!("effect_plot_{:?}", effect_type)) - .height(plot_height) - .width(plot_width) - .show_axes([false, false]) - .view_aspect(2.0) - .show(ui, |plot_ui| { - // Generate the waveform for this effect - let n_points = 100; - let mut points = Vec::with_capacity(n_points); - - for i in 0..n_points { - let x = i as f64 / (n_points - 1) as f64 * 2.0 * std::f64::consts::PI; - let phase = 0.0; // Default phase - let ratio = 1.0; // Default ratio - - // This is a simplified version - the actual effect could be more complex - let y = match effect_type { - EffectType::Sine => (x * ratio + phase).sin(), - EffectType::Square => { - if ((x * ratio + phase) % (2.0 * std::f64::consts::PI)).sin() >= 0.0 - { - 1.0 - } else { - -1.0 - } - } - EffectType::Sawtooth => { - let mut v = ((x * ratio + phase) % (2.0 * std::f64::consts::PI)) - / std::f64::consts::PI - - 1.0; - if v > 1.0 { - v -= 2.0 - }; - v - } - _ => (x * ratio + phase).sin(), // Default - }; - - points.push([x, y]); - } - - let plot_points = PlotPoints::from(points); - plot_ui - .line(Line::new("", plot_points).color(Color32::from_rgb(100, 200, 255))); - }); - }); - } - - fn show_pixel_effects_tab( - &mut self, - ui: &mut egui::Ui, - console_tx: &mpsc::UnboundedSender, - ) { - ui.heading("Pixel Effects"); - ui.add_space(10.0); - - ui.label("Configure effects for pixel bar fixtures"); - ui.add_space(10.0); - - ui.label("Effect Type:"); - ui.horizontal(|ui| { - ui.radio_value(&mut self.pixel_effect_type, 0, "Chase"); - ui.radio_value(&mut self.pixel_effect_type, 1, "Wave"); - ui.radio_value(&mut self.pixel_effect_type, 2, "Strobe"); - ui.radio_value(&mut self.pixel_effect_type, 3, "Color Cycle"); - }); - - ui.add_space(10.0); - - ui.label("Scope:"); - ui.horizontal(|ui| { - ui.radio_value(&mut self.pixel_effect_scope, 0, "Bar (all pixels same)"); - ui.radio_value(&mut self.pixel_effect_scope, 1, "Individual (per-pixel)"); - }); - - ui.add_space(10.0); - - ui.label("Color:"); - ui.horizontal(|ui| { - ui.color_edit_button_rgb(&mut self.pixel_effect_color); - }); - - ui.add_space(20.0); - - // Show current settings - ui.group(|ui| { - ui.label("Current Settings:"); - let effect_name = match self.pixel_effect_type { - 0 => "Chase", - 1 => "Wave", - 2 => "Strobe", - 3 => "Color Cycle", - _ => "Unknown", - }; - let scope_name = if self.pixel_effect_scope == 0 { - "Bar" - } else { - "Individual" - }; - let color_rgb = ( - (self.pixel_effect_color[0] * 255.0) as u8, - (self.pixel_effect_color[1] * 255.0) as u8, - (self.pixel_effect_color[2] * 255.0) as u8, - ); - - ui.label(format!("Effect: {} | Scope: {}", effect_name, scope_name)); - ui.horizontal(|ui| { - ui.label(format!( - "Color: RGB({}, {}, {})", - color_rgb.0, color_rgb.1, color_rgb.2 - )); - let color = Color32::from_rgb(color_rgb.0, color_rgb.1, color_rgb.2); - ui.colored_label(color, "███"); - }); - }); - - ui.add_space(10.0); - - ui.label("Note: Pixel effects will be applied when in preview mode."); - ui.label(format!( - "{} pixel bar fixture(s) selected", - self.selected_fixtures.len() - )); - - ui.add_space(10.0); - - ui.horizontal(|ui| { - if ui - .button("Apply Pixel Effect to Selected Fixtures") - .clicked() - { - // Convert settings to pixel effect command - let color_rgb = ( - (self.pixel_effect_color[0] * 255.0) as u8, - (self.pixel_effect_color[1] * 255.0) as u8, - (self.pixel_effect_color[2] * 255.0) as u8, - ); - - // Map UI values to PixelEffect types - let effect_type = match self.pixel_effect_type { - 0 => PixelEffectType::Chase, - 1 => PixelEffectType::Wave, - 2 => PixelEffectType::Strobe, - 3 => PixelEffectType::ColorCycle, - _ => PixelEffectType::Chase, - }; - - let scope = if self.pixel_effect_scope == 0 { - PixelEffectScope::Bar - } else { - PixelEffectScope::Individual - }; - - // Create the pixel effect - let pixel_effect = PixelEffect { - effect_type, - scope, - color: color_rgb, - params: PixelEffectParams { - interval: Interval::Beat, - interval_ratio: 1.0, - phase: 0.0, - speed: 1.0, - }, - }; - - // Send command to apply pixel effect - let _ = console_tx.send(ConsoleCommand::AddPixelEffect { - name: format!("Programmer_PixelFX_{}", self.selected_fixtures.len()), - fixture_ids: self.selected_fixtures.clone(), - effect: pixel_effect, - distribution: EffectDistribution::All, - }); - } - - if ui.button("Clear Pixel Effects").clicked() { - // Clear all active pixel effects - let _ = console_tx.send(ConsoleCommand::ClearPixelEffects); - } - }); - } - - // Helper function to draw tab buttons - fn draw_tab_button(&mut self, ui: &mut egui::Ui, label: &str, tab: ActiveProgrammerTab) { - let is_active = self.active_tab == tab; - - let mut button = egui::Button::new(label); - if is_active { - button = button - .fill(Color32::from_rgb(30, 30, 30)) - .stroke(Stroke::new(1.0, Color32::from_rgb(0, 100, 255))); - } else { - button = button.fill(Color32::from_rgb(40, 40, 40)); - } - - if ui.add(button).clicked() { - self.active_tab = tab; - } - } - - // Draw a vertical slider with scale markings - fn vertical_slider( - &mut self, - ui: &mut egui::Ui, - param_name: &str, - display_name: &str, - min: f32, - max: f32, - height: f32, - console_tx: &mpsc::UnboundedSender, - ) -> bool { - let mut value = self.get_param(param_name); - let mut changed = false; - - ui.vertical(|ui| { - ui.label(display_name); - - let display_value = - if param_name.contains("color") || param_name == "dimmer" || param_name == "strobe" - { - format!("{}%", (value / max * 100.0).round()) - } else if param_name.contains("pan") || param_name.contains("tilt") { - format!("{}°", value.round()) - } else { - format!("{}", value.round()) - }; - - ui.label(display_value); - - // Create a custom vertical slider - let slider_height = height; - let slider_width = 36.0; - let (rect, response) = ui.allocate_exact_size( - Vec2::new(slider_width, slider_height), - Sense::click_and_drag(), - ); - - if response.dragged() { - let mouse_pos = response - .interact_pointer_pos() - .unwrap_or(Pos2::new(0.0, 0.0)); - let normalized = 1.0 - ((mouse_pos.y - rect.min.y) / slider_height).clamp(0.0, 1.0); - value = min + normalized * (max - min); - self.set_param(param_name, value); - self.update_fixture_values(console_tx); - changed = true; - } - - // Draw the slider background - ui.painter() - .rect_filled(rect, 4.0, Color32::from_rgb(30, 30, 30)); - - // Draw the fill - let fill_height = - ((value - min) / (max - min) * slider_height).clamp(0.0, slider_height); - let fill_rect = Rect::from_min_size( - Pos2::new(rect.min.x, rect.max.y - fill_height), - Vec2::new(slider_width, fill_height), - ); - - // Choose appropriate slider color based on parameter - let fill_color = if param_name == "red" { - Color32::from_rgb(255, 50, 50) - } else if param_name == "green" { - Color32::from_rgb(50, 255, 50) - } else if param_name == "blue" { - Color32::from_rgb(50, 50, 255) - } else if param_name == "white" { - Color32::from_rgb(200, 200, 200) - } else if param_name.contains("effect") { - Color32::from_rgb(150, 50, 200) - } else { - Color32::from_rgb(0, 150, 255) - }; - - ui.painter().rect_filled(fill_rect, 4.0, fill_color); - - // Draw tick marks - for i in 0..=4 { - let y = rect.min.y + i as f32 * (slider_height / 4.0); - ui.painter().line_segment( - [Pos2::new(rect.min.x, y), Pos2::new(rect.max.x, y)], - Stroke::new(1.0, Color32::from_rgb(70, 70, 70)), - ); - } - - // Draw + and - buttons for some sliders - if param_name == "dimmer" || param_name == "strobe" { - ui.horizontal(|ui| { - if ui.button("-").clicked() { - value = (value - (max - min) / 20.0).max(min); - self.set_param(param_name, value); - self.update_fixture_values(console_tx); - changed = true; - } - - if ui.button("+").clicked() { - value = (value + (max - min) / 20.0).min(max); - self.set_param(param_name, value); - self.update_fixture_values(console_tx); - changed = true; - } - }); - } - }); - - changed - } - - fn update_fixture_values(&self, console_tx: &mpsc::UnboundedSender) { - for &fixture_id in &self.selected_fixtures { - for (channel, value) in &self.params { - let _ = console_tx.send(ConsoleCommand::SetProgrammerValue { - fixture_id, - channel: channel.clone(), - value: *value as u8, - }); - } - } - } - - /// Sync all current dashboard programmer values to the console - /// This ensures the full view shows the current state of the dashboard programmer - fn sync_all_values_to_console(&self, console_tx: &mpsc::UnboundedSender) { - for &fixture_id in &self.selected_fixtures { - for (channel, value) in &self.params { - // Only send non-zero values to avoid cluttering the console state - if *value > 0.0 { - let _ = console_tx.send(ConsoleCommand::SetProgrammerValue { - fixture_id, - channel: channel.clone(), - value: *value as u8, - }); - } - } - } - } - - // Intensity tab content - fn show_intensity_tab( - &mut self, - ui: &mut egui::Ui, - console_tx: &mpsc::UnboundedSender, - ) { - ui.horizontal(|ui| { - let spacing = 20.0; - let slider_height = 180.0; - - ui.add_space(spacing); - self.vertical_slider( - ui, - "dimmer", - "Dimmer", - 0.0, - 100.0, - slider_height, - console_tx, - ); - - ui.add_space(spacing); - self.vertical_slider( - ui, - "strobe", - "Strobe", - 0.0, - 100.0, - slider_height, - console_tx, - ); - - ui.add_space(spacing * 2.0); - }); - } - - // Color tab content - fn show_color_tab( - &mut self, - ui: &mut egui::Ui, - console_tx: &mpsc::UnboundedSender, - ) { - ui.horizontal(|ui| { - let spacing = 20.0; - let slider_height = 180.0; - - ui.add_space(spacing); - self.vertical_slider(ui, "red", "Red", 0.0, 255.0, slider_height, console_tx); - - ui.add_space(spacing); - self.vertical_slider(ui, "green", "Green", 0.0, 255.0, slider_height, console_tx); - - ui.add_space(spacing); - self.vertical_slider(ui, "blue", "Blue", 0.0, 255.0, slider_height, console_tx); - - ui.add_space(spacing); - self.vertical_slider(ui, "white", "White", 0.0, 255.0, slider_height, console_tx); - - ui.add_space(spacing * 2.0); - - // Color presets - ui.vertical(|ui| { - ui.label("Presets"); - ui.add_space(5.0); - - // get a mutable copy of the color presets - let color_presets = self.color_presets.clone(); - - egui::Grid::new("color_presets") - .spacing([5.0, 5.0]) - .show(ui, |ui| { - for (i, color) in color_presets.iter().enumerate() { - let button_size = Vec2::new(30.0, 30.0); - let (rect, response) = - ui.allocate_exact_size(button_size, Sense::click()); - - // Draw the colored button - ui.painter().rect_filled(rect, 4.0, *color); - ui.painter().rect_stroke( - rect, - 4.0, - Stroke::new(1.0, Color32::from_gray(100)), - egui::StrokeKind::Inside, - ); - - if response.clicked() { - let r = color.r(); - let g = color.g(); - let b = color.b(); - - self.set_param("red", r as f32); - self.set_param("green", g as f32); - self.set_param("blue", b as f32); - - if r == g && g == b && r > 200 { - // White preset also sets white channel for RGBW fixtures - self.set_param("white", 255.0); - } else { - self.set_param("white", 0.0); - } - self.update_fixture_values(console_tx); - } - - if (i + 1) % 2 == 0 { - ui.end_row(); - } - } - }); - }); - }); - } - - // Position tab content - fn show_position_tab( - &mut self, - ui: &mut egui::Ui, - console_tx: &mpsc::UnboundedSender, - ) { - ui.horizontal(|ui| { - let spacing = 20.0; - let slider_height = 180.0; - - ui.add_space(spacing); - self.vertical_slider(ui, "pan", "Pan", 0.0, 360.0, slider_height, console_tx); - - ui.add_space(spacing); - self.vertical_slider(ui, "tilt", "Tilt", 0.0, 270.0, slider_height, console_tx); - - ui.add_space(spacing * 2.0); - - // Position Grid - ui.vertical(|ui| { - ui.label("Position Grid"); - ui.add_space(5.0); - - let grid_size = 140.0; - let (rect, response) = ui - .allocate_exact_size(Vec2::new(grid_size, grid_size), Sense::click_and_drag()); - - // Draw position grid background - ui.painter() - .rect_filled(rect, grid_size / 2.0, Color32::from_gray(30)); - ui.painter().rect_stroke( - rect, - grid_size / 2.0, - Stroke::new(1.0, Color32::from_gray(70)), - egui::StrokeKind::Inside, - ); - - // Draw crosshairs - ui.painter().line_segment( - [ - Pos2::new(rect.min.x, rect.center().y), - Pos2::new(rect.max.x, rect.center().y), - ], - Stroke::new(1.0, Color32::from_gray(70)), - ); - ui.painter().line_segment( - [ - Pos2::new(rect.center().x, rect.min.y), - Pos2::new(rect.center().x, rect.max.y), - ], - Stroke::new(1.0, Color32::from_gray(70)), - ); - - // Calculate the current position based on pan and tilt values - let pan = self.get_param("pan"); - let tilt = self.get_param("tilt"); - - let pan_normalized = (pan / 360.0).clamp(0.0, 1.0); - let tilt_normalized = (tilt / 270.0).clamp(0.0, 1.0); - - let pos_x = rect.min.x + pan_normalized * grid_size; - let pos_y = rect.min.y + (1.0 - tilt_normalized) * grid_size; - - // Draw the current position marker - ui.painter().circle_filled( - Pos2::new(pos_x, pos_y), - 6.0, - Color32::from_rgb(0, 150, 255), - ); - - // Update position if dragged - if response.dragged() { - if let Some(mouse_pos) = response.interact_pointer_pos() { - let new_pan = - ((mouse_pos.x - rect.min.x) / grid_size * 360.0).clamp(0.0, 360.0); - let new_tilt = (1.0 - (mouse_pos.y - rect.min.y) / grid_size) * 270.0; - - self.set_param("pan", new_pan); - self.set_param("tilt", new_tilt.clamp(0.0, 270.0)); - self.update_fixture_values(console_tx); - } - } - }); - - ui.add_space(spacing); - - // Channel selection for effects - ui.vertical(|ui| { - ui.label("Effect Channels"); - ui.add_space(5.0); - - let active_tab = self.active_tab.clone(); - let tab_effect_mut = self.tab_effects.get_mut(&active_tab); - - if let Some(tab_effect) = tab_effect_mut { - ui.checkbox(&mut tab_effect.pan_selected, "Pan"); - ui.checkbox(&mut tab_effect.tilt_selected, "Tilt"); - } - }); - }); - } - - // Beam tab content - fn show_beam_tab( - &mut self, - ui: &mut egui::Ui, - console_tx: &mpsc::UnboundedSender, - ) { - ui.horizontal(|ui| { - let spacing = 20.0; - let slider_height = 180.0; - - ui.add_space(spacing); - self.vertical_slider(ui, "focus", "Focus", 0.0, 100.0, slider_height, console_tx); - - ui.add_space(spacing); - self.vertical_slider(ui, "zoom", "Zoom", 0.0, 100.0, slider_height, console_tx); - - ui.add_space(spacing); - self.vertical_slider( - ui, - "gobo_rotation", - "Gobo Rot.", - -180.0, - 180.0, - slider_height, - console_tx, - ); - - ui.add_space(spacing * 2.0); - - // Gobo selection - ui.vertical(|ui| { - ui.label("Gobo"); - let gobo_selection = self.get_param("gobo_selection") as usize; - ui.label(format!("{}/8", gobo_selection + 1)); - - egui::Grid::new("gobo_selection") - .spacing([5.0, 5.0]) - .show(ui, |ui| { - for i in 0..8 { - let button_size = Vec2::new(30.0, 30.0); - let (rect, response) = - ui.allocate_exact_size(button_size, Sense::click()); - - // Draw the gobo button - let bg_color = if i == gobo_selection { - Color32::from_rgb(0, 100, 200) - } else { - Color32::from_rgb(40, 40, 40) - }; - - ui.painter().rect_filled(rect, 4.0, bg_color); - ui.painter().rect_stroke( - rect, - 4.0, - Stroke::new(1.0, Color32::from_gray(100)), - egui::StrokeKind::Inside, - ); - - // Draw the number in the center of the button - let text = format!("{}", i + 1); - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - text, - egui::FontId::proportional(12.0), - Color32::WHITE, - ); - - if response.clicked() { - self.set_param("gobo_selection", i as f32); - self.update_fixture_values(console_tx); - } - - if (i + 1) % 2 == 0 { - ui.end_row(); - } - } - }); - }); - }); - } - - // Effects panel - fn show_effects_panel( - &mut self, - ui: &mut egui::Ui, - _state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - ) { - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.set_min_width(200.0); - ui.heading("EFFECTS"); - - // Add a dynamic subtitle based on the active tab - let effects_subtitle = match self.active_tab { - ActiveProgrammerTab::Intensity => "Effects on Intensity", - ActiveProgrammerTab::Color => "Effects on Color", - ActiveProgrammerTab::Position => "Effects on Position", - ActiveProgrammerTab::Beam => "Effects on Beam", - ActiveProgrammerTab::PixelEffects => "Pixel Effects", - }; - ui.label(effects_subtitle); - - ui.add_space(5.0); - - // Render effects controls - self.render_effects_controls(ui, console_tx); - ui.add_space(10.0); - }); - - ui.vertical(|ui| { - let tab_effect_opt = self.tab_effects.get(&self.active_tab); - if let Some(tab_effect) = tab_effect_opt { - self.show_waveform_visualization(ui, tab_effect); - } - }); - }); - } - - fn render_effects_controls( - &mut self, - ui: &mut egui::Ui, - console_tx: &mpsc::UnboundedSender, - ) { - // Get the current tab's effect config - let active_tab = self.active_tab.clone(); - let tab_effect_mut = self.tab_effects.get_mut(&active_tab); - - if let Some(tab_effect) = tab_effect_mut { - // Waveform dropdown - egui::ComboBox::from_label("Waveform") - .selected_text(match tab_effect.effect_waveform { - 0 => "Sine", - 1 => "Square", - 2 => "Sawtooth", - 3 => "Triangle", - _ => "Sine", - }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut tab_effect.effect_waveform, 0, "Sine"); - ui.selectable_value(&mut tab_effect.effect_waveform, 1, "Square"); - ui.selectable_value(&mut tab_effect.effect_waveform, 2, "Sawtooth"); - ui.selectable_value(&mut tab_effect.effect_waveform, 3, "Triangle"); - }); - - // Interval dropdown - egui::ComboBox::from_label("Interval") - .selected_text(match tab_effect.effect_interval { - 0 => "Beat", - 1 => "Bar", - 2 => "Phrase", - _ => "Beat", - }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut tab_effect.effect_interval, 0, "Beat"); - ui.selectable_value(&mut tab_effect.effect_interval, 1, "Bar"); - ui.selectable_value(&mut tab_effect.effect_interval, 2, "Phrase"); - }); - - ui.add_space(10.0); - - // Effect parameter sliders - simplified to avoid borrow checker issues - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.label("Ratio"); - let mut ratio = tab_effect.effect_ratio; - if ui.add(egui::Slider::new(&mut ratio, 0.0..=2.0)).changed() { - tab_effect.effect_ratio = ratio; - } - }); - - ui.add_space(15.0); - - ui.vertical(|ui| { - ui.label("Phase"); - let mut phase = tab_effect.effect_phase; - if ui.add(egui::Slider::new(&mut phase, 0.0..=360.0)).changed() { - tab_effect.effect_phase = phase; - } - }); - }); - - ui.add_space(10.0); - - // Distribution dropdown - egui::ComboBox::from_label("Distribution") - .selected_text(match tab_effect.effect_distribution { - 0 => "All", - 1 => "Step", - 2 => "Wave", - _ => "All", - }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut tab_effect.effect_distribution, 0, "All"); - ui.selectable_value(&mut tab_effect.effect_distribution, 1, "Step"); - ui.selectable_value(&mut tab_effect.effect_distribution, 2, "Wave"); - }); - - // After the Distribution dropdown - ui.add_space(10.0); - - // Only show appropriate input field based on selected distribution - match tab_effect.effect_distribution { - 1 => { - // Step distribution - ui.horizontal(|ui| { - ui.label("Step Value:"); - let mut step_value = tab_effect.effect_step_value as i32; - if ui - .add( - egui::DragValue::new(&mut step_value) - .range(1..=16) - .speed(0.1), - ) - .changed() - { - tab_effect.effect_step_value = step_value.max(1) as usize; - } - }); - } - 2 => { - // Wave distribution - ui.horizontal(|ui| { - ui.label("Wave Offset:"); - let mut wave_offset = tab_effect.effect_wave_offset; - if ui - .add(egui::Slider::new(&mut wave_offset, 0.0..=180.0).suffix("°")) - .changed() - { - tab_effect.effect_wave_offset = wave_offset; - } - }); - } - _ => {} - } - - // Apply Effects Button - if ui.button("Apply Effects").clicked() { - if !self.selected_fixtures.is_empty() { - let effect_type = match tab_effect.effect_waveform { - 0 => EffectType::Sine, - 1 => EffectType::Square, - 2 => EffectType::Sawtooth, - 3 => EffectType::Triangle, - _ => EffectType::Sine, - }; - - let channel_types = match self.active_tab { - ActiveProgrammerTab::Intensity => vec!["dimmer".to_string()], - ActiveProgrammerTab::Color => vec!["color".to_string()], - ActiveProgrammerTab::Position => { - let mut channels = Vec::new(); - if tab_effect.pan_selected { - channels.push("pan".to_string()); - } - if tab_effect.tilt_selected { - channels.push("tilt".to_string()); - } - channels - } - ActiveProgrammerTab::Beam => vec!["beam".to_string()], - ActiveProgrammerTab::PixelEffects => vec!["pixel".to_string()], - }; - - let _ = console_tx.send(ConsoleCommand::ApplyProgrammerEffect { - fixture_ids: self.selected_fixtures.clone(), - channel_types, - effect_type, - waveform: tab_effect.effect_waveform, - interval: tab_effect.effect_interval, - ratio: tab_effect.effect_ratio, - phase: tab_effect.effect_phase, - distribution: tab_effect.effect_distribution, - step_value: if tab_effect.effect_distribution == 1 { - Some(tab_effect.effect_step_value) - } else { - None - }, - wave_offset: if tab_effect.effect_distribution == 2 { - Some(tab_effect.effect_wave_offset) - } else { - None - }, - }); - } - } - } - } - - fn show_waveform_visualization(&self, ui: &mut egui::Ui, tab_effect: &TabEffectConfig) { - // Get effect parameters from the current tab's effect config - let waveform_type = tab_effect.effect_waveform; - let ratio = tab_effect.effect_ratio; - let phase_degrees = tab_effect.effect_phase; - let phase_radians = phase_degrees * PI as f32 / 180.0; - - // Generate points for the selected waveform - let n_points = 100; - let mut points = Vec::with_capacity(n_points); - - for i in 0..n_points { - let x = i as f64 / (n_points - 1) as f64 * 2.0 * PI; - let phase = phase_radians as f64; - let r = ratio as f64; - - // Calculate y based on waveform type - let y = match waveform_type { - 0 => { - // Sine - (x * r + phase).sin() - } - 1 => { - // Square - if ((x * r + phase) % (2.0 * PI)).sin() >= 0.0 { - 1.0 - } else { - -1.0 - } - } - 2 => { - // Sawtooth - let mut v = ((x * r + phase) % (2.0 * PI)) / PI - 1.0; - if v > 1.0 { - v -= 2.0 - }; - v - } - 3 => { - // Triangle - let p = (x * r + phase) % (2.0 * PI); - if p < PI { - -1.0 + 2.0 * p / PI - } else { - 3.0 - 2.0 * p / PI - } - } - _ => (x * r + phase).sin(), // Default to sine - }; - - points.push([x, y]); - } - - // Create plot points and line - let plot_points = PlotPoints::from(points); - let line = Line::new("", plot_points).color(match self.active_tab { - ActiveProgrammerTab::Intensity => egui::Color32::from_rgb(0, 150, 255), - ActiveProgrammerTab::Color => egui::Color32::from_rgb(255, 100, 100), - ActiveProgrammerTab::Position => egui::Color32::from_rgb(100, 255, 100), - ActiveProgrammerTab::Beam => egui::Color32::from_rgb(255, 200, 0), - ActiveProgrammerTab::PixelEffects => egui::Color32::from_rgb(255, 20, 147), - }); - - // Create and show the plot - Plot::new("effect_waveform") - .height(120.0) - .allow_zoom(false) - .allow_drag(false) - .show_axes([false, false]) - .include_y(-1.2) - .include_y(1.2) - .show(ui, |plot_ui| { - plot_ui.line(line); - }); - - // Add title for the plot - ui.label("Waveform Preview"); - } - - fn render_vertical_fader( - &self, - ui: &mut egui::Ui, - value: &mut f32, - min: f32, - max: f32, - height: f32, - ) -> bool { - let mut changed = false; - let rect = ui - .allocate_exact_size(Vec2::new(30.0, height), Sense::click_and_drag()) - .0; - - // Draw background - ui.painter().rect_filled(rect, 4.0, Color32::from_gray(40)); - - // Calculate value position - let normalized_value = (*value - min) / (max - min); - let value_y = rect.bottom() - normalized_value * rect.height(); - - // Draw value indicator - let indicator_rect = Rect::from_min_size( - Pos2::new(rect.left(), value_y - 2.0), - Vec2::new(rect.width(), 4.0), - ); - ui.painter() - .rect_filled(indicator_rect, 2.0, Color32::from_rgb(0, 150, 255)); - - // Handle input - if ui.is_rect_visible(rect) { - let response = ui.interact(rect, ui.id().with("fader"), Sense::click_and_drag()); - if response.dragged() { - let delta = -response.drag_delta().y; - let value_delta = delta / rect.height() * (max - min); - *value = (*value + value_delta).clamp(min, max); - changed = true; - } - } - - // Draw value text - let text = format!("{:.1}", *value); - ui.painter().text( - Pos2::new(rect.right() + 5.0, rect.center().y), - egui::Align2::LEFT_CENTER, - text, - egui::FontId::proportional(12.0), - Color32::WHITE, - ); - - changed - } -} - -pub struct Programmer { - state: ProgrammerState, -} - -impl Programmer { - pub fn new() -> Self { - Self { - state: ProgrammerState::new(), - } - } - - pub fn show( - &mut self, - ui: &mut egui::Ui, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - ) { - self.state.show(ui, state, console_tx); - } - - pub fn render_full_view( - &mut self, - ctx: &egui::Context, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - ) { - self.state.render_full_view(ctx, state, console_tx); - } - - pub fn set_selected_fixtures(&mut self, selected_fixtures: Vec) { - self.state.set_selected_fixtures(selected_fixtures); - } -} - -pub fn render( - ui: &mut eframe::egui::Ui, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, -) { - let mut programmer = ProgrammerState::default(); - programmer.render(ui.ctx(), state, console_tx); -} - -pub fn render_compact( - ui: &mut eframe::egui::Ui, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - programmer_state: &mut ProgrammerState, -) { - programmer_state.show(ui, state, console_tx); -} diff --git a/crates/ui/src/session.rs b/crates/ui/src/session.rs deleted file mode 100644 index 192c35d..0000000 --- a/crates/ui/src/session.rs +++ /dev/null @@ -1,270 +0,0 @@ -use std::time::SystemTime; - -use eframe::egui::{Align, Color32, FontId, Layout, RichText}; -use halo_core::{ConsoleCommand, PlaybackState}; -use tokio::sync::mpsc; - -use crate::state::ConsoleState; - -enum ClockMode { - TimeCode, - System, -} - -/// A panel that shows the current session overview. -/// -/// This includes: -/// - A toggable clock that can show either timecode or the system clock. -/// - The Master BPM display +/- buttons. -/// - Ableton Link status and connected peers. -/// - Large transport controls (GO, HOLD, STOP). -pub struct SessionPanel { - // Clock state - clock_mode: ClockMode, -} - -impl Default for SessionPanel { - fn default() -> Self { - Self { - clock_mode: ClockMode::TimeCode, - } - } -} - -impl SessionPanel { - pub fn render( - &mut self, - ui: &mut eframe::egui::Ui, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - ) { - // Session UI with margin - let frame = eframe::egui::Frame::default().inner_margin(10.0); - - frame.show(ui, |ui| { - ui.vertical(|ui| { - // Top row - header and mode toggle - ui.horizontal(|ui| { - ui.heading("Session"); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - let mode_text = match self.clock_mode { - ClockMode::TimeCode => "TC", - ClockMode::System => "SYS", - }; - - if ui.button(format!("🕒 {}", mode_text)).clicked() { - self.clock_mode = match self.clock_mode { - ClockMode::TimeCode => ClockMode::System, - ClockMode::System => ClockMode::TimeCode, - }; - } - }); - }); - - ui.add_space(10.0); - - // Clock display - ui.group(|ui| { - ui.set_min_width(ui.available_width()); - - let clock_text = match self.clock_mode { - ClockMode::TimeCode => { - if let Some(timecode) = &state.timecode { - format!( - "{:02}:{:02}:{:02}.{:02}", - timecode.hours, - timecode.minutes, - timecode.seconds, - timecode.frames - ) - } else { - "00:00:00.00".to_string() - } - } - ClockMode::System => { - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default(); - let total_secs = now.as_secs(); - let hours = (total_secs / 3600) % 24; - let minutes = (total_secs / 60) % 60; - let seconds = total_secs % 60; - format!("{:02}:{:02}:{:02}", hours, minutes, seconds) - } - }; - - // Large monospace font for clock - let font_id = FontId::monospace(32.0); - ui.vertical(|ui| { - ui.label(RichText::new(clock_text).font(font_id)); - - let mode_label = match self.clock_mode { - ClockMode::TimeCode => "Timecode", - ClockMode::System => "System Clock", - }; - ui.label(mode_label); - }); - }); - - ui.add_space(10.0); - - // BPM controls - ui.horizontal(|ui| { - ui.group(|ui| { - ui.vertical(|ui| { - ui.label("Master BPM"); - - ui.horizontal(|ui| { - if ui.button("-").clicked() { - let _ = console_tx.send(ConsoleCommand::SetBpm { - bpm: state.bpm - 0.1, - }); - } - - let bpm_text = format!("{:.1}", state.bpm); - let font_id = FontId::monospace(24.0); - ui.colored_label( - Color32::from_rgb(255, 215, 0), - RichText::new(bpm_text).font(font_id), - ); - - if ui.button("+").clicked() { - let _ = console_tx.send(ConsoleCommand::SetBpm { - bpm: state.bpm + 0.1, - }); - } - }); - - ui.horizontal(|ui| { - if ui.button("-1.0").clicked() { - let _ = console_tx.send(ConsoleCommand::SetBpm { - bpm: state.bpm - 1.0, - }); - } - if ui.button("+1.0").clicked() { - let _ = console_tx.send(ConsoleCommand::SetBpm { - bpm: state.bpm + 1.0, - }); - } - }); - }); - }); - - ui.add_space(10.0); - - // Ableton Link - ui.group(|ui| { - ui.vertical(|ui| { - ui.label("Ableton Link"); - let link_text = if state.link_enabled { - "LINK ●" - } else { - "LINK ○" - }; - let link_color = if state.link_enabled { - Color32::from_rgb(66, 133, 244) - } else { - ui.style().visuals.text_color() - }; - - if ui - .button(RichText::new(link_text).color(link_color)) - .clicked() - { - if state.link_enabled { - let _ = console_tx.send(ConsoleCommand::DisableAbletonLink); - } else { - let _ = console_tx.send(ConsoleCommand::EnableAbletonLink); - } - } - - let status_text = if state.link_enabled { - "Connected" - } else { - "Disabled" - }; - ui.label(status_text); - - let peers_text = if state.link_enabled { - if state.link_peers == 1 { - "1 peer connected".to_string() - } else { - format!("{} peers connected", state.link_peers) - } - } else { - "No peers connected".to_string() - }; - ui.label(peers_text); - }); - }); - }); - - ui.add_space(10.0); - - // Large transport controls - ui.group(|ui| { - ui.horizontal(|ui| { - ui.set_min_width(ui.available_width()); - // Create large buttons with current state colors - let button_height = 60.0; - let button_width = ui.available_width() / 3.0 - 10.0; - - // Go button - let play_text = - RichText::new("▶ GO") - .size(18.0) - .color(match state.playback_state { - PlaybackState::Playing => ui.style().visuals.text_color(), - _ => Color32::from_rgb(120, 255, 120), - }); - - let play_button = ui.add_sized( - [button_width, button_height], - eframe::egui::Button::new(play_text), - ); - - if play_button.clicked() { - let _ = console_tx.send(ConsoleCommand::Play); - } - - // Hold button - let hold_text = - RichText::new("⏸ HOLD") - .size(18.0) - .color(match state.playback_state { - PlaybackState::Playing => Color32::from_rgb(255, 215, 0), - _ => ui.style().visuals.text_color(), - }); - - let hold_button = ui.add_sized( - [button_width, button_height], - eframe::egui::Button::new(hold_text), - ); - - if hold_button.clicked() { - let _ = console_tx.send(ConsoleCommand::Pause); - } - - // Stop button - let stop_text = - RichText::new("⏹ STOP") - .size(18.0) - .color(match state.playback_state { - PlaybackState::Stopped => ui.style().visuals.text_color(), - _ => Color32::from_rgb(255, 100, 100), - }); - - let stop_button = ui.add_sized( - [button_width, button_height], - eframe::egui::Button::new(stop_text), - ); - - if stop_button.clicked() { - let _ = console_tx.send(ConsoleCommand::Stop); - } - }); - }); - }); - }); - } -} diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs deleted file mode 100644 index edf3b82..0000000 --- a/crates/ui/src/settings.rs +++ /dev/null @@ -1,627 +0,0 @@ -use eframe::egui; -use halo_core::{ConsoleCommand, Settings}; -use tokio::sync::mpsc; - -use crate::state::ConsoleState; - -#[derive(Debug, Clone, Copy, PartialEq)] -enum SettingsTab { - General, - Audio, - Midi, - Outputs, - PixelEngine, -} - -#[derive(Clone)] -pub struct SettingsPanel { - pub open: bool, - active_tab: SettingsTab, - - // General settings - pub target_fps: String, - pub enable_autosave: bool, - pub autosave_interval: String, - - // Audio settings - pub audio_device: String, - pub audio_buffer_size: String, - pub audio_sample_rate: String, - - // MIDI settings - pub midi_enabled: bool, - pub midi_device: String, - pub midi_channel: String, - - // Output settings (DMX/Art-Net) - pub dmx_enabled: bool, - pub dmx_broadcast: bool, - pub dmx_source_ip: String, - pub dmx_dest_ip: String, - pub dmx_port: String, - pub wled_enabled: bool, - pub wled_ip: String, - - // Pixel engine settings - pub pixel_engine_enabled: bool, - pub pixel_engine_fps: String, - - // Fixture settings - pub enable_pan_tilt_limits: bool, - - // Internal state - initialized: bool, -} - -impl Default for SettingsPanel { - fn default() -> Self { - Self { - open: false, - active_tab: SettingsTab::General, - - // General defaults - target_fps: "60".to_string(), - enable_autosave: false, - autosave_interval: "300".to_string(), - - // Audio defaults - audio_device: "Default".to_string(), - audio_buffer_size: "512".to_string(), - audio_sample_rate: "48000".to_string(), - - // MIDI defaults - midi_enabled: false, - midi_device: "None".to_string(), - midi_channel: "1".to_string(), - - // Output defaults - dmx_enabled: true, - dmx_broadcast: false, - dmx_source_ip: "192.168.1.100".to_string(), - dmx_dest_ip: "192.168.1.200".to_string(), - dmx_port: "6454".to_string(), - wled_enabled: false, - wled_ip: "192.168.1.50".to_string(), - - // Pixel engine defaults - pixel_engine_enabled: false, - pixel_engine_fps: "44.0".to_string(), - - // Fixture defaults - enable_pan_tilt_limits: true, - - // Internal state - initialized: false, - } - } -} - -impl SettingsPanel { - pub fn new() -> Self { - Self::default() - } - - pub fn open(&mut self) { - self.open = true; - } - - /// Request audio devices from the console - pub fn request_audio_devices(console_tx: &mpsc::UnboundedSender) { - let _ = console_tx.send(ConsoleCommand::QueryAudioDevices); - } - - /// Load settings from console state - pub fn load_from_state(&mut self, state: &ConsoleState) { - let settings = &state.settings; - - // Load general settings - self.target_fps = settings.target_fps.to_string(); - self.enable_autosave = settings.enable_autosave; - self.autosave_interval = settings.autosave_interval_secs.to_string(); - - // Load audio settings - self.audio_device = settings.audio_device.clone(); - self.audio_buffer_size = settings.audio_buffer_size.to_string(); - self.audio_sample_rate = settings.audio_sample_rate.to_string(); - - // Load MIDI settings - self.midi_enabled = settings.midi_enabled; - self.midi_device = settings.midi_device.clone(); - self.midi_channel = settings.midi_channel.to_string(); - - // Load output settings - self.dmx_enabled = settings.dmx_enabled; - self.dmx_broadcast = settings.dmx_broadcast; - self.dmx_source_ip = settings.dmx_source_ip.clone(); - self.dmx_dest_ip = settings.dmx_dest_ip.clone(); - self.dmx_port = settings.dmx_port.to_string(); - self.wled_enabled = settings.wled_enabled; - self.wled_ip = settings.wled_ip.clone(); - - // Load pixel engine settings - self.pixel_engine_enabled = settings.pixel_engine_enabled; - self.pixel_engine_fps = settings.pixel_engine_fps.to_string(); - - // Load fixture settings - self.enable_pan_tilt_limits = settings.enable_pan_tilt_limits; - } - - pub fn render( - &mut self, - ctx: &egui::Context, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - ) { - if !self.open { - return; - } - - // Load settings from state on first render and request audio devices - if !self.initialized { - self.load_from_state(state); - Self::request_audio_devices(console_tx); - self.initialized = true; - } - - let mut open = self.open; - - let mut should_close_from_button = false; - - egui::Window::new("Settings") - .open(&mut open) - .default_width(600.0) - .default_height(500.0) - .resizable(true) - .collapsible(false) - .show(ctx, |ui| { - should_close_from_button = self.render_content(ui, state, console_tx); - }); - - // Handle close from either X button or Close button - if should_close_from_button { - self.open = false; - } else { - self.open = open; - } - } - - fn render_content( - &mut self, - ui: &mut egui::Ui, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - ) -> bool { - ui.horizontal(|ui| { - ui.selectable_value(&mut self.active_tab, SettingsTab::General, "General"); - ui.selectable_value(&mut self.active_tab, SettingsTab::Audio, "Audio"); - ui.selectable_value(&mut self.active_tab, SettingsTab::Midi, "MIDI"); - ui.selectable_value(&mut self.active_tab, SettingsTab::Outputs, "Outputs"); - ui.selectable_value( - &mut self.active_tab, - SettingsTab::PixelEngine, - "Pixel Engine", - ); - }); - - ui.separator(); - - egui::ScrollArea::vertical().show(ui, |ui| match self.active_tab { - SettingsTab::General => self.render_general_tab(ui, console_tx), - SettingsTab::Audio => self.render_audio_tab(ui, state, console_tx), - SettingsTab::Midi => self.render_midi_tab(ui, console_tx), - SettingsTab::Outputs => self.render_outputs_tab(ui, console_tx), - SettingsTab::PixelEngine => self.render_pixel_engine_tab(ui, state, console_tx), - }); - - ui.separator(); - - // Footer buttons - let mut should_close = false; - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Close").clicked() { - should_close = true; - } - if ui.button("Apply").clicked() { - // Apply settings - self.apply_settings(console_tx); - } - }); - }); - - // Return whether the close button was clicked - should_close - } - - fn render_general_tab( - &mut self, - ui: &mut egui::Ui, - _console_tx: &mpsc::UnboundedSender, - ) { - ui.heading("General Settings"); - ui.add_space(10.0); - - egui::Grid::new("general_settings_grid") - .num_columns(2) - .spacing([40.0, 8.0]) - .striped(true) - .show(ui, |ui| { - ui.label("Target FPS:"); - ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut self.target_fps).desired_width(100.0)); - ui.label("(UI refresh rate)"); - }); - ui.end_row(); - - ui.label("Auto-save:"); - ui.checkbox(&mut self.enable_autosave, "Enable automatic show saving"); - ui.end_row(); - - if self.enable_autosave { - ui.label("Auto-save interval:"); - ui.horizontal(|ui| { - ui.add( - egui::TextEdit::singleline(&mut self.autosave_interval) - .desired_width(100.0), - ); - ui.label("seconds"); - }); - ui.end_row(); - } - }); - - ui.add_space(20.0); - ui.separator(); - ui.add_space(10.0); - - ui.label("Application Information"); - ui.add_space(5.0); - ui.label(format!("Version: {}", env!("CARGO_PKG_VERSION"))); - ui.label("Halo Lighting Console"); - } - - fn render_audio_tab( - &mut self, - ui: &mut egui::Ui, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - ) { - ui.heading("Audio Settings"); - ui.add_space(10.0); - - egui::Grid::new("audio_settings_grid") - .num_columns(2) - .spacing([40.0, 8.0]) - .striped(true) - .show(ui, |ui| { - ui.label("Audio Device:"); - egui::ComboBox::from_id_salt("audio_device_combo") - .selected_text(&self.audio_device) - .show_ui(ui, |ui| { - // Show actual audio devices from the state - if state.audio_devices.is_empty() { - ui.label("Loading devices..."); - if ui.button("Refresh").clicked() { - Self::request_audio_devices(console_tx); - } - } else { - for device in &state.audio_devices { - let label = if device.is_default { - format!("{} (Default)", device.name) - } else { - device.name.clone() - }; - ui.selectable_value( - &mut self.audio_device, - device.name.clone(), - label, - ); - } - } - }); - ui.end_row(); - - ui.label("Buffer Size:"); - egui::ComboBox::from_id_salt("audio_buffer_size") - .selected_text(&self.audio_buffer_size) - .show_ui(ui, |ui| { - ui.selectable_value(&mut self.audio_buffer_size, "128".to_string(), "128"); - ui.selectable_value(&mut self.audio_buffer_size, "256".to_string(), "256"); - ui.selectable_value(&mut self.audio_buffer_size, "512".to_string(), "512"); - ui.selectable_value( - &mut self.audio_buffer_size, - "1024".to_string(), - "1024", - ); - ui.selectable_value( - &mut self.audio_buffer_size, - "2048".to_string(), - "2048", - ); - }); - ui.end_row(); - - ui.label("Sample Rate:"); - egui::ComboBox::from_id_salt("audio_sample_rate") - .selected_text(format!("{} Hz", self.audio_sample_rate)) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut self.audio_sample_rate, - "44100".to_string(), - "44100 Hz", - ); - ui.selectable_value( - &mut self.audio_sample_rate, - "48000".to_string(), - "48000 Hz", - ); - ui.selectable_value( - &mut self.audio_sample_rate, - "96000".to_string(), - "96000 Hz", - ); - }); - ui.end_row(); - }); - - ui.add_space(10.0); - ui.label("Note: Audio device changes will take effect after restart."); - } - - fn render_midi_tab( - &mut self, - ui: &mut egui::Ui, - _console_tx: &mpsc::UnboundedSender, - ) { - ui.heading("MIDI Settings"); - ui.add_space(10.0); - - egui::Grid::new("midi_settings_grid") - .num_columns(2) - .spacing([40.0, 8.0]) - .striped(true) - .show(ui, |ui| { - ui.label("MIDI Input:"); - ui.checkbox(&mut self.midi_enabled, "Enable MIDI input"); - ui.end_row(); - - if self.midi_enabled { - ui.label("MIDI Device:"); - egui::ComboBox::from_id_salt("midi_device_combo") - .selected_text(&self.midi_device) - .show_ui(ui, |ui| { - ui.selectable_value(&mut self.midi_device, "None".to_string(), "None"); - ui.selectable_value( - &mut self.midi_device, - "Virtual MIDI".to_string(), - "Virtual MIDI", - ); - // In a real implementation, enumerate actual MIDI devices here - ui.label("(Available MIDI devices would be listed here)"); - }); - ui.end_row(); - - ui.label("MIDI Channel:"); - egui::ComboBox::from_id_salt("midi_channel") - .selected_text(format!("Channel {}", self.midi_channel)) - .show_ui(ui, |ui| { - for i in 1..=16 { - ui.selectable_value( - &mut self.midi_channel, - i.to_string(), - format!("Channel {i}"), - ); - } - }); - ui.end_row(); - } - }); - - ui.add_space(10.0); - ui.label("MIDI Learn and mapping features coming soon."); - } - - fn render_outputs_tab( - &mut self, - ui: &mut egui::Ui, - _console_tx: &mpsc::UnboundedSender, - ) { - ui.heading("Output Settings"); - ui.add_space(10.0); - - // DMX / Art-Net Section - ui.label("DMX Output (Art-Net)"); - ui.separator(); - ui.add_space(5.0); - - egui::Grid::new("dmx_settings_grid") - .num_columns(2) - .spacing([40.0, 8.0]) - .striped(true) - .show(ui, |ui| { - ui.label("DMX Output:"); - ui.checkbox(&mut self.dmx_enabled, "Enable DMX output"); - ui.end_row(); - - if self.dmx_enabled { - ui.label("Mode:"); - ui.horizontal(|ui| { - ui.radio_value(&mut self.dmx_broadcast, true, "Broadcast"); - ui.radio_value(&mut self.dmx_broadcast, false, "Unicast"); - }); - ui.end_row(); - - ui.label("Source IP:"); - ui.add( - egui::TextEdit::singleline(&mut self.dmx_source_ip).desired_width(150.0), - ); - ui.end_row(); - - if !self.dmx_broadcast { - ui.label("Destination IP:"); - ui.add( - egui::TextEdit::singleline(&mut self.dmx_dest_ip).desired_width(150.0), - ); - ui.end_row(); - } - - ui.label("Port:"); - ui.add(egui::TextEdit::singleline(&mut self.dmx_port).desired_width(100.0)); - ui.end_row(); - } - }); - - ui.add_space(20.0); - - // WLED Section - ui.label("WLED Support"); - ui.separator(); - ui.add_space(5.0); - - egui::Grid::new("wled_settings_grid") - .num_columns(2) - .spacing([40.0, 8.0]) - .striped(true) - .show(ui, |ui| { - ui.label("WLED Output:"); - ui.checkbox(&mut self.wled_enabled, "Enable WLED support (coming soon)"); - ui.end_row(); - - if self.wled_enabled { - ui.label("WLED IP Address:"); - ui.add(egui::TextEdit::singleline(&mut self.wled_ip).desired_width(150.0)); - ui.end_row(); - } - }); - - ui.add_space(10.0); - ui.label("Note: Output changes require restart to take effect."); - } - - fn render_pixel_engine_tab( - &mut self, - ui: &mut egui::Ui, - state: &ConsoleState, - _console_tx: &mpsc::UnboundedSender, - ) { - ui.heading("Pixel Engine Settings"); - ui.add_space(10.0); - - ui.label( - "Configure the pixel engine for driving LED pixel bars via Art-Net (e.g., Enttec Octo MK2).", - ); - ui.add_space(10.0); - - egui::Grid::new("pixel_engine_settings_grid") - .num_columns(2) - .spacing([40.0, 8.0]) - .striped(true) - .show(ui, |ui| { - ui.label("Pixel Engine:"); - ui.checkbox(&mut self.pixel_engine_enabled, "Enable pixel engine"); - ui.end_row(); - - if self.pixel_engine_enabled { - ui.label("Target FPS:"); - ui.horizontal(|ui| { - ui.add( - egui::TextEdit::singleline(&mut self.pixel_engine_fps) - .desired_width(100.0), - ); - ui.label("Hz (default: 44Hz for DMX)"); - }); - ui.end_row(); - } - }); - - ui.add_space(20.0); - - if self.pixel_engine_enabled { - ui.label("Universe Mapping"); - ui.separator(); - ui.add_space(5.0); - - ui.label("Map pixel bar fixtures to Art-Net universes:"); - ui.add_space(5.0); - - // Show current pixel bar fixtures and their universe mappings - egui::ScrollArea::vertical() - .max_height(200.0) - .show(ui, |ui| { - let pixel_fixtures: Vec<_> = state - .fixtures - .iter() - .filter(|(_, f)| { - f.profile.fixture_type == halo_fixtures::FixtureType::PixelBar - }) - .collect(); - - if pixel_fixtures.is_empty() { - ui.label("No pixel bar fixtures patched."); - } else { - egui::Grid::new("pixel_universe_mapping") - .num_columns(3) - .spacing([20.0, 8.0]) - .striped(true) - .show(ui, |ui| { - ui.label("Fixture"); - ui.label("Default Universe"); - ui.label("Info"); - ui.end_row(); - - for (_, fixture) in pixel_fixtures { - ui.label(&fixture.name); - ui.label(format!("{}", fixture.universe)); - ui.label(format!( - "{} @ {}", - fixture.profile.model, fixture.start_address - )); - ui.end_row(); - } - }); - } - }); - - ui.add_space(10.0); - ui.label("Note: Use ConfigurePixelEngine command to map fixtures to custom universes."); - } - - ui.add_space(10.0); - ui.label("Pixel effects can be applied through cues or the programmer panel."); - } - - fn apply_settings(&self, console_tx: &mpsc::UnboundedSender) { - // Convert UI settings to Settings struct - let settings = Settings { - target_fps: self.target_fps.parse().unwrap_or(60), - enable_autosave: self.enable_autosave, - autosave_interval_secs: self.autosave_interval.parse().unwrap_or(300), - - audio_device: self.audio_device.clone(), - audio_buffer_size: self.audio_buffer_size.parse().unwrap_or(512), - audio_sample_rate: self.audio_sample_rate.parse().unwrap_or(48000), - - midi_enabled: self.midi_enabled, - midi_device: self.midi_device.clone(), - midi_channel: self.midi_channel.parse().unwrap_or(1), - - dmx_enabled: self.dmx_enabled, - dmx_broadcast: self.dmx_broadcast, - dmx_source_ip: self.dmx_source_ip.clone(), - dmx_dest_ip: self.dmx_dest_ip.clone(), - dmx_port: self.dmx_port.parse().unwrap_or(6454), - wled_enabled: self.wled_enabled, - wled_ip: self.wled_ip.clone(), - - pixel_engine_enabled: self.pixel_engine_enabled, - pixel_engine_fps: self.pixel_engine_fps.parse().unwrap_or(44.0), - pixel_universe_mapping: std::collections::HashMap::new(), - - enable_pan_tilt_limits: self.enable_pan_tilt_limits, - }; - - // Send update command - let _ = console_tx.send(ConsoleCommand::UpdateSettings { settings }); - println!("Settings applied and sent to console"); - } -} diff --git a/crates/ui/src/show_panel.rs b/crates/ui/src/show_panel.rs deleted file mode 100644 index 5eb461f..0000000 --- a/crates/ui/src/show_panel.rs +++ /dev/null @@ -1,86 +0,0 @@ -use eframe::egui; -use halo_core::ConsoleCommand; -use tokio::sync::mpsc; - -use crate::state::ConsoleState; - -pub struct ShowPanelState { - new_show_name: String, - new_show_path: String, -} - -impl Default for ShowPanelState { - fn default() -> Self { - Self { - new_show_name: String::new(), - new_show_path: String::new(), - } - } -} - -impl ShowPanelState { - pub fn render( - &mut self, - ctx: &egui::Context, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, - ) { - egui::CentralPanel::default().show(ctx, |ui| { - ui.vertical(|ui| { - ui.heading("Show Manager"); - - // Show info - if let Some(show) = &state.show { - ui.heading("Current Show"); - ui.label(format!("Name: {}", show.name)); - ui.label(format!("Version: {}", show.version)); - ui.label(format!("Created: {:?}", show.created_at)); - ui.label(format!("Modified: {:?}", show.modified_at)); - - ui.separator(); - } - - // Show controls - ui.heading("Show Controls"); - ui.horizontal(|ui| { - if ui.button("New Show").clicked() { - // TODO: Implement new show creation - ui.label("New show creation not yet implemented"); - } - - if ui.button("Load Show").clicked() { - // TODO: Implement show loading - ui.label("Show loading not yet implemented"); - } - - if ui.button("Save Show").clicked() { - let _ = console_tx.send(ConsoleCommand::SaveShow); - } - - if ui.button("Save Show As").clicked() { - // TODO: Implement save as - ui.label("Save as not yet implemented"); - } - }); - - ui.separator(); - - // Show statistics - ui.heading("Show Statistics"); - ui.label(format!("Fixtures: {}", state.fixtures.len())); - ui.label(format!("Cue Lists: {}", state.cue_lists.len())); - ui.label(format!("BPM: {:.1}", state.bpm)); - ui.label(format!("Playback State: {:?}", state.playback_state)); - }); - }); - } -} - -pub fn render( - ui: &mut eframe::egui::Ui, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, -) { - let mut show_panel = ShowPanelState::default(); - show_panel.render(ui.ctx(), state, console_tx); -} diff --git a/crates/ui/src/state.rs b/crates/ui/src/state.rs deleted file mode 100644 index 77d4a2b..0000000 --- a/crates/ui/src/state.rs +++ /dev/null @@ -1,272 +0,0 @@ -use std::collections::HashMap; -use std::time::SystemTime; - -use halo_core::audio::waveform::WaveformData; -use halo_core::{ - AudioDeviceInfo, ConsoleCommand, CueList, PlaybackState, RhythmState, Settings, Show, TimeCode, -}; -use halo_fixtures::{Fixture, FixtureLibrary}; -use tokio::sync::mpsc; - -#[derive(Debug, Clone)] -pub struct ConsoleState { - pub fixtures: HashMap, - pub cue_lists: Vec, - pub current_cue_list_index: usize, - pub current_cue_index: usize, - pub current_cue_progress: f32, - pub playback_state: PlaybackState, - pub bpm: f64, - pub current_time: SystemTime, - pub link_peers: u32, - pub link_quantum: f64, - pub link_tempo: f64, - pub link_start_stop_sync: bool, - pub link_enabled: bool, - pub rhythm_state: RhythmState, - pub show: Option, - pub timecode: Option, - pub programmer_preview_mode: bool, - pub selected_fixtures: Vec, - pub programmer_values: HashMap<(usize, String), u8>, // (fixture_id, channel) -> value - pub programmer_effects: Vec<(String, halo_core::EffectType, Vec)>, /* (name, effect_type, fixture_ids) */ - pub settings: Settings, - pub audio_devices: Vec, - pub fixture_library: FixtureLibrary, - pub active_effects_count: usize, - pub last_error: Option, - pub audio_waveform: Option, - pub audio_duration: Option, - pub audio_bpm: Option, - pub pixel_data: HashMap>, -} - -impl Default for ConsoleState { - fn default() -> Self { - Self { - fixtures: HashMap::new(), - cue_lists: Vec::new(), - current_cue_list_index: 0, - current_cue_index: 0, - current_cue_progress: 0.0, - playback_state: PlaybackState::Stopped, - bpm: 120.0, - current_time: SystemTime::now(), - link_peers: 0, - link_quantum: 4.0, - link_tempo: 120.0, - link_start_stop_sync: false, - link_enabled: false, - rhythm_state: RhythmState { - beat_phase: 0.0, - bar_phase: 0.0, - phrase_phase: 0.0, - beats_per_bar: 4, - bars_per_phrase: 4, - last_tap_time: None, - tap_count: 0, - }, - show: None, - timecode: None, - programmer_preview_mode: false, - selected_fixtures: Vec::new(), - programmer_values: HashMap::new(), - programmer_effects: Vec::new(), - settings: Settings::default(), - audio_devices: Vec::new(), - fixture_library: FixtureLibrary::new(), - active_effects_count: 0, - last_error: None, - audio_waveform: None, - audio_duration: None, - audio_bpm: None, - pixel_data: HashMap::new(), - } - } -} - -impl ConsoleState { - pub fn update(&mut self, event: halo_core::ConsoleEvent) { - match event { - halo_core::ConsoleEvent::FixturesUpdated { fixtures } => { - self.fixtures.clear(); - for fixture in fixtures { - self.fixtures.insert(fixture.id.to_string(), fixture); - } - } - halo_core::ConsoleEvent::CueListsUpdated { cue_lists } => { - self.cue_lists = cue_lists; - } - halo_core::ConsoleEvent::CueListSelected { list_index } => { - self.current_cue_list_index = list_index; - } - halo_core::ConsoleEvent::CurrentCueChanged { - cue_index, - progress, - } => { - self.current_cue_index = cue_index; - self.current_cue_progress = progress; - } - halo_core::ConsoleEvent::PlaybackStateChanged { state } => { - self.playback_state = state; - } - halo_core::ConsoleEvent::BpmChanged { bpm } => { - self.bpm = bpm; - } - halo_core::ConsoleEvent::TimecodeUpdated { timecode } => { - self.timecode = Some(timecode); - } - halo_core::ConsoleEvent::LinkStateChanged { enabled, num_peers } => { - self.link_peers = num_peers as u32; - self.link_enabled = enabled; - } - halo_core::ConsoleEvent::FixturePatched { - fixture_id, - fixture, - } => { - self.fixtures.insert(fixture_id.to_string(), fixture); - } - halo_core::ConsoleEvent::FixtureUnpatched { fixture_id } => { - self.fixtures.remove(&fixture_id.to_string()); - } - halo_core::ConsoleEvent::FixtureUpdated { - fixture_id, - fixture, - } => { - self.fixtures.insert(fixture_id.to_string(), fixture); - } - halo_core::ConsoleEvent::FixtureLibraryList { profiles } => { - // Update the fixture library with the profiles from the console - for (id, _display_name) in profiles { - // The library is already initialized with all profiles, so we don't need to do - // anything here This event is mainly for UI updates - // We could potentially use this to populate a cache if needed in the future - let _ = id; // Suppress unused warning - } - } - halo_core::ConsoleEvent::ShowLoaded { show } => { - self.fixtures.clear(); - for fixture in &show.fixtures { - self.fixtures - .insert(fixture.id.to_string(), fixture.clone()); - } - self.cue_lists = show.cue_lists.clone(); - self.current_cue_list_index = 0; // Reset to first cue list when show is loaded - self.show = Some(show); - } - halo_core::ConsoleEvent::RhythmStateUpdated { state } => { - self.rhythm_state = state; - } - halo_core::ConsoleEvent::ProgrammerStateUpdated { - preview_mode, - selected_fixtures, - } => { - self.programmer_preview_mode = preview_mode; - self.selected_fixtures = selected_fixtures; - } - halo_core::ConsoleEvent::ProgrammerValuesUpdated { values } => { - self.programmer_values.clear(); - for (fixture_id, channel, value) in values { - self.programmer_values.insert((fixture_id, channel), value); - } - } - halo_core::ConsoleEvent::ProgrammerEffectsUpdated { effects } => { - self.programmer_effects = effects; - } - // Handle query responses - halo_core::ConsoleEvent::FixturesList { fixtures } => { - self.fixtures.clear(); - for fixture in fixtures { - self.fixtures.insert(fixture.id.to_string(), fixture); - } - } - halo_core::ConsoleEvent::CueListsList { cue_lists } => { - self.cue_lists = cue_lists; - // Reset to first cue list when cue lists are loaded - self.current_cue_list_index = 0; - } - halo_core::ConsoleEvent::CurrentCueListIndex { index } => { - self.current_cue_list_index = index; - } - halo_core::ConsoleEvent::CurrentCue { - cue_index, - progress, - } => { - self.current_cue_index = cue_index; - self.current_cue_progress = progress; - } - halo_core::ConsoleEvent::CurrentPlaybackState { state } => { - self.playback_state = state; - } - halo_core::ConsoleEvent::CurrentRhythmState { state } => { - self.rhythm_state = state; - } - halo_core::ConsoleEvent::CurrentShow { show } => { - self.fixtures.clear(); - for fixture in &show.fixtures { - self.fixtures - .insert(fixture.id.to_string(), fixture.clone()); - } - self.cue_lists = show.cue_lists.clone(); - self.current_cue_list_index = 0; // Reset to first cue list when show is loaded - self.show = Some(show); - } - halo_core::ConsoleEvent::SettingsUpdated { settings } => { - self.settings = settings; - } - halo_core::ConsoleEvent::CurrentSettings { settings } => { - self.settings = settings; - } - halo_core::ConsoleEvent::AudioDevicesList { devices } => { - self.audio_devices = devices; - } - halo_core::ConsoleEvent::TrackingStateUpdated { - active_effect_count, - } => { - self.active_effects_count = active_effect_count; - } - halo_core::ConsoleEvent::Error { message } => { - self.last_error = Some(message); - } - halo_core::ConsoleEvent::WaveformAnalyzed { - waveform_data, - duration, - bpm, - } => { - self.audio_waveform = Some(waveform_data); - self.audio_duration = Some(duration); - self.audio_bpm = bpm; - } - halo_core::ConsoleEvent::PixelDataUpdated { pixel_data } => { - self.pixel_data.clear(); - for (fixture_id, pixels) in pixel_data { - self.pixel_data.insert(fixture_id, pixels); - } - } - _ => { - // Handle other events as needed - } - } - } -} - -/// Context struct that combines console state and command sender -/// This reduces parameter passing and provides a cleaner interface for UI components -pub struct ConsoleContext<'a> { - pub state: &'a ConsoleState, - pub console_tx: &'a mpsc::UnboundedSender, -} - -impl<'a> ConsoleContext<'a> { - pub fn new( - state: &'a ConsoleState, - console_tx: &'a mpsc::UnboundedSender, - ) -> Self { - Self { state, console_tx } - } - - /// Convenience method to send a command - pub fn send_command(&self, command: ConsoleCommand) { - let _ = self.console_tx.send(command); - } -} diff --git a/crates/ui/src/timeline.rs b/crates/ui/src/timeline.rs deleted file mode 100644 index 8b93a82..0000000 --- a/crates/ui/src/timeline.rs +++ /dev/null @@ -1,231 +0,0 @@ -use eframe::egui::{Align2, Color32, FontId, Painter, Rect, Stroke}; -use halo_core::{ConsoleCommand, TimeCode}; -use tokio::sync::mpsc; - -use crate::state::ConsoleState; - -#[derive(Debug, Clone)] -pub struct TimelineState { - pub is_expanded: bool, -} - -impl Default for TimelineState { - fn default() -> Self { - Self { is_expanded: false } - } -} - -pub fn render( - ui: &mut eframe::egui::Ui, - state: &ConsoleState, - timeline_state: &mut TimelineState, - console_tx: &mpsc::UnboundedSender, -) { - ui.horizontal(|ui| { - ui.heading("TIMELINE"); - - ui.add_space(20.0); - - // Expand/collapse toggle - let toggle_text = if timeline_state.is_expanded { - "▼" - } else { - "▶" - }; - if ui.button(toggle_text).clicked() { - timeline_state.is_expanded = !timeline_state.is_expanded; - } - }); - - // Expanded timeline view - if timeline_state.is_expanded { - ui.add_space(10.0); - - // Allocate space for the timeline - let timeline_height = 120.0; - let timeline_response = ui.allocate_rect( - Rect::from_min_size( - ui.available_rect_before_wrap().min, - [ui.available_width(), timeline_height].into(), - ), - eframe::egui::Sense::click(), - ); - - // Draw timeline content - if let Some(waveform_data) = &state.audio_waveform { - draw_timeline_content( - &timeline_response, - ui.painter(), - waveform_data, - state, - console_tx, - ); - } else { - // No waveform data - show placeholder - ui.painter().text( - timeline_response.rect.center(), - eframe::egui::Align2::CENTER_CENTER, - "No audio file loaded", - eframe::egui::FontId::proportional(16.0), - Color32::from_rgb(100, 100, 100), - ); - } - } -} - -fn draw_timeline_content( - response: &eframe::egui::Response, - painter: &Painter, - waveform_data: &halo_core::audio::waveform::WaveformData, - state: &ConsoleState, - console_tx: &mpsc::UnboundedSender, -) { - let rect = response.rect; - let width = rect.width(); - - // Handle click for needle drop - if response.clicked() { - if let Some(pos) = response.interact_pointer_pos() { - let click_x = pos.x - rect.min.x; - let time_ratio = (click_x / width).clamp(0.0, 1.0); - let seek_time = time_ratio as f64 * waveform_data.duration_seconds; - let _ = console_tx.send(ConsoleCommand::SeekAudio { - position_seconds: seek_time, - }); - } - } - - // Draw waveform - draw_waveform(painter, rect, waveform_data); - - // Draw cue markers - draw_cue_markers(painter, rect, state, waveform_data); - - // Draw playback position indicator - if let Some(timecode) = &state.timecode { - let current_time = timecode.to_seconds(); - let time_ratio = (current_time / waveform_data.duration_seconds).clamp(0.0, 1.0); - let position_x = rect.min.x + (time_ratio * width as f64) as f32; - - painter.line_segment( - [ - eframe::egui::pos2(position_x, rect.min.y), - eframe::egui::pos2(position_x, rect.max.y), - ], - Stroke::new(2.0, Color32::from_rgb(255, 100, 50)), - ); - } -} - -fn draw_waveform( - painter: &Painter, - rect: Rect, - waveform_data: &halo_core::audio::waveform::WaveformData, -) { - let width = rect.width(); - let height = rect.height(); - let center_y = rect.center().y; - let samples = &waveform_data.samples; - - if samples.is_empty() { - return; - } - - // Draw waveform as filled area with gradient effect - let mut points = Vec::new(); - let mut bottom_points = Vec::new(); - - for (i, &sample) in samples.iter().enumerate() { - let x = rect.min.x + (i as f32 / samples.len() as f32) * width; - let amplitude = sample.abs() * (height * 0.4); // Scale amplitude - let top_y = center_y - amplitude; - let bottom_y = center_y + amplitude; - - points.push(eframe::egui::pos2(x, top_y)); - bottom_points.push(eframe::egui::pos2(x, bottom_y)); - } - - // Reverse bottom points for closed shape - bottom_points.reverse(); - - // Create closed shape for filled waveform - let mut shape_points = points.clone(); - shape_points.extend(bottom_points); - - if shape_points.len() >= 3 { - // Draw filled waveform with gradient effect - painter.add(eframe::egui::Shape::convex_polygon( - shape_points, - Color32::from_rgb(40, 150, 255), - Stroke::NONE, - )); - - // Draw waveform outline for better definition - painter.add(eframe::egui::Shape::line( - points, - Stroke::new(1.0, Color32::from_rgb(100, 200, 255)), - )); - } -} - -/// Extract timecoded cues from the current cue list -fn get_timecoded_cues(state: &ConsoleState) -> Vec<(usize, String, f64)> { - let mut timecoded_cues = Vec::new(); - - if let Some(cue_list) = state.cue_lists.get(state.current_cue_list_index) { - for (index, cue) in cue_list.cues.iter().enumerate() { - if let Some(timecode_str) = &cue.timecode { - let mut timecode = TimeCode::default(); - if timecode.from_string(timecode_str).is_ok() { - let seconds = timecode.to_seconds(); - timecoded_cues.push((index, cue.name.clone(), seconds)); - } - } - } - } - - // Sort by timecode position - timecoded_cues.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal)); - timecoded_cues -} - -/// Draw cue markers and labels on the timeline -fn draw_cue_markers( - painter: &Painter, - rect: Rect, - state: &ConsoleState, - waveform_data: &halo_core::audio::waveform::WaveformData, -) { - let timecoded_cues = get_timecoded_cues(state); - - for (cue_index, cue_name, cue_seconds) in timecoded_cues { - // Only draw cues that are within the audio duration - if cue_seconds > waveform_data.duration_seconds { - continue; - } - - let time_ratio = (cue_seconds / waveform_data.duration_seconds).clamp(0.0, 1.0); - let position_x = rect.min.x + (time_ratio * rect.width() as f64) as f32; - - // Draw thin vertical marker line - painter.line_segment( - [ - eframe::egui::pos2(position_x, rect.min.y), - eframe::egui::pos2(position_x, rect.max.y), - ], - Stroke::new(1.0, Color32::from_rgb(255, 255, 100)), - ); - - // Draw cue label above the marker - let label_text = format!("Cue {}: {}", cue_index + 1, cue_name); - let label_pos = eframe::egui::pos2(position_x, rect.min.y - 5.0); - - painter.text( - label_pos, - Align2::CENTER_BOTTOM, - label_text, - FontId::proportional(10.0), - Color32::from_rgb(255, 255, 100), - ); - } -} diff --git a/crates/ui/src/utils/icon.rs b/crates/ui/src/utils/icon.rs deleted file mode 100644 index 4fd6201..0000000 --- a/crates/ui/src/utils/icon.rs +++ /dev/null @@ -1,24 +0,0 @@ -// This needs the following added to Cargo.toml: -// -// egui_extras = { version = "*", features = ["all_loaders"] } -// image = { version = "0.25", features = ["jpeg", "png"] } # Add the types you want support -// -// And Rust 2024 Nightly, which you decided not to use just yet purely to show an icon. - -// pub fn load_icon() -> egui::IconData { -// let (icon_rgba, icon_width, icon_height) = { -// let icon = include_bytes!("../../../_docs/halo_logo.png"); -// let image = image::load_from_memory(icon) -// .expect("Failed to open icon path") -// .into_rgba8(); -// let (width, height) = image.dimensions(); -// let rgba = image.into_raw(); -// (rgba, width, height) -// }; - -// egui::IconData { -// rgba: icon_rgba, -// width: icon_width, -// height: icon_height, -// } -// } diff --git a/crates/ui/src/utils/mod.rs b/crates/ui/src/utils/mod.rs deleted file mode 100644 index fa04db7..0000000 --- a/crates/ui/src/utils/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod icon; -pub mod theme; diff --git a/crates/ui/src/utils/theme.rs b/crates/ui/src/utils/theme.rs deleted file mode 100644 index f9c3ff1..0000000 --- a/crates/ui/src/utils/theme.rs +++ /dev/null @@ -1,27 +0,0 @@ -use eframe::egui::Color32; - -pub struct Theme { - pub bg_color: Color32, - pub _panel_bg: Color32, - pub _element_bg: Color32, - pub _text_color: Color32, - pub text_dim: Color32, - pub _border_color: Color32, - pub _highlight_color: Color32, - pub _active_color: Color32, -} - -impl Default for Theme { - fn default() -> Self { - Self { - bg_color: Color32::from_rgb(0, 0, 0), - _panel_bg: Color32::from_rgb(16, 16, 16), - _element_bg: Color32::from_rgb(32, 32, 32), - _text_color: Color32::from_rgb(255, 255, 255), - text_dim: Color32::from_rgb(156, 163, 175), - _border_color: Color32::from_rgb(55, 65, 81), - _highlight_color: Color32::from_rgb(59, 130, 246), - _active_color: Color32::from_rgb(30, 64, 175), - } - } -} diff --git a/crates/ui/src/visualizer.rs b/crates/ui/src/visualizer.rs deleted file mode 100644 index f16ef25..0000000 --- a/crates/ui/src/visualizer.rs +++ /dev/null @@ -1,121 +0,0 @@ -use eframe::egui::{self, Color32, Pos2, Rect, Vec2}; -use halo_core::ConsoleCommand; -use halo_fixtures::FixtureType; -use tokio::sync::mpsc; - -use crate::state::ConsoleState; - -pub fn render( - ui: &mut egui::Ui, - state: &ConsoleState, - _console_tx: &mpsc::UnboundedSender, -) { - // Create a black panel with fixed dimensions - egui::Frame::new() - .fill(Color32::BLACK) - .stroke(egui::Stroke::new(1.0, Color32::from_gray(60))) - .inner_margin(10.0) - .show(ui, |ui| { - // Set both min and max size to prevent expansion - ui.set_min_size(Vec2::new(250.0, 300.0)); - ui.set_max_size(Vec2::new(250.0, 300.0)); - - // Get pixel bar fixtures - let mut pixel_fixtures: Vec<_> = state - .fixtures - .values() - .filter(|f| f.profile.fixture_type == FixtureType::PixelBar) - .collect(); - - // Sort by fixture ID for consistent ordering - pixel_fixtures.sort_by_key(|f| f.id); - - if pixel_fixtures.is_empty() { - // Show placeholder if no pixel fixtures - ui.centered_and_justified(|ui| { - ui.label( - egui::RichText::new("No Pixel Fixtures") - .size(14.0) - .color(Color32::from_gray(100)), - ); - }); - } else { - // Use a scrollable area for many fixtures - egui::ScrollArea::vertical().show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 8.0; - - for fixture in pixel_fixtures { - render_fixture_pixels(ui, fixture, &state.pixel_data); - } - }); - } - }); -} - -fn render_fixture_pixels( - ui: &mut egui::Ui, - fixture: &halo_fixtures::Fixture, - pixel_data: &std::collections::HashMap>, -) { - ui.vertical(|ui| { - // Fixture label - ui.label( - egui::RichText::new(format!("{} (ID: {})", fixture.name, fixture.id)) - .size(11.0) - .color(Color32::from_gray(200)), - ); - - ui.add_space(2.0); - - // Get pixel data for this fixture - if let Some(pixels) = pixel_data.get(&fixture.id) { - // Calculate pixel size to fit within available width - let available_width = 230.0; // Slightly less than panel width for padding - let pixel_count = pixels.len(); - let pixel_width = if pixel_count > 0 { - (available_width / pixel_count as f32).min(10.0) - } else { - 5.0 - }; - let pixel_height = 15.0; - - // Draw the pixel bar - let (response, painter) = ui.allocate_painter( - Vec2::new(available_width, pixel_height), - egui::Sense::hover(), - ); - - let rect = response.rect; - let start_x = rect.min.x; - let y = rect.min.y; - - for (i, (r, g, b)) in pixels.iter().enumerate() { - let x = start_x + (i as f32 * pixel_width); - let pixel_rect = - Rect::from_min_size(Pos2::new(x, y), Vec2::new(pixel_width, pixel_height)); - - // Draw the pixel with its RGB color - painter.rect_filled(pixel_rect, 0.0, Color32::from_rgb(*r, *g, *b)); - - // Draw a subtle border between pixels - if pixel_width > 2.0 { - painter.rect_stroke( - pixel_rect, - 0.0, - egui::Stroke::new(0.5, Color32::from_gray(40)), - egui::StrokeKind::Middle, - ); - } - } - } else { - // No data available for this fixture - ui.label( - egui::RichText::new("No data") - .size(10.0) - .color(Color32::from_gray(80)), - ); - } - - ui.add_space(4.0); - }); -}