Skip to main content

max / audiofiles

19.8 KB · 520 lines History Blame Raw
1 //! Sample edit workflow: the floating editor window, single and batch edit
2 //! operations, result-mode handling, and undo. Edit-completion events from
3 //! the worker land in `poll_workers`, which calls `handle_edit_complete`.
4
5 use super::{AnalysisConfig, BrowserState, PathBuf, split_name_ext};
6
7 impl BrowserState {
8 /// Open the floating sample editor for the given hash.
9 pub fn open_edit_window(&mut self, hash: &str) {
10 // Load analysis for total frames and result mode preference
11 let analysis = self.backend.get_analysis(hash).ok().flatten();
12 let total_frames = analysis
13 .as_ref()
14 .map_or(0, |a| (a.duration * a.sample_rate as f64) as usize);
15
16 self.edit.hash = Some(hash.to_string());
17 self.edit.show_window = true;
18
19 // Reset all params to defaults
20 self.edit.trim_start = 0.0;
21 self.edit.trim_end = 1.0;
22 self.edit.total_frames = total_frames;
23 self.edit.gain_db = 0.0;
24 self.edit.norm_peak = true;
25 self.edit.norm_target = -0.1;
26 self.edit.fade_in = true;
27 self.edit.fade_duration_ms = 100.0;
28 self.edit.fade_curve = audiofiles_core::edit::FadeCurve::Linear;
29
30 // Cache result mode from user_config
31 self.edit.result_mode = match self
32 .backend
33 .get_config(crate::backend::ConfigKey::EditResultMode)
34 .ok()
35 .flatten()
36 .as_deref()
37 {
38 Some("replace") => Some(super::EditResultMode::Replace),
39 Some("sibling") => Some(super::EditResultMode::Sibling),
40 _ => None,
41 };
42 }
43
44 /// Close the floating sample editor.
45 pub fn close_edit_window(&mut self) {
46 self.edit.show_window = false;
47 self.edit.hash = None;
48 }
49
50 /// M-11: best-effort cancel of the in-flight edit. Signals the worker via
51 /// the backend and clears `in_progress` so the UI is interactive again.
52 /// The worker may still finish writing its output; if it does, the result
53 /// path will eventually be handled by `handle_edit_complete` as normal.
54 pub fn cancel_edit_operation(&mut self) {
55 let _ = self.backend.cancel_edit();
56 self.edit.in_progress = false;
57 self.status = "Edit cancelled.".to_string();
58 }
59
60 /// M-12: discard the pending edit result without applying it. The result
61 /// file held in `pending_result` is dropped; if the temp path still exists
62 /// on disk it is removed.
63 pub fn discard_edit_result(&mut self) {
64 if let Some(pending) = self.edit.pending_result.take() {
65 let _ = std::fs::remove_file(&pending.result_path);
66 }
67 self.edit.result_prompt = false;
68 self.status = "Edit result discarded.".to_string();
69 }
70
71 /// Apply trim to the current edit target.
72 pub fn apply_edit_trim(&mut self) {
73 let hash = match &self.edit.hash {
74 Some(h) => h.clone(),
75 None => return,
76 };
77 // total_frames falls back to 0 when analysis is absent; a 0-length span
78 // (or an inverted one) would dispatch Trim{0,0} and produce a zero-length
79 // sample. Guard it like apply_edit_remove_range does (fuzz-2026-07-06 B4).
80 if self.edit.total_frames == 0 {
81 self.status = "Cannot trim: sample length is unknown.".to_string();
82 return;
83 }
84 let start = (self.edit.trim_start * self.edit.total_frames as f32) as usize;
85 let end = (self.edit.trim_end * self.edit.total_frames as f32) as usize;
86 if start >= end {
87 self.status = "Trim range: start must be before end".to_string();
88 return;
89 }
90 let op = audiofiles_core::edit::EditOperation::Trim {
91 start_frame: start,
92 end_frame: end,
93 };
94 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
95 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
96 self.status = format!("Edit failed: {e}");
97 }
98 }
99
100 /// Apply gain adjustment to the current edit target.
101 pub fn apply_edit_gain(&mut self) {
102 let hash = match &self.edit.hash {
103 Some(h) => h.clone(),
104 None => return,
105 };
106 let op = audiofiles_core::edit::EditOperation::Gain {
107 db: self.edit.gain_db,
108 };
109 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
110 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
111 self.status = format!("Edit failed: {e}");
112 }
113 }
114
115 /// Apply normalize to the current edit target.
116 pub fn apply_edit_normalize(&mut self) {
117 let hash = match &self.edit.hash {
118 Some(h) => h.clone(),
119 None => return,
120 };
121 let op = if self.edit.norm_peak {
122 audiofiles_core::edit::EditOperation::NormalizePeak {
123 target_db: self.edit.norm_target,
124 }
125 } else {
126 audiofiles_core::edit::EditOperation::NormalizeLufs {
127 target_lufs: self.edit.norm_target,
128 }
129 };
130 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
131 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
132 self.status = format!("Edit failed: {e}");
133 }
134 }
135
136 /// Apply reverse to the current edit target.
137 pub fn apply_edit_reverse(&mut self) {
138 let hash = match &self.edit.hash {
139 Some(h) => h.clone(),
140 None => return,
141 };
142 let op = audiofiles_core::edit::EditOperation::Reverse;
143 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
144 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
145 self.status = format!("Edit failed: {e}");
146 }
147 }
148
149 /// Apply fade to the current edit target.
150 pub fn apply_edit_fade(&mut self) {
151 let hash = match &self.edit.hash {
152 Some(h) => h.clone(),
153 None => return,
154 };
155 // Convert ms to frames using sample rate from analysis
156 let sample_rate = self
157 .backend
158 .get_analysis(&hash)
159 .ok()
160 .flatten()
161 .map_or(44100, |a| a.sample_rate);
162 let frames = ((self.edit.fade_duration_ms / 1000.0) * sample_rate as f64) as usize;
163 let op = if self.edit.fade_in {
164 audiofiles_core::edit::EditOperation::FadeIn {
165 frames,
166 curve: self.edit.fade_curve,
167 }
168 } else {
169 audiofiles_core::edit::EditOperation::FadeOut {
170 frames,
171 curve: self.edit.fade_curve,
172 }
173 };
174 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
175 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
176 self.status = format!("Edit failed: {e}");
177 }
178 }
179
180 /// Insert silence at the configured position and duration.
181 pub fn apply_edit_insert_silence(&mut self) {
182 let hash = match &self.edit.hash {
183 Some(h) => h.clone(),
184 None => return,
185 };
186 let sample_rate = self
187 .backend
188 .get_analysis(&hash)
189 .ok()
190 .flatten()
191 .map_or(44100, |a| a.sample_rate);
192 let start_frame = ((self.edit.silence_position_ms / 1000.0) * sample_rate as f64) as usize;
193 let duration_frames =
194 ((self.edit.silence_duration_ms / 1000.0) * sample_rate as f64) as usize;
195 let op = audiofiles_core::edit::EditOperation::InsertSilence {
196 start_frame,
197 duration_frames,
198 };
199 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
200 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
201 self.status = format!("Edit failed: {e}");
202 }
203 }
204
205 /// Remove a range of frames between the configured start and end positions.
206 pub fn apply_edit_remove_range(&mut self) {
207 let hash = match &self.edit.hash {
208 Some(h) => h.clone(),
209 None => return,
210 };
211 let sample_rate = self
212 .backend
213 .get_analysis(&hash)
214 .ok()
215 .flatten()
216 .map_or(44100, |a| a.sample_rate);
217 let start_frame = ((self.edit.remove_start_ms / 1000.0) * sample_rate as f64) as usize;
218 let end_frame = ((self.edit.remove_end_ms / 1000.0) * sample_rate as f64) as usize;
219 if start_frame >= end_frame {
220 self.status = "Remove range: start must be before end".to_string();
221 return;
222 }
223 let op = audiofiles_core::edit::EditOperation::RemoveRange {
224 start_frame,
225 end_frame,
226 };
227 let ext = self.backend.sample_extension(&hash).unwrap_or_default();
228 if let Err(e) = self.backend.start_edit(&hash, &ext, op) {
229 self.status = format!("Edit failed: {e}");
230 }
231 }
232
233 /// Apply an edit operation to all selected samples (batch edit).
234 /// Skips directories and samples without hashes.
235 pub fn batch_edit(
236 &mut self,
237 op_factory: impl Fn(&str) -> Option<audiofiles_core::edit::EditOperation>,
238 ) {
239 let hashes = self.selected_sample_hashes();
240 if hashes.is_empty() {
241 self.status = "No samples selected for batch edit".to_string();
242 return;
243 }
244 let mut applied = 0;
245 let mut errors = 0;
246 for hash in &hashes {
247 let Some(op) = op_factory(hash) else {
248 continue;
249 };
250 let ext = self.backend.sample_extension(hash).unwrap_or_default();
251 match self.backend.start_edit(hash, &ext, op) {
252 Ok(()) => applied += 1,
253 Err(_) => errors += 1,
254 }
255 }
256 self.status = if errors > 0 {
257 format!("Batch edit: {applied} applied, {errors} failed")
258 } else {
259 format!("Batch edit: {applied} samples processed")
260 };
261 }
262
263 /// Batch normalize (peak) all selected samples.
264 pub fn batch_normalize_peak(&mut self, target_db: f64) {
265 self.batch_edit(|_hash| {
266 Some(audiofiles_core::edit::EditOperation::NormalizePeak { target_db })
267 });
268 }
269
270 /// Batch normalize (LUFS) all selected samples.
271 pub fn batch_normalize_lufs(&mut self, target_lufs: f64) {
272 self.batch_edit(|_hash| {
273 Some(audiofiles_core::edit::EditOperation::NormalizeLufs { target_lufs })
274 });
275 }
276
277 /// Batch reverse all selected samples.
278 pub fn batch_reverse(&mut self) {
279 self.batch_edit(|_hash| Some(audiofiles_core::edit::EditOperation::Reverse));
280 }
281
282 /// Batch apply gain to all selected samples.
283 pub fn batch_gain(&mut self, db: f64) {
284 self.batch_edit(|_hash| Some(audiofiles_core::edit::EditOperation::Gain { db }));
285 }
286
287 /// Save the user's preferred edit result mode.
288 pub fn set_edit_result_mode(&mut self, mode: super::EditResultMode) {
289 let mode_str = match mode {
290 super::EditResultMode::Replace => "replace",
291 super::EditResultMode::Sibling => "sibling",
292 };
293 super::log_backend_err(
294 "set_config edit_result_mode",
295 self.backend
296 .set_config(crate::backend::ConfigKey::EditResultMode, mode_str),
297 );
298 self.edit.result_mode = Some(mode);
299 }
300
301 /// Handle a completed edit: import result, update VFS, record history.
302 pub(super) fn handle_edit_complete(
303 &mut self,
304 source_hash: String,
305 result_path: PathBuf,
306 operation: audiofiles_core::edit::EditOperation,
307 ) {
308 let op_name = operation.display_name().to_string();
309
310 // Use cached result mode preference
311 let Some(mode) = self.edit.result_mode else {
312 // No preference set, store result and show prompt
313 self.edit.pending_result = Some(super::PendingEditResult {
314 source_hash,
315 result_path,
316 operation,
317 });
318 self.edit.result_prompt = true;
319 return;
320 };
321
322 self.finalize_edit(&source_hash, &result_path, &operation, mode);
323 self.status = format!("Edit applied, {op_name}");
324 }
325
326 /// Apply the chosen edit result mode to a pending edit.
327 pub fn confirm_edit_result(&mut self, mode: super::EditResultMode, remember: bool) {
328 if remember {
329 self.set_edit_result_mode(mode);
330 }
331
332 if let Some(pending) = self.edit.pending_result.take() {
333 let op_name = pending.operation.display_name().to_string();
334 self.finalize_edit(
335 &pending.source_hash,
336 &pending.result_path,
337 &pending.operation,
338 mode,
339 );
340 self.status = format!("Edit applied, {op_name}");
341 }
342 self.edit.result_prompt = false;
343 }
344
345 /// Common finalization: import result file, update VFS, record history, trigger analysis.
346 fn finalize_edit(
347 &mut self,
348 source_hash: &str,
349 result_path: &std::path::Path,
350 operation: &audiofiles_core::edit::EditOperation,
351 mode: super::EditResultMode,
352 ) {
353 // 1. Import the result file into the content-addressed store. Always
354 // clean up the temp regardless of outcome, the prior code only removed
355 // on the success branch, leaking the temp on every import failure.
356 // (The temp is the user's edit output; after import_file copies it
357 // into the store, we own the canonical copy by hash.)
358 let import_result = self.backend.import_file(result_path);
359 let _ = std::fs::remove_file(result_path);
360 let new_hash = match import_result {
361 Ok(h) => h,
362 Err(e) => {
363 self.status = format!("Failed to import edit result: {e}");
364 return;
365 }
366 };
367
368 // 2. Record in edit_history
369 super::log_backend_err(
370 "record_edit_history",
371 self.backend
372 .record_edit_history(source_hash, &new_hash, operation),
373 );
374
375 // 3. Update VFS based on mode
376 let Some(vfs_id) = self.current_vfs_id() else {
377 self.status = "No VFS available".to_string();
378 return;
379 };
380 let source_hashes = [source_hash];
381 let source_nodes = self
382 .backend
383 .find_nodes_by_hashes(vfs_id, &source_hashes)
384 .unwrap_or_default();
385
386 let new_ext = self
387 .backend
388 .sample_extension(&new_hash)
389 .unwrap_or_else(|_| "wav".to_string());
390
391 // C-1 part 2: record the pre-edit VFS positions so `undo_last_edit`
392 // can walk them back. Captured before deletion in Replace mode and
393 // after creation in Sibling mode.
394 let mut replace_targets: Vec<(Option<audiofiles_core::NodeId>, String)> = Vec::new();
395 let mut sibling_node_id: Option<audiofiles_core::NodeId> = None;
396
397 match mode {
398 super::EditResultMode::Replace => {
399 // Update each VFS node that references the source hash
400 for node in &source_nodes {
401 // Delete old node and create new one with same name/parent
402 let parent_id = node.node.parent_id;
403 let name = node.node.name.clone();
404 replace_targets.push((parent_id, name.clone()));
405 super::log_backend_err(
406 "delete_node (edit VFS update)",
407 self.backend.delete_node(node.node.id),
408 );
409 super::log_backend_err(
410 "create_sample_link (edit replace)",
411 self.backend
412 .create_sample_link(vfs_id, parent_id, &name, &new_hash),
413 );
414 }
415 }
416 super::EditResultMode::Sibling => {
417 // Create a sibling node next to the original
418 if let Some(node) = source_nodes.first() {
419 let parent_id = node.node.parent_id;
420 let (stem, _ext) = split_name_ext(&node.node.name);
421 let sibling_name = format!("{stem}_edited.{new_ext}");
422 if let Ok(id) =
423 self.backend
424 .create_sample_link(vfs_id, parent_id, &sibling_name, &new_hash)
425 {
426 sibling_node_id = Some(id);
427 }
428 }
429 }
430 }
431
432 // C-1 part 2: stash the entry only if there's actually something to
433 // undo (no VFS nodes → nothing happened that needs reversing).
434 if !replace_targets.is_empty() || sibling_node_id.is_some() {
435 self.edit.last_undo = Some(super::EditUndoEntry {
436 op_name: operation.display_name().to_string(),
437 source_hash: source_hash.to_string(),
438 result_hash: new_hash.clone(),
439 mode,
440 vfs_id,
441 replace_targets,
442 sibling_node_id,
443 created_at: std::time::Instant::now(),
444 });
445 }
446
447 // 4. Trigger analysis on the new sample
448 let hashes = vec![(new_hash, new_ext)];
449 super::log_backend_err(
450 "start_analysis (post-edit)",
451 self.backend
452 .start_analysis(hashes, AnalysisConfig::default()),
453 );
454
455 // 5. Refresh the file list
456 self.refresh_contents();
457 }
458
459 /// C-1 part 2: reverse the most recent finalized edit. Replace mode walks
460 /// the VFS nodes back to the source hash; Sibling mode deletes the
461 /// created sibling. The original sample blob is preserved by the
462 /// content-addressed store so no audio data needs to be restored.
463 ///
464 /// Returns silently with no-op if `last_undo` is empty.
465 pub fn undo_last_edit(&mut self) {
466 let Some(entry) = self.edit.last_undo.take() else {
467 return;
468 };
469
470 match entry.mode {
471 super::EditResultMode::Replace => {
472 // Re-find any nodes now pointing at result_hash and clear them
473 // first, the edit may have produced multiple nodes when the
474 // sample appeared in multiple places, and we recreate from the
475 // captured (parent_id, name) list.
476 let result_hashes = [entry.result_hash.as_str()];
477 if let Ok(result_nodes) = self
478 .backend
479 .find_nodes_by_hashes(entry.vfs_id, &result_hashes)
480 {
481 for node in &result_nodes {
482 super::log_backend_err(
483 "delete_node (edit VFS update)",
484 self.backend.delete_node(node.node.id),
485 );
486 }
487 }
488 for (parent_id, name) in &entry.replace_targets {
489 super::log_backend_err(
490 "create_sample_link (edit undo)",
491 self.backend.create_sample_link(
492 entry.vfs_id,
493 *parent_id,
494 name,
495 &entry.source_hash,
496 ),
497 );
498 }
499 }
500 super::EditResultMode::Sibling => {
501 if let Some(id) = entry.sibling_node_id {
502 super::log_backend_err(
503 "delete_node (edit undo sibling)",
504 self.backend.delete_node(id),
505 );
506 }
507 }
508 }
509
510 // Drop the matching edit_history row so the audit trail reflects the
511 // undo. Best-effort, the UI undo affordance already disappeared.
512 let _ = self
513 .backend
514 .delete_edit_history(&entry.source_hash, &entry.result_hash);
515
516 self.status = format!("Reverted {}", entry.op_name);
517 self.refresh_contents();
518 }
519 }
520