Skip to main content

max / audiofiles

21.3 KB · 567 lines History Blame Raw
1 //! Floating sample editor window: all edit controls visible simultaneously.
2
3 use egui;
4
5 use crate::state::{BrowserState, EditResultMode};
6 use crate::waveform;
7 use audiofiles_core::edit::FadeCurve;
8
9 use super::theme;
10 use super::widgets;
11
12 /// Draw the floating sample editor window. Call from the overlay layer.
13 pub fn draw_edit_window(ctx: &egui::Context, state: &mut BrowserState) {
14 let mut open = state.edit.show_window;
15 widgets::tool_window(ctx, "Sample Editor", &mut open, 400.0, 320.0, |ui| {
16 // In-progress overlay
17 if state.edit.in_progress {
18 ui.vertical_centered(|ui| {
19 ui.add_space(theme::space::MD);
20 ui.spinner();
21 ui.label("Applying edit...");
22 // M-11: best-effort cancel. Signals the worker and clears
23 // in_progress so the UI is interactive even if the worker
24 // is mid-write — the cancel is advisory, not synchronous.
25 ui.add_space(theme::space::SM);
26 if ui.button("Cancel").clicked() {
27 state.cancel_edit_operation();
28 }
29 });
30 return;
31 }
32
33 // Result prompt overlay
34 if state.edit.result_prompt {
35 draw_result_prompt(ui, state);
36 return;
37 }
38
39 let hash = match &state.edit.hash {
40 Some(h) => h.clone(),
41 None => return,
42 };
43
44 draw_waveform_section(ui, state, &hash);
45 draw_info_line(ui, state);
46
47 ui.separator();
48 draw_trim_section(ui, state);
49
50 ui.separator();
51 draw_levels_section(ui, state);
52
53 ui.separator();
54 draw_transform_section(ui, state);
55
56 ui.separator();
57 draw_silence_section(ui, state);
58
59 ui.separator();
60 draw_result_section(ui, state);
61
62 // Batch edit section (shown when multiple samples selected)
63 ui.separator();
64 draw_batch_section(ui, state);
65 });
66 state.edit.show_window = open;
67 }
68
69 /// Waveform display with playback cursor and click-to-seek.
70 fn draw_waveform_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str) {
71 if let Some(ref waveform_data) = state.selected_waveform {
72 let playback_pos = if state.previewing_hash.as_deref() == Some(hash) {
73 let playback = state.shared.preview.lock();
74 if playback.playing {
75 if let Some(ref buf) = playback.buffer {
76 let total_frames = if playback.streaming {
77 playback.total_frames_estimate.unwrap_or(playback.decoded_frames)
78 } else {
79 buf.data.len() / 2
80 };
81 if total_frames > 0 {
82 Some((playback.position_frac / total_frames as f64) as f32)
83 } else {
84 None
85 }
86 } else {
87 None
88 }
89 } else {
90 None
91 }
92 } else {
93 None
94 };
95
96 // p-6: 120px matches the detail panel waveform; the edit context wants
97 // the larger surface for precise trim work (was 80px).
98 let resp = waveform::draw_waveform(ui, waveform_data, playback_pos, 120.0);
99
100 // C-1 part 1: paint the trim preview overlay. Regions outside the
101 // current [trim_start, trim_end] are dimmed so the user sees what
102 // *will be removed* before clicking Trim. Slider edges = preview
103 // edges. Updates live as the user drags.
104 let trim_start = state.edit.trim_start;
105 let trim_end = state.edit.trim_end;
106 if trim_start > 0.001 || trim_end < 0.999 {
107 let rect = resp.rect;
108 let painter = ui.painter_at(rect);
109 let overlay = theme::trim_mute_overlay();
110 if trim_start > 0.0 {
111 let x_end = rect.left() + rect.width() * trim_start;
112 painter.rect_filled(
113 egui::Rect::from_min_max(rect.min, egui::pos2(x_end, rect.max.y)),
114 0.0,
115 overlay,
116 );
117 }
118 if trim_end < 1.0 {
119 let x_start = rect.left() + rect.width() * trim_end;
120 painter.rect_filled(
121 egui::Rect::from_min_max(egui::pos2(x_start, rect.min.y), rect.max),
122 0.0,
123 overlay,
124 );
125 }
126 // Boundary markers reinforce the cut points when the overlay is
127 // ambiguous (e.g. nearly-zero trim where the dimmed strip is thin).
128 let stroke = egui::Stroke::new(1.0, theme::accent_yellow());
129 if trim_start > 0.0 {
130 let x = rect.left() + rect.width() * trim_start;
131 painter.line_segment(
132 [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
133 stroke,
134 );
135 }
136 if trim_end < 1.0 {
137 let x = rect.left() + rect.width() * trim_end;
138 painter.line_segment(
139 [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
140 stroke,
141 );
142 }
143 }
144
145 // Click-to-seek
146 if resp.clicked() {
147 if let Some(pos) = resp.interact_pointer_pos() {
148 let rect = resp.rect;
149 let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
150 if state.previewing_hash.as_deref() == Some(hash) {
151 let mut playback = state.shared.preview.lock();
152 if let Some(ref buf) = playback.buffer {
153 let total_frames = if playback.streaming {
154 playback.total_frames_estimate.unwrap_or(playback.decoded_frames)
155 } else {
156 buf.data.len() / 2
157 };
158 playback.position_frac = (normalized as f64 * total_frames as f64)
159 .min((playback.decoded_frames.max(1) - 1) as f64);
160 }
161 }
162 }
163 }
164 }
165 }
166
167 /// Info line: name, sample rate, duration, peak dB.
168 fn draw_info_line(ui: &mut egui::Ui, state: &BrowserState) {
169 if let Some(ref analysis) = state.selected_analysis {
170 let name = state.selected_node()
171 .map(|n| n.node.name.clone())
172 .unwrap_or_default();
173 let duration = analysis.duration;
174 let sr = analysis.sample_rate;
175 let peak_str = analysis.peak_db
176 .map(|p| format!("{:.1} dBFS", p))
177 .unwrap_or_default();
178
179 ui.horizontal_wrapped(|ui| {
180 ui.label(egui::RichText::new(&name).strong().size(12.0));
181 ui.label(
182 egui::RichText::new(format!("{} Hz \u{00B7} {:.3}s \u{00B7} {}", sr, duration, peak_str))
183 .color(theme::text_muted())
184 .size(11.0),
185 );
186 });
187 ui.add_space(theme::space::XS);
188 }
189 }
190
191 /// Trim section with start/end sliders.
192 fn draw_trim_section(ui: &mut egui::Ui, state: &mut BrowserState) {
193 let disabled = state.edit.in_progress || state.edit.hash.is_none();
194
195 ui.label(egui::RichText::new("Trim").strong());
196
197 let sample_rate = state.selected_analysis.as_ref()
198 .map(|a| a.sample_rate)
199 .unwrap_or(44100);
200 let total = state.edit.total_frames;
201 let start_time = state.edit.trim_start as f64 * total as f64 / sample_rate as f64;
202 let end_time = state.edit.trim_end as f64 * total as f64 / sample_rate as f64;
203
204 ui.horizontal(|ui| {
205 ui.label("Start:");
206 ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.trim_start, 0.0..=1.0).show_value(false));
207 ui.label(egui::RichText::new(format!("{:.3}s", start_time)).color(theme::text_muted()));
208 });
209
210 ui.horizontal(|ui| {
211 ui.label("End:");
212 ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.trim_end, 0.0..=1.0).show_value(false));
213 ui.label(egui::RichText::new(format!("{:.3}s", end_time)).color(theme::text_muted()));
214 });
215
216 // Clamp start < end
217 if state.edit.trim_start >= state.edit.trim_end {
218 state.edit.trim_start = (state.edit.trim_end - 0.001).max(0.0);
219 }
220
221 ui.horizontal(|ui| {
222 if ui.add_enabled(!disabled, egui::Button::new("Trim")).clicked() {
223 state.apply_edit_trim();
224 }
225 });
226 }
227
228 /// Levels section: gain and normalize.
229 fn draw_levels_section(ui: &mut egui::Ui, state: &mut BrowserState) {
230 let disabled = state.edit.in_progress || state.edit.hash.is_none();
231
232 ui.label(egui::RichText::new("Levels").strong());
233
234 // Current peak display + gain clipping warning
235 let current_peak = state.selected_analysis.as_ref().and_then(|a| a.peak_db);
236 if let Some(peak) = current_peak {
237 let predicted = peak + state.edit.gain_db;
238 if predicted > 0.0 {
239 ui.colored_label(
240 theme::accent_red(),
241 format!("Peak: {:.1} dB \u{2192} {:.1} dB (clips!)", peak, predicted),
242 );
243 }
244 }
245
246 // Gain
247 ui.horizontal(|ui| {
248 ui.label("Gain:");
249 ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.gain_db, -24.0..=24.0).suffix(" dB"));
250 if ui.add_enabled(!disabled, egui::Button::new("Apply")).clicked() {
251 state.apply_edit_gain();
252 }
253 });
254
255 // Normalize
256 ui.horizontal(|ui| {
257 ui.label("Normalize:");
258 // M-13: switching mode resets the target to the canonical default for
259 // the new mode (Peak: -1.0 dBFS, LUFS: -14.0 LUFS). The carried-over
260 // value is meaningless across modes, so snap to a sane starting point.
261 if ui.add_enabled(!disabled, egui::RadioButton::new(state.edit.norm_peak, "Peak")).clicked() {
262 if !state.edit.norm_peak {
263 state.edit.norm_target = -1.0;
264 }
265 state.edit.norm_peak = true;
266 }
267 if ui.add_enabled(!disabled, egui::RadioButton::new(!state.edit.norm_peak, "LUFS")).clicked() {
268 if state.edit.norm_peak {
269 state.edit.norm_target = -14.0;
270 }
271 state.edit.norm_peak = false;
272 }
273 });
274
275 let norm_range = if state.edit.norm_peak { -24.0..=0.0 } else { -24.0..=-6.0 };
276 let norm_suffix = if state.edit.norm_peak { " dBFS" } else { " LUFS" };
277
278 ui.horizontal(|ui| {
279 ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.norm_target, norm_range).suffix(norm_suffix));
280 if ui.add_enabled(!disabled, egui::Button::new("Apply")).clicked() {
281 state.apply_edit_normalize();
282 }
283 });
284 }
285
286 /// Transform section: reverse and fade.
287 fn draw_transform_section(ui: &mut egui::Ui, state: &mut BrowserState) {
288 let disabled = state.edit.in_progress || state.edit.hash.is_none();
289
290 ui.label(egui::RichText::new("Transform").strong());
291
292 // Reverse
293 if ui.add_enabled(!disabled, egui::Button::new("Reverse")).clicked() {
294 state.apply_edit_reverse();
295 }
296
297 // Fade
298 ui.horizontal(|ui| {
299 ui.label("Fade:");
300 if ui.add_enabled(!disabled, egui::RadioButton::new(state.edit.fade_in, "In")).clicked() {
301 state.edit.fade_in = true;
302 }
303 if ui.add_enabled(!disabled, egui::RadioButton::new(!state.edit.fade_in, "Out")).clicked() {
304 state.edit.fade_in = false;
305 }
306 });
307
308 ui.horizontal(|ui| {
309 // m-8: cap raised from 2000 to 10000 ms. Long pads / textures can want
310 // multi-second fades; the old 2-second ceiling was invisible until hit.
311 ui.add_enabled(
312 !disabled,
313 egui::Slider::new(&mut state.edit.fade_duration_ms, 10.0..=10000.0).suffix(" ms"),
314 )
315 .on_hover_text("Maximum fade duration 10s");
316 egui::ComboBox::from_id_salt("edit_fade_curve")
317 .selected_text(match state.edit.fade_curve {
318 FadeCurve::Linear => "Linear",
319 FadeCurve::Logarithmic => "Log",
320 FadeCurve::SCurve => "S-Curve",
321 })
322 .width(70.0)
323 .show_ui(ui, |ui| {
324 ui.selectable_value(&mut state.edit.fade_curve, FadeCurve::Linear, "Linear");
325 ui.selectable_value(&mut state.edit.fade_curve, FadeCurve::Logarithmic, "Log");
326 ui.selectable_value(&mut state.edit.fade_curve, FadeCurve::SCurve, "S-Curve");
327 });
328 if ui.add_enabled(!disabled, egui::Button::new("Apply")).clicked() {
329 state.apply_edit_fade();
330 }
331 });
332 }
333
334 /// Silence section: insert or remove silence.
335 fn draw_silence_section(ui: &mut egui::Ui, state: &mut BrowserState) {
336 let disabled = state.edit.in_progress || state.edit.hash.is_none();
337
338 ui.label(egui::RichText::new("Silence").strong());
339
340 // m-9: clamp Insert/Remove positions to [0, sample_duration_ms] when the
341 // sample's analysis duration is known. Falls back to the previous unbounded
342 // range only if duration is missing (un-analyzed sample). Prevents the
343 // silent-failure / undefined-behaviour case where positions exceed length.
344 let duration_ms_cap = state
345 .selected_analysis
346 .as_ref()
347 .map(|a| a.duration * 1000.0)
348 .unwrap_or(f64::MAX);
349
350 // Insert silence
351 ui.horizontal(|ui| {
352 ui.label("Insert at:");
353 ui.add_enabled(
354 !disabled,
355 egui::DragValue::new(&mut state.edit.silence_position_ms)
356 .speed(10.0)
357 .range(0.0..=duration_ms_cap)
358 .suffix(" ms"),
359 );
360 ui.label("Duration:");
361 ui.add_enabled(
362 !disabled,
363 egui::DragValue::new(&mut state.edit.silence_duration_ms)
364 .speed(10.0)
365 .range(1.0..=60000.0)
366 .suffix(" ms"),
367 );
368 if ui.add_enabled(!disabled, egui::Button::new("Insert")).clicked() {
369 state.apply_edit_insert_silence();
370 }
371 });
372
373 // Remove range
374 ui.horizontal(|ui| {
375 ui.label("Remove from:");
376 ui.add_enabled(
377 !disabled,
378 egui::DragValue::new(&mut state.edit.remove_start_ms)
379 .speed(10.0)
380 .range(0.0..=duration_ms_cap)
381 .suffix(" ms"),
382 );
383 ui.label("to:");
384 ui.add_enabled(
385 !disabled,
386 egui::DragValue::new(&mut state.edit.remove_end_ms)
387 .speed(10.0)
388 .range(0.0..=duration_ms_cap)
389 .suffix(" ms"),
390 );
391 if ui.add_enabled(!disabled, egui::Button::new("Remove")).clicked() {
392 state.apply_edit_remove_range();
393 }
394 });
395 }
396
397 /// Batch edit section: apply operations to all selected samples.
398 fn draw_batch_section(ui: &mut egui::Ui, state: &mut BrowserState) {
399 let selected_count = state.selected_sample_hashes().len();
400 if selected_count < 2 {
401 return;
402 }
403
404 // m-13: heading is plain strong (matches other section headers). The
405 // batch-vs-single distinction moves to a small muted badge so adjacent
406 // sections stay visually balanced.
407 ui.horizontal(|ui| {
408 ui.label(egui::RichText::new("Batch Edit").strong());
409 ui.label(
410 egui::RichText::new(format!("Batch \u{00B7} {} samples", selected_count))
411 .small()
412 .color(theme::text_muted()),
413 );
414 });
415 ui.label(
416 egui::RichText::new("Apply to all selected samples at once")
417 .small()
418 .color(theme::text_muted()),
419 );
420 ui.add_space(theme::space::SM);
421
422 // M-14: bake the panel's current slider values into the button labels so
423 // the broadcast nature is explicit. Removes the silent-piggyback footgun
424 // where the user couldn't tell what value the batch button would use.
425 // Hover hints are dropped — the label now carries the value.
426 let norm_target = state.edit.norm_target;
427 let gain_db = state.edit.gain_db;
428 ui.horizontal(|ui| {
429 if ui
430 .button(format!(
431 "Normalize {} samples to {:.1} dBFS",
432 selected_count, norm_target
433 ))
434 .clicked()
435 {
436 state.batch_normalize_peak(norm_target);
437 }
438 if ui
439 .button(format!(
440 "Normalize {} samples to {:.1} LUFS",
441 selected_count, norm_target
442 ))
443 .clicked()
444 {
445 state.batch_normalize_lufs(norm_target);
446 }
447 });
448 ui.horizontal(|ui| {
449 if ui
450 .button(format!("Apply {:.1} dB to {} samples", gain_db, selected_count))
451 .clicked()
452 {
453 state.batch_gain(gain_db);
454 }
455 if ui.button("Reverse").clicked() {
456 // m-16: gate large-batch Reverse behind a confirm modal. Single-
457 // sample Reverse is its own undo (click again), but on N samples
458 // the "click again to undo" trick requires remembering it ran in
459 // the first place. Threshold 10 keeps small selections frictionless.
460 if selected_count > 10 {
461 state.pending_confirm = Some(
462 crate::state::ConfirmAction::ReverseSamples { count: selected_count },
463 );
464 } else {
465 state.batch_reverse();
466 }
467 }
468 });
469 }
470
471 /// Result mode section: replace original vs create sibling.
472 fn draw_result_section(ui: &mut egui::Ui, state: &mut BrowserState) {
473 ui.label(egui::RichText::new("Result").strong());
474
475 let mut mode = state.edit.result_mode;
476 ui.horizontal(|ui| {
477 if ui.radio_value(&mut mode, Some(EditResultMode::Replace), "Replace original").changed() {
478 if let Some(m) = mode {
479 state.set_edit_result_mode(m);
480 }
481 }
482 if ui.radio_value(&mut mode, Some(EditResultMode::Sibling), "Create sibling").changed() {
483 if let Some(m) = mode {
484 state.set_edit_result_mode(m);
485 }
486 }
487 });
488 // Replace mode advisory. The previous "no in-app undo" copy was retired
489 // when the inline Undo affordance landed (C-1 part 2). The hint still
490 // points the user at Create sibling as the non-destructive default for
491 // workflows that want to keep the original in the VFS.
492 if matches!(state.edit.result_mode, Some(EditResultMode::Replace)) {
493 ui.label(
494 egui::RichText::new(
495 "Replace mode: the original is removed from this vault. Use Create sibling to keep both, or click Undo on the next line within 10s to revert.",
496 )
497 .small()
498 .color(theme::accent_yellow()),
499 );
500 }
501
502 // C-1 part 2: inline Undo for the most recent edit. The content store is
503 // content-addressed so the original sample blob is preserved on Replace —
504 // Undo only walks back the VFS work. Times out after 10s to avoid
505 // surprising the user with a stale affordance after they've moved on.
506 const UNDO_TIMEOUT_SECS: f32 = 10.0;
507 let undo_expired = state
508 .edit
509 .last_undo
510 .as_ref()
511 .map(|e| e.created_at.elapsed().as_secs_f32() > UNDO_TIMEOUT_SECS)
512 .unwrap_or(false);
513 if undo_expired {
514 state.edit.last_undo = None;
515 }
516 if let Some(ref entry) = state.edit.last_undo {
517 let op_label = entry.op_name.clone();
518 let remaining = std::time::Duration::from_secs_f32(UNDO_TIMEOUT_SECS)
519 .saturating_sub(entry.created_at.elapsed());
520 // Wake the UI when the affordance is due to expire so it disappears
521 // even if the user isn't interacting with the panel.
522 ui.ctx().request_repaint_after(remaining);
523 ui.horizontal(|ui| {
524 ui.label(
525 egui::RichText::new(format!("Last edit: {op_label}"))
526 .small()
527 .color(theme::text_muted()),
528 );
529 if ui.small_button("Undo").clicked() {
530 state.undo_last_edit();
531 }
532 });
533 }
534 }
535
536 /// Draw the "Replace or Create Sibling?" prompt after first edit.
537 fn draw_result_prompt(ui: &mut egui::Ui, state: &mut BrowserState) {
538 egui::Frame::popup(ui.style()).show(ui, |ui| {
539 ui.heading("Edit Result");
540 ui.separator();
541 ui.label("How should the edited sample be handled?");
542 ui.add_space(theme::space::MD);
543
544 // m-10: initialise from the persistent result_mode so the checkbox
545 // reflects whether the user has already locked in a default. The
546 // Result section radios are the source of truth; this checkbox mirrors
547 // their state for visibility, then writes through on submit.
548 let mut remember = state.edit.result_mode.is_some();
549 ui.checkbox(&mut remember, "Remember my choice");
550
551 ui.add_space(theme::space::SM);
552 ui.horizontal(|ui| {
553 if ui.button("Replace Original").clicked() {
554 state.confirm_edit_result(EditResultMode::Replace, remember);
555 }
556 if ui.button("Create Sibling").clicked() {
557 state.confirm_edit_result(EditResultMode::Sibling, remember);
558 }
559 // M-12: third option lets the user back out of the prompt without
560 // committing the edit. Drops the pending result file.
561 if ui.button("Discard edit").clicked() {
562 state.discard_edit_result();
563 }
564 });
565 });
566 }
567