16
\$\begingroup\$

Recently, I've been transferring some of my old vinyl records to MP3 files. One of the things I did to make some progress was to automate things, so I initially set things up so that it would record each album to a single MP3 file. Now I've gotten to the step where I want to separate those out into individual tracks, so I wrote this program to do that automatically. It takes an input file in this format:

infile=~/vinyl/UK.mp3
artist=UK
genre=Rock
album=UK

1 - 00:00 - In the Dead Of Night     
2 - 05:38 - By the Light Of Day     
3 - 10:10 - Presto Vivace and Reprise     
4 - 13:18 - Thirty Years     
5 - 21:30 - Alaska     
6 - 26:04 - Time to Kill     
7 - 31:02 - Nevermore     
8 - 39:20 - Mental Medication

It then takes the input file in this metadata file and splits it into named tracks adding the album, artist and genre into each MP3 file using ffmpeg. Here's the script:

songsplit.py

#!/usr/bin/python3
import subprocess
import sys


class Track(object):
    def __init__(self, tracknum, starttime, name):
        self.tracknum = int(tracknum)
        self.starttime = starttime
        self.endtime = starttime
        self.name = name

    def setEnd(self, endtime):
        self.endtime = endtime

    def setargs(self, infile, metadata):
        args = ['ffmpeg', '-i', infile, '-ss', self.starttime]
        if self.endtime != self.starttime:
            args.extend(['-to', self.endtime])
        for key in metadata.keys():
            args.extend(['-metadata', "{}={}".format(key, metadata[key])])
        args.extend(['-metadata', "{}={}".format("track", self.tracknum)])
        args.extend(['-metadata', "{}={}".format("title", self.name)])
        args.append("{:02d} {}.mp3".format(self.tracknum, self.name))
        return args

    def __repr__(self):
        return "{}, {}, {}, {}".format(self.tracknum, self.starttime,
                                       self.endtime, self.name)


def extract(file):
    metadata = {}
    tracks = []
    lines = [line.strip("\n") for line in open(file)]
    for line in lines:
        if line.find('=') > 0:
            m = line.split(sep='=', maxsplit=1)
            if m[0] == 'infile':
                infile = m[1]
            else:
                metadata[m[0]] = m[1]
        else:
            m = line.split(sep=" - ", maxsplit=2)
            if len(m) == 3:
                m = [x.strip() for x in m]
                tk = Track(m[0], m[1], m[2])
                tracknum = int(tk.tracknum)
                if tracknum > 1:
                    tracks[tracknum-2].setEnd(tk.starttime)
                tracks.append(tk)
    for track in tracks:
        subprocess.run(track.setargs(infile, metadata))


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: {} metafile".format(sys.argv[0]))
    else:
        extract(sys.argv[1])

If the metadata file shown above is named UK.meta, then this would be invoked as songsplit.py UK.meta and the result would be a directory of MP3 files like this:

01 In the Dead Of Night.mp3
02 By the Light Of Day.mp3
03 Presto Vivace and Reprise.mp3
04 Thirty Years.mp3
05 Alaska.mp3
06 Time to Kill.mp3
07 Nevermore.mp3
08 Mental Medication.mp3

Some particular questions I have are about the use of subprocess.run() and what error handling I should add. So far, I haven't encountered any problems.

\$\endgroup\$
  • 2
    \$\begingroup\$ Will that invocation of ffmpeg decode and re-encode the audio, rather than copying through? I don't see where you pass -c:a copy or equivalent. I guess re-encoding eliminates all worries about unflushed bit reservoir, but if that's at the expense of absolute quality, I'd avoid it. \$\endgroup\$ – Toby Speight Jun 19 '17 at 17:15
  • 1
    \$\begingroup\$ Due to the way I have my particular installation of ffmpeg configured, re-encoding only incurs a time penalty and not a quality penalty. I decided to do it that way (rather than copying the stream) so that the output would be consistently coded. If it's a concern for others using the script, additional ffmpeg arguments can easily be added. \$\endgroup\$ – Edward Jun 19 '17 at 17:35
  • \$\begingroup\$ @Edward how that is possible? did you actually verify that the result of reencoding decodes to the exactly same waveform as the source file? If you simply don't hear any difference, it doesn't mean that it's not there. \$\endgroup\$ – Sarge Borsch Jun 21 '17 at 4:53
  • 1
    \$\begingroup\$ also, using mp3 at all for archiving vinyl is a very strange decision. you won't have any chance to redo this if you get some unpleasant artifacts of mp3 compression (and mp3 is especially known for failing to faithfully represent some sounds at any bitrate), because vinyls degrade after playing, so if the recordings are of at least some nonzero importance, it would be more reasonable to use a lossless format (FLAC for example) \$\endgroup\$ – Sarge Borsch Jun 21 '17 at 4:57
  • 1
    \$\begingroup\$ Just dropped in to say that your songsplit.py came in super-useful today. Thanks! \$\endgroup\$ – ktorn Oct 23 '18 at 9:58
7
+100
\$\begingroup\$

I'll consider answering your last question (at least at the moment) about subprocess.run().

There's a similar method you can use for this kind of situations named subprocess.check_call(). The difference between the two is that call() already gives you the return code — not as an attribute, just as the return value of the function. The reason you might want to use check_call() is that it takes care of checking that return code for you, raising a CalledProcessError.

Anyway, either one will raise an OSError for the program not being found.

So, a first solution to your problem might look like this:

try:
    subprocess.check_call(track.setargs(infile, metadata))
except subprocess.CalledProcessError:
    pass  # handle errors in the called executable
except OSError:
    pass  # executable not found

Now, I don't necessary like this solution because sincerely I don't know about those specific exception cases, or check_call() / run() (which apparently they both block). What I learned from my previous projects is that .run() basically merges all the old functions — call(), check_call(), check_output() — into one friendlier function.


You should know that subprocess will raise an exception, OSError, when a command is not found. So, if you catch OSError you can handle the non-existent command, and if you check the result code you can find out whether the command succeeded or not.

As a personal advice, If you have no good way to handle it, let it raise. If you want to continue the execution even if this fails, log it somewhere:

try:
    subprocess.run(track.setargs(infile, metadata))
except OSError as e:
    logger.WARNING("Error: {}".format(e))

Depending on what track.setargs() raises, you can change the Error type.

It's worth mentioning that you might want to use except Exception which will cover any runtime exceptions. With except Exception you can remove the OSError except since Exception will cover those as well. This way of exception handling is kind of hand wavey (and it's best avoided) but will keep your program running.


Other aspects:

  • you're reading the entire file into memory before processing it. If the file is very large, that will be slow; and if it's very large, it'll fail. Consider reading the file line by line.
  • I wouldn't bother writing a setEnd method. Calling it is no simpler than just saying tk.endtime = 3.
  • you can test if a string has '=' with: if '=' in line:

PEP8

  • methods are usually snake_cased so if you'll keep your setEnd(self, endtime) it should be renamed to set_end(self, endtime)

Overall, nice job! You did pretty well! Keep going the good work.

\$\endgroup\$
3
\$\begingroup\$

In addition to @MrGrj's answer:

To iterate over a dictionary, you can use the following code:

for key, value in metadata.items():
    ...

See https://stackoverflow.com/a/3294899/ for more details.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.