max / audiofiles
25 files changed,
+1963 insertions,
-16 deletions
| @@ -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 | + | } |