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

1"""Fix time stamps. 

2 

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. 

6 

7## Command line script 

8 

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. 

20 

21To fix this, run 

22```sh 

23> fixtimestamps -s '2025-06-09T10:42:17' logger-2019*.wav 

24``` 

25 

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. 

34 

35Supported date formats are "YYYY-MM-DD" or "YYYYMMDD". 

36Supported time formats are "HH:MM:SS" or "HHMMSS". 

37 

38Adding the `-n` flag runs the script in dry mode, i.e. it just reports 

39what it would do without modifying the audio files: 

40 

41```sh 

42> fixtimestamps -n -s 20250701T173420 *.wav 

43``` 

44 

45Alternatively, the script can be run from within the audioio source 

46tree as: 

47``` 

48python -m src.audioio.fixtimestamps -s 20250701T173420 *.wav 

49``` 

50 

51Running 

52```sh 

53fixtimestamps --help 

54``` 

55prints 

56```text 

57usage: fixtimestamps [-h] [--version] -s STARTTIME [-n] files [files ...] 

58 

59Fix time stamps. 

60 

61positional arguments: 

62 files audio files 

63 

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. 

69 

70version 2.5.0 by Benda-Lab (2020-2025) 

71``` 

72 

73## Functions 

74 

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. 

78 

79""" 

80 

81 

82import re 

83import os 

84import sys 

85import glob 

86import argparse 

87import datetime as dt 

88 

89from pathlib import Path 

90 

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 

96 

97 

98def parse_datetime(string): 

99 """Parse string for a date and a time. 

100 

101 Parameters 

102 ---------- 

103 string: str 

104 String to be parsed. 

105 

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 

143 

144 

145def replace_datetime(string, date_time): 

146 """ Replace in a string date and time. 

147 

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. 

154 

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 

189 

190 

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. 

193 

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. 

203 

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.') 

217 

218 

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 

269 

270 

271def demo(start_time, file_pathes, no_mod=False): 

272 """Modify time stamps of audio files. 

273 

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 

305 

306 

307def main(*cargs): 

308 """Call demo with command line arguments. 

309 

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) 

329 

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 

337 

338 demo(args.starttime, files, args.nomod) 

339 

340 

341if __name__ == "__main__": 

342 main(*sys.argv[1:])