Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 3 additions & 11 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "analwave"
version = "0.4.0"
version = "0.5.0"
edition = "2024"
default-run = "analwave"

Expand All @@ -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"
2 changes: 1 addition & 1 deletion src/analysers.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/analysers/fft.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ pub struct FftAnalyser {
vis: Option<FftVisualizer>,
}

/** 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<i32>, path: Option<PathBuf>) -> Self {
let channels = wav.n_channels() as usize;
Expand Down Expand Up @@ -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;
};
Expand Down
115 changes: 115 additions & 0 deletions src/analysers/peaks.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<f64>>,
}

/** 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<i32>, 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<i32>) {
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
}
}
8 changes: 8 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,12 @@ pub struct Cli {
/// Visualize the FFT output to the given file
#[arg(long)]
pub fft_vis: Option<String>,

/// Track peaks to file
#[arg(short, long, default_value_t = false)]
pub peaks: bool,

/// Peaks output file (defaults to <json_file>_peaks.png)
#[arg(long)]
pub peaks_file: Option<String>,
}
55 changes: 39 additions & 16 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,35 @@ 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;
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<String>,
file: &Option<String>,
suffix: &str,
) -> Option<PathBuf> {
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<i32>) -> Result<u8, ()> {
let mut return_code = 0;

Expand All @@ -27,23 +48,10 @@ fn analyse(args: &Cli, wav: &mut Wav<i32>) -> Result<u8, ()> {
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() {
Expand All @@ -56,6 +64,22 @@ fn analyse(args: &Cli, wav: &mut Wav<i32>) -> Result<u8, ()> {
}
}

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(());
Expand All @@ -77,7 +101,6 @@ fn analyse(args: &Cli, wav: &mut Wav<i32>) -> Result<u8, ()> {
output!("[+] underrun threshold: {} samples", &args.samples);
}

#[cfg(feature = "fft")]
if args.fft || args.fft_vis.is_some() {
output!("[+] FFT bins: {}", &args.fft_bins);
}
Expand Down