
Batch · 8 October 2026 · 11 min
Batch retune a folder to 432 Hz without damaging a master
To batch retune a folder to 432 Hz safely, approve one file first, work from a read-only copy of the inputs, and render into a new target-specific folder. Write each file to a temp name before an atomic rename, use the documented source reference for every file, log every result, and verify counts and durations before you call it finished.
Converting one song to 432 is a listening decision. Converting a folder of forty is an operations job, and operations jobs fail in boring ways. A typo in the output path. A previously retuned master that gets retuned again. A half-downloaded file that kills the queue at track 31 while you're asleep. A command that "worked" but quietly wrote 44.1 kHz files into a 48 kHz delivery. None of those are audio problems. They're process problems, and a batch just makes them faster.
I like to think of a batch as a machine that repeats your judgment forty times. If the judgment is right, you saved an afternoon. If it's wrong, you now have forty wrong files that all look plausible. So the work comes before you press go: decide once, prove it on one file, then build the job so it can't hurt the originals and can be run again without making things worse.
Approve one file before you touch the folder
Pick a representative track and settle three things on it: the render mode (classic resample, where pitch and time move together, or a time-preserving shift), the naming pattern, and the loudness rule. Most of the time the loudness rule should be "none." Listen to that one file end to end. If you're building an alternate release, the process in A 432 alternate master should never cost you the original is the template. Only when the one file is approved does it become a recipe.
Then write the recipe down before the batch runs, not after: input folder, output folder, source reference, target, mode, loudness option, date. You'll want it when someone asks why track nine sounds different. Or when you need to re-run the job six months from now on a new machine.
Preflight: know what's actually in the folder
Real folders are messy. Full mixes sit next to instrumentals, stems, MP3 previews, a .part file from an interrupted download, and a temp file your DAW forgot to clean up. Before anything renders, look at every file and decide whether it belongs in this job.
- Formats and rates. A folder mixing 44.1 and 48 kHz is fine, as long as each output keeps its own rate. A batch that silently forces everything to one rate is a delivery bug. Sample rate and bit depth are delivery hygiene for a reason.
- Channel layouts. A mono demo should come out mono, not as fake stereo, and definitely not as one channel dropped.
- Source reference. This is the one that bites. Don't assume every file is at 440. An orchestral recording may have been made against a higher A. A file somebody already retuned is sitting at 432. The batch can't hear that, and the filename usually won't say. Write the real reference for every exception into a small sidecar list. Files that are already at the target get refused, not retuned again.
- Disk space. Lossless output is big, and a classic retune is 1.85% longer than its source. Predict the output size and compare it to free space before you start, not at file 38.
Make the job impossible to hurt the originals
Three rules do most of the protecting.
Inputs are read-only. Work from a copy of the source folder, and set that copy read-only. If any step tries to write there, the operating system stops it. Never point a batch at the only copy of a client catalog.
Outputs go to a new, target-specific folder. Something like OUTPUT_432_RAW, so nobody mistakes it for a loudness-processed delivery. Keep each base name and add a clear suffix, so Song One.wav becomes Song One_432.wav. If the output already exists, skip it or version it. Never overwrite it.
Every render writes to a temp file first. FFmpeg writes Song One_432.wav.part. The script checks it, and only then renames it to the real name in one atomic step. If the power cuts mid-render, what's left is a .part file the next run knows to delete, never a truncated WAV with a legitimate-looking name. That one habit is what makes a job safe to restart. Engineers call this property idempotent: running it again doesn't damage finished work or stack conversions.
A batch you can actually run
This script builds a deliberately messy synthetic folder: tones generated in code, so it's safe to share and easy to measure. There's a 48 kHz stereo song, a 44.1 kHz club mix with parentheses in the name, a mono demo, a file with an accented name, a recording made against A=442, a master that was already retuned to 432, a corrupt "partial download," a temp file, and a stale .part left over from a fake power cut. Then it runs the same batch twice.
The filter chain resamples up to 200 times the source reference (88,000 for a 440 source, 88,400 for 442), relabels it as 200 times the target (86,400), and comes back down to each file's own rate. That makes the ratio exact for any whole-number reference, and it keeps 44.1 kHz files at 44.1.
#!/usr/bin/env python3
"""09_safe_batch.py: retune a folder to A=432 without ever risking the inputs.
Preflight -> read-only inputs -> temp-file renders -> atomic rename -> CSV log -> verify -> rerun safely.
SYNTHETIC AUDIO ONLY: the input folder is generated below, including deliberately bad files.
Needs: python3 + numpy, ffmpeg/ffprobe on PATH. Usage: python3 09_safe_batch.py [workdir]"""
import csv, json, math, os, shutil, stat, subprocess, sys
import numpy as np
TARGET = 432
work = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "batch_demo")
IN, OUT = os.path.join(work, "INPUT_COPY"), os.path.join(work, "OUTPUT_432_RAW")
LOG = os.path.join(work, "batch_log.csv")
def sh(cmd, **kw):
return subprocess.run(cmd, capture_output=True, text=True, **kw)
def probe(p):
r = sh(["ffprobe", "-v", "error", "-show_streams", "-show_format", "-of", "json", p])
if r.returncode:
return None
j = json.loads(r.stdout)
a = [s for s in j.get("streams", []) if s.get("codec_type") == "audio"]
if not a or "duration" not in j.get("format", {}):
return None
return dict(dur=float(j["format"]["duration"]), rate=int(a[0]["sample_rate"]), ch=a[0]["channels"])
def make_inputs():
if os.path.isdir(work):
for r, ds, fs in os.walk(work):
for x in ds + fs:
os.chmod(os.path.join(r, x), 0o755)
shutil.rmtree(work)
os.makedirs(IN); os.makedirs(OUT)
def tone(name, secs, rate, ch, a4=440.0):
t = np.arange(int(secs * rate)) / rate
x = 0.3 * np.sin(2 * np.pi * a4 * t) + 0.2 * np.sin(2 * np.pi * a4 / 2 * t)
x = np.repeat(x[:, None], ch, 1).astype(np.float32)
subprocess.run(["ffmpeg", "-y", "-v", "error", "-f", "f32le", "-ar", str(rate), "-ac", str(ch), "-i", "-",
"-c:a", "pcm_s24le", os.path.join(IN, name)], input=x.tobytes(), check=True)
tone("Song One.wav", 30, 48000, 2)
tone("song-two (club mix).wav", 12, 44100, 2)
tone("Mono Demo.wav", 8, 48000, 1)
tone("Écoute finale.wav", 10, 48000, 2)
tone("Orchestral_442.wav", 10, 48000, 2, a4=442.0) # recorded against A=442
tone("Already_432_Master.wav", 10, 48000, 2, a4=432.0) # previously retuned
open(os.path.join(IN, "partial_download.wav"), "wb").write(b"RIFF\x00\x10\x00\x00WAVEfmt " + os.urandom(64))
open(os.path.join(IN, "~session.tmp"), "wb").write(b"junk")
# the operator documents real source references; anything unlisted is assumed 440
with open(os.path.join(work, "references.csv"), "w", newline="") as f:
csv.writer(f).writerows([["file", "source_hz"], ["Orchestral_442.wav", "442"],
["Already_432_Master.wav", "432"]])
# leftover from a "power cut" during an earlier run
open(os.path.join(OUT, "Song One_432.wav.part"), "wb").write(b"half a file")
for f in os.listdir(IN): # inputs are read-only from here on
os.chmod(os.path.join(IN, f), stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH)
def run_batch(label):
refs = {r["file"]: float(r["source_hz"]) for r in csv.DictReader(open(os.path.join(work, "references.csv")))}
for f in os.listdir(OUT): # stale temp files are never "done"
if f.endswith(".part"):
os.remove(os.path.join(OUT, f)); print(f" cleaned stale temp: {f}")
rows, need_bytes = [], 0
files = sorted(os.listdir(IN))
for f in files:
src = os.path.join(IN, f)
base, ext = os.path.splitext(f)
dst = os.path.join(OUT, f"{base}_{TARGET}.wav")
ref = refs.get(f, 440.0)
row = dict(file=f, source_hz=ref, status="", detail="", in_s="", out_s="")
info = probe(src) if ext.lower() in (".wav", ".flac", ".aif", ".aiff") else None
if ext.lower() not in (".wav", ".flac", ".aif", ".aiff"):
row.update(status="SKIP", detail="not an audio master")
elif info is None:
row.update(status="ERROR", detail="decoder could not read file")
elif ref == TARGET:
row.update(status="SKIP", detail="source already at target; refusing to stack")
elif os.path.exists(dst):
row.update(status="SKIP", detail="verified output exists", in_s=f"{info['dur']:.3f}")
else:
ratio = TARGET / ref
chain = (f"aresample={int(ref*200)}:resampler=soxr:precision=28,asetrate={TARGET*200},"
f"aresample={info['rate']}:resampler=soxr:precision=28")
tmp = dst + ".part"
r = sh(["ffmpeg", "-n", "-v", "error", "-i", src, "-af", chain, "-c:a", "pcm_s24le", "-f", "wav", tmp])
o = probe(tmp) if r.returncode == 0 else None
exp = info["dur"] / ratio
if o and abs(o["dur"] - exp) < 0.005 and o["rate"] == info["rate"] and o["ch"] == info["ch"]:
os.replace(tmp, dst) # atomic: a file is either whole or absent
row.update(status="OK", detail=f"ratio {ratio:.6f} ({1200*math.log2(ratio):+.2f} c), "
f"{o['rate']} Hz/{o['ch']} ch", in_s=f"{info['dur']:.3f}", out_s=f"{o['dur']:.3f}")
need_bytes += os.path.getsize(dst)
else:
if os.path.exists(tmp): os.remove(tmp)
row.update(status="ERROR", detail="verification failed: " + (r.stderr.strip()[:60] or "duration/format"))
rows.append(row)
new = not os.path.exists(LOG)
with open(LOG, "a", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=["run", *rows[0].keys()])
if new: w.writeheader()
for r in rows: w.writerow({"run": label, **r})
print(f" {'file':26s} {'src Hz':>6s} {'status':6s} {'in s':>7s} {'out s':>7s} detail")
for r in rows:
print(f" {r['file']:26s} {r['source_hz']:6.0f} {r['status']:6s} {r['in_s']:>7s} {r['out_s']:>7s} {r['detail']}")
ok = sum(r["status"] == "OK" for r in rows); sk = sum(r["status"] == "SKIP" for r in rows)
er = sum(r["status"] == "ERROR" for r in rows)
outs = [f for f in os.listdir(OUT) if f.endswith(".wav")]
print(f" inputs {len(files)} | rendered {ok} | skipped {sk} | errors {er} | wavs in output {len(outs)}")
return rows
make_inputs()
du = shutil.disk_usage(work)
pred = sum((probe(os.path.join(IN, f)) or {"dur": 0, "rate": 0, "ch": 0}).get("dur", 0) * 48000 * 2 * 3 / (432/440)
for f in os.listdir(IN))
print(f"preflight: free {du.free/1e9:.1f} GB, predicted lossless output ~{pred/1e6:.1f} MB (worst case 48k/24-bit/stereo)")
print("\nRUN 1")
run_batch("1")
print("\nRUN 2 (same command again, nothing should be re-rendered)")
run_batch("2")
print("\ninputs still read-only:", all(not os.access(os.path.join(IN, f), os.W_OK) for f in os.listdir(IN)))
print("log rows written:", sum(1 for _ in open(LOG)) - 1)
r = 48000 * 432 / 440
print(f"\nprecision note: asetrate needs an integer. 48000*432/440 = {r:.2f} -> 47127 gives "
f"{1200*math.log2(47127/48000):.4f} c vs exact {1200*math.log2(432/440):.4f} c")
Real output from the box:
preflight: free 102.5 GB, predicted lossless output ~23.5 MB (worst case 48k/24-bit/stereo)
RUN 1
cleaned stale temp: Song One_432.wav.part
file src Hz status in s out s detail
Already_432_Master.wav 432 SKIP source already at target; refusing to stack
Mono Demo.wav 440 OK 8.000 8.148 ratio 0.981818 (-31.77 c), 48000 Hz/1 ch
Orchestral_442.wav 442 OK 10.000 10.231 ratio 0.977376 (-39.62 c), 48000 Hz/2 ch
Song One.wav 440 OK 30.000 30.556 ratio 0.981818 (-31.77 c), 48000 Hz/2 ch
partial_download.wav 440 ERROR decoder could not read file
song-two (club mix).wav 440 OK 12.000 12.222 ratio 0.981818 (-31.77 c), 44100 Hz/2 ch
~session.tmp 440 SKIP not an audio master
Écoute finale.wav 440 OK 10.000 10.185 ratio 0.981818 (-31.77 c), 48000 Hz/2 ch
inputs 8 | rendered 5 | skipped 2 | errors 1 | wavs in output 5
RUN 2 (same command again, nothing should be re-rendered)
file src Hz status in s out s detail
Already_432_Master.wav 432 SKIP source already at target; refusing to stack
Mono Demo.wav 440 SKIP 8.000 verified output exists
Orchestral_442.wav 442 SKIP 10.000 verified output exists
Song One.wav 440 SKIP 30.000 verified output exists
partial_download.wav 440 ERROR decoder could not read file
song-two (club mix).wav 440 SKIP 12.000 verified output exists
~session.tmp 440 SKIP not an audio master
Écoute finale.wav 440 SKIP 10.000 verified output exists
inputs 8 | rendered 0 | skipped 7 | errors 1 | wavs in output 5
inputs still read-only: True
log rows written: 16
precision note: asetrate needs an integer. 48000*432/440 = 47127.27 -> 47127 gives -31.7767 c vs exact -31.7667 c
What the log is telling you
The stale temp file got cleaned before anything else happened. That's the restart logic doing its job. A .part file is never treated as a finished render.
The already-432 master was refused. Retuning it again would have dropped it another 31.77 cents, to about 424 Hz, and nobody would have noticed until a listener did. Stacked conversions are the most common silent catalog error, and the only defense is documenting the real source reference.
The 442 recording got its own ratio. 432/442 is 0.977376, about −39.62 cents, not the default −31.77. Feed it the 440 recipe and it would land near 434 Hz, which is neither the original nor the target. The next essay, on 432, 435, 442 and the reference pitch you actually started from, goes deep on why the source number matters.
Every duration matches the math. 30.000 becomes 30.556, 12.000 becomes 12.222, 8.000 becomes 8.148. In classic mode, output duration should equal input divided by 0.981818. The script refuses to rename any output that misses by more than 5 ms. A wildly different runtime means the wrong ratio, a second operation, or a truncated file.
Formats survived. The 44.1 kHz club mix came out at 44.1. The mono demo came out mono. The accented filename and the parentheses didn't break anything, because the script passes paths as arguments instead of gluing them into a shell string.
One error is still an error. The partial download failed decode both times, and the summary says so in plain numbers: 8 inputs, 5 rendered, 2 skipped on purpose, 1 error. A queue with one failed file is not a finished catalog. Resolve the error list before you call it done.
Run 2 rendered nothing. Same command, same folder, zero new files, zero overwrites. That's what you want from a job you might need to restart at 2 a.m.
And the precision footnote: the common one-liner asetrate=47127 lands at −31.7767 cents instead of −31.7667. That's a hundredth of a cent, and no one will ever hear it. But the notes should describe what you actually did, and the exact chain costs nothing extra.
Failure modes and the guard that catches each
| Failure | How it shows up | Guard in the job | How you verify |
|---|---|---|---|
| Overwriting a master | Original gone, no undo | Read-only input copy, separate output folder | Input hashes or write test after the run |
| Interrupted render | Truncated WAV with a real name | Temp file + atomic rename | Stale .part cleaned on restart |
| Stacked retune | Already-432 file lands near 424 Hz | Sidecar of real source references; refuse at-target files | SKIP line in the log |
| Wrong source reference | 442 source lands near 434 Hz | Ratio = target ÷ documented source | Ratio and cents printed per file |
| Format drift | 44.1 forced to 48, mono doubled | Resample back to each file's own rate | Rate and channels compared before rename |
| Corrupt input | Queue stops or skips silently | Probe before render, log the error | Input, output, and error counts reconcile |
| Disk fills mid-job | Half a folder, mystery failures | Predict output size at preflight | Free space printed before run 1 |
Verify with meters, then with ears, by risk
Meters first, because they're cheap. Count inputs and outputs and make sure they reconcile against the log. Check duration, sample rate, channel count, and codec with ffprobe. Peak level only matters if the source was already hot: a resample rebuilds the waveform, so sample peaks can shift a little even when loudness doesn't. A master that was already at the ceiling deserves a true-peak look. True peak is what codecs clip covers why.
Then listen, and don't do it at random. Pick the files most likely to expose each kind of failure. The loudest for clipping. The quietest for noise. The longest for interruption. The shortest for edge handling at the start and end. The widest for channel integrity. The most transient-heavy for conversion quality. Six files, listened to properly, beat forty skimmed in the background.
When you set up a new machine or a new FFmpeg build, run a known test tone through it first and measure the result. A 440 Hz sine should come out at 432.00 Hz and 1.85% longer. If it doesn't, nothing else in the folder will be right either.
Windows notes, because most folder jobs happen there
A few things trip up batch work on Windows specifically. Confirm FFmpeg is on your PATH by running ffmpeg -version in a fresh terminal, not the one you had open when you installed it. Long, deeply nested project folders can hit the classic 260-character MAX_PATH limit, which Microsoft documents as still applying unless an app opts in to long paths. So run the job from a short path like D:\retune\. Test a filename with spaces, parentheses, and an accent before the real run, exactly like the synthetic folder above does. And check whether your output folder is inside a cloud-synced directory, because a sync client touching a half-written file is one more thing that can go sideways.
Keep the log with the delivery
The CSV the script writes, with run number, file, source reference, status, durations, and detail, belongs in the delivered folder next to the WAVs. It's the receipt. If someone finds a problem in a year, the log says which run produced the file, from what, at what ratio. Then you can fix one file instead of redoing the catalog.
- Can I batch convert a whole music folder to 432 Hz at once?
- Yes, but approve one representative file first, work from a read-only copy of the folder, and write outputs to a new folder. Verify input and output counts, durations, sample rates, and channels before treating the batch as finished.
- How do I know a batch 432 conversion worked?
- In classic resampling mode, each output should be the input duration divided by 0.981818, with the same sample rate and channel count. Reconcile input, output, and error counts against a log, then listen to the loudest, quietest, longest, shortest, widest, and most transient-heavy files.
- What happens if I retune a file that is already at 432 Hz?
- It drops another 31.77 cents, to about 424 Hz. Batch tools can't hear that, so keep a list of each file's real source reference and refuse files that are already at the target.
- What if some files were recorded at 442 Hz instead of 440?
- Use the documented source in the ratio: 432 divided by 442 is 0.977376, about −39.62 cents. Applying the 440 recipe would land that file near 434 Hz instead of 432.
- Is it safe to restart a batch job after a crash?
- It is if every render writes to a temporary file and is renamed only after it passes checks, and existing outputs are skipped. Then a restart cleans partial files and doesn't overwrite or double-convert finished work.