# measure # - integrated loudness # - peak # - DC offset # - crest factor import os import glob import resampy import argparse import numpy as np import pandas as pd import soundfile as sf import pyloudnorm as pyln import matplotlib.pyplot as plt from tqdm import tqdm from typing import List def get_stats( filepath: str, silence_threshold: float = 0.001, measure_true_peak: bool = False ): x, sample_rate = sf.read(filepath) # check duration is longer than 60 sec dur_sec = x.shape[0] / sample_rate if dur_sec < 60.0: print(f"Skipping {filepath} with duration = {dur_sec:0.2f} sec") return # check if energy is above threshold energy = np.mean(x**2) if energy < silence_threshold: print(f"Skipping {filepath} with energy = {energy:0.6f}") return meter = pyln.Meter(sample_rate) peak_lin = np.max(np.abs(x)) peak_db = 20 * np.log10(peak_lin + 1e-8) lufs_db = meter.integrated_loudness(x) dc_offset = np.mean(x) rms = np.sqrt(np.mean(x**2)) crest_factor = peak_lin / (rms + 1e-8) # for a fair comparision (loudness normalize to -16 dBLUFS and recompute) gain_lin = 10 ** ((-16.0 - lufs_db) / 20) x_norm = x * gain_lin peak_lin_norm = np.max(np.abs(x)) rms_norm = np.sqrt(np.mean(x_norm**2)) crest_factor_norm = peak_lin_norm / (rms_norm + 1e-8) # compute the compression factor by peak normalizing and then x_peak_norm = x / np.clip(peak_lin, a_min=1e-8, a_max=None) compression_factor = meter.integrated_loudness(x_peak_norm) # resample 4x to get true-peak (this is slow) if measure_true_peak: x_up = resampy.resample(x, sample_rate, int(4 * sample_rate), axis=0) true_peak_lin = np.max(np.abs(x_up)) true_peak_db = 20 * np.log10(true_peak_lin + 1e-8) if peak_lin > 1.0: print(filepath, peak_lin) result = { "filename": os.path.basename(filepath), "peak_lin": peak_lin, "peak_db": peak_db, # "true_peak_lin" : true_peak_lin, # "true_peak_db" : true_peak_db, "lufs_db": lufs_db, "dc_offset": dc_offset, "rms": rms, "crest_factor": crest_factor, "rms_norm": rms_norm, "crest_factor_norm": crest_factor_norm, "compression_factor": compression_factor, } return result def get_stats_from_directory(directory: str, output_dir: str = "outputs"): directory_name = os.path.basename(directory) print(f"Getting stats from audio files in {directory_name}...") # find all audio files filepaths = [] for ext in ["flac", "wav", "mp3", "ogg"]: filepaths += glob.glob(os.path.join(directory, f"*.{ext}")) results = {} for filepath in tqdm(filepaths): result = get_stats(filepath) if result is not None: for key, val in result.items(): if key not in results: results[key] = [] results[key].append(val) # create dataframe df = pd.DataFrame(results) # save to csv csv_filepath = os.path.join(output_dir, f"{directory_name}_stats.csv") df.to_csv(csv_filepath, index=False) def plot_stats_from_csv(filepaths: List[str], output_dir: str = "outputs"): dfs = {} for filepath in filepaths: filename = os.path.basename(filepath).replace(".csv", "") short_name = filename.split("_")[0] df = pd.read_csv(filepath) print(df.head()) dfs[short_name] = df # use the first df to get features skipped_features = ["filename", "crest_factor"] features = [ col for col in dfs[list(dfs.keys())[0]].columns if col not in skipped_features ] fig, axs = plt.subplots(nrows=4, ncols=2, figsize=(8, 10)) axs = np.reshape(axs, -1) feature_names = { "peak_lin": "Peak (linear)", "peak_db": "Peak (dB)", "lufs_db": "dB LUFS", "dc_offset": "DC Offset", "rms": "RMS", "rms_norm": "RMS (post-normalization)", "crest_factor_norm": "Crest Factor (post-normalization)", "compression_factor": "Compression Factor", } heights = [0.8, 0.7, 0.6, 0.5] for feature_idx, feature in enumerate(features): value_arrays = [] df_names = [] print(feature) for df_idx, (df_name, df) in enumerate(dfs.items()): values = np.array(df[feature].to_list()) # Create a boolean mask where the value is not -inf mask = np.isfinite(values) # Apply the mask to get a new array without -inf values values = values[mask] mean_values = np.mean(values) std_values = np.std(values) median_values = np.median(values) max_values = np.max(values) min_values = np.min(values) value_arrays.append(values) df_names.append(df_name) bins = np.histogram(np.hstack(value_arrays), bins=64)[1] # get the bin edges for df_idx, (df_name, value_array) in enumerate(zip(df_names, value_arrays)): stats_string = ( f"[{np.min(value_array):0.2f}, {np.max(value_array):0.2f}] {df_name} " ) print(feature) _, bins, _ = axs[feature_idx].hist( value_array, bins=bins, rwidth=1.0, label=stats_string, alpha=0.5, density=True, ) axs[feature_idx].set_xlabel(feature_names[feature]) axs[feature_idx].set_ylabel("Density") # axs[feature_idx].set_yscale("log") # axs[feature_idx].text( # 0.15, # heights[df_idx], # stats_string, # transform=axs[feature_idx].transAxes, # ) # axs[feature_idx].set_position( # [0.1, 0.1, 0.65, 0.8] # ) # left, bottom, width, height axs[feature_idx].legend() plt.tight_layout() plot_filepath = os.path.join(output_dir, f"stat_.png") plt.savefig(plot_filepath, dpi=300) if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--output_dir", default="outputs") parser.add_argument( "--analyze", help="Path to directory containing audio files to analyze", ) parser.add_argument( "--plot", nargs="+", help="List of paths to csv files containing statistics.", ) parser.add_argument("--max_files", default=1000) args = parser.parse_args() if args.analyze: get_stats_from_directory(args.analyze, args.output_dir) if args.plot: plot_stats_from_csv(args.plot, args.output_dir)