Skip to main content

max / audiofiles

9.2 KB · 279 lines History Blame Raw
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 && let Some(d) = device {
227 state.forge_conform_device(&d);
228 }
229 });
230 ui.label(
231 egui::RichText::new("Resamples and converts bit depth to match the device, as a new sample.")
232 .small()
233 .color(theme::text_muted()),
234 );
235 }
236
237 /// Batch section: trim silence across the current multi-selection. Batch
238 /// normalize/gain live in the Sample Editor's batch section.
239 fn draw_batch_section(ui: &mut egui::Ui, state: &mut BrowserState) {
240 let count = state.selected_sample_hashes().len();
241 ui.label(egui::RichText::new("Batch").strong());
242 if count < 2 {
243 ui.label(
244 egui::RichText::new("Select 2+ samples to batch trim silence.")
245 .small()
246 .color(theme::text_muted()),
247 );
248 return;
249 }
250
251 ui.horizontal(|ui| {
252 ui.label("Threshold:");
253 ui.add(
254 egui::DragValue::new(&mut state.forge.trim_threshold_db)
255 .speed(1.0)
256 .range(-96.0..=-20.0)
257 .suffix(" dBFS"),
258 );
259 });
260 let threshold = state.forge.trim_threshold_db;
261 if ui
262 .button(format!("Trim silence on {count} samples"))
263 .clicked()
264 {
265 state.batch_trim_silence(threshold);
266 }
267 }
268
269 /// Foreshadow the CLAP/VST plugin host — the planned follow-on headline. Copy
270 /// only; not built for this launch.
271 fn draw_foreshadow_section(ui: &mut egui::Ui) {
272 ui.label(
273 egui::RichText::new("Plugin processing (CLAP/VST) — coming soon")
274 .small()
275 .italics()
276 .color(theme::text_muted()),
277 );
278 }
279