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
[docs]
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
[docs]
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:`.status` to get the available devices. Default: system
default
:param output_device: Index or name of the default output device.
Use :meth:`.status` 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,
)
[docs]
@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,
start_padding: float = 0,
end_padding: float = 0,
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 start_padding: Silence, in seconds, to prepend to the stream
before playing the audio. This can be useful to give the audio
backend (e.g. PulseAudio/PipeWire) time to initialize the output
path, avoiding the first fraction of audio being silently dropped
(default: 0).
:param end_padding: Silence, in seconds, to append to the stream before
closing it. This can be useful for short generated audio clips, like
TTS, on backends that may otherwise drop the tail of the playback
when the stream is closed (default: 0).
: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,
start_padding=start_padding,
end_padding=end_padding,
**player_kwargs,
)
player.start()
if join:
player.join()
[docs]
@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)
[docs]
@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()
[docs]
@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)
[docs]
@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())
[docs]
@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()
[docs]
@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)
[docs]
@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
)
[docs]
@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)
[docs]
@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)
[docs]
@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: