From 2fb5e5abc697c4ec44f7e53b7a1f60ad99019875 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Tue, 25 Dec 2018 19:26:08 +0100 Subject: [PATCH] Support for mixes of multiple sounds --- platypush/plugins/sound/core.py | 120 ++++++++++++++++++++++++++++---- 1 file changed, 105 insertions(+), 15 deletions(-) diff --git a/platypush/plugins/sound/core.py b/platypush/plugins/sound/core.py index 65b4354e3b..613a6b2f72 100644 --- a/platypush/plugins/sound/core.py +++ b/platypush/plugins/sound/core.py @@ -1,6 +1,14 @@ +""" +.. moduleauthor:: Fabio Manganiello +""" + +import json +import math + + 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 @@ -11,11 +19,12 @@ class Sound(object): midi_note = None frequency = None + phase = 0.0 gain = 1.0 duration = None - def __init__(self, midi_note=midi_note, frequency=None, gain=gain, - duration=duration, A_frequency=STANDARD_A_FREQUENCY): + def __init__(self, midi_note=midi_note, frequency=None, phase=phase, + gain=gain, duration=duration, A_frequency=STANDARD_A_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 :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) :type gain: float @@ -53,6 +65,7 @@ class Sound(object): raise RuntimeError('Please specify either a MIDI note or a base ' + 'frequency') + self.phase = phase self.gain = gain self.duration = duration @@ -92,10 +105,10 @@ class Sound(object): """ 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 - :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 :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 = 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): - import json + return json.dumps(dict(self)) - return json.dumps({ - 'midi_note': midi_note, - 'frequency': frequency, - 'gain': gain, - 'duration': duration, - }) @classmethod def build(cls, *args, **kwargs): @@ -128,8 +140,6 @@ class Sound(object): key-value representation """ - import json - if args: if isinstance(args[0], cls): return args[0] @@ -143,4 +153,84 @@ class Sound(object): 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: