From 42f3331c2d04b03cfccf10f2dae0f820c962bfb7 Mon Sep 17 00:00:00 2001 From: everbarry Date: Fri, 10 Apr 2026 14:24:26 +0200 Subject: [PATCH] fixes + converter --- README.md | 25 ++++++ convert_for_shuffle.py | 195 +++++++++++++++++++++++++++++++++++++++++ main.py | 40 +++++++-- pyproject.toml | 3 +- 4 files changed, 257 insertions(+), 6 deletions(-) create mode 100644 convert_for_shuffle.py diff --git a/README.md b/README.md index b2b93ac..0bfbbe7 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,34 @@ rebuild-db --help | `-f` / `--force` | Rebuild DB entries from scratch (do not reuse old entries; useful for `.aa` only if you understand the trade-offs) | | `-r` / `--rename` | Rename unsafe paths outside `iPod_Control` | | `-l` / `--nolog` | Do not write `rebuild_db.log.txt` | +| `--shuffle-all` | Force `shuffle=1` on every track (overrides audiobook-style rules) | +| `--verbose` | Log each file as it is written (`type`, `shuffle`, `bookmark`), like the original KeyJ tool | Optional rules file: **`rebuild_db.rules`** in the iPod root (see `--help` and original rebuild_db documentation for the rule syntax). +### WAV, M4A, and “not showing up” + +**Hardware:** Apple’s specs for the 1st- and 2nd-gen iPod shuffle list **MP3, AAC (M4A/M4P/M4B), Audible, and WAV** (and AIFF on 2nd gen) as supported formats. The shuffle has **no screen**—tracks do not “appear” anywhere except through playback order and, on disk, under `iPod_Control/Music/`. + +**Database:** This tool assigns **type 1** (MP3), **type 2** (AAC/M4A), **type 4** (WAV) in `iTunesSD`, matching KeyJ’s rebuild_db. If something still will not play: + +1. Run with **`--verbose`** and confirm every file is listed with **`type=4`** and **`shuffle=1`** for WAV. If **`shuffle=0`**, a rule matched (e.g. **`*.book.???`** treats names like `anything.book.wav` as audiobooks). Use **`--shuffle-all`** or adjust **`rebuild_db.rules`**. +2. **PCM format:** The device is picky like many portables. Prefer **44.1 kHz, 16-bit, stereo, PCM WAV**. **Float WAV, 24/32-bit, or very high sample rates** may fail silently. Re-encode if needed, e.g. `ffmpeg -i in.wav -ar 44100 -ac 2 -sample_fmt s16 out.wav`. +3. **M4A** usually works because AAC matches what iTunes typically synced; if only M4A plays, format (2) vs WAV (4) on the wire is a good clue to try re-encoding WAV to AAC/MP3 as a test. + +### Converting a folder of audio for the shuffle + +Install **ffmpeg**, then (from a checkout or after `pip install ipodutils`): + +```bash +shuffle-convert /path/to/music +# writes 44.1 kHz / 16-bit / stereo WAV under /path/to/music/shuffle_ready/ +shuffle-convert /path/to/music -o /path/to/out -f mp3 +shuffle-convert /path/to/music --dry-run +``` + +By default, conversion uses **high-quality resampling** (SoXR when your ffmpeg supports it), **MP3 V0**, and **AAC 256 kbps**. If you hear ringing or “digital” grain, avoid `--fast` (that mode uses lighter resampling and lower bitrates). For the cleanest WAV, keep the default; re-encode from **lossless sources** when possible—transcoding MP3 → WAV cannot remove existing compression artifacts. + ## Logging By default, output is also written to **`rebuild_db.log.txt`** on the iPod root. Use **`--nolog`** to disable. diff --git a/convert_for_shuffle.py b/convert_for_shuffle.py new file mode 100644 index 0000000..9ca7520 --- /dev/null +++ b/convert_for_shuffle.py @@ -0,0 +1,195 @@ +#!/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() diff --git a/main.py b/main.py index 77a9bf0..f6553a9 100644 --- a/main.py +++ b/main.py @@ -20,7 +20,9 @@ DEFAULT_RULES = [ ([("filename", "~", "*.m4?")], {"type": 2, "shuffle": 1, "bookmark": 0}), ([("filename", "~", "*.m4b")], {"shuffle": 0, "bookmark": 1}), ([("filename", "~", "*.aa")], {"type": 1, "shuffle": 0, "bookmark": 1, "reuse": 1}), - ([("filename", "~", "*.wav")], {"type": 4, "shuffle": 0, "bookmark": 0}), + # type 4 = WAV to the firmware; KeyJ used shuffle=0 (voice-clips style). Use shuffle=1 + # so WAVs join the normal playlist like MP3/M4A. + ([("filename", "~", "*.wav")], {"type": 4, "shuffle": 1, "bookmark": 0}), ([("filename", "~", "*.book.???")], {"shuffle": 0, "bookmark": 1}), ([("filename", "~", "*.announce.???")], {"shuffle": 0, "bookmark": 0}), ([("filename", "~", "/recycled/*")], {"ignore": 1}), @@ -38,8 +40,10 @@ GETOPT_LONG = ( "logfile=", "rename", "ipod-path=", + "shuffle-all", + "verbose", ) -HELP = "Rebuild iPod shuffle database.\n\nMandatory arguments to long options are mandatory for short options too.\n -h, --help display this help text\n -i, --interactive prompt before browsing each directory\n -v, --volume=VOL set playback volume to a value between 0 and 38\n -s, --nosmart do not use smart shuffle\n -n, --nochdir do not change directory to this scripts directory first\n -p, --ipod-path=DIR use DIR as the iPod root (mount point); skip --nochdir behavior\n -l, --nolog do not create a log file\n -f, --force always rebuild database entries, do not re-use old ones\n -L, --logfile set log file name\n\nWithout --ipod-path, run from the iPod's root directory (or rely on --nochdir /\nscript location as before). By default, the whole volume is searched for playable\nfiles, unless at least one DIRECTORY is specified." +HELP = "Rebuild iPod shuffle database.\n\nMandatory arguments to long options are mandatory for short options too.\n -h, --help display this help text\n -i, --interactive prompt before browsing each directory\n -v, --volume=VOL set playback volume to a value between 0 and 38\n -s, --nosmart do not use smart shuffle\n -n, --nochdir do not change directory to this scripts directory first\n -p, --ipod-path=DIR use DIR as the iPod root (mount point); skip --nochdir behavior\n -l, --nolog do not create a log file\n -f, --force always rebuild database entries, do not re-use old ones\n -L, --logfile set log file name\n --shuffle-all force shuffle=1 on every track (overrides audiobook-style rules)\n --verbose log each track as it is written (path, type, shuffle) like KeyJ\n\nWithout --ipod-path, run from the iPod's root directory (or rely on --nochdir /\nscript location as before). By default, the whole volume is searched for playable\nfiles, unless at least one DIRECTORY is specified." ERR_NO_CTRL = "ERROR: No iPod control directory found!\nPlease make sure that:\n (*) this program's working directory is the iPod's root directory\n (*) the iPod was correctly initialized with iTunes" ERR_NO_SD = "ERROR: Cannot write to the iPod database file (iTunesSD)!\nPlease make sure that:\n (*) you have sufficient permissions to write to the iPod volume\n (*) you are actually using an iPod shuffle, and not some other iPod model :)" @@ -66,6 +70,8 @@ class ShuffleRebuild: "logfile": "rebuild_db.log.txt", "rename": False, "ipod_path": None, + "shuffle_all": False, + "verbose": False, } self.rules = list(DEFAULT_RULES) self.domains, self.total_count, self.known_entries = [], 0, {} @@ -342,8 +348,15 @@ class ShuffleRebuild: for ruleset, action in self.rules: if all(self._match_rule(props, r) for r in ruleset): props.update(action) + if self.opts["shuffle_all"]: + props["shuffle"] = 1 if props["ignore"]: return 0 + if self.opts["verbose"]: + self.log( + "=> %s type=%d shuffle=%d bookmark=%d" + % (filename, props["type"], props["shuffle"], props["bookmark"]) + ) entry = props["reuse"] and self.known_entries.get(filename) if not entry: self._entry_template[29] = props["type"] @@ -378,12 +391,15 @@ class ShuffleRebuild: far = [s for s in range(sc) if metric[s] >= th] th = (min(fill) + max(fill) + 1) // 2 emp = [s for s in range(sc) if fill[s] <= th and s in far] - s = random.choice(emp or far) + choices = emp or far or list(range(sc)) + s = random.choice(choices) slices[s].append((n, d)) fill[s] += 1 used.append(s) seq, last = [], -1 for sl in slices: + if not sl: + continue random.shuffle(sl) if len(sl) > 2 and sl[0][1] == last: sl.append(sl.pop(0)) @@ -400,7 +416,7 @@ class ShuffleRebuild: a = array.array("B") a.frombytes(f.read()) ps = a.tolist() - except OSError, EOFError: + except (OSError, EOFError): pass if len(ps) != 21: ps = list(_u24(29)) + [0] * 15 + list(_u24(1)) @@ -431,6 +447,16 @@ class ShuffleRebuild: self.log("Generating shuffle sequence ...", end=" ") seq = list(range(n)) random.shuffle(seq) + # KeyJ: only shuffle=1 tracks join smart-shuffle domains; shuffle=0 (audiobooks, etc.) + # is stored per entry. iTunesShuffle may be shorter than n; do not pad with fake indices. + shuf_tracks = sum(len(d) for d in self.domains) + if n and shuf_tracks < n and not self.opts["shuffle_all"]: + self.log( + "\nNOTE: %d of %d playable tracks use shuffle=1; the rest have shuffle=0 " + "(e.g. *.book.??? / *.m4b rules). Only shuffle=1 tracks appear in the shuffle " + "playlist. Use --shuffle-all, rename files, or rebuild_db.rules if that is wrong." + % (shuf_tracks, n) + ) try: with open("iPod_Control/iTunes/iTunesShuffle", "wb") as f: f.write(b"".join(_u24(x) for x in seq)) @@ -460,7 +486,7 @@ class ShuffleRebuild: ent[33::2].split(b"\0", 1)[0].decode("latin-1", "replace") ] = ent ent = sd_read.read(558) - except OSError, EOFError: + except (OSError, EOFError): pass if sd_read: sd_read.close() @@ -560,6 +586,10 @@ def parse_argv(argv: list, s: ShuffleRebuild) -> list: o["logfile"] = arg elif opt in ("-r", "--rename"): o["rename"] = True + elif opt == "--shuffle-all": + o["shuffle_all"] = True + elif opt == "--verbose": + o["verbose"] = True return args diff --git a/pyproject.toml b/pyproject.toml index 5d8a8ee..4a4a176 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,9 +25,10 @@ dependencies = [] [project.scripts] rebuild-db = "main:cli" +shuffle-convert = "convert_for_shuffle:main" [tool.setuptools] -py-modules = ["main"] +py-modules = ["main", "convert_for_shuffle"] [tool.uv] package = true