""" .. 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: