CoProducer Measure a mix
A row of identical lacquered master tapes on black marble feeding one cyan-lit machine, with a single magenta-lit tape set aside

Batch · 8 October 2026 · 11 min

Batch retune a folder to 432 Hz without damaging a master

NoDAW Labs · Lacquer Vol. 05

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.

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

FailureHow it shows upGuard in the jobHow you verify
Overwriting a masterOriginal gone, no undoRead-only input copy, separate output folderInput hashes or write test after the run
Interrupted renderTruncated WAV with a real nameTemp file + atomic renameStale .part cleaned on restart
Stacked retuneAlready-432 file lands near 424 HzSidecar of real source references; refuse at-target filesSKIP line in the log
Wrong source reference442 source lands near 434 HzRatio = target ÷ documented sourceRatio and cents printed per file
Format drift44.1 forced to 48, mono doubledResample back to each file's own rateRate and channels compared before rename
Corrupt inputQueue stops or skips silentlyProbe before render, log the errorInput, output, and error counts reconcile
Disk fills mid-jobHalf a folder, mystery failuresPredict output size at preflightFree 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.

A free one-pager

The checklist we run before every release.

Loudness, true peak, mono, tails, export settings and the metadata basics, on one page. Leave your email and it opens straight away, or read it here first. No email needed for that.