//! Background edit worker thread: processes single edit operations off the GUI thread. //! //! Follows the same pattern as `analysis::worker`: a dedicated thread receiving //! commands over a channel and emitting events back. use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{mpsc, Arc, Mutex}; use std::thread; use tracing::instrument; use super::{apply_edit, EditOperation}; use crate::export::convert::ConvertedAudio; use crate::export::decode::decode_multichannel; use crate::export::encode::encode_wav; /// Command sent from the GUI thread to the edit worker. pub enum EditCommand { /// Apply an edit operation to a sample. Edit { hash: String, ext: String, path: PathBuf, operation: EditOperation, }, /// Cancel the current operation. Cancel, /// Shut down the worker thread. Shutdown, } /// Event sent from the edit worker back to the GUI thread. pub enum EditEvent { /// Edit operation started. Started { hash: String }, /// Edit operation completed successfully. Complete { source_hash: String, result_path: PathBuf, operation: EditOperation, }, /// Edit operation failed. Error { hash: String, error: String }, } /// Handle for communicating with the background edit worker. pub struct EditWorkerHandle { cmd_tx: mpsc::Sender, event_rx: Mutex>, cancel_flag: Arc, thread: Option>, } impl EditWorkerHandle { /// Poll for the next event without blocking. pub fn try_recv(&self) -> Option { self.event_rx.lock().ok()?.try_recv().ok() } /// Send a command to the worker. pub fn send(&self, cmd: EditCommand) { if matches!(cmd, EditCommand::Cancel) { self.cancel_flag.store(true, Ordering::Release); } let _ = self.cmd_tx.send(cmd); } } impl Drop for EditWorkerHandle { fn drop(&mut self) { self.cancel_flag.store(true, Ordering::Release); let _ = self.cmd_tx.send(EditCommand::Shutdown); if let Some(handle) = self.thread.take() { let _ = handle.join(); } } } /// Spawn the background edit worker thread. #[instrument(skip_all)] pub fn spawn_edit_worker() -> std::io::Result { let (cmd_tx, cmd_rx) = mpsc::channel::(); let (event_tx, event_rx) = mpsc::channel::(); let cancel_flag = Arc::new(AtomicBool::new(false)); let cancel_clone = Arc::clone(&cancel_flag); let thread = thread::Builder::new() .name("edit-worker".to_string()) .spawn(move || { edit_worker_loop(cmd_rx, event_tx, cancel_clone); })?; Ok(EditWorkerHandle { cmd_tx, event_rx: Mutex::new(event_rx), cancel_flag, thread: Some(thread), }) } /// Clean up leftover temp files from previous edit sessions. fn cleanup_edit_temp_dir() { let temp_dir = std::env::temp_dir().join("audiofiles_edit"); if let Ok(entries) = std::fs::read_dir(&temp_dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("wav") { let _ = std::fs::remove_file(&path); } } } } fn edit_worker_loop( cmd_rx: mpsc::Receiver, event_tx: mpsc::Sender, cancel_flag: Arc, ) { // Clean up stale temp files from prior sessions cleanup_edit_temp_dir(); while let Ok(cmd) = cmd_rx.recv() { match cmd { EditCommand::Shutdown => break, EditCommand::Cancel => continue, EditCommand::Edit { hash, ext: _, path, operation, } => { cancel_flag.store(false, Ordering::Release); let _ = event_tx.send(EditEvent::Started { hash: hash.clone(), }); if cancel_flag.load(Ordering::Acquire) { continue; } match process_edit(&path, &operation, &cancel_flag) { Ok(result_path) => { let _ = event_tx.send(EditEvent::Complete { source_hash: hash, result_path, operation, }); } Err(e) => { let _ = event_tx.send(EditEvent::Error { hash, error: e.to_string(), }); } } } } } } /// Decode → edit → encode to a temporary WAV file. fn process_edit( source_path: &Path, operation: &EditOperation, cancel_flag: &AtomicBool, ) -> Result { // 1. Decode let mut decoded = decode_multichannel(source_path)?; // Check cancel between decode and edit if cancel_flag.load(Ordering::Acquire) { return Err(crate::error::CoreError::Internal("edit cancelled".to_string())); } // 2. Apply edit (may change channel count) decoded.channels = apply_edit( &mut decoded.samples, decoded.channels, decoded.sample_rate, operation, )?; // Check cancel between edit and encode if cancel_flag.load(Ordering::Acquire) { return Err(crate::error::CoreError::Internal("edit cancelled".to_string())); } // 3. Write to temp WAV (24-bit to preserve quality) let temp_dir = std::env::temp_dir().join("audiofiles_edit"); std::fs::create_dir_all(&temp_dir).map_err(|e| crate::error::io_err(&temp_dir, e))?; let temp_name = format!("edit_{}.wav", uuid_simple()); let temp_path = temp_dir.join(temp_name); let converted = ConvertedAudio { samples: decoded.samples, sample_rate: decoded.sample_rate, channels: decoded.channels, }; encode_wav(&converted, 24, &temp_path)?; Ok(temp_path) } /// Generate a simple unique-ish ID for temp files (no uuid crate dependency). fn uuid_simple() -> String { use std::time::{SystemTime, UNIX_EPOCH}; let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); format!("{nanos:x}") } #[cfg(test)] mod tests { use super::*; #[test] fn edit_command_variants() { let _cancel = EditCommand::Cancel; let _shutdown = EditCommand::Shutdown; let _edit = EditCommand::Edit { hash: "abc".to_string(), ext: "wav".to_string(), path: PathBuf::from("/test.wav"), operation: EditOperation::Reverse, }; } #[test] fn edit_event_variants() { let _started = EditEvent::Started { hash: "abc".to_string(), }; let _error = EditEvent::Error { hash: "abc".to_string(), error: "failed".to_string(), }; let _complete = EditEvent::Complete { source_hash: "abc".to_string(), result_path: PathBuf::from("/tmp/result.wav"), operation: EditOperation::Reverse, }; } #[test] fn spawn_and_drop() { let handle = spawn_edit_worker().unwrap(); assert!(handle.try_recv().is_none()); drop(handle); } #[test] fn cancel_flag_set_on_cancel() { let handle = spawn_edit_worker().unwrap(); handle.send(EditCommand::Cancel); std::thread::sleep(std::time::Duration::from_millis(10)); assert!(handle.cancel_flag.load(Ordering::Relaxed)); drop(handle); } #[test] fn process_edit_on_real_wav() { use std::io::Write; let dir = tempfile::tempdir().unwrap(); let wav_path = dir.path().join("test.wav"); // Write a minimal mono WAV let samples: Vec = vec![0.5, -0.5, 0.25, -0.25, 0.1, -0.1, 0.0, 0.0]; let channels = 1u16; let sample_rate = 44100u32; let bytes_per_sample = 4u16; let block_align = channels * bytes_per_sample; let data_size = (samples.len() as u32) * 4; let file_size = 36 + data_size; let mut buf = Vec::new(); buf.extend_from_slice(b"RIFF"); buf.extend_from_slice(&file_size.to_le_bytes()); buf.extend_from_slice(b"WAVE"); buf.extend_from_slice(b"fmt "); buf.extend_from_slice(&16u32.to_le_bytes()); buf.extend_from_slice(&3u16.to_le_bytes()); // IEEE float buf.extend_from_slice(&channels.to_le_bytes()); buf.extend_from_slice(&sample_rate.to_le_bytes()); buf.extend_from_slice(&(sample_rate * block_align as u32).to_le_bytes()); buf.extend_from_slice(&block_align.to_le_bytes()); buf.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes()); buf.extend_from_slice(b"data"); buf.extend_from_slice(&data_size.to_le_bytes()); for &s in &samples { buf.extend_from_slice(&s.to_le_bytes()); } std::fs::File::create(&wav_path) .unwrap() .write_all(&buf) .unwrap(); // Test reverse edit let flag = AtomicBool::new(false); let result = process_edit(&wav_path, &EditOperation::Reverse, &flag).unwrap(); assert!(result.exists()); // Read back and verify let decoded = decode_multichannel(&result).unwrap(); assert_eq!(decoded.channels, 1); // Reversed 8 samples: last should be first assert!(decoded.samples.len() >= 8); // Clean up let _ = std::fs::remove_file(&result); } }