//! Destructive sample editing: trim, normalize, reverse, gain, fade, DC offset, channel conversion. //! //! Each operation is a pure function operating on `Vec` sample data. //! The [`EditOperation`] enum dispatches to the correct function via [`apply_edit`]. pub mod channel_convert; pub mod dc_offset; pub mod fade; pub mod gain; pub mod normalize; pub mod reverse; pub mod silence; pub mod trim; pub mod worker; use crate::error::CoreError; pub use fade::FadeCurve; /// A destructive edit operation to apply to sample data. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum EditOperation { Trim { start_frame: usize, end_frame: usize, }, NormalizePeak { target_db: f64, }, NormalizeLufs { target_lufs: f64, }, Reverse, Gain { db: f64, }, FadeIn { frames: usize, curve: FadeCurve, }, FadeOut { frames: usize, curve: FadeCurve, }, /// Remove DC offset (center waveform around zero). RemoveDcOffset, /// Convert mono to stereo (duplicate channels). MonoToStereo, /// Convert stereo/multi-channel to mono (average channels). StereoToMono, /// Insert silence at a frame position. InsertSilence { start_frame: usize, duration_frames: usize, }, /// Remove a range of frames (silence or otherwise). RemoveRange { start_frame: usize, end_frame: usize, }, /// Auto-trim leading and trailing silence below `threshold_db` (dBFS). /// Implemented by the forge batch DSP ([`crate::forge::batch`]). TrimSilence { threshold_db: f64, }, } impl EditOperation { /// Short display name for status messages. pub fn display_name(&self) -> &'static str { match self { EditOperation::Trim { .. } => "Trim", EditOperation::NormalizePeak { .. } => "Normalize (Peak)", EditOperation::NormalizeLufs { .. } => "Normalize (LUFS)", EditOperation::Reverse => "Reverse", EditOperation::Gain { .. } => "Gain", EditOperation::FadeIn { .. } => "Fade In", EditOperation::FadeOut { .. } => "Fade Out", EditOperation::RemoveDcOffset => "Remove DC Offset", EditOperation::MonoToStereo => "Mono → Stereo", EditOperation::StereoToMono => "Stereo → Mono", EditOperation::InsertSilence { .. } => "Insert Silence", EditOperation::RemoveRange { .. } => "Remove Range", EditOperation::TrimSilence { .. } => "Trim Silence", } } } /// Apply an edit operation to interleaved sample data in-place. /// /// Returns the (possibly changed) channel count — channel conversion operations /// modify the number of channels. pub fn apply_edit( samples: &mut Vec, channels: u16, sample_rate: u32, operation: &EditOperation, ) -> Result { match operation { EditOperation::Trim { start_frame, end_frame, } => { trim::apply_trim(samples, channels, *start_frame, *end_frame)?; Ok(channels) } EditOperation::NormalizePeak { target_db } => { normalize::apply_normalize_peak(samples, *target_db)?; Ok(channels) } EditOperation::NormalizeLufs { target_lufs } => { normalize::apply_normalize_lufs(samples, channels, sample_rate, *target_lufs)?; Ok(channels) } EditOperation::Reverse => { reverse::apply_reverse(samples, channels); Ok(channels) } EditOperation::Gain { db } => { gain::apply_gain(samples, *db); Ok(channels) } EditOperation::FadeIn { frames, curve } => { fade::apply_fade_in(samples, channels, *frames, *curve); Ok(channels) } EditOperation::FadeOut { frames, curve } => { fade::apply_fade_out(samples, channels, *frames, *curve); Ok(channels) } EditOperation::RemoveDcOffset => { dc_offset::apply_remove_dc_offset(samples, channels); Ok(channels) } EditOperation::MonoToStereo => channel_convert::apply_mono_to_stereo(samples, channels), EditOperation::StereoToMono => channel_convert::apply_stereo_to_mono(samples, channels), EditOperation::InsertSilence { start_frame, duration_frames, } => { silence::apply_insert_silence(samples, channels, *start_frame, *duration_frames)?; Ok(channels) } EditOperation::RemoveRange { start_frame, end_frame, } => { silence::apply_remove_range(samples, channels, *start_frame, *end_frame)?; Ok(channels) } EditOperation::TrimSilence { threshold_db } => { crate::forge::batch::trim_silence(samples, channels, *threshold_db); Ok(channels) } } } #[cfg(test)] mod tests { use super::*; #[test] fn apply_edit_dispatches_trim() { let mut samples = vec![0.1, 0.2, 0.3, 0.4, 0.5]; let op = EditOperation::Trim { start_frame: 1, end_frame: 4, }; apply_edit(&mut samples, 1, 44100, &op).unwrap(); assert_eq!(samples, vec![0.2, 0.3, 0.4]); } #[test] fn apply_edit_dispatches_reverse() { let mut samples = vec![1.0, 2.0, 3.0]; let op = EditOperation::Reverse; apply_edit(&mut samples, 1, 44100, &op).unwrap(); assert_eq!(samples, vec![3.0, 2.0, 1.0]); } #[test] fn apply_edit_dispatches_gain() { let mut samples = vec![0.5, -0.5]; let op = EditOperation::Gain { db: 0.0 }; apply_edit(&mut samples, 1, 44100, &op).unwrap(); assert_eq!(samples, vec![0.5, -0.5]); } #[test] fn apply_edit_dispatches_normalize_peak() { let mut samples = vec![0.5, -0.5]; let op = EditOperation::NormalizePeak { target_db: 0.0 }; apply_edit(&mut samples, 1, 44100, &op).unwrap(); let peak = samples.iter().fold(0.0f32, |max, &s| max.max(s.abs())); assert!((peak - 1.0).abs() < 0.01); } #[test] fn apply_edit_dispatches_fade_in() { let mut samples = vec![1.0; 4]; let op = EditOperation::FadeIn { frames: 4, curve: FadeCurve::Linear, }; apply_edit(&mut samples, 1, 44100, &op).unwrap(); assert!((samples[0]).abs() < 0.001); } #[test] fn apply_edit_dispatches_fade_out() { let mut samples = vec![1.0; 4]; let op = EditOperation::FadeOut { frames: 4, curve: FadeCurve::Linear, }; apply_edit(&mut samples, 1, 44100, &op).unwrap(); // Last frame should be zero (fade reaches silence) assert!((samples[3]).abs() < 0.001); } #[test] fn edit_operation_display_names() { assert_eq!(EditOperation::Reverse.display_name(), "Reverse"); assert_eq!( EditOperation::Trim { start_frame: 0, end_frame: 1 } .display_name(), "Trim" ); assert_eq!(EditOperation::Gain { db: 0.0 }.display_name(), "Gain"); } #[test] fn edit_operation_serializable() { let op = EditOperation::FadeIn { frames: 100, curve: FadeCurve::SCurve, }; let json = serde_json::to_string(&op).unwrap(); let decoded: EditOperation = serde_json::from_str(&json).unwrap(); assert_eq!(decoded.display_name(), "Fade In"); } #[test] fn apply_edit_dispatches_dc_offset() { let mut samples = vec![1.0, 1.0, 1.0]; // DC offset of 1.0 let ch = apply_edit(&mut samples, 1, 44100, &EditOperation::RemoveDcOffset).unwrap(); assert_eq!(ch, 1); assert!(samples.iter().all(|s| s.abs() < 1e-6)); } #[test] fn apply_edit_dispatches_mono_to_stereo() { let mut samples = vec![0.5, -0.5]; let ch = apply_edit(&mut samples, 1, 44100, &EditOperation::MonoToStereo).unwrap(); assert_eq!(ch, 2); assert_eq!(samples, vec![0.5, 0.5, -0.5, -0.5]); } #[test] fn apply_edit_dispatches_stereo_to_mono() { let mut samples = vec![0.4, 0.6, 0.2, 0.8]; let ch = apply_edit(&mut samples, 2, 44100, &EditOperation::StereoToMono).unwrap(); assert_eq!(ch, 1); assert_eq!(samples.len(), 2); } #[test] fn apply_edit_dispatches_trim_silence() { // mono: silence, content, silence -> trims to the content frames. let mut samples = vec![0.0, 0.0, 0.8, -0.7, 0.0]; let ch = apply_edit(&mut samples, 1, 44100, &EditOperation::TrimSilence { threshold_db: -60.0 }).unwrap(); assert_eq!(ch, 1); assert_eq!(samples, vec![0.8, -0.7]); } #[test] fn channel_conversion_display_names() { assert_eq!(EditOperation::RemoveDcOffset.display_name(), "Remove DC Offset"); assert_eq!(EditOperation::MonoToStereo.display_name(), "Mono → Stereo"); assert_eq!(EditOperation::StereoToMono.display_name(), "Stereo → Mono"); } }