""" The wavio module defines the functions: read(file) Read a WAV file and return a `wavio.Wav` object, with attributes `data`, `rate` and `sampwidth`. write(filename, data, rate, scale=None, sampwidth=None) Write a numpy array to a WAV file. ----- Author: Warren Weckesser License: BSD 2-Clause: Copyright (c) 2015-2022, Warren Weckesser All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """ import warnings as _warnings import wave as _wave import numpy as _np __version__ = "0.0.9" class ClippedDataWarning(UserWarning): def __init__(self, message=None): if message is None: message = ('Some data values were clipped when converted to the ' 'output format.') self.args = (message,) class ClippedDataError(RuntimeError): def __init__(self, message=None): if message is None: message = ('Some data values were clipped when converted to the ' 'output format.') self.args = (message,) def _wav2array(nchannels, sampwidth, data): """data must be the string containing the bytes from the wav file.""" num_samples, remainder = divmod(len(data), sampwidth * nchannels) if remainder > 0: raise ValueError('The length of data is not a multiple of ' 'sampwidth * num_channels.') if sampwidth > 4: raise ValueError("sampwidth must not be greater than 4.") if sampwidth == 3: a = _np.empty((num_samples, nchannels, 4), dtype=_np.uint8) raw_bytes = _np.frombuffer(data, dtype=_np.uint8) a[:, :, :sampwidth] = raw_bytes.reshape(-1, nchannels, sampwidth) a[:, :, sampwidth:] = (a[:, :, sampwidth - 1:sampwidth] >> 7) * 255 result = a.view('> _np.array([0, 8, 16])) & 255 wavdata = a8.astype(_np.uint8).tobytes() else: # Make sure the array is little-endian, and then convert using # tobytes(). a = a.astype('<' + a.dtype.str[1:], copy=False) wavdata = a.tobytes() return wavdata class Wav(object): """ Object returned by `wavio.read`. Attributes are: data : numpy array The array of data read from the WAV file. The shape of the array is (num_samples, num_channels). num_channels is the number of audio channels (1 for mono, 2 for stereo). The data type of the array (i.e. data.dtype) is determined by `sampwidth`:: sampwidth dtype 1 numpy.uint8 2 numpy.int16 3 numpy.int32 4 numpy.int32 rate : int The sampling frequency (i.e. frame rate or sample rate) of the WAV file. sampwidth : int The sample width (i.e. number of bytes per sample) of the WAV file. For example, `sampwidth == 3` is a 24 bit WAV file. """ def __init__(self, data, rate, sampwidth): self.data = data self.rate = rate self.sampwidth = sampwidth def __repr__(self): s = ("Wav(data.shape=%s, data.dtype=%s, rate=%r, sampwidth=%r)" % (self.data.shape, self.data.dtype, self.rate, self.sampwidth)) return s def read(file): """ Read a WAV file. Parameters ---------- file : string or file object Either the name of a file or an open file pointer. Returns ------- wav : wavio.Wav() instance The return value is an instance of the class `wavio.Wav`, with the following attributes: data : numpy array The array of data read from the WAV file. The shape of the array is (num_samples, num_channels). num_channels is the number of audio channels (1 for mono, 2 for stereo). The data type of the array (i.e. data.dtype) is determined by `sampwidth`:: sampwidth dtype 1 numpy.uint8 2 numpy.int16 3 numpy.int32 4 numpy.int32 rate : int The sampling frequency (i.e. frame rate or sample rate) of the WAV file. sampwidth : int The sample width (i.e. number of bytes per sample) of the WAV file. For example, `sampwidth == 3` is a 24 bit WAV file. Notes ----- This function uses the `wave` module of the Python standard libary to read the WAV file, so it has the same limitations as that library. In particular, the function does not read compressed WAV files, and it does not read files with floating point data. The array returned by `wavio.read` is always two-dimensional. If the WAV data is mono, the array will have shape (num_samples, 1). `wavio.read()` does not scale or normalize the data. The data in the array `wav.data` is the data that was in the file. When the file contains 24 bit samples, the resulting numpy array is 32 bit integers, with values that have been sign-extended. """ wav = _wave.open(file) rate = wav.getframerate() nchannels = wav.getnchannels() sampwidth = wav.getsampwidth() nframes = wav.getnframes() data = wav.readframes(nframes) wav.close() array = _wav2array(nchannels, sampwidth, data) w = Wav(data=array, rate=rate, sampwidth=sampwidth) return w _sampwidth_dtypes = {1: _np.uint8, 2: _np.int16, 3: _np.int32, 4: _np.int32} _sampwidth_minmax = {1: (0, 255), 2: (-2**15, 2**15 - 1), 3: (-2**23, 2**23 - 1), 4: (-2**31, 2**31 - 1)} def _float_to_integer(x, sampwidth, scale=None, clip="warn"): # For a given sampwidth and scale, the actual allowed # interval for float input is [-(1 + 1/c)*scale, scale], # where c = 2**(8*sampwidth - 1) - 0.5. Values outside # that interval will result in clipping. nbits = 8*sampwidth c = 2**(nbits - 1) - 0.5 if scale == "auto": scale = max(_np.max(_np.r_[x[x > 0], 0]), _np.max(_np.r_[-x[x < 0], 0])/(1 + 1/c)) elif scale is None: scale = 1.0 if sampwidth == 1: int_min = 0 midpoint = 128 int_max = 255 else: int_min = -2**(nbits - 1) midpoint = 0 int_max = 2**(nbits - 1) - 1 scaled_x = x / scale if _np.any(scaled_x > 1) or _np.any(scaled_x < -1 - 1/c): msg = (f'Some data values have been clipped. With scale={scale}, the ' 'interval of input values that will not be clipped ' f'is [{-(1 + 1/c)*scale}, {scale}]') if clip == "warn": _warnings.warn(ClippedDataWarning(msg)) elif clip == "raise": raise ClippedDataError(msg) y = midpoint + _round_with_half_towards_zero(x/scale*c) y = _np.clip(y, int_min, int_max).astype(_sampwidth_dtypes[sampwidth]) return y def _round_with_half_towards_zero(x): s = _np.sign(x) return s * _np.ceil(_np.abs(x) - 0.5) def write(file, data, rate, scale=None, sampwidth=None, clip="warn"): """ Write the numpy array `data` to a WAV file. The Python standard library "wave" is used to write the data to the file, so this function has the same limitations as that module. In particular, the Python library does not support floating point data, so this function must convert floating point input to integers before writing the data to the file. See below for the conversion rules. *Important notes* * If `data` has an *integer* data type, signed or unsigned and any bit depth, the values are never scaled or shifted. The only possible changes to the values that can occur is if the data must be clipped to fit the desired output sample width. It is an error to give a value for the `scale` parameter if `data` has an integer data type. * If `data` is a floating point type, `sampwidth` must be given. The default behavior is to scale input values in the range [-1, 1] to the output range [min_int+1, max_int], where min_int and max_int are the minimum and maximum values of the output data type determined by `sampwidth`. See the description of `scale` below for more options. Parameters ---------- file : string, or file object open for writing in binary mode Either the name of a file or an open file pointer. data : numpy array, 1- or 2-dimensional, integer or floating point If it is 2-d, the rows are the frames (i.e. samples) and the columns are the channels. rate : int The sampling frequency (i.e. frame rate) of the data. sampwidth : int, optional The sample width, in bytes, of the output file. If `sampwidth` is not given, it is inferred (if possible) from the data type of `data`, as follows:: data.dtype sampwidth ---------- --------- uint8, int8 1 uint16, int16 2 uint32, int32 4 For any other data types, or to write a 24 bit file, `sampwidth` must be given. scale : float, optional This controls the output range when the input is floating point. `scale` must not be given when the input data has integer data type. if `scale` is a numeric value, then input values in the range `[-scale, scale]` are mapped to the integer output range centered at the midpoint of the output range. For 8 bit unsigned integer output (i.e. `sampwdith=1`), the midpoint is 128. For `sampwidth` 2, 3 or 4 (corresponding to signed integer output), the midpoint is 0. If `scale` is the string `"auto"`, the data written to the file is scaled up or down to occupy the full range of the output data type. For example, with `sampwidth=2` the input `data=[-0.5, -1.0, 0.0, 0.25, 1.0]` would result in the 16 signed integers [-16384, -32767, 0, 8192, 32767] being written to the WAV file. By setting `scale=2`, the output values would be [-8192, -16384, 0, 4096, 16384]. When the input is floating point and `scale` is not given, the default value `scale=1.0` is used. clip : str, optional If "warn" (the default), the function will generate a warning if any of the data values must be clipped when written to the format of the output array. If "raise", the function will raise an exception if clipping occurs. If "ignore", no warning or exception is generated if clipping occurs. Examples -------- Create a 3 second 440 Hz sine wave, and save it in a 24-bit WAV file. >>> import numpy as np >>> import wavio >>> rate = 22050 # samples per second >>> T = 3 # sample duration (seconds) >>> n = int(rate*T) # number of samples >>> t = np.arange(n)/rate # grid of time values >>> f = 440.0 # sound frequency (Hz) >>> x = np.sin(2*np.pi * f * t) `x` is a single sine wave with amplitude 1, so we can use the default `scale`. >>> wavio.write("sine24.wav", x, rate, sampwidth=3) Create a file that contains the 16 bit integer values -10000 and 10000 repeated 100 times. Use a sample rate of 8000. >>> x = np.empty(200, dtype=np.int16) >>> x[::2] = -10000 >>> x[1::2] = 10000 >>> wavio.write("foo.wav", x, 8000) Check that the file contains what we expect. The values are checked for exact equality. The input was an integer array, so the values are not scaled. >>> w = wavio.read("foo.wav") >>> np.all(w.data[:, 0] == x) True Write floating point data to a 16 bit WAV file. The floating point values are assumed to be within the range [-2, 2], and we want the values 2 and -2 to correspond to the full output range, even if the actual values in the data do not fill this range. We do that by specifying `scale=2`. `T`, `rate` and `t` are from above. The data is the sum of two sinusoids, with frequencies 440 and 880 Hz, modulated by a parabolic curve that is zero at the start and end of the data. >>> envelope = (4/T**2)*(t * (T - t)) >>> omega1 = 2*np.pi*440 >>> omega2 = 2*np.pi*880 >>> y = envelope*(np.sin(omega1*t) + 0.3*np.sin(omega2*t + 0.2)) >>> y.min(), y.max() (-1.1745469775555515, 1.093833464065767) Write the WAV file, with `scale=2`. >>> wavio.write('harmonic.wav', y, rate, sampwidth=2, scale=2) Check the minimum and maximum integers that were actually written to the file: >>> w = wavio.read("harmonic.wav") >>> w.data.min(), w.data.max() (-19243, 17921) If we want the WAV file to use as much of the range of the output integer type as possible (while still mapping 0.0 in the input to 0 in the output), we set `scale="auto"`. >>> wavio.write('harmonic_full.wav', y, rate, sampwidth=2, scale="auto") >>> w = wavio.read('harmonic_full.wav') >>> w.data.min(), w.data.max() (-32768, 30517) """ if clip not in ["ignore", "warn", "raise"]: raise ValueError('clip must be one of "ignore", "warn" or "raise".') data = _np.asarray(data) if sampwidth is None: if not _np.issubdtype(data.dtype, _np.integer) or data.itemsize > 4: raise ValueError('when data.dtype is not an 8-, 16-, or 32-bit ' 'integer type, sampwidth must be specified.') sampwidth = data.itemsize else: if sampwidth not in [1, 2, 3, 4]: raise ValueError('sampwidth must be 1, 2, 3 or 4.') outdtype = _sampwidth_dtypes[sampwidth] outmin, outmax = _sampwidth_minmax[sampwidth] if _np.issubdtype(data.dtype, _np.integer): if scale is not None: raise ValueError('The scale parameter must not be set when the ' 'input is an integer array. No shifting or ' 'scaling is done to integer input values.') if (data.min() < outmin or data.max() > outmax): if clip == "warn": _warnings.warn(ClippedDataWarning()) elif clip == "raise": raise ClippedDataError() clip_min = max(outmin, _np.iinfo(data.dtype).min) clip_max = min(outmax, _np.iinfo(data.dtype).max) data = data.clip(clip_min, clip_max).astype(outdtype) elif _np.issubdtype(data.dtype, _np.floating): data = _float_to_integer(data, sampwidth, scale=scale, clip=clip) else: raise TypeError(f'unsupported input array data type: {data.dtype}') # At this point, `data` has been converted to have one of the following: # sampwidth dtype # --------- ----- # 1 uint8 # 2 int16 # 3 int32 # 4 int32 # The values in `data` are in the form in which they will be saved; # no more scaling will take place. if data.ndim == 1: data = data.reshape(-1, 1) wavdata = _array2wav(data, sampwidth) w = _wave.open(file, 'wb') w.setnchannels(data.shape[1]) w.setsampwidth(sampwidth) w.setframerate(rate) w.writeframes(wavdata) w.close()