forked from platypush/platypush
Added sound device plugin
This commit is contained in:
parent
7b963f8cf3
commit
be98dcde6a
4 changed files with 393 additions and 2 deletions
|
@ -203,6 +203,9 @@ autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers',
|
||||||
'omxplayer',
|
'omxplayer',
|
||||||
'plexapi',
|
'plexapi',
|
||||||
'cwiid',
|
'cwiid',
|
||||||
|
'sounddevice',
|
||||||
|
'soundfile',
|
||||||
|
'numpy',
|
||||||
]
|
]
|
||||||
|
|
||||||
sys.path.insert(0, os.path.abspath('../..'))
|
sys.path.insert(0, os.path.abspath('../..'))
|
||||||
|
|
377
platypush/plugins/sound.py
Normal file
377
platypush/plugins/sound.py
Normal file
|
@ -0,0 +1,377 @@
|
||||||
|
"""
|
||||||
|
.. moduleauthor:: Fabio Manganiello <blacklight86@gmail.com>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 <https://pysoundfile.readthedocs.io/en/0.9.0/#soundfile.available_subtypes>`_ 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:
|
||||||
|
|
|
@ -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
|
# YAML configuration support
|
||||||
pyyaml
|
pyyaml
|
||||||
requires
|
|
||||||
|
|
||||||
# Apache Kafka backend support
|
# Apache Kafka backend support
|
||||||
kafka-python
|
kafka-python
|
||||||
|
@ -108,3 +114,8 @@ inputs
|
||||||
# Support for Chromecast
|
# Support for Chromecast
|
||||||
# pychromecast
|
# pychromecast
|
||||||
|
|
||||||
|
# Support for sound devices, playback and recording
|
||||||
|
# sounddevice
|
||||||
|
# soundfile
|
||||||
|
# numpy
|
||||||
|
|
||||||
|
|
2
setup.py
2
setup.py
|
@ -59,7 +59,6 @@ setup(
|
||||||
],
|
],
|
||||||
install_requires = [
|
install_requires = [
|
||||||
'pyyaml',
|
'pyyaml',
|
||||||
'requires',
|
|
||||||
'redis',
|
'redis',
|
||||||
],
|
],
|
||||||
extras_require = {
|
extras_require = {
|
||||||
|
@ -91,6 +90,7 @@ setup(
|
||||||
'Support for Kodi plugin': ['kodi-json'],
|
'Support for Kodi plugin': ['kodi-json'],
|
||||||
'Support for Plex plugin': ['plexapi'],
|
'Support for Plex plugin': ['plexapi'],
|
||||||
'Support for Chromecast plugin': ['pychromecast'],
|
'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 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']
|
# 'Support for Flic buttons': ['git+ssh://git@github.com/50ButtonsEach/fliclib-linux-hci']
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue