Coverage for src / audioio / fixtimestamps.py: 79%
160 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-17 21:34 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-17 21:34 +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.
35Supported date formats are "YYYY-MM-DD" or "YYYYMMDD".
36Supported time formats are "HH:MM:SS" or "HHMMSS".
38Adding the `-n` flag runs the script in dry mode, i.e. it just reports
39what it would do without modifying the audio files:
41```sh
42> fixtimestamps -n -s 20250701T173420 *.wav
43```
45Alternatively, the script can be run from within the audioio source
46tree as:
47```
48python -m src.audioio.fixtimestamps -s 20250701T173420 *.wav
49```
51Running
52```sh
53fixtimestamps --help
54```
55prints
56```text
57usage: fixtimestamps [-h] [--version] -s STARTTIME [-n] files [files ...]
59Fix time stamps.
61positional arguments:
62 files audio files
64options:
65 -h, --help show this help message and exit
66 --version show program's version number and exit
67 -s STARTTIME new start time of the first file
68 -n do not modify the files, just report what would be done.
70version 2.5.0 by Benda-Lab (2020-2025)
71```
73## Functions
75- `parse_datetime()`: parse string for a date and a time.
76- `replace_datetime()`: replace in a string date and time.
77- `write_riff_datetime()`: modify time stamps in the metadata of a RIFF/WAVE file.
79"""
82import re
83import os
84import sys
85import glob
86import argparse
87import datetime as dt
89from pathlib import Path
91from .version import __version__, __year__
92from .riffmetadata import read_riff_header, read_chunk_tags, read_format_chunk
93from .riffmetadata import read_info_chunks, read_bext_chunk, read_ixml_chunk, read_guano_chunk
94from .riffmetadata import write_info_chunk, write_bext_chunk, write_ixml_chunk, write_guano_chunk
95from .audiometadata import get_datetime, set_starttime
98def parse_datetime(string):
99 """Parse string for a date and a time.
101 Parameters
102 ----------
103 string: str
104 String to be parsed.
106 Returns
107 -------
108 dtime: datetime or None
109 The date and time parsed from the string.
110 None if neither a date nor a time was found.
111 """
112 date = None
113 time = None
114 time_pos = 0
115 m = re.search('([123][0-9][0-9][0-9]-[01][0-9]-[0123][0-9])', string)
116 if m is not None:
117 date = dt.date.fromisoformat(m[0])
118 time_pos = m.end()
119 else:
120 m = re.search('([123][0-9][0-9][0-9][01][0-9][0123][0-9])', string)
121 if m is not None:
122 dts = m[0]
123 dts = f'{dts[0:4]}-{dts[4:6]}-{dts[6:8]}'
124 date = dt.date.fromisoformat(dts)
125 time_pos = m.end()
126 m = re.search('([012][0-9]:[0-5][0-9]:[0-5][0-9])', string[time_pos:])
127 if m is not None:
128 time = dt.time.fromisoformat(m[0])
129 else:
130 m = re.search('([012][0-9][0-5][0-9][0-5][0-9])', string[time_pos:])
131 if m is not None:
132 dts = m[0]
133 dts = f'{dts[0:2]}:{dts[2:4]}:{dts[4:6]}'
134 time = dt.time.fromisoformat(dts)
135 if date is None and time is None:
136 return None
137 if date is None:
138 date = dt.date(1, 1, 1)
139 if time is None:
140 time = dt.time(0, 0, 0)
141 dtime = dt.datetime.combine(date, time)
142 return dtime
145def replace_datetime(string, date_time):
146 """ Replace in a string date and time.
148 Parameters
149 ----------
150 string: str
151 String in which date and time are replaced.
152 date_time: datetime
153 Date and time to write into the string.
155 Returns
156 -------
157 new_string: str
158 The `string` with date and time replaced by `date_time`.
159 """
160 if date_time is None:
161 return string
162 new_string = string
163 time_pos = 0
164 dts = date_time.date().isoformat()
165 pattern = re.compile('([123][0-9][0-9][0-9]-[01][0-9]-[0123][0-9])')
166 m = pattern.search(new_string)
167 if m is not None:
168 time_pos = m.end()
169 new_string = pattern.sub(dts, new_string)
170 else:
171 pattern = re.compile('([123][0-9][0-9][0-9][01][0-9][0123][0-9])')
172 m = pattern.search(new_string)
173 if m is not None:
174 time_pos = m.end()
175 new_string = pattern.sub(dts.replace('-', ''), new_string)
176 dts = date_time.time().isoformat()
177 pattern = re.compile('([012][0-9]:[0-5][0-9]:[0-5][0-9])')
178 m = pattern.search(new_string[time_pos:])
179 if m is not None:
180 new_string = new_string[:time_pos] + \
181 pattern.sub(dts, new_string[time_pos:])
182 else:
183 pattern = re.compile('([012][0-9][0-5][0-9][0-5][0-9])')
184 m = pattern.search(new_string[time_pos:])
185 if m is not None:
186 new_string = new_string[:time_pos] + \
187 pattern.sub(dts.replace(':', ''), new_string[time_pos:])
188 return new_string
191def write_riff_datetime(path, start_time, file_time=None, no_mod=False):
192 """ Modify time stamps in the metadata of a RIFF/WAVE file.
194 Parameters
195 ----------
196 path: str
197 Path to a wave file.
198 start_time: datetime
199 Date and time to which all time stamps should be set.
200 file_time: None or date_time
201 If provided check whether the time stamp in the metadata
202 matches.
204 Returns
205 -------
206 duration: timedelta
207 Total duration of the audio data in the file.
208 orig_time: date_time or None
209 The time stamp found in the metadata.
210 no_mod: bool
211 Do not modify the files, just report what would be done.
212 """
213 def check_starttime(file_orig, time_time, path):
214 if file_time is not None and orig_time is not None and \
215 abs(orig_time - file_time) > dt.timedelta(seconds=1):
216 raise ValueError(f'"{path}" start time is {orig_time} but should be {file_time} for a continuous recording.')
219 duration = dt.timedelta(seconds=0)
220 orig_time = None
221 store_empty = False
222 with open(path, 'r+b') as sf:
223 try:
224 fsize = read_riff_header(sf)
225 except ValueError:
226 raise ValueError(f'"{path}" is not a valid RIFF/WAVE file, time stamps cannot be modified.')
227 tags = read_chunk_tags(sf)
228 if 'FMT ' not in tags:
229 raise ValueError(f'missing FMT chunk in "{path}".')
230 sf.seek(tags['FMT '][0] - 4, os.SEEK_SET)
231 channels, rate, bits = read_format_chunk(sf)
232 bts = 1 + (bits - 1) // 8
233 if 'DATA' not in tags:
234 raise ValueError(f'missing DATA chunk in "{path}".')
235 dsize = tags['DATA'][1]
236 duration = dt.timedelta(seconds=(dsize//bts//channels)/rate)
237 for chunk in tags:
238 sf.seek(tags[chunk][0] - 4, os.SEEK_SET)
239 md = {}
240 if chunk == 'LIST-INFO':
241 md['INFO'] = read_info_chunks(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_info_chunk(sf, md, tags[chunk][1])
247 elif chunk == 'BEXT':
248 md['BEXT'] = read_bext_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_bext_chunk(sf, md)
254 elif chunk == 'IXML':
255 md['IXML'] = read_ixml_chunk(sf, store_empty)
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_ixml_chunk(sf, md)
261 elif chunk == 'GUAN':
262 md['GUANO'] = read_guano_chunk(sf)
263 orig_time = get_datetime(md)
264 check_starttime(orig_time, file_time, path)
265 if not no_mod and set_starttime(md, start_time):
266 sf.seek(tags[chunk][0] - 8, os.SEEK_SET)
267 write_guano_chunk(sf, md['GUANO'])
268 return duration, orig_time
271def demo(start_time, file_pathes, no_mod=False):
272 """Modify time stamps of audio files.
274 Parameters
275 ----------
276 start_time: str
277 Time stamp of the first file.
278 file_pathes: list of str
279 Pathes of audio files.
280 no_mod: bool
281 Do not modify the files, just report what would be done.
282 """
283 file_time = None
284 start_time = dt.datetime.fromisoformat(start_time)
285 for fp in file_pathes:
286 duration, orig_time = write_riff_datetime(fp, start_time,
287 file_time, no_mod)
288 name_time = parse_datetime(Path(fp).stem)
289 if orig_time is None:
290 orig_time = name_time
291 if file_time is None:
292 file_time = orig_time
293 if orig_time is None:
294 raise ValueError(f'"{fp}" does not contain any time in its metadata or name.')
295 if name_time is not None:
296 p = Path(fp)
297 np = p.with_stem(replace_datetime(p.stem, start_time))
298 if not no_mod:
299 os.rename(fp, np)
300 print(f'{fp} -> {np}')
301 else:
302 print(f'{fp}: {orig_time} -> {start_time}')
303 start_time += duration
304 file_time += duration
307def main(*cargs):
308 """Call demo with command line arguments.
310 Parameters
311 ----------
312 cargs: list of strings
313 Command line arguments as provided by sys.argv[1:]
314 """
315 # command line arguments:
316 parser = argparse.ArgumentParser(add_help=True,
317 description='Fix time stamps.',
318 epilog=f'version {__version__} by Benda-Lab (2020-{__year__})')
319 parser.add_argument('--version', action='version', version=__version__)
320 parser.add_argument('-s', dest='starttime', default=None, type=str, required=True,
321 help='new start time of the first file')
322 parser.add_argument('-n', dest='nomod', action='store_true',
323 help='do not modify the files, just report what would be done.')
324 parser.add_argument('files', type=str, nargs='+',
325 help='audio files')
326 if len(cargs) == 0:
327 cargs = None
328 args = parser.parse_args(cargs)
330 # expand wildcard patterns:
331 files = []
332 if os.name == 'nt':
333 for fn in args.files:
334 files.extend(glob.glob(fn))
335 else:
336 files = args.files
338 demo(args.starttime, files, args.nomod)
341if __name__ == "__main__":
342 main(*sys.argv[1:])