tictacsync 0.4a0__tar.gz → 0.5a0__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.

Potentially problematic release.


This version of tictacsync might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tictacsync
3
- Version: 0.4a0
3
+ Version: 0.5a0
4
4
  Summary: command for syncing audio video recordings
5
5
  Home-page: https://tictacsync.org/
6
6
  Author: Raymond Lutz
@@ -29,15 +29,21 @@ Unfinished sloppy code ahead, but should run without errors. Some functionalitie
29
29
 
30
30
  ## Description
31
31
 
32
- `tictacsync` is a python script to sync audio and video files shot
33
- with [dual system sound](https://www.learnlightandsound.com/blog/2017/2/23/how-to-record-sound-for-video-dual-systemsync-sound) using a specific hardware timecode generator
34
- called [Tic Tac Sync](https://tictacsync.org). The timecode is named YaLTC for *yet
35
- another longitudinal time code* and should be recorded on a scratch
36
- track on each device for the syncing to be performed, later in _postprod_ before editing.
37
-
32
+ `tictacsync` is a python script to sync, cut and join audio files against camera files shot using a specific hardware timecode generator
33
+ called [Tic Tac Sync](https://tictacsync.org). The timecode is named TicTacCode and should be recorded on a scratch
34
+ track on each device for `tictacsync` to work.
38
35
  ## Status
39
36
 
40
- `tictacsync` scans for audio video files and displays their starting time and then merges overlapping audio and video recordings. Multicam syncing with one stereo audio recorder has been tested (spring 2023, [see demo](https://youtu.be/pklTSTi7cqs)). Multi audio recorders coming soon...
37
+ Feature complete! `tictacsync` scans for audio video files and then merges overlapping audio and video recordings, It
38
+
39
+ * Decodes the TicTacCode audio track alongside your audio tracks
40
+ * Establishes UTC start time (and end time) within 100 μs!
41
+ * Syncs, cuts and joins any concurrent audio to camera files (using `FFmpeg`)
42
+ * Processes _multiple_ audio recorders
43
+ * Corrects device clock drift so _both_ ends coincide (thanks to `sox`)
44
+ * Sets video metadata TC of multicam files for NLE timeline alignement
45
+ * Writes _synced_ ISO files with dedicated file names declared in `tracks.txt`
46
+ * Produces nice plots.
41
47
 
42
48
 
43
49
  ## Installation
@@ -77,7 +83,7 @@ For a one line output (or to suppress the progress bars) use the `--terse` flag:
77
83
  > tictacsync --terse dailies/loose/MVI_0024.MP4
78
84
  dailies/loose/MVI_0024.MP4 UTC:2024-03-12 23:07:01.4281 pulse: 27450 in chan 0
79
85
 
80
- To also produce _synced_ ISO audio files, specify `--isos` . A directory named `ISOs` will contain _for each synced video_ a set of audio files of exact same length, padded or trimmed to coincide with the video track. After re-editing and re-mixing a `remergemix` command will resync the new sound track with the video [TODO].
86
+ To also produce _synced_ ISO audio files, specify `--isos` . A directory named `ISOs` will contain _for each synced video_ a set of ISO audio files of exact same length, padded or trimmed to coincide with the video track. After re-editing and re-mixing a `remergemix` command will resync the new audio with the video and _the new sound track will be updated on your NLE timeline_, at least in Kdenlive...
81
87
 
82
88
  > tictacsync --isos dailies/structured
83
89
 
@@ -6,15 +6,21 @@ Unfinished sloppy code ahead, but should run without errors. Some functionalitie
6
6
 
7
7
  ## Description
8
8
 
9
- `tictacsync` is a python script to sync audio and video files shot
10
- with [dual system sound](https://www.learnlightandsound.com/blog/2017/2/23/how-to-record-sound-for-video-dual-systemsync-sound) using a specific hardware timecode generator
11
- called [Tic Tac Sync](https://tictacsync.org). The timecode is named YaLTC for *yet
12
- another longitudinal time code* and should be recorded on a scratch
13
- track on each device for the syncing to be performed, later in _postprod_ before editing.
14
-
9
+ `tictacsync` is a python script to sync, cut and join audio files against camera files shot using a specific hardware timecode generator
10
+ called [Tic Tac Sync](https://tictacsync.org). The timecode is named TicTacCode and should be recorded on a scratch
11
+ track on each device for `tictacsync` to work.
15
12
  ## Status
16
13
 
17
- `tictacsync` scans for audio video files and displays their starting time and then merges overlapping audio and video recordings. Multicam syncing with one stereo audio recorder has been tested (spring 2023, [see demo](https://youtu.be/pklTSTi7cqs)). Multi audio recorders coming soon...
14
+ Feature complete! `tictacsync` scans for audio video files and then merges overlapping audio and video recordings, It
15
+
16
+ * Decodes the TicTacCode audio track alongside your audio tracks
17
+ * Establishes UTC start time (and end time) within 100 μs!
18
+ * Syncs, cuts and joins any concurrent audio to camera files (using `FFmpeg`)
19
+ * Processes _multiple_ audio recorders
20
+ * Corrects device clock drift so _both_ ends coincide (thanks to `sox`)
21
+ * Sets video metadata TC of multicam files for NLE timeline alignement
22
+ * Writes _synced_ ISO files with dedicated file names declared in `tracks.txt`
23
+ * Produces nice plots.
18
24
 
19
25
 
20
26
  ## Installation
@@ -54,7 +60,7 @@ For a one line output (or to suppress the progress bars) use the `--terse` flag:
54
60
  > tictacsync --terse dailies/loose/MVI_0024.MP4
55
61
  dailies/loose/MVI_0024.MP4 UTC:2024-03-12 23:07:01.4281 pulse: 27450 in chan 0
56
62
 
57
- To also produce _synced_ ISO audio files, specify `--isos` . A directory named `ISOs` will contain _for each synced video_ a set of audio files of exact same length, padded or trimmed to coincide with the video track. After re-editing and re-mixing a `remergemix` command will resync the new sound track with the video [TODO].
63
+ To also produce _synced_ ISO audio files, specify `--isos` . A directory named `ISOs` will contain _for each synced video_ a set of ISO audio files of exact same length, padded or trimmed to coincide with the video track. After re-editing and re-mixing a `remergemix` command will resync the new audio with the video and _the new sound track will be updated on your NLE timeline_, at least in Kdenlive...
58
64
 
59
65
  > tictacsync --isos dailies/structured
60
66
 
@@ -31,7 +31,7 @@ setup(
31
31
  'multi2polywav = tictacsync.multi2polywav:main',
32
32
  ]
33
33
  },
34
- version = '0.4a',
34
+ version = '0.5a',
35
35
  description = "command for syncing audio video recordings",
36
36
  long_description_content_type='text/markdown',
37
37
  long_description = long_descr,
@@ -90,10 +90,12 @@ class Media:
90
90
  """
91
91
  path: Path
92
92
  device: Device
93
+
93
94
  def media_at_path(input_structure, p):
94
95
  # return Media object for mediafile using ffprobe
95
96
  dev_UID, dt = get_device_ffprobe_UID(p)
96
- dev_name = None
97
+ dev_name = None
98
+ logger.debug('ffprobe dev_UID:%s dt:%s'%(dev_UID, dt))
97
99
  if input_structure == 'folder_is_device':
98
100
  dev_name = p.parent.name
99
101
  if dev_UID is None:
@@ -114,9 +116,10 @@ def media_at_path(input_structure, p):
114
116
  else:
115
117
  n = sox.file_info.channels(_pathname(p)) # eg 2
116
118
  logger.debug('for file %s dev_UID established %s'%(p.name, dev_UID))
117
- return Media(p,
118
- Device(UID=dev_UID, folder=p.parent, name=dev_name, dev_type=dt,
119
- n_chan=n, ttc=None, tracks=None))
119
+ device = Device(UID=dev_UID, folder=p.parent, name=dev_name, dev_type=dt,
120
+ n_chan=n, ttc=None, tracks=None)
121
+ logger.debug('for path: %s, device:%s'%(p,device))
122
+ return Media(p, device)
120
123
 
121
124
  def get_device_ffprobe_UID(file):
122
125
  """
@@ -141,7 +144,7 @@ def get_device_ffprobe_UID(file):
141
144
  except ffmpeg.Error as e:
142
145
  print('ffmpeg.probe error')
143
146
  print(e.stderr, file)
144
- return None, None
147
+ return None, None #-----------------------------------------------------
145
148
  # fall back to folder name
146
149
  streams = probe['streams']
147
150
  codecs = [stream['codec_type'] for stream in streams]
@@ -155,9 +158,12 @@ def get_device_ffprobe_UID(file):
155
158
  and 'date' not in l ]
156
159
  # this removes any metadata related to the file
157
160
  # but keeps metadata related to the device
161
+ logger.debug('probe_lines %s'%probe_lines)
158
162
  UID = hash(''.join(probe_lines))
159
163
  else:
160
164
  UID = None
165
+ if UID == 0: # empty probe_lines from Audacity ?!?
166
+ UID = None
161
167
  logger.debug('ffprobe_UID is: %s'%UID)
162
168
  return UID, device_type
163
169
 
@@ -257,9 +263,6 @@ class Scanner:
257
263
  for p in paths:
258
264
  new_media = media_at_path(self.input_structure, p) # dev UID set here
259
265
  self.found_media_files.append(new_media)
260
- # files from devices without UID or name
261
- # def _list_all_the_same(l):
262
- # return all(e == l[0] for e in l)
263
266
  def _try_name(medias):
264
267
  # return common first strings in filename
265
268
  names = [m.path.name for m in medias]
@@ -274,7 +277,6 @@ class Scanner:
274
277
  if not m.device.UID]
275
278
  if no_device_UID_media:
276
279
  logger.debug('no_device_UID_media %s'%no_device_UID_media)
277
- # print(no_device_UID_media)
278
280
  start_string = _try_name(no_device_UID_media)
279
281
  if len(start_string) < 2:
280
282
  print('\nError, cant identify the device for those files:')
@@ -286,7 +288,8 @@ class Scanner:
286
288
  if not one_device.UID:
287
289
  one_device.UID = hash(start_string)
288
290
  print('\nWarning, guessing a device ID for those files:')
289
- [print('[gold1]%s[/gold1], '%m.path.name, end='') for m in no_device_UID_media]
291
+ [print('[gold1]%s[/gold1], '%m.path.name, end='') for m
292
+ in no_device_UID_media]
290
293
  print('UID: [gold1]%s[/gold1]'%start_string)
291
294
  for m in no_device_UID_media:
292
295
  m.device = one_device
@@ -126,9 +126,9 @@ def main():
126
126
  # logger.add(sys.stdout, filter="__main__")
127
127
  # logger.add(sys.stdout, filter="device_scanner")
128
128
  # logger.add(sys.stdout, filter="yaltc") _extract_sound_to_merge
129
- # logger.add(sys.stdout, filter=lambda r: r["function"] == "_get_tracks_from_file")
129
+ # logger.add(sys.stdout, filter=lambda r: r["function"] == "media_at_path")
130
+ # logger.add(sys.stdout, filter=lambda r: r["function"] == "scan_media_and_build_devices_UID")
130
131
  # logger.add(sys.stdout, filter=lambda r: r["function"] == "_get_device_mix")
131
- # logger.add(sys.stdout, filter=lambda r: r["function"] == "_sox_mono2stereo")
132
132
  # logger.add(sys.stdout, filter=lambda r: r["function"] == "_sox_mix_files")
133
133
  top_dir = args.directory[0]
134
134
  if os.path.isfile(top_dir):
@@ -277,6 +277,3 @@ if __name__ == '__main__':
277
277
 
278
278
 
279
279
 
280
-
281
-
282
-
@@ -61,7 +61,7 @@ def build_poly_name(multifiles):
61
61
  """
62
62
  Returns string of polywav filename, constructed from similitudes between
63
63
  multifile names. Ex:
64
- /full/path/4CH002I.wav and /full/path/4CH002M.wav returns 4CH002X.wav
64
+ 4CH002I.wav and 4CH002M.wav returns 4CH002X.wav
65
65
  """
66
66
  s1 = str(multifiles[0].stem)
67
67
  s2 = str(multifiles[1].stem)
@@ -96,6 +96,7 @@ def build_poly(multifiles):
96
96
  # multifiles is list of Path
97
97
  # change extensions to mfw (multifile wav)
98
98
  dir_multi = multifiles[0].parent
99
+ multifiles.reverse()
99
100
  poly_name_b = build_poly_name(multifiles) # base only
100
101
  poly_name = str(dir_multi/Path(poly_name_b))
101
102
  filenames = [str(p) for p in multifiles]
@@ -0,0 +1,257 @@
1
+ import argparse, wave, subprocess
2
+ from loguru import logger
3
+ from pathlib import Path
4
+ import sox, tempfile, os, ffmpeg
5
+ from rich import print
6
+ import shutil, sys, re
7
+ from pprint import pformat
8
+ from itertools import groupby
9
+
10
+ try:
11
+ from . import timeline
12
+ except:
13
+ import timeline
14
+
15
+ DEL_TEMP = False
16
+
17
+ logger.level("DEBUG", color="<yellow>")
18
+ logger.remove()
19
+
20
+ # logger.add(sys.stdout, filter=lambda r: r["function"] == "_get_ISO_dirs")
21
+ # logger.add(sys.stdout, filter=lambda r: r["function"] == "_join_audio2video")
22
+ # logger.add(sys.stdout, filter=lambda r: r["function"] == "main")
23
+ # logger.add(sys.stdout, filter="__main__")
24
+
25
+ OUT_DIR = 'SyncedMedia'
26
+ SEC_DELAY_CHANGED_ISO = 10 #sec, ISO_DIR changed if diff time is bigger
27
+
28
+ video_extensions = \
29
+ """webm mkv flv flv vob ogv ogg drc gif gifv mng avi mov
30
+ qt wmv yuv rm rmvb viv asf mp4 m4p m4v mpg mp2 mpeg mpe
31
+ mpv mpg mpeg m2v m4v svi 3gp 3g2 mxf roq nsv""".split() # from wikipedia
32
+
33
+ def _pathname(tempfile_or_path) -> str:
34
+ # utility for obtaining a str from different filesystem objects
35
+ if isinstance(tempfile_or_path, str):
36
+ return tempfile_or_path
37
+ if isinstance(tempfile_or_path, Path):
38
+ return str(tempfile_or_path)
39
+ if isinstance(tempfile_or_path, tempfile._TemporaryFileWrapper):
40
+ return tempfile_or_path.name
41
+ else:
42
+ raise Exception('%s should be Path or tempfile...'%tempfile_or_path)
43
+
44
+ def _keep_VIDEO_only(video_path):
45
+ # return file handle to a temp video file formed from the video_path
46
+ # stripped of its sound
47
+ in1 = ffmpeg.input(_pathname(video_path))
48
+ video_extension = video_path.suffix
49
+ silenced_opts = ["-loglevel", "quiet", "-nostats", "-hide_banner"]
50
+ file_handle = tempfile.NamedTemporaryFile(suffix=video_extension,
51
+ delete=DEL_TEMP)
52
+ out1 = in1.output(file_handle.name, map='0:v', vcodec='copy')
53
+ ffmpeg.run([out1.global_args(*silenced_opts)], overwrite_output=True)
54
+ return file_handle
55
+
56
+ def _join_audio2video(audio_path: Path, video: Path):
57
+ """
58
+ Replace audio in video (argument) by the audio contained in
59
+ audio_path (argument) returns nothing
60
+ """
61
+ video_ext = video.name.split('.')[1]
62
+ vid_only_handle = _keep_VIDEO_only(video)
63
+ a_n = _pathname(audio_path)
64
+ v_n = _pathname(vid_only_handle)
65
+ out_n = _pathname(video)
66
+ # building args for debug purpose only:
67
+ ffmpeg_args = (
68
+ ffmpeg
69
+ .input(v_n)
70
+ .output(out_n, shortest=None, vcodec='copy')
71
+ .global_args('-i', a_n, "-hide_banner")
72
+ .overwrite_output()
73
+ .get_args()
74
+ )
75
+ logger.debug('ffmpeg args: %s'%' '.join(ffmpeg_args))
76
+ try: # for real now
77
+ _, out = (
78
+ ffmpeg
79
+ .input(v_n)
80
+ .output(out_n, shortest=None, vcodec='copy')
81
+ .global_args('-i', a_n, "-hide_banner")
82
+ .overwrite_output()
83
+ .run(capture_stderr=True)
84
+ )
85
+ logger.debug('ffmpeg output')
86
+ for l in out.decode("utf-8").split('\n'):
87
+ logger.debug(l)
88
+ except ffmpeg.Error as e:
89
+ print('ffmpeg.run error merging: \n\t %s + %s = %s\n'%(
90
+ audio_path,
91
+ video_path,
92
+ synced_clip_file
93
+ ))
94
+ print(e)
95
+ print(e.stderr.decode('UTF-8'))
96
+ sys.exit(1)
97
+
98
+ def _changed(dir) -> bool:
99
+ """
100
+ Returns True if any content of dir (arg) is more recent than dir itself by a
101
+ delay of SEC_DELAY_CHANGED_ISO. Uses modification times.
102
+ """
103
+ logger.debug(f'checking {dir.name} for change')
104
+ ISO_modification_time = biggest_mtime_in_dir(dir)
105
+ clip = clip_from_iso(dir)
106
+ clip_mod_time = clip.stat().st_mtime
107
+ # difference of modification time in secs
108
+ ISO_more_recent_by = ISO_modification_time - clip_mod_time
109
+ logger.debug('ISO_more_recent_by: %s'%(ISO_more_recent_by))
110
+ iso_edited = ISO_more_recent_by > SEC_DELAY_CHANGED_ISO
111
+ logger.debug(f'_changed: {iso_edited}')
112
+ return iso_edited
113
+
114
+ def sox_mix(ISOdir):
115
+ """
116
+ Mixes all wav files present in ISOdir (so excludes ttc file and nullified
117
+ ones).
118
+ Returns a mono or stereo tempfile
119
+ """
120
+ logger.debug(f'mixing {ISOdir}')
121
+ def is_stereo_mic(p):
122
+ re_result = re.match(r'mic([lrLR])*', p.name)
123
+ # logger.debug(f're_result {re_result}')
124
+ return re_result is not None
125
+ stereo_mics = [p for p in ISOdir.iterdir() if is_stereo_mic(p)]
126
+ monofiles = [p for p in ISOdir.iterdir() if p not in stereo_mics]
127
+ # removing ttc files
128
+ def notTTC(p):
129
+ return p.name[:3] != 'ttc'
130
+ monofiles = [p for p in monofiles if notTTC(p)]
131
+ if stereo_mics == []: # mono
132
+ return timeline._sox_mix_files(monofiles) #-----------------------------
133
+ logger.debug(f'stereo_mics: {stereo_mics}')
134
+ def mic(p):
135
+ return re.search(r'(mic\d*)([lrLR])*', p.name).groups()
136
+ mics = [mic(p) for p in stereo_mics]
137
+ p_and_mic = list(zip(stereo_mics, mics))
138
+ logger.debug(f'p_and_mic: {p_and_mic}')
139
+ same_mic_key = lambda pair: pair[1][0]
140
+ p_and_mic = sorted(p_and_mic, key=same_mic_key)
141
+ grouped_by_mic = [ (k, list(iterator)) for k, iterator
142
+ in groupby(p_and_mic, same_mic_key)]
143
+ logger.debug(f'grouped_by_mic: {grouped_by_mic}')
144
+ def order_left_right(groupby_element):
145
+ # returns left and right path for a mic
146
+ name, paths = groupby_element
147
+ def chan(pair):
148
+ # (PosixPath('mic1r_ZOOM.wav'), ('mic1', 'r')) -> 'r'
149
+ return pair[1][1]
150
+ path_n_mic = sorted(paths, key=lambda pair: pair[1][1])
151
+ return [p[0] for p in path_n_mic] # just the path, not ('mic1', 'r')
152
+ left_right_paths = [order_left_right(e) for e in grouped_by_mic]
153
+ # logger.debug(f'left_right_paths: {left_right_paths}')
154
+ stereo_files = [timeline._sox_combine(pair) for pair in left_right_paths]
155
+ monoNstereo = monofiles + stereo_files
156
+ return timeline._sox_mix_files(monoNstereo)
157
+
158
+ def get_mix_file(iso_dir):
159
+ """
160
+ If iso_dir (arg) contains a mono mix sound file or a stereo mix, returns its
161
+ path. If not, this creates the mix and returns it.
162
+ """
163
+ wav_files = list(iso_dir.iterdir())
164
+ logger.debug(f'wav_files {wav_files}')
165
+ def is_mix(p):
166
+ re_result = re.match(r'mix([lrLR])*', p.name)
167
+ # logger.debug(f're_result {re_result}')
168
+ return re_result is not None
169
+ location_mix = [p for p in wav_files if is_mix(p)]
170
+ if location_mix == []:
171
+ logger.debug('no mix track, do the mix')
172
+ return sox_mix(iso_dir)
173
+ else:
174
+ return location_mix
175
+
176
+ def biggest_mtime_in_dir(folder: Path) -> float:
177
+ # return the most recent mod time of the files in a folder
178
+ dir_content = list(folder.iterdir())
179
+ stats = [p.stat() for p in dir_content]
180
+ mtimes = [stat.st_mtime for stat in stats]
181
+ return max(mtimes)
182
+
183
+ def clip_from_iso(ISO_dir: Path) -> Path:
184
+ # find the sibling video file of ISO_dir. eg : MVI_01.ISO -> MVI_01.MP4
185
+ folder = ISO_dir.parent
186
+ siblings = list(folder.glob('%s.*'%ISO_dir.stem))
187
+ candidates =[p for p in siblings if p.name.split('.')[1] != 'ISO']
188
+ # should be unique
189
+ if len(candidates) != 1:
190
+ print(f'Error finding video corresponding to {ISO_dir}, quitting')
191
+ sys.exit(1)
192
+ return candidates[0]
193
+
194
+ def _get_ISO_dirs(top_dir):
195
+ """
196
+ Check if top_dir contains (or somewhere beneath) videos with their
197
+ accompanying ISO folder (like the pair MVI_023.MP4 + MVI_023.ISO). If not
198
+ warns and exits.
199
+
200
+ Returns list of paths pointing to ISO dirs.
201
+ """
202
+ p = Path(top_dir)
203
+ ISO_dirs = list(Path(top_dir).rglob('*.ISO'))
204
+ logger.debug('all files: %s'%pformat(ISO_dirs))
205
+ # validation: .ISO should be dir
206
+ all_are_dir = all([p.is_dir() for p in ISO_dirs])
207
+ logger.debug('.ISO are all dir %s'%all_are_dir)
208
+ if not all_are_dir:
209
+ print('Error: some .ISO are not folders??? Quitting. %s'%ISO_dirs)
210
+ sys.exit(1)
211
+ # for each folder check a video file exists with the same stem
212
+ # but with a video format extension (listed in video_extensions)
213
+ for ISO in ISO_dirs:
214
+ logger.debug(f'checking {ISO}')
215
+ voisins = list(ISO.parent.glob(f'{ISO.stem}.*'))
216
+ voisins_suffixes = [p.suffix for p in voisins]
217
+ # remove ISO
218
+ voisins_suffixes.remove('.ISO')
219
+ # validations: should remain one element and should be video
220
+ suffix = voisins_suffixes[0].lower()[1:] # remove dot
221
+ logger.debug(f'remaining ext: {suffix}')
222
+ if len(voisins_suffixes) != 1 or suffix not in video_extensions:
223
+ print(f'Error with {voisins}, no video unique sibling?')
224
+ sys.exit(1) #-------------------------------------------------------
225
+ logger.debug(f'All ok, returning {ISO_dirs}')
226
+ return ISO_dirs
227
+
228
+ def main():
229
+ parser = argparse.ArgumentParser()
230
+ parser.add_argument(
231
+ "directory",
232
+ type=str,
233
+ nargs=1,
234
+ help="path of media directory containing Synced videos and their .ISO folder",
235
+ default='.'
236
+ )
237
+ args = parser.parse_args()
238
+ logger.debug('args %s'%args)
239
+ ISO_dirs = _get_ISO_dirs(args.directory[0])
240
+ logger.debug(f'Will check any change in {pformat(ISO_dirs)}')
241
+ changed_ISOs = [isod for isod in ISO_dirs if _changed(isod)]
242
+ logger.debug(f'changed_ISOs: {changed_ISOs}')
243
+ if changed_ISOs != []:
244
+ print('Will remix audio for:')
245
+ for p in changed_ISOs:
246
+ print(p.name)
247
+ newaudio_and_videos = [(get_mix_file(iso), clip_from_iso(iso)) for iso
248
+ in changed_ISOs]
249
+ for audio, video_clip in newaudio_and_videos:
250
+ print(f'Will remerge {video_clip.name}')
251
+ _join_audio2video(audio, video_clip)
252
+ if newaudio_and_videos == []:
253
+ print('Nothing has changed, bye.')
254
+ sys.exit(0)
255
+
256
+ if __name__ == '__main__':
257
+ main()
@@ -9,14 +9,16 @@ from rich import print
9
9
  from itertools import groupby
10
10
  # import opentimelineio as otio
11
11
  from datetime import timedelta
12
- import pprint, shutil, os
12
+ import pprint, shutil, os, sys
13
13
  from subprocess import Popen, PIPE
14
14
 
15
15
  from inspect import currentframe, getframeinfo
16
16
  try:
17
17
  from . import yaltc
18
+ from . import device_scanner
18
19
  except:
19
20
  import yaltc
21
+ import device_scanner
20
22
 
21
23
  CLUSTER_GAP = 0.5 # secs between multicam clusters
22
24
  DEL_TEMP = False
@@ -218,7 +220,6 @@ def _sox_mix_channels(multichan_tmpfl, stereo_pairs=[]) -> tempfile.NamedTempora
218
220
  # stereo tracks present, so stereo output
219
221
  logger.debug('stereo tracks present %s, so stereo output'%stereo_pairs)
220
222
  stereo_files = [_sox_keep(pair) for pair in stereo_pairs]
221
- #### ???
222
223
  return
223
224
 
224
225
  def _sox_mono2stereo(temp_file) -> tempfile.NamedTemporaryFile:
@@ -717,6 +718,7 @@ class AudioStitcherVideoMerger:
717
718
  kept_channel = (device.ttc + 1)%2 # 1 -> 0 and 0 -> 1
718
719
  logger.debug('no tracks.txt, keeping one chan %i'%kept_channel)
719
720
  return _sox_keep(multichan_tmpfl, [kept_channel + 1]) #-------------
721
+ logger.debug('device.n_chan != 2, so multitrack')
720
722
  # it's multitrack (more than 2 channels)
721
723
  if device.tracks is None:
722
724
  # multitrack but no mix done on location, so do mono mix with all
@@ -728,7 +730,21 @@ class AudioStitcherVideoMerger:
728
730
  logger.debug('mixing channels: %s (sox #)'%sox_kept_channels)
729
731
  kept_audio = _sox_keep(multichan_tmpfl, sox_kept_channels)
730
732
  return _sox_mix_channels(kept_audio) #------------------------------
731
- # user wrote a tracks.txt metadata file, check it
733
+ logger.debug('there IS a device.tracks')
734
+ # user wrote a tracks.txt metadata file, check it to get the mix(or do
735
+ # it). But first a check is done if the ttc tracks concur: the track
736
+ # detected by the Decoder class, stored in device.ttc VS the track
737
+ # declared by the user, device.tracks.ttc (see device_scanner.py). If
738
+ # not, warn the user and exit.
739
+ logger.debug('ttc channel declared for the device: %i, ttc detected: %i, non zero base indexing'%
740
+ (device.ttc, device.tracks.ttc))
741
+ if device.ttc + 1 != device.tracks.ttc: # warn and quit
742
+ print('Error: TicTacCode channel detected is [gold1]%i[/gold1]'%
743
+ device.ttc + 1, end=' ')
744
+ print('and the file [gold1]%s[/gold1] specifies channel [gold1]%i[/gold1],'%
745
+ (device_scanner.TRACKSFN, device.tracks.ttc))
746
+ print('Please correct the discrepancy and rerun. Quitting.')
747
+ sys.exit(1)
732
748
  if device.tracks.mix == [] and device.tracks.stereomics == []:
733
749
  # it's multitrac and no mix done on location, so do a mono mix with
734
750
  # all, but here remove '0' and TTC tracks from mix
@@ -741,6 +757,7 @@ class AudioStitcherVideoMerger:
741
757
  logger.debug('mixing channels: %s (sox #)'%sox_kept_channels)
742
758
  kept_audio = _sox_keep(multichan_tmpfl, sox_kept_channels)
743
759
  return _sox_mix_channels(kept_audio) #------------------------------
760
+ logger.debug('device.tracks.mix != [] or device.tracks.stereomics != []')
744
761
  if device.tracks.mix != []:
745
762
  # Mix were done on location, no and we only have to extracted it
746
763
  # from the recording. If mono mix, device.tracks.mix has one element;
@@ -764,8 +781,10 @@ class AudioStitcherVideoMerger:
764
781
  logger.debug('%s mix declared on channel %s (sox #)'%
765
782
  (mix_kind, device.tracks.mix))
766
783
  return _sox_keep(multichan_tmpfl, device.tracks.mix) #--------------
767
- # if here, all cases have been covered except tracks.txt AND no mix AND
768
- # stereo mic(s) so first a coherency check, and then proceed
784
+ logger.debug('device.tracks.mix == []')
785
+ # if here, all cases have been covered, all is remaining is this case:
786
+ # tracks.txt exists AND there is no mix AND stereo mic(s) so first a
787
+ # coherency check, and then proceed
769
788
  if device.tracks.stereomics == []:
770
789
  print('Error, no stereo mic?, check tracks.txt. Quitting')
771
790
  sys.exit(1)
@@ -780,6 +799,7 @@ class AudioStitcherVideoMerger:
780
799
  logger.debug('stereo_mic_idx_flat %s'%stereo_mic_idx_flat)
781
800
  mono_tracks = [i for i in range(1, device.n_chan + 1)
782
801
  if i not in stereo_mic_idx_flat]
802
+ logger.debug('mono_tracks: %s'%mono_tracks)
783
803
  # remove TTC track number
784
804
  mono_tracks.remove(device.ttc + 1)
785
805
  logger.debug('mono_tracks %s'%mono_tracks)
@@ -831,8 +851,11 @@ class AudioStitcherVideoMerger:
831
851
  synced_clip_file = synced_clip_dir/\
832
852
  Path(self.videoclip.new_rec_name).name
833
853
  logger.debug('editing files for %s'%synced_clip_file)
834
- self.videoclip.final_synced_file = synced_clip_file # relative
835
- # collecting edited audio by device, in (Device, tempfile) pairs:
854
+ self.videoclip.final_synced_file = synced_clip_file # relative path
855
+ # Collecting edited audio by device, in (Device, tempfile) pairs:
856
+ # for a given self.videoclip, each audio device will have a sequence
857
+ # of matched, synced and joined audio files present in a single
858
+ # edited audio file, returned by _get_concatenated_audiofile_for
836
859
  merged_audio_files_by_device = [
837
860
  (d, self._get_concatenated_audiofile_for(d))
838
861
  for d in self._get_audio_devices()]
@@ -879,18 +902,6 @@ class AudioStitcherVideoMerger:
879
902
  device_mixes = [self._get_device_mix(device, multi_chan_audio)
880
903
  for device, multi_chan_audio
881
904
  in merged_audio_files_by_device]
882
-
883
-
884
-
885
- # If multiple audio recorders were used and one of
886
- # them has mixL and mixR tracks, two possibilities:
887
-
888
- # A- others have mixL mixR too: mix of device_mixes are done (and none mix
889
- # tracks are ignored but copied in the ISOs folder if asked)
890
- # B- others don't have mixL-mixR: all tracks from them are panned
891
- # 50-50 and stereo-mixed
892
-
893
-
894
905
  logger.debug('there are %i dev device_mixes'%len(device_mixes))
895
906
  logger.debug('device_mixes %s'%device_mixes)
896
907
  mix_of_device_mixes = _sox_mix_files(device_mixes)
@@ -910,7 +921,7 @@ class AudioStitcherVideoMerger:
910
921
  in merged_audio_files_by_device]
911
922
  logger.debug('devices_and_monofiles: %s'%
912
923
  pprint.pformat(devices_and_monofiles))
913
- def _trnm(dev, idx): # used in the list comprehension just below
924
+ def _trnm(dev, idx): # used in the loop just below
914
925
  # generates track name for later if asked_ISOs
915
926
  # idx is from 0 to nchan-1 for this device
916
927
  if dev.tracks == None:
@@ -928,7 +939,10 @@ class AudioStitcherVideoMerger:
928
939
  names_audio_tempfiles = []
929
940
  for dev, mono_tmpfiles_list in devices_and_monofiles:
930
941
  for idx, monotf in enumerate(mono_tmpfiles_list):
931
- names_audio_tempfiles.append((_trnm(dev, idx), monotf))
942
+ track_name = _trnm(dev, idx)
943
+ if track_name[0] == '0': # muted, skip
944
+ continue
945
+ names_audio_tempfiles.append((track_name, monotf))
932
946
  logger.debug('names_audio_tempfiles %s'%names_audio_tempfiles)
933
947
  self._write_ISOs(names_audio_tempfiles)
934
948
  logger.debug('merged_audio_files_by_device %s'%
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tictacsync
3
- Version: 0.4a0
3
+ Version: 0.5a0
4
4
  Summary: command for syncing audio video recordings
5
5
  Home-page: https://tictacsync.org/
6
6
  Author: Raymond Lutz
@@ -29,15 +29,21 @@ Unfinished sloppy code ahead, but should run without errors. Some functionalitie
29
29
 
30
30
  ## Description
31
31
 
32
- `tictacsync` is a python script to sync audio and video files shot
33
- with [dual system sound](https://www.learnlightandsound.com/blog/2017/2/23/how-to-record-sound-for-video-dual-systemsync-sound) using a specific hardware timecode generator
34
- called [Tic Tac Sync](https://tictacsync.org). The timecode is named YaLTC for *yet
35
- another longitudinal time code* and should be recorded on a scratch
36
- track on each device for the syncing to be performed, later in _postprod_ before editing.
37
-
32
+ `tictacsync` is a python script to sync, cut and join audio files against camera files shot using a specific hardware timecode generator
33
+ called [Tic Tac Sync](https://tictacsync.org). The timecode is named TicTacCode and should be recorded on a scratch
34
+ track on each device for `tictacsync` to work.
38
35
  ## Status
39
36
 
40
- `tictacsync` scans for audio video files and displays their starting time and then merges overlapping audio and video recordings. Multicam syncing with one stereo audio recorder has been tested (spring 2023, [see demo](https://youtu.be/pklTSTi7cqs)). Multi audio recorders coming soon...
37
+ Feature complete! `tictacsync` scans for audio video files and then merges overlapping audio and video recordings, It
38
+
39
+ * Decodes the TicTacCode audio track alongside your audio tracks
40
+ * Establishes UTC start time (and end time) within 100 μs!
41
+ * Syncs, cuts and joins any concurrent audio to camera files (using `FFmpeg`)
42
+ * Processes _multiple_ audio recorders
43
+ * Corrects device clock drift so _both_ ends coincide (thanks to `sox`)
44
+ * Sets video metadata TC of multicam files for NLE timeline alignement
45
+ * Writes _synced_ ISO files with dedicated file names declared in `tracks.txt`
46
+ * Produces nice plots.
41
47
 
42
48
 
43
49
  ## Installation
@@ -77,7 +83,7 @@ For a one line output (or to suppress the progress bars) use the `--terse` flag:
77
83
  > tictacsync --terse dailies/loose/MVI_0024.MP4
78
84
  dailies/loose/MVI_0024.MP4 UTC:2024-03-12 23:07:01.4281 pulse: 27450 in chan 0
79
85
 
80
- To also produce _synced_ ISO audio files, specify `--isos` . A directory named `ISOs` will contain _for each synced video_ a set of audio files of exact same length, padded or trimmed to coincide with the video track. After re-editing and re-mixing a `remergemix` command will resync the new sound track with the video [TODO].
86
+ To also produce _synced_ ISO audio files, specify `--isos` . A directory named `ISOs` will contain _for each synced video_ a set of ISO audio files of exact same length, padded or trimmed to coincide with the video track. After re-editing and re-mixing a `remergemix` command will resync the new audio with the video and _the new sound track will be updated on your NLE timeline_, at least in Kdenlive...
81
87
 
82
88
  > tictacsync --isos dailies/structured
83
89
 
@@ -1,144 +0,0 @@
1
- import argparse, wave, subprocess
2
- from loguru import logger
3
- from pathlib import Path
4
- import sox, tempfile, os, ffmpeg
5
- from rich import print
6
- import shutil
7
-
8
-
9
- logger.remove()
10
- # OUT_DIR = 'SyncedMedia'
11
-
12
- def _pathname(tempfile_or_path) -> str:
13
- if isinstance(tempfile_or_path, str):
14
- return tempfile_or_path
15
- if isinstance(tempfile_or_path, Path):
16
- return str(tempfile_or_path)
17
- if isinstance(tempfile_or_path, tempfile._TemporaryFileWrapper):
18
- return tempfile_or_path.name
19
- else:
20
- raise Exception('%s should be Path or tempfile...'%tempfile_or_path)
21
-
22
- def _join(left_right: list, video: Path, out: Path):
23
- video_ext = video.name.split('.')[1]
24
- audio_left = (
25
- ffmpeg
26
- .input(_pathname(left_right[0])))
27
- audio_right = (
28
- ffmpeg
29
- .input(_pathname(left_right[1])))
30
- input_video = ffmpeg.input(_pathname(video))
31
- out_file = tempfile.NamedTemporaryFile(suffix='.%s'%video_ext)
32
- try:
33
- (ffmpeg
34
- .filter((audio_left, audio_right), 'join', inputs=2,
35
- channel_layout='stereo')
36
- .output(input_video.video, _pathname(out_file),
37
- shortest=None, vcodec='copy', loglevel="quiet")
38
- .overwrite_output()
39
- .run())
40
- except ffmpeg.Error as e:
41
- print(e)
42
- print(e.stderr.decode('UTF-8'))
43
- sys.exit(1)
44
- shutil.copyfile(_pathname(out_file), _pathname(video))
45
-
46
-
47
- def sync_cam(dir):
48
- # dir is a CAM dir, contents are clips and ISO folders
49
- ISOs = list(dir.glob('*.ISO'))
50
- for iso in ISOs:
51
- # iso is a folder
52
- statResult = iso.stat()
53
- mtime = statResult.st_mtime
54
- # print('%s: %s'%(iso.name, biggest_mtime_in_dir(iso)))
55
- iso_mod_time = biggest_mtime_in_dir(iso)
56
- clip = clip_from_iso(iso)
57
- clip_mod_time = clip.stat().st_mtime
58
- iso_edited = iso_mod_time > clip_mod_time
59
- # print('clip %s should be resync: %s'%(clip.name, iso_edited))
60
- # print(clip)
61
- if iso_edited:
62
- print('Resyncing [gold1]%s[/gold1]'%clip.name)
63
- LR_channels = list(valid_audio_files(iso))
64
- _join(LR_channels, clip, 'test.MOV')
65
-
66
-
67
- def valid_audio_files(isofolder: Path) -> list:
68
- """
69
- Returns two valid audio files to be synced with video
70
- case A - only two files differing by L and R
71
- case B - more than two files, two are mixL mixR
72
- case C - one file only -> sys.exit(1)
73
- case D - more than two files, no mixL mixR -> sys.exit(1)
74
- """
75
- files = list(isofolder.iterdir())
76
- if len(files) == 1: # case C
77
- print('Error with folder %s: no mixL.wav, mixR.wav'%isofolder)
78
- print('or micL.wav, micR.wav... Quitting.')
79
- sys.exit(1)
80
- def _is_case_A(files):
81
- if len(files) != 2:
82
- return False
83
- stems = [p.stem.upper() for p in files]
84
- lasts = [st[-1] for st in stems]
85
- LR_pair = ''.join(lasts) in ['LR', 'RL']
86
- prefix = [st[:-1] for st in stems]
87
- same = prefix[0] == prefix[1]
88
- return same and LR_pair
89
- if _is_case_A(files):
90
- return files
91
- def _is_case_B(files):
92
- if len(files) <= 2:
93
- return False
94
- stems = [p.stem.upper() for p in files]
95
- return 'MIXL' in stems and 'MIXR' in stems
96
- if _is_case_B(files):
97
- return isofolder.glob('mix?.*')
98
- print('Error with folder %s: no mixL.wav, mixR.wav'%isofolder)
99
- print('or micL.wav, micR.wav... Quitting.')
100
- sys.exit(1)
101
-
102
- def biggest_mtime_in_dir(folder: Path) -> float:
103
- # return the most recent mod time in a folder
104
- dir_content = list(folder.iterdir())
105
- stats = [p.stat() for p in dir_content]
106
- mtimes = [stat.st_mtime for stat in stats]
107
- return max(mtimes)
108
-
109
- def clip_from_iso(p: Path) -> Path:
110
- folder = p.parent
111
- pair = list(folder.glob('%s.*'%p.stem))
112
- return [p for p in pair if p.name.split('.')[1] != 'ISO'][0]
113
-
114
- def synciso(top_dir):
115
- p = Path(top_dir)/OUT_DIR
116
- dir_content = list(p.iterdir())
117
- logger.debug('dir_content %s'%dir_content)
118
- all_are_dir = all([p.is_dir() for p in dir_content])
119
- logger.debug('all_are_dir %s'%all_are_dir)
120
- if not all_are_dir:
121
- print('Error: resync possible only on structured folders,')
122
- print('Rerun tictacsync with one directory for each device.')
123
- sys.exit(1)
124
- [sync_cam(f) for f in dir_content]
125
-
126
- def main():
127
- parser = argparse.ArgumentParser()
128
- parser.add_argument(
129
- "directory",
130
- type=str,
131
- nargs='+',
132
- help="path of media directory containing Synced videos and their ISOs",
133
- default='.'
134
- )
135
- args = parser.parse_args()
136
- # logger.info('arguments: %s'%args)
137
- logger.debug('args %s'%args)
138
- synciso(args.directory)
139
- # for e in keylist:
140
- # print(' ', e)
141
- sys.exit(0)
142
-
143
- if __name__ == '__main__':
144
- main()
File without changes
File without changes