Skip to main content

max / audiofiles

9.6 KB · 318 lines History Blame Raw
1 //! Background edit worker thread: processes single edit operations off the GUI thread.
2 //!
3 //! Follows the same pattern as `analysis::worker`: a dedicated thread receiving
4 //! commands over a channel and emitting events back.
5
6 use std::path::{Path, PathBuf};
7 use std::sync::atomic::{AtomicBool, Ordering};
8 use std::sync::{mpsc, Arc, Mutex};
9 use std::thread;
10
11 use tracing::instrument;
12
13 use super::{apply_edit, EditOperation};
14 use crate::export::convert::ConvertedAudio;
15 use crate::export::decode::decode_multichannel;
16 use crate::export::encode::encode_wav;
17
18 /// Command sent from the GUI thread to the edit worker.
19 pub enum EditCommand {
20 /// Apply an edit operation to a sample.
21 Edit {
22 hash: String,
23 ext: String,
24 path: PathBuf,
25 operation: EditOperation,
26 },
27 /// Cancel the current operation.
28 Cancel,
29 /// Shut down the worker thread.
30 Shutdown,
31 }
32
33 /// Event sent from the edit worker back to the GUI thread.
34 pub enum EditEvent {
35 /// Edit operation started.
36 Started { hash: String },
37 /// Edit operation completed successfully.
38 Complete {
39 source_hash: String,
40 result_path: PathBuf,
41 operation: EditOperation,
42 },
43 /// Edit operation failed.
44 Error { hash: String, error: String },
45 }
46
47 /// Handle for communicating with the background edit worker.
48 pub struct EditWorkerHandle {
49 cmd_tx: mpsc::Sender<EditCommand>,
50 event_rx: Mutex<mpsc::Receiver<EditEvent>>,
51 cancel_flag: Arc<AtomicBool>,
52 thread: Option<thread::JoinHandle<()>>,
53 }
54
55 impl EditWorkerHandle {
56 /// Poll for the next event without blocking.
57 pub fn try_recv(&self) -> Option<EditEvent> {
58 self.event_rx.lock().ok()?.try_recv().ok()
59 }
60
61 /// Send a command to the worker.
62 pub fn send(&self, cmd: EditCommand) {
63 if matches!(cmd, EditCommand::Cancel) {
64 self.cancel_flag.store(true, Ordering::Release);
65 }
66 let _ = self.cmd_tx.send(cmd);
67 }
68 }
69
70 impl Drop for EditWorkerHandle {
71 fn drop(&mut self) {
72 self.cancel_flag.store(true, Ordering::Release);
73 let _ = self.cmd_tx.send(EditCommand::Shutdown);
74 if let Some(handle) = self.thread.take() {
75 let _ = handle.join();
76 }
77 }
78 }
79
80 /// Spawn the background edit worker thread.
81 #[instrument(skip_all)]
82 pub fn spawn_edit_worker() -> std::io::Result<EditWorkerHandle> {
83 let (cmd_tx, cmd_rx) = mpsc::channel::<EditCommand>();
84 let (event_tx, event_rx) = mpsc::channel::<EditEvent>();
85 let cancel_flag = Arc::new(AtomicBool::new(false));
86 let cancel_clone = Arc::clone(&cancel_flag);
87
88 let thread = thread::Builder::new()
89 .name("edit-worker".to_string())
90 .spawn(move || {
91 edit_worker_loop(cmd_rx, event_tx, cancel_clone);
92 })?;
93
94 Ok(EditWorkerHandle {
95 cmd_tx,
96 event_rx: Mutex::new(event_rx),
97 cancel_flag,
98 thread: Some(thread),
99 })
100 }
101
102 /// Clean up leftover temp files from previous edit sessions.
103 fn cleanup_edit_temp_dir() {
104 let temp_dir = std::env::temp_dir().join("audiofiles_edit");
105 if let Ok(entries) = std::fs::read_dir(&temp_dir) {
106 for entry in entries.flatten() {
107 let path = entry.path();
108 if path.extension().and_then(|e| e.to_str()) == Some("wav") {
109 let _ = std::fs::remove_file(&path);
110 }
111 }
112 }
113 }
114
115 fn edit_worker_loop(
116 cmd_rx: mpsc::Receiver<EditCommand>,
117 event_tx: mpsc::Sender<EditEvent>,
118 cancel_flag: Arc<AtomicBool>,
119 ) {
120 // Clean up stale temp files from prior sessions
121 cleanup_edit_temp_dir();
122
123 while let Ok(cmd) = cmd_rx.recv() {
124 match cmd {
125 EditCommand::Shutdown => break,
126 EditCommand::Cancel => continue,
127 EditCommand::Edit {
128 hash,
129 ext: _,
130 path,
131 operation,
132 } => {
133 cancel_flag.store(false, Ordering::Release);
134
135 let _ = event_tx.send(EditEvent::Started {
136 hash: hash.clone(),
137 });
138
139 if cancel_flag.load(Ordering::Acquire) {
140 continue;
141 }
142
143 match process_edit(&path, &operation, &cancel_flag) {
144 Ok(result_path) => {
145 let _ = event_tx.send(EditEvent::Complete {
146 source_hash: hash,
147 result_path,
148 operation,
149 });
150 }
151 Err(e) => {
152 let _ = event_tx.send(EditEvent::Error {
153 hash,
154 error: e.to_string(),
155 });
156 }
157 }
158 }
159 }
160 }
161 }
162
163 /// Decode → edit → encode to a temporary WAV file.
164 fn process_edit(
165 source_path: &Path,
166 operation: &EditOperation,
167 cancel_flag: &AtomicBool,
168 ) -> Result<PathBuf, crate::error::CoreError> {
169 // 1. Decode
170 let mut decoded = decode_multichannel(source_path)?;
171
172 // Check cancel between decode and edit
173 if cancel_flag.load(Ordering::Acquire) {
174 return Err(crate::error::CoreError::Internal("edit cancelled".to_string()));
175 }
176
177 // 2. Apply edit (may change channel count)
178 decoded.channels = apply_edit(
179 &mut decoded.samples,
180 decoded.channels,
181 decoded.sample_rate,
182 operation,
183 )?;
184
185 // Check cancel between edit and encode
186 if cancel_flag.load(Ordering::Acquire) {
187 return Err(crate::error::CoreError::Internal("edit cancelled".to_string()));
188 }
189
190 // 3. Write to temp WAV (24-bit to preserve quality)
191 let temp_dir = std::env::temp_dir().join("audiofiles_edit");
192 std::fs::create_dir_all(&temp_dir).map_err(|e| crate::error::io_err(&temp_dir, e))?;
193
194 let temp_name = format!("edit_{}.wav", uuid_simple());
195 let temp_path = temp_dir.join(temp_name);
196
197 let converted = ConvertedAudio {
198 samples: decoded.samples,
199 sample_rate: decoded.sample_rate,
200 channels: decoded.channels,
201 };
202 encode_wav(&converted, 24, &temp_path)?;
203
204 Ok(temp_path)
205 }
206
207 /// Generate a simple unique-ish ID for temp files (no uuid crate dependency).
208 fn uuid_simple() -> String {
209 use std::time::{SystemTime, UNIX_EPOCH};
210 let nanos = SystemTime::now()
211 .duration_since(UNIX_EPOCH)
212 .unwrap_or_default()
213 .as_nanos();
214 format!("{nanos:x}")
215 }
216
217 #[cfg(test)]
218 mod tests {
219 use super::*;
220
221 #[test]
222 fn edit_command_variants() {
223 let _cancel = EditCommand::Cancel;
224 let _shutdown = EditCommand::Shutdown;
225 let _edit = EditCommand::Edit {
226 hash: "abc".to_string(),
227 ext: "wav".to_string(),
228 path: PathBuf::from("/test.wav"),
229 operation: EditOperation::Reverse,
230 };
231 }
232
233 #[test]
234 fn edit_event_variants() {
235 let _started = EditEvent::Started {
236 hash: "abc".to_string(),
237 };
238 let _error = EditEvent::Error {
239 hash: "abc".to_string(),
240 error: "failed".to_string(),
241 };
242 let _complete = EditEvent::Complete {
243 source_hash: "abc".to_string(),
244 result_path: PathBuf::from("/tmp/result.wav"),
245 operation: EditOperation::Reverse,
246 };
247 }
248
249 #[test]
250 fn spawn_and_drop() {
251 let handle = spawn_edit_worker().unwrap();
252 assert!(handle.try_recv().is_none());
253 drop(handle);
254 }
255
256 #[test]
257 fn cancel_flag_set_on_cancel() {
258 let handle = spawn_edit_worker().unwrap();
259 handle.send(EditCommand::Cancel);
260 std::thread::sleep(std::time::Duration::from_millis(10));
261 assert!(handle.cancel_flag.load(Ordering::Relaxed));
262 drop(handle);
263 }
264
265 #[test]
266 fn process_edit_on_real_wav() {
267 use std::io::Write;
268
269 let dir = tempfile::tempdir().unwrap();
270 let wav_path = dir.path().join("test.wav");
271
272 // Write a minimal mono WAV
273 let samples: Vec<f32> = vec![0.5, -0.5, 0.25, -0.25, 0.1, -0.1, 0.0, 0.0];
274 let channels = 1u16;
275 let sample_rate = 44100u32;
276 let bytes_per_sample = 4u16;
277 let block_align = channels * bytes_per_sample;
278 let data_size = (samples.len() as u32) * 4;
279 let file_size = 36 + data_size;
280
281 let mut buf = Vec::new();
282 buf.extend_from_slice(b"RIFF");
283 buf.extend_from_slice(&file_size.to_le_bytes());
284 buf.extend_from_slice(b"WAVE");
285 buf.extend_from_slice(b"fmt ");
286 buf.extend_from_slice(&16u32.to_le_bytes());
287 buf.extend_from_slice(&3u16.to_le_bytes()); // IEEE float
288 buf.extend_from_slice(&channels.to_le_bytes());
289 buf.extend_from_slice(&sample_rate.to_le_bytes());
290 buf.extend_from_slice(&(sample_rate * block_align as u32).to_le_bytes());
291 buf.extend_from_slice(&block_align.to_le_bytes());
292 buf.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes());
293 buf.extend_from_slice(b"data");
294 buf.extend_from_slice(&data_size.to_le_bytes());
295 for &s in &samples {
296 buf.extend_from_slice(&s.to_le_bytes());
297 }
298 std::fs::File::create(&wav_path)
299 .unwrap()
300 .write_all(&buf)
301 .unwrap();
302
303 // Test reverse edit
304 let flag = AtomicBool::new(false);
305 let result = process_edit(&wav_path, &EditOperation::Reverse, &flag).unwrap();
306 assert!(result.exists());
307
308 // Read back and verify
309 let decoded = decode_multichannel(&result).unwrap();
310 assert_eq!(decoded.channels, 1);
311 // Reversed 8 samples: last should be first
312 assert!(decoded.samples.len() >= 8);
313
314 // Clean up
315 let _ = std::fs::remove_file(&result);
316 }
317 }
318