Skip to main content

max / audiofiles

Beta hardening: fuzz/audit fixes + dead-dep prune Code-fuzz: fix trial copy (14->30 days), add storage_stats tombstone read-path filter (M019 Phase-1 completeness) with test, correct a misleading sync changelog log message. Rust-fuzz: remove a per-frame magnitude clone in the STFT hot path (derive flux from stored frames; drop redundant prev_spectrum), add the missing SAFETY comment on the libdispatch extern block, and convert a provably-safe manifest unwrap to expect. Build cleanup: clear all warnings (unused tags import; extract testable aiff_sample_data_size helper). Dependency-prune: drop dead docengine workspace dependency. 806 tests green, 0 warnings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-06 00:24 UTC
Commit: f36eac9cc7031a27b4a0a9ed59f824ccabd57345
Parent: 9e7c56c
9 files changed, +53 insertions, -39 deletions
M Cargo.toml -1
@@ -46,6 +46,5 @@ open = "5"
46 46 rayon = "1.10"
47 47 libc = "0.2"
48 48 midir = "0.11"
49 - docengine = { path = "../../MNW/shared/docengine" }
50 49 tagtree = { path = "../../MNW/shared/tagtree" }
51 50 theme-common = { path = "../../MNW/shared/theme-common" }
@@ -65,7 +65,7 @@ impl AudioFilesApp {
65 65 "Trial expired".to_string()
66 66 }
67 67 }
68 - None => "Start free trial — 14 days, no card".to_string(),
68 + None => "Start free trial — 30 days, no card".to_string(),
69 69 };
70 70 let trial_btn = egui::Button::new(egui::RichText::new(trial_label).strong());
71 71 if ui.add_enabled(!trial_expired, trial_btn).clicked() {
@@ -24,6 +24,9 @@ use objc2_app_kit::{
24 24 use objc2_foundation::{NSArray, NSPoint, NSRect, NSSize, NSURL};
25 25 use tracing::{debug, warn};
26 26
27 + // SAFETY: `_dispatch_main_q` and `dispatch_async` are public symbols exported by
28 + // Apple's libdispatch. The declared signatures match the system headers; dispatch
29 + // is thread-safe and `RcBlock` keeps the closure alive across the async boundary.
27 30 unsafe extern "C" {
28 31 static _dispatch_main_q: c_void;
29 32 fn dispatch_async(queue: *const c_void, block: &block2::Block<dyn Fn()>);
@@ -98,7 +98,6 @@ fn compute_spectral_inner(
98 98 let mut flatnesses = Vec::new();
99 99 let mut rolloffs = Vec::new();
100 100 let mut bandwidths = Vec::new();
101 - let mut prev_spectrum: Option<Vec<f64>> = None;
102 101 let mut onset_diffs = Vec::new();
103 102 let mut magnitude_frames = Vec::new();
104 103
@@ -181,21 +180,26 @@ fn compute_spectral_inner(
181 180 }
182 181 rolloffs.push(rolloff_freq);
183 182
183 + // Store this frame, then compute onset strength against the previous
184 + // stored frame. `magnitude_frames` already retains every (non-silent)
185 + // frame for the later MFCC pass, so the previous spectrum is just the
186 + // second-to-last element — no separate `prev_spectrum` copy needed.
187 + magnitude_frames.push(magnitudes);
188 +
184 189 // Onset strength via spectral flux: sum of positive magnitude increases
185 190 // between consecutive frames. Only positive differences are counted
186 191 // (half-wave rectification) because onsets are characterised by energy
187 192 // appearing, not disappearing.
188 - if let Some(ref prev) = prev_spectrum {
189 - let flux: f64 = magnitudes
193 + if magnitude_frames.len() >= 2 {
194 + let curr = &magnitude_frames[magnitude_frames.len() - 1];
195 + let prev = &magnitude_frames[magnitude_frames.len() - 2];
196 + let flux: f64 = curr
190 197 .iter()
191 198 .zip(prev.iter())
192 199 .map(|(&curr, &prev)| (curr - prev).max(0.0))
193 200 .sum();
194 201 onset_diffs.push(flux);
195 202 }
196 -
197 - magnitude_frames.push(magnitudes.clone());
198 - prev_spectrum = Some(magnitudes);
199 203 }
200 204
201 205 pos += hop_size;
@@ -1324,9 +1324,13 @@ impl Database {
1324 1324 }
1325 1325
1326 1326 /// Aggregate storage stats: (sample_count, total_file_bytes).
1327 + ///
1328 + /// Excludes tombstoned rows (`deleted_at IS NOT NULL`) so the figure matches
1329 + /// the library the user actually sees — the M019 read-path filter applies here
1330 + /// like every other sample read site.
1327 1331 pub fn storage_stats(&self) -> Result<(u64, u64), DbError> {
1328 1332 let (count, total): (u64, u64) = self.conn.query_row(
1329 - "SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM samples",
1333 + "SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM samples WHERE deleted_at IS NULL",
1330 1334 [],
1331 1335 |row| Ok((row.get(0)?, row.get(1)?)),
1332 1336 )?;
@@ -1619,6 +1623,12 @@ mod tests {
1619 1623 "tombstoned sample should be hidden from sample_extension; got {tomb_ext:?}"
1620 1624 );
1621 1625
1626 + // storage_stats also applies the read-path filter: only the live sample
1627 + // (file_size 1) is counted, not the tombstoned one.
1628 + let (count, bytes) = db.storage_stats().unwrap();
1629 + assert_eq!(count, 1, "tombstoned sample should not be counted");
1630 + assert_eq!(bytes, 1, "tombstoned sample's bytes should be excluded");
1631 +
1622 1632 // Default retain-days seed is present.
1623 1633 let retain: String = conn
1624 1634 .query_row(
@@ -11,6 +11,19 @@ use super::convert::ConvertedAudio;
11 11 use super::dither::SimpleRng;
12 12 use crate::error::{io_err, CoreError};
13 13
14 + /// Compute the SSND sample-data size in bytes, rejecting files that would
15 + /// overflow the AIFF spec's u32 chunk-size limit (4 GB).
16 + fn aiff_sample_data_size(num_frames: usize, channels: u16, bit_depth: u16) -> Result<u32, CoreError> {
17 + let bytes_per_sample = (bit_depth / 8) as u64;
18 + let sample_data_size_u64 = num_frames as u64 * channels as u64 * bytes_per_sample;
19 + if sample_data_size_u64 > u32::MAX as u64 {
20 + return Err(CoreError::Export(format!(
21 + "AIFF: file too large ({num_frames} frames, {channels} channels, {bit_depth}-bit = {sample_data_size_u64} bytes, exceeds 4 GB chunk limit)",
22 + )));
23 + }
24 + Ok(sample_data_size_u64 as u32)
25 + }
26 +
14 27 /// Encode audio to an AIFF file at the given path.
15 28 ///
16 29 /// - 16-bit: applies TPDF dither before quantization (same algorithm as WAV encoder).
@@ -27,17 +40,8 @@ pub fn encode_aiff(audio: &ConvertedAudio, bit_depth: u16, dest: &Path) -> Resul
27 40 return Err(CoreError::Export("AIFF: 0 channels".to_string()));
28 41 }
29 42 let num_frames = audio.samples.len() / channels as usize;
30 - let bytes_per_sample = (bit_depth / 8) as u64;
31 43
32 - // Check for u32 overflow — the AIFF spec limits chunk sizes to u32.
33 - let sample_data_size_u64 = num_frames as u64 * channels as u64 * bytes_per_sample;
34 - if sample_data_size_u64 > u32::MAX as u64 {
35 - return Err(CoreError::Export(format!(
36 - "AIFF: file too large ({} frames, {} channels, {}-bit = {} bytes, exceeds 4 GB chunk limit)",
37 - num_frames, channels, bit_depth, sample_data_size_u64,
38 - )));
39 - }
40 - let sample_data_size = sample_data_size_u64 as u32;
44 + let sample_data_size = aiff_sample_data_size(num_frames, channels, bit_depth)?;
41 45
42 46 // SSND chunk: 8 (offset + block_size) + sample data
43 47 let ssnd_chunk_size = 8u32.checked_add(sample_data_size).ok_or_else(|| {
@@ -315,21 +319,12 @@ mod tests {
315 319
316 320 #[test]
317 321 fn aiff_rejects_oversized_file() {
318 - let dir = tempfile::tempdir().unwrap();
319 - let path = dir.path().join("too_big.aiff");
320 - // Simulate a file that would overflow u32: need > 4GB of sample data.
321 - // stereo 24-bit: each frame = 6 bytes. u32::MAX / 6 + 1 frames overflows.
322 + // We can't allocate >4 GB of samples, so drive the guard directly via the
323 + // extracted helper. stereo 24-bit: 6 bytes/frame; just past u32::MAX/6 overflows.
322 324 let overflow_frames = (u32::MAX as usize / 6) + 1;
323 - // We can't allocate that much memory, so test the arithmetic check
324 - // by constructing ConvertedAudio with a len that implies overflow.
325 - // The check is on num_frames * channels * bytes_per_sample, so use
326 - // a large-but-allocatable sample vec that still overflows u32 at 24-bit stereo.
327 - // Actually, we just need num_frames to be large enough. Since we can't
328 - // allocate billions of samples, verify the error message format instead.
329 - // Create a minimal audio with 1 sample and manually verify the overflow math.
330 - assert!(
331 - overflow_frames as u64 * 2 * 3 > u32::MAX as u64,
332 - "test setup: should overflow"
333 - );
325 + assert!(aiff_sample_data_size(overflow_frames, 2, 24).is_err());
326 + // The largest in-bounds case must still succeed.
327 + let max_frames = u32::MAX as usize / 6;
328 + assert!(aiff_sample_data_size(max_frames, 2, 24).is_ok());
334 329 }
335 330 }
@@ -15,7 +15,6 @@ use std::path::PathBuf;
15 15 use crate::db::Database;
16 16 use crate::error::Result;
17 17 use crate::id_types::{NodeId, VfsId};
18 - use crate::tags;
19 18
20 19 use self::profile::NamingRules;
21 20
@@ -552,8 +551,8 @@ mod tests {
552 551 assert!(items[0].tags.is_empty());
553 552
554 553 // Add tags
555 - tags::add_tag(&db, &items[0].hash, "kick").unwrap();
556 - tags::add_tag(&db, &items[0].hash, "drums").unwrap();
554 + crate::tags::add_tag(&db, &items[0].hash, "kick").unwrap();
555 + crate::tags::add_tag(&db, &items[0].hash, "drums").unwrap();
557 556
558 557 enrich_with_tags(&db, &mut items);
559 558 assert_eq!(items[0].tags.len(), 2);
@@ -146,7 +146,11 @@ fn parse_naming(section: &NamingSection) -> Result<NamingRules, PluginError> {
146 146 section.separator,
147 147 )));
148 148 }
149 - let separator = section.separator.chars().next().unwrap();
149 + let separator = section
150 + .separator
151 + .chars()
152 + .next()
153 + .expect("separator length checked to be exactly 1 above");
150 154
151 155 Ok(NamingRules {
152 156 case,
@@ -245,7 +245,7 @@ async fn push_changes(
245 245 .collect();
246 246
247 247 if skipped > 0 {
248 - tracing::warn!(skipped, "Some changelog entries could not be pushed — they will be retried next sync");
248 + tracing::warn!(skipped, "Some changelog entries could not be parsed (unknown op or bad JSON) and were marked pushed to break the retry loop; they are dropped, not retried");
249 249 }
250 250
251 251 if !changes.is_empty() {