Skip to main content

max / audiofiles

8.2 KB · 203 lines History Blame Raw
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 && bpm > 0.0 {
23 self.forge.bpm = bpm;
24 }
25 let ext = self.backend.sample_extension(hash).unwrap_or_else(|_| "wav".to_string());
26 let name = self
27 .selected_node()
28 .map(|n| n.node.name.clone())
29 .or_else(|| self.backend.sample_original_name(hash).ok())
30 .unwrap_or_else(|| "sample".to_string());
31
32 // Cache the device list for the conform picker.
33 self.forge.devices = self
34 .backend
35 .list_device_profiles()
36 .unwrap_or_default()
37 .into_iter()
38 .map(|d| (d.name, d.format_summary.unwrap_or_default()))
39 .collect();
40
41 // Capture the waveform now so the forge display stays bound to this
42 // sample even if the file-list selection changes while it's open.
43 self.forge.waveform = self.backend.get_waveform(hash).ok().flatten();
44
45 self.forge.hash = Some(hash.to_string());
46 self.forge.ext = ext;
47 self.forge.name = name;
48 self.forge.source_rate = source_rate;
49 self.forge.slice_marks.clear();
50 // Reset the device selection so a prior sample's target doesn't appear
51 // pre-chosen for a sample it was never selected for.
52 self.forge.conform_device = None;
53 self.forge.busy = false;
54 self.forge.show_window = true;
55 }
56
57 /// Close the forge window.
58 pub fn close_forge_window(&mut self) {
59 self.forge.show_window = false;
60 self.forge.hash = None;
61 self.forge.slice_marks.clear();
62 self.forge.waveform = None;
63 }
64
65 /// Build the [`ChopMethod`] from the current UI selection.
66 pub fn forge_chop_method(&self) -> ChopMethod {
67 match self.forge.chop_mode {
68 ChopMode::Transient => ChopMethod::Transient { sensitivity: self.forge.sensitivity },
69 ChopMode::Equal => ChopMethod::EqualDivisions(self.forge.divisions.max(1)),
70 ChopMode::Bpm => ChopMethod::BpmGrid {
71 bpm: self.forge.bpm,
72 subdivisions_per_beat: self.forge.subdivisions.max(1),
73 },
74 }
75 }
76
77 /// Compute slice-boundary markers for the current method (overlay preview).
78 pub fn forge_preview_slices(&mut self) {
79 let Some(hash) = self.forge.hash.clone() else { return };
80 let ext = self.forge.ext.clone();
81 let method = self.forge_chop_method();
82 match self.backend.compute_chop_preview(&hash, &ext, &method) {
83 Ok(marks) => {
84 // Slice count = boundaries minus the trailing 1.0 end marker.
85 let count = marks.len().saturating_sub(1);
86 self.forge.slice_marks = marks;
87 self.status = format!("Preview: {count} slices");
88 }
89 Err(e) => {
90 self.forge.slice_marks.clear();
91 self.status = format!("Chop preview failed: {e}");
92 }
93 }
94 }
95
96 /// Chop the loaded sample into slices written into a new VFS folder.
97 pub fn forge_apply_chop(&mut self) {
98 let Some(hash) = self.forge.hash.clone() else { return };
99 let Some(vfs_id) = self.current_vfs_id() else {
100 self.status = "No VFS available".to_string();
101 return;
102 };
103 let ext = self.forge.ext.clone();
104 let name = self.forge.name.clone();
105 let method = self.forge_chop_method();
106 let parent_id = self.current_dir;
107
108 self.forge.busy = true;
109 let result = self
110 .backend
111 .chop_sample(vfs_id, &hash, &ext, &name, parent_id, &method);
112 self.forge.busy = false;
113
114 match result {
115 Ok(count) => {
116 self.status = format!("Chopped into {count} slices");
117 self.refresh_contents();
118 }
119 Err(e) => self.status = format!("Chop failed: {e}"),
120 }
121 }
122
123 /// Conform the loaded sample to the selected device's format, writing a new
124 /// sibling sample.
125 pub fn forge_conform_device(&mut self, device_name: &str) {
126 let Some(hash) = self.forge.hash.clone() else { return };
127 let Some(vfs_id) = self.current_vfs_id() else {
128 self.status = "No VFS available".to_string();
129 return;
130 };
131 let target = match self
132 .backend
133 .device_conform_target(device_name, self.forge.source_rate)
134 {
135 Ok(Some(t)) => t,
136 Ok(None) => {
137 self.status = format!("Device profile not found: {device_name}");
138 return;
139 }
140 Err(e) => {
141 self.status = format!("Conform failed: {e}");
142 return;
143 }
144 };
145 let ext = self.forge.ext.clone();
146 let name = self.forge.name.clone();
147 let parent_id = self.current_dir;
148
149 self.forge.busy = true;
150 let result = self
151 .backend
152 .conform_sample(vfs_id, &hash, &ext, &name, parent_id, &target);
153 self.forge.busy = false;
154
155 match result {
156 Ok(conformed) => {
157 let mut msg = format!(
158 "Conformed for {device_name} ({} Hz, {}-bit)",
159 target.sample_rate, target.bit_depth
160 );
161 // Surface any true-peak overshoot so the encoder's clamp is never
162 // a silent loss: either it was trimmed (opt-in) or it will clip.
163 if let Some(o) = conformed.overshoot {
164 use audiofiles_core::forge::OvershootAction;
165 match o.action {
166 OvershootAction::Trimmed { gain_db } => msg.push_str(&format!(
167 " — true-peak +{:.1} dB trimmed {:.1} dB to avoid clipping",
168 o.peak_dbfs,
169 gain_db.abs()
170 )),
171 OvershootAction::Flagged => msg.push_str(&format!(
172 " — warning: true-peak +{:.1} dB will clip (enable auto-trim in Settings to prevent)",
173 o.peak_dbfs
174 )),
175 }
176 }
177 self.status = msg;
178 self.refresh_contents();
179 }
180 Err(e) => self.status = format!("Conform failed: {e}"),
181 }
182 }
183
184 /// Toggle the forge overshoot policy and persist it. When on, conforms that
185 /// overshoot full scale at an integer target are trimmed to the ceiling;
186 /// when off (default) the signal is left untouched and only reported.
187 pub fn toggle_forge_auto_trim_overshoot(&mut self) {
188 self.forge_auto_trim_overshoot = !self.forge_auto_trim_overshoot;
189 let _ = self.backend.set_config(
190 crate::backend::FORGE_AUTO_TRIM_OVERSHOOT_KEY,
191 if self.forge_auto_trim_overshoot { "true" } else { "false" },
192 );
193 }
194
195 /// Batch trim leading/trailing silence across the current selection. Reuses
196 /// the edit pipeline via the `TrimSilence` operation.
197 pub fn batch_trim_silence(&mut self, threshold_db: f64) {
198 self.batch_edit(move |_hash| {
199 Some(audiofiles_core::edit::EditOperation::TrimSilence { threshold_db })
200 });
201 }
202 }
203