Skip to main content

max / audiofiles

Remove sourcehut CI config Sourcehut is used as a backup mirror, not for CI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-02 23:18 UTC
Commit: 345aa3726c94ee0ea1ba9f98e6bf86b238a2b8f4
Parent: 5c22707
33 files changed, +738 insertions, -159 deletions
D .build.yml -31
@@ -1,31 +0,0 @@
1 - image: archlinux
2 - packages:
3 - - rust
4 - - cmake
5 - - clang
6 - - git
7 - - pkg-config
8 - - perl
9 - - libxcb
10 - - libxkbcommon
11 - - mesa
12 - - alsa-lib
13 - sources:
14 - - https://git.sr.ht/~maxmj/audiofiles
15 - environment:
16 - CARGO_INCREMENTAL: "0"
17 - RUST_BACKTRACE: "1"
18 - tasks:
19 - - check: |
20 - cd audiofiles
21 - cargo check --workspace 2>&1
22 - - test: |
23 - cd audiofiles
24 - cargo test --workspace 2>&1
25 - - clippy: |
26 - cd audiofiles
27 - cargo clippy --workspace --all-targets -- -D warnings 2>&1
28 - - audit: |
29 - cargo install --locked cargo-audit
30 - cd audiofiles
31 - cargo audit 2>&1
@@ -138,8 +138,10 @@ pub(crate) fn fill_preview(
138 138 return;
139 139 };
140 140
141 + // For streaming, use the smaller of decoded_frames and actual data length
142 + // to prevent OOB if decoded_frames is updated before data is fully appended.
141 143 let total_frames = if guard.streaming {
142 - guard.decoded_frames
144 + guard.decoded_frames.min(preview_buf.data.len() / 2)
143 145 } else {
144 146 preview_buf.data.len() / 2
145 147 };
@@ -195,6 +195,9 @@ pub async fn deactivate_key(server_url: &str, key: &str, machine_id: &str) -> Re
195 195 #[derive(Debug, Clone, Serialize, Deserialize)]
196 196 pub struct TrialState {
197 197 pub first_launch_date: String,
198 + /// Last time the app was launched — used to detect system clock rollback.
199 + #[serde(default)]
200 + pub last_seen_date: Option<String>,
198 201 }
199 202
200 203 /// Load the trial state from `trial.json` in the config directory.
@@ -214,14 +217,37 @@ pub fn save_trial(config_dir: &Path, state: &TrialState) -> io::Result<()> {
214 217 }
215 218
216 219 /// Calculate days remaining in the trial (goes negative after day 30).
220 + ///
221 + /// Detects system clock rollback: if `now < last_seen_date`, assumes the clock
222 + /// was set back to extend the trial and returns 0 (expired).
217 223 pub fn trial_days_remaining(trial: &TrialState) -> i64 {
218 224 let Ok(first) = chrono::DateTime::parse_from_rfc3339(&trial.first_launch_date) else {
219 225 return 0;
220 226 };
221 - let elapsed = chrono::Utc::now().signed_duration_since(first);
227 + let now = chrono::Utc::now();
228 +
229 + // Clock rollback detection: if now is before last_seen_date, expire immediately
230 + if let Some(ref last) = trial.last_seen_date {
231 + if let Ok(last_seen) = chrono::DateTime::parse_from_rfc3339(last) {
232 + if now.signed_duration_since(last_seen).num_hours() < -1 {
233 + // Allow up to 1 hour of drift (DST, NTP correction)
234 + return 0;
235 + }
236 + }
237 + }
238 +
239 + let elapsed = now.signed_duration_since(first);
222 240 30 - elapsed.num_days()
223 241 }
224 242
243 + /// Update the last_seen_date to now. Call on each app launch.
244 + pub fn touch_trial(config_dir: &std::path::Path) {
245 + if let Some(mut trial) = load_trial(config_dir) {
246 + trial.last_seen_date = Some(chrono::Utc::now().to_rfc3339());
247 + let _ = save_trial(config_dir, &trial);
248 + }
249 + }
250 +
225 251 #[cfg(test)]
226 252 mod tests {
227 253 use super::*;
@@ -334,6 +360,7 @@ mod tests {
334 360 let dir = tempfile::tempdir().unwrap();
335 361 let state = TrialState {
336 362 first_launch_date: "2026-04-01T00:00:00Z".to_string(),
363 + last_seen_date: None,
337 364 };
338 365 save_trial(dir.path(), &state).unwrap();
339 366 let loaded = load_trial(dir.path()).unwrap();
@@ -350,6 +377,7 @@ mod tests {
350 377 fn trial_days_remaining_fresh() {
351 378 let state = TrialState {
352 379 first_launch_date: chrono::Utc::now().to_rfc3339(),
380 + last_seen_date: None,
353 381 };
354 382 assert_eq!(trial_days_remaining(&state), 30);
355 383 }
@@ -359,7 +387,20 @@ mod tests {
359 387 let past = chrono::Utc::now() - chrono::Duration::days(35);
360 388 let state = TrialState {
361 389 first_launch_date: past.to_rfc3339(),
390 + last_seen_date: None,
362 391 };
363 392 assert_eq!(trial_days_remaining(&state), -5);
364 393 }
394 +
395 + #[test]
396 + fn trial_clock_rollback_detected() {
397 + let now = chrono::Utc::now();
398 + let future = now + chrono::Duration::days(10);
399 + let state = TrialState {
400 + first_launch_date: now.to_rfc3339(),
401 + last_seen_date: Some(future.to_rfc3339()),
402 + };
403 + // now < last_seen_date by 10 days → clock was rolled back → expire
404 + assert_eq!(trial_days_remaining(&state), 0);
405 + }
365 406 }
@@ -472,6 +472,7 @@ impl AudioFilesApp {
472 472 let machine_id = license::get_or_create_machine_id(&config_dir);
473 473 let license_status = license::load_license(&config_dir);
474 474 let trial_state = license::load_trial(&config_dir);
475 + license::touch_trial(&config_dir);
475 476
476 477 // Load (or create) the vault registry
477 478 let vault_registry = match vault::load_registry() {
@@ -730,8 +731,10 @@ impl AudioFilesApp {
730 731 /// Start or continue trial mode: create trial state if needed, then proceed.
731 732 fn start_trial(&mut self) {
732 733 if self.trial_state.is_none() {
734 + let now = chrono::Utc::now().to_rfc3339();
733 735 let trial = license::TrialState {
734 - first_launch_date: chrono::Utc::now().to_rfc3339(),
736 + first_launch_date: now.clone(),
737 + last_seen_date: Some(now),
735 738 };
736 739 if let Err(e) = license::save_trial(&self.config_dir, &trial) {
737 740 tracing::error!("Failed to save trial state: {e}");
@@ -1043,7 +1046,7 @@ impl eframe::App for AudioFilesApp {
1043 1046 ui.add_space(4.0);
1044 1047 ui.horizontal(|ui| {
1045 1048 if ui.button("Download").clicked()
1046 - && download_url.starts_with("https://")
1049 + && crate::updater::is_trusted_download_url(&download_url)
1047 1050 {
1048 1051 let _ = open::that(&download_url);
1049 1052 }
@@ -73,6 +73,15 @@ impl UpdateChecker {
73 73 }
74 74 }
75 75
76 + /// Verify that a download URL points to a trusted domain.
77 + pub fn is_trusted_download_url(url: &str) -> bool {
78 + const TRUSTED_PREFIXES: &[&str] = &[
79 + "https://makenot.work/",
80 + "https://dist.makenot.work/",
81 + ];
82 + TRUSTED_PREFIXES.iter().any(|prefix| url.starts_with(prefix))
83 + }
84 +
76 85 /// Check the MNW OTA endpoint once.
77 86 async fn check_once(status: &Arc<Mutex<UpdateStatus>>) {
78 87 let current = match Version::parse(CURRENT_VERSION) {
@@ -122,7 +131,7 @@ async fn check_once(status: &Arc<Mutex<UpdateStatus>>) {
122 131 match resp.json::<UpdateResponse>().await {
123 132 Ok(update) => {
124 133 if let Ok(remote) = Version::parse(&update.version) {
125 - if remote > current {
134 + if remote > current && is_trusted_download_url(&update.url) {
126 135 tracing::info!("Update available: v{}", update.version);
127 136 let mut s = status.lock();
128 137 s.available = true;
@@ -143,16 +143,20 @@ impl DirectBackend {
143 143
144 144 // validate_sample hook: filter items through Rhai script
145 145 if let Some(ref ast) = plugin.hooks.validate_sample {
146 - let db = self.db.lock();
147 - items.retain(|item| {
148 - let info = build_sample_info(&db, &self.store, item);
149 - audiofiles_rhai::hooks::run_validate_sample(
150 - self.plugin_registry.engine(),
151 - ast,
152 - info,
153 - )
154 - .unwrap_or(true)
155 - });
146 + // Collect all sample info with the lock held, then drop it before running scripts
147 + // to avoid holding the DB lock during potentially slow Rhai execution.
148 + let infos: Vec<_> = {
149 + let db = self.db.lock();
150 + items.iter().map(|item| build_sample_info(&db, &self.store, item)).collect()
151 + };
152 + let engine = self.plugin_registry.engine();
153 + let mut keep = vec![true; items.len()];
154 + for (i, info) in infos.into_iter().enumerate() {
155 + keep[i] = audiofiles_rhai::hooks::run_validate_sample(engine, ast, info)
156 + .unwrap_or(false);
157 + }
158 + let mut ki = 0;
159 + items.retain(|_| { let k = keep[ki]; ki += 1; k });
156 160 }
157 161
158 162 // transform_filename hook: pre-compute output names with custom naming logic
@@ -188,10 +192,13 @@ impl DirectBackend {
188 192 stem,
189 193 ctx,
190 194 ) {
195 + // Sanitize: strip path separators and NUL bytes to prevent traversal
196 + let safe_stem = new_stem.replace(['/', '\\', '\0'], "_");
197 + let safe_stem = if safe_stem.is_empty() { "untitled".to_string() } else { safe_stem };
191 198 *name = if ext.is_empty() {
192 - new_stem
199 + safe_stem
193 200 } else {
194 - format!("{new_stem}.{ext}")
201 + format!("{safe_stem}.{ext}")
195 202 };
196 203 }
197 204 }
@@ -245,18 +252,51 @@ fn build_sample_info(
245 252 .unwrap_or(0)
246 253 });
247 254
255 + // Probe bit depth from the file header (cheap — reads only the header)
256 + let bit_depth = store
257 + .sample_path(&item.hash, &item.ext)
258 + .ok()
259 + .and_then(|p| probe_bit_depth(&p))
260 + .unwrap_or(0);
261 +
248 262 audiofiles_rhai::types::RhaiSampleInfo {
249 263 hash: item.hash.to_string(),
250 264 name: item.name.clone(),
251 265 extension: item.ext.clone(),
252 266 sample_rate,
253 - bit_depth: 0, // not stored in DB; hooks can check other fields
267 + bit_depth,
254 268 channels,
255 269 duration,
256 270 file_size,
257 271 }
258 272 }
259 273
274 + /// Probe bit depth from a WAV/AIFF file header without full decode.
275 + #[cfg(feature = "device-profiles")]
276 + fn probe_bit_depth(path: &std::path::Path) -> Option<u16> {
277 + // Try hound first (WAV)
278 + if let Ok(reader) = hound::WavReader::open(path) {
279 + return Some(reader.spec().bits_per_sample);
280 + }
281 + // Try symphonia for AIFF/other formats
282 + let file = std::fs::File::open(path).ok()?;
283 + let mss = symphonia::core::io::MediaSourceStream::new(Box::new(file), Default::default());
284 + let mut hint = symphonia::core::probe::Hint::new();
285 + if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
286 + hint.with_extension(ext);
287 + }
288 + let probed = symphonia::default::get_probe()
289 + .format(
290 + &hint,
291 + mss,
292 + &symphonia::core::formats::FormatOptions::default(),
293 + &symphonia::core::meta::MetadataOptions::default(),
294 + )
295 + .ok()?;
296 + let track = probed.format.default_track()?;
297 + track.codec_params.bits_per_sample.map(|b| b as u16)
298 + }
299 +
260 300 impl Backend for DirectBackend {
261 301 // --- VFS ---
262 302
@@ -696,6 +736,12 @@ impl Backend for DirectBackend {
696 736 }
697 737 };
698 738
739 + // Cancel any existing import worker before starting a new one
740 + if let Some(old) = self.import_worker.lock().take() {
741 + old.send(ImportCommand::Cancel);
742 + drop(old); // joins the thread
743 + }
744 +
699 745 let handle = crate::import::spawn_import_worker(db_path, store_root)
700 746 .map_err(|e| BackendError::Other(format!("failed to spawn import worker: {e}")))?;
701 747 handle.send(ImportCommand::ImportDirectory {
@@ -729,6 +775,12 @@ impl Backend for DirectBackend {
729 775 .collect()
730 776 };
731 777
778 + // Cancel any existing analysis worker before starting a new one
779 + if let Some(old) = self.analysis_worker.lock().take() {
780 + old.send(WorkerCommand::Cancel);
781 + drop(old);
782 + }
783 +
732 784 let handle = audiofiles_core::analysis::worker::spawn_worker()
733 785 .map_err(|e| BackendError::Other(format!("failed to spawn analysis worker: {e}")))?;
734 786 handle.send(WorkerCommand::AnalyzeBatch { samples, config });
@@ -748,6 +800,12 @@ impl Backend for DirectBackend {
748 800 self.resolve_device_profile(&mut config, &mut items);
749 801
750 802 let store_root = self.store.root().to_path_buf();
803 + // Cancel any existing export worker before starting a new one
804 + if let Some(old) = self.export_worker.lock().take() {
805 + old.send(ExportCommand::Cancel);
806 + drop(old);
807 + }
808 +
751 809 let handle = crate::export::spawn_export_worker(store_root)
752 810 .map_err(|e| BackendError::Other(format!("failed to spawn export worker: {e}")))?;
753 811 handle.send(ExportCommand::Export { items, config });
@@ -787,6 +845,12 @@ impl Backend for DirectBackend {
787 845 ));
788 846 }
789 847
848 + // Cancel any existing edit worker before starting a new one
849 + if let Some(old) = self.edit_worker.lock().take() {
850 + old.send(EditCommand::Cancel);
851 + drop(old);
852 + }
853 +
790 854 let handle = audiofiles_core::edit::worker::spawn_edit_worker()
791 855 .map_err(|e| BackendError::Other(format!("failed to spawn edit worker: {e}")))?;
792 856 handle.send(EditCommand::Edit {
@@ -208,7 +208,16 @@ impl IDataObject_Impl for FileDataObject_Impl {
208 208 unsafe {
209 209 let h = GlobalAlloc(GMEM_MOVEABLE, self.size)?;
210 210 let src = GlobalLock(self.hdrop);
211 + if src.is_null() {
212 + let _ = GlobalFree(Some(h));
213 + return Err(Error::from(E_OUTOFMEMORY));
214 + }
211 215 let dst = GlobalLock(h);
216 + if dst.is_null() {
217 + let _ = GlobalUnlock(self.hdrop);
218 + let _ = GlobalFree(Some(h));
219 + return Err(Error::from(E_OUTOFMEMORY));
220 + }
212 221 std::ptr::copy_nonoverlapping(src as *const u8, dst as *mut u8, self.size);
213 222 let _ = GlobalUnlock(self.hdrop);
214 223 let _ = GlobalUnlock(h);
@@ -16,6 +16,14 @@ pub struct BpmKeyResult {
16 16 /// since tempo detection needs enough rhythmic content to find a reliable beat grid.
17 17 #[instrument(skip_all)]
18 18 pub fn detect_bpm_key(samples: &[f32], sample_rate: u32, min_duration: f64) -> BpmKeyResult {
19 + if sample_rate == 0 {
20 + return BpmKeyResult {
21 + bpm: None,
22 + bpm_confidence: None,
23 + key: None,
24 + key_confidence: None,
25 + };
26 + }
19 27 let duration = samples.len() as f64 / sample_rate as f64;
20 28 if duration < min_duration {
21 29 return BpmKeyResult {
@@ -216,6 +216,9 @@ impl TreeNode {
216 216 left,
217 217 right,
218 218 } => {
219 + if *feature >= NUM_FEATURES {
220 + return 0; // fallback for malformed model
221 + }
219 222 if features[*feature] <= *threshold {
220 223 left.predict(features)
221 224 } else {
@@ -326,27 +329,48 @@ static LAYER2_BASS_MODEL: OnceLock<RandomForestModel> = OnceLock::new();
326 329 static LAYER2_VOCAL_MODEL: OnceLock<RandomForestModel> = OnceLock::new();
327 330 static LAYER2_SYNTH_MODEL: OnceLock<RandomForestModel> = OnceLock::new();
328 331
332 + /// Fallback empty model — classification falls back to rule-based layer 1 only.
333 + fn empty_model() -> RandomForestModel {
334 + RandomForestModel {
335 + trees: vec![],
336 + num_classes: 0,
337 + class_names: vec![],
338 + }
339 + }
340 +
329 341 fn layer2_model() -> &'static RandomForestModel {
330 342 LAYER2_DRUM_MODEL.get_or_init(|| {
331 - serde_json::from_slice(LAYER2_DRUM_BYTES).expect("embedded drum model is invalid JSON")
343 + serde_json::from_slice(LAYER2_DRUM_BYTES).unwrap_or_else(|e| {
344 + tracing::error!("Failed to deserialize embedded drum model: {e}");
345 + empty_model()
346 + })
332 347 })
333 348 }
334 349
335 350 fn layer2_bass_model() -> &'static RandomForestModel {
336 351 LAYER2_BASS_MODEL.get_or_init(|| {
337 - serde_json::from_slice(LAYER2_BASS_BYTES).expect("embedded bass model is invalid JSON")
352 + serde_json::from_slice(LAYER2_BASS_BYTES).unwrap_or_else(|e| {
353 + tracing::error!("Failed to deserialize embedded bass model: {e}");
354 + empty_model()
355 + })
338 356 })
339 357 }
340 358
341 359 fn layer2_vocal_model() -> &'static RandomForestModel {
342 360 LAYER2_VOCAL_MODEL.get_or_init(|| {
343 - serde_json::from_slice(LAYER2_VOCAL_BYTES).expect("embedded vocal model is invalid JSON")
361 + serde_json::from_slice(LAYER2_VOCAL_BYTES).unwrap_or_else(|e| {
362 + tracing::error!("Failed to deserialize embedded vocal model: {e}");
363 + empty_model()
364 + })
344 365 })
345 366 }
346 367
347 368 fn layer2_synth_model() -> &'static RandomForestModel {
348 369 LAYER2_SYNTH_MODEL.get_or_init(|| {
349 - serde_json::from_slice(LAYER2_SYNTH_BYTES).expect("embedded synth model is invalid JSON")
370 + serde_json::from_slice(LAYER2_SYNTH_BYTES).unwrap_or_else(|e| {
371 + tracing::error!("Failed to deserialize embedded synth model: {e}");
372 + empty_model()
373 + })
350 374 })
351 375 }
352 376
@@ -113,6 +113,10 @@ pub fn decode_to_mono(path: &Path) -> Result<DecodedAudio, CoreError> {
113 113 let num_frames = decoded.frames();
114 114 let num_channels = spec.channels.count();
115 115
116 + if num_channels == 0 {
117 + continue;
118 + }
119 +
116 120 let mut sample_buf = SampleBuffer::<f32>::new(num_frames as u64, *decoded.spec());
117 121 sample_buf.copy_interleaved_ref(decoded);
118 122 let samples = sample_buf.samples();
@@ -78,7 +78,7 @@ fn start_end_correlation(samples: &[f32]) -> f64 {
78 78
79 79 /// Check if the sample duration is a clean multiple of one beat at the given BPM.
80 80 fn is_beat_aligned(samples: &[f32], sample_rate: u32, bpm: f64) -> bool {
81 - if bpm <= 0.0 {
81 + if bpm <= 0.0 || sample_rate == 0 {
82 82 return false;
83 83 }
84 84
@@ -84,8 +84,30 @@ pub fn analyze_sample(
84 84 path: &Path,
85 85 config: &AnalysisConfig,
86 86 ) -> Result<AnalysisResult, CoreError> {
87 + // Guard against memory exhaustion: reject files over 2 GB before decoding.
88 + // A 2 GB compressed file would expand to several GB of f32 samples.
89 + const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024;
90 + if let Ok(metadata) = std::fs::metadata(path) {
91 + if metadata.len() > MAX_FILE_SIZE {
92 + return Err(CoreError::Analysis(crate::error::AnalysisError::ProbeFailed(
93 + format!("file too large for analysis ({} MB, max {} MB)",
94 + metadata.len() / (1024 * 1024), MAX_FILE_SIZE / (1024 * 1024)),
95 + )));
96 + }
97 + }
98 +
87 99 let decoded = decode::decode_to_mono(path)?;
88 100
101 + // Hard cap: reject files over 30 minutes to prevent memory exhaustion.
102 + // A 30-minute 96kHz mono signal is ~660 MB of f32 — beyond that is almost
103 + // certainly not a sample.
104 + const MAX_DECODE_DURATION: f64 = 1800.0;
105 + if decoded.duration > MAX_DECODE_DURATION {
106 + return Err(CoreError::Analysis(crate::error::AnalysisError::ProbeFailed(
107 + format!("file too long for analysis ({:.0}s, max {MAX_DECODE_DURATION}s)", decoded.duration),
108 + )));
109 + }
110 +
89 111 // Cap samples for expensive analyses (STFT, BPM/key). Cheap analyses and
90 112 // fingerprint use the full signal.
91 113 let capped_samples: &[f32] = if let Some(max_secs) = config.max_analysis_seconds {
@@ -157,8 +157,13 @@ fn worker_loop(
157 157 current_name: name,
158 158 });
159 159
160 - match analyze_sample(hash, path, &config) {
161 - Ok(result) => {
160 + // Catch panics so a single bad sample doesn't kill the worker
161 + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
162 + analyze_sample(hash, path, &config)
163 + }));
164 +
165 + match result {
166 + Ok(Ok(result)) => {
162 167 let suggestions = if config.auto_suggest_tags {
163 168 super::suggest::suggest_tags(&result)
164 169 } else {
@@ -169,12 +174,18 @@ fn worker_loop(
169 174 suggestions,
170 175 });
171 176 }
172 - Err(e) => {
177 + Ok(Err(e)) => {
173 178 let _ = event_tx.send(WorkerEvent::SampleError {
174 179 hash: hash.clone(),
175 180 error: e.to_string(),
176 181 });
177 182 }
183 + Err(_panic) => {
184 + let _ = event_tx.send(WorkerEvent::SampleError {
185 + hash: hash.clone(),
186 + error: "analysis panicked (internal error)".to_string(),
187 + });
188 + }
178 189 }
179 190
180 191 completed.fetch_add(1, Ordering::Relaxed);
@@ -608,6 +608,13 @@ const MIGRATION_013: &str = r#"
608 608 ALTER TABLE samples ADD COLUMN source_path TEXT;
609 609 "#;
610 610
611 + const MIGRATION_014: &str = r#"
612 + -- Prevent duplicate root-level VFS node names. The existing UNIQUE(vfs_id, parent_id, name)
613 + -- constraint treats NULLs as distinct, so root nodes (parent_id IS NULL) could collide.
614 + CREATE UNIQUE INDEX IF NOT EXISTS idx_vfs_nodes_root_unique
615 + ON vfs_nodes(vfs_id, name) WHERE parent_id IS NULL;
616 + "#;
617 +
611 618 impl Database {
612 619 /// Open (or create) the database at the given path and run migrations.
613 620 #[instrument(skip_all)]
@@ -669,6 +676,7 @@ impl Database {
669 676 MIGRATION_011,
670 677 MIGRATION_012,
671 678 MIGRATION_013,
679 + MIGRATION_014,
672 680 ];
673 681
674 682 for (i, sql) in MIGRATIONS.iter().enumerate() {
@@ -709,8 +717,17 @@ impl Database {
709 717 .collect::<Vec<_>>()
710 718 .join("\n");
711 719 if !non_alter.trim().is_empty() {
712 - // Ignore errors from already-created objects
713 - let _ = self.conn.execute_batch(&non_alter);
720 + // Ignore "already exists" errors from prior partial runs;
721 + // log anything else as a warning.
722 + if let Err(e) = self.conn.execute_batch(&non_alter) {
723 + let msg = e.to_string();
724 + if !msg.contains("already exists") {
725 + tracing::warn!(
726 + migration = target,
727 + "Non-ALTER migration statement failed: {msg}"
728 + );
729 + }
730 + }
714 731 }
715 732 self.conn.execute_batch(
716 733 &format!("PRAGMA user_version = {};\nCOMMIT;", target),
@@ -809,7 +826,7 @@ mod tests {
809 826 .conn()
810 827 .query_row("PRAGMA user_version", [], |row| row.get(0))
811 828 .unwrap();
812 - assert_eq!(version, 13);
829 + assert_eq!(version, 14);
813 830 }
814 831
815 832 #[test]
@@ -820,7 +837,7 @@ mod tests {
820 837 .conn()
821 838 .query_row("PRAGMA user_version", [], |row| row.get(0))
822 839 .unwrap();
823 - assert_eq!(version, 13);
840 + assert_eq!(version, 14);
824 841 }
825 842
826 843 #[test]
@@ -0,0 +1,85 @@
1 + //! Channel conversion: mono-to-stereo and stereo-to-mono destructive edits.
2 +
3 + use crate::error::CoreError;
4 +
5 + /// Convert mono interleaved samples to stereo by duplicating each sample.
6 + ///
7 + /// Returns the new channel count (2). Caller must update the stored channel count.
8 + pub fn apply_mono_to_stereo(samples: &mut Vec<f32>) -> Result<u16, CoreError> {
9 + let n = samples.len();
10 + let mut stereo = Vec::with_capacity(n * 2);
11 + for &s in samples.iter() {
12 + stereo.push(s);
13 + stereo.push(s);
14 + }
15 + *samples = stereo;
16 + Ok(2)
17 + }
18 +
19 + /// Convert stereo interleaved samples to mono by averaging L+R per frame.
20 + ///
21 + /// Returns the new channel count (1). Caller must update the stored channel count.
22 + pub fn apply_stereo_to_mono(samples: &mut Vec<f32>, channels: u16) -> Result<u16, CoreError> {
23 + if channels < 2 {
24 + return Ok(channels); // already mono, no-op
25 + }
26 + let ch = channels as usize;
27 + let num_frames = samples.len() / ch;
28 + let mut mono = Vec::with_capacity(num_frames);
29 + for frame in 0..num_frames {
30 + let mut sum = 0.0f32;
31 + for c in 0..ch {
32 + sum += samples[frame * ch + c];
33 + }
34 + mono.push(sum / ch as f32);
35 + }
36 + *samples = mono;
37 + Ok(1)
38 + }
39 +
40 + #[cfg(test)]
41 + mod tests {
42 + use super::*;
43 +
44 + #[test]
45 + fn mono_to_stereo() {
46 + let mut samples = vec![0.1, 0.2, 0.3];
47 + let ch = apply_mono_to_stereo(&mut samples).unwrap();
48 + assert_eq!(ch, 2);
49 + assert_eq!(samples, vec![0.1, 0.1, 0.2, 0.2, 0.3, 0.3]);
50 + }
51 +
52 + #[test]
53 + fn stereo_to_mono() {
54 + let mut samples = vec![0.4, 0.6, 0.2, 0.8];
55 + let ch = apply_stereo_to_mono(&mut samples, 2).unwrap();
56 + assert_eq!(ch, 1);
57 + assert_eq!(samples.len(), 2);
58 + assert!((samples[0] - 0.5).abs() < 1e-6);
59 + assert!((samples[1] - 0.5).abs() < 1e-6);
60 + }
61 +
62 + #[test]
63 + fn mono_to_mono_noop() {
64 + let mut samples = vec![0.1, 0.2];
65 + let ch = apply_stereo_to_mono(&mut samples, 1).unwrap();
66 + assert_eq!(ch, 1);
67 + assert_eq!(samples, vec![0.1, 0.2]);
68 + }
69 +
70 + #[test]
71 + fn roundtrip_preserves_energy() {
72 + let mut samples = vec![0.5, -0.5, 0.3, -0.3];
73 + let _ = apply_stereo_to_mono(&mut samples, 2).unwrap();
74 + // Mono: [0.0, 0.0] — opposing channels cancel
75 + assert!((samples[0]).abs() < 1e-6);
76 + }
77 +
78 + #[test]
79 + fn empty_input() {
80 + let mut samples: Vec<f32> = vec![];
81 + let ch = apply_mono_to_stereo(&mut samples).unwrap();
82 + assert_eq!(ch, 2);
83 + assert!(samples.is_empty());
84 + }
85 + }
@@ -0,0 +1,75 @@
1 + //! DC offset removal: compute mean amplitude and subtract it from all samples.
2 +
3 + /// Remove DC offset from interleaved audio samples.
4 + ///
5 + /// Computes the mean sample value (the DC component) and subtracts it,
6 + /// centering the waveform around zero. Operates per-channel to handle
7 + /// stereo files where channels may have different offsets.
8 + pub fn apply_remove_dc_offset(samples: &mut [f32], channels: u16) {
9 + if samples.is_empty() || channels == 0 {
10 + return;
11 + }
12 + let ch = channels as usize;
13 + let num_frames = samples.len() / ch;
14 + if num_frames == 0 {
15 + return;
16 + }
17 +
18 + // Compute per-channel mean
19 + let mut sums = vec![0.0f64; ch];
20 + for frame in 0..num_frames {
21 + for c in 0..ch {
22 + sums[c] += samples[frame * ch + c] as f64;
23 + }
24 + }
25 + let means: Vec<f32> = sums.iter().map(|s| (*s / num_frames as f64) as f32).collect();
26 +
27 + // Subtract per-channel mean
28 + for frame in 0..num_frames {
29 + for c in 0..ch {
30 + samples[frame * ch + c] -= means[c];
31 + }
32 + }
33 + }
34 +
35 + #[cfg(test)]
36 + mod tests {
37 + use super::*;
38 +
39 + #[test]
40 + fn removes_dc_offset_mono() {
41 + let mut samples = vec![0.5, 0.6, 0.4, 0.5]; // mean = 0.5
42 + apply_remove_dc_offset(&mut samples, 1);
43 + let mean: f32 = samples.iter().sum::<f32>() / samples.len() as f32;
44 + assert!(mean.abs() < 1e-6, "mean should be ~0, got {mean}");
45 + assert!((samples[0] - 0.0).abs() < 1e-6);
46 + assert!((samples[1] - 0.1).abs() < 1e-6);
47 + }
48 +
49 + #[test]
50 + fn removes_dc_offset_stereo() {
51 + // L channel: mean 0.5, R channel: mean -0.5
52 + let mut samples = vec![0.5, -0.5, 0.5, -0.5];
53 + apply_remove_dc_offset(&mut samples, 2);
54 + // After removal, both channels centered at 0
55 + assert!((samples[0]).abs() < 1e-6);
56 + assert!((samples[1]).abs() < 1e-6);
57 + }
58 +
59 + #[test]
60 + fn noop_on_already_centered() {
61 + let original = vec![0.1, -0.1, 0.2, -0.2];
62 + let mut samples = original.clone();
63 + apply_remove_dc_offset(&mut samples, 1);
64 + for (a, b) in samples.iter().zip(original.iter()) {
65 + assert!((a - b).abs() < 1e-6);
66 + }
67 + }
68 +
69 + #[test]
70 + fn empty_input() {
71 + let mut samples: Vec<f32> = vec![];
72 + apply_remove_dc_offset(&mut samples, 1);
73 + assert!(samples.is_empty());
74 + }
75 + }
@@ -1,8 +1,10 @@
1 - //! Destructive sample editing: trim, normalize, reverse, gain, fade.
1 + //! Destructive sample editing: trim, normalize, reverse, gain, fade, DC offset, channel conversion.
2 2 //!
3 3 //! Each operation is a pure function operating on `Vec<f32>` sample data.
4 4 //! The [`EditOperation`] enum dispatches to the correct function via [`apply_edit`].
5 5
6 + pub mod channel_convert;
7 + pub mod dc_offset;
6 8 pub mod fade;
7 9 pub mod gain;
8 10 pub mod normalize;
@@ -39,6 +41,12 @@ pub enum EditOperation {
39 41 frames: usize,
40 42 curve: FadeCurve,
41 43 },
44 + /// Remove DC offset (center waveform around zero).
45 + RemoveDcOffset,
46 + /// Convert mono to stereo (duplicate channels).
47 + MonoToStereo,
48 + /// Convert stereo/multi-channel to mono (average channels).
49 + StereoToMono,
42 50 }
43 51
44 52 impl EditOperation {
@@ -52,44 +60,61 @@ impl EditOperation {
52 60 EditOperation::Gain { .. } => "Gain",
53 61 EditOperation::FadeIn { .. } => "Fade In",
54 62 EditOperation::FadeOut { .. } => "Fade Out",
63 + EditOperation::RemoveDcOffset => "Remove DC Offset",
64 + EditOperation::MonoToStereo => "Mono → Stereo",
65 + EditOperation::StereoToMono => "Stereo → Mono",
55 66 }
56 67 }
57 68 }
58 69
59 70 /// Apply an edit operation to interleaved sample data in-place.
71 + ///
72 + /// Returns the (possibly changed) channel count — channel conversion operations
73 + /// modify the number of channels.
60 74 pub fn apply_edit(
61 75 samples: &mut Vec<f32>,
62 76 channels: u16,
63 77 sample_rate: u32,
64 78 operation: &EditOperation,
65 - ) -> Result<(), CoreError> {
79 + ) -> Result<u16, CoreError> {
66 80 match operation {
67 81 EditOperation::Trim {
68 82 start_frame,
69 83 end_frame,
70 - } => trim::apply_trim(samples, channels, *start_frame, *end_frame),
84 + } => {
85 + trim::apply_trim(samples, channels, *start_frame, *end_frame)?;
86 + Ok(channels)
87 + }
71 88 EditOperation::NormalizePeak { target_db } => {
72 - normalize::apply_normalize_peak(samples, *target_db)
89 + normalize::apply_normalize_peak(samples, *target_db)?;
90 + Ok(channels)
73 91 }
74 92 EditOperation::NormalizeLufs { target_lufs } => {
75 - normalize::apply_normalize_lufs(samples, channels, sample_rate, *target_lufs)
93 + normalize::apply_normalize_lufs(samples, channels, sample_rate, *target_lufs)?;
94 + Ok(channels)
76 95 }
77 96 EditOperation::Reverse => {
78 97 reverse::apply_reverse(samples, channels);
79 - Ok(())
98 + Ok(channels)
80 99 }
81 100 EditOperation::Gain { db } => {
82 101 gain::apply_gain(samples, *db);
83 - Ok(())
102 + Ok(channels)
84 103 }
85 104 EditOperation::FadeIn { frames, curve } => {
86 105 fade::apply_fade_in(samples, channels, *frames, *curve);
87 - Ok(())
106 + Ok(channels)
88 107 }
89 108 EditOperation::FadeOut { frames, curve } => {
90 109 fade::apply_fade_out(samples, channels, *frames, *curve);
91 - Ok(())
110 + Ok(channels)
111 + }
112 + EditOperation::RemoveDcOffset => {
113 + dc_offset::apply_remove_dc_offset(samples, channels);
114 + Ok(channels)
92 115 }
116 + EditOperation::MonoToStereo => channel_convert::apply_mono_to_stereo(samples),
117 + EditOperation::StereoToMono => channel_convert::apply_stereo_to_mono(samples, channels),
93 118 }
94 119 }
95 120
@@ -180,4 +205,35 @@ mod tests {
180 205 let decoded: EditOperation = serde_json::from_str(&json).unwrap();
181 206 assert_eq!(decoded.display_name(), "Fade In");
182 207 }
208 +
209 + #[test]
210 + fn apply_edit_dispatches_dc_offset() {
211 + let mut samples = vec![1.0, 1.0, 1.0]; // DC offset of 1.0
212 + let ch = apply_edit(&mut samples, 1, 44100, &EditOperation::RemoveDcOffset).unwrap();
213 + assert_eq!(ch, 1);
214 + assert!(samples.iter().all(|s| s.abs() < 1e-6));
215 + }
216 +
217 + #[test]
218 + fn apply_edit_dispatches_mono_to_stereo() {
219 + let mut samples = vec![0.5, -0.5];
220 + let ch = apply_edit(&mut samples, 1, 44100, &EditOperation::MonoToStereo).unwrap();
221 + assert_eq!(ch, 2);
222 + assert_eq!(samples, vec![0.5, 0.5, -0.5, -0.5]);
223 + }
224 +
225 + #[test]
226 + fn apply_edit_dispatches_stereo_to_mono() {
227 + let mut samples = vec![0.4, 0.6, 0.2, 0.8];
228 + let ch = apply_edit(&mut samples, 2, 44100, &EditOperation::StereoToMono).unwrap();
229 + assert_eq!(ch, 1);
230 + assert_eq!(samples.len(), 2);
231 + }
232 +
233 + #[test]
234 + fn channel_conversion_display_names() {
235 + assert_eq!(EditOperation::RemoveDcOffset.display_name(), "Remove DC Offset");
236 + assert_eq!(EditOperation::MonoToStereo.display_name(), "Mono → Stereo");
237 + assert_eq!(EditOperation::StereoToMono.display_name(), "Stereo → Mono");
238 + }
183 239 }
@@ -61,7 +61,7 @@ impl EditWorkerHandle {
61 61 /// Send a command to the worker.
62 62 pub fn send(&self, cmd: EditCommand) {
63 63 if matches!(cmd, EditCommand::Cancel) {
64 - self.cancel_flag.store(true, Ordering::Relaxed);
64 + self.cancel_flag.store(true, Ordering::Release);
65 65 }
66 66 let _ = self.cmd_tx.send(cmd);
67 67 }
@@ -69,7 +69,7 @@ impl EditWorkerHandle {
69 69
70 70 impl Drop for EditWorkerHandle {
71 71 fn drop(&mut self) {
72 - self.cancel_flag.store(true, Ordering::Relaxed);
72 + self.cancel_flag.store(true, Ordering::Release);
73 73 let _ = self.cmd_tx.send(EditCommand::Shutdown);
74 74 if let Some(handle) = self.thread.take() {
75 75 let _ = handle.join();
@@ -99,11 +99,27 @@ pub fn spawn_edit_worker() -> std::io::Result<EditWorkerHandle> {
99 99 })
100 100 }
101 101
102 + /// Clean up leftover temp files from previous edit sessions.
103 + fn cleanup_edit_temp_dir() {
104 + let temp_dir = std::env::temp_dir().join("audiofiles_edit");
105 + if let Ok(entries) = std::fs::read_dir(&temp_dir) {
106 + for entry in entries.flatten() {
107 + let path = entry.path();
108 + if path.extension().and_then(|e| e.to_str()) == Some("wav") {
109 + let _ = std::fs::remove_file(&path);
110 + }
111 + }
112 + }
113 + }
114 +
102 115 fn edit_worker_loop(
103 116 cmd_rx: mpsc::Receiver<EditCommand>,
104 117 event_tx: mpsc::Sender<EditEvent>,
105 118 cancel_flag: Arc<AtomicBool>,
106 119 ) {
120 + // Clean up stale temp files from prior sessions
121 + cleanup_edit_temp_dir();
122 +
107 123 while let Ok(cmd) = cmd_rx.recv() {
108 124 match cmd {
109 125 EditCommand::Shutdown => break,
@@ -114,17 +130,17 @@ fn edit_worker_loop(
114 130 path,
115 131 operation,
116 132 } => {
117 - cancel_flag.store(false, Ordering::Relaxed);
133 + cancel_flag.store(false, Ordering::Release);
118 134
119 135 let _ = event_tx.send(EditEvent::Started {
120 136 hash: hash.clone(),
121 137 });
122 138
123 - if cancel_flag.load(Ordering::Relaxed) {
139 + if cancel_flag.load(Ordering::Acquire) {
124 140 continue;
125 141 }
126 142
127 - match process_edit(&path, &operation) {
143 + match process_edit(&path, &operation, &cancel_flag) {
128 144 Ok(result_path) => {
129 145 let _ = event_tx.send(EditEvent::Complete {
130 146 source_hash: hash,
@@ -148,18 +164,29 @@ fn edit_worker_loop(
148 164 fn process_edit(
149 165 source_path: &Path,
150 166 operation: &EditOperation,
167 + cancel_flag: &AtomicBool,
151 168 ) -> Result<PathBuf, crate::error::CoreError> {
152 169 // 1. Decode
153 170 let mut decoded = decode_multichannel(source_path)?;
154 171
155 - // 2. Apply edit
156 - apply_edit(
172 + // Check cancel between decode and edit
173 + if cancel_flag.load(Ordering::Acquire) {
174 + return Err(crate::error::CoreError::Internal("edit cancelled".to_string()));
175 + }
176 +
177 + // 2. Apply edit (may change channel count)
178 + decoded.channels = apply_edit(
157 179 &mut decoded.samples,
158 180 decoded.channels,
159 181 decoded.sample_rate,
160 182 operation,
161 183 )?;
162 184
185 + // Check cancel between edit and encode
186 + if cancel_flag.load(Ordering::Acquire) {
187 + return Err(crate::error::CoreError::Internal("edit cancelled".to_string()));
188 + }
189 +
163 190 // 3. Write to temp WAV (24-bit to preserve quality)
164 191 let temp_dir = std::env::temp_dir().join("audiofiles_edit");
165 192 std::fs::create_dir_all(&temp_dir).map_err(|e| crate::error::io_err(&temp_dir, e))?;
@@ -274,7 +301,8 @@ mod tests {
274 301 .unwrap();
275 302
276 303 // Test reverse edit
277 - let result = process_edit(&wav_path, &EditOperation::Reverse).unwrap();
304 + let flag = AtomicBool::new(false);
305 + let result = process_edit(&wav_path, &EditOperation::Reverse, &flag).unwrap();
278 306 assert!(result.exists());
279 307
280 308 // Read back and verify
@@ -20,6 +20,9 @@ pub fn convert_channels(
20 20 src_channels: u16,
21 21 target: &ExportChannels,
22 22 ) -> (Vec<f32>, u16) {
23 + if src_channels == 0 || samples.is_empty() {
24 + return (Vec::new(), src_channels.max(1));
25 + }
23 26 match target {
24 27 ExportChannels::Original => (samples.to_vec(), src_channels),
25 28 ExportChannels::Mono => {
@@ -77,6 +80,11 @@ pub fn resample(
77 80 if src_rate == dst_rate {
78 81 return Ok(samples.to_vec());
79 82 }
83 + if channels == 0 || src_rate == 0 || dst_rate == 0 {
84 + return Err(CoreError::Export(format!(
85 + "invalid resample params: channels={channels}, src_rate={src_rate}, dst_rate={dst_rate}"
86 + )));
87 + }
80 88
81 89 let ch = channels as usize;
82 90 let num_frames = samples.len() / ch;
@@ -23,6 +23,9 @@ pub fn encode_aiff(audio: &ConvertedAudio, bit_depth: u16, dest: &Path) -> Resul
23 23 }
24 24
25 25 let channels = audio.channels;
26 + if channels == 0 {
27 + return Err(CoreError::Export("AIFF: 0 channels".to_string()));
28 + }
26 29 let num_frames = audio.samples.len() / channels as usize;
27 30 let bytes_per_sample = (bit_depth / 8) as u64;
28 31
M docs/todo.md +54 -3