"""Converts audio files to Ogg Vorbis, the format Susurrus ships its sounds in. python Scripts/convertaudio.py [--out DIR] [--quality N] [--force] Needs ffmpeg and ffprobe on PATH, which only someone adding sounds does. Converted files are checked in. - An Ogg Vorbis input is copied byte for byte. Encoding lossy audio again loses quality, and ffmpeg's Ogg demuxer gives some Vorbis packets wrong timestamps, which the encoder then follows. - A lossless input (WAV, FLAC, AIFF and so on) is encoded with libvorbis. - Any other input is lossy. It is converted with a warning, since this loses quality a second time. - Channels and sample rate pass through. Susurrus plays mono and stereo, so more channels fail. - Metadata and cover art are dropped, and the encoder is told to be bitexact, so running again on the same input gives the same bytes and a checked-in file doesn't churn. """ import argparse import json import os import shutil import subprocess import sys DEFAULT_QUALITY = 5 MAX_CHANNELS = 2 AUDIO_EXTENSIONS = { ".wav", ".flac", ".mp3", ".ogg", ".oga", ".opus", ".m4a", ".aac", ".wma", ".aif", ".aiff", ".ac3", ".mka", } LOSSLESS_CODECS = {"flac", "alac", "wavpack", "ape", "tta", "truehd", "mlp"} INSTALL_HINT = "winget install Gyan.FFmpeg" class AudioInfo: def __init__(self, codec, container, channels, sample_rate): self.codec = codec self.container = container self.channels = channels self.sample_rate = sample_rate class ConvertError(Exception): pass def find_tool(name): path = shutil.which(name) if path is None: raise ConvertError(f"{name} isn't on PATH. Install it with: {INSTALL_HINT}") return path def probe(path, ffprobe): """Returns the AudioInfo of the first audio stream of path.""" result = subprocess.run( [ffprobe, "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name,channels,sample_rate:format=format_name", "-of", "json", path], capture_output=True, text=True) if result.returncode != 0: raise ConvertError(f"ffprobe can't read it: {result.stderr.strip()}") parsed = json.loads(result.stdout) streams = parsed.get("streams", []) if not streams: raise ConvertError("it has no audio stream") stream = streams[0] return AudioInfo(stream.get("codec_name", ""), parsed.get("format", {}).get("format_name", ""), int(stream.get("channels", 0)), int(stream.get("sample_rate", 0))) def classify(info): """Returns "copy" for Ogg Vorbis, "lossless" or "lossy".""" if info.codec == "vorbis" and "ogg" in info.container.split(","): return "copy" if info.codec.startswith("pcm_") or info.codec in LOSSLESS_CODECS: return "lossless" return "lossy" def collect(inputs): """Returns (path, relative path under its folder or the file's own name) for every audio file named.""" found = [] for item in inputs: if os.path.isdir(item): for directory, names, files in os.walk(item): names.sort() for name in sorted(files): if os.path.splitext(name)[1].lower() in AUDIO_EXTENSIONS: path = os.path.join(directory, name) found.append((path, os.path.relpath(path, item))) elif os.path.isfile(item): found.append((item, os.path.basename(item))) else: raise ConvertError(f"{item} isn't a file or a folder.") return found def plan(inputs, out_directory): """Returns (source, destination) pairs. Two sources can't share a destination.""" pairs = [] taken = {} for source, relative in collect(inputs): stem = os.path.splitext(relative)[0] + ".ogg" if out_directory is None: destination = os.path.join(os.path.dirname(source), os.path.basename(stem)) else: destination = os.path.join(out_directory, stem) key = os.path.normcase(os.path.abspath(destination)) if key in taken: raise ConvertError(f"{taken[key]} and {source} would both become {destination}.") taken[key] = source pairs.append((source, destination)) return pairs def is_current(source, destination): return os.path.exists(destination) and os.path.getmtime(destination) >= os.path.getmtime(source) def convert(source, destination, quality, force, ffmpeg, ffprobe): """Converts one file and returns a line saying what happened, or raises ConvertError.""" if not force and os.path.abspath(source) != os.path.abspath(destination) and is_current(source, destination): return "up to date" info = probe(source, ffprobe) if info.channels > MAX_CHANNELS: raise ConvertError(f"it has {info.channels} channels, and Susurrus plays mono and stereo.") kind = classify(info) if kind == "copy" and os.path.abspath(source) == os.path.abspath(destination): return "already Ogg Vorbis" directory = os.path.dirname(os.path.abspath(destination)) os.makedirs(directory, exist_ok=True) partial = os.path.join(directory, "converting.part.ogg") try: if kind == "copy": shutil.copyfile(source, partial) else: result = subprocess.run( [ffmpeg, "-nostdin", "-v", "error", "-y", "-i", source, "-vn", "-map", "0:a:0", "-map_metadata", "-1", "-c:a", "libvorbis", "-q:a", str(quality), "-fflags", "+bitexact", "-flags:a", "+bitexact", partial], capture_output=True, text=True) if result.returncode != 0: raise ConvertError(f"ffmpeg failed: {result.stderr.strip()}") os.replace(partial, destination) finally: if os.path.exists(partial): os.remove(partial) if kind == "copy": return "copied, it is already Ogg Vorbis" if kind == "lossless": return f"encoded at quality {quality}" note = "WARNING: it is lossy, so this loses quality twice" if info.codec == "mp3": note += ", and an MP3 may not loop without a gap" return f"encoded at quality {quality}. {note}" def parse_arguments(argv): parser = argparse.ArgumentParser(description="Converts audio files to Ogg Vorbis.") parser.add_argument("inputs", nargs="+", help="audio files, or folders searched for them") parser.add_argument("--out", help="folder for the results, which keep their folder structure. Default is beside " "each input") parser.add_argument("--quality", type=float, default=DEFAULT_QUALITY, help=f"libvorbis quality from -1 to 10, default {DEFAULT_QUALITY}") parser.add_argument("--force", action="store_true", help="convert even when the result is newer than its input") arguments = parser.parse_args(argv) if not -1 <= arguments.quality <= 10: parser.error("--quality must be from -1 to 10.") return arguments def main(argv=None): arguments = parse_arguments(sys.argv[1:] if argv is None else argv) try: ffmpeg = find_tool("ffmpeg") ffprobe = find_tool("ffprobe") pairs = plan(arguments.inputs, arguments.out) except ConvertError as error: print(error) return 2 if not pairs: print("No audio files found.") return 2 failures = 0 for source, destination in pairs: try: message = convert(source, destination, arguments.quality, arguments.force, ffmpeg, ffprobe) print(f"{source}: {message}") except ConvertError as error: failures += 1 print(f"{source}: FAILED, {error}") print(f"{len(pairs) - failures} of {len(pairs)} converted or up to date.") return 1 if failures else 0 if __name__ == "__main__": sys.exit(main())