import
This commit is contained in:
commit
04c8f3901b
2 changed files with 293 additions and 0 deletions
285
unmix.py
Executable file
285
unmix.py
Executable file
|
@ -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 [<W>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. <W>--<G>detect-silence<.> <m>-40<.> <m>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. <W>--<G>pad<.> <m>0.25<.> <m>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 <W>--<G>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("<R>%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 <W>--<G>detect-silence<.> or <W>--<G>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())
|
8
version.py
Normal file
8
version.py
Normal file
|
@ -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
|
Loading…
Add table
Add a link
Reference in a new issue