""" The module of Sound class for handling sound """
import time
import typing
import numpy
import pyaudio
import soundfile
from gensound.exceptions import *
def _repeat_array(sound: numpy.array, want_length: int) -> numpy.array:
""" Repeat numpy.array for enlarging a sound duration
:param sound: Sound data for repeat.
:param want_length: The length of an output array.
:return: Repeated sound data.
:exception ValueError:
>>> array = numpy.array([[1, 2, 3]]).T
>>> numpy.allclose(_repeat_array(array, 6),
... numpy.array([[1, 2, 3, 1, 2, 3]]).T)
True
>>> numpy.allclose(_repeat_array(array, 8),
... numpy.array([[1, 2, 3, 1, 2, 3, 1, 2]]).T)
True
>>> numpy.allclose(_repeat_array(array, 2), numpy.array([[1, 2]]).T)
True
"""
if want_length <= 0:
raise ValueError(
'want_length must be greater than 0 but got {}'.format(want_length)
)
if len(sound.shape) != 2:
raise ValueError('sound should two dimensions')
if sound.shape[0] <= 0 or sound.shape[1] <= 0:
raise ValueError('sound should have least one element')
need_length = int(numpy.ceil(want_length / sound.shape[0]))
drop_length = int(need_length * sound.shape[0] - want_length)
repeated = (sound.T
.reshape([sound.shape[1], sound.shape[0], 1])
.repeat(need_length, axis=0)
.reshape([sound.shape[1], -1]).T)
if drop_length > 0:
return repeated[:-drop_length]
else:
return repeated
def _assertFrequency(frequency: float) -> None:
""" Check if the frequency is greater than 0 """
if frequency <= 0:
raise InvalidFrequencyError(frequency)
def _assertSamplerate(samplerate: float) -> None:
""" Check if the samplerate is greater than 0 """
if samplerate <= 0:
raise InvalidSamplerateError(samplerate)
def _assertSameSamplerate(samplerates: typing.Sequence[float]) -> None:
""" Check if all samplerates is same value """
if any(samplerates[0] != s for s in samplerates[1:]):
raise DifferentSamplerateError(tuple(samplerates))
def _assertSameChannels(channels: typing.Sequence[int]) -> None:
""" Check if all number of channels is same value """
if any(channels[0] != c for c in channels[1:]):
raise DifferentChannelsError(tuple(channels))
def _assertDuration(duration: float) -> None:
""" Check if the duration is longer than 0 """
if duration <= 0:
raise InvalidDurationError(duration)
def _assertVolume(volume: float) -> None:
""" Check if the volume in between 0.0 to 1.0 """
if volume < 0.0 or 1.0 < volume:
raise InvalidVolumeError(volume)
[docs]class Sound:
""" The class for handling sound
:param data: Sound data array. First dimension is time, second
dimension is channel. Will clipping if value were out of
-1.0 to 1.0.
:param samplerate: Sampling rate of sound data.
:exception ValueError: Data was invalid dimensions.
:exception InvalidSamplerateError: Samplerate was 0 or less.
:exception InvalidDurationError: Data was empty.
"""
def __init__(self, data: numpy.array, samplerate: float) -> None:
if len(data.shape) > 2:
raise ValueError('data dimensions must be 1 or 2 but got {}'
.format(len(data.shape)))
if len(data.shape) == 1:
data = data.reshape([-1, 1])
_assertSamplerate(samplerate)
_assertDuration(data.shape[0])
self._data = data.clip(-1.0, 1.0)
self._samplerate = samplerate
@classmethod
[docs] def from_sinwave(cls,
frequency: float,
duration: float = 1.0,
volume: float = 1.0,
samplerate: float = 44100,
smooth_end: bool = True) -> 'Sound':
""" Generate sine wave sound
:param frequency: Frequency of new sound.
:param duration: Duration in seconds of new sound.
:param volume: The volume of new sound.
:param samplerate: Sampling rate of new sound.
:param smooth_end: Do make smooth end or not. Please see above.
:return: A new :class:`Sound` instance.
:exception InvalidDurationError: Duration was 0 or less.
:exception InvalidFrequencyError: Frequency was 0 or less.
:exception InvalidSamplerateError: Samplerate was 0 or less.
:exception InvalidVolumeError: Volume was lower than 0.0 or higher
than 1.0.
This example makes 440Hz sine wave sound.
>>> sound = Sound.from_sinwave(440)
Can set duration and volume with arguments.
This example makes 2 seconds, 50% volume.
>>> sound = Sound.from_sinwave(440, duration=2.0, volume=0.5)
Can make 2 seconds sine wave with repeat too. But, this way may make
noise at the joint point of sounds.
Recommend using from_sinwave() as possible.
Make a smooth end if smooth_end is true. but duration will inaccuracy.
This error is not critical most cases so smooth_end is true by default.
>>> sound = Sound.from_sinwave(880, duration=1.0, smooth_end=True)
>>> sound.duration
1.017687074829932
Please pass false to smooth_end if want accurate duration. But please
be careful, may make noise in end of sound if disable smooth_end.
>>> sound = Sound.from_sinwave(880, duration=1.0, smooth_end=False)
>>> sound.duration
1.0
"""
_assertDuration(duration)
_assertFrequency(frequency)
_assertSamplerate(samplerate)
_assertVolume(volume)
wavelength = samplerate / frequency
one_wave = numpy.sin(
numpy.arange(wavelength) / wavelength * 2 * numpy.pi
) * volume
repeat_count = int(numpy.round(duration * samplerate / wavelength))
repeated = numpy.repeat(one_wave.reshape([1, -1]),
repeat_count,
axis=0).flatten()
if smooth_end is False:
repeated = repeated[:int(numpy.round(duration * samplerate))]
return cls(repeated, samplerate)
@classmethod
[docs] def from_sawtoothwave(cls,
frequency: float,
duration: float = 1.0,
volume: float = 1.0,
samplerate: float = 44100) -> 'Sound':
""" Generate sawtooth wave sound
:param frequency: Frequency of new sound.
:param duration: Duration in seconds of new sound.
:param volume: The volume of new sound.
:param samplerate: Sampling rate of new sound.
:return: A new :class:`Sound` instance.
:exception InvalidDurationError: Duration was 0 or less.
:exception InvalidFrequencyError: Frequency was 0 or less.
:exception InvalidSamplerateError: Samplerate was 0 or less.
:exception InvalidVolumeError: Volume was lower than 0.0 or higher
than 1.0.
"""
_assertDuration(duration)
_assertFrequency(frequency)
_assertSamplerate(samplerate)
_assertVolume(volume)
count = numpy.arange(0, duration, 1 / samplerate) * frequency
data = count % 1
data /= data.max()
return cls((data * 2 - 1) * volume, samplerate)
@classmethod
[docs] def from_squarewave(cls,
frequency: float,
duration: float = 1.0,
volume: float = 1.0,
samplerate: float = 44100) -> 'Sound':
""" Generate square wave sound
:param frequency: Frequency of new sound.
:param duration: Duration in seconds of new sound.
:param volume: The volume of new sound.
:param samplerate: Sampling rate of new sound.
:return: A new :class:`Sound` instance.
:exception InvalidDurationError: Duration was 0 or less.
:exception InvalidFrequencyError: Frequency was 0 or less.
:exception InvalidSamplerateError: Samplerate was 0 or less.
:exception InvalidVolumeError: Volume was lower than 0.0 or higher
than 1.0.
"""
_assertDuration(duration)
_assertFrequency(frequency)
_assertSamplerate(samplerate)
_assertVolume(volume)
return cls(numpy.hstack([
numpy.ones(int(numpy.round(samplerate / frequency / 2))),
-numpy.ones(int(numpy.round(samplerate / frequency / 2))),
]) * volume, samplerate).repeat(duration)
@classmethod
[docs] def silence(cls,
duration: float = 1.0,
samplerate: float = 44100) -> 'Sound':
""" Generate silent sound
:duration: Duration of new sound.
:samplerate: Sampling rate of new sound.
:return: A new :class:`Sound` instance.
:exception InvalidDurationError: Duration was 0 or less.
:exception InvalidSamplerateError: Samplerate was 0 or less.
"""
_assertDuration(duration)
_assertSamplerate(samplerate)
length = int(numpy.round(duration * samplerate))
return cls(numpy.array([0] * length), samplerate)
@classmethod
[docs] def from_whitenoise(cls,
duration: float = 1.0,
volume: float = 1.0,
samplerate: float = 44100) -> 'Sound':
""" Generate white noise
:param duration: Duration in seconds of new sound.
:param volume: The volume of new sound.
:param samplerate: Sampling rate of new sound.
:return: A new Sound instance.
:exception InvalidDurationError: Duration was 0 or less.
:exception InvalidSamplerateError: Samplerate was 0 or less.
:exception InvalidVolumeError: Volume was lower than 0.0 or higher
than 1.0.
"""
_assertDuration(duration)
_assertSamplerate(samplerate)
_assertVolume(volume)
length = int(numpy.round(duration * samplerate))
return cls(numpy.random.rand(length) * volume, samplerate)
@classmethod
[docs] def from_file(cls, file_: typing.Union[str, typing.BinaryIO]) -> 'Sound':
""" Read sound from file or file-like
:param file_: File name or file-like object.
:return: A new :class:`Sound` instance.
"""
data, samplerate = soundfile.read(file_)
data /= numpy.max([-data.min(), data.max()])
return cls(data, samplerate)
@classmethod
[docs] def from_array(cls,
array: typing.Sequence[float],
samplerate: float) -> 'Sound':
""" Make new sound from float array
:param array: Sound data. Elements must in between -1.0 to 1.0.
:param samplerate: Sampling rate of new sound.
:return: A new :class:`Sound` instance.
:exception InvalidDurationError: The array was empty.
:exception InvalidSamplerateError: Samplerate was 0 or less.
This method is same as passing numpy.array to the Sound constructor.
>>> (Sound.from_array([-0.1, 0.0, 1.0], 3)
... == Sound(numpy.array([-0.1, 0.0, 1.0]), 3))
True
"""
return Sound(numpy.array(array), samplerate)
@classmethod
[docs] def from_fft(cls,
spectrum: numpy.array,
samplerate: float = None) -> 'Sound':
""" Make new sound from spectrum data like a result from fft() method.
:param spectrum: A spectrum data. Please see fft() method about a
format.
:param samplerate: Sampling rate of new sound. Use spectrum data if
None.
:return: A new :class:`Sound` instance.
:exception InvalidDurationError: Duration was 0 or less.
:exception InvalidSamplerateError: Samplerate was 0 or less.
"""
if samplerate is None:
samplerate = spectrum[0, -1, 0].real * 2
_assertSamplerate(samplerate)
return Sound(
numpy.array([numpy.fft.irfft(x[:, 1]) for x in spectrum]).T,
samplerate,
)
@property
def duration(self) -> float:
""" Duration in seconds of this sound """
return self.data.shape[0] / self.samplerate
@property
def samplerate(self) -> float:
""" Sampling rate of this sound """
return self._samplerate
@property
def n_channels(self) -> int:
""" Number of channels """
return self.data.shape[1]
@property
def volume(self) -> float:
""" Volume of this dound
This volume means the maximum value of the wave.
Please be careful that is not gain.
"""
return max(self.data.max(), -self.data.min())
@property
def data(self) -> numpy.array:
""" Raw data of sound
This array is two dimensions. The first dimension means time, and the
second dimension means channels.
"""
return self._data
def __eq__(self, another: typing.Any) -> bool:
""" Compare with another Sound instance """
if not isinstance(another, Sound):
return False
return (self.samplerate == another.samplerate
and numpy.allclose(self.data, another.data))
def __ne__(self, another: typing.Any) -> bool:
return not (self == another)
def __getitem__(self, position: typing.Union[float, slice]) -> 'Sound':
""" Slice a sound
:param position: A position in seconds in float or slice.
:return: A new :class:`Sound` instance that sliced.
:exception ValueError: Passed slice had step value.
If passed float a position, returns very short sound that only has
1/samplerate seconds.
>>> sound = Sound.from_sinwave(440)
>>> short_sound = sound[0.5]
>>> short_sound.duration == 1 / sound.samplerate
True
The step of the slice is not supported. Will raise ValueError if passed
slice that has a step.
>>> sound[::1]
Traceback (most recent call last):
...
ValueError: step is not supported
:class:`Trim effect<gensound.effect.Trim>` has the same function this.
You can use :class:`<Trim effect<gensound.effect.Trim>` when process
many sounds by :class:`JoinedEffect<gensound.effect.JoinedEffect>`.
"""
if isinstance(position, (int, float)):
if position < 0 or self.duration < position:
raise OutOfDurationError(position, 0.0, self.duration)
index = int(numpy.round(position * self.samplerate))
if index >= self.data.shape[0]:
index = self.data.shape[0] - 1
data = self.data[index, :].reshape([1, -1])
else:
if position.step is not None:
raise ValueError('step is not supported')
start = position.start
if start is not None:
start = int(numpy.round(start * self.samplerate))
stop = position.stop
if stop is not None:
stop = int(numpy.round(stop * self.samplerate))
data = self.data[start:stop, :]
return Sound(data, self.samplerate)
[docs] def split_channels(self) -> typing.Sequence[numpy.array]:
""" Split channels into Sound
:return: A list of :class:`Sound` instances.
"""
return [
Sound(self.data[:, i], self.samplerate)
for i in range(self.n_channels)
]
[docs] def as_monaural(self) -> 'Sound':
""" Create a new instance that converted to monaural sound
:return: A :class:`Sound` instance that monaural.
If an instance already monaural sound, may returns the same instance.
"""
if self.n_channels == 1:
return self
return Sound(numpy.average(self.data, axis=1), self.samplerate)
[docs] def as_stereo(self, channels: int = 2) -> 'Sound':
""" Create a new instance that converted to multi channel sound
:return: A new :class:`Sound` instance that stereo or multi channel.
:exception ValueError: A given channels were lesser than a number of
channels of this sound.
This method will return the same instance if this sound already had the
same number of channels.
"""
if self.n_channels == channels:
return self
if self.n_channels > channels:
raise ValueError('channels must be {} or greater but got {}'
.format(self.n_channels, channels))
return Sound(self.data.reshape([-1, 1]).repeat(channels, axis=1),
self.samplerate)
[docs] def fft(self) -> numpy.array:
""" Calculate fft
:return: An array that pair of frequency and value.
"""
freqs = (numpy.fft.rfftfreq(self.data.shape[0]) * self.samplerate)
freqs = freqs.reshape([-1, 1])
return numpy.array([
numpy.hstack([
freqs,
numpy.fft.rfft(self.data[:, channel]).reshape([-1, 1])
])
for channel in range(self.n_channels)
])
[docs] def repeat(self, duration: float) -> 'Sound':
""" Create a new instance that repeated same sound
:param duration: Duration in seconds to repeat.
:return: A new :class:`Sound` instance that repeated same sound.
:exception InvalidDurationError: Duration was shorter than 0.
>>> sound = Sound.from_sinwave(440)
>>> sound.repeat(5).duration
5.0
This function can not only repeat but trimming.
But recommend use slice because become hard to understand if using it
for trimming.
>>> sound.repeat(0.5).duration
0.5
>>> sound.repeat(0.5) == sound[:0.5]
True
"""
_assertDuration(duration)
return Sound(
_repeat_array(self.data,
int(numpy.round(duration * self.samplerate))),
self.samplerate,
)
[docs] def concat(self, another: 'Sound') -> 'Sound':
""" Create a new instance that concatenated another sound
:param another: The sound that concatenates after of self.
Must it has same sampling rate.
:return: A new :class:`Sound` that concatenated self and other.
:exception DifferentSamplerateError: The samplerate was different.
:exception DifferentChannelsError: The number of channels was
different.
>>> sound = Sound.from_sinwave(440, duration=3)
>>> a, b = sound[:1], sound[1:]
>>> a.concat(b) == sound
True
Recommend using :func:`concat<gensound.sound.concat>` function instead
of this method if concatenate many sounds. Because
:func:`concat<gensound.sound.concat>` function is optimized for many
sounds.
>>> concat(a, b) == a.concat(b)
True
"""
return concat(self, another)
[docs] def overlay(self, another: 'Sound') -> 'Sound':
""" Create a new instance that was overlay another sound
:param another: The sound that overlay.
:return: A new :class:`Sound` that overlay another sound.
:exception DifferentSamplerateError: The samplerate was different.
:exception DifferentChannelsError: The number of channels was
different.
>>> a = Sound.from_array([0.1, 0.2], 1)
>>> b = Sound.from_array([0.1, 0.2, 0.3], 1)
>>> a.overlay(b) == Sound.from_array([0.2, 0.4, 0.3], 1)
True
Recommend using :func:`overlay<gensound.sound.overlay>` function
instead of this method if overlay many sounds. Because
:func:`overlay<gensound.sound.overlay>` function is optimized for many
sounds.
>>> overlay(a, b) == a.overlay(b)
True
"""
return overlay(self, another)
[docs] def write(self,
file_: typing.Union[str, typing.BinaryIO],
format_: typing.Optional[str] = None) -> None:
""" Write sound into file or file-like
:param file_: A file name or file-like object to write sound.
:param format_: Format type of output like a 'wav'. Automatically
detect from file name if None.
"""
soundfile.write(file_, self.data, self.samplerate, format=format_)
[docs] def play(self) -> None:
""" Play sound """
class Callback:
def __init__(self, data: numpy.array) -> None:
self.idx = 0
self.data = data
def next(self,
in_: None,
frame_count: int,
time_info: dict,
status: int) -> typing.Tuple[numpy.array, int]:
d = self.data[self.idx:self.idx + frame_count]
self.idx += frame_count
flag = pyaudio.paContinue
if len(d) <= 0:
flag = pyaudio.paComplete
return d.astype(numpy.float32), flag
pa = pyaudio.PyAudio()
stream = pa.open(format=pyaudio.paFloat32,
channels=1,
rate=self.samplerate,
output=True,
stream_callback=Callback(self.data).next)
stream.start_stream()
while stream.is_active():
time.sleep(0.1)
stream.stop_stream()
stream.close()
pa.terminate()
[docs]def concat(*sounds: Sound) -> Sound:
""" Concatenate multiple sounds
:param sounds: Sound instances to concatenate. Must they has some sampling
rate.
:return: A concatenated :class:`Sound` instance.
:exception DifferentSamplerateError: The samplerate of sounds was
different.
:exception DifferentChannelsError: The number of channels of sounds was
different.
>>> a = Sound.from_array([0.1, 0.2], 1)
>>> b = Sound.from_array([0.3, 0.4], 1)
>>> c = Sound.from_array([0.5, 0.6], 1)
>>> concat(a, b, c) == Sound.from_array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6], 1)
True
"""
_assertSameSamplerate([s.samplerate for s in sounds])
_assertSameChannels([s.n_channels for s in sounds])
return Sound(numpy.vstack([x.data for x in sounds]), sounds[0].samplerate)
[docs]def overlay(*sounds: Sound) -> Sound:
""" Overlay multiple sounds
:param sounds: Sound instances to overlay.
Must they has some sampling rate.
:return: A new :class:`Sound` instance that overlay all sounds.
:exception DifferentSamplerateError: The samplerate of sounds was
different.
:exception DifferentChannelsError: The number of channels of sounds was
different.
BE CAREFUL: This function doesn't care about clipping. Perhaps, need to
change volume before use this if overlay many sounds.
>>> a = Sound.from_array([0.1, 0.2], 1)
>>> b = Sound.from_array([0.3, 0.4], 1)
>>> c = Sound.from_array([0.5, 0.6], 1)
>>> overlay(a, b, c) == Sound.from_array([0.9, 1.0], 1)
True
The second element of this sample isn't 1.2 but 1.0 because of clipping was
an occurrence.
"""
_assertSameSamplerate([s.samplerate for s in sounds])
_assertSameChannels([s.n_channels for s in sounds])
longest = max(x.data.shape[0] for x in sounds)
padded = numpy.array([
numpy.vstack([x.data,
numpy.zeros([longest - x.data.shape[0], x.n_channels])])
for x in sounds
])
return Sound(padded.sum(axis=0), sounds[0].samplerate)
[docs]def merge_channels(*sounds: Sound) -> Sound:
""" Merge multiple sounds as a sound that has multiple channels
:param sounds: Sound instances to merge. Must they has some sampling rate.
:return: A new :class:`Sound` that merged sounds as channels.
:exception DifferentSamplerateError: The samplerate of sounds was
different.
BE CAREFUL: all sounds calculate as monaural sound.
"""
_assertSameSamplerate([s.samplerate for s in sounds])
longest = max(x.data.shape[0] for x in sounds)
padded = numpy.hstack([
numpy.vstack([x.as_monaural().data,
numpy.zeros([longest - x.data.shape[0], 1])])
for x in sounds
])
return Sound(padded, sounds[0].samplerate)