max / audiofiles
9 files changed,
+53 insertions,
-39 deletions
| @@ -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() { |