Files
LlamaCasty/scripts/avsync.py
T
gramps af6210082c fix(tools): a negative lip-sync offset shortens the audio track by design
The length check called -74 ms of audio a clock problem on a clip recorded with the global
offset at -100. Trimming the stream head to advance the mix removes exactly that much track,
so the mismatch is the feature working. The verdict now says so instead of sending the next
hour after a clock that is fine.

Same clip: correlation +30 ms (two windows above 0.55), clap contact +34 ms — the two methods
agree, so the offset needs -130 rather than -100.
2026-11-12 14:09:55 -08:00

256 lines
10 KiB
Python

#!/usr/bin/env python3
"""Measure a recording's A/V sync without listening to it.
Prints the three numbers that matter, from the file itself:
1. how long each stream actually is (a length mismatch is a clock problem),
2. how far apart they drift per second (a rate problem),
3. the residual offset between mouth motion and speech (a constant problem).
Usage: avsync.py <recording.mp4> [--ffmpeg <path>] [--crop WxH+X+Y]
Exit code is 0 when the file is measurable, 1 when it is not.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import numpy as np
DEFAULT_FFMPEG = r"C:\Users\gramp\AppData\Roaming\ytLlive\tools\ffmpeg.exe"
def host_path(path: str) -> str:
"""Accept a Windows path under WSL (and a native path everywhere else)."""
if os.name != "nt" and len(path) > 2 and path[1] == ":":
return f"/mnt/{path[0].lower()}/{path[2:].replace(chr(92), '/').lstrip('/')}"
return path
def run(ffmpeg: str, args: list[str]) -> bytes:
proc = subprocess.run([host_path(ffmpeg), "-v", "error", *args], capture_output=True)
if proc.returncode != 0:
sys.stderr.write(proc.stderr.decode("utf-8", "replace"))
raise SystemExit(1)
return proc.stdout
def probe(ffprobe: str, path: str) -> dict:
out = run(ffprobe, ["-show_entries",
"stream=index,codec_type,duration",
"-show_entries", "format=duration",
"-of", "json", path])
return json.loads(out)
def stream_durations(meta: dict) -> tuple[float, float]:
video = audio = 0.0
for s in meta.get("streams", []):
d = float(s.get("duration") or 0)
if s.get("codec_type") == "video" and d:
video = max(video, d)
elif s.get("codec_type") == "audio" and d:
audio = max(audio, d)
return video, audio
def envelopes(ffmpeg: str, path: str, crop: str | None):
audio_bytes = run(ffmpeg, ["-i", path, "-map", "0:a", "-ac", "1", "-ar", "48000",
"-f", "s16le", "-"])
audio = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float64) / 32768.0
filters = []
if crop:
# Accept the everyday "WxH+X+Y" geometry and translate it to the filter's w:h:x:y.
spec = crop.replace("x", ":", 1).replace("+", ":")
if ":" not in spec and crop.count(":") == 3:
spec = crop
filters.append(f"crop={spec}")
filters += ["scale=64:64", "format=gray"]
video_bytes = run(ffmpeg, ["-i", path, "-map", "0:v", "-vf", ",".join(filters),
"-f", "rawvideo", "-"])
frames = np.frombuffer(video_bytes, dtype=np.uint8).reshape(-1, 64, 64).astype(np.float64)
motion = np.abs(np.diff(frames, axis=0)).mean(axis=(1, 2))
motion = np.concatenate([[motion[0] if len(motion) else 0.0], motion])
return audio, motion
def offset_ms(audio: np.ndarray, motion: np.ndarray, fps: float, max_ms: float = 400.0):
"""Cross-correlate mouth motion against speech energy on a 100 Hz grid.
Returns (lag_ms, correlation) where a POSITIVE lag means the sound arrives later than the
mouth movement (audio delayed / video ahead).
"""
grid = 100.0
duration = min(len(audio) / 48000.0, len(motion) / fps)
t = np.arange(0, duration, 1.0 / grid)
def resample(x: np.ndarray, rate: float) -> np.ndarray:
return x[np.clip((t * rate).astype(int), 0, len(x) - 1)]
win = max(1, int(48000 / grid))
n = len(audio) // win
audio_rms = np.sqrt((audio[: n * win].reshape(n, win) ** 2).mean(axis=1))
audio_env = np.interp(t, np.arange(n) / grid, audio_rms)
def smooth(x: np.ndarray) -> np.ndarray:
k = max(3, int(grid * 0.05) | 1)
return np.convolve(x, np.ones(k) / k, mode="same")
def z(x: np.ndarray) -> np.ndarray:
s = x.std()
return (x - x.mean()) / (s if s > 1e-9 else 1.0)
m = z(smooth(resample(motion, fps)))
a = z(smooth(audio_env))
limit = int(grid * max_ms / 1000)
def best_for(m_seg: np.ndarray, a_seg: np.ndarray) -> tuple[float, float]:
best_corr, best_lag = -9.0, 0
for k in range(-limit, limit + 1):
a_part = a_seg[max(0, k): len(a_seg) + min(0, k)]
m_part = m_seg[max(0, -k): len(m_seg) - max(0, k)]
corr = float((m_part * a_part).mean())
if corr > best_corr:
best_corr, best_lag = corr, k
return best_lag / grid * 1000.0, best_corr
# Whole-clip first; then in 3 s windows, because a periodic voice (a spoken alphabet, a
# song) can lock the global search onto the wrong beat. Only windows that correlate
# strongly are trusted, and their median is the answer.
overall_lag, overall_corr = best_for(m, a)
seg = int(3 * grid)
windows = []
for start in range(0, max(1, len(m) - seg), seg):
lag, corr = best_for(m[start:start + seg], a[start:start + seg])
windows.append((start / grid, lag, corr))
strong = sorted(lag for _, lag, corr in windows if corr >= 0.35)
if len(strong) >= 2:
mid = len(strong) // 2
robust = strong[mid] if len(strong) % 2 else (strong[mid - 1] + strong[mid]) / 2
spread = strong[-1] - strong[0]
if spread > 150:
# Windows that disagree this much cannot be averaged into an answer. Say so.
return float("nan"), max(corr for _, _, corr in windows), windows
return robust, max(corr for _, _, corr in windows), windows
if strong:
return float("nan"), max(corr for _, _, corr in windows), windows
return overall_lag, overall_corr, windows
def clap_offset(audio: np.ndarray, motion: np.ndarray, fps: float) -> tuple[float, int]:
"""Pair sharp audio transients with the nearest big visual change and take the median.
A clap is the only event in an ordinary recording with an unmistakable audio spike and an
unmistakable visual spike a fixed distance apart, which is why it beats correlating a mouth
against speech: no periodicity to lock onto wrongly. Returns (median_ms, pair_count) where a
POSITIVE value means the sound arrived later than the picture.
"""
if fps <= 0 or len(motion) < 4:
return float("nan"), 0
win = 240 # 5 ms amplitude windows
n = len(audio) // win
if n < 8:
return float("nan"), 0
amp = np.abs(audio[: n * win].reshape(n, win)).max(axis=1)
times = np.arange(n) * win / 48000.0
threshold = np.percentile(amp, 99.0)
events: list[float] = []
last = -9.0
for t in times[amp >= threshold]:
if t - last > 0.6: # one event per clap, not one per sample of its decay
events.append(float(t))
last = t
deltas = []
for t in events:
lo, hi = int((t - 0.4) * fps), int((t + 0.4) * fps)
if lo < 0 or hi >= len(motion):
continue
peak = (lo + int(np.argmax(motion[lo:hi]))) / fps
deltas.append((t - peak) * 1000.0)
if len(deltas) < 3:
return float("nan"), len(deltas)
# Keep the cluster: an arm entering the frame moves more than the clap itself and lands early.
deltas.sort()
mid = len(deltas) // 2
median = deltas[mid] if len(deltas) % 2 else (deltas[mid - 1] + deltas[mid]) / 2
tight = [d for d in deltas if abs(d - median) <= 60]
if len(tight) < 3:
return float("nan"), len(tight)
return float(np.median(tight)), len(tight)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("recording")
ap.add_argument("--ffmpeg", default=os.environ.get("YTLLIVE_FFMPEG", DEFAULT_FFMPEG))
ap.add_argument("--crop", default=None,
help="webcam region, e.g. 764x764+1032+159 (defaults to the whole frame)")
ap.add_argument("--max-ms", type=float, default=400.0)
args = ap.parse_args()
ffmpeg = args.ffmpeg
ffprobe = ffmpeg.replace("ffmpeg.exe", "ffprobe.exe")
if not os.path.exists(host_path(ffmpeg)):
sys.stderr.write(f"ffmpeg not found: {ffmpeg}\n")
return 1
video, audio = stream_durations(probe(ffprobe, args.recording))
if not video or not audio:
sys.stderr.write("could not read both stream durations\n")
return 1
diff = audio - video
pct = diff / video * 100.0
samples, motion = envelopes(ffmpeg, args.recording, args.crop)
fps = len(motion) / (len(samples) / 48000.0) if len(motion) else 0.0
lag, corr, windows = offset_ms(samples, motion, fps, args.max_ms) if fps > 0 else (0.0, 0.0, [])
print(f"video {video:8.3f} s")
print(f"audio {audio:8.3f} s")
print(f"delta {diff:+8.3f} s ({pct:+.2f}% of the video length)")
if abs(diff) < 0.05:
print(" lengths match")
else:
print(" LENGTH MISMATCH — a clock problem, UNLESS a negative lip-sync offset is set:")
print(f" trimming the stream head by N ms makes the audio track N ms shorter by design")
for start, wlag, wcorr in windows:
mark = "*" if wcorr >= 0.35 else " "
print(f" {start:5.1f}s {wlag:+7.0f} ms corr {wcorr:.2f} {mark}")
if lag != lag or corr < 0.35:
clap, pairs = clap_offset(samples, motion, fps)
if clap == clap:
print(f"offset {clap:+8.0f} ms (clap pairs: {pairs})")
direction = "sound LATER than the lips" if clap > 0 else "sound EARLIER than the lips"
if abs(clap) >= 20:
print(f" {direction} — adjust the mic delay by {-clap:+.0f} ms")
else:
print(" within a frame or two — leave it alone")
return 0
print("offset unmeasurable — too little agreement between mouth motion and speech")
print(f" ({pairs} usable clap pairs) record 3 claps, hands in the webcam frame, 2 s apart")
return 0
print(f"offset {lag:+8.0f} ms (correlation {corr:.2f})")
if abs(lag) >= 20:
direction = "sound LATER than the lips" if lag > 0 else "sound EARLIER than the lips"
print(f" {direction} — adjust the mic delay by {-lag:+.0f} ms")
else:
print(" within a frame or two — leave it alone")
return 0
if __name__ == "__main__":
raise SystemExit(main())