tictacsync 0.1a14__py3-none-any.whl → 1.4.4b0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of tictacsync might be problematic. Click here for more details.
- tictacsync/device_scanner.py +362 -169
- tictacsync/entry.py +240 -135
- tictacsync/mamconf.py +157 -0
- tictacsync/mamdav.py +642 -0
- tictacsync/mamreap.py +481 -0
- tictacsync/mamsync.py +343 -0
- tictacsync/multi2polywav.py +21 -14
- tictacsync/timeline.py +1126 -442
- tictacsync/yaltc.py +895 -1067
- tictacsync-1.4.4b0.dist-info/METADATA +118 -0
- tictacsync-1.4.4b0.dist-info/RECORD +16 -0
- tictacsync-1.4.4b0.dist-info/entry_points.txt +7 -0
- tictacsync/LTCcheck.py +0 -394
- tictacsync-0.1a14.dist-info/METADATA +0 -96
- tictacsync-0.1a14.dist-info/RECORD +0 -13
- tictacsync-0.1a14.dist-info/entry_points.txt +0 -4
- {tictacsync-0.1a14.dist-info → tictacsync-1.4.4b0.dist-info}/LICENSE +0 -0
- {tictacsync-0.1a14.dist-info → tictacsync-1.4.4b0.dist-info}/WHEEL +0 -0
- {tictacsync-0.1a14.dist-info → tictacsync-1.4.4b0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: tictacsync
|
|
3
|
+
Version: 1.4.4b0
|
|
4
|
+
Summary: commands for syncing audio video recordings
|
|
5
|
+
Home-page: https://tictacsync.org/
|
|
6
|
+
Author: Raymond Lutz
|
|
7
|
+
Author-email: lutzrayblog@mac.com
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Operating System :: POSIX
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Capture/Recording
|
|
19
|
+
Classifier: Topic :: Multimedia :: Video :: Non-Linear Editor
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: sox >=1.4.1
|
|
24
|
+
Requires-Dist: ffmpeg-python >=0.2.0
|
|
25
|
+
Requires-Dist: loguru >=0.6.0
|
|
26
|
+
Requires-Dist: matplotlib >=3.7.1
|
|
27
|
+
Requires-Dist: numpy >=1.24.3
|
|
28
|
+
Requires-Dist: rich >=10.12.0
|
|
29
|
+
Requires-Dist: lmfit
|
|
30
|
+
Requires-Dist: scikit-image
|
|
31
|
+
Requires-Dist: scipy >=1.10.1
|
|
32
|
+
Requires-Dist: platformdirs
|
|
33
|
+
|
|
34
|
+
# tictacsync
|
|
35
|
+
|
|
36
|
+
## Warning: this is at beta stage
|
|
37
|
+
|
|
38
|
+
Unfinished sloppy code ahead, but should run without errors. Some functionalities are still missing. Don't run the code without parental supervision. Suggestions and enquiries are welcome via the [lists hosted on sourcehut](https://sr.ht/~proflutz/TicTacSync/lists).
|
|
39
|
+
|
|
40
|
+
## Description
|
|
41
|
+
|
|
42
|
+
`tictacsync` is a python script to sync, cut and join audio files against camera files shot using a specific hardware timecode generator
|
|
43
|
+
called [Tic Tac Sync](https://tictacsync.org). The timecode is named TicTacCode and should be recorded on a scratch
|
|
44
|
+
track on each device for `tictacsync` to work.
|
|
45
|
+
## Status
|
|
46
|
+
|
|
47
|
+
Feature complete! `tictacsync` scans for audio video files and then merges overlapping audio and video recordings, It
|
|
48
|
+
|
|
49
|
+
* Decodes the TicTacCode audio track alongside your audio tracks
|
|
50
|
+
* Establishes UTC start time (and end time) within 100 μs!
|
|
51
|
+
* Syncs, cuts and joins any concurrent audio to camera files (using `FFmpeg`)
|
|
52
|
+
* Processes _multiple_ audio recorders
|
|
53
|
+
* Corrects device clock drift so _both_ ends coincide (thanks to `sox`)
|
|
54
|
+
* Sets video metadata TC of multicam files for NLE timeline alignement
|
|
55
|
+
* Writes _synced_ ISO files with dedicated file names declared in `tracks.txt`
|
|
56
|
+
* Produces nice plots.
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
This uses the [python interpreter](https://www.python.org/downloads/) and multiple packages (so you need python 3 + pip). Also, you need to install two non-python command line executables: [ffmpeg](https://windowsloop.com/install-ffmpeg-windows-10/) and [sox](https://sourceforge.net/projects/sox/files/). Make sure those are _accessible through your `PATH` system environment variable_.
|
|
62
|
+
Then pip install the syncing program:
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
> pip install tictacsync
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
This should install python dependencies _and_ the `tictacsync` command.
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
Download multiple sample files [here](https://nuage.lutz.quebec/s/4jw4xgqysLPS8EQ/download/dailies1_3.zip) (700+ MB, sorry) unzip and run:
|
|
72
|
+
|
|
73
|
+
> tictacsync dailies/loose
|
|
74
|
+
The program `tictacsync` will recursively scan the directory given as argument, find all audio that coincide with any video and merge them into a subfolder named `SyncedMedia`. When the argument is an unique media file (not a directory), no syncing will occur but the decoded starting time will be printed to stdout:
|
|
75
|
+
|
|
76
|
+
> tictacsync dailies/loose/MVI_0024.MP4
|
|
77
|
+
|
|
78
|
+
Recording started at 2024-03-12 23:07:01.4281 UTC
|
|
79
|
+
true sample rate: 48000.736 Hz
|
|
80
|
+
first sync at 27450 samples in channel 0
|
|
81
|
+
N.B.: all results are precise to the displayed digits!
|
|
82
|
+
|
|
83
|
+
If shooting multicam, put clips in their respective directories (using the camera name as folder name) _and_ the audio under their own directory. `tictacsync` will detect that structured input and will generate multicam folders ready to be imported into your NLE (for now only DaVinci Resolve has been validated).
|
|
84
|
+
|
|
85
|
+
## Options
|
|
86
|
+
#### `-v`
|
|
87
|
+
|
|
88
|
+
For a very verbose output add the `-v` flag:
|
|
89
|
+
|
|
90
|
+
> tictacsync -v dailies/loose/MVI_0024.MP4
|
|
91
|
+
#### `--terse`
|
|
92
|
+
For a one line output (or to suppress the progress bars) use the `--terse` flag:
|
|
93
|
+
|
|
94
|
+
> tictacsync --terse dailies/loose/MVI_0024.MP4
|
|
95
|
+
dailies/loose/MVI_0024.MP4 UTC:2024-03-12 23:07:01.4281 pulse: 27450 in chan 0
|
|
96
|
+
#### `--isos`
|
|
97
|
+
|
|
98
|
+
Specifying `--isos` produces _synced_ ISO audio files: for each synced \<video-clip\> a directory named `<video-clip>_ISO` will contain a set of ISO audio files each of exact same length, padded or trimmed to coincide with the video start. After re-editing and re-mixing in your DAW of choice a `remergemix` command will resync the new audio with the video and _the new sound track will be updated on your NLE timeline_, _automagically_ on some NLEs or on command for [Davinci Resolve](https://www.niwa.nu/dr-scripts/).
|
|
99
|
+
|
|
100
|
+
> tictacsync --isos dailies/structured
|
|
101
|
+
#### `-p`
|
|
102
|
+
|
|
103
|
+
When called with the `-p` flag, zoomable plots will be produced for diagnostic purpose (close the plotting window for the 2nd one) and the decoded starting time will be output to stdin:
|
|
104
|
+
|
|
105
|
+
> tictacsync -p dailies/loose/MVI_0024.MP4
|
|
106
|
+
|
|
107
|
+
Typical first plot produced :
|
|
108
|
+
|
|
109
|
+

|
|
110
|
+
|
|
111
|
+
Typical second plot produced (note the 34 [FSK](https://en.wikipedia.org/wiki/Frequency-shift_keying) encoded bits `0010111101001111100110000110010000`):
|
|
112
|
+

|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
<!-- To run some tests, from top level `git cloned` dir:
|
|
116
|
+
|
|
117
|
+
cd tictacsync ; python -m pytest
|
|
118
|
+
Yes, the coverage is low. -->
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
tictacsync/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
tictacsync/device_scanner.py,sha256=YxA3_0O1ZPE1ZuD-OgD-dWhtTWE-LamDhqXXyLo3IMw,26132
|
|
3
|
+
tictacsync/entry.py,sha256=pcGuS4_o0o5dREpcccx1_X3w14PeHdQi5z1Ikzmhpwk,16198
|
|
4
|
+
tictacsync/mamconf.py,sha256=nfXTwabx-tJmBcpnDR4CRkFe9W4fudzfnbq_nHUg0qE,6424
|
|
5
|
+
tictacsync/mamdav.py,sha256=2we8tfIbJBtDMQdpZZVlCQ9hCQRMbKmV2aU3dDEUf2k,27457
|
|
6
|
+
tictacsync/mamreap.py,sha256=ej7Ap8nbVBCkfah2j5hrE7QBWuqL6Zm-OEsQpNK8mYg,21085
|
|
7
|
+
tictacsync/mamsync.py,sha256=mpoHUAuJWiZ1JfVCECiiSLH_HNdXNV1Z_VlUlJBlPcM,14565
|
|
8
|
+
tictacsync/multi2polywav.py,sha256=qJJhjwIgP1BCTpi2e0wfR95XlgZ2-EIqmefVh-jUBPc,7438
|
|
9
|
+
tictacsync/timeline.py,sha256=ykmB8EfnprQZoEHXRYzriASNWZ7bHfkmQ2-TR6gxZ6Y,75985
|
|
10
|
+
tictacsync/yaltc.py,sha256=xrgL7qokP1A7B_VF4W_BZcC7q9APSmYpmtWH8_t3VWc,68003
|
|
11
|
+
tictacsync-1.4.4b0.dist-info/LICENSE,sha256=ZAOPXLh1zlQAnhHUd7oLslKM01YZ5UiAu3STYjwIxck,1068
|
|
12
|
+
tictacsync-1.4.4b0.dist-info/METADATA,sha256=R8XV5GMFARw0IBnpbaZ19AAAejTr2RYbcw73zpyj9LM,5689
|
|
13
|
+
tictacsync-1.4.4b0.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
|
14
|
+
tictacsync-1.4.4b0.dist-info/entry_points.txt,sha256=0R8K6T0iUJGj87LDZ0vNO8pToshbkxrXZqTRgcjBlMk,244
|
|
15
|
+
tictacsync-1.4.4b0.dist-info/top_level.txt,sha256=eaCWG-BsYTRR-gLTJbK4RfcaXajr0gjQ6wG97MkGRrg,11
|
|
16
|
+
tictacsync-1.4.4b0.dist-info/RECORD,,
|
tictacsync/LTCcheck.py
DELETED
|
@@ -1,394 +0,0 @@
|
|
|
1
|
-
print('Loading modules')
|
|
2
|
-
import subprocess, io
|
|
3
|
-
import argparse, os, sys, ffmpeg
|
|
4
|
-
from loguru import logger
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from scipy.io import wavfile
|
|
7
|
-
import numpy as np
|
|
8
|
-
import matplotlib.pyplot as plt
|
|
9
|
-
from rich.progress import track, Progress
|
|
10
|
-
from pprint import pprint
|
|
11
|
-
from collections import deque
|
|
12
|
-
import wave
|
|
13
|
-
try:
|
|
14
|
-
from . import yaltc
|
|
15
|
-
except:
|
|
16
|
-
import yaltc
|
|
17
|
-
try:
|
|
18
|
-
from . import device_scanner
|
|
19
|
-
except:
|
|
20
|
-
import device_scanner
|
|
21
|
-
|
|
22
|
-
# LEVELMODE = 'over_noise_silence'
|
|
23
|
-
LEVELMODE = 'mean_silence_AFSK'
|
|
24
|
-
|
|
25
|
-
OFFSET_NXT_PULSE = 50 # samples
|
|
26
|
-
LENGTH_EXTRACT = int(14e-3 * 96000) # samples max freq
|
|
27
|
-
|
|
28
|
-
logger.level("DEBUG", color="<yellow>")
|
|
29
|
-
logger.remove()
|
|
30
|
-
# logger.add(sys.stdout, filter="tictacsync.LTCcheck")
|
|
31
|
-
# logger.add(sys.stdout, filter="tictacsync.yaltc")
|
|
32
|
-
|
|
33
|
-
def ppm(a,b):
|
|
34
|
-
return 1e6*(max(a,b)/min(a,b)-1)
|
|
35
|
-
|
|
36
|
-
class TCframe:
|
|
37
|
-
def __init__(self, string, max_FF):
|
|
38
|
-
# string is 'HH:MM:SS:FF' or ;|,|.
|
|
39
|
-
# max_FF is int for max frame number (hence fps-1)
|
|
40
|
-
string = string.replace('.',':').replace(';',':').replace(',',':')
|
|
41
|
-
ints = [int(e) for e in string.split(':')]
|
|
42
|
-
self.HH = ints[0]
|
|
43
|
-
self.MM = ints[1]
|
|
44
|
-
self.SS = ints[2]
|
|
45
|
-
self.FF = ints[3]
|
|
46
|
-
self.MAXFF = max_FF
|
|
47
|
-
|
|
48
|
-
def __repr__(self):
|
|
49
|
-
# return '%s-%s-%s-%s/%i'%(*self.ints(), self.MAXFF)
|
|
50
|
-
return '%02i-%02i-%02i-%02i'%self.ints()
|
|
51
|
-
|
|
52
|
-
def ints(self):
|
|
53
|
-
return (self.HH,self.MM,self.SS,self.FF)
|
|
54
|
-
|
|
55
|
-
def __eq__(self, other):
|
|
56
|
-
a,b,c,d = self.ints()
|
|
57
|
-
h,m,s,f = other.ints()
|
|
58
|
-
return a==h and b==m and c==s and d==f
|
|
59
|
-
|
|
60
|
-
def __sub__(self, tcf2):
|
|
61
|
-
# H1, M1, S1, F1 = self.ints()
|
|
62
|
-
# H2, M2, S2, F2 = tcf2.ints()
|
|
63
|
-
f1 = np.array(self.ints())
|
|
64
|
-
f2 = np.array(tcf2.ints())
|
|
65
|
-
HR, MR, SR, FR = f1 - f2
|
|
66
|
-
if FR < 0:
|
|
67
|
-
FR += self.MAXFF + 1
|
|
68
|
-
SR -= 1 # borrow
|
|
69
|
-
if SR < 0:
|
|
70
|
-
SR += 60
|
|
71
|
-
MR -= 1 # borrow
|
|
72
|
-
if MR < 0:
|
|
73
|
-
MR += 60
|
|
74
|
-
HR -= 1 # borrow
|
|
75
|
-
if HR < 0:
|
|
76
|
-
HR += 24 # underflow?
|
|
77
|
-
# logger.debug('%s %s'%(self.ints(), tcf2.ints()))
|
|
78
|
-
return TCframe('%02i:%02i:%02i:%02i'%(HR,MR,SR,FR), self.MAXFF)
|
|
79
|
-
|
|
80
|
-
def read_whole_audio_data(path):
|
|
81
|
-
dryrun = (ffmpeg
|
|
82
|
-
.input(str(path))
|
|
83
|
-
.output('pipe:', format='s16le', acodec='pcm_s16le')
|
|
84
|
-
.get_args())
|
|
85
|
-
dryrun = ' '.join(dryrun)
|
|
86
|
-
logger.debug('using ffmpeg-python built args to pipe wav file into numpy array:\nffmpeg %s'%dryrun)
|
|
87
|
-
try:
|
|
88
|
-
out, _ = (ffmpeg
|
|
89
|
-
.input(str(path))
|
|
90
|
-
.output('pipe:', format='s16le', acodec='pcm_s16le')
|
|
91
|
-
.global_args("-loglevel", "quiet")
|
|
92
|
-
.global_args("-nostats")
|
|
93
|
-
.global_args("-hide_banner")
|
|
94
|
-
.run(capture_stdout=True))
|
|
95
|
-
data = np.frombuffer(out, np.int16)
|
|
96
|
-
except ffmpeg.Error as e:
|
|
97
|
-
print('error',e.stderr)
|
|
98
|
-
with wave.open(path, 'rb') as f:
|
|
99
|
-
samplerate = f.getframerate()
|
|
100
|
-
n_chan = f.getnchannels()
|
|
101
|
-
all_channels_data = data.reshape(int(len(data)/n_chan),n_chan).T
|
|
102
|
-
return all_channels_data
|
|
103
|
-
|
|
104
|
-
def find_nearest_fps(value):
|
|
105
|
-
array = np.asarray([24, 25, 30])
|
|
106
|
-
idx = (np.abs(array - value)).argmin()
|
|
107
|
-
return array[idx]
|
|
108
|
-
|
|
109
|
-
def fps_rel_to_audio(frame_pos, samplerate):
|
|
110
|
-
_, first_frame_pos = frame_pos[0]
|
|
111
|
-
_, scnd_last_frame_pos = frame_pos[-2]
|
|
112
|
-
frame_duration = (scnd_last_frame_pos - first_frame_pos)/len(frame_pos[:-2]) # in audio samples
|
|
113
|
-
fps = float(samplerate) / frame_duration
|
|
114
|
-
return fps
|
|
115
|
-
|
|
116
|
-
# def HHMMSSFF_from_line(line):
|
|
117
|
-
# line = line.replace('.',':')
|
|
118
|
-
# line = line.replace(';',':')
|
|
119
|
-
# ll = line.split()[1].split(':')
|
|
120
|
-
# return [int(e) for e in ll]
|
|
121
|
-
|
|
122
|
-
def check_continuity_and_DF(LTC_frames_and_pos):
|
|
123
|
-
errors = []
|
|
124
|
-
DF_flag = False
|
|
125
|
-
oneframe = TCframe('00:00:00:01',None)
|
|
126
|
-
threeframes = TCframe('00:00:00:03',None)
|
|
127
|
-
last_two_TC = deque([], maxlen=2)
|
|
128
|
-
last_two_TC.append(LTC_frames_and_pos[0][0])
|
|
129
|
-
last_two_TC.append(LTC_frames_and_pos[1][0])
|
|
130
|
-
for frame, pos in track(LTC_frames_and_pos[2:],
|
|
131
|
-
description="Checking each frame increment"):
|
|
132
|
-
last_two_TC.append(frame)
|
|
133
|
-
past, now = last_two_TC
|
|
134
|
-
diff = now - past
|
|
135
|
-
if diff not in [oneframe, threeframes]:
|
|
136
|
-
errors.append((frame, pos))
|
|
137
|
-
continue
|
|
138
|
-
if diff == oneframe:
|
|
139
|
-
continue
|
|
140
|
-
if diff == threeframes:
|
|
141
|
-
# DF? check if it is 59:xx and minutes are not mult. of tens
|
|
142
|
-
if past.SS != 59 or now.MM%10 == 0:
|
|
143
|
-
errors.append((frame, pos))
|
|
144
|
-
DF_flag = True
|
|
145
|
-
return errors, DF_flag
|
|
146
|
-
|
|
147
|
-
def ltcdump_and_check(file, channel):
|
|
148
|
-
# returns list of anormal frames, a bool if TC is DF, fps and
|
|
149
|
-
# a list of tuples (frame => str, sample position in file => int) as
|
|
150
|
-
# determined by external util ltcdump https://github.com/x42/ltc-tools
|
|
151
|
-
process_list = ["ltcdump","-c %i"%channel, file]
|
|
152
|
-
logger.debug('process %s'%process_list)
|
|
153
|
-
proc = subprocess.Popen(process_list, stdout=subprocess.PIPE)
|
|
154
|
-
LTC_frames_and_pos = []
|
|
155
|
-
iter_io = io.TextIOWrapper(proc.stdout, encoding="utf-8")
|
|
156
|
-
next(iter_io) # ltcdump 1st line: User bits Timecode | Pos. (samples)
|
|
157
|
-
print()
|
|
158
|
-
try:
|
|
159
|
-
next(iter_io) # ltcdump 2nd line: #DISCONTINUITY
|
|
160
|
-
except StopIteration:
|
|
161
|
-
print('ltcdump has no output, is channel #%i really LTC?'%channel)
|
|
162
|
-
quit()
|
|
163
|
-
old = 0
|
|
164
|
-
for line in track(iter_io,
|
|
165
|
-
description=' Parsing ltcdump output'): # next ones
|
|
166
|
-
# print(line)
|
|
167
|
-
if line == '#DISCONTINUITY\n':
|
|
168
|
-
# print('#DISCONTINUITY!')
|
|
169
|
-
continue
|
|
170
|
-
user_bits, HHMMSSFF_str, _, start_sample, end_sample =\
|
|
171
|
-
line.split()
|
|
172
|
-
audio_position = int(end_sample)
|
|
173
|
-
# print(audio_position - old, end=' ')
|
|
174
|
-
# old = audio_position
|
|
175
|
-
# audio_position = int(start_sample)
|
|
176
|
-
tc = HHMMSSFF_str
|
|
177
|
-
LTC_frames_and_pos.append((tc, audio_position))
|
|
178
|
-
with wave.open(file, 'rb') as f:
|
|
179
|
-
samplerate = f.getframerate()
|
|
180
|
-
fps = fps_rel_to_audio(LTC_frames_and_pos, samplerate)
|
|
181
|
-
rounded_fps = round(fps)
|
|
182
|
-
LTC_frames_and_pos = [(TCframe(tc, rounded_fps-1), pos) for tc, pos in LTC_frames_and_pos]
|
|
183
|
-
errors, DF_flag = check_continuity_and_DF(LTC_frames_and_pos)
|
|
184
|
-
return errors, DF_flag, fps, LTC_frames_and_pos
|
|
185
|
-
|
|
186
|
-
def find_pulses(TTC_data, recording):
|
|
187
|
-
samplerate = recording.true_samplerate
|
|
188
|
-
i_samplerate = round(samplerate)
|
|
189
|
-
pulse_position = recording.sync_position
|
|
190
|
-
logger.debug('first detected pulse %i'%pulse_position)
|
|
191
|
-
# first_pulse_nbr_of_seconds = int(pulse_position/samplerate)
|
|
192
|
-
# if first_pulse_nbr_of_seconds > 1:
|
|
193
|
-
# pulse_position = pulse_position%i_samplerate # very first pulse in file
|
|
194
|
-
# print('0 %i'%pulse_position)
|
|
195
|
-
pulse_position = pulse_position%i_samplerate
|
|
196
|
-
logger.debug('starting at %i'%pulse_position)
|
|
197
|
-
second = 0
|
|
198
|
-
duration = int(recording.get_duration())
|
|
199
|
-
decoder = recording.decoder
|
|
200
|
-
pulse_detection_level = decoder._get_pulse_detection_level()
|
|
201
|
-
logger.debug(' detection level %f'%pulse_detection_level)
|
|
202
|
-
pulses = []
|
|
203
|
-
approx_next_pulse = pulse_position
|
|
204
|
-
skipped_printed = False
|
|
205
|
-
while second < duration - 1:
|
|
206
|
-
second += 1
|
|
207
|
-
approx_next_pulse -= OFFSET_NXT_PULSE
|
|
208
|
-
start_of_extract = approx_next_pulse
|
|
209
|
-
sound_extract = TTC_data[start_of_extract:start_of_extract + LENGTH_EXTRACT]
|
|
210
|
-
abs_signal = abs(sound_extract)
|
|
211
|
-
detected_point = \
|
|
212
|
-
np.argmax(abs_signal > pulse_detection_level)
|
|
213
|
-
old_pulse_position = pulse_position
|
|
214
|
-
pulse_position = detected_point + start_of_extract
|
|
215
|
-
diff = pulse_position - old_pulse_position
|
|
216
|
-
logger.debug('pulse_position %f old_pulse_position %f diff %f'%(pulse_position,
|
|
217
|
-
old_pulse_position, diff))
|
|
218
|
-
if not np.isclose(diff, samplerate, rtol=1e-4):
|
|
219
|
-
if not skipped_printed:
|
|
220
|
-
print('\nSkipped: ', end='')
|
|
221
|
-
skipped_printed = True
|
|
222
|
-
print('%i, '%(pulse_position), end='')
|
|
223
|
-
# if diff < samplerate:
|
|
224
|
-
# else:
|
|
225
|
-
# print('skipped: samples %i and %i are too far'%(pulse_position, old_pulse_position))
|
|
226
|
-
else:
|
|
227
|
-
pulses.append((second, pulse_position))
|
|
228
|
-
approx_next_pulse = pulse_position + i_samplerate
|
|
229
|
-
if skipped_printed:
|
|
230
|
-
print('\n')
|
|
231
|
-
return pulses
|
|
232
|
-
|
|
233
|
-
def main():
|
|
234
|
-
print('in main()')
|
|
235
|
-
parser = argparse.ArgumentParser()
|
|
236
|
-
parser.add_argument(
|
|
237
|
-
"LTC_chan",
|
|
238
|
-
type=int,
|
|
239
|
-
# nargs=2,
|
|
240
|
-
help="LTC channel number"
|
|
241
|
-
)
|
|
242
|
-
parser.add_argument(
|
|
243
|
-
"file_argument",
|
|
244
|
-
type=str,
|
|
245
|
-
nargs=1,
|
|
246
|
-
help="media file"
|
|
247
|
-
)
|
|
248
|
-
args = parser.parse_args()
|
|
249
|
-
# print(args.channels)
|
|
250
|
-
LTC_chan = args.LTC_chan
|
|
251
|
-
file_argument = args.file_argument[0]
|
|
252
|
-
logger.info('args.file_argument: %s'%file_argument)
|
|
253
|
-
if os.path.isdir(file_argument):
|
|
254
|
-
print('argument shoud be a media file, not a directory. Bye...')
|
|
255
|
-
quit()
|
|
256
|
-
# print(file_argument)
|
|
257
|
-
if not os.path.exists(file_argument):
|
|
258
|
-
print('%s does not exist, bye'%file_argument)
|
|
259
|
-
quit()
|
|
260
|
-
errors, DF_flag, fps_rel_to_audio, LTC_frames_and_pos = ltcdump_and_check(file_argument, LTC_chan)
|
|
261
|
-
if errors:
|
|
262
|
-
print('errors! %s'%errors)
|
|
263
|
-
print('Some errors in those %i but detected FPS rel to audio is %0.3f%s'%(len(LTC_frames_and_pos),
|
|
264
|
-
fps_rel_to_audio, 'DF' if DF_flag else 'NDF'))
|
|
265
|
-
else:
|
|
266
|
-
print('\nAll %i frames are sequential and detected FPS rel to audio is %0.3f%s\n'%(len(LTC_frames_and_pos),
|
|
267
|
-
fps_rel_to_audio, 'DF' if DF_flag else 'NDF'))
|
|
268
|
-
# print('trying to decode TTC...')
|
|
269
|
-
with Progress(transient=True) as progress:
|
|
270
|
-
task = progress.add_task("trying to decode TTC...")
|
|
271
|
-
progress.start()
|
|
272
|
-
m = device_scanner.media_dict_from_path(Path(file_argument))
|
|
273
|
-
logger.debug('media_dict_from_path %s'%m)
|
|
274
|
-
recording = yaltc.Recording(m, )
|
|
275
|
-
logger.debug('Rec %s'%recording)
|
|
276
|
-
time = recording.get_start_time(progress=progress, task=task)
|
|
277
|
-
if time == None:
|
|
278
|
-
print('Start time couldnt be determined')
|
|
279
|
-
else:
|
|
280
|
-
audio_samplerate_gps_corrected = recording.true_samplerate
|
|
281
|
-
audio_error = audio_samplerate_gps_corrected/recording.get_samplerate()
|
|
282
|
-
gps_corrected_framerate = fps_rel_to_audio*audio_error
|
|
283
|
-
print('gps_corrected_framerate',gps_corrected_framerate,audio_error)
|
|
284
|
-
frac_time = int(time.microsecond / 1e2)
|
|
285
|
-
d = '%s.%s'%(time.strftime("%Y-%m-%d %H:%M:%S"),frac_time)
|
|
286
|
-
base = os.path.basename(file_argument)
|
|
287
|
-
print('%s UTC:%s pulse: %i on chan %i'%(base, d,
|
|
288
|
-
recording.sync_position,
|
|
289
|
-
recording.YaLTC_channel))
|
|
290
|
-
print('audio samplerate (gps)', audio_samplerate_gps_corrected)
|
|
291
|
-
all_channels_data = read_whole_audio_data(file_argument)
|
|
292
|
-
TTC_data = all_channels_data[recording.YaLTC_channel]
|
|
293
|
-
sec_and_pulses = find_pulses(TTC_data, recording)
|
|
294
|
-
secs, pulses = list(zip(*sec_and_pulses))
|
|
295
|
-
pulses = list(pulses)
|
|
296
|
-
logger.debug('pulses %s'%pulses)
|
|
297
|
-
samples_between_UTC_pulses = []
|
|
298
|
-
for n1, n2 in zip(pulses[1:], pulses):
|
|
299
|
-
delta = n1 - n2
|
|
300
|
-
if np.isclose(delta, audio_samplerate_gps_corrected, rtol=1e-3):
|
|
301
|
-
samples_between_UTC_pulses.append(delta - audio_samplerate_gps_corrected)
|
|
302
|
-
samples_between_UTC_pulses = np.array(samples_between_UTC_pulses)
|
|
303
|
-
pulse_length_std = samples_between_UTC_pulses.std()
|
|
304
|
-
max_min_over_2 = abs(samples_between_UTC_pulses.max() - samples_between_UTC_pulses.min())/2
|
|
305
|
-
# print(samples_between_UTC_pulses)
|
|
306
|
-
# print('time is measured with a precision of %f audio samples'%(pulse_length_std))
|
|
307
|
-
precision = 1e6*max_min_over_2/audio_samplerate_gps_corrected
|
|
308
|
-
print('Time is measured with a precision of %0.1f audio samples (%0.1f μs)'%(max_min_over_2, precision))
|
|
309
|
-
frame_duration = 1/fps_rel_to_audio
|
|
310
|
-
rel_min_error = 100*1e-6*precision/frame_duration
|
|
311
|
-
print('so LTC syncword jitter less than %0.1f %% wont be detected'%(rel_min_error))
|
|
312
|
-
# fig, ax = plt.subplots()
|
|
313
|
-
# n, bins, patches = ax.hist(samples_between_UTC_pulses)
|
|
314
|
-
# plt.show()
|
|
315
|
-
# x = range(len(pulses))
|
|
316
|
-
a, b = np.polyfit(pulses, secs, 1)
|
|
317
|
-
logger.debug('slope, b = %f %f'%(a,b))
|
|
318
|
-
# sr_slope = 1/a
|
|
319
|
-
# print(sr_slope/recording.true_samplerate)
|
|
320
|
-
coherent_sr = np.isclose(a*audio_samplerate_gps_corrected, 1, rtol=1e-7)
|
|
321
|
-
logger.debug('samplerates (slope VS rec) are close: %s ratio %f'%(coherent_sr,
|
|
322
|
-
a*audio_samplerate_gps_corrected))
|
|
323
|
-
if not coherent_sr:
|
|
324
|
-
print('warning, wav samplerate are incoherent (Rec + Decode VS slope)')
|
|
325
|
-
def make_sample2time(a, b):
|
|
326
|
-
return lambda n : a*n + b
|
|
327
|
-
sample2time = make_sample2time(a, b)
|
|
328
|
-
logger.debug('sample2time fct: %s'%sample2time)
|
|
329
|
-
LTC_samples = [N for _, N in LTC_frames_and_pos]
|
|
330
|
-
LTC_times = [sample2time(N) for N in LTC_samples]
|
|
331
|
-
slope_fps, _ = np.polyfit(LTC_times, range(len(LTC_times)), 1)
|
|
332
|
-
print('slope_fps l329', ppm(slope_fps,24))
|
|
333
|
-
print('diff slope, ppm',ppm(gps_corrected_framerate, slope_fps))
|
|
334
|
-
LTC_frame_durations_samples = [a - b for a, b in zip(LTC_samples[1:], LTC_samples)]
|
|
335
|
-
# print(LTC_frame_durations_samples)
|
|
336
|
-
frame_duration = 1/fps_rel_to_audio
|
|
337
|
-
errors_useconds = [1e6*(frame_duration -(a - b)) for a, b in zip(LTC_times[1:], LTC_times)]
|
|
338
|
-
# print(errors_useconds)
|
|
339
|
-
errors_useconds = np.array(errors_useconds)
|
|
340
|
-
LTC_std = abs(errors_useconds).std()
|
|
341
|
-
LTC_max_min = abs(errors_useconds.max() - errors_useconds.min())/2
|
|
342
|
-
# print('Mean frame duration is %i audio samples'%)
|
|
343
|
-
print('\nhere LTC frame duration varies by %f μs ('%LTC_max_min, end='')
|
|
344
|
-
print('%0.3fFPS nominal frame duration is %0.0f μs)\n'%(fps_rel_to_audio, 1e6/fps_rel_to_audio))
|
|
345
|
-
# print(errors_useconds[:200])
|
|
346
|
-
# audio_sampling_period = 1/samplerate
|
|
347
|
-
# print(audio_sampling_period)
|
|
348
|
-
# errors_in_audiosamples = [int(e/audio_sampling_period) for e in errors_seconds]
|
|
349
|
-
# print(delta_milliseconds)
|
|
350
|
-
# plt.plot(LTC_times, marker='.', markersize='1',
|
|
351
|
-
# linestyle='None', color='black')
|
|
352
|
-
# plt.show()
|
|
353
|
-
# print(LTC_times)
|
|
354
|
-
# fig, ax = plt.subplots()
|
|
355
|
-
|
|
356
|
-
# the histogram of the data
|
|
357
|
-
# print(errors_in_audiosamples)
|
|
358
|
-
fig, ax = plt.subplots()
|
|
359
|
-
n, bins, patches = ax.hist(errors_useconds, bins=40)
|
|
360
|
-
plt.show()
|
|
361
|
-
quit()
|
|
362
|
-
|
|
363
|
-
if __name__ == '__main__':
|
|
364
|
-
main()
|
|
365
|
-
|
|
366
|
-
# import matplotlib.pyplot as plt
|
|
367
|
-
# import numpy as np
|
|
368
|
-
|
|
369
|
-
# rng = np.random.default_rng(19680801)
|
|
370
|
-
|
|
371
|
-
# # example data
|
|
372
|
-
# mu = 106 # mean of distribution
|
|
373
|
-
# sigma = 17 # standard deviation of distribution
|
|
374
|
-
# x = rng.normal(loc=mu, scale=sigma, size=420)
|
|
375
|
-
|
|
376
|
-
# num_bins = 42
|
|
377
|
-
|
|
378
|
-
# fig, ax = plt.subplots()
|
|
379
|
-
|
|
380
|
-
# # the histogram of the data
|
|
381
|
-
# n, bins, patches = ax.hist(x, num_bins, density=True)
|
|
382
|
-
|
|
383
|
-
# # add a 'best fit' line
|
|
384
|
-
# y = ((1 / (np.sqrt(2 * np.pi) * sigma)) *
|
|
385
|
-
# np.exp(-0.5 * (1 / sigma * (bins - mu))**2))
|
|
386
|
-
# ax.plot(bins, y, '--')
|
|
387
|
-
# ax.set_xlabel('Value')
|
|
388
|
-
# ax.set_ylabel('Probability density')
|
|
389
|
-
# ax.set_title('Histogram of normal distribution sample: '
|
|
390
|
-
# fr'$\mu={mu:.0f}$, $\sigma={sigma:.0f}$')
|
|
391
|
-
|
|
392
|
-
# # Tweak spacing to prevent clipping of ylabel
|
|
393
|
-
# fig.tight_layout()
|
|
394
|
-
# plt.show()
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: tictacsync
|
|
3
|
-
Version: 0.1a14
|
|
4
|
-
Summary: command for syncing audio video recordings
|
|
5
|
-
Home-page: https://sr.ht/~proflutz/TicTacSync/
|
|
6
|
-
Author: Raymond Lutz
|
|
7
|
-
Author-email: lutzrayblog@mac.com
|
|
8
|
-
Classifier: Development Status :: 2 - Pre-Alpha
|
|
9
|
-
Classifier: Environment :: Console
|
|
10
|
-
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
-
Classifier: Operating System :: MacOS
|
|
13
|
-
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
-
Classifier: Operating System :: POSIX
|
|
15
|
-
Classifier: Programming Language :: Python :: 3
|
|
16
|
-
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
17
|
-
Classifier: Topic :: Utilities
|
|
18
|
-
Classifier: Topic :: Multimedia :: Sound/Audio :: Capture/Recording
|
|
19
|
-
Classifier: Topic :: Multimedia :: Video :: Non-Linear Editor
|
|
20
|
-
Requires-Python: >=3.10
|
|
21
|
-
Description-Content-Type: text/markdown
|
|
22
|
-
License-File: LICENSE
|
|
23
|
-
Requires-Dist: sox >=1.4.1
|
|
24
|
-
Requires-Dist: ffmpeg-python >=0.2.0
|
|
25
|
-
Requires-Dist: loguru >=0.6.0
|
|
26
|
-
Requires-Dist: matplotlib >=3.7.1
|
|
27
|
-
Requires-Dist: numpy >=1.24.3
|
|
28
|
-
Requires-Dist: rich >=10.12.0
|
|
29
|
-
Requires-Dist: lmfit
|
|
30
|
-
Requires-Dist: scipy >=1.10.1
|
|
31
|
-
Requires-Dist: scikit-learn ==1.2.2
|
|
32
|
-
|
|
33
|
-
# tictacsync
|
|
34
|
-
|
|
35
|
-
## Warning: this is at pre-alpha stage
|
|
36
|
-
|
|
37
|
-
Unfinished sloppy code ahead, but should run without errors. Some functionalities are still missing. Don't run the code without parental supervision. Suggestions and enquiries are welcome via the [lists hosted on sourcehut](https://sr.ht/~proflutz/TicTacSync/lists).
|
|
38
|
-
|
|
39
|
-
## Description
|
|
40
|
-
|
|
41
|
-
`tictacsync` is a python script to sync audio and video files shot
|
|
42
|
-
with [dual system sound](https://www.learnlightandsound.com/blog/2017/2/23/how-to-record-sound-for-video-dual-systemsync-sound) using a specific hardware timecode generator
|
|
43
|
-
called [Tic Tac Sync](https://tictacsync.org). The timecode is named YaLTC for *yet
|
|
44
|
-
another longitudinal time code* and should be recorded on a scratch
|
|
45
|
-
track on each device for the syncing to be performed, later in _postprod_ before editing.
|
|
46
|
-
|
|
47
|
-
## Status
|
|
48
|
-
|
|
49
|
-
`tictacsync` scans for audio video files and displays their starting time and then merges overlapping audio and video recordings. Multicam syncing with one stereo audio recorder has been tested (spring 2023, [see demo](https://youtu.be/pklTSTi7cqs)). Multi audio recorders coming soon...
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
## Installation
|
|
53
|
-
|
|
54
|
-
This uses the [python interpreter](https://www.python.org/downloads/) and multiple packages (so you need python 3 + pip). Also, you need to install two non-python command line executables: [ffmpeg](https://windowsloop.com/install-ffmpeg-windows-10/) and [sox](https://sourceforge.net/projects/sox/files/). Make sure those are _accessible through your `PATH` system environment variable_.
|
|
55
|
-
Then pip install the syncing program:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
pip install tictacsync
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
This should install python dependencies _and_ the `tictacsync` command.
|
|
62
|
-
## Usage
|
|
63
|
-
|
|
64
|
-
Download some sample files [here](https://tictacsync.org/sampleFiles.zip), unzip and run
|
|
65
|
-
|
|
66
|
-
tictacsync sampleFiles
|
|
67
|
-
The resulting synced videos will be in a subfolder named `tictacsynced`. For a very verbose output add the `-v` flag:
|
|
68
|
-
|
|
69
|
-
tictacsync -v sampleFiles
|
|
70
|
-
|
|
71
|
-
When the argument is an unique media file (not a directory), no syncing will occur but the decoded starting time will be printed to stdout:
|
|
72
|
-
|
|
73
|
-
tictacsync sampleFiles/canon24fps01.MOV
|
|
74
|
-
|
|
75
|
-
Recording started at 2023-04-23 01:09:08.1605 UTC
|
|
76
|
-
true sample rate: 48000.545 Hz
|
|
77
|
-
first sync at 37414 samples
|
|
78
|
-
N.B.: all results are precise to the displayed digits!
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
When called with the `-p` flag, zoomable plots will be produced for diagnostic purpose (close the plotting window for the 2nd one) and the decoded starting time will be output to stdin:
|
|
82
|
-
|
|
83
|
-
tictacsync -p sampleFiles/canon24fps01.MOV
|
|
84
|
-
|
|
85
|
-
Typical first plot produced :
|
|
86
|
-
|
|
87
|
-

|
|
88
|
-
|
|
89
|
-
Typical second plot produced (note the 34 [FSK](https://en.wikipedia.org/wiki/Frequency-shift_keying) encoded bits `0010111101001111100110000110010000`):
|
|
90
|
-

|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
<!-- To run some tests, from top level `git cloned` dir:
|
|
94
|
-
|
|
95
|
-
cd tictacsync ; python -m pytest
|
|
96
|
-
Yes, the coverage is low. -->
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
tictacsync/LTCcheck.py,sha256=SUq93zfky5njzvnsnIj-5nC01QYf3hVBegJftPOFeJQ,15708
|
|
2
|
-
tictacsync/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
-
tictacsync/device_scanner.py,sha256=wDkhE_H33Zh5RIjASS33HOmX5-qI-dJ3h9yyvaTrAI0,15177
|
|
4
|
-
tictacsync/entry.py,sha256=ICTDI6ZPaPsDnkZX-c7dahr6qpG09x618j9nPh3x2jM,10279
|
|
5
|
-
tictacsync/multi2polywav.py,sha256=7Qnb986D3HJIAkNJ3IyvPWxIdqqh3KIfwnwFoUBw0pU,7013
|
|
6
|
-
tictacsync/timeline.py,sha256=1NpfbvBv24oDtet8SafPcpvk5ppK8qTdEybs8qz8gkc,39258
|
|
7
|
-
tictacsync/yaltc.py,sha256=Hu827V61yp1VcjZHJ9HMlCL_EEcxlhi_4hesOQy66bc,71391
|
|
8
|
-
tictacsync-0.1a14.dist-info/LICENSE,sha256=ZAOPXLh1zlQAnhHUd7oLslKM01YZ5UiAu3STYjwIxck,1068
|
|
9
|
-
tictacsync-0.1a14.dist-info/METADATA,sha256=K96y62aTWDK0EmlHyFR0ISO75ZrLf9OoKETTtoM0x8I,4218
|
|
10
|
-
tictacsync-0.1a14.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
|
11
|
-
tictacsync-0.1a14.dist-info/entry_points.txt,sha256=bre_DmdWFgHljcC0cyqh_dC9siGANO04MQ_wYsO2sxY,135
|
|
12
|
-
tictacsync-0.1a14.dist-info/top_level.txt,sha256=eaCWG-BsYTRR-gLTJbK4RfcaXajr0gjQ6wG97MkGRrg,11
|
|
13
|
-
tictacsync-0.1a14.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|