Skip to main content

max / audiofiles

Initial commit
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-05 18:51 UTC
Commit: bb710d396f12e2e55000088d7788b93b399d1128
130 files changed, +26944 insertions, -0 deletions
A .build.yml +31
@@ -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 --"
A .gitignore +17
@@ -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
A Cargo.lock +500
@@ -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
A Cargo.toml +44
@@ -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"
A LICENSE +118
@@ -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 + }
A docs/about.md +107
A docs/todo.md +191