196 lines
5.2 KiB
Python
196 lines
5.2 KiB
Python
#!/usr/bin/env python3
|
||
"""Convert audio files in a folder to formats friendly to 1st/2nd-gen iPod shuffle.
|
||
|
||
Requires ffmpeg on PATH. Defaults to 44.1 kHz, 16-bit, stereo PCM WAV."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
# Extensions we attempt to decode with ffmpeg (lowercase).
|
||
INPUT_EXTENSIONS = frozenset(
|
||
{
|
||
".wav",
|
||
".mp3",
|
||
".m4a",
|
||
".m4b",
|
||
".m4p",
|
||
".flac",
|
||
".ogg",
|
||
".opus",
|
||
".aif",
|
||
".aiff",
|
||
".wma",
|
||
".mpc",
|
||
".wv",
|
||
}
|
||
)
|
||
|
||
OUTPUT_EXT = {"wav": ".wav", "mp3": ".mp3", "aac": ".m4a"}
|
||
|
||
|
||
def _ffmpeg_has_soxr() -> bool:
|
||
r = subprocess.run(
|
||
["ffmpeg", "-hide_banner", "-h", "filter=aresample"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=10,
|
||
)
|
||
return "soxr" in (r.stdout + r.stderr)
|
||
|
||
|
||
def _resample_filter(high_quality: bool) -> str:
|
||
"""Filter graph for 44.1 kHz stereo; SoXR avoids metallic ringing from default resampler."""
|
||
if not high_quality:
|
||
return "aresample=44100"
|
||
if _ffmpeg_has_soxr():
|
||
return (
|
||
"aresample=44100:resampler=soxr:precision=28:cutoff=0.91:"
|
||
"dither_method=triangular_hp"
|
||
)
|
||
return "aresample=44100:filter_size=256:cutoff=0.97:cheby=1"
|
||
|
||
|
||
def ffmpeg_cmd(
|
||
src: Path,
|
||
dst: Path,
|
||
fmt: str,
|
||
*,
|
||
high_quality: bool,
|
||
) -> list[str]:
|
||
af = _resample_filter(high_quality)
|
||
base = [
|
||
"ffmpeg",
|
||
"-nostdin",
|
||
"-hide_banner",
|
||
"-loglevel",
|
||
"error",
|
||
"-y",
|
||
"-i",
|
||
str(src),
|
||
"-af",
|
||
af,
|
||
"-ac",
|
||
"2",
|
||
]
|
||
if fmt == "wav":
|
||
return base + [
|
||
"-sample_fmt",
|
||
"s16",
|
||
"-c:a",
|
||
"pcm_s16le",
|
||
str(dst),
|
||
]
|
||
if fmt == "mp3":
|
||
# V0 (~245 kbps) is much cleaner than -q:a 2; optional LAME joint stereo off
|
||
q = "0" if high_quality else "2"
|
||
extra = ["-joint_stereo", "0"] if high_quality else []
|
||
return base + ["-c:a", "libmp3lame", "-q:a", q] + extra + [str(dst)]
|
||
if fmt == "aac":
|
||
br = "256k" if high_quality else "192k"
|
||
return base + ["-c:a", "aac", "-b:a", br, str(dst)]
|
||
raise ValueError(fmt)
|
||
|
||
|
||
def main() -> None:
|
||
p = argparse.ArgumentParser(
|
||
description="Convert audio in a folder to iPod shuffle–friendly format (needs ffmpeg)."
|
||
)
|
||
p.add_argument(
|
||
"input_dir",
|
||
type=Path,
|
||
help="Folder containing source audio files",
|
||
)
|
||
p.add_argument(
|
||
"-o",
|
||
"--output-dir",
|
||
type=Path,
|
||
default=None,
|
||
help="Where to write converted files (default: <input_dir>/shuffle_ready)",
|
||
)
|
||
p.add_argument(
|
||
"-f",
|
||
"--format",
|
||
choices=("wav", "mp3", "aac"),
|
||
default="wav",
|
||
help="Output format: wav=44.1kHz s16 stereo (default), mp3, or aac (.m4a)",
|
||
)
|
||
p.add_argument(
|
||
"-n",
|
||
"--dry-run",
|
||
action="store_true",
|
||
help="Print actions only",
|
||
)
|
||
p.add_argument(
|
||
"--force",
|
||
action="store_true",
|
||
help="Overwrite existing outputs",
|
||
)
|
||
p.add_argument(
|
||
"--fast",
|
||
action="store_true",
|
||
help="Faster, lighter processing (older -ar style resampling, lower MP3/AAC quality)",
|
||
)
|
||
args = p.parse_args()
|
||
high_quality = not args.fast
|
||
|
||
if not shutil.which("ffmpeg"):
|
||
print("error: ffmpeg not found on PATH; install it and try again.", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
inp: Path = args.input_dir.resolve()
|
||
if not inp.is_dir():
|
||
print(f"error: not a directory: {inp}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
out_root = args.output_dir
|
||
if out_root is None:
|
||
out_root = inp / "shuffle_ready"
|
||
out_root = out_root.resolve()
|
||
|
||
ext_out = OUTPUT_EXT[args.format]
|
||
jobs: list[tuple[Path, Path]] = []
|
||
for path in sorted(inp.rglob("*")):
|
||
if not path.is_file():
|
||
continue
|
||
if path.suffix.lower() not in INPUT_EXTENSIONS:
|
||
continue
|
||
rel = path.relative_to(inp)
|
||
dst = out_root / rel.with_suffix(ext_out)
|
||
if dst == path:
|
||
dst = out_root / (rel.as_posix() + "_converted" + ext_out)
|
||
jobs.append((path, dst))
|
||
|
||
if not jobs:
|
||
print(f"No audio files found under {inp}")
|
||
sys.exit(0)
|
||
|
||
for src, dst in jobs:
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
cmd = ffmpeg_cmd(src, dst, args.format, high_quality=high_quality)
|
||
if args.dry_run:
|
||
print(" ".join(cmd))
|
||
continue
|
||
if dst.exists() and not args.force:
|
||
print(f"skip (exists): {dst}")
|
||
continue
|
||
print(f"{src} -> {dst}")
|
||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||
if r.returncode != 0:
|
||
print(f"error: ffmpeg failed on {src}\n{r.stderr}", file=sys.stderr)
|
||
sys.exit(r.returncode)
|
||
|
||
if args.dry_run:
|
||
print(f"{len(jobs)} file(s) would be converted.")
|
||
else:
|
||
mode = "fast" if args.fast else "high-quality (SoXR resample when available)"
|
||
print(f"Done: {len(jobs)} file(s) -> {out_root} [{mode}]")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|