ffmpeg-skill 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env python3
2
+ """Detect the time offset between two recordings by audio cross-correlation
3
+ and (optionally) write a synced output.
4
+
5
+ Pure standard library: both tracks are decoded by ffmpeg to mono 8 kHz PCM,
6
+ reduced to a coarse loudness envelope, and cross-correlated with an FFT
7
+ implemented in Python. Precision is roughly +/- one envelope step (default
8
+ 5 ms), which is plenty for lining up a lav mic or a second camera.
9
+
10
+ Offset semantics: a positive offset means the SECOND input starts LATER
11
+ than the reference, i.e. `second` must be shifted earlier by that amount.
12
+
13
+ Examples:
14
+ python3 sync.py camera.mp4 lavmic.wav # print offset only
15
+ python3 sync.py camera.mp4 lavmic.wav --replace-audio -o synced.mp4
16
+ python3 sync.py camA.mp4 camB.mp4 --trim-second -o camB_synced.mp4
17
+ python3 sync.py cam.mp4 mic.wav --max-offset 60 --json
18
+ """
19
+ import argparse
20
+ import cmath
21
+ import json
22
+ import math
23
+ import os
24
+ import struct
25
+ import subprocess
26
+ import sys
27
+ from typing import List
28
+
29
+ from _common import aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, x264_args
30
+
31
+ SR = 8000 # decode sample rate
32
+
33
+
34
+ def decode_mono(path: str, seconds: float) -> List[float]:
35
+ ffmpeg = require_tool("ffmpeg")
36
+ cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, "-t", f"{seconds:.3f}",
37
+ "-vn", "-ac", "1", "-ar", str(SR), "-f", "s16le", "-"]
38
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
39
+ if proc.returncode != 0 or not proc.stdout:
40
+ die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}")
41
+ n = len(proc.stdout) // 2
42
+ return [v / 32768.0 for v in struct.unpack(f"<{n}h", proc.stdout[: n * 2])]
43
+
44
+
45
+ def envelope(samples: List[float], step: int) -> List[float]:
46
+ """RMS energy per block, mean-removed so silence does not correlate."""
47
+ env = []
48
+ for i in range(0, len(samples) - step + 1, step):
49
+ block = samples[i : i + step]
50
+ env.append(math.sqrt(sum(x * x for x in block) / step))
51
+ if not env:
52
+ return env
53
+ mean = sum(env) / len(env)
54
+ return [e - mean for e in env]
55
+
56
+
57
+ def fft(a: List[complex]) -> List[complex]:
58
+ """Iterative radix-2 Cooley-Tukey FFT. len(a) must be a power of two."""
59
+ n = len(a)
60
+ a = list(a)
61
+ j = 0
62
+ for i in range(1, n):
63
+ bit = n >> 1
64
+ while j & bit:
65
+ j ^= bit
66
+ bit >>= 1
67
+ j ^= bit
68
+ if i < j:
69
+ a[i], a[j] = a[j], a[i]
70
+ length = 2
71
+ while length <= n:
72
+ ang = -2 * math.pi / length
73
+ wlen = complex(math.cos(ang), math.sin(ang))
74
+ half = length // 2
75
+ for i in range(0, n, length):
76
+ w = 1 + 0j
77
+ for k in range(half):
78
+ u = a[i + k]
79
+ v = a[i + k + half] * w
80
+ a[i + k] = u + v
81
+ a[i + k + half] = u - v
82
+ w *= wlen
83
+ length <<= 1
84
+ return a
85
+
86
+
87
+ def ifft(a: List[complex]) -> List[complex]:
88
+ n = len(a)
89
+ conj = [x.conjugate() for x in a]
90
+ out = fft(conj)
91
+ return [x.conjugate() / n for x in out]
92
+
93
+
94
+ def cross_correlate(ref: List[float], other: List[float], max_lag: int):
95
+ n = 1
96
+ while n < len(ref) + len(other):
97
+ n <<= 1
98
+ fa = fft([complex(x) for x in ref] + [0j] * (n - len(ref)))
99
+ fb = fft([complex(x) for x in other] + [0j] * (n - len(other)))
100
+ prod = [x * y.conjugate() for x, y in zip(fa, fb)]
101
+ corr = ifft(prod)
102
+ # corr[k] = sum ref[i+k]*other[i] -> lag k means 'other' is delayed by k relative to ref? see below
103
+ best_lag, best_val = 0, -float("inf")
104
+ max_lag = min(max_lag, n // 2 - 1)
105
+ for lag in range(-max_lag, max_lag + 1):
106
+ val = corr[lag % n].real
107
+ if val > best_val:
108
+ best_val, best_lag = val, lag
109
+ energy = math.sqrt(sum(x * x for x in ref) * sum(x * x for x in other)) or 1.0
110
+ return best_lag, best_val / energy
111
+
112
+
113
+ def main() -> int:
114
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
115
+ ap.add_argument("reference", help="reference recording (usually the camera video)")
116
+ ap.add_argument("second", help="recording to align (external audio or second camera)")
117
+ ap.add_argument("-o", "--output", help="output file when writing a synced result")
118
+ ap.add_argument("--max-offset", type=float, default=30.0, help="largest offset to search in seconds (default 30)")
119
+ ap.add_argument("--analyze-seconds", type=float, default=120.0, help="how much audio to analyse from each file (default 120)")
120
+ ap.add_argument("--step-ms", type=float, default=5.0, help="envelope resolution in ms (default 5)")
121
+ ap.add_argument("--json", action="store_true", help="print the result as JSON")
122
+ mode = ap.add_mutually_exclusive_group()
123
+ mode.add_argument("--replace-audio", action="store_true", help="write reference video with the second file's audio, aligned")
124
+ mode.add_argument("--trim-second", action="store_true", help="write the second file shifted so it lines up with the reference")
125
+ ap.add_argument("--crf", type=int, default=18)
126
+ args = ap.parse_args()
127
+
128
+ for p in (args.reference, args.second):
129
+ if not probe(p).get("audio"):
130
+ die(f"{p} has no audio stream to correlate")
131
+
132
+ step = max(1, int(SR * args.step_ms / 1000))
133
+ ref = envelope(decode_mono(args.reference, args.analyze_seconds), step)
134
+ oth = envelope(decode_mono(args.second, args.analyze_seconds), step)
135
+ if len(ref) < 10 or len(oth) < 10:
136
+ die("not enough audio to analyse")
137
+
138
+ max_lag = int(args.max_offset * SR / step)
139
+ lag, score = cross_correlate(ref, oth, max_lag)
140
+ # With prod = FFT(ref) * conj(FFT(other)), the peak sits at lag k where ref[i] ~ other[i - k]:
141
+ # the same event happens k steps later in the reference than in the second file, which
142
+ # means the second recording STARTED k steps later. Positive offset = second starts later.
143
+ offset = lag * step / SR
144
+
145
+ result = {
146
+ "reference": args.reference,
147
+ "second": args.second,
148
+ "offset_seconds": round(offset, 4),
149
+ "confidence": round(max(0.0, min(1.0, score)), 3),
150
+ "meaning": ("second starts %.3fs %s than reference" % (abs(offset), "later" if offset > 0 else "earlier")),
151
+ }
152
+ if result["confidence"] < 0.1:
153
+ info("warning: low correlation confidence; check that both files contain the same audio event")
154
+
155
+ if args.replace_audio or args.trim_second:
156
+ output = args.output or default_output(args.reference if args.replace_audio else args.second, "synced", "mp4")
157
+ # second started later (offset > 0) -> delay it by `offset` (pad the head)
158
+ # second started earlier (offset < 0) -> drop its first `-offset` seconds
159
+ delay_ms = int(round(offset * 1000))
160
+ head_trim = -offset if offset < 0 else 0.0
161
+ second_meta = probe(args.second)
162
+ has_video = bool(second_meta.get("video"))
163
+
164
+ if args.replace_audio:
165
+ cmd = ffmpeg_base() + ["-i", args.reference]
166
+ if head_trim > 0:
167
+ cmd += ["-ss", f"{head_trim:.4f}"]
168
+ cmd += ["-i", args.second, "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy"]
169
+ if delay_ms > 0:
170
+ cmd += ["-af", f"adelay={delay_ms}:all=1"]
171
+ cmd += aac_args() + ["-shortest", output]
172
+ proc = run(cmd, check=False)
173
+ if proc.returncode != 0:
174
+ cmd = [c for c in cmd if c != "copy"]
175
+ idx = cmd.index("-c:v"); del cmd[idx]
176
+ cmd = cmd[:-1] + x264_args(args.crf) + [output]
177
+ run(cmd)
178
+ else:
179
+ if head_trim > 0:
180
+ cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second, "-c", "copy", "-avoid_negative_ts", "make_zero", output]
181
+ proc = run(cmd, check=False)
182
+ if proc.returncode != 0:
183
+ cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + (x264_args(args.crf) if has_video else []) + audio_codec_for(output) + [output]
184
+ run(cmd)
185
+ else:
186
+ af = f"adelay={delay_ms}:all=1"
187
+ cmd = ffmpeg_base() + ["-i", args.second]
188
+ if has_video:
189
+ cmd += ["-vf", f"tpad=start_duration={offset:.4f}"] + x264_args(args.crf)
190
+ cmd += ["-af", af] + audio_codec_for(output) + [output]
191
+ run(cmd)
192
+ result["output"] = output
193
+ info(f"wrote {output}")
194
+
195
+ if args.json:
196
+ print(json.dumps(result, indent=2))
197
+ else:
198
+ print(f"offset: {result['offset_seconds']:+.3f}s ({result['meaning']}), confidence {result['confidence']:.2f}")
199
+ if "output" in result:
200
+ print(result["output"])
201
+ return 0
202
+
203
+
204
+ if __name__ == "__main__":
205
+ sys.exit(main())