Support for mixes of multiple sounds

This commit is contained in:
Fabio Manganiello 2018-12-25 19:26:08 +01:00
parent d89184358a
commit 2fb5e5abc6

View file

@ -1,6 +1,14 @@
"""
.. moduleauthor:: Fabio Manganiello <blacklight86@gmail.com>
"""
import json
import math
class Sound(object): class Sound(object):
""" """
Class model a synthetic sound that can be played through the audio device Models a basic synthetic sound that can be played through an audio device
""" """
STANDARD_A_FREQUENCY = 440.0 STANDARD_A_FREQUENCY = 440.0
@ -11,11 +19,12 @@ class Sound(object):
midi_note = None midi_note = None
frequency = None frequency = None
phase = 0.0
gain = 1.0 gain = 1.0
duration = None duration = None
def __init__(self, midi_note=midi_note, frequency=None, gain=gain, def __init__(self, midi_note=midi_note, frequency=None, phase=phase,
duration=duration, A_frequency=STANDARD_A_FREQUENCY): gain=gain, duration=duration, A_frequency=STANDARD_A_FREQUENCY):
""" """
You can construct a sound either from a MIDI note or a base frequency You can construct a sound either from a MIDI note or a base frequency
@ -26,6 +35,9 @@ class Sound(object):
:param frequency: Sound base frequency in Hz :param frequency: Sound base frequency in Hz
:type frequency: float :type frequency: float
:param phase: Wave phase shift as a multiple of pi (default: 0.0)
:type phase: float
:param gain: Note gain/volume between 0.0 and 1.0 (default: 1.0) :param gain: Note gain/volume between 0.0 and 1.0 (default: 1.0)
:type gain: float :type gain: float
@ -53,6 +65,7 @@ class Sound(object):
raise RuntimeError('Please specify either a MIDI note or a base ' + raise RuntimeError('Please specify either a MIDI note or a base ' +
'frequency') 'frequency')
self.phase = phase
self.gain = gain self.gain = gain
self.duration = duration self.duration = duration
@ -92,10 +105,10 @@ class Sound(object):
""" """
Get the wave binary data associated to this sound Get the wave binary data associated to this sound
:param t_start: Start offset for the sine wave in seconds. Default: 0 :param t_start: Start offset for the wave in seconds. Default: 0
:type t_start: float :type t_start: float
:param t_end: End offset for the sine wave in seconds. Default: 0 :param t_end: End offset for the wave in seconds. Default: 0
:type t_end: float :type t_end: float
:param samplerate: Audio sample rate. Default: 44100 Hz :param samplerate: Audio sample rate. Default: 44100 Hz
@ -108,18 +121,17 @@ class Sound(object):
x = np.linspace(t_start, t_end, int((t_end-t_start)*samplerate)) x = np.linspace(t_start, t_end, int((t_end-t_start)*samplerate))
x = x.reshape(len(x), 1) x = x.reshape(len(x), 1)
return self.gain * np.sin(2 * np.pi * self.frequency * x) return self.gain * np.sin((2*np.pi*self.frequency*x) + np.pi*self.phase)
def __iter__(self):
for attr in ['midi_note', 'frequency', 'gain', 'duration']:
yield (attr, getattr(self, attr))
def __str__(self): def __str__(self):
import json return json.dumps(dict(self))
return json.dumps({
'midi_note': midi_note,
'frequency': frequency,
'gain': gain,
'duration': duration,
})
@classmethod @classmethod
def build(cls, *args, **kwargs): def build(cls, *args, **kwargs):
@ -128,8 +140,6 @@ class Sound(object):
key-value representation key-value representation
""" """
import json
if args: if args:
if isinstance(args[0], cls): if isinstance(args[0], cls):
return args[0] return args[0]
@ -143,4 +153,84 @@ class Sound(object):
raise RuntimeError('Usage: {}'.format(__doc__)) raise RuntimeError('Usage: {}'.format(__doc__))
class Mix(object):
"""
This class models a set of mixed :class:`Sound` instances that can be played
through an audio stream to an audio device
"""
_sounds = []
def __init__(self, *sounds):
for sound in sounds:
self.add(sound)
def __iter__(self):
for sound in self._sounds:
yield dict(sound)
def __str__(self):
return json.dumps(list(self))
def add(self, sound):
self._sounds.append(Sound.build(sound))
def get_wave(self, t_start=0., t_end=0., normalize_range=(-1.0, 1.0),
on_clip='scale', samplerate=Sound._DEFAULT_SAMPLERATE):
"""
Get the wave binary data associated to this mix
:param t_start: Start offset for the wave in seconds. Default: 0
:type t_start: float
:param t_end: End offset for the wave in seconds. Default: 0
:type t_end: float
:param normalize_range: Normalization range. If set the gain values of the
wave will be normalized to fit into the specified range if it
"clips" above or below. Default: ``(-1.0, 1.0)``
:type normalize_range: list[float]
:param on_clip: Action to take on wave clipping if ``normalize_range``
is set. Possible values: "``scale``" (scale down the frame to remove
the clipping) or "``clip``" (saturate the values above/below range).
Default: "``scale``".
:param samplerate: Audio sample rate. Default: 44100 Hz
:type samplerate: int
:returns: A numpy.ndarray[n,1] with the raw float values
"""
wave = None
for sound in self._sounds:
sound_wave = sound.get_wave(t_start=t_start, t_end=t_end,
samplerate=samplerate)
if wave is None:
wave = sound_wave
else:
wave += sound_wave
if normalize_range:
scale_factor = (normalize_range[1]-normalize_range[0]) / \
(wave.max()-wave.min())
if scale_factor < 1.0: # Wave clipping
if on_clip == 'scale':
wave = scale_factor * wave
elif on_clip == 'clip':
wave[wave < normalize_range[0]] = normalize_range[0]
wave[wave > normalize_range[1]] = normalize_range[1]
else:
raise RuntimeError('Supported values for "on_clip": ' +
'"scale" or "clip"')
return wave
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et: