import os import math import glob import torch import torchaudio import numpy as np import pyloudnorm as pyln import pandas as pd from tqdm import tqdm from typing import List, Optional from suno_utils.utils.text import read_jsonl def compute_band_energy( waveform: torch.Tensor, sample_rate: int, n_fft: int = 4096 ) -> float: bands = {"bass": (20, 250), "mid": (250, 2500), "high": (2500, 20000)} # Convert to mono if stereo if waveform.shape[0] > 1: waveform = torch.mean(waveform, dim=0, keepdim=True) # Split into frames frame_length = n_fft hop_length = n_fft // 2 # 50% overlap frames = waveform.unfold(1, frame_length, hop_length) # Compute FFT for each frame spectrum = torch.fft.rfft(frames.squeeze(0)) # [num_frames, n_fft//2 + 1] freqs = torch.fft.rfftfreq(n_fft, d=1 / sample_rate) # [n_fft//2 + 1] # Compute magnitudes for each frame magnitudes = torch.abs(spectrum) # [num_frames, n_fft//2 + 1] # Compute centroid for each frame numerator = torch.sum( freqs.view(1, -1) * magnitudes, dim=1 ) # Sum over frequencies for each frame denominator = torch.sum(magnitudes, dim=1) results = [] # Compute mean centroid across all frames centroid = torch.mean(numerator / (denominator + 1e-8)) results.append(float(centroid)) for band_name, (low_freq, high_freq) in bands.items(): # Create frequency mask mask = (freqs >= low_freq) & (freqs <= high_freq) # They should now have the same size band_energy = torch.mean(magnitudes * mask) results.append(float(band_energy)) return results def calculate_stereo_width(waveform): # Split into left and right channels left = waveform[0] right = waveform[1] # Compute mid/side representation mid = (left + right) / 2 side = (left - right) / 2 # Compute RMS energy of mid and side channels mid_energy = torch.sqrt(torch.mean(mid**2)) side_energy = torch.sqrt(torch.mean(side**2)) # Compute stereo width based on mid/side ratio # Normalize to range 0-1 using sigmoid-like function width_ratio = (side_energy / (mid_energy + 1e-8)).item() stereo_width = 2 * (1 / (1 + np.exp(-width_ratio)) - 0.5) return stereo_width.item() def calculate_crest_factor(signal, window_size): """ Calculate RMS values for windows of audio data using PyTorch. Parameters: signal (torch.Tensor): Audio signal tensor window_size (int): Size of the window for RMS calculation Returns: torch.Tensor: Tensor of RMS values """ # Ensure input is a tensor if not isinstance(signal, torch.Tensor): signal = torch.tensor(signal, dtype=torch.float32) # convert to mono if stereo if signal.shape[0] > 1: signal = signal.mean(dim=0) # Calculate pad size pad_size = window_size - (len(signal) % window_size) if pad_size < window_size: # Use constant padding (default value is 0) padded_signal = torch.nn.functional.pad(signal, (0, pad_size)) else: padded_signal = signal # Reshape signal into windows using unfold # unfold(dimension, size, step) creates overlapping windows # here we use step=size to create non-overlapping windows windows = padded_signal.unfold(0, window_size, window_size) # Calculate RMS for each window # torch.mean along dim=1 averages across the window # keepdim=False reduces the dimension window_rms = torch.sqrt(torch.mean(windows**2, dim=1)) rms = torch.mean(window_rms).item() # get the peak value peak = torch.max(torch.abs(signal)).item() # compute crest factor crest_factor = peak / (rms + 1e-8) return np.log(crest_factor + 1e-8) import torch import torchaudio def measure_silence_percentage( waveform: torch.Tensor, sample_rate: int, silence_threshold_db: float = -60, window_size_ms: int = 100, ) -> float: """ Measures the percentage of audio that could be considered silent. Args: file_path: Path to audio file silence_threshold_db: RMS threshold in dB below which audio is considered silent window_size_ms: Size of analysis window in milliseconds Returns: Percentage (0-100) of audio that is below the silence threshold """ # Convert to mono if stereo if waveform.shape[0] > 1: waveform = torch.mean(waveform, dim=0, keepdim=True) # Calculate window size in samples window_size = int(sample_rate * window_size_ms / 1000) # Unfold the waveform into windows windows = waveform.unfold(1, window_size, window_size) # Calculate RMS for each window rms = torch.sqrt(torch.mean(windows**2, dim=2)) db = 20 * torch.log10(rms + 1e-10) # Calculate percentage of windows below threshold silence_percentage = 100 * torch.mean((db < silence_threshold_db).float()).item() return silence_percentage def measure_spectral_flatness(audio_tensor): """ Calculate spectral flatness (Wiener entropy) of the signal. Returns value between 0 (pure tone) and 1 (white noise). Parameters: audio_tensor: Input audio tensor of shape [..., samples] Returns: torch.Tensor: Spectral flatness value between 0 and 1 """ # Get spectrum magnitude audio_tensor = audio_tensor.mean(dim=0) spectrum = torch.abs(torch.fft.rfft(audio_tensor, dim=-1)) # Add small epsilon to avoid log(0) epsilon = 1e-8 spectrum = spectrum + epsilon # Calculate geometric mean and arithmetic mean log_spectrum = torch.log(spectrum) geometric_mean = torch.exp(torch.mean(log_spectrum, dim=-1)) arithmetic_mean = torch.mean(spectrum, dim=-1) # Compute flatness flatness = geometric_mean / (arithmetic_mean + 1e-8) return flatness.item() def download_audio(s3_filepath: str, example_id: str, tmp_dir: str): filename = os.path.basename(s3_filepath) out_filepath = os.path.join(tmp_dir, f"{example_id}-{filename}") # only download the file if its not already downloaded if not os.path.isfile(out_filepath): os.system(f"aws s3 cp {s3_filepath} {out_filepath} > /dev/null 2>&1") return out_filepath import subprocess from subprocess import TimeoutExpired import os def download_audio_with_timeout( s3_filepath: str, example_id: str, tmp_dir: str, timeout: int = 300 ): filename = os.path.basename(s3_filepath) # check if the file extension is mp3, mp4, m4a, wav, webm, or ogg if not filename.endswith((".mp3", ".mp4", ".m4a", ".wav", ".webm", ".ogg")): raise ValueError(f"Unsupported file extension: {filename}") out_filepath = os.path.join(tmp_dir, f"{example_id}-{filename}") if not os.path.isfile(out_filepath): try: subprocess.run( ["aws", "s3", "cp", s3_filepath, out_filepath], capture_output=True, timeout=timeout, check=True, ) except TimeoutExpired: if os.path.exists(out_filepath): os.remove(out_filepath) raise return out_filepath class AudioProductionFeatures(torch.utils.data.Dataset): def __init__( self, metas: List[dict], sample_rate: int = 48000, target_loudness: float = -16.0, tmp_dir: str = "/tmp/cjs", ): self.metas = metas self.sample_rate = sample_rate self.target_loudness = target_loudness self.meter = pyln.Meter(self.sample_rate) # we need to filter metas to only use ones with s3_filepath # but, we have to be careful to maintain the index of the original metadata filtered_metas = [] for meta_idx, meta in enumerate(metas): if "audio_filepath" in meta: meta["orig_idx"] = meta_idx filtered_metas.append(meta) elif "s3_filepath" in meta: meta["orig_idx"] = meta_idx meta["audio_filepath"] = meta["s3_filepath"] filtered_metas.append(meta) num_filtered = len(filtered_metas) num_original = len(metas) percent_remaining = (num_filtered / num_original) * 100 print( f"{num_filtered}/{num_original} ({percent_remaining:0.2f}%) examples have s3_filepath." ) self.metas = filtered_metas self.tmp_dir = tmp_dir os.makedirs(self.tmp_dir, exist_ok=True) def __len__(self): return len(self.metas) def __getitem__(self, idx): meta = self.metas[idx] # check if the file exists in the local directory local_filepath_dir = "/app2/suno/data/raw_audio_opus_v0" local_filepath = os.path.join(local_filepath_dir, meta["id"] + ".opus") if os.path.exists(local_filepath): filepath = local_filepath else: # check for filepath ("audio_filepath") if "audio_filepath" in meta: filepath = meta["audio_filepath"] elif "s3_filepath" in meta: filepath = meta["s3_filepath"] else: raise ValueError(f"No filepath found in meta: {meta}") try: filepath = download_audio_with_timeout( meta["audio_filepath"], meta["id"], self.tmp_dir ) except Exception as e: print(f"Error: {e}") print(f"Failed to download: {filepath}") return meta["id"], torch.tensor([np.nan] * 6) try: audio, sample_rate = torchaudio.load(filepath) # crop audio to max of 8min audio = audio[:, : int(8 * 60 * sample_rate)] # ensure audio is stereo if audio.size(0) == 1: audio = audio.repeat(2, 1) elif audio.size(0) > 2: audio = audio[:2, :] except Exception as e: print(f"Error: {e}") print(f"Failed to load: {filepath}") return meta["id"], torch.tensor([np.nan] * 6) # delete audio file os.remove(filepath) # resample if necessary if sample_rate != self.sample_rate: audio = torchaudio.functional.resample(audio, sample_rate, self.sample_rate) # loudness normalization try: loudness = self.meter.integrated_loudness(audio.permute(1, 0).numpy()) except Exception as e: print(f"Error: {e}") print(f"Failed to normalize loudness: {filepath}") return meta["id"], torch.tensor([np.nan] * 6) # check if loudness is -inf if loudness == -np.inf: loudness = -80.0 loudness_diff = self.target_loudness - loudness # limit the loudness difference to +/- 20 db loudness_diff = np.clip(loudness_diff, -20, 20) audio *= 10 ** (loudness_diff / 20.0) # this can cause problems # compute features with torch.no_grad(): results_spectral = compute_band_energy(audio, self.sample_rate) results_crest_factor = calculate_crest_factor(audio, 1024) results_stereo_width = calculate_stereo_width(audio) results_spectral_flatness = measure_spectral_flatness(audio) results_silence_percentage = measure_silence_percentage( audio, self.sample_rate ) results = results_spectral + [ results_crest_factor, results_stereo_width, results_spectral_flatness, results_silence_percentage, loudness, ] return meta["id"], torch.tensor(results) def collate_fn(batch): return batch def save_checkpoint( data: list, output_filepath: str, columns: list, tmp_suffix: str = ".tmp" ): """ Safely save data to CSV with temporary file to prevent corruption. Args: data: List of rows to save output_filepath: Path to save the CSV columns: Column names for the DataFrame tmp_suffix: Suffix for temporary file """ tmp_filepath = output_filepath + tmp_suffix df = pd.DataFrame(data, columns=columns) # Save to temporary file first df.to_csv(tmp_filepath, index=False) # If successful, rename to final filename os.replace(tmp_filepath, output_filepath) def load_existing_data(filepath: str) -> tuple[Optional[pd.DataFrame], set]: """ Load existing data and return DataFrame and set of processed IDs. Args: filepath: Path to the CSV file Returns: Tuple of (DataFrame or None, set of processed IDs) """ if os.path.exists(filepath): try: data = pd.read_csv(filepath) print(f"Loaded {len(data)} rows from {filepath}") return data, set(data["id"]) except Exception as e: print(f"Error loading existing data: {e}") # Backup corrupted file if os.path.exists(filepath): backup_path = filepath + ".corrupted" os.rename(filepath, backup_path) print(f"Moved potentially corrupted file to {backup_path}") return None, set() return None, set() if __name__ == "__main__": num_workers = 220 # metas_filepath = "/home/christian/code/christian/metadata/genius_hq_metas.jsonl" # output_filepath = "/home/christian/code/christian/metadata/genius_hq_audio_production_features_v2.csv" # metas_filepath = "/home/christian/code/christian/metadata/youtube_music_metas.jsonl" # output_filepath = "/home/christian/code/christian/metadata/youtube_music_audio_production_features_v2.csv" # metas_filepath = ( # "/home/christian/code/christian/metadata/v4/discogs_subset_metas_v0.jsonl" # ) metas_filepath = "/app/suno/tmp/clean_deezer_v0_metas.jsonl" output_filepath = "/home/christian/code/christian/metadata/v4/deezer_audio_production_features_v2.csv" checkpoint_interval = 10_000 # Save every 1000 samples # Create output directory if it doesn't exist output_dir = os.path.dirname(output_filepath) os.makedirs(output_dir, exist_ok=True) # Load metas metas = read_jsonl(metas_filepath) # Load existing data and get processed IDs existing_data, processed_ids = load_existing_data(output_filepath) # Filter metas to only include unprocessed items metas = [meta for meta in metas if meta["id"] not in processed_ids] print( f"Found {len(metas)} unprocessed items out of {len(processed_ids)} existing items" ) # Define columns for consistency columns = [ "id", "spectral_centroid", "bass", "mid", "high", "crest_factor", "stereo_width", "spectral_flatness", "silence_percentage", "loudness", ] # Initialize dataset and dataloader dataset = AudioProductionFeatures(metas, sample_rate=48000, target_loudness=-16.0) dataloader = torch.utils.data.DataLoader( dataset, batch_size=1, shuffle=False, num_workers=num_workers, persistent_workers=True, ) # Initialize new data list new_data = [] try: for meta_id, results in tqdm(dataloader): result_list = results[0].tolist() new_data.append([meta_id[0]] + result_list) # Save checkpoint at regular intervals if len(new_data) % checkpoint_interval == 0: # If we have existing data, combine it with new data if existing_data is not None: combined_data = pd.concat( [existing_data, pd.DataFrame(new_data, columns=columns)], ignore_index=True, ) combined_data.to_csv(output_filepath, index=False) else: save_checkpoint(new_data, output_filepath, columns) print(f"Saved checkpoint with {len(new_data)} new samples") except KeyboardInterrupt: print("\nInterrupted by user. Saving final checkpoint...") except Exception as e: print(f"\nEncountered error: {e}") print("Attempting to save final checkpoint...") finally: # Save final checkpoint if there's any unsaved data if new_data: if existing_data is not None: combined_data = pd.concat( [existing_data, pd.DataFrame(new_data, columns=columns)], ignore_index=True, ) combined_data.to_csv(output_filepath, index=False) else: save_checkpoint(new_data, output_filepath, columns) print(f"Saved final checkpoint with {len(new_data)} new samples")