I think we can all agree that every one of us likes to listen to some music while driving somewhere. My brother and I ride together several times per week in his 2011 Suzuki Swift. It’s a nice car that has built-in mp3 player which can play files from USB stick. We like to pick out the music we listen, so we started making our playlist some time ago. The whole list can be best described to wider audiences as ‘World Music’ (Cuban Salsa, Timba, Son, Rumba, for those of you who want to know), and many of those tracks, especially older ones, can only be found on youtube. Some of the tracks I downloaded using various online youtube-to-mp3 converters, some of them i downloaded with the help of youtube-dl, and some I additionally opened with audacity, trimmed the beginning, end, or amplified the track if it was too quiet, and then saved it as mp3. I know, lossy-to-lossy conversion brings sound quality down even further, but since it’s for a stock car stereo it wasn’t that important. Recently we noticed that the player skipped some of the tracks that are located on the usb. I started to analyze why was that happening and soon inferred that the tracks i obtained through online youtube converter were skipping, and those I manipulated with audacity were all playing fine. I then recalled that Audacity uses LAME MP3 Encoder, and when I further analyzed other mp3 files with ffmpeg and found out they are encoded using LAVC codec. I continued to investigate further and found out that it was not uncommon for older dedicated mp3 players to have problems playing tracks encoded with newer encoders. I now just had to convert all the files to mp3 using lame encoder.
Since there are more than 1000 tracks on our list, I couldn’t manually inspect & convert each one, so I needed to automate the process. This could probably be done with bash scripting, but I don’t know bash that well and I didn’t want to search the web & tweak the code I find online until it works somehow. I needed another solution, and the language I feel most comfortable with is Clojure. Another great thing with Clojure is that it has Babashka which is useful because it doesn’t have JVM startup penalty and that makes it ideal for scripting. And I even found this ffmpeg clojure wrapper which lets me call
ffprobe shell commands from clojure. I know that native library like ffmpeg javacpp presets would be a better solution, but that’s probably overkill, as the wrapper is good enough. I experimented a little bit and did the following:
- Wrote a function to get the encoder which is used to make mp3 file
1 2 (defn get-enc [file] (:encoder (:tags (first (:streams (ffprobe! [:show_streams file]))))))
- Another function to read the source file bitrate, since I wanted every file to be as close to original as possible.
1 2 (defn get-bitrate [file] (subs (:bit_rate (first (:streams (ffprobe! [:show_streams file])))) 0 3))
- Then made a function to get all files with mp3 and m4a extensions from the directory and put their names in lazyseq. For this i used
1 2 3 (defn get-files [dir] (filter (fn [x] (or (st/includes? x ".mp3") (st/includes? x ".m4a"))) (st/split-lines (:out (sh "ls" dir)))))
- Finally made a
convertfunction to operate on single file, with plan to use it with clojure’s
mapfunction to operate on all files:
1 2 3 4 5 6 7 8 (defn convert [file] (with-open [task (ffmpeg! [:y :i file :b (get-bitrate file) :acodec "libmp3lame" (str "converted_" file])] (.wait-for task) (.stdout task)))
But it wasn’t easy as that. When I took a look at the converted file, the encoder was still Lavc, not Lame. I verified that the problem lies in ffmpeg itself by calling the program directly from terminal with the same options. I even tried decoding it to wav first, then encoding it again with lame (libmp3lame option should encode the file using lame, but it doesn’t do so), but the result was still the same (I tested the file on car mp3 player, skipped as usual). After some searching online I found out that I was not the only one with that problem and that ffmpeg “sometimes overrides certain flags”. I’m not sure if that is true, but definitely didn’t know what to do next… except to decode the file to wav, then pipe it into
lame in terminal to make sure that proper encoder will be applied. And voilà, it worked!
Calling Shell From Babashka
Now I just had to call lame program from my babashka script. Since ffclj (aforementioned clojure ffmpeg wrapper) was only for ffmpeg, I decided I will combine it with clojure.java.shell call to lame. I could’ve probably written expansion for ffclj, but I figured the shell call was good enough, since it was pretty simple encoding with almost no options included. So my final convert function looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 (defn convert [file] (do (with-open [task (ffmpeg! [:y :i (str cwd file) :f "wav" (str dest (rem-ext file))])] (.wait-for task) (.stdout task)) (let [bitrate (get-bitrate file) input (str dest (rem-ext file)) output (str input ".mp3")] (sh "lame" "--quiet" input "-b" bitrate output) (sh "rm" input))))
Let me digest:
- I introduced two vars:
(def cwd "/path/to/source/mp3s/")and
(def dest (str cwd "lame/"). The idea is that I would define the current working directory where the files should be read and destination directory which is a subdirectory of cwd named lame. This is because I want to keep script in one place and to easily switch dir where I want to convert the files from (or I am actually lazy and don’t want to have to copy the script to source files dir every time I have to convert something).
- I wrote a
rem-extfunction, so the temporary decoded wav file wouldn’t have an extension. This made it simple for me to append .mp3 later:
1 2 (defn rem-ext [file] (st/replace file #".mp3|.m4a" ""))
- Finally, the convert function was expanded so it takes absolute file path, so that it doesn’t matter where the actual program is run from. The
:f "wav"tells ffmpeg to decode file to wav using default preset. Next line tells it to save the file as “/sourcedir/lame/file-withuout-extension”. When that task finishes the local vars are set:
bitratestores the source file bitrate, so the output file will have the same bitrate, not lower which will bring down it’s quality even further, or higher which will only result in bigger file size for no purpose.
inputstores absolute path for our decoded wav file, and
outputis the same as input with .mp3 appended. Those vars are passed to lame shell call thorugh clojure.java.shell/sh function and, when the conversion finishes, the final
shcall removes the temporary wav file.
Bulk Converting All Files
Since I don’t want to touch files that are already encoded with lame, I wrote a function which will filter only files which are encoded with anything but lame:
1 2 (defn get-non-lame-files [flist] (filter (fn [x] (not (st/includes? (st/lower-case (str (get-enc x))) "lame"))) flist))
(str (get-enc x)) is there because some files (mostly .m4a) return
nil as their encoder, so calling
nil will give us NullPointerException. Another thing that I needed to do is to create yet another function (It’s the last one, I promise) to make “lame” directory if it doesn’t exist:
1 2 3 4 (defn mkdir  (if (= 2 (:exit (sh "ls" dest))) (sh "mkdir" dest) (:err (sh "mkdir" dest))))
And now, all I needed was to call
(map convert (get-non-lame-files filelist)) and that was it. lame directory was created in my source dir, and files kept quickly pouring in. Now my brother and I can enjoy all of the songs from our playlist while riding in the car.
Was this all necessary?
I know that we could just play music from our phones with aux cable or something like that, but that would just make it messier, since we would need one cable to output the audio and another to charge the phone. And going to next/previous tracks wouldn’t work. Plus it was a cool task to give me pleasure of using my clojure skills for something practical :)
You can clone my ffconvert repository, which is actually a regular clojure project generated with leiningen, and this repo also contains script.clj file, which is a babashka script. I figured I would test my solution both in babashka and with JVM clojure because - why not!
If you have some ideas on how this could be better please feel free to tell me in the comments or through email.