max / audiofiles
7 files changed,
+161 insertions,
-54 deletions
| @@ -55,8 +55,14 @@ pub fn spawn_classifier_job(db_path: PathBuf, job: ClassifierJob) -> std::io::Re | |||
| 55 | 55 | let thread = thread::Builder::new() | |
| 56 | 56 | .name("classifier-job".to_string()) | |
| 57 | 57 | .spawn(move || { | |
| 58 | + | // Catch panics so a failed job reports back instead of unwinding the | |
| 59 | + | // thread silently — otherwise the GUI's try_recv() never resolves and | |
| 60 | + | // the trigger buttons stay disabled forever. | |
| 58 | 61 | let event = match Database::open(&db_path) { | |
| 59 | - | Ok(db) => run_job(&db, job), | |
| 62 | + | Ok(db) => std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_job(&db, job))) | |
| 63 | + | .unwrap_or_else(|_| { | |
| 64 | + | ClassifierWorkerEvent::Failed("classifier job panicked (internal error)".to_string()) | |
| 65 | + | }), | |
| 60 | 66 | Err(e) => { | |
| 61 | 67 | error!("Classifier worker failed to open database: {e}"); | |
| 62 | 68 | ClassifierWorkerEvent::Failed(format!("could not open database: {e}")) |
| @@ -117,39 +117,50 @@ fn worker_loop( | |||
| 117 | 117 | ExportCommand::Export { items, config } => { | |
| 118 | 118 | let cancelled = std::sync::atomic::AtomicBool::new(false); | |
| 119 | 119 | ||
| 120 | - | let summary = audiofiles_core::export::run_export( | |
| 121 | - | &items, | |
| 122 | - | &config, | |
| 123 | - | &store, | |
| 124 | - | |completed, total, current_name| { | |
| 125 | - | // Check for cancel between files | |
| 126 | - | if let Ok(ExportCommand::Cancel) | Ok(ExportCommand::Shutdown) = | |
| 127 | - | cmd_rx.try_recv() | |
| 128 | - | { | |
| 129 | - | cancelled.store(true, std::sync::atomic::Ordering::Relaxed); | |
| 130 | - | return false; | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | let _ = event_tx.send(ExportEvent::Progress { | |
| 134 | - | completed, | |
| 135 | - | total, | |
| 136 | - | current_name: current_name.to_string(), | |
| 137 | - | }); | |
| 138 | - | ||
| 139 | - | true | |
| 140 | - | }, | |
| 141 | - | ); | |
| 120 | + | // Catch panics so a bad file reports an error instead of unwinding | |
| 121 | + | // the worker thread — that would wedge the UI in the exporting | |
| 122 | + | // state, since ExportEvent::Complete is the only exit. | |
| 123 | + | let summary = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| 124 | + | audiofiles_core::export::run_export( | |
| 125 | + | &items, | |
| 126 | + | &config, | |
| 127 | + | &store, | |
| 128 | + | |completed, total, current_name| { | |
| 129 | + | // Check for cancel between files | |
| 130 | + | if let Ok(ExportCommand::Cancel) | Ok(ExportCommand::Shutdown) = | |
| 131 | + | cmd_rx.try_recv() | |
| 132 | + | { | |
| 133 | + | cancelled.store(true, std::sync::atomic::Ordering::Relaxed); | |
| 134 | + | return false; | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | let _ = event_tx.send(ExportEvent::Progress { | |
| 138 | + | completed, | |
| 139 | + | total, | |
| 140 | + | current_name: current_name.to_string(), | |
| 141 | + | }); | |
| 142 | + | ||
| 143 | + | true | |
| 144 | + | }, | |
| 145 | + | ) | |
| 146 | + | })); | |
| 142 | 147 | ||
| 143 | 148 | match summary { | |
| 144 | - | Ok(ExportSummary { total, errors }) => { | |
| 149 | + | Ok(Ok(ExportSummary { total, errors })) => { | |
| 145 | 150 | let _ = event_tx.send(ExportEvent::Complete { total, errors }); | |
| 146 | 151 | } | |
| 147 | - | Err(e) => { | |
| 152 | + | Ok(Err(e)) => { | |
| 148 | 153 | let _ = event_tx.send(ExportEvent::Complete { | |
| 149 | 154 | total: 0, | |
| 150 | 155 | errors: vec![("export".to_string(), e.to_string())], | |
| 151 | 156 | }); | |
| 152 | 157 | } | |
| 158 | + | Err(_panic) => { | |
| 159 | + | let _ = event_tx.send(ExportEvent::Complete { | |
| 160 | + | total: 0, | |
| 161 | + | errors: vec![("export".to_string(), "export panicked (internal error)".to_string())], | |
| 162 | + | }); | |
| 163 | + | } | |
| 153 | 164 | } | |
| 154 | 165 | } | |
| 155 | 166 | } |
| @@ -266,22 +266,37 @@ impl ImportContext<'_> { | |||
| 266 | 266 | let name = audiofiles_core::util::get_filename(path, "unknown"); | |
| 267 | 267 | self.send_progress(name); | |
| 268 | 268 | ||
| 269 | - | match import_single_file(path, vfs_id, parent_id, self.store, self.db, self.loose_files) { | |
| 270 | - | Ok(ImportFileResult::Imported(hash, ext)) => { | |
| 269 | + | // Catch panics so one malformed file can't unwind and kill the import | |
| 270 | + | // worker thread — that would wedge the UI in the progress screen forever, | |
| 271 | + | // since ImportEvent::Complete (sent only at loop end) is the only event | |
| 272 | + | // that exits Importing. A panicked file is counted as an error and the | |
| 273 | + | // import continues to completion. | |
| 274 | + | let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| 275 | + | import_single_file(path, vfs_id, parent_id, self.store, self.db, self.loose_files) | |
| 276 | + | })); | |
| 277 | + | match outcome { | |
| 278 | + | Ok(Ok(ImportFileResult::Imported(hash, ext))) => { | |
| 271 | 279 | self.imported.push((hash, ext)); | |
| 272 | 280 | *self.completed += 1; | |
| 273 | 281 | } | |
| 274 | - | Ok(ImportFileResult::Duplicate) => { | |
| 282 | + | Ok(Ok(ImportFileResult::Duplicate)) => { | |
| 275 | 283 | *self.duplicates += 1; | |
| 276 | 284 | *self.completed += 1; | |
| 277 | 285 | } | |
| 278 | - | Err(e) => { | |
| 286 | + | Ok(Err(e)) => { | |
| 279 | 287 | *self.errors += 1; | |
| 280 | 288 | let _ = self.event_tx.send(ImportEvent::FileError { | |
| 281 | 289 | path: path.display().to_string(), | |
| 282 | 290 | error: e.to_string(), | |
| 283 | 291 | }); | |
| 284 | 292 | } | |
| 293 | + | Err(_panic) => { | |
| 294 | + | *self.errors += 1; | |
| 295 | + | let _ = self.event_tx.send(ImportEvent::FileError { | |
| 296 | + | path: path.display().to_string(), | |
| 297 | + | error: "import panicked (internal error)".to_string(), | |
| 298 | + | }); | |
| 299 | + | } | |
| 285 | 300 | } | |
| 286 | 301 | } | |
| 287 | 302 | } |
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | //! Normalize operations: peak normalize and LUFS normalize. | |
| 2 | 2 | ||
| 3 | 3 | use crate::analysis::basic::peak_db; | |
| 4 | + | use crate::analysis::loudness::measure_lufs; | |
| 4 | 5 | use crate::error::CoreError; | |
| 5 | 6 | ||
| 6 | 7 | /// Peak-normalize samples to the target dBFS level. | |
| @@ -29,36 +30,36 @@ pub fn apply_normalize_peak(samples: &mut [f32], target_db: f64) -> Result<(), C | |||
| 29 | 30 | Ok(()) | |
| 30 | 31 | } | |
| 31 | 32 | ||
| 32 | - | /// LUFS-normalize samples to the target loudness level. | |
| 33 | + | /// LUFS-normalize samples to the target integrated loudness. | |
| 33 | 34 | /// | |
| 34 | - | /// Uses a simplified integrated loudness measurement (mean square of | |
| 35 | - | /// all samples, which approximates ITU-R BS.1770 for short samples). | |
| 35 | + | /// Measures integrated loudness with the ITU-R BS.1770-4 meter | |
| 36 | + | /// ([`measure_lufs`] — the same meter analysis reports), then applies a flat | |
| 37 | + | /// gain to reach `target_lufs`. Like the peak path, this applies no clipping: | |
| 38 | + | /// loudness normalize can legitimately push peaks past full scale, and the | |
| 39 | + | /// final encode is the single place that clamps. `channels` is unused because | |
| 40 | + | /// the meter operates on the interleaved buffer (matching how analysis | |
| 41 | + | /// measures loudness), but is kept for signature symmetry with the other ops. | |
| 36 | 42 | pub fn apply_normalize_lufs( | |
| 37 | 43 | samples: &mut [f32], | |
| 38 | 44 | _channels: u16, | |
| 39 | - | _sample_rate: u32, | |
| 45 | + | sample_rate: u32, | |
| 40 | 46 | target_lufs: f64, | |
| 41 | 47 | ) -> Result<(), CoreError> { | |
| 42 | 48 | if samples.is_empty() { | |
| 43 | 49 | return Ok(()); | |
| 44 | 50 | } | |
| 45 | 51 | ||
| 46 | - | // Compute integrated loudness (simplified: RMS-based approximation) | |
| 47 | - | let sum_sq: f64 = samples.iter().map(|&s| (s as f64) * (s as f64)).sum(); | |
| 48 | - | let mean_sq = sum_sq / samples.len() as f64; | |
| 49 | - | if mean_sq <= 1e-20 { | |
| 50 | - | // Silent — nothing to normalize | |
| 52 | + | let current_lufs = measure_lufs(samples, sample_rate); | |
| 53 | + | if current_lufs <= -70.0 { | |
| 54 | + | // Silent / below the absolute gate — nothing to normalize. | |
| 51 | 55 | return Ok(()); | |
| 52 | 56 | } | |
| 53 | 57 | ||
| 54 | - | // LUFS ≈ -0.691 + 10 * log10(mean_square) for simple signals | |
| 55 | - | // We use this approximation for short samples | |
| 56 | - | let current_lufs = -0.691 + 10.0 * mean_sq.log10(); | |
| 57 | 58 | let gain_db = target_lufs - current_lufs; | |
| 58 | 59 | let scale = 10.0_f64.powf(gain_db / 20.0) as f32; | |
| 59 | 60 | ||
| 60 | 61 | for sample in samples.iter_mut() { | |
| 61 | - | *sample = (*sample * scale).clamp(-1.0, 1.0); | |
| 62 | + | *sample *= scale; | |
| 62 | 63 | } | |
| 63 | 64 | ||
| 64 | 65 | Ok(()) | |
| @@ -132,4 +133,36 @@ mod tests { | |||
| 132 | 133 | apply_normalize_lufs(&mut samples, 1, 44100, -14.0).unwrap(); | |
| 133 | 134 | assert!(samples.iter().all(|&s| s == 0.0)); | |
| 134 | 135 | } | |
| 136 | + | ||
| 137 | + | #[test] | |
| 138 | + | fn normalize_lufs_hits_target_within_tolerance() { | |
| 139 | + | // A 1kHz tone at half amplitude, long enough for the BS.1770 gate. | |
| 140 | + | let sr = 48000u32; | |
| 141 | + | let mut samples: Vec<f32> = (0..sr) | |
| 142 | + | .map(|i| 0.5 * (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / sr as f32).sin()) | |
| 143 | + | .collect(); | |
| 144 | + | ||
| 145 | + | apply_normalize_lufs(&mut samples, 1, sr, -14.0).unwrap(); | |
| 146 | + | ||
| 147 | + | let measured = measure_lufs(&samples, sr); | |
| 148 | + | assert!( | |
| 149 | + | (measured - (-14.0)).abs() < 0.5, | |
| 150 | + | "normalized loudness should be ~-14 LUFS, got {measured}" | |
| 151 | + | ); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | #[test] | |
| 155 | + | fn normalize_lufs_does_not_silently_clip_when_boosting() { | |
| 156 | + | // A quiet tone boosted toward 0 LUFS will exceed full scale; the LUFS | |
| 157 | + | // path must NOT clamp (the old code did, silently distorting the tone). | |
| 158 | + | let sr = 48000u32; | |
| 159 | + | let mut samples: Vec<f32> = (0..sr) | |
| 160 | + | .map(|i| 0.05 * (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / sr as f32).sin()) | |
| 161 | + | .collect(); | |
| 162 | + | ||
| 163 | + | apply_normalize_lufs(&mut samples, 1, sr, 0.0).unwrap(); | |
| 164 | + | ||
| 165 | + | let peak = samples.iter().fold(0.0f32, |m, &s| m.max(s.abs())); | |
| 166 | + | assert!(peak > 1.0, "boosting a quiet tone to 0 LUFS should overshoot full scale, got peak {peak}"); | |
| 167 | + | } | |
| 135 | 168 | } |
| @@ -140,20 +140,32 @@ fn edit_worker_loop( | |||
| 140 | 140 | continue; | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | - | match process_edit(&path, &operation, &cancel_flag) { | |
| 144 | - | Ok(result_path) => { | |
| 143 | + | // Catch panics so a malformed file (bad decode/encode) reports an | |
| 144 | + | // error instead of unwinding and killing the worker thread — | |
| 145 | + | // which would silently stop all future edits with no UI signal. | |
| 146 | + | let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| 147 | + | process_edit(&path, &operation, &cancel_flag) | |
| 148 | + | })); | |
| 149 | + | match outcome { | |
| 150 | + | Ok(Ok(result_path)) => { | |
| 145 | 151 | let _ = event_tx.send(EditEvent::Complete { | |
| 146 | 152 | source_hash: hash, | |
| 147 | 153 | result_path, | |
| 148 | 154 | operation, | |
| 149 | 155 | }); | |
| 150 | 156 | } | |
| 151 | - | Err(e) => { | |
| 157 | + | Ok(Err(e)) => { | |
| 152 | 158 | let _ = event_tx.send(EditEvent::Error { | |
| 153 | 159 | hash, | |
| 154 | 160 | error: e.to_string(), | |
| 155 | 161 | }); | |
| 156 | 162 | } | |
| 163 | + | Err(_panic) => { | |
| 164 | + | let _ = event_tx.send(EditEvent::Error { | |
| 165 | + | hash, | |
| 166 | + | error: "edit panicked (internal error)".to_string(), | |
| 167 | + | }); | |
| 168 | + | } | |
| 157 | 169 | } | |
| 158 | 170 | } | |
| 159 | 171 | } |
| @@ -124,8 +124,13 @@ fn forge_worker_loop( | |||
| 124 | 124 | if cancel_flag.load(Ordering::Acquire) { | |
| 125 | 125 | continue; | |
| 126 | 126 | } | |
| 127 | - | match chop_to_temp(&source_path, &base_name, &method) { | |
| 128 | - | Ok(staging) => { | |
| 127 | + | // Isolate panics: a bad file must report an error, not unwind | |
| 128 | + | // the worker thread and silently stop all future forge jobs. | |
| 129 | + | let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| 130 | + | chop_to_temp(&source_path, &base_name, &method) | |
| 131 | + | })); | |
| 132 | + | match outcome { | |
| 133 | + | Ok(Ok(staging)) => { | |
| 129 | 134 | // If cancelled mid-flight, drop the staged temp files | |
| 130 | 135 | // rather than handing them to the GUI. | |
| 131 | 136 | if cancel_flag.load(Ordering::Acquire) { | |
| @@ -136,11 +141,16 @@ fn forge_worker_loop( | |||
| 136 | 141 | } | |
| 137 | 142 | let _ = event_tx.send(ForgeEvent::ChopComplete { staging }); | |
| 138 | 143 | } | |
| 139 | - | Err(e) => { | |
| 144 | + | Ok(Err(e)) => { | |
| 140 | 145 | let _ = event_tx.send(ForgeEvent::Error { | |
| 141 | 146 | error: e.to_string(), | |
| 142 | 147 | }); | |
| 143 | 148 | } | |
| 149 | + | Err(_panic) => { | |
| 150 | + | let _ = event_tx.send(ForgeEvent::Error { | |
| 151 | + | error: "chop panicked (internal error)".to_string(), | |
| 152 | + | }); | |
| 153 | + | } | |
| 144 | 154 | } | |
| 145 | 155 | } | |
| 146 | 156 | ForgeCommand::Conform { | |
| @@ -154,19 +164,27 @@ fn forge_worker_loop( | |||
| 154 | 164 | if cancel_flag.load(Ordering::Acquire) { | |
| 155 | 165 | continue; | |
| 156 | 166 | } | |
| 157 | - | match conform_to_temp(&source_path, &base_name, &target, auto_trim) { | |
| 158 | - | Ok(staging) => { | |
| 167 | + | let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| 168 | + | conform_to_temp(&source_path, &base_name, &target, auto_trim) | |
| 169 | + | })); | |
| 170 | + | match outcome { | |
| 171 | + | Ok(Ok(staging)) => { | |
| 159 | 172 | if cancel_flag.load(Ordering::Acquire) { | |
| 160 | 173 | let _ = std::fs::remove_file(&staging.temp_path); | |
| 161 | 174 | continue; | |
| 162 | 175 | } | |
| 163 | 176 | let _ = event_tx.send(ForgeEvent::ConformComplete { staging }); | |
| 164 | 177 | } | |
| 165 | - | Err(e) => { | |
| 178 | + | Ok(Err(e)) => { | |
| 166 | 179 | let _ = event_tx.send(ForgeEvent::Error { | |
| 167 | 180 | error: e.to_string(), | |
| 168 | 181 | }); | |
| 169 | 182 | } | |
| 183 | + | Err(_panic) => { | |
| 184 | + | let _ = event_tx.send(ForgeEvent::Error { | |
| 185 | + | error: "conform panicked (internal error)".to_string(), | |
| 186 | + | }); | |
| 187 | + | } | |
| 170 | 188 | } | |
| 171 | 189 | } | |
| 172 | 190 | } |
| @@ -187,13 +187,25 @@ impl SampleStore { | |||
| 187 | 187 | } | |
| 188 | 188 | } | |
| 189 | 189 | ||
| 190 | - | // Insert into DB (ignore if hash already exists) | |
| 190 | + | // Insert into DB (ignore if hash already exists). If this fails after we | |
| 191 | + | // just wrote a fresh blob, that blob is unreferenced — no row points at | |
| 192 | + | // it, and since `needs_write` was true nothing pointed at it before | |
| 193 | + | // either. Unlink it so a DB-write failure (disk full on the WAL, lock | |
| 194 | + | // timeout) can't leak an unreachable blob that no GC path reclaims — | |
| 195 | + | // upholding this module's "never orphan a blob" invariant (see remove()). | |
| 196 | + | // When `needs_write` was false the blob pre-existed and may be shared by | |
| 197 | + | // another row, so it is left untouched. | |
| 191 | 198 | let now = unix_now(); | |
| 192 | - | db.conn().execute( | |
| 199 | + | if let Err(e) = db.conn().execute( | |
| 193 | 200 | "INSERT OR IGNORE INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified, duration) | |
| 194 | 201 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", | |
| 195 | 202 | rusqlite::params![hash, original_name, ext, file_size, now, now, duration], | |
| 196 | - | )?; | |
| 203 | + | ) { | |
| 204 | + | if needs_write { | |
| 205 | + | let _ = fs::remove_file(&dest); | |
| 206 | + | } | |
| 207 | + | return Err(CoreError::Db(e)); | |
| 208 | + | } | |
| 197 | 209 | ||
| 198 | 210 | Ok(hash) | |
| 199 | 211 | } |