//! Sample Forge state: open/close the forge window and drive chop, conform, and //! batch operations through the backend. //! //! Note: chop/conform/preview currently run synchronously on the GUI thread. //! This is fine for the common case (chopping loops and one-shots is fast), but //! conforming or chopping a long multichannel file can briefly stall the UI. //! Moving this work onto a worker thread (mirroring the import/analysis/export //! workers) is a tracked follow-up; see the audiofiles todo. The `busy` flag is //! in place for when that lands. use audiofiles_core::forge::ChopMethod; use super::{BrowserState, ChopMode}; impl BrowserState { /// Open the forge window for a sample, seeding parameters from its analysis. pub fn open_forge_window(&mut self, hash: &str) { let analysis = self.backend.get_analysis(hash).ok().flatten(); let source_rate = analysis.as_ref().map(|a| a.sample_rate).unwrap_or(44100); // Seed BPM from analysis when present so BPM-grid chop is ready to go. if let Some(bpm) = analysis.as_ref().and_then(|a| a.bpm) && bpm > 0.0 { self.forge.bpm = bpm; } let ext = self.backend.sample_extension(hash).unwrap_or_else(|_| "wav".to_string()); let name = self .selected_node() .map(|n| n.node.name.clone()) .or_else(|| self.backend.sample_original_name(hash).ok()) .unwrap_or_else(|| "sample".to_string()); // Cache the device list for the conform picker. self.forge.devices = self .backend .list_device_profiles() .unwrap_or_default() .into_iter() .map(|d| (d.name, d.format_summary.unwrap_or_default())) .collect(); // Capture the waveform now so the forge display stays bound to this // sample even if the file-list selection changes while it's open. self.forge.waveform = self.backend.get_waveform(hash).ok().flatten(); self.forge.hash = Some(hash.to_string()); self.forge.ext = ext; self.forge.name = name; self.forge.source_rate = source_rate; self.forge.slice_marks.clear(); // Reset the device selection so a prior sample's target doesn't appear // pre-chosen for a sample it was never selected for. self.forge.conform_device = None; self.forge.busy = false; self.forge.show_window = true; } /// Close the forge window. pub fn close_forge_window(&mut self) { self.forge.show_window = false; self.forge.hash = None; self.forge.slice_marks.clear(); self.forge.waveform = None; } /// Build the [`ChopMethod`] from the current UI selection. pub fn forge_chop_method(&self) -> ChopMethod { match self.forge.chop_mode { ChopMode::Transient => ChopMethod::Transient { sensitivity: self.forge.sensitivity }, ChopMode::Equal => ChopMethod::EqualDivisions(self.forge.divisions.max(1)), ChopMode::Bpm => ChopMethod::BpmGrid { bpm: self.forge.bpm, subdivisions_per_beat: self.forge.subdivisions.max(1), }, } } /// Compute slice-boundary markers for the current method (overlay preview). pub fn forge_preview_slices(&mut self) { let Some(hash) = self.forge.hash.clone() else { return }; let ext = self.forge.ext.clone(); let method = self.forge_chop_method(); match self.backend.compute_chop_preview(&hash, &ext, &method) { Ok(marks) => { // Slice count = boundaries minus the trailing 1.0 end marker. let count = marks.len().saturating_sub(1); self.forge.slice_marks = marks; self.status = format!("Preview: {count} slices"); } Err(e) => { self.forge.slice_marks.clear(); self.status = format!("Chop preview failed: {e}"); } } } /// Chop the loaded sample into slices written into a new VFS folder. pub fn forge_apply_chop(&mut self) { let Some(hash) = self.forge.hash.clone() else { return }; let Some(vfs_id) = self.current_vfs_id() else { self.status = "No VFS available".to_string(); return; }; let ext = self.forge.ext.clone(); let name = self.forge.name.clone(); let method = self.forge_chop_method(); let parent_id = self.current_dir; self.forge.busy = true; let result = self .backend .chop_sample(vfs_id, &hash, &ext, &name, parent_id, &method); self.forge.busy = false; match result { Ok(count) => { self.status = format!("Chopped into {count} slices"); self.refresh_contents(); } Err(e) => self.status = format!("Chop failed: {e}"), } } /// Conform the loaded sample to the selected device's format, writing a new /// sibling sample. pub fn forge_conform_device(&mut self, device_name: &str) { let Some(hash) = self.forge.hash.clone() else { return }; let Some(vfs_id) = self.current_vfs_id() else { self.status = "No VFS available".to_string(); return; }; let target = match self .backend .device_conform_target(device_name, self.forge.source_rate) { Ok(Some(t)) => t, Ok(None) => { self.status = format!("Device profile not found: {device_name}"); return; } Err(e) => { self.status = format!("Conform failed: {e}"); return; } }; let ext = self.forge.ext.clone(); let name = self.forge.name.clone(); let parent_id = self.current_dir; self.forge.busy = true; let result = self .backend .conform_sample(vfs_id, &hash, &ext, &name, parent_id, &target); self.forge.busy = false; match result { Ok(conformed) => { let mut msg = format!( "Conformed for {device_name} ({} Hz, {}-bit)", target.sample_rate, target.bit_depth ); // Surface any true-peak overshoot so the encoder's clamp is never // a silent loss: either it was trimmed (opt-in) or it will clip. if let Some(o) = conformed.overshoot { use audiofiles_core::forge::OvershootAction; match o.action { OvershootAction::Trimmed { gain_db } => msg.push_str(&format!( " — true-peak +{:.1} dB trimmed {:.1} dB to avoid clipping", o.peak_dbfs, gain_db.abs() )), OvershootAction::Flagged => msg.push_str(&format!( " — warning: true-peak +{:.1} dB will clip (enable auto-trim in Settings to prevent)", o.peak_dbfs )), } } self.status = msg; self.refresh_contents(); } Err(e) => self.status = format!("Conform failed: {e}"), } } /// Toggle the forge overshoot policy and persist it. When on, conforms that /// overshoot full scale at an integer target are trimmed to the ceiling; /// when off (default) the signal is left untouched and only reported. pub fn toggle_forge_auto_trim_overshoot(&mut self) { self.forge_auto_trim_overshoot = !self.forge_auto_trim_overshoot; let _ = self.backend.set_config( crate::backend::FORGE_AUTO_TRIM_OVERSHOOT_KEY, if self.forge_auto_trim_overshoot { "true" } else { "false" }, ); } /// Batch trim leading/trailing silence across the current selection. Reuses /// the edit pipeline via the `TrimSilence` operation. pub fn batch_trim_silence(&mut self, threshold_db: f64) { self.batch_edit(move |_hash| { Some(audiofiles_core::edit::EditOperation::TrimSilence { threshold_db }) }); } }