Coverage for src/audioio/audioconverter.py: 92%

222 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 19:14 +0000

1"""Command line script for converting, downsampling, renaming and merging audio files. 

2 

3```sh 

4audioconverter -o test.wav test.mp3 

5``` 

6converts 'test.mp3' to 'test.wav'. 

7 

8The script reads all input files with `audioio.audioloader.load_audio()`, 

9combines the audio and marker data and writes them along with the 

10metadata to an output file using `audioio.audiowriter.write_audio()`. 

11 

12Thus, all formats supported by these functions and the installed 

13python audio modules are available. This implies that MP3 files can be 

14read via the [audioread](https://github.com/beetbox/audioread) module, 

15and they can be written via [pydub](https://github.com/jiaaro/pydub). 

16Many other input and output file formats are supported by the [sndfile 

17library](http://www.mega-nerd.com/libsndfile/), provided the 

18[SoundFile](http://pysoundfile.readthedocs.org) or 

19[wavefile](https://github.com/vokimon/python-wavefile) python packages 

20are [installed](https://bendalab.github.io/audioio/installation). 

21 

22Metadata and markers are preserved if possible. 

23 

24Run 

25```sh 

26audioconverter -l 

27``` 

28for a list of supported output file formats and 

29```sh 

30audioconverter -f wav -l 

31``` 

32for a list of supported encodings for a given output format (`-f` option). 

33 

34Running 

35```sh 

36audioconverter --help 

37``` 

38prints 

39```text 

40usage: audioconverter [-h] [--version] [-v] [-l] [-f FORMAT] [-e ENCODING] [-s SCALE] [-u [THRESH]] 

41 [-U [THRESH]] [-d FAC] [-c CHANNELS] [-a KEY=VALUE] [-r KEY] [-n NUM] 

42 [-o OUTPATH] [-i KWARGS] 

43 [files ...] 

44 

45Convert audio file formats. 

46 

47positional arguments: 

48 files one or more input files to be combined into a single output file 

49 

50options: 

51 -h, --help show this help message and exit 

52 --version show program's version number and exit 

53 -v print debug output 

54 -l list supported file formats and encodings 

55 -f FORMAT audio format of output file 

56 -e ENCODING audio encoding of output file 

57 -s SCALE scale the data by factor SCALE 

58 -u [THRESH] unwrap clipped data with threshold relative to maximum of input range (default is 

59 0.5) and divide by two 

60 -U [THRESH] unwrap clipped data with threshold relative to maximum of input range (default is 

61 0.5) and clip 

62 -d FAC downsample by integer factor 

63 -c CHANNELS comma and dash separated list of channels to be saved (first channel is 0) 

64 -a KEY=VALUE add key-value pairs to metadata. Keys can have section names separated by "." 

65 -r KEY remove keys from metadata. Keys can have section names separated by "." 

66 -n NUM merge NUM input files into one output file 

67 -o OUTPATH path or filename of output file. Metadata keys enclosed in curly braces will be 

68 replaced by their values from the input file 

69 -i KWARGS key-word arguments for the data loader function 

70 

71version 2.6.0 by Benda-Lab (2020-2025) 

72``` 

73 

74## Functions 

75 

76- `add_arguments()`: add command line arguments to parser. 

77- `parse_channels()`: parse channel selection string. 

78- `parse_load_kwargs()`: parse additional arguments for loading data. 

79- `check_format()`: check whether requested audio format is valid and supported. 

80- `list_formats_encodings()`: list available formats or encodings. 

81- `make_outfile()`: make name for output file. 

82- `modify_data()`: modify audio data and add modifications to metadata. 

83- `format_outfile()`: put metadata values into name of output file. 

84- `main()`: command line script for converting, downsampling, renaming and merging audio files. 

85""" 

86 

87import os 

88import sys 

89import argparse 

90import numpy as np 

91 

92from pathlib import Path 

93from scipy.signal import decimate 

94 

95from .version import __version__, __year__ 

96from .audioloader import load_audio, markers, AudioLoader 

97from .audiometadata import flatten_metadata, unflatten_metadata 

98from .audiometadata import add_metadata, remove_metadata, cleanup_metadata 

99from .audiometadata import bext_history_str, add_history 

100from .audiometadata import update_gain, add_unwrap 

101from .audiotools import unwrap 

102from .audiowriter import available_formats, available_encodings 

103from .audiowriter import format_from_extension, write_audio 

104 

105 

106def add_arguments(parser): 

107 """Add command line arguments to parser. 

108 

109 Parameters 

110 ---------- 

111 parser: argparse.ArgumentParser 

112 The parser. 

113 """ 

114 parser.add_argument('--version', action='version', version=__version__) 

115 parser.add_argument('-v', action='count', dest='verbose', default=0, 

116 help='print debug output') 

117 parser.add_argument('-l', dest='list_formats', action='store_true', 

118 help='list supported file formats and encodings') 

119 parser.add_argument('-f', dest='data_format', default=None, type=str, 

120 metavar='FORMAT', help='audio format of output file') 

121 parser.add_argument('-e', dest='encoding', default=None, type=str, 

122 help='audio encoding of output file') 

123 parser.add_argument('-s', dest='scale', default=1, type=float, 

124 help='scale the data by factor SCALE') 

125 parser.add_argument('-u', dest='unwrap', default=0, type=float, 

126 metavar='THRESH', const=1.5, nargs='?', 

127 help='unwrap clipped data with threshold relative to maximum of input range (default is 0.5) and divide by two') 

128 parser.add_argument('-U', dest='unwrap_clip', default=0, type=float, 

129 metavar='THRESH', const=1.5, nargs='?', 

130 help='unwrap clipped data with threshold relative to maximum of input range (default is 0.5) and clip') 

131 parser.add_argument('-d', dest='decimate', default=1, type=int, 

132 metavar='FAC', 

133 help='downsample by integer factor') 

134 parser.add_argument('-c', dest='channels', default='', type=str, 

135 help='comma and dash separated list of channels to be saved (first channel is 0)') 

136 parser.add_argument('-a', dest='md_list', action='append', default=[], 

137 type=str, metavar='KEY=VALUE', 

138 help='add key-value pairs to metadata. Keys can have section names separated by "."') 

139 parser.add_argument('-r', dest='remove_keys', action='append', default=[], 

140 type=str, metavar='KEY', 

141 help='remove keys from metadata. Keys can have section names separated by "."') 

142 parser.add_argument('-n', dest='nmerge', default=0, type=int, metavar='NUM', 

143 help='merge NUM input files into one output file') 

144 parser.add_argument('-o', dest='outpath', default=None, type=str, 

145 help='path or filename of output file. Metadata keys enclosed in curly braces will be replaced by their values from the input file') 

146 parser.add_argument('-i', dest='load_kwargs', default=[], 

147 action='append', metavar='KWARGS', 

148 help='key-word arguments for the data loader function') 

149 parser.add_argument('files', nargs='*', type=str, 

150 help='one or more input files to be combined into a single output file') 

151 

152 

153def parse_channels(cstr): 

154 """Parse channel selection string. 

155 

156 Parameters 

157 ---------- 

158 cstr: str 

159 String with comma separated channels and dash separated channel ranges. 

160 

161 Returns 

162 ------- 

163 channels: list of int 

164 List of selected channels. 

165 """ 

166 cs = [s.strip() for s in cstr.split(',')] 

167 channels = [] 

168 for c in cs: 

169 if len(c) == 0: 

170 continue 

171 css = [s.strip() for s in c.split('-')] 

172 if len(css) == 2: 

173 channels.extend(list(range(int(css[0]), int(css[1])+1))) 

174 else: 

175 channels.append(int(c)) 

176 return channels 

177 

178 

179def parse_load_kwargs(load_strs, sep='auto'): 

180 """Parse additional arguments for loading data. 

181 

182 Parameters 

183 ---------- 

184 load_strs: list of str 

185 Strings with comma separated key-value pairs as returned 

186 by args.load_kwargs from `add_arguments()`. 

187 sep: str 

188 String that separates keys and values. 

189 If 'auto' then take either ':' or '=' depending on which one 

190 occurs more often and earlier in load_strs. 

191 

192 Returns 

193 ------- 

194 load_kwargs: dict 

195 Key-word arguments for `load_audio()` and related functions. 

196 Value strings containing integer or floating point numbers 

197 are converted to `int` and `float`, respectively. 

198 """ 

199 # find separator: 

200 if sep.lower() == 'auto' or len(sep) == 0: 

201 sep_c = 0 

202 sep_e = 0 

203 for s in load_strs: 

204 for kw in s.split(','): 

205 i_c = kw.find(':') 

206 i_e = kw.find('=') 

207 if i_c >= 0 or i_e >= 0: 

208 if i_c < 0: 

209 i_c = len(kw) 

210 if i_e < 0: 

211 i_e = len(kw) 

212 if i_c < i_e: 

213 sep_c += 1 

214 if i_e < i_c: 

215 sep_e += 1 

216 sep = ':' if sep_c > sep_e else '=' 

217 # parse strings: 

218 load_kwargs = {} 

219 for s in load_strs: 

220 for kw in s.split(','): 

221 kws = kw.split(sep) 

222 if len(kws) >= 2: 

223 key = kws[0].strip() 

224 value = sep.join(kws[1:]).strip() 

225 try: 

226 val = int(value) 

227 except ValueError: 

228 try: 

229 val = float(value) 

230 except ValueError: 

231 val = value 

232 load_kwargs[key] = val 

233 return load_kwargs 

234 

235 

236def check_format(format): 

237 """Check whether requested audio format is valid and supported. 

238 

239 If the format is not available print an error message on console. 

240 

241 Parameters 

242 ---------- 

243 format: string 

244 Audio format to be checked. 

245 

246 Returns 

247 ------- 

248 valid: bool 

249 True if the requested audio format is valid. 

250 """ 

251 if not format or format.upper() not in available_formats(): 

252 print(f'! invalid audio file format "{format}"!') 

253 print('run') 

254 print(f'> {__file__} -l') 

255 print('for a list of available formats') 

256 return False 

257 else: 

258 return True 

259 

260 

261def list_formats_encodings(data_format): 

262 """List available formats or encodings. 

263 

264 Parameters 

265 ---------- 

266 data_format: None or str 

267 If provided, list encodings for this data format. 

268 Otherwise, list available audio file formats. 

269 """ 

270 if not data_format: 

271 print('available file formats:') 

272 for f in available_formats(): 

273 print(f' {f}') 

274 else: 

275 if not check_format(data_format): 

276 sys.exit(-1) 

277 print(f'available encodings for {data_format} file format:') 

278 for e in available_encodings(data_format): 

279 print(f' {e}') 

280 

281 

282def make_outfile(outpath, infile, data_format, blocks, format_from_ext): 

283 """Make name for output file. 

284 

285 Parameters 

286 ---------- 

287 outpath: None or str 

288 Requested output path. 

289 infile: Path 

290 Path of the input file. 

291 data_format: None or str 

292 Requested output file format. 

293 blocks: bool 

294 If True, produce outputfile for group of input files. 

295 format_from_ext: function 

296 Function that inspects a filename for its extension and 

297 deduces a file format from it. 

298 

299 Returns 

300 ------- 

301 outfile: Path 

302 Path of output file. 

303 data_format: str 

304 Format of output file. 

305 """ 

306 outpath = Path('' if outpath is None else outpath) 

307 if blocks and not data_format and \ 

308 format_from_ext(outpath) is None and \ 

309 not outpath.exists(): 

310 outpath.mkdir() 

311 if outpath == Path() or outpath.is_dir(): 

312 if outpath != Path(): 

313 outfile = outpath / infile 

314 else: 

315 outfile = infile 

316 if not data_format: 

317 print('! need to specify an audio format via -f or a file extension !') 

318 sys.exit(-1) 

319 outfile = outfile.with_suffix('.' + data_format.lower()) 

320 else: 

321 outfile = outpath 

322 if data_format: 

323 outfile = outfile.with_suffix('.' + data_format.lower()) 

324 else: 

325 data_format = format_from_ext(outfile) 

326 return outfile, data_format 

327 

328 

329def modify_data(data, rate, metadata, channels, scale, 

330 unwrap_clip, unwrap_thresh, ampl_max, unit, decimate_fac): 

331 """Modify audio data and add modifications to metadata. 

332 

333 Parameters 

334 ---------- 

335 data: 2-D array of float 

336 The data to be written into the output file. 

337 rate: float 

338 Sampling rate of the data in Hertz. 

339 metadata: nested dict 

340 Metadata. 

341 channels: list of int 

342 List of channels to be selected from the data. 

343 scale: float 

344 Scaling factor to be applied to the data. 

345 unwrap_clip: float 

346 If larger than zero, unwrap the data using this as a threshold 

347 relative to `ampl_max`, and clip the data at +-`ampl_max`. 

348 unwrap_thresh: float 

349 If larger than zero, unwrap the data using this as a threshold 

350 relative to `ampl_max`, and downscale the data by a factor of two. 

351 Also update the gain in the metadata. 

352 ampl_max: float 

353 Maximum amplitude of the input range. 

354 unit: str 

355 Unit of the input range. 

356 decimate_fac: int 

357 Downsample the data by this factor. 

358 

359 Returns 

360 ------- 

361 """ 

362 # select channels: 

363 if len(channels) > 0: 

364 data = data[:,channels] 

365 # scale data: 

366 if scale != 1: 

367 data *= scale 

368 if not update_gain(metadata, 1/scale): 

369 metadata['gain'] = 1/scale 

370 # fix data: 

371 if unwrap_clip > 1e-3: 

372 unwrap(data, unwrap_clip, ampl_max) 

373 data[data > +ampl_max] = +ampl_max 

374 data[data < -ampl_max] = -ampl_max 

375 add_unwrap(metadata, unwrap_clip*ampl_max, ampl_max, unit) 

376 elif unwrap_thresh > 1e-3: 

377 unwrap(data, unwrap_thresh, ampl_max) 

378 data *= 0.5 

379 update_gain(metadata, 0.5) 

380 add_unwrap(metadata, unwrap_thresh*ampl_max, 0.0, unit) 

381 # decimate: 

382 if decimate_fac > 1: 

383 data = decimate(data, decimate_fac, axis=0) 

384 rate /= decimate_fac 

385 return data, rate 

386 

387 

388def format_outfile(outfile, metadata): 

389 """Put metadata values into name of output file. 

390 

391 Parameters 

392 ---------- 

393 outfile: Path 

394 Path of output file. May contain metadata keys enclosed in curly braces. 

395 metadata: nested dict 

396 Metadata. 

397 

398 Returns 

399 ------- 

400 outfile: Path 

401 Output path with formatted name. 

402 """ 

403 if len(metadata) > 0 and '{' in outfile.stem and '}' in outfile.stem: 

404 fmd = flatten_metadata(metadata) 

405 fmd = {k:(fmd[k] if isinstance(fmd[k], (int, float)) else fmd[k].replace(' ', '_')) for k in fmd} 

406 outfile = outfile.with_stem(outfile.stem.format(**fmd)) 

407 return outfile 

408 

409 

410def main(*cargs): 

411 """Command line script for converting, downsampling, renaming and merging audio files. 

412 

413 Parameters 

414 ---------- 

415 cargs: list of strings 

416 Command line arguments as returned by sys.argv[1:]. 

417 """ 

418 # command line arguments: 

419 parser = argparse.ArgumentParser(add_help=True, 

420 description='Convert audio file formats.', 

421 epilog=f'version {__version__} by Benda-Lab (2020-{__year__})') 

422 add_arguments(parser) 

423 if len(cargs) == 0: 

424 cargs = None 

425 args = parser.parse_args(cargs) 

426 

427 channels = parse_channels(args.channels) 

428 

429 if args.list_formats: 

430 if args.data_format is None and len(args.files) > 0: 

431 args.data_format = args.files[0] 

432 list_formats_encodings(args.data_format) 

433 return 

434 

435 if len(args.files) == 0 or len(args.files[0]) == 0: 

436 print('! need to specify at least one input file !') 

437 sys.exit(-1) 

438 

439 # expand wildcard patterns: 

440 files = [] 

441 if os.name == 'nt': 

442 for fn in args.files: 

443 files.extend(glob.glob(fn)) 

444 else: 

445 files = args.files 

446 

447 nmerge = args.nmerge 

448 if nmerge == 0: 

449 nmerge = len(files) 

450 

451 # kwargs for audio loader: 

452 load_kwargs = parse_load_kwargs(args.load_kwargs) 

453 

454 # read in audio: 

455 try: 

456 data = AudioLoader(files, verbose=args.verbose - 1, 

457 **load_kwargs) 

458 except FileNotFoundError: 

459 print(f'file "{files[0]}" not found!') 

460 sys.exit(-1) 

461 if len(data.file_paths) < len(files): 

462 print(f'file "{files[len(data.file_paths)]}" does not continue file "{data.file_paths[-1]}"!') 

463 sys.exit(-1) 

464 md = data.metadata() 

465 add_metadata(md, args.md_list, '.') 

466 if len(args.remove_keys) > 0: 

467 remove_metadata(md, args.remove_keys, '.') 

468 cleanup_metadata(md) 

469 locs, labels = data.markers() 

470 pre_history = bext_history_str(data.encoding, 

471 data.rate, 

472 data.channels, 

473 os.fsdecode(data.filepath)) 

474 if args.verbose > 1: 

475 print(f'loaded audio file "{data.filepath}"') 

476 

477 if data.encoding is not None and args.encoding is None: 

478 args.encoding = data.encoding 

479 for i0 in range(0, len(data.file_paths), nmerge): 

480 infile = data.file_paths[i0] 

481 outfile, data_format = make_outfile(args.outpath, infile, 

482 args.data_format, 

483 nmerge < len(files), 

484 format_from_extension) 

485 if not check_format(data_format): 

486 sys.exit(-1) 

487 if infile.resolve() == outfile.resolve(): 

488 print(f'! cannot convert "{infile}" to itself !') 

489 sys.exit(-1) 

490 

491 if len(data.file_paths) > 1: 

492 i1 = i0 + nmerge - 1 

493 if i1 >= len(data.end_indices): 

494 i1 = len(data.end_indices) - 1 

495 si = data.start_indices[i0] 

496 ei = data.end_indices[i1] 

497 else: 

498 si = 0 

499 ei = data.frames 

500 wdata, wrate = modify_data(data[si:ei], data.rate, 

501 md, channels, args.scale, 

502 args.unwrap_clip, args.unwrap, 1.0, 

503 '', args.decimate) 

504 mask = (locs[:, 0] >= si) & (locs[:, 0] < ei) 

505 wlocs = locs[mask, :] 

506 if len(wlocs) > 0: 

507 wlocs[:, 0] -= si 

508 wlabels = labels[mask, :] 

509 outfile = format_outfile(outfile, md) 

510 # history: 

511 hkey = 'CodingHistory' 

512 if 'BEXT' in md: 

513 hkey = 'BEXT.' + hkey 

514 history = bext_history_str(args.encoding, wrate, 

515 data.shape[1], os.fsdecode(outfile)) 

516 add_history(md, history, hkey, pre_history) 

517 # write out audio: 

518 try: 

519 write_audio(outfile, wdata, wrate, md, wlocs, wlabels, 

520 format=data_format, encoding=args.encoding) 

521 except PermissionError: 

522 print(f'failed to write "{outfile}": permission denied!') 

523 sys.exit(-1) 

524 # message: 

525 if args.verbose > 1: 

526 print(f'wrote "{outfile}"') 

527 elif args.verbose: 

528 print(f'converted audio file "{infile}" to "{outfile}"') 

529 data.close() 

530 

531 

532if __name__ == '__main__': 

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