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/device_scanner.py +362 -169
- tictacsync/entry.py +240 -135
- tictacsync/mamconf.py +157 -0
- tictacsync/mamdav.py +642 -0
- tictacsync/mamreap.py +481 -0
- tictacsync/mamsync.py +343 -0
- tictacsync/multi2polywav.py +21 -14
- tictacsync/timeline.py +1126 -442
- tictacsync/yaltc.py +895 -1067
- tictacsync-1.4.4b0.dist-info/METADATA +118 -0
- tictacsync-1.4.4b0.dist-info/RECORD +16 -0
- tictacsync-1.4.4b0.dist-info/entry_points.txt +7 -0
- tictacsync/LTCcheck.py +0 -394
- tictacsync-0.1a14.dist-info/METADATA +0 -96
- tictacsync-0.1a14.dist-info/RECORD +0 -13
- tictacsync-0.1a14.dist-info/entry_points.txt +0 -4
- {tictacsync-0.1a14.dist-info → tictacsync-1.4.4b0.dist-info}/LICENSE +0 -0
- {tictacsync-0.1a14.dist-info → tictacsync-1.4.4b0.dist-info}/WHEEL +0 -0
- {tictacsync-0.1a14.dist-info → tictacsync-1.4.4b0.dist-info}/top_level.txt +0 -0
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
|
|
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 =
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
73
|
-
files in
|
|
74
|
-
object (
|
|
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
|
|
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
|
-
|
|
89
|
-
The video
|
|
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
|
-
|
|
93
|
-
keys are elements of matched_audio_recordings
|
|
94
|
-
|
|
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,
|
|
103
|
-
self.
|
|
376
|
+
def __init__(self, video_clip):
|
|
377
|
+
self.videoclip = video_clip
|
|
104
378
|
# self.matched_audio_recordings = []
|
|
105
|
-
self.
|
|
379
|
+
self.soxed_audio = {}
|
|
106
380
|
logger.debug('instantiating AudioStitcherVideoMerger for %s'%
|
|
107
|
-
|
|
381
|
+
video_clip)
|
|
108
382
|
|
|
109
383
|
def add_matched_audio(self, audio_rec):
|
|
110
384
|
"""
|
|
111
|
-
Populates
|
|
385
|
+
Populates AudioStitcherVideoMerger.soxed_audio,
|
|
386
|
+
a dict as {Recording : path}
|
|
112
387
|
|
|
113
|
-
|
|
114
|
-
within Matcher.
|
|
388
|
+
This fct is called
|
|
389
|
+
within Matcher.scan_audio_for_each_videoclip()
|
|
115
390
|
|
|
116
|
-
Returns nothing, fills self.
|
|
391
|
+
Returns nothing, fills self.soxed_audio dict with
|
|
117
392
|
matched audio.
|
|
118
393
|
|
|
119
394
|
"""
|
|
120
|
-
self.
|
|
395
|
+
self.soxed_audio[audio_rec] = audio_rec.AVpath
|
|
121
396
|
"""
|
|
122
|
-
Here at this point, self.
|
|
123
|
-
after a call to _edit_audio_file(),
|
|
124
|
-
a new file and
|
|
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.
|
|
134
|
-
Simply keys of self.
|
|
405
|
+
Returns audio recordings that overlap self.videoclip.
|
|
406
|
+
Simply keys of self.soxed_audio dict
|
|
135
407
|
"""
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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 =
|
|
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
|
-
#
|
|
150
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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('
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
478
|
+
overlapping recordings, produced by the same audio recorder.
|
|
175
479
|
|
|
176
480
|
"""
|
|
177
481
|
logger.debug('concatenating device %s'%str(device))
|
|
178
|
-
|
|
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
|
|
186
|
-
self.
|
|
187
|
-
for r in
|
|
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
|
-
|
|
190
|
-
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
logger.debug('
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
237
|
-
for i, rec in enumerate(
|
|
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.
|
|
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.
|
|
269
|
-
first recording, padded or chopped according to AudioStitcherVideoMerger.
|
|
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
|
-
|
|
277
|
-
if
|
|
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-
|
|
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 = (
|
|
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.
|
|
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.
|
|
646
|
+
recording_fh = self.soxed_audio[audio_rec]
|
|
337
647
|
logger.debug('for recording %s, matching %s'%(audio_rec,
|
|
338
|
-
self.
|
|
648
|
+
self.videoclip))
|
|
339
649
|
input_file = _pathname(recording_fh)
|
|
340
|
-
logger.debug('AudioStitcherVideoMerger.
|
|
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.
|
|
658
|
+
self.soxed_audio[audio_rec] = output_fh
|
|
659
|
+
return output_fh
|
|
348
660
|
|
|
349
|
-
def
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
355
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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
|
-
|
|
675
|
+
SyncedMedia/ (or anchor_dir)
|
|
364
676
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
494
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
911
|
+
This is called when only audio recorders were found (no cam).
|
|
501
912
|
|
|
502
|
-
|
|
913
|
+
top_dir: Path, directory where media were looked for
|
|
503
914
|
|
|
504
|
-
|
|
505
|
-
|
|
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
|
|
1028
|
+
Sets AudioStitcherVideoMerger.final_synced_file on completion
|
|
513
1029
|
"""
|
|
514
|
-
logger.debug('
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
|
631
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
self.
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1283
|
+
all the scanned recordings with valid TicTacCode, set in __init__()
|
|
709
1284
|
|
|
710
|
-
|
|
1285
|
+
mergers : list
|
|
711
1286
|
of AudioStitcherVideoMerger Class instances, built by
|
|
712
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
766
|
-
|
|
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.
|
|
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
|
-
|
|
772
|
-
for
|
|
773
|
-
reference_tag = 'video' if
|
|
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
|
-
|
|
776
|
-
|
|
777
|
-
audio_stitch = AudioStitcherVideoMerger(
|
|
1330
|
+
reference_tag,
|
|
1331
|
+
videoclip))
|
|
1332
|
+
audio_stitch = AudioStitcherVideoMerger(videoclip)
|
|
778
1333
|
for audio in audio_recs:
|
|
779
|
-
|
|
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.
|
|
1340
|
+
self.mergers.append(audio_stitch)
|
|
785
1341
|
else:
|
|
786
1342
|
logger.debug('\n nothing\n')
|
|
787
|
-
print('No overlap found for %s'%
|
|
1343
|
+
print('No overlap found for [gold1]%s[/gold1]'%videoclip.AVpath.name)
|
|
788
1344
|
del audio_stitch
|
|
789
|
-
logger.debug('%i
|
|
1345
|
+
logger.debug('%i mergers created'%len(self.mergers))
|
|
790
1346
|
|
|
791
|
-
def _does_overlap(self,
|
|
1347
|
+
def _does_overlap(self, videoclip, audio_rec):
|
|
792
1348
|
A1, A2 = audio_rec.get_start_time(), audio_rec.get_end_time()
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
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'%
|
|
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.
|
|
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.
|
|
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
|
|