Simple TCL script that uses 'ffmpeg' to normalize mp3 audio levels

normalize.tcl is a simple TCL script that drives 'ffmpeg' to normalise audio levels for a group of mp3 files.

For each mp3 file found into a directory, it reads the mean volume level, calculates the average level among various files and adjusts the volume level of each of them.

#!/bin/sh
#\
exec tclsh "$0" ${1+"$@"}

# Copyright 2015 Tholis Biroi (tholis DOT biroi AT yahoo DOT it)
#
# This file is part of 'normalize.tcl'.
# 'normalize.tcl' is free software: you can redistribute it and/or modify 
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or 
# (at your option) any later version.
# 'normalize.tcl' is distributed in the hope that it will be useful, but 
# WITHOUT ANY WARRANTY; without even the implied warranty of 
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 
#
# See the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License 
# along with 'normalize.tcl'. If not, see http://www.gnu.org/licenses/.
#
#
#
# 'normalize.tcl' is a simple TCL script that drives 'ffmpeg' to normalise
# audio levels for a group of mp3 files.
# For each mp3 file found into a directory, it reads the mean volume level, 
# calculates the average level among various files and adjusts the volume
# level of each of them.
#

# Global debugging variable
# To override this option set '-d' on the command line
set debugOn false

# log --
#
# puts "" wrapper 
#
proc log {label args} {
    global debugOn

    switch $label {
        "info" {
            puts {*}$args
        }
        "error" {
            puts "error: [join {*}$args]" 
        }
        "warning" {
            puts "warning: [join {*}$args]" 
        }
        "debug" {
            if {$debugOn == "true"} {
                puts "debug: [join {*}$args]" 
            }
        }
        default {
            # do nothing in any case
        }
    }
}


# get_volumes --
#
# Exec 'ffmpeg' in order to get the volume mean level
#
#
proc get_volume {mp3} {
    if {$mp3 == {}} {
        log error "Empty file name."
        return {}
    }

    # Set volume variable
    set volume {}

    # Set 'ffmpeg' command
    set cmd "ffmpeg  -i \"$mp3\" -af \"volumedetect\" -f null /dev/null"    
    log debug "'ffmpeg' cmd= $cmd"

    # Exec 'ffmpeg'
    if {[catch {eval exec -ignorestderr $cmd 2>@1} out]} {
        log error "'ffmpeg' execution command failed."
        log debug "reason= $out"
        return {}
    }

    # In order to avoid 'case sensitive' parsing, the output of the 
    # command is converted to uppercase
    set Out [string toupper $out]    
    log debug "'ffmpeg' out= $Out"

    # Now scan the out a line at time searching for 'MEAN_VOLUME:' 
    # output string label
    set lines [split $Out "\n"]
    
    foreach line $lines {
        log debug "$line"
        # first of all search for 'VOLUMEDETECT' string and if foud
        # search for 'MEAN_VOLUME:' string.
        if {[string first VOLUMEDETECT $line] == -1} {
            # Not found, skip line parsing 
            continue
        }

        # 'VOLUMEDETECT' string found, search for 'MEAN_VOLUME' string
        set pos [string first MEAN_VOLUME $line]
        if { $pos != -1} {
            set start [expr {$pos + 11}]
            set volStr [string range $line $start end]
            log debug "volStr= $volStr"
            
            # Extract and trim the first word as volume
            set words [split $volStr]
            log debug "words= $words"
            set volume [string trim [lindex $words 1]]
            log debug "volume= $volume"
        }
    }

    return $volume
} ;# end get_volume


# set_volume --
#
# Exec 'ffmpeg' to re-encode the mp3
#
proc set_volume {mp3 actualVol targetVol} {
    if {($mp3 == {}) || ($actualVol == {}) || ($actualVol == {})} {
        log error "One or more parameter are empty"
        return {}
    }

    # Create filename output
    set mp3Root [file rootname $mp3]
    set mp3OutFile "${mp3Root}.norm.mp3"

    # If normalized file already exists, will be deleted
    if {[file exists $mp3OutFile]} {
        catch {file delete -force -- $mp3OutFile}
    }

    # calculate the delta volume
    set deltaVol [expr {$targetVol - $actualVol}]

    # Set 'ffmpeg' command
    set cmd "ffmpeg -y -i \"$mp3\"  -af \"volume=${deltaVol}dB\" \"$mp3OutFile\""
    
    # Exec 'ffmpeg'
    if {[catch {eval exec -ignorestderr $cmd 2>@1} out]} {
        log error "'ffmpeg' execution command failed."
        log debug "reason= $out"
        return {}
    }   
    
    # For debug purposes
    set Out [string toupper $out]    
    log debug "'ffmpeg' out= $Out"

    return $deltaVol
} ;# end set_volume


# byebye --
#
proc byebye {} {
    log info ""
    log info "Bye!"
    log info ""
} ;# end byebye


# print_help --
# 
# Prints a little help
#
proc print_help {} {
    global argv0

    log info ""
    log info "Usage: $argv0 \[-h|--help\]|<mp3 dir>"
    log info ""
    log info "-h|--help  Print thi help"
    log info "<mp3 dir>  Directory path containing mp3 to normalize"
    log info ""
    
    byebye
    return
} ;# end print_help

# Main -----------------------------------------------------------------
log info ""
log info "MP3 normalizer v0.9 21 mar 2015"
log info ""
log info ""

# Save current dir
set currDir [pwd]
log debug "Working dir= $currDir"

# Control input parameters to setup working dir 
# If no parameter is passed a little help is printed on screen
if {$argc == 0} {
    print_help
    exit 0
}

# If more than one parameter is passed
if {$argc != 1} {
    log error "Wrong number of arguments."
    log error "Use '-h' or '--help' option to print usage info."
    
    byebye
    exit 1
}

# If only one paramter is passed, it could be the help option or the
# desired working path
if {([lindex $argv 0] == "-h") || ([lindex $argv 0] == "--help")} {
    print_help
    exit 0
}

# Save the passed workDir in order to make some controls
set workDir [lindex $argv 0]

# The path passed must be a directory path
if { ![file isdirectory $workDir] } {
    log error "The argument passed is not a valid directory path"

    byebye
    exit 1
}

# The argument passed must be an existing directory
if { ![file exists $workDir] } {
    log error "Directory '$workDir' does not exists"

    byebye
    exit 1
}

# Move on working dir
cd $workDir

# Get the list of files in the current directory
set mp3Files [glob -nocomplain *.mp3]

if {$mp3Files == {}} {
    log info "No .mp3 files found on working dir: '$workDir'"
    
    byebye
    exit 1
}

# Exclude from this list files with exetension *.norm.mp3"
set mp3FileList {}
foreach mp3 $mp3Files {
    set rootFname [file rootname $mp3]
    set ext [file extension $rootFname]

    if {$ext == ".norm"} {
        # Skip already normalized files from mp3 list
        continue
    }

    lappend mp3FileList $mp3
}


# Init the mp3 array
#set mp3Ar {}

log info "List of file mp3 to be normalized:"

# Foreach *.mp3 file 
foreach mp3 $mp3FileList {
    log info "   '$mp3'"

    # Extract volumes
    set vol [get_volume $mp3]
    if {$vol == {}} {
        log warning "No volume information found for file: $mp3"
    } else {
        # Fill the array of volumes
        set mp3Ar($mp3) $vol
    }
}

log info ""

# parray only for debugging
#parray mp3Ar

# Calculating the average volume
set avgVol 0
set mp3List  [array names mp3Ar]
set numFiles [llength $mp3List]

foreach mp3 $mp3List {
    set avgVol [expr {$mp3Ar($mp3) + $avgVol}]
}
set avgVolume [expr {$avgVol/double($numFiles)}]
set avgVol [format "%0.1f" $avgVolume]

log info "Avg Volume= $avgVol"
log info ""

# Now foreach file calculate delta volume to normalize it
log info "File normalization at $avgVol dB"
foreach mp3 $mp3List {
    log info "    '$mp3' from $mp3Ar($mp3) to $avgVol"
    if {[set_volume $mp3 $mp3Ar($mp3) $avgVol] == {}} {
        log info "warning: Set volume failed for file '$mp3'"
    }
}
log info ""
log info "Done."

# Before exit return to run dir
cd $currDir

byebye

exit 0