max / audiofiles
130 files changed,
+26944 insertions,
-0 deletions
| @@ -0,0 +1,31 @@ | |||
| 1 | + | image: archlinux | |
| 2 | + | packages: | |
| 3 | + | - rust | |
| 4 | + | - cmake | |
| 5 | + | - clang | |
| 6 | + | - git | |
| 7 | + | - pkg-config | |
| 8 | + | - perl | |
| 9 | + | - libxcb | |
| 10 | + | - libxkbcommon | |
| 11 | + | - mesa | |
| 12 | + | - alsa-lib | |
| 13 | + | sources: | |
| 14 | + | - https://git.sr.ht/~maxmj/audiofiles | |
| 15 | + | environment: | |
| 16 | + | CARGO_INCREMENTAL: "0" | |
| 17 | + | RUST_BACKTRACE: "1" | |
| 18 | + | tasks: | |
| 19 | + | - check: | | |
| 20 | + | cd audiofiles | |
| 21 | + | cargo check --workspace 2>&1 | |
| 22 | + | - test: | | |
| 23 | + | cd audiofiles | |
| 24 | + | cargo test --workspace 2>&1 | |
| 25 | + | - clippy: | | |
| 26 | + | cd audiofiles | |
| 27 | + | cargo clippy --workspace --all-targets -- -D warnings 2>&1 | |
| 28 | + | - audit: | | |
| 29 | + | cargo install --locked cargo-audit | |
| 30 | + | cd audiofiles | |
| 31 | + | cargo audit 2>&1 |
| @@ -0,0 +1,2 @@ | |||
| 1 | + | [alias] | |
| 2 | + | xtask = "run --package xtask --release --" |
| @@ -0,0 +1,17 @@ | |||
| 1 | + | /target | |
| 2 | + | *.clap | |
| 3 | + | *.vst3 | |
| 4 | + | dist/ | |
| 5 | + | ||
| 6 | + | # Environment | |
| 7 | + | .env | |
| 8 | + | .env.* | |
| 9 | + | ||
| 10 | + | # OS | |
| 11 | + | .DS_Store | |
| 12 | + | ||
| 13 | + | # IDE | |
| 14 | + | .idea/ | |
| 15 | + | .vscode/ | |
| 16 | + | *.swp | |
| 17 | + | *.swo |
| @@ -0,0 +1,6804 @@ | |||
| 1 | + | # This file is automatically @generated by Cargo. | |
| 2 | + | # It is not intended for manual editing. | |
| 3 | + | version = 4 | |
| 4 | + | ||
| 5 | + | [[package]] | |
| 6 | + | name = "ab_glyph" | |
| 7 | + | version = "0.2.32" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" | |
| 10 | + | dependencies = [ | |
| 11 | + | "ab_glyph_rasterizer", | |
| 12 | + | "owned_ttf_parser", | |
| 13 | + | ] | |
| 14 | + | ||
| 15 | + | [[package]] | |
| 16 | + | name = "ab_glyph_rasterizer" | |
| 17 | + | version = "0.1.10" | |
| 18 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 19 | + | checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" | |
| 20 | + | ||
| 21 | + | [[package]] | |
| 22 | + | name = "addr2line" | |
| 23 | + | version = "0.25.1" | |
| 24 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 25 | + | checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" | |
| 26 | + | dependencies = [ | |
| 27 | + | "gimli", | |
| 28 | + | ] | |
| 29 | + | ||
| 30 | + | [[package]] | |
| 31 | + | name = "adler2" | |
| 32 | + | version = "2.0.1" | |
| 33 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 34 | + | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" | |
| 35 | + | ||
| 36 | + | [[package]] | |
| 37 | + | name = "aead" | |
| 38 | + | version = "0.5.2" | |
| 39 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 40 | + | checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" | |
| 41 | + | dependencies = [ | |
| 42 | + | "crypto-common", | |
| 43 | + | "generic-array", | |
| 44 | + | ] | |
| 45 | + | ||
| 46 | + | [[package]] | |
| 47 | + | name = "ahash" | |
| 48 | + | version = "0.8.12" | |
| 49 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 50 | + | checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" | |
| 51 | + | dependencies = [ | |
| 52 | + | "cfg-if", | |
| 53 | + | "const-random", | |
| 54 | + | "getrandom 0.3.4", | |
| 55 | + | "once_cell", | |
| 56 | + | "version_check", | |
| 57 | + | "zerocopy", | |
| 58 | + | ] | |
| 59 | + | ||
| 60 | + | [[package]] | |
| 61 | + | name = "aho-corasick" | |
| 62 | + | version = "1.1.4" | |
| 63 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 64 | + | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" | |
| 65 | + | dependencies = [ | |
| 66 | + | "memchr", | |
| 67 | + | ] | |
| 68 | + | ||
| 69 | + | [[package]] | |
| 70 | + | name = "alsa" | |
| 71 | + | version = "0.9.1" | |
| 72 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 73 | + | checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" | |
| 74 | + | dependencies = [ | |
| 75 | + | "alsa-sys", | |
| 76 | + | "bitflags 2.11.0", | |
| 77 | + | "cfg-if", | |
| 78 | + | "libc", | |
| 79 | + | ] | |
| 80 | + | ||
| 81 | + | [[package]] | |
| 82 | + | name = "alsa-sys" | |
| 83 | + | version = "0.3.1" | |
| 84 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 85 | + | checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" | |
| 86 | + | dependencies = [ | |
| 87 | + | "libc", | |
| 88 | + | "pkg-config", | |
| 89 | + | ] | |
| 90 | + | ||
| 91 | + | [[package]] | |
| 92 | + | name = "android-activity" | |
| 93 | + | version = "0.6.0" | |
| 94 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 95 | + | checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" | |
| 96 | + | dependencies = [ | |
| 97 | + | "android-properties", | |
| 98 | + | "bitflags 2.11.0", | |
| 99 | + | "cc", | |
| 100 | + | "cesu8", | |
| 101 | + | "jni", | |
| 102 | + | "jni-sys", | |
| 103 | + | "libc", | |
| 104 | + | "log", | |
| 105 | + | "ndk 0.9.0", | |
| 106 | + | "ndk-context", | |
| 107 | + | "ndk-sys 0.6.0+11769913", | |
| 108 | + | "num_enum", | |
| 109 | + | "thiserror 1.0.69", | |
| 110 | + | ] | |
| 111 | + | ||
| 112 | + | [[package]] | |
| 113 | + | name = "android-properties" | |
| 114 | + | version = "0.2.2" | |
| 115 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 116 | + | checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" | |
| 117 | + | ||
| 118 | + | [[package]] | |
| 119 | + | name = "android_system_properties" | |
| 120 | + | version = "0.1.5" | |
| 121 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 122 | + | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" | |
| 123 | + | dependencies = [ | |
| 124 | + | "libc", | |
| 125 | + | ] | |
| 126 | + | ||
| 127 | + | [[package]] | |
| 128 | + | name = "anyhow" | |
| 129 | + | version = "1.0.101" | |
| 130 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 131 | + | checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" | |
| 132 | + | ||
| 133 | + | [[package]] | |
| 134 | + | name = "anymap3" | |
| 135 | + | version = "1.0.1" | |
| 136 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 137 | + | checksum = "170433209e817da6aae2c51aa0dd443009a613425dd041ebfb2492d1c4c11a25" | |
| 138 | + | ||
| 139 | + | [[package]] | |
| 140 | + | name = "arboard" | |
| 141 | + | version = "3.6.1" | |
| 142 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 143 | + | checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" | |
| 144 | + | dependencies = [ | |
| 145 | + | "clipboard-win", | |
| 146 | + | "image", | |
| 147 | + | "log", | |
| 148 | + | "objc2 0.6.3", | |
| 149 | + | "objc2-app-kit 0.3.2", | |
| 150 | + | "objc2-core-foundation", | |
| 151 | + | "objc2-core-graphics", | |
| 152 | + | "objc2-foundation 0.3.2", | |
| 153 | + | "parking_lot", | |
| 154 | + | "percent-encoding", | |
| 155 | + | "windows-sys 0.60.2", | |
| 156 | + | "x11rb", | |
| 157 | + | ] | |
| 158 | + | ||
| 159 | + | [[package]] | |
| 160 | + | name = "argon2" | |
| 161 | + | version = "0.5.3" | |
| 162 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 163 | + | checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" | |
| 164 | + | dependencies = [ | |
| 165 | + | "base64ct", | |
| 166 | + | "blake2", | |
| 167 | + | "cpufeatures", | |
| 168 | + | "password-hash", | |
| 169 | + | ] | |
| 170 | + | ||
| 171 | + | [[package]] | |
| 172 | + | name = "arrayvec" | |
| 173 | + | version = "0.7.6" | |
| 174 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 175 | + | checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" | |
| 176 | + | ||
| 177 | + | [[package]] | |
| 178 | + | name = "as-raw-xcb-connection" | |
| 179 | + | version = "1.0.1" | |
| 180 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 181 | + | checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" | |
| 182 | + | ||
| 183 | + | [[package]] | |
| 184 | + | name = "ashpd" | |
| 185 | + | version = "0.11.1" | |
| 186 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 187 | + | checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" | |
| 188 | + | dependencies = [ | |
| 189 | + | "async-fs", | |
| 190 | + | "async-net", | |
| 191 | + | "enumflags2", | |
| 192 | + | "futures-channel", | |
| 193 | + | "futures-util", | |
| 194 | + | "rand 0.9.2", | |
| 195 | + | "raw-window-handle 0.6.2", | |
| 196 | + | "serde", | |
| 197 | + | "serde_repr", | |
| 198 | + | "url", | |
| 199 | + | "wayland-backend", | |
| 200 | + | "wayland-client", | |
| 201 | + | "wayland-protocols", | |
| 202 | + | "zbus", | |
| 203 | + | ] | |
| 204 | + | ||
| 205 | + | [[package]] | |
| 206 | + | name = "assert_no_alloc" | |
| 207 | + | version = "1.1.2" | |
| 208 | + | source = "git+https://github.com/robbert-vdh/rust-assert-no-alloc.git?branch=feature%2Fnested-permit-forbid#a6fb4f62b9624715291e320ea5f0f70e73b035cf" | |
| 209 | + | dependencies = [ | |
| 210 | + | "backtrace", | |
| 211 | + | "log", | |
| 212 | + | ] | |
| 213 | + | ||
| 214 | + | [[package]] | |
| 215 | + | name = "async-broadcast" | |
| 216 | + | version = "0.7.2" | |
| 217 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 218 | + | checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" | |
| 219 | + | dependencies = [ | |
| 220 | + | "event-listener", | |
| 221 | + | "event-listener-strategy", | |
| 222 | + | "futures-core", | |
| 223 | + | "pin-project-lite", | |
| 224 | + | ] | |
| 225 | + | ||
| 226 | + | [[package]] | |
| 227 | + | name = "async-channel" | |
| 228 | + | version = "2.5.0" | |
| 229 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 230 | + | checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" | |
| 231 | + | dependencies = [ | |
| 232 | + | "concurrent-queue", | |
| 233 | + | "event-listener-strategy", | |
| 234 | + | "futures-core", | |
| 235 | + | "pin-project-lite", | |
| 236 | + | ] | |
| 237 | + | ||
| 238 | + | [[package]] | |
| 239 | + | name = "async-executor" | |
| 240 | + | version = "1.14.0" | |
| 241 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 242 | + | checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" | |
| 243 | + | dependencies = [ | |
| 244 | + | "async-task", | |
| 245 | + | "concurrent-queue", | |
| 246 | + | "fastrand", | |
| 247 | + | "futures-lite", | |
| 248 | + | "pin-project-lite", | |
| 249 | + | "slab", | |
| 250 | + | ] | |
| 251 | + | ||
| 252 | + | [[package]] | |
| 253 | + | name = "async-fs" | |
| 254 | + | version = "2.2.0" | |
| 255 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 256 | + | checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" | |
| 257 | + | dependencies = [ | |
| 258 | + | "async-lock", | |
| 259 | + | "blocking", | |
| 260 | + | "futures-lite", | |
| 261 | + | ] | |
| 262 | + | ||
| 263 | + | [[package]] | |
| 264 | + | name = "async-io" | |
| 265 | + | version = "2.6.0" | |
| 266 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 267 | + | checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" | |
| 268 | + | dependencies = [ | |
| 269 | + | "autocfg", | |
| 270 | + | "cfg-if", | |
| 271 | + | "concurrent-queue", | |
| 272 | + | "futures-io", | |
| 273 | + | "futures-lite", | |
| 274 | + | "parking", | |
| 275 | + | "polling", | |
| 276 | + | "rustix 1.1.3", | |
| 277 | + | "slab", | |
| 278 | + | "windows-sys 0.61.2", | |
| 279 | + | ] | |
| 280 | + | ||
| 281 | + | [[package]] | |
| 282 | + | name = "async-lock" | |
| 283 | + | version = "3.4.2" | |
| 284 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 285 | + | checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" | |
| 286 | + | dependencies = [ | |
| 287 | + | "event-listener", | |
| 288 | + | "event-listener-strategy", | |
| 289 | + | "pin-project-lite", | |
| 290 | + | ] | |
| 291 | + | ||
| 292 | + | [[package]] | |
| 293 | + | name = "async-net" | |
| 294 | + | version = "2.0.0" | |
| 295 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 296 | + | checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" | |
| 297 | + | dependencies = [ | |
| 298 | + | "async-io", | |
| 299 | + | "blocking", | |
| 300 | + | "futures-lite", | |
| 301 | + | ] | |
| 302 | + | ||
| 303 | + | [[package]] | |
| 304 | + | name = "async-process" | |
| 305 | + | version = "2.5.0" | |
| 306 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 307 | + | checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" | |
| 308 | + | dependencies = [ | |
| 309 | + | "async-channel", | |
| 310 | + | "async-io", | |
| 311 | + | "async-lock", | |
| 312 | + | "async-signal", | |
| 313 | + | "async-task", | |
| 314 | + | "blocking", | |
| 315 | + | "cfg-if", | |
| 316 | + | "event-listener", | |
| 317 | + | "futures-lite", | |
| 318 | + | "rustix 1.1.3", | |
| 319 | + | ] | |
| 320 | + | ||
| 321 | + | [[package]] | |
| 322 | + | name = "async-recursion" | |
| 323 | + | version = "1.1.1" | |
| 324 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 325 | + | checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" | |
| 326 | + | dependencies = [ | |
| 327 | + | "proc-macro2", | |
| 328 | + | "quote", | |
| 329 | + | "syn 2.0.116", | |
| 330 | + | ] | |
| 331 | + | ||
| 332 | + | [[package]] | |
| 333 | + | name = "async-signal" | |
| 334 | + | version = "0.2.13" | |
| 335 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 336 | + | checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" | |
| 337 | + | dependencies = [ | |
| 338 | + | "async-io", | |
| 339 | + | "async-lock", | |
| 340 | + | "atomic-waker", | |
| 341 | + | "cfg-if", | |
| 342 | + | "futures-core", | |
| 343 | + | "futures-io", | |
| 344 | + | "rustix 1.1.3", | |
| 345 | + | "signal-hook-registry", | |
| 346 | + | "slab", | |
| 347 | + | "windows-sys 0.61.2", | |
| 348 | + | ] | |
| 349 | + | ||
| 350 | + | [[package]] | |
| 351 | + | name = "async-task" | |
| 352 | + | version = "4.7.1" | |
| 353 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 354 | + | checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" | |
| 355 | + | ||
| 356 | + | [[package]] | |
| 357 | + | name = "async-trait" | |
| 358 | + | version = "0.1.89" | |
| 359 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 360 | + | checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" | |
| 361 | + | dependencies = [ | |
| 362 | + | "proc-macro2", | |
| 363 | + | "quote", | |
| 364 | + | "syn 2.0.116", | |
| 365 | + | ] | |
| 366 | + | ||
| 367 | + | [[package]] | |
| 368 | + | name = "atk" | |
| 369 | + | version = "0.18.2" | |
| 370 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 371 | + | checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" | |
| 372 | + | dependencies = [ | |
| 373 | + | "atk-sys", | |
| 374 | + | "glib", | |
| 375 | + | "libc", | |
| 376 | + | ] | |
| 377 | + | ||
| 378 | + | [[package]] | |
| 379 | + | name = "atk-sys" | |
| 380 | + | version = "0.18.2" | |
| 381 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 382 | + | checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" | |
| 383 | + | dependencies = [ | |
| 384 | + | "glib-sys", | |
| 385 | + | "gobject-sys", | |
| 386 | + | "libc", | |
| 387 | + | "system-deps", | |
| 388 | + | ] | |
| 389 | + | ||
| 390 | + | [[package]] | |
| 391 | + | name = "atomic-waker" | |
| 392 | + | version = "1.1.2" | |
| 393 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 394 | + | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |
| 395 | + | ||
| 396 | + | [[package]] | |
| 397 | + | name = "atomic_float" | |
| 398 | + | version = "0.1.0" | |
| 399 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 400 | + | checksum = "62af46d040ba9df09edc6528dae9d8e49f5f3e82f55b7d2ec31a733c38dbc49d" | |
| 401 | + | ||
| 402 | + | [[package]] | |
| 403 | + | name = "atomic_refcell" | |
| 404 | + | version = "0.1.13" | |
| 405 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 406 | + | checksum = "41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c" | |
| 407 | + | ||
| 408 | + | [[package]] | |
| 409 | + | name = "atty" | |
| 410 | + | version = "0.2.14" | |
| 411 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 412 | + | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" | |
| 413 | + | dependencies = [ | |
| 414 | + | "hermit-abi 0.1.19", | |
| 415 | + | "libc", | |
| 416 | + | "winapi", | |
| 417 | + | ] | |
| 418 | + | ||
| 419 | + | [[package]] | |
| 420 | + | name = "audiofiles-app" | |
| 421 | + | version = "0.1.0" | |
| 422 | + | dependencies = [ | |
| 423 | + | "audiofiles-browser", | |
| 424 | + | "audiofiles-core", | |
| 425 | + | "audiofiles-sync", | |
| 426 | + | "cpal", | |
| 427 | + | "dirs", | |
| 428 | + | "eframe", | |
| 429 | + | "parking_lot", | |
| 430 | + | "thiserror 2.0.18", | |
| 431 | + | "tokio", | |
| 432 | + | "tracing", | |
| 433 | + | "tracing-subscriber", | |
| 434 | + | "tray-icon", | |
| 435 | + | ] | |
| 436 | + | ||
| 437 | + | [[package]] | |
| 438 | + | name = "audiofiles-browser" | |
| 439 | + | version = "0.1.0" | |
| 440 | + | dependencies = [ | |
| 441 | + | "audiofiles-core", | |
| 442 | + | "audiofiles-rhai", | |
| 443 | + | "audiofiles-sync", | |
| 444 | + | "dirs", | |
| 445 | + | "egui", | |
| 446 | + | "egui_extras", | |
| 447 | + | "parking_lot", | |
| 448 | + | "rfd", | |
| 449 | + | "rusqlite", | |
| 450 | + | "serde", | |
| 451 | + | "serde_json", | |
| 452 | + | "symphonia", | |
| 453 | + | "tempfile", | |
| 454 | + | "thiserror 2.0.18", | |
| 455 | + | "toml 0.8.23", | |
| 456 | + | ] | |
| 457 | + | ||
| 458 | + | [[package]] | |
| 459 | + | name = "audiofiles-core" | |
| 460 | + | version = "0.1.0" | |
| 461 | + | dependencies = [ | |
| 462 | + | "bs1770", | |
| 463 | + | "hound", | |
| 464 | + | "realfft", | |
| 465 | + | "rubato", | |
| 466 | + | "rusqlite", | |
| 467 | + | "serde", | |
| 468 | + | "serde_json", | |
| 469 | + | "sha2", | |
| 470 | + | "stratum-dsp", | |
| 471 | + | "symphonia", | |
| 472 | + | "tempfile", | |
| 473 | + | "thiserror 2.0.18", | |
| 474 | + | ] | |
| 475 | + | ||
| 476 | + | [[package]] | |
| 477 | + | name = "audiofiles-ipc" | |
| 478 | + | version = "0.1.0" | |
| 479 | + | dependencies = [ | |
| 480 | + | "audiofiles-core", | |
| 481 | + | "serde", | |
| 482 | + | "serde_json", | |
| 483 | + | "thiserror 2.0.18", | |
| 484 | + | ] | |
| 485 | + | ||
| 486 | + | [[package]] | |
| 487 | + | name = "audiofiles-plugin" | |
| 488 | + | version = "0.1.0" | |
| 489 | + | dependencies = [ | |
| 490 | + | "audiofiles-browser", | |
| 491 | + | "audiofiles-core", | |
| 492 | + | "dirs", | |
| 493 | + | "nih_plug", | |
| 494 | + | "nih_plug_egui", | |
| 495 | + | "parking_lot", | |
| 496 | + | ] | |
| 497 | + | ||
| 498 | + | [[package]] | |
| 499 | + | name = "audiofiles-rhai" | |
| 500 | + | version = "0.1.0" |
Lines truncated
| @@ -0,0 +1,44 @@ | |||
| 1 | + | [workspace] | |
| 2 | + | members = ["crates/*", "xtask"] | |
| 3 | + | resolver = "2" | |
| 4 | + | ||
| 5 | + | [workspace.package] | |
| 6 | + | edition = "2021" | |
| 7 | + | license-file = "LICENSE" | |
| 8 | + | ||
| 9 | + | [workspace.dependencies] | |
| 10 | + | audiofiles-core = { path = "crates/audiofiles-core" } | |
| 11 | + | audiofiles-browser = { path = "crates/audiofiles-browser" } | |
| 12 | + | audiofiles-sync = { path = "crates/audiofiles-sync" } | |
| 13 | + | audiofiles-rhai = { path = "crates/audiofiles-rhai" } | |
| 14 | + | nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "28b149ec4d62757d0b448809148a0c3ca6e09a95", features = ["assert_process_allocs"] } | |
| 15 | + | nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "28b149ec4d62757d0b448809148a0c3ca6e09a95" } | |
| 16 | + | egui = { version = "0.31.1", default-features = false, features = ["default_fonts"] } | |
| 17 | + | egui_extras = { version = "0.31.1", default-features = false } | |
| 18 | + | eframe = { version = "0.31.1", default-features = false, features = ["default_fonts", "glow"] } | |
| 19 | + | cpal = "=0.15.3" | |
| 20 | + | rusqlite = { version = "0.31.0", features = ["bundled"] } | |
| 21 | + | thiserror = "2.0.18" | |
| 22 | + | sha2 = "0.10.9" | |
| 23 | + | symphonia = { version = "0.5.5", default-features = false, features = ["wav", "aiff", "mp3", "flac", "ogg", "vorbis", "pcm"] } | |
| 24 | + | parking_lot = "0.12.5" | |
| 25 | + | dirs = "6.0.0" | |
| 26 | + | nih_plug_xtask = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "28b149ec4d62757d0b448809148a0c3ca6e09a95" } | |
| 27 | + | stratum-dsp = "=1.0.0" | |
| 28 | + | bs1770 = "=1.0.0" | |
| 29 | + | realfft = "3.5.0" | |
| 30 | + | toml = "0.8.23" | |
| 31 | + | rfd = "0.15.4" | |
| 32 | + | serde = { version = "1.0.228", features = ["derive"] } | |
| 33 | + | serde_json = "1.0.149" | |
| 34 | + | rubato = "0.14" | |
| 35 | + | hound = "3.5" | |
| 36 | + | tracing = "0.1.44" | |
| 37 | + | tracing-subscriber = "0.3.22" | |
| 38 | + | rhai = { version = "1.21", features = ["sync"] } | |
| 39 | + | tray-icon = "0.21" | |
| 40 | + | tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] } | |
| 41 | + | uuid = { version = "1", features = ["v4"] } | |
| 42 | + | base64 = "0.22" | |
| 43 | + | chrono = "0.4" | |
| 44 | + | rand = "0.8" |
| @@ -0,0 +1,118 @@ | |||
| 1 | + | PolyForm Noncommercial License 1.0.0 | |
| 2 | + | ||
| 3 | + | <https://polyformproject.org/licenses/noncommercial/1.0.0> | |
| 4 | + | ||
| 5 | + | Acceptance | |
| 6 | + | ||
| 7 | + | In order to get any license under these terms, you must agree to them as | |
| 8 | + | both strict obligations and conditions to all your licenses. | |
| 9 | + | ||
| 10 | + | Copyright License | |
| 11 | + | ||
| 12 | + | The licensor grants you a copyright license for the software to do | |
| 13 | + | everything you might do with the software that would otherwise infringe | |
| 14 | + | the licensor's copyright in it for any permitted purpose. However, you | |
| 15 | + | may only distribute the software according to Distribution License and | |
| 16 | + | make changes or new works based on the software according to Changes and | |
| 17 | + | New Works License. | |
| 18 | + | ||
| 19 | + | Distribution License | |
| 20 | + | ||
| 21 | + | The licensor grants you an additional copyright license to distribute | |
| 22 | + | copies of the software. Your license to distribute covers distributing | |
| 23 | + | the software with changes and new works permitted by Changes and New | |
| 24 | + | Works License. | |
| 25 | + | ||
| 26 | + | Notices | |
| 27 | + | ||
| 28 | + | You must ensure that anyone who gets a copy of any part of the software | |
| 29 | + | from you also gets a copy of these terms or the URL for them above, as | |
| 30 | + | well as copies of any plain-text lines beginning with "Required Notice:" | |
| 31 | + | that the licensor provided with the software. For example: | |
| 32 | + | ||
| 33 | + | Required Notice: Copyright Yoyodyne, Inc. (http://example.com) | |
| 34 | + | ||
| 35 | + | Changes and New Works License | |
| 36 | + | ||
| 37 | + | The licensor grants you an additional copyright license to make changes | |
| 38 | + | and new works based on the software for any permitted purpose. | |
| 39 | + | ||
| 40 | + | Patent License | |
| 41 | + | ||
| 42 | + | The licensor grants you a patent license for the software that covers | |
| 43 | + | patent claims the licensor can license, or becomes able to license, that | |
| 44 | + | you would infringe by using the software. | |
| 45 | + | ||
| 46 | + | Noncommercial Purposes | |
| 47 | + | ||
| 48 | + | Any noncommercial purpose is a permitted purpose. | |
| 49 | + | ||
| 50 | + | Personal Uses | |
| 51 | + | ||
| 52 | + | Personal use for research, experiment, and testing for the benefit of | |
| 53 | + | public knowledge, personal study, private entertainment, hobby projects, | |
| 54 | + | amateur pursuits, or religious observance, without any anticipated | |
| 55 | + | commercial application, is use for a permitted purpose. | |
| 56 | + | ||
| 57 | + | Noncommercial Organizations | |
| 58 | + | ||
| 59 | + | Use by any charitable organization, educational institution, public | |
| 60 | + | research organization, public safety or health organization, | |
| 61 | + | environmental protection organization, or government institution is use | |
| 62 | + | for a permitted purpose regardless of the source of funding or | |
| 63 | + | obligations resulting from the funding. | |
| 64 | + | ||
| 65 | + | Fair Use | |
| 66 | + | ||
| 67 | + | You may have "fair use" rights for the software under the law. These | |
| 68 | + | terms do not limit them. | |
| 69 | + | ||
| 70 | + | No Other Rights | |
| 71 | + | ||
| 72 | + | These terms do not allow you to sublicense or transfer any of your | |
| 73 | + | licenses to anyone else, or prevent the licensor from granting licenses | |
| 74 | + | to anyone else. These terms do not imply any other licenses. | |
| 75 | + | ||
| 76 | + | Patent Defense | |
| 77 | + | ||
| 78 | + | If you make any written claim that the software infringes or contributes | |
| 79 | + | to infringement of any patent, your patent license for the software | |
| 80 | + | granted under these terms ends immediately. If your company makes such a | |
| 81 | + | claim, your patent license ends immediately for work on behalf of your | |
| 82 | + | company. | |
| 83 | + | ||
| 84 | + | Violations | |
| 85 | + | ||
| 86 | + | The first time you are notified in writing that you have violated any of | |
| 87 | + | these terms, or done anything with the software not covered by your | |
| 88 | + | licenses, your licenses can nonetheless continue if you come into full | |
| 89 | + | compliance with these terms, and take practical steps to correct past | |
| 90 | + | violations, within 32 days of receiving notice. Otherwise, all your | |
| 91 | + | licenses end immediately. | |
| 92 | + | ||
| 93 | + | No Liability | |
| 94 | + | ||
| 95 | + | As far as the law allows, the software comes as is, without any warranty | |
| 96 | + | or condition, and the licensor will not be liable to you for any damages | |
| 97 | + | arising out of these terms or the use or nature of the software, under | |
| 98 | + | any kind of legal claim. | |
| 99 | + | ||
| 100 | + | Definitions | |
| 101 | + | ||
| 102 | + | The licensor is the individual or entity offering these terms, and the | |
| 103 | + | software is the software the licensor makes available under these terms. | |
| 104 | + | ||
| 105 | + | You refers to the individual or entity agreeing to these terms. | |
| 106 | + | ||
| 107 | + | Your company is any legal entity, sole proprietorship, or other kind of | |
| 108 | + | organization that you work for, plus all organizations that have control | |
| 109 | + | over, are under the control of, or are under common control with that | |
| 110 | + | organization. Control means ownership of substantially all the assets of | |
| 111 | + | an entity, or the power to direct its management and policies by vote, | |
| 112 | + | contract, or otherwise. Control can be direct or indirect. | |
| 113 | + | ||
| 114 | + | Your licenses are all the licenses granted to you for the software under | |
| 115 | + | these terms. | |
| 116 | + | ||
| 117 | + | Use means anything you do with the software requiring one of your | |
| 118 | + | licenses. |
| @@ -0,0 +1,18 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "audiofiles-app" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition.workspace = true | |
| 5 | + | ||
| 6 | + | [dependencies] | |
| 7 | + | audiofiles-core = { workspace = true } | |
| 8 | + | audiofiles-browser = { workspace = true } | |
| 9 | + | audiofiles-sync = { workspace = true } | |
| 10 | + | eframe = { workspace = true } | |
| 11 | + | cpal = { workspace = true } | |
| 12 | + | parking_lot = { workspace = true } | |
| 13 | + | dirs = { workspace = true } | |
| 14 | + | tracing = { workspace = true } | |
| 15 | + | thiserror = { workspace = true } | |
| 16 | + | tracing-subscriber = { workspace = true } | |
| 17 | + | tray-icon = { workspace = true } | |
| 18 | + | tokio = { workspace = true } |
| @@ -0,0 +1,40 @@ | |||
| 1 | + | <?xml version="1.0" encoding="UTF-8"?> | |
| 2 | + | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 3 | + | <plist version="1.0"> | |
| 4 | + | <dict> | |
| 5 | + | <key>CFBundleName</key> | |
| 6 | + | <string>AudioFiles</string> | |
| 7 | + | <key>CFBundleIdentifier</key> | |
| 8 | + | <string>com.audiofiles.app</string> | |
| 9 | + | <key>CFBundleVersion</key> | |
| 10 | + | <string>0.1.0</string> | |
| 11 | + | <key>CFBundleShortVersionString</key> | |
| 12 | + | <string>0.1.0</string> | |
| 13 | + | <key>CFBundleExecutable</key> | |
| 14 | + | <string>audiofiles-app</string> | |
| 15 | + | <key>CFBundlePackageType</key> | |
| 16 | + | <string>APPL</string> | |
| 17 | + | <key>NSHighResolutionCapable</key> | |
| 18 | + | <true/> | |
| 19 | + | <key>CFBundleDocumentTypes</key> | |
| 20 | + | <array> | |
| 21 | + | <dict> | |
| 22 | + | <key>CFBundleTypeName</key> | |
| 23 | + | <string>Audio File</string> | |
| 24 | + | <key>CFBundleTypeRole</key> | |
| 25 | + | <string>Viewer</string> | |
| 26 | + | <key>LSHandlerRank</key> | |
| 27 | + | <string>Alternate</string> | |
| 28 | + | <key>CFBundleTypeExtensions</key> | |
| 29 | + | <array> | |
| 30 | + | <string>wav</string> | |
| 31 | + | <string>flac</string> | |
| 32 | + | <string>mp3</string> | |
| 33 | + | <string>ogg</string> | |
| 34 | + | <string>aiff</string> | |
| 35 | + | <string>aif</string> | |
| 36 | + | </array> | |
| 37 | + | </dict> | |
| 38 | + | </array> | |
| 39 | + | </dict> | |
| 40 | + | </plist> |
| @@ -0,0 +1,231 @@ | |||
| 1 | + | //! cpal audio output stream: reads from shared preview playback state. | |
| 2 | + | ||
| 3 | + | use std::sync::Arc; | |
| 4 | + | ||
| 5 | + | use audiofiles_browser::preview::PreviewPlayback; | |
| 6 | + | use audiofiles_browser::state::SharedState; | |
| 7 | + | use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; | |
| 8 | + | use cpal::Stream; | |
| 9 | + | use parking_lot::Mutex; | |
| 10 | + | use thiserror::Error; | |
| 11 | + | ||
| 12 | + | /// Errors from audio output stream setup. | |
| 13 | + | #[derive(Error, Debug)] | |
| 14 | + | pub enum AudioError { | |
| 15 | + | #[error("no output audio device found")] | |
| 16 | + | NoDevice, | |
| 17 | + | #[error("default output config: {0}")] | |
| 18 | + | DefaultConfig(#[from] cpal::DefaultStreamConfigError), | |
| 19 | + | #[error("unsupported sample format: {0:?}")] | |
| 20 | + | UnsupportedFormat(cpal::SampleFormat), | |
| 21 | + | #[error("build stream: {0}")] | |
| 22 | + | BuildStream(#[from] cpal::BuildStreamError), | |
| 23 | + | #[error("stream play: {0}")] | |
| 24 | + | Play(#[from] cpal::PlayStreamError), | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /// Build and start a cpal output stream that reads from the shared preview state. | |
| 28 | + | /// Returns the stream handle (must be kept alive for playback to continue). | |
| 29 | + | pub fn start_output_stream(shared: Arc<SharedState>) -> Result<Stream, AudioError> { | |
| 30 | + | let host = cpal::default_host(); | |
| 31 | + | let device = host | |
| 32 | + | .default_output_device() | |
| 33 | + | .ok_or(AudioError::NoDevice)?; | |
| 34 | + | ||
| 35 | + | let config = device.default_output_config()?; | |
| 36 | + | ||
| 37 | + | let channels = config.channels() as usize; | |
| 38 | + | ||
| 39 | + | let stream = match config.sample_format() { | |
| 40 | + | cpal::SampleFormat::F32 => build_stream::<f32>( | |
| 41 | + | &device, | |
| 42 | + | &config.into(), | |
| 43 | + | shared, | |
| 44 | + | channels, | |
| 45 | + | ), | |
| 46 | + | cpal::SampleFormat::I16 => build_stream::<i16>( | |
| 47 | + | &device, | |
| 48 | + | &config.into(), | |
| 49 | + | shared, | |
| 50 | + | channels, | |
| 51 | + | ), | |
| 52 | + | cpal::SampleFormat::U16 => build_stream::<u16>( | |
| 53 | + | &device, | |
| 54 | + | &config.into(), | |
| 55 | + | shared, | |
| 56 | + | channels, | |
| 57 | + | ), | |
| 58 | + | fmt => Err(AudioError::UnsupportedFormat(fmt)), | |
| 59 | + | }?; | |
| 60 | + | ||
| 61 | + | stream.play()?; | |
| 62 | + | Ok(stream) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>( | |
| 66 | + | device: &cpal::Device, | |
| 67 | + | config: &cpal::StreamConfig, | |
| 68 | + | shared: Arc<SharedState>, | |
| 69 | + | channels: usize, | |
| 70 | + | ) -> Result<Stream, AudioError> { | |
| 71 | + | let stream = device | |
| 72 | + | .build_output_stream( | |
| 73 | + | config, | |
| 74 | + | move |data: &mut [T], _: &cpal::OutputCallbackInfo| { | |
| 75 | + | fill_cpal_output(&shared.preview, data, channels); | |
| 76 | + | }, | |
| 77 | + | |err| { | |
| 78 | + | tracing::error!("audio stream error: {err}"); | |
| 79 | + | }, | |
| 80 | + | None, | |
| 81 | + | )?; | |
| 82 | + | Ok(stream) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// Fill a cpal output buffer from the preview playback state. | |
| 86 | + | /// Same logic as the plugin's fill_output but writes to a generic sample slice. | |
| 87 | + | pub(crate) fn fill_cpal_output<T: cpal::SizedSample + cpal::FromSample<f32>>( | |
| 88 | + | playback: &Mutex<PreviewPlayback>, | |
| 89 | + | data: &mut [T], | |
| 90 | + | channels: usize, | |
| 91 | + | ) { | |
| 92 | + | let Some(mut guard) = playback.try_lock() else { | |
| 93 | + | // GUI thread holds lock (decoding) — output silence | |
| 94 | + | for sample in data.iter_mut() { | |
| 95 | + | *sample = T::from_sample(0.0f32); | |
| 96 | + | } | |
| 97 | + | return; | |
| 98 | + | }; | |
| 99 | + | ||
| 100 | + | if !guard.playing { | |
| 101 | + | for sample in data.iter_mut() { | |
| 102 | + | *sample = T::from_sample(0.0f32); | |
| 103 | + | } | |
| 104 | + | return; | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | let Some(ref preview_buf) = guard.buffer else { | |
| 108 | + | for sample in data.iter_mut() { | |
| 109 | + | *sample = T::from_sample(0.0f32); | |
| 110 | + | } | |
| 111 | + | return; | |
| 112 | + | }; | |
| 113 | + | ||
| 114 | + | let total_frames = preview_buf.data.len() / 2; | |
| 115 | + | let mut pos = guard.position; | |
| 116 | + | let num_frames = data.len() / channels; | |
| 117 | + | ||
| 118 | + | for frame in 0..num_frames { | |
| 119 | + | if pos >= total_frames { | |
| 120 | + | // Reached end — stop and silence remaining | |
| 121 | + | guard.playing = false; | |
| 122 | + | guard.position = 0; | |
| 123 | + | for sample in &mut data[(frame * channels)..] { | |
| 124 | + | *sample = T::from_sample(0.0f32); | |
| 125 | + | } | |
| 126 | + | return; | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | let left = preview_buf.data[pos * 2]; | |
| 130 | + | let right = preview_buf.data[pos * 2 + 1]; | |
| 131 | + | ||
| 132 | + | let base = frame * channels; | |
| 133 | + | if channels >= 2 { | |
| 134 | + | data[base] = T::from_sample(left); | |
| 135 | + | data[base + 1] = T::from_sample(right); | |
| 136 | + | for ch in 2..channels { | |
| 137 | + | data[base + ch] = T::from_sample(0.0f32); | |
| 138 | + | } | |
| 139 | + | } else if channels == 1 { | |
| 140 | + | data[base] = T::from_sample((left + right) * 0.5); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | pos += 1; | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | guard.position = pos; | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | #[cfg(test)] | |
| 150 | + | mod tests { | |
| 151 | + | use super::*; | |
| 152 | + | use audiofiles_browser::preview::PreviewBuffer; | |
| 153 | + | ||
| 154 | + | fn make_playback(data: Vec<f32>, playing: bool) -> Mutex<PreviewPlayback> { | |
| 155 | + | Mutex::new(PreviewPlayback { | |
| 156 | + | buffer: Some(PreviewBuffer { | |
| 157 | + | data, | |
| 158 | + | channels: 2, | |
| 159 | + | sample_rate: 44100, | |
| 160 | + | }), | |
| 161 | + | position: 0, | |
| 162 | + | playing, | |
| 163 | + | }) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | #[test] | |
| 167 | + | fn fill_cpal_stereo_f32() { | |
| 168 | + | let playback = make_playback(vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6], true); | |
| 169 | + | let mut data = vec![0.0f32; 6]; // 3 frames * 2 channels | |
| 170 | + | ||
| 171 | + | fill_cpal_output(&playback, &mut data, 2); | |
| 172 | + | ||
| 173 | + | assert_eq!(data, vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6]); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | #[test] | |
| 177 | + | fn fill_cpal_mono_f32() { | |
| 178 | + | let playback = make_playback(vec![0.4, 0.6, 0.2, 0.8], true); | |
| 179 | + | let mut data = vec![0.0f32; 2]; // 2 frames * 1 channel | |
| 180 | + | ||
| 181 | + | fill_cpal_output(&playback, &mut data, 1); | |
| 182 | + | ||
| 183 | + | assert_eq!(data[0], (0.4 + 0.6) * 0.5); | |
| 184 | + | assert_eq!(data[1], (0.2 + 0.8) * 0.5); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | #[test] | |
| 188 | + | fn fill_cpal_stops_at_end() { | |
| 189 | + | // 2 frames of preview, 4-frame output buffer | |
| 190 | + | let playback = make_playback(vec![0.1, 0.2, 0.3, 0.4], true); | |
| 191 | + | let mut data = vec![9.0f32; 8]; // 4 frames * 2 channels | |
| 192 | + | ||
| 193 | + | fill_cpal_output(&playback, &mut data, 2); | |
| 194 | + | ||
| 195 | + | // First 2 frames: preview data | |
| 196 | + | assert_eq!(data[0], 0.1); | |
| 197 | + | assert_eq!(data[1], 0.2); | |
| 198 | + | assert_eq!(data[2], 0.3); | |
| 199 | + | assert_eq!(data[3], 0.4); | |
| 200 | + | // Last 2 frames: silence | |
| 201 | + | assert_eq!(data[4], 0.0); | |
| 202 | + | assert_eq!(data[5], 0.0); | |
| 203 | + | assert_eq!(data[6], 0.0); | |
| 204 | + | assert_eq!(data[7], 0.0); | |
| 205 | + | ||
| 206 | + | let guard = playback.lock(); | |
| 207 | + | assert!(!guard.playing); | |
| 208 | + | assert_eq!(guard.position, 0); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | #[test] | |
| 212 | + | fn fill_cpal_not_playing_outputs_silence() { | |
| 213 | + | let playback = make_playback(vec![0.5, 0.5], false); | |
| 214 | + | let mut data = vec![1.0f32; 4]; | |
| 215 | + | ||
| 216 | + | fill_cpal_output(&playback, &mut data, 2); | |
| 217 | + | ||
| 218 | + | assert!(data.iter().all(|&s| s == 0.0)); | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | #[test] | |
| 222 | + | fn audio_error_display() { | |
| 223 | + | let variants: Vec<Box<dyn std::fmt::Display>> = vec![ | |
| 224 | + | Box::new(AudioError::NoDevice), | |
| 225 | + | Box::new(AudioError::UnsupportedFormat(cpal::SampleFormat::U32)), | |
| 226 | + | ]; | |
| 227 | + | for err in &variants { | |
| 228 | + | assert!(!err.to_string().is_empty()); | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | } |
| @@ -0,0 +1,222 @@ | |||
| 1 | + | //! AudioFiles standalone desktop app. | |
| 2 | + | //! | |
| 3 | + | //! Launches an eframe window with the shared egui browser UI and a cpal audio | |
| 4 | + | //! output stream for sample preview playback. | |
| 5 | + | ||
| 6 | + | mod audio; | |
| 7 | + | mod tray; | |
| 8 | + | ||
| 9 | + | use std::path::{Path, PathBuf}; | |
| 10 | + | use std::sync::Arc; | |
| 11 | + | ||
| 12 | + | use audiofiles_browser::state::{BrowserState, SharedState}; | |
| 13 | + | use audiofiles_sync::{SyncKitConfig, SyncManager}; | |
| 14 | + | use eframe::egui; | |
| 15 | + | use eframe::egui::ViewportCommand; | |
| 16 | + | ||
| 17 | + | /// Launch the AudioFiles standalone app. | |
| 18 | + | /// | |
| 19 | + | /// Initialises tracing, resolves the platform data directory, starts a cpal | |
| 20 | + | /// audio output stream for sample preview, and opens an eframe window running | |
| 21 | + | /// the shared egui browser UI. | |
| 22 | + | fn main() -> eframe::Result<()> { | |
| 23 | + | tracing_subscriber::fmt::init(); | |
| 24 | + | ||
| 25 | + | let data_dir = dirs::data_dir() | |
| 26 | + | .unwrap_or_else(|| PathBuf::from(".")) | |
| 27 | + | .join("AudioFiles"); | |
| 28 | + | ||
| 29 | + | // Tokio runtime for sync operations | |
| 30 | + | let runtime = tokio::runtime::Builder::new_multi_thread() | |
| 31 | + | .worker_threads(2) | |
| 32 | + | .enable_all() | |
| 33 | + | .build() | |
| 34 | + | .expect("failed to start tokio runtime"); | |
| 35 | + | ||
| 36 | + | // SyncManager (optional, configured via env vars) | |
| 37 | + | let sync_manager = create_sync_manager(&data_dir, runtime.handle()); | |
| 38 | + | ||
| 39 | + | let shared = Arc::new(SharedState::new()); | |
| 40 | + | ||
| 41 | + | // Start cpal audio output stream | |
| 42 | + | let _stream = match audio::start_output_stream(shared.clone()) { | |
| 43 | + | Ok(s) => Some(s), | |
| 44 | + | Err(e) => { | |
| 45 | + | tracing::error!("Failed to start audio output: {e}"); | |
| 46 | + | None | |
| 47 | + | } | |
| 48 | + | }; | |
| 49 | + | ||
| 50 | + | // Create system tray icon (non-fatal if it fails) | |
| 51 | + | let app_tray = match tray::AppTray::new() { | |
| 52 | + | Ok(t) => Some(t), | |
| 53 | + | Err(e) => { | |
| 54 | + | tracing::warn!("Failed to create system tray: {e}"); | |
| 55 | + | None | |
| 56 | + | } | |
| 57 | + | }; | |
| 58 | + | ||
| 59 | + | let options = eframe::NativeOptions { | |
| 60 | + | viewport: egui::ViewportBuilder::default() | |
| 61 | + | .with_title("AudioFiles") | |
| 62 | + | .with_inner_size([900.0, 600.0]) | |
| 63 | + | .with_min_inner_size([600.0, 400.0]) | |
| 64 | + | .with_drag_and_drop(true), | |
| 65 | + | ..Default::default() | |
| 66 | + | }; | |
| 67 | + | ||
| 68 | + | eframe::run_native( | |
| 69 | + | "AudioFiles", | |
| 70 | + | options, | |
| 71 | + | Box::new(move |_cc| { | |
| 72 | + | Ok(Box::new(AudioFilesApp::new( | |
| 73 | + | data_dir, shared, app_tray, sync_manager, runtime, | |
| 74 | + | ))) | |
| 75 | + | }), | |
| 76 | + | ) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// Create a SyncManager if AF_SYNC_SERVER_URL and AF_SYNC_API_KEY env vars are set. | |
| 80 | + | fn create_sync_manager( | |
| 81 | + | data_dir: &Path, | |
| 82 | + | runtime: &tokio::runtime::Handle, | |
| 83 | + | ) -> Option<SyncManager> { | |
| 84 | + | let server_url = std::env::var("AF_SYNC_SERVER_URL").ok()?; | |
| 85 | + | let api_key = std::env::var("AF_SYNC_API_KEY").ok()?; | |
| 86 | + | let config = SyncKitConfig { | |
| 87 | + | server_url, | |
| 88 | + | api_key, | |
| 89 | + | }; | |
| 90 | + | let db_path = data_dir.join("audiofiles.db"); | |
| 91 | + | let manager = SyncManager::new(config, db_path, runtime.clone()); | |
| 92 | + | manager.try_restore_session(); | |
| 93 | + | manager.start_scheduler(); | |
| 94 | + | Some(manager) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | struct AudioFilesApp { | |
| 98 | + | browser: Option<BrowserState>, | |
| 99 | + | error: Option<String>, | |
| 100 | + | tray: Option<tray::AppTray>, | |
| 101 | + | sync_manager: Option<SyncManager>, | |
| 102 | + | _runtime: tokio::runtime::Runtime, | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | impl AudioFilesApp { | |
| 106 | + | fn new( | |
| 107 | + | data_dir: PathBuf, | |
| 108 | + | shared: Arc<SharedState>, | |
| 109 | + | tray: Option<tray::AppTray>, | |
| 110 | + | sync_manager: Option<SyncManager>, | |
| 111 | + | runtime: tokio::runtime::Runtime, | |
| 112 | + | ) -> Self { | |
| 113 | + | let sample_rate = 44100.0; | |
| 114 | + | ||
| 115 | + | match BrowserState::new(&data_dir, shared, sample_rate) { | |
| 116 | + | Ok(mut browser) => { | |
| 117 | + | // Import any files/directories passed as CLI arguments | |
| 118 | + | for arg in std::env::args().skip(1) { | |
| 119 | + | let path = PathBuf::from(&arg); | |
| 120 | + | if path.exists() { | |
| 121 | + | browser.import_path(&path); | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | Self { | |
| 125 | + | browser: Some(browser), | |
| 126 | + | error: None, | |
| 127 | + | tray, | |
| 128 | + | sync_manager, | |
| 129 | + | _runtime: runtime, | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | Err(e) => { | |
| 133 | + | tracing::error!("Failed to init browser: {e}"); | |
| 134 | + | Self { | |
| 135 | + | browser: None, | |
| 136 | + | error: Some(format!("{e}")), | |
| 137 | + | tray, | |
| 138 | + | sync_manager, | |
| 139 | + | _runtime: runtime, | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | impl eframe::App for AudioFilesApp { | |
| 147 | + | fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { | |
| 148 | + | // Poll tray menu events | |
| 149 | + | if let Some(ref tray) = self.tray { | |
| 150 | + | if let Some(action) = tray.poll() { | |
| 151 | + | match action { | |
| 152 | + | tray::TrayAction::ShowWindow => { | |
| 153 | + | ctx.send_viewport_cmd(ViewportCommand::Focus); | |
| 154 | + | } | |
| 155 | + | tray::TrayAction::TogglePlayback => { | |
| 156 | + | if let Some(ref mut browser) = self.browser { | |
| 157 | + | browser.toggle_preview(); | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | tray::TrayAction::Quit => { | |
| 161 | + | ctx.send_viewport_cmd(ViewportCommand::Close); | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // Update tray tooltip based on playback state | |
| 168 | + | if let Some(ref tray) = self.tray { | |
| 169 | + | if let Some(ref browser) = self.browser { | |
| 170 | + | let playing = browser.shared.preview.lock().playing; | |
| 171 | + | if playing { | |
| 172 | + | tray.set_tooltip(&browser.status); | |
| 173 | + | } else { | |
| 174 | + | tray.set_tooltip("AudioFiles"); | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // Check if sync pulled remote changes → refresh browser contents | |
| 180 | + | if let Some(ref sync) = self.sync_manager { | |
| 181 | + | if sync.status().needs_refresh { | |
| 182 | + | if let Some(ref mut browser) = self.browser { | |
| 183 | + | browser.refresh_vfs_list(); | |
| 184 | + | browser.refresh_contents(); | |
| 185 | + | } | |
| 186 | + | sync.clear_needs_refresh(); | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // Handle dropped files (drag-and-drop import) | |
| 191 | + | let dropped: Vec<PathBuf> = ctx | |
| 192 | + | .input(|i| { | |
| 193 | + | i.raw | |
| 194 | + | .dropped_files | |
| 195 | + | .iter() | |
| 196 | + | .filter_map(|f| f.path.clone()) | |
| 197 | + | .collect() | |
| 198 | + | }); | |
| 199 | + | ||
| 200 | + | if let Some(ref mut browser) = self.browser { | |
| 201 | + | for path in dropped { | |
| 202 | + | if path.is_dir() { | |
| 203 | + | let strategy = audiofiles_browser::import::ImportStrategy::MergeIntoVfs { | |
| 204 | + | vfs_id: browser.current_vfs_id(), | |
| 205 | + | parent_id: browser.current_dir, | |
| 206 | + | }; | |
| 207 | + | browser.start_folder_import(path, strategy); | |
| 208 | + | } else { | |
| 209 | + | browser.import_path(&path); | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | audiofiles_browser::editor::draw_browser(ctx, browser, self.sync_manager.as_ref()); | |
| 213 | + | } else { | |
| 214 | + | egui::CentralPanel::default().show(ctx, |ui| { | |
| 215 | + | ui.heading("AudioFiles"); | |
| 216 | + | if let Some(ref err) = self.error { | |
| 217 | + | ui.label(format!("Error: could not initialize database.\n{err}")); | |
| 218 | + | } | |
| 219 | + | }); | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | } |
| @@ -0,0 +1,102 @@ | |||
| 1 | + | //! System tray icon with context menu for the standalone app. | |
| 2 | + | ||
| 3 | + | use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}; | |
| 4 | + | use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; | |
| 5 | + | ||
| 6 | + | /// Actions the tray context menu can trigger. | |
| 7 | + | pub enum TrayAction { | |
| 8 | + | ShowWindow, | |
| 9 | + | TogglePlayback, | |
| 10 | + | Quit, | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | /// Owns the tray icon and tracks menu item IDs for event matching. | |
| 14 | + | pub struct AppTray { | |
| 15 | + | icon: TrayIcon, | |
| 16 | + | show_id: tray_icon::menu::MenuId, | |
| 17 | + | toggle_id: tray_icon::menu::MenuId, | |
| 18 | + | quit_id: tray_icon::menu::MenuId, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | impl AppTray { | |
| 22 | + | /// Create the tray icon and context menu. Must be called on the main thread (macOS). | |
| 23 | + | pub fn new() -> Result<Self, tray_icon::Error> { | |
| 24 | + | let show = MenuItem::new("Show Window", true, None); | |
| 25 | + | let toggle = MenuItem::new("Play / Pause", true, None); | |
| 26 | + | let quit = MenuItem::new("Quit", true, None); | |
| 27 | + | ||
| 28 | + | let show_id = show.id().clone(); | |
| 29 | + | let toggle_id = toggle.id().clone(); | |
| 30 | + | let quit_id = quit.id().clone(); | |
| 31 | + | ||
| 32 | + | let menu = Menu::new(); | |
| 33 | + | let _ = menu.append_items(&[ | |
| 34 | + | &show, | |
| 35 | + | &PredefinedMenuItem::separator(), | |
| 36 | + | &toggle, | |
| 37 | + | &PredefinedMenuItem::separator(), | |
| 38 | + | &quit, | |
| 39 | + | ]); | |
| 40 | + | ||
| 41 | + | let icon = TrayIconBuilder::new() | |
| 42 | + | .with_menu(Box::new(menu)) | |
| 43 | + | .with_tooltip("AudioFiles") | |
| 44 | + | .with_icon(build_icon()) | |
| 45 | + | .build()?; | |
| 46 | + | ||
| 47 | + | Ok(Self { | |
| 48 | + | icon, | |
| 49 | + | show_id, | |
| 50 | + | toggle_id, | |
| 51 | + | quit_id, | |
| 52 | + | }) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// Poll for menu events. Non-blocking, returns `None` if no event. | |
| 56 | + | pub fn poll(&self) -> Option<TrayAction> { | |
| 57 | + | if let Ok(event) = MenuEvent::receiver().try_recv() { | |
| 58 | + | if event.id == self.show_id { | |
| 59 | + | Some(TrayAction::ShowWindow) | |
| 60 | + | } else if event.id == self.toggle_id { | |
| 61 | + | Some(TrayAction::TogglePlayback) | |
| 62 | + | } else if event.id == self.quit_id { | |
| 63 | + | Some(TrayAction::Quit) | |
| 64 | + | } else { | |
| 65 | + | None | |
| 66 | + | } | |
| 67 | + | } else { | |
| 68 | + | None | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// Update the tooltip to reflect playback state. | |
| 73 | + | pub fn set_tooltip(&self, text: &str) { | |
| 74 | + | let _ = self.icon.set_tooltip(Some(text)); | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Generate a simple 32x32 RGBA waveform icon programmatically. | |
| 79 | + | fn build_icon() -> Icon { | |
| 80 | + | const SIZE: usize = 32; | |
| 81 | + | let mut rgba = vec![0u8; SIZE * SIZE * 4]; | |
| 82 | + | ||
| 83 | + | // Draw five vertical bars of varying heights to suggest a waveform. | |
| 84 | + | // Bar colour: dark grey (#3d3530) on transparent background. | |
| 85 | + | let bar_colour: [u8; 4] = [0x3d, 0x35, 0x30, 0xff]; | |
| 86 | + | let bar_xs: [(usize, usize); 5] = [(5, 8), (10, 13), (15, 18), (20, 23), (25, 28)]; | |
| 87 | + | let bar_heights: [usize; 5] = [12, 22, 32, 18, 10]; | |
| 88 | + | ||
| 89 | + | for (i, &(x_start, x_end)) in bar_xs.iter().enumerate() { | |
| 90 | + | let h = bar_heights[i]; | |
| 91 | + | let y_start = (SIZE - h) / 2; | |
| 92 | + | let y_end = y_start + h; | |
| 93 | + | for y in y_start..y_end { | |
| 94 | + | for x in x_start..x_end { | |
| 95 | + | let offset = (y * SIZE + x) * 4; | |
| 96 | + | rgba[offset..offset + 4].copy_from_slice(&bar_colour); | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | Icon::from_rgba(rgba, SIZE as u32, SIZE as u32).expect("valid 32x32 RGBA icon") | |
| 102 | + | } |
| @@ -0,0 +1,27 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "audiofiles-browser" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition.workspace = true | |
| 5 | + | ||
| 6 | + | [features] | |
| 7 | + | default = ["device-profiles"] | |
| 8 | + | device-profiles = ["dep:audiofiles-rhai"] | |
| 9 | + | ||
| 10 | + | [dependencies] | |
| 11 | + | audiofiles-core = { workspace = true } | |
| 12 | + | audiofiles-rhai = { workspace = true, optional = true } | |
| 13 | + | audiofiles-sync = { workspace = true } | |
| 14 | + | egui = { workspace = true } | |
| 15 | + | egui_extras = { workspace = true } | |
| 16 | + | symphonia = { workspace = true } | |
| 17 | + | parking_lot = { workspace = true } | |
| 18 | + | dirs = { workspace = true } | |
| 19 | + | thiserror = { workspace = true } | |
| 20 | + | toml = { workspace = true } | |
| 21 | + | rfd = { workspace = true } | |
| 22 | + | serde = { workspace = true } | |
| 23 | + | serde_json = { workspace = true } | |
| 24 | + | rusqlite = { workspace = true } | |
| 25 | + | ||
| 26 | + | [dev-dependencies] | |
| 27 | + | tempfile = "3.25.0" |
| @@ -0,0 +1,937 @@ | |||
| 1 | + | //! Direct backend: wraps `Mutex<Database>` + `SampleStore`, calls core functions directly. | |
| 2 | + | //! | |
| 3 | + | //! This is the "same as before" implementation — every Backend method delegates | |
| 4 | + | //! to the corresponding audiofiles-core function. Used in standalone mode, tests, | |
| 5 | + | //! and as a reference implementation. | |
| 6 | + | ||
| 7 | + | use std::path::{Path, PathBuf}; | |
| 8 | + | ||
| 9 | + | use audiofiles_core::analysis::config::AnalysisConfig; | |
| 10 | + | use audiofiles_core::analysis::waveform::WaveformData; | |
| 11 | + | use audiofiles_core::analysis::AnalysisResult; | |
| 12 | + | use audiofiles_core::db::Database; | |
| 13 | + | use audiofiles_core::export::profile::DeviceProfileSummary; | |
| 14 | + | use audiofiles_core::export::ExportItem; | |
| 15 | + | use audiofiles_core::search::SearchFilter; | |
| 16 | + | use audiofiles_core::smart_folders::SmartFolder; | |
| 17 | + | use audiofiles_core::store::SampleStore; | |
| 18 | + | use audiofiles_core::vfs::{self, Vfs, VfsNode, VfsNodeWithAnalysis}; | |
| 19 | + | use audiofiles_core::{fingerprint, search, similarity, smart_folders, tags, NodeId, SmartFolderId, VfsId}; | |
| 20 | + | use parking_lot::Mutex; | |
| 21 | + | ||
| 22 | + | use super::{ | |
| 23 | + | Backend, BackendEvent, BackendResult, ExportConfigDesc, ExportItemDesc, ImportStrategyDesc, | |
| 24 | + | ImportedFolderDesc, | |
| 25 | + | }; | |
| 26 | + | ||
| 27 | + | use crate::export::{ExportCommand, ExportHandle}; | |
| 28 | + | use crate::import::{ImportCommand, ImportEvent, ImportHandle, ImportStrategy}; | |
| 29 | + | ||
| 30 | + | use audiofiles_core::analysis::worker::{WorkerCommand, WorkerEvent, WorkerHandle}; | |
| 31 | + | ||
| 32 | + | /// Direct backend: talks to SQLite and the sample store in-process. | |
| 33 | + | pub struct DirectBackend { | |
| 34 | + | db: Mutex<Database>, | |
| 35 | + | store: SampleStore, | |
| 36 | + | data_dir: PathBuf, | |
| 37 | + | // Worker handles for long-running operations | |
| 38 | + | import_worker: Mutex<Option<ImportHandle>>, | |
| 39 | + | analysis_worker: Mutex<Option<WorkerHandle>>, | |
| 40 | + | export_worker: Mutex<Option<ExportHandle>>, | |
| 41 | + | // Device plugin registry (when device-profiles feature is enabled) | |
| 42 | + | #[cfg(feature = "device-profiles")] | |
| 43 | + | plugin_registry: audiofiles_rhai::registry::PluginRegistry, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | impl DirectBackend { | |
| 47 | + | /// Create a new DirectBackend from a database and sample store. | |
| 48 | + | pub fn new(db: Database, store: SampleStore, data_dir: PathBuf) -> Self { | |
| 49 | + | Self { | |
| 50 | + | db: Mutex::new(db), | |
| 51 | + | store, | |
| 52 | + | data_dir, | |
| 53 | + | import_worker: Mutex::new(None), | |
| 54 | + | analysis_worker: Mutex::new(None), | |
| 55 | + | export_worker: Mutex::new(None), | |
| 56 | + | #[cfg(feature = "device-profiles")] | |
| 57 | + | plugin_registry: audiofiles_rhai::create_registry().unwrap_or_else(|_| { | |
| 58 | + | audiofiles_rhai::registry::PluginRegistry::new() | |
| 59 | + | }), | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// Access the store (needed for preview decode path in BrowserState). | |
| 64 | + | pub fn store(&self) -> &SampleStore { | |
| 65 | + | &self.store | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// Access the data directory path. | |
| 69 | + | pub fn data_dir(&self) -> &Path { | |
| 70 | + | &self.data_dir | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// Resolve a device profile's constraints into the export config and filter items. | |
| 74 | + | /// | |
| 75 | + | /// Called before spawning the export worker so profile resolution happens | |
| 76 | + | /// on the main thread (where PluginRegistry is accessible). | |
| 77 | + | #[cfg(feature = "device-profiles")] | |
| 78 | + | fn resolve_device_profile( | |
| 79 | + | &self, | |
| 80 | + | config: &mut audiofiles_core::export::ExportConfig, | |
| 81 | + | items: &mut Vec<ExportItem>, | |
| 82 | + | ) { | |
| 83 | + | use audiofiles_core::export::profile::ChannelConstraint; | |
| 84 | + | use audiofiles_core::export::{ExportChannels, ExportFormat}; | |
| 85 | + | ||
| 86 | + | let profile_name = match config.device_profile { | |
| 87 | + | Some(ref name) => name.clone(), | |
| 88 | + | None => return, | |
| 89 | + | }; | |
| 90 | + | ||
| 91 | + | let plugin = match self.plugin_registry.get(&profile_name) { | |
| 92 | + | Some(p) => p, | |
| 93 | + | None => return, | |
| 94 | + | }; | |
| 95 | + | ||
| 96 | + | let profile = &plugin.profile; | |
| 97 | + | ||
| 98 | + | // Format: if Original, set to profile's first supported format | |
| 99 | + | if config.format == ExportFormat::Original { | |
| 100 | + | if let Some(fmt) = profile.audio.formats.first() { | |
| 101 | + | config.format = fmt.clone(); | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Sample rate: if not set, use profile's first rate | |
| 106 | + | if config.sample_rate.is_none() { | |
| 107 | + | config.sample_rate = profile.audio.sample_rates.first().copied(); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // Bit depth: if not set, use profile's first depth | |
| 111 | + | if config.bit_depth.is_none() { | |
| 112 | + | config.bit_depth = profile.audio.bit_depths.first().copied(); | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Channels | |
| 116 | + | match profile.audio.channels { | |
| 117 | + | ChannelConstraint::Mono => config.channels = ExportChannels::Mono, | |
| 118 | + | ChannelConstraint::Stereo => config.channels = ExportChannels::Stereo, | |
| 119 | + | ChannelConstraint::Both => {} // leave as-is | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // Naming rules | |
| 123 | + | config.naming_rules = profile.naming.clone(); | |
| 124 | + | ||
| 125 | + | // File size limit | |
| 126 | + | config.max_file_size_bytes = profile.limits.as_ref().and_then(|l| l.max_file_size_bytes); | |
| 127 | + | ||
| 128 | + | // validate_sample hook: filter items through Rhai script | |
| 129 | + | if let Some(ref ast) = plugin.hooks.validate_sample { | |
| 130 | + | let db = self.db.lock(); | |
| 131 | + | items.retain(|item| { | |
| 132 | + | let info = build_sample_info(&db, &self.store, item); | |
| 133 | + | audiofiles_rhai::hooks::run_validate_sample( | |
| 134 | + | self.plugin_registry.engine(), | |
| 135 | + | ast, | |
| 136 | + | info, | |
| 137 | + | ) | |
| 138 | + | .unwrap_or(true) | |
| 139 | + | }); | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // transform_filename hook: pre-compute output names with custom naming logic | |
| 143 | + | if let Some(ref ast) = plugin.hooks.transform_filename { | |
| 144 | + | let pattern = config | |
| 145 | + | .naming_pattern | |
| 146 | + | .as_ref() | |
| 147 | + | .and_then(|p| audiofiles_core::rename::RenamePattern::parse(p).ok()); | |
| 148 | + | ||
| 149 | + | let mut names = audiofiles_core::export::resolve_output_names( | |
| 150 | + | items, | |
| 151 | + | config, | |
| 152 | + | pattern.as_ref(), | |
| 153 | + | ); | |
| 154 | + | ||
| 155 | + | let device_name = profile.name.clone(); | |
| 156 | + | let destination = config.destination.display().to_string(); | |
| 157 | + | let total = names.len() as i64; | |
| 158 | + | ||
| 159 | + | for (i, name) in names.iter_mut().enumerate() { | |
| 160 | + | let (stem, ext) = split_name_ext(name); | |
| 161 | + | let ctx = audiofiles_rhai::types::RhaiExportContext { | |
| 162 | + | device_name: device_name.clone(), | |
| 163 | + | destination: destination.clone(), | |
| 164 | + | filename: stem.clone(), | |
| 165 | + | extension: ext.clone(), | |
| 166 | + | index: i as i64, | |
| 167 | + | total, | |
| 168 | + | }; | |
| 169 | + | if let Ok(new_stem) = audiofiles_rhai::hooks::run_transform_filename( | |
| 170 | + | self.plugin_registry.engine(), | |
| 171 | + | ast, | |
| 172 | + | stem, | |
| 173 | + | ctx, | |
| 174 | + | ) { | |
| 175 | + | *name = if ext.is_empty() { | |
| 176 | + | new_stem | |
| 177 | + | } else { | |
| 178 | + | format!("{new_stem}.{ext}") | |
| 179 | + | }; | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | config.name_overrides = Some(names); | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | #[cfg(feature = "device-profiles")] | |
| 189 | + | use audiofiles_core::util::split_name_ext; | |
| 190 | + | ||
| 191 | + | /// Build a RhaiSampleInfo from an ExportItem and database lookups. | |
| 192 | + | #[cfg(feature = "device-profiles")] | |
| 193 | + | fn build_sample_info( | |
| 194 | + | db: &audiofiles_core::db::Database, | |
| 195 | + | store: &audiofiles_core::store::SampleStore, | |
| 196 | + | item: &ExportItem, | |
| 197 | + | ) -> audiofiles_rhai::types::RhaiSampleInfo { | |
| 198 | + | // Query audio_analysis for sample_rate and channels | |
| 199 | + | let (sample_rate, channels, duration) = db | |
| 200 | + | .conn() | |
| 201 | + | .query_row( | |
| 202 | + | "SELECT sample_rate, channels, duration FROM audio_analysis WHERE hash = ?1", | |
| 203 | + | [&item.hash], | |
| 204 | + | |row| { | |
| 205 | + | Ok(( | |
| 206 | + | row.get::<_, u32>(0)?, | |
| 207 | + | row.get::<_, u16>(1)?, | |
| 208 | + | row.get::<_, f64>(2)?, | |
| 209 | + | )) | |
| 210 | + | }, | |
| 211 | + | ) | |
| 212 | + | .unwrap_or((0, 0, item.duration.unwrap_or(0.0))); | |
| 213 | + | ||
| 214 | + | // Query samples for file_size | |
| 215 | + | let file_size = db | |
| 216 | + | .conn() | |
| 217 | + | .query_row( | |
| 218 | + | "SELECT file_size FROM samples WHERE hash = ?1", | |
| 219 | + | [&item.hash], | |
| 220 | + | |row| row.get::<_, u64>(0), | |
| 221 | + | ) | |
| 222 | + | .unwrap_or_else(|_| { | |
| 223 | + | // Fallback: read from store | |
| 224 | + | store | |
| 225 | + | .sample_path(&item.hash, &item.ext) | |
| 226 | + | .ok() | |
| 227 | + | .and_then(|p| std::fs::metadata(p).ok()) | |
| 228 | + | .map(|m| m.len()) | |
| 229 | + | .unwrap_or(0) | |
| 230 | + | }); | |
| 231 | + | ||
| 232 | + | audiofiles_rhai::types::RhaiSampleInfo { | |
| 233 | + | hash: item.hash.to_string(), | |
| 234 | + | name: item.name.clone(), | |
| 235 | + | extension: item.ext.clone(), | |
| 236 | + | sample_rate, | |
| 237 | + | bit_depth: 0, // not stored in DB; hooks can check other fields | |
| 238 | + | channels, | |
| 239 | + | duration, | |
| 240 | + | file_size, | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | impl Backend for DirectBackend { | |
| 245 | + | // --- VFS --- | |
| 246 | + | ||
| 247 | + | fn list_vfs(&self) -> BackendResult<Vec<Vfs>> { | |
| 248 | + | let db = self.db.lock(); | |
| 249 | + | Ok(vfs::list_vfs(&db)?) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | fn create_vfs(&self, name: &str) -> BackendResult<VfsId> { | |
| 253 | + | let db = self.db.lock(); | |
| 254 | + | Ok(vfs::create_vfs(&db, name)?) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | fn rename_vfs(&self, id: VfsId, new_name: &str) -> BackendResult<()> { | |
| 258 | + | let db = self.db.lock(); | |
| 259 | + | Ok(vfs::rename_vfs(&db, id, new_name)?) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | fn delete_vfs(&self, id: VfsId) -> BackendResult<()> { | |
| 263 | + | let db = self.db.lock(); | |
| 264 | + | Ok(vfs::delete_vfs(&db, id)?) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | fn list_children_enriched( | |
| 268 | + | &self, | |
| 269 | + | vfs_id: VfsId, | |
| 270 | + | parent_id: Option<NodeId>, | |
| 271 | + | ) -> BackendResult<Vec<VfsNodeWithAnalysis>> { | |
| 272 | + | let db = self.db.lock(); | |
| 273 | + | Ok(vfs::list_children_enriched(&db, vfs_id, parent_id)?) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | fn list_children( | |
| 277 | + | &self, | |
| 278 | + | vfs_id: VfsId, | |
| 279 | + | parent_id: Option<NodeId>, | |
| 280 | + | ) -> BackendResult<Vec<VfsNode>> { | |
| 281 | + | let db = self.db.lock(); | |
| 282 | + | Ok(vfs::list_children(&db, vfs_id, parent_id)?) | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | fn create_directory( | |
| 286 | + | &self, | |
| 287 | + | vfs_id: VfsId, | |
| 288 | + | parent_id: Option<NodeId>, | |
| 289 | + | name: &str, | |
| 290 | + | ) -> BackendResult<NodeId> { | |
| 291 | + | let db = self.db.lock(); | |
| 292 | + | Ok(vfs::create_directory(&db, vfs_id, parent_id, name)?) | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | fn create_sample_link( | |
| 296 | + | &self, | |
| 297 | + | vfs_id: VfsId, | |
| 298 | + | parent_id: Option<NodeId>, | |
| 299 | + | name: &str, | |
| 300 | + | sample_hash: &str, | |
| 301 | + | ) -> BackendResult<NodeId> { | |
| 302 | + | let db = self.db.lock(); | |
| 303 | + | Ok(vfs::create_sample_link(&db, vfs_id, parent_id, name, sample_hash)?) | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | fn get_node(&self, id: NodeId) -> BackendResult<VfsNode> { | |
| 307 | + | let db = self.db.lock(); | |
| 308 | + | Ok(vfs::get_node(&db, id)?) | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | fn get_breadcrumb(&self, node_id: NodeId) -> BackendResult<Vec<VfsNode>> { | |
| 312 | + | let db = self.db.lock(); | |
| 313 | + | Ok(vfs::get_breadcrumb(&db, node_id)?) | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | fn rename_node(&self, id: NodeId, new_name: &str) -> BackendResult<()> { | |
| 317 | + | let db = self.db.lock(); | |
| 318 | + | Ok(vfs::rename_node(&db, id, new_name)?) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | fn move_node(&self, id: NodeId, new_parent_id: Option<NodeId>) -> BackendResult<()> { | |
| 322 | + | let db = self.db.lock(); | |
| 323 | + | Ok(vfs::move_node(&db, id, new_parent_id)?) | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | fn delete_node(&self, id: NodeId) -> BackendResult<()> { | |
| 327 | + | let db = self.db.lock(); | |
| 328 | + | Ok(vfs::delete_node(&db, id)?) | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | fn restore_node(&self, node: &VfsNode) -> BackendResult<()> { | |
| 332 | + | let db = self.db.lock(); | |
| 333 | + | Ok(vfs::restore_node(&db, node)?) | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | fn collect_subtree(&self, node_id: NodeId) -> BackendResult<Vec<VfsNode>> { | |
| 337 | + | let db = self.db.lock(); | |
| 338 | + | Ok(vfs::collect_subtree(&db, node_id)?) | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | fn list_all_directories(&self, vfs_id: VfsId) -> BackendResult<Vec<(NodeId, String)>> { | |
| 342 | + | let db = self.db.lock(); | |
| 343 | + | Ok(vfs::list_all_directories(&db, vfs_id)?) | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | fn find_nodes_by_hashes( | |
| 347 | + | &self, | |
| 348 | + | vfs_id: VfsId, | |
| 349 | + | hashes: &[&str], | |
| 350 | + | ) -> BackendResult<Vec<VfsNodeWithAnalysis>> { | |
| 351 | + | let db = self.db.lock(); | |
| 352 | + | Ok(vfs::find_nodes_by_hashes(&db, vfs_id, hashes)?) | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // --- Tags --- | |
| 356 | + | ||
| 357 | + | fn add_tag(&self, hash: &str, tag: &str) -> BackendResult<()> { | |
| 358 | + | let db = self.db.lock(); | |
| 359 | + | Ok(tags::add_tag(&db, hash, tag)?) | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | fn remove_tag(&self, hash: &str, tag: &str) -> BackendResult<()> { | |
| 363 | + | let db = self.db.lock(); | |
| 364 | + | Ok(tags::remove_tag(&db, hash, tag)?) | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | fn get_sample_tags(&self, hash: &str) -> BackendResult<Vec<String>> { | |
| 368 | + | let db = self.db.lock(); | |
| 369 | + | Ok(tags::get_sample_tags(&db, hash)?) | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | fn list_all_tags(&self) -> BackendResult<Vec<String>> { | |
| 373 | + | let db = self.db.lock(); | |
| 374 | + | Ok(tags::list_all_tags(&db)?) | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | fn bulk_add_tag(&self, hashes: &[&str], tag: &str) -> BackendResult<usize> { | |
| 378 | + | let db = self.db.lock(); | |
| 379 | + | Ok(tags::bulk_add_tag(&db, hashes, tag)?) | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | fn bulk_remove_tag(&self, hashes: &[&str], tag: &str) -> BackendResult<usize> { | |
| 383 | + | let db = self.db.lock(); | |
| 384 | + | Ok(tags::bulk_remove_tag(&db, hashes, tag)?) | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | // --- Search --- | |
| 388 | + | ||
| 389 | + | fn search_in_folder( | |
| 390 | + | &self, | |
| 391 | + | filter: &SearchFilter, | |
| 392 | + | vfs_id: VfsId, | |
| 393 | + | parent_id: Option<NodeId>, | |
| 394 | + | ) -> BackendResult<Vec<VfsNodeWithAnalysis>> { | |
| 395 | + | let db = self.db.lock(); | |
| 396 | + | Ok(search::search_in_folder(&db, filter, vfs_id, parent_id)?) | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | fn search_global(&self, filter: &SearchFilter) -> BackendResult<Vec<VfsNodeWithAnalysis>> { | |
| 400 | + | let db = self.db.lock(); | |
| 401 | + | Ok(search::search_global(&db, filter)?) | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | // --- Smart folders --- | |
| 405 | + | ||
| 406 | + | fn list_smart_folders(&self, vfs_id: VfsId) -> BackendResult<Vec<SmartFolder>> { | |
| 407 | + | let db = self.db.lock(); | |
| 408 | + | Ok(smart_folders::list_smart_folders(&db, vfs_id)?) | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | fn create_smart_folder( | |
| 412 | + | &self, | |
| 413 | + | vfs_id: VfsId, | |
| 414 | + | name: &str, | |
| 415 | + | filter: &SearchFilter, | |
| 416 | + | ) -> BackendResult<SmartFolderId> { | |
| 417 | + | let db = self.db.lock(); | |
| 418 | + | Ok(smart_folders::create_smart_folder(&db, vfs_id, name, filter)?) | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | fn delete_smart_folder(&self, id: SmartFolderId) -> BackendResult<()> { | |
| 422 | + | let db = self.db.lock(); | |
| 423 | + | Ok(smart_folders::delete_smart_folder(&db, id)?) | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | fn rename_smart_folder(&self, id: SmartFolderId, new_name: &str) -> BackendResult<()> { | |
| 427 | + | let db = self.db.lock(); | |
| 428 | + | Ok(smart_folders::rename_smart_folder(&db, id, new_name)?) | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | // --- Analysis --- | |
| 432 | + | ||
| 433 | + | fn get_analysis(&self, hash: &str) -> BackendResult<Option<AnalysisResult>> { | |
| 434 | + | let db = self.db.lock(); | |
| 435 | + | Ok(audiofiles_core::analysis::load_analysis(&db, hash)) | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | fn save_analysis(&self, result: &AnalysisResult) -> BackendResult<()> { | |
| 439 | + | let db = self.db.lock(); | |
| 440 | + | Ok(audiofiles_core::analysis::save_analysis(&db, result)?) | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | fn get_waveform(&self, hash: &str) -> BackendResult<Option<WaveformData>> { | |
| 444 | + | let db = self.db.lock(); | |
| 445 | + | Ok(audiofiles_core::analysis::waveform::load_waveform(&db, hash)) | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | // --- Similarity --- | |
| 449 | + | ||
| 450 | + | fn find_similar( | |
| 451 | + | &self, | |
| 452 | + | hash: &str, | |
| 453 | + | limit: usize, | |
| 454 | + | ) -> BackendResult<Vec<similarity::SimilarResult>> { | |
| 455 | + | let db = self.db.lock(); | |
| 456 | + | Ok(similarity::find_similar(&db, hash, limit)?) | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | fn find_near_duplicates( | |
| 460 | + | &self, | |
| 461 | + | hash: &str, | |
| 462 | + | limit: usize, | |
| 463 | + | ) -> BackendResult<Vec<fingerprint::DuplicateResult>> { | |
| 464 | + | let db = self.db.lock(); | |
| 465 | + | Ok(fingerprint::find_near_duplicates(&db, hash, limit)?) | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | // --- Store --- | |
| 469 | + | ||
| 470 | + | fn import_file(&self, path: &Path) -> BackendResult<String> { | |
| 471 | + | let db = self.db.lock(); | |
| 472 | + | Ok(self.store.import(path, &db)?) | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | fn sample_path(&self, hash: &str, ext: &str) -> BackendResult<PathBuf> { | |
| 476 | + | Ok(self.store.sample_path(hash, ext)?) | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | fn sample_extension(&self, hash: &str) -> BackendResult<String> { | |
| 480 | + | let db = self.db.lock(); | |
| 481 | + | Ok(audiofiles_core::store::sample_extension(&db, hash)?) | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | fn sample_original_name(&self, hash: &str) -> BackendResult<String> { | |
| 485 | + | let db = self.db.lock(); | |
| 486 | + | Ok(audiofiles_core::store::sample_original_name(&db, hash)?) | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | // --- Export --- | |
| 490 | + | ||
| 491 | + | fn collect_export_items( | |
| 492 | + | &self, | |
| 493 | + | vfs_id: VfsId, | |
| 494 | + | parent_id: Option<NodeId>, | |
| 495 | + | ) -> BackendResult<Vec<ExportItem>> { | |
| 496 | + | let db = self.db.lock(); | |
| 497 | + | Ok(audiofiles_core::export::collect_export_items(&db, vfs_id, parent_id)?) | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | fn enrich_export_with_tags(&self, items: &mut [ExportItem]) -> BackendResult<()> { |
Lines truncated
| @@ -0,0 +1,394 @@ | |||
| 1 | + | //! Backend abstraction for database and store access. | |
| 2 | + | //! | |
| 3 | + | //! The [`Backend`] trait defines all operations that BrowserState needs from | |
| 4 | + | //! the data layer. Two implementations exist: | |
| 5 | + | //! | |
| 6 | + | //! - [`DirectBackend`] — wraps `Mutex<Database>` + `SampleStore`, calls core functions directly. | |
| 7 | + | //! Used in tests, standalone mode, and as a reference implementation. | |
| 8 | + | //! - `DaemonBackend` (Phase D) — forwards calls to the daemon over Unix socket via JSON-RPC. | |
| 9 | + | ||
| 10 | + | pub mod direct; | |
| 11 | + | ||
| 12 | + | use std::path::{Path, PathBuf}; | |
| 13 | + | ||
| 14 | + | use audiofiles_core::analysis::config::AnalysisConfig; | |
| 15 | + | use audiofiles_core::analysis::suggest::TagSuggestion; | |
| 16 | + | use audiofiles_core::analysis::waveform::WaveformData; | |
| 17 | + | use audiofiles_core::analysis::AnalysisResult; | |
| 18 | + | use audiofiles_core::export::profile::DeviceProfileSummary; | |
| 19 | + | use audiofiles_core::export::{ExportConfig, ExportItem}; | |
| 20 | + | use audiofiles_core::search::SearchFilter; | |
| 21 | + | use audiofiles_core::smart_folders::SmartFolder; | |
| 22 | + | use audiofiles_core::vfs::{Vfs, VfsNode, VfsNodeWithAnalysis}; | |
| 23 | + | use audiofiles_core::{NodeId, SmartFolderId, VfsId}; | |
| 24 | + | ||
| 25 | + | pub use direct::DirectBackend; | |
| 26 | + | ||
| 27 | + | /// Result type for backend operations. | |
| 28 | + | pub type BackendResult<T> = Result<T, BackendError>; | |
| 29 | + | ||
| 30 | + | /// Unified error type for backend operations. | |
| 31 | + | #[derive(Debug, thiserror::Error)] | |
| 32 | + | pub enum BackendError { | |
| 33 | + | #[error("{0}")] | |
| 34 | + | Core(#[from] audiofiles_core::error::CoreError), | |
| 35 | + | ||
| 36 | + | #[error("{0}")] | |
| 37 | + | Other(String), | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// Events from long-running background operations (import, analysis, export). | |
| 41 | + | /// | |
| 42 | + | /// Unifies the separate ImportEvent, WorkerEvent, and ExportEvent types so that | |
| 43 | + | /// a single `poll_events()` call can drain all pending worker notifications. | |
| 44 | + | #[derive(Debug, serde::Serialize, serde::Deserialize)] | |
| 45 | + | pub enum BackendEvent { | |
| 46 | + | // Import events | |
| 47 | + | ImportWalkComplete { | |
| 48 | + | total: usize, | |
| 49 | + | }, | |
| 50 | + | ImportProgress { | |
| 51 | + | completed: usize, | |
| 52 | + | total: usize, | |
| 53 | + | current_name: String, | |
| 54 | + | }, | |
| 55 | + | ImportFileError { | |
| 56 | + | path: String, | |
| 57 | + | error: String, | |
| 58 | + | }, | |
| 59 | + | ImportComplete { | |
| 60 | + | imported: Vec<(String, String)>, | |
| 61 | + | total_files: usize, | |
| 62 | + | errors: usize, | |
| 63 | + | duplicates: usize, | |
| 64 | + | folders: Vec<ImportedFolderDesc>, | |
| 65 | + | }, | |
| 66 | + | ||
| 67 | + | // Analysis events | |
| 68 | + | AnalysisProgress { | |
| 69 | + | completed: usize, | |
| 70 | + | total: usize, | |
| 71 | + | current_name: String, | |
| 72 | + | }, | |
| 73 | + | AnalysisSampleDone { | |
| 74 | + | result: Box<AnalysisResult>, | |
| 75 | + | suggestions: Vec<TagSuggestion>, | |
| 76 | + | }, | |
| 77 | + | AnalysisSampleError { | |
| 78 | + | hash: String, | |
| 79 | + | error: String, | |
| 80 | + | }, | |
| 81 | + | AnalysisBatchComplete, | |
| 82 | + | ||
| 83 | + | // Export events | |
| 84 | + | ExportProgress { | |
| 85 | + | completed: usize, | |
| 86 | + | total: usize, | |
| 87 | + | current_name: String, | |
| 88 | + | }, | |
| 89 | + | ExportComplete { | |
| 90 | + | total: usize, | |
| 91 | + | errors: Vec<(String, String)>, | |
| 92 | + | }, | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// Serializable description of an imported folder (for IPC). | |
| 96 | + | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] | |
| 97 | + | pub struct ImportedFolderDesc { | |
| 98 | + | pub name: String, | |
| 99 | + | pub samples: Vec<(String, String)>, | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /// Serializable description of an import strategy (for IPC). | |
| 103 | + | #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] | |
| 104 | + | pub enum ImportStrategyDesc { | |
| 105 | + | Flat { vfs_id: VfsId, parent_id: Option<NodeId> }, | |
| 106 | + | NewVfs { vfs_name: String }, | |
| 107 | + | MergeIntoVfs { vfs_id: VfsId, parent_id: Option<NodeId> }, | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /// Serializable description of an export item (for IPC). | |
| 111 | + | pub type ExportItemDesc = ExportItem; | |
| 112 | + | ||
| 113 | + | /// Serializable description of an export config (for IPC). | |
| 114 | + | pub type ExportConfigDesc = ExportConfig; | |
| 115 | + | ||
| 116 | + | /// The core abstraction separating UI from data access. | |
| 117 | + | /// | |
| 118 | + | /// Every method is synchronous and blocking. The trait is `Send + Sync` so it | |
| 119 | + | /// can live inside `BrowserState` (which must be Send + Sync for nih-plug). | |
| 120 | + | pub trait Backend: Send + Sync { | |
| 121 | + | // --- VFS --- | |
| 122 | + | ||
| 123 | + | /// List all VFS roots, ordered alphabetically. | |
| 124 | + | fn list_vfs(&self) -> BackendResult<Vec<Vfs>>; | |
| 125 | + | ||
| 126 | + | /// Create a new VFS root. Returns the new VFS ID. | |
| 127 | + | fn create_vfs(&self, name: &str) -> BackendResult<VfsId>; | |
| 128 | + | ||
| 129 | + | /// Rename a VFS root. | |
| 130 | + | fn rename_vfs(&self, id: VfsId, new_name: &str) -> BackendResult<()>; | |
| 131 | + | ||
| 132 | + | /// Delete a VFS root and all its nodes. | |
| 133 | + | fn delete_vfs(&self, id: VfsId) -> BackendResult<()>; | |
| 134 | + | ||
| 135 | + | /// List children of a directory with analysis data joined in. | |
| 136 | + | fn list_children_enriched( | |
| 137 | + | &self, | |
| 138 | + | vfs_id: VfsId, | |
| 139 | + | parent_id: Option<NodeId>, | |
| 140 | + | ) -> BackendResult<Vec<VfsNodeWithAnalysis>>; | |
| 141 | + | ||
| 142 | + | /// List direct children (without analysis data). | |
| 143 | + | fn list_children( | |
| 144 | + | &self, | |
| 145 | + | vfs_id: VfsId, | |
| 146 | + | parent_id: Option<NodeId>, | |
| 147 | + | ) -> BackendResult<Vec<VfsNode>>; | |
| 148 | + | ||
| 149 | + | /// Create a directory node. Returns the new node ID. | |
| 150 | + | fn create_directory( | |
| 151 | + | &self, | |
| 152 | + | vfs_id: VfsId, | |
| 153 | + | parent_id: Option<NodeId>, | |
| 154 | + | name: &str, | |
| 155 | + | ) -> BackendResult<NodeId>; | |
| 156 | + | ||
| 157 | + | /// Create a sample link node. Returns the new node ID. | |
| 158 | + | fn create_sample_link( | |
| 159 | + | &self, | |
| 160 | + | vfs_id: VfsId, | |
| 161 | + | parent_id: Option<NodeId>, | |
| 162 | + | name: &str, | |
| 163 | + | sample_hash: &str, | |
| 164 | + | ) -> BackendResult<NodeId>; | |
| 165 | + | ||
| 166 | + | /// Fetch a single VFS node by ID. | |
| 167 | + | fn get_node(&self, id: NodeId) -> BackendResult<VfsNode>; | |
| 168 | + | ||
| 169 | + | /// Walk from a node up to the VFS root, returning root→node path. | |
| 170 | + | fn get_breadcrumb(&self, node_id: NodeId) -> BackendResult<Vec<VfsNode>>; | |
| 171 | + | ||
| 172 | + | /// Rename a VFS node. | |
| 173 | + | fn rename_node(&self, id: NodeId, new_name: &str) -> BackendResult<()>; | |
| 174 | + | ||
| 175 | + | /// Move a VFS node to a new parent. | |
| 176 | + | fn move_node(&self, id: NodeId, new_parent_id: Option<NodeId>) -> BackendResult<()>; | |
| 177 | + | ||
| 178 | + | /// Delete a VFS node (cascades to children). | |
| 179 | + | fn delete_node(&self, id: NodeId) -> BackendResult<()>; | |
| 180 | + | ||
| 181 | + | /// Re-insert a previously deleted node (for undo). | |
| 182 | + | fn restore_node(&self, node: &VfsNode) -> BackendResult<()>; | |
| 183 | + | ||
| 184 | + | /// Recursively collect a node and all its descendants. | |
| 185 | + | fn collect_subtree(&self, node_id: NodeId) -> BackendResult<Vec<VfsNode>>; | |
| 186 | + | ||
| 187 | + | /// List all directories in a VFS with full paths. | |
| 188 | + | fn list_all_directories(&self, vfs_id: VfsId) -> BackendResult<Vec<(NodeId, String)>>; | |
| 189 | + | ||
| 190 | + | /// Find VFS nodes by sample hashes within a specific VFS. | |
| 191 | + | fn find_nodes_by_hashes( | |
| 192 | + | &self, | |
| 193 | + | vfs_id: VfsId, | |
| 194 | + | hashes: &[&str], | |
| 195 | + | ) -> BackendResult<Vec<VfsNodeWithAnalysis>>; | |
| 196 | + | ||
| 197 | + | // --- Tags --- | |
| 198 | + | ||
| 199 | + | /// Add a tag to a sample. | |
| 200 | + | fn add_tag(&self, hash: &str, tag: &str) -> BackendResult<()>; | |
| 201 | + | ||
| 202 | + | /// Remove a tag from a sample. | |
| 203 | + | fn remove_tag(&self, hash: &str, tag: &str) -> BackendResult<()>; | |
| 204 | + | ||
| 205 | + | /// Get all tags for a sample. | |
| 206 | + | fn get_sample_tags(&self, hash: &str) -> BackendResult<Vec<String>>; | |
| 207 | + | ||
| 208 | + | /// List all tags in the database. | |
| 209 | + | fn list_all_tags(&self) -> BackendResult<Vec<String>>; | |
| 210 | + | ||
| 211 | + | /// Add a tag to multiple samples. Returns count of tags added. | |
| 212 | + | fn bulk_add_tag(&self, hashes: &[&str], tag: &str) -> BackendResult<usize>; | |
| 213 | + | ||
| 214 | + | /// Remove a tag from multiple samples. Returns count of tags removed. | |
| 215 | + | fn bulk_remove_tag(&self, hashes: &[&str], tag: &str) -> BackendResult<usize>; | |
| 216 | + | ||
| 217 | + | // --- Search --- | |
| 218 | + | ||
| 219 | + | /// Search within a specific VFS folder. | |
| 220 | + | fn search_in_folder( | |
| 221 | + | &self, | |
| 222 | + | filter: &SearchFilter, | |
| 223 | + | vfs_id: VfsId, | |
| 224 | + | parent_id: Option<NodeId>, | |
| 225 | + | ) -> BackendResult<Vec<VfsNodeWithAnalysis>>; | |
| 226 | + | ||
| 227 | + | /// Search globally across all VFS roots. | |
| 228 | + | fn search_global(&self, filter: &SearchFilter) -> BackendResult<Vec<VfsNodeWithAnalysis>>; | |
| 229 | + | ||
| 230 | + | // --- Smart folders --- | |
| 231 | + | ||
| 232 | + | /// List smart folders for a VFS. | |
| 233 | + | fn list_smart_folders(&self, vfs_id: VfsId) -> BackendResult<Vec<SmartFolder>>; | |
| 234 | + | ||
| 235 | + | /// Create a smart folder. Returns the new ID. | |
| 236 | + | fn create_smart_folder( | |
| 237 | + | &self, | |
| 238 | + | vfs_id: VfsId, | |
| 239 | + | name: &str, | |
| 240 | + | filter: &SearchFilter, | |
| 241 | + | ) -> BackendResult<SmartFolderId>; | |
| 242 | + | ||
| 243 | + | /// Delete a smart folder. | |
| 244 | + | fn delete_smart_folder(&self, id: SmartFolderId) -> BackendResult<()>; | |
| 245 | + | ||
| 246 | + | /// Rename a smart folder. | |
| 247 | + | fn rename_smart_folder(&self, id: SmartFolderId, new_name: &str) -> BackendResult<()>; | |
| 248 | + | ||
| 249 | + | // --- Analysis --- | |
| 250 | + | ||
| 251 | + | /// Get the full analysis result for a sample, if it exists. | |
| 252 | + | fn get_analysis(&self, hash: &str) -> BackendResult<Option<AnalysisResult>>; | |
| 253 | + | ||
| 254 | + | /// Save an analysis result to the database. | |
| 255 | + | fn save_analysis(&self, result: &AnalysisResult) -> BackendResult<()>; | |
| 256 | + | ||
| 257 | + | /// Get waveform display data for a sample. | |
| 258 | + | fn get_waveform(&self, hash: &str) -> BackendResult<Option<WaveformData>>; | |
| 259 | + | ||
| 260 | + | // --- Similarity --- | |
| 261 | + | ||
| 262 | + | /// Find samples similar to the given hash. | |
| 263 | + | fn find_similar( | |
| 264 | + | &self, | |
| 265 | + | hash: &str, | |
| 266 | + | limit: usize, | |
| 267 | + | ) -> BackendResult<Vec<audiofiles_core::similarity::SimilarResult>>; | |
| 268 | + | ||
| 269 | + | /// Find near-duplicate samples by fingerprint comparison. | |
| 270 | + | fn find_near_duplicates( | |
| 271 | + | &self, | |
| 272 | + | hash: &str, | |
| 273 | + | limit: usize, | |
| 274 | + | ) -> BackendResult<Vec<audiofiles_core::fingerprint::DuplicateResult>>; | |
| 275 | + | ||
| 276 | + | // --- Store --- | |
| 277 | + | ||
| 278 | + | /// Import a file into the content-addressed store. Returns the hash. | |
| 279 | + | fn import_file(&self, path: &Path) -> BackendResult<String>; | |
| 280 | + | ||
| 281 | + | /// Get the filesystem path for a stored sample. | |
| 282 | + | fn sample_path(&self, hash: &str, ext: &str) -> BackendResult<PathBuf>; | |
| 283 | + | ||
| 284 | + | /// Look up the file extension for a sample hash. | |
| 285 | + | fn sample_extension(&self, hash: &str) -> BackendResult<String>; | |
| 286 | + | ||
| 287 | + | /// Look up the original filename for a sample hash. | |
| 288 | + | fn sample_original_name(&self, hash: &str) -> BackendResult<String>; | |
| 289 | + | ||
| 290 | + | // --- Export --- | |
| 291 | + | ||
| 292 | + | /// Collect export items from a VFS subtree. | |
| 293 | + | fn collect_export_items( | |
| 294 | + | &self, | |
| 295 | + | vfs_id: VfsId, | |
| 296 | + | parent_id: Option<NodeId>, | |
| 297 | + | ) -> BackendResult<Vec<ExportItem>>; | |
| 298 | + | ||
| 299 | + | /// Populate tags on export items. | |
| 300 | + | fn enrich_export_with_tags(&self, items: &mut [ExportItem]) -> BackendResult<()>; | |
| 301 | + | ||
| 302 | + | // --- Device profiles --- | |
| 303 | + | ||
| 304 | + | /// List available device profiles for device-aware export. | |
| 305 | + | fn list_device_profiles(&self) -> BackendResult<Vec<DeviceProfileSummary>>; | |
| 306 | + | ||
| 307 | + | // --- Config --- | |
| 308 | + | ||
| 309 | + | /// Get a user config value by key. | |
| 310 | + | fn get_config(&self, key: &str) -> BackendResult<Option<String>>; | |
| 311 | + | ||
| 312 | + | /// Set a user config value. | |
| 313 | + | fn set_config(&self, key: &str, value: &str) -> BackendResult<()>; | |
| 314 | + | ||
| 315 | + | /// Set whether a VFS should sync audio file blobs to cloud. | |
| 316 | + | fn set_vfs_sync_files(&self, id: VfsId, enabled: bool) -> BackendResult<()>; | |
| 317 | + | ||
| 318 | + | /// Get whether a VFS has audio file blob syncing enabled. | |
| 319 | + | fn get_vfs_sync_files(&self, id: VfsId) -> BackendResult<bool>; | |
| 320 | + | ||
| 321 | + | // --- Long-running operations --- | |
| 322 | + | ||
| 323 | + | /// Start a folder import in the background. | |
| 324 | + | fn start_import( | |
| 325 | + | &self, | |
| 326 | + | source: &Path, | |
| 327 | + | strategy: ImportStrategyDesc, | |
| 328 | + | ) -> BackendResult<()>; | |
| 329 | + | ||
| 330 | + | /// Start analysis on a batch of samples. | |
| 331 | + | fn start_analysis( | |
| 332 | + | &self, | |
| 333 | + | samples: Vec<(String, String)>, | |
| 334 | + | config: AnalysisConfig, | |
| 335 | + | ) -> BackendResult<()>; | |
| 336 | + | ||
| 337 | + | /// Start an export operation. | |
| 338 | + | fn start_export( | |
| 339 | + | &self, | |
| 340 | + | items: Vec<ExportItemDesc>, | |
| 341 | + | config: ExportConfigDesc, | |
| 342 | + | ) -> BackendResult<()>; | |
| 343 | + | ||
| 344 | + | /// Cancel a running import. | |
| 345 | + | fn cancel_import(&self) -> BackendResult<()>; | |
| 346 | + | ||
| 347 | + | /// Cancel a running analysis. | |
| 348 | + | fn cancel_analysis(&self) -> BackendResult<()>; | |
| 349 | + | ||
| 350 | + | /// Cancel a running export. | |
| 351 | + | fn cancel_export(&self) -> BackendResult<()>; | |
| 352 | + | ||
| 353 | + | /// Non-blocking poll for worker events. | |
| 354 | + | fn poll_events(&self) -> Vec<BackendEvent>; | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | #[cfg(test)] | |
| 358 | + | mod tests { | |
| 359 | + | use super::*; | |
| 360 | + | ||
| 361 | + | #[test] | |
| 362 | + | fn backend_error_from_core() { | |
| 363 | + | let core_err = audiofiles_core::error::CoreError::NodeNotFound(NodeId::from(42)); | |
| 364 | + | let backend_err: BackendError = core_err.into(); | |
| 365 | + | assert!(backend_err.to_string().contains("42")); | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | #[test] | |
| 369 | + | fn backend_error_other() { | |
| 370 | + | let err = BackendError::Other("test error".to_string()); | |
| 371 | + | assert_eq!(err.to_string(), "test error"); | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | #[test] | |
| 375 | + | fn imported_folder_desc_serializes() { | |
| 376 | + | let desc = ImportedFolderDesc { | |
| 377 | + | name: "Drums".to_string(), | |
| 378 | + | samples: vec![("hash1".to_string(), "wav".to_string())], | |
| 379 | + | }; | |
| 380 | + | let json = serde_json::to_string(&desc).unwrap(); | |
| 381 | + | assert!(json.contains("Drums")); | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | #[test] | |
| 385 | + | fn import_strategy_desc_variants() { | |
| 386 | + | let flat = ImportStrategyDesc::Flat { vfs_id: VfsId::from(1), parent_id: None }; | |
| 387 | + | let json = serde_json::to_string(&flat).unwrap(); | |
| 388 | + | assert!(json.contains("Flat")); | |
| 389 | + | ||
| 390 | + | let new_vfs = ImportStrategyDesc::NewVfs { vfs_name: "Test".to_string() }; | |
| 391 | + | let json = serde_json::to_string(&new_vfs).unwrap(); | |
| 392 | + | assert!(json.contains("Test")); | |
| 393 | + | } | |
| 394 | + | } |
| @@ -0,0 +1,278 @@ | |||
| 1 | + | //! Browser GUI: thin dispatcher that routes to UI submodules based on the current workflow state. | |
| 2 | + | ||
| 3 | + | use egui; | |
| 4 | + | ||
| 5 | + | use crate::state::{BrowserState, ImportMode}; | |
| 6 | + | use crate::ui::{detail, export_screens, file_list, filter_panel, footer, import_screens, instrument_panel, overlays, sidebar, theme, toolbar}; | |
| 7 | + | use audiofiles_core::vfs::NodeType; | |
| 8 | + | ||
| 9 | + | /// Top-level draw function called each frame from the update closure. | |
| 10 | + | /// | |
| 11 | + | /// Pass `sync_manager: None` when sync is not configured (e.g. from the CLAP plugin). | |
| 12 | + | pub fn draw_browser( | |
| 13 | + | ctx: &egui::Context, | |
| 14 | + | state: &mut BrowserState, | |
| 15 | + | sync_manager: Option<&audiofiles_sync::SyncManager>, | |
| 16 | + | ) { | |
| 17 | + | theme::apply_theme(ctx); | |
| 18 | + | ||
| 19 | + | match &state.import_mode { | |
| 20 | + | ImportMode::None => { | |
| 21 | + | handle_keyboard(ctx, state); | |
| 22 | + | draw_normal_browser(ctx, state); | |
| 23 | + | } | |
| 24 | + | ImportMode::ConfigureImport { .. } => { | |
| 25 | + | import_screens::draw_configure_import(ctx, state); | |
| 26 | + | } | |
| 27 | + | ImportMode::Importing { .. } | |
| 28 | + | | ImportMode::Analyzing { .. } | |
| 29 | + | | ImportMode::Exporting { .. } => { | |
| 30 | + | if state.poll_workers() { | |
| 31 | + | ctx.request_repaint(); | |
| 32 | + | } | |
| 33 | + | match &state.import_mode { | |
| 34 | + | ImportMode::Importing { .. } => { | |
| 35 | + | import_screens::draw_import_progress(ctx, state); | |
| 36 | + | } | |
| 37 | + | ImportMode::Analyzing { .. } => { | |
| 38 | + | import_screens::draw_analysis_progress(ctx, state); | |
| 39 | + | } | |
| 40 | + | ImportMode::Exporting { .. } => { | |
| 41 | + | export_screens::draw_export_progress(ctx, state); | |
| 42 | + | } | |
| 43 | + | // poll_workers may have transitioned the mode | |
| 44 | + | ImportMode::TagFolders { .. } => { | |
| 45 | + | import_screens::draw_tag_folders(ctx, state); | |
| 46 | + | } | |
| 47 | + | ImportMode::ConfigureAnalysis { .. } => { | |
| 48 | + | import_screens::draw_configure_analysis(ctx, state); | |
| 49 | + | } | |
| 50 | + | ImportMode::ReviewSuggestions { .. } => { | |
| 51 | + | import_screens::draw_review_suggestions(ctx, state); | |
| 52 | + | } | |
| 53 | + | ImportMode::ExportComplete { .. } => { | |
| 54 | + | export_screens::draw_export_complete(ctx, state); | |
| 55 | + | } | |
| 56 | + | _ => {} | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ImportMode::TagFolders { .. } => { | |
| 60 | + | import_screens::draw_tag_folders(ctx, state); | |
| 61 | + | } | |
| 62 | + | ImportMode::ConfigureAnalysis { .. } => { | |
| 63 | + | import_screens::draw_configure_analysis(ctx, state); | |
| 64 | + | } | |
| 65 | + | ImportMode::ReviewSuggestions { .. } => { | |
| 66 | + | import_screens::draw_review_suggestions(ctx, state); | |
| 67 | + | } | |
| 68 | + | ImportMode::ConfigureExport { .. } => { | |
| 69 | + | export_screens::draw_configure_export(ctx, state); | |
| 70 | + | } | |
| 71 | + | ImportMode::ExportComplete { .. } => { | |
| 72 | + | export_screens::draw_export_complete(ctx, state); | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // Overlays drawn on top of any screen | |
| 77 | + | if state.pending_confirm.is_some() { | |
| 78 | + | overlays::draw_confirm_dialog(ctx, state); | |
| 79 | + | } | |
| 80 | + | if state.bulk_modal.is_some() { | |
| 81 | + | overlays::draw_bulk_modal(ctx, state); | |
| 82 | + | } | |
| 83 | + | if state.show_help { | |
| 84 | + | overlays::draw_help_overlay(ctx, state); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Sync panel overlay | |
| 88 | + | if state.show_sync_panel { | |
| 89 | + | if let Some(sync) = sync_manager { | |
| 90 | + | crate::ui::sync_panel::draw_sync_panel(ctx, state, sync); | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// Draw the main browser layout: toolbar, footer, sidebar, detail panel, and file list. | |
| 96 | + | fn draw_normal_browser(ctx: &egui::Context, state: &mut BrowserState) { | |
| 97 | + | // Top toolbar (breadcrumb + search) | |
| 98 | + | egui::TopBottomPanel::top("toolbar") | |
| 99 | + | .exact_height(56.0) | |
| 100 | + | .show(ctx, |ui| { | |
| 101 | + | toolbar::draw_toolbar(ui, state); | |
| 102 | + | }); | |
| 103 | + | ||
| 104 | + | // Bottom footer | |
| 105 | + | egui::TopBottomPanel::bottom("footer").show(ctx, |ui| { | |
| 106 | + | footer::draw_footer(ui, ctx, state); | |
| 107 | + | }); | |
| 108 | + | ||
| 109 | + | // Instrument panel (above footer, below central) | |
| 110 | + | if state.instrument_visible { | |
| 111 | + | egui::TopBottomPanel::bottom("instrument_panel") | |
| 112 | + | .exact_height(120.0) | |
| 113 | + | .show(ctx, |ui| { | |
| 114 | + | instrument_panel::draw_instrument_panel(ui, state); | |
| 115 | + | }); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // Left sidebar (or filter panel) | |
| 119 | + | if state.filter_panel_open { | |
| 120 | + | egui::SidePanel::left("filter_panel") | |
| 121 | + | .default_width(200.0) | |
| 122 | + | .width_range(160.0..=300.0) | |
| 123 | + | .show(ctx, |ui| { | |
| 124 | + | egui::ScrollArea::vertical().show(ui, |ui| { | |
| 125 | + | filter_panel::draw_filter_panel(ui, state); | |
| 126 | + | }); | |
| 127 | + | }); | |
| 128 | + | } else if state.sidebar_visible { | |
| 129 | + | egui::SidePanel::left("sidebar") | |
| 130 | + | .default_width(180.0) | |
| 131 | + | .width_range(120.0..=280.0) | |
| 132 | + | .show(ctx, |ui| { | |
| 133 | + | sidebar::draw_sidebar(ui, state); | |
| 134 | + | }); | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // Right detail panel (auto-hide below 700px). | |
| 138 | + | // 700.0 is the minimum window width at which the detail panel is shown. | |
| 139 | + | // Below this, the file list alone needs the full width to remain usable. | |
| 140 | + | if state.detail_visible { | |
| 141 | + | let available = ctx.screen_rect().width(); | |
| 142 | + | if available >= 700.0 { | |
| 143 | + | egui::SidePanel::right("detail") | |
| 144 | + | .default_width(250.0) | |
| 145 | + | .width_range(200.0..=400.0) | |
| 146 | + | .show(ctx, |ui| { | |
| 147 | + | egui::ScrollArea::vertical().show(ui, |ui| { | |
| 148 | + | detail::draw_detail(ui, state); | |
| 149 | + | }); | |
| 150 | + | }); | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // Central file list | |
| 155 | + | egui::CentralPanel::default().show(ctx, |ui| { | |
| 156 | + | file_list::draw_file_list(ui, state); | |
| 157 | + | }); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /// Process keyboard shortcuts. | |
| 161 | + | fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) { | |
| 162 | + | // Don't handle keyboard shortcuts if a text field has focus | |
| 163 | + | if ctx.memory(|m| m.focused().is_some()) { | |
| 164 | + | // Still handle Escape to clear search | |
| 165 | + | ctx.input(|input| { | |
| 166 | + | if input.key_pressed(egui::Key::Escape) | |
| 167 | + | && !state.search_query.is_empty() | |
| 168 | + | { | |
| 169 | + | state.search_query.clear(); | |
| 170 | + | state.apply_search(); | |
| 171 | + | } | |
| 172 | + | }); | |
| 173 | + | return; | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | ctx.input(|input| { | |
| 177 | + | // Escape: dismiss dialogs in priority order | |
| 178 | + | if input.key_pressed(egui::Key::Escape) { | |
| 179 | + | if state.show_sync_panel { | |
| 180 | + | state.show_sync_panel = false; | |
| 181 | + | } else if state.bulk_modal.is_some() { | |
| 182 | + | state.close_bulk_modal(); | |
| 183 | + | } else if state.pending_confirm.is_some() { | |
| 184 | + | state.dismiss_confirm(); | |
| 185 | + | } else if state.show_help { | |
| 186 | + | state.show_help = false; | |
| 187 | + | } else if !state.search_query.is_empty() { | |
| 188 | + | state.search_query.clear(); | |
| 189 | + | state.apply_search(); | |
| 190 | + | } | |
| 191 | + | return; | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | if input.key_pressed(egui::Key::F1) { | |
| 195 | + | state.show_help = !state.show_help; | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | if input.key_pressed(egui::Key::F2) | |
| 199 | + | && state.selection.count() > 1 | |
| 200 | + | { | |
| 201 | + | state.open_bulk_rename_modal(); | |
| 202 | + | if state.bulk_modal.is_some() { | |
| 203 | + | state.update_rename_previews(); | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | if input.key_pressed(egui::Key::Delete) { | |
| 208 | + | state.confirm_delete_selected(); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // Cmd+A: select all | |
| 212 | + | if input.modifiers.command && input.key_pressed(egui::Key::A) { | |
| 213 | + | let len = state.visible_len(); | |
| 214 | + | state.selection.select_all(len); | |
| 215 | + | return; | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // Cmd+Z: undo | |
| 219 | + | if input.modifiers.command && input.key_pressed(egui::Key::Z) { | |
| 220 | + | state.undo(); | |
| 221 | + | return; | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // Cmd+T: bulk tag | |
| 225 | + | if input.modifiers.command && input.key_pressed(egui::Key::T) { | |
| 226 | + | if state.selection.count() > 1 { | |
| 227 | + | state.open_bulk_tag_modal(); | |
| 228 | + | } | |
| 229 | + | return; | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | let shift = input.modifiers.shift; | |
| 233 | + | ||
| 234 | + | if input.key_pressed(egui::Key::ArrowDown) || input.key_pressed(egui::Key::J) { | |
| 235 | + | if shift { | |
| 236 | + | state.selection.extend_down(state.visible_len()); | |
| 237 | + | } else { | |
| 238 | + | state.select_next(); | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | if input.key_pressed(egui::Key::ArrowUp) || input.key_pressed(egui::Key::K) { | |
| 242 | + | if shift { | |
| 243 | + | state.selection.extend_up(); | |
| 244 | + | } else { | |
| 245 | + | state.select_prev(); | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | if input.key_pressed(egui::Key::Enter) || input.key_pressed(egui::Key::ArrowRight) { | |
| 249 | + | if let Some(node) = state.selected_node() { | |
| 250 | + | match node.node.node_type { | |
| 251 | + | NodeType::Directory => state.enter_directory(), | |
| 252 | + | NodeType::Sample => { | |
| 253 | + | if let Some(hash) = &node.node.sample_hash { | |
| 254 | + | let hash = hash.clone(); | |
| 255 | + | state.trigger_preview(&hash); | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | } else if state.current_dir.is_some() && state.selection.focus == 0 { | |
| 260 | + | state.go_up(); | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | if input.key_pressed(egui::Key::Backspace) || input.key_pressed(egui::Key::ArrowLeft) { | |
| 264 | + | state.go_up(); | |
| 265 | + | } | |
| 266 | + | if input.key_pressed(egui::Key::Space) { | |
| 267 | + | state.toggle_preview(); | |
| 268 | + | } | |
| 269 | + | // "/" focuses the search bar | |
| 270 | + | if input.key_pressed(egui::Key::Slash) { | |
| 271 | + | state.focus_search = true; | |
| 272 | + | } | |
| 273 | + | // "I" toggles instrument panel | |
| 274 | + | if input.key_pressed(egui::Key::I) { | |
| 275 | + | state.toggle_instrument(); | |
| 276 | + | } | |
| 277 | + | }); | |
| 278 | + | } |
| @@ -0,0 +1,87 @@ | |||
| 1 | + | //! Typed errors for the browser crate, replacing `Result<_, String>`. | |
| 2 | + | ||
| 3 | + | use std::path::PathBuf; | |
| 4 | + | use thiserror::Error; | |
| 5 | + | ||
| 6 | + | /// Errors from audio preview decoding. | |
| 7 | + | #[derive(Error, Debug)] | |
| 8 | + | pub enum PreviewError { | |
| 9 | + | #[error("failed to open {path}: {source}")] | |
| 10 | + | Open { | |
| 11 | + | path: PathBuf, | |
| 12 | + | source: std::io::Error, | |
| 13 | + | }, | |
| 14 | + | #[error("failed to probe format: {0}")] | |
| 15 | + | Probe(String), | |
| 16 | + | #[error("no audio track found")] | |
| 17 | + | NoTrack, | |
| 18 | + | #[error("failed to create decoder: {0}")] | |
| 19 | + | Decoder(String), | |
| 20 | + | #[error("packet read error: {0}")] | |
| 21 | + | Packet(String), | |
| 22 | + | #[error("decode error: {0}")] | |
| 23 | + | Decode(String), | |
| 24 | + | #[error("no audio data decoded")] | |
| 25 | + | NoData, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// Errors from theme file loading. | |
| 29 | + | #[derive(Error, Debug)] | |
| 30 | + | pub enum ThemeError { | |
| 31 | + | #[error("failed to read {path}: {source}")] | |
| 32 | + | Read { | |
| 33 | + | path: PathBuf, | |
| 34 | + | source: std::io::Error, | |
| 35 | + | }, | |
| 36 | + | #[error("failed to parse {path}: {source}")] | |
| 37 | + | Parse { | |
| 38 | + | path: PathBuf, | |
| 39 | + | source: toml::de::Error, | |
| 40 | + | }, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | #[cfg(test)] | |
| 44 | + | mod tests { | |
| 45 | + | use super::*; | |
| 46 | + | ||
| 47 | + | #[test] | |
| 48 | + | fn preview_error_display() { | |
| 49 | + | let err = PreviewError::NoTrack; | |
| 50 | + | assert_eq!(err.to_string(), "no audio track found"); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | #[test] | |
| 54 | + | fn preview_error_open_includes_path() { | |
| 55 | + | let err = PreviewError::Open { | |
| 56 | + | path: PathBuf::from("/tmp/test.wav"), | |
| 57 | + | source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"), | |
| 58 | + | }; | |
| 59 | + | let msg = err.to_string(); | |
| 60 | + | assert!(msg.contains("/tmp/test.wav")); | |
| 61 | + | assert!(msg.contains("not found")); | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | #[test] | |
| 65 | + | fn theme_error_display() { | |
| 66 | + | let err = ThemeError::Read { | |
| 67 | + | path: PathBuf::from("theme.toml"), | |
| 68 | + | source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"), | |
| 69 | + | }; | |
| 70 | + | assert!(err.to_string().contains("theme.toml")); | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | #[test] | |
| 74 | + | fn preview_error_variants_exhaustive() { | |
| 75 | + | // Verify all variants construct without panic | |
| 76 | + | let _ = PreviewError::Open { | |
| 77 | + | path: PathBuf::new(), | |
| 78 | + | source: std::io::Error::other(""), | |
| 79 | + | }; | |
| 80 | + | let _ = PreviewError::Probe("test".into()); | |
| 81 | + | let _ = PreviewError::NoTrack; | |
| 82 | + | let _ = PreviewError::Decoder("test".into()); | |
| 83 | + | let _ = PreviewError::Packet("test".into()); | |
| 84 | + | let _ = PreviewError::Decode("test".into()); | |
| 85 | + | let _ = PreviewError::NoData; | |
| 86 | + | } | |
| 87 | + | } |
| @@ -0,0 +1,206 @@ | |||
| 1 | + | //! Background export worker: writes VFS samples to the filesystem off the GUI thread. | |
| 2 | + | //! | |
| 3 | + | //! Mirrors the pattern in `import.rs` — dedicated thread with its own SampleStore, | |
| 4 | + | //! communicating via channels. The GUI thread polls events each frame. | |
| 5 | + | ||
| 6 | + | use std::path::PathBuf; | |
| 7 | + | use std::sync::{mpsc, Mutex}; | |
| 8 | + | use std::thread; | |
| 9 | + | ||
| 10 | + | use audiofiles_core::export::{ExportConfig, ExportItem, ExportSummary}; | |
| 11 | + | use audiofiles_core::store::SampleStore; | |
| 12 | + | ||
| 13 | + | /// Command sent from the GUI thread to the export worker. | |
| 14 | + | pub enum ExportCommand { | |
| 15 | + | /// Export the given items with the provided configuration. | |
| 16 | + | Export { | |
| 17 | + | items: Vec<ExportItem>, | |
| 18 | + | config: ExportConfig, | |
| 19 | + | }, | |
| 20 | + | /// Cancel the current export. | |
| 21 | + | Cancel, | |
| 22 | + | /// Shut down the worker thread. | |
| 23 | + | Shutdown, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// Event sent from the export worker back to the GUI thread. | |
| 27 | + | pub enum ExportEvent { | |
| 28 | + | /// Progress update for one file processed. | |
| 29 | + | Progress { | |
| 30 | + | completed: usize, | |
| 31 | + | total: usize, | |
| 32 | + | current_name: String, | |
| 33 | + | }, | |
| 34 | + | /// The export is complete. | |
| 35 | + | Complete { | |
| 36 | + | total: usize, | |
| 37 | + | errors: Vec<(String, String)>, | |
| 38 | + | }, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// Handle for communicating with the background export worker. | |
| 42 | + | /// | |
| 43 | + | /// The receiver is wrapped in a `Mutex` so `BrowserState` remains `Sync` (required by nih-plug). | |
| 44 | + | /// Only the GUI thread actually calls `try_recv`, so contention is zero. | |
| 45 | + | pub struct ExportHandle { | |
| 46 | + | cmd_tx: mpsc::Sender<ExportCommand>, | |
| 47 | + | event_rx: Mutex<mpsc::Receiver<ExportEvent>>, | |
| 48 | + | _thread: Option<thread::JoinHandle<()>>, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | impl ExportHandle { | |
| 52 | + | /// Poll for the next event without blocking. | |
| 53 | + | pub fn try_recv(&self) -> Option<ExportEvent> { | |
| 54 | + | self.event_rx.lock().ok()?.try_recv().ok() | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// Send a command to the worker. | |
| 58 | + | pub fn send(&self, cmd: ExportCommand) { | |
| 59 | + | let _ = self.cmd_tx.send(cmd); | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | impl Drop for ExportHandle { | |
| 64 | + | fn drop(&mut self) { | |
| 65 | + | let _ = self.cmd_tx.send(ExportCommand::Shutdown); | |
| 66 | + | if let Some(handle) = self._thread.take() { | |
| 67 | + | let _ = handle.join(); | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// Spawn the background export worker thread. | |
| 73 | + | /// | |
| 74 | + | /// The worker opens its own `SampleStore` to avoid Mutex contention with the GUI thread. | |
| 75 | + | pub fn spawn_export_worker(store_root: PathBuf) -> ExportHandle { | |
| 76 | + | let (cmd_tx, cmd_rx) = mpsc::channel::<ExportCommand>(); | |
| 77 | + | let (event_tx, event_rx) = mpsc::channel::<ExportEvent>(); | |
| 78 | + | ||
| 79 | + | let thread = thread::Builder::new() | |
| 80 | + | .name("export-worker".to_string()) | |
| 81 | + | .spawn(move || { | |
| 82 | + | worker_loop(cmd_rx, event_tx, &store_root); | |
| 83 | + | }) | |
| 84 | + | .expect("failed to spawn export worker thread"); | |
| 85 | + | ||
| 86 | + | ExportHandle { | |
| 87 | + | cmd_tx, | |
| 88 | + | event_rx: Mutex::new(event_rx), | |
| 89 | + | _thread: Some(thread), | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | fn worker_loop( | |
| 94 | + | cmd_rx: mpsc::Receiver<ExportCommand>, | |
| 95 | + | event_tx: mpsc::Sender<ExportEvent>, | |
| 96 | + | store_root: &std::path::Path, | |
| 97 | + | ) { | |
| 98 | + | let store = match SampleStore::new(store_root) { | |
| 99 | + | Ok(s) => s, | |
| 100 | + | Err(e) => { | |
| 101 | + | let _ = event_tx.send(ExportEvent::Complete { | |
| 102 | + | total: 0, | |
| 103 | + | errors: vec![("init".to_string(), e.to_string())], | |
| 104 | + | }); | |
| 105 | + | eprintln!("Export worker failed to open store: {e}"); | |
| 106 | + | return; | |
| 107 | + | } | |
| 108 | + | }; | |
| 109 | + | ||
| 110 | + | while let Ok(cmd) = cmd_rx.recv() { | |
| 111 | + | match cmd { | |
| 112 | + | ExportCommand::Shutdown => break, | |
| 113 | + | ExportCommand::Cancel => continue, | |
| 114 | + | ExportCommand::Export { items, config } => { | |
| 115 | + | let cancelled = std::sync::atomic::AtomicBool::new(false); | |
| 116 | + | ||
| 117 | + | let summary = audiofiles_core::export::run_export( | |
| 118 | + | &items, | |
| 119 | + | &config, | |
| 120 | + | &store, | |
| 121 | + | |completed, total, current_name| { | |
| 122 | + | // Check for cancel between files | |
| 123 | + | if let Ok(ExportCommand::Cancel) | Ok(ExportCommand::Shutdown) = | |
| 124 | + | cmd_rx.try_recv() | |
| 125 | + | { | |
| 126 | + | cancelled.store(true, std::sync::atomic::Ordering::Relaxed); | |
| 127 | + | return false; | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | let _ = event_tx.send(ExportEvent::Progress { | |
| 131 | + | completed, | |
| 132 | + | total, | |
| 133 | + | current_name: current_name.to_string(), | |
| 134 | + | }); | |
| 135 | + | ||
| 136 | + | true | |
| 137 | + | }, | |
| 138 | + | ); | |
| 139 | + | ||
| 140 | + | match summary { | |
| 141 | + | Ok(ExportSummary { total, errors }) => { | |
| 142 | + | let _ = event_tx.send(ExportEvent::Complete { total, errors }); | |
| 143 | + | } | |
| 144 | + | Err(e) => { | |
| 145 | + | let _ = event_tx.send(ExportEvent::Complete { | |
| 146 | + | total: 0, | |
| 147 | + | errors: vec![("export".to_string(), e.to_string())], | |
| 148 | + | }); | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | #[cfg(test)] | |
| 157 | + | mod tests { | |
| 158 | + | use super::*; | |
| 159 | + | ||
| 160 | + | #[test] | |
| 161 | + | fn export_command_variants_constructible() { | |
| 162 | + | let _export = ExportCommand::Export { | |
| 163 | + | items: vec![], | |
| 164 | + | config: ExportConfig { | |
| 165 | + | format: audiofiles_core::export::ExportFormat::Original, | |
| 166 | + | sample_rate: None, | |
| 167 | + | bit_depth: None, | |
| 168 | + | channels: audiofiles_core::export::ExportChannels::Original, | |
| 169 | + | naming_pattern: None, | |
| 170 | + | flatten: false, | |
| 171 | + | metadata_sidecar: false, | |
| 172 | + | destination: PathBuf::from("/tmp/export"), | |
| 173 | + | device_profile: None, | |
| 174 | + | naming_rules: None, | |
| 175 | + | max_file_size_bytes: None, | |
| 176 | + | name_overrides: None, | |
| 177 | + | }, | |
| 178 | + | }; | |
| 179 | + | let _cancel = ExportCommand::Cancel; | |
| 180 | + | let _shutdown = ExportCommand::Shutdown; | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | #[test] | |
| 184 | + | fn export_event_variants_constructible() { | |
| 185 | + | let _progress = ExportEvent::Progress { | |
| 186 | + | completed: 5, | |
| 187 | + | total: 10, | |
| 188 | + | current_name: "kick.wav".to_string(), | |
| 189 | + | }; | |
| 190 | + | let _complete = ExportEvent::Complete { | |
| 191 | + | total: 10, | |
| 192 | + | errors: vec![], | |
| 193 | + | }; | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | #[test] | |
| 197 | + | fn spawn_and_drop_does_not_hang() { | |
| 198 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 199 | + | let store_root = dir.path().join("store"); | |
| 200 | + | std::fs::create_dir_all(&store_root).unwrap(); | |
| 201 | + | ||
| 202 | + | let handle = spawn_export_worker(store_root); | |
| 203 | + | assert!(handle.try_recv().is_none()); | |
| 204 | + | drop(handle); // Should send Shutdown and join cleanly | |
| 205 | + | } | |
| 206 | + | } |