Coverage for src/audioio/fixtimestamps.py: 11%
154 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-03 21:40 +0000
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-03 21:40 +0000
1"""Fix time stamps.
3Change time stamps in the metadata (of wave files) and file names
4*without rewriting* the entire file. This is useful in case the
5real-time clock of a recorder failed.
7## Command line script
9Let's assume you have a continous recording spread over the following
10four files each covering 3 minutes of the recording:
11```txt
12logger-20190101T000015.wav
13logger-20190101T000315.wav
14logger-20190101T000615.wav
15logger-20190101T000915.wav
16```
17However, the recording was actually started at 2025-06-09T10:42:17.
18Obviously, the real-time clock failed, since all times in the file name
19and the time stamps in the metadata start in the year 2019.
21To fix this, run
22```sh
23> fixtimestamps -s '2025-06-09T10:42:17' logger-2019*.wav
24```
26Then the files are renamed:
27```txt
28logger-20190101T000015.wav -> logger-20250609T104217.wav
29logger-20190101T000315.wav -> logger-20250609T104517.wav
30logger-20190101T000615.wav -> logger-20250609T104817.wav
31logger-20190101T000915.wav -> logger-20250609T105117.wav
32```
33and the time stamps in the meta data are set accordingly.
35Adding the `-n` flag runs the script in dry mode, i.e. it just reports what it would do without modifying the audio files:
37```sh
38> fixtimestamps -n -s 20250701T173420 *.wav
39```
41Alternatively, the script can be run from within the audioio source tree as:
42```
43python -m src.audioio.fixtimestamps -s 20250701T173420 *.wav
44```
46Running
47```sh
48fixtimestamps --help
49```
50prints
51```text
52usage: fixtimestamps [-h] [--version] -s STARTTIME [-n] files [files ...]
54Fix time stamps.
56positional arguments:
57 files audio files
59options:
60 -h, --help show this help message and exit
61 --version show program's version number and exit
62 -s STARTTIME new start time of the first file
63 -n do not modify the files, just report what would be done.
65version 2.5.0 by Benda-Lab (2020-2025)
66```
68## Functions
70- `parse_datetime()`: parse string for a date and a time.
71- `replace_datetime()`: replace in a string date and time.
72- `write_riff_datetime()`: modify time stamps in the metadata of a RIFF/WAVE file.
74"""
77import re
78import os
79import sys
80import argparse
81import datetime as dt
82from pathlib import Path
83from .version import __version__, __year__
84from .riffmetadata import read_riff_header, read_chunk_tags, read_format_chunk
85from .riffmetadata import read_info_chunks, read_bext_chunk, read_ixml_chunk, read_guano_chunk
86from .riffmetadata import write_info_chunk, write_bext_chunk, write_ixml_chunk, write_guano_chunk
87from .audiometadata import get_datetime, set_starttime
90def parse_datetime(string):
91 """Parse string for a date and a time.
93 Parameters
94 ----------
95 string: str
96 String to be parsed.
98 Returns
99 -------
100 dtime: datetime or None
101 The date and time parsed from the string.
102 None if neither a date nor a time was found.
103 """
104 date = None
105 time = None
106 time_pos = 0
107 m = re.search('([123][0-9][0-9][0-9]-[01][0-9]-[0123][0-9])', string)
108 if m is not None:
109 date = dt.date.fromisoformat(m[0])
110 time_pos = m.end()
111 else:
112 m = re.search('([123][0-9][0-9][0-9][01][0-9][0123][0-9])', string)
113 if m is not None:
114 dts = m[0]
115 dts = f'{dts[0:4]}-{dts[4:6]}-{dts[6:8]}'
116 date = dt.date.fromisoformat(dts)
117 time_pos = m.end()
118 m = re.search('([012][0-9]:[0-5][0-9]:[0-5][0-9])', string[time_pos:])
119 if m is not None:
120 time = dt.time.fromisoformat(m[0])
121 else:
122 m = re.search('([012][0-9][0-5][0-9][0-5][0-9])', string[time_pos:])
123 if m is not None:
124 dts = m[0]
125 dts = f'{dts[0:2]}:{dts[2:4]}:{dts[4:6]}'
126 time = dt.time.fromisoformat(dts)
127 if date is None and time is None:
128 return None
129 if date is None:
130 date = dt.date(1, 1, 1)
131 if time is None:
132 time = dt.time(0, 0, 0)
133 dtime = dt.datetime.combine(date, time)
134 return dtime
137def replace_datetime(string, date_time):
138 """ Replace in a string date and time.
140 Parameters
141 ----------
142 string: str
143 String in which date and time are replaced.
144 date_time: datetime
145 Date and time to write into the string.
147 Returns
148 -------
149 new_string: str
150 The `string` with date and time replaced by `date_time`.
151 """
152 if date_time is None:
153 return string
154 new_string = string
155 time_pos = 0
156 dts = date_time.date().isoformat()
157 pattern = re.compile('([123][0-9][0-9][0-9]-[01][0-9]-[0123][0-9])')
158 m = pattern.search(new_string)
159 if m is not None:
160 time_pos = m.end()
161 new_string = pattern.sub(dts, new_string)
162 else:
163 pattern = re.compile('([123][0-9][0-9][0-9][01][0-9][0123][0-9])')
164 m = pattern.search(new_string)
165 if m is not None:
166 time_pos = m.end()
167 new_string = pattern.sub(dts.replace('-', ''), new_string)
168 dts = date_time.time().isoformat()
169 pattern = re.compile('([012][0-9]:[0-5][0-9]:[0-5][0-9])')
170 m = pattern.search(new_string[time_pos:])
171 if m is not None:
172 new_string = new_string[:time_pos] + \
173 pattern.sub(dts, new_string[time_pos:])
174 else:
175 pattern = re.compile('([012][0-9][0-5][0-9][0-5][0-9])')
176 m = pattern.search(new_string[time_pos:])
177 if m is not None:
178 new_string = new_string[:time_pos] + \
179 pattern.sub(dts.replace(':', ''), new_string[time_pos:])
180 return new_string
183def write_riff_datetime(path, start_time, file_time=None, no_mod=False):
184 """ Modify time stamps in the metadata of a RIFF/WAVE file.
186 Parameters
187 ----------
188 path: str
189 Path to a wave file.
190 start_time: datetime
191 Date and time to which all time stamps should be set.
192 file_time: None or date_time
193 If provided check whether the time stamp in the metadata
194 matches.
196 Returns
197 -------
198 duration: timedelta
199 Total duration of the audio data in the file.
200 orig_time: date_time or None
201 The time stamp found in the metadata.
202 no_mod: bool
203 Do not modify the files, just report what would be done.
204 """
205 def check_starttime(file_orig, time_time, path):
206 if file_time is not None and orig_time is not None and \
207 abs(orig_time - file_time) > dt.timedelta(seconds=1):
208 raise ValueError(f'"{path}" start time is {orig_time} but should be {file_time} for a continuous recording.')
211 duration = dt.timedelta(seconds=0)
212 orig_time = None
213 store_empty = False
214 with open(path, 'r+b') as sf:
215 try:
216 fsize = read_riff_header(sf)
217 except ValueError:
218 raise ValueError(f'"{path}" is not a valid RIFF/WAVE file, time stamps cannot be modified.')
219 tags = read_chunk_tags(sf)
220 if 'FMT ' not in tags:
221 raise ValueError(f'missing FMT chunk in "{path}".')
222 sf.seek(tags['FMT '][0] - 4, os.SEEK_SET)
223 channels, rate, bits = read_format_chunk(sf)
224 bts = 1 + (bits - 1) // 8
225 if 'DATA' not in tags:
226 raise ValueError(f'missing DATA chunk in "{path}".')
227 dsize = tags['DATA'][1]
228 duration = dt.timedelta(seconds=(dsize//bts//channels)/rate)
229 for chunk in tags:
230 sf.seek(tags[chunk][0] - 4, os.SEEK_SET)
231 md = {}
232 if chunk == 'LIST-INFO':
233 md['INFO'] = read_info_chunks(sf, store_empty)
234 orig_time = get_datetime(md)
235 check_starttime(orig_time, file_time, path)
236 if not no_mod and set_starttime(md, start_time):
237 sf.seek(tags[chunk][0] - 8, os.SEEK_SET)
238 write_info_chunk(sf, md, tags[chunk][1])
239 elif chunk == 'BEXT':
240 md['BEXT'] = read_bext_chunk(sf, store_empty)
241 orig_time = get_datetime(md)
242 check_starttime(orig_time, file_time, path)
243 if not no_mod and set_starttime(md, start_time):
244 sf.seek(tags[chunk][0] - 8, os.SEEK_SET)
245 write_bext_chunk(sf, md)
246 elif chunk == 'IXML':
247 md['IXML'] = read_ixml_chunk(sf, store_empty)
248 orig_time = get_datetime(md)
249 check_starttime(orig_time, file_time, path)
250 if not no_mod and set_starttime(md, start_time):
251 sf.seek(tags[chunk][0] - 8, os.SEEK_SET)
252 write_ixml_chunk(sf, md)
253 elif chunk == 'GUAN':
254 md['GUANO'] = read_guano_chunk(sf)
255 orig_time = get_datetime(md)
256 check_starttime(orig_time, file_time, path)
257 if not no_mod and set_starttime(md, start_time):
258 sf.seek(tags[chunk][0] - 8, os.SEEK_SET)
259 write_guano_chunk(sf, md['GUANO'])
260 return duration, orig_time
263def demo(start_time, file_pathes, no_mod=False):
264 """Modify time stamps of audio files.
266 Parameters
267 ----------
268 start_time: str
269 Time stamp of the first file.
270 file_pathes: list of str
271 Pathes of audio files.
272 no_mod: bool
273 Do not modify the files, just report what would be done.
274 """
275 file_time = None
276 start_time = dt.datetime.fromisoformat(start_time)
277 for fp in file_pathes:
278 duration, orig_time = write_riff_datetime(fp, start_time,
279 file_time, no_mod)
280 name_time = parse_datetime(Path(fp).stem)
281 if orig_time is None:
282 orig_time = name_time
283 if file_time is None:
284 file_time = orig_time
285 if orig_time is None:
286 raise ValueError(f'"{fp}" does not contain any time in its metadata or name.')
287 if name_time is not None:
288 p = Path(fp)
289 np = p.with_stem(replace_datetime(p.stem, start_time))
290 if not no_mod:
291 os.rename(fp, np)
292 print(f'{fp} -> {np}')
293 else:
294 print(f'{fp}: {orig_time} -> {start_time}')
295 start_time += duration
296 file_time += duration
299def main(*cargs):
300 """Call demo with command line arguments.
302 Parameters
303 ----------
304 cargs: list of strings
305 Command line arguments as provided by sys.argv[1:]
306 """
307 # command line arguments:
308 parser = argparse.ArgumentParser(add_help=True,
309 description='Fix time stamps.',
310 epilog=f'version {__version__} by Benda-Lab (2020-{__year__})')
311 parser.add_argument('--version', action='version', version=__version__)
312 parser.add_argument('-s', dest='starttime', default=None, type=str, required=True,
313 help='new start time of the first file')
314 parser.add_argument('-n', dest='nomod', action='store_true',
315 help='do not modify the files, just report what would be done.')
316 parser.add_argument('files', type=str, nargs='+',
317 help='audio files')
318 if len(cargs) == 0:
319 cargs = None
320 args = parser.parse_args(cargs)
322 demo(args.starttime, args.files, args.nomod)
325if __name__ == "__main__":
326 main(*sys.argv[1:])