commit 04c8f3901b768f623f5c90c6c7fdc4b1002c9a5c Author: Martin Sekera Date: Tue Sep 15 02:30:11 2020 +0200 import diff --git a/unmix.py b/unmix.py new file mode 100755 index 0000000..1b7f278 --- /dev/null +++ b/unmix.py @@ -0,0 +1,285 @@ +#! /usr/bin/env python3 +# encoding: utf-8 + +from dataclasses import dataclass +import os +import over +import re +import sys +import version + +# -------------------------------------------------- +# Exceptions + +class UnknownFileType(Exception): + pass + +class InvocationError(Exception): + pass + +class FileSyntaxError(Exception): + pass + +# -------------------------------------------------- +# Functions + +def detect_silence(path, min_length_s=2, level_dB=-32): + """ + Detects segments that are at least min_length_s long where + the audio level does not exceed level_db. + """ + + cmd = over.cmd.Command("ffmpeg", "-i", "INFILE", "-af", "silencedetect=noise=%.1fdB:d=%.2f" %(level_dB, min_length_s), "-f", "null", "-") + cmd.INFILE = "file:" + path + cmd.run(stderr=True) + raw = cmd.get_all_output().decode("utf-8") + ends = re.findall("silence_end: ([0-9]+\.[0-9]+) . silence_duration: ([0-9]+\.[0-9]+)", raw) + + ## convert (end, duration) tuples to (start, duration) + starts = [] + + for end_s, dur_s in ends: + dur = float(dur_s) + start = float(end_s) - dur + starts.append((start, dur)) + + return starts + +def floats(*args): + for arg in args: + yield float(arg) + +def sanitize_timestamp(raw): + parts = raw.split(":") + parts.reverse() + t = 0.0 + + for order, part in enumerate(parts): + t += float(part) * 60**order + + return t + +# -------------------------------------------------- +# Classes + +class Script: + def __init__(self, title): + self.lines = ["## " + title] + + def define_die(self): + self.lines.append("") + self.lines.append("function die {") + self.lines.append('\techo "failed while $@"') + self.lines.append("\texit 5") + self.lines.append("}") + + def chapter(self, comment): + self.lines.append("") + self.lines.append("# " + comment) + + def line(self, a, die=None): + if type(a) == over.cmd.Command: + line = " ".join(a.dump(pretty=True)) + else: + line = a + + if die: + line += " || die %s" %(die) + + self.lines.append(line) + + def dump(self): + return "\n".join(self.lines) + +@dataclass +class Track: + start: float + duration: float + number: int + title: str = None + album: str = None + artist: str = None + genre: str = None + date: str = None + + def __str__(self): + return "Track(#%02d: %s +%s \"%s\"" %(self.number, self.start, self.duration, self.title or "-") + + def set_name(self, fmt): + name = fmt + name = name.replace("%n", str(self.number).zfill(2)) + name = name.replace("%a", self.artist or "") + name = name.replace("%t", self.title or "") + + self.final_file = name + self.wav_file = name + ".wav" + +# -------------------------------------------------- + +if __name__ == "__main__": + main = over.app.Main("unmix", version.str, "GPL-3") + main.add_option("artist", "Force an album-wide artist name.", str, count=1, abbr="a") + main.add_option("album", "Force an album-wide album name.", str, count=1, abbr="l") + main.add_option("date", "Force an album-wide date.", str, count=1, abbr="d") + main.add_option("genre", "Force an album-wide genre.", str, count=1, abbr="g") + main.add_option("path", "Path to the media file.", str, count=1, abbr="f") + main.add_option("playlist", "Name of the file containing the playlist. See [Playlist<.>] for the what fors.", str, count=1, abbr="p") + main.add_option("encoder", "Encoding command with placeholders for individial parameters.", str, ["opusenc --title TITLE --artist ARTIST --album ALBUM --tracknumber TRACKNUMBER --genre GENRE --date DATE INFILE OUTFILE"], count=1, abbr="e") + main.add_option("detect-silence", "Autodetect silence instead of parsing a playlist. Takes 2 arguments: the sound level in dB, and the minimum duration. E.g. --detect-silence<.> -40<.> 2.25<.>.", floats, count=2, abbr="s") + main.add_option("pad", "Pad each track with silence. Takes 2 arguments: how much padding to add to the beginning and end of each track. E.g. --pad<.> 0.25<.> 0.5<.>.", floats, count=2, abbr="x") + main.add_option("output-playback", "Output playback commands (to verify timestamps) instead of the splitting & encoding scripts.", bool, [False], abbr="P") + main.add_option("output-playlist", "Output just the playlist. This really only makes sense with --detect-silence<.>.", bool, [False], abbr="L") + main.add_option("track-name", "Track name format.", str, ["%n %a - %t.opus"], abbr="t") + main.add_option("verbose", "Print playlist lines as they are being parsed to stderr.", bool, [False], abbr="v") + main.add_doc("Description", ["Takes a playlist and turns it into a shell script that splits a large mix into individual audio files."]) + main.add_doc("Playlist", ["Empty lines and those starting with # are ignored. Each line represents a track. The format is: START DURATION ARTIST TITLE. Fields are tab-separated."]) + main.setup() + + ## Sanity checks + ## + if not (main.cfg.path and os.path.isfile(main.cfg.path)): + main.print("%s<.> is not a readable file" %(main.cfg.path), main.print.tl.fail) + main.exit(1) + + if not (bool(main.cfg.playlist) ^ bool(main.cfg.detect_silence)): + main.print("nothing to do, you must specify either --detect-silence<.> or --playlist<.>", main.print.tl.fail) + main.exit(1) + + ## autodetect silence or parse playlist + ## + tracks = [] + + if main.cfg.detect_silence: + lvl, dur = main.cfg.detect_silence + gaps = detect_silence(main.cfg.path, dur, lvl) + pos = 0.0 + n = 1 + + for gap_start, gap_length in gaps: + tracks.append(Track( + start = pos, + duration = gap_start - pos, + number = n + )) + + pos = gap_start + gap_length + n += 1 + + elif main.cfg.playlist: + with open(main.cfg.playlist) as f: + n = 1 + + for line in f: + line = line.strip() + + if not line or line[0] == "#": + continue + + start_s, duration_s, artist, title = line.split("\t") + start = sanitize_timestamp(start_s) + duration = sanitize_timestamp(duration_s) + + tracks.append(Track( + start = start, + duration = duration, + number = n, + artist = artist.strip(), + title = title.strip() + )) + + n += 1 + + ## apply global overrides + ## + if main.cfg.artist: + for track in tracks: + track.artist = main.cfg.artist + + if main.cfg.album: + for track in tracks: + track.album = main.cfg.album + + if main.cfg.genre: + for track in tracks: + track.genre = main.cfg.genre + + if main.cfg.date: + for track in tracks: + track.date = main.cfg.date + + if main.cfg.pad: + pad_start, pad_end = main.cfg.pad + + for track in tracks: + track.start = max(0, track.start - pad_start) + track.duration += pad_start + pad_end + + s = Script(main.invocation) + + ## output just the playlist + if main.cfg.output_playlist: + s.chapter("playlist") + + for track in tracks: + s.line("\t".join(( + "%7.02f" %(track.start), + "%7.02f" %(track.duration), + track.artist or "", + track.title or "" + ))) + + ## output the split command, if enabled + ## + else: + if main.cfg.output_playback: + s.chapter("playback (to verify timestamps)") + + for track in tracks: + cmd = over.cmd.Command("ffplay", "-ss", "SEEK", "-t", "DURATION", "-autoexit", "INFILE") + cmd.SEEK = "%.2f" %(track.start) + cmd.DURATION = "%.2f" %(track.duration) + cmd.INFILE = "file:" + main.cfg.path + s.line(cmd) + else: + s.define_die() + s.chapter("split into individual tracks") + + for track in tracks: + cmd = over.cmd.Command("ffmpeg", "-ss", "SEEK", "-i", "INFILE", "-t", "DURATION", "-vn", "-sn", "-codec:a", "pcm_s16le", "OUTFILE") + cmd.SEEK = "%.2f" %(track.start) + cmd.DURATION = "%.2f" %(track.duration) + cmd.INFILE = "file:" + main.cfg.path + track.set_name(main.cfg.track_name) + cmd.OUTFILE = track.wav_file + s.line(cmd, die="splitting track %d" %(track.number)) + + ## output encode commands + ## + if not (main.cfg.output_playback or main.cfg.output_playlist): + s.chapter("encode and set metadata") + + for track in tracks: + # this must contain: TITLE ARTIST ALBUM TRACKNUMBER GENRE DATE INFILE OUTFILE + cmd = over.cmd.Command(*main.cfg.encoder.split()) + cmd.TITLE = track.title or "" + cmd.ARTIST = track.artist or "" + cmd.ALBUM = track.album or "" + cmd.TRACKNUMBER = str(track.number).zfill(2) + cmd.GENRE = track.genre or "" + cmd.DATE = track.date or "" + cmd.INFILE = track.wav_file + cmd.OUTFILE = track.final_file + + s.line(cmd, die="encoding track %d" %(track.number)) + + ## remove temporary files + ## + s.chapter("remove temporary files") + + for track in tracks: + cmd = over.cmd.Command("rm", "-f", "INFILE") + cmd.INFILE = track.wav_file + s.line(cmd, die="removing track %d wav file" %(track.number)) + + ## output the script itself + print(s.dump()) diff --git a/version.py b/version.py new file mode 100644 index 0000000..b4d0d50 --- /dev/null +++ b/version.py @@ -0,0 +1,8 @@ +#! /usr/bin/env python3 +# encoding: utf-8 + +major = 0 # VERSION_MAJOR_IDENTIFIER +minor = 2 # VERSION_MINOR_IDENTIFIER +# VERSION_LAST_MM 0.2 +patch = 0 # VERSION_PATCH_IDENTIFIER +str = "0.2.0" # VERSION_STRING_IDENTIFIER