forked from platypush/platypush
I couldn't find an easy and reliable way of routing `warnings.warn` to `logging`. Closes: #281
472 lines
18 KiB
Python
472 lines
18 KiB
Python
from dataclasses import asdict
|
|
from typing import Iterable, List, Optional, Union
|
|
|
|
from platypush.plugins import RunnablePlugin, action
|
|
|
|
from ._manager import AudioManager
|
|
from ._model import DeviceType, StreamType
|
|
|
|
|
|
class SoundPlugin(RunnablePlugin):
|
|
"""
|
|
Plugin to interact with a sound device.
|
|
|
|
Among the other features, enabling this plugin allows you to stream audio
|
|
over HTTP from the device where the application is running, if
|
|
:class:`platypush.backend.http.HttpBackend` is enabled.
|
|
|
|
Simply open
|
|
``http://<host>:<backend-port>/sound/stream.[mp3|wav|acc|ogg]?token=<your-token>``
|
|
to access live recording from the audio device.
|
|
|
|
It can also be used as a general-purpose audio player and synthesizer,
|
|
supporting both local and remote audio resources, as well as a MIDI-like
|
|
interface through the :meth:`.play` command.
|
|
"""
|
|
|
|
_DEFAULT_BLOCKSIZE = 1024
|
|
_DEFAULT_QUEUE_SIZE = 10
|
|
|
|
def __init__(
|
|
self,
|
|
input_device: Optional[DeviceType] = None,
|
|
output_device: Optional[DeviceType] = None,
|
|
input_blocksize: int = _DEFAULT_BLOCKSIZE,
|
|
output_blocksize: int = _DEFAULT_BLOCKSIZE,
|
|
queue_size: Optional[int] = _DEFAULT_QUEUE_SIZE,
|
|
ffmpeg_bin: str = 'ffmpeg',
|
|
**kwargs,
|
|
):
|
|
"""
|
|
:param input_device: Index or name of the default input device. Use
|
|
:meth:`.query_devices` to get the available devices. Default: system
|
|
default
|
|
:param output_device: Index or name of the default output device.
|
|
Use :meth:`.query_devices` to get the available devices. Default:
|
|
system default
|
|
:param input_blocksize: Blocksize to be applied to the input device.
|
|
Try to increase this value if you get input overflow errors while
|
|
recording. Default: 1024
|
|
:param output_blocksize: Blocksize to be applied to the output device.
|
|
Try to increase this value if you get output underflow errors while
|
|
playing. Default: 1024
|
|
:param queue_size: When running in synth mode, this is the maximum
|
|
number of generated audio frames that will be queued before the
|
|
audio device processes them (default: 100).
|
|
:param ffmpeg_bin: Path of the ``ffmpeg`` binary (default: search for
|
|
the ``ffmpeg`` in the ``PATH``).
|
|
"""
|
|
|
|
super().__init__(**kwargs)
|
|
|
|
self.input_device = input_device
|
|
self.output_device = output_device
|
|
self.input_blocksize = input_blocksize
|
|
self.output_blocksize = output_blocksize
|
|
self.ffmpeg_bin = ffmpeg_bin
|
|
self._manager = AudioManager(
|
|
input_blocksize=self.input_blocksize,
|
|
output_blocksize=self.output_blocksize,
|
|
should_stop=self._should_stop,
|
|
input_device=input_device,
|
|
output_device=output_device,
|
|
queue_size=queue_size,
|
|
)
|
|
|
|
@action
|
|
def play(
|
|
self,
|
|
resource: Optional[str] = None,
|
|
file: Optional[str] = None,
|
|
sound: Optional[Union[dict, Iterable[dict]]] = None,
|
|
device: Optional[DeviceType] = None,
|
|
duration: Optional[float] = None,
|
|
blocksize: Optional[int] = None,
|
|
sample_rate: Optional[int] = None,
|
|
channels: int = 2,
|
|
volume: float = 100,
|
|
dtype: Optional[str] = None,
|
|
format: Optional[str] = None, # pylint: disable=redefined-builtin
|
|
stream_name: Optional[str] = None,
|
|
stream_index: Optional[int] = None,
|
|
join: bool = False,
|
|
):
|
|
"""
|
|
Plays an audio file/URL (any audio format supported by ffmpeg works) or
|
|
a synthetic sound.
|
|
|
|
:param resource: Audio resource to be played. It can be a local file or
|
|
a URL.
|
|
:param file: **Deprecated**. Use ``resource`` instead.
|
|
:param sound: Sound to play. Specify this if you want to play
|
|
synthetic sounds. You can also create polyphonic sounds by just
|
|
calling play multiple times. Frequencies can be specified either by
|
|
``midi_note`` - either as strings (e.g. ``A4``) or integers (e.g.
|
|
``69``) - or by ``frequency`` (e.g. ``440`` for 440 Hz). You can
|
|
also specify a list of sounds here if you want to apply multiple
|
|
harmonics on a base sound.
|
|
Some examples:
|
|
|
|
.. code-block:: python
|
|
|
|
{
|
|
"frequency": 440, # 440 Hz
|
|
"volume": 100, # Maximum volume
|
|
"duration": 1.0 # 1 second or until stop_playback
|
|
}
|
|
|
|
.. code-block:: python
|
|
|
|
[
|
|
{
|
|
"midi_note": "A4", # A @ 440 Hz
|
|
"volume": 100, # Maximum volume
|
|
"duration": 1.0 # 1 second or until stop_playback
|
|
},
|
|
{
|
|
"midi_note": "E5", # Play the harmonic one fifth up
|
|
"volume": 25, # 1/4 of the volume
|
|
"duration": 1.0 # 1 second or until stop_playback
|
|
"phase": 3.14 # ~180 degrees phase
|
|
# Make it a triangular wave (default: sin).
|
|
# Supported types: "sin", "triang", "square",
|
|
# "sawtooth"
|
|
"shape: "triang"
|
|
}
|
|
]
|
|
|
|
.. code-block:: python
|
|
|
|
[
|
|
{
|
|
"midi_note": "C4", # C4 MIDI note
|
|
"duration": 0.5 # 0.5 seconds or until stop_playback
|
|
},
|
|
{
|
|
"midi_note": "G5", # G5 MIDI note
|
|
"duration": 0.5, # 0.5 seconds or until stop_playback
|
|
"delay": 0.5 # Start this note 0.5 seconds
|
|
# after playback has started
|
|
}
|
|
]
|
|
|
|
:param device: Output device (default: default configured device or
|
|
system default audio output if not configured)
|
|
:param duration: Playback duration, in seconds. Default: None - play
|
|
until the end of the audio source or until :meth:`.stop_playback`.
|
|
:param blocksize: Audio block size (default: configured
|
|
`output_blocksize` or 2048)
|
|
:param sample_rate: Audio sample rate. Default: audio file sample rate
|
|
if in file mode, 44100 Hz if in synth mode
|
|
:param channels: Number of audio channels. Default: number of channels
|
|
in the audio file in file mode, 1 if in synth mode
|
|
:param volume: Playback volume, between 0 and 100. Default: 100.
|
|
:param dtype: Data type for the audio samples, if playing raw PCM audio
|
|
frames. Supported types: 'float64', 'float32', 'int32', 'int16',
|
|
'int8', 'uint8'.
|
|
:param format: Output audio format, if you want to convert the audio to
|
|
another format before playing it. The list of available formats can
|
|
be retrieved through the ``ffmpeg -formats`` command. Default: None
|
|
:param stream_index: If specified, play to an already active stream
|
|
index (you can get them through :meth:`.query_streams`). Default:
|
|
creates a new audio stream through PortAudio.
|
|
:param stream_name: Name of the stream to play to. If set, the sound
|
|
will be played to the specified stream name, or a stream with that
|
|
name will be created. If not set, and ``stream_index`` is not set
|
|
either, then a new stream will be created on the next available
|
|
index and named ``platypush-stream-<index>``.
|
|
:param join: If True, then the method will block until the playback is
|
|
completed. Default: False.
|
|
"""
|
|
|
|
dev = self._manager.get_device(device=device, type=StreamType.OUTPUT)
|
|
blocksize = blocksize or self.output_blocksize
|
|
|
|
if file:
|
|
self.logger.warning(
|
|
'file is deprecated, use resource instead',
|
|
)
|
|
if not resource:
|
|
resource = file
|
|
|
|
if not (resource or sound):
|
|
raise RuntimeError(
|
|
'Please specify either a file to play or a list of sound objects'
|
|
)
|
|
|
|
self.logger.info(
|
|
'Starting playback of %s to sound device [%s] on stream [%s]',
|
|
resource or sound,
|
|
dev.index,
|
|
stream_index,
|
|
)
|
|
|
|
player_kwargs = {}
|
|
if dtype:
|
|
player_kwargs['dtype'] = dtype
|
|
if format:
|
|
player_kwargs['format'] = format
|
|
|
|
player = self._manager.create_player(
|
|
device=dev.index,
|
|
infile=resource,
|
|
sound=sound,
|
|
duration=duration,
|
|
blocksize=blocksize,
|
|
sample_rate=sample_rate,
|
|
channels=channels,
|
|
volume=volume,
|
|
stream_name=stream_name,
|
|
**player_kwargs,
|
|
)
|
|
|
|
player.start()
|
|
if join:
|
|
player.join()
|
|
|
|
@action
|
|
def stream_recording(self, *args, **kwargs):
|
|
"""
|
|
Deprecated alias for :meth:`.record`.
|
|
"""
|
|
self.logger.warning(
|
|
'sound.stream_recording is deprecated, use sound.record instead',
|
|
)
|
|
|
|
return self.record(*args, **kwargs)
|
|
|
|
@action
|
|
def record(
|
|
self,
|
|
device: Optional[DeviceType] = None,
|
|
output_device: Optional[DeviceType] = None,
|
|
fifo: Optional[str] = None,
|
|
outfile: Optional[str] = None,
|
|
duration: Optional[float] = None,
|
|
sample_rate: Optional[int] = None,
|
|
dtype: str = 'int16',
|
|
blocksize: Optional[int] = None,
|
|
latency: Union[float, str] = 'high',
|
|
channels: int = 1,
|
|
volume: float = 100,
|
|
redis_queue: Optional[str] = None,
|
|
format: str = 'wav', # pylint: disable=redefined-builtin
|
|
stream: bool = True,
|
|
stream_name: Optional[str] = None,
|
|
play_audio: bool = False,
|
|
):
|
|
"""
|
|
Return audio data from an audio source
|
|
|
|
:param device: Input device (default: default configured device or
|
|
system default audio input if not configured)
|
|
:param output_device: Audio output device if ``play_audio=True`` (audio
|
|
pass-through) (default: default configured device or system default
|
|
audio output if not configured)
|
|
:param fifo: Path of a FIFO that will be used to exchange audio frames
|
|
with other consumers.
|
|
:param outfile: If specified, the audio data will be persisted on the
|
|
specified audio file too.
|
|
:param duration: Recording duration in seconds (default: record until
|
|
stop event)
|
|
:param sample_rate: Recording sample rate (default: device default rate)
|
|
:param dtype: Data type for the audio samples. Supported types:
|
|
'float64', 'float32', 'int32', 'int16', 'int8', 'uint8'. Default:
|
|
int16.
|
|
:param blocksize: Audio block size (default: configured
|
|
`input_blocksize` or 2048)
|
|
:param play_audio: If True, then the recorded audio will be played in
|
|
real-time on the selected ``output_device`` (default: False).
|
|
:param latency: Device latency in seconds (default: the device's default high latency)
|
|
:param channels: Number of channels (default: 1)
|
|
:param volume: Recording volume, between 0 and 100. Default: 100.
|
|
:param redis_queue: If set, the audio chunks will also be published to
|
|
this Redis channel, so other consumers can process them downstream.
|
|
:param format: Audio format. Supported: wav, mp3, ogg, aac, flac.
|
|
Default: wav.
|
|
:param stream: If True (default), then the audio will be streamed to an
|
|
HTTP endpoint too (default: ``/sound/stream<.format>``).
|
|
:param stream_name: Custom name for the output stream.
|
|
"""
|
|
|
|
dev = self._manager.get_device(device=device, type=StreamType.INPUT)
|
|
self._manager.create_recorder(
|
|
dev.index,
|
|
output_device=output_device,
|
|
fifo=fifo,
|
|
outfile=outfile,
|
|
duration=duration,
|
|
sample_rate=sample_rate,
|
|
dtype=dtype,
|
|
blocksize=blocksize,
|
|
latency=latency,
|
|
channels=channels,
|
|
volume=volume,
|
|
redis_queue=redis_queue,
|
|
format=format,
|
|
stream=stream,
|
|
stream_name=stream_name,
|
|
play_audio=play_audio,
|
|
).start()
|
|
|
|
@action
|
|
def recordplay(self, *args, **kwargs):
|
|
"""
|
|
Deprecated alias for :meth:`.record`.
|
|
"""
|
|
self.logger.warning(
|
|
'sound.recordplay is deprecated, use sound.record with `play_audio=True` instead',
|
|
)
|
|
|
|
kwargs['play_audio'] = True
|
|
return self.record(*args, **kwargs)
|
|
|
|
@action
|
|
def status(self) -> List[dict]:
|
|
"""
|
|
:return: The current status of the audio devices and streams.
|
|
|
|
Example:
|
|
|
|
.. code-block:: json
|
|
|
|
[
|
|
{
|
|
"streams": [
|
|
{
|
|
"device": 3,
|
|
"direction": "output",
|
|
"outfile": "/dev/null",
|
|
"infile": "/mnt/hd/media/music/audio.mp3",
|
|
"ffmpeg_bin": "ffmpeg",
|
|
"channels": 2,
|
|
"sample_rate": 44100,
|
|
"dtype": "int16",
|
|
"streaming": false,
|
|
"duration": null,
|
|
"blocksize": 1024,
|
|
"latency": "high",
|
|
"redis_queue": "platypush-stream-AudioResourcePlayer-3",
|
|
"audio_pass_through": false,
|
|
"state": "PAUSED",
|
|
"started_time": "2023-06-19T11:57:05.882329",
|
|
"stream_index": 1,
|
|
"stream_name": "platypush:audio:output:1"
|
|
}
|
|
],
|
|
"index": 3,
|
|
"name": "default",
|
|
"hostapi": 0,
|
|
"max_input_channels": 32,
|
|
"max_output_channels": 32,
|
|
"default_samplerate": 44100,
|
|
"default_low_input_latency": 0.008707482993197279,
|
|
"default_low_output_latency": 0.008707482993197279,
|
|
"default_high_input_latency": 0.034829931972789115,
|
|
"default_high_output_latency": 0.034829931972789115
|
|
}
|
|
]
|
|
|
|
"""
|
|
devices = self._manager.get_devices()
|
|
streams = self._manager.get_streams()
|
|
ret = {dev.index: {'streams': [], **asdict(dev)} for dev in devices}
|
|
for stream in streams:
|
|
if stream.device is None:
|
|
continue
|
|
|
|
dev_index = int(
|
|
stream.device
|
|
if isinstance(stream.device, (int, str))
|
|
else stream.device[0]
|
|
)
|
|
|
|
ret[dev_index]['streams'].append(stream.asdict())
|
|
|
|
return list(ret.values())
|
|
|
|
@action
|
|
def query_streams(self):
|
|
"""
|
|
Deprecated alias for :meth:`.status`.
|
|
"""
|
|
|
|
self.logger.warning(
|
|
'sound.query_streams is deprecated, use sound.status instead',
|
|
)
|
|
|
|
return self.status()
|
|
|
|
@action
|
|
def stop_playback(
|
|
self,
|
|
device: Optional[DeviceType] = None,
|
|
streams: Optional[Iterable[Union[int, str]]] = None,
|
|
):
|
|
"""
|
|
:param device: Only stop the streams on the specified device, by name or index (default: all).
|
|
:param streams: Streams to stop by index or name (default: all).
|
|
"""
|
|
self._manager.stop_audio(device=device, streams=streams, type=StreamType.OUTPUT)
|
|
|
|
@action
|
|
def pause_playback(
|
|
self,
|
|
device: Optional[DeviceType] = None,
|
|
streams: Optional[Iterable[Union[int, str]]] = None,
|
|
):
|
|
"""
|
|
:param device: Only stop the streams on the specified device, by name or index (default: all).
|
|
:param streams: Streams to stop by index or name (default: all).
|
|
"""
|
|
self._manager.pause_audio(
|
|
device=device, streams=streams, type=StreamType.OUTPUT
|
|
)
|
|
|
|
@action
|
|
def stop_recording(
|
|
self, device: Optional[DeviceType] = None, timeout: Optional[float] = 2
|
|
):
|
|
"""
|
|
Stop the current recording process on the selected device (default:
|
|
default input device), if it is running.
|
|
"""
|
|
self._manager.stop_audio(device, StreamType.INPUT, timeout=timeout)
|
|
|
|
@action
|
|
def pause_recording(self, device: Optional[DeviceType] = None):
|
|
"""
|
|
Toggle the recording pause state on the selected device (default:
|
|
default input device), if it is running.
|
|
|
|
If paused, the recording will be resumed. If running, it will be
|
|
paused. Otherwise, no action will be taken.
|
|
"""
|
|
self._manager.pause_audio(device, StreamType.INPUT)
|
|
|
|
@action
|
|
def set_volume(
|
|
self,
|
|
volume: float,
|
|
device: Optional[DeviceType] = None,
|
|
streams: Optional[Iterable[Union[int, str]]] = None,
|
|
):
|
|
"""
|
|
Set the audio input/output volume.
|
|
|
|
:param volume: New volume, between 0 and 100.
|
|
:param device: Set the volume only on the specified device (default:
|
|
all).
|
|
:param streams: Set the volume only on the specified list of stream
|
|
indices/names (default: all).
|
|
"""
|
|
self._manager.set_volume(volume=volume, device=device, streams=streams)
|
|
|
|
def main(self):
|
|
try:
|
|
self.wait_stop()
|
|
finally:
|
|
self._manager.stop_audio()
|
|
|
|
|
|
# vim:sw=4:ts=4:et:
|