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:
@@ -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.
|
||||||
|
|||||||
+45
-14
@@ -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 "
|
print(f" {clips[i + 1].name}: trimming {overlap_s:.3f}s of duplicated "
|
||||||
f"{clips[i].name} and {clips[i + 1].name}; treating as 0 overlap",
|
f"start (confidence {score:.3f})", file=sys.stderr)
|
||||||
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
|
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)
|
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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user