max / audiofiles
6 files changed,
+136 insertions,
-46 deletions
| @@ -60,6 +60,25 @@ fn main() -> eframe::Result<()> { | |||
| 60 | 60 | .with(tracing_subscriber::fmt::layer()) | |
| 61 | 61 | .init(); | |
| 62 | 62 | ||
| 63 | + | // Log panics (with source location) before the process unwinds, so a crash | |
| 64 | + | // in the egui update loop leaves a diagnosable trail in the log rather than a | |
| 65 | + | // bare backtrace on stderr. The default hook still runs after, for the trace. | |
| 66 | + | let default_hook = std::panic::take_hook(); | |
| 67 | + | std::panic::set_hook(Box::new(move |info| { | |
| 68 | + | let location = info | |
| 69 | + | .location() | |
| 70 | + | .map(|l| format!("{}:{}", l.file(), l.line())) | |
| 71 | + | .unwrap_or_else(|| "unknown".to_string()); | |
| 72 | + | let msg = info | |
| 73 | + | .payload() | |
| 74 | + | .downcast_ref::<&str>() | |
| 75 | + | .map(|s| (*s).to_string()) | |
| 76 | + | .or_else(|| info.payload().downcast_ref::<String>().cloned()) | |
| 77 | + | .unwrap_or_else(|| "<non-string panic>".to_string()); | |
| 78 | + | tracing::error!(location = %location, "panic: {msg}"); | |
| 79 | + | default_hook(info); | |
| 80 | + | })); | |
| 81 | + | ||
| 63 | 82 | let config_dir = dirs::config_dir() | |
| 64 | 83 | .unwrap_or_else(|| PathBuf::from(".")) | |
| 65 | 84 | .join("audiofiles"); |
| @@ -28,6 +28,15 @@ fn is_skipped_dir(path: &Path) -> bool { | |||
| 28 | 28 | audiofiles_core::util::is_macos_metadata_dir(path) | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | + | /// True if `path` is a symbolic link (to anything). Used to avoid descending | |
| 32 | + | /// into symlinked directories during import: a link pointing at an ancestor | |
| 33 | + | /// would recurse forever and overflow the stack. Symlinked *files* still import. | |
| 34 | + | fn is_symlink(path: &Path) -> bool { | |
| 35 | + | fs::symlink_metadata(path) | |
| 36 | + | .map(|m| m.file_type().is_symlink()) | |
| 37 | + | .unwrap_or(false) | |
| 38 | + | } | |
| 39 | + | ||
| 31 | 40 | /// How imported files should be organized in the VFS. | |
| 32 | 41 | pub enum ImportStrategy { | |
| 33 | 42 | /// All links in one directory, no subdirs created. | |
| @@ -176,7 +185,10 @@ fn count_audio_files( | |||
| 176 | 185 | for entry in entries.flatten() { | |
| 177 | 186 | let path = entry.path(); | |
| 178 | 187 | if path.is_dir() { | |
| 179 | - | if !is_skipped_dir(&path) { | |
| 188 | + | // Don't descend into symlinked directories: a link back to an | |
| 189 | + | // ancestor would loop forever. Symlinked *files* still import. | |
| 190 | + | let is_symlink = entry.file_type().map(|t| t.is_symlink()).unwrap_or(false); | |
| 191 | + | if !is_symlink && !is_skipped_dir(&path) { | |
| 180 | 192 | stack.push(path); | |
| 181 | 193 | } | |
| 182 | 194 | } else if is_audio_file(&path) { | |
| @@ -326,7 +338,7 @@ fn import_directory_recursive( | |||
| 326 | 338 | } | |
| 327 | 339 | ||
| 328 | 340 | if path.is_dir() { | |
| 329 | - | if is_skipped_dir(&path) { | |
| 341 | + | if is_skipped_dir(&path) || is_symlink(&path) { | |
| 330 | 342 | continue; | |
| 331 | 343 | } | |
| 332 | 344 | let dir_name = audiofiles_core::util::get_filename(&path, "folder"); | |
| @@ -391,7 +403,7 @@ fn import_directory_flat( | |||
| 391 | 403 | } | |
| 392 | 404 | ||
| 393 | 405 | if path.is_dir() { | |
| 394 | - | if is_skipped_dir(&path) { | |
| 406 | + | if is_skipped_dir(&path) || is_symlink(&path) { | |
| 395 | 407 | continue; | |
| 396 | 408 | } | |
| 397 | 409 | // Recurse into subdirs but still put all links at the same flat level | |
| @@ -436,7 +448,7 @@ fn import_structured( | |||
| 436 | 448 | } | |
| 437 | 449 | ||
| 438 | 450 | if path.is_dir() { | |
| 439 | - | if is_skipped_dir(&path) { | |
| 451 | + | if is_skipped_dir(&path) || is_symlink(&path) { | |
| 440 | 452 | continue; | |
| 441 | 453 | } | |
| 442 | 454 | let dir_name = audiofiles_core::util::get_filename(&path, "folder"); | |
| @@ -575,16 +587,21 @@ fn worker_loop( | |||
| 575 | 587 | ||
| 576 | 588 | let _ = event_tx.send(ImportEvent::WalkComplete { total, total_bytes }); | |
| 577 | 589 | ||
| 578 | - | // Check if loose-files mode is enabled for this vault | |
| 579 | - | let loose_files = db | |
| 580 | - | .conn() | |
| 581 | - | .query_row( | |
| 582 | - | "SELECT value FROM user_config WHERE key = 'loose_files'", | |
| 583 | - | [], | |
| 584 | - | |row| row.get::<_, String>(0), | |
| 585 | - | ) | |
| 586 | - | .ok() | |
| 587 | - | .is_some_and(|v| v == "1"); | |
| 590 | + | // Check if loose-files mode is enabled for this vault. Distinguish | |
| 591 | + | // "not configured" (legitimate default: off) from a real DB error, | |
| 592 | + | // which must not silently flip import into copy mode unnoticed. | |
| 593 | + | let loose_files = match db.conn().query_row( | |
| 594 | + | "SELECT value FROM user_config WHERE key = 'loose_files'", | |
| 595 | + | [], | |
| 596 | + | |row| row.get::<_, String>(0), | |
| 597 | + | ) { | |
| 598 | + | Ok(v) => v == "1", | |
| 599 | + | Err(rusqlite::Error::QueryReturnedNoRows) => false, | |
| 600 | + | Err(e) => { | |
| 601 | + | tracing::warn!("Failed to read loose_files config, defaulting to off: {e}"); | |
| 602 | + | false | |
| 603 | + | } | |
| 604 | + | }; | |
| 588 | 605 | ||
| 589 | 606 | // Phase 2: import files with progress | |
| 590 | 607 | let mut completed = 0usize; |
| @@ -296,7 +296,6 @@ impl BrowserState { | |||
| 296 | 296 | pub fn clear_similarity_search(&mut self) { | |
| 297 | 297 | self.similarity_search_hash = None; | |
| 298 | 298 | self.similarity_source_name = None; | |
| 299 | - | self.similarity_source_name = None; | |
| 300 | 299 | self.refresh_contents(); | |
| 301 | 300 | } | |
| 302 | 301 |
| @@ -370,17 +370,13 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 370 | 370 | // Older dismissals still recoverable via Settings → Reset | |
| 371 | 371 | // suggestions; this is just the fast-path for a stray click. | |
| 372 | 372 | const UNDO_WINDOW: f32 = 5.0; | |
| 373 | - | let show_undo = state | |
| 373 | + | // Bind the tag directly so there's no re-fetch + expect() in render. | |
| 374 | + | let undo_tag = state | |
| 374 | 375 | .last_dismissed_suggestion | |
| 375 | 376 | .as_ref() | |
| 376 | - | .filter(|(c, _, _)| c == &class_str) | |
| 377 | - | .map(|(_, _, at)| at.elapsed().as_secs_f32() < UNDO_WINDOW); | |
| 378 | - | if show_undo == Some(true) { | |
| 379 | - | let (_, tag, _) = state | |
| 380 | - | .last_dismissed_suggestion | |
| 381 | - | .as_ref() | |
| 382 | - | .expect("checked Some above") | |
| 383 | - | .clone(); | |
| 377 | + | .filter(|(c, _, at)| c == &class_str && at.elapsed().as_secs_f32() < UNDO_WINDOW) | |
| 378 | + | .map(|(_, tag, _)| tag.clone()); | |
| 379 | + | if let Some(tag) = undo_tag { | |
| 384 | 380 | ui.add_space(theme::space::XS); | |
| 385 | 381 | ui.horizontal(|ui| { | |
| 386 | 382 | ui.label( |
| @@ -17,10 +17,25 @@ pub struct WaveformData { | |||
| 17 | 17 | pub duration: f64, | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | - | /// Generate waveform display data by downsampling mono samples into min/max peak pairs. | |
| 20 | + | /// Generate waveform display data by downsampling interleaved audio into min/max | |
| 21 | + | /// peak pairs. | |
| 22 | + | /// | |
| 23 | + | /// `samples` is interleaved across `channels`; buckets are taken over *frames* | |
| 24 | + | /// (so duration and the time axis are correct for stereo/multichannel input, not | |
| 25 | + | /// 2x-stretched), and each bucket's min/max spans all channels. Non-finite | |
| 26 | + | /// samples (NaN/inf from a corrupt float WAV) are skipped, and a bucket with no | |
| 27 | + | /// finite sample stores a flat `0.0/0.0` pair rather than the inverted | |
| 28 | + | /// `f32::MAX / f32::MIN` sentinels. | |
| 21 | 29 | #[instrument(skip_all)] | |
| 22 | - | pub fn generate_waveform(samples: &[f32], sample_rate: u32, num_buckets: usize) -> WaveformData { | |
| 23 | - | if samples.is_empty() || num_buckets == 0 || sample_rate == 0 { | |
| 30 | + | pub fn generate_waveform( | |
| 31 | + | samples: &[f32], | |
| 32 | + | channels: u16, | |
| 33 | + | sample_rate: u32, | |
| 34 | + | num_buckets: usize, | |
| 35 | + | ) -> WaveformData { | |
| 36 | + | let channels = channels.max(1) as usize; | |
| 37 | + | let frames = samples.len() / channels; | |
| 38 | + | if frames == 0 || num_buckets == 0 || sample_rate == 0 { | |
| 24 | 39 | return WaveformData { | |
| 25 | 40 | num_buckets: 0, | |
| 26 | 41 | peaks: Vec::new(), | |
| @@ -29,15 +44,14 @@ pub fn generate_waveform(samples: &[f32], sample_rate: u32, num_buckets: usize) | |||
| 29 | 44 | }; | |
| 30 | 45 | } | |
| 31 | 46 | ||
| 32 | - | let duration = samples.len() as f64 / sample_rate as f64; | |
| 47 | + | let duration = frames as f64 / sample_rate as f64; | |
| 33 | 48 | ||
| 34 | - | let bucket_size = samples.len() as f64 / num_buckets as f64; | |
| 49 | + | let bucket_frames = frames as f64 / num_buckets as f64; | |
| 35 | 50 | let mut peaks = Vec::with_capacity(num_buckets * 2); | |
| 36 | 51 | ||
| 37 | 52 | for i in 0..num_buckets { | |
| 38 | - | let start = (i as f64 * bucket_size) as usize; | |
| 39 | - | let end = ((i + 1) as f64 * bucket_size) as usize; | |
| 40 | - | let end = end.min(samples.len()); | |
| 53 | + | let start = (i as f64 * bucket_frames) as usize; | |
| 54 | + | let end = (((i + 1) as f64 * bucket_frames) as usize).min(frames); | |
| 41 | 55 | ||
| 42 | 56 | if start >= end { | |
| 43 | 57 | peaks.push(0.0); | |
| @@ -47,17 +61,29 @@ pub fn generate_waveform(samples: &[f32], sample_rate: u32, num_buckets: usize) | |||
| 47 | 61 | ||
| 48 | 62 | let mut min_val = f32::MAX; | |
| 49 | 63 | let mut max_val = f32::MIN; | |
| 50 | - | for &s in &samples[start..end] { | |
| 51 | - | if s < min_val { | |
| 52 | - | min_val = s; | |
| 53 | - | } | |
| 54 | - | if s > max_val { | |
| 55 | - | max_val = s; | |
| 64 | + | let mut saw_finite = false; | |
| 65 | + | for frame in start..end { | |
| 66 | + | for c in 0..channels { | |
| 67 | + | let s = samples[frame * channels + c]; | |
| 68 | + | if s.is_finite() { | |
| 69 | + | saw_finite = true; | |
| 70 | + | if s < min_val { | |
| 71 | + | min_val = s; | |
| 72 | + | } | |
| 73 | + | if s > max_val { | |
| 74 | + | max_val = s; | |
| 75 | + | } | |
| 76 | + | } | |
| 56 | 77 | } | |
| 57 | 78 | } | |
| 58 | 79 | ||
| 59 | - | peaks.push(min_val); | |
| 60 | - | peaks.push(max_val); | |
| 80 | + | if saw_finite { | |
| 81 | + | peaks.push(min_val); | |
| 82 | + | peaks.push(max_val); | |
| 83 | + | } else { | |
| 84 | + | peaks.push(0.0); | |
| 85 | + | peaks.push(0.0); | |
| 86 | + | } | |
| 61 | 87 | } | |
| 62 | 88 | ||
| 63 | 89 | WaveformData { | |
| @@ -138,7 +164,7 @@ mod tests { | |||
| 138 | 164 | .map(|i| (2.0 * std::f32::consts::PI * 440.0 * i as f32 / sample_rate as f32).sin()) | |
| 139 | 165 | .collect(); | |
| 140 | 166 | ||
| 141 | - | let waveform = generate_waveform(&samples, sample_rate, 100); | |
| 167 | + | let waveform = generate_waveform(&samples, 1, sample_rate, 100); | |
| 142 | 168 | ||
| 143 | 169 | assert_eq!(waveform.num_buckets, 100); | |
| 144 | 170 | assert_eq!(waveform.peaks.len(), 200); | |
| @@ -156,7 +182,7 @@ mod tests { | |||
| 156 | 182 | ||
| 157 | 183 | #[test] | |
| 158 | 184 | fn generate_empty_samples() { | |
| 159 | - | let waveform = generate_waveform(&[], 44100, 50); | |
| 185 | + | let waveform = generate_waveform(&[], 1, 44100, 50); | |
| 160 | 186 | assert_eq!(waveform.num_buckets, 0); | |
| 161 | 187 | assert!(waveform.peaks.is_empty()); | |
| 162 | 188 | } | |
| @@ -164,12 +190,31 @@ mod tests { | |||
| 164 | 190 | #[test] | |
| 165 | 191 | fn generate_zero_buckets() { | |
| 166 | 192 | let samples = vec![0.5; 1000]; | |
| 167 | - | let waveform = generate_waveform(&samples, 44100, 0); | |
| 193 | + | let waveform = generate_waveform(&samples, 1, 44100, 0); | |
| 168 | 194 | assert_eq!(waveform.num_buckets, 0); | |
| 169 | 195 | assert!(waveform.peaks.is_empty()); | |
| 170 | 196 | } | |
| 171 | 197 | ||
| 172 | 198 | #[test] | |
| 199 | + | fn stereo_duration_counts_frames_not_samples() { | |
| 200 | + | // 44100 stereo frames (88200 interleaved samples) = 1 second, not 2. | |
| 201 | + | let samples = vec![0.5f32; 44100 * 2]; | |
| 202 | + | let waveform = generate_waveform(&samples, 2, 44100, 50); | |
| 203 | + | assert!((waveform.duration - 1.0).abs() < 0.001); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | #[test] | |
| 207 | + | fn all_nan_bucket_stores_flat_pair_not_inverted_sentinels() { | |
| 208 | + | let samples = vec![f32::NAN; 1000]; | |
| 209 | + | let waveform = generate_waveform(&samples, 1, 44100, 10); | |
| 210 | + | // Every bucket is all-NaN: must be a flat 0.0/0.0 pair, never MAX/MIN. | |
| 211 | + | for pair in waveform.peaks.chunks_exact(2) { | |
| 212 | + | assert_eq!(pair[0], 0.0); | |
| 213 | + | assert_eq!(pair[1], 0.0); | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | #[test] | |
| 173 | 218 | fn save_and_load_roundtrip() { | |
| 174 | 219 | let db = Database::open_in_memory().unwrap(); | |
| 175 | 220 |
| @@ -6,14 +6,18 @@ use crate::error::CoreError; | |||
| 6 | 6 | ||
| 7 | 7 | /// Peak-normalize samples to the target dBFS level. | |
| 8 | 8 | /// | |
| 9 | - | /// Computes current peak, calculates gain needed to reach `target_db`, | |
| 10 | - | /// and scales all samples. No clipping protection — the caller should | |
| 11 | - | /// ensure `target_db <= 0.0`. | |
| 9 | + | /// Computes current peak, calculates the gain needed to reach `target_db`, and | |
| 10 | + | /// scales all samples. `target_db` is clamped to `<= 0.0` dBFS: peak-normalizing | |
| 11 | + | /// to a positive target means amplifying past full scale, which would silently | |
| 12 | + | /// clip at the integer re-encode, so 0 dBFS (full scale) is the ceiling. | |
| 12 | 13 | pub fn apply_normalize_peak(samples: &mut [f32], target_db: f64) -> Result<(), CoreError> { | |
| 13 | 14 | if samples.is_empty() { | |
| 14 | 15 | return Ok(()); | |
| 15 | 16 | } | |
| 16 | 17 | ||
| 18 | + | // Guard the boundary: a peak target above full scale can only clip. | |
| 19 | + | let target_db = target_db.min(0.0); | |
| 20 | + | ||
| 17 | 21 | let current_peak = peak_db(samples); | |
| 18 | 22 | if current_peak <= -96.0 { | |
| 19 | 23 | // Silent — nothing to normalize | |
| @@ -92,6 +96,16 @@ mod tests { | |||
| 92 | 96 | } | |
| 93 | 97 | ||
| 94 | 98 | #[test] | |
| 99 | + | fn normalize_peak_clamps_positive_target_to_full_scale() { | |
| 100 | + | // A +6 dBFS target would amplify past full scale and clip on re-encode; | |
| 101 | + | // the guard clamps it to 0 dBFS, so the result peaks at ~1.0, not ~2.0. | |
| 102 | + | let mut samples = vec![0.5, -0.5, 0.25]; | |
| 103 | + | apply_normalize_peak(&mut samples, 6.0).unwrap(); | |
| 104 | + | let peak = samples.iter().fold(0.0f32, |max, &s| max.max(s.abs())); | |
| 105 | + | assert!((peak - 1.0).abs() < 0.001, "peak should be clamped to ~1.0, got {peak}"); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | #[test] | |
| 95 | 109 | fn normalize_peak_silence() { | |
| 96 | 110 | let mut samples = vec![0.0, 0.0, 0.0]; | |
| 97 | 111 | apply_normalize_peak(&mut samples, 0.0).unwrap(); |