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