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

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 

35Adding the `-n` flag runs the script in dry mode, i.e. it just reports what it would do without modifying the audio files: 

36 

37```sh 

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

39``` 

40 

41Alternatively, the script can be run from within the audioio source tree as: 

42``` 

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

44``` 

45 

46Running 

47```sh 

48fixtimestamps --help 

49``` 

50prints 

51```text 

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

53 

54Fix time stamps. 

55 

56positional arguments: 

57 files audio files 

58 

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. 

64 

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

66``` 

67 

68## Functions 

69 

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. 

73 

74""" 

75 

76 

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 

89 

90 

91def parse_datetime(string): 

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

93 

94 Parameters 

95 ---------- 

96 string: str 

97 String to be parsed. 

98 

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 

136 

137 

138def replace_datetime(string, date_time): 

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

140 

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. 

147 

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 

182 

183 

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. 

186 

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. 

196 

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

210 

211 

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 

262 

263 

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

265 """Modify time stamps of audio files. 

266 

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 

298 

299 

300def main(*cargs): 

301 """Call demo with command line arguments. 

302 

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) 

322 

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 

330 

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

332 

333 

334if __name__ == "__main__": 

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