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/yaltc.py CHANGED
@@ -5,7 +5,7 @@ import lmfit, tempfile
5
5
  # from skimage.morphology import closing, square
6
6
  from numpy import arcsin, sin, pi
7
7
  from matplotlib.lines import Line2D
8
- import math, re, os
8
+ import math, re, os, sys, itertools
9
9
  import sox
10
10
  from subprocess import Popen, PIPE
11
11
  from pathlib import Path
@@ -15,109 +15,82 @@ logging.config.dictConfig({
15
15
  'disable_existing_loggers': True,
16
16
  }) # for sox "output file already exists and will be overwritten on build"
17
17
  from datetime import datetime, timezone, timedelta
18
+ from pprint import pformat
18
19
  from collections import deque
19
20
  from loguru import logger
20
- from sklearn.mixture import GaussianMixture
21
+ from skimage.morphology import closing, erosion, remove_small_objects
22
+ from skimage.measure import regionprops, label
21
23
  import ffmpeg, shutil
22
- # import opentimelineio as otio
23
24
  from rich import print
25
+ from rich.console import Console
26
+ # from rich.text import Text
27
+ from rich.table import Table
28
+ try:
29
+ from . import device_scanner
30
+ except:
31
+ import device_scanner
24
32
 
25
- # try:
26
- # from cfuzzyset import cFuzzySet as FuzzySet
27
- # except ImportError:
28
- # from fuzzyset import FuzzySet
29
- # pip install fuzzyset2
33
+ TEENSY_MAX_LAG = 1.01*128/44100 # sec, duration of a default length audio block
34
+
35
+ # see extract_seems_TicTacCode() for duration criterion values
36
+
37
+ TRACKSFILE = 'tracks.txt'
38
+ SILENT_TRACK_TOKENS = '-0n'
30
39
 
31
40
 
32
41
  CACHING = True
33
- DEL_TEMP = True
34
- DB_RMS_SILENCE_SOX = -50
35
- MAXDRIFT = 10e-3 # in sec, normally 10e-3 (10 ms)
36
- # MAXDRIFT = 0 # in sec, normally 10e-3 (10 ms)
42
+ DEL_TEMP = False
43
+ MAXDRIFT = 15e-3 # in sec, for end of clip
37
44
 
38
- SAFE_SILENCE_WINDOW_WIDTH = 400 # ms, not the full 500 ms, to accommodate decay
39
- # used in _get_silent_zone_indices()
40
- WORDWIDTHFACTOR = 2
41
- # see _get_word_width_parameters()
42
45
 
43
- OVER_NOISE_SYNC_DETECT_LEVEL = 2
46
+ ################## pasted from FSKfreqCalculator.py output:
47
+ F1 = 630.00 # Hertz
48
+ F2 = 1190.00 # Hz , both from FSKfreqCalculator output
49
+ SYMBOL_LENGTH = 14.286 # ms, from FSKfreqCalculator.py
50
+ N_SYMBOLS = 35 # including sync pulse
51
+ ##################
44
52
 
45
- MINIMUM_LENGTH = 4 # sec
53
+ MINIMUM_LENGTH = 8 # sec
46
54
  TRIAL_TIMES = [ # in seconds
47
- (0.5, -2),
48
- (0.5, -3.5),
49
- (0.5, -5),
55
+ (3.5, -2),
56
+ (3.5, -3.5),
57
+ (3.5, -5),
50
58
  (2, -2),
51
59
  (2, -3.5),
52
60
  (2, -5),
53
- (3.5, -2),
54
- (3.5, -3.5),
61
+ (0.5, -2),
62
+ (0.5, -3.5),
55
63
  ]
56
- SOUND_EXTRACT_LENGTH = 1.56 # second
64
+ SOUND_EXTRACT_LENGTH = (10*SYMBOL_LENGTH*1e-3 + 1) # second
57
65
  SYMBOL_LENGTH_TOLERANCE = 0.07 # relative
58
66
  FSK_TOLERANCE = 60 # Hz
59
67
  SAMD21_LATENCY = 63 # microseconds, for DAC conversion
60
68
  YEAR_ZERO = 2021
61
69
 
62
- ################## pasted from FSKfreqCalculator.py output:
63
- F1 = 630.00 # Hertz
64
- F2 = 1190.00 # Hz , both from FSKfreqCalculator output
65
- SYMBOL_LENGTH = 14.286 # ms, from FSKfreqCalculator.py
66
- N_SYMBOLS_SAMD21 = 35 # including sync pulse
67
- ##################
68
70
 
69
71
  BPF_LOW_FRQ, BPF_HIGH_FRQ = (0.5*F1, 2*F2)
70
72
 
71
- # try:
72
- # layouts, _ = (
73
- # ffmpeg.input('').output('')
74
- # .global_args("-loglevel", "quiet","-nostats","-hide_banner","-layouts")
75
- # .run(capture_stdout=True)
76
- # )
77
- # except ffmpeg.Error as e:
78
- # print('error',e.stderr)
79
-
80
-
81
-
82
- # names_str, layouts = [l.split('\n') for l in
83
- # layouts.decode("utf-8").split('DECOMPOSITION')]
84
- # [names_str.pop(0) for i in range(2)]
85
- # [names_str.pop(-1) for i in range(3)]
86
- # layouts.pop(0)
87
- # layouts.pop(-1)
88
- # channel_name_regex = re.compile(r'(\w+)\s+(.+)')
89
- # layouts_regex = re.compile(r'(\S+)\s+(.+)')
90
- # FFMPEG_CHAN_NAMES = [
91
- # channel_name_regex.match(name).group(1)
92
- # for name in names_str
93
- # ]
94
- # FFMPEG_LAYO_NAMES = dict([
95
- # layouts_regex.match(lay_out).groups()
96
- # for lay_out in layouts
97
- # ])
98
-
99
- # """
100
- # FFMPEG_CHAN_NAMES are the names used by ffmpeg for channels, eg: FL, FR, FC for
101
- # front left, front right and front center.
102
- # FFMPEG_LAYO_NAMES are the order of channels for each layout, eg: stereo is FL+FR
103
- # and 5.1 is FL+FR+FC+LFE+BL+BR. Layout names are the dict keys.
104
- # Run ffmpeg -layouts for details.
105
-
106
- # """
107
73
 
108
74
  # utility for accessing pathnames
109
75
  def _pathname(tempfile_or_path):
76
+ # always returns a str
110
77
  if isinstance(tempfile_or_path, type('')):
111
- return tempfile_or_path
78
+ return tempfile_or_path ################################################
112
79
  if isinstance(tempfile_or_path, Path):
113
- return str(tempfile_or_path)
80
+ return str(tempfile_or_path) ###########################################
114
81
  if isinstance(tempfile_or_path, tempfile._TemporaryFileWrapper):
115
- return tempfile_or_path.name
82
+ return tempfile_or_path.name ###########################################
116
83
  else:
117
84
  raise Exception('%s should be Path or tempfile... is %s'%(
118
85
  tempfile_or_path,
119
86
  type(tempfile_or_path)))
120
87
 
88
+ # for skimage.measure.regionprops
89
+ def _width(region):
90
+ _,x1,_,x2 = region.bbox
91
+ return x2-x1
92
+
93
+
121
94
  def to_precision(x,p):
122
95
  """
123
96
  returns a string representation of x formatted with a precision of p
@@ -127,7 +100,7 @@ def to_precision(x,p):
127
100
  """
128
101
  x = float(x)
129
102
  if x == 0.:
130
- return "0." + "0"*(p-1)
103
+ return "0." + "0"*(p-1) ################################################
131
104
  out = []
132
105
  if x < 0:
133
106
  out.append("-")
@@ -168,19 +141,46 @@ def to_precision(x,p):
168
141
 
169
142
  return "".join(out)
170
143
 
144
+ def read_audio_data_from_file(file, n_channels):
145
+ """
146
+ reads file and returns a numpy.array of shape (N1 channels, N2 samples)
147
+ where N1 >= 2 (minimaly solo track + TC)
148
+ """
149
+ dryrun = (ffmpeg
150
+ .input(_pathname(file))
151
+ .output('pipe:', format='s16le', acodec='pcm_s16le')
152
+ .get_args())
153
+ dryrun = ' '.join(dryrun)
154
+ logger.debug('using ffmpeg-python built args to pipe audio stream into numpy array:\nffmpeg %s'%dryrun)
155
+ try:
156
+ out, _ = (ffmpeg
157
+ # .input(str(path), ss=time_where, t=chunk_length)
158
+ # .input(str(self.AVpath))
159
+ .input(_pathname(file))
160
+ .output('pipe:', format='s16le', acodec='pcm_s16le')
161
+ .global_args("-loglevel", "quiet")
162
+ .global_args("-nostats")
163
+ .global_args("-hide_banner")
164
+ .run(capture_stdout=True))
165
+ data = np.frombuffer(out, np.int16)
166
+ except ffmpeg.Error as e:
167
+ print('error',e.stderr)
168
+ # transform 1D interleaved channels to [chan1, chan2, chanN]
169
+ return data.reshape(int(len(data)/n_channels),n_channels).T
170
+
171
+
171
172
  class Decoder:
172
173
  """
173
- Object encapsulating DSP processes to demodulate YaLTC track from audio file;
174
- Decoders are instantiated by their respective Recording object. Produces
175
- plots on demand for diagnostic purposes.
174
+ Object encapsulating DSP processes to demodulate TicTacCode track from audio
175
+ file; Decoders are instantiated by their respective Recording object.
176
+ Produces plots on demand for diagnostic purposes.
176
177
 
177
178
  Attributes:
178
179
 
179
- sound_extract : numpy.ndarray of int16, shaped (N)
180
- duration of about SOUND_EXTRACT_LENGTH sec. sound data extract,
181
- could be anywhere in the audio file (start, end, etc...) Set by
182
- Recording object. This audio signal might or might not be the YaLTC
183
- track.
180
+ sound_extract : 1d numpy.ndarray of int16
181
+ length determined by SOUND_EXTRACT_LENGTH (sec). Could be anywhere
182
+ in the audio file (start, end, etc...) Set by Recording object.
183
+ This audio signal might or might not be the TicTacCode track.
184
184
 
185
185
  sound_extract_position : int
186
186
  where the sound_extract is located in the file, samples
@@ -191,8 +191,8 @@ class Decoder:
191
191
  rec : Recording
192
192
  recording on which the decoder is working
193
193
 
194
- SN_ratio : float
195
- signal over noise ratio in dB.
194
+ effective_word_duration : float
195
+ duration of a word, influenced by ucontroller clock
196
196
 
197
197
  pulse_detection_level : float
198
198
  level used to detect sync pulse
@@ -207,13 +207,9 @@ class Decoder:
207
207
  detected_pulse_position : int
208
208
  pulse position (samples) relative to the start of self.sound_extract
209
209
 
210
- cached_convolution_fit : dict
211
- if _fit_triangular_signal_to_convoluted_env() has already been called,
212
- will use cached values if sound_extract_position is the same.
213
-
214
210
  """
215
211
 
216
- def __init__(self, aRec):
212
+ def __init__(self, aRec, do_plots):
217
213
  """
218
214
  Initialises Decoder
219
215
 
@@ -223,584 +219,333 @@ class Decoder:
223
219
 
224
220
  """
225
221
  self.rec = aRec
222
+ self.do_plots = do_plots
226
223
  self.clear_decoder()
227
224
 
228
225
  def clear_decoder(self):
229
226
  self.sound_data_extract = None
230
- self.cached_convolution_fit = {'sound_extract_position': None}
231
227
  self.pulse_detection_level = None
232
- self.silent_zone_indices = None
233
228
  self.detected_pulse_position = None
234
- self.estimated_pulse_position = None
235
-
236
- def set_sound_extract_and_sr(self, extract, s_r, where):
237
- self.sound_extract = extract
238
- self.samplerate = s_r
239
- self.sound_extract_position = where
240
- self.cached_convolution_fit = {'sound_extract_position': None}
241
- logger.debug('sound_extract set, samplerate %i location %i'%(s_r, where))
242
-
243
-
244
- # there's always at least one complete 0.5 silence interval in a 1.5 second signal
245
-
246
- def _get_envelope(self):
229
+
230
+ def set_sound_extract_and_sr(self, sound_extract, samplerate, sound_extract_position):
247
231
  """
248
- Compute self.sound_extract envelope, filtering its hilbert transform.
249
- Uses scipy.signal.hilbert() and scipy.signal.savgol_filter();
250
- window_length and polyorder savgol_filter() parameters values
251
- have been found by hit and miss on audio data.
252
-
253
- maybe:
254
- Values are roughly normalized: between 0 and approximately 1.0
255
-
256
- Returns
257
- -------
258
- numpy.ndarray the same length of self.recording.sound_extract
232
+ Sets:
233
+ self.sound_extract -- mono data of short duration
234
+ self.samplerate -- in Hz
235
+ self.sound_extract_position -- position in the whole file
259
236
 
260
- """
261
- WINDOW_LENGTH, POLYORDER = (15, 3) # parameters found by experiment, hit and miss
262
- absolute_of_hilbert = np.abs(scipy.signal.hilbert(self.sound_extract))
263
- envelope = scipy.signal.savgol_filter(absolute_of_hilbert,
264
- WINDOW_LENGTH, POLYORDER)
265
- logger.debug('self.sound_extract envelope length %i samples'%len(envelope))
266
- return envelope
267
-
268
- def _get_signal_level(self):
269
- abs_signal = abs(self.sound_extract)
270
- return 2 * abs_signal.mean() # since 50% duty cycle
271
-
272
- def _get_approx_pulse_position(self):
273
- """
274
- Returns the estimated pulse position using the detected silent
275
- zone . The position is in samples number
276
- relative to extract beginning
277
- """
278
- # if self.detected_pulse_position:
279
- # logger.debug('returning detected value')
280
- # return self.detected_pulse_position
281
- if self.estimated_pulse_position:
282
- logger.debug('returning cached estimated value')
283
- return self.estimated_pulse_position
284
- _, silence_center_x = self._fit_triangular_signal_to_convoluted_env()
285
- # symbol_width_samples = 1e-3*SYMBOL_LENGTH
286
- self.estimated_pulse_position = silence_center_x + int(0.5*(
287
- 0.5 - 1e-3*SYMBOL_LENGTH)*self.samplerate)
288
- logger.debug('returning estimated value from silence mid position')
289
- return self.estimated_pulse_position
290
-
291
- def _get_pulse_position(self):
292
- # relative to extract beginning
293
- if self.detected_pulse_position:
294
- logger.debug('returning detected value')
295
- return self.detected_pulse_position
296
- return None
297
-
298
- def _get_pulse_detection_level(self):
299
- # return level at which the sync pulse will be detected
300
- if self.pulse_detection_level is None:
301
- silence_floor = self._get_silence_floor()
302
- # lower_BFSK_level = silence_floor
303
- # pulse_position = self._get_pulse_position()
304
- lower_BFSK_level = self._get_minimal_bfsk()
305
- value = math.sqrt(silence_floor * lower_BFSK_level)
306
- # value = OVER_NOISE_SYNC_DETECT_LEVEL * silence_floor
307
- logger.debug('setting pulse_detection_level to %f'%value)
308
- self.pulse_detection_level = value
309
- return value
310
- else:
311
- return self.pulse_detection_level
237
+ Computes and sets:
238
+ self.pulse_detection_level
239
+ self.sound_extract_one_bit
240
+ self.words_props (contains the sync pulse too)
312
241
 
313
- def _get_square_convolution(self):
242
+ Returns nothing
314
243
  """
315
- Compute self.sound_extract envelope convolution with a square signal of
316
- 0.5 second (+ SYMBOL_LENGTH) width, using numpy.convolve().
317
- Values are roughly normalized: between 0 and approximately 1.0
244
+ logger.debug('sound_extract: %s, samplerate: %s Hz, sound_extract_position %s'%(
245
+ sound_extract, samplerate, sound_extract_position))
246
+ if len(sound_extract) == 0:
247
+ logger.error('sound extract is empty, is sound track duration OK?')
248
+ raise Exception('sound extract is empty, is sound track duration OK?')
249
+ self.sound_extract_position = sound_extract_position
250
+ self.samplerate = samplerate
251
+ self.sound_extract = sound_extract
252
+ self.pulse_detection_level = np.std(sound_extract)/4
253
+ logger.debug('pulse_detection_level %f'%self.pulse_detection_level)
254
+ bits = np.abs(sound_extract)>self.pulse_detection_level
255
+ N_ones = round(1.5*SYMBOL_LENGTH*1e-3*samplerate) # so it includes sync pulse
256
+ self.sound_extract_one_bit = closing(bits, np.ones(N_ones))
257
+ if self.do_plots:
258
+ self._plot_extract()
259
+ logger.debug('sound_extract_one_bit len %i'%len(self.sound_extract_one_bit))
260
+ self.words_props = regionprops(label(np.array(2*[self.sound_extract_one_bit]))) # new
261
+
262
+ def extract_seems_TicTacCode(self):
263
+ """
264
+ Determines if signal in sound_extract seems to be TTC.
318
265
 
266
+ Uses the conditions below:
319
267
 
320
- Returns:
321
-
322
- #1 the normalized convolution, a numpy.ndarray shorter than
323
- self.sound_extract, see numpy.convolve(..., mode='valid')
324
-
325
- #2 a list of int, samples indexes where the convolution is computed
268
+ Extract duration is 1.143 s. (ie one sec + 1 symbol duration)
269
+ In self.word_props (list of morphology.regionprops):
270
+ if one region, duration should be in [0.499 0.512] sec
271
+ if two regions, total duration should be in [0.50 0.655]
326
272
 
273
+ Returns True if self.sound_data_extract seems TicTacCode
327
274
  """
328
- sqr_window_width = int((0.5 + SYMBOL_LENGTH/1e3)*self.samplerate) # in samples
329
- sqr_signal = np.ones(sqr_window_width,dtype=int)
330
- envelope = self._get_envelope()
331
- mean = envelope.mean()
332
- if mean: # in case of zero padding (? dont remember why)
333
- factor = 0.5/mean # since 50% duty cycle
275
+ failing_comment = '' # used as a flag
276
+ props = self.words_props
277
+ if len(props) not in [1,2]:
278
+ failing_comment = 'len(props) not in [1,2]: %i'%len(props)
334
279
  else:
335
- factor = 1
336
- normalized_envelope = factor*envelope
337
- convol = np.convolve(normalized_envelope, sqr_signal,
338
- mode='valid')/sqr_window_width
339
- start = int(sqr_window_width/2)
340
-
341
- x = range(start, len(convol) + start)
342
- return [*x], convol
343
-
344
- def _get_word_envelope(self):
345
- """
346
- Chop the signal envelope keeping the word region and smooth it over the
347
- longest BFSK period
348
- """
349
- SR = self.samplerate
350
- envelope = self._get_envelope()
351
- pulse_position = self._get_approx_pulse_position()
352
- samples_to_end = len(self.sound_extract) - pulse_position
353
- is_too_near_the_end = samples_to_end/SR < 0.5
354
- logger.debug('pulse_position is_too_near_the_end %s'%
355
- is_too_near_the_end)
356
- if is_too_near_the_end:
357
- pulse_position -= SR # one second sooner
358
- symbol_width_samples = 1e-3*SYMBOL_LENGTH*SR
359
- word_start = int(pulse_position + 3*symbol_width_samples)
360
- word_end = int(pulse_position + 0.5*SR)
361
- word_end -= int(2*symbol_width_samples) # slide to the left a little
362
- logger.debug('word start, end: %i %i (in extract)'%(
363
- word_start, word_end))
364
- logger.debug('word start, end: %i %i (in file)'%(
365
- word_start + self.sound_extract_position,
366
- word_end + self.sound_extract_position))
367
- w_envelope = envelope[word_start : word_end]
368
- word_envelope_truncated = word_end-word_start != len(w_envelope)
369
- logger.debug('w_envelope is sliced out of bounds: %s'%(
370
- str(word_envelope_truncated)))
371
- logger.debug('word envelope length %i samples %f secs'%(
372
- len(w_envelope), len(w_envelope)/SR))
373
- max_period = int(self.samplerate*max(1/F1,1/F2))
374
- logger.debug('max BFSK period %i in samples'%max_period)
375
- period_window = np.ones(max_period,dtype=int)/max_period
376
- # smooth over longest BFSK period
377
- return np.convolve(w_envelope, period_window, mode='same')
378
-
379
- def _get_minimal_bfsk(self):
380
- """
381
- because of non-flat frequency response, bfsk bits dont have the same
382
- amplitude. This returns the least of both by detecting a bimodal
383
- gaussian distribution
384
-
385
- """
386
- # w_envelope = self._get_word_envelope()
387
- # word_start = int(min_position + shift + 0.3*self.samplerate)
388
- # word = w_envelope[word_start : int(word_start + 0.4*self.samplerate)]
389
- word = self._get_word_envelope()
390
- # plt.plot(word)
391
- # plt.show()
392
- n = len(word)
393
- word = word.reshape(n, 1)
394
- gm = GaussianMixture(n_components=2, random_state=0).fit(word)
395
- bfsk_minimal_amplitude = min(gm.means_)
396
- logger.debug('bfsk_minimal_amplitude %f'%bfsk_minimal_amplitude)
397
- return bfsk_minimal_amplitude
398
-
399
- def _fit_triangular_signal_to_convoluted_env(self):
400
- """
401
- Try to fit a triangular signal to the envelope convoluted with a square
402
- signal to evaluate if audio is composed of 0.5 sec signal and 0.5 s
403
- silence. If so, the convolution is a triangular fct and a
404
- Levenberg–Marquardt fit is used to detect this occurence (lmfit).
405
- Results are cached in self.cached_convolution_fit alongside
406
- self.sound_extract_position for hit/miss checks.
407
-
408
-
409
- Returns
410
- -------
411
- float
412
- chi_sqr from lmfit.minimize(), indicative of fit quality
413
- int
414
- position of triangular minimum (base of the v shape), this
415
- corresponds to the center of silent zone.
416
-
417
- """
418
- cached_value = self.cached_convolution_fit['sound_extract_position']
419
- logger.debug('cache hit? asked:%s cached: %s'%(
420
- self.sound_extract_position,
421
- cached_value))
422
- logger.debug('(sound_extract_position values)')
423
- if (CACHING and
424
- # cache_is_clean and
425
- cached_value and
426
- math.isclose(
427
- self.sound_extract_position,
428
- cached_value,
429
- abs_tol=0.1)):
430
- logger.debug('yes, fit values cached:')
431
- v1 = self.cached_convolution_fit['chi_square']
432
- v2 = self.cached_convolution_fit['minimum position']
433
- v2_file = v2 + self.sound_extract_position
434
- logger.debug('cached chi_sq: %s minimum position in file: %s'%(v1, v2_file))
435
- return (v1, v2)
436
- # cached!
437
- x_shifted, convolution = self._get_square_convolution()
438
- # see numpy.convolve(..., mode='valid')
439
- x = np.arange(len(convolution))
440
- trig_params = lmfit.Parameters()
441
- trig_params.add(
442
- 'A', value=1, min=0, max=2
443
- )
444
- period0 = 2*self.samplerate
445
- trig_params.add(
446
- 'period', value=period0, min=0.9*period0,
447
- max=1.1*period0
448
- )
449
- trig_params.add(
450
- 'min_position', value=len(convolution)/2
451
- ) # at center
452
- def trig_wave(pars, x, signal_data=None):
453
- # looking for phase sx with a sin of 1 sec period and 0<y<1.0
454
- A = pars['A']
455
- p = pars['period']
456
- mp = pars['min_position']
457
- model = 2*A*arcsin(abs(sin((x - mp)*2*pi/p)))/pi
458
- if signal_data is None:
459
- return model
460
- return model - signal_data
461
- fit_trig = lmfit.minimize(
462
- trig_wave, trig_params,
463
- args=(x,), kws={'signal_data': convolution}
464
- )
465
- chi_square = fit_trig.chisqr
466
- shift = x_shifted[0] # convolution is shorter than sound envelope
467
- min_position = int(fit_trig.params['min_position'].value) + shift
468
- logger.debug('chi_square %.1f minimum convolution position %i in file'%
469
- (chi_square, min_position + self.sound_extract_position))
470
- self.cached_convolution_fit['sound_extract_position'] = self.sound_extract_position
471
- self.cached_convolution_fit['chi_square'] = chi_square
472
- self.cached_convolution_fit['minimum position'] = min_position
473
-
474
- return chi_square, min_position + shift
475
-
476
- def extract_seems_YaLTC(self):
477
- """margin
478
- evaluate if sound data is half signal, half silence
479
- no test is done on frequency components nor BFSK modulation.
480
-
481
- Returns
482
- -------
483
- True if sound seems YaLTC
484
-
485
- """
486
- chi_square, _ = self._fit_triangular_signal_to_convoluted_env()
487
- seems_YaLTC = chi_square < 200 # good fit so, yes
488
- logger.debug('seems YaLTC: %s'%seems_YaLTC)
489
- return seems_YaLTC
490
-
491
- def _get_silent_zone_indices(self):
492
- """
493
- Returns silent zone boundary positions relative to the start
494
- of self.sound_extract.
495
-
496
- Returns
497
- -------
498
- left_window_boundary : int
499
- left indice.
500
- right_window_boundary : int
501
- right indice.
280
+ logger.debug('len(props), %i, is in [1,2]'%len(props))
281
+ if len(props) == 1:
282
+ logger.debug('one region')
283
+ w = _width(props[0])/self.samplerate
284
+ # self.effective_word_duration = w
285
+ # logger.debug('effective_word_duration %f (one region)'%w)
286
+ if not 0.499 < w < 0.512: # TODO: move as TOP OF FILE PARAMS
287
+ failing_comment = '_width %f not in [0.499 0.512]'%w
288
+ else:
289
+ logger.debug('0.499 < width < 0.512, %f'%w)
290
+ else: # 2 regions
291
+ logger.debug('two regions')
292
+ widths = [_width(p)/self.samplerate for p in props] # in sec
293
+ total_w = sum(widths)
294
+ # extra_window_duration = SOUND_EXTRACT_LENGTH - 1
295
+ # eff_w = total_w - extra_window_duration
296
+ # logger.debug('effective_word_duration %f (two regions)'%eff_w)
297
+ if not 0.5 < total_w < 0.656:
298
+ failing_comment = 'two regions duration %f not in [0.50 0.655]\n%s'%(total_w, widths)
299
+ # fig, ax = plt.subplots()
300
+ # p(ax, sound_extract_one_bit)
301
+ else:
302
+ logger.debug('0.5 < total_w < 0.656, %f'%total_w)
303
+ logger.debug('failing_comment: %s'%(
304
+ 'none' if failing_comment=='' else failing_comment))
305
+ return failing_comment == '' # no comment = extract seems TicTacCode
502
306
 
503
- """
504
- if self.silent_zone_indices:
505
- return self.silent_zone_indices
506
- _, silence_center_position = self._fit_triangular_signal_to_convoluted_env()
507
- srate = self.samplerate
508
- half_window = int(SAFE_SILENCE_WINDOW_WIDTH * 1e-3 * srate/2)
509
- left_window_boundary = silence_center_position - half_window
510
- right_window_boundary = silence_center_position + half_window
511
- # margin = 0.75 * srate
512
- values = np.array([left_window_boundary, right_window_boundary,
513
- silence_center_position])
514
- values += self.sound_extract_position # samples pos in file
515
- logger.debug('silent zone, left: %i, right %i, center %i'%tuple(values))
516
- self.silent_zone_indices = (left_window_boundary, right_window_boundary)
517
- return self.silent_zone_indices
518
-
519
- def _get_silence_floor(self):
520
- # analyses the 0.5 silence zone
521
- start_silent_zone, end_silent_zone = self._get_silent_zone_indices()
522
- signal = self.sound_extract
523
- silent_signal = signal[start_silent_zone:end_silent_zone]
524
- max_value = 1.001*np.abs(silent_signal).max() # a little headroom
525
- max_value = 0 # should toggle this with a CLI option
526
- five_sigmas = 5 * silent_signal.std()
527
- return max(max_value, five_sigmas) # if guassian, five sigmas will do it
528
-
529
- def make_silence_analysis_plot(self, title=None, filename=None):
530
- # save figure in filename if set, otherwise
531
- # start an interactive plot, title is for matplotlib
532
- pulse_pos_in_file = self._get_approx_pulse_position()
533
- pulse_pos_in_file += self.sound_extract_position
534
- pulse_position_sec = pulse_pos_in_file/self.samplerate
535
- duration_in_sec = self.rec.get_duration()
536
- if pulse_position_sec > duration_in_sec/2:
537
- pulse_position_sec -= duration_in_sec
538
- title = 'Silence analysis around %.2f s'%(pulse_position_sec)
539
- logger.debug('make_silence_analysis_plot(title=%s, filename=%s)'%(
540
- title, filename))
541
- start_silent_zone, end_silent_zone = self._get_silent_zone_indices()
542
- signal = self.sound_extract
543
- x_signal = range(len(signal))
544
- x_convolution, convolution = self._get_square_convolution()
545
- scaled_convo = self._get_signal_level()*convolution
546
- # since 0 < convolution < 1
547
- trig_level = self._get_pulse_detection_level()
548
- sound_extract_position = self.sound_extract_position
549
- def x2f(nx):
550
- return nx + sound_extract_position
551
- def f2x(nf):
552
- return nf - sound_extract_position
307
+ def _plot_extract(self):
553
308
  fig, ax = plt.subplots()
554
- plt.title(title)
555
- secax = ax.secondary_xaxis('top', functions=(x2f, f2x))
556
- # secax.set_xlabel('position in file')
557
- # ax.set_xlabel('position in extract')
309
+ start = self.sound_extract_position
310
+ i_samples = np.arange(start, start + len(self.sound_extract))
558
311
  yt = ax.get_yaxis_transform()
312
+ ax.hlines(0, 0, 1,
313
+ transform=yt, alpha=0.3,
314
+ linewidth=2, colors='black')
315
+ ax.plot(i_samples, self.sound_extract, marker='o', markersize='1',
316
+ linewidth=1.5,alpha=0.3, color='blue' )
317
+ ax.plot(i_samples, self.sound_extract_one_bit*np.max(np.abs(self.sound_extract)),
318
+ marker='o', markersize='1',
319
+ linewidth=1.5,alpha=0.3,color='red')
559
320
  xt = ax.get_xaxis_transform()
560
- _, silence_center_x = self._fit_triangular_signal_to_convoluted_env()
561
- # symbol_width_samples = 1e-3*SYMBOL_LENGTH
562
- # pulse_relative_pos = int(0.5*(0.5 - 1e-3*SYMBOL_LENGTH)*self.samplerate)
563
- approx_pulse_x = self._get_approx_pulse_position()
564
- ax.vlines(
565
- silence_center_x, 0.4, 0.6,
566
- transform=xt, linewidth=1, colors='black'
567
- )
568
- ax.vlines(
569
- approx_pulse_x, 0.1, 0.9,
570
- transform=xt, linewidth=1, colors='yellow'
571
- )
572
- bfsk_min = self._get_minimal_bfsk()
573
- ax.hlines(
574
- bfsk_min, 0, 1,
575
- transform=yt, linewidth=1, colors='red'
576
- )
577
- ax.hlines(
578
- trig_level, 0, 1,
579
- transform=yt, linewidth=1, colors='blue'
580
- )
581
- ax.hlines(
582
- -trig_level, 0, 1,
583
- transform=yt, linewidth=1, colors='blue'
584
- )
585
- ax.hlines(
586
- 0, 0, 1,
587
- transform=yt, linewidth=0.5, colors='black'
588
- )
589
- # plt.title(title)
321
+ ax.hlines(self.pulse_detection_level, 0, 1,
322
+ transform=yt, alpha=0.3,
323
+ linewidth=2, colors='green')
590
324
  custom_lines = [
591
- Line2D([0], [0], color='black', lw=2),
592
325
  Line2D([0], [0], color='green', lw=2),
593
326
  Line2D([0], [0], color='blue', lw=2),
594
327
  Line2D([0], [0], color='red', lw=2),
595
- Line2D([0], [0], color='yellow', lw=2),
596
328
  ]
597
329
  ax.legend(
598
330
  custom_lines,
599
- 'sqr convolution,silence zone,pulse level,lower FSK ampl., approx puls.'.split(','),
331
+ 'detection level, signal, detected region'.split(','),
600
332
  loc='lower right')
601
- ax.plot(
602
- x_signal, signal,
603
- marker='o', markersize='1',
604
- linewidth=0.3, color='purple', alpha=0.3)
605
- ax.axvspan(
606
- start_silent_zone, end_silent_zone,
607
- alpha=0.1, color='green')
608
- ax.plot(
609
- x_convolution, scaled_convo,
610
- marker='.', markersize='0.2',
611
- linestyle='None', color='black', alpha=1)
612
- # linewidth=0.3, linestyle='None', color='black', alpha=0.3)
613
- # ax.set_xlabel('Decoder.sound_extract samples')
614
- if filename == None:
615
- plt.show()
616
- else:
617
- logger.debug('saving silence_analysis_plot to %s'%filename)
618
- plt.savefig(
619
- filename,
620
- format="png")
621
- plt.close()
333
+ ax.set_title('Finding word + sync pulse')
334
+ plt.xlabel("Position in file %s (samples)"%self.rec)
335
+ plt.show()
622
336
 
623
- def _detect_sync_pulse_position(self):
624
- """
625
- Determines noise level during silence period and use it to detect the
626
- sync pulse position. Computes SN_ratio and stores it. Start searching
627
- around end of silent zone. Adjustment are made so a complete 0.5
628
- second signal is at the right of the starting search position so a
629
- complete 0.5 s word is available for decoding.
630
-
631
- Returns the pulse position relative to the extract beginning.
632
-
633
- Sets self.detected_pulse_position to the returned value.
337
+ def get_time_in_sound_extract(self):
338
+ """
339
+ Tries to decode time present in self.sound_extract, if successfull
340
+ return a time dict, eg:{'version': 0, 'seconds':
341
+ 44, 'minutes': 57, 'hours': 19,
342
+ 'day': 1, 'month': 3, 'year offset': 1,
343
+ 'pulse at': 670451.2217 } otherwise return None
634
344
  """
635
- pulse_detection_level = self._get_pulse_detection_level()
636
- abs_signal = abs(self.sound_extract)
637
- mean_during_word = 2*abs_signal.mean()
638
- self.SN_ratio = 20*math.log10(mean_during_word/pulse_detection_level)
639
- logger.debug('SN ratio: %f dB'%(self.SN_ratio))
640
- search_pulse_start_point = self._get_approx_pulse_position()
641
- search_pulse_start_point -= 3*SYMBOL_LENGTH*1e-3*self.samplerate
642
- search_pulse_start_point = int(search_pulse_start_point)
643
- # search_pulse_start_point = end_silent_zone
644
- logger.debug('_get_pulse_position %i as starting point'%search_pulse_start_point)
645
- distance_to_end = len(self.sound_extract) - search_pulse_start_point
646
- time_to_end = distance_to_end/self.samplerate
647
- logger.debug('time_to_end after search_pulse_start_point %f sec'%
648
- time_to_end)
649
- if time_to_end < 0.5 + SYMBOL_LENGTH*1e-3: # sec
650
- logger.debug('search_pulse_start_point too near the end')
651
- # go back one second
652
- search_pulse_start_point -= self.samplerate
653
- logger.debug('new starting point %i'%search_pulse_start_point)
345
+ pulse_detected = self._detect_sync_pulse_position()
346
+ if not pulse_detected:
347
+ return None
348
+ symbols_data = self._get_symbols_data()
349
+ frequencies = [self._get_main_frequency(data_slice)
350
+ for data_slice in symbols_data ]
351
+ logger.debug('found frequencies %s'%frequencies)
352
+ def _get_bit_from_freq(freq):
353
+ mid_FSK = 0.5*(F1 + F2)
354
+ return '1' if freq > mid_FSK else '0'
355
+ bits = [_get_bit_from_freq(f) for f in frequencies]
356
+ bits_string = ''.join(bits)
357
+ logger.debug('giving bits: LSB %s MSB'%bits_string)
358
+
359
+ def _values_from_bits(bits):
360
+ word_payload_bits_positions = {
361
+ # start, finish (excluded)
362
+ 'version':(0,3), # 3 bits
363
+ 'seconds':(3,9), # 6 bits
364
+ 'minutes':(9,15),
365
+ 'hours':(15,20),
366
+ 'day':(20,25),
367
+ 'month':(25,29),
368
+ 'year offset':(29,34),
369
+ }
370
+ binary_words = { key : bits[slice(*value)]
371
+ for key, value
372
+ in word_payload_bits_positions.items()
373
+ }
374
+ int_values = { key : int(''.join(reversed(val)),2)
375
+ for key, val in binary_words.items()
376
+ }
377
+ return int_values
378
+ time_values = _values_from_bits(bits_string)
379
+ logger.debug(' decoded time %s'%time_values)
380
+ sync_pos_in_file = self.detected_pulse_position + \
381
+ self.sound_extract_position
382
+ time_values['pulse at'] = sync_pos_in_file
383
+ if not 0 <= time_values['seconds'] <= 59:
384
+ return None
385
+ if not 0 <= time_values['minutes'] <= 59:
386
+ return None
387
+ if not 0 <= time_values['hours'] <= 23:
388
+ return None
389
+ if not 1 <= time_values['month'] <= 12:
390
+ return None
391
+ return time_values
392
+
393
+ def _detect_sync_pulse_position(self):
394
+ # sets self.detected_pulse_position, relative to sound_extract
395
+ #
396
+ regions = self.words_props # contains the sync pulse too
397
+ # len(self.words_props) should be 1 or 2 for vallid TTC
398
+ logger.debug('len() of words_props: %i'%len(self.words_props))
399
+ whole_region = [p for p in regions if 0.499 < _width(p)/self.samplerate < 0.512]
400
+ logger.debug('region widths %s'%[_width(p)/self.samplerate for p in regions])
401
+ logger.debug('number of whole_region %i'%len(whole_region))
402
+ if len(regions) == 1 and len(whole_region) != 1:
403
+ # oops
404
+ logger.debug('len(regions) == 1 and len(whole_region) != 1, failed')
405
+ return False #######################################################
406
+ if len(whole_region) > 1:
407
+ print('error in _detect_sync_pulse_position: len(whole_region) > 1 ')
408
+ return False #######################################################
409
+ if len(whole_region) == 1:
410
+ # sync pulse at the begining of this one
411
+ _, spike, _, _ = whole_region[0].bbox
654
412
  else:
655
- logger.debug('ok, ample room for 0.5 s word')
656
- search_pulse_start_point = max(search_pulse_start_point, 0)
657
- logger.debug('search_pulse_start_point: %i in extract'%
658
- search_pulse_start_point)
659
- abs_signal_after_silence = abs_signal[search_pulse_start_point:]
660
- # here the real searching with numpy.argmax()
661
- first_point = \
662
- np.argmax(abs_signal_after_silence > pulse_detection_level)
663
- first_point += search_pulse_start_point
664
- logger.debug('found sync pulse at %i in extract'%first_point)
665
- logger.debug('found sync pulse at %i in file'%(first_point
666
- + self.sound_extract_position))
667
- distance_to_end = len(self.sound_extract) - first_point
668
- time_to_end = distance_to_end/self.samplerate
669
- logger.debug('time_to_end after detected pulse %f sec'%
670
- time_to_end)
671
- if time_to_end < 0.5 + SYMBOL_LENGTH*1e-3: # sec
672
- logger.debug('detected_pulse_position too near the end')
673
- logger.debug('back one second')
674
- first_point -= self.samplerate
675
- logger.debug('new sync pulse at %i in extract'%first_point)
676
- logger.debug('new sync pulse at %i in file'%first_point)
413
+ # whole_region is [] (all fractionnal) and
414
+ # sync pulse at the begining of the 2nd region
415
+ _, spike, _, _ = regions[1].bbox
416
+ # but check there is still enough place for ten bits:
417
+ # 6 for secs + 3 for revision + blanck after sync
418
+ minimum_samples = int(self.samplerate*10*SYMBOL_LENGTH*1e-3)
419
+ whats_left = len(self.sound_extract) - spike
420
+ if whats_left < minimum_samples:
421
+ spike -= self.samplerate
422
+ # else: stay there, will decode seconds in whats_left
423
+ half_symbol_width = int(0.5*1e-3*SYMBOL_LENGTH*self.samplerate) # samples
424
+ left, right = (spike - half_symbol_width, spike+half_symbol_width)
425
+ spike_data = self.sound_extract[left:right]
426
+ biggest_positive = np.max(spike_data)
427
+ biggest_negative = np.min(spike_data)
428
+ if abs(biggest_negative) > biggest_positive:
429
+ # flip
430
+ spike_data = -1 * spike_data
431
+ def fit_line_until_negative():
432
+ import numpy as np
433
+ start = np.argmax(spike_data)
434
+ xs = [start]
435
+ ys = [spike_data[start]]
436
+ i = 1
437
+ while spike_data[start - i] > 0 and start - i >= 0:
438
+ xs.append(start - i)
439
+ ys.append(spike_data[start - i])
440
+ i += 1
441
+ # ax.scatter(xs, ys)
442
+ import numpy as np
443
+ coeff = np.polyfit(xs, ys, 1)
444
+ m, b = coeff
445
+ zero = int(-b/m)
446
+ # check if data is from USB audio and tweak
447
+ y_fit = np.poly1d(coeff)(xs)
448
+ err = abs(np.sum(np.abs(y_fit-ys))/np.mean(ys))
449
+ logger.debug('fit error for line in ramp: %f'%err)
450
+ if err < 0.01: #good fit so not analog
451
+ zero += 1
452
+ return zero
453
+ sync_sample = fit_line_until_negative() + left
454
+ logger.debug('sync pulse found at %i in extract, %i in file'%(
455
+ sync_sample, sync_sample + self.sound_extract_position))
456
+ self.detected_pulse_position = sync_sample
457
+ return True
458
+
459
+ def _get_symbols_data(self):
460
+ # part of extract AFTER sync pulse
461
+ whats_left = len(self.sound_extract) - self.detected_pulse_position # in samples
462
+ whats_left /= self.samplerate # in sec
463
+ whole_word_is_in_extr = whats_left > 0.512
464
+ if whole_word_is_in_extr:
465
+ # one region
466
+ logger.debug('word is in one sole region')
467
+ length_needed = round(0.5*self.samplerate)
468
+ length_needed += round(self.samplerate*SYMBOL_LENGTH*1e-3)
469
+ whole_word = self.sound_extract[self.detected_pulse_position:
470
+ self.detected_pulse_position + length_needed]
677
471
  else:
678
- logger.debug('ok, ample room for 0.5 s word after detected pulse')
679
- self.detected_pulse_position = first_point
680
- return first_point
681
-
682
- def _get_word_width_parameters(self):
683
- """
684
- Returns the parameters used to find and demodulate 2FSK word:
685
- presumed_symbol_length
686
- word_width_threshold
687
- search_end_position
688
- """
689
- abs_signal = abs(self.sound_extract)
690
- pulse_position = self._get_pulse_position()
691
- bfsk_min = self._get_minimal_bfsk()
692
- params = {'word_width_threshold': 0.8*bfsk_min}
693
- sr = self.samplerate
694
- presumed_symbol_length = SYMBOL_LENGTH*1e-3*sr
695
- # in ms, now in samples
696
- presumed_word_width = presumed_symbol_length * N_SYMBOLS_SAMD21
697
- # word includes sync pulse
698
- params['search_end_position'] = \
699
- int(presumed_symbol_length + pulse_position + \
700
- presumed_word_width) + 1700 # samples for headroom
701
- params['presumed_symbol_length'] = presumed_symbol_length
702
- return params
703
-
704
- def _get_BFSK_word_boundaries(self):
705
- n_bits = N_SYMBOLS_SAMD21 - 1
706
- sr = self.samplerate
707
- wwp = self._get_word_width_parameters()
708
- pulse_position = self._get_pulse_position()
709
- # search_start_position = wwp['search_start_position']
710
- search_end_position = wwp['search_end_position']
711
- word_width_threshold = wwp['word_width_threshold']
712
- word_extract = self.sound_extract[pulse_position :
713
- search_end_position]
714
- logger.debug('word left boundary search position: %i relative to extract'%
715
- (search_end_position))
716
- logger.debug('extract starting at %i in file (Decoder.sound_extract_position)'%
717
- self.sound_extract_position)
718
- flipped_extract = np.flip(np.abs(word_extract))
719
- right_boundary = len(word_extract) - \
720
- np.argmax(flipped_extract > word_width_threshold) + \
721
- pulse_position
722
- # left_boundary = np.argmax(
723
- # np.abs(word_extract) > word_width_threshold) + \
724
- # search_start_position
725
- left_boundary = pulse_position
726
- left_in_file = left_boundary + self.sound_extract_position
727
- right_in_file = right_boundary + self.sound_extract_position
728
- logger.debug('word boundaries: %i and %i in file'%
729
- (left_in_file, right_in_file))
730
- eff_symbol_length = 1e3*(right_boundary-left_boundary)/(n_bits*sr)
731
- logger.debug('effective symbol length %.4f ms '%
732
- eff_symbol_length)
733
- logger.debug('presumed symbol length %.4f ms '%SYMBOL_LENGTH)
734
- relative_error = (eff_symbol_length-SYMBOL_LENGTH)/SYMBOL_LENGTH
735
- status = True
736
- if relative_error > SYMBOL_LENGTH_TOLERANCE:
737
- logger.error(
738
- 'actual symbol length differs too much: %.2f vs %.2f ms'%
739
- (eff_symbol_length, SYMBOL_LENGTH))
740
- # return None, None
741
- status = False
742
- logger.debug(' relative discrepancy %.4f%%'%(abs(100*relative_error)))
743
- return status, left_boundary, right_boundary
744
-
745
- def _get_BFSK_symbols_boundaries(self):
746
- # returns indices of start of each slice and boundaries
747
- pulse_position = self._get_pulse_position()
748
- boundaries_OK, left_boundary, right_boundary = \
749
- self._get_BFSK_word_boundaries()
750
- if left_boundary is None:
751
- return None, None, None
752
- symbol_width_samples = \
753
- float(right_boundary - left_boundary)/N_SYMBOLS_SAMD21
754
- symbol_positions = symbol_width_samples * \
755
- np.arange(float(0), float(N_SYMBOLS_SAMD21 + 1)) + \
756
- pulse_position
757
- int_symb_positions = symbol_positions.round().astype(int)
758
- logger.debug('%i symbol positions %s samples in file'%
759
- (
760
- len(int_symb_positions),
761
- int_symb_positions + self.sound_extract_position)
762
- )
763
- return int_symb_positions[:-1], left_boundary, right_boundary
764
-
765
- def _values_from_bits(self, bits):
766
- word_payload_bits_positions = {
767
- 'version':(0,2),
768
- 'clock source':(2,3),
769
- 'seconds':(3,9),
770
- 'minutes':(9,15),
771
- 'hours':(15,20),
772
- 'day':(20,25),
773
- 'month':(25,29),
774
- 'year offset':(29,34),
775
- }
776
- binary_words = { key : bits[slice(*value)]
777
- for key, value
778
- in word_payload_bits_positions.items()
779
- }
780
- int_values = { key : self._get_int_from_binary_str(val)
781
- for key, val in binary_words.items()
782
- }
783
- logger.debug(' Demodulated values %s'%int_values)
784
- return int_values
785
-
786
- def _slice_sound_extract(self, symbols_indices):
787
- indices_left_shifted = deque(list(symbols_indices))
788
- indices_left_shifted.rotate(-1)
789
- all_intervals = list(zip(
790
- symbols_indices,
791
- indices_left_shifted
792
- ))
793
- word_intervals = all_intervals[1:-1]
794
- # [0, 11, 23, 31, 45] => [(11, 23), (23, 31), (31, 45)]
795
- logger.debug('slicing intervals, word_intervals = %s'%
796
- word_intervals)
797
- # skip sample after pulse, start at BFSK word
798
- filtered_sound_extract = self._band_pass_filter(self.sound_extract)
799
- slices = [filtered_sound_extract[slice(*pair)]
800
- for pair in word_intervals]
801
- np.set_printoptions(threshold=5)
802
- # logger.debug('data slices: \n%s'%pprint.pformat(slices))
803
- # raise Exception
472
+ # Two regions.
473
+ logger.debug('word is in two regions, will wrap past seconds')
474
+ # Consistency check: if not whole_word_is_in_extr
475
+ # check has been done so seconds are encoded in what s left
476
+ minimum_samples = round(self.samplerate*10*SYMBOL_LENGTH*1e-3)
477
+ if whats_left*self.samplerate < minimum_samples:
478
+ print('bug in _get_data_symbol():')
479
+ print(' whats_left*self.samplerate < minimum_samples')
480
+ # Should now build a whole 0.5 sec word by joining remaining data
481
+ # from previous second beep
482
+ left_piece = self.sound_extract[self.detected_pulse_position:]
483
+ one_second_before_idx = round(len(self.sound_extract) - self.samplerate)
484
+ length_needed = round(0.5*self.samplerate - len(left_piece))
485
+ length_needed += round(self.samplerate*SYMBOL_LENGTH*1e-3)
486
+ right_piece = self.sound_extract[one_second_before_idx:
487
+ one_second_before_idx + length_needed]
488
+ whole_word = np.concatenate((left_piece, right_piece))
489
+ logger.debug('two chunks lengths: %i %i samples'%(len(left_piece),
490
+ len(right_piece)))
491
+ # search for word start (some jitter because of Teensy Audio Lib)
492
+ symbol_length = round(self.samplerate*SYMBOL_LENGTH*1e-3)
493
+ start = round(0.5*symbol_length) # half symbol
494
+ end = start + symbol_length
495
+ word_begining = whole_word[start:]
496
+ gt_detection_level = np.argwhere(np.abs(word_begining)>self.pulse_detection_level)
497
+ word_start = gt_detection_level[0][0]
498
+ word_end = gt_detection_level[-1][0]
499
+ self.effective_word_duration = (word_end - word_start)/self.samplerate
500
+ logger.debug('effective_word_duration %f s'%self.effective_word_duration)
501
+ uCTRLR_error = self.effective_word_duration/((N_SYMBOLS -1)*SYMBOL_LENGTH*1e-3)
502
+ logger.debug('uCTRLR_error %f (time ratio)'%uCTRLR_error)
503
+ word_start += start # relative to Decoder extract
504
+ # check if gap is indeed less than TEENSY_MAX_LAG
505
+ silence_length = word_start
506
+ gap = silence_length - symbol_length
507
+ relative_gap = gap/(TEENSY_MAX_LAG*self.samplerate)
508
+ logger.debug('Audio update() gap between sync pulse and word start: ')
509
+ logger.debug('%.2f ms (max value %.2f)'%(1e3*gap/self.samplerate,
510
+ 1e3*TEENSY_MAX_LAG))
511
+ logger.debug('relative audio_block gap %.2f'%(relative_gap))
512
+ # if relative_gap > 1: # dont tell: simply fail and try elsewhere
513
+ # print('Warning: gap between spike and word is too big for %s'%self.rec)
514
+ # print('Audio update() gap between sync pulse and word start: ')
515
+ # print('%.2f ms (max value %.2f)'%(1e3*gap/self.samplerate,
516
+ # 1e3*TEENSY_MAX_LAG))
517
+ symbol_width_samples_theor = self.samplerate*SYMBOL_LENGTH*1e-3
518
+ symbol_width_samples_eff = self.effective_word_duration * \
519
+ self.samplerate/(N_SYMBOLS - 1)
520
+ logger.debug('symbol width %i theo; %i effective (samples)'%(
521
+ symbol_width_samples_theor,
522
+ symbol_width_samples_eff))
523
+ symbol_positions = symbol_width_samples_eff * \
524
+ np.arange(float(0), float(N_SYMBOLS - 1)) + word_start
525
+ # symbols_indices contains 34 start of symbols (samples)
526
+ symbols_indices = symbol_positions.round().astype(int)
527
+ if self.do_plots:
528
+ fig, ax = plt.subplots()
529
+ ax.hlines(0, 0, 1,
530
+ transform=ax.get_yaxis_transform(), alpha=0.3,
531
+ linewidth=2, colors='black')
532
+ start = self.sound_extract_position
533
+ i_samples = np.arange(start, start + len(whole_word))
534
+ ax.plot(i_samples, whole_word, marker='o', markersize='1',
535
+ linewidth=1.5,alpha=0.3, color='blue' )
536
+ xt = ax.get_xaxis_transform()
537
+ for x in symbols_indices:
538
+ ax.vlines(x + start, 0, 1,
539
+ transform=xt,
540
+ linewidth=0.6, colors='green')
541
+ ax.set_title('Slicing the 34 bits word:')
542
+ plt.xlabel("Position in file %s (samples)"%self.rec)
543
+ ax.vlines(start, 0, 1,
544
+ transform=xt,
545
+ linewidth=0.6, colors='red')
546
+ plt.show()
547
+ slice_width = round(SYMBOL_LENGTH*1e-3*self.samplerate)
548
+ slices = [whole_word[i:i+slice_width] for i in symbols_indices]
804
549
  return slices
805
550
 
806
551
  def _get_main_frequency(self, symbol_data):
@@ -811,194 +556,7 @@ class Decoder:
811
556
  freq_in_hertz = abs(freq * self.samplerate)
812
557
  return int(round(freq_in_hertz))
813
558
 
814
- # def _get_bit_from_freq(self, freq):
815
- # if math.isclose(freq, F1, abs_tol=FSK_TOLERANCE):
816
- # return '0'
817
- # if math.isclose(freq, F2, abs_tol=FSK_TOLERANCE):
818
- # return '1'
819
- # else:
820
- # return None
821
-
822
- def _get_bit_from_freq(self, freq):
823
- mid_FSK = 0.5*(F1 + F2)
824
- return '1' if freq > mid_FSK else '0'
825
-
826
- def _get_int_from_binary_str(self, string_of_01s):
827
- return int(''.join(reversed(string_of_01s)),2)
828
- # LSB is leftmost in YaLTC
829
-
830
- def _demod_values_are_OK(self, values_dict):
831
- ranges = {
832
- 'seconds': range(60),
833
- 'minutes': range(60),
834
- 'hours': range(24),
835
- 'day': range(1,32),
836
- 'month': range(1,13),
837
- }
838
- for key in ranges:
839
- val = values_dict[key]
840
- ok = val in ranges[key]
841
- logger.debug(
842
- 'checking range for %s: %i, Ok? %s'%(key, val, ok))
843
- if not ok:
844
- logger.error('demodulated value is out of range')
845
- return False
846
- return True
847
559
 
848
- def _plot_slices(self, sync_pulse, symbols_indices, word_lft, word_rght,
849
- title=None, filename=None):
850
- # save figure in filename if set, otherwise
851
- # start an interactive plot, title is for matplotlib
852
- signal = self.sound_extract
853
- # signal = self._band_pass_filter(signal)
854
- start = self.sound_extract_position
855
- x_signal_in_file = range(
856
- start,
857
- start + len(signal)
858
- )
859
- wwp = self._get_word_width_parameters()
860
- start_silent_zone, end_silent_zone = self._get_silent_zone_indices()
861
- search_end_position = wwp['search_end_position'] + start
862
- logger.debug('doing slice plot')
863
- fig, ax = plt.subplots()
864
- plt.title(title)
865
- ax.plot(
866
- x_signal_in_file, signal,
867
- marker='.', markersize='1',
868
- linewidth=0.3, color='purple', alpha=0.3)
869
- yt = ax.get_yaxis_transform()
870
- ax.hlines(
871
- wwp['word_width_threshold'], 0, 1,
872
- transform=yt, linewidth=0.6, colors='green')
873
- ax.hlines(
874
- 0, 0, 1,
875
- transform=yt, linewidth=0.6, colors='black')
876
- ax.hlines(
877
- -wwp['word_width_threshold'], 0, 1,
878
- transform=yt, linewidth=0.6, colors='green')
879
- pulse_level = self._get_pulse_detection_level()
880
- ax.hlines(
881
- pulse_level, 0, 1,
882
- transform=yt, linewidth=0.6, colors='blue')
883
- ax.hlines(
884
- -pulse_level, 0, 1,
885
- transform=yt, linewidth=0.6, colors='blue')
886
- xt = ax.get_xaxis_transform()
887
- # ax.vlines(
888
- # search_start_position,
889
- # 0, 1, transform=xt, linewidth=0.6, colors='blue')
890
- ax.vlines(
891
- search_end_position,
892
- 0, 1, transform=xt, linewidth=0.6, colors='blue')
893
- ax.plot(
894
- [sync_pulse + start], [0],
895
- marker='D', markersize='7',
896
- linewidth=0.3, color='blue', alpha=0.3)
897
- ax.plot(
898
- [start_silent_zone + start], [0],
899
- marker='>', markersize='10',
900
- linewidth=0.3, color='green', alpha=0.3)
901
- ax.plot(
902
- [end_silent_zone + start], [0],
903
- marker='<', markersize='10',
904
- linewidth=0.3, color='green', alpha=0.3)
905
- boundaries_OK, word_lft, word_rght = \
906
- self._get_BFSK_word_boundaries()
907
- ax.vlines(
908
- word_lft + start, 0, 1,
909
- transform=ax.get_xaxis_transform(),
910
- linewidth=0.6, colors='red')
911
- ax.vlines(
912
- word_rght + start, 0, 1,
913
- transform=ax.get_xaxis_transform(),
914
- linewidth=0.6, colors='red')
915
- for x in symbols_indices + self.sound_extract_position:
916
- ax.vlines(
917
- x, 0, 1,
918
- transform=ax.get_xaxis_transform(),
919
- linewidth=0.3, colors='green')
920
- ax.set_xlabel(
921
- 'samples in file')
922
- plt.xlim(
923
- [sync_pulse - 300 + start, wwp['search_end_position'] + 400 + start])
924
- if filename == None:
925
- plt.show()
926
- else:
927
- plt.ylim(
928
- [-1.5*wwp['word_width_threshold'],
929
- 1.1*signal.max()])
930
- height = 1000
931
- plt.savefig(
932
- filename,
933
- format='png',
934
- dpi=height/fig.get_size_inches()[1])
935
- plt.close()
936
- logger.debug('done slice plot')
937
-
938
- def _band_pass_filter(self, data):
939
- # return filtered data
940
- def _bandpass(data: np.ndarray, edges: list[float], sample_rate: float, poles: int = 5):
941
- sos = scipy.signal.butter(poles, edges, 'bandpass', fs=sample_rate, output='sos')
942
- filtered_data = scipy.signal.sosfiltfilt(sos, data)
943
- return filtered_data
944
- sample_rate = self.samplerate
945
- times = np.arange(len(data))/sample_rate
946
- return _bandpass(data, [BPF_LOW_FRQ, BPF_HIGH_FRQ], sample_rate)
947
-
948
- def get_time_in_sound_extract(self, plots):
949
- if self.sound_extract is None:
950
- return None
951
- if plots:
952
- self.make_silence_analysis_plot()
953
- pulse_position = self._detect_sync_pulse_position()
954
- pulse_pos_in_file = pulse_position + self.sound_extract_position
955
- pulse_position_sec = pulse_pos_in_file/self.samplerate
956
- logger.debug('found sync pulse at sample %i in file'%pulse_pos_in_file)
957
- symbols_indices, word_lft, word_rght = \
958
- self._get_BFSK_symbols_boundaries()
959
- if plots:
960
- title = 'Bit slicing at %s, %.2f s'%(pulse_pos_in_file,
961
- pulse_position_sec)
962
- # self.make_silence_analysis_plot()
963
- logger.debug('calling _plot_slices()')
964
- self._plot_slices(pulse_position, symbols_indices, word_lft,
965
- word_rght, title)
966
- if symbols_indices is None:
967
- return None
968
- sliced_data = self._slice_sound_extract(symbols_indices)
969
- frequencies = [self._get_main_frequency(data_slice)
970
- for data_slice
971
- in sliced_data
972
- ]
973
- logger.debug('frequencies = %s'%frequencies)
974
- sr = self.samplerate
975
- n_bits = N_SYMBOLS_SAMD21 - 1
976
- eff_symbol_length = 1e3*(word_rght-word_lft)/(n_bits*sr)
977
- length_ratio = eff_symbol_length / SYMBOL_LENGTH
978
- logger.debug('symbol length_ratio (eff/supposed) %f'%length_ratio)
979
- corrected_freq = np.array(frequencies)*length_ratio
980
- logger.debug('corrected freq (using symbol length) = %s'%corrected_freq)
981
- bits = [self._get_bit_from_freq(f) for f in corrected_freq]
982
- for i, bit in enumerate(bits):
983
- if bit == None:
984
- logger.warning('cant decode frequency %i for bit at %i-%i'%(
985
- corrected_freq[i],
986
- symbols_indices[i],
987
- symbols_indices[i+1]))
988
- if None in bits:
989
- return None
990
- bits_string = ''.join(bits)
991
- logger.debug('bits = %s'%bits_string)
992
- time_values = self._values_from_bits(bits_string)
993
- time_values['pulse at'] = (pulse_position +
994
- self.sound_extract_position -
995
- SAMD21_LATENCY*1e-6*self.samplerate)
996
- time_values['clock source'] = 'GPS' \
997
- if time_values['clock source'] == 1 else 'RTC'
998
- if self._demod_values_are_OK(time_values):
999
- return time_values
1000
- else:
1001
- return None
1002
560
 
1003
561
  class Recording:
1004
562
  """
@@ -1006,28 +564,22 @@ class Recording:
1006
564
 
1007
565
  Attributes:
1008
566
  AVpath : pathlib.path
1009
- path of video+sound+YaLTC file, relative to working directory
567
+ path of video+sound+TicTacCode file, relative to working directory
1010
568
 
1011
- sound_without_YaLTC : pathlib.path
1012
- path of sound file stripped of silent and YaLTC channels
569
+ audio_data : in16 numpy.array of shape [nchan] x [N samples]
1013
570
 
1014
- device : str
1015
- identifies the device used for the recording, set in __init__()
571
+ # valid_sound : pathlib.path
572
+ # path of sound file stripped of silent and TicTacCode channels
1016
573
 
1017
- new_rec_name : str
1018
- built using the device name, ex: "CAM_A001"
1019
- set by Timeline._rename_all_recs()
574
+ device : Device
575
+ identifies the device used for the recording, set in __init__()
1020
576
 
1021
577
  probe : dict
1022
578
  returned value of ffmpeg.probe(self.AVpath)
1023
579
 
1024
- YaLTC_channel : int
580
+ TicTacCode_channel : int
1025
581
  which channel is sync track. 0 is first channel,
1026
- set in _read_sound_find_YaLTC().
1027
-
1028
- silent_channels : list of ints
1029
- list of channel which are silent. 1 is first channel,
1030
- set in _strip_yaltc()
582
+ set in _find_TicTacCode().
1031
583
 
1032
584
  decoder : yaltc.decoder
1033
585
  associated decoder object, if file is audiovideo
@@ -1043,7 +595,7 @@ class Recording:
1043
595
  sync_position : int
1044
596
  position of first detected syn pulse
1045
597
 
1046
- is_reference : bool (True for ref rec only)
598
+ is_audio_reference : bool (True for ref rec only)
1047
599
  in multi recorders set-ups, user decides if a sound-only recording
1048
600
  is the time reference for all other audio recordings. By
1049
601
  default any video recording is the time reference for other audio,
@@ -1052,24 +604,24 @@ class Recording:
1052
604
 
1053
605
  device_relative_speed : float
1054
606
  the ratio of the recording device clock speed relative to the
1055
- reference_rec clock device, in order to correct clock drift with
1056
- pysox tempo transform. If value < 1.0 then the recording is slower
1057
- than reference_rec. Updated by each AudioStitcherVideoMerger
1058
- instance so the value can change depending on the reference
1059
- recording (video or main sound). A mean is calculated for all
607
+ video recorder clock device, in order to correct clock drift with
608
+ pysox tempo transform. If value < 1.0 then the recording is
609
+ slower than video recorder. Updated by each
610
+ MediaMerger instance so the value can change
611
+ depending on the video recording . A mean is calculated for all
1060
612
  recordings of the same device in
1061
- AudioStitcherVideoMerger._get_concatenated_audiofile_for()
613
+ MediaMerger._get_concatenated_audiofile_for()
1062
614
 
1063
615
  time_position : float
1064
616
  The time (in seconds) at which the recording starts relative to the
1065
- reference recording. Updated by each AudioStitcherVideoMerger
1066
- instance so the value can change depending on the reference
617
+ video recording. Updated by each MediaMerger
618
+ instance so the value can change depending on the video
1067
619
  recording (a video or main sound).
1068
620
 
1069
621
  final_synced_file : a pathlib.Path
1070
622
  contains the path of the merged video file after the call to
1071
- AudioStitcher.build_audio_and_write_video if the Recording is a
1072
- reference recording, relative to the working directory
623
+ AudioStitcher.build_audio_and_write_merged_media if the Recording is a
624
+ video recording, relative to the working directory
1073
625
 
1074
626
  synced_audio : pathlib.Path
1075
627
  contains the path of audio only of self.final_synced_file. Absolute
@@ -1083,51 +635,59 @@ class Recording:
1083
635
 
1084
636
  """
1085
637
 
1086
- def __init__(self, media):
638
+ def __init__(self, media, do_plots=False):
1087
639
  """
640
+ Set AVfilename string and check if file exists, does not read any
641
+ media data right away but uses ffprobe to parses the file and sets
642
+ probe attribute.
643
+
644
+ Logs a warning and sets Recording.decoder to None if ffprobe cant
645
+ interpret the file or if file has no audio. If file contains audio,
646
+ initialise Recording.decoder(but doesnt try to decode anything yet).
647
+
1088
648
  If multifile recording, AVfilename is sox merged audio file;
1089
- Set AVfilename string and check if file exists, does not read
1090
- any media data right away but uses ffprobe to parses the file and
1091
- sets probe attribute.
1092
- Logs a warning if ffprobe cant interpret the file or if file
1093
- has no audio; if file contains audio, instantiates a Decoder object
1094
- (but doesnt try to decode anything yet)
1095
649
 
1096
650
  Parameters
1097
651
  ----------
1098
- media : dict as
1099
- {
1100
- 'path' : as is ,
1101
- 'device type' : REC or CAM,
1102
- 'sample length' : as is
1103
- 'dev UID' : see get_device_ffprobe_UID()
1104
- }
1105
-
652
+ media : Media dataclass with attributes:
653
+ path: Path
654
+ device: Device
655
+
656
+ with Device having attibutes (from device_scanner module):
657
+ UID: int
658
+ folder: Path
659
+ name: str
660
+ dev_type: str
661
+ tracks: Tracks
662
+
663
+ with Tracks having attributes (from device_scanner module):
664
+ ttc: int # track number of TicTacCode signal
665
+ unused: list # of unused tracks
666
+ stereomics: list # of stereo mics track tuples (Lchan#, Rchan#)
667
+ mix: list # of mixed tracks, if a pair, order is L than R
668
+ others: list #of all other tags: (tag, track#) tuples
669
+ rawtrx: list # list of strings read from file
670
+ error_msg: str # 'None' if none
1106
671
  Raises
1107
672
  ------
1108
673
  an Exception if AVfilename doesnt exist
1109
674
 
1110
675
  """
1111
-
1112
- self.AVpath = media['path']
1113
- # self.device = media['dev UID']
1114
- self.device = media['dev']
676
+ self.AVpath = media.path
677
+ self.device = media.device
1115
678
  self.true_samplerate = None
1116
679
  self.start_time = None
1117
680
  self.in_cam_audio_sync_arror = 0
1118
681
  self.decoder = None
1119
682
  self.probe = None
1120
- self.YaLTC_channel = None
1121
- self.is_reference = False
683
+ self.TicTacCode_channel = None
684
+ self.is_audio_reference = False
1122
685
  self.device_relative_speed = 1.0
1123
- self.sound_without_YaLTC = None
686
+ # self.valid_sound = None
1124
687
  self.final_synced_file = None
1125
688
  self.synced_audio = None
1126
- self.new_rec_name = media['path'].name
1127
- # self.matched_audio_records = []
1128
- # self.relative_position = 0
1129
- # self.editing_front = {'what':None,'how': 0}
1130
- # self.editing_rear = {'what':None,'how': 0}
689
+ # self.new_rec_name = media.path.name
690
+ self.do_plots = do_plots
1131
691
  logger.debug('__init__ Recording object %s'%self.__repr__())
1132
692
  logger.debug(' in directory %s'%self.AVpath.parent)
1133
693
  recording_init_fail = ''
@@ -1150,7 +710,7 @@ class Recording:
1150
710
  elif self.get_duration() < MINIMUM_LENGTH:
1151
711
  recording_init_fail = 'file too short, %f s\n'%self.get_duration()
1152
712
  if recording_init_fail == '': # success
1153
- self.decoder = Decoder(self)
713
+ self.decoder = Decoder(self, do_plots)
1154
714
  # self._set_multi_files_siblings()
1155
715
  self._check_for_camera_error_correction()
1156
716
  else:
@@ -1159,11 +719,25 @@ class Recording:
1159
719
  print('Recording init failed: %s'%recording_init_fail)
1160
720
  self.probe = None
1161
721
  self.decoder = None
722
+ return
1162
723
  logger.debug('ffprobe found: %s'%self.probe)
1163
724
  logger.debug('n audio chan: %i'%self.get_audio_channels_nbr())
725
+ # self._read_audio_data()
726
+ N = self.get_audio_channels_nbr()
727
+ data = read_audio_data_from_file(self.AVpath, n_channels=N)
728
+ if len(data) == 1 and not self.is_video():
729
+ print(f'file sound is mono ({self.AVpath}), bye.')
730
+ sys.exit(0)
731
+ if np.isclose(np.std(data), 0, rtol=1e-2):
732
+ logger.error("ffmpeg can't extract audio from %s"%file)
733
+ sys.exit(0)
734
+ self.audio_data = data
735
+ logger.debug('Recording.audio_data: %s of shape %s'%(self.audio_data,
736
+ self.audio_data.shape))
1164
737
 
1165
738
  def __repr__(self):
1166
- return 'Recording of %s'%_pathname(self.new_rec_name)
739
+ # return 'Recording of %s'%_pathname(self.new_rec_name)
740
+ return _pathname(self.AVpath)
1167
741
 
1168
742
  def _check_for_camera_error_correction(self):
1169
743
  # look for a file number
@@ -1180,7 +754,7 @@ class Recording:
1180
754
  f = str(calib_file[0])
1181
755
  print('problem parsing name of [gold1]%s[/gold1],'%f)
1182
756
  print('move elsewhere and rerun, quitting.\n')
1183
- quit()
757
+ sys.exit(1)
1184
758
  self.in_cam_audio_sync_arror = value
1185
759
  logger.debug('found error correction %i ms.'%value)
1186
760
 
@@ -1200,19 +774,19 @@ class Recording:
1200
774
  recording duration in seconds.
1201
775
 
1202
776
  """
1203
- if self.sound_without_YaLTC:
1204
- val = sox.file_info.duration(_pathname(self.sound_without_YaLTC))
1205
- logger.debug('duration of sound_without_YaLTC %f'%val)
1206
- return val
777
+ if self.is_audio():
778
+ val = sox.file_info.duration(_pathname(self.AVpath))
779
+ logger.debug('sox duration of valid_sound %f for %s'%(val,_pathname(self.AVpath)))
780
+ return val #########################################################
1207
781
  else:
1208
782
  if self.probe is None:
1209
- return 0
783
+ return 0 #######################################################
1210
784
  try:
1211
785
  probed_duration = float(self.probe['format']['duration'])
1212
786
  except:
1213
787
  logger.error('oups, cant find duration from ffprobe')
1214
788
  raise Exception('stopping here')
1215
- logger.debug('ffprobed duration is: %f sec'%probed_duration)
789
+ logger.debug('ffprobed duration is: %f sec for %s'%(probed_duration, self))
1216
790
  return probed_duration # duration in s
1217
791
 
1218
792
  def get_original_duration(self):
@@ -1228,27 +802,29 @@ class Recording:
1228
802
  recording duration in seconds.
1229
803
 
1230
804
  """
1231
- val = sox.file_info.duration(_pathname(self.sound_without_YaLTC))
1232
- logger.debug('duration of sound_without_YaLTC %f'%val)
805
+ val = sox.file_info.duration(_pathname(self.AVpath))
806
+ logger.debug('duration of AVpath %f'%val)
1233
807
  return val
1234
808
 
1235
809
  def get_corrected_duration(self):
1236
810
  """
1237
811
  uses device_relative_speed to compute corrected duration. Updated by
1238
- each AudioStitcherVideoMerger object in
1239
- AudioStitcherVideoMerger._get_concatenated_audiofile_for()
812
+ each MediaMerger object in
813
+ MediaMerger._get_concatenated_audiofile_for()
1240
814
  """
1241
815
  return self.get_duration()/self.device_relative_speed
1242
816
 
1243
817
  def needs_dedrifting(self):
1244
818
  rel_sp = self.device_relative_speed
1245
- if rel_sp > 1:
1246
- delta = (rel_sp - 1)*self.get_original_duration()
1247
- else:
1248
- delta = (1 - rel_sp)*self.get_original_duration()
819
+ # if rel_sp > 1:
820
+ # delta = (rel_sp - 1)*self.get_original_duration()
821
+ # else:
822
+ # delta = (1 - rel_sp)*self.get_original_duration()
823
+ delta = abs((1 - rel_sp)*self.get_original_duration())
1249
824
  logger.debug('%s delta drift %.2f ms'%(str(self), delta*1e3))
1250
825
  if delta > MAXDRIFT:
1251
- print('%s needs dedrifting: delta of %i ms is too big'%(self.AVpath, delta*1e3))
826
+ print('\n[gold1]%s[/gold1] will get drift correction: delta of [gold1]%.3f[/gold1] ms is too big'%
827
+ (self.AVpath, delta*1e3))
1252
828
  return delta > MAXDRIFT, delta
1253
829
 
1254
830
  def get_end_time(self):
@@ -1268,32 +844,38 @@ class Recording:
1268
844
  end = self.get_end_time()
1269
845
  return start < datetime and datetime < end
1270
846
 
1271
- def _find_time_around(self, time, plots):
1272
- # actually reading sound data and trying to decode it
1273
- # through decoder object, if successful return a time dict, eg:
1274
- # {'version': 0, 'clock source': 'GPS', 'seconds': 44, 'minutes': 57,
1275
- # 'hours': 19, 'day': 1, 'month': 3, 'year offset': 1,
1276
- # 'pulse at': 670451.2217 }
1277
- # otherwise return None
1278
- if time < 0:
847
+ def _find_time_around(self, time):
848
+ """
849
+ Tries to decode FSK around time (in sec)
850
+ through decoder object; if successful return a time dict, eg:
851
+ {'version': 0, 'seconds': 44, 'minutes': 57,
852
+ 'hours': 19, 'day': 1, 'month': 3, 'year offset': 1,
853
+ 'pulse at': 670451.2217 }
854
+ otherwise return None
855
+ """
856
+ if time < 0: # negative = referenced from the end
1279
857
  there = self.get_duration() + time
1280
858
  else:
1281
859
  there = time
1282
- self._read_sound_find_YaLTC(there, SOUND_EXTRACT_LENGTH)
1283
- if self.YaLTC_channel is None:
860
+ self._find_TicTacCode(there, SOUND_EXTRACT_LENGTH)
861
+ if self.TicTacCode_channel is None:
1284
862
  return None
1285
863
  else:
1286
- return self.decoder.get_time_in_sound_extract(plots)
864
+ return self.decoder.get_time_in_sound_extract()
1287
865
 
1288
866
  def _get_timedate_from_dict(self, time_dict):
1289
- python_datetime = datetime(
1290
- time_dict['year offset'] + YEAR_ZERO,
1291
- time_dict['month'],
1292
- time_dict['day'],
1293
- time_dict['hours'],
1294
- time_dict['minutes'],
1295
- time_dict['seconds'],
1296
- tzinfo=timezone.utc)
867
+ try:
868
+ python_datetime = datetime(
869
+ time_dict['year offset'] + YEAR_ZERO,
870
+ time_dict['month'],
871
+ time_dict['day'],
872
+ time_dict['hours'],
873
+ time_dict['minutes'],
874
+ time_dict['seconds'],
875
+ tzinfo=timezone.utc)
876
+ except ValueError as e:
877
+ print('Error converting date in _get_timedate_from_dict',e)
878
+ sys.exit(1)
1297
879
  python_datetime += timedelta(seconds=1) # PPS precedes NMEA sequ
1298
880
  return python_datetime
1299
881
 
@@ -1301,7 +883,7 @@ class Recording:
1301
883
  """
1302
884
  For error checking. This verifies if two sync pulse apart
1303
885
  are correctly space with sample interval deduced from
1304
- time difference of demodulated YaLTC times. The same
886
+ time difference of demodulated TicTacCode times. The same
1305
887
  process is used for determining the true sample rate
1306
888
  in _compute_true_samplerate(). On entry check if either time
1307
889
  is None, return False if so.
@@ -1315,12 +897,11 @@ class Recording:
1315
897
 
1316
898
  Returns
1317
899
  -------
1318
- TYPE
1319
- DESCRIPTION.
1320
900
 
1321
901
  """
1322
902
  if t1 == None or t2 == None:
1323
- return False
903
+ return False #######################################################
904
+ logger.debug('t1 : %s t2: %s'%(t1, t2))
1324
905
  datetime_1 = self._get_timedate_from_dict(t1)
1325
906
  datetime_2 = self._get_timedate_from_dict(t2)
1326
907
  # if datetime_2 < datetime_1:
@@ -1361,19 +942,19 @@ class Recording:
1361
942
  to_precision(true_samplerate, 8))
1362
943
  return true_samplerate
1363
944
 
1364
- def set_time_position_to(self, reference_recording):
945
+ def set_time_position_to(self, video_clip):
1365
946
  """
1366
947
  Sets self.time_position, the time (in seconds) at which the recording
1367
- starts relative to the reference recording. Updated by each AudioStitcherVideoMerger
1368
- instance so the value can change depending on the reference
948
+ starts relative to the video recording. Updated by each MediaMerger
949
+ instance so the value can change depending on the video
1369
950
  recording (a video or main sound).
1370
951
 
1371
952
  called by timeline.AudioStitcher._get_concatenated_audiofile_for()
1372
953
 
1373
954
  """
1374
- ref_start_time = reference_recording.get_start_time()
955
+ video_start_time = video_clip.get_start_time()
1375
956
  self.time_position = (self.get_start_time()
1376
- - ref_start_time).total_seconds()
957
+ - video_start_time).total_seconds()
1377
958
 
1378
959
  def get_Dt_with(self, later_recording):
1379
960
  """
@@ -1385,25 +966,29 @@ class Recording:
1385
966
  t2 = later_recording.get_start_time()
1386
967
  return t2 - t1
1387
968
 
1388
- def get_start_time(self, plots=False):
969
+ def get_start_time(self):
1389
970
  """
1390
- Try to decode a YaLTC_channel at start AND finish;
1391
- if successful, returns a python datetime object;
971
+ Try to decode a TicTacCode_channel at start AND finish;
972
+ if successful, returns a datetime.datetime instance;
1392
973
  if not returns None.
1393
- If successful AND self is audio, sets self.sound_without_YaLTC
1394
974
  """
975
+ logger.debug('for %s, recording.start_time %s'%(self,
976
+ self.start_time))
977
+ if self.decoder is None:
978
+ return None # ffprobe failes or file too short, see __init__
1395
979
  if self.start_time is not None:
1396
- return self.start_time
980
+ logger.debug('Recording.start_time already found %s'%self.start_time)
981
+ return self.start_time #############################################
1397
982
  cached_times = {}
1398
- def find_time(t_sec, plots=False):
983
+ def find_time(t_sec):
1399
984
  time_k = int(t_sec)
1400
985
  # if cached_times.has_key(time_k):
1401
986
  if CACHING and time_k in cached_times:
1402
987
  logger.debug('cache hit _find_time_around() for t=%s s'%time_k)
1403
- return cached_times[time_k]
988
+ return cached_times[time_k] ####################################
1404
989
  else:
1405
990
  logger.debug('_find_time_around() for t=%s s not cached'%time_k)
1406
- new_t = self._find_time_around(t_sec, plots)
991
+ new_t = self._find_time_around(t_sec)
1407
992
  cached_times[time_k] = new_t
1408
993
  return new_t
1409
994
  for i, pair in enumerate(TRIAL_TIMES):
@@ -1416,12 +1001,12 @@ class Recording:
1416
1001
  logger.warning('More than one trial: #%i/%i'%(i+1,
1417
1002
  len(TRIAL_TIMES)))
1418
1003
  # time_around_beginning = self._find_time_around(near_beg)
1419
- time_around_beginning = find_time(near_beg, plots)
1420
- if self.YaLTC_channel is None:
1421
- return None
1004
+ time_around_beginning = find_time(near_beg)
1005
+ # if self.TicTacCode_channel is None:
1006
+ # return None ####################################################
1422
1007
  logger.debug('Trial #%i, end at %f'%(i+1, near_end))
1423
1008
  # time_around_end = self._find_time_around(near_end)
1424
- time_around_end = find_time(near_end, plots)
1009
+ time_around_end = find_time(near_end)
1425
1010
  logger.debug('trial result, time_around_beginning:\n %s'%
1426
1011
  (time_around_beginning))
1427
1012
  logger.debug('trial result, time_around_end:\n %s'%
@@ -1431,14 +1016,15 @@ class Recording:
1431
1016
  time_around_end)
1432
1017
  logger.debug('_two_times_are_coherent: %s'%coherence)
1433
1018
  if coherence:
1019
+ logger.debug('Trial #%i successful'%(i+1))
1434
1020
  break
1435
1021
  if not coherence:
1436
1022
  logger.warning('found times are incoherent')
1437
- return None
1023
+ return None ########################################################
1438
1024
  if None in [time_around_beginning, time_around_end]:
1439
1025
  logger.warning('didnt find any time in file')
1440
1026
  self.start_time = None
1441
- return None
1027
+ return None ########################################################
1442
1028
  true_sr = self._compute_true_samplerate(
1443
1029
  time_around_beginning,
1444
1030
  time_around_end)
@@ -1454,60 +1040,322 @@ class Recording:
1454
1040
  logger.debug('recording started at %s'%start_UTC)
1455
1041
  self.start_time = start_UTC
1456
1042
  self.sync_position = time_around_beginning['pulse at']
1457
- if self.is_audio():
1458
- self.sound_without_YaLTC = self._strip_yaltc()
1043
+ # if self.is_audio():
1044
+ # self.valid_sound = self.AVpath
1459
1045
  return start_UTC
1460
1046
 
1461
- def _strip_yaltc(self):
1462
- """
1463
- Returns a tempfile.NamedTemporaryFile of sound file stripped
1464
- of YaLTC channel and of any silent channel. Criteria for
1465
- silence is sox stats "RMS lev db" < DB_RMS_SILENCE_SOX
1466
-
1467
- Sets self.silent_channels
1047
+ def _find_timed_tracks_(self, tracks_file) -> device_scanner.Tracks:
1048
+ """
1049
+ Look for any ISO 8601 timestamp e.g.: 2007-04-05T14:30Z
1050
+ and choose the right chunk according to Recording.start_time
1051
+ """
1052
+ file=open(tracks_file,"r")
1053
+ whole_txt = file.read()
1054
+ tracks_lines = []
1055
+ for l in whole_txt.splitlines():
1056
+ after_sharp = l.split('#')[0]
1057
+ if len(after_sharp) > 0:
1058
+ tracks_lines.append(after_sharp)
1059
+ logger.debug('file %s filtered lines:\n%s'%(tracks_file,
1060
+ pformat(tracks_lines)))
1061
+ def _seems_timestamp(line):
1062
+ # will validate format later with datetime.fromisoformat()
1063
+ m = re.match(r'ts=(.*)', line)
1064
+ # m = re.match(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z', line)
1065
+ if m != None:
1066
+ return m.groups()[0]
1067
+ else:
1068
+ return None
1069
+ chunks = []
1070
+ new_chunk = []
1071
+ timestamps_str = []
1072
+ for line in tracks_lines:
1073
+ timestamp_candidate = _seems_timestamp(line)
1074
+ if timestamp_candidate != None:
1075
+ logger.debug(f'timestamp: {line}')
1076
+ timestamps_str.append(timestamp_candidate)
1077
+ chunks.append(new_chunk)
1078
+ new_chunk = []
1079
+ else:
1080
+ new_chunk.append(line)
1081
+ chunks.append(new_chunk)
1082
+ logger.debug(f'chunks {chunks}, timestamps_str {timestamps_str}')
1083
+ str_frmt = '%Y-%m-%dT%H:%MZ'
1084
+ # from strings to datetime instances
1085
+ timestamps = []
1086
+ for dtstr in timestamps_str:
1087
+ try:
1088
+ ts = datetime.fromisoformat(dtstr)
1089
+ except:
1090
+ print(f'Error: in file {tracks_file},\ntimestamp {dtstr} is ill formatted, bye.')
1091
+ sys.exit(0)
1092
+ timestamps.append(ts)
1093
+ # timestamps = [datetime.strptime(dtstr, str_frmt, tzinfo=timezone.utc)
1094
+ # for dtstr in timestamps]
1095
+ logger.debug(f'datetime timestamps {timestamps}')
1096
+ # input validations, check order:
1097
+ if sorted(timestamps) != timestamps:
1098
+ print(f'Error in {tracks_file}\nSome timestamps are not in ascending order:\n')
1099
+ multi_lines = "\n".join(tracks_lines)
1100
+ print(f'{multi_lines}, Bye.')
1101
+ sys.exit(0)
1102
+ time_ranges = [t2-t1 for t1,t2 in zip(timestamps, timestamps[1:])]
1103
+ logger.debug(f'time_ranges {time_ranges} ')
1104
+ # check times between timestamps are realistic
1105
+ if timedelta(0) in time_ranges:
1106
+ print(f'Error in {tracks_file}\nSome timestamps are repeating:\n')
1107
+ multi_lines = "\n".join(tracks_lines)
1108
+ print(f'{multi_lines}, Bye.')
1109
+ sys.exit(0)
1110
+ if any([ dt < timedelta(minutes=2) for dt in time_ranges]):
1111
+ print(f'Warning in {tracks_file}\nSome timestamps are spaced by less than 2 minutes:\n')
1112
+ print("\n".join(tracks_lines))
1113
+ print(f'If this is an error, correct and rerun. For now will continue...')
1114
+ if any([ dt > timedelta(days=1) for dt in time_ranges]):
1115
+ print(f'Warning in {tracks_file}\nSome timestamps are spaced by more than 24 hrs:\n')
1116
+ print("\n".join(tracks_lines))
1117
+ print(f'If this is an error, correct and rerun. For now will continue...')
1118
+ # add 'infinite in future' to time stamps for time matching
1119
+ future = datetime.max
1120
+ future = future.replace(tzinfo=timezone.utc)
1121
+ timestamps.append(future)
1122
+ # zip it with chunks
1123
+ timed_chunks = list(zip(chunks,timestamps))
1124
+ logger.debug(f'timed_chunks\n{pformat(timed_chunks)} ')
1125
+ logger.debug(f'will find match with {self.start_time}')
1126
+ # for tch in timed_chunks:
1127
+ # print(tch[1], self.start_time)
1128
+ # print(tch[1] > self.start_time)
1129
+ idx = 0
1130
+ # while timed_chunks[idx][1] < self.start_time:
1131
+ # logger.debug(f'does {timed_chunks[idx][1]} < {self.start_time} ?')
1132
+ # idx += 1
1133
+ max_idx = len(timed_chunks) - 1
1134
+ while True:
1135
+ if timed_chunks[idx][1] > self.start_time or idx == max_idx:
1136
+ break
1137
+ idx += 1
1138
+ chunk_idx = idx
1139
+ logger.debug(f'chunk_idx {chunk_idx}')
1140
+ right_chunk = chunks[chunk_idx]
1141
+ logger.debug(f'found right chunk {right_chunk}')
1142
+ tracks_instance = self._parse_trx_lines(right_chunk, tracks_file)
1143
+ return tracks_instance
1144
+
1145
+ def _parse_trx_lines(self, tracks_lines_with_spaces, tracks_file):
1146
+ """
1147
+ read track names for naming separated ISOs
1148
+ from tracks_file.
1149
+
1150
+ tokens looked for: mix; mix L; mix R; 0 and TC
1151
+
1152
+ repeating "mic*" pattern signals a stereo track
1153
+ and entries will correspondingly panned into
1154
+ a stero mix named mixL.wav and mixL.wav
1155
+
1156
+ mic L # spaces are ignored |
1157
+ mic R | stereo pair
1158
+ micB L
1159
+ micB R
1160
+
1161
+ Returns: a Tracks instance:
1162
+ # track numbers start at 1 for first track (as needed by sox)
1163
+ ttc: int # track number of TicTacCode signal
1164
+ unused: list # of unused tracks
1165
+ stereomics: list # of stereo mics track tuples (Lchan#, Rchan#)
1166
+ mix: list # of mixed tracks, if a pair, order is L than R
1167
+ others: list #of all other tags: (tag, track#) tuples
1168
+ rawtrx: list # list of strings read from file
1169
+ error_msg: str # 'None' if none
1170
+ e.g.: Tracks( ttc=2,
1171
+ unused=[],
1172
+ stereomics=[('mic', (4, 3)), ('mic2', (6, 5))],
1173
+ mix=[], others=[('clics', 1)],
1174
+ rawtrx=['clics', 'TC', 'micL', 'micR', 'mic2L;1000', 'mic2R;1000', 'mixL', 'mixR'],
1175
+ error_msg=None, lag_values=[None, None, None, None, '1000', '1000', None, None])
1176
+ """
1177
+ def _WOspace(chaine):
1178
+ ch = [c for c in chaine if c != ' ']
1179
+ return ''.join(ch)
1180
+ tracks_lines = [_WOspace(l) for l in tracks_lines_with_spaces if len(l) > 0 ]
1181
+ rawtrx = [l for l in tracks_lines_with_spaces if len(l) > 0 ]
1182
+ # add index with tuples, starting at 1
1183
+ logger.debug('tracks_lines whole: %s'%tracks_lines)
1184
+ def _detach_lag_value(line):
1185
+ # look for ";number" ending any line, returns a two-list
1186
+ splt = line.split(';')
1187
+ if len(splt) == 1:
1188
+ splt += [None]
1189
+ if len(splt) != 2:
1190
+ # error
1191
+ print('\nText error in %s, line %s has too many ";"'%(
1192
+ tracks_file, line))
1193
+ return splt
1194
+ tracks_lines, lag_values = zip(*[_detach_lag_value(l) for l
1195
+ in tracks_lines])
1196
+ lag_values = [e for e in lag_values] # from tuple to list
1197
+ # logger.debug('tracks_lines WO lag: %s'%tracks_lines)
1198
+ tracks_lines = [l.lower() for l in tracks_lines]
1199
+ logger.debug('tracks_lines lower case: %s'%tracks_lines)
1200
+ # print(lag_values)
1201
+ logger.debug('lag_values: %s'%lag_values)
1202
+ tagsWOl_r = [e[:-1] for e in tracks_lines] # skip last letter
1203
+ logger.debug('tags WO LR letter %s'%tagsWOl_r)
1204
+ # find idx of start of pairs
1205
+ # ['clics', 'TC', 'micL', 'micR', 'mic2L', 'mic2R', 'mixL', 'mixR']
1206
+ def _micOrmix(a,b):
1207
+ # test if same and mic mic or mix mix
1208
+ if len(a) == 0:
1209
+ return False
1210
+ return (a == b) and (a in 'micmix')
1211
+ pair_idx_start =[i for i, same in enumerate([_micOrmix(a,b) for a,b
1212
+ in zip(tagsWOl_r,tagsWOl_r[1:])]) if same]
1213
+ logger.debug('pair_idx_start %s'%pair_idx_start)
1214
+ def LR_OK(idx):
1215
+ # in tracks_lines, check if idx ends a LR pair
1216
+ # delays, if any, have been removed
1217
+ a = tracks_lines[idx][-1]
1218
+ b = tracks_lines[idx+1][-1]
1219
+ return a+b in ['lr', 'rl']
1220
+ LR_OKs = [LR_OK(p) for p in pair_idx_start]
1221
+ logger.debug('LR_OKs %s'%LR_OKs)
1222
+ if not all(LR_OKs):
1223
+ print('\nError in %s'%tracks_file)
1224
+ print('Some tracks are paired but not L and R: %s'%rawtrx)
1225
+ print('quitting...')
1226
+ quit()
1227
+ complete_pairs_idx = pair_idx_start + [i + 1 for i in pair_idx_start]
1228
+ singles = set(range(len(tracks_lines))).difference(complete_pairs_idx)
1229
+ logger.debug('complete_pairs_idx %s'%complete_pairs_idx)
1230
+ logger.debug('singles %s'%singles)
1231
+ singles_tag = [tracks_lines[i] for i in singles]
1232
+ logger.debug('singles_tag %s'%singles_tag)
1233
+ n_tc_token = sum([t == 'tc' for t in singles_tag])
1234
+ logger.debug('n tc tags %s'%n_tc_token)
1235
+ if n_tc_token == 0:
1236
+ print('\nError in %s'%tracks_file)
1237
+ print('with %s'%rawtrx)
1238
+ print('no TC track found, quitting...')
1239
+ quit()
1240
+ if n_tc_token > 1:
1241
+ print('\nError in %s'%tracks_file)
1242
+ print('with %s'%rawtrx)
1243
+ print('more than one TC track, quitting...')
1244
+ quit()
1245
+ output_tracks = device_scanner.Tracks(None,[],[],[],[],rawtrx,None,[])
1246
+ output_tracks.ttc = tracks_lines.index('tc') + 1 # 1st = 1
1247
+ logger.debug('ttc_chan %s'%output_tracks.ttc)
1248
+ zeroed = [i+1 for i, t in enumerate(tracks_lines) if t == '0']
1249
+ logger.debug('zeroed %s'%zeroed)
1250
+ output_tracks.unused = zeroed
1251
+ output_tracks.others = [(st, tracks_lines.index(st)+1) for st
1252
+ in singles_tag if st not
1253
+ in ['tc', 'monomix', '0']]
1254
+ logger.debug('output_tracks.others %s'%output_tracks.others)
1255
+ # check for monomix
1256
+ if 'monomix' in tracks_lines:
1257
+ output_tracks.mix = [tracks_lines.index('monomix')+1]
1258
+ else:
1259
+ output_tracks.mix = []
1260
+ # check for stereo mix
1261
+ def _findLR(i_first):
1262
+ # returns L R indexes (+1 for sox non zero based indexing)
1263
+ i_2nd = i_first + 1
1264
+ a = tracks_lines[i_first][-1] # l|r at end
1265
+ b = tracks_lines[i_2nd][-1] # l|r at end
1266
+ if a == 'l':
1267
+ if b == 'r':
1268
+ # sequence is mixL mixR
1269
+ return i_first+1, i_2nd+1
1270
+ else:
1271
+ print('\nError in %s'%tracks_file)
1272
+ print('with %s'%rawtrx)
1273
+ print('can not find stereo mix')
1274
+ quit()
1275
+ elif a == 'r':
1276
+ if b == 'l':
1277
+ # sequence is mixR mixL
1278
+ return i_2nd+1, i_first+1
1279
+ else:
1280
+ print('\nError in %s'%tracks_file)
1281
+ print('with %s'%rawtrx)
1282
+ print('can not find stereo mix')
1283
+ quit()
1284
+ logger.debug('for now, output_tracks.mix %s'%output_tracks.mix)
1285
+ mix_pair = [p for p in pair_idx_start if tracks_lines[p][1:] == 'mix']
1286
+ if len(mix_pair) == 1:
1287
+ # one stereo mix, remove it from other pairs
1288
+ i = mix_pair[0]
1289
+ LR_pair = _findLR(i)
1290
+ logger.debug('LR_pair %s'%str(LR_pair))
1291
+ pair_idx_start.remove(i)
1292
+ # consistency check
1293
+ if output_tracks.mix != []:
1294
+ # already found a mono mix above!
1295
+ print('\nError in %s'%tracks_file)
1296
+ print('with %s'%rawtrx)
1297
+ print('found a mono mix AND a stereo mix')
1298
+ quit()
1299
+ output_tracks.mix = LR_pair
1300
+ logger.debug('finally, output_tracks.mix %s'%str(output_tracks.mix))
1301
+ logger.debug('remaining pairs %s'%pair_idx_start)
1302
+ # those are stereo pairs
1303
+ stereo_pairs = []
1304
+ for first_in_pair in pair_idx_start:
1305
+ suffix = tracks_lines[first_in_pair][:-1]
1306
+ stereo_pairs.append((suffix, _findLR(first_in_pair)))
1307
+ logger.debug('stereo_pairs %s'%stereo_pairs)
1308
+ output_tracks.stereomics = stereo_pairs
1309
+ logger.debug('finished: %s'%output_tracks)
1310
+ return output_tracks
1311
+
1312
+ def load_track_info(self):
1313
+ """
1314
+ If audio rec, look for eventual track names in TRACKSFILE file, stored inside the
1315
+ recorder folder alongside the audio files. If there, store a Tracks
1316
+ object into Recording.device.tracks .
1468
1317
  """
1469
- input_file = _pathname(self.AVpath)
1470
- # building dict according to pysox.remix format.
1471
- # eg: 4 channels with YaLTC_channel at #2 returns {1: [1], 2: [3], 3: [4]}
1472
- # ie the number of channels drops by one and chan 2 is missing
1473
- YaLTC_channel = self.YaLTC_channel + 1 # sox channels start at 1
1474
- n_channels = sox.file_info.channels(input_file) # eg 2
1475
- args = ['sox', input_file, '-n', 'channels', 'stats', '-w', '0.01']
1476
- _, _, stat_output = sox.core.sox(args)
1477
- sox_RMS_lev_dB = stat_output.split('\n')[5].split()[4:]
1478
- logger.debug('Sox RMS %s, n_channels %i'%(sox_RMS_lev_dB, n_channels))
1479
- self.silent_channels = [i + 1 for i,db in enumerate(sox_RMS_lev_dB)
1480
- if float(db) < DB_RMS_SILENCE_SOX]
1481
- logger.debug('silent chans (1st = #1) %s'%self.silent_channels)
1482
- chan_list_out = range(1, n_channels) # eg [1,2,3]
1483
- all_channels = range(1, n_channels + 1) # eg [1,2,3,4]
1484
- excluded_chans = self.silent_channels + [YaLTC_channel]
1485
- logger.debug('excluded chans %s'%excluded_chans)
1486
- kept_chans = [[n] for n in all_channels if n not in excluded_chans]
1487
- # eg [[1], [3], [4]]
1488
- sox_remix_dict = dict(zip(chan_list_out, kept_chans))
1489
- # {1: [1], 2: [3], 3: [4]}
1490
- output_fh = tempfile.NamedTemporaryFile(suffix='.wav', delete=DEL_TEMP)
1491
- out_file = _pathname(output_fh)
1492
- logger.debug('sox in and out files: %s %s'%(input_file, out_file))
1493
- # sox_transform.set_output_format(channels=1)
1494
- sox_transform = sox.Transformer()
1495
- sox_transform.remix(sox_remix_dict)
1496
- logger.debug('sox remix transform: %s'%sox_transform)
1497
- logger.debug('sox remix dict: %s'%sox_remix_dict)
1498
- status = sox_transform.build(input_file, out_file, return_output=True )
1499
- logger.debug('sox.build exit code %s'%str(status))
1500
- p = Popen('ffprobe %s -hide_banner'%input_file,
1501
- shell=True, stdout=PIPE, stderr=PIPE)
1502
- stdout, stderr = p.communicate()
1503
- logger.debug('remixed input_file ffprobe:\n%s'%(stdout +
1504
- stderr).decode('utf-8'))
1505
- p = Popen('ffprobe %s -hide_banner'%out_file,
1506
- shell=True, stdout=PIPE, stderr=PIPE)
1507
- stdout, stderr = p.communicate()
1508
- logger.debug('remixed out_file ffprobe:\n%s'%(stdout +
1509
- stderr).decode('utf-8'))
1510
- return output_fh
1318
+ if self.is_video():
1319
+ return
1320
+ source_audio_folder = self.device.folder
1321
+ tracks_file = source_audio_folder/TRACKSFILE
1322
+ track_names = False
1323
+ # a_recording = [m for m in self.found_media_files
1324
+ # if m.device == device][0]
1325
+ # logger.debug('a_recording for device %s : %s'%(device, a_recording))
1326
+ nchan = self.get_audio_channels_nbr()
1327
+ # nchan = sox.file_info.channels(str(a_recording.path))
1328
+ if os.path.isfile(tracks_file):
1329
+ logger.debug('found file: %s'%(TRACKSFILE))
1330
+ tracks = self._find_timed_tracks_(tracks_file)
1331
+ if tracks.error_msg:
1332
+ print('\nError parsing [gold1]%s[/gold1] file: %s, quitting.\n'%
1333
+ (tracks_file, tracks.error_msg))
1334
+ sys.exit(1)
1335
+ logger.debug('parsed tracks %s'%tracks)
1336
+ ntracks = 2*len(tracks.stereomics)
1337
+ ntracks += len(tracks.mix)
1338
+ ntracks += len(tracks.unused)
1339
+ ntracks += len(tracks.others)
1340
+ ntracks += 1 # for ttc track
1341
+ logger.debug(' n chan: %i n tracks file: %i'%(nchan, ntracks))
1342
+ if ntracks != nchan:
1343
+ print('\nError parsing %s content'%tracks_file)
1344
+ print('incoherent number of tracks, %i vs %i quitting\n'%
1345
+ (nchan, ntracks))
1346
+ sys.exit(1)
1347
+ err_msg = tracks.error_msg
1348
+ if err_msg != None:
1349
+ print('\nError, quitting: in file %s, %s'%(tracks_file, err_msg))
1350
+ raise Exception
1351
+ else:
1352
+ self.device.tracks = tracks
1353
+ logger.debug('for rec %s'%self)
1354
+ logger.debug('tracks object: %s'%self.device.tracks)
1355
+ return
1356
+ else:
1357
+ logger.debug('no tracks.txt file found')
1358
+ return None
1511
1359
 
1512
1360
  def _ffprobe_audio_stream(self):
1513
1361
  streams = self.probe['streams']
@@ -1545,28 +1393,30 @@ class Recording:
1545
1393
  ppm = - (nominal/true - 1) * 1e6
1546
1394
  return int(ppm)
1547
1395
 
1548
- def get_speed_ratio(self, ref_recording):
1396
+ def get_speed_ratio(self, videoclip):
1397
+ # ratio between real samplerates of audio and videoclip
1549
1398
  nominal = self.get_samplerate()
1550
1399
  true = self.true_samplerate
1551
1400
  ratio = true/nominal
1552
- nominal_ref = ref_recording.get_samplerate()
1553
- true_ref = ref_recording.true_samplerate
1554
- ratio_ref = true_ref/nominal_ref
1401
+ nominal_vid = videoclip.get_samplerate()
1402
+ true_ref = videoclip.true_samplerate
1403
+ ratio_ref = true_ref/nominal_vid
1555
1404
  return ratio/ratio_ref
1556
1405
 
1557
1406
  def get_samplerate(self):
1558
- # return int samplerate (nominal)
1407
+ # returns int samplerate (nominal)
1559
1408
  string = self._ffprobe_audio_stream()['sample_rate']
1560
- return eval(string) # eg eval(24000/1001)
1409
+ logger.debug('ffprobe samplerate: %s'%string)
1410
+ return eval(string)
1561
1411
 
1562
1412
  def get_framerate(self):
1563
- # return int samplerate (nominal)
1564
1413
  string = self._ffprobe_video_stream()['avg_frame_rate']
1565
1414
  return eval(string) # eg eval(24000/1001)
1566
1415
 
1567
- def get_timecode(self, with_offset=0):
1568
- # returns a HHMMSS:FR string
1416
+ def get_start_timecode_string(self, with_offset=0):
1417
+ # returns a HH:MM:SS:FR string
1569
1418
  start_datetime = self.get_start_time()
1419
+ # logger.debug('CLI_offset %s'%CLI_offset)
1570
1420
  logger.debug('start_datetime %s'%start_datetime)
1571
1421
  start_datetime += timedelta(seconds=with_offset)
1572
1422
  logger.debug('shifted start_datetime %s (offset %f)'%(start_datetime,
@@ -1602,20 +1452,20 @@ class Recording:
1602
1452
 
1603
1453
  def has_audio(self):
1604
1454
  if not self.probe:
1605
- return False
1455
+ return False #######################################################
1606
1456
  streams = self.probe['streams']
1607
1457
  codecs = [stream['codec_type'] for stream in streams]
1608
1458
  return 'audio' in codecs
1609
1459
 
1610
1460
  def get_audio_channels_nbr(self):
1611
1461
  if not self.has_audio():
1612
- return 0
1462
+ return 0 ###########################################################
1613
1463
  audio_str = self._ffprobe_audio_stream()
1614
1464
  return audio_str['channels']
1615
1465
 
1616
1466
  def is_video(self):
1617
1467
  if not self.probe:
1618
- return False
1468
+ return False #######################################################
1619
1469
  streams = self.probe['streams']
1620
1470
  codecs = [stream['codec_type'] for stream in streams]
1621
1471
  return 'video' in codecs
@@ -1623,15 +1473,17 @@ class Recording:
1623
1473
  def is_audio(self):
1624
1474
  return not self.is_video()
1625
1475
 
1626
- def _read_sound_find_YaLTC(self, time_where, chunk_length):
1476
+ def _find_TicTacCode(self, time_where, chunk_length):
1627
1477
  """
1628
- Loads audio data reading from self.AVpath;
1629
- Split data into channels if stereo;
1630
- Send this data to Decoder object with set_sound_extract_and_sr() to find
1631
- which channel contains a YaLTC track and sets YaLTC_channel
1632
- accordingly: a tuple (#file, #chan). On exit, self.decoder.sound_extract
1633
- contains YaLTC data ready to be demodulated. If not, self.YaLTC_channel
1634
- is set to None.
1478
+ Extracts a chunk from Recording.audio_data and sends it to
1479
+ Recording.decoder object with set_sound_extract_and_sr() to find which
1480
+ channel contains a TicTacCode track and sets TicTacCode_channel
1481
+ accordingly (index of channel). On exit, self.decoder.sound_extract
1482
+ contains TicTacCode data ready to be demodulated. If not,
1483
+ self.TicTacCode_channel is set to None.
1484
+
1485
+ If this has been called before (checking self.TicTacCode_channel) then
1486
+ is simply read the audio in and calls set_sound_extract_and_sr().
1635
1487
 
1636
1488
  Args:
1637
1489
  time_where : float
@@ -1643,7 +1495,8 @@ class Recording:
1643
1495
  self.decoder.set_sound_extract_and_sr()
1644
1496
 
1645
1497
  Sets:
1646
- self.YaLTC_channel
1498
+ self.TicTacCode_channel = index of TTC chan
1499
+ self.device.ttc = index of TTC chan
1647
1500
 
1648
1501
  Returns:
1649
1502
  this Recording instance
@@ -1653,56 +1506,52 @@ class Recording:
1653
1506
  decoder = self.decoder
1654
1507
  if decoder:
1655
1508
  decoder.clear_decoder()
1656
- # decoder.cached_convolution_fit['is clean'] = False
1657
1509
  if not self.has_audio():
1658
- self.YaLTC_channel = None
1659
- return
1660
- logger.debug('will read around %.2f sec'%time_where)
1661
- dryrun = (ffmpeg
1662
- .input(str(path), ss=time_where, t=chunk_length)
1663
- .output('pipe:', format='s16le', acodec='pcm_s16le')
1664
- .get_args())
1665
- dryrun = ' '.join(dryrun)
1666
- logger.debug('using ffmpeg-python built args to pipe wav file into numpy array:\nffmpeg %s'%dryrun)
1667
- try:
1668
- out, _ = (ffmpeg
1669
- .input(str(path), ss=time_where, t=chunk_length)
1670
- .output('pipe:', format='s16le', acodec='pcm_s16le')
1671
- .global_args("-loglevel", "quiet")
1672
- .global_args("-nostats")
1673
- .global_args("-hide_banner")
1674
- .run(capture_stdout=True))
1675
- data = np.frombuffer(out, np.int16)
1676
- except ffmpeg.Error as e:
1677
- print('error',e.stderr)
1678
- sound_extract_position = int(self.get_samplerate()*time_where) # from sec to samples
1679
- n_chan = self.get_audio_channels_nbr()
1680
- if n_chan == 1 and not self.is_video():
1681
- logger.warning('file is sound mono')
1682
- all_channels_data = data.reshape(int(len(data)/n_chan),n_chan).T
1683
- for i_chan, chan_dat in enumerate(all_channels_data):
1684
- logger.debug('testing chan %i'%i_chan)
1510
+ self.TicTacCode_channel = None
1511
+ return #############################################################
1512
+ sound_data_var = np.std(self.audio_data)
1513
+ sound_extract_position = int(self.get_samplerate()*time_where)
1514
+ logger.debug('extracting sound at %i with variance %f'%(
1515
+ sound_extract_position,
1516
+ sound_data_var))
1517
+ if self.TicTacCode_channel == None:
1518
+ logger.debug('first call, will loop through all %i channels'%len(
1519
+ self.audio_data))
1520
+ for i_chan, chan_dat in enumerate(self.audio_data):
1521
+ logger.debug('testing chan %i'%i_chan)
1522
+ start_idx = round(time_where*self.get_samplerate())
1523
+ extract_length = round(chunk_length*self.get_samplerate())
1524
+ end_idx = start_idx + extract_length
1525
+ extract_audio_data = chan_dat[start_idx:end_idx]
1526
+ decoder.set_sound_extract_and_sr(
1527
+ extract_audio_data,
1528
+ self.get_samplerate(),
1529
+ sound_extract_position
1530
+ )
1531
+ if decoder.extract_seems_TicTacCode():
1532
+ self.TicTacCode_channel = i_chan
1533
+ self.device.ttc = i_chan
1534
+ logger.debug('found TicTacCode channel: chan #%i'%
1535
+ self.TicTacCode_channel)
1536
+ return self ################################################
1537
+ # end of loop: none found
1538
+ # self.TicTacCode_channel = None # was None already
1539
+ logger.warning('found no TicTacCode channel')
1540
+ else:
1541
+ logger.debug('been here before, TTC chan is %i'%
1542
+ self.TicTacCode_channel)
1543
+ start_idx = round(time_where*self.get_samplerate())
1544
+ extract_length = round(chunk_length*self.get_samplerate())
1545
+ end_idx = start_idx + extract_length
1546
+ chan_dat = self.audio_data[self.TicTacCode_channel]
1547
+ extract_audio_data = chan_dat[start_idx:end_idx]
1685
1548
  decoder.set_sound_extract_and_sr(
1686
- chan_dat,
1549
+ extract_audio_data,
1687
1550
  self.get_samplerate(),
1688
1551
  sound_extract_position
1689
1552
  )
1690
- if decoder.extract_seems_YaLTC():
1691
- self.YaLTC_channel = i_chan
1692
- logger.debug('find YaLTC channel, chan #%i'%
1693
- self.YaLTC_channel)
1694
- return self
1695
- # end of loop: none found
1696
- self.YaLTC_channel = None
1697
- logger.warning('found no YaLTC channel')
1698
1553
  return self
1699
1554
 
1700
- def seems_to_have_YaLTC_at_beginning(self):
1701
- if self.probe is None:
1702
- return False
1703
- self._read_sound_find_YaLTC(TRIAL_TIMES[0][0], SOUND_EXTRACT_LENGTH)
1704
- return self.YaLTC_channel is not None
1705
-
1706
1555
  def does_overlap_with_time(self, time):
1707
1556
  A1, A2 = self.get_start_time(), self.get_end_time()
1708
1557
  # R1, R2 = rec.get_start_time(), rec.get_end_time()
@@ -1747,24 +1596,3 @@ class Recording:
1747
1596
  )
1748
1597
  return clip
1749
1598
 
1750
- import argparse
1751
-
1752
- if __name__ == '__main__':
1753
- parser = argparse.ArgumentParser()
1754
- parser.add_argument("mediafile", nargs="?", help="path of media file with YaLTC")
1755
- parser.add_argument('-v', action='store_true')
1756
- args = parser.parse_args()
1757
- if not args.v:
1758
- logger.remove()
1759
- fn = args.mediafile
1760
- a_recording = Recording(fn)
1761
- start_time = a_recording.get_start_time()
1762
- if start_time is not None:
1763
- print('%s started at %s'%(fn, start_time))
1764
- else:
1765
- print("can't find start time for %s"%fn)
1766
-
1767
-
1768
-
1769
-
1770
-