iPodUtils/convert_for_shuffle.py
2026-04-10 14:24:26 +02:00

196 lines
5.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 shufflefriendly 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()