My AI editor sent me a clip at 0.75x speed. My voice measured 85 Hz instead of my normal 110 to 120. Now every render gets this check before I see it.
Read https://jay-burnett-packs.vercel.app/pitch/agent.md and set this up for me.It reads agent.md: install, test and undo steps. Want it by hand instead? Your email below opens the full page.
You're in, the files are below.
Measures the median pitch of a render and fails it if it's more than 10% off your normal voice. Measure yourself once from a clip you know is right, then check every render.
python3 pitch_gate.py --measure good_clip.mp4 # prints your number, e.g. 117 Hz
python3 pitch_gate.py render.mp4 --base 117 # PASS or FAIL (exit 2)
python3 pitch_gate.py render.mp4 --ref good_clip.mp4#!/usr/bin/env python3
"""pitch_gate.py: fail a render if the voice in it is pitched away from your real voice.
Why it exists: my camera files were read at the wrong audio sample rate, so a whole edit
came back slowed to 0.75x and my voice sounded about 25% deeper. The lips still matched
(the video was slowed by the same amount), so nothing else caught it. This does.
First, measure your normal voice once from a clip you know is right:
python3 pitch_gate.py --measure good_clip.mp4
Then check every render against that number (or against the good clip directly):
python3 pitch_gate.py render.mp4 --base 117
python3 pitch_gate.py render.mp4 --ref good_clip.mp4
Exit 0 = PASS, 2 = FAIL. Default tolerance is 10% (--tol 0.10).
It looks for a voice between 60 and 300 Hz, which covers most speaking voices. Very high voices need hi raised in the code.
Needs: ffmpeg on your PATH, Python 3 and numpy (pip install numpy).
Music under the voice can nudge the number, so test the voice-only stem when you have one.
"""
import argparse, os, shutil, subprocess, sys, tempfile, wave
import numpy as np
FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
def median_pitch(path):
"""Median speaking pitch (Hz) from simple autocorrelation on the loud frames."""
with tempfile.TemporaryDirectory() as tmp:
wav = os.path.join(tmp, "a.wav")
subprocess.run([FFMPEG, "-v", "error", "-y", "-i", path, "-vn", "-ac", "1", "-ar", "16000", wav],
check=True, stdin=subprocess.DEVNULL)
w = wave.open(wav)
x = np.frombuffer(w.readframes(w.getnframes()), np.int16) / 32768.0
sr, n = 16000, 640 # 40 ms frames
if len(x) < n * 4:
return float("nan")
rms = np.sqrt((x ** 2).mean())
found = []
for i in range(0, len(x) - n, n):
f = x[i:i + n]
if np.sqrt((f ** 2).mean()) < 0.5 * rms: # skip quiet frames and pauses
continue
f = f - f.mean()
ac = np.correlate(f, f, "full")[n - 1:]
lo, hi = sr // 300, sr // 60 # human voice range, 60 to 300 Hz
k = lo + int(np.argmax(ac[lo:hi]))
if ac[k] > 0.4 * ac[0]: # only frames with a clear pitch
found.append(sr / k)
return float(np.median(found)) if found else float("nan")
ap = argparse.ArgumentParser(description="Fail a render whose voice pitch drifted from your real voice.")
ap.add_argument("render", nargs="?")
ap.add_argument("--measure", metavar="CLIP", help="print the median pitch of a known-good clip and exit")
ap.add_argument("--base", type=float, help="your normal median pitch in Hz (from --measure)")
ap.add_argument("--ref", help="a known-good clip to compare against instead of --base")
ap.add_argument("--tol", type=float, default=0.10, help="allowed drift, 0.10 = 10%%")
a = ap.parse_args()
if a.measure:
print(f"median pitch of {os.path.basename(a.measure)}: {median_pitch(a.measure):.1f} Hz (use it as --base)")
sys.exit(0)
if not a.render or not (a.base or a.ref):
ap.error("give a render plus --base <Hz> or --ref <good clip> (run --measure first to get your number)")
base = median_pitch(a.ref) if a.ref else a.base
got = median_pitch(a.render)
if got != got or base != base: # NaN: no voiced frames found
print("pitch_gate: no clear voice found in one of the files. Check the audio track.")
sys.exit(2)
ratio = got / base
ok = abs(ratio - 1) <= a.tol
print(f"pitch_gate {os.path.basename(a.render)}: {got:.1f} Hz vs {base:.1f} Hz ({ratio:.2f}x) -> {'PASS' if ok else 'FAIL'}")
if not ok:
print(" Most likely a sample-rate or speed mistake (0.75x = audio read at 36 kHz instead of 48 kHz).")
print(" Run the ffprobe line and duration_check.py on the source file before anything else.")
sys.exit(0 if ok else 2)
Needs Python 3, numpy and ffmpeg. Music under the voice can nudge the number, so test the voice-only version when you have one.
Prints the real audio sample rate of any camera file. Mine said 48000. The tool that broke it read it as 36000.
ffprobe -v error -select_streams a:0 -show_entries stream=codec_name,sample_rate,channels -of default=nw=1 your_clip.movBefore any edit starts, the decoded audio has to match the video length (within 0.10 s by default). A sample-rate mix-up makes them disagree (0.75 or 1.33 is the giveaway). It checks the source file. Your editor can still misread it later, which is what pitch_gate.py catches on the render.
python3 duration_check.py camera_file.mov
python3 duration_check.py footage/*.mov --tol 0.10#!/usr/bin/env python3
"""duration_check.py: before any edit starts, the decoded audio has to be as long as the video.
If your tools read the audio at the wrong sample rate, the audio comes out longer or shorter
than the picture. Catch it here, on the source file, before anything gets cut.
python3 duration_check.py camera_file.mov # one file
python3 duration_check.py footage/*.mov --tol 0.10 # a whole card
Exit 0 = every file PASS, 2 = at least one FAIL.
Needs: ffmpeg and ffprobe on your PATH, Python 3.
"""
import argparse, json, shutil, subprocess, sys
FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
FFPROBE = shutil.which("ffprobe") or "ffprobe"
def probe(path):
out = subprocess.run([FFPROBE, "-v", "error", "-show_entries",
"stream=codec_type,sample_rate,duration:format=duration", "-of", "json", path],
capture_output=True, text=True, check=True, stdin=subprocess.DEVNULL).stdout
d = json.loads(out)
vid = next((s for s in d["streams"] if s["codec_type"] == "video"), None)
aud = next((s for s in d["streams"] if s["codec_type"] == "audio"), None)
vdur = float((vid or {}).get("duration") or d["format"]["duration"])
rate = int((aud or {}).get("sample_rate") or 0)
return vdur, rate
def decoded_audio_seconds(path, rate):
"""Decode the audio exactly the way an editor would and count the samples."""
p = subprocess.run([FFMPEG, "-v", "error", "-i", path, "-vn", "-ac", "1", "-ar", str(rate),
"-f", "s16le", "-"], capture_output=True, check=True, stdin=subprocess.DEVNULL)
return len(p.stdout) / 2 / rate
ap = argparse.ArgumentParser()
ap.add_argument("files", nargs="+")
ap.add_argument("--tol", type=float, default=0.10, help="seconds of difference allowed")
a = ap.parse_args()
bad = 0
for f in a.files:
vdur, rate = probe(f)
if not rate:
print(f"{f}: no audio track")
bad += 1
continue
adur = decoded_audio_seconds(f, rate)
ok = abs(adur - vdur) <= a.tol
bad += not ok
print(f"{f}: video {vdur:.2f} s, audio {adur:.2f} s at {rate} Hz -> {'PASS' if ok else 'FAIL'}")
if not ok:
print(f" ratio {adur / vdur:.3f}. 0.75 or 1.33 means a 36k / 48k sample-rate mix-up.")
sys.exit(2 if bad else 0)
Order matters. The cheap checks run on the source, the pitch check runs on every render.
I play the broken clip and the fixed one side by side on my channel.
Watch on YouTube