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
« 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.
3```sh
4audioconverter -o test.wav test.mp3
5```
6converts 'test.mp3' to 'test.wav'.
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()`.
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).
22Metadata and markers are preserved if possible.
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).
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 ...]
45Convert audio file formats.
47positional arguments:
48 files one or more input files to be combined into a single output file
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
71version 2.6.0 by Benda-Lab (2020-2025)
72```
74## Functions
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"""
87import os
88import sys
89import argparse
90import numpy as np
92from pathlib import Path
93from scipy.signal import decimate
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
106def add_arguments(parser):
107 """Add command line arguments to parser.
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')
153def parse_channels(cstr):
154 """Parse channel selection string.
156 Parameters
157 ----------
158 cstr: str
159 String with comma separated channels and dash separated channel ranges.
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
179def parse_load_kwargs(load_strs, sep='auto'):
180 """Parse additional arguments for loading data.
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.
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
236def check_format(format):
237 """Check whether requested audio format is valid and supported.
239 If the format is not available print an error message on console.
241 Parameters
242 ----------
243 format: string
244 Audio format to be checked.
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
261def list_formats_encodings(data_format):
262 """List available formats or encodings.
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}')
282def make_outfile(outpath, infile, data_format, blocks, format_from_ext):
283 """Make name for output file.
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.
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
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.
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.
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
388def format_outfile(outfile, metadata):
389 """Put metadata values into name of output file.
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.
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
410def main(*cargs):
411 """Command line script for converting, downsampling, renaming and merging audio files.
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)
427 channels = parse_channels(args.channels)
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
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)
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
447 nmerge = args.nmerge
448 if nmerge == 0:
449 nmerge = len(files)
451 # kwargs for audio loader:
452 load_kwargs = parse_load_kwargs(args.load_kwargs)
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}"')
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)
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()
532if __name__ == '__main__':
533 main(*sys.argv[1:])