Skip to main content

max / audiofiles

Knock out ultra-fuzz minor/note items - Peak normalize now clamps target_db to <= 0 dBFS (edit/normalize.rs): a positive peak target only amplifies past full scale and clips on the integer re-encode. (DSP minor) - generate_waveform is now channel-aware and NaN-safe (analysis/waveform.rs): buckets are taken over frames so stereo duration isn't 2x-stretched, min/max spans all channels, non-finite samples are skipped, and an all-NaN bucket stores a flat 0.0/0.0 pair instead of inverted f32::MAX/MIN sentinels. (DSP minor x2) - Import directory walks no longer descend into symlinked directories (import.rs): a link to an ancestor would recurse forever / overflow the stack. Guard added to count_audio_files and all three recursive importers; symlinked files still import. (UI/import minor) - loose_files config read distinguishes "not configured" (default off) from a real DB error, which now warns instead of silently flipping to copy mode. (import minor) - detail.rs undo affordance binds the tag directly, removing an .expect() from the render path. (UI note) - App logs panics with source location before unwinding, so a crash in the egui update loop is diagnosable from the log. (UI note) - Removed a dead duplicate assignment in clear_similarity_search. (UI note) Tests: core 534 green (3 new: peak clamp, stereo duration, all-NaN bucket), browser 224 green, clippy clean. (Note: one pre-existing intermittent core test flake, ~1/8 runs, unrelated to these changes.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 23:31 UTC
Commit: f057fbd3d38ee5de76b5c08b0996d01e76f8ed55
Parent: da3d3dd
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();