max / audiofiles
33 files changed,
+738 insertions,
-159 deletions
| @@ -1,31 +0,0 @@ | |||
| 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 |
| @@ -138,8 +138,10 @@ pub(crate) fn fill_preview( | |||
| 138 | 138 | return; | |
| 139 | 139 | }; | |
| 140 | 140 | ||
| 141 | + | // For streaming, use the smaller of decoded_frames and actual data length | |
| 142 | + | // to prevent OOB if decoded_frames is updated before data is fully appended. | |
| 141 | 143 | let total_frames = if guard.streaming { | |
| 142 | - | guard.decoded_frames | |
| 144 | + | guard.decoded_frames.min(preview_buf.data.len() / 2) | |
| 143 | 145 | } else { | |
| 144 | 146 | preview_buf.data.len() / 2 | |
| 145 | 147 | }; |
| @@ -195,6 +195,9 @@ pub async fn deactivate_key(server_url: &str, key: &str, machine_id: &str) -> Re | |||
| 195 | 195 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 196 | 196 | pub struct TrialState { | |
| 197 | 197 | pub first_launch_date: String, | |
| 198 | + | /// Last time the app was launched — used to detect system clock rollback. | |
| 199 | + | #[serde(default)] | |
| 200 | + | pub last_seen_date: Option<String>, | |
| 198 | 201 | } | |
| 199 | 202 | ||
| 200 | 203 | /// Load the trial state from `trial.json` in the config directory. | |
| @@ -214,14 +217,37 @@ pub fn save_trial(config_dir: &Path, state: &TrialState) -> io::Result<()> { | |||
| 214 | 217 | } | |
| 215 | 218 | ||
| 216 | 219 | /// Calculate days remaining in the trial (goes negative after day 30). | |
| 220 | + | /// | |
| 221 | + | /// Detects system clock rollback: if `now < last_seen_date`, assumes the clock | |
| 222 | + | /// was set back to extend the trial and returns 0 (expired). | |
| 217 | 223 | pub fn trial_days_remaining(trial: &TrialState) -> i64 { | |
| 218 | 224 | let Ok(first) = chrono::DateTime::parse_from_rfc3339(&trial.first_launch_date) else { | |
| 219 | 225 | return 0; | |
| 220 | 226 | }; | |
| 221 | - | let elapsed = chrono::Utc::now().signed_duration_since(first); | |
| 227 | + | let now = chrono::Utc::now(); | |
| 228 | + | ||
| 229 | + | // Clock rollback detection: if now is before last_seen_date, expire immediately | |
| 230 | + | if let Some(ref last) = trial.last_seen_date { | |
| 231 | + | if let Ok(last_seen) = chrono::DateTime::parse_from_rfc3339(last) { | |
| 232 | + | if now.signed_duration_since(last_seen).num_hours() < -1 { | |
| 233 | + | // Allow up to 1 hour of drift (DST, NTP correction) | |
| 234 | + | return 0; | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | let elapsed = now.signed_duration_since(first); | |
| 222 | 240 | 30 - elapsed.num_days() | |
| 223 | 241 | } | |
| 224 | 242 | ||
| 243 | + | /// Update the last_seen_date to now. Call on each app launch. | |
| 244 | + | pub fn touch_trial(config_dir: &std::path::Path) { | |
| 245 | + | if let Some(mut trial) = load_trial(config_dir) { | |
| 246 | + | trial.last_seen_date = Some(chrono::Utc::now().to_rfc3339()); | |
| 247 | + | let _ = save_trial(config_dir, &trial); | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 225 | 251 | #[cfg(test)] | |
| 226 | 252 | mod tests { | |
| 227 | 253 | use super::*; | |
| @@ -334,6 +360,7 @@ mod tests { | |||
| 334 | 360 | let dir = tempfile::tempdir().unwrap(); | |
| 335 | 361 | let state = TrialState { | |
| 336 | 362 | first_launch_date: "2026-04-01T00:00:00Z".to_string(), | |
| 363 | + | last_seen_date: None, | |
| 337 | 364 | }; | |
| 338 | 365 | save_trial(dir.path(), &state).unwrap(); | |
| 339 | 366 | let loaded = load_trial(dir.path()).unwrap(); | |
| @@ -350,6 +377,7 @@ mod tests { | |||
| 350 | 377 | fn trial_days_remaining_fresh() { | |
| 351 | 378 | let state = TrialState { | |
| 352 | 379 | first_launch_date: chrono::Utc::now().to_rfc3339(), | |
| 380 | + | last_seen_date: None, | |
| 353 | 381 | }; | |
| 354 | 382 | assert_eq!(trial_days_remaining(&state), 30); | |
| 355 | 383 | } | |
| @@ -359,7 +387,20 @@ mod tests { | |||
| 359 | 387 | let past = chrono::Utc::now() - chrono::Duration::days(35); | |
| 360 | 388 | let state = TrialState { | |
| 361 | 389 | first_launch_date: past.to_rfc3339(), | |
| 390 | + | last_seen_date: None, | |
| 362 | 391 | }; | |
| 363 | 392 | assert_eq!(trial_days_remaining(&state), -5); | |
| 364 | 393 | } | |
| 394 | + | ||
| 395 | + | #[test] | |
| 396 | + | fn trial_clock_rollback_detected() { | |
| 397 | + | let now = chrono::Utc::now(); | |
| 398 | + | let future = now + chrono::Duration::days(10); | |
| 399 | + | let state = TrialState { | |
| 400 | + | first_launch_date: now.to_rfc3339(), | |
| 401 | + | last_seen_date: Some(future.to_rfc3339()), | |
| 402 | + | }; | |
| 403 | + | // now < last_seen_date by 10 days → clock was rolled back → expire | |
| 404 | + | assert_eq!(trial_days_remaining(&state), 0); | |
| 405 | + | } | |
| 365 | 406 | } |
| @@ -472,6 +472,7 @@ impl AudioFilesApp { | |||
| 472 | 472 | let machine_id = license::get_or_create_machine_id(&config_dir); | |
| 473 | 473 | let license_status = license::load_license(&config_dir); | |
| 474 | 474 | let trial_state = license::load_trial(&config_dir); | |
| 475 | + | license::touch_trial(&config_dir); | |
| 475 | 476 | ||
| 476 | 477 | // Load (or create) the vault registry | |
| 477 | 478 | let vault_registry = match vault::load_registry() { | |
| @@ -730,8 +731,10 @@ impl AudioFilesApp { | |||
| 730 | 731 | /// Start or continue trial mode: create trial state if needed, then proceed. | |
| 731 | 732 | fn start_trial(&mut self) { | |
| 732 | 733 | if self.trial_state.is_none() { | |
| 734 | + | let now = chrono::Utc::now().to_rfc3339(); | |
| 733 | 735 | let trial = license::TrialState { | |
| 734 | - | first_launch_date: chrono::Utc::now().to_rfc3339(), | |
| 736 | + | first_launch_date: now.clone(), | |
| 737 | + | last_seen_date: Some(now), | |
| 735 | 738 | }; | |
| 736 | 739 | if let Err(e) = license::save_trial(&self.config_dir, &trial) { | |
| 737 | 740 | tracing::error!("Failed to save trial state: {e}"); | |
| @@ -1043,7 +1046,7 @@ impl eframe::App for AudioFilesApp { | |||
| 1043 | 1046 | ui.add_space(4.0); | |
| 1044 | 1047 | ui.horizontal(|ui| { | |
| 1045 | 1048 | if ui.button("Download").clicked() | |
| 1046 | - | && download_url.starts_with("https://") | |
| 1049 | + | && crate::updater::is_trusted_download_url(&download_url) | |
| 1047 | 1050 | { | |
| 1048 | 1051 | let _ = open::that(&download_url); | |
| 1049 | 1052 | } |
| @@ -73,6 +73,15 @@ impl UpdateChecker { | |||
| 73 | 73 | } | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | /// Verify that a download URL points to a trusted domain. | |
| 77 | + | pub fn is_trusted_download_url(url: &str) -> bool { | |
| 78 | + | const TRUSTED_PREFIXES: &[&str] = &[ | |
| 79 | + | "https://makenot.work/", | |
| 80 | + | "https://dist.makenot.work/", | |
| 81 | + | ]; | |
| 82 | + | TRUSTED_PREFIXES.iter().any(|prefix| url.starts_with(prefix)) | |
| 83 | + | } | |
| 84 | + | ||
| 76 | 85 | /// Check the MNW OTA endpoint once. | |
| 77 | 86 | async fn check_once(status: &Arc<Mutex<UpdateStatus>>) { | |
| 78 | 87 | let current = match Version::parse(CURRENT_VERSION) { | |
| @@ -122,7 +131,7 @@ async fn check_once(status: &Arc<Mutex<UpdateStatus>>) { | |||
| 122 | 131 | match resp.json::<UpdateResponse>().await { | |
| 123 | 132 | Ok(update) => { | |
| 124 | 133 | if let Ok(remote) = Version::parse(&update.version) { | |
| 125 | - | if remote > current { | |
| 134 | + | if remote > current && is_trusted_download_url(&update.url) { | |
| 126 | 135 | tracing::info!("Update available: v{}", update.version); | |
| 127 | 136 | let mut s = status.lock(); | |
| 128 | 137 | s.available = true; |
| @@ -143,16 +143,20 @@ impl DirectBackend { | |||
| 143 | 143 | ||
| 144 | 144 | // validate_sample hook: filter items through Rhai script | |
| 145 | 145 | if let Some(ref ast) = plugin.hooks.validate_sample { | |
| 146 | - | let db = self.db.lock(); | |
| 147 | - | items.retain(|item| { | |
| 148 | - | let info = build_sample_info(&db, &self.store, item); | |
| 149 | - | audiofiles_rhai::hooks::run_validate_sample( | |
| 150 | - | self.plugin_registry.engine(), | |
| 151 | - | ast, | |
| 152 | - | info, | |
| 153 | - | ) | |
| 154 | - | .unwrap_or(true) | |
| 155 | - | }); | |
| 146 | + | // Collect all sample info with the lock held, then drop it before running scripts | |
| 147 | + | // to avoid holding the DB lock during potentially slow Rhai execution. | |
| 148 | + | let infos: Vec<_> = { | |
| 149 | + | let db = self.db.lock(); | |
| 150 | + | items.iter().map(|item| build_sample_info(&db, &self.store, item)).collect() | |
| 151 | + | }; | |
| 152 | + | let engine = self.plugin_registry.engine(); | |
| 153 | + | let mut keep = vec![true; items.len()]; | |
| 154 | + | for (i, info) in infos.into_iter().enumerate() { | |
| 155 | + | keep[i] = audiofiles_rhai::hooks::run_validate_sample(engine, ast, info) | |
| 156 | + | .unwrap_or(false); | |
| 157 | + | } | |
| 158 | + | let mut ki = 0; | |
| 159 | + | items.retain(|_| { let k = keep[ki]; ki += 1; k }); | |
| 156 | 160 | } | |
| 157 | 161 | ||
| 158 | 162 | // transform_filename hook: pre-compute output names with custom naming logic | |
| @@ -188,10 +192,13 @@ impl DirectBackend { | |||
| 188 | 192 | stem, | |
| 189 | 193 | ctx, | |
| 190 | 194 | ) { | |
| 195 | + | // Sanitize: strip path separators and NUL bytes to prevent traversal | |
| 196 | + | let safe_stem = new_stem.replace(['/', '\\', '\0'], "_"); | |
| 197 | + | let safe_stem = if safe_stem.is_empty() { "untitled".to_string() } else { safe_stem }; | |
| 191 | 198 | *name = if ext.is_empty() { | |
| 192 | - | new_stem | |
| 199 | + | safe_stem | |
| 193 | 200 | } else { | |
| 194 | - | format!("{new_stem}.{ext}") | |
| 201 | + | format!("{safe_stem}.{ext}") | |
| 195 | 202 | }; | |
| 196 | 203 | } | |
| 197 | 204 | } | |
| @@ -245,18 +252,51 @@ fn build_sample_info( | |||
| 245 | 252 | .unwrap_or(0) | |
| 246 | 253 | }); | |
| 247 | 254 | ||
| 255 | + | // Probe bit depth from the file header (cheap — reads only the header) | |
| 256 | + | let bit_depth = store | |
| 257 | + | .sample_path(&item.hash, &item.ext) | |
| 258 | + | .ok() | |
| 259 | + | .and_then(|p| probe_bit_depth(&p)) | |
| 260 | + | .unwrap_or(0); | |
| 261 | + | ||
| 248 | 262 | audiofiles_rhai::types::RhaiSampleInfo { | |
| 249 | 263 | hash: item.hash.to_string(), | |
| 250 | 264 | name: item.name.clone(), | |
| 251 | 265 | extension: item.ext.clone(), | |
| 252 | 266 | sample_rate, | |
| 253 | - | bit_depth: 0, // not stored in DB; hooks can check other fields | |
| 267 | + | bit_depth, | |
| 254 | 268 | channels, | |
| 255 | 269 | duration, | |
| 256 | 270 | file_size, | |
| 257 | 271 | } | |
| 258 | 272 | } | |
| 259 | 273 | ||
| 274 | + | /// Probe bit depth from a WAV/AIFF file header without full decode. | |
| 275 | + | #[cfg(feature = "device-profiles")] | |
| 276 | + | fn probe_bit_depth(path: &std::path::Path) -> Option<u16> { | |
| 277 | + | // Try hound first (WAV) | |
| 278 | + | if let Ok(reader) = hound::WavReader::open(path) { | |
| 279 | + | return Some(reader.spec().bits_per_sample); | |
| 280 | + | } | |
| 281 | + | // Try symphonia for AIFF/other formats | |
| 282 | + | let file = std::fs::File::open(path).ok()?; | |
| 283 | + | let mss = symphonia::core::io::MediaSourceStream::new(Box::new(file), Default::default()); | |
| 284 | + | let mut hint = symphonia::core::probe::Hint::new(); | |
| 285 | + | if let Some(ext) = path.extension().and_then(|e| e.to_str()) { | |
| 286 | + | hint.with_extension(ext); | |
| 287 | + | } | |
| 288 | + | let probed = symphonia::default::get_probe() | |
| 289 | + | .format( | |
| 290 | + | &hint, | |
| 291 | + | mss, | |
| 292 | + | &symphonia::core::formats::FormatOptions::default(), | |
| 293 | + | &symphonia::core::meta::MetadataOptions::default(), | |
| 294 | + | ) | |
| 295 | + | .ok()?; | |
| 296 | + | let track = probed.format.default_track()?; | |
| 297 | + | track.codec_params.bits_per_sample.map(|b| b as u16) | |
| 298 | + | } | |
| 299 | + | ||
| 260 | 300 | impl Backend for DirectBackend { | |
| 261 | 301 | // --- VFS --- | |
| 262 | 302 | ||
| @@ -696,6 +736,12 @@ impl Backend for DirectBackend { | |||
| 696 | 736 | } | |
| 697 | 737 | }; | |
| 698 | 738 | ||
| 739 | + | // Cancel any existing import worker before starting a new one | |
| 740 | + | if let Some(old) = self.import_worker.lock().take() { | |
| 741 | + | old.send(ImportCommand::Cancel); | |
| 742 | + | drop(old); // joins the thread | |
| 743 | + | } | |
| 744 | + | ||
| 699 | 745 | let handle = crate::import::spawn_import_worker(db_path, store_root) | |
| 700 | 746 | .map_err(|e| BackendError::Other(format!("failed to spawn import worker: {e}")))?; | |
| 701 | 747 | handle.send(ImportCommand::ImportDirectory { | |
| @@ -729,6 +775,12 @@ impl Backend for DirectBackend { | |||
| 729 | 775 | .collect() | |
| 730 | 776 | }; | |
| 731 | 777 | ||
| 778 | + | // Cancel any existing analysis worker before starting a new one | |
| 779 | + | if let Some(old) = self.analysis_worker.lock().take() { | |
| 780 | + | old.send(WorkerCommand::Cancel); | |
| 781 | + | drop(old); | |
| 782 | + | } | |
| 783 | + | ||
| 732 | 784 | let handle = audiofiles_core::analysis::worker::spawn_worker() | |
| 733 | 785 | .map_err(|e| BackendError::Other(format!("failed to spawn analysis worker: {e}")))?; | |
| 734 | 786 | handle.send(WorkerCommand::AnalyzeBatch { samples, config }); | |
| @@ -748,6 +800,12 @@ impl Backend for DirectBackend { | |||
| 748 | 800 | self.resolve_device_profile(&mut config, &mut items); | |
| 749 | 801 | ||
| 750 | 802 | let store_root = self.store.root().to_path_buf(); | |
| 803 | + | // Cancel any existing export worker before starting a new one | |
| 804 | + | if let Some(old) = self.export_worker.lock().take() { | |
| 805 | + | old.send(ExportCommand::Cancel); | |
| 806 | + | drop(old); | |
| 807 | + | } | |
| 808 | + | ||
| 751 | 809 | let handle = crate::export::spawn_export_worker(store_root) | |
| 752 | 810 | .map_err(|e| BackendError::Other(format!("failed to spawn export worker: {e}")))?; | |
| 753 | 811 | handle.send(ExportCommand::Export { items, config }); | |
| @@ -787,6 +845,12 @@ impl Backend for DirectBackend { | |||
| 787 | 845 | )); | |
| 788 | 846 | } | |
| 789 | 847 | ||
| 848 | + | // Cancel any existing edit worker before starting a new one | |
| 849 | + | if let Some(old) = self.edit_worker.lock().take() { | |
| 850 | + | old.send(EditCommand::Cancel); | |
| 851 | + | drop(old); | |
| 852 | + | } | |
| 853 | + | ||
| 790 | 854 | let handle = audiofiles_core::edit::worker::spawn_edit_worker() | |
| 791 | 855 | .map_err(|e| BackendError::Other(format!("failed to spawn edit worker: {e}")))?; | |
| 792 | 856 | handle.send(EditCommand::Edit { |
| @@ -208,7 +208,16 @@ impl IDataObject_Impl for FileDataObject_Impl { | |||
| 208 | 208 | unsafe { | |
| 209 | 209 | let h = GlobalAlloc(GMEM_MOVEABLE, self.size)?; | |
| 210 | 210 | let src = GlobalLock(self.hdrop); | |
| 211 | + | if src.is_null() { | |
| 212 | + | let _ = GlobalFree(Some(h)); | |
| 213 | + | return Err(Error::from(E_OUTOFMEMORY)); | |
| 214 | + | } | |
| 211 | 215 | let dst = GlobalLock(h); | |
| 216 | + | if dst.is_null() { | |
| 217 | + | let _ = GlobalUnlock(self.hdrop); | |
| 218 | + | let _ = GlobalFree(Some(h)); | |
| 219 | + | return Err(Error::from(E_OUTOFMEMORY)); | |
| 220 | + | } | |
| 212 | 221 | std::ptr::copy_nonoverlapping(src as *const u8, dst as *mut u8, self.size); | |
| 213 | 222 | let _ = GlobalUnlock(self.hdrop); | |
| 214 | 223 | let _ = GlobalUnlock(h); |
| @@ -16,6 +16,14 @@ pub struct BpmKeyResult { | |||
| 16 | 16 | /// since tempo detection needs enough rhythmic content to find a reliable beat grid. | |
| 17 | 17 | #[instrument(skip_all)] | |
| 18 | 18 | pub fn detect_bpm_key(samples: &[f32], sample_rate: u32, min_duration: f64) -> BpmKeyResult { | |
| 19 | + | if sample_rate == 0 { | |
| 20 | + | return BpmKeyResult { | |
| 21 | + | bpm: None, | |
| 22 | + | bpm_confidence: None, | |
| 23 | + | key: None, | |
| 24 | + | key_confidence: None, | |
| 25 | + | }; | |
| 26 | + | } | |
| 19 | 27 | let duration = samples.len() as f64 / sample_rate as f64; | |
| 20 | 28 | if duration < min_duration { | |
| 21 | 29 | return BpmKeyResult { |
| @@ -216,6 +216,9 @@ impl TreeNode { | |||
| 216 | 216 | left, | |
| 217 | 217 | right, | |
| 218 | 218 | } => { | |
| 219 | + | if *feature >= NUM_FEATURES { | |
| 220 | + | return 0; // fallback for malformed model | |
| 221 | + | } | |
| 219 | 222 | if features[*feature] <= *threshold { | |
| 220 | 223 | left.predict(features) | |
| 221 | 224 | } else { | |
| @@ -326,27 +329,48 @@ static LAYER2_BASS_MODEL: OnceLock<RandomForestModel> = OnceLock::new(); | |||
| 326 | 329 | static LAYER2_VOCAL_MODEL: OnceLock<RandomForestModel> = OnceLock::new(); | |
| 327 | 330 | static LAYER2_SYNTH_MODEL: OnceLock<RandomForestModel> = OnceLock::new(); | |
| 328 | 331 | ||
| 332 | + | /// Fallback empty model — classification falls back to rule-based layer 1 only. | |
| 333 | + | fn empty_model() -> RandomForestModel { | |
| 334 | + | RandomForestModel { | |
| 335 | + | trees: vec![], | |
| 336 | + | num_classes: 0, | |
| 337 | + | class_names: vec![], | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | ||
| 329 | 341 | fn layer2_model() -> &'static RandomForestModel { | |
| 330 | 342 | LAYER2_DRUM_MODEL.get_or_init(|| { | |
| 331 | - | serde_json::from_slice(LAYER2_DRUM_BYTES).expect("embedded drum model is invalid JSON") | |
| 343 | + | serde_json::from_slice(LAYER2_DRUM_BYTES).unwrap_or_else(|e| { | |
| 344 | + | tracing::error!("Failed to deserialize embedded drum model: {e}"); | |
| 345 | + | empty_model() | |
| 346 | + | }) | |
| 332 | 347 | }) | |
| 333 | 348 | } | |
| 334 | 349 | ||
| 335 | 350 | fn layer2_bass_model() -> &'static RandomForestModel { | |
| 336 | 351 | LAYER2_BASS_MODEL.get_or_init(|| { | |
| 337 | - | serde_json::from_slice(LAYER2_BASS_BYTES).expect("embedded bass model is invalid JSON") | |
| 352 | + | serde_json::from_slice(LAYER2_BASS_BYTES).unwrap_or_else(|e| { | |
| 353 | + | tracing::error!("Failed to deserialize embedded bass model: {e}"); | |
| 354 | + | empty_model() | |
| 355 | + | }) | |
| 338 | 356 | }) | |
| 339 | 357 | } | |
| 340 | 358 | ||
| 341 | 359 | fn layer2_vocal_model() -> &'static RandomForestModel { | |
| 342 | 360 | LAYER2_VOCAL_MODEL.get_or_init(|| { | |
| 343 | - | serde_json::from_slice(LAYER2_VOCAL_BYTES).expect("embedded vocal model is invalid JSON") | |
| 361 | + | serde_json::from_slice(LAYER2_VOCAL_BYTES).unwrap_or_else(|e| { | |
| 362 | + | tracing::error!("Failed to deserialize embedded vocal model: {e}"); | |
| 363 | + | empty_model() | |
| 364 | + | }) | |
| 344 | 365 | }) | |
| 345 | 366 | } | |
| 346 | 367 | ||
| 347 | 368 | fn layer2_synth_model() -> &'static RandomForestModel { | |
| 348 | 369 | LAYER2_SYNTH_MODEL.get_or_init(|| { | |
| 349 | - | serde_json::from_slice(LAYER2_SYNTH_BYTES).expect("embedded synth model is invalid JSON") | |
| 370 | + | serde_json::from_slice(LAYER2_SYNTH_BYTES).unwrap_or_else(|e| { | |
| 371 | + | tracing::error!("Failed to deserialize embedded synth model: {e}"); | |
| 372 | + | empty_model() | |
| 373 | + | }) | |
| 350 | 374 | }) | |
| 351 | 375 | } | |
| 352 | 376 |
| @@ -113,6 +113,10 @@ pub fn decode_to_mono(path: &Path) -> Result<DecodedAudio, CoreError> { | |||
| 113 | 113 | let num_frames = decoded.frames(); | |
| 114 | 114 | let num_channels = spec.channels.count(); | |
| 115 | 115 | ||
| 116 | + | if num_channels == 0 { | |
| 117 | + | continue; | |
| 118 | + | } | |
| 119 | + | ||
| 116 | 120 | let mut sample_buf = SampleBuffer::<f32>::new(num_frames as u64, *decoded.spec()); | |
| 117 | 121 | sample_buf.copy_interleaved_ref(decoded); | |
| 118 | 122 | let samples = sample_buf.samples(); |
| @@ -78,7 +78,7 @@ fn start_end_correlation(samples: &[f32]) -> f64 { | |||
| 78 | 78 | ||
| 79 | 79 | /// Check if the sample duration is a clean multiple of one beat at the given BPM. | |
| 80 | 80 | fn is_beat_aligned(samples: &[f32], sample_rate: u32, bpm: f64) -> bool { | |
| 81 | - | if bpm <= 0.0 { | |
| 81 | + | if bpm <= 0.0 || sample_rate == 0 { | |
| 82 | 82 | return false; | |
| 83 | 83 | } | |
| 84 | 84 |
| @@ -84,8 +84,30 @@ pub fn analyze_sample( | |||
| 84 | 84 | path: &Path, | |
| 85 | 85 | config: &AnalysisConfig, | |
| 86 | 86 | ) -> Result<AnalysisResult, CoreError> { | |
| 87 | + | // Guard against memory exhaustion: reject files over 2 GB before decoding. | |
| 88 | + | // A 2 GB compressed file would expand to several GB of f32 samples. | |
| 89 | + | const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024; | |
| 90 | + | if let Ok(metadata) = std::fs::metadata(path) { | |
| 91 | + | if metadata.len() > MAX_FILE_SIZE { | |
| 92 | + | return Err(CoreError::Analysis(crate::error::AnalysisError::ProbeFailed( | |
| 93 | + | format!("file too large for analysis ({} MB, max {} MB)", | |
| 94 | + | metadata.len() / (1024 * 1024), MAX_FILE_SIZE / (1024 * 1024)), | |
| 95 | + | ))); | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 87 | 99 | let decoded = decode::decode_to_mono(path)?; | |
| 88 | 100 | ||
| 101 | + | // Hard cap: reject files over 30 minutes to prevent memory exhaustion. | |
| 102 | + | // A 30-minute 96kHz mono signal is ~660 MB of f32 — beyond that is almost | |
| 103 | + | // certainly not a sample. | |
| 104 | + | const MAX_DECODE_DURATION: f64 = 1800.0; | |
| 105 | + | if decoded.duration > MAX_DECODE_DURATION { | |
| 106 | + | return Err(CoreError::Analysis(crate::error::AnalysisError::ProbeFailed( | |
| 107 | + | format!("file too long for analysis ({:.0}s, max {MAX_DECODE_DURATION}s)", decoded.duration), | |
| 108 | + | ))); | |
| 109 | + | } | |
| 110 | + | ||
| 89 | 111 | // Cap samples for expensive analyses (STFT, BPM/key). Cheap analyses and | |
| 90 | 112 | // fingerprint use the full signal. | |
| 91 | 113 | let capped_samples: &[f32] = if let Some(max_secs) = config.max_analysis_seconds { |
| @@ -157,8 +157,13 @@ fn worker_loop( | |||
| 157 | 157 | current_name: name, | |
| 158 | 158 | }); | |
| 159 | 159 | ||
| 160 | - | match analyze_sample(hash, path, &config) { | |
| 161 | - | Ok(result) => { | |
| 160 | + | // Catch panics so a single bad sample doesn't kill the worker | |
| 161 | + | let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| 162 | + | analyze_sample(hash, path, &config) | |
| 163 | + | })); | |
| 164 | + | ||
| 165 | + | match result { | |
| 166 | + | Ok(Ok(result)) => { | |
| 162 | 167 | let suggestions = if config.auto_suggest_tags { | |
| 163 | 168 | super::suggest::suggest_tags(&result) | |
| 164 | 169 | } else { | |
| @@ -169,12 +174,18 @@ fn worker_loop( | |||
| 169 | 174 | suggestions, | |
| 170 | 175 | }); | |
| 171 | 176 | } | |
| 172 | - | Err(e) => { | |
| 177 | + | Ok(Err(e)) => { | |
| 173 | 178 | let _ = event_tx.send(WorkerEvent::SampleError { | |
| 174 | 179 | hash: hash.clone(), | |
| 175 | 180 | error: e.to_string(), | |
| 176 | 181 | }); | |
| 177 | 182 | } | |
| 183 | + | Err(_panic) => { | |
| 184 | + | let _ = event_tx.send(WorkerEvent::SampleError { | |
| 185 | + | hash: hash.clone(), | |
| 186 | + | error: "analysis panicked (internal error)".to_string(), | |
| 187 | + | }); | |
| 188 | + | } | |
| 178 | 189 | } | |
| 179 | 190 | ||
| 180 | 191 | completed.fetch_add(1, Ordering::Relaxed); |
| @@ -608,6 +608,13 @@ const MIGRATION_013: &str = r#" | |||
| 608 | 608 | ALTER TABLE samples ADD COLUMN source_path TEXT; | |
| 609 | 609 | "#; | |
| 610 | 610 | ||
| 611 | + | const MIGRATION_014: &str = r#" | |
| 612 | + | -- Prevent duplicate root-level VFS node names. The existing UNIQUE(vfs_id, parent_id, name) | |
| 613 | + | -- constraint treats NULLs as distinct, so root nodes (parent_id IS NULL) could collide. | |
| 614 | + | CREATE UNIQUE INDEX IF NOT EXISTS idx_vfs_nodes_root_unique | |
| 615 | + | ON vfs_nodes(vfs_id, name) WHERE parent_id IS NULL; | |
| 616 | + | "#; | |
| 617 | + | ||
| 611 | 618 | impl Database { | |
| 612 | 619 | /// Open (or create) the database at the given path and run migrations. | |
| 613 | 620 | #[instrument(skip_all)] | |
| @@ -669,6 +676,7 @@ impl Database { | |||
| 669 | 676 | MIGRATION_011, | |
| 670 | 677 | MIGRATION_012, | |
| 671 | 678 | MIGRATION_013, | |
| 679 | + | MIGRATION_014, | |
| 672 | 680 | ]; | |
| 673 | 681 | ||
| 674 | 682 | for (i, sql) in MIGRATIONS.iter().enumerate() { | |
| @@ -709,8 +717,17 @@ impl Database { | |||
| 709 | 717 | .collect::<Vec<_>>() | |
| 710 | 718 | .join("\n"); | |
| 711 | 719 | if !non_alter.trim().is_empty() { | |
| 712 | - | // Ignore errors from already-created objects | |
| 713 | - | let _ = self.conn.execute_batch(&non_alter); | |
| 720 | + | // Ignore "already exists" errors from prior partial runs; | |
| 721 | + | // log anything else as a warning. | |
| 722 | + | if let Err(e) = self.conn.execute_batch(&non_alter) { | |
| 723 | + | let msg = e.to_string(); | |
| 724 | + | if !msg.contains("already exists") { | |
| 725 | + | tracing::warn!( | |
| 726 | + | migration = target, | |
| 727 | + | "Non-ALTER migration statement failed: {msg}" | |
| 728 | + | ); | |
| 729 | + | } | |
| 730 | + | } | |
| 714 | 731 | } | |
| 715 | 732 | self.conn.execute_batch( | |
| 716 | 733 | &format!("PRAGMA user_version = {};\nCOMMIT;", target), | |
| @@ -809,7 +826,7 @@ mod tests { | |||
| 809 | 826 | .conn() | |
| 810 | 827 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 811 | 828 | .unwrap(); | |
| 812 | - | assert_eq!(version, 13); | |
| 829 | + | assert_eq!(version, 14); | |
| 813 | 830 | } | |
| 814 | 831 | ||
| 815 | 832 | #[test] | |
| @@ -820,7 +837,7 @@ mod tests { | |||
| 820 | 837 | .conn() | |
| 821 | 838 | .query_row("PRAGMA user_version", [], |row| row.get(0)) | |
| 822 | 839 | .unwrap(); | |
| 823 | - | assert_eq!(version, 13); | |
| 840 | + | assert_eq!(version, 14); | |
| 824 | 841 | } | |
| 825 | 842 | ||
| 826 | 843 | #[test] |
| @@ -0,0 +1,85 @@ | |||
| 1 | + | //! Channel conversion: mono-to-stereo and stereo-to-mono destructive edits. | |
| 2 | + | ||
| 3 | + | use crate::error::CoreError; | |
| 4 | + | ||
| 5 | + | /// Convert mono interleaved samples to stereo by duplicating each sample. | |
| 6 | + | /// | |
| 7 | + | /// Returns the new channel count (2). Caller must update the stored channel count. | |
| 8 | + | pub fn apply_mono_to_stereo(samples: &mut Vec<f32>) -> Result<u16, CoreError> { | |
| 9 | + | let n = samples.len(); | |
| 10 | + | let mut stereo = Vec::with_capacity(n * 2); | |
| 11 | + | for &s in samples.iter() { | |
| 12 | + | stereo.push(s); | |
| 13 | + | stereo.push(s); | |
| 14 | + | } | |
| 15 | + | *samples = stereo; | |
| 16 | + | Ok(2) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /// Convert stereo interleaved samples to mono by averaging L+R per frame. | |
| 20 | + | /// | |
| 21 | + | /// Returns the new channel count (1). Caller must update the stored channel count. | |
| 22 | + | pub fn apply_stereo_to_mono(samples: &mut Vec<f32>, channels: u16) -> Result<u16, CoreError> { | |
| 23 | + | if channels < 2 { | |
| 24 | + | return Ok(channels); // already mono, no-op | |
| 25 | + | } | |
| 26 | + | let ch = channels as usize; | |
| 27 | + | let num_frames = samples.len() / ch; | |
| 28 | + | let mut mono = Vec::with_capacity(num_frames); | |
| 29 | + | for frame in 0..num_frames { | |
| 30 | + | let mut sum = 0.0f32; | |
| 31 | + | for c in 0..ch { | |
| 32 | + | sum += samples[frame * ch + c]; | |
| 33 | + | } | |
| 34 | + | mono.push(sum / ch as f32); | |
| 35 | + | } | |
| 36 | + | *samples = mono; | |
| 37 | + | Ok(1) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | #[cfg(test)] | |
| 41 | + | mod tests { | |
| 42 | + | use super::*; | |
| 43 | + | ||
| 44 | + | #[test] | |
| 45 | + | fn mono_to_stereo() { | |
| 46 | + | let mut samples = vec![0.1, 0.2, 0.3]; | |
| 47 | + | let ch = apply_mono_to_stereo(&mut samples).unwrap(); | |
| 48 | + | assert_eq!(ch, 2); | |
| 49 | + | assert_eq!(samples, vec![0.1, 0.1, 0.2, 0.2, 0.3, 0.3]); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | #[test] | |
| 53 | + | fn stereo_to_mono() { | |
| 54 | + | let mut samples = vec![0.4, 0.6, 0.2, 0.8]; | |
| 55 | + | let ch = apply_stereo_to_mono(&mut samples, 2).unwrap(); | |
| 56 | + | assert_eq!(ch, 1); | |
| 57 | + | assert_eq!(samples.len(), 2); | |
| 58 | + | assert!((samples[0] - 0.5).abs() < 1e-6); | |
| 59 | + | assert!((samples[1] - 0.5).abs() < 1e-6); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | #[test] | |
| 63 | + | fn mono_to_mono_noop() { | |
| 64 | + | let mut samples = vec![0.1, 0.2]; | |
| 65 | + | let ch = apply_stereo_to_mono(&mut samples, 1).unwrap(); | |
| 66 | + | assert_eq!(ch, 1); | |
| 67 | + | assert_eq!(samples, vec![0.1, 0.2]); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | #[test] | |
| 71 | + | fn roundtrip_preserves_energy() { | |
| 72 | + | let mut samples = vec![0.5, -0.5, 0.3, -0.3]; | |
| 73 | + | let _ = apply_stereo_to_mono(&mut samples, 2).unwrap(); | |
| 74 | + | // Mono: [0.0, 0.0] — opposing channels cancel | |
| 75 | + | assert!((samples[0]).abs() < 1e-6); | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | #[test] | |
| 79 | + | fn empty_input() { | |
| 80 | + | let mut samples: Vec<f32> = vec![]; | |
| 81 | + | let ch = apply_mono_to_stereo(&mut samples).unwrap(); | |
| 82 | + | assert_eq!(ch, 2); | |
| 83 | + | assert!(samples.is_empty()); | |
| 84 | + | } | |
| 85 | + | } |
| @@ -0,0 +1,75 @@ | |||
| 1 | + | //! DC offset removal: compute mean amplitude and subtract it from all samples. | |
| 2 | + | ||
| 3 | + | /// Remove DC offset from interleaved audio samples. | |
| 4 | + | /// | |
| 5 | + | /// Computes the mean sample value (the DC component) and subtracts it, | |
| 6 | + | /// centering the waveform around zero. Operates per-channel to handle | |
| 7 | + | /// stereo files where channels may have different offsets. | |
| 8 | + | pub fn apply_remove_dc_offset(samples: &mut [f32], channels: u16) { | |
| 9 | + | if samples.is_empty() || channels == 0 { | |
| 10 | + | return; | |
| 11 | + | } | |
| 12 | + | let ch = channels as usize; | |
| 13 | + | let num_frames = samples.len() / ch; | |
| 14 | + | if num_frames == 0 { | |
| 15 | + | return; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // Compute per-channel mean | |
| 19 | + | let mut sums = vec![0.0f64; ch]; | |
| 20 | + | for frame in 0..num_frames { | |
| 21 | + | for c in 0..ch { | |
| 22 | + | sums[c] += samples[frame * ch + c] as f64; | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | let means: Vec<f32> = sums.iter().map(|s| (*s / num_frames as f64) as f32).collect(); | |
| 26 | + | ||
| 27 | + | // Subtract per-channel mean | |
| 28 | + | for frame in 0..num_frames { | |
| 29 | + | for c in 0..ch { | |
| 30 | + | samples[frame * ch + c] -= means[c]; | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | #[cfg(test)] | |
| 36 | + | mod tests { | |
| 37 | + | use super::*; | |
| 38 | + | ||
| 39 | + | #[test] | |
| 40 | + | fn removes_dc_offset_mono() { | |
| 41 | + | let mut samples = vec![0.5, 0.6, 0.4, 0.5]; // mean = 0.5 | |
| 42 | + | apply_remove_dc_offset(&mut samples, 1); | |
| 43 | + | let mean: f32 = samples.iter().sum::<f32>() / samples.len() as f32; | |
| 44 | + | assert!(mean.abs() < 1e-6, "mean should be ~0, got {mean}"); | |
| 45 | + | assert!((samples[0] - 0.0).abs() < 1e-6); | |
| 46 | + | assert!((samples[1] - 0.1).abs() < 1e-6); | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | #[test] | |
| 50 | + | fn removes_dc_offset_stereo() { | |
| 51 | + | // L channel: mean 0.5, R channel: mean -0.5 | |
| 52 | + | let mut samples = vec![0.5, -0.5, 0.5, -0.5]; | |
| 53 | + | apply_remove_dc_offset(&mut samples, 2); | |
| 54 | + | // After removal, both channels centered at 0 | |
| 55 | + | assert!((samples[0]).abs() < 1e-6); | |
| 56 | + | assert!((samples[1]).abs() < 1e-6); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | #[test] | |
| 60 | + | fn noop_on_already_centered() { | |
| 61 | + | let original = vec![0.1, -0.1, 0.2, -0.2]; | |
| 62 | + | let mut samples = original.clone(); | |
| 63 | + | apply_remove_dc_offset(&mut samples, 1); | |
| 64 | + | for (a, b) in samples.iter().zip(original.iter()) { | |
| 65 | + | assert!((a - b).abs() < 1e-6); | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | #[test] | |
| 70 | + | fn empty_input() { | |
| 71 | + | let mut samples: Vec<f32> = vec![]; | |
| 72 | + | apply_remove_dc_offset(&mut samples, 1); | |
| 73 | + | assert!(samples.is_empty()); | |
| 74 | + | } | |
| 75 | + | } |
| @@ -1,8 +1,10 @@ | |||
| 1 | - | //! Destructive sample editing: trim, normalize, reverse, gain, fade. | |
| 1 | + | //! Destructive sample editing: trim, normalize, reverse, gain, fade, DC offset, channel conversion. | |
| 2 | 2 | //! | |
| 3 | 3 | //! Each operation is a pure function operating on `Vec<f32>` sample data. | |
| 4 | 4 | //! The [`EditOperation`] enum dispatches to the correct function via [`apply_edit`]. | |
| 5 | 5 | ||
| 6 | + | pub mod channel_convert; | |
| 7 | + | pub mod dc_offset; | |
| 6 | 8 | pub mod fade; | |
| 7 | 9 | pub mod gain; | |
| 8 | 10 | pub mod normalize; | |
| @@ -39,6 +41,12 @@ pub enum EditOperation { | |||
| 39 | 41 | frames: usize, | |
| 40 | 42 | curve: FadeCurve, | |
| 41 | 43 | }, | |
| 44 | + | /// Remove DC offset (center waveform around zero). | |
| 45 | + | RemoveDcOffset, | |
| 46 | + | /// Convert mono to stereo (duplicate channels). | |
| 47 | + | MonoToStereo, | |
| 48 | + | /// Convert stereo/multi-channel to mono (average channels). | |
| 49 | + | StereoToMono, | |
| 42 | 50 | } | |
| 43 | 51 | ||
| 44 | 52 | impl EditOperation { | |
| @@ -52,44 +60,61 @@ impl EditOperation { | |||
| 52 | 60 | EditOperation::Gain { .. } => "Gain", | |
| 53 | 61 | EditOperation::FadeIn { .. } => "Fade In", | |
| 54 | 62 | EditOperation::FadeOut { .. } => "Fade Out", | |
| 63 | + | EditOperation::RemoveDcOffset => "Remove DC Offset", | |
| 64 | + | EditOperation::MonoToStereo => "Mono → Stereo", | |
| 65 | + | EditOperation::StereoToMono => "Stereo → Mono", | |
| 55 | 66 | } | |
| 56 | 67 | } | |
| 57 | 68 | } | |
| 58 | 69 | ||
| 59 | 70 | /// Apply an edit operation to interleaved sample data in-place. | |
| 71 | + | /// | |
| 72 | + | /// Returns the (possibly changed) channel count — channel conversion operations | |
| 73 | + | /// modify the number of channels. | |
| 60 | 74 | pub fn apply_edit( | |
| 61 | 75 | samples: &mut Vec<f32>, | |
| 62 | 76 | channels: u16, | |
| 63 | 77 | sample_rate: u32, | |
| 64 | 78 | operation: &EditOperation, | |
| 65 | - | ) -> Result<(), CoreError> { | |
| 79 | + | ) -> Result<u16, CoreError> { | |
| 66 | 80 | match operation { | |
| 67 | 81 | EditOperation::Trim { | |
| 68 | 82 | start_frame, | |
| 69 | 83 | end_frame, | |
| 70 | - | } => trim::apply_trim(samples, channels, *start_frame, *end_frame), | |
| 84 | + | } => { | |
| 85 | + | trim::apply_trim(samples, channels, *start_frame, *end_frame)?; | |
| 86 | + | Ok(channels) | |
| 87 | + | } | |
| 71 | 88 | EditOperation::NormalizePeak { target_db } => { | |
| 72 | - | normalize::apply_normalize_peak(samples, *target_db) | |
| 89 | + | normalize::apply_normalize_peak(samples, *target_db)?; | |
| 90 | + | Ok(channels) | |
| 73 | 91 | } | |
| 74 | 92 | EditOperation::NormalizeLufs { target_lufs } => { | |
| 75 | - | normalize::apply_normalize_lufs(samples, channels, sample_rate, *target_lufs) | |
| 93 | + | normalize::apply_normalize_lufs(samples, channels, sample_rate, *target_lufs)?; | |
| 94 | + | Ok(channels) | |
| 76 | 95 | } | |
| 77 | 96 | EditOperation::Reverse => { | |
| 78 | 97 | reverse::apply_reverse(samples, channels); | |
| 79 | - | Ok(()) | |
| 98 | + | Ok(channels) | |
| 80 | 99 | } | |
| 81 | 100 | EditOperation::Gain { db } => { | |
| 82 | 101 | gain::apply_gain(samples, *db); | |
| 83 | - | Ok(()) | |
| 102 | + | Ok(channels) | |
| 84 | 103 | } | |
| 85 | 104 | EditOperation::FadeIn { frames, curve } => { | |
| 86 | 105 | fade::apply_fade_in(samples, channels, *frames, *curve); | |
| 87 | - | Ok(()) | |
| 106 | + | Ok(channels) | |
| 88 | 107 | } | |
| 89 | 108 | EditOperation::FadeOut { frames, curve } => { | |
| 90 | 109 | fade::apply_fade_out(samples, channels, *frames, *curve); | |
| 91 | - | Ok(()) | |
| 110 | + | Ok(channels) | |
| 111 | + | } | |
| 112 | + | EditOperation::RemoveDcOffset => { | |
| 113 | + | dc_offset::apply_remove_dc_offset(samples, channels); | |
| 114 | + | Ok(channels) | |
| 92 | 115 | } | |
| 116 | + | EditOperation::MonoToStereo => channel_convert::apply_mono_to_stereo(samples), | |
| 117 | + | EditOperation::StereoToMono => channel_convert::apply_stereo_to_mono(samples, channels), | |
| 93 | 118 | } | |
| 94 | 119 | } | |
| 95 | 120 | ||
| @@ -180,4 +205,35 @@ mod tests { | |||
| 180 | 205 | let decoded: EditOperation = serde_json::from_str(&json).unwrap(); | |
| 181 | 206 | assert_eq!(decoded.display_name(), "Fade In"); | |
| 182 | 207 | } | |
| 208 | + | ||
| 209 | + | #[test] | |
| 210 | + | fn apply_edit_dispatches_dc_offset() { | |
| 211 | + | let mut samples = vec![1.0, 1.0, 1.0]; // DC offset of 1.0 | |
| 212 | + | let ch = apply_edit(&mut samples, 1, 44100, &EditOperation::RemoveDcOffset).unwrap(); | |
| 213 | + | assert_eq!(ch, 1); | |
| 214 | + | assert!(samples.iter().all(|s| s.abs() < 1e-6)); | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | #[test] | |
| 218 | + | fn apply_edit_dispatches_mono_to_stereo() { | |
| 219 | + | let mut samples = vec![0.5, -0.5]; | |
| 220 | + | let ch = apply_edit(&mut samples, 1, 44100, &EditOperation::MonoToStereo).unwrap(); | |
| 221 | + | assert_eq!(ch, 2); | |
| 222 | + | assert_eq!(samples, vec![0.5, 0.5, -0.5, -0.5]); | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | #[test] | |
| 226 | + | fn apply_edit_dispatches_stereo_to_mono() { | |
| 227 | + | let mut samples = vec![0.4, 0.6, 0.2, 0.8]; | |
| 228 | + | let ch = apply_edit(&mut samples, 2, 44100, &EditOperation::StereoToMono).unwrap(); | |
| 229 | + | assert_eq!(ch, 1); | |
| 230 | + | assert_eq!(samples.len(), 2); | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | #[test] | |
| 234 | + | fn channel_conversion_display_names() { | |
| 235 | + | assert_eq!(EditOperation::RemoveDcOffset.display_name(), "Remove DC Offset"); | |
| 236 | + | assert_eq!(EditOperation::MonoToStereo.display_name(), "Mono → Stereo"); | |
| 237 | + | assert_eq!(EditOperation::StereoToMono.display_name(), "Stereo → Mono"); | |
| 238 | + | } | |
| 183 | 239 | } |
| @@ -61,7 +61,7 @@ impl EditWorkerHandle { | |||
| 61 | 61 | /// Send a command to the worker. | |
| 62 | 62 | pub fn send(&self, cmd: EditCommand) { | |
| 63 | 63 | if matches!(cmd, EditCommand::Cancel) { | |
| 64 | - | self.cancel_flag.store(true, Ordering::Relaxed); | |
| 64 | + | self.cancel_flag.store(true, Ordering::Release); | |
| 65 | 65 | } | |
| 66 | 66 | let _ = self.cmd_tx.send(cmd); | |
| 67 | 67 | } | |
| @@ -69,7 +69,7 @@ impl EditWorkerHandle { | |||
| 69 | 69 | ||
| 70 | 70 | impl Drop for EditWorkerHandle { | |
| 71 | 71 | fn drop(&mut self) { | |
| 72 | - | self.cancel_flag.store(true, Ordering::Relaxed); | |
| 72 | + | self.cancel_flag.store(true, Ordering::Release); | |
| 73 | 73 | let _ = self.cmd_tx.send(EditCommand::Shutdown); | |
| 74 | 74 | if let Some(handle) = self.thread.take() { | |
| 75 | 75 | let _ = handle.join(); | |
| @@ -99,11 +99,27 @@ pub fn spawn_edit_worker() -> std::io::Result<EditWorkerHandle> { | |||
| 99 | 99 | }) | |
| 100 | 100 | } | |
| 101 | 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 | + | ||
| 102 | 115 | fn edit_worker_loop( | |
| 103 | 116 | cmd_rx: mpsc::Receiver<EditCommand>, | |
| 104 | 117 | event_tx: mpsc::Sender<EditEvent>, | |
| 105 | 118 | cancel_flag: Arc<AtomicBool>, | |
| 106 | 119 | ) { | |
| 120 | + | // Clean up stale temp files from prior sessions | |
| 121 | + | cleanup_edit_temp_dir(); | |
| 122 | + | ||
| 107 | 123 | while let Ok(cmd) = cmd_rx.recv() { | |
| 108 | 124 | match cmd { | |
| 109 | 125 | EditCommand::Shutdown => break, | |
| @@ -114,17 +130,17 @@ fn edit_worker_loop( | |||
| 114 | 130 | path, | |
| 115 | 131 | operation, | |
| 116 | 132 | } => { | |
| 117 | - | cancel_flag.store(false, Ordering::Relaxed); | |
| 133 | + | cancel_flag.store(false, Ordering::Release); | |
| 118 | 134 | ||
| 119 | 135 | let _ = event_tx.send(EditEvent::Started { | |
| 120 | 136 | hash: hash.clone(), | |
| 121 | 137 | }); | |
| 122 | 138 | ||
| 123 | - | if cancel_flag.load(Ordering::Relaxed) { | |
| 139 | + | if cancel_flag.load(Ordering::Acquire) { | |
| 124 | 140 | continue; | |
| 125 | 141 | } | |
| 126 | 142 | ||
| 127 | - | match process_edit(&path, &operation) { | |
| 143 | + | match process_edit(&path, &operation, &cancel_flag) { | |
| 128 | 144 | Ok(result_path) => { | |
| 129 | 145 | let _ = event_tx.send(EditEvent::Complete { | |
| 130 | 146 | source_hash: hash, | |
| @@ -148,18 +164,29 @@ fn edit_worker_loop( | |||
| 148 | 164 | fn process_edit( | |
| 149 | 165 | source_path: &Path, | |
| 150 | 166 | operation: &EditOperation, | |
| 167 | + | cancel_flag: &AtomicBool, | |
| 151 | 168 | ) -> Result<PathBuf, crate::error::CoreError> { | |
| 152 | 169 | // 1. Decode | |
| 153 | 170 | let mut decoded = decode_multichannel(source_path)?; | |
| 154 | 171 | ||
| 155 | - | // 2. Apply edit | |
| 156 | - | apply_edit( | |
| 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( | |
| 157 | 179 | &mut decoded.samples, | |
| 158 | 180 | decoded.channels, | |
| 159 | 181 | decoded.sample_rate, | |
| 160 | 182 | operation, | |
| 161 | 183 | )?; | |
| 162 | 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 | + | ||
| 163 | 190 | // 3. Write to temp WAV (24-bit to preserve quality) | |
| 164 | 191 | let temp_dir = std::env::temp_dir().join("audiofiles_edit"); | |
| 165 | 192 | std::fs::create_dir_all(&temp_dir).map_err(|e| crate::error::io_err(&temp_dir, e))?; | |
| @@ -274,7 +301,8 @@ mod tests { | |||
| 274 | 301 | .unwrap(); | |
| 275 | 302 | ||
| 276 | 303 | // Test reverse edit | |
| 277 | - | let result = process_edit(&wav_path, &EditOperation::Reverse).unwrap(); | |
| 304 | + | let flag = AtomicBool::new(false); | |
| 305 | + | let result = process_edit(&wav_path, &EditOperation::Reverse, &flag).unwrap(); | |
| 278 | 306 | assert!(result.exists()); | |
| 279 | 307 | ||
| 280 | 308 | // Read back and verify |
| @@ -20,6 +20,9 @@ pub fn convert_channels( | |||
| 20 | 20 | src_channels: u16, | |
| 21 | 21 | target: &ExportChannels, | |
| 22 | 22 | ) -> (Vec<f32>, u16) { | |
| 23 | + | if src_channels == 0 || samples.is_empty() { | |
| 24 | + | return (Vec::new(), src_channels.max(1)); | |
| 25 | + | } | |
| 23 | 26 | match target { | |
| 24 | 27 | ExportChannels::Original => (samples.to_vec(), src_channels), | |
| 25 | 28 | ExportChannels::Mono => { | |
| @@ -77,6 +80,11 @@ pub fn resample( | |||
| 77 | 80 | if src_rate == dst_rate { | |
| 78 | 81 | return Ok(samples.to_vec()); | |
| 79 | 82 | } | |
| 83 | + | if channels == 0 || src_rate == 0 || dst_rate == 0 { | |
| 84 | + | return Err(CoreError::Export(format!( | |
| 85 | + | "invalid resample params: channels={channels}, src_rate={src_rate}, dst_rate={dst_rate}" | |
| 86 | + | ))); | |
| 87 | + | } | |
| 80 | 88 | ||
| 81 | 89 | let ch = channels as usize; | |
| 82 | 90 | let num_frames = samples.len() / ch; |
| @@ -23,6 +23,9 @@ pub fn encode_aiff(audio: &ConvertedAudio, bit_depth: u16, dest: &Path) -> Resul | |||
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | 25 | let channels = audio.channels; | |
| 26 | + | if channels == 0 { | |
| 27 | + | return Err(CoreError::Export("AIFF: 0 channels".to_string())); | |
| 28 | + | } | |
| 26 | 29 | let num_frames = audio.samples.len() / channels as usize; | |
| 27 | 30 | let bytes_per_sample = (bit_depth / 8) as u64; | |
| 28 | 31 |