DOS batch script to perform volume normalization + conversion to MP3 (using ffmpeg)



  • This was enough of an endeavor that I figured I'd share it so maybe someone else might also benefit from it.

    teh codez
    @echo off
    setlocal enabledelayedexpansion
    
    ::by default, this batch file needs to be located in the same folder as ffmpeg.exe (typically the bin folder)
    ::otherwise, specify its full path, so that the batch file knows where the ffmpeg.exe binary is located:
    set "ffmpeg=%~dp0ffmpeg.exe"
    
    ::set the audio bitrate for output files (192 kbps)... if you want a different quality, you can change this value
    set "ab=192k"
    
    ::set the options to write ID3v1 and ID3v2.3 metadata, and to include album art in JPEG format
    set "metadata_opts=-id3v2_version 3 -write_id3v1 1 -vcodec mjpeg -q:v 3 -huffman optimal"
    
    if [%1] == [] echo Nothing to do. Did you forget something? & pause & exit /b
    
    :start
    echo %1
    
    ::by default, the destination file will have the same drive + path + name as the source (~dpn), but a .mp3 extension
    ::if the source file already has a .mp3 extension, however, then " (normalized)" will also be added to the name
    ::you can change these, but note that the .mp3 extension tells ffmpeg to encode the file as .mp3, so don't omit it
    set "output_file=%~dpn1.mp3"
    if /i "%~x1"==".mp3" set "output_file=%~dpn1 (normalized).mp3"
    
    ::if you'd like to instead place the output files in a "Normalized" subfolder, uncomment this line:
    ::set "output_file=%~dp1Normalized\%~n1.mp3"
    
    ::if the destination file already exists, it will be skipped... a message is printed, but the script doesn't pause
    if exist "!output_file!" echo "!output_file!" already exists. & goto continue
    
    if not exist "!output_file!\.." mkdir "!output_file!\.."
    if not exist "!output_file!\.." echo Unable to find or create the output location. & pause & goto continue
    
    ::to use the (significantly faster) volume filter, instead of loudnorm, uncomment out the following "goto volume" line
    ::goto volume
    
    ::volume produces the same audio you'd get by using Audacity's amplify filter for max amplification without clipping
    ::loudnorm implements the EBU R128 loudness normalization algorithm
    ::note that the volume filter should work on most versions, but loudnorm may require a fairly recent version of ffmpeg
    
    :loudnorm
    ::first pass - run ffmpeg and get stats, then loop through its output (stdout+stderr) line by line to parse the info
    set "loudnorm_opts=loudnorm=I=-16:dual_mono=true:TP=-1.5:LRA=11:print_format=summary"
    for /f "delims=" %%i in ('call "!ffmpeg!" -i %1 -filter:a !loudnorm_opts! -f null nul 2^>^&1') do (
      set "l=%%i"
      if "!l:~0,17!" == "Input Integrated:" set "i_i=!l:~20,6!"
      if "!l:~0,16!" == "Input True Peak:" set "i_tp=!l:~20,6!"
      if "!l:~0,10!" == "Input LRA:" set "i_lra=!l:~20,6!"
      if "!l:~0,16!" == "Input Threshold:" set "i_thr=!l:~20,6!"
      if "!l:~0,14!" == "Target Offset:" set "t_o=!l:~20,6!"
    )
    
    ::second pass - pass values obtained from the first pass back in again as arguments for loudnorm using a linear filter
    set "audio_filter=measured_I=!i_i!:measured_TP=!i_tp!:measured_LRA=!i_lra!:measured_thresh=!i_thr!:offset=!t_o!"
    set "audio_filter=loudnorm=I=-16:TP=-1.5:LRA=11:!audio_filter: =!:linear=true"
    goto second_pass
    
    :volume
    ::first pass - run ffmpeg with the volumedetect filter to analyze the audio and get the max volume (in decibels)
    for /f "delims=" %%i in ('call "!ffmpeg!" -i %1 -filter:a volumedetect -f null nul 2^>^&1') do (
      set "l=%%i"
      if "!l:~35,10!" == "max_volume" set "max_volume=!l:~47!"
    )
    
    ::negate max_volume to get the maximum amplification that can be applied to the audio without causing clipping
    set "max_volume=!max_volume: dB=!"
    if not "!max_volume:~0,1!" == "-" set "amplify=-!max_volume!"
    if "!max_volume:~0,1!" == "-" set "amplify=!max_volume:-=!"
    
    ::second pass - pass the negative of the value of max_volume back in as the amplification value for the volume filter
    set "audio_filter="volume=!amplify!dB""
    goto second_pass
    
    :second_pass
    echo ==^> "!output_file!"
    
    ::the ffmpeg output is suppressed... if you'd like to see it, remove ">nul 2>&1" from the command line
    "!ffmpeg!" -n -i %1 !metadata_opts! -filter:a !audio_filter! -ab !ab! "!output_file!" >nul 2>&1
    
    :continue
    ::shift the batch file's arguments and then see if there's anything left for us to do
    shift
    if not [%1] == [] goto start
    

    It should be pretty well internally documented. There are a handful of things that you can change if you want, but it should work as written if you save it as a .bat file in the same folder where ffmpeg.exe is located. Drag-and-drop files onto it, and it will perform volume normalization and create MP3 files in the same location as the source files.

    I've included two different loudness filters, one that uses ffmpeg's loudnorm filter (EBU R128 loudness normalization), and one that uses the volume filter. Note that the latter is significantly quicker. The default is loudnorm (there's a commented-out goto in the batch script that can be un-commented to use volume).

    Metadata and album artwork in original files should be preserved in the output... album art will be re-encoded in JPEG format (with the mjpeg codec) at a fairly high quality setting.

    If you have a bunch of audio files to process, running one instance may not utilize as much CPU (and therefore not be as fast) as running several instances in parallel.

    It doesn't like filenames with ! or ^ characters (and there's no error handling for this)... other than that, it should handle pretty much anything, I think, but don't push it too hard...

    There is probably some limit to how many files can be dropped onto the batch script at one time... this limitation would be due to the way that Windows passes the filenames to the script. It could be modified to process all of the MP3 files in a folder, instead, but :kneeling_warthog:

    Line 26, if un-commented, will change it so instead of placing the output files in the same folder as the source files, a Normalized subfolder will be created (if necessary) and the output files will be placed there instead. This will make it unnecessary for it to add (normalized) to make the output filenames unique. Just remove :: from the beginning of this line:

    ::set "output_file=%~dp1Normalized\%~n1.mp3"
    

    Finally, the version of ffmpeg that I've used was ffmpeg-4.1-win32-static. If you have problems, try either upgrading your version or switching to the older volume filter, and then it should probably work.


  • Trolleybus Mechanic

    Disappointing. I was actually hoping you were running this on DOS. I was going to ask "why DOS?".



  • @mikehurley Well, I guess technically I'd have to say that it runs in a Windows command prompt. I'd still refer to it as a DOS batch script, though, because that's where the batch scripting language originated.

    I'm not sure how far backward portable it'd be, though. It probably wouldn't even run on DOS.


  • Considered Harmful

    @anotherusername If you stubbed the dependencies I don't see why not, from what I remember of writing DOS batch scripts with menus 'n' shit back in the day.

    I still think it's wrong that they just won't let everyone use Unix, but oh well. When it becomes available in your universe maybe check it out, the scripting is less horrific


  • Trolleybus Mechanic

    @Gribnit said in DOS batch script to perform volume normalization + conversion to MP3 (using ffmpeg):

    @anotherusername If you stubbed the dependencies I don't see why not, from what I remember of writing DOS batch scripts with menus 'n' shit back in the day.

    I still think it's wrong that they just won't let everyone use Unix, but oh well. When it becomes available in your universe maybe check it out, the scripting is less horrific

    Or just use powershell. That's what I use for all my handy-to-have scripts at home.



  • @Gribnit The main things I'm not sure would port backward are:

    • for /f which executes a command, captures its output stream, tokenizes it by delimiters, and iterates through the output tokens
    • I don't know how much of the string/filename variable manipulation was supported back in the day

    Without the former, the output would probably need to be redirected to a temporary file, and then it might be possible to parse the file... as far as the second, working around it would depend on what exactly worked and what didn't.


  • Resident Tankie ☭

    I'd only suggest switching to VBR (in ffmpeg, if you want a ~192kbps target, it's -q:a=2, where 0 is maximum quality, practically indistinguishable from 320kbps CBR), unless the target is some old device (which will read the file correctly but might display time incorrectly, and in some cases, may skip silence - I have listening tests for my students that I have to manually pause during the 45 seconds or so of time given to read through the test because our devices just skip through them). Keep in mind that loudnorm does apply some dynamic audio compression (it's practically "transparent" but there is some) and, even though MP3 is not an archival format, it may provide undesirable results.



  • @admiral_p Thanks, I'll try that. It's -q:a 2 though. It didn't like -q:a=2 at all.



  • It's interesting how folks solve the same problems. I've been using MediaMonkey as my library-based music player for many years. It includes a flexible format conversion and normalization module to set up presets, per-device settings, and so on. I can just plug in my phone, right-click a playlist (or whatever) and pick "Sync to (phone name)".

    I do use a VBScript to add album and track Replay Gain tags to my ripped CDs. It's not all that interesting (one call to metaflac in the guts of a "loop through all the subfolders" script) and isn't well commented, but FWIW:

    Add Replay Gain to All.vbs
    Option Explicit
    
    Const scriptName = "Add Replay Gain To All"
    Const scriptVer = "1.1.0"
    
    Const errNeedPath = "To use this script, drop a folder onto the script icon."
    
    
    Dim gShellObj
    Set gShellObj = WScript.CreateObject("WScript.Shell")
    Dim gFolderCount
    gFolderCount = 0
    
    Main
    
    
    ' Customize this to do whatever you need it to do.
    Function DoTheWork(ByRef folderObj)
      Dim returnMe
      returnMe = True
      
      Dim filePaths
      filePaths = ""
    
      Dim fileObj, fileCollection
      Set fileCollection = folderObj.Files
      For Each fileObj in fileCollection
        If (StrComp(Right(fileObj.Name, 5), ".flac", 1) = 0) Then
          ' Do the thing (in this case, collect the paths for later)
          filePaths = filePaths & " " & fileObj.ShortPath
        End If
      Next
    
      If (Len(filePaths) > 0) Then
        'WScript.Echo("metaflac --add-replay-gain " & filePaths)
        Call gShellObj.Run("F:\Utils\flac\win32\metaflac --add-replay-gain " & filePaths, 1, True)
        gFolderCount = gFolderCount + 1
      End If
      
      DoTheWork = returnMe
    End Function
    
    
    ' Customize this to report something to the user.
    ' The default result is the top-level boolean from LoopWithin.
    Sub ReportResults(ByVal result)
      If (result) Then
        Call MsgBox("Updated " & CStr(gFolderCount) & " album" & Pluralize(gFolderCount) & ".", 0, scriptName)
      Else
        Call MsgBox("An error occurred while doing the work.", 0, scriptName)
      End If
    End Sub
    
    
    
    Function LoopWithin(ByRef folderObj, ByVal lookInSubdirs)
      Dim returnMe
      returnMe = True
       
      If returnMe And lookInSubdirs Then
        Dim subObj, subCollection
        Set subCollection = folderObj.SubFolders
        For Each subObj in subCollection
          returnMe = LoopWithin(subObj, lookInSubdirs)
        Next
      End If
    
      If returnMe then
        returnMe = DoTheWork(folderObj)
      End If
      
      LoopWithin = returnMe
    End Function
    
    
    Function Pluralize(ByVal count)
      If (count <> 1) Then
        Pluralize = "s"
      Else
        Pluralize = ""
      End If
    End Function
    
    
    Sub Main
      Dim i, args, continueOn
      continueOn = True
      
      Set args = WScript.Arguments
      If (args.Count <> 1) Then
        WScript.Echo(errNeedPath)
        continueOn = False
      End If
    
      If continueOn Then
        Dim fso, folderObj
        Set fso = CreateObject("Scripting.FileSystemObject")
        Set folderObj = fso.GetFolder(args(0))
    	ReportResults(LoopWithin(folderObj, True))
      End If
    End Sub