Skip to main content

max / audiofiles

6.4 KB · 210 lines History Blame Raw
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 tracing::{error, instrument};
11
12 use audiofiles_core::export::{ExportConfig, ExportItem, ExportSummary};
13 use audiofiles_core::store::SampleStore;
14
15 /// Command sent from the GUI thread to the export worker.
16 pub enum ExportCommand {
17 /// Export the given items with the provided configuration.
18 Export {
19 items: Vec<ExportItem>,
20 config: ExportConfig,
21 },
22 /// Cancel the current export.
23 Cancel,
24 /// Shut down the worker thread.
25 Shutdown,
26 }
27
28 /// Event sent from the export worker back to the GUI thread.
29 pub enum ExportEvent {
30 /// Progress update for one file processed.
31 Progress {
32 completed: usize,
33 total: usize,
34 current_name: String,
35 },
36 /// The export is complete.
37 Complete {
38 total: usize,
39 errors: Vec<(String, String)>,
40 },
41 }
42
43 /// Handle for communicating with the background export worker.
44 ///
45 /// The receiver is wrapped in a `Mutex` so `BrowserState` remains `Sync` (required by nih-plug).
46 /// Only the GUI thread actually calls `try_recv`, so contention is zero.
47 pub struct ExportHandle {
48 cmd_tx: mpsc::Sender<ExportCommand>,
49 event_rx: Mutex<mpsc::Receiver<ExportEvent>>,
50 _thread: Option<thread::JoinHandle<()>>,
51 }
52
53 impl ExportHandle {
54 /// Poll for the next event without blocking.
55 pub fn try_recv(&self) -> Option<ExportEvent> {
56 self.event_rx.lock().ok()?.try_recv().ok()
57 }
58
59 /// Send a command to the worker.
60 pub fn send(&self, cmd: ExportCommand) {
61 let _ = self.cmd_tx.send(cmd);
62 }
63 }
64
65 impl Drop for ExportHandle {
66 fn drop(&mut self) {
67 let _ = self.cmd_tx.send(ExportCommand::Shutdown);
68 if let Some(handle) = self._thread.take() {
69 let _ = handle.join();
70 }
71 }
72 }
73
74 /// Spawn the background export worker thread.
75 ///
76 /// The worker opens its own `SampleStore` to avoid Mutex contention with the GUI thread.
77 #[instrument(skip_all)]
78 pub fn spawn_export_worker(store_root: PathBuf) -> std::io::Result<ExportHandle> {
79 let (cmd_tx, cmd_rx) = mpsc::channel::<ExportCommand>();
80 let (event_tx, event_rx) = mpsc::channel::<ExportEvent>();
81
82 let thread = thread::Builder::new()
83 .name("export-worker".to_string())
84 .spawn(move || {
85 worker_loop(cmd_rx, event_tx, &store_root);
86 })?;
87
88 Ok(ExportHandle {
89 cmd_tx,
90 event_rx: Mutex::new(event_rx),
91 _thread: Some(thread),
92 })
93 }
94
95 #[instrument(skip_all)]
96 fn worker_loop(
97 cmd_rx: mpsc::Receiver<ExportCommand>,
98 event_tx: mpsc::Sender<ExportEvent>,
99 store_root: &std::path::Path,
100 ) {
101 let store = match SampleStore::new(store_root) {
102 Ok(s) => s,
103 Err(e) => {
104 let _ = event_tx.send(ExportEvent::Complete {
105 total: 0,
106 errors: vec![("init".to_string(), e.to_string())],
107 });
108 error!("Export worker failed to open store: {e}");
109 return;
110 }
111 };
112
113 while let Ok(cmd) = cmd_rx.recv() {
114 match cmd {
115 ExportCommand::Shutdown => break,
116 ExportCommand::Cancel => continue,
117 ExportCommand::Export { items, config } => {
118 let cancelled = std::sync::atomic::AtomicBool::new(false);
119
120 let summary = audiofiles_core::export::run_export(
121 &items,
122 &config,
123 &store,
124 |completed, total, current_name| {
125 // Check for cancel between files
126 if let Ok(ExportCommand::Cancel) | Ok(ExportCommand::Shutdown) =
127 cmd_rx.try_recv()
128 {
129 cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
130 return false;
131 }
132
133 let _ = event_tx.send(ExportEvent::Progress {
134 completed,
135 total,
136 current_name: current_name.to_string(),
137 });
138
139 true
140 },
141 );
142
143 match summary {
144 Ok(ExportSummary { total, errors }) => {
145 let _ = event_tx.send(ExportEvent::Complete { total, errors });
146 }
147 Err(e) => {
148 let _ = event_tx.send(ExportEvent::Complete {
149 total: 0,
150 errors: vec![("export".to_string(), e.to_string())],
151 });
152 }
153 }
154 }
155 }
156 }
157 }
158
159 #[cfg(test)]
160 mod tests {
161 use super::*;
162
163 #[test]
164 fn export_command_variants_constructible() {
165 let _export = ExportCommand::Export {
166 items: vec![],
167 config: ExportConfig {
168 format: audiofiles_core::export::ExportFormat::Original,
169 sample_rate: None,
170 bit_depth: None,
171 channels: audiofiles_core::export::ExportChannels::Original,
172 naming_pattern: None,
173 flatten: false,
174 metadata_sidecar: false,
175 destination: PathBuf::from("/tmp/export"),
176 device_profile: None,
177 naming_rules: None,
178 max_file_size_bytes: None,
179 name_overrides: None,
180 },
181 };
182 let _cancel = ExportCommand::Cancel;
183 let _shutdown = ExportCommand::Shutdown;
184 }
185
186 #[test]
187 fn export_event_variants_constructible() {
188 let _progress = ExportEvent::Progress {
189 completed: 5,
190 total: 10,
191 current_name: "kick.wav".to_string(),
192 };
193 let _complete = ExportEvent::Complete {
194 total: 10,
195 errors: vec![],
196 };
197 }
198
199 #[test]
200 fn spawn_and_drop_does_not_hang() {
201 let dir = tempfile::TempDir::new().unwrap();
202 let store_root = dir.path().join("store");
203 std::fs::create_dir_all(&store_root).unwrap();
204
205 let handle = spawn_export_worker(store_root).unwrap();
206 assert!(handle.try_recv().is_none());
207 drop(handle); // Should send Shutdown and join cleanly
208 }
209 }
210