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