From 60119412cb724a35c110647c71368076b5b6936e Mon Sep 17 00:00:00 2001 From: vonforum Date: Sat, 20 Dec 2025 10:30:26 +0200 Subject: [PATCH] Add peaks analysis --- Cargo.lock | 2 +- Cargo.toml | 14 ++--- src/analysers.rs | 2 +- src/analysers/fft.rs | 4 +- src/analysers/peaks.rs | 115 +++++++++++++++++++++++++++++++++++++++++ src/cli.rs | 8 +++ src/main.rs | 55 ++++++++++++++------ 7 files changed, 169 insertions(+), 31 deletions(-) create mode 100644 src/analysers/peaks.rs diff --git a/Cargo.lock b/Cargo.lock index 5c254c8..1667931 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "analwave" -version = "0.4.0" +version = "0.5.0" dependencies = [ "aus", "clap", diff --git a/Cargo.toml b/Cargo.toml index 4172836..4ac3f5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "analwave" -version = "0.4.0" +version = "0.5.0" edition = "2024" default-run = "analwave" @@ -11,13 +11,5 @@ indicatif = "0.18.0" wavers = "1.5.1" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" -aus = { version = "0.1.8", optional = true } -png = { version = "0.18.0", optional = true } - -[features] -default = ["fft"] -fft = ["dep:aus", "dep:png"] - -[[bin]] -name = "fft-vis" -required-features = ["fft"] +aus = "0.1.8" +png = "0.18.0" diff --git a/src/analysers.rs b/src/analysers.rs index 4069607..b199eb7 100644 --- a/src/analysers.rs +++ b/src/analysers.rs @@ -1,8 +1,8 @@ use wavers::Samples; -#[cfg(feature = "fft")] pub mod fft; pub mod loudness; +pub mod peaks; pub mod underruns; pub trait Analyser { diff --git a/src/analysers/fft.rs b/src/analysers/fft.rs index 5657f91..b8be3f6 100644 --- a/src/analysers/fft.rs +++ b/src/analysers/fft.rs @@ -153,7 +153,7 @@ pub struct FftAnalyser { vis: Option, } -/** Writes FFT results to a .png file as little-endian raw bytes. */ +/** Writes FFT results to a .png file as little-endian raw f64s. */ impl FftAnalyser { pub fn new(args: &Cli, wav: &Wav, path: Option) -> Self { let channels = wav.n_channels() as usize; @@ -246,7 +246,7 @@ impl Analyser for FftAnalyser { }; let Ok(_) = writer.write_image_data(&raw.results) else { - println!("Could not write FFT image data"); + println!("FFT: Could not write image data"); return 0; }; diff --git a/src/analysers/peaks.rs b/src/analysers/peaks.rs new file mode 100644 index 0000000..9ecac3d --- /dev/null +++ b/src/analysers/peaks.rs @@ -0,0 +1,115 @@ +use std::{fs::File, io::BufWriter, path::PathBuf}; + +use aus::analysis::dbfs; +use png::{BitDepth, ColorType, Encoder}; +use wavers::{Samples, Wav}; + +use crate::{analysers::Analyser, cli::Cli}; + +pub struct PeaksAnalyzer { + channels: usize, + path: PathBuf, + peaks: Vec>, +} + +/** Writes peaks to a .png file as little-endian raw f64s. +Each channel is written as a square with dimensions ⌈√(sample count)⌉² and padded with f64::NEG_INFINITY. */ +impl PeaksAnalyzer { + pub fn new(_args: &Cli, wav: &Wav, path: PathBuf) -> Self { + let channels = wav.n_channels() as usize; + + Self { + channels, + path, + peaks: vec![vec![]; channels], + } + } +} + +impl Analyser for PeaksAnalyzer { + fn analyse(&mut self, _label: &str, _frame_counter: usize, frame: &Samples) { + for (channel, sample) in frame.iter().enumerate() { + self.peaks[channel].push(dbfs(*sample as f64, 1e-20)); + } + } + + fn finish(&mut self, _label: &str) -> u8 { + if self.peaks.is_empty() { + return 0; + } + + let Ok(file) = File::create(&self.path) else { + println!( + "Peaks: Could not create output file at {}", + self.path.display() + ); + + return 0; + }; + + let mut results = vec![]; + + for channel in &self.peaks { + for peak in channel { + results.extend(peak.to_le_bytes()); + } + + let num_peaks = channel.len(); + let sqrt = (num_peaks as f64).sqrt(); + let width = sqrt.ceil() as u32; + let height = sqrt.ceil() as u32; + + // Pad the image to a square shape + for _ in 0..(width * height - num_peaks as u32) { + results.extend(f64::NEG_INFINITY.to_le_bytes()); + } + } + + let sqrt = (self.peaks[0].len() as f64).sqrt(); + let width = sqrt.ceil() as u32; + let height = sqrt.ceil() as u32 * self.channels as u32; + + let mut w = BufWriter::new(file); + let mut encoder = Encoder::new(&mut w, width, height); + encoder.set_color(ColorType::Rgba); + encoder.set_depth(BitDepth::Sixteen); + + let Ok(mut writer) = encoder.write_header() else { + println!("Peaks: Could not write PNG header"); + + return 0; + }; + + let Ok(_) = writer.write_image_data(&results) else { + println!("Peaks: Could not write image data"); + + return 0; + }; + + 0 + } + + fn json(&self) -> Vec<(String, serde_json::Value)> { + let mut results = vec![]; + + if let Ok(path) = self.path.canonicalize() + && self.peaks.len() > 0 + { + let path = path.to_string_lossy().to_string(); + let channel_size = self.peaks[0].len(); + let w = (channel_size as f64).sqrt().ceil() as u32; + let squared_size = w * w; + let padding = squared_size - channel_size as u32; + + let json = serde_json::json!({ + "output": path, + "channelSize": channel_size, + "squareSize": squared_size, + "padding": padding, + }); + results.push(("peaks".to_string(), json)); + } + + results + } +} diff --git a/src/cli.rs b/src/cli.rs index 12832b2..96a1d09 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -66,4 +66,12 @@ pub struct Cli { /// Visualize the FFT output to the given file #[arg(long)] pub fft_vis: Option, + + /// Track peaks to file + #[arg(short, long, default_value_t = false)] + pub peaks: bool, + + /// Peaks output file (defaults to _peaks.png) + #[arg(long)] + pub peaks_file: Option, } diff --git a/src/main.rs b/src/main.rs index 08d02d9..c7f8c6c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,8 @@ use std::process::ExitCode; use wavers::{Wav, WaversResult}; use analwave::analysers::{ - Analyser, fft::FftAnalyser, loudness::LoudnessAnalyser, underruns::UnderrunAnalyser, + Analyser, fft::FftAnalyser, loudness::LoudnessAnalyser, peaks::PeaksAnalyzer, + underruns::UnderrunAnalyser, }; use analwave::cli::Cli; use analwave::output; @@ -12,6 +13,26 @@ use analwave::output::{fmt_frame, init_output}; use analwave::json::write_json; +/// Set png output path to either the provided PNG file path, +/// or derive it from the JSON output path. +fn calculate_png_path( + json: &Option, + file: &Option, + suffix: &str, +) -> Option { + if let Some(file) = file { + Some(PathBuf::from(file)) + } else if let Some(json) = json { + let mut path = PathBuf::from(json); + let name = path.file_stem().unwrap().to_string_lossy(); + path.set_file_name(format!("{name}_{suffix}.png")); + + Some(path) + } else { + None + } +} + fn analyse(args: &Cli, wav: &mut Wav) -> Result { let mut return_code = 0; @@ -27,23 +48,10 @@ fn analyse(args: &Cli, wav: &mut Wav) -> Result { analysers.push(Box::new(UnderrunAnalyser::new(args, wav))); } - #[cfg(feature = "fft")] if args.fft || args.fft_vis.is_some() { let mut path = None; if args.fft { - // Set FFT output path to either the provided FFT file path, - // or derive it from the JSON output path. - path = if let Some(file) = args.fft_file.as_ref() { - Some(PathBuf::from(file)) - } else if let Some(json) = args.json.as_ref() { - let mut path = PathBuf::from(json); - let name = path.file_stem().unwrap().to_string_lossy(); - path.set_file_name(format!("{name}_fft.png")); - - Some(path) - } else { - None - }; + path = calculate_png_path(&args.json, &args.fft_file, "fft"); } if args.fft && path.is_none() { @@ -56,6 +64,22 @@ fn analyse(args: &Cli, wav: &mut Wav) -> Result { } } + if args.peaks { + let mut path = None; + if args.peaks { + path = calculate_png_path(&args.json, &args.peaks_file, "peaks"); + } + + if let Some(path) = path { + analysers.push(Box::new(PeaksAnalyzer::new(args, wav, path))); + } else { + println!( + "Peaks output was enabled but no path could be determined, please provide --peaks-file or --json" + ); + return Err(()); + } + } + if analysers.is_empty() { println!("No detection is active, exiting."); return Err(()); @@ -77,7 +101,6 @@ fn analyse(args: &Cli, wav: &mut Wav) -> Result { output!("[+] underrun threshold: {} samples", &args.samples); } - #[cfg(feature = "fft")] if args.fft || args.fft_vis.is_some() { output!("[+] FFT bins: {}", &args.fft_bins); }