From ac3bdbcfe9f425e7ff797431798e3c64bdd4deb3 Mon Sep 17 00:00:00 2001 From: ergosteur Date: Sun, 26 Jul 2026 12:49:18 -0400 Subject: [PATCH] Add continuity check: abort if clip audio doesn't actually overlap Distinguishes a genuine audio mismatch at a clip boundary (evidence the inputs aren't really a continuous sequence) from a boundary that's simply too quiet to judge either way, using an RMS floor alongside the existing cross-correlation confidence score. A confident mismatch now aborts before encoding instead of silently splicing unrelated clips together; --allow-discontinuous opts back into the old behavior. --- README.md | 22 ++++++++++++++++- concat_live_clips.py | 59 +++++++++++++++++++++++++++++++++----------- 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3d3c94b..f9bdefa 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,11 @@ This script: needed here). 2. Cross-correlates the audio at each clip boundary to measure the *actual* overlap duration from the content itself (not just filename order or - whole-second creation timestamps, which aren't precise enough). + whole-second creation timestamps, which aren't precise enough). If a + boundary's audio simply doesn't match — a sign the inputs aren't actually + a continuous sequence (wrong order, a missing clip, unrelated files) — the + run aborts before encoding anything, rather than silently splicing + together clips that don't belong together. 3. Trims the duplicated span off the start of each subsequent clip. 4. Re-encodes the video across the joins (trimming mid-GOP HEVC can't be done with a plain stream copy) and concatenates everything into one file. @@ -75,6 +79,22 @@ python .\concat_live_clips.py (2441..2445 | ForEach-Object { "IMG_$_.MOV" }) | `--preset` | x265 preset (default: `medium`) | | `--metadata-from N` | Take container metadata from the Nth input clip, 0-indexed (default: `0`, the first clip) | | `--confidence-threshold N` | Minimum audio cross-correlation confidence required to trust an overlap detection before falling back to 0 (default: `0.9`) | +| `--min-signal-rms N` | Below this audio RMS level (int16 scale), a boundary is considered too quiet to judge continuity, so a low-confidence match there won't trigger an abort (default: `25`) | +| `--allow-discontinuous` | Proceed even if audio at a clip boundary doesn't match, instead of aborting | `--crf`, `--bitrate`, and `--lossless` are mutually exclusive; the default (no flag) auto-computes a bitrate target from the source clips. + +### Continuity check + +Before encoding, each clip boundary's audio is checked for a real match. +Three outcomes: + +- **Confident match** (confidence ≥ `--confidence-threshold`) — the overlap + is measured and trimmed as normal. +- **Too quiet to tell** (signal below `--min-signal-rms`) — treated as 0 + overlap and the run proceeds, since there's no reliable signal either way. +- **Confident mismatch** (enough signal, but it doesn't correlate) — treated + as evidence the clips aren't actually a continuous sequence, and the run + aborts with an error before any encoding happens. Pass + `--allow-discontinuous` to concatenate them anyway. diff --git a/concat_live_clips.py b/concat_live_clips.py index 6cc6385..dfaf671 100755 --- a/concat_live_clips.py +++ b/concat_live_clips.py @@ -65,10 +65,13 @@ def load_audio(path): def best_overlap(a_tail, b_head, sr=SR, kmin_s=0.05, coarse_step_s=0.002, refine_range_s=0.01): """Find the overlap (seconds) that best aligns the tail of clip A with the head of clip B, via normalized cross-correlation, plus a confidence - score in [0, 1].""" + score in [0, 1] and the RMS signal level (on the original int16 scale) + of the compared windows, so a caller can tell a genuine mismatch apart + from both sides just being too quiet to correlate reliably.""" n = min(len(a_tail), len(b_head)) a_tail = a_tail[-n:] b_head = b_head[:n] + rms = float(min(np.sqrt(np.mean(a_tail ** 2)), np.sqrt(np.mean(b_head ** 2)))) kmin = max(1, int(kmin_s * sr)) coarse_step = max(1, int(coarse_step_s * sr)) @@ -83,7 +86,7 @@ def best_overlap(a_tail, b_head, sr=SR, kmin_s=0.05, coarse_step_s=0.002, refine ks = list(range(kmin, n, coarse_step)) if not ks: - return 0.0, 0.0 + return 0.0, 0.0, rms scores = [score(k) for k in ks] best_i = int(np.argmax(scores)) best_k = ks[best_i] @@ -98,10 +101,17 @@ def best_overlap(a_tail, b_head, sr=SR, kmin_s=0.05, coarse_step_s=0.002, refine best_score = s best_k = k - return best_k / sr, best_score + return best_k / sr, best_score, rms -def detect_overlaps(clips, confidence_threshold): +def detect_overlaps(clips, confidence_threshold, min_signal_rms, allow_discontinuous): + """Measure the audio overlap at each clip boundary. Junctions with a + confident match are trimmed accordingly. Junctions with a poor match + AND enough signal to trust that result are treated as evidence the + clips aren't actually a continuous sequence, and abort the run (unless + allow_discontinuous is set) rather than silently stitching together + what might be unrelated clips. Junctions too quiet to judge either way + fall back to 0 overlap, same as a confident 0.""" with tempfile.TemporaryDirectory() as tmp: raws = [] for c in clips: @@ -111,18 +121,33 @@ def detect_overlaps(clips, confidence_threshold): audios = [load_audio(r) for r in raws] overlaps = [] + discontinuities = [] for i in range(len(audios) - 1): - overlap_s, score = best_overlap(audios[i], audios[i + 1]) - if score < confidence_threshold: - print( - f"warning: low-confidence overlap ({score:.3f}) between " - f"{clips[i].name} and {clips[i + 1].name}; treating as 0 overlap", - file=sys.stderr, - ) + overlap_s, score, rms = best_overlap(audios[i], audios[i + 1]) + pair = f"{clips[i].name} -> {clips[i + 1].name}" + if score >= confidence_threshold: + print(f" {clips[i + 1].name}: trimming {overlap_s:.3f}s of duplicated " + f"start (confidence {score:.3f})", file=sys.stderr) + elif rms < min_signal_rms: + print(f"warning: {pair} is too quiet (rms={rms:.0f}) to verify overlap " + f"via audio; assuming continuous with 0 overlap", file=sys.stderr) + overlap_s = 0.0 + else: + print(f"warning: {pair} audio does not match (confidence {score:.3f}, " + f"rms={rms:.0f}) -- these clips may not be a continuous sequence", + file=sys.stderr) + discontinuities.append(pair) overlap_s = 0.0 - print(f" {clips[i + 1].name}: trimming {overlap_s:.3f}s of duplicated " - f"start (confidence {score:.3f})", file=sys.stderr) overlaps.append(overlap_s) + + if discontinuities and not allow_discontinuous: + sys.exit( + "error: audio doesn't match at " + ", ".join(discontinuities) + " -- " + "these clips don't look like a continuous sequence (wrong order, " + "missing clip, or unrelated files?). Re-check the input, or pass " + "--allow-discontinuous to concatenate them anyway." + ) + return overlaps @@ -210,6 +235,12 @@ def main(): help="Take container metadata from the Nth clip, 0-indexed (default: 0, the first clip)") parser.add_argument("--confidence-threshold", type=float, default=0.9, help="Minimum audio cross-correlation confidence to trust an overlap detection (default: 0.9)") + parser.add_argument("--min-signal-rms", type=float, default=25.0, + help="Below this audio RMS level (int16 scale), a clip boundary is considered too quiet " + "to judge continuity and a low-confidence match there won't trigger an error (default: 25)") + parser.add_argument("--allow-discontinuous", action="store_true", + help="Proceed even if audio at a clip boundary doesn't match (normally treated as a " + "sign these aren't actually a continuous sequence, and aborts)") args = parser.parse_args() clips = args.clips @@ -222,7 +253,7 @@ def main(): output = args.output or default_output_name(clips) print(f"Detecting real overlap between {len(clips)} clips via audio cross-correlation...", file=sys.stderr) - overlaps = detect_overlaps(clips, args.confidence_threshold) + overlaps = detect_overlaps(clips, args.confidence_threshold, args.min_signal_rms, args.allow_discontinuous) filter_complex = build_filter(clips, overlaps)