Coverage for src/audioio/fixtimestamps.py: 11%
160 statements
« prev ^ index » next coverage.py v7.10.1, created at 2025-08-02 12:23 +0000
« prev ^ index » next coverage.py v7.10.1, created at 2025-08-02 12:23 +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 glob
81import argparse
82import datetime as dt
83from pathlib import Path
84from .version import __version__, __year__
85from .riffmetadata import read_riff_header, read_chunk_tags, read_format_chunk
86from .riffmetadata import read_info_chunks, read_bext_chunk, read_ixml_chunk, read_guano_chunk
87from .riffmetadata import write_info_chunk, write_bext_chunk, write_ixml_chunk, write_guano_chunk
88from .audiometadata import get_datetime, set_starttime
91def parse_datetime(string):
92 """Parse string for a date and a time.
94 Parameters
95 ----------
96 string: str
97 String to be parsed.
99 Returns
100 -------
101 dtime: datetime or None
102 The date and time parsed from the string.
103 None if neither a date nor a time was found.
104 """
105 date = None
106 time = None
107 time_pos = 0
108 m = re.search('([123][0-9][0-9][0-9]-[01][0-9]-[0123][0-9])', string)
109 if m is not None:
110 date = dt.date.fromisoformat(m[0])
111 time_pos = m.end()
112 else:
113 m = re.search('([123][0-9][0-9][0-9][01][0-9][0123][0-9])', string)
114 if m is not None:
115 dts = m[0]
116 dts = f'{dts[0:4]}-{dts[4:6]}-{dts[6:8]}'
117 date = dt.date.fromisoformat(dts)
118 time_pos = m.end()
119 m = re.search('([012][0-9]:[0-5][0-9]:[0-5][0-9])', string[time_pos:])
120 if m is not None:
121 time = dt.time.fromisoformat(m[0])
122 else:
123 m = re.search('([012][0-9][0-5][0-9][0-5][0-9])', string[time_pos:])
124 if m is not None:
125 dts = m[0]
126 dts = f'{dts[0:2]}:{dts[2:4]}:{dts[4:6]}'
127 time = dt.time.fromisoformat(dts)
128 if date is None and time is None:
129 return None
130 if date is None:
131 date = dt.date(1, 1, 1)
132 if time is None:
133 time = dt.time(0, 0, 0)
134 dtime = dt.datetime.combine(date, time)
135 return dtime
138def replace_datetime(string, date_time):
139 """ Replace in a string date and time.
141 Parameters
142 ----------
143 string: str
144 String in which date and time are replaced.
145 date_time: datetime
146 Date and time to write into the string.
148 Returns
149 -------
150 new_string: str
151 The `string` with date and time replaced by `date_time`.
152 """
153 if date_time is None:
154 return string
155 new_string = string
156 time_pos = 0
157 dts = date_time.date().isoformat()
158 pattern = re.compile('([123][0-9][0-9][0-9]-[01][0-9]-[0123][0-9])')
159 m = pattern.search(new_string)
160 if m is not None:
161 time_pos = m.end()
162 new_string = pattern.sub(dts, new_string)
163 else:
164 pattern = re.compile('([123][0-9][0-9][0-9][01][0-9][0123][0-9])')
165 m = pattern.search(new_string)
166 if m is not None:
167 time_pos = m.end()
168 new_string = pattern.sub(dts.replace('-', ''), new_string)
169 dts = date_time.time().isoformat()
170 pattern = re.compile('([012][0-9]:[0-5][0-9]:[0-5][0-9])')
171 m = pattern.search(new_string[time_pos:])
172 if m is not None:
173 new_string = new_string[:time_pos] + \
174 pattern.sub(dts, new_string[time_pos:])
175 else:
176 pattern = re.compile('([012][0-9][0-5][0-9][0-5][0-9])')
177 m = pattern.search(new_string[time_pos:])
178 if m is not None:
179 new_string = new_string[:time_pos] + \
180 pattern.sub(dts.replace(':', ''), new_string[time_pos:])
181 return new_string
184def write_riff_datetime(path, start_time, file_time=None, no_mod=False):
185 """ Modify time stamps in the metadata of a RIFF/WAVE file.
187 Parameters
188 ----------
189 path: str
190 Path to a wave file.
191 start_time: datetime
192 Date and time to which all time stamps should be set.
193 file_time: None or date_time
194 If provided check whether the time stamp in the metadata
195 matches.
197 Returns
198 -------
199 duration: timedelta
200 Total duration of the audio data in the file.
201 orig_time: date_time or None
202 The time stamp found in the metadata.
203 no_mod: bool
204 Do not modify the files, just report what would be done.
205 """
206 def check_starttime(file_orig, time_time, path):
207 if file_time is not None and orig_time is not None and \
208 abs(orig_time - file_time) > dt.timedelta(seconds=1):
209 raise ValueError(f'"{path}" start time is {orig_time} but should be {file_time} for a continuous recording.')
212 duration = dt.timedelta(seconds=0)
213 orig_time = None
214 store_empty = False
215 with open(path, 'r+b') as sf:
216 try:
217 fsize = read_riff_header(sf)
218 except ValueError:
219 raise ValueError(f'"{path}" is not a valid RIFF/WAVE file, time stamps cannot be modified.')
220 tags = read_chunk_tags(sf)
221 if 'FMT ' not in tags:
222 raise ValueError(f'missing FMT chunk in "{path}".')
223 sf.seek(tags['FMT '][0] - 4, os.SEEK_SET)
224 channels, rate, bits = read_format_chunk(sf)
225 bts = 1 + (bits - 1) // 8
226 if 'DATA' not in tags:
227 raise ValueError(f'missing DATA chunk in "{path}".')
228 dsize = tags['DATA'][1]
229 duration = dt.timedelta(seconds=(dsize//bts//channels)/rate)
230 for chunk in tags:
231 sf.seek(tags[chunk][0] - 4, os.SEEK_SET)
232 md = {}
233 if chunk == 'LIST-INFO':
234 md['INFO'] = read_info_chunks(sf, store_empty)
235 orig_time = get_datetime(md)
236 check_starttime(orig_time, file_time, path)
237 if not no_mod and set_starttime(md, start_time):
238 sf.seek(tags[chunk][0] - 8, os.SEEK_SET)
239 write_info_chunk(sf, md, tags[chunk][1])
240 elif chunk == 'BEXT':
241 md['BEXT'] = read_bext_chunk(sf, store_empty)
242 orig_time = get_datetime(md)
243 check_starttime(orig_time, file_time, path)
244 if not no_mod and set_starttime(md, start_time):
245 sf.seek(tags[chunk][0] - 8, os.SEEK_SET)
246 write_bext_chunk(sf, md)
247 elif chunk == 'IXML':
248 md['IXML'] = read_ixml_chunk(sf, store_empty)
249 orig_time = get_datetime(md)
250 check_starttime(orig_time, file_time, path)
251 if not no_mod and set_starttime(md, start_time):
252 sf.seek(tags[chunk][0] - 8, os.SEEK_SET)
253 write_ixml_chunk(sf, md)
254 elif chunk == 'GUAN':
255 md['GUANO'] = read_guano_chunk(sf)
256 orig_time = get_datetime(md)
257 check_starttime(orig_time, file_time, path)
258 if not no_mod and set_starttime(md, start_time):
259 sf.seek(tags[chunk][0] - 8, os.SEEK_SET)
260 write_guano_chunk(sf, md['GUANO'])
261 return duration, orig_time
264def demo(start_time, file_pathes, no_mod=False):
265 """Modify time stamps of audio files.
267 Parameters
268 ----------
269 start_time: str
270 Time stamp of the first file.
271 file_pathes: list of str
272 Pathes of audio files.
273 no_mod: bool
274 Do not modify the files, just report what would be done.
275 """
276 file_time = None
277 start_time = dt.datetime.fromisoformat(start_time)
278 for fp in file_pathes:
279 duration, orig_time = write_riff_datetime(fp, start_time,
280 file_time, no_mod)
281 name_time = parse_datetime(Path(fp).stem)
282 if orig_time is None:
283 orig_time = name_time
284 if file_time is None:
285 file_time = orig_time
286 if orig_time is None:
287 raise ValueError(f'"{fp}" does not contain any time in its metadata or name.')
288 if name_time is not None:
289 p = Path(fp)
290 np = p.with_stem(replace_datetime(p.stem, start_time))
291 if not no_mod:
292 os.rename(fp, np)
293 print(f'{fp} -> {np}')
294 else:
295 print(f'{fp}: {orig_time} -> {start_time}')
296 start_time += duration
297 file_time += duration
300def main(*cargs):
301 """Call demo with command line arguments.
303 Parameters
304 ----------
305 cargs: list of strings
306 Command line arguments as provided by sys.argv[1:]
307 """
308 # command line arguments:
309 parser = argparse.ArgumentParser(add_help=True,
310 description='Fix time stamps.',
311 epilog=f'version {__version__} by Benda-Lab (2020-{__year__})')
312 parser.add_argument('--version', action='version', version=__version__)
313 parser.add_argument('-s', dest='starttime', default=None, type=str, required=True,
314 help='new start time of the first file')
315 parser.add_argument('-n', dest='nomod', action='store_true',
316 help='do not modify the files, just report what would be done.')
317 parser.add_argument('files', type=str, nargs='+',
318 help='audio files')
319 if len(cargs) == 0:
320 cargs = None
321 args = parser.parse_args(cargs)
323 # expand wildcard patterns:
324 files = []
325 if os.name == 'nt':
326 for fn in args.files:
327 files.extend(glob.glob(fn))
328 else:
329 files = args.files
331 demo(args.starttime, files, args.nomod)
334if __name__ == "__main__":
335 main(*sys.argv[1:])