Files
MDMAchine-ComfyUI_MD_Nodes/tools/normalize_audio.py
T

437 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
## ████ Audio Normalizer & Converter v1.0.0
---
### ░▒▓ ORIGIN & DEV:
- **Cast into the void by:** MDMAchine
- **License:** Apache 2.0 _(No cursed floppy disks allowed)_
---
### ░▒▓ DESCRIPTION:
A **Command-Line Utility** designed with precision. Built for **batch processing audio files for normalization, conversion, and renaming**, it channels the chaos of demoscene nights. _May work with various audio formats, but don't expect mercy or miracles._
---
### ░▒▓ FEATURES:
- ✓ LUFS loudness normalization
- ✓ Supports multiple output audio formats (MP3, WAV, FLAC, AAC, OGG, OPUS)
- ✓ Custom output file naming (prefix/suffix)
- ✓ Dry run mode for testing without file modification
- ✓ Cross-platform compatibility
---
### ░▒▓ CHANGELOG:
#### v1.0 – Initial Release:
- Core logic implemented with LUFS normalization
- Basic stability ensured
- Ground-up optimization passes
#### vX.X – Optimization Drop:
- [Insert performance boosts or fixes]
- UI/UX tweaks
- Parameter matrix expanded
---
### ░▒▓ CONFIGURATION:
- **Primary Use:** Normalize audio to a target LUFS, convert formats, and rename files in bulk.
- **Secondary Use:** Prepare audio datasets for consistent loudness and format.
- **Edge Use:** _Process large, unverified audio libraries with custom settings._
---
### ░▒▓ WARNING:
This tool may trigger:
- ▓▒░ Temporal distortion
- ▓▒░ Memories of ANSI art & screaming modems
- ▓▒░ A sense of unstoppable creative power
"""
import os
import subprocess
import argparse
import datetime
from tqdm import tqdm
import platform
# --- ANSI Color Codes (for terminal output) ---
class Colors:
RESET = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
RED = '\033[91m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
BLUE = '\033[94m'
MAGENTA = '\033[95m'
CYAN = '\033[96m'
WHITE = '\033[97m'
# Attempt to enable ANSI escape codes for Windows cmd.exe
if platform.system() == "Windows":
try:
import ctypes
kernel32 = ctypes.WinDLL('kernel32')
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) # ENABLE_VIRTUAL_TERMINAL_PROCESSING
except Exception:
# Fallback if enabling fails
for attr in dir(Colors):
if not attr.startswith('__'):
setattr(Colors, attr, '')
# --- Script Header and Revisions ---
"""
Audio Processing Script: Normalize, Convert, and Rename
Revisions:
- 2024-05-20: Initial version with LUFS normalization.
- 2024-05-21: Added MP3 V0 conversion and metadata preservation.
- 2025-06-13: Added extensive comments, detailed help output, and revision history.
- 2025-06-13: Integrated --help command functionality with usage examples.
- 2025-06-13: Implemented flexible output naming (prefix/suffix), optional normalization,
optional MP3 V0 conversion, selectable output format, and dry-run mode.
- 2025-06-13: Enhanced terminal output with colors, emojis, progress bar, and summary.
- 2025-06-13: Added interactive startup menu for user-friendly configuration.
Description:
This script automates the processing of audio files. It can:
1. Normalize audio files to a target LUFS (Loudness Units Full Scale) level.
2. Convert audio files to a specified output format (defaulting to high-quality MP3 V0).
3. Preserve metadata (like artist, title, album) from the original files.
4. Offer flexible control over output filenames (custom prefixes/suffixes).
5. Provide a dry-run mode to preview actions without making changes.
The script processes all common audio file types found in a specified input folder and
saves the processed files to a designated output folder.
"""
def print_header(title):
"""Prints a styled header."""
print(f"\n{Colors.BLUE}{'='*70}{Colors.RESET}")
print(f"{Colors.BOLD}{Colors.CYAN}✨ {title.upper()} ✨{Colors.RESET}")
print(f"{Colors.BLUE}{'='*70}{Colors.RESET}")
def print_subheader(title):
"""Prints a styled sub-header."""
print(f"\n{Colors.MAGENTA}{'-'*len(title)}{Colors.RESET}")
print(f"{Colors.BOLD}{Colors.MAGENTA}{title}{Colors.RESET}")
print(f"{Colors.MAGENTA}{'-'*len(title)}{Colors.RESET}")
def print_footer(message):
"""Prints a styled footer."""
print(f"\n{Colors.BLUE}{'='*70}{Colors.RESET}")
print(f"{Colors.BOLD}{Colors.GREEN}{message}{Colors.RESET}")
print(f"{Colors.BLUE}{'='*70}{Colors.RESET}\n")
def process_audio_folder(input_folder, output_folder, target_lufs=-14.0,
apply_loudnorm=True, output_prefix="normalized_",
output_suffix="", output_format="mp3", dry_run=False):
"""
Processes audio files based on provided arguments: normalizes, converts, and renames.
"""
print_header("Audio Processing Start")
# --- Input Validation ---
if not os.path.isdir(input_folder):
print(f"{Colors.RED}❌ Error:{Colors.RESET} Input folder '{Colors.YELLOW}{input_folder}{Colors.RESET}' does not exist. Please check the path and try again.")
return
# --- Output Folder Creation ---
if dry_run:
print(f"{Colors.YELLOW}🚧 [DRY RUN]{Colors.RESET} Would create output folder '{Colors.CYAN}{output_folder}{Colors.RESET}' if it doesn't exist.")
else:
os.makedirs(output_folder, exist_ok=True)
print(f"📁 Output folder '{Colors.CYAN}{output_folder}{Colors.RESET}' ensured.")
# --- Supported Audio Extensions (FFmpeg can generally read these) ---
supported_extensions = ('.mp3', '.wav', '.flac', '.aac', '.m4a', '.ogg', '.opus', '.wma', '.aiff', '.aif')
# --- Collect files to process ---
files_to_process = []
for filename in os.listdir(input_folder):
if any(filename.lower().endswith(ext) for ext in supported_extensions):
files_to_process.append(filename)
if not files_to_process:
print(f"{Colors.YELLOW}⚠️ No supported audio files found in '{Colors.CYAN}{input_folder}{Colors.RESET}'. Exiting.{Colors.RESET}")
return
# --- Process Files in Folder with Progress Bar ---
processed_count = 0
failed_count = 0
skipped_count = 0 # For dry run
total_files = len(files_to_process)
print(f"\n🚀 Starting processing of {Colors.BOLD}{total_files}{Colors.RESET} files...")
# tqdm wraps the iterable to show a progress bar
for i, filename in enumerate(tqdm(files_to_process, desc="Overall Progress")):
input_filepath = os.path.join(input_folder, filename)
base_filename = os.path.splitext(filename)[0]
# --- Construct Output Filename ---
output_filename = f"{output_prefix}{base_filename}{output_suffix}.{output_format}"
output_filepath = os.path.join(output_folder, output_filename)
print(f"\n{Colors.BOLD}--- File {i+1}/{total_files}: {filename} ---{Colors.RESET}")
print(f" 📂 {Colors.BLUE}Input:{Colors.RESET} {input_filepath}")
print(f" 💾 {Colors.BLUE}Output:{Colors.RESET} {output_filepath}")
if dry_run:
print(f" {Colors.YELLOW}🚧 [DRY RUN]{Colors.RESET} This file would be processed.")
if apply_loudnorm:
print(f" 🔊 {Colors.YELLOW}[DRY RUN]{Colors.RESET} Would apply LUFS normalization to {Colors.BOLD}{target_lufs} LUFS{Colors.RESET}.")
else:
print(f" 🔇 {Colors.YELLOW}[DRY RUN]{Colors.RESET} Would SKIP LUFS normalization.")
print(f" 🎵 {Colors.YELLOW}[DRY RUN]{Colors.RESET} Would convert to '{Colors.BOLD}{output_format.upper()}{Colors.RESET}' format.")
print(f" 🏷️ {Colors.YELLOW}[DRY RUN]{Colors.RESET} Would preserve metadata.")
skipped_count += 1
continue # Skip actual processing in dry-run mode
try:
# --- Build FFmpeg Command ---
cmd_normalize_convert = [
'ffmpeg',
'-i', input_filepath,
'-map_metadata', '0', # Preserve all metadata from the input file
'-y', # Overwrite output files without asking
]
# Add loudness normalization if requested
if apply_loudnorm:
print(f" Applying {Colors.GREEN}LUFS normalization{Colors.RESET} to {Colors.BOLD}{target_lufs} LUFS{Colors.RESET}...")
cmd_normalize_convert.extend(['-filter_complex', f'loudnorm=I={target_lufs}:LRA=7:tp=-1'])
else:
print(f" {Colors.YELLOW}Skipping LUFS normalization.{Colors.RESET}")
# Add specific encoding parameters based on output_format
if output_format.lower() == 'mp3':
print(f" Converting to {Colors.GREEN}MP3 V0 (highest quality VBR){Colors.RESET}...")
cmd_normalize_convert.extend([
'-c:a', 'libmp3lame',
'-q:a', '0',
'-ar', '48000',
'-ac', '2',
])
elif output_format.lower() == 'wav':
print(f" Converting to {Colors.GREEN}WAV (PCM 16-bit){Colors.RESET}...")
cmd_normalize_convert.extend([
'-c:a', 'pcm_s16le',
'-ar', '48000',
'-ac', '2',
])
elif output_format.lower() == 'flac':
print(f" Converting to {Colors.GREEN}FLAC (lossless){Colors.RESET}...")
cmd_normalize_convert.extend([
'-c:a', 'flac',
'-compression_level', '8', # Max compression for FLAC
'-ar', '48000',
'-ac', '2',
])
elif output_format.lower() == 'aac':
print(f" Converting to {Colors.GREEN}AAC (high quality){Colors.RESET}...")
cmd_normalize_convert.extend([
'-c:a', 'aac',
'-b:a', '256k', # Bitrate for AAC (good quality)
'-ar', '48000',
'-ac', '2',
])
elif output_format.lower() == 'ogg':
print(f" Converting to {Colors.GREEN}Ogg Vorbis (high quality){Colors.RESET}...")
cmd_normalize_convert.extend([
'-c:a', 'libvorbis',
'-q:a', '6', # Quality 0-10, 6 is good balance
'-ar', '48000',
'-ac', '2',
])
elif output_format.lower() == 'opus':
print(f" Converting to {Colors.GREEN}Opus (high quality){Colors.RESET}...")
cmd_normalize_convert.extend([
'-c:a', 'libopus',
'-b:a', '128k', # Bitrate for Opus, highly efficient
'-ar', '48000',
'-ac', '2',
])
else:
print(f" {Colors.YELLOW}⚠️ Warning:{Colors.RESET} Output format '{output_format}' is not explicitly handled for quality. FFmpeg will use default for this format.")
cmd_normalize_convert.extend([
'-ar', '48000',
'-ac', '2',
])
cmd_normalize_convert.append(output_filepath) # Add the output file path at the end
# Execute FFmpeg Command
subprocess.run(cmd_normalize_convert, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) # Suppress FFmpeg's own verbose output for cleaner script output
print(f" ✅ {Colors.GREEN}Successfully processed{Colors.RESET} -> '{Colors.CYAN}{os.path.basename(output_filepath)}{Colors.RESET}'")
processed_count += 1
except subprocess.CalledProcessError as e:
print(f" ❌ {Colors.RED}Error processing '{filename}':{Colors.RESET} FFmpeg exited with code {e.returncode}")
# If you want to see FFmpeg's detailed error, uncomment lines below.
# print(f" FFmpeg stderr: {e.stderr.decode('utf-8', errors='ignore')}")
failed_count += 1
except FileNotFoundError:
print(f"\n{Colors.RED}❌ FATAL ERROR:{Colors.RESET} FFmpeg not found!")
print(f" Please ensure FFmpeg is installed and its executable is accessible in your system's PATH.")
print(f" You can download FFmpeg from {Colors.UNDERLINE}https://ffmpeg.org/download.html{Colors.RESET}")
return # Exit the function as FFmpeg is essential.
except Exception as e:
print(f" ❌ {Colors.RED}An unexpected error occurred with '{filename}':{Colors.RESET} {e}")
failed_count += 1
# --- Summary ---
print(f"\n{Colors.BOLD}{Colors.MAGENTA}--- Processing Summary ---{Colors.RESET}")
print(f" Files Found: {Colors.CYAN}{total_files}{Colors.RESET}")
if dry_run:
print(f" {Colors.YELLOW}Files to be processed (Dry Run): {skipped_count}{Colors.RESET}")
else:
print(f" {Colors.GREEN}Successfully Processed: {processed_count}{Colors.RESET}")
print(f" {Colors.RED}Failed to Process: {failed_count}{Colors.RESET}")
if dry_run:
print_footer(f"🚧 DRY RUN COMPLETE. No files were modified or created. 🚧")
else:
if failed_count == 0:
print_footer(f"🎉 ALL FILES PROCESSED SUCCESSFULLY! 🎉")
elif processed_count > 0:
print_footer(f"✅ PROCESSING COMPLETE WITH SOME ISSUES. Check errors above. ✅")
else:
print_footer(f"💔 PROCESSING FAILED FOR ALL FILES. Please check errors. 💔")
def interactive_mode():
"""Guides the user through setting options interactively."""
print_header("Interactive Setup")
input_folder = ""
while not os.path.isdir(input_folder):
input_folder = input(f"{Colors.BLUE}▶️ Enter the INPUT folder path (e.g., C:\\MyAudio): {Colors.RESET}").strip()
if not os.path.isdir(input_folder):
print(f"{Colors.RED}❌ Invalid path. Folder does not exist. Please try again.{Colors.RESET}")
output_folder = ""
while not output_folder:
output_folder = input(f"{Colors.BLUE}▶️ Enter the OUTPUT folder path (e.g., C:\\ProcessedAudio): {Colors.RESET}").strip()
if not output_folder:
print(f"{Colors.RED}❌ Output path cannot be empty. Please try again.{Colors.RESET}")
print_subheader("Normalization Settings")
target_lufs = -14.0
try:
lufs_input = input(f"{Colors.BLUE}▶️ Enter target LUFS (e.g., -14.0 for streaming, default: {target_lufs}): {Colors.RESET}").strip()
if lufs_input:
target_lufs = float(lufs_input)
except ValueError:
print(f"{Colors.YELLOW}⚠️ Invalid LUFS value. Using default: {target_lufs} LUFS.{Colors.RESET}")
apply_loudnorm_choice = input(f"{Colors.BLUE}▶️ Apply loudness normalization? (yes/no, default: yes): {Colors.RESET}").strip().lower()
apply_loudnorm = (apply_loudnorm_choice == 'yes' or apply_loudnorm_choice == '')
print_subheader("Output Format Settings")
output_formats = ['mp3', 'wav', 'flac', 'aac', 'ogg', 'opus']
output_format = "mp3"
print(f" Available formats: {Colors.CYAN}{', '.join(output_formats)}{Colors.RESET}")
format_input = input(f"{Colors.BLUE}▶️ Enter desired output format (default: {output_format}): {Colors.RESET}").strip().lower()
if format_input and format_input in output_formats:
output_format = format_input
elif format_input:
print(f"{Colors.YELLOW}⚠️ Invalid format '{format_input}'. Using default: {output_format}.{Colors.RESET}")
print_subheader("Output Filename Settings")
output_prefix = input(f"{Colors.BLUE}▶️ Enter output filename prefix (leave empty for no prefix, default: 'normalized_'): {Colors.RESET}").strip()
if output_prefix == "": # Allow explicit empty string
pass
elif not output_prefix: # If user just pressed enter, use default
output_prefix = "normalized_"
output_suffix = input(f"{Colors.BLUE}▶️ Enter output filename suffix (before extension, e.g., '_final', default: no suffix): {Colors.RESET}").strip()
print_subheader("Operation Mode")
dry_run_choice = input(f"{Colors.BLUE}▶️ Perform a dry run (simulate only)? (yes/no, default: no): {Colors.RESET}").strip().lower()
dry_run = (dry_run_choice == 'yes')
print_header("Configuration Summary")
print(f" {Colors.BOLD}Input Folder:{Colors.RESET} {Colors.CYAN}{input_folder}{Colors.RESET}")
print(f" {Colors.BOLD}Output Folder:{Colors.RESET} {Colors.CYAN}{output_folder}{Colors.RESET}")
print(f" {Colors.BOLD}Normalization:{Colors.RESET} {'Yes' if apply_loudnorm else 'No'}")
if apply_loudnorm:
print(f" {Colors.BOLD}Target LUFS:{Colors.RESET} {target_lufs}")
print(f" {Colors.BOLD}Output Format:{Colors.RESET} {output_format.upper()}")
print(f" {Colors.BOLD}Output Prefix:{Colors.RESET} '{output_prefix}'")
print(f" {Colors.BOLD}Output Suffix:{Colors.RESET} '{output_suffix}'")
print(f" {Colors.BOLD}Mode:{Colors.RESET} {'DRY RUN (No files will be changed)' if dry_run else 'LIVE RUN (Files will be processed)'}")
confirm = input(f"\n{Colors.YELLOW}Confirm and start processing with these settings? (yes/no): {Colors.RESET}").strip().lower()
if confirm == 'yes':
process_audio_folder(input_folder, output_folder, target_lufs,
apply_loudnorm, output_prefix, output_suffix,
output_format, dry_run)
else:
print(f"{Colors.RED}❌ Processing cancelled by user.{Colors.RESET}")
print_footer("Operation Cancelled")
# --- Main Execution Block ---
if __name__ == "__main__":
# Check if any command-line arguments (besides the script name itself) are provided
if len(os.sys.argv) > 1:
# If arguments are provided, use argparse (for power users)
parser = argparse.ArgumentParser(
description="""
✨ Audio Processing Script: Normalize, Convert, and Rename ✨
Normalize audio files to a target LUFS level, convert them to a specified format (default MP3 V0),
and preserve metadata. Offers flexible naming and processing options.
Uses FFmpeg. Ensure FFmpeg is installed and available in your system's PATH.
Usage Examples:
# Normalize all audio files in 'input_dir' to -14 LUFS, convert to MP3 V0,
# and save to 'output_dir' with 'normalized_' prefix (default behavior):
python your_script_name.py "input_dir" "output_dir"
# Normalize to -16 LUFS, convert to MP3 V0, save to 'output_dir', no prefix/suffix:
python your_script_name.py "input_dir" "output_dir" --target_lufs -16.0 --output_prefix "" --output_suffix ""
# Convert all audio files in 'input_dir' to WAV format (no normalization),
# save to 'output_dir' with 'converted_' prefix:
python your_script_name.py "input_dir" "output_dir" --no_normalize --output_format wav --output_prefix "converted_"
# Perform a dry run to see what would happen (no files created/modified):
python your_script_name.py "input_dir" "output_dir" --dry_run --output_prefix "preview_"
# Normalize to -18 LUFS, keep output as FLAC, add '_loud' suffix:
python your_script_name.py "input_dir" "output_dir" --target_lufs -18.0 --output_format flac --output_suffix "_loud"
""",
formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument("input_folder", help="Path to the folder containing audio files to be processed.")
parser.add_argument("output_folder", help="Path to the folder where processed audio files will be saved.")
parser.add_argument("--target_lufs", type=float, default=-14.0, help="""Target Integrated Loudness (LUFS) level for normalization. (Default: -14.0)""")
parser.add_argument("--no_normalize", action="store_true", help="Skip the LUFS normalization step.")
parser.add_argument("--output_format", type=str, default="mp3", choices=['mp3', 'wav', 'flac', 'aac', 'ogg', 'opus'], help="""Desired output audio format. (Default: mp3)""")
parser.add_argument("--output_prefix", type=str, default="normalized_", help="""Custom prefix for output filenames. Use "" for no prefix. (Default: "normalized_")""")
parser.add_argument("--output_suffix", type=str, default="", help="""Custom suffix for output filenames, placed before the extension. (Default: "")""")
parser.add_argument("--dry_run", action="store_true", help="""Simulate the process without actually creating or modifying any files.""")
args = parser.parse_args()
process_audio_folder(
args.input_folder,
args.output_folder,
target_lufs=args.target_lufs,
apply_loudnorm=not args.no_normalize,
output_prefix=args.output_prefix,
output_suffix=args.output_suffix,
output_format=args.output_format,
dry_run=args.dry_run
)
else:
# If no arguments, start the interactive mode
interactive_mode()