Skip to main content

max / audiofiles

Fix orphan-blob leak; add forge resample-overshoot handling Store: import now resolves the blob extension from the existing row for the hash, so identical bytes imported under a different extension reuse the canonical content-addressed blob instead of leaving an unreachable orphan. Restores "import twice is a no-op". Forge: conform detects true-peak overshoot (>1.0) at integer targets. Default leaves the signal untouched and warns it will clip; a new Settings > Forge "Auto-trim resample overshoot" toggle opts in to a single linear gain to full scale, reported when applied. 32-bit float targets are unaffected (lossless passthrough). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-08 19:32 UTC
Commit: 89d6796f6c58e4c4f202cbbbc30b7f52c5f65792
Parent: 04e06ab
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
M Cargo.lock +1
@@ -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");