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

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 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 

88 

89 

90def parse_datetime(string): 

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

92 

93 Parameters 

94 ---------- 

95 string: str 

96 String to be parsed. 

97 

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 

135 

136 

137def replace_datetime(string, date_time): 

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

139 

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. 

146 

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 

181 

182 

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. 

185 

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. 

195 

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

209 

210 

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 

261 

262 

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

264 """Modify time stamps of audio files. 

265 

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 

297 

298 

299def main(*cargs): 

300 """Call demo with command line arguments. 

301 

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) 

321 

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

323 

324 

325if __name__ == "__main__": 

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