From be98dcde6a48fe1aad6970033bde4ff63f674b1a Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 15 Dec 2018 01:18:45 +0100 Subject: [PATCH] Added sound device plugin --- docs/source/conf.py | 3 + platypush/plugins/sound.py | 377 +++++++++++++++++++++++++++++++++++++ requirements.txt | 13 +- setup.py | 2 +- 4 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 platypush/plugins/sound.py diff --git a/docs/source/conf.py b/docs/source/conf.py index 4fd608a0..80640997 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -203,6 +203,9 @@ autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers', 'omxplayer', 'plexapi', 'cwiid', + 'sounddevice', + 'soundfile', + 'numpy', ] sys.path.insert(0, os.path.abspath('../..')) diff --git a/platypush/plugins/sound.py b/platypush/plugins/sound.py new file mode 100644 index 00000000..7eb1654a --- /dev/null +++ b/platypush/plugins/sound.py @@ -0,0 +1,377 @@ +""" +.. moduleauthor:: Fabio Manganiello +""" + +import os +import queue +import tempfile +import time + +from enum import Enum +from threading import Thread, Event, RLock + +import sounddevice as sd + +from platypush.plugins import Plugin, action + + +class PlaybackState(Enum): + STOPPED='STOPPED', + PLAYING='PLAYING', + PAUSED='PAUSED' + + +class RecordingState(Enum): + STOPPED='STOPPED', + RECORDING='RECORDING', + PAUSED='PAUSED' + + +class SoundPlugin(Plugin): + """ + Plugin to interact with a sound device. + + Requires: + + * **sounddevice** (``pip install sounddevice``) + * **soundfile** (``pip install soundfile``) + * **numpy** (``pip install numpy``) + """ + + _DEFAULT_PLAY_BLOCKSIZE = 2048 + _DEFAULT_PLAY_BUFSIZE = 20 + + def __init__(self, input_device=None, output_device=None, *args, **kwargs): + """ + :param input_device: Index or name of the default input device. Use :method:`platypush.plugins.sound.query_devices` to get the available devices. Default: system default + :type input_device: int or str + + :param output_device: Index or name of the default output device. Use :method:`platypush.plugins.sound.query_devices` to get the available devices. Default: system default + :type output_device: int or str + """ + + super().__init__(*args, **kwargs) + + self.input_device = input_device or 'default' + self.output_device = output_device or 'default' + self.playback_state = PlaybackState.STOPPED + self.playback_state_lock = RLock() + self.playback_paused_changed = Event() + self.recording_state = RecordingState.STOPPED + self.recording_state_lock = RLock() + self.recording_paused_changed = Event() + + @action + def query_devices(self, category=None): + """ + Query the available devices + + :param category: Device category to query. Can be either input or output. Default: None (query all devices) + :type category: str + + :returns: A dictionary representing the available devices. Example:: + + [ + { + "name": "pulse", + "hostapi": 0, + "max_input_channels": 32, + "max_output_channels": 32, + "default_low_input_latency": 0.008684807256235827, + "default_low_output_latency": 0.008684807256235827, + "default_high_input_latency": 0.034807256235827665, + "default_high_output_latency": 0.034807256235827665, + "default_samplerate": 44100 + }, + { + "name": "default", + "hostapi": 0, + "max_input_channels": 32, + "max_output_channels": 32, + "default_low_input_latency": 0.008684807256235827, + "default_low_output_latency": 0.008684807256235827, + "default_high_input_latency": 0.034807256235827665, + "default_high_output_latency": 0.034807256235827665, + "default_samplerate": 44100 + } + ] + + """ + + devs = sd.query_devices() + if category == 'input': + devs = [d for d in devs if d.get('max_input_channels') > 0] + elif category == 'output': + devs = [d for d in devs if d.get('max_output_channels') > 0] + + return devs + + + @action + def play(self, file, device=None, blocksize=_DEFAULT_PLAY_BLOCKSIZE, + bufsize=_DEFAULT_PLAY_BUFSIZE): + """ + Plays a sound file (support formats: wav, raw) + + :param file: Sound file + :type file: str + + :param device: Output device (default: default configured device or system default audio output if not configured) + :type device: int or str + + :param blocksize: Audio block size (default: 2048) + :type blocksize: int + + :param bufsize: Size of the audio buffer (default: 20) + :type bufsize: int + """ + + self.stop_playback() + self.playback_paused_changed.clear() + + q = queue.Queue(maxsize=bufsize) + completed_callback_event = Event() + file = os.path.abspath(os.path.expanduser(file)) + + if not device: + device = self.output_device + + def audio_callback(outdata, frames, time, status): + if self._get_playback_state() == PlaybackState.STOPPED: + raise sd.CallbackAbort + + while self._get_playback_state() == PlaybackState.PAUSED: + self.playback_paused_changed.wait() + + assert frames == blocksize + if status.output_underflow: + self.logger.warning('Output underflow: increase blocksize?') + outdata = b'\x00' * len(outdata) + return + + assert not status + + try: + data = q.get_nowait() + except queue.Empty: + self.logger.warning('Buffer is empty: increase buffersize?') + raise sd.CallbackAbort + + if len(data) < len(outdata): + outdata[:len(data)] = data + outdata[len(data):] = b'\x00' * (len(outdata) - len(data)) + raise sd.CallbackStop + else: + outdata[:] = data + + try: + import soundfile as sf + + with sf.SoundFile(file) as f: + self.start_playback() + self.logger.info('Started playback of [{}] to device [{}]'. + format(file, device)) + + for _ in range(bufsize): + data = f.buffer_read(blocksize, dtype='float32') + if not data: + break + + while self._get_playback_state() == PlaybackState.PAUSED: + self.playback_paused_changed.wait() + + if self._get_playback_state() == PlaybackState.STOPPED: + raise sd.CallbackAbort + + q.put_nowait(data) + + stream = sd.RawOutputStream( + samplerate=f.samplerate, blocksize=blocksize, + device=device, channels=f.channels, dtype='float32', + callback=audio_callback, + finished_callback=completed_callback_event.set) + + with stream: + timeout = blocksize * bufsize / f.samplerate + while data: + while self._get_playback_state() == PlaybackState.PAUSED: + self.playback_paused_changed.wait() + + data = f.buffer_read(blocksize, dtype='float32') + + if self._get_playback_state() == PlaybackState.STOPPED: + raise sd.CallbackAbort + + try: + q.put(data, timeout=timeout) + except queue.Full as e: + if self._get_playback_state() != PlaybackState.PAUSED: + raise e + + completed_callback_event.wait() + except queue.Full as e: + self.logger.warning('Playback timeout: audio callback failed?') + finally: + self.stop_playback() + + + @action + def record(self, file=None, duration=None, device=None, sample_rate=None, + latency=0, channels=1, subtype='PCM_24'): + """ + Records audio to a sound file (support formats: wav, raw) + + :param file: Sound file (default: the method will create a temporary file with the recording) + :type file: str + + :param duration: Recording duration in seconds (default: record until stop event) + :type duration: float + + :param device: Input device (default: default configured device or system default audio input if not configured) + :type device: int or str + + :param sample_rate: Recording sample rate (default: device default rate) + :type sample_rate: int + + :param latency: Device latency in seconds (default: 0) + :type latency: float + + :param channels: Number of channels (default: 1) + :type channels: int + + :param subtype: Recording subtype - see `soundfile docs `_ for a list of the available subtypes (default: PCM_24) + :type subtype: str + """ + + self.stop_recording() + self.recording_paused_changed.clear() + + if file: + file = os.path.abspath(os.path.expanduser(file)) + else: + file = tempfile.mktemp(prefix='platypush_recording_', suffix='.wav', + dir='') + + if os.path.isfile(file): + os.unlink(file) + + if not device: + device = self.input_device + + if sample_rate is None: + dev_info = sd.query_devices(device, 'input') + sample_rate = int(dev_info['default_samplerate']) + + q = queue.Queue() + + def audio_callback(indata, frames, time, status): + while self._get_recording_state() == RecordingState.PAUSED: + self.recording_paused_changed.wait() + + if status: + self.logger.warning('Recording callback status: {}'.format( + str(status))) + + q.put(indata.copy()) + + + try: + import soundfile as sf + import numpy + + with sf.SoundFile(file, mode='x', samplerate=sample_rate, + channels=channels, subtype=subtype) as f: + with sd.InputStream(samplerate=sample_rate, device=device, + channels=channels, callback=audio_callback, + latency=latency): + self.start_recording() + self.logger.info('Started recording from device [{}] to [{}]'. + format(device, file)) + + recording_started_time = time.time() + + while self._get_recording_state() != RecordingState.STOPPED \ + and (duration is None or + time.time() - recording_started_time < duration): + while self._get_recording_state() == RecordingState.PAUSED: + self.recording_paused_changed.wait() + + if duration is None: + block = q.get() + else: + block = q.get(block=True, timeout=max(0, duration - + (time.time() - recording_started_time))) + + f.write(block) + + f.flush() + + except queue.Empty as e: + self.logger.warning('Recording timeout: audio callback failed?') + finally: + self.stop_recording() + + def start_playback(self): + with self.playback_state_lock: + self.playback_state = PlaybackState.PLAYING + + @action + def stop_playback(self): + with self.playback_state_lock: + self.playback_state = PlaybackState.STOPPED + self.logger.info('Playback stopped') + + @action + def pause_playback(self): + with self.playback_state_lock: + if self.playback_state == PlaybackState.PAUSED: + self.playback_state = PlaybackState.PLAYING + elif self.playback_state == PlaybackState.PLAYING: + self.playback_state = PlaybackState.PAUSED + else: + return + + self.logger.info('Playback paused state toggled') + self.playback_paused_changed.set() + + def start_recording(self): + with self.recording_state_lock: + self.recording_state = RecordingState.RECORDING + + @action + def stop_recording(self): + with self.recording_state_lock: + self.recording_state = RecordingState.STOPPED + self.logger.info('Recording stopped') + + @action + def pause_recording(self): + with self.recording_state_lock: + if self.recording_state == RecordingState.PAUSED: + self.recording_state = RecordingState.RECORDING + elif self.recording_state == RecordingState.RECORDING: + self.recording_state = RecordingState.PAUSED + else: + return + + self.logger.info('Recording paused state toggled') + self.recording_paused_changed.set() + + def _get_playback_state(self): + with self.playback_state_lock: + return self.playback_state + + def _get_recording_state(self): + with self.recording_state_lock: + return self.recording_state + + @action + def get_state(self): + return { + 'playback_state': self._get_playback_state().name, + 'recording_state': self._get_recording_state().name, + } + + +# vim:sw=4:ts=4:et: + diff --git a/requirements.txt b/requirements.txt index 9677af43..d02204a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,12 @@ +### +# Platypush Python requirements +# +# Uncomment the lines associated to the optional dependencies that +# you may want to install to use some particular plugins or backends +### + # YAML configuration support pyyaml -requires # Apache Kafka backend support kafka-python @@ -108,3 +114,8 @@ inputs # Support for Chromecast # pychromecast +# Support for sound devices, playback and recording +# sounddevice +# soundfile +# numpy + diff --git a/setup.py b/setup.py index 8516f7aa..c26c6179 100755 --- a/setup.py +++ b/setup.py @@ -59,7 +59,6 @@ setup( ], install_requires = [ 'pyyaml', - 'requires', 'redis', ], extras_require = { @@ -91,6 +90,7 @@ setup( 'Support for Kodi plugin': ['kodi-json'], 'Support for Plex plugin': ['plexapi'], 'Support for Chromecast plugin': ['pychromecast'], + 'Support for sound devices': ['sounddevice', 'soundfile', 'numpy'], # 'Support for Leap Motion backend': ['git+ssh://git@github.com:BlackLight/leap-sdk-python3.git'], # 'Support for Flic buttons': ['git+ssh://git@github.com/50ButtonsEach/fliclib-linux-hci'] },