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.
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/SKILL.md +165 -0
- package/bin/install.js +103 -0
- package/package.json +31 -0
- package/scripts/_common.py +271 -0
- package/scripts/caption.py +157 -0
- package/scripts/cut.py +135 -0
- package/scripts/export.py +112 -0
- package/scripts/fit.py +161 -0
- package/scripts/loudness.py +84 -0
- package/scripts/overlay.py +171 -0
- package/scripts/probe.py +58 -0
- package/scripts/sync.py +205 -0
package/scripts/sync.py
ADDED
|
@@ -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())
|