tictacsync 0.1a14__py3-none-any.whl → 1.4.4b0__py3-none-any.whl

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.

tictacsync/timeline.py CHANGED
@@ -9,39 +9,74 @@ 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 shutil, os, sys, stat, subprocess
13
+ from subprocess import Popen, PIPE
14
+ from pprint import pformat
13
15
 
14
16
  from inspect import currentframe, getframeinfo
15
17
  try:
16
18
  from . import yaltc
19
+ from . import device_scanner
17
20
  except:
18
21
  import yaltc
22
+ import device_scanner
19
23
 
20
- CLUSTER_GAP = 0.5 # secs between multicam clusters
21
- ISOsDIR = 'SyncedMedia/ISOs'
22
- CAMDIR = 'SyncedMedia/CAMs'
23
- TRACKSFN = 'tracks.txt'
24
- SILENT_TRACK_TOKENS = '-0n'
24
+ CLUSTER_GAP = 2 # secs between multicam clusters
25
25
  DEL_TEMP = False
26
+ DB_OSX_NORM = -6 #dB
27
+ OUT_DIR_DEFAULT = 'SyncedMedia'
28
+ MCCDIR = 'SyncedMulticamClips'
29
+
30
+
31
+ # utility to lock ISO audio files
32
+ def remove_write_permissions(path):
33
+ """Remove write permissions from this path, while keeping all other permissions intact.
34
+
35
+ Params:
36
+ path: The path whose permissions to alter.
37
+ """
38
+ NO_USER_WRITING = ~stat.S_IWUSR
39
+ NO_GROUP_WRITING = ~stat.S_IWGRP
40
+ NO_OTHER_WRITING = ~stat.S_IWOTH
41
+ NO_WRITING = NO_USER_WRITING & NO_GROUP_WRITING & NO_OTHER_WRITING
42
+ current_permissions = stat.S_IMODE(os.lstat(path).st_mode)
43
+ os.chmod(path, current_permissions & NO_WRITING)
26
44
 
27
45
  # utility for accessing pathnames
28
- def _pathname(tempfile_or_path):
46
+ def _pathname(tempfile_or_path) -> str:
29
47
  if isinstance(tempfile_or_path, str):
30
- return tempfile_or_path
48
+ return tempfile_or_path ################################################
31
49
  if isinstance(tempfile_or_path, yaltc.Recording):
32
- return str(tempfile_or_path.AVpath)
50
+ return str(tempfile_or_path.AVpath) ####################################
33
51
  if isinstance(tempfile_or_path, Path):
34
- return str(tempfile_or_path)
52
+ return str(tempfile_or_path) ###########################################
35
53
  if isinstance(tempfile_or_path, tempfile._TemporaryFileWrapper):
36
- return tempfile_or_path.name
54
+ return tempfile_or_path.name ###########################################
37
55
  else:
38
56
  raise Exception('%s should be Path or tempfile...'%tempfile_or_path)
39
57
 
58
+ def ffprobe_duration(f):
59
+ pr = ffmpeg.probe(f)
60
+ return pr['format']['duration']
61
+
62
+ # utility for printing groupby results
63
+ def print_grby(grby):
64
+ for key, keylist in grby:
65
+ print('\ngrouped by %s:'%key)
66
+ for e in keylist:
67
+ print(' ', e)
40
68
 
69
+ # deltatime utility
70
+ def from_midnight(a_datetime) -> timedelta:
71
+ # returns a deltatime from a datetime, "dropping" the date information
72
+ return(timedelta(hours=a_datetime.hour, minutes=a_datetime.minute,
73
+ seconds=a_datetime.second,
74
+ microseconds=a_datetime.microsecond))
41
75
 
42
76
  # utility for extracting one audio channel
43
77
  def _extr_channel(source, dest, channel):
44
78
  # int channel = 1 for first channel
79
+ # returns nothing, output is written to the filesystem
45
80
  sox_transform = sox.Transformer()
46
81
  mix_dict = {1:[channel]}
47
82
  logger.debug('sox args %s %s %s'%(source, dest, mix_dict))
@@ -50,153 +85,422 @@ def _extr_channel(source, dest, channel):
50
85
  status = sox_transform.build(str(source), str(dest))
51
86
  logger.debug('sox status %s'%status)
52
87
 
88
+ def _same(aList):
89
+ return aList.count(aList[0]) == len(aList)
53
90
 
54
- # utility for printing groupby results
55
- def print_grby(grby):
56
- for key, keylist in grby:
57
- print('\ngrouped by %s:'%key)
58
- for e in keylist:
59
- print(' ', e)
91
+ def _flatten(xss):
92
+ return [x for xs in xss for x in xs]
60
93
 
61
- # deltatime utility
62
- def from_midnight(a_datetime):
63
- # returns a deltatime from a datetime, skipping the date
64
- return(timedelta(hours=a_datetime.hour, minutes=a_datetime.minute,
65
- seconds=a_datetime.second,
66
- microseconds=a_datetime.microsecond))
94
+ def _sox_keep(audio_file, kept_channels: list) -> tempfile.NamedTemporaryFile:
95
+ """
96
+ Returns a NamedTemporaryFile containing the selected kept_channels
97
+
98
+ Channels numbers in kept_channels are not ZBIDXed as per SOX format
99
+ """
100
+ audio_file = _pathname(audio_file)
101
+ nchan = sox.file_info.channels(audio_file)
102
+ logger.debug('in file of %i chan, have to keep %s'%
103
+ (nchan, kept_channels))
104
+ all_channels = range(1, nchan + 1) # from 1 to nchan included
105
+ # Building dict according to pysox.remix format.
106
+ # https://pysox.readthedocs.io/en/latest/api.html#sox.transform.Transformer.remix
107
+ # eg: {1: [3], 2: [4]} to keep channels 3 & 4
108
+ kept_channels = [[n] for n in kept_channels]
109
+ sox_remix_dict = dict(zip(all_channels, kept_channels))
110
+ output_tempfile = tempfile.NamedTemporaryFile(suffix='.wav', delete=DEL_TEMP)
111
+ out_file = _pathname(output_tempfile)
112
+ logger.debug('sox in and out files: %s %s'%(audio_file, out_file))
113
+ # sox_transform.set_output_format(channels=1)
114
+ sox_transform = sox.Transformer()
115
+ sox_transform.remix(sox_remix_dict)
116
+ logger.debug('sox remix transform: %s'%sox_transform)
117
+ logger.debug('sox remix dict: %s'%sox_remix_dict)
118
+ status = sox_transform.build(audio_file, out_file, return_output=True )
119
+ logger.debug('sox.build exit code %s'%str(status))
120
+ p = Popen('ffprobe %s -hide_banner'%audio_file,
121
+ shell=True, stdout=PIPE, stderr=PIPE)
122
+ stdout, stderr = p.communicate()
123
+ logger.debug('remixed input_file ffprobe:\n%s'%(stdout +
124
+ stderr).decode('utf-8'))
125
+ p = Popen('ffprobe %s -hide_banner'%out_file,
126
+ shell=True, stdout=PIPE, stderr=PIPE)
127
+ stdout, stderr = p.communicate()
128
+ logger.debug('remixed out_file ffprobe:\n%s'%(stdout +
129
+ stderr).decode('utf-8'))
130
+ return output_tempfile
131
+
132
+ def _sox_split_channels(multi_chan_audio:Path) -> list:
133
+ nchan = sox.file_info.channels(_pathname(multi_chan_audio))
134
+ source = _pathname(multi_chan_audio)
135
+ paths = []
136
+ for i in range(nchan):
137
+ out_fh = tempfile.NamedTemporaryFile(suffix='.wav',
138
+ delete=DEL_TEMP)
139
+ sox_transform = sox.Transformer()
140
+ mix_dict = {1:[i+1]}
141
+ sox_transform.remix(mix_dict)
142
+ dest = _pathname(out_fh)
143
+ status = sox_transform.build(source, dest)
144
+ logger.debug('source %s dest %s'%(source, dest))
145
+ logger.debug('sox status %s'%status)
146
+ paths.append(out_fh)
147
+ logger.debug('paths %s'%paths)
148
+ return paths
149
+
150
+ def _sox_combine(paths) -> Path:
151
+ """
152
+ Combines (stacks) files referred by the list of Path into a new temporary
153
+ files passed on return each files are stacked in a different channel, so
154
+ len(paths) == n_channels
155
+ """
156
+ if len(paths) == 1: # one device only, nothing to stack
157
+ logger.debug('one device only, nothing to stack')
158
+ return paths[0] ########################################################
159
+ out_file_handle = tempfile.NamedTemporaryFile(suffix='.wav',
160
+ delete=DEL_TEMP)
161
+ filenames = [_pathname(p) for p in paths]
162
+ out_file_name = _pathname(out_file_handle)
163
+ logger.debug('combining files: %s into %s'%(
164
+ filenames,
165
+ out_file_name))
166
+ cbn = sox.Combiner()
167
+ cbn.set_input_format(file_type=['wav']*len(paths))
168
+ status = cbn.build(
169
+ filenames,
170
+ out_file_name,
171
+ combine_type='merge')
172
+ logger.debug('sox.build status: %s'%status)
173
+ if status != True:
174
+ print('Error, sox did not merge files in _sox_combine()')
175
+ sys.exit(1)
176
+ merged_duration = sox.file_info.duration(
177
+ _pathname(out_file_handle))
178
+ nchan = sox.file_info.channels(
179
+ _pathname(out_file_handle))
180
+ logger.debug('merged file duration %f s with %i channels '%
181
+ (merged_duration, nchan))
182
+ return out_file_handle
183
+
184
+ def _sox_multi2stereo(multichan_tmpfl, stereo_trxs) -> tempfile.NamedTemporaryFile:
185
+
186
+ """
187
+ This mixes down all the tracks in multichan_tmpfl to a stereo wav file. Any
188
+ mono tracks are panned 50-50 (mono tracks are those not present in argument
189
+ stereo_trxs)
190
+
191
+ Args:
192
+ multichan_tmpfl : tempfile.NamedTemporaryFile
193
+ contains the edited and synced audio, almost ready to be merged
194
+ with the concurrent video file
195
+ stereo_trxs : list of pairs of integers
196
+ each pairs identifies a left-right tracks, 1st track in
197
+ multichan_tmpfl is index 1 (sox is not ZBIDX)
198
+ Returns:
199
+ the tempfile.NamedTemporaryFile of a stereo wav file
200
+ containing the audio to be merged with the video
201
+ """
202
+ n_chan_input = sox.file_info.channels(_pathname(multichan_tmpfl))
203
+ logger.debug('n chan input: %s'%n_chan_input)
204
+ if n_chan_input == 1: # nothing to mix down
205
+ return multichan_tmpfl #################################################
206
+ stereo_tempfile = tempfile.NamedTemporaryFile(suffix='.wav',
207
+ delete=DEL_TEMP)
208
+ tfm = sox.Transformer()
209
+ tfm.channels(1) # why ? https://pysox.readthedocs.io/en/latest/api.html?highlight=channels#sox.transform.Transformer.channels
210
+ status = tfm.build(_pathname(multichan_tmpfl),_pathname(stereo_tempfile))
211
+ logger.debug('n chan ouput: %s'%
212
+ sox.file_info.channels(_pathname(stereo_tempfile)))
213
+ logger.debug('sox.build status for _sox_multi2stereo(): %s'%status)
214
+ if status != True:
215
+ print('Error, sox did not normalize file in _sox_multi2stereo()')
216
+ sys.exit(1)
217
+ return stereo_tempfile
218
+
219
+ def _sox_mix_channels(multichan_tmpfl, stereo_pairs=[]) -> tempfile.NamedTemporaryFile:
220
+ """
221
+ Returns a mix down of the multichannel wav file. If stereo_pairs list is
222
+ empty, a mono mix is done with all the channel present in multichan_tmpfl.
223
+ If stereo_pairs contains one or more elements, a stereo mix is returned with
224
+ the specified Left-Right pairs and all other mono tracks (panned 50-50)
225
+
226
+ Note: stereo_pairs numbers are not ZBIDXed
227
+ """
228
+ n_chan_input = sox.file_info.channels(_pathname(multichan_tmpfl))
229
+ logger.debug('n chan input: %s'%n_chan_input)
230
+ if n_chan_input == 1: # nothing to mix down
231
+ return multichan_tmpfl #################################################
232
+ if stereo_pairs == []:
233
+ # all mono
234
+ mono_tpfl = tempfile.NamedTemporaryFile(suffix='.wav',
235
+ delete=DEL_TEMP)
236
+ tfm = sox.Transformer()
237
+ tfm.channels(1)
238
+ status = tfm.build(_pathname(multichan_tmpfl),_pathname(mono_tpfl))
239
+ logger.debug('number of chan in ouput: %s'%
240
+ sox.file_info.channels(_pathname(mono_tpfl)))
241
+ logger.debug('sox.build status for _sox_mix_channels(): %s'%status)
242
+ if status != True:
243
+ print('Error, sox did not normalize file in _sox_mix_channels()')
244
+ sys.exit(1)
245
+ return mono_tpfl
246
+ else:
247
+ # stereo tracks present, so stereo output
248
+ logger.debug('stereo tracks present %s, so stereo output'%stereo_pairs)
249
+ stereo_files = [_sox_keep(pair) for pair in stereo_pairs]
250
+ return
251
+
252
+ def _sox_mono2stereo(temp_file) -> tempfile.NamedTemporaryFile:
253
+ # upgrade a mono file to stereo panning 50-50
254
+ stereo_tempfile = tempfile.NamedTemporaryFile(suffix='.wav',
255
+ delete=DEL_TEMP)
256
+ tfm = sox.Transformer()
257
+ tfm.channels(2)
258
+ status = tfm.build(_pathname(temp_file),_pathname(stereo_tempfile))
259
+ logger.debug('n chan ouput: %s'%
260
+ sox.file_info.channels(_pathname(stereo_tempfile)))
261
+ logger.debug('sox.build status for _sox_mono2stereo(): %s'%status)
262
+ if status != True:
263
+ print('Error, sox did not normalize file in _sox_mono2stereo()')
264
+ sys.exit(1)
265
+ return stereo_tempfile
266
+
267
+ def _sox_mix_files(temp_files_to_mix:list) -> tempfile.NamedTemporaryFile:
268
+ """
269
+ Mix files referred by the list of Path into a new temporary files passed on
270
+ return. If one of the files is stereo, upgrade each mono file to a panned
271
+ 50-50 stereo file before mixing.
272
+ """
273
+ def _sox_norm(tempf):
274
+ normed_tempfile = tempfile.NamedTemporaryFile(suffix='.wav',
275
+ delete=DEL_TEMP)
276
+ tfm = sox.Transformer()
277
+ tfm.norm(DB_OSX_NORM)
278
+ status = tfm.build(_pathname(tempf),_pathname(normed_tempfile))
279
+ logger.debug('sox.build status for norm(): %s'%status)
280
+ if status != True:
281
+ print('Error, sox did not normalize file in _sox_mix_files()')
282
+ sys.exit(1)
283
+ return normed_tempfile
284
+ N = len(temp_files_to_mix)
285
+ if N == 1: # nothing to mix
286
+ logger.debug('one file: nothing to mix')
287
+ return temp_files_to_mix[0] ########################################################
288
+ cbn = sox.Combiner()
289
+ cbn.set_input_format(file_type=['wav']*N)
290
+ # check if stereo files are present
291
+ max_n_chan = max([sox.file_info.channels(f) for f
292
+ in [_pathname(p) for p in temp_files_to_mix]])
293
+ logger.debug('max_n_chan %s'%max_n_chan)
294
+ if max_n_chan == 2:
295
+ # upgrade all mono to stereo
296
+ stereo_tempfiles = [p for p in temp_files_to_mix
297
+ if sox.file_info.channels(_pathname(p)) == 2 ]
298
+ mono_tempfiles = [p for p in temp_files_to_mix
299
+ if sox.file_info.channels(_pathname(p)) == 1 ]
300
+ logger.debug('there are %i mono files and %i stereo files'%
301
+ (len(stereo_tempfiles), len(mono_tempfiles)))
302
+ new_stereo = [_sox_mono2stereo(tmpfl) for tmpfl
303
+ in mono_tempfiles]
304
+ stereo_tempfiles += new_stereo
305
+ files_to_mix = [_pathname(tempfl) for tempfl in stereo_tempfiles]
306
+ else:
307
+ # all mono
308
+ files_to_mix = [_pathname(tempfl) for tempfl in temp_files_to_mix]
309
+ mixed_tempf = tempfile.NamedTemporaryFile(suffix='.wav',delete=DEL_TEMP)
310
+ status = cbn.build(files_to_mix,
311
+ _pathname(mixed_tempf),
312
+ combine_type='mix',
313
+ input_volumes=[1/N]*N)
314
+ logger.debug('sox.build status for mix: %s'%status)
315
+ if status != True:
316
+ print('Error, sox did not mix files in _sox_mix_files()')
317
+ sys.exit(1)
318
+ normed_tempfile = tempfile.NamedTemporaryFile(suffix='.wav',delete=DEL_TEMP)
319
+ tfm = sox.Transformer()
320
+ tfm.norm(DB_OSX_NORM)
321
+ status = tfm.build(_pathname(mixed_tempf),_pathname(normed_tempfile))
322
+ logger.debug('sox.build status for norm(): %s'%status)
323
+ if status != True:
324
+ print('Error, sox did not normalize file in _sox_mix_files()')
325
+ sys.exit(1)
326
+ return normed_tempfile
67
327
 
68
328
  class AudioStitcherVideoMerger:
69
329
  """
70
330
  Typically each found video is associated with an AudioStitcherVideoMerger
71
331
  instance. AudioStitcherVideoMerger does the actual audio-video file
72
- processing of merging self.ref_recording (gen. a video) with all audio
73
- files in self.edited_audio as determined by the Matcher
74
- object (it instanciates and manages AudioStitcherVideoMerger objects).
332
+ processing of merging AudioStitcherVideoMerger.videoclip (gen. a video)
333
+ with all audio files in AudioStitcherVideoMerger.soxed_audio as
334
+ determined by the Matcher object (Matcher instanciates and manages
335
+ AudioStitcherVideoMerger objects).
75
336
 
76
337
  All audio file edits are done using pysox and video+audio merging with
77
338
  ffmpeg. When necessary, clock drift is corrected for all overlapping audio
78
339
  devices to match the precise clock value of the ref recording (to a few
79
340
  ppm), using sox tempo transform.
80
341
 
81
- N.B.: A audio_stitch doesn't extend beyond the corresponding ref_recording
342
+ N.B.: A audio_stitch doesn't extend beyond the corresponding videoclip
82
343
  video start and end times: it is not a audio montage for the whole movie
83
344
  project.
84
345
 
85
346
 
347
+ Class attribute
348
+
349
+ tempoed_recs : dict as {Recording : path}
350
+
351
+ a cache for already time-stretched audio files. Keys are elements
352
+ of matched_audio_recordings and the value are tuples:
353
+ (factor, file_handle), the file_handle points to the precedently
354
+ produced NamedTemporaryFile; factor is the value that was used in
355
+ the sox tempo transform.
356
+
86
357
  Attributes:
87
358
 
88
- ref_recording : a Recording instance
89
- The video (or designated main sound) audio files are synced to
359
+ videoclip : a Recording instance
360
+ The video to which audio files are synced
90
361
 
362
+ ref_audio : a Recording instance
363
+ If no video is present, this is the reference audio to which others
364
+ audio files are synced
91
365
 
92
- edited_audio : dict as {Recording : path}
93
- keys are elements of matched_audio_recordings of class Recording
94
- and the value stores the Pathlib path of the eventual edited
95
- audio (trimmed , padded or time stretched). Before building the
96
- audio_montage, path points to the initial
97
- Recording.sound_without_YaLTC
366
+ soxed_audio : dict as {Recording : path}
367
+ keys are elements of matched_audio_recordings and the value are
368
+ the Pathlib path of the eventual edited audio(trimmed or padded).
98
369
 
370
+ synced_clip_dir : Path
371
+ where synced clips are written
99
372
 
100
373
  """
374
+ tempoed_recs = {}
101
375
 
102
- def __init__(self, reference_recording):
103
- self.ref_recording = reference_recording
376
+ def __init__(self, video_clip):
377
+ self.videoclip = video_clip
104
378
  # self.matched_audio_recordings = []
105
- self.edited_audio = {}
379
+ self.soxed_audio = {}
106
380
  logger.debug('instantiating AudioStitcherVideoMerger for %s'%
107
- reference_recording)
381
+ video_clip)
108
382
 
109
383
  def add_matched_audio(self, audio_rec):
110
384
  """
111
- Populates self.edited_audio, a dict as {Recording : path}
385
+ Populates AudioStitcherVideoMerger.soxed_audio,
386
+ a dict as {Recording : path}
112
387
 
113
- AudioStitcherVideoMerger.add_matched_audio() is called
114
- within Matcher.scan_audio_for_each_ref_rec()
388
+ This fct is called
389
+ within Matcher.scan_audio_for_each_videoclip()
115
390
 
116
- Returns nothing, fills self.edited_audio dict with
391
+ Returns nothing, fills self.soxed_audio dict with
117
392
  matched audio.
118
393
 
119
394
  """
120
- self.edited_audio[audio_rec] = audio_rec.sound_without_YaLTC
395
+ self.soxed_audio[audio_rec] = audio_rec.AVpath
121
396
  """
122
- Here at this point, self.edited_audio[audio_rec] is unedited but
123
- after a call to _edit_audio_file(), edited_audio[audio_rec] points to
124
- a new file and the precedent is unchanged (that's why from
125
- AudioStitcherVideoMerger instance to another
126
- audio_rec.sound_without_YaLTC doesn't need to be reinitialized since
127
- it stays unchanged)
397
+ Here at this point, self.soxed_audio[audio_rec] is unedited but
398
+ after a call to _edit_audio_file(), soxed_audio[audio_rec] points to
399
+ a new file and audio_rec.AVpath is unchanged.
128
400
  """
129
401
  return
130
402
 
131
403
  def get_matched_audio_recs(self):
132
404
  """
133
- Returns audio recordings that overlap self.ref_recording.
134
- Simply keys of self.edited_audio dict
405
+ Returns audio recordings that overlap self.videoclip.
406
+ Simply keys of self.soxed_audio dict
135
407
  """
136
- return list(self.edited_audio.keys())
408
+ logger.debug(f'soxed_audio {pformat(self.soxed_audio)}')
409
+ return list(self.soxed_audio.keys())
137
410
 
138
411
  def _get_audio_devices(self):
139
- # ex {'RCR_A', 'RCR_B', 'RCR_C'} if multisound
140
- return set([r.device for r in self.get_matched_audio_recs()])
412
+ devices = set([r.device for r in self.get_matched_audio_recs()])
413
+ logger.debug('get_matched_audio_recs: %s'%
414
+ pformat(self.get_matched_audio_recs()))
415
+ logger.debug('devices %s'%devices)
416
+ return devices
417
+
418
+ def _get_secondary_audio_devices(self):
419
+ # when only audio devices are synced.
420
+ # identical to _get_audio_devices()...
421
+ # name changed for clarity
422
+ return self._get_audio_devices()
141
423
 
142
424
  def _get_all_recordings_for(self, device):
143
425
  # return recordings for a particular device, sorted by time
144
- recs = [a for a in self.get_matched_audio_recs() if a.device == device]
426
+ recs = self.get_matched_audio_recs()
427
+ logger.debug(f'device: {device.name} matched audio recs: {recs}')
428
+ recs = [a for a in recs if a.device == device]
145
429
  recs.sort(key=lambda r: r.start_time)
146
430
  return recs
147
431
 
148
432
  def _dedrift_rec(self, rec):
149
- # first_audio_p = rec.AVpath
150
- initial_duration = sox.file_info.duration(
151
- _pathname(rec.sound_without_YaLTC))
152
- sox_transform = sox.Transformer()
153
- # tempo_scale_factor = rec.device_relative_speed
433
+ # instanciates a sox.Transformer() with tempo() effect
434
+ # add applies it via a call to _edit_audio_file(rec, sox_transform)
154
435
  tempo_scale_factor = rec.device_relative_speed
155
- print('%s needs to be time stretched: speed %f'%(rec,
156
- tempo_scale_factor))
436
+ audio_dev = rec.device.name
437
+ video_dev = self.videoclip.device.name
438
+ print('when merging with [gold1]%s[/gold1].'%self.videoclip)
157
439
  if tempo_scale_factor > 1:
158
- print('Device clock too fast relative to reference: file too long\n')
440
+ print('Because [gold1]%s[/gold1] clock too fast relative to [gold1]%s[/gold1]: file is too long by a %.12f factor;'%
441
+ (audio_dev, video_dev, tempo_scale_factor))
442
+ else:
443
+ print('Because [gold1]%s[/gold1] clock too slow relative to [gold1]%s[/gold1]: file is short by a %.12f factor'%
444
+ (audio_dev, video_dev, tempo_scale_factor))
445
+ logger.debug('tempoed_recs dict:%s'%AudioStitcherVideoMerger.tempoed_recs)
446
+ if rec in AudioStitcherVideoMerger.tempoed_recs:
447
+ logger.debug('%s already tempoed'%rec)
448
+ cached_factor, cached_file = AudioStitcherVideoMerger.tempoed_recs[rec]
449
+ error_factor = tempo_scale_factor/cached_factor
450
+ logger.debug('tempo factors, needed: %f cached %f'%(tempo_scale_factor,cached_factor))
451
+ delta_cache = abs((1 - error_factor)*rec.get_original_duration())
452
+ logger.debug('error if cache is used: %f ms'%(delta_cache*1e3))
453
+ delta_cache_is_ok = delta_cache < yaltc.MAXDRIFT
159
454
  else:
160
- print('Device clock too slow relative to reference: file too short\n')
161
- sox_transform.tempo(tempo_scale_factor)
162
- # scaled_file = self._get_soxed_file(rec, sox_transform)
163
- logger.debug('sox_transform %s'%sox_transform.effects)
164
- self._edit_audio_file(rec, sox_transform)
165
- scaled_file_name = _pathname(self.edited_audio[rec])
166
- new_duration = sox.file_info.duration(scaled_file_name)
167
- # goal_duration = rec.get_corrected_duration()
168
- logger.debug('initial_duration %f new_duration %f ratio:%f'%(
169
- initial_duration, new_duration, initial_duration/new_duration))
455
+ delta_cache_is_ok = False
456
+ if delta_cache_is_ok:
457
+ logger.debug('ok, will use %s'%cached_file)
458
+ self.soxed_audio[rec] = cached_file
459
+ else:
460
+ logger.debug('%s not tempoed yet'%rec)
461
+ sox_transform = sox.Transformer()
462
+ sox_transform.tempo(tempo_scale_factor)
463
+ # scaled_file = self._get_soxed_file(rec, sox_transform)
464
+ logger.debug('sox_transform %s'%sox_transform.effects)
465
+ soxed_fh = self._edit_audio_file(rec, sox_transform)
466
+ scaled_file_name = _pathname(soxed_fh)
467
+ AudioStitcherVideoMerger.tempoed_recs[rec] = (tempo_scale_factor, soxed_fh)
468
+ new_duration = sox.file_info.duration(scaled_file_name)
469
+ initial_duration = sox.file_info.duration(
470
+ _pathname(rec.AVpath))
471
+ logger.debug('Verif: initial_duration %.12f new_duration %.12f ratio:%.12f'%(
472
+ initial_duration, new_duration, initial_duration/new_duration))
473
+ logger.debug('delta duration %f ms'%((new_duration-initial_duration)*1e3))
170
474
 
171
475
  def _get_concatenated_audiofile_for(self, device):
172
476
  """
173
477
  return a handle for the final audio file formed by all detected
174
- overlapping recordings, produced by the same specified device.
478
+ overlapping recordings, produced by the same audio recorder.
175
479
 
176
480
  """
177
481
  logger.debug('concatenating device %s'%str(device))
178
- recordings = self._get_all_recordings_for(device)
482
+ audio_recs = self._get_all_recordings_for(device)
179
483
  # [TODO here] Check if all unidentified device files are not
180
484
  # overlapping because they are considered produced by the same
181
485
  # device. If some overlap then necessarily they're from different
182
486
  # ones. List the files and warn the user there is a risk of error if
183
487
  # they're not from the same device.
184
488
 
185
- logger.debug('%i audio files for reference rec %s:'%(len(recordings),
186
- self.ref_recording))
187
- for r in recordings:
489
+ logger.debug('%i audio files for videoclip %s:'%(len(audio_recs),
490
+ self.videoclip))
491
+ for r in audio_recs:
188
492
  logger.debug(' %s'%r)
189
- speeds = numpy.array([rec.get_speed_ratio(self.ref_recording)
190
- for rec in recordings])
493
+ # ratio between real samplerates of audio and videoclip
494
+ speeds = numpy.array([rec.get_speed_ratio(self.videoclip)
495
+ for rec in audio_recs])
191
496
  mean_speed = numpy.mean(speeds)
192
- for r in recordings:
193
- r.device_relative_speed = mean_speed
194
- # r.device_relative_speed = 0.9
195
- logger.debug('set device_relative_speed for %s'%r)
196
- logger.debug(' value: %f'%r.device_relative_speed)
197
- r.set_time_position_to(self.ref_recording)
198
- logger.debug('time_position for %s: %fs relative to %s'%(r,
199
- r.time_position, self.ref_recording))
497
+ for audio in audio_recs:
498
+ audio.device_relative_speed = mean_speed
499
+ logger.debug('set device_relative_speed for %s'%audio)
500
+ logger.debug(' value: %f'%audio.device_relative_speed)
501
+ audio.set_time_position_to(self.videoclip)
502
+ logger.debug('time_position for %s: %fs relative to %s'%(audio,
503
+ audio.time_position, self.videoclip))
200
504
  # st_dev_speeds just to check for anomalous situation
201
505
  st_dev_speeds = numpy.std(speeds)
202
506
  logger.debug('mean speed for %s: %.6f std dev: %.0e'%(device,
@@ -204,7 +508,7 @@ class AudioStitcherVideoMerger:
204
508
  st_dev_speeds))
205
509
  if st_dev_speeds > 1.0e-5:
206
510
  logger.error('too much variation for device speeds')
207
- quit()
511
+ sys.exit(1)
208
512
  """
209
513
  Because of length
210
514
  transformations with pysox.tempo, it is not the sum of REC durations
@@ -221,7 +525,7 @@ class AudioStitcherVideoMerger:
221
525
 
222
526
  # process first element 'by hand' outside the loop
223
527
  # first_audio is a Recording, not a path nor filehandle
224
- first_audio = recordings[0]
528
+ first_audio = audio_recs[0]
225
529
  needs_dedrift, delta = first_audio.needs_dedrifting()
226
530
  logger.debug('first audio is %s'%first_audio)
227
531
  logger.debug('checking drift, first audio: delta of %0.2f ms'%(
@@ -233,8 +537,8 @@ class AudioStitcherVideoMerger:
233
537
  self._pad_or_trim_first_audio(first_audio)
234
538
  # loop for the other files
235
539
  # growing_file = first_audio.edited_version
236
- growing_file = self.edited_audio[first_audio]
237
- for i, rec in enumerate(recordings[1:]):
540
+ growing_file = self.soxed_audio[first_audio]
541
+ for i, rec in enumerate(audio_recs[1:]):
238
542
  logger.debug('Padding and joining for %s'%rec)
239
543
  needs_dedrift, delta = rec.needs_dedrifting()
240
544
  logger.debug('next audio is %s'%rec)
@@ -252,12 +556,17 @@ class AudioStitcherVideoMerger:
252
556
  rec.time_position))
253
557
  # TODO check if rec.needs_dedrifting() before padding
254
558
  pad_duration = rec.time_position - end_time
559
+ if pad_duration < 0:
560
+ raise Exception('for rec %s, time_position < end_time? %f %f'%
561
+ (rec,rec.time_position,end_time))
255
562
  self._pad_file(rec, pad_duration)
256
563
  # new_file = rec.edited_version
257
- new_file = self.edited_audio[rec]
564
+ new_file = self.soxed_audio[rec]
258
565
  growing_file = self._concatenate_audio_files(growing_file, new_file)
259
566
  end_time = sox.file_info.duration(growing_file.name)
260
567
  logger.debug('total edited audio duration %.2f s'%end_time)
568
+ logger.debug('video duration %.2f s'%
569
+ self.videoclip.get_duration())
261
570
  return growing_file
262
571
 
263
572
  def _pad_or_trim_first_audio(self, first_rec):
@@ -265,18 +574,18 @@ class AudioStitcherVideoMerger:
265
574
  TODO: check if first_rec is a Recording or tempfile (maybe a tempfile if dedrifted)
266
575
  NO: will change tempo after trimming/padding
267
576
 
268
- Store (into Recording.edited_audio dict) the handle of the sox processed
269
- first recording, padded or chopped according to AudioStitcherVideoMerger.ref_recording
577
+ Store (into Recording.soxed_audio dict) the handle of the sox processed
578
+ first recording, padded or chopped according to AudioStitcherVideoMerger.videoclip
270
579
  starting time. Length of the written file can differ from length of the
271
580
  submitted Recording object if drift is corrected with sox tempo
272
581
  transform, so check it with sox.file_info.duration()
273
582
  """
274
583
  logger.debug(' editing %s'%first_rec)
275
584
  audio_start = first_rec.get_start_time()
276
- ref_start = self.ref_recording.get_start_time()
277
- if ref_start < audio_start: # padding
585
+ video_start = self.videoclip.get_start_time()
586
+ if video_start < audio_start: # padding
278
587
  logger.debug('padding')
279
- pad_duration = (audio_start-ref_start).total_seconds()
588
+ pad_duration = (audio_start-video_start).total_seconds()
280
589
  """padding first_file:
281
590
  ┏━━━━━━━━━━━━━━━┓
282
591
  ┗━━━━━━━━━━━━━━━┛ref
@@ -286,7 +595,7 @@ class AudioStitcherVideoMerger:
286
595
  self._pad_file(first_rec, pad_duration)
287
596
  else:
288
597
  logger.debug('trimming')
289
- length = (ref_start-audio_start).total_seconds()
598
+ length = (video_start-audio_start).total_seconds()
290
599
  """chopping first_file:
291
600
  ┏━━━━━━━━━━━━━━━┓
292
601
  ┗━━━━━━━━━━━━━━━┛ref
@@ -314,9 +623,9 @@ class AudioStitcherVideoMerger:
314
623
 
315
624
  def _pad_file(self, recording, pad_duration):
316
625
  # set recording.edited_version to the handle file a sox padded audio
626
+ logger.debug('sox_transform.pad arg: %f secs'%pad_duration)
317
627
  sox_transform = sox.Transformer()
318
628
  sox_transform.pad(pad_duration)
319
- logger.debug('sox_transform.pad arg: %f secs'%pad_duration)
320
629
  self._edit_audio_file(recording, sox_transform)
321
630
 
322
631
  def _chop_file(self, recording, length):
@@ -329,290 +638,550 @@ class AudioStitcherVideoMerger:
329
638
  def _edit_audio_file(self, audio_rec, sox_transform):
330
639
  """
331
640
  Apply the specified sox_transform onto the audio_rec and update
332
- self.edited_audio dict with the result (with audio_rec as the key)
641
+ self.soxed_audio dict with the result (with audio_rec as the key)
642
+ Returns the filehandle of the result.
333
643
  """
334
644
  output_fh = tempfile.NamedTemporaryFile(suffix='.wav', delete=DEL_TEMP)
335
645
  logger.debug('transform: %s'%sox_transform.effects)
336
- recording_fh = self.edited_audio[audio_rec]
646
+ recording_fh = self.soxed_audio[audio_rec]
337
647
  logger.debug('for recording %s, matching %s'%(audio_rec,
338
- self.ref_recording))
648
+ self.videoclip))
339
649
  input_file = _pathname(recording_fh)
340
- logger.debug('AudioStitcherVideoMerger.edited_audio[audio_rec]: %s'%
650
+ logger.debug('AudioStitcherVideoMerger.soxed_audio[audio_rec]: %s'%
341
651
  input_file)
342
652
  out_file = _pathname(output_fh)
343
653
  logger.debug('sox in and out files: %s %s'%(input_file, out_file))
654
+ logger.debug('calling sox_transform.build()')
344
655
  status = sox_transform.build(input_file, out_file, return_output=True )
345
656
  logger.debug('sox.build exit code %s'%str(status))
346
657
  # audio_rec.edited_version = output_fh
347
- self.edited_audio[audio_rec] = output_fh
658
+ self.soxed_audio[audio_rec] = output_fh
659
+ return output_fh
348
660
 
349
- def _parse_track_values(self, tracks_file):
661
+ def _write_ISOs(self, edited_audio_all_devices,
662
+ snd_root=None, synced_root=None, raw_root=None, audio_only=False):
350
663
  """
351
- read track names for naming separated ISOs
352
- from tracks_file.
664
+ [TODO: this multiline doc is obsolete]
665
+ Writes isolated audio files that were synced to synced_clip_file,
666
+ each track will have its dedicated monofile, named sequentially or with
667
+ the name find in TRACKSFILE if any, see Scanner._get_tracks_from_file()
353
668
 
354
- repeting prefixes signals a stereo track
355
- and entries will correspondingly panned into
356
- a stero mix named mixL.wav and mixR.wav
669
+ edited_audio_all_devices:
670
+ a list of (name, mono_tempfile, dev) -------------------------------------------> add argument for device for calling _get_all_recordings_for() for file for metada
357
671
 
358
- mic1 L # space is optionnal and will be removed
359
- mic1 R
360
- mic2 L
361
- mic2 R
672
+ Returns nothing, output is written to filesystem as below.
673
+ ISOs subfolders structure when user invokes the --isos flag:
362
674
 
363
- mixL
675
+ SyncedMedia/ (or anchor_dir)
364
676
 
365
- Returns:
366
- a dict with keys:
367
- 'ttc', value is track number of TicTacCode signal (1 = 1st track)
368
- '0', list on unused tracks
369
- 'stereomics', list of stereo mics track (Lchan#, Rchan#)
370
- 'mix', list of mixed tracks, if a pair, order is L than R
371
- 'others', list of all other tags: (tag, track#)
372
- 'error msg', value 'None' if none
677
+ leftCAM/
678
+
679
+ canon24fps01.MOV ━━━━┓ name of clip is name of folder
680
+ canon24fps01_ISO/ <━━┛
681
+ chan_1.wav
682
+ chan_2.wav [UPDATE FOR MAM mode]
683
+ canon24fps02.MOV
684
+ canon24fps01_ISO/
685
+ chan_1.wav
686
+ chan_2.wav
687
+
688
+ rightCAM/
373
689
  """
374
- def _WOspace(chaine):
375
- ch = [c for c in chaine if c != ' ']
376
- return ''.join(ch)
377
- def _WO_LR(chaine):
378
- ch = [c for c in chaine if c not in 'lr']
379
- return ''.join(ch)
380
- def _seemsStereoMic(tag):
381
- # is tag likely a stereo pair tag?
382
- # should start with 'mic' and end with 'l' or 'r'
383
- return tag[:3]=='mic' and tag[-1] in 'lr'
384
- file=open(tracks_file,"r")
385
- whole_txt = file.read()
386
- logger.debug('all_lines:\n%s'%whole_txt)
387
- tracks_lines = [l.split('#')[0] for l in whole_txt.splitlines() if len(l) > 0 ]
388
- tracks_lines = [_WOspace(l).lower() for l in tracks_lines if len(l) > 0 ]
389
- tracks_lines = [(t,ix+1) for ix,t in enumerate(tracks_lines)]
390
- # tracks_lines = [l for l in tracks_lines if l not in ['ttc', '0']]
391
- # track_names = [l.split()[0] for l in tracks_lines if l != 'ttc']
392
- logger.debug('tracks_lines: %s'%tracks_lines)
393
- # first check for stereo mic pairs (could be more than one pair):
394
- spairs = [e for e in tracks_lines if _seemsStereoMic(e[0])]
395
- error_output_stereo = {'error msg': 'Confusing stereo pair tags: %s'%
396
- ' '.join([e[0] for e in spairs])}
397
- # spairs is stereo pairs candidates
398
- if len(spairs)%2 == 1: # not pairs?, quit.
399
- return error_output_stereo
400
- logger.debug('_seemsStereoM: %s'%spairs)
401
- output_dict = {'error msg': None}
402
- if spairs:
403
- even_idxes = range(0,len(spairs),2)
404
- paired = [(spairs[i], spairs[i+1]) for i in even_idxes]
405
- # eg [(('mic1l', 1), ('mic1r', 2)), (('mic2l', 3), ('mic2r', 4))]
406
- def _mic_same(p):
407
- # p = (('mic1l', 1), ('mic1r', 2))
408
- # check if mic1 == mic1
409
- p1, p2 = p
410
- return _WO_LR(p1[0]) == _WO_LR(p2[0])
411
- mic_prefix_OK = all([_mic_same(p) for p in paired])
412
- logger.debug('mic_prefix_OK: %s'%mic_prefix_OK)
413
- if not mic_prefix_OK:
414
- return error_output_stereo
415
- def _LR(p):
416
- # p = (('mic1l', 1), ('mic1r', 2))
417
- # check if L then R
418
- p1, p2 = p
419
- return p1[0][-1] == 'l' and p2[0][-1] == 'r'
420
- mic_LR_OK = all([_LR(p) for p in paired])
421
- logger.debug('mic_LR_OK %s'%mic_LR_OK)
422
- if not mic_LR_OK:
423
- return error_output_stereo
424
- def _stereo_mic_pref_chan(p):
425
- # p = (('mic1l', 1), ('mic1r', 2))
426
- # returns ('mic1', (1,2))
427
- first, second = p
428
- mic_prefix = _WO_LR(first[0])
429
- return (mic_prefix, (first[1], second[1]) )
430
- grouped_stereo_mic_channels = [_stereo_mic_pref_chan(p) for p
431
- in paired]
432
- logger.debug('grouped_stereo_mic_channels: %s'%
433
- grouped_stereo_mic_channels)
434
- output_dict['stereomics'] = grouped_stereo_mic_channels
435
-
436
- [tracks_lines.remove(e) for e in spairs]
437
- logger.debug('stereo mic pairs done, continue with %s'%tracks_lines)
438
- # second, check for stereo mix down (one mixL mixR pair)
439
- def _seemsStereoMix(tag):
440
- # is tag likely a stereo pair tag?
441
- # should start with 'mic' and end with 'l' or 'r'
442
- return tag[:3]=='mix' and tag[-1] in 'lr'
443
- stereo_mix_tags = [e for e in tracks_lines if _seemsStereoMix(e[0])]
444
- logger.debug('stereo_mix_tags: %s'%stereo_mix_tags)
445
- error_output = {'error msg': 'Confusing mix pair tags: %s'%
446
- ' '.join([e[0] for e in stereo_mix_tags])}
447
- if stereo_mix_tags:
448
- if len(stereo_mix_tags) != 2:
449
- return error_output
450
- mix_LR_OK = _LR(stereo_mix_tags)
451
- logger.debug('mix_LR_OK %s'%mix_LR_OK)
452
- if not mix_LR_OK:
453
- return error_output
454
- if stereo_mix_tags:
455
- stereo_mix_channels = [t[1] for t in stereo_mix_tags]
456
- output_dict['mix'] = stereo_mix_channels
457
- [tracks_lines.remove(e) for e in stereo_mix_tags]
458
- logger.debug('stereo mix done, will continue with %s'%tracks_lines)
459
- # third, check for a mono mix
460
- mono_mix_tags = [e for e in tracks_lines if e[0] == 'mix']
461
- if mono_mix_tags:
462
- logger.debug('mono_mix_tags: %s'%mono_mix_tags)
463
- if len(mono_mix_tags) != 1:
464
- return {'error msg': 'more than one "mix" token'}
465
- output_dict['mix'] = [mono_mix_tags[0][1]]
466
- else:
467
- output_dict['mix'] = []
468
- [tracks_lines.remove(e) for e in mono_mix_tags]
469
- logger.debug('mono mix done, will continue with %s'%tracks_lines)
470
- # fourth, look for 'ttc'
471
- ttc_chan = [idx for tag, idx in tracks_lines if tag == 'ttc']
472
- if ttc_chan:
473
- if len(ttc_chan) > 1:
474
- return {'error msg': 'more than one "ttc" token'}
475
- output_dict['ttc'] = ttc_chan[0]
476
- tracks_lines.remove(('ttc', ttc_chan[0]))
477
- else:
478
- return {'error msg': 'no "ttc" token'}
479
- # fifth, check for '0'
480
- logger.debug('ttc done, will continue with %s'%tracks_lines)
481
- zeroed = [idx for tag, idx in tracks_lines if tag == '0']
482
- logger.debug('zeroed %s'%zeroed)
483
- if zeroed:
484
- output_dict['0'] = zeroed
485
- [tracks_lines.remove(('0',i)) for i in zeroed]
486
- else:
487
- output_dict['0'] = []
488
- # sixth, check for 'others'
489
- logger.debug('0s done, will continue with %s'%tracks_lines)
490
- if tracks_lines:
491
- output_dict['others'] = tracks_lines
690
+ def _fit_length(audio_tempfile) -> tempfile.NamedTemporaryFile:
691
+ """
692
+ Changes the length of audio contained in audio_tempfile so it is the
693
+ same as video length associated with this AudioStitcherVideoMerger.
694
+ Returns a tempfile.NamedTemporaryFile with the new audio
695
+ """
696
+ sox_transform = sox.Transformer()
697
+ audio_length = sox.file_info.duration(_pathname(audio_tempfile))
698
+ video_length = self.videoclip.get_duration()
699
+ if audio_length > video_length:
700
+ # trim audio
701
+ sox_transform.trim(0, video_length)
702
+ else:
703
+ # pad audio
704
+ sox_transform.pad(0, video_length - audio_length)
705
+ out_tf = tempfile.NamedTemporaryFile(suffix='.wav',
706
+ delete=DEL_TEMP)
707
+ logger.debug('transform: %s'%sox_transform.effects)
708
+ input_file = _pathname(audio_tempfile)
709
+ out_file = _pathname(out_tf)
710
+ logger.debug('sox in and out files: %s %s'%(input_file, out_file))
711
+ status = sox_transform.build(input_file, out_file,
712
+ return_output=True )
713
+ logger.debug('sox.build exit code %s'%str(status))
714
+ logger.debug('audio duration %.2f s'%
715
+ sox.file_info.duration(_pathname(out_tf)))
716
+ logger.debug('video duration %.2f s'%
717
+ self.videoclip.get_duration())
718
+ logger.debug(f'video {self.videoclip}')
719
+ return out_tf
720
+ def _meta_wav_dest(p1, p2, p3):
721
+ """
722
+ takes metadata from p1, sound from p2 and combine them to create p3.
723
+ arguments are pathlib.Path or string;
724
+ returns nothing.
725
+ """
726
+ f1, f2, f3 = [_pathname(p) for p in [p1, p2, p3]]
727
+ process_list = ['ffmpeg', '-y', '-loglevel', 'quiet', '-nostats', '-hide_banner',
728
+ '-i', f1, '-i', f2, '-map', '1',
729
+ '-map_metadata', '0', '-c', 'copy', f3]
730
+ proc = subprocess.run(process_list)
731
+ logger.debug(f'synced_clip_file raw')
732
+ if snd_root == None:
733
+ # alongside mode
734
+ synced_clip_file = self.videoclip.final_synced_file
735
+ logger.debug('alongside mode')
736
+ synced_clip_dir = synced_clip_file.parent
492
737
  else:
493
- output_dict['others'] = []
494
- return output_dict
738
+ # MAM mode
739
+ synced_clip_file = self.videoclip.AVpath
740
+ logger.debug('MAM mode')
741
+ rel = synced_clip_file.parent.relative_to(raw_root)
742
+ synced_clip_dir = Path(snd_root)/Path(raw_root).name/rel
743
+ logger.debug(f'synced_clip_dir: {synced_clip_dir}')
744
+ # build ISOs subfolders structure, see comment string below
745
+ video_stem_WO_suffix = synced_clip_file.stem
746
+ # ISOdir = synced_clip_dir/(video_stem_WO_suffix + 'ISO')
747
+ ISOdir = synced_clip_dir/(video_stem_WO_suffix + '_SND')/'ISOfiles'
748
+ os.makedirs(ISOdir, exist_ok=True)
749
+ logger.debug('edited_audio_all_devices %s'%edited_audio_all_devices)
750
+ logger.debug('ISOdir %s'%ISOdir)
751
+ for name, mono_tmpfl, device in edited_audio_all_devices:
752
+ logger.debug(f'name:{name} mono_tmpfl:{mono_tmpfl} device:{pformat(device)}')
753
+ # destination = ISOdir/(f'{video_stem_WO_suffix}_{name}.wav')
754
+ destination = ISOdir/(f'{name}_{video_stem_WO_suffix}.wav')
755
+ mono_tmpfl_trimpad = _fit_length(mono_tmpfl)
756
+ # if audio_only, self.ref_audio does not have itself as matching audio
757
+ if audio_only and device == self.ref_audio.device:
758
+ first_rec = self.ref_audio
759
+ else:
760
+ first_rec = self._get_all_recordings_for(device)[0]
761
+ logger.debug(f'will use {first_rec} for metadata source to copy over {destination}')
762
+ _meta_wav_dest(first_rec.AVpath, mono_tmpfl_trimpad, destination)
763
+ # remove_write_permissions(destination)
764
+ logger.debug('destination:%s'%destination)
765
+
766
+ def _get_device_mix(self, device, multichan_tmpfl) -> tempfile.NamedTemporaryFile:
767
+ """
768
+ Build or get a mix from edited and joined audio for a given device
769
+
770
+ Returns a mix for merging with video clip. The way the mix is obtained
771
+ (or created) depends if a tracks.txt for the device was submitted and
772
+ depends on its content. There are 4 cases (explained later):
773
+
774
+ #1 no mix (or no tracks.txt), all mono
775
+ #2 no mix, one or more stereo mics
776
+ #3 mono mix declared
777
+ #4 stereo mix declared
778
+
779
+ In details:
780
+
781
+ If no device tracks.txt file declared a mix track (or if tracks.txt is
782
+ absent) a mix is done programmatically. Two possibilities:
783
+
784
+ #1- no stereo pairs were declared: a global mono mix is returned.
785
+ #2- one or more stereo pair mics were used and declared (micL, micR):
786
+ a global stereo mix is returned with mono tracks panned 50-50
787
+
788
+ If device has an associated Tracks description AND it declares a (mono or
789
+ stereo) mix track, this fct returns a tempfile containing the
790
+ corresponding tracks, simply extracting them from multichan_tmpfl
791
+ (those covers cases #3 and #4)
792
+
793
+ Args:
794
+ device : device_scanner.Device dataclass
795
+ the device that recorded the audio found in multichan_tmpfl
796
+ multichan_tmpfl : tempfile.NamedTemporaryFile
797
+ contains the edited and synced audio, almost ready to be merged
798
+ with the concurrent video file (after mix down)
495
799
 
496
- def build_audio_and_write_video(self, top_dir,
497
- OUT_struct_for_mcam,
498
- write_ISOs):
800
+ Returns:
801
+ the tempfile.NamedTemporaryFile of a stereo or mono wav file
802
+ containing the audio to be merged with the video in
803
+ self.videoclip
804
+
805
+
806
+ """
807
+ logger.debug('device %s'%device)
808
+ if device.n_chan == 2:
809
+ # tracks.txt or not,
810
+ # it's stereo, ie audio + TTC, so remove TTC and return
811
+ kept_channel = (device.ttc + 1)%2 # 1 -> 0 and 0 -> 1
812
+ logger.debug('no tracks.txt, keeping one chan %i'%kept_channel)
813
+ return _sox_keep(multichan_tmpfl, [kept_channel + 1]) #-------------
814
+ logger.debug('device.n_chan != 2, so multitrack')
815
+ # it's multitrack (more than 2 channels)
816
+ if device.tracks is None:
817
+ # multitrack but no mix done on location, so do mono mix with all
818
+ all_channels = list(range(device.n_chan))
819
+ logger.debug('multitrack but no tracks.txt, mixing %s except TTC at %i'%
820
+ (all_channels, device.ttc))
821
+ all_channels.remove(device.ttc)
822
+ sox_kept_channels = [i + 1 for i in all_channels] # sox indexing
823
+ logger.debug('mixing channels: %s (sox #)'%sox_kept_channels)
824
+ kept_audio = _sox_keep(multichan_tmpfl, sox_kept_channels)
825
+ return _sox_mix_channels(kept_audio) #------------------------------
826
+ logger.debug('there IS a device.tracks')
827
+ # user wrote a tracks.txt metadata file, check it to get the mix(or do
828
+ # it). But first a check is done if the ttc tracks concur: the track
829
+ # detected by the Decoder class, stored in device.ttc VS the track
830
+ # declared by the user, device.tracks.ttc (see device_scanner.py). If
831
+ # not, warn the user and exit.
832
+ logger.debug('ttc channel declared for the device: %i, ttc detected: %i, non zero base indexing'%
833
+ (device.ttc, device.tracks.ttc))
834
+ if device.ttc + 1 != device.tracks.ttc: # warn and quit
835
+ print('Error: TicTacCode channel detected is [gold1]%i[/gold1]'%
836
+ (device.ttc), end=' ')
837
+ print('and file [gold1]%s[/gold1]\nfor the device [gold1]%s[/gold1] specifies channel [gold1]%i[/gold1],'%
838
+ (device.folder/Path(yaltc.TRACKSFILE),
839
+ device.name, device.tracks.ttc-1))
840
+ print('Please correct the discrepancy and rerun. Quitting.')
841
+ sys.exit(1)
842
+ if device.tracks.mix == [] and device.tracks.stereomics == []:
843
+ # it's multitrac and no mix done on location, so do a mono mix with
844
+ # all, but here remove '0' and TTC tracks from mix
845
+ all_channels = list(range(1, device.n_chan + 1)) # sox not ZBIDX
846
+ to_remove = device.tracks.unused + [device.ttc+1]# unused is sox idx
847
+ logger.debug('multitrack but no mix, mixing mono %s except # %s (sox #)'%
848
+ (all_channels, to_remove))
849
+ sox_kept_channels = [i for i in all_channels
850
+ if i not in to_remove]
851
+ logger.debug('mixing channels: %s (sox #)'%sox_kept_channels)
852
+ kept_audio = _sox_keep(multichan_tmpfl, sox_kept_channels)
853
+ return _sox_mix_channels(kept_audio) #------------------------------
854
+ logger.debug('device.tracks.mix != [] or device.tracks.stereomics != []')
855
+ if device.tracks.mix != []:
856
+ # Mix were done on location, no and we only have to extracted it
857
+ # from the recording. If mono mix, device.tracks.mix has one element;
858
+ # if stereo mix, device.tracks.mix is a pair of number:
859
+ logger.debug('%s has mix %s'%(device.name, device.tracks.mix))
860
+ logger.debug('device %s'%device)
861
+ # just checking coherency
862
+ if 'tc' in device.tracks.rawtrx:
863
+ trx_TTC_chan = device.tracks.rawtrx.index('tc')
864
+ elif 'TC' in device.tracks.rawtrx:
865
+ trx_TTC_chan = device.tracks.rawtrx.index('TC')
866
+ else:
867
+ print('Error: no tc or ttc tag in track.txt')
868
+ print(device.tracks.rawtrx)
869
+ sys.exit(1)
870
+ logger.debug('TTC chan %i, dev ttc %i'%(trx_TTC_chan, device.ttc))
871
+ if trx_TTC_chan != device.ttc:
872
+ print('Error: ttc channel # incoherency in track.txt')
873
+ sys.exit(1)
874
+ # coherency check done, extract mix track (or tracks if stereo)
875
+ mix_kind = 'mono' if len(device.tracks.mix) == 1 else 'stereo'
876
+ logger.debug('%s mix declared on channel %s (sox #)'%
877
+ (mix_kind, device.tracks.mix))
878
+ return _sox_keep(multichan_tmpfl, device.tracks.mix) #--------------
879
+ logger.debug('device.tracks.mix == []')
880
+ # if here, all cases have been covered, all is remaining is this case:
881
+ # tracks.txt exists AND there is no mix AND stereo mic(s) so first a
882
+ # coherency check, and then proceed
883
+ if device.tracks.stereomics == []:
884
+ print('Error, no stereo mic?, check tracks.txt. Quitting')
885
+ sys.exit(1)
886
+ logger.debug('processing stereo pair(s) %s'%device.tracks.stereomics)
887
+ stereo_mic_idx_pairs = [pair for name, pair in device.tracks.stereomics]
888
+ logger.debug('stereo pairs idxs %s'%stereo_mic_idx_pairs)
889
+ mic_stereo_files = [_sox_keep(multichan_tmpfl, pair) for pair
890
+ in stereo_mic_idx_pairs]
891
+ # flatten list of tuples of channels being stereo mics
892
+ stereo_mic_idx_flat = [item for sublist in stereo_mic_idx_pairs
893
+ for item in sublist]
894
+ logger.debug('stereo_mic_idx_flat %s'%stereo_mic_idx_flat)
895
+ mono_tracks = [i for i in range(1, device.n_chan + 1)
896
+ if i not in stereo_mic_idx_flat]
897
+ logger.debug('mono_tracks (with ttc+zeroed included): %s'%mono_tracks)
898
+ # remove TTC track number
899
+ to_remove = device.tracks.unused + [device.ttc+1]# unused is sox idx
900
+ [mono_tracks.remove(t) for t in to_remove]
901
+ # mono_tracks.remove(device.ttc + 1)
902
+ logger.debug('mono_tracks (ttc+zeroed removed)%s'%mono_tracks)
903
+ mono_files = [_sox_keep(multichan_tmpfl, [chan]) for chan
904
+ in mono_tracks]
905
+ new_stereo_files = [_sox_mono2stereo(f) for f in mono_files]
906
+ stereo_files = mic_stereo_files + new_stereo_files
907
+ return _sox_mix_files(stereo_files)
908
+
909
+ def _build_and_write_audio(self, top_dir, anchor_dir=None):
499
910
  """
500
- OUT_struct_for_mcam: bool flag
911
+ This is called when only audio recorders were found (no cam).
501
912
 
502
- write_ISOs: bool flag
913
+ top_dir: Path, directory where media were looked for
503
914
 
504
- For each audio devices found overlapping self.ref_recording: pad, trim
505
- or stretch audio files calling _get_concatenated_audiofile_for(), and
915
+ anchor_dir: str for optional folder specified as CLI argument, if
916
+ value is None, fall back to OUT_DIR_DEFAULT
917
+
918
+ For each audio devices found overlapping self.ref_audio: pad, trim
919
+ or stretch audio files by calling _get_concatenated_audiofile_for(), and
920
+ put them in merged_audio_files_by_device. More than one audio recorder
921
+ can be used for a shot: that's why merged_audio_files_by_device is a
922
+ list.
923
+
924
+ Returns nothing
925
+
926
+ Sets AudioStitcherVideoMerger.final_synced_file on completion to list
927
+ containing all the synced and patched audio files.
928
+ """
929
+ self.ref_audio = self.videoclip # ref audio was stored in videoclip
930
+ logger.debug('Will merge audio against %s from %s'%(self.ref_audio,
931
+ self.ref_audio.device.name))
932
+ # eg, suppose the user called tictacsync with 'mondayPM' as top folder
933
+ # to scan for dailies (and 'somefolder' for output):
934
+ if anchor_dir == None:
935
+ synced_clip_dir = Path(top_dir)/OUT_DIR_DEFAULT # = mondayPM/SyncedMedia
936
+ else:
937
+ synced_clip_dir = Path(anchor_dir)/Path(top_dir).name # = somefolder/mondayPM
938
+ self.synced_clip_dir = synced_clip_dir
939
+ os.makedirs(synced_clip_dir, exist_ok=True)
940
+ logger.debug('synced_clip_dir is: %s'%synced_clip_dir)
941
+ synced_clip_file = synced_clip_dir/self.videoclip.AVpath.name
942
+ logger.debug('editing files for synced_clip_file%s'%synced_clip_file)
943
+ self.ref_audio.final_synced_file = synced_clip_file # relative path
944
+ # Collecting edited audio by device, in (Device, tempfile) pairs:
945
+ # for a given self.ref_audio, each other audio device will have a sequence
946
+ # of matched, synced and joined audio files present in a single
947
+ # edited audio file, returned by _get_concatenated_audiofile_for
948
+ merged_audio_files_by_device = [
949
+ (d, self._get_concatenated_audiofile_for(d))
950
+ for d in self._get_secondary_audio_devices()]
951
+ # at this point, audio editing has been done in tempfiles
952
+ logger.debug('%i elements in merged_audio_files_by_device'%len(
953
+ merged_audio_files_by_device))
954
+ for d, f, in merged_audio_files_by_device:
955
+ logger.debug('device: %s'%d.name)
956
+ logger.debug('file %s of %i channels'%(f.name,
957
+ sox.file_info.channels(f.name)))
958
+ logger.debug('')
959
+ if not merged_audio_files_by_device:
960
+ # no audio file overlaps for this clip
961
+ return #############################################################
962
+ logger.debug('will output ISO files since no cam')
963
+ devices_and_monofiles = [(device, _sox_split_channels(multi_chan_audio))
964
+ for device, multi_chan_audio
965
+ in merged_audio_files_by_device]
966
+ # add device and file from self.ref_audio
967
+ new_tuple = (self.ref_audio.device,
968
+ _sox_split_channels(self.ref_audio.AVpath))
969
+ devices_and_monofiles.append(new_tuple)
970
+ logger.debug('devices_and_monofiles: %s'%
971
+ pformat(devices_and_monofiles))
972
+ def _trnm(dev, idx): # used in the loop just below
973
+ # generates track name for later if asked_ISOs
974
+ # idx is from 0 to nchan-1 for this device
975
+ if dev.tracks == None:
976
+ chan_name = 'chan%s'%str(idx+1).zfill(2)
977
+ else:
978
+ # sanitize
979
+ symbols = set(r"""`~!@#$%^&*()_-+={[}}|\:;"'<,>.?/""")
980
+ chan_name = dev.tracks.rawtrx[idx]
981
+ logger.debug('raw chan_name %s'%chan_name)
982
+ chan_name = chan_name.split(';')[0] # if ex: "lav bob;25"
983
+ logger.debug('chan_name WO ; lag: %s'%chan_name)
984
+ chan_name =''.join([e if e not in symbols else ''
985
+ for e in chan_name])
986
+ logger.debug('chan_name WO special chars: %s'%chan_name)
987
+ chan_name = chan_name.replace(' ', '_')
988
+ logger.debug('chan_name WO spaces: %s'%chan_name)
989
+ chan_name += '_' + dev.name # TODO: make this an option?
990
+ logger.debug('track_name %s'%chan_name)
991
+ return chan_name #####################################################
992
+ # replace device, idx pair with track name (+ device name if many)
993
+ # loop over devices than loop over tracks
994
+ names_audio_tempfiles = []
995
+ for dev, mono_tmpfiles_list in devices_and_monofiles:
996
+ for idx, monotf in enumerate(mono_tmpfiles_list):
997
+ track_name = _trnm(dev, idx)
998
+ logger.debug('track_name %s'%track_name)
999
+ if track_name[0] == '0': # muted, skip
1000
+ continue
1001
+ names_audio_tempfiles.append((track_name, monotf, dev))
1002
+ logger.debug('names_audio_tempfiles %s'%pformat(names_audio_tempfiles))
1003
+ self._write_ISOs(names_audio_tempfiles, audio_only=True)
1004
+ logger.debug('merged_audio_files_by_device %s'%
1005
+ merged_audio_files_by_device)
1006
+
1007
+ def _build_audio_and_write_video(self, top_dir, dont_write_cam_folder,
1008
+ asked_ISOs, synced_root = None,
1009
+ snd_root = None, raw_root = None):
1010
+ """
1011
+ top_dir: Path, directory where media were looked for
1012
+
1013
+ anchor_dir: str for optional folder specified as CLI argument, if
1014
+ value is None, fall back to OUT_DIR_DEFAULT
1015
+
1016
+ dont_write_cam_folder: True if needs to bypass writing multicam folders
1017
+
1018
+ asked_ISOs: bool flag specified as CLI argument
1019
+
1020
+ For each audio devices found overlapping self.videoclip: pad, trim
1021
+ or stretch audio files by calling _get_concatenated_audiofile_for(), and
506
1022
  put them in merged_audio_files_by_device. More than one audio recorder
507
1023
  can be used for a shot: that's why merged_audio_files_by_device is a
508
1024
  list
509
1025
 
510
1026
  Returns nothing
511
1027
 
512
- Sets self.final_synced_file on completion
1028
+ Sets AudioStitcherVideoMerger.final_synced_file on completion
513
1029
  """
514
- logger.debug('device for rec %s: %s'%(self.ref_recording,
515
- self.ref_recording.device))
516
- D1, D2 = CAMDIR.split('/')
517
- if OUT_struct_for_mcam:
518
- device_name = self.ref_recording.device.name
519
- synced_clip_dir = Path(top_dir)/D1/D2/device_name
520
- logger.debug('created %s'%synced_clip_dir)
1030
+ logger.debug(' fct args: top_dir %s, dont_write_cam_folder %s, asked_ISOs %s'%
1031
+ (top_dir, dont_write_cam_folder, asked_ISOs))
1032
+ logger.debug('device for rec %s: %s'%(self.videoclip,
1033
+ self.videoclip.device))
1034
+ if synced_root == None:
1035
+ # alongside, within SyncedMedia dirs
1036
+ synced_clip_dir = self.videoclip.AVpath.parent/OUT_DIR_DEFAULT
1037
+ logger.debug('"alongside mode" for clip: %s'%self.videoclip.AVpath)
1038
+ logger.debug(f'will save in {synced_clip_dir}')
521
1039
  else:
522
- synced_clip_dir = Path(top_dir)/D1
1040
+ # MAM mode
1041
+ logger.debug('MAM mode')
1042
+ synced_clip_dir = Path(synced_root)/str(self.videoclip.AVpath.parent)[1:] # strip leading /
1043
+ logger.debug(f'self.videoclip.AVpath.parent: {self.videoclip.AVpath.parent}')
1044
+ logger.debug(f'raw_root {raw_root}')
1045
+ # rel = self.videoclip.AVpath.parent.relative_to(raw_root).parent # removes ROLL01?
1046
+ rel = self.videoclip.AVpath.parent.relative_to(raw_root)
1047
+ logger.debug(f'relative path {rel}')
1048
+ synced_clip_dir = Path(synced_root)/Path(raw_root).name/rel
1049
+ logger.debug(f'will save in {synced_clip_dir}')
1050
+ self.synced_clip_dir = synced_clip_dir
523
1051
  os.makedirs(synced_clip_dir, exist_ok=True)
524
- synced_clip_file = synced_clip_dir/Path(self.ref_recording.new_rec_name).name
525
- logger.debug('editing files for %s'%synced_clip_file)
526
- # looping over devices:
1052
+ # logger.debug('synced_clip_dir is: %s'%synced_clip_dir)
1053
+ synced_clip_file = synced_clip_dir/self.videoclip.AVpath.name
1054
+ logger.debug('editing files for synced_clip_file %s'%synced_clip_file)
1055
+ self.videoclip.final_synced_file = synced_clip_file # relative path
1056
+ # Collecting edited audio by device, in (Device, tempfiles) pairs:
1057
+ # for a given self.videoclip, each audio device will have a sequence
1058
+ # of matched, synced and joined audio files present in a single
1059
+ # edited audio file, returned by _get_concatenated_audiofile_for
527
1060
  merged_audio_files_by_device = [
528
1061
  (d, self._get_concatenated_audiofile_for(d))
529
1062
  for d in self._get_audio_devices()]
530
- if len(merged_audio_files_by_device) > 1:
531
- logger.error('more than one audio device... not yet implemented')
532
- quit()
1063
+ # at this point, audio editing has been done in multichan wav tempfiles
1064
+ logger.debug('merged_audio_files_by_device %s'%merged_audio_files_by_device)
1065
+ for d, f, in merged_audio_files_by_device:
1066
+ logger.debug('%s'%d)
1067
+ logger.debug('file %s'%f.name)
533
1068
  if len(merged_audio_files_by_device) == 0:
534
1069
  # no audio file overlaps for this clip
535
- return
536
- device, joined_audio = merged_audio_files_by_device[0] # [TO DO] multi audio recorders,
537
- # should loop over the list... For now, process just the sole element
538
- logger.debug('device, joined_audio %s %s'%(device, joined_audio))
539
- joined_audio = joined_audio.name # [TO DO] multi audio recorders
540
- # joinded_audio is mono, stereo or even polywav from multitrack recorders
541
- # generates ISOs too
542
- source_audio_folder = device.folder
543
- # look for track names in TRACKSFN file:
544
- tracks_file = source_audio_folder/TRACKSFN
545
- track_names = False
546
- nchan = sox.file_info.channels(str(joined_audio))
547
- if os.path.isfile(tracks_file):
548
- logger.debug('found file: %s'%(TRACKSFN))
549
- track_dict = self._parse_track_values(tracks_file)
550
- if track_dict['error msg']:
551
- print('\nError parsing %s file: %s, quitting.\n'%(tracks_file,
552
- track_dict['error msg']))
553
- quit()
554
-
555
- logger.debug('parsed track_dict %s'%track_dict)
556
- n_IDied_chan = 2*len(track_dict['stereomics'])
557
- n_IDied_chan += len(track_dict['mix'])
558
- n_IDied_chan += len(track_dict['0'])
559
- n_IDied_chan += len(track_dict['others'])
560
- n_IDied_chan += 1 # for ttc track
561
- logger.debug(' n chan: %i n tracks file: %i'%(nchan, n_IDied_chan))
562
- if n_IDied_chan != nchan:
563
- print('\nError parsing %s content'%tracks_file)
564
- print('incoherent number of tracks, %i vs %i quitting\n'%
565
- (nchan, n_IDied_chan))
566
- # quit()
567
- err_msg = track_dict['error msg']
568
- if err_msg != None:
569
- print('Error, quitting: in file %s, %s'%(tracks_file, err_msg))
570
- raise Exception
571
- else:
572
- logger.debug('no tracks.txt file found')
573
- if write_ISOs:
574
- # build ISOs subfolders structure, see comment string below
575
- video_stem_WO_suffix = synced_clip_file.stem.split('.')[0]
576
- D1, D2 = ISOsDIR.split('/')
577
- ISOdir = Path(top_dir)/D1/D2/video_stem_WO_suffix
578
- os.makedirs(ISOdir, exist_ok=True) # audio_name = synced_clip_file.stem + '.wav'
579
- ISO_multi_chan = ISOdir / 'ISO_multi_chan.wav'
580
- logger.debug('temp file: %s'%(ISO_multi_chan))
581
- logger.debug('will split audio to %s'%(ISOdir))
582
- shutil.copy(joined_audio, ISO_multi_chan)
583
- filepath = str(ISO_multi_chan)
584
- for n in range(nchan):
585
- if track_names:
586
- iso_destination = ISOdir / (track_names[n]+'.wav')
1070
+ return #############################################################
1071
+ if len(merged_audio_files_by_device) == 1:
1072
+ # only one audio recorder was used, pick singleton in list
1073
+ dev, concatenate_audio_file = merged_audio_files_by_device[0]
1074
+ logger.debug('one audio device only: %s'%dev)
1075
+ # check if this sole recorder is stereo
1076
+ if dev.n_chan == 2:
1077
+ # consistency check
1078
+ nchan_sox = sox.file_info.channels(
1079
+ _pathname(concatenate_audio_file))
1080
+ logger.debug('Two chan only, nchan_sox: %i dev.n_chan %i'%
1081
+ (nchan_sox, dev.n_chan))
1082
+ if not nchan_sox == 2:
1083
+ raise Exception('Error in channel processing')
1084
+ # all OK, merge and return
1085
+ logger.debug('simply mono to merge, TTC on chan %i'%
1086
+ dev.ttc)
1087
+ # only 2 channels so keep the channel OTHER than TTC
1088
+ if dev.ttc == 1:
1089
+ # keep channel 0, but + 1 because of sox indexing
1090
+ sox_kept_channel = 1
587
1091
  else:
588
- iso_destination = ISOdir / ('chan_%i.wav'%n)
589
- logger.debug('iso_destination %s'%iso_destination)
590
- _extr_channel(ISO_multi_chan, iso_destination, n+1)
591
- os.remove(ISO_multi_chan)
592
- video_path = self.ref_recording.AVpath
593
- logger.debug('will merge with %s'%(joined_audio))
594
- self._merge_audio_and_video(joined_audio, synced_clip_file)
595
- self.ref_recording.final_synced_file = synced_clip_file # relative
596
-
597
- """
598
- ISOs subfolders structure with --io foldercam option:
599
-
600
- SyncedMedia/
601
- ISOs/
602
- leftCAM/
603
- canon24fps01/ <━━━━┓ name of clip is name of folder
604
- chan_0.wav ┃
605
- chan_1.wav ┃
606
- chan_2.wav ┃
607
- canon24fps02 ┃
608
- chan_0.wav ┃
609
- chan_1.wav ┃
610
- chan_2.wav ┃
611
- CAMs/ ┃
612
- leftCAM/ ┃
613
- canon24fps01.MOV ╺━┛
614
- canon24fps02.MOV
615
- """
1092
+ # dev.ttc == 0 so keep ch 1, but + 1 (sox indexing)
1093
+ sox_kept_channel = 2
1094
+ self.videoclip.synced_audio = \
1095
+ _sox_keep(concatenate_audio_file, [sox_kept_channel])
1096
+ self._merge_audio_and_video()
1097
+ if asked_ISOs:
1098
+ print('WARNING: you asked for ISO files but found one audio channel only...')
1099
+ return #########################################################
1100
+ #
1101
+ # if not returned yet from fct, either multitracks and/or multi
1102
+ # recorders so check if a mix has been done on location and identified
1103
+ # as is in atracks.txt file. Split audio channels in mono wav tempfiles
1104
+ # at the same time
1105
+ #
1106
+ multiple_recorders = len(merged_audio_files_by_device) > 1
1107
+ logger.debug('multiple_recorder: %s'%multiple_recorders)
1108
+ # the device_mixes list contains all audio recorders if many. If only
1109
+ # one audiorecorder was used (most of the cases) len(device_mixes) is 1
1110
+ device_mixes = [self._get_device_mix(device, multi_chan_audio)
1111
+ for device, multi_chan_audio
1112
+ in merged_audio_files_by_device]
1113
+ logger.debug('there are %i dev device_mixes'%len(device_mixes))
1114
+ logger.debug('device_mixes %s'%device_mixes)
1115
+ mix_of_device_mixes = _sox_mix_files(device_mixes)
1116
+ logger.debug('will merge with %s'%(_pathname(mix_of_device_mixes)))
1117
+ self.videoclip.synced_audio = mix_of_device_mixes
1118
+ logger.debug('mix_of_device_mixes (final mix) has %i channels'%
1119
+ sox.file_info.channels(_pathname(mix_of_device_mixes)))
1120
+ self._merge_audio_and_video()
1121
+ # devices_and_monofiles is list of (device, [monofiles])
1122
+ # [(dev1, multichan1), (dev2, multichan2)] in
1123
+ # merged_audio_files_by_device ->
1124
+ # [(dev1, [mono1_ch1, mono1_ch2]), (dev2, [mono2_ch1, mono2_ch2)]] in
1125
+ # devices_and_monofiles:
1126
+ if asked_ISOs:
1127
+ logger.debug('will output ISO files...')
1128
+ devices_and_monofiles = [(device, _sox_split_channels(multi_chan_audio))
1129
+ for device, multi_chan_audio
1130
+ in merged_audio_files_by_device]
1131
+ logger.debug('devices_and_monofiles: %s'%
1132
+ pformat(devices_and_monofiles))
1133
+ def _build_from_tracks_txt(dev, idx):
1134
+ # used in the loop just below
1135
+ # generates track name for later if asked_ISOs
1136
+ # idx is from 0 to nchan-1 for this device
1137
+ if dev.tracks == None:
1138
+ logger.debug('dev.tracks == None')
1139
+ # no tracks.txt was found so use ascending numbers for name
1140
+ chan_name = 'chan%s'%str(idx+1).zfill(2)
1141
+ else:
1142
+ # sanitize names in tracks.txt
1143
+ symbols = set(r"""`~!@#$%^&*()_-+={[}}|\:;"'<,>.?/""")
1144
+ chan_name = dev.tracks.rawtrx[idx]
1145
+ logger.debug('raw chan_name %s'%chan_name)
1146
+ chan_name = chan_name.split(';')[0] # if ex: "lav bob;25"
1147
+ logger.debug('chan_name WO ; lag: %s'%chan_name)
1148
+ chan_name =''.join([e if e not in symbols else ''
1149
+ for e in chan_name])
1150
+ logger.debug('chan_name WO special chars: %s'%chan_name)
1151
+ chan_name = chan_name.replace(' ', '_')
1152
+ logger.debug('chan_name WO spaces: %s'%chan_name)
1153
+ if multiple_recorders:
1154
+ chan_name += '_' + dev.name # TODO: make this an option?
1155
+ logger.debug('track_name %s'%chan_name)
1156
+ return chan_name #####################################################
1157
+ # replace device, idx pair with track name (+ device name if many)
1158
+ # loop over devices than loop over tracks
1159
+ names_audio_tempfiles = []
1160
+ for dev, mono_tmpfiles_list in devices_and_monofiles:
1161
+ for idx, monotf in enumerate(mono_tmpfiles_list):
1162
+ track_name = _build_from_tracks_txt(dev, idx)
1163
+ logger.debug('track_name %s'%track_name)
1164
+ if track_name[0] == '0': # muted, skip
1165
+ continue
1166
+ names_audio_tempfiles.append((track_name, monotf, dev))
1167
+ logger.debug('names_audio_tempfiles %s'%pformat(names_audio_tempfiles))
1168
+ self._write_ISOs(names_audio_tempfiles,
1169
+ snd_root=snd_root, synced_root=synced_root, raw_root=raw_root)
1170
+ logger.debug('merged_audio_files_by_device %s'%
1171
+ merged_audio_files_by_device)
1172
+ # This loop below for logging purpose only:
1173
+ for idx, pair in enumerate(merged_audio_files_by_device):
1174
+ # dev_joined_audio is mono, stereo or even polywav from multitrack
1175
+ # recorders. For one video there could be more than one dev_joined_audio
1176
+ # if multiple audio recorders where used during the take.
1177
+ # this loop is for one device at the time.
1178
+ device, dev_joined_audio = pair
1179
+ logger.debug('idx: % i device.folder: %s'%(idx, device.folder))
1180
+ nchan = sox.file_info.channels(_pathname(dev_joined_audio))
1181
+ logger.debug('dev_joined_audio: %s nchan:%i'%
1182
+ (_pathname(dev_joined_audio), nchan))
1183
+ logger.debug('duration %f s'%
1184
+ sox.file_info.duration(_pathname(dev_joined_audio)))
616
1185
 
617
1186
  def _keep_VIDEO_only(self, video_path):
618
1187
  # return file handle to a temp video file formed from the video_path
@@ -625,16 +1194,30 @@ class AudioStitcherVideoMerger:
625
1194
  out1 = in1.output(file_handle.name, map='0:v', vcodec='copy')
626
1195
  ffmpeg.run([out1.global_args(*silenced_opts)], overwrite_output=True)
627
1196
  return file_handle
628
- # os.path.split audio channels if more than one
629
1197
 
630
- def _merge_audio_and_video(self, audio_path, synced_clip_file):
631
- # call ffmpeg to join audio and video
1198
+ def _merge_audio_and_video(self):
1199
+ """
1200
+ Calls ffmpeg to join video in self.videoclip.AVpath to
1201
+ audio in self.videoclip.synced_audio. Audio in original video
1202
+ is dropped.
1203
+
1204
+ On entry, videoclip.final_synced_file is a Path to an non existing
1205
+ file (contrarily to videoclip.synced_audio).
1206
+ On exit, self.videoclip.final_synced_file points to the final synced
1207
+ video file.
632
1208
 
633
- video_path = self.ref_recording.AVpath
634
- timecode = self.ref_recording.get_timecode()
635
- self.ref_recording.synced_audio = audio_path
1209
+ Returns nothing.
1210
+ """
1211
+ synced_clip_file = self.videoclip.final_synced_file
1212
+ video_path = self.videoclip.AVpath
1213
+ logger.debug(f'original clip {video_path}')
1214
+ logger.debug(f'clip duration {ffprobe_duration(video_path)} s')
1215
+ timecode = self.videoclip.get_start_timecode_string()
1216
+ # self.videoclip.synced_audio = audio_path
1217
+ audio_path = self.videoclip.synced_audio
1218
+ logger.debug(f'audio duration {sox.file_info.duration(_pathname(audio_path))}')
636
1219
  vid_only_handle = self._keep_VIDEO_only(video_path)
637
- a_n = str(audio_path)
1220
+ a_n = _pathname(audio_path)
638
1221
  v_n = str(vid_only_handle.name)
639
1222
  out_n = str(synced_clip_file)
640
1223
  logger.debug('Merging: \n\t %s + %s = %s\n'%(
@@ -646,8 +1229,8 @@ class AudioStitcherVideoMerger:
646
1229
  ffmpeg_args = (
647
1230
  ffmpeg
648
1231
  .input(v_n)
649
- # .output(out_n, shortest=None, vcodec='copy', acodec='copy',
650
1232
  .output(out_n, shortest=None, vcodec='copy',
1233
+ # .output(out_n, vcodec='copy',
651
1234
  timecode=timecode)
652
1235
  .global_args('-i', a_n, "-hide_banner")
653
1236
  .overwrite_output()
@@ -658,8 +1241,8 @@ class AudioStitcherVideoMerger:
658
1241
  _, out = (
659
1242
  ffmpeg
660
1243
  .input(v_n)
1244
+ # .output(out_n, vcodec='copy',
661
1245
  .output(out_n, shortest=None, vcodec='copy',
662
- # metadata='reel_name=foo', not all container support gen MD
663
1246
  timecode=timecode,
664
1247
  )
665
1248
  .global_args('-i', a_n, "-hide_banner")
@@ -667,8 +1250,8 @@ class AudioStitcherVideoMerger:
667
1250
  .run(capture_stderr=True)
668
1251
  )
669
1252
  logger.debug('ffmpeg output')
670
- for l in out.decode("utf-8").split('\n'):
671
- logger.debug(l)
1253
+ # for l in out.decode("utf-8").split('\n'):
1254
+ # logger.debug(l)
672
1255
  except ffmpeg.Error as e:
673
1256
  print('ffmpeg.run error merging: \n\t %s + %s = %s\n'%(
674
1257
  audio_path,
@@ -677,7 +1260,9 @@ class AudioStitcherVideoMerger:
677
1260
  ))
678
1261
  print(e)
679
1262
  print(e.stderr.decode('UTF-8'))
680
- quit()
1263
+ sys.exit(1)
1264
+ logger.debug(f'merged clip {out_n}')
1265
+ logger.debug(f'clip duration {ffprobe_duration(out_n)} s')
681
1266
 
682
1267
 
683
1268
  class Matcher:
@@ -688,70 +1273,39 @@ class Matcher:
688
1273
  AudioStitcherVideoMerger objects that do the actual file manipulations. Each video
689
1274
  (and main sound) will have its AudioStitcherVideoMerger instance.
690
1275
 
691
- All videos are de facto reference recording and matching audio files are
692
- looked up for each one of them.
693
-
694
1276
  The Matcher doesn't keep neither set any editing information in itself: the
695
1277
  in and out time values (UTC times) used are those kept inside each Recording
696
1278
  instances.
697
1279
 
698
- [NOT YET IMPLEMENTED]: When shooting is done with multiple audio recorders,
699
- ONE audio device can be designated as 'main sound' and used as reference
700
- recording; then all audio tracks are synced together against this main
701
- sound audio file, keeping the YaLTC track alongside for syncing against
702
- their video counterpart(in a second pass and after a mixdown editing).
703
- [/NOT YET IMPLEMENTED]
704
-
705
1280
  Attributes:
706
1281
 
707
1282
  recordings : list of Recording instances
708
- all the scanned recordings with valid YaLTC, set in __init__()
1283
+ all the scanned recordings with valid TicTacCode, set in __init__()
709
1284
 
710
- video_mergers : list
1285
+ mergers : list
711
1286
  of AudioStitcherVideoMerger Class instances, built by
712
- scan_audio_for_each_ref_rec(); each video has a corresponding
1287
+ scan_audio_for_each_videoclip(); each video has a corresponding
713
1288
  AudioStitcherVideoMerger object. An audio_stitch doesn't extend
714
1289
  beyond the corresponding video start and end times.
715
1290
 
1291
+ multicam_clips_clusters : list
1292
+ of {'end': t1, 'start': t2, 'vids': [r1,r3]} where r1 and r3
1293
+ are overlapping.
1294
+
716
1295
  """
717
1296
 
718
1297
  def __init__(self, recordings_list):
719
1298
  """
720
1299
  At this point in the program, all recordings in recordings_list should
721
1300
  have a valid Recording.start_time attribute and one of its channels
722
- containing a YaLTC signal (which the start_time has been demodulated
1301
+ containing a TicTacCode signal (which the start_time has been demodulated
723
1302
  from)
724
1303
 
725
1304
  """
726
1305
  self.recordings = recordings_list
727
- self.video_mergers = []
728
- # self._rename_all_recs(IO_structure)
1306
+ self.mergers = []
729
1307
 
730
-
731
- def _rename_all_recs(self):
732
- """
733
- Add _synced to filenames of synced video files. Change stored name only:
734
- files have yet to be written to.
735
- """
736
- # match IO_structure:
737
- # case 'foldercam':
738
- for rec in self.recordings:
739
- rec_extension = rec.AVpath.suffix
740
- rel_path_new_name = '%s%s'%(rec.AVpath.stem, rec_extension)
741
- rec.new_rec_name = Path(rel_path_new_name)
742
- logger.debug('for %s new name: %s'%(
743
- _pathname(rec.AVpath),
744
- _pathname(rec.new_rec_name)))
745
- # case 'loose':
746
- # for rec in self.recordings:
747
- # rec_extension = rec.AVpath.suffix
748
- # rel_path_new_name = '%s%s%s'%('ttsd_',rec.AVpath.stem, rec_extension)
749
- # rec.new_rec_name = Path(rel_path_new_name)
750
- # logger.debug('for %s new name: %s'%(
751
- # _pathname(rec.AVpath),
752
- # _pathname(rec.new_rec_name)))
753
-
754
- def scan_audio_for_each_ref_rec(self):
1308
+ def scan_audio_for_each_videoclip(self):
755
1309
  """
756
1310
  For each video (and for the Main Sound) in self.recordings, this finds
757
1311
  any audio that has overlapping times and instantiates a
@@ -762,73 +1316,72 @@ class Matcher:
762
1316
  V3 checked against ...
763
1317
  Main Sound checked against A1, A2, A3, A4
764
1318
  """
765
- refeference_recordings = [r for r in self.recordings if r.is_video()
766
- or r.is_reference]
1319
+ video_recordings = [r for r in self.recordings
1320
+ if r.is_video() or r.is_audio_reference]
1321
+ # if r.is_audio_reference then audio, and will pass as video
767
1322
  audio_recs = [r for r in self.recordings if r.is_audio()
768
- and not r.is_reference]
1323
+ and not r.is_audio_reference]
769
1324
  if not audio_recs:
770
1325
  print('\nNo audio recording found, syncing of videos only not implemented yet, exiting...\n')
771
- quit()
772
- for ref_rec in refeference_recordings:
773
- reference_tag = 'video' if ref_rec.is_video() else 'audio'
1326
+ sys.exit(1)
1327
+ for videoclip in video_recordings:
1328
+ reference_tag = 'video' if videoclip.is_video() else 'audio'
774
1329
  logger.debug('Looking for overlaps with %s %s'%(
775
- reference_tag,
776
- ref_rec))
777
- audio_stitch = AudioStitcherVideoMerger(ref_rec)
1330
+ reference_tag,
1331
+ videoclip))
1332
+ audio_stitch = AudioStitcherVideoMerger(videoclip)
778
1333
  for audio in audio_recs:
779
- if self._does_overlap(ref_rec, audio):
1334
+ logger.debug('checking %s'%audio)
1335
+ if self._does_overlap(videoclip, audio):
780
1336
  audio_stitch.add_matched_audio(audio)
781
1337
  logger.debug('recording %s overlaps,'%(audio))
782
1338
  # print(' recording [gold1]%s[/gold1] overlaps,'%(audio))
783
1339
  if len(audio_stitch.get_matched_audio_recs()) > 0:
784
- self.video_mergers.append(audio_stitch)
1340
+ self.mergers.append(audio_stitch)
785
1341
  else:
786
1342
  logger.debug('\n nothing\n')
787
- print('No overlap found for %s'%ref_rec.AVpath.name)
1343
+ print('No overlap found for [gold1]%s[/gold1]'%videoclip.AVpath.name)
788
1344
  del audio_stitch
789
- logger.debug('%i video_mergers created'%len(self.video_mergers))
1345
+ logger.debug('%i mergers created'%len(self.mergers))
790
1346
 
791
- def _does_overlap(self, ref_rec, audio_rec):
1347
+ def _does_overlap(self, videoclip, audio_rec):
792
1348
  A1, A2 = audio_rec.get_start_time(), audio_rec.get_end_time()
793
- R1, R2 = ref_rec.get_start_time(), ref_rec.get_end_time()
794
- no_overlap = (A2 < R1) or (A1 > R2)
795
- return not no_overlap
796
-
797
- def shrink_gaps_between_takes(self, with_gap=CLUSTER_GAP):
798
- """
799
- for single cam shootings this simply sets the gap between takes,
800
- tweaking each vid timecode metadata to distribute them next to each
801
- other along NLE timeline. For multicam takes, shifts are computed so
802
- video clusters are near but dont overlap, ex:
803
-
804
- Cluster 1 Cluster 2
805
- 1111111111111 2222222222 (cam A)
806
- 11111111111[...]222222222 (cam B)
807
-
808
- or
809
- 1111111111111 222222 (cam A)
810
- 1111111 22222 (cam B)
811
-
812
- Returns nothing, changes are done in the video files metadata
813
- (each referenced by Recording.final_synced_file)
814
- """
815
- vids = [m.ref_recording for m in self.video_mergers]
1349
+ logger.debug('audio str stp: %s %s'%(A1,A2))
1350
+ R1, R2 = videoclip.get_start_time(), videoclip.get_end_time()
1351
+ logger.debug('video str stp: %s %s'%(R1,R2))
1352
+ case1 = A1 < R1 < A2
1353
+ case2 = A1 < R2 < A2
1354
+ case3 = R1 < A1 < R2
1355
+ case4 = R1 < A2 < R2
1356
+ return case1 or case2 or case3 or case4
1357
+
1358
+ def set_up_clusters(self):
1359
+ # builds the list self.multicam_clips_clusters. A list
1360
+ # of {'end': t1, 'start': t2, 'vids': [r1,r3]} where r1 and r3
1361
+ # are overlapping.
1362
+ # if no overlap occurs, length of vid = 1, ex 'vids': [r1]
1363
+ # so not really a cluster in those cases
1364
+ # returns nothing and sets Matcher.multicam_clips_clusters
1365
+ vids = [m.videoclip for m in self.mergers]
816
1366
  # INs_and_OUTs contains (time, direction, video) for each video,
817
1367
  # where direction is 'in|out' and video an instance of Recording
818
1368
  INs_and_OUTs = [(vid.get_start_time(), 'in', vid) for vid in vids]
819
1369
  for vid in vids:
820
1370
  INs_and_OUTs.append((vid.get_end_time(), 'out', vid))
821
1371
  INs_and_OUTs = sorted(INs_and_OUTs, key=lambda vtuple: vtuple[0])
822
- logger.debug('INs_and_OUTs: %s'%INs_and_OUTs)
1372
+ logger.debug('INs_and_OUTs: %s'%pformat(INs_and_OUTs))
823
1373
  new_cluster = True
824
1374
  current_cluster = {'vids':[]}
825
1375
  N_in, N_out = (0, 0)
826
1376
  # clusters is a list of {'end': t1, 'start': t2, 'vids': [r1,r3]}
827
1377
  clusters = []
1378
+ # a cluster begins (and grows) when a time of type 'in' is encountered
1379
+ # a cluster degrows when a time of type 'out' is encountered and
1380
+ # closes when its size (N_currently_open) reach back to zero
828
1381
  for t, direction, video in INs_and_OUTs:
829
1382
  if new_cluster and direction == 'out':
830
1383
  logger.error('cant begin a cluster with a out time %s'%video)
831
- quit()
1384
+ sys.exit(1)
832
1385
  if new_cluster:
833
1386
  current_cluster['start'] = t
834
1387
  new_cluster = False
@@ -845,7 +1398,44 @@ class Matcher:
845
1398
  new_cluster = True
846
1399
  current_cluster = {'vids':[]}
847
1400
  N_in, N_out = (0, 0)
848
- logger.debug('clusters: %s'%pprint.pformat(clusters))
1401
+ logger.debug('clusters: %s'%pformat(clusters))
1402
+ self.multicam_clips_clusters = clusters
1403
+ return
1404
+
1405
+ def shrink_gaps_between_takes(self, CLI_offset, with_gap=CLUSTER_GAP):
1406
+ """
1407
+ for single cam shootings this simply sets the gap between takes,
1408
+ tweaking each vid timecode metadata to distribute them next to each
1409
+ other along NLE timeline.
1410
+
1411
+ Moves clusters at the timelineoffset
1412
+
1413
+ For multicam takes, shifts are computed so
1414
+ video clusters are near but dont overlap, ex:
1415
+
1416
+ ***** are inserted gaps
1417
+
1418
+ Cluster 1 Cluster 2
1419
+ 1111111111111 2222222222 (cam A)
1420
+ 11111111111******222222222 (cam B)
1421
+
1422
+ or
1423
+ 11111111111111 222222 (cam A)
1424
+ 1111111 ******222222222 (cam B)
1425
+
1426
+ argument:
1427
+ CLI_offset (str), option from command-line
1428
+ with_gap (float), the gap duration in seconds
1429
+
1430
+ Returns nothing, changes are done in the video files metadata
1431
+ (each referenced by Recording.final_synced_file)
1432
+ """
1433
+ vids = [m.videoclip for m in self.mergers]
1434
+ logger.debug('vids %s'%vids)
1435
+ if len(vids) == 1:
1436
+ logger.debug('just one take, no gap to shrink')
1437
+ return #############################################################
1438
+ clusters = self.multicam_clips_clusters
849
1439
  # if there are N clusters, there are N-1 gaps to evaluate and shorten
850
1440
  # (lengthen?) to a value of with_gap seconds
851
1441
  gaps = [c2['start'] - c1['end'] for c1, c2
@@ -864,21 +1454,115 @@ class Matcher:
864
1454
  cummulative_offsets = [td.total_seconds() for td in cummulative_offsets]
865
1455
  logger.debug('cummulative_offsets: %s'%cummulative_offsets)
866
1456
  time_of_first = clusters[0]['start']
1457
+ # compute CLI_offset_in_seconds from HH:MM:SS:FF in CLI_offset
1458
+ h, m, s, f = [float(s) for s in CLI_offset[0].split(':')]
1459
+ logger.debug('CLI_offset float values %s'%[h,m,s,f])
1460
+ CLI_offset_in_seconds = 3600*h + 60*m + s + f/vids[0].get_framerate()
1461
+ logger.debug('CLI_offset in seconds %f'%CLI_offset_in_seconds)
867
1462
  offset_for_all_clips = - from_midnight(time_of_first).total_seconds()
1463
+ offset_for_all_clips += CLI_offset_in_seconds
868
1464
  logger.debug('time_of_first: %s'%time_of_first)
869
1465
  logger.debug('offset_for_all_clips: %s'%offset_for_all_clips)
870
1466
  for cluster, offset in zip(clusters, cummulative_offsets):
1467
+ # first one starts at 00:00:00:00
871
1468
  total_offset = offset + offset_for_all_clips
872
1469
  logger.debug('for %s offset in sec: %f'%(cluster['vids'],
873
1470
  total_offset))
874
1471
  for vid in cluster['vids']:
875
- tc = vid.get_timecode(with_offset=total_offset)
1472
+ # tc = vid.get_start_timecode_string(CLI_offset, with_offset=total_offset)
1473
+ tc = vid.get_start_timecode_string(with_offset=total_offset)
876
1474
  logger.debug('for %s old tc: %s new tc %s'%(vid,
877
- vid.get_timecode(), tc))
1475
+ vid.get_start_timecode_string(), tc))
878
1476
  vid.write_file_timecode(tc)
879
1477
  return
880
1478
 
1479
+ def move_multicam_to_dir(self, raw_root=None, synced_root=None):
1480
+ # creates a dedicated multicam directory and move clusters there
1481
+ # e.g., for "top/day01/camA/roll02"
1482
+ # ^ at that level
1483
+ # 0 1 2 3
1484
+ # Note: ROLLxx maybe present or not.
1485
+ #
1486
+ # check for consistency: are all clips at the same level from topdir?
1487
+ # Only for video, not audio (which doesnt fill up cards)
1488
+ logger.debug(f'synced_root: {synced_root}')
1489
+ video_medias = [m for m in self.recordings if m.device.dev_type == 'CAM']
1490
+ video_paths = [m.AVpath.parts for m in video_medias]
1491
+ AV_path_lengths = [len(p) for p in video_paths]
1492
+ # print('AV_path_lengths', AV_path_lengths)
1493
+ if not _same(AV_path_lengths):
1494
+ print('\nError with some clips, check if their locations are consistent (all at the same level in folders).')
1495
+ print('Video synced but could not regroup multicam clips.')
1496
+ sys.exit(0)
1497
+ # now find at which level CAMs reside (maybe there are ROLLxx)
1498
+ CAM_levels = [vm.AVpath.parts.index(vm.device.name)
1499
+ for vm in video_medias]
1500
+ # find for all, should be same
1501
+ if not _same(CAM_levels):
1502
+ print('\nError with some clips, check if their locations are consistent (all at the same level in folders).')
1503
+ print('Video synced but could not regroup multicam clips.')
1504
+ sys.exit(0)
1505
+ # pick first
1506
+ CAM_level, avp = CAM_levels[0], video_medias[0].AVpath
1507
+ logger.debug('CAM_levels: %s for %s\n'%(CAM_level, avp))
1508
+ # MCCDIR = 'SyncedMulticamClips'
1509
+ parts_up_a_level = [prt for prt in avp.parts[:CAM_level] if prt != '/']
1510
+ logger.debug(f'parts_up_a_level: {parts_up_a_level}')
1511
+ if synced_root == None:
1512
+ # alongside mode
1513
+ logger.debug('alongside mode')
1514
+ multicam_dir = Path('/').joinpath(*parts_up_a_level)/MCCDIR
1515
+ else:
1516
+ # MAM mode
1517
+ logger.debug('MAM mode')
1518
+ abs_path_up = Path('/').joinpath(*parts_up_a_level)/MCCDIR
1519
+ logger.debug(f'abs_path_up: {abs_path_up}')
1520
+ rel_up = abs_path_up.relative_to(raw_root)
1521
+ logger.debug(f'rel_up: {rel_up}')
1522
+ multicam_dir = Path(synced_root)/Path(raw_root).name/rel_up
1523
+ # multicam_dir = Path(synced_root).joinpath(*parts_up_a_level)/MCCDIR
1524
+ logger.debug('multicam_dir: %s'%multicam_dir)
1525
+ Path.mkdir(multicam_dir, exist_ok=True)
1526
+ cam_clips = []
1527
+ [cam_clips.append(cl['vids']) for cl in self.multicam_clips_clusters]
1528
+ cam_clips = _flatten(cam_clips)
1529
+ logger.debug('cam_clips: %s'%pformat(cam_clips))
1530
+ cam_names = set([r.device.name for r in cam_clips])
1531
+ # create new dirs for each CAM
1532
+ [Path.mkdir(multicam_dir/cam_name, exist_ok=True)
1533
+ for cam_name in cam_names]
1534
+ # move clips there
1535
+ if synced_root == None:
1536
+ # alongside mode
1537
+ for r in cam_clips:
1538
+ cam = r.device.name
1539
+ clip_name = r.AVpath.name
1540
+ dest = r.final_synced_file.replace(multicam_dir/cam/clip_name)
1541
+ logger.debug('dest: %s'%dest)
1542
+ origin_folder = r.final_synced_file.parent
1543
+ folder_now_empty = len(list(origin_folder.glob('*'))) == 0
1544
+ if folder_now_empty:
1545
+ logger.debug('after moving %s, folder is now empty, removing it'%dest)
1546
+ origin_folder.rmdir()
1547
+ print('\nMoved %i multicam clips in %s'%(len(cam_clips), multicam_dir))
1548
+ else:
1549
+ # MAM mode
1550
+ for r in cam_clips:
1551
+ cam = r.device.name
1552
+ clip_name = r.AVpath.name
1553
+ logger.debug(f'r.final_synced_file: {r.final_synced_file}')
1554
+ dest = r.final_synced_file.replace(multicam_dir/cam/clip_name)
1555
+ # leave a symlink behind
1556
+ os.symlink(multicam_dir/cam/clip_name, r.final_synced_file)
1557
+ logger.debug('dest: %s'%dest)
1558
+ origin_folder = r.final_synced_file.parent
1559
+ # folder_now_empty = len(list(origin_folder.glob('*'))) == 0
1560
+ # if folder_now_empty:
1561
+ # logger.debug('after moving %s, folder is now empty, removing it'%dest)
1562
+ # origin_folder.rmdir()
1563
+ print('\nMoved %i multicam clips in %s'%(len(cam_clips), multicam_dir))
881
1564
 
1565
+
882
1566
 
883
1567
 
884
1568