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
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/
@@ -16,6 +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 20 use audiofiles_core::search::SearchFilter;
20 21 use audiofiles_core::collections::Collection;
21 22 use audiofiles_core::store::SampleStore;
@@ -713,6 +714,100 @@ impl Backend for DirectBackend {
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 @@ mod tests {
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 @@ 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 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 @@ pub trait Backend: Send + Sync {
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.
@@ -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 @@ pub fn draw_browser(
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 @@ pub fn draw_browser(
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 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
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 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
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();
@@ -0,0 +1,175 @@
1 + //! Sample Forge state: open/close the forge window and drive chop, conform, and
2 + //! batch operations through the backend.
3 + //!
4 + //! Note: chop/conform/preview currently run synchronously on the GUI thread.
5 + //! This is fine for the common case (chopping loops and one-shots is fast), but
6 + //! conforming or chopping a long multichannel file can briefly stall the UI.
7 + //! Moving this work onto a worker thread (mirroring the import/analysis/export
8 + //! workers) is a tracked follow-up; see the audiofiles todo. The `busy` flag is
9 + //! in place for when that lands.
10 +
11 + use audiofiles_core::forge::ChopMethod;
12 +
13 + use super::{BrowserState, ChopMode};
14 +
15 + impl BrowserState {
16 + /// Open the forge window for a sample, seeding parameters from its analysis.
17 + pub fn open_forge_window(&mut self, hash: &str) {
18 + let analysis = self.backend.get_analysis(hash).ok().flatten();
19 + let source_rate = analysis.as_ref().map(|a| a.sample_rate).unwrap_or(44100);
20 + // Seed BPM from analysis when present so BPM-grid chop is ready to go.
21 + if let Some(bpm) = analysis.as_ref().and_then(|a| a.bpm) {
22 + if bpm > 0.0 {
23 + self.forge.bpm = bpm;
24 + }
25 + }
26 + let ext = self.backend.sample_extension(hash).unwrap_or_else(|_| "wav".to_string());
27 + let name = self
28 + .selected_node()
29 + .map(|n| n.node.name.clone())
30 + .or_else(|| self.backend.sample_original_name(hash).ok())
31 + .unwrap_or_else(|| "sample".to_string());
32 +
33 + // Cache the device list for the conform picker.
34 + self.forge.devices = self
35 + .backend
36 + .list_device_profiles()
37 + .unwrap_or_default()
38 + .into_iter()
39 + .map(|d| (d.name, d.format_summary.unwrap_or_default()))
40 + .collect();
41 +
42 + // Capture the waveform now so the forge display stays bound to this
43 + // sample even if the file-list selection changes while it's open.
44 + self.forge.waveform = self.backend.get_waveform(hash).ok().flatten();
45 +
46 + self.forge.hash = Some(hash.to_string());
47 + self.forge.ext = ext;
48 + self.forge.name = name;
49 + self.forge.source_rate = source_rate;
50 + self.forge.slice_marks.clear();
51 + // Reset the device selection so a prior sample's target doesn't appear
52 + // pre-chosen for a sample it was never selected for.
53 + self.forge.conform_device = None;
54 + self.forge.busy = false;
55 + self.forge.show_window = true;
56 + }
57 +
58 + /// Close the forge window.
59 + pub fn close_forge_window(&mut self) {
60 + self.forge.show_window = false;
61 + self.forge.hash = None;
62 + self.forge.slice_marks.clear();
63 + self.forge.waveform = None;
64 + }
65 +
66 + /// Build the [`ChopMethod`] from the current UI selection.
67 + pub fn forge_chop_method(&self) -> ChopMethod {
68 + match self.forge.chop_mode {
69 + ChopMode::Transient => ChopMethod::Transient { sensitivity: self.forge.sensitivity },
70 + ChopMode::Equal => ChopMethod::EqualDivisions(self.forge.divisions.max(1)),
71 + ChopMode::Bpm => ChopMethod::BpmGrid {
72 + bpm: self.forge.bpm,
73 + subdivisions_per_beat: self.forge.subdivisions.max(1),
74 + },
75 + }
76 + }
77 +
78 + /// Compute slice-boundary markers for the current method (overlay preview).
79 + pub fn forge_preview_slices(&mut self) {
80 + let Some(hash) = self.forge.hash.clone() else { return };
81 + let ext = self.forge.ext.clone();
82 + let method = self.forge_chop_method();
83 + match self.backend.compute_chop_preview(&hash, &ext, &method) {
84 + Ok(marks) => {
85 + // Slice count = boundaries minus the trailing 1.0 end marker.
86 + let count = marks.len().saturating_sub(1);
87 + self.forge.slice_marks = marks;
88 + self.status = format!("Preview: {count} slices");
89 + }
90 + Err(e) => {
91 + self.forge.slice_marks.clear();
92 + self.status = format!("Chop preview failed: {e}");
93 + }
94 + }
95 + }
96 +
97 + /// Chop the loaded sample into slices written into a new VFS folder.
98 + pub fn forge_apply_chop(&mut self) {
99 + let Some(hash) = self.forge.hash.clone() else { return };
100 + let Some(vfs_id) = self.current_vfs_id() else {
101 + self.status = "No VFS available".to_string();
102 + return;
103 + };
104 + let ext = self.forge.ext.clone();
105 + let name = self.forge.name.clone();
106 + let method = self.forge_chop_method();
107 + let parent_id = self.current_dir;
108 +
109 + self.forge.busy = true;
110 + let result = self
111 + .backend
112 + .chop_sample(vfs_id, &hash, &ext, &name, parent_id, &method);
113 + self.forge.busy = false;
114 +
115 + match result {
116 + Ok(count) => {
117 + self.status = format!("Chopped into {count} slices");
118 + self.refresh_contents();
119 + }
120 + Err(e) => self.status = format!("Chop failed: {e}"),
121 + }
122 + }
123 +
124 + /// Conform the loaded sample to the selected device's format, writing a new
125 + /// sibling sample.
126 + pub fn forge_conform_device(&mut self, device_name: &str) {
127 + let Some(hash) = self.forge.hash.clone() else { return };
128 + let Some(vfs_id) = self.current_vfs_id() else {
129 + self.status = "No VFS available".to_string();
130 + return;
131 + };
132 + let target = match self
133 + .backend
134 + .device_conform_target(device_name, self.forge.source_rate)
135 + {
136 + Ok(Some(t)) => t,
137 + Ok(None) => {
138 + self.status = format!("Device profile not found: {device_name}");
139 + return;
140 + }
141 + Err(e) => {
142 + self.status = format!("Conform failed: {e}");
143 + return;
144 + }
145 + };
146 + let ext = self.forge.ext.clone();
147 + let name = self.forge.name.clone();
148 + let parent_id = self.current_dir;
149 +
150 + self.forge.busy = true;
151 + let result = self
152 + .backend
153 + .conform_sample(vfs_id, &hash, &ext, &name, parent_id, &target);
154 + self.forge.busy = false;
155 +
156 + match result {
157 + Ok(_) => {
158 + self.status = format!(
159 + "Conformed for {device_name} ({} Hz, {}-bit)",
160 + target.sample_rate, target.bit_depth
161 + );
162 + self.refresh_contents();
163 + }
164 + Err(e) => self.status = format!("Conform failed: {e}"),
165 + }
166 + }
167 +
168 + /// Batch trim leading/trailing silence across the current selection. Reuses
169 + /// the edit pipeline via the `TrimSilence` operation.
170 + pub fn batch_trim_silence(&mut self, threshold_db: f64) {
171 + self.batch_edit(move |_hash| {
172 + Some(audiofiles_core::edit::EditOperation::TrimSilence { threshold_db })
173 + });
174 + }
175 + }
@@ -34,6 +34,7 @@ use crate::preview::PreviewPlayback;
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 @@ pub struct BrowserState {
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 @@ pub struct BrowserState {
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 @@ impl BrowserState {
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 @@ impl BrowserState {
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 @@ impl Default for EditUiState {
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 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) {
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 });
@@ -0,0 +1,279 @@
1 + //! Floating Sample Forge window: chop, conform, and batch operations — the
2 + //! "maker" surface that turns a managed sample into hardware-ready material.
3 +
4 + use egui;
5 +
6 + use crate::state::{BrowserState, ChopMode};
7 + use crate::waveform;
8 +
9 + use super::theme;
10 + use super::widgets;
11 +
12 + /// Draw the forge window. Call from the overlay layer.
13 + pub fn draw_forge_window(ctx: &egui::Context, state: &mut BrowserState) {
14 + let mut open = state.forge.show_window;
15 + widgets::tool_window(ctx, "Sample Forge", &mut open, 420.0, 340.0, |ui| {
16 + if state.forge.hash.is_none() {
17 + ui.label("Select a sample and open the forge to chop, conform, or batch-process it.");
18 + return;
19 + }
20 +
21 + if state.forge.busy {
22 + ui.horizontal(|ui| {
23 + ui.spinner();
24 + ui.label("Working...");
25 + });
26 + ui.separator();
27 + }
28 +
29 + draw_waveform_with_marks(ui, state);
30 + draw_info_line(ui, state);
31 +
32 + ui.separator();
33 + draw_chop_section(ui, state);
34 +
35 + ui.separator();
36 + draw_conform_section(ui, state);
37 +
38 + ui.separator();
39 + draw_batch_section(ui, state);
40 +
41 + ui.separator();
42 + draw_foreshadow_section(ui);
43 + });
44 + state.forge.show_window = open;
45 + }
46 +
47 + /// Waveform with slice-boundary markers overlaid (from Preview). Uses the
48 + /// forge's own captured waveform so it stays bound to the sample being forged
49 + /// even if the file-list selection changes.
50 + fn draw_waveform_with_marks(ui: &mut egui::Ui, state: &BrowserState) {
51 + if let Some(ref waveform_data) = state.forge.waveform {
52 + let resp = waveform::draw_waveform(ui, waveform_data, None, 120.0);
53 + let rect = resp.rect;
54 + let painter = ui.painter_at(rect);
55 + for &frac in &state.forge.slice_marks {
56 + let x = rect.left() + rect.width() * frac.clamp(0.0, 1.0);
57 + painter.line_segment(
58 + [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
59 + egui::Stroke::new(1.0, theme::accent_yellow()),
60 + );
61 + }
62 + }
63 + }
64 +
65 + fn draw_info_line(ui: &mut egui::Ui, state: &BrowserState) {
66 + let name = &state.forge.name;
67 + let rate = state.forge.source_rate;
68 + ui.horizontal_wrapped(|ui| {
69 + ui.label(egui::RichText::new(name).strong().size(12.0));
70 + ui.label(
71 + egui::RichText::new(format!("{rate} Hz"))
72 + .color(theme::text_muted())
73 + .size(11.0),
74 + );
75 + });
76 + }
77 +
78 + /// Chop controls: method + parameters, preview, and chop.
79 + fn draw_chop_section(ui: &mut egui::Ui, state: &mut BrowserState) {
80 + let disabled = state.forge.busy;
81 + ui.label(egui::RichText::new("Chop").strong());
82 +
83 + ui.horizontal(|ui| {
84 + if ui
85 + .add_enabled(!disabled, egui::RadioButton::new(state.forge.chop_mode == ChopMode::Transient, "Transient"))
86 + .clicked()
87 + {
88 + state.forge.chop_mode = ChopMode::Transient;
89 + state.forge.slice_marks.clear();
90 + }
91 + if ui
92 + .add_enabled(!disabled, egui::RadioButton::new(state.forge.chop_mode == ChopMode::Equal, "Divisions"))
93 + .clicked()
94 + {
95 + state.forge.chop_mode = ChopMode::Equal;
96 + state.forge.slice_marks.clear();
97 + }
98 + if ui
99 + .add_enabled(!disabled, egui::RadioButton::new(state.forge.chop_mode == ChopMode::Bpm, "BPM grid"))
100 + .clicked()
101 + {
102 + state.forge.chop_mode = ChopMode::Bpm;
103 + state.forge.slice_marks.clear();
104 + }
105 + });
106 +
107 + match state.forge.chop_mode {
108 + ChopMode::Transient => {
109 + ui.horizontal(|ui| {
110 + ui.label("Sensitivity:");
111 + if ui
112 + .add_enabled(
113 + !disabled,
114 + egui::Slider::new(&mut state.forge.sensitivity, 0.0..=1.0),
115 + )
116 + .changed()
117 + {
118 + state.forge.slice_marks.clear();
119 + }
120 + });
121 + }
122 + ChopMode::Equal => {
123 + ui.horizontal(|ui| {
124 + ui.label("Slices:");
125 + for n in [2usize, 4, 8, 16, 32] {
126 + if ui
127 + .add_enabled(!disabled, egui::Button::selectable(state.forge.divisions == n, n.to_string()))
128 + .clicked()
129 + {
130 + state.forge.divisions = n;
131 + state.forge.slice_marks.clear();
132 + }
133 + }
134 + });
135 + }
136 + ChopMode::Bpm => {
137 + ui.horizontal(|ui| {
138 + ui.label("BPM:");
139 + if ui
140 + .add_enabled(!disabled, egui::DragValue::new(&mut state.forge.bpm).speed(0.5).range(20.0..=300.0))
141 + .changed()
142 + {
143 + state.forge.slice_marks.clear();
144 + }
145 + ui.label("Per beat:");
146 + for n in [1u32, 2, 4] {
147 + let label = match n {
148 + 1 => "1/4",
149 + 2 => "1/8",
150 + _ => "1/16",
151 + };
152 + if ui
153 + .add_enabled(!disabled, egui::Button::selectable(state.forge.subdivisions == n, label))
154 + .clicked()
155 + {
156 + state.forge.subdivisions = n;
157 + state.forge.slice_marks.clear();
158 + }
159 + }
160 + });
161 + }
162 + }
163 +
164 + ui.horizontal(|ui| {
165 + if ui.add_enabled(!disabled, egui::Button::new("Preview slices")).clicked() {
166 + state.forge_preview_slices();
167 + }
168 + let slice_count = state.forge.slice_marks.len().saturating_sub(1);
169 + let chop_label = if slice_count > 0 {
170 + format!("Chop into {slice_count} slices")
171 + } else {
172 + "Chop".to_string()
173 + };
174 + if ui.add_enabled(!disabled, egui::Button::new(chop_label)).clicked() {
175 + state.forge_apply_chop();
176 + }
177 + });
178 + ui.label(
179 + egui::RichText::new("Slices are written into a new folder beside this sample.")
180 + .small()
181 + .color(theme::text_muted()),
182 + );
183 + }
184 +
185 + /// Conform controls: pick a device, conform to its accepted format.
186 + fn draw_conform_section(ui: &mut egui::Ui, state: &mut BrowserState) {
187 + let disabled = state.forge.busy;
188 + ui.label(egui::RichText::new("Conform for device").strong());
189 +
190 + if state.forge.devices.is_empty() {
191 + ui.label(
192 + egui::RichText::new("No device profiles available.")
193 + .small()
194 + .color(theme::text_muted()),
195 + );
196 + return;
197 + }
198 +
199 + let selected_text = state
200 + .forge
201 + .conform_device
202 + .clone()
203 + .unwrap_or_else(|| "Select device...".to_string());
204 +
205 + ui.horizontal(|ui| {
206 + egui::ComboBox::from_id_salt("forge_conform_device")
207 + .selected_text(selected_text)
208 + .width(200.0)
209 + .show_ui(ui, |ui| {
210 + for (name, summary) in &state.forge.devices {
211 + let label = if summary.is_empty() {
212 + name.clone()
213 + } else {
214 + format!("{name} ({summary})")
215 + };
216 + let selected = state.forge.conform_device.as_deref() == Some(name);
217 + if ui.selectable_label(selected, label).clicked() {
218 + state.forge.conform_device = Some(name.clone());
219 + }
220 + }
221 + });
222 +
223 + let device = state.forge.conform_device.clone();
224 + let can_conform = !disabled && device.is_some();
225 + if ui.add_enabled(can_conform, egui::Button::new("Conform")).clicked() {
226 + if let Some(d) = device {
227 + state.forge_conform_device(&d);
228 + }
229 + }
230 + });
231 + ui.label(
232 + egui::RichText::new("Resamples and converts bit depth to match the device, as a new sample.")
233 + .small()
234 + .color(theme::text_muted()),
235 + );
236 + }
237 +
238 + /// Batch section: trim silence across the current multi-selection. Batch
239 + /// normalize/gain live in the Sample Editor's batch section.
240 + fn draw_batch_section(ui: &mut egui::Ui, state: &mut BrowserState) {
241 + let count = state.selected_sample_hashes().len();
242 + ui.label(egui::RichText::new("Batch").strong());
243 + if count < 2 {
244 + ui.label(
245 + egui::RichText::new("Select 2+ samples to batch trim silence.")
246 + .small()
247 + .color(theme::text_muted()),
248 + );
249 + return;
250 + }
251 +
252 + ui.horizontal(|ui| {
253 + ui.label("Threshold:");
254 + ui.add(
255 + egui::DragValue::new(&mut state.forge.trim_threshold_db)
256 + .speed(1.0)
257 + .range(-96.0..=-20.0)
258 + .suffix(" dBFS"),
259 + );
260 + });
261 + let threshold = state.forge.trim_threshold_db;
262 + if ui
263 + .button(format!("Trim silence on {count} samples"))
264 + .clicked()
265 + {
266 + state.batch_trim_silence(threshold);
267 + }
268 + }
269 +
270 + /// Foreshadow the CLAP/VST plugin host — the planned follow-on headline. Copy
271 + /// only; not built for this launch.
272 + fn draw_foreshadow_section(ui: &mut egui::Ui) {
273 + ui.label(
274 + egui::RichText::new("Plugin processing (CLAP/VST) — coming soon")
275 + .small()
276 + .italics()
277 + .color(theme::text_muted()),
278 + );
279 + }
@@ -4,7 +4,6 @@ use super::super::{theme, widgets};
4 4 use crate::import::ImportStrategy;
5 5 use crate::state::{BrowserState, ImportMode};
6 6
7 - const STEPS: &[&str] = &["Configure", "Tag folders", "Analyze", "Review"];
8 7
9 8 /// Draw the import configuration screen with strategy radio buttons.
10 9 pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
@@ -16,7 +15,10 @@ pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
16 15 };
17 16
18 17 egui::CentralPanel::default().show_inside(ui, |ui| {
19 - widgets::wizard_steps(ui, STEPS, 0);
18 + // Scroll the body so the Cancel/Import row stays reachable on a short
19 + // window or when the merge combo + vault field + warnings all expand (P5).
20 + egui::ScrollArea::vertical().show(ui, |ui| {
21 + widgets::wizard_steps(ui, super::WIZARD_STEPS, 0);
20 22 ui.heading("Import Folder");
21 23 ui.add_space(theme::space::MD);
22 24 // Source: path + Change button (M-5). Picking the wrong folder no
@@ -252,6 +254,7 @@ pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
252 254 }
253 255 }
254 256 });
257 + });
255 258 });
256 259 }
257 260
@@ -266,7 +269,7 @@ pub fn draw_configure_analysis(ui: &mut egui::Ui, state: &mut BrowserState) {
266 269 };
267 270
268 271 egui::CentralPanel::default().show_inside(ui, |ui| {
269 - widgets::wizard_steps(ui, STEPS, 2);
272 + widgets::wizard_steps(ui, super::WIZARD_STEPS, 2);
270 273 ui.heading("Configure Analysis");
271 274 ui.add_space(theme::space::MD);
272 275 ui.label(format!("{sample_count} samples to analyze"));
@@ -6,6 +6,11 @@ mod progress;
6 6 mod summary;
7 7 mod tagging;
8 8
9 + /// Shared wizard step labels for the import flow. The step rail renders on every
10 + /// screen of the flow (including the progress screens) so the "where am I / how
11 + /// much is left" cue never vanishes mid-flow (P5).
12 + pub(super) const WIZARD_STEPS: &[&str] = &["Configure", "Tag folders", "Analyze", "Review"];
13 +
9 14 pub use configure::{draw_configure_analysis, draw_configure_import};
10 15 pub use progress::{draw_analysis_progress, draw_cleanup_progress, draw_import_progress, draw_operation_cancelled};
11 16 pub use summary::draw_review_errors;
@@ -106,6 +106,9 @@ pub fn draw_import_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
106 106 };
107 107
108 108 egui::CentralPanel::default().show_inside(ui, |ui| {
109 + // Keep the step rail visible during the import work (P5) — current step
110 + // is still Configure, since import is the work that step kicks off.
111 + widgets::wizard_steps(ui, super::WIZARD_STEPS, 0);
109 112 ui.heading("Importing Folder...");
110 113 ui.add_space(theme::space::LG);
111 114
@@ -260,6 +263,9 @@ pub fn draw_analysis_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
260 263 };
261 264
262 265 egui::CentralPanel::default().show_inside(ui, |ui| {
266 + // Step rail stays visible on the slow Analyze screen, where orientation
267 + // matters most (P5). Analyze is step index 2.
268 + widgets::wizard_steps(ui, super::WIZARD_STEPS, 2);
263 269 ui.heading("Analyzing Samples...");
264 270 ui.add_space(theme::space::LG);
265 271
@@ -6,7 +6,6 @@ use super::super::{theme, widgets};
6 6 use crate::state::{BrowserState, ImportMode, ReviewSort};
7 7 use audiofiles_core::tags;
8 8
9 - const STEPS: &[&str] = &["Configure", "Tag folders", "Analyze", "Review"];
10 9
11 10
12 11 /// Draw the post-import folder tagging screen.
@@ -46,7 +45,7 @@ pub fn draw_tag_folders(ui: &mut egui::Ui, state: &mut BrowserState) {
46 45 // staying highlighted after Skip is cosmetic only. `wizard_steps` has
47 46 // no skipped state and adding one isn't justified for a non-navigable
48 47 // indicator. Leave as-is.
49 - widgets::wizard_steps(ui, STEPS, 1);
48 + widgets::wizard_steps(ui, super::WIZARD_STEPS, 1);
50 49 ui.heading("Tag Imported Folders");
51 50 ui.add_space(theme::space::XS);
52 51 // p-2: scope summary under the heading so users gauge the batch size
@@ -151,7 +150,7 @@ pub fn draw_review_suggestions(ui: &mut egui::Ui, state: &mut BrowserState) {
151 150 };
152 151
153 152 egui::Panel::top("review_header").show_inside(ui, |ui| {
154 - widgets::wizard_steps(ui, STEPS, 3);
153 + widgets::wizard_steps(ui, super::WIZARD_STEPS, 3);
155 154 ui.horizontal(|ui| {
156 155 ui.heading("Review Tag Suggestions");
157 156 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -5,6 +5,7 @@ pub mod edit_panel;
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;
@@ -104,6 +104,7 @@ fn draw_shortcuts_tab(ui: &mut egui::Ui, state: &mut BrowserState) {
104 104 ];
105 105 let toggles: &[(String, &str)] = &[
106 106 ("E".to_string(), "Toggle sample editor"),
107 + ("F".to_string(), "Toggle sample forge (chop / conform / batch)"),
107 108 ("I".to_string(), "Toggle instrument panel"),
108 109 ("L".to_string(), "Toggle loop"),
109 110 ("S".to_string(), "Toggle sidebar"),
@@ -56,6 +56,7 @@ fn tag_context_menu(response: egui::Response, tag: &str, state: &mut BrowserStat
56 56 response.context_menu(|ui| {
57 57 if ui.button("Rename tag…").clicked() {
58 58 state.tag_rename_target = Some((tag.to_string(), tag.to_string()));
59 + state.focus_inline_editor = true;
59 60 // M-12: compute affected-sample count + descendant tags now so the
60 61 // modal can show the consequences before the user commits. Descendants
61 62 // are not propagated by `rename_tag_globally` (exact-match-only).
@@ -283,12 +284,11 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
283 284 ui.close();
284 285 }
285 286 // Always render Delete so the user can see the capability exists;
286 - // disable when removing it would leave zero vaults.
287 + // disable when removing it would leave zero vaults. Routed through
288 + // the shared danger affordance for consistency with collection/tag
289 + // deletes (P2).
287 290 let delete_enabled = vfs_count > 1;
288 - let btn = egui::Button::new(
289 - egui::RichText::new("Delete").color(theme::accent_red()),
290 - );
291 - let delete_resp = ui.add_enabled(delete_enabled, btn);
291 + let delete_resp = widgets::danger_button_enabled(ui, "Delete", delete_enabled);
292 292 let delete_resp = if !delete_enabled {
293 293 delete_resp.on_disabled_hover_text(
294 294 "Create another vault first — audiofiles needs at least one.",
@@ -314,6 +314,7 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
314 314 if ui.link(egui::RichText::new("Create one").color(theme::accent_blue())).clicked() {
315 315 state.show_collection_create = true;
316 316 state.collection_create_input.clear();
317 + state.focus_inline_editor = true;
317 318 }
318 319 });
319 320 } else {
@@ -352,6 +353,7 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
352 353 resp.context_menu(|ui| {
353 354 if ui.button("Rename").clicked() {
354 355 state.collection_rename_target = Some((coll_id, coll_name.clone()));
356 + state.focus_inline_editor = true;
355 357 ui.close();
356 358 }
357 359 if widgets::danger_button(ui, "Delete").clicked() {
@@ -381,9 +383,13 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
381 383 .color(theme::text_muted()),
382 384 );
383 385 }
386 + let want_focus = std::mem::take(&mut state.focus_inline_editor);
384 387 ui.horizontal(|ui| {
385 388 let Some((_, buf)) = state.collection_rename_target.as_mut() else { return; };
386 389 let resp = ui.text_edit_singleline(buf);
390 + if want_focus {
391 + resp.request_focus();
392 + }
387 393 let mut commit = false;
388 394 if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
389 395 commit = true;
@@ -410,8 +416,12 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
410 416
411 417 // Inline create input
412 418 if state.show_collection_create {
419 + let want_focus = std::mem::take(&mut state.focus_inline_editor);
413 420 ui.horizontal(|ui| {
414 421 let resp = ui.text_edit_singleline(&mut state.collection_create_input);
422 + if want_focus {
423 + resp.request_focus();
424 + }
415 425 if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
416 426 let name = state.collection_create_input.trim().to_string();
417 427 if !name.is_empty() {
@@ -438,6 +448,7 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
438 448 } else if ui.small_button("+").on_hover_text("Create a new collection").clicked() {
439 449 state.show_collection_create = true;
440 450 state.collection_create_input.clear();
451 + state.focus_inline_editor = true;
441 452 }
442 453 });
443 454
@@ -490,6 +501,7 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
490 501 if let Some((old_tag, _)) = state.tag_rename_target.clone() {
491 502 let mut commit: Option<(String, String)> = None;
492 503 let mut cancel = false;
504 + let want_focus = std::mem::take(&mut state.focus_inline_editor);
493 505 ui.horizontal(|ui| {
494 506 ui.label(
495 507 egui::RichText::new(format!("Renaming tag: {old_tag} \u{2192}"))
@@ -500,6 +512,9 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
500 512 let resp = ui.add(
501 513 egui::TextEdit::singleline(buf).hint_text(old_tag.as_str()),
502 514 );
515 + if want_focus {
516 + resp.request_focus();
517 + }
503 518 if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
504 519 let new_name = buf.trim().to_string();
505 520 if !new_name.is_empty() && new_name != old_tag {
@@ -43,6 +43,29 @@ pub enum NameModalOutcome {
43 43 Cancelled,
44 44 }
45 45
46 + /// Paint a semi-opaque full-window scrim that swallows pointer input, so a modal
47 + /// drawn *after* this call is genuinely modal — the underlying file list no
48 + /// longer responds to clicks behind it. (The Escape handler already enforces
49 + /// keyboard dismissal priority; this closes the mouse-leakage gap, P2.)
50 + ///
51 + /// Call this immediately before drawing a modal window. Both the scrim and the
52 + /// modal live in `Order::Middle`; the scrim is created first so it sits below
53 + /// the modal but above the panels.
54 + pub fn modal_scrim(ctx: &egui::Context) {
55 + let screen = ctx.content_rect();
56 + egui::Area::new(egui::Id::new("modal_scrim"))
57 + .order(egui::Order::Middle)
58 + .fixed_pos(screen.min)
59 + .show(ctx, |ui| {
60 + // Full-screen interactive surface consumes clicks/drags that miss
61 + // the modal, preventing them from reaching the live UI beneath.
62 + let resp = ui.allocate_response(screen.size(), egui::Sense::click_and_drag());
63 + ui.painter()
64 + .rect_filled(screen, 0.0, egui::Color32::from_black_alpha(128));
65 + resp
66 + });
67 + }
68 +
46 69 /// Canonical center-anchored, non-resizable modal scaffold.
47 70 ///
48 71 /// Replaces the inline
@@ -391,6 +414,16 @@ pub fn danger_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
391 414 ui.add(egui::Button::new(egui::RichText::new(label).color(theme::accent_red())))
392 415 }
393 416
417 + /// Destructive action that may be disabled (e.g. Delete-vault when only one
418 + /// vault remains). Same red colouring as [`danger_button`]; routes through
419 + /// `add_enabled` so disabled state and hover text work consistently.
420 + pub fn danger_button_enabled(ui: &mut egui::Ui, label: &str, enabled: bool) -> egui::Response {
421 + ui.add_enabled(
422 + enabled,
423 + egui::Button::new(egui::RichText::new(label).color(theme::accent_red())),
424 + )
425 + }
426 +
394 427 /// Small destructive action (per-row Remove/Delete affordances, context-menu
395 428 /// items inside a tighter layout). Same colouring as [`danger_button`].
396 429 pub fn danger_small_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
@@ -58,6 +58,11 @@ pub enum EditOperation {
58 58 start_frame: usize,
59 59 end_frame: usize,
60 60 },
61 + /// Auto-trim leading and trailing silence below `threshold_db` (dBFS).
62 + /// Implemented by the forge batch DSP ([`crate::forge::batch`]).
63 + TrimSilence {
64 + threshold_db: f64,
65 + },
61 66 }
62 67
63 68 impl EditOperation {
@@ -76,6 +81,7 @@ impl EditOperation {
76 81 EditOperation::StereoToMono => "Stereo → Mono",
77 82 EditOperation::InsertSilence { .. } => "Insert Silence",
78 83 EditOperation::RemoveRange { .. } => "Remove Range",
84 + EditOperation::TrimSilence { .. } => "Trim Silence",
79 85 }
80 86 }
81 87 }
@@ -142,6 +148,10 @@ pub fn apply_edit(
142 148 silence::apply_remove_range(samples, channels, *start_frame, *end_frame)?;
143 149 Ok(channels)
144 150 }
151 + EditOperation::TrimSilence { threshold_db } => {
152 + crate::forge::batch::trim_silence(samples, channels, *threshold_db);
153 + Ok(channels)
154 + }
145 155 }
146 156 }
147 157
@@ -258,6 +268,15 @@ mod tests {
258 268 }
259 269
260 270 #[test]
271 + fn apply_edit_dispatches_trim_silence() {
272 + // mono: silence, content, silence -> trims to the content frames.
273 + let mut samples = vec![0.0, 0.0, 0.8, -0.7, 0.0];
274 + let ch = apply_edit(&mut samples, 1, 44100, &EditOperation::TrimSilence { threshold_db: -60.0 }).unwrap();
275 + assert_eq!(ch, 1);
276 + assert_eq!(samples, vec![0.8, -0.7]);
277 + }
278 +
279 + #[test]
261 280 fn channel_conversion_display_names() {
262 281 assert_eq!(EditOperation::RemoveDcOffset.display_name(), "Remove DC Offset");
263 282 assert_eq!(EditOperation::MonoToStereo.display_name(), "Mono → Stereo");
@@ -11,21 +11,47 @@ use tracing::instrument;
11 11
12 12 /// Encode audio to a WAV file at the given path.
13 13 ///
14 + /// - 8-bit: unsigned PCM (the WAV spec stores 8-bit as offset-128 unsigned),
15 + /// TPDF-dithered before quantization since 8-bit is coarse enough to audibly
16 + /// benefit. Supports hardware that wants lo-fi 8-bit samples (e.g. M8).
14 17 /// - 16-bit: applies TPDF dither before quantization.
15 18 /// - 24-bit: direct f32-to-i32 scaling, no dither needed.
19 + /// - 32-bit: IEEE float, written verbatim (lossless passthrough of the f32 data).
16 20 #[instrument(skip_all)]
17 21 pub fn encode_wav(audio: &ConvertedAudio, bit_depth: u16, dest: &Path) -> Result<(), CoreError> {
22 + let sample_format = if bit_depth == 32 {
23 + SampleFormat::Float
24 + } else {
25 + SampleFormat::Int
26 + };
18 27 let spec = WavSpec {
19 28 channels: audio.channels,
20 29 sample_rate: audio.sample_rate,
21 30 bits_per_sample: bit_depth,
22 - sample_format: SampleFormat::Int,
31 + sample_format,
23 32 };
24 33
25 34 let mut writer =
26 35 WavWriter::create(dest, spec).map_err(|e| io_err(dest, std::io::Error::other(e)))?;
27 36
28 37 match bit_depth {
38 + 8 => {
39 + // 8-bit WAV is unsigned: midpoint 128, range 0..=255. hound writes
40 + // i8 for 8-bit Int specs, so we map the signed quantization into i8
41 + // (which hound serializes as the unsigned byte). TPDF dither at the
42 + // 8-bit LSB masks quantization distortion on this coarse grid.
43 + let seed = audio.samples.as_ptr() as u64 ^ audio.samples.len() as u64;
44 + let mut rng = SimpleRng::new(seed);
45 + let scale = i8::MAX as f32;
46 + for &sample in &audio.samples {
47 + let dither = (rng.next_f32() + rng.next_f32() - 1.0) / scale;
48 + let dithered = (sample + dither) * scale;
49 + let clamped = dithered.round().clamp(i8::MIN as f32, i8::MAX as f32) as i8;
50 + writer
51 + .write_sample(clamped)
52 + .map_err(|e| CoreError::Export(format!("WAV write: {e}")))?;
53 + }
54 + }
29 55 16 => {
30 56 // Seed from data pointer so each export gets a different dither pattern
31 57 let seed = audio.samples.as_ptr() as u64 ^ audio.samples.len() as u64;
@@ -52,9 +78,18 @@ pub fn encode_wav(audio: &ConvertedAudio, bit_depth: u16, dest: &Path) -> Result
52 78 .map_err(|e| CoreError::Export(format!("WAV write: {e}")))?;
53 79 }
54 80 }
81 + 32 => {
82 + // IEEE float: the in-memory representation is already f32, so this is
83 + // a lossless passthrough — no scaling, dither, or clamping needed.
84 + for &sample in &audio.samples {
85 + writer
86 + .write_sample(sample)
87 + .map_err(|e| CoreError::Export(format!("WAV write: {e}")))?;
88 + }
89 + }
55 90 _ => {
56 91 return Err(CoreError::Export(format!(
57 - "unsupported bit depth: {bit_depth}"
92 + "unsupported bit depth: {bit_depth} (expected 8, 16, 24, or 32)"
58 93 )));
59 94 }
60 95 }
@@ -139,11 +174,61 @@ mod tests {
139 174 }
140 175
141 176 #[test]
177 + fn wav_8bit_roundtrip() {
178 + let dir = tempfile::tempdir().unwrap();
179 + let path = dir.path().join("test_8.wav");
180 +
181 + let audio = make_audio(vec![0.0, 0.5, -0.5, 0.25], 1, 44100);
182 + encode_wav(&audio, 8, &path).unwrap();
183 +
184 + let mut reader = hound::WavReader::open(&path).unwrap();
185 + let spec = reader.spec();
186 + assert_eq!(spec.bits_per_sample, 8);
187 + assert_eq!(spec.sample_format, hound::SampleFormat::Int);
188 +
189 + let samples: Vec<i32> = reader.samples::<i32>().map(|s| s.unwrap()).collect();
190 + assert_eq!(samples.len(), 4);
191 +
192 + // 8-bit is coarse; tolerance is a few LSBs (quantization + dither).
193 + let scale = i8::MAX as f32;
194 + let tolerance = 3.0 / scale;
195 + let originals = [0.0f32, 0.5, -0.5, 0.25];
196 + for (i, &orig) in originals.iter().enumerate() {
197 + let read_back = samples[i] as f32 / scale;
198 + assert!(
199 + (read_back - orig).abs() < tolerance,
200 + "sample {i}: expected ~{orig}, got {read_back}"
201 + );
202 + }
203 + }
204 +
205 + #[test]
206 + fn wav_32bit_float_roundtrip() {
207 + let dir = tempfile::tempdir().unwrap();
208 + let path = dir.path().join("test_32f.wav");
209 +
210 + let originals = vec![0.0f32, 0.5, -0.5, 0.25, 0.123_456_7];
211 + let audio = make_audio(originals.clone(), 1, 96000);
212 + encode_wav(&audio, 32, &path).unwrap();
213 +
214 + let mut reader = hound::WavReader::open(&path).unwrap();
215 + let spec = reader.spec();
216 + assert_eq!(spec.bits_per_sample, 32);
217 + assert_eq!(spec.sample_format, hound::SampleFormat::Float);
218 + assert_eq!(spec.sample_rate, 96000);
219 +
220 + // Float is a lossless passthrough — exact equality.
221 + let samples: Vec<f32> = reader.samples::<f32>().map(|s| s.unwrap()).collect();
222 + assert_eq!(samples, originals);
223 + }
224 +
225 + #[test]
142 226 fn unsupported_bit_depth_returns_error() {
143 227 let dir = tempfile::tempdir().unwrap();
144 - let path = dir.path().join("test_32.wav");
228 + let path = dir.path().join("test_20.wav");
145 229 let audio = make_audio(vec![0.0], 1, 44100);
146 - let result = encode_wav(&audio, 32, &path);
230 + // 20-bit is not one of the supported depths (8/16/24/32).
231 + let result = encode_wav(&audio, 20, &path);
147 232 assert!(result.is_err());
148 233 }
149 234 }
@@ -0,0 +1,112 @@
1 + //! Batch forge DSP: silence detection for trim-silence.
2 + //!
3 + //! Batch normalize (peak/LUFS) reuses the existing [`crate::edit::normalize`]
4 + //! operations applied across a selection; the new DSP here is content-boundary
5 + //! detection used to auto-trim leading and trailing silence.
6 +
7 + /// Find the half-open frame range `[start, end)` containing audible content —
8 + /// the first and last frame whose peak across channels exceeds `threshold_db`
9 + /// (dBFS). Returns `None` when the whole buffer is below the threshold (silent).
10 + pub fn find_content_bounds(
11 + samples: &[f32],
12 + channels: u16,
13 + threshold_db: f64,
14 + ) -> Option<(usize, usize)> {
15 + let ch = channels.max(1) as usize;
16 + let total_frames = samples.len() / ch;
17 + if total_frames == 0 {
18 + return None;
19 + }
20 + let threshold = 10.0_f64.powf(threshold_db / 20.0) as f32;
21 +
22 + let frame_peak = |frame: usize| -> f32 {
23 + let base = frame * ch;
24 + let mut peak = 0.0f32;
25 + for c in 0..ch {
26 + peak = peak.max(samples[base + c].abs());
27 + }
28 + peak
29 + };
30 +
31 + let start = (0..total_frames).find(|&f| frame_peak(f) > threshold)?;
32 + // `start` exists, so a last audible frame exists too.
33 + let last = (0..total_frames).rev().find(|&f| frame_peak(f) > threshold)?;
34 + Some((start, last + 1))
35 + }
36 +
37 + /// Trim leading and trailing silence in place, keeping only the audible content
38 + /// region. Returns `true` if any frames were removed. A fully silent buffer is
39 + /// left untouched (returns `false`) rather than emptied.
40 + pub fn trim_silence(samples: &mut Vec<f32>, channels: u16, threshold_db: f64) -> bool {
41 + let ch = channels.max(1) as usize;
42 + let total_frames = samples.len() / ch;
43 + let Some((start, end)) = find_content_bounds(samples, channels, threshold_db) else {
44 + return false;
45 + };
46 + if start == 0 && end == total_frames {
47 + return false;
48 + }
49 + // Trailing first so the leading drain indices stay valid.
50 + samples.truncate(end * ch);
51 + samples.drain(0..start * ch);
52 + true
53 + }
54 +
55 + #[cfg(test)]
56 + mod tests {
57 + use super::*;
58 +
59 + #[test]
60 + fn finds_content_in_padded_signal() {
61 + // mono: 2 silent, 3 loud, 2 silent
62 + let samples = vec![0.0, 0.0, 0.8, -0.7, 0.6, 0.0, 0.0];
63 + let (start, end) = find_content_bounds(&samples, 1, -60.0).unwrap();
64 + assert_eq!((start, end), (2, 5));
65 + }
66 +
67 + #[test]
68 + fn silent_buffer_has_no_bounds() {
69 + let samples = vec![0.0f32; 100];
70 + assert!(find_content_bounds(&samples, 1, -60.0).is_none());
71 + }
72 +
73 + #[test]
74 + fn threshold_excludes_low_noise() {
75 + // A -80 dB noise floor (~0.0001) below a -60 dB threshold (~0.001).
76 + let samples = vec![0.0001, 0.0001, 0.5, 0.0001];
77 + let (start, end) = find_content_bounds(&samples, 1, -60.0).unwrap();
78 + assert_eq!((start, end), (2, 3));
79 + }
80 +
81 + #[test]
82 + fn trim_silence_removes_pads() {
83 + let mut samples = vec![0.0, 0.0, 0.8, -0.7, 0.6, 0.0, 0.0];
84 + let trimmed = trim_silence(&mut samples, 1, -60.0);
85 + assert!(trimmed);
86 + assert_eq!(samples, vec![0.8, -0.7, 0.6]);
87 + }
88 +
89 + #[test]
90 + fn trim_silence_stereo() {
91 + // 4 stereo frames: silent, loud, loud, silent.
92 + let mut samples = vec![0.0, 0.0, 0.5, 0.5, 0.6, -0.6, 0.0, 0.0];
93 + let trimmed = trim_silence(&mut samples, 2, -60.0);
94 + assert!(trimmed);
95 + assert_eq!(samples, vec![0.5, 0.5, 0.6, -0.6]);
96 + }
97 +
98 + #[test]
99 + fn trim_silence_noop_when_already_tight() {
100 + let mut samples = vec![0.8, -0.7, 0.6];
101 + let original = samples.clone();
102 + assert!(!trim_silence(&mut samples, 1, -60.0));
103 + assert_eq!(samples, original);
104 + }
105 +
106 + #[test]
107 + fn trim_silence_leaves_silent_buffer_untouched() {
108 + let mut samples = vec![0.0f32; 50];
109 + assert!(!trim_silence(&mut samples, 1, -60.0));
110 + assert_eq!(samples.len(), 50);
111 + }
112 + }