Skip to main content

max / audiofiles

Add Sample Forge DAWless slice and beta UX polish Forge (new audiofiles-core::forge module): - Chop: transient (spectral-flux onset NMS), equal divisions, BPM grid; export slices into a {name}_slices VFS folder. - Conform: resample + bit-depth + channels to a device's accepted format (ConformTarget::for_device); writes a new sibling sample. - Batch: silence-detection DSP + EditOperation::TrimSilence via the batch path. - Runner ties the DSP to the store/VFS; backend + Sample Forge window (F key / detail button) with chop preview, device picker, batch trim. - Extend export::encode to 8-bit unsigned and 32-bit float WAV (also fixes a latent export gap for M8/Tracker/Blackbox depths). - CLAP/VST host foreshadowed as "coming soon" copy only. UX polish: modal scrim behind modals, inline sidebar editor auto-focus, VFS Delete via danger_button, import step-rail on progress screens, scrollable configure-import. Audit fixes: onset boundaries offset one hop earlier, strongest-first peak suppression, forge waveform bound to its own sample, device selection reset. .gitignore: keep dist/*.sh version-controlled. 839 tests green, release build 0 warnings.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-07 19:15 UTC
Signed with PGP, not checked
Commit: f2c413a51e2368706b73df6fae38c8d4236a7186
Parent: b4e681d
25 files changed, +1963 insertions, -16 deletions
M .gitignore +3
@@ -4,6 +4,9 @@
4 4 dist/*.dmg
5 5 dist/*.AppImage
6 6 dist/*.deb
7 + # Release/build scripts are source — keep them version-controlled even if a
8 + # broader dist/ ignore is ever added.
9 + !dist/*.sh
7 10 dist/AudioFiles.app/
8 11 dist/AppDir/
9 12 dist/tools/
@@ -3,7 +3,7 @@
3 3 use egui;
4 4
5 5 use crate::state::{BrowserState, ImportMode};
6 - use crate::ui::{detail, edit_panel, export_screens, file_list, filter_panel, footer, import_screens, instrument_panel, overlays, sidebar, theme, toolbar};
6 + use crate::ui::{detail, edit_panel, export_screens, file_list, filter_panel, footer, forge_panel, import_screens, instrument_panel, overlays, sidebar, theme, toolbar};
7 7 use audiofiles_core::vfs::NodeType;
8 8
9 9 /// Top-level draw function called each frame from the update closure.
@@ -92,6 +92,22 @@
92 92 }
93 93 }
94 94
95 + // Scrim behind genuine modals (not the floating tool windows). Painted once
96 + // before any modal so it sits below the topmost modal but blocks pointer
97 + // input to the live UI underneath (P2).
98 + let modal_active = state.pending_confirm.is_some()
99 + || state.bulk_modal.is_some()
100 + || state.show_help
101 + || state.show_vfs_create
102 + || state.vfs_rename_target.is_some()
103 + || state.show_dir_create
104 + || state.dir_rename_target.is_some()
105 + || state.show_loose_files_warning
106 + || state.pending_import_preflight.is_some();
107 + if modal_active {
108 + crate::ui::widgets::modal_scrim(ctx);
109 + }
110 +
95 111 // Overlays drawn on top of any screen
96 112 if state.pending_confirm.is_some() {
97 113 overlays::draw_confirm_dialog(ctx, state);
@@ -139,6 +155,11 @@
139 155 if state.edit.show_window {
140 156 edit_panel::draw_edit_window(ctx, state);
141 157 }
158 +
159 + // Floating sample forge window
160 + if state.forge.show_window {
161 + forge_panel::draw_forge_window(ctx, state);
162 + }
142 163 }
143 164
144 165 /// Draw the main browser layout: toolbar, footer, sidebar, detail panel, and file list.
@@ -227,6 +248,8 @@
227 248 if input.key_pressed(egui::Key::Escape) {
228 249 if state.settings.show_manager {
229 250 state.settings.show_manager = false;
251 + } else if state.forge.show_window {
252 + state.close_forge_window();
230 253 } else if state.edit.show_window {
231 254 state.close_edit_window();
232 255 } else if state.sync.show_panel {
@@ -387,6 +410,17 @@
387 410 }
388 411 }
389 412 }
413 + // "F" toggles the floating Sample Forge window for the selected sample
414 + if input.key_pressed(egui::Key::F) && !shift {
415 + if state.forge.show_window {
416 + state.close_forge_window();
417 + } else if let Some(node) = state.selected_node() {
418 + if let Some(hash) = &node.node.sample_hash {
419 + let hash = hash.clone();
420 + state.open_forge_window(&hash);
421 + }
422 + }
423 + }
390 424 // "L" toggles loop
391 425 if input.key_pressed(egui::Key::L) {
392 426 state.toggle_loop();
@@ -40,6 +40,7 @@
40 40 pub mod error;
41 41 pub mod export;
42 42 pub mod fingerprint;
43 + pub mod forge;
43 44 pub mod id_types;
44 45 pub mod instrument;
45 46 pub mod rename;
@@ -16,6 +16,7 @@
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 20 use audiofiles_core::search::SearchFilter;
20 21 use audiofiles_core::collections::Collection;
21 22 use audiofiles_core::store::SampleStore;
@@ -713,6 +714,100 @@
713 714 }
714 715 }
715 716
717 + fn device_conform_target(
718 + &self,
719 + profile_name: &str,
720 + source_rate: u32,
721 + ) -> BackendResult<Option<ConformTarget>> {
722 + #[cfg(feature = "device-profiles")]
723 + {
724 + Ok(self
725 + .plugin_registry
726 + .get(profile_name)
727 + .map(|plugin| ConformTarget::for_device(&plugin.profile, source_rate)))
728 + }
729 + #[cfg(not(feature = "device-profiles"))]
730 + {
731 + let _ = (profile_name, source_rate);
732 + Ok(None)
733 + }
734 + }
735 +
736 + // --- Sample Forge ---
737 +
738 + #[instrument(skip_all)]
739 + fn compute_chop_preview(
740 + &self,
741 + hash: &str,
742 + ext: &str,
743 + method: &ChopMethod,
744 + ) -> BackendResult<Vec<f32>> {
745 + let db = self.db.lock();
746 + let path = audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?;
747 + let decoded = audiofiles_core::export::decode::decode_multichannel(&path)?;
748 + let total_frames = (decoded.samples.len() / decoded.channels.max(1) as usize).max(1);
749 + let slices = audiofiles_core::forge::compute_slices(
750 + &decoded.samples,
751 + decoded.channels,
752 + decoded.sample_rate,
753 + method,
754 + )?;
755 + // Boundary fractions: each slice's start, plus the final end (1.0).
756 + let mut marks: Vec<f32> = slices
757 + .iter()
758 + .map(|s| s.start_frame as f32 / total_frames as f32)
759 + .collect();
760 + marks.push(1.0);
761 + Ok(marks)
762 + }
763 +
764 + #[instrument(skip_all)]
765 + fn chop_sample(
766 + &self,
767 + vfs_id: VfsId,
768 + hash: &str,
769 + ext: &str,
770 + name: &str,
771 + parent_id: Option<NodeId>,
772 + method: &ChopMethod,
773 + ) -> BackendResult<usize> {
774 + let db = self.db.lock();
775 + let path = audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?;
776 + let result = audiofiles_core::forge::chop_to_vfs(
777 + &self.store,
778 + &db,
779 + vfs_id,
780 + &path,
781 + name,
782 + parent_id,
783 + method,
784 + )?;
785 + Ok(result.slice_count)
786 + }
787 +
788 + #[instrument(skip_all)]
789 + fn conform_sample(
790 + &self,
791 + vfs_id: VfsId,
792 + hash: &str,
793 + ext: &str,
794 + name: &str,
795 + parent_id: Option<NodeId>,
796 + target: &ConformTarget,
797 + ) -> BackendResult<String> {
798 + let db = self.db.lock();
799 + let path = audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?;
800 + Ok(audiofiles_core::forge::conform_to_vfs(
801 + &self.store,
802 + &db,
803 + vfs_id,
804 + &path,
805 + name,
806 + parent_id,
807 + target,
808 + )?)
809 + }
810 +
716 811 // --- Config ---
717 812
718 813 fn get_config(&self, key: &str) -> BackendResult<Option<String>> {
@@ -1313,6 +1408,78 @@
1313 1408 assert_eq!(dirs.len(), 2);
1314 1409 }
1315 1410
1411 + /// Write a minimal float-PCM WAV for forge integration tests.
1412 + fn write_float_wav(path: &Path, channels: u16, sample_rate: u32, samples: &[f32]) {
1413 + use std::io::Write;
1414 + let bytes_per_sample = 4u16;
1415 + let block_align = channels * bytes_per_sample;
1416 + let data_size = (samples.len() as u32) * 4;
1417 + let file_size = 36 + data_size;
1418 + let mut buf = Vec::with_capacity(44 + data_size as usize);
1419 + buf.extend_from_slice(b"RIFF");
1420 + buf.extend_from_slice(&file_size.to_le_bytes());
1421 + buf.extend_from_slice(b"WAVE");
1422 + buf.extend_from_slice(b"fmt ");
1423 + buf.extend_from_slice(&16u32.to_le_bytes());
1424 + buf.extend_from_slice(&3u16.to_le_bytes());
1425 + buf.extend_from_slice(&channels.to_le_bytes());
1426 + buf.extend_from_slice(&sample_rate.to_le_bytes());
1427 + buf.extend_from_slice(&(sample_rate * block_align as u32).to_le_bytes());
1428 + buf.extend_from_slice(&block_align.to_le_bytes());
1429 + buf.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes());
1430 + buf.extend_from_slice(b"data");
1431 + buf.extend_from_slice(&data_size.to_le_bytes());
1432 + for &s in samples {
1433 + buf.extend_from_slice(&s.to_le_bytes());
1434 + }
1435 + std::fs::File::create(path).unwrap().write_all(&buf).unwrap();
1436 + }
1437 +
1438 + #[test]
1439 + fn chop_sample_creates_slice_folder() {
1440 + use audiofiles_core::forge::ChopMethod;
1441 + // Keep the temp dir alive for the whole test (store lives under it).
1442 + let dir = tempfile::TempDir::new().unwrap();
1443 + let db = Database::open_in_memory().unwrap();
1444 + let store = SampleStore::new(dir.path().join("store")).unwrap();
1445 + let backend = DirectBackend::new(db, store, dir.path().to_path_buf());
1446 +
1447 + let vfs_id = backend.create_vfs("Test").unwrap();
1448 + let samples: Vec<f32> = (0..2000).map(|i| ((i % 40) as f32 / 40.0) - 0.5).collect();
1449 + let src = dir.path().join("loop.wav");
1450 + write_float_wav(&src, 1, 44100, &samples);
1451 + let hash = backend.import_file(&src).unwrap();
1452 +
1453 + // Preview returns slice boundaries (N starts + trailing 1.0).
1454 + let marks = backend
1455 + .compute_chop_preview(&hash, "wav", &ChopMethod::EqualDivisions(4))
1456 + .unwrap();
1457 + assert_eq!(marks.len(), 5);
1458 + assert_eq!(marks.last().copied(), Some(1.0));
1459 +
1460 + let count = backend
1461 + .chop_sample(vfs_id, &hash, "wav", "loop.wav", None, &ChopMethod::EqualDivisions(4))
1462 + .unwrap();
1463 + assert_eq!(count, 4);
1464 +
1465 + // A "loop_slices" directory now holds 4 samples.
1466 + let roots = backend.list_children(vfs_id, None).unwrap();
1467 + let slice_dir = roots.iter().find(|n| n.name == "loop_slices").unwrap();
1468 + let slices = backend.list_children(vfs_id, Some(slice_dir.id)).unwrap();
1469 + assert_eq!(slices.len(), 4);
1470 + }
1471 +
1472 + #[test]
1473 + #[cfg(feature = "device-profiles")]
1474 + fn device_conform_target_resolves_bundled() {
1475 + let backend = setup();
1476 + // A bundled mono device resolves to a mono target.
1477 + let target = backend
1478 + .device_conform_target("SP-404 MKII", 48000)
1479 + .unwrap();
1480 + assert!(target.is_some(), "SP-404 MKII should resolve to a target");
1481 + }
1482 +
1316 1483 #[test]
1317 1484 #[cfg(feature = "device-profiles")]
1318 1485 fn list_device_profiles_returns_bundled() {
@@ -18,6 +18,7 @@
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 22 use audiofiles_core::search::SearchFilter;
22 23 use audiofiles_core::collections::Collection;
23 24 use audiofiles_core::vfs::{Vfs, VfsNode, VfsNodeWithAnalysis};
@@ -399,6 +400,50 @@
399 400 /// List available device profiles for device-aware export.
400 401 fn list_device_profiles(&self) -> BackendResult<Vec<DeviceProfileSummary>>;
401 402
403 + /// Resolve a device profile into a conform target for a source of the given
404 + /// sample rate. Returns `None` when the profile isn't found (or device
405 + /// profiles are unavailable in this build).
406 + fn device_conform_target(
407 + &self,
408 + profile_name: &str,
409 + source_rate: u32,
410 + ) -> BackendResult<Option<ConformTarget>>;
411 +
412 + // --- Sample Forge ---
413 +
414 + /// Compute slice-boundary positions (normalized 0..1 fractions of total
415 + /// length) for the given chop method, for waveform overlay preview.
416 + fn compute_chop_preview(
417 + &self,
418 + hash: &str,
419 + ext: &str,
420 + method: &ChopMethod,
421 + ) -> BackendResult<Vec<f32>>;
422 +
423 + /// Chop a sample into slices written as new samples in a `"{name}_slices"`
424 + /// directory under `parent_id`. Returns the number of slices created.
425 + fn chop_sample(
426 + &self,
427 + vfs_id: VfsId,
428 + hash: &str,
429 + ext: &str,
430 + name: &str,
431 + parent_id: Option<NodeId>,
432 + method: &ChopMethod,
433 + ) -> BackendResult<usize>;
434 +
435 + /// Conform a sample to `target`, writing the result as a new sibling sample
436 + /// under `parent_id`. Returns the new sample's hash.
437 + fn conform_sample(
438 + &self,
439 + vfs_id: VfsId,
440 + hash: &str,
441 + ext: &str,
442 + name: &str,
443 + parent_id: Option<NodeId>,
444 + target: &ConformTarget,
445 + ) -> BackendResult<String>;
446 +
402 447 // --- Config ---
403 448
404 449 /// Get a user config value by key.
@@ -34,6 +34,7 @@
34 34 mod navigation;
35 35 pub mod import_workflow;
36 36 mod bulk_ops;
37 + mod forge;
37 38 mod library;
38 39 mod playback;
39 40 mod ui;
@@ -241,6 +242,9 @@
241 242 pub focus_search: bool,
242 243 /// Set by Tab from the file table to focus the detail-panel tag input on the next frame.
243 244 pub focus_tag_input: bool,
245 + /// Set when an inline sidebar editor (collection/tag create or rename) opens,
246 + /// so the text field auto-focuses on its first frame (P2 visible-focus gap).
247 + pub focus_inline_editor: bool,
244 248 /// Per-classification dismissed tag suggestions: e.g. dismissing
245 249 /// "percussion" on a kick suppresses it on every future kick. Persisted
246 250 /// under config key "suggestions.dismissed" as a JSON `<class>` → `[tag]` map.
@@ -274,6 +278,9 @@
274 278 // Edit — floating editor window
275 279 pub edit: EditUiState,
276 280
281 + // Forge — floating sample-forge window (chop / conform / batch)
282 + pub forge: ForgeUiState,
283 +
277 284 // Display density
278 285 pub row_height: f32,
279 286
@@ -499,6 +506,7 @@
499 506 name_modal_error: None,
500 507 focus_search: false,
501 508 focus_tag_input: false,
509 + focus_inline_editor: false,
502 510 dismissed_suggestions,
503 511 last_dismissed_suggestion: None,
504 512 scroll_to_row: None,
@@ -511,6 +519,7 @@
511 519 tag_rename_preview: None,
512 520 show_collection_create: false,
513 521 edit: EditUiState::default(),
522 + forge: ForgeUiState::default(),
514 523 row_height,
515 524 show_vfs_banner: !vfs_explained,
516 525 show_first_launch_hint: !hints_dismissed,
@@ -408,6 +408,78 @@
408 408 }
409 409 }
410 410
411 + /// Which chop method the forge UI is configured for.
412 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
413 + pub enum ChopMode {
414 + /// Slice at detected transients.
415 + Transient,
416 + /// Slice into N equal divisions.
417 + Equal,
418 + /// Slice on a BPM grid.
419 + Bpm,
420 + }
421 +
422 + /// GUI-side state for the Sample Forge window (chop / conform / batch).
423 + pub struct ForgeUiState {
424 + pub show_window: bool,
425 + /// Hash of the sample being forged.
426 + pub hash: Option<String>,
427 + /// Extension of the source sample (for decode path resolution).
428 + pub ext: String,
429 + /// Display name of the source (used to name slices / conform output).
430 + pub name: String,
431 + /// Source sample rate, for device conform target selection.
432 + pub source_rate: u32,
433 + /// Currently selected chop method.
434 + pub chop_mode: ChopMode,
435 + /// Transient sensitivity, 0..1.
436 + pub sensitivity: f32,
437 + /// Equal-divisions slice count.
438 + pub divisions: usize,
439 + /// BPM for grid chop (seeded from analysis when available).
440 + pub bpm: f64,
441 + /// Subdivisions per beat for grid chop (1 = beats, 2 = eighths, 4 = sixteenths).
442 + pub subdivisions: u32,
443 + /// Waveform of the sample being forged, captured at open time so the display
444 + /// stays bound to `hash` even if the file-list selection changes underneath.
445 + pub waveform: Option<audiofiles_core::analysis::waveform::WaveformData>,
446 + /// Normalized slice-boundary fractions (0..1) for the waveform overlay; set
447 + /// by Preview, cleared when parameters change.
448 + pub slice_marks: Vec<f32>,
449 + /// True while a chop/conform run is in flight (disables controls).
450 + pub busy: bool,
451 + /// Selected device profile name for conform (None = no device chosen).
452 + pub conform_device: Option<String>,
453 + /// Cached device list `(name, format_summary)` for the conform picker,
454 + /// populated when the window opens.
455 + pub devices: Vec<(String, String)>,
456 + /// Threshold (dBFS) for batch trim-silence.
457 + pub trim_threshold_db: f64,
458 + }
459 +
460 + impl Default for ForgeUiState {
461 + fn default() -> Self {
462 + Self {
463 + show_window: false,
464 + hash: None,
465 + ext: "wav".to_string(),
466 + name: String::new(),
467 + source_rate: 44100,
468 + chop_mode: ChopMode::Equal,
469 + sensitivity: 0.5,
470 + divisions: 8,
471 + bpm: 120.0,
472 + subdivisions: 1,
473 + waveform: None,
474 + slice_marks: Vec::new(),
475 + busy: false,
476 + conform_device: None,
477 + devices: Vec::new(),
478 + trim_threshold_db: -60.0,
479 + }
480 + }
481 + }
482 +
411 483 /// Actions the MIDI setup UI can request from the app layer.
412 484 pub enum MidiAction {
413 485 /// Connect to the MIDI input port at this index.
@@ -370,6 +370,9 @@
370 370 if ui.button("Edit").on_hover_text("Open sample editor (E)").clicked() {
371 371 state.open_edit_window(&hash);
372 372 }
373 + if ui.button("Forge").on_hover_text("Chop / conform / batch (F)").clicked() {
374 + state.open_forge_window(&hash);
375 + }
373 376 }
374 377 });
375 378 });
@@ -5,6 +5,7 @@
5 5 pub mod export_screens;
6 6 pub mod file_list;
7 7 pub mod file_list_menus;
8 + pub mod forge_panel;
8 9 pub mod filter_panel;
9 10 pub mod footer;
10 11 pub mod import_screens;