max / audiofiles
11 files changed,
+301 insertions,
-16 deletions
| @@ -4,6 +4,14 @@ All notable changes to audiofiles will be documented in this file. | |||
| 4 | 4 | ||
| 5 | 5 | Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). | |
| 6 | 6 | ||
| 7 | + | ## [Unreleased] | |
| 8 | + | ||
| 9 | + | ### Added | |
| 10 | + | - Forge: resample overshoot handling. Conforming to an integer target now detects true-peak overshoot (>1.0); by default the signal is left untouched and a warning is shown that it will clip. A new Settings > Forge toggle, "Auto-trim resample overshoot", opts in to the gentlest reversible fix (a single linear gain to full scale), reported when applied. 32-bit float targets are unaffected (lossless passthrough). | |
| 11 | + | ||
| 12 | + | ### Fixed | |
| 13 | + | - Store: importing identical bytes under a different file extension no longer leaves an unreachable orphan blob on disk; the import reuses the canonical content-addressed blob, restoring "import twice is a no-op". | |
| 14 | + | ||
| 7 | 15 | ## [0.4.1] - 2026-05-31 | |
| 8 | 16 | ||
| 9 | 17 | ### Added |
| @@ -4964,6 +4964,7 @@ dependencies = [ | |||
| 4964 | 4964 | "reqwest", | |
| 4965 | 4965 | "serde", | |
| 4966 | 4966 | "serde_json", | |
| 4967 | + | "sha2", | |
| 4967 | 4968 | "thiserror 2.0.18", | |
| 4968 | 4969 | "tokio", | |
| 4969 | 4970 | "tokio-stream", |
| @@ -16,7 +16,7 @@ use audiofiles_core::edit::EditOperation; | |||
| 16 | 16 | use audiofiles_core::edit::worker::{EditCommand, EditEvent, EditWorkerHandle}; | |
| 17 | 17 | use audiofiles_core::export::profile::DeviceProfileSummary; | |
| 18 | 18 | use audiofiles_core::export::ExportItem; | |
| 19 | - | use audiofiles_core::forge::{ChopMethod, ConformTarget}; | |
| 19 | + | use audiofiles_core::forge::{ChopMethod, ConformResult, ConformTarget}; | |
| 20 | 20 | use audiofiles_core::search::SearchFilter; | |
| 21 | 21 | use audiofiles_core::collections::Collection; | |
| 22 | 22 | use audiofiles_core::store::SampleStore; | |
| @@ -793,8 +793,19 @@ impl Backend for DirectBackend { | |||
| 793 | 793 | name: &str, | |
| 794 | 794 | parent_id: Option<NodeId>, | |
| 795 | 795 | target: &ConformTarget, | |
| 796 | - | ) -> BackendResult<String> { | |
| 796 | + | ) -> BackendResult<ConformResult> { | |
| 797 | 797 | let db = self.db.lock(); | |
| 798 | + | // Overshoot policy: trim to the ceiling only when the user has opted in; | |
| 799 | + | // the default leaves the signal untouched and merely reports. | |
| 800 | + | let auto_trim = db | |
| 801 | + | .conn() | |
| 802 | + | .query_row( | |
| 803 | + | "SELECT value FROM user_config WHERE key = ?1", | |
| 804 | + | [super::FORGE_AUTO_TRIM_OVERSHOOT_KEY], | |
| 805 | + | |row| row.get::<_, String>(0), | |
| 806 | + | ) | |
| 807 | + | .ok() | |
| 808 | + | .is_some_and(|v| v == "true"); | |
| 798 | 809 | let path = audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?; | |
| 799 | 810 | Ok(audiofiles_core::forge::conform_to_vfs( | |
| 800 | 811 | &self.store, | |
| @@ -804,6 +815,7 @@ impl Backend for DirectBackend { | |||
| 804 | 815 | name, | |
| 805 | 816 | parent_id, | |
| 806 | 817 | target, | |
| 818 | + | auto_trim, | |
| 807 | 819 | )?) | |
| 808 | 820 | } | |
| 809 | 821 |
| @@ -18,7 +18,7 @@ use audiofiles_core::analysis::AnalysisResult; | |||
| 18 | 18 | use audiofiles_core::edit::EditOperation; | |
| 19 | 19 | use audiofiles_core::export::profile::DeviceProfileSummary; | |
| 20 | 20 | use audiofiles_core::export::{ExportConfig, ExportItem}; | |
| 21 | - | use audiofiles_core::forge::{ChopMethod, ConformTarget}; | |
| 21 | + | use audiofiles_core::forge::{ChopMethod, ConformResult, ConformTarget}; | |
| 22 | 22 | use audiofiles_core::search::SearchFilter; | |
| 23 | 23 | use audiofiles_core::collections::Collection; | |
| 24 | 24 | use audiofiles_core::vfs::{Vfs, VfsNode, VfsNodeWithAnalysis}; | |
| @@ -153,6 +153,13 @@ pub struct StorageStats { | |||
| 153 | 153 | pub db_bytes: u64, | |
| 154 | 154 | } | |
| 155 | 155 | ||
| 156 | + | /// User-config key (in the `user_config` table) for the forge's overshoot | |
| 157 | + | /// policy. When the stored value is `"true"`, a conform that overshoots full | |
| 158 | + | /// scale at an integer target is trimmed to the ceiling (the gentlest reversible | |
| 159 | + | /// fix); otherwise (default) the signal is left untouched and the overshoot is | |
| 160 | + | /// only reported, leaving the encoder's clamp as the disclosed last resort. | |
| 161 | + | pub const FORGE_AUTO_TRIM_OVERSHOOT_KEY: &str = "forge.auto_trim_overshoot"; | |
| 162 | + | ||
| 156 | 163 | /// The core abstraction separating UI from data access. | |
| 157 | 164 | /// | |
| 158 | 165 | /// Every method is synchronous and blocking. The trait is `Send + Sync` so it | |
| @@ -433,7 +440,10 @@ pub trait Backend: Send + Sync { | |||
| 433 | 440 | ) -> BackendResult<usize>; | |
| 434 | 441 | ||
| 435 | 442 | /// Conform a sample to `target`, writing the result as a new sibling sample | |
| 436 | - | /// under `parent_id`. Returns the new sample's hash. | |
| 443 | + | /// under `parent_id`. The result carries the new sample's hash plus any | |
| 444 | + | /// true-peak overshoot the conform surfaced (see [`ConformResult`]); whether | |
| 445 | + | /// an overshoot is trimmed or left for the encoder follows the | |
| 446 | + | /// [`FORGE_AUTO_TRIM_OVERSHOOT_KEY`] user-config toggle. | |
| 437 | 447 | fn conform_sample( | |
| 438 | 448 | &self, | |
| 439 | 449 | vfs_id: VfsId, | |
| @@ -442,7 +452,7 @@ pub trait Backend: Send + Sync { | |||
| 442 | 452 | name: &str, | |
| 443 | 453 | parent_id: Option<NodeId>, | |
| 444 | 454 | target: &ConformTarget, | |
| 445 | - | ) -> BackendResult<String>; | |
| 455 | + | ) -> BackendResult<ConformResult>; | |
| 446 | 456 | ||
| 447 | 457 | // --- Config --- | |
| 448 | 458 |
| @@ -153,17 +153,45 @@ impl BrowserState { | |||
| 153 | 153 | self.forge.busy = false; | |
| 154 | 154 | ||
| 155 | 155 | match result { | |
| 156 | - | Ok(_) => { | |
| 157 | - | self.status = format!( | |
| 156 | + | Ok(conformed) => { | |
| 157 | + | let mut msg = format!( | |
| 158 | 158 | "Conformed for {device_name} ({} Hz, {}-bit)", | |
| 159 | 159 | target.sample_rate, target.bit_depth | |
| 160 | 160 | ); | |
| 161 | + | // Surface any true-peak overshoot so the encoder's clamp is never | |
| 162 | + | // a silent loss: either it was trimmed (opt-in) or it will clip. | |
| 163 | + | if let Some(o) = conformed.overshoot { | |
| 164 | + | use audiofiles_core::forge::OvershootAction; | |
| 165 | + | match o.action { | |
| 166 | + | OvershootAction::Trimmed { gain_db } => msg.push_str(&format!( | |
| 167 | + | " — true-peak +{:.1} dB trimmed {:.1} dB to avoid clipping", | |
| 168 | + | o.peak_dbfs, | |
| 169 | + | gain_db.abs() | |
| 170 | + | )), | |
| 171 | + | OvershootAction::Flagged => msg.push_str(&format!( | |
| 172 | + | " — warning: true-peak +{:.1} dB will clip (enable auto-trim in Settings to prevent)", | |
| 173 | + | o.peak_dbfs | |
| 174 | + | )), | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | self.status = msg; | |
| 161 | 178 | self.refresh_contents(); | |
| 162 | 179 | } | |
| 163 | 180 | Err(e) => self.status = format!("Conform failed: {e}"), | |
| 164 | 181 | } | |
| 165 | 182 | } | |
| 166 | 183 | ||
| 184 | + | /// Toggle the forge overshoot policy and persist it. When on, conforms that | |
| 185 | + | /// overshoot full scale at an integer target are trimmed to the ceiling; | |
| 186 | + | /// when off (default) the signal is left untouched and only reported. | |
| 187 | + | pub fn toggle_forge_auto_trim_overshoot(&mut self) { | |
| 188 | + | self.forge_auto_trim_overshoot = !self.forge_auto_trim_overshoot; | |
| 189 | + | let _ = self.backend.set_config( | |
| 190 | + | crate::backend::FORGE_AUTO_TRIM_OVERSHOOT_KEY, | |
| 191 | + | if self.forge_auto_trim_overshoot { "true" } else { "false" }, | |
| 192 | + | ); | |
| 193 | + | } | |
| 194 | + | ||
| 167 | 195 | /// Batch trim leading/trailing silence across the current selection. Reuses | |
| 168 | 196 | /// the edit pipeline via the `TrimSilence` operation. | |
| 169 | 197 | pub fn batch_trim_silence(&mut self, threshold_db: f64) { |
| @@ -148,6 +148,10 @@ pub struct BrowserState { | |||
| 148 | 148 | pub sample_rate: f32, | |
| 149 | 149 | pub loop_enabled: bool, | |
| 150 | 150 | pub autoplay: bool, | |
| 151 | + | /// Forge overshoot policy: when true, a conform that overshoots full scale at | |
| 152 | + | /// an integer target is trimmed to the ceiling; when false (default) the | |
| 153 | + | /// signal is left untouched and the overshoot is only reported. | |
| 154 | + | pub forge_auto_trim_overshoot: bool, | |
| 151 | 155 | ||
| 152 | 156 | // Instrument | |
| 153 | 157 | pub instrument_visible: bool, | |
| @@ -381,6 +385,12 @@ impl BrowserState { | |||
| 381 | 385 | // Load preview settings | |
| 382 | 386 | let loop_enabled = backend.get_config("preview_loop").ok().flatten().as_deref() == Some("1"); | |
| 383 | 387 | let autoplay = backend.get_config("preview_autoplay").ok().flatten().as_deref() == Some("1"); | |
| 388 | + | let forge_auto_trim_overshoot = backend | |
| 389 | + | .get_config(crate::backend::FORGE_AUTO_TRIM_OVERSHOOT_KEY) | |
| 390 | + | .ok() | |
| 391 | + | .flatten() | |
| 392 | + | .as_deref() | |
| 393 | + | == Some("true"); | |
| 384 | 394 | ||
| 385 | 395 | // First-run VFS banner | |
| 386 | 396 | let vfs_explained = backend.get_config("vfs_explained").ok().flatten().as_deref() == Some("1"); | |
| @@ -463,6 +473,7 @@ impl BrowserState { | |||
| 463 | 473 | sample_rate, | |
| 464 | 474 | loop_enabled, | |
| 465 | 475 | autoplay, | |
| 476 | + | forge_auto_trim_overshoot, | |
| 466 | 477 | instrument_visible: false, | |
| 467 | 478 | instrument_root_note: 60, | |
| 468 | 479 | instrument_locked: false, |
| @@ -23,6 +23,8 @@ pub fn draw_settings_panel(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 23 | 23 | ui.add_space(theme::space::SM); | |
| 24 | 24 | draw_preview_section(ui, state); | |
| 25 | 25 | ui.add_space(theme::space::SM); | |
| 26 | + | draw_forge_section(ui, state); | |
| 27 | + | ui.add_space(theme::space::SM); | |
| 26 | 28 | draw_display_section(ui, state); | |
| 27 | 29 | ui.add_space(theme::space::SM); | |
| 28 | 30 | draw_license_section(ui, state); | |
| @@ -508,6 +510,28 @@ fn draw_preview_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 508 | 510 | }); | |
| 509 | 511 | } | |
| 510 | 512 | ||
| 513 | + | // ── Forge section ── | |
| 514 | + | ||
| 515 | + | fn draw_forge_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 516 | + | egui::CollapsingHeader::new(egui::RichText::new("Forge").strong()) | |
| 517 | + | .default_open(false) | |
| 518 | + | .show(ui, |ui| { | |
| 519 | + | let mut auto_trim = state.forge_auto_trim_overshoot; | |
| 520 | + | if ui | |
| 521 | + | .checkbox(&mut auto_trim, "Auto-trim resample overshoot") | |
| 522 | + | .on_hover_text( | |
| 523 | + | "Resampling can push peaks just past full scale. Off (default): the \ | |
| 524 | + | signal is left untouched and a warning is shown if a conform will clip. \ | |
| 525 | + | On: the forge applies the smallest gain reduction to bring the peak back \ | |
| 526 | + | to full scale, avoiding the clip.", | |
| 527 | + | ) | |
| 528 | + | .changed() | |
| 529 | + | { | |
| 530 | + | state.toggle_forge_auto_trim_overshoot(); | |
| 531 | + | } | |
| 532 | + | }); | |
| 533 | + | } | |
| 534 | + | ||
| 511 | 535 | // ── Display section ── | |
| 512 | 536 | ||
| 513 | 537 | fn draw_display_section(ui: &mut egui::Ui, state: &mut BrowserState) { |
| @@ -63,6 +63,66 @@ pub struct ConformedAudio { | |||
| 63 | 63 | pub bit_depth: u16, | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | + | /// Peak absolute amplitude across all samples; `0.0` for an empty buffer. | |
| 67 | + | pub fn peak_amplitude(samples: &[f32]) -> f32 { | |
| 68 | + | samples.iter().fold(0.0f32, |m, &s| m.max(s.abs())) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// What the forge did about an integer-target true-peak overshoot. | |
| 72 | + | #[derive(Debug, Clone, Copy, PartialEq)] | |
| 73 | + | pub enum OvershootAction { | |
| 74 | + | /// Left untouched (default). The encoder's clamp — its documented | |
| 75 | + | /// last-resort exception — will hard-clip the out-of-range samples. | |
| 76 | + | Flagged, | |
| 77 | + | /// Scaled down by `gain_db` (negative) so the true peak sits at full scale, | |
| 78 | + | /// avoiding the clamp. Reversible (multiply back up) and reported, so it is | |
| 79 | + | /// never a silent edit. | |
| 80 | + | Trimmed { gain_db: f32 }, | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// Report of a true-peak overshoot detected at conform time. Only produced when | |
| 84 | + | /// the conformed buffer exceeds full scale (`peak > 1.0`) at an integer target. | |
| 85 | + | #[derive(Debug, Clone, Copy, PartialEq)] | |
| 86 | + | pub struct OvershootReport { | |
| 87 | + | /// True peak of the conformed buffer in dBFS (always `> 0.0` here). | |
| 88 | + | pub peak_dbfs: f32, | |
| 89 | + | /// Whether the overshoot was left for the encoder to clamp, or trimmed. | |
| 90 | + | pub action: OvershootAction, | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// Inspect a conformed buffer for true-peak overshoot (band-limited resampling | |
| 94 | + | /// can reconstruct inter-sample peaks above the source's, pushing values past | |
| 95 | + | /// `±1.0`) and optionally trim it. Returns `None` when nothing overshoots. | |
| 96 | + | /// | |
| 97 | + | /// Callers skip this for 32-bit float targets, which store the f32 verbatim and | |
| 98 | + | /// never clip. | |
| 99 | + | /// | |
| 100 | + | /// - `auto_trim = false` (default): leaves the signal byte-for-byte intact and | |
| 101 | + | /// only reports — the forge makes no implicit edit to the user's audio; the | |
| 102 | + | /// encoder clamp handles the out-of-range values as the necessary exception. | |
| 103 | + | /// - `auto_trim = true`: applies the gentlest reversible fix — a single linear | |
| 104 | + | /// gain bringing the true peak to exactly full scale — and reports the dB | |
| 105 | + | /// applied, so the change is visible, not silent. | |
| 106 | + | pub fn resolve_overshoot(samples: &mut [f32], auto_trim: bool) -> Option<OvershootReport> { | |
| 107 | + | let peak = peak_amplitude(samples); | |
| 108 | + | if peak <= 1.0 { | |
| 109 | + | return None; | |
| 110 | + | } | |
| 111 | + | let peak_dbfs = 20.0 * peak.log10(); | |
| 112 | + | if auto_trim { | |
| 113 | + | let gain = 1.0 / peak; | |
| 114 | + | for s in samples.iter_mut() { | |
| 115 | + | *s *= gain; | |
| 116 | + | } | |
| 117 | + | Some(OvershootReport { | |
| 118 | + | peak_dbfs, | |
| 119 | + | action: OvershootAction::Trimmed { gain_db: 20.0 * gain.log10() }, | |
| 120 | + | }) | |
| 121 | + | } else { | |
| 122 | + | Some(OvershootReport { peak_dbfs, action: OvershootAction::Flagged }) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 66 | 126 | /// Conform interleaved `samples` (at `src_rate`, `src_channels` wide) to `target`. | |
| 67 | 127 | /// | |
| 68 | 128 | /// Applies channel conversion then resampling; the bit depth is carried through | |
| @@ -190,4 +250,49 @@ mod tests { | |||
| 190 | 250 | }; | |
| 191 | 251 | assert!(conform(&[0.0, 1.0], 1, 44100, &target).is_err()); | |
| 192 | 252 | } | |
| 253 | + | ||
| 254 | + | #[test] | |
| 255 | + | fn peak_amplitude_finds_max_abs() { | |
| 256 | + | assert_eq!(peak_amplitude(&[]), 0.0); | |
| 257 | + | assert_eq!(peak_amplitude(&[0.2, -0.9, 0.5]), 0.9); | |
| 258 | + | assert_eq!(peak_amplitude(&[-1.3, 0.1]), 1.3); | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | #[test] | |
| 262 | + | fn resolve_overshoot_none_when_within_range() { | |
| 263 | + | let mut buf = vec![0.5, -1.0, 0.99]; | |
| 264 | + | assert!(resolve_overshoot(&mut buf, false).is_none()); | |
| 265 | + | assert!(resolve_overshoot(&mut buf, true).is_none()); | |
| 266 | + | // Untouched. | |
| 267 | + | assert_eq!(buf, vec![0.5, -1.0, 0.99]); | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | #[test] | |
| 271 | + | fn resolve_overshoot_flags_without_altering() { | |
| 272 | + | let mut buf = vec![0.5, -1.25, 0.8]; | |
| 273 | + | let report = resolve_overshoot(&mut buf, false).expect("overshoot"); | |
| 274 | + | assert_eq!(report.action, OvershootAction::Flagged); | |
| 275 | + | // peak 1.25 -> ~1.94 dBFS | |
| 276 | + | assert!((report.peak_dbfs - 1.938).abs() < 0.01, "got {}", report.peak_dbfs); | |
| 277 | + | // Signal is byte-for-byte intact under the default policy. | |
| 278 | + | assert_eq!(buf, vec![0.5, -1.25, 0.8]); | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | #[test] | |
| 282 | + | fn resolve_overshoot_trims_to_full_scale() { | |
| 283 | + | let mut buf = vec![0.5, -1.25, 0.8]; | |
| 284 | + | let report = resolve_overshoot(&mut buf, true).expect("overshoot"); | |
| 285 | + | match report.action { | |
| 286 | + | OvershootAction::Trimmed { gain_db } => { | |
| 287 | + | // 1/1.25 = 0.8 -> ~ -1.94 dB | |
| 288 | + | assert!((gain_db + 1.938).abs() < 0.01, "got {gain_db}"); | |
| 289 | + | } | |
| 290 | + | other => panic!("expected Trimmed, got {other:?}"), | |
| 291 | + | } | |
| 292 | + | // True peak now sits at exactly full scale, nothing exceeds it. | |
| 293 | + | assert!((peak_amplitude(&buf) - 1.0).abs() < 1e-6); | |
| 294 | + | // Relative shape preserved (linear gain): -1.25 * 0.8 = -1.0, 0.5 * 0.8 = 0.4. | |
| 295 | + | assert!((buf[0] - 0.4).abs() < 1e-6); | |
| 296 | + | assert!((buf[1] + 1.0).abs() < 1e-6); | |
| 297 | + | } | |
| 193 | 298 | } |
| @@ -23,5 +23,8 @@ pub mod runner; | |||
| 23 | 23 | ||
| 24 | 24 | pub use batch::{find_content_bounds, trim_silence}; | |
| 25 | 25 | pub use chop::{compute_slices, detect_bpm, render_slice, ChopMethod, Slice}; | |
| 26 | - | pub use conform::{conform, ConformTarget, ConformedAudio}; | |
| 27 | - | pub use runner::{chop_to_vfs, conform_to_vfs, ChopResult}; | |
| 26 | + | pub use conform::{ | |
| 27 | + | conform, peak_amplitude, resolve_overshoot, ConformTarget, ConformedAudio, OvershootAction, | |
| 28 | + | OvershootReport, | |
| 29 | + | }; | |
| 30 | + | pub use runner::{chop_to_vfs, conform_to_vfs, ChopResult, ConformResult}; |
| @@ -17,7 +17,7 @@ use crate::store::SampleStore; | |||
| 17 | 17 | use crate::vfs; | |
| 18 | 18 | ||
| 19 | 19 | use super::chop::{compute_slices, render_slice, ChopMethod}; | |
| 20 | - | use super::conform::{conform, ConformTarget}; | |
| 20 | + | use super::conform::{conform, resolve_overshoot, ConformTarget, OvershootReport}; | |
| 21 | 21 | ||
| 22 | 22 | /// Result of a chop-to-VFS run. | |
| 23 | 23 | #[derive(Debug)] | |
| @@ -28,6 +28,17 @@ pub struct ChopResult { | |||
| 28 | 28 | pub slice_count: usize, | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | + | /// Result of a conform-to-VFS run. | |
| 32 | + | #[derive(Debug)] | |
| 33 | + | pub struct ConformResult { | |
| 34 | + | /// The new sample's content hash. | |
| 35 | + | pub hash: String, | |
| 36 | + | /// Present when the conformed buffer overshot full scale at an integer | |
| 37 | + | /// target — either flagged (left for the encoder to clamp) or trimmed, | |
| 38 | + | /// depending on the caller's `auto_trim` choice. | |
| 39 | + | pub overshoot: Option<OvershootReport>, | |
| 40 | + | } | |
| 41 | + | ||
| 31 | 42 | /// Decode `source_path`, chop it with `method`, and write each slice as a new | |
| 32 | 43 | /// sample into a new `"{base_name}_slices"` directory under `parent_id`. | |
| 33 | 44 | /// | |
| @@ -84,7 +95,21 @@ pub fn chop_to_vfs( | |||
| 84 | 95 | } | |
| 85 | 96 | ||
| 86 | 97 | /// Decode `source_path`, conform it to `target`, and write the result as a new | |
| 87 | - | /// sibling sample under `parent_id`. Returns the new sample's hash. | |
| 98 | + | /// sibling sample under `parent_id`. | |
| 99 | + | /// | |
| 100 | + | /// The conformed f32 signal is carried pristine to the encode boundary. Resampling | |
| 101 | + | /// can push inter-sample (true) peaks past `±1.0`; at an integer target the | |
| 102 | + | /// encoder must then clamp (a destructive, irreversible edit). `auto_trim` | |
| 103 | + | /// chooses how to resolve that one forced case: | |
| 104 | + | /// - `false` (default): leave the signal untouched and report the overshoot, so | |
| 105 | + | /// the forge makes no implicit edit; the encoder clamp is the disclosed | |
| 106 | + | /// last-resort exception. | |
| 107 | + | /// - `true`: apply the gentlest reversible fix (a single linear gain to full | |
| 108 | + | /// scale) instead of clamping, reported in the result. | |
| 109 | + | /// | |
| 110 | + | /// 32-bit float targets store the f32 verbatim and never clip, so the check is | |
| 111 | + | /// skipped for them. | |
| 112 | + | #[allow(clippy::too_many_arguments)] // store/db/vfs context + target + the overshoot policy flag | |
| 88 | 113 | pub fn conform_to_vfs( | |
| 89 | 114 | store: &SampleStore, | |
| 90 | 115 | db: &Database, | |
| @@ -93,9 +118,18 @@ pub fn conform_to_vfs( | |||
| 93 | 118 | base_name: &str, | |
| 94 | 119 | parent_id: Option<NodeId>, | |
| 95 | 120 | target: &ConformTarget, | |
| 96 | - | ) -> Result<String, CoreError> { | |
| 121 | + | auto_trim: bool, | |
| 122 | + | ) -> Result<ConformResult, CoreError> { | |
| 97 | 123 | let decoded = decode_multichannel(source_path)?; | |
| 98 | - | let conformed = conform(&decoded.samples, decoded.channels, decoded.sample_rate, target)?; | |
| 124 | + | let mut conformed = conform(&decoded.samples, decoded.channels, decoded.sample_rate, target)?; | |
| 125 | + | ||
| 126 | + | // Integer targets clamp at the encoder; 32-bit float is a lossless f32 | |
| 127 | + | // passthrough with no clamp, so only the integer path can overshoot. | |
| 128 | + | let overshoot = if conformed.bit_depth == 32 { | |
| 129 | + | None | |
| 130 | + | } else { | |
| 131 | + | resolve_overshoot(&mut conformed.audio.samples, auto_trim) | |
| 132 | + | }; | |
| 99 | 133 | ||
| 100 | 134 | let stem = sanitize_stem(base_name); | |
| 101 | 135 | let rate_tag = format_rate_tag(target.sample_rate); | |
| @@ -109,7 +143,7 @@ pub fn conform_to_vfs( | |||
| 109 | 143 | let _ = std::fs::remove_file(&temp_path); | |
| 110 | 144 | let hash = import?; | |
| 111 | 145 | vfs::create_sample_link(db, vfs_id, parent_id, &out_name, &hash)?; | |
| 112 | - | Ok(hash) | |
| 146 | + | Ok(ConformResult { hash, overshoot }) | |
| 113 | 147 | } | |
| 114 | 148 | ||
| 115 | 149 | /// Drop the extension and strip path-unfriendly characters from a base name. | |
| @@ -228,7 +262,11 @@ mod tests { | |||
| 228 | 262 | bit_depth: 16, | |
| 229 | 263 | channels: ExportChannels::Mono, | |
| 230 | 264 | }; | |
| 231 | - | let hash = conform_to_vfs(&store, &db, vfs_id, &src, "pad.wav", None, &target).unwrap(); | |
| 265 | + | let result = | |
| 266 | + | conform_to_vfs(&store, &db, vfs_id, &src, "pad.wav", None, &target, false).unwrap(); | |
| 267 | + | // Benign in-range content must not be flagged as overshooting. | |
| 268 | + | assert!(result.overshoot.is_none()); | |
| 269 | + | let hash = result.hash; | |
| 232 | 270 | assert!(!hash.is_empty()); | |
| 233 | 271 | ||
| 234 | 272 | let children = vfs::list_children(&db, vfs_id, None).unwrap(); |
| @@ -140,7 +140,23 @@ impl SampleStore { | |||
| 140 | 140 | } | |
| 141 | 141 | let hash = format!("{:x}", hasher.finalize()); | |
| 142 | 142 | ||
| 143 | - | let ext = crate::util::get_extension(path); | |
| 143 | + | // Resolve the blob extension from an existing row if this hash is already | |
| 144 | + | // known. The store is content-addressed (one blob per hash), so identical | |
| 145 | + | // bytes imported under a different extension must reuse the existing blob | |
| 146 | + | // rather than write a second `{hash}.{newext}` file: remove() resolves the | |
| 147 | + | // blob path from the DB row, so any divergent-extension copy would be | |
| 148 | + | // unreachable and leak disk forever. Query without the deleted_at filter so | |
| 149 | + | // the blob naming stays consistent even for soft-deleted rows. Fresh import | |
| 150 | + | // falls back to the incoming file's extension. | |
| 151 | + | let ext = match db.conn().query_row( | |
| 152 | + | "SELECT file_extension FROM samples WHERE hash = ?1", | |
| 153 | + | [&hash], | |
| 154 | + | |row| row.get::<_, String>(0), | |
| 155 | + | ) { | |
| 156 | + | Ok(existing) => existing, | |
| 157 | + | Err(rusqlite::Error::QueryReturnedNoRows) => crate::util::get_extension(path), | |
| 158 | + | Err(e) => return Err(CoreError::Db(e)), | |
| 159 | + | }; | |
| 144 | 160 | let original_name = crate::util::get_filename(path, "unknown"); | |
| 145 | 161 | ||
| 146 | 162 | // Probe duration from file headers (cheap, no full decode) | |
| @@ -699,6 +715,35 @@ mod tests { | |||
| 699 | 715 | } | |
| 700 | 716 | ||
| 701 | 717 | #[test] | |
| 718 | + | fn import_same_bytes_different_extension_reuses_blob() { | |
| 719 | + | let (dir, db, store) = setup(); | |
| 720 | + | // Identical bytes, two different audio extensions (is_audio_file keys on | |
| 721 | + | // extension, so the content can be arbitrary). | |
| 722 | + | let wav = create_test_file(&dir, "loop.wav", b"identical bytes"); | |
| 723 | + | let aiff = create_test_file(&dir, "loop.aiff", b"identical bytes"); | |
| 724 | + | ||
| 725 | + | let h1 = store.import(&wav, &db).unwrap(); | |
| 726 | + | let h2 = store.import(&aiff, &db).unwrap(); | |
| 727 | + | assert_eq!(h1, h2, "identical bytes hash to the same sample"); | |
| 728 | + | ||
| 729 | + | // Exactly one blob on disk: the second import must reuse `{hash}.wav`, not | |
| 730 | + | // write an unreachable `{hash}.aiff` orphan. | |
| 731 | + | let blob_count = || { | |
| 732 | + | std::fs::read_dir(store.root()) | |
| 733 | + | .unwrap() | |
| 734 | + | .filter_map(|e| e.ok()) | |
| 735 | + | .filter(|e| e.path().is_file()) | |
| 736 | + | .count() | |
| 737 | + | }; | |
| 738 | + | assert_eq!(blob_count(), 1, "second extension must reuse the first blob"); | |
| 739 | + | ||
| 740 | + | // And remove() leaves nothing behind — the orphan would otherwise survive, | |
| 741 | + | // since remove() resolves the blob path from the DB row's extension. | |
| 742 | + | store.remove(&h1, &db).unwrap(); | |
| 743 | + | assert_eq!(blob_count(), 0, "no orphan blob remains after remove"); | |
| 744 | + | } | |
| 745 | + | ||
| 746 | + | #[test] | |
| 702 | 747 | fn remove_deletes_file_and_row() { | |
| 703 | 748 | let (dir, db, store) = setup(); | |
| 704 | 749 | let src = create_test_file(&dir, "snare.wav", b"snare data"); |