polysync 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (24) hide show
  1. {polysync-0.2.0/src/polysync.egg-info → polysync-0.3.0}/PKG-INFO +1 -1
  2. {polysync-0.2.0 → polysync-0.3.0}/pyproject.toml +1 -1
  3. {polysync-0.2.0 → polysync-0.3.0}/src/polysync/__init__.py +1 -1
  4. polysync-0.3.0/src/polysync/edit/audiomix.py +124 -0
  5. {polysync-0.2.0 → polysync-0.3.0}/src/polysync/edit/render_cuts.py +35 -8
  6. {polysync-0.2.0 → polysync-0.3.0}/src/polysync/edit/render_pip.py +34 -9
  7. {polysync-0.2.0 → polysync-0.3.0/src/polysync.egg-info}/PKG-INFO +1 -1
  8. {polysync-0.2.0 → polysync-0.3.0}/src/polysync.egg-info/SOURCES.txt +1 -0
  9. {polysync-0.2.0 → polysync-0.3.0}/LICENSE +0 -0
  10. {polysync-0.2.0 → polysync-0.3.0}/README.md +0 -0
  11. {polysync-0.2.0 → polysync-0.3.0}/setup.cfg +0 -0
  12. {polysync-0.2.0 → polysync-0.3.0}/src/polysync/audio.py +0 -0
  13. {polysync-0.2.0 → polysync-0.3.0}/src/polysync/cli.py +0 -0
  14. {polysync-0.2.0 → polysync-0.3.0}/src/polysync/edit/__init__.py +0 -0
  15. {polysync-0.2.0 → polysync-0.3.0}/src/polysync/edit/autoedit.py +0 -0
  16. {polysync-0.2.0 → polysync-0.3.0}/src/polysync/edit/grade.py +0 -0
  17. {polysync-0.2.0 → polysync-0.3.0}/src/polysync/sidecar.py +0 -0
  18. {polysync-0.2.0 → polysync-0.3.0}/src/polysync/sync.py +0 -0
  19. {polysync-0.2.0 → polysync-0.3.0}/src/polysync/verify.py +0 -0
  20. {polysync-0.2.0 → polysync-0.3.0}/src/polysync.egg-info/dependency_links.txt +0 -0
  21. {polysync-0.2.0 → polysync-0.3.0}/src/polysync.egg-info/entry_points.txt +0 -0
  22. {polysync-0.2.0 → polysync-0.3.0}/src/polysync.egg-info/requires.txt +0 -0
  23. {polysync-0.2.0 → polysync-0.3.0}/src/polysync.egg-info/top_level.txt +0 -0
  24. {polysync-0.2.0 → polysync-0.3.0}/tests/test_sync_synthetic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polysync
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Multicam audio sync and director-style auto-edit — align N angles of one event by audio cross-correlation, then cut/PiP them into one MP4. Reversible sidecars, never re-encodes the originals.
5
5
  Author: 王建硕 (Jian Shuo Wang)
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "polysync"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Multicam audio sync and director-style auto-edit — align N angles of one event by audio cross-correlation, then cut/PiP them into one MP4. Reversible sidecars, never re-encodes the originals."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -11,7 +11,7 @@ Public API:
11
11
  from .sync import compute_sync, SyncResult, SyncError
12
12
  from .sidecar import read_sidecar, write_sidecar, sidecar_path, SCHEMA_VERSION
13
13
 
14
- __version__ = "0.2.0"
14
+ __version__ = "0.3.0"
15
15
  __all__ = [
16
16
  "compute_sync", "SyncResult", "SyncError",
17
17
  "read_sidecar", "write_sidecar", "sidecar_path", "SCHEMA_VERSION",
@@ -0,0 +1,124 @@
1
+ """Speaker-gated ("ducked") audio mix for multicam interviews.
2
+
3
+ The default render takes a single camera's mic as the soundtrack. With close,
4
+ bleeding mics that's noisy: every mic also picks up the *other* speaker plus room
5
+ tone, so a constant sum sounds muddy and loudness-normalization pumps the bleed
6
+ up during pauses. This builds a cleaner track instead: per moment, keep only the
7
+ ACTIVE speaker's mic at full level and duck the rest.
8
+
9
+ Who's active is decided by each mic's energy relative to ITS OWN baseline (not
10
+ absolute level) — that's what tracks the talker despite a louder close mic
11
+ bleeding. Far/room mics (e.g. a wide establishing cam) are auto-excluded: any
12
+ mic whose overall level is >`exclude_db` below the loudest is dropped as an
13
+ audio candidate so the reverby room mic is never selected.
14
+
15
+ `build_ducked_audio` returns a finished wav (gated → high-pass → light denoise →
16
+ loudness-normalized). The renderers use it in place of the single-cam audio when
17
+ `--duck-audio` is passed.
18
+ """
19
+ import subprocess
20
+ import tempfile
21
+ from pathlib import Path
22
+
23
+ import numpy as np
24
+
25
+ from .. import audio
26
+
27
+
28
+ def _aligned_mic(path, delta, sr, n):
29
+ """Extract a cam's loudest mic at `sr`, shifted into the reference timeline
30
+ (so index t corresponds to reference second t/sr), length `n` samples."""
31
+ with tempfile.NamedTemporaryFile(suffix=".pcm", delete=False) as tf:
32
+ tmp = tf.name
33
+ audio.extract_pcm(path, tmp, sr) # loudest stream, mono
34
+ x = audio.read_pcm(tmp)
35
+ Path(tmp).unlink(missing_ok=True)
36
+ pad = int(round(delta * sr))
37
+ if pad > 0:
38
+ x = np.concatenate([np.zeros(pad, np.float32), x])
39
+ elif pad < 0:
40
+ x = x[-pad:]
41
+ if len(x) < n:
42
+ x = np.pad(x, (0, n - len(x)))
43
+ return x[:n]
44
+
45
+
46
+ def build_ducked_audio(inputs, deltas, coverage, duration, out_path, sr=48000,
47
+ duck_db=-18.0, frame_ms=100.0, margin=0.20,
48
+ exclude_db=14.0, audio_cams=None, verbose=True):
49
+ """Write a speaker-gated, cleaned wav to `out_path`. Returns out_path.
50
+
51
+ `audio_cams` (list of cam indices) explicitly picks which mics to gate among
52
+ — use it to exclude a wide/room mic that sits at a similar LEVEL to a real
53
+ speaker mic (level alone can't tell a close lav from a near room mic). If
54
+ None, fall back to dropping any mic >`exclude_db` below the loudest.
55
+ """
56
+ n = int(duration * sr)
57
+ mics = [_aligned_mic(p, d, sr, n) for p, d in zip(inputs, deltas)]
58
+
59
+ lvl = np.array([20 * np.log10(np.sqrt(np.mean(m ** 2)) + 1e-6) for m in mics])
60
+ if audio_cams:
61
+ keep = [k for k in audio_cams if 0 <= k < len(mics)]
62
+ else:
63
+ keep = [k for k in range(len(mics)) if lvl[k] >= lvl.max() - exclude_db]
64
+ if verbose:
65
+ print(" mic levels(dB): %s; audio candidates: %s"
66
+ % ([round(float(x), 1) for x in lvl], keep))
67
+
68
+ hop = int(frame_ms / 1000 * sr)
69
+ nf = n // hop
70
+
71
+ def frame_logE(x):
72
+ return np.array([np.log(np.sqrt(np.mean(x[i*hop:(i+1)*hop] ** 2) + 1) + 1)
73
+ for i in range(nf)])
74
+
75
+ # coverage mask per cam (frames where the cam has valid footage in ref time)
76
+ cov = np.zeros((len(mics), nf), dtype=bool)
77
+ for k in range(len(mics)):
78
+ s, e = coverage[k] if k < len(coverage) else (0.0, duration)
79
+ cov[k, max(0, int(s/ (frame_ms/1000))): int(e/(frame_ms/1000))] = True
80
+
81
+ # baseline-normalized energy per candidate
82
+ norm = np.full((len(mics), nf), -1e9)
83
+ for k in keep:
84
+ E = frame_logE(mics[k])
85
+ base = np.median(E[cov[k]]) if cov[k].any() else np.median(E)
86
+ norm[k] = np.where(cov[k], E - base, -1e9)
87
+
88
+ # active cam per frame = argmax normalized among covered candidates
89
+ active = np.full(nf, keep[0], dtype=int)
90
+ for f in range(nf):
91
+ vals = [(norm[k, f], k) for k in keep if cov[k, f]]
92
+ if vals:
93
+ active[f] = max(vals)[1]
94
+
95
+ # gain mask per cam (active=1 else duck), smoothed to crossfade
96
+ duck = 10 ** (duck_db / 20.0)
97
+ out = np.zeros(n, dtype=np.float32)
98
+ ker = np.ones(int(0.2 * sr)) / int(0.2 * sr)
99
+ for k in range(len(mics)):
100
+ if k not in keep:
101
+ continue
102
+ gf = np.where(active == k, 1.0, duck)
103
+ gs = np.repeat(gf, hop)
104
+ gs = np.pad(gs, (0, n - len(gs)), mode="edge")
105
+ gs = np.convolve(gs, ker, "same")
106
+ out += mics[k] * gs
107
+
108
+ pk = np.max(np.abs(out))
109
+ if pk > 0:
110
+ out *= 0.95 * 32767 / pk
111
+ with tempfile.NamedTemporaryFile(suffix=".pcm", delete=False) as tf:
112
+ raw = tf.name
113
+ out.astype(np.int16).tofile(raw)
114
+
115
+ # high-pass rumble, light FFT denoise, loudness-normalize -> finished wav
116
+ subprocess.run(
117
+ ["ffmpeg", "-nostdin", "-y", "-hide_banner", "-loglevel", "error",
118
+ "-f", "s16le", "-ar", str(sr), "-ac", "1", "-i", raw,
119
+ "-af", "highpass=f=70,afftdn=nr=10,loudnorm=I=-16:TP=-1.5:LRA=11",
120
+ "-ar", str(sr), "-ac", "2", str(out_path)],
121
+ check=True,
122
+ )
123
+ Path(raw).unlink(missing_ok=True)
124
+ return out_path
@@ -10,28 +10,42 @@ delivery (小红书 / Reels / Shorts) pass `--width 1080 --height 1920 --fill`.
10
10
  import argparse
11
11
  import json
12
12
  import subprocess
13
+ import tempfile
13
14
  from pathlib import Path
14
15
 
15
16
  from .grade import resolve_lut, parse_rotate, segment_filter
17
+ from .audiomix import build_ducked_audio
16
18
 
17
19
 
18
20
  def render_cuts(edl_path, out, encoder="hevc_videotoolbox", bitrate="12M",
19
21
  width=1920, height=1080, fps=30, lut=None, log=None,
20
- rotate=None, fill=False, run=True):
22
+ rotate=None, fill=False, duck_audio=False, duck_db=-18.0,
23
+ audio_cams=None, run=True):
21
24
  plan = json.loads(Path(edl_path).read_text())
22
25
  inputs = plan["inputs"]
23
26
  deltas = plan.get("deltas", [0.0] * len(inputs))
24
27
  edl = plan["edl"]
25
28
  audio_src = plan["audio_source"]
29
+ duration = plan["duration_sec"]
26
30
  W, H = width, height
27
31
  lut_path = resolve_lut(lut, log)
28
32
  rot = parse_rotate(rotate)
29
33
 
34
+ # Speaker-gated soundtrack: build a cleaned wav up front, use it as the audio.
35
+ ducked_wav = None
36
+ if duck_audio:
37
+ coverage = plan.get("coverage", [[0.0, duration]] * len(inputs))
38
+ ducked_wav = str(Path(tempfile.mkdtemp()) / "ducked.wav")
39
+ build_ducked_audio(inputs, deltas, coverage, duration, ducked_wav,
40
+ duck_db=duck_db, audio_cams=audio_cams)
41
+
30
42
  cmd = ["ffmpeg", "-nostdin", "-y"]
31
43
  for src, dlt in zip(inputs, deltas):
32
44
  if abs(dlt) > 1e-9:
33
45
  cmd.extend(["-itsoffset", "%.6f" % dlt])
34
46
  cmd.extend(["-i", src])
47
+ if ducked_wav:
48
+ cmd.extend(["-i", ducked_wav]) # extra input, already ref-aligned
35
49
 
36
50
  filters = [
37
51
  segment_filter(row["cam"], row["start"], row["end"], i, W, H, fps,
@@ -42,13 +56,16 @@ def render_cuts(edl_path, out, encoder="hevc_videotoolbox", bitrate="12M",
42
56
  filters.append("%sconcat=n=%d:v=1:a=0[vout]" % (concat, len(edl)))
43
57
  fc = ";".join(filters)
44
58
 
45
- audio_offset = edl[0]["start"] if edl else 0.0
46
- duration = plan["duration_sec"]
47
- fc += (";[%d:a:0]atrim=start=%s:duration=%s,asetpts=PTS-STARTPTS[aout]"
48
- % (audio_src, audio_offset, duration))
59
+ cmd.extend(["-filter_complex", fc, "-map", "[vout]"])
60
+ if ducked_wav:
61
+ cmd.extend(["-map", "%d:a:0" % len(inputs)])
62
+ else:
63
+ audio_offset = edl[0]["start"] if edl else 0.0
64
+ fc2 = ("[%d:a:0]atrim=start=%s:duration=%s,asetpts=PTS-STARTPTS[aout]"
65
+ % (audio_src, audio_offset, duration))
66
+ cmd[cmd.index("-filter_complex") + 1] = fc + ";" + fc2
67
+ cmd.extend(["-map", "[aout]"])
49
68
  cmd.extend([
50
- "-filter_complex", fc,
51
- "-map", "[vout]", "-map", "[aout]",
52
69
  "-t", str(duration),
53
70
  "-c:v", encoder, "-b:v", bitrate, "-tag:v", "hvc1",
54
71
  "-c:a", "aac", "-b:a", "192k",
@@ -75,10 +92,20 @@ def main(argv=None):
75
92
  help="per-cam rotation CAM:DEG (90=CW), repeatable")
76
93
  ap.add_argument("--fill", action="store_true",
77
94
  help="crop to fill instead of letterbox-pad (use for vertical)")
95
+ ap.add_argument("--duck-audio", action="store_true",
96
+ help="speaker-gated soundtrack: keep the active speaker's mic, "
97
+ "duck the rest (cleaner than a single-cam mic for interviews)")
98
+ ap.add_argument("--duck-db", type=float, default=-18.0,
99
+ help="level of ducked (inactive) mics, dB (default -18)")
100
+ ap.add_argument("--audio-cams",
101
+ help="comma-separated cam indices to gate among (e.g. 0,1) — "
102
+ "exclude wide/room mics; default = auto by level")
78
103
  args = ap.parse_args(argv)
104
+ cams = [int(x) for x in args.audio_cams.split(",")] if args.audio_cams else None
79
105
  render_cuts(args.edl, args.out, encoder=args.encoder, bitrate=args.bitrate,
80
106
  width=args.width, height=args.height, fps=args.fps,
81
- lut=args.lut, log=args.log, rotate=args.rotate, fill=args.fill)
107
+ lut=args.lut, log=args.log, rotate=args.rotate, fill=args.fill,
108
+ duck_audio=args.duck_audio, duck_db=args.duck_db, audio_cams=cams)
82
109
 
83
110
 
84
111
  if __name__ == "__main__":
@@ -9,9 +9,11 @@ Per-segment EDL rows may carry a `pip` field (cam index) to override the picker.
9
9
  import argparse
10
10
  import json
11
11
  import subprocess
12
+ import tempfile
12
13
  from pathlib import Path
13
14
 
14
15
  from .grade import resolve_lut, parse_rotate, _transpose_chain
16
+ from .audiomix import build_ducked_audio
15
17
 
16
18
  POSITIONS = {
17
19
  "bottom-right": ("W-w-{m}", "H-h-{m}"),
@@ -43,18 +45,26 @@ def pick_pip(row, K, coverage, mode="next"):
43
45
  def render_pip(edl_path, out, encoder="hevc_videotoolbox", bitrate="12M",
44
46
  width=1920, height=1080, fps=30, pip="bottom-right",
45
47
  pip_width=480, pip_margin=24, border_px=4, pip_pick="next",
46
- lut=None, log=None, rotate=None, run=True):
48
+ lut=None, log=None, rotate=None, duck_audio=False, duck_db=-18.0,
49
+ audio_cams=None, run=True):
47
50
  plan = json.loads(Path(edl_path).read_text())
48
51
  inputs = plan["inputs"]
49
52
  deltas = plan.get("deltas", [0.0] * len(inputs))
50
53
  edl = plan["edl"]
51
54
  audio_src = plan["audio_source"]
55
+ duration = plan["duration_sec"]
52
56
  K = len(inputs)
53
- coverage = plan.get("coverage", [[0.0, plan["duration_sec"]]] * K)
57
+ coverage = plan.get("coverage", [[0.0, duration]] * K)
54
58
  lut_path = resolve_lut(lut, log)
55
59
  rot = parse_rotate(rotate)
56
60
  grade = ("lut3d=%s," % lut_path) if lut_path else ""
57
61
 
62
+ ducked_wav = None
63
+ if duck_audio:
64
+ ducked_wav = str(Path(tempfile.mkdtemp()) / "ducked.wav")
65
+ build_ducked_audio(inputs, deltas, coverage, duration, ducked_wav,
66
+ duck_db=duck_db, audio_cams=audio_cams)
67
+
58
68
  W, H = width, height
59
69
  pw = pip_width
60
70
  ph = round(pw * 9 / 16)
@@ -68,6 +78,8 @@ def render_pip(edl_path, out, encoder="hevc_videotoolbox", bitrate="12M",
68
78
  if abs(dlt) > 1e-9:
69
79
  cmd.extend(["-itsoffset", "%.6f" % dlt])
70
80
  cmd.extend(["-i", src])
81
+ if ducked_wav:
82
+ cmd.extend(["-i", ducked_wav]) # extra input, already ref-aligned
71
83
 
72
84
  filters = []
73
85
  for i, row in enumerate(edl):
@@ -103,14 +115,19 @@ def render_pip(edl_path, out, encoder="hevc_videotoolbox", bitrate="12M",
103
115
 
104
116
  concat = "".join("[v%d]" % i for i in range(len(edl)))
105
117
  filters.append("%sconcat=n=%d:v=1:a=0[vout]" % (concat, len(edl)))
106
- audio_offset = edl[0]["start"] if edl else 0.0
107
- dur = plan["duration_sec"]
118
+ dur = duration
108
119
  fc = ";".join(filters)
109
- fc += (";[%d:a:0]atrim=start=%s:duration=%s,asetpts=PTS-STARTPTS[aout]"
110
- % (audio_src, audio_offset, dur))
120
+ cmd.extend(["-filter_complex", None, "-map", "[vout]"]) # fc filled below
121
+ if ducked_wav:
122
+ cmd[cmd.index("-filter_complex") + 1] = fc
123
+ cmd.extend(["-map", "%d:a:0" % K])
124
+ else:
125
+ audio_offset = edl[0]["start"] if edl else 0.0
126
+ fc += (";[%d:a:0]atrim=start=%s:duration=%s,asetpts=PTS-STARTPTS[aout]"
127
+ % (audio_src, audio_offset, dur))
128
+ cmd[cmd.index("-filter_complex") + 1] = fc
129
+ cmd.extend(["-map", "[aout]"])
111
130
  cmd.extend([
112
- "-filter_complex", fc,
113
- "-map", "[vout]", "-map", "[aout]",
114
131
  "-t", str(dur),
115
132
  "-c:v", encoder, "-b:v", bitrate, "-tag:v", "hvc1",
116
133
  "-c:a", "aac", "-b:a", "192k",
@@ -141,12 +158,20 @@ def main(argv=None):
141
158
  ap.add_argument("--log", help="built-in log->Rec.709 grade (e.g. slog3)")
142
159
  ap.add_argument("--rotate", action="append",
143
160
  help="per-cam rotation CAM:DEG (90=CW), repeatable")
161
+ ap.add_argument("--duck-audio", action="store_true",
162
+ help="speaker-gated soundtrack (keep active speaker's mic, duck rest)")
163
+ ap.add_argument("--duck-db", type=float, default=-18.0,
164
+ help="level of ducked (inactive) mics, dB (default -18)")
165
+ ap.add_argument("--audio-cams",
166
+ help="comma-separated cam indices to gate among (e.g. 0,1)")
144
167
  args = ap.parse_args(argv)
168
+ cams = [int(x) for x in args.audio_cams.split(",")] if args.audio_cams else None
145
169
  render_pip(args.edl, args.out, encoder=args.encoder, bitrate=args.bitrate,
146
170
  width=args.width, height=args.height, fps=args.fps, pip=args.pip,
147
171
  pip_width=args.pip_width, pip_margin=args.pip_margin,
148
172
  border_px=args.border_px, pip_pick=args.pip_pick,
149
- lut=args.lut, log=args.log, rotate=args.rotate)
173
+ lut=args.lut, log=args.log, rotate=args.rotate,
174
+ duck_audio=args.duck_audio, duck_db=args.duck_db, audio_cams=cams)
150
175
 
151
176
 
152
177
  if __name__ == "__main__":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polysync
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Multicam audio sync and director-style auto-edit — align N angles of one event by audio cross-correlation, then cut/PiP them into one MP4. Reversible sidecars, never re-encodes the originals.
5
5
  Author: 王建硕 (Jian Shuo Wang)
6
6
  License: MIT
@@ -14,6 +14,7 @@ src/polysync.egg-info/entry_points.txt
14
14
  src/polysync.egg-info/requires.txt
15
15
  src/polysync.egg-info/top_level.txt
16
16
  src/polysync/edit/__init__.py
17
+ src/polysync/edit/audiomix.py
17
18
  src/polysync/edit/autoedit.py
18
19
  src/polysync/edit/grade.py
19
20
  src/polysync/edit/render_cuts.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes