Source code for chaudio.source

"""Audio Source (:mod:`chaudio.source`)
===================================================

.. currentmodule:: chaudio.source


All these are essentially abstractions above a data array of samples.

Has support for variable number of channels, any samplerate, and data type

Operators are overriden, so that you can apply them to a constant, numpy array, or other audio source

When you set a property (like source.hz or source.channels), the internal data is updated automatically.

"""

import numpy as np

import scipy
import scipy.signal

import chaudio


[docs]class Source(object): """Represents the default audio source, with variable number of channels and samplerate """ # initialize, given any data, and optionally hz and dtype (which are determined based on 'data', or chaudio defaults)
[docs] def __init__(self, data, hz=None, dtype=None): """Source creation routine Creates a Source consisting of ``data``. If ``data`` is a np.ndarray, assume that these are raw sample data, taken at ``hz`` samplerate (if none is given, ``44100``). If no ``dtype`` is given, default to ``data.dtype`` If ``data`` is a tuple or list, assume that it contains channel data, and set the number of channels to the length of the tuple/list, and each individual channel to the np.ndarray at the corresponding index. If ``data`` is :class:`chaudio.source.Source`, copy it, but apply the ``hz`` and ``dtype`` parameters for the new format. If ``hz`` isn't given, use ``data.hz`` as the default, and do the same with ``dtype`` and ``data.dtype``. If ``data`` is :class:`chaudio.arrangers.Arranger`, calculate its data, and turn into a source. If ``data`` is a chaudio class, resample the data to ``hz``. Otherwise, assume it is the input was sampled at ``hz`` per second. Parameters ---------- data : np.ndarray, :class:`chaudio.source.Source`, :class:`chaudio.arrangers.Arranger`, tuple, list Describes how to gather data. See beats : int, float Number of beats (or pulses) per measure division : int, float Note division that represents a single pulse bpm : int, optional The speed, in beats per minute """ if issubclass(type(data), Source): # if we are creating a Source from another Source, just essentially copy it self._data = data.copy_data() self._channels = data.channels elif type(data) in (tuple, list): # if we are given a python tuple or list, we assume that it contains the channels of data assert(len(data) >= 1) self._data = list(data) elif type(data) is np.ndarray: # assume a mono input self._data = [data] elif issubclass(type(data), chaudio.arrangers.Arranger): # if it is an arranger, treat it like we passed in it's source self._data = data.source.copy_data() self._channels = data.source.channels else: raise Exception("Don't know how to convert '%s' into '%s'" % (type(data).__name__, type(self).__name__)) # only try to determine hz if we were not given any if hz is None: if issubclass(type(data), Source): # just copying an existing source's hz self._hz = data.hz elif issubclass(type(data), chaudio.arrangers.Arranger): # copy it as if we passed in the arranger's source self._hz = data.source.hz else: # return a default self._hz = chaudio.defaults["hz"] else: # explicitly given, so just outright set it self._hz = hz # only try to determine dtype if we were not given any if dtype is None: if issubclass(type(data), Source): # just copying an existing source's dtype self._dtype = data.dtype elif issubclass(type(data), chaudio.arrangers.Arranger): # act as if we passed in the arranger's source self._dtype = data.source.dtype elif type(data) is np.ndarray: # the numpy array stores this info, so try that self._dtype = data.dtype else: # return a default (this should be a float-like type) self._dtype = chaudio.defaults["dtype"] else: # explicitly given self._dtype = dtype # this self.dtype forces the internal data to update, so leave this here self.dtype = self._dtype
# returns a copy (so that any mutations done will not affect the original)
[docs] def copy(self): """Returns a copy of the item Returns ------- :class:`chaudio.source.Source` An exact copy of the current object """ return type(self)(self.copy_data(), self.hz)
# copies just the internal data as a list of channel data
[docs] def copy_data(self): """Returns a copy of the raw sample data Returns ------- list of np.ndarray Channels with np.ndarray 's describing the sample data """ r = [None] * self.channels for i in range(0, self.channels): r[i] = np.copy(self.data[i]) return r
[docs] def resample(self, tohz): """Internally adjust the sample rate in a smart way (using FFT and IFFT) This doesn't return anything, so it changes the object it's called on. To return a new source, and not change the one being called, use :meth:`chaudio.source.Source.resampled` Parameters ---------- tohz : int, float The sample rate, in samples per second """ self.hz = tohz
[docs] def resampled(self, tohz): """Returns a copy of the current object resampled to ``tohz`` To not make a copy, and instead alter the object in place, use :meth:`chaudio.source.Source.resample` Parameters ---------- tohz : int, float The sample rate, in samples per second Returns ------- :class:`chaudio.source.Source` A copy of the object resampled to ``tohz`` samplerate """ res = self.copy() res.resample(tohz) return res
[docs] def rechannel(self, tochannels): """Internally adjust how many channels are stored This doesn't return anything, so it changes the object it's called on. To return a new source, and not change the one being called, use :meth:`chaudio.source.Source.rechanneled` If ``tochannels == self.channels``, no change is made. Else, the behaviour is thus: If ``tochannels == 1`` (which means ``self.channels == 2``), the new data array contains 1 item, which is the average of the previous two channels. This will roughly sound the same (as in if you have any sounds that are purely in one channel or the other, they will still be heard). If ``tochannels == 2`` (which means ``self.channels == 1``), the new data array is the old one, but duplicated. To return an altered copy, and not change the object itself, use :meth:`chaudio.source.Source.rechanneled` Parameters ---------- tochannels : { 1, 2 } The number of channels the source should have. Must be ``1`` or ``2``. """ self.channels = tochannels
[docs] def rechanneled(self, tochannels): """Return a copy of the object, with a specified number of channels To not make a copy, and instead alter the object in place, use :meth:`chaudio.source.Source.rechannel` Parameters ---------- tochannels : { 1, 2 } The number of channels the source should have. Must be ``1`` or ``2``. Returns ------- :class:`chaudio.source.Source` A copy of the object with the number of channels changed to ``tochannels`` """ res = self.copy() res.channels = tochannels return res
[docs] def redtype(self, todtype): """Internally adjust what data format is used This probably shouldn't be used by your application, as it does not rescale values. It's main use is in the :mod:`chaudio.util` module, for outputting as WAVE data. Changes the internal data format To make a copy, and not alter the current object, use :meth:`chaudio.source.Source.redtyped` Parameters ---------- todtype : { np.int8, np.int16, np.int32, np.float32, np.float16 } Numpy data format """ self.dtype = todtype
[docs] def redtyped(self, todtype): """Return a copy of the object, with a specified internal data format To not make a copy, and instead alter the object in place, use :meth:`chaudio.source.Source.redtype` Parameters ---------- todtype : { np.int8, np.int16, np.int32, np.float32, np.float16 } Numpy data format Returns ------- :class:`chaudio.source.Source` A copy of the object with the data format changed to ``todtype`` """ res = self.copy() res.dtype = todtype return res
# return list of channel data
[docs] def get_data(self): return self._data
# sets the data, and must be a list or tuple of channel data
[docs] def set_data(self, v): assert(type(v) in (list, tuple)) self._data = list(v)
data = property(get_data, set_data) # returns the internal data format
[docs] def get_dtype(self): return self._dtype
# essentially casts the internal data to the new type
[docs] def set_dtype(self, v): for i in range(0, self.channels): self.data[i] = self.data[i].astype(v) self._dtype = v
dtype = property(get_dtype, set_dtype) # _channels is no variable, so it is determined by the length of the list of channel data
[docs] def get_channels(self): return len(self.data)
# this may need support for more than 2 channels (which is probably not used anyways)
[docs] def set_channels(self, v): # only these are supported, as of now (not sure how's the best way to handle other cases) assert(v in (1, 2)) if v != self.channels: # we know that our channels is 1 if v = 2, and 2 if v = 1 if v == 1: assert(len(self.data) == 2) # average the data, so that we don't lose any parts. # TODO: perhaps add configuration kwarg (and chaudio.defaults) for a method to decrease number of channels? self.data = [(self.data[0] + self.data[1]) / 2.0] else: assert(len(self.data) == 1) # just duplicate the single channel we already have self.data = [self.data[0], self.data[0]]
channels = property(get_channels, set_channels) # return sample rate (in samples / second)
[docs] def get_hz(self): return self._hz
# will automatically resample the internal array
[docs] def set_hz(self, v): if self.hz != v: for i in range(0, self.channels): # TODO: consider adding optimized methods for resampling to an integer multiple of the old samplerate # this method internally probably will end up using FFT most of the time, and will thus be time consuming for large inputs self.data[i] = scipy.signal.resample(self.data[i], int(len(self.data[i]) * v / self._hz)) self._hz = v
hz = property(get_hz, set_hz) # returns a floating point value representing the length of this source in seconds
[docs] def get_seconds(self): return float(self.samples) / self.hz
[docs] def set_seconds(self): # this is called when someone tries: source.seconds = 2 # this isn't supported, because it is not the function of source to provide high level functionality, such as autoresizing raise Exception("seconds is not a settable property!")
seconds = property(get_seconds, set_seconds) # returns how many samples all the channels last (all channels should be equal length)
[docs] def get_samples(self): # all channels should be the same length, so we check that agree_len = len(self.data[0]) for i in range(1, len(self.data)): if agree_len != len(self.data[i]): # people should never see this raise Exception("internal error: channels have different length (please report this issue!)") # if all are the same, return the length they agree at return agree_len
[docs] def set_samples(self, v): # this isn't supported, because it's not needed. Instead, use source.ensure(samples) # there should not be a property (such as samples) that can internally append or change length. Only methods should handle that raise Exception("samples is not a settable property!")
samples = property(get_samples, set_samples) # overriding methods # string representation, showing channels, samples, and samplerate
[docs] def __str__(self): return "Source[%s] %sl %.2fs %shz" % (self.channels, self.samples, self.seconds, self.hz)
# returns length in samples
[docs] def __len__(self): return self.samples
# use like source[channel:index] (channel can be : for all channels, like source[:,0]) # this returns either a numpy array or tuple/list of numpy arrays (for multiple channels) # source[N,X] acts like self.data[N][X] (N and X can be anything), which is a list of channels with X applied to each # source[N] is the same as source.data[N]
[docs] def __getitem__(self, key): """Return a portion of the data in a source If ``key`` is an int or slice, return the channels indicated, in list format. So, use ``source[:]`` to return all channels as a list, or ``source[0]`` for the 0th channel (which is left on a stereo source). If ``key`` is a tuple, return all the channels represented by ``source[key[0]]`` subscripted with ``key[1]``. So, ``source[0, :5]`` returns the first 5 samples of the ``0``th channel. ``source[:, :5]`` returns a list of the first 5 values for each channel. Parameters ---------- key : int, slice, tuple If int or slice, return the channels represented by ``key``. If it's a tuple, return channel[key[1]] for each channel represented by ``key[0]``. See examples for more info. Returns ------- list of np.ndarray or np.ndarray If the ``key`` specified a single channel, return just that channel's specified data as np.ndarray. If multiple channels are indicated, return a list of channel data. """ assert(type(key) in (int, tuple, slice)) if type(key) is tuple: # it has multiple keys, so return them each channel with the key applied assert(len(key) == 2) k0 = self.data[key[0]] if type(k0) is not list: return k0[key[1]] else: return [i[key[1]] for i in k0] else: # they are asking for a specific channel, so just give them a channel return self.data[key]
# source[N] = V sets the Nth channel to V, which should be a numpy array # source[N,X] = V is tricky logic, but tries to emulate the inverse of __getitem__ # thus, in general the assumed function of source.__setitem__(k, v) should work like this: # source.__setitem__(k, source.__getitem__(k)) means source does not functionally change # however, this also supports setting to another Source, although this is still expiremental
[docs] def __setitem__(self, key, val): """Set a portion of the data in a source If ``key`` is an int or slice, set the channels indicated, in list format. So, use ``source[:] = y`` to set channels to a y, which must be a list of np.ndarray. If ``key`` is a tuple, set all the channels represented by ``source[key[0]]`` subscripted with ``key[1]`` to ``val``. So, ``source[0, :5] = y`` sets the first five values of the ``0``th channels to y. Note that ``y`` must be either a constant, or have the same shape as the values it is replacing. In our example, ``y`` would have to be a constant, or a np.ndarray with length 5 In general, the following should hold for any source ``x``, key ``key``, and value ``val``: >>> x[key] = val >>> print (x[key] == val) True Parameters ---------- key : int, slice, tuple If int or slice, set the channel data represented by ``key``. If it's a tuple, set channel[key[1]] for each channel represented by ``key[0]``. See examples for more info. val : int, float, list, tuple, np.ndarray The value to set the specified samples to. If it is a list, tuple, or np.ndarray, it must be the same shape as the values it is replacing. So, if saying ``x[0, :5] = y``, ``y`` must be int, float, or ``len(y)`` must be 5. Returns ------- list of np.ndarray or np.ndarray If the ``key`` specified a single channel, return just that channel's specified data as np.ndarray. If multiple channels are indicated, return a list of channel data. """ assert(type(key) in (int, tuple, slice)) if type(key) is tuple: if issubclass(type(val), Source): # warn them chaudio.msgprint("warning: using __setitem__ with a Source value on a Source object may give unexpected results") valc = val.copy() valc.channels = self.channels valc.hz = self.hz self.data[key[0]][key[1]] = valc else: rs = self.data[key[0]] if not isinstance(rs, list): self.data[key[0]] = val else: for x in rs: x[key[1]] = val else: # they are asking for a specific channel self.data[key] = val
# this inserts something at an offset (in samples)
[docs] def clear(self): """Empties all data To return a copy and not modify the original object, use :meth:`chaudio.source.Source.cleared`. This empties all data out """ self.data = [np.zeros(0, dtype=self.dtype) for x in range(0, self.channels)]
# this inserts something at an offset (in samples)
[docs] def cleared(self): """Empties all data, and returns a copy To modify in place, :meth:`chaudio.source.Source.clear`. This empties all data out """ res = self.copy() res.clear() return res
# this inserts something at an offset (in samples)
[docs] def insert(self, offset, _val): """Inserts samples at a given offset To return a copy and not modify the original object, use :meth:`chaudio.source.Source.inserted`. This clears ``data[offset:offset+len(_val)]``, and sets it to ``_val`` Parameters ---------- offset : int What sample to insert at _val : numpy.ndarray, :class:`chaudio.source.Source`, :class:`chaudio.arrangers.Arranger` This is converted to a source internally, see :meth:`chaudio.source.Source.__init__` for details on how this is done. """ val = Source(_val) val.hz = self._hz val.channels = self.channels # ensure we have enough room self.ensure(length=offset + len(val)) # loop through and insert them (replacing data at that position) for i in range(0, self.channels): self._data[i][offset:offset+len(val)] = val._data[i]
[docs] def inserted(self, offset, _val): """Returns a copy with inserted samples at a given offset To not make a copy, and rather edit inplace, use :meth:`chaudio.source.Source.insert`. Parameters ---------- offset : int What sample to insert at _val : numpy.ndarray, :class:`chaudio.source.Source`, :class:`chaudio.arrangers.Arranger` This is converted to a source internally, see :meth:`chaudio.source.Source.__init__` for details on how this is done. Returns ------- :class:`chaudio.source.Source` A copy of the object with the ``data[offset:offset+len(_val)]`` assigned to ``_val``. """ res = self.copy() res.insert(offset, _val) return res
# plops in _v before the existing self._data
[docs] def prepend(self, _val): """Prepend values to the data array To return a copy and not modify the original object, use :meth:`chaudio.source.Source.prepended`. This sets ``self.data`` to ``_val`` and ``data`` concatenated. This essentially can be used to add delays, silence, or prepend any other data Parameters ---------- _val : numpy.ndarray, :class:`chaudio.source.Source`, :class:`chaudio.arrangers.Arranger` This is converted to a source internally, see :meth:`chaudio.source.Source.__init__` for details on how this is done. """ v = Source(_val) v.hz = self.hz v.channels = self.channels for i in range(0, self.channels): self._data[i] = np.append(v.data[i], self._data[i])
# returns a copy
[docs] def prepended(self, _val): """Returns a copy with prepended values to the data array To modify the original object and not make a copy, use :meth:`chaudio.source.Source.prepend`. This returns a copy of the object called on, with ``_val`` prepended before it. Parameters ---------- _val : numpy.ndarray, :class:`chaudio.source.Source`, :class:`chaudio.arrangers.Arranger` This is converted to a source internally, see :meth:`chaudio.source.Source.__init__` for details on how this is done. Returns ------- :class:`chaudio.source.Source` ``_val`` and the object called with concatenated """ r = self.copy() r.prepend(_val) return r
# appends _v after the existing self._data
[docs] def append(self, _val): """Append values to the data array To return a copy and not modify the original object, use :meth:`chaudio.source.Source.appended`. This sets ``self.data`` to ``data`` and ``_val`` concatenated. This tacks on ``_val`` to the end. Parameters ---------- _val : numpy.ndarray, :class:`chaudio.source.Source`, :class:`chaudio.arrangers.Arranger` This is converted to a source internally, see :meth:`chaudio.source.Source.__init__` for details on how this is done. """ v = Source(_val) v.hz = self.hz v.channels = self.channels for i in range(0, self.channels): self._data[i] = np.append(self._data[i], v.data[i])
# returns a copy
[docs] def appended(self, _val): """Returns a copy of the object with values appended to the data array To not make a copy, and instead modify the object called with, use :meth:`chaudio.source.Source.append`. Parameters ---------- _val : numpy.ndarray, :class:`chaudio.source.Source`, :class:`chaudio.arrangers.Arranger` This is converted to a source internally, see :meth:`chaudio.source.Source.__init__` for details on how this is done. Returns ------- :class:`chaudio.source.Source` A copy of the current object, but with ``_val`` appended. """ r = self.copy() r.append(_val) return r
[docs] def ensure(self, length=None): """Makes sure that the source is a certain length, which will append 0's to the end if needed To return a copy and not modify the original object, use :meth:`chaudio.source.Source.ensured`. Parameters ---------- _val : numpy.ndarray, :class:`chaudio.source.Source`, :class:`chaudio.arrangers.Arranger` This is converted to a source internally, see :meth:`chaudio.source.Source.__init__` for details on how this is done. """ if length is not None and length > len(self._data[0]): needed_len = length - len(self._data[0]) pad = np.zeros((needed_len, ), dtype=self.dtype) for i in range(0, self.channels): self._data[i] = np.append(self._data[i], pad)
# returns a copy
[docs] def ensured(self, length=None): """Makes a copy that is guaranteed to be a certain length, which will append 0's to the end if needed To modify the original object, use :meth:`chaudio.source.Source.ensure`. Parameters ---------- _val : numpy.ndarray, :class:`chaudio.source.Source`, :class:`chaudio.arrangers.Arranger` This is converted to a source internally, see :meth:`chaudio.source.Source.__init__` for details on how this is done. Returns ------- :class:`chaudio.source.Source` A copy of the object being called, but it is guaranteed to be of a certain length """ r = self.copy() r.ensure(length=length) return r
# returns what to operate on channels with any operator
[docs] def __opit__(self, v): if issubclass(type(v), Source): v.hz = self.hz v.channels = self.channels return v.data elif issubclass(type(v), chaudio.arrangers.Arranger): return self.__opit__(v.source) elif type(v) is np.ndarray: return [v] * self.channels else: r = [np.array([v] * self.samples, dtype=self.dtype)] * self.channels return r
# all operators are distributed if float/int, or paired if arrays
[docs] def __add__(self, _v): r = self.copy() v = self.__opit__(_v) for i in range(0, self.channels): r.data[i] += v[i] return r
[docs] def __sub__(self, _v): r = self.copy() v = self.__opit__(_v) for i in range(0, self.channels): r.data[i] -= v[i] return r
[docs] def __mul__(self, _v): r = self.copy() v = self.__opit__(_v) for i in range(0, self.channels): r.data[i] *= v[i] return r
[docs] def __div__(self, _v): r = self.copy() v = self.__opit__(_v) for i in range(0, self.channels): r.data[i] /= (v[i]) return r
[docs] def __truediv__(self, _v): r = self.copy() v = self.__opit__(_v) for i in range(0, self.channels): r.data[i] /= v[i] return r
[docs] def __floordiv__(self, _v): r = self.copy() v = self.__opit__(_v) for i in range(0, self.channels): r.data[i] //= v[i] return r
[docs] def __mod__(self, _v): r = self.copy() v = self.__opit__(_v) for i in range(0, self.channels): r.data[i] %= v[i] return r
[docs] def __pow__(self, _v): r = self.copy() v = self.__opit__(_v) for i in range(0, self.channels): r.data[i] **= v[i] return r
__radd__ = __add__ __rsub__ = __sub__ __rmul__ = __mul__ __rdiv__ = __div__ __rtruediv__ = __truediv__ __rfloordiv__ = __floordiv__ __rmod__ = __mod__ __rpow__ = __pow__
[docs]class Mono(Source):
[docs] def __init__(self, data, hz=None): """A source with only 1 channel Returns ------- :class:`chaudio.source.Mono` The same as using :meth:`chaudio.source.Source.__init__`, but then ensures there is only 1 channel """ super().__init__(data, hz) self.channels = 1
[docs] def set_channels(self, v): raise Exception("Can not set channels on a %s source!" % type(self).__name__)
[docs]class Stereo(Source):
[docs] def __init__(self, data, hz=None): """A source with only 2 channels Returns ------- :class:`chaudio.source.Stereo` The same as using :meth:`chaudio.source.Source.__init__`, but then ensures there is only 2 channel """ super().__init__(data, hz) self.channels = 2
[docs] def set_channels(self, v): raise Exception("Can not set channels on a %s source!" % type(self).__name__)