forked from platypush/platypush
Refactoring the sound
plugin to use ffmpeg as a stream converter.
This commit is contained in:
parent
4587b262b0
commit
e238fcb6e4
9 changed files with 401 additions and 167 deletions
|
@ -1,74 +0,0 @@
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
from flask import Response, Blueprint, request
|
|
||||||
|
|
||||||
from platypush.backend.http.app import template_folder
|
|
||||||
from platypush.backend.http.app.utils import authenticate, send_request
|
|
||||||
|
|
||||||
sound = Blueprint('sound', __name__, template_folder=template_folder)
|
|
||||||
|
|
||||||
# Declare routes list
|
|
||||||
__routes__ = [
|
|
||||||
sound,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# Generates the .wav file header for a given set of samples and specs
|
|
||||||
# noinspection PyRedundantParentheses
|
|
||||||
def gen_header(sample_rate, sample_width, channels):
|
|
||||||
datasize = int(2000 * 1e6) # Arbitrary data size for streaming
|
|
||||||
o = bytes("RIFF", ' ascii') # (4byte) Marks file as RIFF
|
|
||||||
o += (datasize + 36).to_bytes(4, 'little') # (4byte) File size in bytes
|
|
||||||
o += bytes("WAVE", 'ascii') # (4byte) File type
|
|
||||||
o += bytes("fmt ", 'ascii') # (4byte) Format Chunk Marker
|
|
||||||
o += (16).to_bytes(4, 'little') # (4byte) Length of above format data
|
|
||||||
o += (1).to_bytes(2, 'little') # (2byte) Format type (1 - PCM)
|
|
||||||
o += channels.to_bytes(2, 'little') # (2byte)
|
|
||||||
o += sample_rate.to_bytes(4, 'little') # (4byte)
|
|
||||||
o += (sample_rate * channels * sample_width // 8).to_bytes(4, 'little') # (4byte)
|
|
||||||
o += (channels * sample_width // 8).to_bytes(2, 'little') # (2byte)
|
|
||||||
o += sample_width.to_bytes(2, 'little') # (2byte)
|
|
||||||
o += bytes("data", 'ascii') # (4byte) Data Chunk Marker
|
|
||||||
o += datasize.to_bytes(4, 'little') # (4byte) Data size in bytes
|
|
||||||
return o
|
|
||||||
|
|
||||||
|
|
||||||
def audio_feed(device, fifo, sample_rate, blocksize, latency, channels):
|
|
||||||
send_request(action='sound.stream_recording', device=device, sample_rate=sample_rate,
|
|
||||||
dtype='int16', fifo=fifo, blocksize=blocksize, latency=latency,
|
|
||||||
channels=channels)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(fifo, 'rb') as f: # lgtm [py/path-injection]
|
|
||||||
send_header = True
|
|
||||||
|
|
||||||
while True:
|
|
||||||
audio = f.read(blocksize)
|
|
||||||
|
|
||||||
if audio:
|
|
||||||
if send_header:
|
|
||||||
audio = gen_header(sample_rate=sample_rate, sample_width=16, channels=channels) + audio
|
|
||||||
send_header = False
|
|
||||||
|
|
||||||
yield audio
|
|
||||||
finally:
|
|
||||||
send_request(action='sound.stop_recording')
|
|
||||||
|
|
||||||
|
|
||||||
@sound.route('/sound/stream', methods=['GET'])
|
|
||||||
@authenticate()
|
|
||||||
def get_sound_feed():
|
|
||||||
device = request.args.get('device')
|
|
||||||
sample_rate = request.args.get('sample_rate', 44100)
|
|
||||||
blocksize = request.args.get('blocksize', 512)
|
|
||||||
latency = request.args.get('latency', 0)
|
|
||||||
channels = request.args.get('channels', 1)
|
|
||||||
fifo = request.args.get('fifo', os.path.join(tempfile.gettempdir(), 'inputstream'))
|
|
||||||
|
|
||||||
return Response(audio_feed(device=device, fifo=fifo, sample_rate=sample_rate,
|
|
||||||
blocksize=blocksize, latency=latency, channels=channels),
|
|
||||||
mimetype='audio/x-wav;codec=pcm')
|
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
|
|
@ -1,3 +1,3 @@
|
||||||
from ._base import StreamingRoute, logger
|
from ._base import StreamingRoute
|
||||||
|
|
||||||
__all__ = ['StreamingRoute', 'logger']
|
__all__ = ['StreamingRoute']
|
||||||
|
|
|
@ -11,8 +11,6 @@ from platypush.backend.http.app.utils.auth import AuthStatus, get_auth_status
|
||||||
|
|
||||||
from ..mixins import PubSubMixin
|
from ..mixins import PubSubMixin
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@stream_request_body
|
@stream_request_body
|
||||||
class StreamingRoute(RequestHandler, PubSubMixin, ABC):
|
class StreamingRoute(RequestHandler, PubSubMixin, ABC):
|
||||||
|
@ -20,6 +18,10 @@ class StreamingRoute(RequestHandler, PubSubMixin, ABC):
|
||||||
Base class for Tornado streaming routes.
|
Base class for Tornado streaming routes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.logger = getLogger(__name__)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def prepare(self):
|
def prepare(self):
|
||||||
"""
|
"""
|
||||||
|
@ -32,7 +34,7 @@ class StreamingRoute(RequestHandler, PubSubMixin, ABC):
|
||||||
self.send_error(auth_status.value.code, error=auth_status.value.message)
|
self.send_error(auth_status.value.code, error=auth_status.value.message)
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(
|
self.logger.info(
|
||||||
'Client %s connected to %s', self.request.remote_ip, self.request.path
|
'Client %s connected to %s', self.request.remote_ip, self.request.path
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -63,3 +65,62 @@ class StreamingRoute(RequestHandler, PubSubMixin, ABC):
|
||||||
authentication and return 401 if authentication fails.
|
authentication and return 401 if authentication fails.
|
||||||
"""
|
"""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_redis_queue(cls, *_, **__) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Returns the Redis channel associated with a given set of arguments.
|
||||||
|
|
||||||
|
This is None by default, and it should be implemented by subclasses if
|
||||||
|
required.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def forward_stream(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Utility method that does the following:
|
||||||
|
|
||||||
|
1. It listens for new messages on the subscribed Redis channels;
|
||||||
|
2. It applies a filter on the channel if :meth:`._get_redis_queue`
|
||||||
|
returns a non-null result given ``args`` and ``kwargs``;
|
||||||
|
3. It forward the frames read from the Redis channel(s) to the HTTP client;
|
||||||
|
4. It periodically invokes :meth:`._should_stop` to cleanly
|
||||||
|
terminate when the HTTP client socket is closed.
|
||||||
|
|
||||||
|
"""
|
||||||
|
redis_queue = self._get_redis_queue( # pylint: disable=assignment-from-none
|
||||||
|
*args, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
if redis_queue:
|
||||||
|
self.subscribe(redis_queue)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for msg in self.listen():
|
||||||
|
if self._should_stop():
|
||||||
|
break
|
||||||
|
|
||||||
|
if redis_queue and msg.channel != redis_queue:
|
||||||
|
continue
|
||||||
|
|
||||||
|
frame = msg.data
|
||||||
|
if frame:
|
||||||
|
self.write(frame)
|
||||||
|
self.flush()
|
||||||
|
finally:
|
||||||
|
if redis_queue:
|
||||||
|
self.unsubscribe(redis_queue)
|
||||||
|
|
||||||
|
def _should_stop(self):
|
||||||
|
"""
|
||||||
|
Utility method used by :meth:`._forward_stream` to automatically
|
||||||
|
terminate when the client connection is closed (it can be overridden by
|
||||||
|
the subclasses).
|
||||||
|
"""
|
||||||
|
if self._finished:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if self.request.connection and getattr(self.request.connection, 'stream', None):
|
||||||
|
return self.request.connection.stream.closed() # type: ignore
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
|
@ -1,19 +1,17 @@
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import json
|
import json
|
||||||
from logging import getLogger
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from typing_extensions import override
|
from typing_extensions import override
|
||||||
|
|
||||||
from tornado.web import stream_request_body
|
from tornado.web import stream_request_body
|
||||||
from platypush.context import get_plugin
|
from platypush.context import get_plugin
|
||||||
|
|
||||||
|
from platypush.config import Config
|
||||||
from platypush.plugins.camera import Camera, CameraPlugin, StreamWriter
|
from platypush.plugins.camera import Camera, CameraPlugin, StreamWriter
|
||||||
from platypush.utils import get_plugin_name_by_class
|
from platypush.utils import get_plugin_name_by_class
|
||||||
|
|
||||||
from .. import StreamingRoute
|
from .. import StreamingRoute
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class RequestType(Enum):
|
class RequestType(Enum):
|
||||||
"""
|
"""
|
||||||
|
@ -31,10 +29,9 @@ class CameraRoute(StreamingRoute):
|
||||||
Route for camera streams.
|
Route for camera streams.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_redis_queue_prefix = '_platypush/camera'
|
_redis_queue_prefix = f'_platypush/{Config.get("device_id") or ""}/camera'
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
# TODO Support multiple concurrent requests
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self._camera: Optional[Camera] = None
|
self._camera: Optional[Camera] = None
|
||||||
self._request_type = RequestType.UNKNOWN
|
self._request_type = RequestType.UNKNOWN
|
||||||
|
@ -61,29 +58,6 @@ class CameraRoute(StreamingRoute):
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _should_stop(self):
|
|
||||||
if self._finished:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if self.request.connection and getattr(self.request.connection, 'stream', None):
|
|
||||||
return self.request.connection.stream.closed() # type: ignore
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def send_feed(self, camera: CameraPlugin):
|
|
||||||
redis_queue = self._get_redis_queue_by_camera(camera)
|
|
||||||
for msg in self.listen():
|
|
||||||
if self._should_stop():
|
|
||||||
break
|
|
||||||
|
|
||||||
if msg.channel != redis_queue:
|
|
||||||
continue
|
|
||||||
|
|
||||||
frame = msg.data
|
|
||||||
if frame:
|
|
||||||
self.write(frame)
|
|
||||||
self.flush()
|
|
||||||
|
|
||||||
def send_frame(self, camera: Camera):
|
def send_frame(self, camera: Camera):
|
||||||
frame = None
|
frame = None
|
||||||
for _ in range(camera.info.warmup_frames):
|
for _ in range(camera.info.warmup_frames):
|
||||||
|
@ -121,8 +95,9 @@ class CameraRoute(StreamingRoute):
|
||||||
|
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
@override
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_redis_queue_by_camera(cls, camera: CameraPlugin) -> str:
|
def _get_redis_queue(cls, camera: CameraPlugin, *_, **__) -> str:
|
||||||
plugin_name = get_plugin_name_by_class(camera.__class__)
|
plugin_name = get_plugin_name_by_class(camera.__class__)
|
||||||
assert plugin_name, f'No such plugin: {plugin_name}'
|
assert plugin_name, f'No such plugin: {plugin_name}'
|
||||||
return '/'.join(
|
return '/'.join(
|
||||||
|
@ -144,9 +119,8 @@ class CameraRoute(StreamingRoute):
|
||||||
|
|
||||||
stream_class = StreamWriter.get_class_by_name(self._extension)
|
stream_class = StreamWriter.get_class_by_name(self._extension)
|
||||||
camera = self._get_camera(plugin)
|
camera = self._get_camera(plugin)
|
||||||
redis_queue = self._get_redis_queue_by_camera(camera)
|
redis_queue = self._get_redis_queue(camera)
|
||||||
self.set_header('Content-Type', stream_class.mimetype)
|
self.set_header('Content-Type', stream_class.mimetype)
|
||||||
self.subscribe(redis_queue)
|
|
||||||
|
|
||||||
with camera.open(
|
with camera.open(
|
||||||
stream=True,
|
stream=True,
|
||||||
|
@ -159,6 +133,6 @@ class CameraRoute(StreamingRoute):
|
||||||
if self._request_type == RequestType.PHOTO:
|
if self._request_type == RequestType.PHOTO:
|
||||||
self.send_frame(session)
|
self.send_frame(session)
|
||||||
elif self._request_type == RequestType.VIDEO:
|
elif self._request_type == RequestType.VIDEO:
|
||||||
self.send_feed(camera)
|
self.forward_stream(camera)
|
||||||
|
|
||||||
self.finish()
|
self.finish()
|
||||||
|
|
97
platypush/backend/http/app/streaming/plugins/sound.py
Normal file
97
platypush/backend/http/app/streaming/plugins/sound.py
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import json
|
||||||
|
from typing import Generator, Optional
|
||||||
|
from typing_extensions import override
|
||||||
|
|
||||||
|
from tornado.web import stream_request_body
|
||||||
|
|
||||||
|
from platypush.backend.http.app.utils import send_request
|
||||||
|
from platypush.config import Config
|
||||||
|
|
||||||
|
from .. import StreamingRoute
|
||||||
|
|
||||||
|
|
||||||
|
@stream_request_body
|
||||||
|
class SoundRoute(StreamingRoute):
|
||||||
|
"""
|
||||||
|
Route for audio streams.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_redis_queue_prefix = f'_platypush/{Config.get("device_id") or ""}/sound'
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._audio_headers_written: bool = False
|
||||||
|
"""Send the audio file headers before we send the first audio frame."""
|
||||||
|
|
||||||
|
@override
|
||||||
|
@classmethod
|
||||||
|
def path(cls) -> str:
|
||||||
|
return r"/sound/stream\.?([a-zA-Z0-9_]+)?"
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _audio_stream(self, **kwargs) -> Generator[None, None, None]:
|
||||||
|
response = send_request(
|
||||||
|
'sound.stream_recording',
|
||||||
|
dtype='int16',
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response and not response.is_error(), (
|
||||||
|
'Streaming error: ' + str(response.errors) if response else '(unknown)'
|
||||||
|
)
|
||||||
|
|
||||||
|
yield
|
||||||
|
send_request('sound.stop_recording')
|
||||||
|
|
||||||
|
@override
|
||||||
|
@classmethod
|
||||||
|
def _get_redis_queue(cls, device: Optional[str] = None, *_, **__) -> str:
|
||||||
|
return '/'.join([cls._redis_queue_prefix, *([device] if device else [])])
|
||||||
|
|
||||||
|
def _get_args(self, **kwargs):
|
||||||
|
kwargs.update({k: v[0].decode() for k, v in self.request.arguments.items()})
|
||||||
|
device = kwargs.get('device')
|
||||||
|
return {
|
||||||
|
'device': device,
|
||||||
|
'sample_rate': int(kwargs.get('sample_rate', 44100)),
|
||||||
|
'blocksize': int(kwargs.get('blocksize', 512)),
|
||||||
|
'latency': float(kwargs.get('latency', 0)),
|
||||||
|
'channels': int(kwargs.get('channels', 1)),
|
||||||
|
'format': kwargs.get('format', 'wav'),
|
||||||
|
'redis_queue': kwargs.get('redis_queue', self._get_redis_queue(device)),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _content_type_by_extension(extension: str) -> str:
|
||||||
|
if extension == 'mp3':
|
||||||
|
return 'audio/mpeg'
|
||||||
|
if extension == 'ogg':
|
||||||
|
return 'audio/ogg'
|
||||||
|
if extension == 'wav':
|
||||||
|
return 'audio/wav;codec=pcm'
|
||||||
|
if extension == 'flac':
|
||||||
|
return 'audio/flac'
|
||||||
|
if extension == 'aac':
|
||||||
|
return 'audio/aac'
|
||||||
|
return 'application/octet-stream'
|
||||||
|
|
||||||
|
def get(self, extension: Optional[str] = None) -> None:
|
||||||
|
ext = extension or 'wav'
|
||||||
|
args = self._get_args(format=ext)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._audio_stream(**args):
|
||||||
|
self.set_header('Content-Type', self._content_type_by_extension(ext))
|
||||||
|
self.forward_stream(**args)
|
||||||
|
|
||||||
|
self.finish()
|
||||||
|
except AssertionError as e:
|
||||||
|
self.set_header("Content-Type", "application/json")
|
||||||
|
self.set_status(400, str(e))
|
||||||
|
self.finish(json.dumps({"error": str(e)}))
|
||||||
|
except Exception as e:
|
||||||
|
self.set_header("Content-Type", "application/json")
|
||||||
|
self.logger.exception(e)
|
||||||
|
self.set_status(500, str(e))
|
||||||
|
self.finish(json.dumps({"error": str(e)}))
|
|
@ -1,3 +1,4 @@
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import importlib
|
import importlib
|
||||||
import inspect
|
import inspect
|
||||||
|
@ -5,7 +6,9 @@ from typing import List, Type
|
||||||
|
|
||||||
import pkgutil
|
import pkgutil
|
||||||
|
|
||||||
from ..streaming import StreamingRoute, logger
|
from ..streaming import StreamingRoute
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_streaming_routes() -> List[Type[StreamingRoute]]:
|
def get_streaming_routes() -> List[Type[StreamingRoute]]:
|
||||||
|
|
|
@ -6,7 +6,10 @@ import time
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from threading import Thread, Event, RLock
|
from threading import Thread, Event, RLock
|
||||||
from typing import Optional
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
import sounddevice as sd
|
||||||
|
import soundfile as sf
|
||||||
|
|
||||||
from platypush.context import get_bus
|
from platypush.context import get_bus
|
||||||
from platypush.message.event.sound import (
|
from platypush.message.event.sound import (
|
||||||
|
@ -15,8 +18,10 @@ from platypush.message.event.sound import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from platypush.plugins import Plugin, action
|
from platypush.plugins import Plugin, action
|
||||||
|
from platypush.utils import get_redis
|
||||||
|
|
||||||
from .core import Sound, Mix
|
from .core import Sound, Mix
|
||||||
|
from ._converter import ConverterProcess
|
||||||
|
|
||||||
|
|
||||||
class PlaybackState(Enum):
|
class PlaybackState(Enum):
|
||||||
|
@ -49,10 +54,11 @@ class SoundPlugin(Plugin):
|
||||||
* **sounddevice** (``pip install sounddevice``)
|
* **sounddevice** (``pip install sounddevice``)
|
||||||
* **soundfile** (``pip install soundfile``)
|
* **soundfile** (``pip install soundfile``)
|
||||||
* **numpy** (``pip install numpy``)
|
* **numpy** (``pip install numpy``)
|
||||||
|
* **ffmpeg** package installed on the system (for streaming support)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_STREAM_NAME_PREFIX = 'platypush-stream-'
|
_STREAM_NAME_PREFIX = 'platypush-stream-'
|
||||||
_default_input_stream_fifo = os.path.join(tempfile.gettempdir(), 'inputstream')
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@ -60,6 +66,7 @@ class SoundPlugin(Plugin):
|
||||||
output_device=None,
|
output_device=None,
|
||||||
input_blocksize=Sound._DEFAULT_BLOCKSIZE,
|
input_blocksize=Sound._DEFAULT_BLOCKSIZE,
|
||||||
output_blocksize=Sound._DEFAULT_BLOCKSIZE,
|
output_blocksize=Sound._DEFAULT_BLOCKSIZE,
|
||||||
|
ffmpeg_bin: str = 'ffmpeg',
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
@ -82,6 +89,9 @@ class SoundPlugin(Plugin):
|
||||||
Try to increase this value if you get output underflow errors while
|
Try to increase this value if you get output underflow errors while
|
||||||
playing. Default: 1024
|
playing. Default: 1024
|
||||||
:type output_blocksize: int
|
:type output_blocksize: int
|
||||||
|
|
||||||
|
:param ffmpeg_bin: Path of the ``ffmpeg`` binary (default: search for
|
||||||
|
the ``ffmpeg`` in the ``PATH``).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
@ -102,6 +112,7 @@ class SoundPlugin(Plugin):
|
||||||
self.stream_name_to_index = {}
|
self.stream_name_to_index = {}
|
||||||
self.stream_index_to_name = {}
|
self.stream_index_to_name = {}
|
||||||
self.completed_callback_events = {}
|
self.completed_callback_events = {}
|
||||||
|
self.ffmpeg_bin = ffmpeg_bin
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_default_device(category):
|
def _get_default_device(category):
|
||||||
|
@ -111,9 +122,6 @@ class SoundPlugin(Plugin):
|
||||||
:param category: Device category to query. Can be either input or output
|
:param category: Device category to query. Can be either input or output
|
||||||
:type category: str
|
:type category: str
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sounddevice as sd
|
|
||||||
|
|
||||||
return sd.query_hostapis()[0].get('default_' + category.lower() + '_device')
|
return sd.query_hostapis()[0].get('default_' + category.lower() + '_device')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
@ -155,8 +163,6 @@ class SoundPlugin(Plugin):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sounddevice as sd
|
|
||||||
|
|
||||||
devs = sd.query_devices()
|
devs = sd.query_devices()
|
||||||
if category == 'input':
|
if category == 'input':
|
||||||
devs = [d for d in devs if d.get('max_input_channels') > 0]
|
devs = [d for d in devs if d.get('max_input_channels') > 0]
|
||||||
|
@ -166,8 +172,6 @@ class SoundPlugin(Plugin):
|
||||||
return devs
|
return devs
|
||||||
|
|
||||||
def _play_audio_callback(self, q, blocksize, streamtype, stream_index):
|
def _play_audio_callback(self, q, blocksize, streamtype, stream_index):
|
||||||
import sounddevice as sd
|
|
||||||
|
|
||||||
is_raw_stream = streamtype == sd.RawOutputStream
|
is_raw_stream = streamtype == sd.RawOutputStream
|
||||||
|
|
||||||
def audio_callback(outdata, frames, *, status):
|
def audio_callback(outdata, frames, *, status):
|
||||||
|
@ -277,8 +281,6 @@ class SoundPlugin(Plugin):
|
||||||
'Please specify either a file to play or a ' + 'list of sound objects'
|
'Please specify either a file to play or a ' + 'list of sound objects'
|
||||||
)
|
)
|
||||||
|
|
||||||
import sounddevice as sd
|
|
||||||
|
|
||||||
if blocksize is None:
|
if blocksize is None:
|
||||||
blocksize = self.output_blocksize
|
blocksize = self.output_blocksize
|
||||||
|
|
||||||
|
@ -301,8 +303,6 @@ class SoundPlugin(Plugin):
|
||||||
device = self._get_default_device('output')
|
device = self._get_default_device('output')
|
||||||
|
|
||||||
if file:
|
if file:
|
||||||
import soundfile as sf
|
|
||||||
|
|
||||||
f = sf.SoundFile(file)
|
f = sf.SoundFile(file)
|
||||||
if not samplerate:
|
if not samplerate:
|
||||||
samplerate = f.samplerate if f else Sound._DEFAULT_SAMPLERATE
|
samplerate = f.samplerate if f else Sound._DEFAULT_SAMPLERATE
|
||||||
|
@ -444,10 +444,12 @@ class SoundPlugin(Plugin):
|
||||||
fifo: Optional[str] = None,
|
fifo: Optional[str] = None,
|
||||||
duration: Optional[float] = None,
|
duration: Optional[float] = None,
|
||||||
sample_rate: Optional[int] = None,
|
sample_rate: Optional[int] = None,
|
||||||
dtype: Optional[str] = 'float32',
|
dtype: str = 'float32',
|
||||||
blocksize: Optional[int] = None,
|
blocksize: Optional[int] = None,
|
||||||
latency: float = 0,
|
latency: Union[float, str] = 'high',
|
||||||
channels: int = 1,
|
channels: int = 1,
|
||||||
|
redis_queue: Optional[str] = None,
|
||||||
|
format: str = 'wav',
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Return audio data from an audio source
|
Return audio data from an audio source
|
||||||
|
@ -464,12 +466,13 @@ class SoundPlugin(Plugin):
|
||||||
float32
|
float32
|
||||||
:param blocksize: Audio block size (default: configured
|
:param blocksize: Audio block size (default: configured
|
||||||
`input_blocksize` or 2048)
|
`input_blocksize` or 2048)
|
||||||
:param latency: Device latency in seconds (default: 0)
|
:param latency: Device latency in seconds (default: the device's default high latency)
|
||||||
:param channels: Number of channels (default: 1)
|
:param channels: Number of channels (default: 1)
|
||||||
|
: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. Default: wav.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sounddevice as sd
|
|
||||||
|
|
||||||
self.recording_paused_changed.clear()
|
self.recording_paused_changed.clear()
|
||||||
|
|
||||||
if device is None:
|
if device is None:
|
||||||
|
@ -485,30 +488,42 @@ class SoundPlugin(Plugin):
|
||||||
blocksize = self.input_blocksize
|
blocksize = self.input_blocksize
|
||||||
|
|
||||||
if not fifo:
|
if not fifo:
|
||||||
fifo = self._default_input_stream_fifo
|
fifo = os.devnull
|
||||||
|
|
||||||
q = queue.Queue()
|
def audio_callback(audio_converter: ConverterProcess):
|
||||||
|
# _ = frames
|
||||||
def audio_callback(indata, frames, time_duration, status): # noqa
|
# __ = time
|
||||||
|
def callback(indata, _, __, status):
|
||||||
while self._get_recording_state() == RecordingState.PAUSED:
|
while self._get_recording_state() == RecordingState.PAUSED:
|
||||||
self.recording_paused_changed.wait()
|
self.recording_paused_changed.wait()
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
self.logger.warning('Recording callback status: %s', status)
|
self.logger.warning('Recording callback status: %s', status)
|
||||||
|
|
||||||
q.put(indata.copy())
|
audio_converter.write(indata.tobytes())
|
||||||
|
|
||||||
|
return callback
|
||||||
|
|
||||||
def streaming_thread():
|
def streaming_thread():
|
||||||
try:
|
try:
|
||||||
with sd.InputStream(
|
with ConverterProcess(
|
||||||
|
ffmpeg_bin=self.ffmpeg_bin,
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
channels=channels,
|
||||||
|
dtype=dtype,
|
||||||
|
chunk_size=self.input_blocksize,
|
||||||
|
output_format=format,
|
||||||
|
) as converter, sd.InputStream(
|
||||||
samplerate=sample_rate,
|
samplerate=sample_rate,
|
||||||
device=device,
|
device=device,
|
||||||
channels=channels,
|
channels=channels,
|
||||||
callback=audio_callback,
|
callback=audio_callback(converter),
|
||||||
dtype=dtype,
|
dtype=dtype,
|
||||||
latency=latency,
|
latency=latency,
|
||||||
blocksize=blocksize,
|
blocksize=blocksize,
|
||||||
), open(fifo, 'wb') as audio_queue:
|
), open(
|
||||||
|
fifo, 'wb'
|
||||||
|
) as audio_queue:
|
||||||
self.start_recording()
|
self.start_recording()
|
||||||
get_bus().post(SoundRecordingStartedEvent())
|
get_bus().post(SoundRecordingStartedEvent())
|
||||||
self.logger.info('Started recording from device [%s]', device)
|
self.logger.info('Started recording from device [%s]', device)
|
||||||
|
@ -521,23 +536,23 @@ class SoundPlugin(Plugin):
|
||||||
while self._get_recording_state() == RecordingState.PAUSED:
|
while self._get_recording_state() == RecordingState.PAUSED:
|
||||||
self.recording_paused_changed.wait()
|
self.recording_paused_changed.wait()
|
||||||
|
|
||||||
get_args = (
|
timeout = (
|
||||||
{
|
max(
|
||||||
'block': True,
|
|
||||||
'timeout': max(
|
|
||||||
0,
|
0,
|
||||||
duration - (time.time() - recording_started_time),
|
duration - (time.time() - recording_started_time),
|
||||||
),
|
)
|
||||||
}
|
|
||||||
if duration is not None
|
if duration is not None
|
||||||
else {}
|
else 1
|
||||||
)
|
)
|
||||||
|
|
||||||
data = q.get(**get_args)
|
data = converter.read(timeout=timeout)
|
||||||
if not len(data):
|
if not data:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
audio_queue.write(data)
|
audio_queue.write(data)
|
||||||
|
if redis_queue:
|
||||||
|
get_redis().publish(redis_queue, data)
|
||||||
|
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
self.logger.warning('Recording timeout: audio callback failed?')
|
self.logger.warning('Recording timeout: audio callback failed?')
|
||||||
finally:
|
finally:
|
||||||
|
@ -549,11 +564,8 @@ class SoundPlugin(Plugin):
|
||||||
self.logger.info('Removing previous input stream FIFO %s', fifo)
|
self.logger.info('Removing previous input stream FIFO %s', fifo)
|
||||||
os.unlink(fifo)
|
os.unlink(fifo)
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(
|
|
||||||
f'{fifo} exists and is not a FIFO. Please remove it or rename it'
|
|
||||||
)
|
|
||||||
|
|
||||||
os.mkfifo(fifo, 0o644)
|
os.mkfifo(fifo, 0o644)
|
||||||
|
|
||||||
Thread(target=streaming_thread).start()
|
Thread(target=streaming_thread).start()
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
@ -565,7 +577,7 @@ class SoundPlugin(Plugin):
|
||||||
sample_rate=None,
|
sample_rate=None,
|
||||||
format=None,
|
format=None,
|
||||||
blocksize=None,
|
blocksize=None,
|
||||||
latency=0,
|
latency='high',
|
||||||
channels=1,
|
channels=1,
|
||||||
subtype='PCM_24',
|
subtype='PCM_24',
|
||||||
):
|
):
|
||||||
|
@ -590,7 +602,7 @@ class SoundPlugin(Plugin):
|
||||||
:param blocksize: Audio block size (default: configured `input_blocksize` or 2048)
|
:param blocksize: Audio block size (default: configured `input_blocksize` or 2048)
|
||||||
:type blocksize: int
|
:type blocksize: int
|
||||||
|
|
||||||
:param latency: Device latency in seconds (default: 0)
|
:param latency: Device latency in seconds (default: the device's default high latency)
|
||||||
:type latency: float
|
:type latency: float
|
||||||
|
|
||||||
:param channels: Number of channels (default: 1)
|
:param channels: Number of channels (default: 1)
|
||||||
|
@ -613,8 +625,6 @@ class SoundPlugin(Plugin):
|
||||||
channels,
|
channels,
|
||||||
subtype,
|
subtype,
|
||||||
):
|
):
|
||||||
import sounddevice as sd
|
|
||||||
|
|
||||||
self.recording_paused_changed.clear()
|
self.recording_paused_changed.clear()
|
||||||
|
|
||||||
if outfile:
|
if outfile:
|
||||||
|
@ -661,8 +671,6 @@ class SoundPlugin(Plugin):
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import soundfile as sf
|
|
||||||
|
|
||||||
with sf.SoundFile(
|
with sf.SoundFile(
|
||||||
outfile,
|
outfile,
|
||||||
mode='w',
|
mode='w',
|
||||||
|
@ -785,8 +793,6 @@ class SoundPlugin(Plugin):
|
||||||
:type dtype: str
|
:type dtype: str
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sounddevice as sd
|
|
||||||
|
|
||||||
self.recording_paused_changed.clear()
|
self.recording_paused_changed.clear()
|
||||||
|
|
||||||
if input_device is None:
|
if input_device is None:
|
||||||
|
@ -806,8 +812,9 @@ class SoundPlugin(Plugin):
|
||||||
if blocksize is None:
|
if blocksize is None:
|
||||||
blocksize = self.output_blocksize
|
blocksize = self.output_blocksize
|
||||||
|
|
||||||
# noinspection PyUnusedLocal
|
# _ = frames
|
||||||
def audio_callback(indata, outdata, frames, time, status):
|
# __ = time
|
||||||
|
def audio_callback(indata, outdata, _, __, status):
|
||||||
while self._get_recording_state() == RecordingState.PAUSED:
|
while self._get_recording_state() == RecordingState.PAUSED:
|
||||||
self.recording_paused_changed.wait()
|
self.recording_paused_changed.wait()
|
||||||
|
|
||||||
|
|
162
platypush/plugins/sound/_converter.py
Normal file
162
platypush/plugins/sound/_converter.py
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
import asyncio
|
||||||
|
from asyncio.subprocess import PIPE
|
||||||
|
from queue import Empty
|
||||||
|
|
||||||
|
from queue import Queue
|
||||||
|
from threading import Thread
|
||||||
|
from typing import Optional, Self
|
||||||
|
|
||||||
|
from platypush.context import get_or_create_event_loop
|
||||||
|
|
||||||
|
_dtype_to_ffmpeg_format = {
|
||||||
|
'int8': 's8',
|
||||||
|
'uint8': 'u8',
|
||||||
|
'int16': 's16le',
|
||||||
|
'uint16': 'u16le',
|
||||||
|
'int32': 's32le',
|
||||||
|
'uint32': 'u32le',
|
||||||
|
'float32': 'f32le',
|
||||||
|
'float64': 'f64le',
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
Supported input types:
|
||||||
|
'int8', 'uint8', 'int16', 'uint16', 'int32', 'uint32', 'float32', 'float64'
|
||||||
|
"""
|
||||||
|
|
||||||
|
_output_format_to_ffmpeg_args = {
|
||||||
|
'wav': ('-f', 'wav'),
|
||||||
|
'ogg': ('-f', 'ogg'),
|
||||||
|
'mp3': ('-f', 'mp3'),
|
||||||
|
'aac': ('-f', 'adts'),
|
||||||
|
'flac': ('-f', 'flac'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ConverterProcess(Thread):
|
||||||
|
"""
|
||||||
|
Wrapper for an ffmpeg converter instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ffmpeg_bin: str,
|
||||||
|
sample_rate: int,
|
||||||
|
channels: int,
|
||||||
|
dtype: str,
|
||||||
|
chunk_size: int,
|
||||||
|
output_format: str,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
:param ffmpeg_bin: Path to the ffmpeg binary.
|
||||||
|
:param sample_rate: The sample rate of the input audio.
|
||||||
|
:param channels: The number of channels of the input audio.
|
||||||
|
:param dtype: The (numpy) data type of the raw input audio.
|
||||||
|
:param chunk_size: Number of bytes that will be read at once from the
|
||||||
|
ffmpeg process.
|
||||||
|
:param output_format: Output audio format.
|
||||||
|
"""
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
ffmpeg_format = _dtype_to_ffmpeg_format.get(dtype)
|
||||||
|
assert ffmpeg_format, (
|
||||||
|
f'Unsupported data type: {dtype}. Supported data types: '
|
||||||
|
f'{list(_dtype_to_ffmpeg_format.keys())}'
|
||||||
|
)
|
||||||
|
|
||||||
|
self._ffmpeg_bin = ffmpeg_bin
|
||||||
|
self._ffmpeg_format = ffmpeg_format
|
||||||
|
self._sample_rate = sample_rate
|
||||||
|
self._channels = channels
|
||||||
|
self._chunk_size = chunk_size
|
||||||
|
self._output_format = output_format
|
||||||
|
self._closed = False
|
||||||
|
self._out_queue = Queue()
|
||||||
|
self.ffmpeg = None
|
||||||
|
self._loop = None
|
||||||
|
|
||||||
|
def __enter__(self) -> Self:
|
||||||
|
self.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_, **__):
|
||||||
|
if self.ffmpeg and self._loop:
|
||||||
|
self._loop.call_soon_threadsafe(self.ffmpeg.kill)
|
||||||
|
|
||||||
|
self.ffmpeg = None
|
||||||
|
|
||||||
|
if self._loop:
|
||||||
|
self._loop = None
|
||||||
|
|
||||||
|
def _check_ffmpeg(self):
|
||||||
|
assert (
|
||||||
|
self.ffmpeg and self.ffmpeg.returncode is None
|
||||||
|
), 'The ffmpeg process has already terminated'
|
||||||
|
|
||||||
|
def _get_format_args(self):
|
||||||
|
ffmpeg_args = _output_format_to_ffmpeg_args.get(self._output_format)
|
||||||
|
assert ffmpeg_args, (
|
||||||
|
f'Unsupported output format: {self._output_format}. Supported formats: '
|
||||||
|
f'{list(_output_format_to_ffmpeg_args.keys())}'
|
||||||
|
)
|
||||||
|
|
||||||
|
return ffmpeg_args
|
||||||
|
|
||||||
|
async def _audio_proxy(self, timeout: Optional[float] = None):
|
||||||
|
self.ffmpeg = await asyncio.create_subprocess_exec(
|
||||||
|
self._ffmpeg_bin,
|
||||||
|
'-f',
|
||||||
|
self._ffmpeg_format,
|
||||||
|
'-ar',
|
||||||
|
str(self._sample_rate),
|
||||||
|
'-ac',
|
||||||
|
str(self._channels),
|
||||||
|
'-i',
|
||||||
|
'pipe:',
|
||||||
|
*self._get_format_args(),
|
||||||
|
'pipe:',
|
||||||
|
stdin=PIPE,
|
||||||
|
stdout=PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.ffmpeg.wait(), 0.1)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
while self._loop and self.ffmpeg and self.ffmpeg.returncode is None:
|
||||||
|
self._check_ffmpeg()
|
||||||
|
assert (
|
||||||
|
self.ffmpeg and self.ffmpeg.stdout
|
||||||
|
), 'The stdout is closed for the ffmpeg process'
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await asyncio.wait_for(
|
||||||
|
self.ffmpeg.stdout.read(self._chunk_size), timeout
|
||||||
|
)
|
||||||
|
self._out_queue.put(data)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self._out_queue.put(b'')
|
||||||
|
|
||||||
|
def write(self, data: bytes):
|
||||||
|
self._check_ffmpeg()
|
||||||
|
assert (
|
||||||
|
self.ffmpeg and self._loop and self.ffmpeg.stdin
|
||||||
|
), 'The stdin is closed for the ffmpeg process'
|
||||||
|
|
||||||
|
self._loop.call_soon_threadsafe(self.ffmpeg.stdin.write, data)
|
||||||
|
|
||||||
|
def read(self, timeout: Optional[float] = None) -> Optional[bytes]:
|
||||||
|
try:
|
||||||
|
return self._out_queue.get(timeout=timeout)
|
||||||
|
except Empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
super().run()
|
||||||
|
self._loop = get_or_create_event_loop()
|
||||||
|
self._loop.run_until_complete(self._audio_proxy(timeout=1))
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
|
@ -11,5 +11,9 @@ manifest:
|
||||||
- sounddevice
|
- sounddevice
|
||||||
- soundfile
|
- soundfile
|
||||||
- numpy
|
- numpy
|
||||||
|
apt:
|
||||||
|
- ffmpeg
|
||||||
|
pacman:
|
||||||
|
- ffmpeg
|
||||||
package: platypush.plugins.sound
|
package: platypush.plugins.sound
|
||||||
type: plugin
|
type: plugin
|
||||||
|
|
Loading…
Reference in a new issue