-
Notifications
You must be signed in to change notification settings - Fork 7
/
auto-volume.py
116 lines (109 loc) · 5.13 KB
/
auto-volume.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# Build auto-volume.lua from the template and a JSON file of data
import os
import re
import json
import argparse
import xml.etree.ElementTree as ET
from urllib.parse import unquote, urlparse
import pydub
# Expects a series of paths as arguments. Files will be checked;
# directories will be recursed into. Unreadable or unparseable files
# will be ignored.
CACHE_FILE = os.path.abspath(os.path.join(__file__, "../auto-volume.json"))
TEMPLATE_FILE = os.path.abspath(os.path.join(__file__, "../auto-volume.lua"))
file_info = {}
def parse_file(fn, *, force=False):
fn = os.path.abspath(fn)
if fn in file_info and not force: return
# TODO: Detect playlists more reliably (and handle m3u too)
if fn.endswith(".xspf"):
for el in ET.parse(fn).getroot().findall(".//*/{http://xspf.org/ns/0/}location"):
parse_file(os.path.join(fn, "..", unquote(urlparse(el.text).path)), force=force)
return
if fn[-4] == "." and fn[-3:].lower() in {"mid", "kar", "xml", "txt", "jpg", "srt", "vpl", "nfo", "sfv", "m3u", "pdf"}:
# These files are almost certainly going to be unparseable. Save ourselves
# the trouble. I think there's an issue with files that ffmpeg claims to
# parse, but which turn out to have no audio tracks???
return
if os.path.basename(fn).lower().startswith(("readme", ".git")): return # Ditto
try: fn.encode("utf-8")
except UnicodeEncodeError:
print("Filename does not encode cleanly, won't work in Lua")
print(repr(fn))
return
try:
audio = pydub.AudioSegment.from_file(fn)
print("%s: %.2f dB (max %.2f)" % (fn, audio.dBFS, audio.max_dBFS))
# Find leading/trailing silence by iteratively scanning the start/end
# until we find some actual audio. Total silence shows up as -inf, but
# teeny bits of sound might show up with extremely negative values.
# Tweak the threshold of -100 until it's satisfactory.
for head in range(10, len(audio), 10):
if audio[:head].dBFS > -100: break
for tail in range(10, len(audio), 10):
if audio[-tail:].dBFS > -100: break
head = max(head - 250, 0) # Skip all but a quarter-second of silence
tail = max(tail - 250, 0)
file_info[fn] = {"vol": audio.dBFS, "lead": head, "trail": tail}
file_info[...] = True
except pydub.exceptions.CouldntDecodeError:
print(fn, "... unable to parse")
except KeyError:
# There's some sort of problem with parsing some webm files.
# Can be fixed by using FFMPEG to change container format to MKV
# (use "-c copy" to avoid transcoding the actual data). For now,
# just skip these files (they'll be re-attempted next time).
# It seems to be an issue with files containing vp9 video?? Not
# always webm containers. Maybe pydub is querying a list of
# tracks, finding that [0] is video and [1] is audio, but then
# is getting back only the audio track, because the video is
# unparseable??? Debug this later. For now, just move on.
print(fn, "... KeyError parse failure")
except:
# If anything else goes wrong, show which file failed.
print(repr(fn))
raise
# CAUTION: This will recurse into symlinked directories. Don't symlink back to the
# parent or you'll get a lovely little infinite loop.
def parse_dir(path, *, force=False):
if path.endswith("/.git"): return # Don't descend into git directories, you'll just waste time
for child in os.scandir(path):
if child.is_dir(): parse_dir(child.path, force=force)
else: parse_file(child.path, force=force)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="VLC Auto-Volume pre-parser")
parser.add_argument("-a", "--all", help="Parse all files even if we've seen them already", action="store_true")
parser.add_argument("--play", help="Invoke VLC after parsing", action="store_true")
parser.add_argument("paths", metavar="file", nargs='+', help="File/dir to process")
args = parser.parse_args()
try:
with open(CACHE_FILE) as f: file_info = json.load(f)
except FileNotFoundError: pass
for path in args.paths:
if os.path.isdir(path): parse_dir(path, force=args.all)
else: parse_file(path, force=args.all)
if ... in file_info: # Dirty flag
del file_info[...] # Remove the flag from the cache file
with open(CACHE_FILE, "w") as f:
json.dump(file_info, f)
with open(os.path.expanduser("~/.local/share/vlc/lua/extensions/auto-volume.lua"), "w") as out, \
open(TEMPLATE_FILE) as template:
data = template.read()
# Patch in volume data
before, after = data.split("-- [ volume-data-goes-here ] --", 1)
vol = [ "[%r]=%s," % ("file://" + fn, file_info[fn]["vol"] * 2.56)
for fn in sorted(file_info) ]
data = before + "\n\t".join(vol) + after
# Patch in lead silence data
before, after = data.split("-- [ start-silence-data-goes-here ] --", 1)
sil = [ "[%r]=%s," % ("file://" + fn, file_info[fn]["lead"] * 1000)
for fn in sorted(file_info) if file_info[fn].get("lead")]
data = before + "\n\t".join(sil) + after
# Patch in trail silence data
before, after = data.split("-- [ end-silence-data-goes-here ] --", 1)
sil = [ "[%r]=%s," % ("file://" + fn, file_info[fn]["trail"] * 1000)
for fn in sorted(file_info) if file_info[fn].get("trail")]
data = before + "\n\t".join(sil) + after
out.write(data)
if args.play:
os.execvp("vlc", ["vlc"] + args.paths)