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.
This commit is contained in:
2026-07-26 12:49:18 -04:00
parent 302afee062
commit ac3bdbcfe9
2 changed files with 66 additions and 15 deletions
+21 -1
View File
@@ -21,7 +21,11 @@ This script:
needed here). needed here).
2. Cross-correlates the audio at each clip boundary to measure the *actual* 2. Cross-correlates the audio at each clip boundary to measure the *actual*
overlap duration from the content itself (not just filename order or 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. 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 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. 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`) | | `--preset` | x265 preset (default: `medium`) |
| `--metadata-from N` | Take container metadata from the Nth input clip, 0-indexed (default: `0`, the first clip) | | `--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`) | | `--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 `--crf`, `--bitrate`, and `--lossless` are mutually exclusive; the default
(no flag) auto-computes a bitrate target from the source clips. (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.
+44 -13
View File
@@ -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): 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 """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 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)) n = min(len(a_tail), len(b_head))
a_tail = a_tail[-n:] a_tail = a_tail[-n:]
b_head = b_head[: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)) kmin = max(1, int(kmin_s * sr))
coarse_step = max(1, int(coarse_step_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)) ks = list(range(kmin, n, coarse_step))
if not ks: if not ks:
return 0.0, 0.0 return 0.0, 0.0, rms
scores = [score(k) for k in ks] scores = [score(k) for k in ks]
best_i = int(np.argmax(scores)) best_i = int(np.argmax(scores))
best_k = ks[best_i] 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_score = s
best_k = k 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: with tempfile.TemporaryDirectory() as tmp:
raws = [] raws = []
for c in clips: for c in clips:
@@ -111,18 +121,33 @@ def detect_overlaps(clips, confidence_threshold):
audios = [load_audio(r) for r in raws] audios = [load_audio(r) for r in raws]
overlaps = [] overlaps = []
discontinuities = []
for i in range(len(audios) - 1): for i in range(len(audios) - 1):
overlap_s, score = best_overlap(audios[i], audios[i + 1]) overlap_s, score, rms = best_overlap(audios[i], audios[i + 1])
if score < confidence_threshold: pair = f"{clips[i].name} -> {clips[i + 1].name}"
print( if score >= confidence_threshold:
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 = 0.0
print(f" {clips[i + 1].name}: trimming {overlap_s:.3f}s of duplicated " print(f" {clips[i + 1].name}: trimming {overlap_s:.3f}s of duplicated "
f"start (confidence {score:.3f})", file=sys.stderr) 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
overlaps.append(overlap_s) 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 return overlaps
@@ -210,6 +235,12 @@ def main():
help="Take container metadata from the Nth clip, 0-indexed (default: 0, the first clip)") 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, parser.add_argument("--confidence-threshold", type=float, default=0.9,
help="Minimum audio cross-correlation confidence to trust an overlap detection (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() args = parser.parse_args()
clips = args.clips clips = args.clips
@@ -222,7 +253,7 @@ def main():
output = args.output or default_output_name(clips) output = args.output or default_output_name(clips)
print(f"Detecting real overlap between {len(clips)} clips via audio cross-correlation...", file=sys.stderr) 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) filter_complex = build_filter(clips, overlaps)