On ty-20261112-1401-952-0000-2.mp4 the per-window estimates ranged from +400 to -370 ms and only one window cleared 0.35, yet the tool printed "-370 ms, adjust by +370" from an overall correlation of 0.08. A number that wrong is worse than no number: it invites another build. Now: windows above 0.35 must also agree within 150 ms, and anything weaker reports "unmeasurable" with the remedy — three claps with the hands in the webcam frame, two seconds apart, which gives a sharp visual and audio transient instead of a mouth-motion proxy. Also reverted an onset-based envelope tried on the same clip: it dropped the best window's correlation from 0.47 to 0.08, so the plain level envelope stays. What the run did establish: lengths match (15.555 s video against 15.601 s audio, 0.3%), and the log confirms "mic delay 350 ms" was applied — the drift fix and the delay wiring both hold.
197 lines
7.7 KiB
Python
197 lines
7.7 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 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)")
|
|
verdict = "lengths match" if abs(diff) < 0.05 else "LENGTH MISMATCH — a clock problem"
|
|
print(f" {verdict}")
|
|
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:
|
|
print("offset unmeasurable — too little agreement between mouth motion and speech")
|
|
print(" record 3 claps with your 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())
|