forked from platypush/platypush
Merge pull request 'New streaming endpoints' (#265) from 264-streaming-endpoints into master
Reviewed-on: platypush/platypush#265
This commit is contained in:
commit
b01bf43552
108 changed files with 4136 additions and 1826 deletions
|
@ -16,12 +16,11 @@ from tornado.web import Application, FallbackHandler
|
|||
|
||||
from platypush.backend import Backend
|
||||
from platypush.backend.http.app import application
|
||||
from platypush.backend.http.app.utils import get_ws_routes
|
||||
from platypush.backend.http.app.ws.events import events_redis_topic
|
||||
from platypush.backend.http.app.utils import get_streaming_routes, get_ws_routes
|
||||
from platypush.backend.http.app.ws.events import WSEventProxy
|
||||
|
||||
from platypush.bus.redis import RedisBus
|
||||
from platypush.config import Config
|
||||
from platypush.utils import get_redis
|
||||
|
||||
|
||||
class HttpBackend(Backend):
|
||||
|
@ -286,7 +285,7 @@ class HttpBackend(Backend):
|
|||
|
||||
def notify_web_clients(self, event):
|
||||
"""Notify all the connected web clients (over websocket) of a new event"""
|
||||
get_redis().publish(events_redis_topic, str(event))
|
||||
WSEventProxy.publish(event) # noqa: E1120
|
||||
|
||||
def _get_secret_key(self, _create=False):
|
||||
if _create:
|
||||
|
@ -331,7 +330,10 @@ class HttpBackend(Backend):
|
|||
container = WSGIContainer(application)
|
||||
tornado_app = Application(
|
||||
[
|
||||
*[(route.path(), route) for route in get_ws_routes()],
|
||||
*[
|
||||
(route.path(), route)
|
||||
for route in [*get_ws_routes(), *get_streaming_routes()]
|
||||
],
|
||||
(r'.*', FallbackHandler, {'fallback': container}),
|
||||
]
|
||||
)
|
||||
|
@ -352,7 +354,7 @@ class HttpBackend(Backend):
|
|||
)
|
||||
|
||||
if self.use_werkzeug_server:
|
||||
application.config['redis_queue'] = self.bus.redis_queue
|
||||
application.config['redis_queue'] = self.bus.redis_queue # type: ignore
|
||||
application.run(
|
||||
host=self.bind_address,
|
||||
port=self.port,
|
||||
|
|
158
platypush/backend/http/app/mixins/__init__.py
Normal file
158
platypush/backend/http/app/mixins/__init__.py
Normal file
|
@ -0,0 +1,158 @@
|
|||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import logging
|
||||
from multiprocessing import RLock
|
||||
from typing import Generator, Iterable, Optional, Set, Union
|
||||
|
||||
from redis import ConnectionError as RedisConnectionError
|
||||
from redis.client import PubSub
|
||||
|
||||
from platypush.config import Config
|
||||
from platypush.message import Message as AppMessage
|
||||
from platypush.utils import get_redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MessageType = Union[AppMessage, bytes, str, dict, list, set, tuple]
|
||||
"""Types of supported messages on Redis/websocket channels."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
"""
|
||||
A wrapper for a message received on a Redis subscription.
|
||||
"""
|
||||
|
||||
data: bytes
|
||||
"""The data received in the message."""
|
||||
channel: str
|
||||
"""The channel the message was received on."""
|
||||
|
||||
|
||||
class PubSubMixin:
|
||||
"""
|
||||
A mixin for Tornado route handlers that support pub/sub mechanisms.
|
||||
"""
|
||||
|
||||
def __init__(self, *_, subscriptions: Optional[Iterable[str]] = None, **__):
|
||||
self._pubsub: Optional[PubSub] = None
|
||||
"""Pub/sub proxy."""
|
||||
self._subscriptions: Set[str] = set(subscriptions or [])
|
||||
"""Set of current channel subscriptions."""
|
||||
self._pubsub_lock = RLock()
|
||||
"""
|
||||
Subscriptions lock. It ensures that the list of subscriptions is
|
||||
manipulated by one thread or process at the time.
|
||||
"""
|
||||
|
||||
self.subscribe(*self._subscriptions)
|
||||
|
||||
@property
|
||||
@contextmanager
|
||||
def pubsub(self):
|
||||
"""
|
||||
Pub/sub proxy lazy property with context manager.
|
||||
"""
|
||||
with self._pubsub_lock:
|
||||
# Lazy initialization for the pub/sub object.
|
||||
if self._pubsub is None:
|
||||
self._pubsub = get_redis().pubsub()
|
||||
|
||||
# Yield the pub/sub object (context manager pattern).
|
||||
yield self._pubsub
|
||||
|
||||
with self._pubsub_lock:
|
||||
# Close and free the pub/sub object if it has no active subscriptions.
|
||||
if self._pubsub is not None and len(self._subscriptions) == 0:
|
||||
self._pubsub.close()
|
||||
self._pubsub = None
|
||||
|
||||
@staticmethod
|
||||
def _serialize(data: MessageType) -> bytes:
|
||||
"""
|
||||
Serialize a message as bytes before delivering it to either a Redis or websocket channel.
|
||||
"""
|
||||
if isinstance(data, AppMessage):
|
||||
data = str(data)
|
||||
if isinstance(data, (list, tuple, set)):
|
||||
data = list(data)
|
||||
if isinstance(data, (list, dict)):
|
||||
data = json.dumps(data, cls=AppMessage.Encoder)
|
||||
if isinstance(data, str):
|
||||
data = data.encode('utf-8')
|
||||
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def publish(cls, data: MessageType, *channels: str) -> None:
|
||||
"""
|
||||
Publish data on one or more Redis channels.
|
||||
"""
|
||||
for channel in channels:
|
||||
get_redis().publish(channel, cls._serialize(data))
|
||||
|
||||
def subscribe(self, *channels: str) -> None:
|
||||
"""
|
||||
Subscribe to a set of Redis channels.
|
||||
"""
|
||||
with self.pubsub as pubsub:
|
||||
for channel in channels:
|
||||
pubsub.subscribe(channel)
|
||||
self._subscriptions.add(channel)
|
||||
|
||||
def unsubscribe(self, *channels: str) -> None:
|
||||
"""
|
||||
Unsubscribe from a set of Redis channels.
|
||||
"""
|
||||
with self.pubsub as pubsub:
|
||||
for channel in channels:
|
||||
if channel in self._subscriptions:
|
||||
pubsub.unsubscribe(channel)
|
||||
self._subscriptions.remove(channel)
|
||||
|
||||
def listen(self) -> Generator[Message, None, None]:
|
||||
"""
|
||||
Listens for pub/sub messages and yields them.
|
||||
"""
|
||||
try:
|
||||
with self.pubsub as pubsub:
|
||||
for msg in pubsub.listen():
|
||||
channel = msg.get('channel', b'').decode()
|
||||
if msg.get('type') != 'message' or not (
|
||||
channel and channel in self._subscriptions
|
||||
):
|
||||
continue
|
||||
|
||||
yield Message(data=msg.get('data', b''), channel=channel)
|
||||
except (AttributeError, RedisConnectionError):
|
||||
return
|
||||
|
||||
def _pubsub_close(self):
|
||||
"""
|
||||
Closes the pub/sub object.
|
||||
"""
|
||||
with self._pubsub_lock:
|
||||
if self._pubsub is not None:
|
||||
try:
|
||||
self._pubsub.close()
|
||||
except Exception as e:
|
||||
logger.debug('Error on pubsub close: %s', e)
|
||||
finally:
|
||||
self._pubsub = None
|
||||
|
||||
def on_close(self):
|
||||
"""
|
||||
Extensible close handler that closes the pub/sub object.
|
||||
"""
|
||||
self._pubsub_close()
|
||||
|
||||
@staticmethod
|
||||
def get_channel(channel: str) -> str:
|
||||
"""
|
||||
Utility method that returns the prefixed Redis channel for a certain subscription name.
|
||||
"""
|
||||
return f'_platypush/{Config.get("device_id")}/{channel}' # type: ignore
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -1,116 +0,0 @@
|
|||
import json
|
||||
from typing import Optional
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask.wrappers import Response
|
||||
|
||||
from platypush.backend.http.app import template_folder
|
||||
from platypush.backend.http.app.utils import authenticate
|
||||
from platypush.context import get_plugin
|
||||
from platypush.plugins.camera import CameraPlugin, Camera, StreamWriter
|
||||
|
||||
camera = Blueprint('camera', __name__, template_folder=template_folder)
|
||||
|
||||
# Declare routes list
|
||||
__routes__ = [
|
||||
camera,
|
||||
]
|
||||
|
||||
|
||||
def get_camera(plugin: str) -> CameraPlugin:
|
||||
plugin_name = f'camera.{plugin}'
|
||||
p = get_plugin(plugin_name)
|
||||
assert p, f'No such plugin: {plugin_name}'
|
||||
return p
|
||||
|
||||
|
||||
def get_frame(session: Camera, timeout: Optional[float] = None) -> Optional[bytes]:
|
||||
if session.stream:
|
||||
with session.stream.ready:
|
||||
session.stream.ready.wait(timeout=timeout)
|
||||
return session.stream.frame
|
||||
|
||||
|
||||
def feed(camera: CameraPlugin, **kwargs):
|
||||
with camera.open(**kwargs) as session:
|
||||
camera.start_camera(session)
|
||||
while True:
|
||||
frame = get_frame(session, timeout=5.0)
|
||||
if frame:
|
||||
yield frame
|
||||
|
||||
|
||||
def get_args(kwargs):
|
||||
kwargs = kwargs.copy()
|
||||
if 't' in kwargs:
|
||||
del kwargs['t']
|
||||
|
||||
for k, v in kwargs.items():
|
||||
if k == 'resolution':
|
||||
v = json.loads('[{}]'.format(v))
|
||||
else:
|
||||
try:
|
||||
v = int(v)
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
v = float(v)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
kwargs[k] = v
|
||||
|
||||
return kwargs
|
||||
|
||||
|
||||
@camera.route('/camera/<plugin>/photo.<extension>', methods=['GET'])
|
||||
@authenticate()
|
||||
def get_photo(plugin, extension):
|
||||
plugin = get_camera(plugin)
|
||||
extension = 'jpeg' if extension in ('jpg', 'jpeg') else extension
|
||||
|
||||
with plugin.open(stream=True, stream_format=extension, frames_dir=None, **get_args(request.args)) as session:
|
||||
plugin.start_camera(session)
|
||||
frame = None
|
||||
for _ in range(session.info.warmup_frames):
|
||||
frame = get_frame(session)
|
||||
|
||||
return Response(frame, mimetype=session.stream.mimetype)
|
||||
|
||||
|
||||
@camera.route('/camera/<plugin>/video.<extension>', methods=['GET'])
|
||||
@authenticate()
|
||||
def get_video(plugin, extension):
|
||||
stream_class = StreamWriter.get_class_by_name(extension)
|
||||
camera = get_camera(plugin)
|
||||
return Response(
|
||||
feed(camera, stream=True, stream_format=extension, frames_dir=None,
|
||||
**get_args(request.args)
|
||||
), mimetype=stream_class.mimetype
|
||||
)
|
||||
|
||||
|
||||
@camera.route('/camera/<plugin>/photo', methods=['GET'])
|
||||
@authenticate()
|
||||
def get_photo_default(plugin):
|
||||
return get_photo(plugin, 'jpeg')
|
||||
|
||||
|
||||
@camera.route('/camera/<plugin>/video', methods=['GET'])
|
||||
@authenticate()
|
||||
def get_video_default(plugin):
|
||||
return get_video(plugin, 'mjpeg')
|
||||
|
||||
|
||||
@camera.route('/camera/<plugin>/frame', methods=['GET'])
|
||||
@authenticate()
|
||||
def get_photo_deprecated(plugin):
|
||||
return get_photo_default(plugin)
|
||||
|
||||
|
||||
@camera.route('/camera/<plugin>/feed', methods=['GET'])
|
||||
@authenticate()
|
||||
def get_video_deprecated(plugin):
|
||||
return get_video_default(plugin)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -1,51 +0,0 @@
|
|||
from flask import Blueprint
|
||||
|
||||
from platypush.backend.http.app import template_folder
|
||||
from platypush.backend.http.app.routes.plugins.camera import get_photo, get_video
|
||||
from platypush.backend.http.app.utils import authenticate
|
||||
|
||||
camera_ir_mlx90640 = Blueprint('camera-ir-mlx90640', __name__, template_folder=template_folder)
|
||||
|
||||
# Declare routes list
|
||||
__routes__ = [
|
||||
camera_ir_mlx90640,
|
||||
]
|
||||
|
||||
|
||||
@camera_ir_mlx90640.route('/camera/ir/mlx90640/photo.<extension>', methods=['GET'])
|
||||
@authenticate()
|
||||
def get_photo_route(extension):
|
||||
return get_photo('ir.mlx90640', extension)
|
||||
|
||||
|
||||
@camera_ir_mlx90640.route('/camera/ir/mlx90640/video.<extension>', methods=['GET'])
|
||||
@authenticate()
|
||||
def get_video_route(extension):
|
||||
return get_video('ir.mlx90640', extension)
|
||||
|
||||
|
||||
@camera_ir_mlx90640.route('/camera/ir/mlx90640/photo', methods=['GET'])
|
||||
@authenticate()
|
||||
def get_photo_route_default():
|
||||
return get_photo_route('jpeg')
|
||||
|
||||
|
||||
@camera_ir_mlx90640.route('/camera/ir/mlx90640/video', methods=['GET'])
|
||||
@authenticate()
|
||||
def get_video_route_default():
|
||||
return get_video_route('mjpeg')
|
||||
|
||||
|
||||
@camera_ir_mlx90640.route('/camera/ir/mlx90640/frame', methods=['GET'])
|
||||
@authenticate()
|
||||
def get_photo_route_deprecated():
|
||||
return get_photo_route_default()
|
||||
|
||||
|
||||
@camera_ir_mlx90640.route('/camera/ir/mlx90640/feed', methods=['GET'])
|
||||
@authenticate()
|
||||
def get_video_route_deprecated():
|
||||
return get_video_route_default()
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -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:
|
3
platypush/backend/http/app/streaming/__init__.py
Normal file
3
platypush/backend/http/app/streaming/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from ._base import StreamingRoute
|
||||
|
||||
__all__ = ['StreamingRoute']
|
126
platypush/backend/http/app/streaming/_base.py
Normal file
126
platypush/backend/http/app/streaming/_base.py
Normal file
|
@ -0,0 +1,126 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from http.client import responses
|
||||
import json
|
||||
from logging import getLogger
|
||||
from typing import Optional
|
||||
from typing_extensions import override
|
||||
|
||||
from tornado.web import RequestHandler, stream_request_body
|
||||
|
||||
from platypush.backend.http.app.utils.auth import AuthStatus, get_auth_status
|
||||
|
||||
from ..mixins import PubSubMixin
|
||||
|
||||
|
||||
@stream_request_body
|
||||
class StreamingRoute(RequestHandler, PubSubMixin, ABC):
|
||||
"""
|
||||
Base class for Tornado streaming routes.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.logger = getLogger(__name__)
|
||||
|
||||
@override
|
||||
def prepare(self):
|
||||
"""
|
||||
Request preparation logic. It performs user authentication if
|
||||
``auth_required`` returns True, and it can be extended/overridden.
|
||||
"""
|
||||
if self.auth_required:
|
||||
auth_status = get_auth_status(self.request)
|
||||
if auth_status != AuthStatus.OK:
|
||||
self.send_error(auth_status.value.code, error=auth_status.value.message)
|
||||
return
|
||||
|
||||
self.logger.info(
|
||||
'Client %s connected to %s', self.request.remote_ip, self.request.path
|
||||
)
|
||||
|
||||
@override
|
||||
def write_error(self, status_code: int, error: Optional[str] = None, **_):
|
||||
"""
|
||||
Make sure that errors are always returned in JSON format.
|
||||
"""
|
||||
self.set_header("Content-Type", "application/json")
|
||||
self.finish(
|
||||
json.dumps(
|
||||
{"status": status_code, "error": error or responses.get(status_code)}
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def path(cls) -> str:
|
||||
"""
|
||||
Path/URL pattern for this route.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def auth_required(self) -> bool:
|
||||
"""
|
||||
If set to True (default) then this route will require user
|
||||
authentication and return 401 if authentication fails.
|
||||
"""
|
||||
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
|
138
platypush/backend/http/app/streaming/plugins/camera.py
Normal file
138
platypush/backend/http/app/streaming/plugins/camera.py
Normal file
|
@ -0,0 +1,138 @@
|
|||
from enum import Enum
|
||||
import json
|
||||
from typing import Optional
|
||||
from typing_extensions import override
|
||||
|
||||
from tornado.web import stream_request_body
|
||||
from platypush.context import get_plugin
|
||||
|
||||
from platypush.config import Config
|
||||
from platypush.plugins.camera import Camera, CameraPlugin, StreamWriter
|
||||
from platypush.utils import get_plugin_name_by_class
|
||||
|
||||
from .. import StreamingRoute
|
||||
|
||||
|
||||
class RequestType(Enum):
|
||||
"""
|
||||
Models the camera route request type (video or photo)
|
||||
"""
|
||||
|
||||
UNKNOWN = ''
|
||||
PHOTO = 'photo'
|
||||
VIDEO = 'video'
|
||||
|
||||
|
||||
@stream_request_body
|
||||
class CameraRoute(StreamingRoute):
|
||||
"""
|
||||
Route for camera streams.
|
||||
"""
|
||||
|
||||
_redis_queue_prefix = f'_platypush/{Config.get("device_id") or ""}/camera'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._camera: Optional[Camera] = None
|
||||
self._request_type = RequestType.UNKNOWN
|
||||
self._extension: str = ''
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def path(cls) -> str:
|
||||
return r"/camera/([a-zA-Z0-9_./]+)/([a-zA-Z0-9_]+)\.?([a-zA-Z0-9_]+)?"
|
||||
|
||||
def _get_camera(self, plugin: str) -> CameraPlugin:
|
||||
plugin_name = f'camera.{plugin.replace("/", ".")}'
|
||||
p = get_plugin(plugin_name)
|
||||
assert p, f'No such plugin: {plugin_name}'
|
||||
return p
|
||||
|
||||
def _get_frame(
|
||||
self, camera: Camera, timeout: Optional[float] = None
|
||||
) -> Optional[bytes]:
|
||||
if camera.stream:
|
||||
with camera.stream.ready:
|
||||
camera.stream.ready.wait(timeout=timeout)
|
||||
return camera.stream.frame
|
||||
|
||||
return None
|
||||
|
||||
def send_frame(self, camera: Camera):
|
||||
frame = None
|
||||
for _ in range(camera.info.warmup_frames):
|
||||
frame = self._get_frame(camera)
|
||||
|
||||
if frame:
|
||||
self.write(frame)
|
||||
self.flush()
|
||||
|
||||
def _set_request_type_and_extension(self, route: str, extension: str):
|
||||
if route in {'photo', 'frame'}:
|
||||
self._request_type = RequestType.PHOTO
|
||||
if extension == 'jpg':
|
||||
extension = 'jpeg'
|
||||
self._extension = extension or 'jpeg'
|
||||
elif route in {'video', 'feed'}:
|
||||
self._request_type = RequestType.VIDEO
|
||||
self._extension = extension or 'mjpeg'
|
||||
|
||||
def _get_args(self, kwargs: dict):
|
||||
kwargs = {k: v[0].decode() for k, v in kwargs.items() if k != 't'}
|
||||
for k, v in kwargs.items():
|
||||
if k == 'resolution':
|
||||
v = json.loads(f'[{v}]')
|
||||
else:
|
||||
try:
|
||||
v = int(v)
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
v = float(v)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
kwargs[k] = v
|
||||
|
||||
return kwargs
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def _get_redis_queue(cls, camera: CameraPlugin, *_, **__) -> str:
|
||||
plugin_name = get_plugin_name_by_class(camera.__class__)
|
||||
assert plugin_name, f'No such plugin: {plugin_name}'
|
||||
return '/'.join(
|
||||
[
|
||||
cls._redis_queue_prefix,
|
||||
plugin_name,
|
||||
*map(
|
||||
str,
|
||||
[camera.camera_info.device] if camera.camera_info.device else [],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def get(self, plugin: str, route: str, extension: str = '') -> None:
|
||||
self._set_request_type_and_extension(route, extension)
|
||||
if not (self._request_type and self._extension):
|
||||
self.write_error(404, 'Not Found')
|
||||
return
|
||||
|
||||
stream_class = StreamWriter.get_class_by_name(self._extension)
|
||||
camera = self._get_camera(plugin)
|
||||
redis_queue = self._get_redis_queue(camera)
|
||||
self.set_header('Content-Type', stream_class.mimetype)
|
||||
|
||||
with camera.open(
|
||||
stream=True,
|
||||
stream_format=self._extension,
|
||||
frames_dir=None,
|
||||
redis_queue=redis_queue,
|
||||
**self._get_args(self.request.arguments),
|
||||
) as session:
|
||||
camera.start_camera(session)
|
||||
if self._request_type == RequestType.PHOTO:
|
||||
self.send_frame(session)
|
||||
elif self._request_type == RequestType.VIDEO:
|
||||
self.forward_stream(camera)
|
||||
|
||||
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.record',
|
||||
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)}))
|
|
@ -13,6 +13,7 @@ from .routes import (
|
|||
get_remote_base_url,
|
||||
get_routes,
|
||||
)
|
||||
from .streaming import get_streaming_routes
|
||||
from .ws import get_ws_routes
|
||||
|
||||
__all__ = [
|
||||
|
@ -27,6 +28,7 @@ __all__ = [
|
|||
'get_message_response',
|
||||
'get_remote_base_url',
|
||||
'get_routes',
|
||||
'get_streaming_routes',
|
||||
'get_ws_routes',
|
||||
'logger',
|
||||
'send_message',
|
||||
|
|
42
platypush/backend/http/app/utils/streaming.py
Normal file
42
platypush/backend/http/app/utils/streaming.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
import logging
|
||||
import os
|
||||
import importlib
|
||||
import inspect
|
||||
from typing import List, Type
|
||||
|
||||
import pkgutil
|
||||
|
||||
from ..streaming import StreamingRoute
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_streaming_routes() -> List[Type[StreamingRoute]]:
|
||||
"""
|
||||
Scans for streaming routes.
|
||||
"""
|
||||
from platypush.backend.http import HttpBackend
|
||||
|
||||
base_pkg = '.'.join([HttpBackend.__module__, 'app', 'streaming'])
|
||||
base_dir = os.path.join(
|
||||
os.path.dirname(inspect.getfile(HttpBackend)), 'app', 'streaming'
|
||||
)
|
||||
routes = []
|
||||
|
||||
for _, mod_name, _ in pkgutil.walk_packages([base_dir], prefix=base_pkg + '.'):
|
||||
try:
|
||||
module = importlib.import_module(mod_name)
|
||||
except Exception as e:
|
||||
logger.warning('Could not import module %s', mod_name)
|
||||
logger.exception(e)
|
||||
continue
|
||||
|
||||
for _, obj in inspect.getmembers(module):
|
||||
if (
|
||||
inspect.isclass(obj)
|
||||
and not inspect.isabstract(obj)
|
||||
and issubclass(obj, StreamingRoute)
|
||||
):
|
||||
routes.append(obj)
|
||||
|
||||
return routes
|
|
@ -1,3 +1,3 @@
|
|||
from ._base import WSRoute, logger, pubsub_redis_topic
|
||||
from ._base import WSRoute, logger
|
||||
|
||||
__all__ = ['WSRoute', 'logger', 'pubsub_redis_topic']
|
||||
__all__ = ['WSRoute', 'logger']
|
||||
|
|
|
@ -1,37 +1,28 @@
|
|||
from abc import ABC, abstractclassmethod
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from logging import getLogger
|
||||
from threading import RLock, Thread
|
||||
from typing import Any, Generator, Iterable, Optional, Union
|
||||
from threading import Thread
|
||||
from typing_extensions import override
|
||||
|
||||
from redis import ConnectionError as RedisConnectionError
|
||||
from tornado.ioloop import IOLoop
|
||||
from tornado.websocket import WebSocketHandler
|
||||
|
||||
from platypush.backend.http.app.utils.auth import AuthStatus, get_auth_status
|
||||
from platypush.config import Config
|
||||
from platypush.message import Message
|
||||
from platypush.utils import get_redis
|
||||
|
||||
from ..mixins import MessageType, PubSubMixin
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
def pubsub_redis_topic(topic: str) -> str:
|
||||
return f'_platypush/{Config.get("device_id")}/{topic}' # type: ignore
|
||||
|
||||
|
||||
class WSRoute(WebSocketHandler, Thread, ABC):
|
||||
class WSRoute(WebSocketHandler, Thread, PubSubMixin, ABC):
|
||||
"""
|
||||
Base class for Tornado websocket endpoints.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, redis_topics: Optional[Iterable[str]] = None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._redis_topics = set(redis_topics or [])
|
||||
self._sub = get_redis().pubsub()
|
||||
def __init__(self, *args, **kwargs):
|
||||
WebSocketHandler.__init__(self, *args)
|
||||
PubSubMixin.__init__(self, **kwargs)
|
||||
Thread.__init__(self)
|
||||
self._io_loop = IOLoop.current()
|
||||
self._sub_lock = RLock()
|
||||
|
||||
@override
|
||||
def open(self, *_, **__):
|
||||
|
@ -51,10 +42,11 @@ class WSRoute(WebSocketHandler, Thread, ABC):
|
|||
pass
|
||||
|
||||
@override
|
||||
def on_message(self, message): # type: ignore
|
||||
pass
|
||||
def on_message(self, message):
|
||||
return message
|
||||
|
||||
@abstractclassmethod
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def app_name(cls) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
@ -66,55 +58,25 @@ class WSRoute(WebSocketHandler, Thread, ABC):
|
|||
def auth_required(self):
|
||||
return True
|
||||
|
||||
def subscribe(self, *topics: str) -> None:
|
||||
with self._sub_lock:
|
||||
for topic in topics:
|
||||
self._sub.subscribe(topic)
|
||||
self._redis_topics.add(topic)
|
||||
|
||||
def unsubscribe(self, *topics: str) -> None:
|
||||
with self._sub_lock:
|
||||
for topic in topics:
|
||||
if topic in self._redis_topics:
|
||||
self._sub.unsubscribe(topic)
|
||||
self._redis_topics.remove(topic)
|
||||
|
||||
def listen(self) -> Generator[Any, None, None]:
|
||||
try:
|
||||
for msg in self._sub.listen():
|
||||
if (
|
||||
msg.get('type') != 'message'
|
||||
and msg.get('channel').decode() not in self._redis_topics
|
||||
):
|
||||
continue
|
||||
|
||||
yield msg.get('data')
|
||||
except (AttributeError, RedisConnectionError):
|
||||
return
|
||||
|
||||
def send(self, msg: Union[str, bytes, dict, list, tuple, set]) -> None:
|
||||
if isinstance(msg, (list, tuple, set)):
|
||||
msg = list(msg)
|
||||
if isinstance(msg, (list, dict)):
|
||||
msg = json.dumps(msg, cls=Message.Encoder)
|
||||
|
||||
def send(self, msg: MessageType) -> None:
|
||||
self._io_loop.asyncio_loop.call_soon_threadsafe( # type: ignore
|
||||
self.write_message, msg
|
||||
self.write_message, self._serialize(msg)
|
||||
)
|
||||
|
||||
@override
|
||||
def run(self) -> None:
|
||||
super().run()
|
||||
for topic in self._redis_topics:
|
||||
self._sub.subscribe(topic)
|
||||
self.subscribe(*self._subscriptions)
|
||||
|
||||
@override
|
||||
def on_close(self):
|
||||
topics = self._redis_topics.copy()
|
||||
for topic in topics:
|
||||
self.unsubscribe(topic)
|
||||
super().on_close()
|
||||
for channel in self._subscriptions.copy():
|
||||
self.unsubscribe(channel)
|
||||
|
||||
if self._pubsub:
|
||||
self._pubsub.close()
|
||||
|
||||
self._sub.close()
|
||||
logger.info(
|
||||
'Client %s disconnected from %s, reason=%s, message=%s',
|
||||
self.request.remote_ip,
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
from typing_extensions import override
|
||||
|
||||
from platypush.backend.http.app.mixins import MessageType
|
||||
from platypush.message.event import Event
|
||||
|
||||
from . import WSRoute, logger, pubsub_redis_topic
|
||||
from . import WSRoute, logger
|
||||
from ..utils import send_message
|
||||
|
||||
events_redis_topic = pubsub_redis_topic('events')
|
||||
|
||||
|
||||
class WSEventProxy(WSRoute):
|
||||
"""
|
||||
|
@ -14,14 +13,23 @@ class WSEventProxy(WSRoute):
|
|||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.subscribe(events_redis_topic)
|
||||
super().__init__(*args, subscriptions=[self.events_channel], **kwargs)
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def app_name(cls) -> str:
|
||||
return 'events'
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def events_channel(cls) -> str:
|
||||
return cls.get_channel('events')
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def publish(cls, data: MessageType, *_) -> None:
|
||||
super().publish(data, cls.events_channel)
|
||||
|
||||
@override
|
||||
def on_message(self, message):
|
||||
try:
|
||||
|
@ -38,9 +46,9 @@ class WSEventProxy(WSRoute):
|
|||
def run(self) -> None:
|
||||
for msg in self.listen():
|
||||
try:
|
||||
evt = Event.build(msg)
|
||||
evt = Event.build(msg.data)
|
||||
except Exception as e:
|
||||
logger.warning('Error parsing event: %s: %s', msg, e)
|
||||
continue
|
||||
|
||||
self.send(str(evt))
|
||||
self.send(evt)
|
||||
|
|
|
@ -1 +1 @@
|
|||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/fonts/poppins.css"><title>platypush</title><script defer="defer" type="module" src="/static/js/chunk-vendors.0f6060b6.js"></script><script defer="defer" type="module" src="/static/js/app.8e3d4fb1.js"></script><link href="/static/css/chunk-vendors.0fcd36f0.css" rel="stylesheet"><link href="/static/css/app.0a781c41.css" rel="stylesheet"><link rel="icon" type="image/svg+xml" href="/img/icons/favicon.svg"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#ffffff"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="Platypush"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#ffffff"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"><script defer="defer" src="/static/js/chunk-vendors-legacy.037e71b7.js" nomodule></script><script defer="defer" src="/static/js/app-legacy.523328cf.js" nomodule></script></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/fonts/poppins.css"><title>platypush</title><script defer="defer" type="module" src="/static/js/chunk-vendors.0f6060b6.js"></script><script defer="defer" type="module" src="/static/js/app.a0889d9d.js"></script><link href="/static/css/chunk-vendors.0fcd36f0.css" rel="stylesheet"><link href="/static/css/app.0a781c41.css" rel="stylesheet"><link rel="icon" type="image/svg+xml" href="/img/icons/favicon.svg"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#ffffff"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="Platypush"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#ffffff"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"><script defer="defer" src="/static/js/chunk-vendors-legacy.037e71b7.js" nomodule></script><script defer="defer" src="/static/js/app-legacy.83532d44.js" nomodule></script></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/4118.25e7d5ff.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/4118.25e7d5ff.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
platypush/backend/http/webapp/dist/static/js/4118-legacy.fdfd71bc.js
vendored
Normal file
2
platypush/backend/http/webapp/dist/static/js/4118-legacy.fdfd71bc.js
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
"use strict";(self["webpackChunkplatypush"]=self["webpackChunkplatypush"]||[]).push([[4118],{4118:function(n,t,r){r.r(t),r.d(t,{default:function(){return b}});var e=r(6252),o=function(n){return(0,e.dD)("data-v-911495ca"),n=n(),(0,e.Cn)(),n},a={class:"sound"},u={class:"sound-container"},i={key:0,autoplay:"",preload:"none",ref:"player"},s=["src"],c=(0,e.Uk)(" Your browser does not support audio elements "),d={class:"controls"},p=o((function(){return(0,e._)("i",{class:"fa fa-play"},null,-1)})),l=(0,e.Uk)(" Start streaming audio "),f=[p,l],g=o((function(){return(0,e._)("i",{class:"fa fa-stop"},null,-1)})),k=(0,e.Uk)(" Stop streaming audio "),y=[g,k];function m(n,t,r,o,p,l){return(0,e.wg)(),(0,e.iD)("div",a,[(0,e._)("div",u,[p.recording?((0,e.wg)(),(0,e.iD)("audio",i,[(0,e._)("source",{src:"/sound/stream.aac?t=".concat((new Date).getTime())},null,8,s),c],512)):(0,e.kq)("",!0)]),(0,e._)("div",d,[p.recording?((0,e.wg)(),(0,e.iD)("button",{key:1,type:"button",onClick:t[1]||(t[1]=function(){return l.stopRecording&&l.stopRecording.apply(l,arguments)})},y)):((0,e.wg)(),(0,e.iD)("button",{key:0,type:"button",onClick:t[0]||(t[0]=function(){return l.startRecording&&l.startRecording.apply(l,arguments)})},f))])])}var w=r(8534),h=(r(5666),r(6813)),v={name:"Sound",mixins:[h.Z],data:function(){return{recording:!1}},methods:{startRecording:function(){this.recording=!0},stopRecording:function(){var n=this;return(0,w.Z)(regeneratorRuntime.mark((function t(){return regeneratorRuntime.wrap((function(t){while(1)switch(t.prev=t.next){case 0:return n.recording=!1,t.next=3,n.request("sound.stop_recording");case 3:case"end":return t.stop()}}),t)})))()}}},R=r(3744);const _=(0,R.Z)(v,[["render",m],["__scopeId","data-v-911495ca"]]);var b=_}}]);
|
||||
//# sourceMappingURL=4118-legacy.fdfd71bc.js.map
|
1
platypush/backend/http/webapp/dist/static/js/4118-legacy.fdfd71bc.js.map
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/js/4118-legacy.fdfd71bc.js.map
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"static/js/4118-legacy.fdfd71bc.js","mappings":"oPACOA,MAAM,S,GACJA,MAAM,mB,SACFC,SAAA,GAASC,QAAQ,OAAOC,IAAI,U,qBAC8B,kD,GAK9DH,MAAM,Y,uBAEP,OAA0B,KAAvBA,MAAM,cAAY,Q,eAAK,4B,GAA1B,K,uBAIA,OAA0B,KAAvBA,MAAM,cAAY,Q,eAAK,2B,GAA1B,K,0CAdN,QAiBM,MAjBN,EAiBM,EAhBJ,OAKM,MALN,EAKM,CAJ8C,EAAAI,YAAA,WAAlD,QAGQ,QAHR,EAGQ,EAFN,OAA+D,UAAtDC,IAAG,mCAA8BC,MAAQC,YAAlD,UAEM,GAHR,yBAMF,OAQM,MARN,EAQM,CAPiD,EAAAH,YAArD,WAIA,QAES,U,MAFDI,KAAK,SAAU,QAAK,8BAAE,EAAAC,eAAA,EAAAA,cAAA,kBAAF,IAA5B,MAJqD,WAArD,QAES,U,MAFDD,KAAK,SAAU,QAAK,8BAAE,EAAAE,gBAAA,EAAAA,eAAA,kBAAF,IAA5B,O,mCAcN,GACEC,KAAM,QACNC,OAAQ,CAACC,EAAA,GAETC,KAJa,WAKX,MAAO,CACLV,WAAW,EAEd,EAEDW,QAAS,CACPL,eADO,WAELM,KAAKZ,WAAY,CAClB,EAEKK,cALC,WAKe,uJACpB,EAAKL,WAAY,EADG,SAEd,EAAKa,QAAQ,wBAFC,4CAGrB,I,UCnCL,MAAMC,GAA2B,OAAgB,EAAQ,CAAC,CAAC,SAASC,GAAQ,CAAC,YAAY,qBAEzF,O","sources":["webpack://platypush/./src/components/panels/Sound/Index.vue","webpack://platypush/./src/components/panels/Sound/Index.vue?0677"],"sourcesContent":["<template>\n <div class=\"sound\">\n <div class=\"sound-container\">\n <audio autoplay preload=\"none\" ref=\"player\" v-if=\"recording\">\n <source :src=\"`/sound/stream.aac?t=${(new Date()).getTime()}`\">\n Your browser does not support audio elements\n </audio>\n </div>\n\n <div class=\"controls\">\n <button type=\"button\" @click=\"startRecording\" v-if=\"!recording\">\n <i class=\"fa fa-play\"></i> Start streaming audio\n </button>\n\n <button type=\"button\" @click=\"stopRecording\" v-else>\n <i class=\"fa fa-stop\"></i> Stop streaming audio\n </button>\n </div>\n </div>\n</template>\n\n<script>\nimport Utils from \"@/Utils\";\n\nexport default {\n name: \"Sound\",\n mixins: [Utils],\n\n data() {\n return {\n recording: false,\n };\n },\n\n methods: {\n startRecording() {\n this.recording = true\n },\n\n async stopRecording() {\n this.recording = false\n await this.request('sound.stop_recording')\n },\n },\n}\n</script>\n\n<style lang=\"scss\" scoped>\n.sound {\n width: 100%;\n height: 90%;\n margin-top: 7%;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n align-items: center;\n\n .sound-container {\n margin-bottom: 1em;\n }\n}\n</style>\n","import { render } from \"./Index.vue?vue&type=template&id=911495ca&scoped=true\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\nimport \"./Index.vue?vue&type=style&index=0&id=911495ca&lang=scss&scoped=true\"\n\nimport exportComponent from \"/home/blacklight/git_tree/platypush/platypush/backend/http/webapp/node_modules/vue-loader/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render],['__scopeId',\"data-v-911495ca\"]])\n\nexport default __exports__"],"names":["class","autoplay","preload","ref","recording","src","Date","getTime","type","stopRecording","startRecording","name","mixins","Utils","data","methods","this","request","__exports__","render"],"sourceRoot":""}
|
2
platypush/backend/http/webapp/dist/static/js/4118.eb9d25ca.js
vendored
Normal file
2
platypush/backend/http/webapp/dist/static/js/4118.eb9d25ca.js
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
"use strict";(self["webpackChunkplatypush"]=self["webpackChunkplatypush"]||[]).push([[4118],{4118:function(t,n,o){o.r(n),o.d(n,{default:function(){return b}});var e=o(6252);const r=t=>((0,e.dD)("data-v-911495ca"),t=t(),(0,e.Cn)(),t),s={class:"sound"},a={class:"sound-container"},i={key:0,autoplay:"",preload:"none",ref:"player"},c=["src"],d=(0,e.Uk)(" Your browser does not support audio elements "),u={class:"controls"},l=r((()=>(0,e._)("i",{class:"fa fa-play"},null,-1))),p=(0,e.Uk)(" Start streaming audio "),g=[l,p],k=r((()=>(0,e._)("i",{class:"fa fa-stop"},null,-1))),f=(0,e.Uk)(" Stop streaming audio "),y=[k,f];function h(t,n,o,r,l,p){return(0,e.wg)(),(0,e.iD)("div",s,[(0,e._)("div",a,[l.recording?((0,e.wg)(),(0,e.iD)("audio",i,[(0,e._)("source",{src:`/sound/stream.aac?t=${(new Date).getTime()}`},null,8,c),d],512)):(0,e.kq)("",!0)]),(0,e._)("div",u,[l.recording?((0,e.wg)(),(0,e.iD)("button",{key:1,type:"button",onClick:n[1]||(n[1]=(...t)=>p.stopRecording&&p.stopRecording(...t))},y)):((0,e.wg)(),(0,e.iD)("button",{key:0,type:"button",onClick:n[0]||(n[0]=(...t)=>p.startRecording&&p.startRecording(...t))},g))])])}var w=o(6813),m={name:"Sound",mixins:[w.Z],data(){return{recording:!1}},methods:{startRecording(){this.recording=!0},async stopRecording(){this.recording=!1,await this.request("sound.stop_recording")}}},v=o(3744);const _=(0,v.Z)(m,[["render",h],["__scopeId","data-v-911495ca"]]);var b=_}}]);
|
||||
//# sourceMappingURL=4118.eb9d25ca.js.map
|
1
platypush/backend/http/webapp/dist/static/js/4118.eb9d25ca.js.map
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/js/4118.eb9d25ca.js.map
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"static/js/4118.eb9d25ca.js","mappings":"4OACOA,MAAM,S,GACJA,MAAM,mB,SACFC,SAAA,GAASC,QAAQ,OAAOC,IAAI,U,qBAC8B,kD,GAK9DH,MAAM,Y,UAEP,OAA0B,KAAvBA,MAAM,cAAY,W,WAAK,4B,GAA1B,K,UAIA,OAA0B,KAAvBA,MAAM,cAAY,W,WAAK,2B,GAA1B,K,0CAdN,QAiBM,MAjBN,EAiBM,EAhBJ,OAKM,MALN,EAKM,CAJ8C,EAAAI,YAAA,WAAlD,QAGQ,QAHR,EAGQ,EAFN,OAA+D,UAAtDC,IAAG,4BAA8BC,MAAQC,aAAlD,UAEM,GAHR,yBAMF,OAQM,MARN,EAQM,CAPiD,EAAAH,YAArD,WAIA,QAES,U,MAFDI,KAAK,SAAU,QAAK,oBAAE,EAAAC,eAAA,EAAAA,iBAAA,KAA9B,MAJqD,WAArD,QAES,U,MAFDD,KAAK,SAAU,QAAK,oBAAE,EAAAE,gBAAA,EAAAA,kBAAA,KAA9B,O,eAcN,GACEC,KAAM,QACNC,OAAQ,CAACC,EAAA,GAETC,OACE,MAAO,CACLV,WAAW,EAEd,EAEDW,QAAS,CACPL,iBACEM,KAAKZ,WAAY,CAClB,EAEDa,sBACED,KAAKZ,WAAY,QACXY,KAAKE,QAAQ,uBACpB,I,UCnCL,MAAMC,GAA2B,OAAgB,EAAQ,CAAC,CAAC,SAASC,GAAQ,CAAC,YAAY,qBAEzF,O","sources":["webpack://platypush/./src/components/panels/Sound/Index.vue","webpack://platypush/./src/components/panels/Sound/Index.vue?0677"],"sourcesContent":["<template>\n <div class=\"sound\">\n <div class=\"sound-container\">\n <audio autoplay preload=\"none\" ref=\"player\" v-if=\"recording\">\n <source :src=\"`/sound/stream.aac?t=${(new Date()).getTime()}`\">\n Your browser does not support audio elements\n </audio>\n </div>\n\n <div class=\"controls\">\n <button type=\"button\" @click=\"startRecording\" v-if=\"!recording\">\n <i class=\"fa fa-play\"></i> Start streaming audio\n </button>\n\n <button type=\"button\" @click=\"stopRecording\" v-else>\n <i class=\"fa fa-stop\"></i> Stop streaming audio\n </button>\n </div>\n </div>\n</template>\n\n<script>\nimport Utils from \"@/Utils\";\n\nexport default {\n name: \"Sound\",\n mixins: [Utils],\n\n data() {\n return {\n recording: false,\n };\n },\n\n methods: {\n startRecording() {\n this.recording = true\n },\n\n async stopRecording() {\n this.recording = false\n await this.request('sound.stop_recording')\n },\n },\n}\n</script>\n\n<style lang=\"scss\" scoped>\n.sound {\n width: 100%;\n height: 90%;\n margin-top: 7%;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n align-items: center;\n\n .sound-container {\n margin-bottom: 1em;\n }\n}\n</style>\n","import { render } from \"./Index.vue?vue&type=template&id=911495ca&scoped=true\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\nimport \"./Index.vue?vue&type=style&index=0&id=911495ca&lang=scss&scoped=true\"\n\nimport exportComponent from \"/home/blacklight/git_tree/platypush/platypush/backend/http/webapp/node_modules/vue-loader/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render],['__scopeId',\"data-v-911495ca\"]])\n\nexport default __exports__"],"names":["class","autoplay","preload","ref","recording","src","Date","getTime","type","stopRecording","startRecording","name","mixins","Utils","data","methods","this","async","request","__exports__","render"],"sourceRoot":""}
|
|
@ -1,2 +1,2 @@
|
|||
"use strict";(self["webpackChunkplatypush"]=self["webpackChunkplatypush"]||[]).push([[4548],{4548:function(a,e,n){n.r(e),n.d(e,{default:function(){return f}});var r=n(6252);function u(a,e,n,u,t,p){var c=(0,r.up)("Camera");return(0,r.wg)(),(0,r.j4)(c,{"camera-plugin":"pi"})}var t=n(5528),p={name:"CameraPi",components:{Camera:t["default"]}},c=n(3744);const s=(0,c.Z)(p,[["render",u]]);var f=s}}]);
|
||||
//# sourceMappingURL=4548-legacy.e2883bdd.js.map
|
||||
"use strict";(self["webpackChunkplatypush"]=self["webpackChunkplatypush"]||[]).push([[4548],{4548:function(a,e,n){n.r(e),n.d(e,{default:function(){return f}});var r=n(6252);function u(a,e,n,u,t,p){var c=(0,r.up)("Camera");return(0,r.wg)(),(0,r.j4)(c,{"camera-plugin":"pi"})}var t=n(9021),p={name:"CameraPi",components:{Camera:t["default"]}},c=n(3744);const s=(0,c.Z)(p,[["render",u]]);var f=s}}]);
|
||||
//# sourceMappingURL=4548-legacy.7f4c9c3f.js.map
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"static/js/4548-legacy.e2883bdd.js","mappings":"gPACE,QAA6B,GAArB,gBAAc,M,eAMxB,GACEA,KAAM,WACNC,WAAY,CAACC,OAAA,e,UCJf,MAAMC,GAA2B,OAAgB,EAAQ,CAAC,CAAC,SAASC,KAEpE,O","sources":["webpack://platypush/./src/components/panels/CameraPi/Index.vue","webpack://platypush/./src/components/panels/CameraPi/Index.vue?7074"],"sourcesContent":["<template>\n <Camera camera-plugin=\"pi\" />\n</template>\n\n<script>\nimport Camera from \"@/components/panels/Camera/Index\";\n\nexport default {\n name: \"CameraPi\",\n components: {Camera},\n}\n</script>\n","import { render } from \"./Index.vue?vue&type=template&id=6f4a0590\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\nimport exportComponent from \"/home/blacklight/git_tree/platypush/platypush/backend/http/webapp/node_modules/vue-loader/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__"],"names":["name","components","Camera","__exports__","render"],"sourceRoot":""}
|
||||
{"version":3,"file":"static/js/4548-legacy.7f4c9c3f.js","mappings":"gPACE,QAA6B,GAArB,gBAAc,M,eAMxB,GACEA,KAAM,WACNC,WAAY,CAACC,OAAA,e,UCJf,MAAMC,GAA2B,OAAgB,EAAQ,CAAC,CAAC,SAASC,KAEpE,O","sources":["webpack://platypush/./src/components/panels/CameraPi/Index.vue","webpack://platypush/./src/components/panels/CameraPi/Index.vue?7074"],"sourcesContent":["<template>\n <Camera camera-plugin=\"pi\" />\n</template>\n\n<script>\nimport Camera from \"@/components/panels/Camera/Index\";\n\nexport default {\n name: \"CameraPi\",\n components: {Camera},\n}\n</script>\n","import { render } from \"./Index.vue?vue&type=template&id=6f4a0590\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\nimport exportComponent from \"/home/blacklight/git_tree/platypush/platypush/backend/http/webapp/node_modules/vue-loader/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__"],"names":["name","components","Camera","__exports__","render"],"sourceRoot":""}
|
2
platypush/backend/http/webapp/dist/static/js/4548.75a2e6f8.js
vendored
Normal file
2
platypush/backend/http/webapp/dist/static/js/4548.75a2e6f8.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/js/4548.75a2e6f8.js.map
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/js/4548.75a2e6f8.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -1,2 +1,2 @@
|
|||
"use strict";(self["webpackChunkplatypush"]=self["webpackChunkplatypush"]||[]).push([[5111],{5111:function(a,e,n){n.r(e),n.d(e,{default:function(){return s}});var r=n(6252);function u(a,e,n,u,t,p){var f=(0,r.up)("Camera");return(0,r.wg)(),(0,r.j4)(f,{"camera-plugin":"ffmpeg"})}var t=n(5528),p={name:"CameraFfmpeg",components:{Camera:t["default"]}},f=n(3744);const c=(0,f.Z)(p,[["render",u]]);var s=c}}]);
|
||||
//# sourceMappingURL=5111-legacy.262ea3c5.js.map
|
||||
"use strict";(self["webpackChunkplatypush"]=self["webpackChunkplatypush"]||[]).push([[5111],{5111:function(a,e,n){n.r(e),n.d(e,{default:function(){return s}});var r=n(6252);function u(a,e,n,u,t,p){var f=(0,r.up)("Camera");return(0,r.wg)(),(0,r.j4)(f,{"camera-plugin":"ffmpeg"})}var t=n(9021),p={name:"CameraFfmpeg",components:{Camera:t["default"]}},f=n(3744);const c=(0,f.Z)(p,[["render",u]]);var s=c}}]);
|
||||
//# sourceMappingURL=5111-legacy.d4568c17.js.map
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"static/js/5111-legacy.262ea3c5.js","mappings":"gPACE,QAAiC,GAAzB,gBAAc,U,eAMxB,GACEA,KAAM,eACNC,WAAY,CAACC,OAAA,e,UCJf,MAAMC,GAA2B,OAAgB,EAAQ,CAAC,CAAC,SAASC,KAEpE,O","sources":["webpack://platypush/./src/components/panels/CameraFfmpeg/Index.vue","webpack://platypush/./src/components/panels/CameraFfmpeg/Index.vue?3548"],"sourcesContent":["<template>\n <Camera camera-plugin=\"ffmpeg\" />\n</template>\n\n<script>\nimport Camera from \"@/components/panels/Camera/Index\";\n\nexport default {\n name: \"CameraFfmpeg\",\n components: {Camera},\n}\n</script>\n","import { render } from \"./Index.vue?vue&type=template&id=dd632828\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\nimport exportComponent from \"/home/blacklight/git_tree/platypush/platypush/backend/http/webapp/node_modules/vue-loader/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__"],"names":["name","components","Camera","__exports__","render"],"sourceRoot":""}
|
||||
{"version":3,"file":"static/js/5111-legacy.d4568c17.js","mappings":"gPACE,QAAiC,GAAzB,gBAAc,U,eAMxB,GACEA,KAAM,eACNC,WAAY,CAACC,OAAA,e,UCJf,MAAMC,GAA2B,OAAgB,EAAQ,CAAC,CAAC,SAASC,KAEpE,O","sources":["webpack://platypush/./src/components/panels/CameraFfmpeg/Index.vue","webpack://platypush/./src/components/panels/CameraFfmpeg/Index.vue?3548"],"sourcesContent":["<template>\n <Camera camera-plugin=\"ffmpeg\" />\n</template>\n\n<script>\nimport Camera from \"@/components/panels/Camera/Index\";\n\nexport default {\n name: \"CameraFfmpeg\",\n components: {Camera},\n}\n</script>\n","import { render } from \"./Index.vue?vue&type=template&id=dd632828\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\nimport exportComponent from \"/home/blacklight/git_tree/platypush/platypush/backend/http/webapp/node_modules/vue-loader/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__"],"names":["name","components","Camera","__exports__","render"],"sourceRoot":""}
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
platypush/backend/http/webapp/dist/static/js/5111.fbd25a85.js
vendored
Normal file
2
platypush/backend/http/webapp/dist/static/js/5111.fbd25a85.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/js/5111.fbd25a85.js.map
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/js/5111.fbd25a85.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -1,2 +0,0 @@
|
|||
"use strict";(self["webpackChunkplatypush"]=self["webpackChunkplatypush"]||[]).push([[5193],{5193:function(n,t,r){r.r(t),r.d(t,{default:function(){return b}});var e=r(6252),o=function(n){return(0,e.dD)("data-v-30d09191"),n=n(),(0,e.Cn)(),n},u={class:"sound"},a={class:"sound-container"},i={key:0,autoplay:"",preload:"none",ref:"player"},s=["src"],c=(0,e.Uk)(" Your browser does not support audio elements "),d={class:"controls"},p=o((function(){return(0,e._)("i",{class:"fa fa-play"},null,-1)})),l=(0,e.Uk)(" Start streaming audio "),f=[p,l],g=o((function(){return(0,e._)("i",{class:"fa fa-stop"},null,-1)})),k=(0,e.Uk)(" Stop streaming audio "),y=[g,k];function m(n,t,r,o,p,l){return(0,e.wg)(),(0,e.iD)("div",u,[(0,e._)("div",a,[p.recording?((0,e.wg)(),(0,e.iD)("audio",i,[(0,e._)("source",{src:"/sound/stream?t=".concat((new Date).getTime()),type:"audio/x-wav;codec=pcm"},null,8,s),c],512)):(0,e.kq)("",!0)]),(0,e._)("div",d,[p.recording?((0,e.wg)(),(0,e.iD)("button",{key:1,type:"button",onClick:t[1]||(t[1]=function(){return l.stopRecording&&l.stopRecording.apply(l,arguments)})},y)):((0,e.wg)(),(0,e.iD)("button",{key:0,type:"button",onClick:t[0]||(t[0]=function(){return l.startRecording&&l.startRecording.apply(l,arguments)})},f))])])}var w=r(8534),v=(r(5666),r(6813)),h={name:"Sound",mixins:[v.Z],data:function(){return{recording:!1}},methods:{startRecording:function(){this.recording=!0},stopRecording:function(){var n=this;return(0,w.Z)(regeneratorRuntime.mark((function t(){return regeneratorRuntime.wrap((function(t){while(1)switch(t.prev=t.next){case 0:return n.recording=!1,t.next=3,n.request("sound.stop_recording");case 3:case"end":return t.stop()}}),t)})))()}}},R=r(3744);const _=(0,R.Z)(h,[["render",m],["__scopeId","data-v-30d09191"]]);var b=_}}]);
|
||||
//# sourceMappingURL=5193-legacy.d8c2e027.js.map
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"static/js/5193-legacy.d8c2e027.js","mappings":"oPACOA,MAAM,S,GACJA,MAAM,mB,SACFC,SAAA,GAASC,QAAQ,OAAOC,IAAI,U,qBAEuD,kD,GAKvFH,MAAM,Y,uBAEP,OAA0B,KAAvBA,MAAM,cAAY,Q,eAAK,4B,GAA1B,K,uBAIA,OAA0B,KAAvBA,MAAM,cAAY,Q,eAAK,2B,GAA1B,K,0CAfN,QAkBM,MAlBN,EAkBM,EAjBJ,OAMM,MANN,EAMM,CAL8C,EAAAI,YAAA,WAAlD,QAIQ,QAJR,EAIQ,EAFN,OAAwF,UAA/EC,IAAG,+BAA0BC,MAAQC,WAAaC,KAAK,yBAAhE,UAEM,GAJR,yBAOF,OAQM,MARN,EAQM,CAPiD,EAAAJ,YAArD,WAIA,QAES,U,MAFDI,KAAK,SAAU,QAAK,8BAAE,EAAAC,eAAA,EAAAA,cAAA,kBAAF,IAA5B,MAJqD,WAArD,QAES,U,MAFDD,KAAK,SAAU,QAAK,8BAAE,EAAAE,gBAAA,EAAAA,eAAA,kBAAF,IAA5B,O,mCAcN,GACEC,KAAM,QACNC,OAAQ,CAACC,EAAA,GAETC,KAJa,WAKX,MAAO,CACLV,WAAW,EAEd,EAEDW,QAAS,CACPL,eADO,WAELM,KAAKZ,WAAY,CAClB,EAEKK,cALC,WAKe,uJACpB,EAAKL,WAAY,EADG,SAEd,EAAKa,QAAQ,wBAFC,4CAGrB,I,UCpCL,MAAMC,GAA2B,OAAgB,EAAQ,CAAC,CAAC,SAASC,GAAQ,CAAC,YAAY,qBAEzF,O","sources":["webpack://platypush/./src/components/panels/Sound/Index.vue","webpack://platypush/./src/components/panels/Sound/Index.vue?0677"],"sourcesContent":["<template>\n <div class=\"sound\">\n <div class=\"sound-container\">\n <audio autoplay preload=\"none\" ref=\"player\" v-if=\"recording\">\n <!--suppress HtmlUnknownTarget -->\n <source :src=\"`/sound/stream?t=${(new Date()).getTime()}`\" type=\"audio/x-wav;codec=pcm\">\n Your browser does not support audio elements\n </audio>\n </div>\n\n <div class=\"controls\">\n <button type=\"button\" @click=\"startRecording\" v-if=\"!recording\">\n <i class=\"fa fa-play\"></i> Start streaming audio\n </button>\n\n <button type=\"button\" @click=\"stopRecording\" v-else>\n <i class=\"fa fa-stop\"></i> Stop streaming audio\n </button>\n </div>\n </div>\n</template>\n\n<script>\nimport Utils from \"@/Utils\";\n\nexport default {\n name: \"Sound\",\n mixins: [Utils],\n\n data() {\n return {\n recording: false,\n };\n },\n\n methods: {\n startRecording() {\n this.recording = true\n },\n\n async stopRecording() {\n this.recording = false\n await this.request('sound.stop_recording')\n },\n },\n}\n</script>\n\n<style lang=\"scss\" scoped>\n.sound {\n width: 100%;\n height: 90%;\n margin-top: 7%;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n align-items: center;\n\n .sound-container {\n margin-bottom: 1em;\n }\n}\n</style>\n","import { render } from \"./Index.vue?vue&type=template&id=30d09191&scoped=true\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\nimport \"./Index.vue?vue&type=style&index=0&id=30d09191&lang=scss&scoped=true\"\n\nimport exportComponent from \"/home/blacklight/git_tree/platypush/platypush/backend/http/webapp/node_modules/vue-loader/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render],['__scopeId',\"data-v-30d09191\"]])\n\nexport default __exports__"],"names":["class","autoplay","preload","ref","recording","src","Date","getTime","type","stopRecording","startRecording","name","mixins","Utils","data","methods","this","request","__exports__","render"],"sourceRoot":""}
|
|
@ -1,2 +0,0 @@
|
|||
"use strict";(self["webpackChunkplatypush"]=self["webpackChunkplatypush"]||[]).push([[5193],{5193:function(t,n,o){o.r(n),o.d(n,{default:function(){return b}});var e=o(6252);const r=t=>((0,e.dD)("data-v-30d09191"),t=t(),(0,e.Cn)(),t),s={class:"sound"},a={class:"sound-container"},i={key:0,autoplay:"",preload:"none",ref:"player"},d=["src"],c=(0,e.Uk)(" Your browser does not support audio elements "),u={class:"controls"},l=r((()=>(0,e._)("i",{class:"fa fa-play"},null,-1))),p=(0,e.Uk)(" Start streaming audio "),g=[l,p],k=r((()=>(0,e._)("i",{class:"fa fa-stop"},null,-1))),y=(0,e.Uk)(" Stop streaming audio "),f=[k,y];function w(t,n,o,r,l,p){return(0,e.wg)(),(0,e.iD)("div",s,[(0,e._)("div",a,[l.recording?((0,e.wg)(),(0,e.iD)("audio",i,[(0,e._)("source",{src:`/sound/stream?t=${(new Date).getTime()}`,type:"audio/x-wav;codec=pcm"},null,8,d),c],512)):(0,e.kq)("",!0)]),(0,e._)("div",u,[l.recording?((0,e.wg)(),(0,e.iD)("button",{key:1,type:"button",onClick:n[1]||(n[1]=(...t)=>p.stopRecording&&p.stopRecording(...t))},f)):((0,e.wg)(),(0,e.iD)("button",{key:0,type:"button",onClick:n[0]||(n[0]=(...t)=>p.startRecording&&p.startRecording(...t))},g))])])}var h=o(6813),m={name:"Sound",mixins:[h.Z],data(){return{recording:!1}},methods:{startRecording(){this.recording=!0},async stopRecording(){this.recording=!1,await this.request("sound.stop_recording")}}},v=o(3744);const _=(0,v.Z)(m,[["render",w],["__scopeId","data-v-30d09191"]]);var b=_}}]);
|
||||
//# sourceMappingURL=5193.1de6bb98.js.map
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"file":"static/js/5193.1de6bb98.js","mappings":"4OACOA,MAAM,S,GACJA,MAAM,mB,SACFC,SAAA,GAASC,QAAQ,OAAOC,IAAI,U,qBAEuD,kD,GAKvFH,MAAM,Y,UAEP,OAA0B,KAAvBA,MAAM,cAAY,W,WAAK,4B,GAA1B,K,UAIA,OAA0B,KAAvBA,MAAM,cAAY,W,WAAK,2B,GAA1B,K,0CAfN,QAkBM,MAlBN,EAkBM,EAjBJ,OAMM,MANN,EAMM,CAL8C,EAAAI,YAAA,WAAlD,QAIQ,QAJR,EAIQ,EAFN,OAAwF,UAA/EC,IAAG,wBAA0BC,MAAQC,YAAaC,KAAK,yBAAhE,UAEM,GAJR,yBAOF,OAQM,MARN,EAQM,CAPiD,EAAAJ,YAArD,WAIA,QAES,U,MAFDI,KAAK,SAAU,QAAK,oBAAE,EAAAC,eAAA,EAAAA,iBAAA,KAA9B,MAJqD,WAArD,QAES,U,MAFDD,KAAK,SAAU,QAAK,oBAAE,EAAAE,gBAAA,EAAAA,kBAAA,KAA9B,O,eAcN,GACEC,KAAM,QACNC,OAAQ,CAACC,EAAA,GAETC,OACE,MAAO,CACLV,WAAW,EAEd,EAEDW,QAAS,CACPL,iBACEM,KAAKZ,WAAY,CAClB,EAEDa,sBACED,KAAKZ,WAAY,QACXY,KAAKE,QAAQ,uBACpB,I,UCpCL,MAAMC,GAA2B,OAAgB,EAAQ,CAAC,CAAC,SAASC,GAAQ,CAAC,YAAY,qBAEzF,O","sources":["webpack://platypush/./src/components/panels/Sound/Index.vue","webpack://platypush/./src/components/panels/Sound/Index.vue?0677"],"sourcesContent":["<template>\n <div class=\"sound\">\n <div class=\"sound-container\">\n <audio autoplay preload=\"none\" ref=\"player\" v-if=\"recording\">\n <!--suppress HtmlUnknownTarget -->\n <source :src=\"`/sound/stream?t=${(new Date()).getTime()}`\" type=\"audio/x-wav;codec=pcm\">\n Your browser does not support audio elements\n </audio>\n </div>\n\n <div class=\"controls\">\n <button type=\"button\" @click=\"startRecording\" v-if=\"!recording\">\n <i class=\"fa fa-play\"></i> Start streaming audio\n </button>\n\n <button type=\"button\" @click=\"stopRecording\" v-else>\n <i class=\"fa fa-stop\"></i> Stop streaming audio\n </button>\n </div>\n </div>\n</template>\n\n<script>\nimport Utils from \"@/Utils\";\n\nexport default {\n name: \"Sound\",\n mixins: [Utils],\n\n data() {\n return {\n recording: false,\n };\n },\n\n methods: {\n startRecording() {\n this.recording = true\n },\n\n async stopRecording() {\n this.recording = false\n await this.request('sound.stop_recording')\n },\n },\n}\n</script>\n\n<style lang=\"scss\" scoped>\n.sound {\n width: 100%;\n height: 90%;\n margin-top: 7%;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n align-items: center;\n\n .sound-container {\n margin-bottom: 1em;\n }\n}\n</style>\n","import { render } from \"./Index.vue?vue&type=template&id=30d09191&scoped=true\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\nimport \"./Index.vue?vue&type=style&index=0&id=30d09191&lang=scss&scoped=true\"\n\nimport exportComponent from \"/home/blacklight/git_tree/platypush/platypush/backend/http/webapp/node_modules/vue-loader/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render],['__scopeId',\"data-v-30d09191\"]])\n\nexport default __exports__"],"names":["class","autoplay","preload","ref","recording","src","Date","getTime","type","stopRecording","startRecording","name","mixins","Utils","data","methods","this","async","request","__exports__","render"],"sourceRoot":""}
|
2
platypush/backend/http/webapp/dist/static/js/5465-legacy.f819fef2.js
vendored
Normal file
2
platypush/backend/http/webapp/dist/static/js/5465-legacy.f819fef2.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/js/5465-legacy.f819fef2.js.map
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/js/5465-legacy.f819fef2.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
2
platypush/backend/http/webapp/dist/static/js/5465.e48f0738.js
vendored
Normal file
2
platypush/backend/http/webapp/dist/static/js/5465.e48f0738.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/js/5465.e48f0738.js.map
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/js/5465.e48f0738.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -1,2 +1,2 @@
|
|||
"use strict";(self["webpackChunkplatypush"]=self["webpackChunkplatypush"]||[]).push([[699],{699:function(a,e,r){r.r(e),r.d(e,{default:function(){return m}});var n=r(6252);function t(a,e,r,t,u,s){var p=(0,n.up)("Camera");return(0,n.wg)(),(0,n.j4)(p,{"camera-plugin":"gstreamer"})}var u=r(5528),s={name:"CameraGstreamer",components:{Camera:u["default"]}},p=r(3744);const c=(0,p.Z)(s,[["render",t]]);var m=c}}]);
|
||||
//# sourceMappingURL=699-legacy.cb1ccfbb.js.map
|
||||
"use strict";(self["webpackChunkplatypush"]=self["webpackChunkplatypush"]||[]).push([[699],{699:function(a,e,r){r.r(e),r.d(e,{default:function(){return m}});var n=r(6252);function t(a,e,r,t,u,s){var p=(0,n.up)("Camera");return(0,n.wg)(),(0,n.j4)(p,{"camera-plugin":"gstreamer"})}var u=r(9021),s={name:"CameraGstreamer",components:{Camera:u["default"]}},p=r(3744);const c=(0,p.Z)(s,[["render",t]]);var m=c}}]);
|
||||
//# sourceMappingURL=699-legacy.e258b653.js.map
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"static/js/699-legacy.cb1ccfbb.js","mappings":"8OACE,QAAoC,GAA5B,gBAAc,a,eAMxB,GACEA,KAAM,kBACNC,WAAY,CAACC,OAAA,e,UCJf,MAAMC,GAA2B,OAAgB,EAAQ,CAAC,CAAC,SAASC,KAEpE,O","sources":["webpack://platypush/./src/components/panels/CameraGstreamer/Index.vue","webpack://platypush/./src/components/panels/CameraGstreamer/Index.vue?5a11"],"sourcesContent":["<template>\n <Camera camera-plugin=\"gstreamer\" />\n</template>\n\n<script>\nimport Camera from \"@/components/panels/Camera/Index\";\n\nexport default {\n name: \"CameraGstreamer\",\n components: {Camera},\n}\n</script>\n","import { render } from \"./Index.vue?vue&type=template&id=6c669f2b\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\nimport exportComponent from \"/home/blacklight/git_tree/platypush/platypush/backend/http/webapp/node_modules/vue-loader/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__"],"names":["name","components","Camera","__exports__","render"],"sourceRoot":""}
|
||||
{"version":3,"file":"static/js/699-legacy.e258b653.js","mappings":"8OACE,QAAoC,GAA5B,gBAAc,a,eAMxB,GACEA,KAAM,kBACNC,WAAY,CAACC,OAAA,e,UCJf,MAAMC,GAA2B,OAAgB,EAAQ,CAAC,CAAC,SAASC,KAEpE,O","sources":["webpack://platypush/./src/components/panels/CameraGstreamer/Index.vue","webpack://platypush/./src/components/panels/CameraGstreamer/Index.vue?5a11"],"sourcesContent":["<template>\n <Camera camera-plugin=\"gstreamer\" />\n</template>\n\n<script>\nimport Camera from \"@/components/panels/Camera/Index\";\n\nexport default {\n name: \"CameraGstreamer\",\n components: {Camera},\n}\n</script>\n","import { render } from \"./Index.vue?vue&type=template&id=6c669f2b\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\nimport exportComponent from \"/home/blacklight/git_tree/platypush/platypush/backend/http/webapp/node_modules/vue-loader/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__"],"names":["name","components","Camera","__exports__","render"],"sourceRoot":""}
|
2
platypush/backend/http/webapp/dist/static/js/699.85a689b1.js
vendored
Normal file
2
platypush/backend/http/webapp/dist/static/js/699.85a689b1.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/js/699.85a689b1.js.map
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/js/699.85a689b1.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -1,2 +1,2 @@
|
|||
"use strict";(self["webpackChunkplatypush"]=self["webpackChunkplatypush"]||[]).push([[8184],{8184:function(a,e,n){n.r(e),n.d(e,{default:function(){return f}});var r=n(6252);function u(a,e,n,u,t,c){var p=(0,r.up)("Camera");return(0,r.wg)(),(0,r.j4)(p,{"camera-plugin":"cv"})}var t=n(5528),c={name:"CameraCv",components:{Camera:t["default"]}},p=n(3744);const s=(0,p.Z)(c,[["render",u]]);var f=s}}]);
|
||||
//# sourceMappingURL=8184-legacy.702db0b7.js.map
|
||||
"use strict";(self["webpackChunkplatypush"]=self["webpackChunkplatypush"]||[]).push([[8184],{8184:function(a,e,n){n.r(e),n.d(e,{default:function(){return f}});var r=n(6252);function u(a,e,n,u,t,c){var p=(0,r.up)("Camera");return(0,r.wg)(),(0,r.j4)(p,{"camera-plugin":"cv"})}var t=n(9021),c={name:"CameraCv",components:{Camera:t["default"]}},p=n(3744);const s=(0,p.Z)(c,[["render",u]]);var f=s}}]);
|
||||
//# sourceMappingURL=8184-legacy.73f24c6e.js.map
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"static/js/8184-legacy.702db0b7.js","mappings":"gPACE,QAA6B,GAArB,gBAAc,M,eAMxB,GACEA,KAAM,WACNC,WAAY,CAACC,OAAA,e,UCJf,MAAMC,GAA2B,OAAgB,EAAQ,CAAC,CAAC,SAASC,KAEpE,O","sources":["webpack://platypush/./src/components/panels/CameraCv/Index.vue","webpack://platypush/./src/components/panels/CameraCv/Index.vue?6f97"],"sourcesContent":["<template>\n <Camera camera-plugin=\"cv\" />\n</template>\n\n<script>\nimport Camera from \"@/components/panels/Camera/Index\";\n\nexport default {\n name: \"CameraCv\",\n components: {Camera},\n}\n</script>\n","import { render } from \"./Index.vue?vue&type=template&id=351194be\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\nimport exportComponent from \"/home/blacklight/git_tree/platypush/platypush/backend/http/webapp/node_modules/vue-loader/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__"],"names":["name","components","Camera","__exports__","render"],"sourceRoot":""}
|
||||
{"version":3,"file":"static/js/8184-legacy.73f24c6e.js","mappings":"gPACE,QAA6B,GAArB,gBAAc,M,eAMxB,GACEA,KAAM,WACNC,WAAY,CAACC,OAAA,e,UCJf,MAAMC,GAA2B,OAAgB,EAAQ,CAAC,CAAC,SAASC,KAEpE,O","sources":["webpack://platypush/./src/components/panels/CameraCv/Index.vue","webpack://platypush/./src/components/panels/CameraCv/Index.vue?6f97"],"sourcesContent":["<template>\n <Camera camera-plugin=\"cv\" />\n</template>\n\n<script>\nimport Camera from \"@/components/panels/Camera/Index\";\n\nexport default {\n name: \"CameraCv\",\n components: {Camera},\n}\n</script>\n","import { render } from \"./Index.vue?vue&type=template&id=351194be\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\nimport exportComponent from \"/home/blacklight/git_tree/platypush/platypush/backend/http/webapp/node_modules/vue-loader/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__"],"names":["name","components","Camera","__exports__","render"],"sourceRoot":""}
|
2
platypush/backend/http/webapp/dist/static/js/8184.3768abaf.js
vendored
Normal file
2
platypush/backend/http/webapp/dist/static/js/8184.3768abaf.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/js/8184.3768abaf.js.map
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/js/8184.3768abaf.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -1,2 +1,2 @@
|
|||
"use strict";(self["webpackChunkplatypush"]=self["webpackChunkplatypush"]||[]).push([[9895],{9895:function(a,r,e){e.r(r),e.d(r,{default:function(){return i}});var t=e(6252);function s(a,r,e,s,n,c){var u=(0,t.up)("Camera");return(0,t.wg)(),(0,t.j4)(u,{"camera-plugin":"ir.mlx90640",ref:"camera"},null,512)}var n=e(5528),c={name:"CameraIrMlx90640",components:{Camera:n["default"]},mounted:function(){var a=this.$root.config["camera.".concat(this.cameraPlugin)]||{};a.resolution||(this.$refs.camera.attrs.resolution=[32,24]),a.scale_x||(this.$refs.camera.attrs.scale_x=15),a.scale_y||(this.$refs.camera.attrs.scale_y=15)}},u=e(3744);const l=(0,u.Z)(c,[["render",s]]);var i=l}}]);
|
||||
//# sourceMappingURL=9895-legacy.acee9428.js.map
|
||||
"use strict";(self["webpackChunkplatypush"]=self["webpackChunkplatypush"]||[]).push([[9895],{9895:function(a,r,e){e.r(r),e.d(r,{default:function(){return i}});var t=e(6252);function s(a,r,e,s,n,c){var u=(0,t.up)("Camera");return(0,t.wg)(),(0,t.j4)(u,{"camera-plugin":"ir.mlx90640",ref:"camera"},null,512)}var n=e(9021),c={name:"CameraIrMlx90640",components:{Camera:n["default"]},mounted:function(){var a=this.$root.config["camera.".concat(this.cameraPlugin)]||{};a.resolution||(this.$refs.camera.attrs.resolution=[32,24]),a.scale_x||(this.$refs.camera.attrs.scale_x=15),a.scale_y||(this.$refs.camera.attrs.scale_y=15)}},u=e(3744);const l=(0,u.Z)(c,[["render",s]]);var i=l}}]);
|
||||
//# sourceMappingURL=9895-legacy.1fd296a4.js.map
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"static/js/9895-legacy.acee9428.js","mappings":"gPACE,QAAmD,GAA3C,gBAAc,cAAcA,IAAI,UAAxC,S,eAMF,GACEC,KAAM,mBACNC,WAAY,CAACC,OAAA,cAEbC,QAJa,WAKX,IAAMC,EAASC,KAAKC,MAAMF,OAAX,iBAA4BC,KAAKE,gBAAmB,CAAC,EAC/DH,EAAOI,aACVH,KAAKI,MAAMC,OAAOC,MAAMH,WAAa,CAAC,GAAI,KACvCJ,EAAOQ,UACVP,KAAKI,MAAMC,OAAOC,MAAMC,QAAU,IAC/BR,EAAOS,UACVR,KAAKI,MAAMC,OAAOC,MAAME,QAAU,GACrC,G,UCdH,MAAMC,GAA2B,OAAgB,EAAQ,CAAC,CAAC,SAASC,KAEpE,O","sources":["webpack://platypush/./src/components/panels/CameraIrMlx90640/Index.vue","webpack://platypush/./src/components/panels/CameraIrMlx90640/Index.vue?0a62"],"sourcesContent":["<template>\n <Camera camera-plugin=\"ir.mlx90640\" ref=\"camera\" />\n</template>\n\n<script>\nimport Camera from \"@/components/panels/Camera/Index\";\n\nexport default {\n name: \"CameraIrMlx90640\",\n components: {Camera},\n\n mounted() {\n const config = this.$root.config[`camera.${this.cameraPlugin}`] || {}\n if (!config.resolution)\n this.$refs.camera.attrs.resolution = [32, 24]\n if (!config.scale_x)\n this.$refs.camera.attrs.scale_x = 15\n if (!config.scale_y)\n this.$refs.camera.attrs.scale_y = 15\n },\n}\n</script>\n","import { render } from \"./Index.vue?vue&type=template&id=5585d4f1\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\nimport exportComponent from \"/home/blacklight/git_tree/platypush/platypush/backend/http/webapp/node_modules/vue-loader/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__"],"names":["ref","name","components","Camera","mounted","config","this","$root","cameraPlugin","resolution","$refs","camera","attrs","scale_x","scale_y","__exports__","render"],"sourceRoot":""}
|
||||
{"version":3,"file":"static/js/9895-legacy.1fd296a4.js","mappings":"gPACE,QAAmD,GAA3C,gBAAc,cAAcA,IAAI,UAAxC,S,eAMF,GACEC,KAAM,mBACNC,WAAY,CAACC,OAAA,cAEbC,QAJa,WAKX,IAAMC,EAASC,KAAKC,MAAMF,OAAX,iBAA4BC,KAAKE,gBAAmB,CAAC,EAC/DH,EAAOI,aACVH,KAAKI,MAAMC,OAAOC,MAAMH,WAAa,CAAC,GAAI,KACvCJ,EAAOQ,UACVP,KAAKI,MAAMC,OAAOC,MAAMC,QAAU,IAC/BR,EAAOS,UACVR,KAAKI,MAAMC,OAAOC,MAAME,QAAU,GACrC,G,UCdH,MAAMC,GAA2B,OAAgB,EAAQ,CAAC,CAAC,SAASC,KAEpE,O","sources":["webpack://platypush/./src/components/panels/CameraIrMlx90640/Index.vue","webpack://platypush/./src/components/panels/CameraIrMlx90640/Index.vue?0a62"],"sourcesContent":["<template>\n <Camera camera-plugin=\"ir.mlx90640\" ref=\"camera\" />\n</template>\n\n<script>\nimport Camera from \"@/components/panels/Camera/Index\";\n\nexport default {\n name: \"CameraIrMlx90640\",\n components: {Camera},\n\n mounted() {\n const config = this.$root.config[`camera.${this.cameraPlugin}`] || {}\n if (!config.resolution)\n this.$refs.camera.attrs.resolution = [32, 24]\n if (!config.scale_x)\n this.$refs.camera.attrs.scale_x = 15\n if (!config.scale_y)\n this.$refs.camera.attrs.scale_y = 15\n },\n}\n</script>\n","import { render } from \"./Index.vue?vue&type=template&id=5585d4f1\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\nimport exportComponent from \"/home/blacklight/git_tree/platypush/platypush/backend/http/webapp/node_modules/vue-loader/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__"],"names":["ref","name","components","Camera","mounted","config","this","$root","cameraPlugin","resolution","$refs","camera","attrs","scale_x","scale_y","__exports__","render"],"sourceRoot":""}
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
platypush/backend/http/webapp/dist/static/js/9895.a39079d5.js
vendored
Normal file
2
platypush/backend/http/webapp/dist/static/js/9895.a39079d5.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/js/9895.a39079d5.js.map
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/js/9895.a39079d5.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -40,8 +40,7 @@
|
|||
|
||||
<div class="audio-container">
|
||||
<audio autoplay preload="none" ref="player" v-if="audioOn">
|
||||
<!--suppress HtmlUnknownTarget -->
|
||||
<source :src="`/sound/stream?t=${(new Date()).getTime()}`" type="audio/x-wav;codec=pcm">
|
||||
<source :src="`/sound/stream.aac?t=${(new Date()).getTime()}`">
|
||||
Your browser does not support audio elements
|
||||
</audio>
|
||||
</div>
|
||||
|
|
|
@ -2,8 +2,7 @@
|
|||
<div class="sound">
|
||||
<div class="sound-container">
|
||||
<audio autoplay preload="none" ref="player" v-if="recording">
|
||||
<!--suppress HtmlUnknownTarget -->
|
||||
<source :src="`/sound/stream?t=${(new Date()).getTime()}`" type="audio/x-wav;codec=pcm">
|
||||
<source :src="`/sound/stream.aac?t=${(new Date()).getTime()}`">
|
||||
Your browser does not support audio elements
|
||||
</audio>
|
||||
</div>
|
||||
|
|
|
@ -1,65 +1,70 @@
|
|||
from abc import ABC
|
||||
from typing import Optional, Tuple, Union
|
||||
from platypush.message.event import Event
|
||||
|
||||
|
||||
class SoundEvent(Event):
|
||||
""" Base class for sound events """
|
||||
class SoundEvent(Event, ABC):
|
||||
"""Base class for sound events"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
def __init__(
|
||||
self, *args, device: Optional[Union[str, Tuple[str, str]]] = None, **kwargs
|
||||
):
|
||||
super().__init__(*args, device=device, **kwargs)
|
||||
|
||||
|
||||
class SoundPlaybackStartedEvent(SoundEvent):
|
||||
class SoundEventWithResource(SoundEvent, ABC):
|
||||
"""Base class for sound events with resource names attached"""
|
||||
|
||||
def __init__(self, *args, resource: Optional[str] = None, **kwargs):
|
||||
super().__init__(*args, resource=resource, **kwargs)
|
||||
|
||||
|
||||
class SoundPlaybackStartedEvent(SoundEventWithResource):
|
||||
"""
|
||||
Event triggered when a new sound playback starts
|
||||
"""
|
||||
|
||||
def __init__(self, filename=None, *args, **kwargs):
|
||||
super().__init__(*args, filename=filename, **kwargs)
|
||||
|
||||
|
||||
class SoundPlaybackStoppedEvent(SoundEvent):
|
||||
class SoundPlaybackStoppedEvent(SoundEventWithResource):
|
||||
"""
|
||||
Event triggered when the sound playback stops
|
||||
"""
|
||||
|
||||
def __init__(self, filename=None, *args, **kwargs):
|
||||
super().__init__(*args, filename=filename, **kwargs)
|
||||
|
||||
|
||||
class SoundPlaybackPausedEvent(SoundEvent):
|
||||
class SoundPlaybackPausedEvent(SoundEventWithResource):
|
||||
"""
|
||||
Event triggered when the sound playback pauses
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class SoundPlaybackResumedEvent(SoundEventWithResource):
|
||||
"""
|
||||
Event triggered when the sound playback resumsed from a paused state
|
||||
"""
|
||||
|
||||
|
||||
class SoundRecordingStartedEvent(SoundEvent):
|
||||
class SoundRecordingStartedEvent(SoundEventWithResource):
|
||||
"""
|
||||
Event triggered when a new recording starts
|
||||
"""
|
||||
|
||||
def __init__(self, filename=None, *args, **kwargs):
|
||||
super().__init__(*args, filename=filename, **kwargs)
|
||||
|
||||
|
||||
class SoundRecordingStoppedEvent(SoundEvent):
|
||||
class SoundRecordingStoppedEvent(SoundEventWithResource):
|
||||
"""
|
||||
Event triggered when a sound recording stops
|
||||
"""
|
||||
|
||||
def __init__(self, filename=None, *args, **kwargs):
|
||||
super().__init__(*args, filename=filename, **kwargs)
|
||||
|
||||
|
||||
class SoundRecordingPausedEvent(SoundEvent):
|
||||
class SoundRecordingPausedEvent(SoundEventWithResource):
|
||||
"""
|
||||
Event triggered when a sound recording pauses
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class SoundRecordingResumedEvent(SoundEvent):
|
||||
"""
|
||||
Event triggered when a sound recording resumes from a paused state
|
||||
"""
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -7,24 +7,43 @@ import time
|
|||
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
from multiprocessing import Process
|
||||
from queue import Queue
|
||||
from typing import Optional, Union, Dict, Tuple, IO
|
||||
from typing import Generator, Optional, Union, Dict, Tuple, IO
|
||||
|
||||
from platypush.config import Config
|
||||
from platypush.message.event.camera import CameraRecordingStartedEvent, CameraPictureTakenEvent, \
|
||||
CameraRecordingStoppedEvent, CameraVideoRenderedEvent
|
||||
from platypush.message.event.camera import (
|
||||
CameraRecordingStartedEvent,
|
||||
CameraPictureTakenEvent,
|
||||
CameraRecordingStoppedEvent,
|
||||
CameraVideoRenderedEvent,
|
||||
)
|
||||
from platypush.plugins import Plugin, action
|
||||
from platypush.plugins.camera.model.camera import CameraInfo, Camera
|
||||
from platypush.plugins.camera.model.exceptions import CameraException, CaptureAlreadyRunningException
|
||||
from platypush.plugins.camera.model.exceptions import (
|
||||
CameraException,
|
||||
CaptureAlreadyRunningException,
|
||||
)
|
||||
from platypush.plugins.camera.model.writer import VideoWriter, StreamWriter
|
||||
from platypush.plugins.camera.model.writer.ffmpeg import FFmpegFileWriter
|
||||
from platypush.plugins.camera.model.writer.preview import PreviewWriter, PreviewWriterFactory
|
||||
from platypush.plugins.camera.model.writer.preview import (
|
||||
PreviewWriter,
|
||||
PreviewWriterFactory,
|
||||
)
|
||||
from platypush.utils import get_plugin_name_by_class
|
||||
|
||||
__all__ = ['Camera', 'CameraInfo', 'CameraException', 'CameraPlugin', 'CaptureAlreadyRunningException',
|
||||
'VideoWriter', 'StreamWriter', 'PreviewWriter']
|
||||
__all__ = [
|
||||
'Camera',
|
||||
'CameraInfo',
|
||||
'CameraException',
|
||||
'CameraPlugin',
|
||||
'CaptureAlreadyRunningException',
|
||||
'VideoWriter',
|
||||
'StreamWriter',
|
||||
'PreviewWriter',
|
||||
]
|
||||
|
||||
|
||||
class CameraPlugin(Plugin, ABC):
|
||||
|
@ -66,15 +85,32 @@ class CameraPlugin(Plugin, ABC):
|
|||
_camera_info_class = CameraInfo
|
||||
_video_writer_class = FFmpegFileWriter
|
||||
|
||||
def __init__(self, device: Optional[Union[int, str]] = None, resolution: Tuple[int, int] = (640, 480),
|
||||
frames_dir: Optional[str] = None, warmup_frames: int = 5, warmup_seconds: Optional[float] = 0.,
|
||||
capture_timeout: Optional[float] = 20.0, scale_x: Optional[float] = None,
|
||||
scale_y: Optional[float] = None, rotate: Optional[float] = None, grayscale: Optional[bool] = None,
|
||||
color_transform: Optional[Union[int, str]] = None, fps: float = 16, horizontal_flip: bool = False,
|
||||
vertical_flip: bool = False, input_format: Optional[str] = None, output_format: Optional[str] = None,
|
||||
stream_format: str = 'mjpeg', listen_port: Optional[int] = 5000, bind_address: str = '0.0.0.0',
|
||||
ffmpeg_bin: str = 'ffmpeg', input_codec: Optional[str] = None, output_codec: Optional[str] = None,
|
||||
**kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
device: Optional[Union[int, str]] = None,
|
||||
resolution: Tuple[int, int] = (640, 480),
|
||||
frames_dir: Optional[str] = None,
|
||||
warmup_frames: int = 5,
|
||||
warmup_seconds: Optional[float] = 0.0,
|
||||
capture_timeout: Optional[float] = 20.0,
|
||||
scale_x: Optional[float] = None,
|
||||
scale_y: Optional[float] = None,
|
||||
rotate: Optional[float] = None,
|
||||
grayscale: Optional[bool] = None,
|
||||
color_transform: Optional[Union[int, str]] = None,
|
||||
fps: float = 16,
|
||||
horizontal_flip: bool = False,
|
||||
vertical_flip: bool = False,
|
||||
input_format: Optional[str] = None,
|
||||
output_format: Optional[str] = None,
|
||||
stream_format: str = 'mjpeg',
|
||||
listen_port: Optional[int] = 5000,
|
||||
bind_address: str = '0.0.0.0',
|
||||
ffmpeg_bin: str = 'ffmpeg',
|
||||
input_codec: Optional[str] = None,
|
||||
output_codec: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
:param device: Identifier of the default capturing device.
|
||||
:param resolution: Default resolution, as a tuple of two integers.
|
||||
|
@ -117,22 +153,38 @@ class CameraPlugin(Plugin, ABC):
|
|||
"""
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.workdir = os.path.join(Config.get('workdir'), get_plugin_name_by_class(self))
|
||||
workdir = Config.get('workdir')
|
||||
plugin_name = get_plugin_name_by_class(self)
|
||||
assert isinstance(workdir, str) and plugin_name
|
||||
self.workdir = os.path.join(workdir, plugin_name)
|
||||
pathlib.Path(self.workdir).mkdir(mode=0o755, exist_ok=True, parents=True)
|
||||
|
||||
# noinspection PyArgumentList
|
||||
self.camera_info = self._camera_info_class(device, color_transform=color_transform, warmup_frames=warmup_frames,
|
||||
warmup_seconds=warmup_seconds, rotate=rotate, scale_x=scale_x,
|
||||
scale_y=scale_y, capture_timeout=capture_timeout, fps=fps,
|
||||
input_format=input_format, output_format=output_format,
|
||||
stream_format=stream_format, resolution=resolution,
|
||||
grayscale=grayscale, listen_port=listen_port,
|
||||
horizontal_flip=horizontal_flip, vertical_flip=vertical_flip,
|
||||
ffmpeg_bin=ffmpeg_bin, input_codec=input_codec,
|
||||
output_codec=output_codec, bind_address=bind_address,
|
||||
frames_dir=os.path.abspath(
|
||||
os.path.expanduser(frames_dir or
|
||||
os.path.join(self.workdir, 'frames'))))
|
||||
self.camera_info = self._camera_info_class(
|
||||
device,
|
||||
color_transform=color_transform,
|
||||
warmup_frames=warmup_frames,
|
||||
warmup_seconds=warmup_seconds or 0,
|
||||
rotate=rotate,
|
||||
scale_x=scale_x,
|
||||
scale_y=scale_y,
|
||||
capture_timeout=capture_timeout or 20,
|
||||
fps=fps,
|
||||
input_format=input_format,
|
||||
output_format=output_format,
|
||||
stream_format=stream_format,
|
||||
resolution=resolution,
|
||||
grayscale=grayscale,
|
||||
listen_port=listen_port,
|
||||
horizontal_flip=horizontal_flip,
|
||||
vertical_flip=vertical_flip,
|
||||
ffmpeg_bin=ffmpeg_bin,
|
||||
input_codec=input_codec,
|
||||
output_codec=output_codec,
|
||||
bind_address=bind_address,
|
||||
frames_dir=os.path.abspath(
|
||||
os.path.expanduser(frames_dir or os.path.join(self.workdir, 'frames'))
|
||||
),
|
||||
)
|
||||
|
||||
self._devices: Dict[Union[int, str], Camera] = {}
|
||||
self._streams: Dict[Union[int, str], Camera] = {}
|
||||
|
@ -142,7 +194,13 @@ class CameraPlugin(Plugin, ABC):
|
|||
merged_info.set(**info)
|
||||
return merged_info
|
||||
|
||||
def open_device(self, device: Optional[Union[int, str]] = None, stream: bool = False, **params) -> Camera:
|
||||
def open_device(
|
||||
self,
|
||||
device: Optional[Union[int, str]],
|
||||
stream: bool = False,
|
||||
redis_queue: Optional[str] = None,
|
||||
**params,
|
||||
) -> Camera:
|
||||
"""
|
||||
Initialize and open a device.
|
||||
|
||||
|
@ -160,25 +218,31 @@ class CameraPlugin(Plugin, ABC):
|
|||
assert device is not None, 'No device specified/configured'
|
||||
if device in self._devices:
|
||||
camera = self._devices[device]
|
||||
if camera.capture_thread and camera.capture_thread.is_alive() and camera.start_event.is_set():
|
||||
if (
|
||||
camera.capture_thread
|
||||
and camera.capture_thread.is_alive()
|
||||
and camera.start_event.is_set()
|
||||
):
|
||||
raise CaptureAlreadyRunningException(device)
|
||||
|
||||
camera.start_event.clear()
|
||||
camera.capture_thread = None
|
||||
else:
|
||||
# noinspection PyArgumentList
|
||||
camera = self._camera_class(info=info)
|
||||
|
||||
camera.info.set(**params)
|
||||
camera.object = self.prepare_device(camera)
|
||||
|
||||
if stream:
|
||||
if stream and camera.info.stream_format:
|
||||
writer_class = StreamWriter.get_class_by_name(camera.info.stream_format)
|
||||
camera.stream = writer_class(camera=camera, plugin=self)
|
||||
camera.stream = writer_class(
|
||||
camera=camera, plugin=self, redis_queue=redis_queue
|
||||
)
|
||||
|
||||
if camera.info.frames_dir:
|
||||
pathlib.Path(os.path.abspath(os.path.expanduser(camera.info.frames_dir))).mkdir(
|
||||
mode=0o755, exist_ok=True, parents=True)
|
||||
pathlib.Path(
|
||||
os.path.abspath(os.path.expanduser(camera.info.frames_dir))
|
||||
).mkdir(mode=0o755, exist_ok=True, parents=True)
|
||||
|
||||
self._devices[device] = camera
|
||||
return camera
|
||||
|
@ -205,29 +269,43 @@ class CameraPlugin(Plugin, ABC):
|
|||
:param camera: Camera object. ``camera.info.capture_timeout`` is used as a capture thread termination timeout
|
||||
if set.
|
||||
"""
|
||||
if camera.capture_thread and camera.capture_thread.is_alive() and \
|
||||
threading.get_ident() != camera.capture_thread.ident:
|
||||
if (
|
||||
camera.capture_thread
|
||||
and camera.capture_thread.is_alive()
|
||||
and threading.get_ident() != camera.capture_thread.ident
|
||||
):
|
||||
try:
|
||||
camera.capture_thread.join(timeout=camera.info.capture_timeout)
|
||||
except Exception as e:
|
||||
self.logger.warning('Error on FFmpeg capture wait: {}'.format(str(e)))
|
||||
self.logger.warning('Error on FFmpeg capture wait: %s', e)
|
||||
|
||||
@contextmanager
|
||||
def open(self, device: Optional[Union[int, str]] = None, stream: bool = None, **info) -> Camera:
|
||||
def open(
|
||||
self,
|
||||
device: Optional[Union[int, str]] = None,
|
||||
stream: bool = False,
|
||||
redis_queue: Optional[str] = None,
|
||||
**info,
|
||||
) -> Generator[Camera, None, None]:
|
||||
"""
|
||||
Initialize and open a device using a context manager pattern.
|
||||
|
||||
:param device: Capture device by name, path or ID.
|
||||
:param stream: If set, the frames will be streamed to ``camera.stream``.
|
||||
:param redis_queue: If set, the frames will be streamed to
|
||||
``redis_queue``.
|
||||
:param info: Camera parameters override - see constructors parameters.
|
||||
:return: The initialized :class:`platypush.plugins.camera.Camera` object.
|
||||
"""
|
||||
camera = None
|
||||
try:
|
||||
camera = self.open_device(device, stream=stream, **info)
|
||||
camera = self.open_device(
|
||||
device, stream=stream, redis_queue=redis_queue, **info
|
||||
)
|
||||
yield camera
|
||||
finally:
|
||||
self.close_device(camera)
|
||||
if camera:
|
||||
self.close_device(camera)
|
||||
|
||||
@abstractmethod
|
||||
def prepare_device(self, device: Camera):
|
||||
|
@ -256,7 +334,6 @@ class CameraPlugin(Plugin, ABC):
|
|||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
# noinspection PyShadowingBuiltins
|
||||
@staticmethod
|
||||
def store_frame(frame, filepath: str, format: Optional[str] = None):
|
||||
"""
|
||||
|
@ -267,9 +344,10 @@ class CameraPlugin(Plugin, ABC):
|
|||
:param format: Output format.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
if isinstance(frame, bytes):
|
||||
frame = list(frame)
|
||||
elif not isinstance(frame, Image.Image):
|
||||
if not isinstance(frame, Image.Image):
|
||||
frame = Image.fromarray(frame)
|
||||
|
||||
save_args = {}
|
||||
|
@ -278,16 +356,28 @@ class CameraPlugin(Plugin, ABC):
|
|||
|
||||
frame.save(filepath, **save_args)
|
||||
|
||||
def _store_frame(self, frame, frames_dir: Optional[str] = None, image_file: Optional[str] = None,
|
||||
*args, **kwargs) -> str:
|
||||
def _store_frame(
|
||||
self,
|
||||
frame,
|
||||
frames_dir: Optional[str] = None,
|
||||
image_file: Optional[str] = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
:meth:`.store_frame` wrapper.
|
||||
"""
|
||||
if image_file:
|
||||
filepath = os.path.abspath(os.path.expanduser(image_file))
|
||||
else:
|
||||
filepath = os.path.abspath(os.path.expanduser(
|
||||
os.path.join(frames_dir or '', datetime.now().strftime('%Y-%m-%d_%H-%M-%S-%f.jpg'))))
|
||||
filepath = os.path.abspath(
|
||||
os.path.expanduser(
|
||||
os.path.join(
|
||||
frames_dir or '',
|
||||
datetime.now().strftime('%Y-%m-%d_%H-%M-%S-%f.jpg'),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
pathlib.Path(filepath).parent.mkdir(mode=0o755, exist_ok=True, parents=True)
|
||||
self.store_frame(frame, filepath, *args, **kwargs)
|
||||
|
@ -295,7 +385,9 @@ class CameraPlugin(Plugin, ABC):
|
|||
|
||||
def start_preview(self, camera: Camera):
|
||||
if camera.preview and not camera.preview.closed:
|
||||
self.logger.info('A preview window is already active on device {}'.format(camera.info.device))
|
||||
self.logger.info(
|
||||
'A preview window is already active on device %s', camera.info.device
|
||||
)
|
||||
return
|
||||
|
||||
camera.preview = PreviewWriterFactory.get(camera, self)
|
||||
|
@ -315,7 +407,9 @@ class CameraPlugin(Plugin, ABC):
|
|||
|
||||
camera.preview = None
|
||||
|
||||
def frame_processor(self, frame_queue: Queue, camera: Camera, image_file: Optional[str] = None):
|
||||
def frame_processor(
|
||||
self, frame_queue: Queue, camera: Camera, image_file: Optional[str] = None
|
||||
):
|
||||
while True:
|
||||
frame = frame_queue.get()
|
||||
if frame is None:
|
||||
|
@ -326,18 +420,31 @@ class CameraPlugin(Plugin, ABC):
|
|||
frame = self.to_grayscale(frame)
|
||||
|
||||
frame = self.rotate_frame(frame, camera.info.rotate)
|
||||
frame = self.flip_frame(frame, camera.info.horizontal_flip, camera.info.vertical_flip)
|
||||
frame = self.flip_frame(
|
||||
frame, camera.info.horizontal_flip, camera.info.vertical_flip
|
||||
)
|
||||
frame = self.scale_frame(frame, camera.info.scale_x, camera.info.scale_y)
|
||||
|
||||
for output in camera.get_outputs():
|
||||
output.write(frame)
|
||||
|
||||
if camera.info.frames_dir or image_file:
|
||||
self._store_frame(frame=frame, frames_dir=camera.info.frames_dir, image_file=image_file)
|
||||
self._store_frame(
|
||||
frame=frame,
|
||||
frames_dir=camera.info.frames_dir,
|
||||
image_file=image_file,
|
||||
)
|
||||
|
||||
def capturing_thread(self, camera: Camera, duration: Optional[float] = None, video_file: Optional[str] = None,
|
||||
image_file: Optional[str] = None, n_frames: Optional[int] = None, preview: bool = False,
|
||||
**kwargs):
|
||||
def capturing_thread(
|
||||
self,
|
||||
camera: Camera,
|
||||
duration: Optional[float] = None,
|
||||
video_file: Optional[str] = None,
|
||||
image_file: Optional[str] = None,
|
||||
n_frames: Optional[int] = None,
|
||||
preview: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Camera capturing thread.
|
||||
|
||||
|
@ -366,25 +473,36 @@ class CameraPlugin(Plugin, ABC):
|
|||
if duration and camera.info.warmup_seconds:
|
||||
duration = duration + camera.info.warmup_seconds
|
||||
if video_file:
|
||||
camera.file_writer = self._video_writer_class(camera=camera, plugin=self, output_file=video_file)
|
||||
camera.file_writer = self._video_writer_class(
|
||||
camera=camera, plugin=self, output_file=video_file
|
||||
)
|
||||
|
||||
frame_queue = Queue()
|
||||
frame_processor = threading.Thread(target=self.frame_processor,
|
||||
kwargs=dict(frame_queue=frame_queue, camera=camera, image_file=image_file))
|
||||
frame_processor = threading.Thread(
|
||||
target=self.frame_processor,
|
||||
kwargs={
|
||||
'frame_queue': frame_queue,
|
||||
'camera': camera,
|
||||
'image_file': image_file,
|
||||
},
|
||||
)
|
||||
frame_processor.start()
|
||||
self.fire_event(CameraRecordingStartedEvent(**evt_args))
|
||||
|
||||
try:
|
||||
while camera.start_event.is_set():
|
||||
if (duration and time.time() - recording_started_time >= duration) \
|
||||
or (n_frames and captured_frames >= n_frames):
|
||||
if (duration and time.time() - recording_started_time >= duration) or (
|
||||
n_frames and captured_frames >= n_frames
|
||||
):
|
||||
break
|
||||
|
||||
frame_capture_start = time.time()
|
||||
try:
|
||||
frame = self.capture_frame(camera, **kwargs)
|
||||
if not frame:
|
||||
self.logger.warning('Invalid frame received, terminating the capture session')
|
||||
self.logger.warning(
|
||||
'Invalid frame received, terminating the capture session'
|
||||
)
|
||||
break
|
||||
|
||||
frame_queue.put(frame)
|
||||
|
@ -392,12 +510,20 @@ class CameraPlugin(Plugin, ABC):
|
|||
self.logger.warning(str(e))
|
||||
continue
|
||||
|
||||
if not n_frames or not camera.info.warmup_seconds or \
|
||||
(time.time() - recording_started_time >= camera.info.warmup_seconds):
|
||||
if (
|
||||
not n_frames
|
||||
or not camera.info.warmup_seconds
|
||||
or (
|
||||
time.time() - recording_started_time
|
||||
>= camera.info.warmup_seconds
|
||||
)
|
||||
):
|
||||
captured_frames += 1
|
||||
|
||||
if camera.info.fps:
|
||||
wait_time = (1. / camera.info.fps) - (time.time() - frame_capture_start)
|
||||
wait_time = (1.0 / camera.info.fps) - (
|
||||
time.time() - frame_capture_start
|
||||
)
|
||||
if wait_time > 0:
|
||||
time.sleep(wait_time)
|
||||
finally:
|
||||
|
@ -407,7 +533,7 @@ class CameraPlugin(Plugin, ABC):
|
|||
try:
|
||||
output.close()
|
||||
except Exception as e:
|
||||
self.logger.warning('Could not close camera output: {}'.format(str(e)))
|
||||
self.logger.warning('Could not close camera output: %s', e)
|
||||
|
||||
self.close_device(camera, wait_capture=False)
|
||||
frame_processor.join(timeout=5.0)
|
||||
|
@ -426,17 +552,26 @@ class CameraPlugin(Plugin, ABC):
|
|||
:param camera: An initialized :class:`platypush.plugins.camera.Camera` object.
|
||||
:param preview: Show a preview of the camera frames.
|
||||
"""
|
||||
assert not (camera.capture_thread and camera.capture_thread.is_alive()), \
|
||||
'A capture session is already in progress'
|
||||
assert not (
|
||||
camera.capture_thread and camera.capture_thread.is_alive()
|
||||
), 'A capture session is already in progress'
|
||||
|
||||
camera.capture_thread = threading.Thread(target=self.capturing_thread, args=(camera, *args),
|
||||
kwargs={'preview': preview, **kwargs})
|
||||
camera.capture_thread = threading.Thread(
|
||||
target=self.capturing_thread,
|
||||
args=(camera, *args),
|
||||
kwargs={'preview': preview, **kwargs},
|
||||
)
|
||||
camera.capture_thread.start()
|
||||
camera.start_event.set()
|
||||
|
||||
@action
|
||||
def capture_video(self, duration: Optional[float] = None, video_file: Optional[str] = None, preview: bool = False,
|
||||
**camera) -> Union[str, dict]:
|
||||
def capture_video(
|
||||
self,
|
||||
duration: Optional[float] = None,
|
||||
video_file: Optional[str] = None,
|
||||
preview: bool = False,
|
||||
**camera,
|
||||
) -> Optional[Union[str, dict]]:
|
||||
"""
|
||||
Capture a video.
|
||||
|
||||
|
@ -448,14 +583,20 @@ class CameraPlugin(Plugin, ABC):
|
|||
to the recorded resource. Otherwise, it will return the status of the camera device after starting it.
|
||||
"""
|
||||
camera = self.open_device(**camera)
|
||||
self.start_camera(camera, duration=duration, video_file=video_file, frames_dir=None, image_file=None,
|
||||
preview=preview)
|
||||
self.start_camera(
|
||||
camera,
|
||||
duration=duration,
|
||||
video_file=video_file,
|
||||
frames_dir=None,
|
||||
image_file=None,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
if duration:
|
||||
self.wait_capture(camera)
|
||||
return video_file
|
||||
|
||||
return self.status(camera.info.device)
|
||||
return self.status(camera.info.device).output
|
||||
|
||||
@action
|
||||
def stop_capture(self, device: Optional[Union[int, str]] = None):
|
||||
|
@ -465,12 +606,12 @@ class CameraPlugin(Plugin, ABC):
|
|||
:param device: Name/path/ID of the device to stop (default: all the active devices).
|
||||
"""
|
||||
devices = self._devices.copy()
|
||||
stop_devices = list(devices.values())[:]
|
||||
stop_devices = list(devices.values())
|
||||
if device:
|
||||
stop_devices = [self._devices[device]] if device in self._devices else []
|
||||
|
||||
for device in stop_devices:
|
||||
self.close_device(device)
|
||||
for dev in stop_devices:
|
||||
self.close_device(dev)
|
||||
|
||||
@action
|
||||
def capture_image(self, image_file: str, preview: bool = False, **camera) -> str:
|
||||
|
@ -484,15 +625,18 @@ class CameraPlugin(Plugin, ABC):
|
|||
"""
|
||||
|
||||
with self.open(**camera) as camera:
|
||||
warmup_frames = camera.info.warmup_frames if camera.info.warmup_frames else 1
|
||||
self.start_camera(camera, image_file=image_file, n_frames=warmup_frames, preview=preview)
|
||||
warmup_frames = (
|
||||
camera.info.warmup_frames if camera.info.warmup_frames else 1
|
||||
)
|
||||
self.start_camera(
|
||||
camera, image_file=image_file, n_frames=warmup_frames, preview=preview
|
||||
)
|
||||
self.wait_capture(camera)
|
||||
|
||||
return image_file
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
@action
|
||||
def take_picture(self, image_file: str, preview: bool = False, **camera) -> str:
|
||||
def take_picture(self, image_file: str, **camera) -> str:
|
||||
"""
|
||||
Alias for :meth:`.capture_image`.
|
||||
|
||||
|
@ -501,11 +645,16 @@ class CameraPlugin(Plugin, ABC):
|
|||
:param preview: Show a preview of the camera frames.
|
||||
:return: The local path to the saved image.
|
||||
"""
|
||||
return self.capture_image(image_file, **camera)
|
||||
return str(self.capture_image(image_file, **camera).output)
|
||||
|
||||
@action
|
||||
def capture_sequence(self, duration: Optional[float] = None, n_frames: Optional[int] = None, preview: bool = False,
|
||||
**camera) -> str:
|
||||
def capture_sequence(
|
||||
self,
|
||||
duration: Optional[float] = None,
|
||||
n_frames: Optional[int] = None,
|
||||
preview: bool = False,
|
||||
**camera,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Capture a sequence of frames from a camera and store them to a directory.
|
||||
|
||||
|
@ -517,12 +666,16 @@ class CameraPlugin(Plugin, ABC):
|
|||
:return: The directory where the image files have been stored.
|
||||
"""
|
||||
with self.open(**camera) as camera:
|
||||
self.start_camera(camera, duration=duration, n_frames=n_frames, preview=preview)
|
||||
self.start_camera(
|
||||
camera, duration=duration, n_frames=n_frames, preview=preview
|
||||
)
|
||||
self.wait_capture(camera)
|
||||
return camera.info.frames_dir
|
||||
|
||||
@action
|
||||
def capture_preview(self, duration: Optional[float] = None, n_frames: Optional[int] = None, **camera) -> dict:
|
||||
def capture_preview(
|
||||
self, duration: Optional[float] = None, n_frames: Optional[int] = None, **camera
|
||||
) -> dict:
|
||||
"""
|
||||
Start a camera preview session.
|
||||
|
||||
|
@ -533,16 +686,18 @@ class CameraPlugin(Plugin, ABC):
|
|||
"""
|
||||
camera = self.open_device(frames_dir=None, **camera)
|
||||
self.start_camera(camera, duration=duration, n_frames=n_frames, preview=True)
|
||||
return self.status(camera.info.device)
|
||||
return self.status(camera.info.device) # type: ignore
|
||||
|
||||
@staticmethod
|
||||
def _prepare_server_socket(camera: Camera) -> socket.socket:
|
||||
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server_socket.bind(( # lgtm [py/bind-socket-all-network-interfaces]
|
||||
camera.info.bind_address or '0.0.0.0',
|
||||
camera.info.listen_port
|
||||
))
|
||||
server_socket.bind(
|
||||
( # lgtm [py/bind-socket-all-network-interfaces]
|
||||
camera.info.bind_address or '0.0.0.0',
|
||||
camera.info.listen_port,
|
||||
)
|
||||
)
|
||||
server_socket.listen(1)
|
||||
server_socket.settimeout(1)
|
||||
return server_socket
|
||||
|
@ -550,16 +705,18 @@ class CameraPlugin(Plugin, ABC):
|
|||
def _accept_client(self, server_socket: socket.socket) -> Optional[IO]:
|
||||
try:
|
||||
sock = server_socket.accept()[0]
|
||||
self.logger.info('Accepted client connection from {}'.format(sock.getpeername()))
|
||||
self.logger.info('Accepted client connection from %s', sock.getpeername())
|
||||
return sock.makefile('wb')
|
||||
except socket.timeout:
|
||||
return
|
||||
|
||||
def streaming_thread(self, camera: Camera, stream_format: str, duration: Optional[float] = None):
|
||||
def streaming_thread(
|
||||
self, camera: Camera, stream_format: str, duration: Optional[float] = None
|
||||
):
|
||||
streaming_started_time = time.time()
|
||||
server_socket = self._prepare_server_socket(camera)
|
||||
sock = None
|
||||
self.logger.info('Starting streaming on port {}'.format(camera.info.listen_port))
|
||||
self.logger.info('Starting streaming on port %s', camera.info.listen_port)
|
||||
|
||||
try:
|
||||
while camera.stream_event.is_set():
|
||||
|
@ -571,36 +728,43 @@ class CameraPlugin(Plugin, ABC):
|
|||
continue
|
||||
|
||||
if camera.info.device not in self._devices:
|
||||
info = camera.info.to_dict()
|
||||
info = asdict(camera.info)
|
||||
info['stream_format'] = stream_format
|
||||
camera = self.open_device(stream=True, **info)
|
||||
|
||||
assert camera.stream, 'No camera stream available'
|
||||
camera.stream.sock = sock
|
||||
self.start_camera(camera, duration=duration, frames_dir=None, image_file=None)
|
||||
self.start_camera(
|
||||
camera, duration=duration, frames_dir=None, image_file=None
|
||||
)
|
||||
finally:
|
||||
self._cleanup_stream(camera, server_socket, sock)
|
||||
self.logger.info('Stopped camera stream')
|
||||
|
||||
def _cleanup_stream(self, camera: Camera, server_socket: socket.socket, client: IO):
|
||||
def _cleanup_stream(
|
||||
self, camera: Camera, server_socket: socket.socket, client: Optional[IO]
|
||||
):
|
||||
if client:
|
||||
try:
|
||||
client.close()
|
||||
except Exception as e:
|
||||
self.logger.warning('Error on client socket close: {}'.format(str(e)))
|
||||
self.logger.warning('Error on client socket close: %s', e)
|
||||
|
||||
try:
|
||||
server_socket.close()
|
||||
except Exception as e:
|
||||
self.logger.warning('Error on server socket close: {}'.format(str(e)))
|
||||
self.logger.warning('Error on server socket close: %s', e)
|
||||
|
||||
if camera.stream:
|
||||
try:
|
||||
camera.stream.close()
|
||||
except Exception as e:
|
||||
self.logger.warning('Error while closing the encoding stream: {}'.format(str(e)))
|
||||
self.logger.warning('Error while closing the encoding stream: %s', e)
|
||||
|
||||
@action
|
||||
def start_streaming(self, duration: Optional[float] = None, stream_format: str = 'mkv', **camera) -> dict:
|
||||
def start_streaming(
|
||||
self, duration: Optional[float] = None, stream_format: str = 'mkv', **camera
|
||||
) -> dict:
|
||||
"""
|
||||
Expose the video stream of a camera over a TCP connection.
|
||||
|
||||
|
@ -610,18 +774,28 @@ class CameraPlugin(Plugin, ABC):
|
|||
:return: The status of the device.
|
||||
"""
|
||||
camera = self.open_device(stream=True, stream_format=stream_format, **camera)
|
||||
return self._start_streaming(camera, duration, stream_format)
|
||||
return self._start_streaming(camera, duration, stream_format) # type: ignore
|
||||
|
||||
def _start_streaming(self, camera: Camera, duration: Optional[float], stream_format: str):
|
||||
def _start_streaming(
|
||||
self, camera: Camera, duration: Optional[float], stream_format: str
|
||||
):
|
||||
assert camera.info.listen_port, 'No listen_port specified/configured'
|
||||
assert not camera.stream_event.is_set() and camera.info.device not in self._streams, \
|
||||
'A streaming session is already running for device {}'.format(camera.info.device)
|
||||
assert (
|
||||
not camera.stream_event.is_set() and camera.info.device not in self._streams
|
||||
), f'A streaming session is already running for device {camera.info.device}'
|
||||
assert camera.info.device, 'No device name available'
|
||||
|
||||
self._streams[camera.info.device] = camera
|
||||
camera.stream_event.set()
|
||||
|
||||
camera.stream_thread = threading.Thread(target=self.streaming_thread, kwargs=dict(
|
||||
camera=camera, duration=duration, stream_format=stream_format))
|
||||
camera.stream_thread = threading.Thread(
|
||||
target=self.streaming_thread,
|
||||
kwargs={
|
||||
'camera': camera,
|
||||
'duration': duration,
|
||||
'stream_format': stream_format,
|
||||
},
|
||||
)
|
||||
camera.stream_thread.start()
|
||||
return self.status(camera.info.device)
|
||||
|
||||
|
@ -633,12 +807,12 @@ class CameraPlugin(Plugin, ABC):
|
|||
:param device: Name/path/ID of the device to stop (default: all the active devices).
|
||||
"""
|
||||
streams = self._streams.copy()
|
||||
stop_devices = list(streams.values())[:]
|
||||
stop_devices = list(streams.values())
|
||||
if device:
|
||||
stop_devices = [self._streams[device]] if device in self._streams else []
|
||||
|
||||
for device in stop_devices:
|
||||
self._stop_streaming(device)
|
||||
for dev in stop_devices:
|
||||
self._stop_streaming(dev)
|
||||
|
||||
def _stop_streaming(self, camera: Camera):
|
||||
camera.stream_event.clear()
|
||||
|
@ -654,11 +828,18 @@ class CameraPlugin(Plugin, ABC):
|
|||
return {}
|
||||
|
||||
return {
|
||||
**camera.info.to_dict(),
|
||||
'active': True if camera.capture_thread and camera.capture_thread.is_alive() else False,
|
||||
'capturing': True if camera.capture_thread and camera.capture_thread.is_alive() and
|
||||
camera.start_event.is_set() else False,
|
||||
'streaming': camera.stream_thread and camera.stream_thread.is_alive() and camera.stream_event.is_set(),
|
||||
**asdict(camera.info),
|
||||
'active': bool(camera.capture_thread and camera.capture_thread.is_alive()),
|
||||
'capturing': bool(
|
||||
camera.capture_thread
|
||||
and camera.capture_thread.is_alive()
|
||||
and camera.start_event.is_set()
|
||||
),
|
||||
'streaming': (
|
||||
camera.stream_thread
|
||||
and camera.stream_thread.is_alive()
|
||||
and camera.stream_event.is_set()
|
||||
),
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -670,10 +851,7 @@ class CameraPlugin(Plugin, ABC):
|
|||
if device:
|
||||
return self._status(device)
|
||||
|
||||
return {
|
||||
id: self._status(device)
|
||||
for id, camera in self._devices.items()
|
||||
}
|
||||
return {id: self._status(id) for id in self._devices}
|
||||
|
||||
@staticmethod
|
||||
def transform_frame(frame, color_transform):
|
||||
|
@ -690,6 +868,7 @@ class CameraPlugin(Plugin, ABC):
|
|||
:param frame: Image frame (default: a ``PIL.Image`` object).
|
||||
"""
|
||||
from PIL import ImageOps
|
||||
|
||||
return ImageOps.grayscale(frame)
|
||||
|
||||
@staticmethod
|
||||
|
@ -724,7 +903,9 @@ class CameraPlugin(Plugin, ABC):
|
|||
return frame
|
||||
|
||||
@staticmethod
|
||||
def scale_frame(frame, scale_x: Optional[float] = None, scale_y: Optional[float] = None):
|
||||
def scale_frame(
|
||||
frame, scale_x: Optional[float] = None, scale_y: Optional[float] = None
|
||||
):
|
||||
"""
|
||||
Frame scaling logic. The default implementation assumes that frame is a ``PIL.Image`` object.
|
||||
|
||||
|
@ -733,6 +914,7 @@ class CameraPlugin(Plugin, ABC):
|
|||
:param scale_y: Y-scale factor.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
if not (scale_x and scale_y) or (scale_x == 1 and scale_y == 1):
|
||||
return frame
|
||||
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
import math
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Optional, Union, Tuple, Set
|
||||
|
||||
import numpy as np
|
||||
|
||||
from platypush.plugins.camera.model.writer import StreamWriter, VideoWriter, FileVideoWriter
|
||||
from platypush.plugins.camera.model.writer import (
|
||||
StreamWriter,
|
||||
VideoWriter,
|
||||
FileVideoWriter,
|
||||
)
|
||||
from platypush.plugins.camera.model.writer.preview import PreviewWriter
|
||||
|
||||
|
||||
|
@ -13,8 +17,8 @@ from platypush.plugins.camera.model.writer.preview import PreviewWriter
|
|||
class CameraInfo:
|
||||
device: Optional[Union[int, str]]
|
||||
bind_address: Optional[str] = None
|
||||
capture_timeout: float = 20.0
|
||||
color_transform: Optional[str] = None
|
||||
capture_timeout: float = 0
|
||||
color_transform: Optional[Union[int, str]] = None
|
||||
ffmpeg_bin: Optional[str] = None
|
||||
fps: Optional[float] = None
|
||||
frames_dir: Optional[str] = None
|
||||
|
@ -32,42 +36,15 @@ class CameraInfo:
|
|||
stream_format: Optional[str] = None
|
||||
vertical_flip: bool = False
|
||||
warmup_frames: int = 0
|
||||
warmup_seconds: float = 0.
|
||||
warmup_seconds: float = 0
|
||||
|
||||
def set(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
if hasattr(self, k):
|
||||
setattr(self, k, v)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'bind_address': self.bind_address,
|
||||
'capture_timeout': self.capture_timeout,
|
||||
'color_transform': self.color_transform,
|
||||
'device': self.device,
|
||||
'ffmpeg_bin': self.ffmpeg_bin,
|
||||
'fps': self.fps,
|
||||
'frames_dir': self.frames_dir,
|
||||
'grayscale': self.grayscale,
|
||||
'horizontal_flip': self.horizontal_flip,
|
||||
'input_codec': self.input_codec,
|
||||
'input_format': self.input_format,
|
||||
'listen_port': self.listen_port,
|
||||
'output_codec': self.output_codec,
|
||||
'output_format': self.output_format,
|
||||
'resolution': list(self.resolution or ()),
|
||||
'rotate': self.rotate,
|
||||
'scale_x': self.scale_x,
|
||||
'scale_y': self.scale_y,
|
||||
'stream_format': self.stream_format,
|
||||
'vertical_flip': self.vertical_flip,
|
||||
'warmup_frames': self.warmup_frames,
|
||||
'warmup_seconds': self.warmup_seconds,
|
||||
}
|
||||
|
||||
def clone(self):
|
||||
# noinspection PyArgumentList
|
||||
return self.__class__(**self.to_dict())
|
||||
return self.__class__(**asdict(self))
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -97,10 +74,15 @@ class Camera:
|
|||
return writers
|
||||
|
||||
def effective_resolution(self) -> Tuple[int, int]:
|
||||
"""
|
||||
Calculates the effective resolution of the camera in pixels, taking
|
||||
into account the base resolution, the scale and the rotation.
|
||||
"""
|
||||
assert self.info.resolution, 'No base resolution specified'
|
||||
rot = (self.info.rotate or 0) * math.pi / 180
|
||||
sin = math.sin(rot)
|
||||
cos = math.cos(rot)
|
||||
scale = np.array([[self.info.scale_x or 1., self.info.scale_y or 1.]])
|
||||
scale = np.array([[self.info.scale_x or 1.0, self.info.scale_y or 1.0]])
|
||||
resolution = np.array([[self.info.resolution[0], self.info.resolution[1]]])
|
||||
rot_matrix = np.array([[sin, cos], [cos, sin]])
|
||||
resolution = (scale * abs(np.cross(rot_matrix, resolution)))[0]
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import io
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
@ -9,6 +9,8 @@ from typing import Optional, IO
|
|||
|
||||
from PIL.Image import Image
|
||||
|
||||
from platypush.utils import get_redis
|
||||
|
||||
|
||||
class VideoWriter(ABC):
|
||||
"""
|
||||
|
@ -26,11 +28,11 @@ class VideoWriter(ABC):
|
|||
self.closed = False
|
||||
|
||||
@abstractmethod
|
||||
def write(self, img: Image):
|
||||
def write(self, image: Image):
|
||||
"""
|
||||
Write an image to the channel.
|
||||
|
||||
:param img: PIL Image instance.
|
||||
:param image: PIL Image instance.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
@ -49,7 +51,7 @@ class VideoWriter(ABC):
|
|||
"""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
def __exit__(self, *_, **__):
|
||||
"""
|
||||
Context manager-based interface.
|
||||
"""
|
||||
|
@ -60,8 +62,9 @@ class FileVideoWriter(VideoWriter, ABC):
|
|||
"""
|
||||
Abstract class to handle frames-to-video file operations.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, output_file: str, **kwargs):
|
||||
VideoWriter.__init__(self, *args, **kwargs)
|
||||
super().__init__(self, *args, **kwargs)
|
||||
self.output_file = os.path.abspath(os.path.expanduser(output_file))
|
||||
|
||||
|
||||
|
@ -69,12 +72,20 @@ class StreamWriter(VideoWriter, ABC):
|
|||
"""
|
||||
Abstract class for camera streaming operations.
|
||||
"""
|
||||
def __init__(self, *args, sock: Optional[IO] = None, **kwargs):
|
||||
VideoWriter.__init__(self, *args, **kwargs)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
sock: Optional[IO] = None,
|
||||
redis_queue: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.frame: Optional[bytes] = None
|
||||
self.frame_time: Optional[float] = None
|
||||
self.buffer = io.BytesIO()
|
||||
self.ready = threading.Condition()
|
||||
self.ready = multiprocessing.Condition()
|
||||
self.redis_queue = redis_queue
|
||||
self.sock = sock
|
||||
|
||||
def write(self, image: Image):
|
||||
|
@ -101,6 +112,9 @@ class StreamWriter(VideoWriter, ABC):
|
|||
self.logger.info('Client connection closed')
|
||||
self.close()
|
||||
|
||||
if self.redis_queue:
|
||||
get_redis().publish(self.redis_queue, data)
|
||||
|
||||
@abstractmethod
|
||||
def encode(self, image: Image) -> bytes:
|
||||
"""
|
||||
|
@ -117,16 +131,20 @@ class StreamWriter(VideoWriter, ABC):
|
|||
try:
|
||||
self.sock.close()
|
||||
except Exception as e:
|
||||
self.logger.warning('Could not close camera resource: {}'.format(str(e)))
|
||||
self.logger.warning('Could not close camera resource: %s', e)
|
||||
|
||||
super().close()
|
||||
|
||||
@staticmethod
|
||||
def get_class_by_name(name: str):
|
||||
from platypush.plugins.camera.model.writer.index import StreamHandlers
|
||||
|
||||
name = name.upper()
|
||||
assert hasattr(StreamHandlers, name), 'No such stream handler: {}. Supported types: {}'.format(
|
||||
name, [hndl.name for hndl in list(StreamHandlers)])
|
||||
assert hasattr(
|
||||
StreamHandlers, name
|
||||
), f'No such stream handler: {name}. Supported types: ' + (
|
||||
', '.join([hndl.name for hndl in list(StreamHandlers)])
|
||||
)
|
||||
|
||||
return getattr(StreamHandlers, name).value
|
||||
|
||||
|
|
|
@ -8,7 +8,11 @@ from typing import Optional, Tuple
|
|||
from PIL.Image import Image
|
||||
|
||||
from platypush.plugins.camera.model.camera import Camera
|
||||
from platypush.plugins.camera.model.writer import VideoWriter, FileVideoWriter, StreamWriter
|
||||
from platypush.plugins.camera.model.writer import (
|
||||
VideoWriter,
|
||||
FileVideoWriter,
|
||||
StreamWriter,
|
||||
)
|
||||
|
||||
|
||||
class FFmpegWriter(VideoWriter, ABC):
|
||||
|
@ -16,9 +20,17 @@ class FFmpegWriter(VideoWriter, ABC):
|
|||
Generic FFmpeg encoder for camera frames.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, input_file: str = '-', input_format: str = 'rawvideo', output_file: str = '-',
|
||||
output_format: Optional[str] = None, pix_fmt: Optional[str] = None,
|
||||
output_opts: Optional[Tuple] = None, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
input_file: str = '-',
|
||||
input_format: str = 'rawvideo',
|
||||
output_file: str = '-',
|
||||
output_format: Optional[str] = None,
|
||||
pix_fmt: Optional[str] = None,
|
||||
output_opts: Optional[Tuple] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.input_file = input_file
|
||||
|
@ -29,21 +41,34 @@ class FFmpegWriter(VideoWriter, ABC):
|
|||
self.pix_fmt = pix_fmt
|
||||
self.output_opts = output_opts or ()
|
||||
|
||||
self.logger.info('Starting FFmpeg. Command: {}'.format(' '.join(self.ffmpeg_args)))
|
||||
self.ffmpeg = subprocess.Popen(self.ffmpeg_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||
self.logger.info('Starting FFmpeg. Command: ' + ' '.join(self.ffmpeg_args))
|
||||
self.ffmpeg = subprocess.Popen(
|
||||
self.ffmpeg_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE
|
||||
)
|
||||
|
||||
@property
|
||||
def ffmpeg_args(self):
|
||||
return [self.camera.info.ffmpeg_bin, '-y',
|
||||
'-f', self.input_format,
|
||||
*(('-pix_fmt', self.pix_fmt) if self.pix_fmt else ()),
|
||||
'-s', '{}x{}'.format(self.width, self.height),
|
||||
'-r', str(self.camera.info.fps),
|
||||
'-i', self.input_file,
|
||||
*(('-f', self.output_format) if self.output_format else ()),
|
||||
*self.output_opts,
|
||||
*(('-vcodec', self.camera.info.output_codec) if self.camera.info.output_codec else ()),
|
||||
self.output_file]
|
||||
return [
|
||||
self.camera.info.ffmpeg_bin,
|
||||
'-y',
|
||||
'-f',
|
||||
self.input_format,
|
||||
*(('-pix_fmt', self.pix_fmt) if self.pix_fmt else ()),
|
||||
'-s',
|
||||
f'{self.width}x{self.height}',
|
||||
'-r',
|
||||
str(self.camera.info.fps),
|
||||
'-i',
|
||||
self.input_file,
|
||||
*(('-f', self.output_format) if self.output_format else ()),
|
||||
*self.output_opts,
|
||||
*(
|
||||
('-vcodec', self.camera.info.output_codec)
|
||||
if self.camera.info.output_codec
|
||||
else ()
|
||||
),
|
||||
self.output_file,
|
||||
]
|
||||
|
||||
def is_closed(self):
|
||||
return self.closed or not self.ffmpeg or self.ffmpeg.poll() is not None
|
||||
|
@ -55,7 +80,7 @@ class FFmpegWriter(VideoWriter, ABC):
|
|||
try:
|
||||
self.ffmpeg.stdin.write(image.convert('RGB').tobytes())
|
||||
except Exception as e:
|
||||
self.logger.warning('FFmpeg send error: {}'.format(str(e)))
|
||||
self.logger.warning('FFmpeg send error: %s', e)
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
|
@ -63,7 +88,7 @@ class FFmpegWriter(VideoWriter, ABC):
|
|||
if self.ffmpeg and self.ffmpeg.stdin:
|
||||
try:
|
||||
self.ffmpeg.stdin.close()
|
||||
except (IOError, OSError):
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if self.ffmpeg:
|
||||
|
@ -77,7 +102,7 @@ class FFmpegWriter(VideoWriter, ABC):
|
|||
if self.ffmpeg and self.ffmpeg.stdout:
|
||||
try:
|
||||
self.ffmpeg.stdout.close()
|
||||
except (IOError, OSError):
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
self.ffmpeg = None
|
||||
|
@ -98,10 +123,26 @@ class FFmpegStreamWriter(StreamWriter, FFmpegWriter, ABC):
|
|||
Stream camera frames using FFmpeg.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, output_format: str, output_opts: Optional[Tuple] = None, **kwargs):
|
||||
super().__init__(*args, pix_fmt='rgb24', output_format=output_format, output_opts=output_opts or (
|
||||
'-tune', 'zerolatency', '-preset', 'superfast', '-trellis', '0',
|
||||
'-fflags', 'nobuffer'), **kwargs)
|
||||
def __init__(
|
||||
self, *args, output_format: str, output_opts: Optional[Tuple] = None, **kwargs
|
||||
):
|
||||
super().__init__(
|
||||
*args,
|
||||
pix_fmt='rgb24',
|
||||
output_format=output_format,
|
||||
output_opts=output_opts
|
||||
or (
|
||||
'-tune',
|
||||
'zerolatency',
|
||||
'-preset',
|
||||
'superfast',
|
||||
'-trellis',
|
||||
'0',
|
||||
'-fflags',
|
||||
'nobuffer',
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
self._reader = threading.Thread(target=self._reader_thread)
|
||||
self._reader.start()
|
||||
|
||||
|
@ -115,7 +156,7 @@ class FFmpegStreamWriter(StreamWriter, FFmpegWriter, ABC):
|
|||
try:
|
||||
data = self.ffmpeg.stdout.read(1 << 15)
|
||||
except Exception as e:
|
||||
self.logger.warning('FFmpeg reader error: {}'.format(str(e)))
|
||||
self.logger.warning('FFmpeg reader error: %s', e)
|
||||
break
|
||||
|
||||
if not data:
|
||||
|
@ -123,7 +164,7 @@ class FFmpegStreamWriter(StreamWriter, FFmpegWriter, ABC):
|
|||
|
||||
if self.frame is None:
|
||||
latency = time.time() - start_time
|
||||
self.logger.info('FFmpeg stream latency: {} secs'.format(latency))
|
||||
self.logger.info('FFmpeg stream latency: %d secs', latency)
|
||||
|
||||
with self.ready:
|
||||
self.frame = data
|
||||
|
@ -140,12 +181,16 @@ class FFmpegStreamWriter(StreamWriter, FFmpegWriter, ABC):
|
|||
try:
|
||||
self.ffmpeg.stdin.write(data)
|
||||
except Exception as e:
|
||||
self.logger.warning('FFmpeg send error: {}'.format(str(e)))
|
||||
self.logger.warning('FFmpeg send error: %s', e)
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
super().close()
|
||||
if self._reader and self._reader.is_alive() and threading.get_ident() != self._reader.ident:
|
||||
if (
|
||||
self._reader
|
||||
and self._reader.is_alive()
|
||||
and threading.get_ident() != self._reader.ident
|
||||
):
|
||||
self._reader.join(timeout=5.0)
|
||||
self._reader = None
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
10
platypush/plugins/sound/_converters/__init__.py
Normal file
10
platypush/plugins/sound/_converters/__init__.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
from ._base import AudioConverter
|
||||
from ._from_raw import RawInputAudioConverter
|
||||
from ._to_raw import RawOutputAudioConverter, RawOutputAudioFromFileConverter
|
||||
|
||||
__all__ = [
|
||||
'AudioConverter',
|
||||
'RawInputAudioConverter',
|
||||
'RawOutputAudioConverter',
|
||||
'RawOutputAudioFromFileConverter',
|
||||
]
|
331
platypush/plugins/sound/_converters/_base.py
Normal file
331
platypush/plugins/sound/_converters/_base.py
Normal file
|
@ -0,0 +1,331 @@
|
|||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
from asyncio.subprocess import PIPE
|
||||
from logging import getLogger
|
||||
from queue import Empty, Queue
|
||||
from threading import Event, RLock, Thread
|
||||
from typing import Any, Callable, Coroutine, Iterable, 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 raw types:
|
||||
'int8', 'uint8', 'int16', 'uint16', 'int32', 'uint32', 'float32', 'float64'
|
||||
"""
|
||||
|
||||
|
||||
class AudioConverter(Thread, ABC):
|
||||
"""
|
||||
Base class for an ffmpeg audio converter instance.
|
||||
"""
|
||||
|
||||
_format_to_ffmpeg_args = {
|
||||
'wav': ('-f', 'wav'),
|
||||
'ogg': ('-f', 'ogg'),
|
||||
'mp3': ('-f', 'mp3'),
|
||||
'aac': ('-f', 'adts'),
|
||||
'flac': ('-f', 'flac'),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
ffmpeg_bin: str,
|
||||
sample_rate: int,
|
||||
channels: int,
|
||||
volume: float,
|
||||
dtype: str,
|
||||
chunk_size: int,
|
||||
format: Optional[str] = None, # pylint: disable=redefined-builtin
|
||||
on_exit: Optional[Callable[[], Any]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
:param ffmpeg_bin: Path to the ffmpeg binary.
|
||||
:param sample_rate: The sample rate of the input/output audio.
|
||||
:param channels: The number of channels of the input/output audio.
|
||||
:param volume: Audio volume, as a percentage between 0 and 100.
|
||||
:param dtype: The (numpy) data type of the raw input/output audio.
|
||||
:param chunk_size: Number of bytes that will be read at once from the
|
||||
ffmpeg process.
|
||||
:param format: Input/output audio format.
|
||||
:param on_exit: Function to call when the ffmpeg process exits.
|
||||
"""
|
||||
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._ffmpeg_task: Optional[Coroutine] = None
|
||||
self._sample_rate = sample_rate
|
||||
self._channels = channels
|
||||
self._chunk_size = chunk_size
|
||||
self._format = format
|
||||
self._closed = False
|
||||
self._out_queue = Queue()
|
||||
self.ffmpeg = None
|
||||
self.volume = volume
|
||||
self.logger = getLogger(__name__)
|
||||
self._loop = None
|
||||
self._should_stop = Event()
|
||||
self._stop_lock = RLock()
|
||||
self._on_exit = on_exit
|
||||
self._ffmpeg_terminated = Event()
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
"""
|
||||
Audio converter context manager.
|
||||
|
||||
It starts and registers the ffmpeg converter process.
|
||||
"""
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_, **__):
|
||||
"""
|
||||
Audio converter context manager.
|
||||
|
||||
It stops and unregisters the ffmpeg converter process.
|
||||
"""
|
||||
self.stop()
|
||||
|
||||
def _check_ffmpeg(self):
|
||||
assert not self.terminated, 'The ffmpeg process has already terminated'
|
||||
|
||||
@property
|
||||
def gain(self) -> float:
|
||||
return self.volume / 100
|
||||
|
||||
@property
|
||||
def terminated(self) -> bool:
|
||||
return self._ffmpeg_terminated.is_set()
|
||||
|
||||
@property
|
||||
def _default_args(self) -> Iterable[str]:
|
||||
"""
|
||||
Set of arguments common to all ffmpeg converter instances.
|
||||
"""
|
||||
return ('-hide_banner', '-loglevel', 'warning', '-y')
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _input_format_args(self) -> Iterable[str]:
|
||||
"""
|
||||
Ffmpeg audio input arguments.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _output_format_args(self):
|
||||
"""
|
||||
Ffmpeg audio output arguments.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def _channel_layout_args(self) -> Iterable[str]:
|
||||
"""
|
||||
Set of extra ffmpeg arguments for the channel layout.
|
||||
"""
|
||||
args = ('-ac', str(self._channels))
|
||||
if self._channels == 1:
|
||||
return args + ('-channel_layout', 'mono')
|
||||
if self._channels == 2:
|
||||
return args + ('-channel_layout', 'stereo')
|
||||
return args
|
||||
|
||||
@property
|
||||
def _raw_ffmpeg_args(self) -> Iterable[str]:
|
||||
"""
|
||||
Ffmpeg arguments for raw audio input/output given the current
|
||||
configuration.
|
||||
"""
|
||||
return (
|
||||
'-f',
|
||||
self._ffmpeg_format,
|
||||
'-ar',
|
||||
str(self._sample_rate),
|
||||
*self._channel_layout_args,
|
||||
)
|
||||
|
||||
@property
|
||||
def _audio_volume_args(self) -> Iterable[str]:
|
||||
"""
|
||||
Ffmpeg audio volume arguments.
|
||||
"""
|
||||
return ('-filter:a', f'volume={self.gain}')
|
||||
|
||||
@property
|
||||
def _input_source_args(self) -> Iterable[str]:
|
||||
"""
|
||||
Default arguments for the ffmpeg input source (default: ``-i pipe:``,
|
||||
ffmpeg will read from a pipe filled by the application).
|
||||
"""
|
||||
return ('-i', 'pipe:')
|
||||
|
||||
@property
|
||||
def _output_target_args(self) -> Iterable[str]:
|
||||
"""
|
||||
Default arguments for the ffmpeg output target (default: ``pipe:``,
|
||||
ffmpeg will write the output to a pipe read by the application).
|
||||
"""
|
||||
return ('pipe:',)
|
||||
|
||||
@property
|
||||
def _converter_stdin(self) -> Optional[int]:
|
||||
"""
|
||||
Default stdin file descriptor to be used by the ffmpeg converter.
|
||||
|
||||
Default: ``PIPE``, as the ffmpeg process by default reads audio frames
|
||||
from the stdin.
|
||||
"""
|
||||
return PIPE
|
||||
|
||||
@property
|
||||
def _compressed_ffmpeg_args(self) -> Iterable[str]:
|
||||
"""
|
||||
Ffmpeg arguments for the compressed audio given the current
|
||||
configuration.
|
||||
"""
|
||||
if not self._format:
|
||||
return ()
|
||||
|
||||
ffmpeg_args = self._format_to_ffmpeg_args.get(self._format)
|
||||
assert ffmpeg_args, (
|
||||
f'Unsupported output format: {self._format}. Supported formats: '
|
||||
f'{list(self._format_to_ffmpeg_args.keys())}'
|
||||
)
|
||||
|
||||
return ffmpeg_args
|
||||
|
||||
async def _audio_proxy(self, timeout: Optional[float] = None):
|
||||
"""
|
||||
Proxy the converted audio stream to the output queue for downstream
|
||||
consumption.
|
||||
"""
|
||||
ffmpeg_args = (
|
||||
self._ffmpeg_bin,
|
||||
*self._default_args,
|
||||
*self._input_format_args,
|
||||
*self._input_source_args,
|
||||
*self._output_format_args,
|
||||
*self._output_target_args,
|
||||
)
|
||||
|
||||
self.ffmpeg = await asyncio.create_subprocess_exec(
|
||||
*ffmpeg_args,
|
||||
stdin=self._converter_stdin,
|
||||
stdout=PIPE,
|
||||
)
|
||||
|
||||
self.logger.info('Running ffmpeg: %s', ' '.join(ffmpeg_args))
|
||||
|
||||
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
|
||||
and not self.should_stop
|
||||
):
|
||||
self._check_ffmpeg()
|
||||
assert (
|
||||
self.ffmpeg and self.ffmpeg.stdout
|
||||
), 'The stdout is closed for the ffmpeg process'
|
||||
|
||||
self._ffmpeg_terminated.clear()
|
||||
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):
|
||||
"""
|
||||
Write raw data to the ffmpeg process.
|
||||
"""
|
||||
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]:
|
||||
"""
|
||||
Read the next chunk of converted audio bytes from the converter queue.
|
||||
"""
|
||||
try:
|
||||
return self._out_queue.get(timeout=timeout)
|
||||
except Empty:
|
||||
return None
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Main runner. It runs the audio proxy in a loop and cleans up everything
|
||||
in case of stop/failure.
|
||||
"""
|
||||
super().run()
|
||||
self._loop = get_or_create_event_loop()
|
||||
try:
|
||||
self._ffmpeg_task = self._audio_proxy(timeout=1)
|
||||
self._loop.run_until_complete(self._ffmpeg_task)
|
||||
except RuntimeError as e:
|
||||
self.logger.warning(e)
|
||||
finally:
|
||||
self.stop()
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
Sets the stop event, kills the ffmpeg process and resets the context.
|
||||
"""
|
||||
with self._stop_lock:
|
||||
self._should_stop.set()
|
||||
if self._ffmpeg_task:
|
||||
self._ffmpeg_task.close()
|
||||
self._ffmpeg_task = None
|
||||
|
||||
try:
|
||||
if self.ffmpeg and self.ffmpeg.returncode is None:
|
||||
self.ffmpeg.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
self.ffmpeg = None
|
||||
self._loop = None
|
||||
|
||||
self._ffmpeg_terminated.set()
|
||||
|
||||
if self._on_exit:
|
||||
self._on_exit()
|
||||
|
||||
@property
|
||||
def should_stop(self) -> bool:
|
||||
"""
|
||||
Proxy property for the ``_should_stop`` event.
|
||||
"""
|
||||
return self._should_stop.is_set()
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
23
platypush/plugins/sound/_converters/_from_raw.py
Normal file
23
platypush/plugins/sound/_converters/_from_raw.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
from typing import Iterable
|
||||
from typing_extensions import override
|
||||
|
||||
from ._base import AudioConverter
|
||||
|
||||
|
||||
class RawInputAudioConverter(AudioConverter):
|
||||
"""
|
||||
Converts raw audio input to a compressed media format.
|
||||
"""
|
||||
|
||||
@property
|
||||
@override
|
||||
def _input_format_args(self) -> Iterable[str]:
|
||||
return self._raw_ffmpeg_args
|
||||
|
||||
@property
|
||||
@override
|
||||
def _output_format_args(self) -> Iterable[str]:
|
||||
return self._compressed_ffmpeg_args
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
38
platypush/plugins/sound/_converters/_to_raw.py
Normal file
38
platypush/plugins/sound/_converters/_to_raw.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
from typing import Iterable
|
||||
from typing_extensions import override
|
||||
|
||||
from ._base import AudioConverter
|
||||
|
||||
|
||||
class RawOutputAudioConverter(AudioConverter):
|
||||
"""
|
||||
Converts input audio to raw audio output.
|
||||
"""
|
||||
|
||||
@property
|
||||
@override
|
||||
def _input_format_args(self) -> Iterable[str]:
|
||||
return self._compressed_ffmpeg_args
|
||||
|
||||
@property
|
||||
@override
|
||||
def _output_format_args(self) -> Iterable[str]:
|
||||
return self._raw_ffmpeg_args
|
||||
|
||||
|
||||
class RawOutputAudioFromFileConverter(RawOutputAudioConverter):
|
||||
"""
|
||||
Converts an input file to raw audio output.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, infile: str, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.infile = infile
|
||||
|
||||
@property
|
||||
@override
|
||||
def _input_source_args(self) -> Iterable[str]:
|
||||
return ('-i', self.infile)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
3
platypush/plugins/sound/_manager/__init__.py
Normal file
3
platypush/plugins/sound/_manager/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from ._main import AudioManager
|
||||
|
||||
__all__ = ["AudioManager"]
|
91
platypush/plugins/sound/_manager/_device.py
Normal file
91
platypush/plugins/sound/_manager/_device.py
Normal file
|
@ -0,0 +1,91 @@
|
|||
from typing import List, Optional
|
||||
|
||||
import sounddevice as sd
|
||||
|
||||
from .._model import AudioDevice, DeviceType, StreamType
|
||||
|
||||
|
||||
class DeviceManager:
|
||||
"""
|
||||
The device manager is responsible for managing the virtual audio device
|
||||
abstractions exposed by the OS.
|
||||
|
||||
For example, on a pure ALSA system virtual devices are usually mapped the
|
||||
physical audio devices available on the system.
|
||||
|
||||
On a system that runs through PulseAudio or Jack, there may be a
|
||||
``default`` virtual device whose sound card mappings may be managed by the
|
||||
audio server.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_device: Optional[DeviceType] = None,
|
||||
output_device: Optional[DeviceType] = None,
|
||||
):
|
||||
"""
|
||||
:param input_device: The default input device to use (by index or name).
|
||||
:param output_device: The default output device to use (by index or name).
|
||||
"""
|
||||
self.input_device = (
|
||||
self.get_device(input_device, StreamType.INPUT)
|
||||
if input_device is not None
|
||||
else None
|
||||
)
|
||||
|
||||
self.output_device = (
|
||||
self.get_device(output_device, StreamType.OUTPUT)
|
||||
if output_device is not None
|
||||
else None
|
||||
)
|
||||
|
||||
def get_devices(
|
||||
self, type: Optional[StreamType] = None # pylint: disable=redefined-builtin
|
||||
) -> List[AudioDevice]:
|
||||
"""
|
||||
Get available audio devices.
|
||||
|
||||
:param type: The type of devices to filter (default: return all).
|
||||
"""
|
||||
devices: List[dict] = sd.query_devices() # type: ignore
|
||||
if type:
|
||||
devices = [dev for dev in devices if dev.get(f'max_{type.value}_channels')]
|
||||
|
||||
return [AudioDevice(**info) for info in devices]
|
||||
|
||||
def get_device(
|
||||
self,
|
||||
device: Optional[DeviceType] = None,
|
||||
type: Optional[StreamType] = None, # pylint: disable=redefined-builtin
|
||||
) -> AudioDevice:
|
||||
"""
|
||||
Search for a device.
|
||||
|
||||
Either ``device`` or ``type`` have to be specified.
|
||||
|
||||
:param device: The device to search for, either by index or name. If
|
||||
not specified, then the default device for the given type is
|
||||
returned.
|
||||
:param type: The type of the device to search.
|
||||
"""
|
||||
assert device or type, 'Please specify either device or type'
|
||||
if device is None:
|
||||
if type == StreamType.INPUT and self.input_device is not None:
|
||||
return self.input_device
|
||||
if type == StreamType.OUTPUT and self.output_device is not None:
|
||||
return self.output_device
|
||||
|
||||
try:
|
||||
info: dict = sd.query_devices(
|
||||
kind=type.value if type else None, device=device # type: ignore
|
||||
)
|
||||
except sd.PortAudioError as e:
|
||||
raise AssertionError(
|
||||
f'Could not get device for type={type} and device={device}: {e}',
|
||||
type,
|
||||
device,
|
||||
e,
|
||||
) from e
|
||||
|
||||
assert info, f'No such device: {device}'
|
||||
return AudioDevice(**info)
|
291
platypush/plugins/sound/_manager/_main.py
Normal file
291
platypush/plugins/sound/_manager/_main.py
Normal file
|
@ -0,0 +1,291 @@
|
|||
from logging import getLogger
|
||||
import os
|
||||
import stat
|
||||
from threading import Event
|
||||
from time import time
|
||||
from typing import Iterable, List, Optional, Union
|
||||
|
||||
from .._model import AudioDevice, DeviceType, StreamType
|
||||
from .._streams import AudioPlayer, AudioRecorder, AudioThread
|
||||
from ._device import DeviceManager
|
||||
from ._stream import StreamManager
|
||||
|
||||
|
||||
class AudioManager:
|
||||
"""
|
||||
The audio manager is responsible for managing multiple audio controllers and
|
||||
their access to audio resources.
|
||||
|
||||
It main purpose is to act as a proxy/facade between the high-level audio
|
||||
plugin and the audio functionalities (allocating streams, managing the state
|
||||
of the player and recorder processes, etc.).
|
||||
"""
|
||||
|
||||
_default_signal_timeout = 2
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
should_stop: Event,
|
||||
input_blocksize: int,
|
||||
output_blocksize: int,
|
||||
input_device: Optional[DeviceType] = None,
|
||||
output_device: Optional[DeviceType] = None,
|
||||
queue_size: Optional[int] = None,
|
||||
):
|
||||
"""
|
||||
:param should_stop: Event to synchronize the audio manager stop.
|
||||
:param input_blocksize: Block size for the input stream.
|
||||
:param output_blocksize: Block size for the output stream.
|
||||
:param input_device: Default device to use for the input stream.
|
||||
:param output_device: Default device to use for the output stream.
|
||||
:param queue_size: Maximum size of the audio queues.
|
||||
"""
|
||||
self._should_stop = should_stop
|
||||
self._device_manager = DeviceManager(
|
||||
input_device=input_device, output_device=output_device
|
||||
)
|
||||
|
||||
self._stream_manager = StreamManager(device_manager=self._device_manager)
|
||||
self.logger = getLogger(__name__)
|
||||
self.input_blocksize = input_blocksize
|
||||
self.output_blocksize = output_blocksize
|
||||
self.queue_size = queue_size
|
||||
|
||||
def create_player(
|
||||
self,
|
||||
device: DeviceType,
|
||||
channels: int,
|
||||
volume: float,
|
||||
infile: Optional[str] = None,
|
||||
sound: Optional[Union[dict, Iterable[dict]]] = None,
|
||||
duration: Optional[float] = None,
|
||||
sample_rate: Optional[int] = None,
|
||||
dtype: str = 'int16',
|
||||
blocksize: Optional[int] = None,
|
||||
latency: Union[float, str] = 'high',
|
||||
stream_name: Optional[str] = None,
|
||||
) -> AudioPlayer:
|
||||
"""
|
||||
Create an audio player thread.
|
||||
|
||||
:param device: Audio device to use.
|
||||
:param channels: Number of output channels.
|
||||
:param volume: Output volume, between 0 and 100.
|
||||
:param infile: File or URL to play.
|
||||
:param sound: Alternatively to a file/URL, you can play synthetic
|
||||
sounds.
|
||||
:param duration: Duration of the stream in seconds.
|
||||
:param sample_rate: Sample rate of the stream.
|
||||
:param dtype: Data type of the stream.
|
||||
:param blocksize: Block size of the stream.
|
||||
:param latency: Latency of the stream.
|
||||
:param stream_name: Name of the stream.
|
||||
"""
|
||||
dev = self._device_manager.get_device(device, type=StreamType.OUTPUT)
|
||||
player = AudioPlayer.build(
|
||||
device=device,
|
||||
infile=infile,
|
||||
sound=sound,
|
||||
duration=duration,
|
||||
volume=volume,
|
||||
sample_rate=sample_rate or dev.default_samplerate,
|
||||
dtype=dtype,
|
||||
blocksize=blocksize or self.output_blocksize,
|
||||
latency=latency,
|
||||
channels=channels,
|
||||
queue_size=self.queue_size,
|
||||
should_stop=self._should_stop,
|
||||
)
|
||||
|
||||
self._stream_manager.register(
|
||||
player, dev, StreamType.OUTPUT, stream_name=stream_name
|
||||
)
|
||||
return player
|
||||
|
||||
def create_recorder(
|
||||
self,
|
||||
device: DeviceType,
|
||||
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,
|
||||
) -> AudioRecorder:
|
||||
"""
|
||||
Create an audio recorder thread.
|
||||
|
||||
:param device: Audio device to use.
|
||||
:param output_device: Output device to use.
|
||||
:param fifo: Path to an output FIFO file to use to synchronize the audio
|
||||
to other processes.
|
||||
:param outfile: Optional output file for the recorded audio.
|
||||
:param duration: Duration of the recording in seconds.
|
||||
:param sample_rate: Sample rate of the stream.
|
||||
:param dtype: Data type of the stream.
|
||||
:param blocksize: Block size of the stream.
|
||||
:param latency: Latency of the stream.
|
||||
:param channels: Number of output channels.
|
||||
:param volume: Input volume, between 0 and 100.
|
||||
:param redis_queue: Name of the Redis queue to use.
|
||||
:param format: Format of the recorded audio.
|
||||
:param stream: Whether to stream the recorded audio.
|
||||
:param play_audio: Whether to play the recorded audio in real-time.
|
||||
:param stream_name: Name of the stream.
|
||||
"""
|
||||
blocksize = blocksize or self.input_blocksize
|
||||
dev = self._device_manager.get_device(device, type=StreamType.OUTPUT)
|
||||
|
||||
if fifo:
|
||||
fifo = os.path.expanduser(fifo)
|
||||
if os.path.exists(fifo) and stat.S_ISFIFO(os.stat(fifo).st_mode):
|
||||
self.logger.info('Removing previous input stream FIFO %s', fifo)
|
||||
os.unlink(fifo)
|
||||
|
||||
os.mkfifo(fifo, 0o644)
|
||||
outfile = fifo
|
||||
elif outfile:
|
||||
outfile = os.path.expanduser(outfile)
|
||||
|
||||
outfile = outfile or fifo or os.devnull
|
||||
recorder = AudioRecorder(
|
||||
device=(
|
||||
(
|
||||
dev.index,
|
||||
self._device_manager.get_device(
|
||||
type=StreamType.OUTPUT, device=output_device
|
||||
).index,
|
||||
)
|
||||
if play_audio
|
||||
else dev.index
|
||||
),
|
||||
outfile=outfile,
|
||||
duration=duration,
|
||||
sample_rate=sample_rate or dev.default_samplerate,
|
||||
dtype=dtype,
|
||||
blocksize=blocksize,
|
||||
latency=latency,
|
||||
output_format=format,
|
||||
channels=channels,
|
||||
volume=volume,
|
||||
redis_queue=redis_queue,
|
||||
stream=stream,
|
||||
audio_pass_through=play_audio,
|
||||
queue_size=self.queue_size,
|
||||
should_stop=self._should_stop,
|
||||
)
|
||||
|
||||
self._stream_manager.register(
|
||||
recorder, dev, StreamType.INPUT, stream_name=stream_name
|
||||
)
|
||||
return recorder
|
||||
|
||||
def get_device(
|
||||
self,
|
||||
device: Optional[DeviceType] = None,
|
||||
type: Optional[StreamType] = None, # pylint: disable=redefined-builtin
|
||||
) -> AudioDevice:
|
||||
"""
|
||||
Proxy to ``self._device_manager.get_device``.
|
||||
"""
|
||||
return self._device_manager.get_device(device=device, type=type)
|
||||
|
||||
def get_devices(
|
||||
self,
|
||||
type: Optional[StreamType] = None, # pylint: disable=redefined-builtin
|
||||
) -> List[AudioDevice]:
|
||||
"""
|
||||
Proxy to ``self._device_manager.get_devices``.
|
||||
"""
|
||||
return self._device_manager.get_devices(type=type)
|
||||
|
||||
def get_streams(
|
||||
self,
|
||||
device: Optional[DeviceType] = None,
|
||||
type: Optional[StreamType] = None, # pylint: disable=redefined-builtin
|
||||
streams: Optional[Iterable[Union[str, int]]] = None,
|
||||
) -> List[AudioThread]:
|
||||
"""
|
||||
Proxy to ``self._stream_manager.get``.
|
||||
"""
|
||||
return self._stream_manager.get(device=device, type=type, streams=streams)
|
||||
|
||||
def stop_audio(
|
||||
self,
|
||||
device: Optional[DeviceType] = None,
|
||||
type: Optional[StreamType] = None, # pylint: disable=redefined-builtin
|
||||
streams: Optional[Iterable[Union[str, int]]] = None,
|
||||
timeout: Optional[float] = 2,
|
||||
):
|
||||
"""
|
||||
Stops audio sessions.
|
||||
|
||||
:param device: Filter by host audio device.
|
||||
:param type: Filter by stream type (input or output).
|
||||
:param streams: Filter by stream indices/names.
|
||||
:param timeout: Wait timeout in seconds.
|
||||
"""
|
||||
streams_to_stop = self._stream_manager.get(device, type, streams=streams)
|
||||
|
||||
# Send the stop signals
|
||||
for audio_thread in streams_to_stop:
|
||||
audio_thread.notify_stop()
|
||||
|
||||
# Wait for termination (with timeout)
|
||||
wait_start = time()
|
||||
for audio_thread in streams_to_stop:
|
||||
audio_thread.join(
|
||||
timeout=max(0, timeout - (time() - wait_start))
|
||||
if timeout is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Remove references
|
||||
for audio_thread in streams_to_stop:
|
||||
self._stream_manager.unregister(audio_thread)
|
||||
|
||||
def pause_audio(
|
||||
self,
|
||||
device: Optional[DeviceType] = None,
|
||||
type: Optional[StreamType] = None, # pylint: disable=redefined-builtin
|
||||
streams: Optional[Iterable[Union[str, int]]] = None,
|
||||
):
|
||||
"""
|
||||
Pauses/resumes audio sessions.
|
||||
|
||||
:param device: Filter by host audio device.
|
||||
:param type: Filter by stream type (input or output).
|
||||
:param streams: Filter by stream indices/names.
|
||||
"""
|
||||
streams_to_pause = self._stream_manager.get(device, type, streams=streams)
|
||||
|
||||
# Send the pause toggle signals
|
||||
for audio_thread in streams_to_pause:
|
||||
audio_thread.notify_pause()
|
||||
|
||||
def set_volume(
|
||||
self,
|
||||
volume: float,
|
||||
device: Optional[DeviceType] = None,
|
||||
streams: Optional[Iterable[Union[str, int]]] = None,
|
||||
):
|
||||
"""
|
||||
: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).
|
||||
"""
|
||||
stream_objs = self._stream_manager.get(device=device, streams=streams)
|
||||
|
||||
for stream in stream_objs:
|
||||
stream.volume = volume
|
207
platypush/plugins/sound/_manager/_stream.py
Normal file
207
platypush/plugins/sound/_manager/_stream.py
Normal file
|
@ -0,0 +1,207 @@
|
|||
from collections import defaultdict
|
||||
from logging import getLogger
|
||||
from threading import RLock
|
||||
from typing import Dict, Iterable, List, Optional, Union
|
||||
|
||||
from .._model import AudioDevice, DeviceType, StreamType
|
||||
from .._streams import AudioThread
|
||||
from ._device import DeviceManager
|
||||
|
||||
|
||||
class StreamManager:
|
||||
"""
|
||||
The audio manager is responsible for storing the current state of the
|
||||
playing/recording audio streams and allowing fast flexible lookups (by
|
||||
stream index, name, type, device, and any combination of those).
|
||||
"""
|
||||
|
||||
def __init__(self, device_manager: DeviceManager):
|
||||
"""
|
||||
:param device_manager: Reference to the device manager.
|
||||
"""
|
||||
self._next_stream_index = 1
|
||||
self._device_manager = device_manager
|
||||
self._state_lock = RLock()
|
||||
self._stream_index_by_name: Dict[str, int] = {}
|
||||
self._stream_name_by_index: Dict[int, str] = {}
|
||||
self._stream_index_to_device: Dict[int, AudioDevice] = {}
|
||||
self._stream_index_to_type: Dict[int, StreamType] = {}
|
||||
self.logger = getLogger(__name__)
|
||||
|
||||
self._streams: Dict[
|
||||
int, Dict[StreamType, Dict[int, AudioThread]]
|
||||
] = defaultdict(lambda: {stream_type: {} for stream_type in StreamType})
|
||||
""" {device_index: {stream_type: {stream_index: audio_thread}}} """
|
||||
|
||||
self._streams_by_index: Dict[StreamType, Dict[int, AudioThread]] = {
|
||||
stream_type: {} for stream_type in StreamType
|
||||
}
|
||||
""" {stream_type: {stream_index: [audio_threads]}} """
|
||||
|
||||
self._stream_locks: Dict[int, Dict[StreamType, RLock]] = defaultdict(
|
||||
lambda: {stream_type: RLock() for stream_type in StreamType}
|
||||
)
|
||||
""" {device_index: {stream_type: RLock}} """
|
||||
|
||||
@classmethod
|
||||
def _generate_stream_name(
|
||||
cls,
|
||||
type: StreamType, # pylint: disable=redefined-builtin
|
||||
stream_index: int,
|
||||
) -> str:
|
||||
return f'platypush:audio:{type.value}:{stream_index}'
|
||||
|
||||
def _gen_next_stream_index(
|
||||
self,
|
||||
type: StreamType, # pylint: disable=redefined-builtin
|
||||
stream_name: Optional[str] = None,
|
||||
) -> int:
|
||||
"""
|
||||
:param type: The type of the stream to allocate (input or output).
|
||||
:param stream_name: The name of the stream to allocate.
|
||||
:return: The index of the new stream.
|
||||
"""
|
||||
with self._state_lock:
|
||||
stream_index = self._next_stream_index
|
||||
|
||||
if not stream_name:
|
||||
stream_name = self._generate_stream_name(type, stream_index)
|
||||
|
||||
self._stream_name_by_index[stream_index] = stream_name
|
||||
self._stream_index_by_name[stream_name] = stream_index
|
||||
self._next_stream_index += 1
|
||||
|
||||
return stream_index
|
||||
|
||||
def register(
|
||||
self,
|
||||
audio_thread: AudioThread,
|
||||
device: AudioDevice,
|
||||
type: StreamType, # pylint: disable=redefined-builtin
|
||||
stream_name: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Registers an audio stream to a device.
|
||||
|
||||
:param audio_thread: Stream to register.
|
||||
:param device: Device to register the stream to.
|
||||
:param type: The type of the stream to allocate (input or output).
|
||||
:param stream_name: The name of the stream to allocate.
|
||||
"""
|
||||
with self._state_lock:
|
||||
stream_index = audio_thread.stream_index
|
||||
if stream_index is None:
|
||||
stream_index = audio_thread.stream_index = self._gen_next_stream_index(
|
||||
type, stream_name=stream_name
|
||||
)
|
||||
|
||||
self._streams[device.index][type][stream_index] = audio_thread
|
||||
self._stream_index_to_device[stream_index] = device
|
||||
self._stream_index_to_type[stream_index] = type
|
||||
self._streams_by_index[type][stream_index] = audio_thread
|
||||
|
||||
def unregister(
|
||||
self,
|
||||
audio_thread: AudioThread,
|
||||
device: Optional[AudioDevice] = None,
|
||||
type: Optional[StreamType] = None, # pylint: disable=redefined-builtin
|
||||
):
|
||||
"""
|
||||
Unregisters an audio stream from a device.
|
||||
|
||||
:param audio_thread: Stream to unregister.
|
||||
:param device: Device to unregister the stream from.
|
||||
:param type: The type of the stream to unregister (input or output).
|
||||
"""
|
||||
with self._state_lock:
|
||||
stream_index = audio_thread.stream_index
|
||||
if stream_index is None:
|
||||
return
|
||||
|
||||
if device is None:
|
||||
device = self._stream_index_to_device.get(stream_index)
|
||||
|
||||
if not type:
|
||||
type = self._stream_index_to_type.get(stream_index)
|
||||
|
||||
if device is None or type is None:
|
||||
return
|
||||
|
||||
self._streams[device.index][type].pop(stream_index, None)
|
||||
self._stream_index_to_device.pop(stream_index, None)
|
||||
self._stream_index_to_type.pop(stream_index, None)
|
||||
self._streams_by_index[type].pop(stream_index, None)
|
||||
stream_name = self._stream_name_by_index.pop(stream_index, None)
|
||||
if stream_name:
|
||||
self._stream_index_by_name.pop(stream_name, None)
|
||||
|
||||
def _get_by_device_and_type(
|
||||
self,
|
||||
device: Optional[DeviceType] = None,
|
||||
type: Optional[StreamType] = None, # pylint: disable=redefined-builtin
|
||||
) -> List[AudioThread]:
|
||||
"""
|
||||
Filter streams by device and/or type.
|
||||
"""
|
||||
devs = (
|
||||
[self._device_manager.get_device(device, type)]
|
||||
if device is not None
|
||||
else self._device_manager.get_devices(type)
|
||||
)
|
||||
|
||||
return [
|
||||
audio_thread
|
||||
for dev in devs
|
||||
for stream_info in (
|
||||
[self._streams[dev.index].get(type, {})]
|
||||
if type
|
||||
else list(self._streams[dev.index].values())
|
||||
)
|
||||
for audio_thread in stream_info.values()
|
||||
if audio_thread and audio_thread.is_alive()
|
||||
]
|
||||
|
||||
def _get_by_stream_index_or_name(
|
||||
self, streams: Iterable[Union[str, int]]
|
||||
) -> List[AudioThread]:
|
||||
"""
|
||||
Filter streams by index or name.
|
||||
"""
|
||||
threads = []
|
||||
|
||||
for stream in streams:
|
||||
try:
|
||||
stream_index = int(stream)
|
||||
except (TypeError, ValueError):
|
||||
stream_index = self._stream_index_by_name.get(stream) # type: ignore
|
||||
if stream_index is None:
|
||||
self.logger.warning('No such audio stream: %s', stream)
|
||||
continue
|
||||
|
||||
stream_type = self._stream_index_to_type.get(stream_index)
|
||||
if not stream_type:
|
||||
self.logger.warning(
|
||||
'No type available for this audio stream: %s', stream
|
||||
)
|
||||
continue
|
||||
|
||||
thread = self._streams_by_index.get(stream_type, {}).get(stream_index)
|
||||
if thread:
|
||||
threads.append(thread)
|
||||
|
||||
return threads
|
||||
|
||||
def get(
|
||||
self,
|
||||
device: Optional[DeviceType] = None,
|
||||
type: Optional[StreamType] = None, # pylint: disable=redefined-builtin
|
||||
streams: Optional[Iterable[Union[str, int]]] = None,
|
||||
) -> List[AudioThread]:
|
||||
"""
|
||||
Searches streams, either by device and/or type, or by stream index/name.
|
||||
"""
|
||||
return (
|
||||
self._get_by_stream_index_or_name(streams)
|
||||
if streams
|
||||
else self._get_by_device_and_type(device, type)
|
||||
)
|
42
platypush/plugins/sound/_model.py
Normal file
42
platypush/plugins/sound/_model.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Union
|
||||
|
||||
DeviceType = Union[int, str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioDevice:
|
||||
"""
|
||||
Maps the properties of an audio device.
|
||||
"""
|
||||
|
||||
index: int
|
||||
name: str
|
||||
hostapi: int
|
||||
max_input_channels: int
|
||||
max_output_channels: int
|
||||
default_samplerate: int
|
||||
default_low_input_latency: float = 0
|
||||
default_low_output_latency: float = 0
|
||||
default_high_input_latency: float = 0
|
||||
default_high_output_latency: float = 0
|
||||
|
||||
|
||||
class AudioState(Enum):
|
||||
"""
|
||||
Audio states.
|
||||
"""
|
||||
|
||||
STOPPED = 'STOPPED'
|
||||
RUNNING = 'RUNNING'
|
||||
PAUSED = 'PAUSED'
|
||||
|
||||
|
||||
class StreamType(Enum):
|
||||
"""
|
||||
Stream types.
|
||||
"""
|
||||
|
||||
INPUT = 'input'
|
||||
OUTPUT = 'output'
|
6
platypush/plugins/sound/_streams/__init__.py
Normal file
6
platypush/plugins/sound/_streams/__init__.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from ._base import AudioThread
|
||||
from ._player import AudioPlayer
|
||||
from ._recorder import AudioRecorder
|
||||
|
||||
|
||||
__all__ = ['AudioPlayer', 'AudioRecorder', 'AudioThread']
|
502
platypush/plugins/sound/_streams/_base.py
Normal file
502
platypush/plugins/sound/_streams/_base.py
Normal file
|
@ -0,0 +1,502 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from logging import getLogger
|
||||
import os
|
||||
import queue
|
||||
from threading import Event, RLock, Thread
|
||||
import time
|
||||
from typing import IO, Callable, Final, Generator, Optional, Tuple, Type, Union
|
||||
from typing_extensions import override
|
||||
|
||||
import sounddevice as sd
|
||||
|
||||
from platypush.context import get_bus
|
||||
from platypush.message.event.sound import SoundEvent
|
||||
from platypush.utils import get_redis
|
||||
|
||||
from .._converters import AudioConverter
|
||||
from .._model import AudioState, StreamType
|
||||
|
||||
_StreamType = Union[sd.Stream, sd.OutputStream]
|
||||
|
||||
|
||||
class AudioThread(Thread, ABC):
|
||||
"""
|
||||
Base class for audio play/record stream threads.
|
||||
"""
|
||||
|
||||
_DEFAULT_FILE: Final[str] = os.devnull
|
||||
"""Unless otherwise specified, the audio streams will be sent to /dev/null"""
|
||||
_DEFAULT_CONVERTER_TIMEOUT: Final[float] = 1
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: Union[str, Tuple[str, str]],
|
||||
channels: int,
|
||||
volume: float,
|
||||
sample_rate: int,
|
||||
dtype: str,
|
||||
blocksize: int,
|
||||
ffmpeg_bin: str = 'ffmpeg',
|
||||
stream: bool = False,
|
||||
audio_pass_through: bool = False,
|
||||
infile: Optional[str] = None,
|
||||
outfile: Optional[str] = None,
|
||||
duration: Optional[float] = None,
|
||||
latency: Union[float, str] = 'high',
|
||||
redis_queue: Optional[str] = None,
|
||||
should_stop: Optional[Event] = None,
|
||||
converter_timeout: Optional[float] = None,
|
||||
stream_name: Optional[str] = None,
|
||||
queue_size: Optional[int] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
:param device: Audio device to use.
|
||||
:param channels: Number of channels to use.
|
||||
:param volume: Input/output volume, between 0 and 100.
|
||||
:param sample_rate: Sample rate to use.
|
||||
:param dtype: Data type to use.
|
||||
:param blocksize: Block size to use.
|
||||
:param ffmpeg_bin: Path to the ffmpeg binary.
|
||||
:param stream: Whether to stream the audio to Redis consumers.
|
||||
:param audio_pass_through: Whether to pass the audio through to the
|
||||
application's output stream.
|
||||
:param infile: Path to the input file or URL, if this is an output
|
||||
stream.
|
||||
:param outfile: Path to the output file.
|
||||
:param duration: Duration of the audio stream.
|
||||
:param latency: Latency to use.
|
||||
:param redis_queue: Redis queue to use.
|
||||
:param should_stop: Synchronize with upstream stop events.
|
||||
:param converter_timeout: How long to wait for the converter to finish.
|
||||
:param stream_name: Name of the stream.
|
||||
:param queue_size: Maximum size of the audio queue.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.device = device
|
||||
self.outfile = os.path.expanduser(outfile or self._DEFAULT_FILE)
|
||||
self.infile = os.path.expanduser(infile or self._DEFAULT_FILE)
|
||||
self.ffmpeg_bin = ffmpeg_bin
|
||||
self.channels = channels
|
||||
self.volume = volume
|
||||
self.sample_rate = sample_rate
|
||||
self.dtype = dtype
|
||||
self.stream = stream
|
||||
self.duration = duration
|
||||
self.blocksize = blocksize * channels
|
||||
self.latency = latency
|
||||
self._redis_queue = redis_queue
|
||||
self.audio_pass_through = audio_pass_through
|
||||
self.queue_size = queue_size
|
||||
self._stream_name = stream_name
|
||||
self.logger = getLogger(__name__)
|
||||
|
||||
self._state = AudioState.STOPPED
|
||||
self._state_lock = RLock()
|
||||
self._started_time: Optional[float] = None
|
||||
self._converter: Optional[AudioConverter] = None
|
||||
self._should_stop = should_stop or Event()
|
||||
self._converter_timeout = converter_timeout or self._DEFAULT_CONVERTER_TIMEOUT
|
||||
self.audio_stream: Optional[_StreamType] = None
|
||||
self.stream_index: Optional[int] = None
|
||||
self.paused_changed = Event()
|
||||
self._converter_terminated = Event()
|
||||
|
||||
@property
|
||||
def should_stop(self) -> bool:
|
||||
"""
|
||||
Proxy for `._should_stop.is_set()`.
|
||||
"""
|
||||
return self._should_stop.is_set() or bool(
|
||||
self.state == AudioState.STOPPED and self._started_time
|
||||
)
|
||||
|
||||
@property
|
||||
def gain(self) -> float:
|
||||
return self.volume / 100
|
||||
|
||||
def wait_stop(self, timeout: Optional[float] = None):
|
||||
"""
|
||||
Wait for the stop signal to be received.
|
||||
"""
|
||||
return self._should_stop.wait(timeout=timeout)
|
||||
|
||||
def _audio_callback(self) -> Callable:
|
||||
"""
|
||||
Returns a callback to handle the raw frames captures from the audio device.
|
||||
"""
|
||||
|
||||
def empty_callback(*_, **__):
|
||||
pass
|
||||
|
||||
return empty_callback
|
||||
|
||||
@property
|
||||
def stream_name(self) -> str:
|
||||
if self._stream_name:
|
||||
return self._stream_name
|
||||
|
||||
ret = f'platypush:audio:{self.direction.value}'
|
||||
if self.stream_index is not None:
|
||||
ret += f':{self.stream_index}'
|
||||
return ret
|
||||
|
||||
@stream_name.setter
|
||||
def stream_name(self, value: Optional[str]):
|
||||
self._stream_name = value
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def direction(self) -> StreamType:
|
||||
"""
|
||||
The default direction for this stream - input or output.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _audio_converter_type(self) -> Optional[Type[AudioConverter]]:
|
||||
"""
|
||||
This property indicates the type that should be used for the audio
|
||||
converter.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _started_event_type(self) -> Type[SoundEvent]:
|
||||
"""
|
||||
Event type that will be emitted when the audio starts.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _stopped_event_type(self) -> Type[SoundEvent]:
|
||||
"""
|
||||
Event type that will be emitted when the audio stops.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _paused_event_type(self) -> Type[SoundEvent]:
|
||||
"""
|
||||
Event type that will be emitted when the audio is paused.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _resumed_event_type(self) -> Type[SoundEvent]:
|
||||
"""
|
||||
Event type that will be emitted when the audio is resumed.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def _stream_type(self) -> Union[Type[sd.Stream], Type[sd.OutputStream]]:
|
||||
"""
|
||||
The type of stream this thread is mapped to.
|
||||
"""
|
||||
return sd.Stream
|
||||
|
||||
@property
|
||||
def _converter_args(self) -> dict:
|
||||
"""
|
||||
Extra arguments to pass to the audio converter.
|
||||
"""
|
||||
return {}
|
||||
|
||||
@property
|
||||
def _stream_args(self) -> dict:
|
||||
"""
|
||||
Extra arguments to pass to the stream constructor.
|
||||
"""
|
||||
return {}
|
||||
|
||||
@property
|
||||
def redis_queue(self) -> str:
|
||||
"""
|
||||
Redis queue for audio streaming.
|
||||
"""
|
||||
if self._redis_queue:
|
||||
return self._redis_queue
|
||||
|
||||
dev = (
|
||||
self.device
|
||||
if isinstance(self.device, (str, int))
|
||||
else '-'.join(map(str, self.device))
|
||||
)
|
||||
|
||||
name = f'platypush-audio-stream-{self.__class__.__name__}-{dev}'
|
||||
if self.stream_index is not None:
|
||||
name = f'{name}-{self.stream_index}'
|
||||
|
||||
return name
|
||||
|
||||
def _on_audio_converted(self, data: bytes, out_f: Optional[IO] = None):
|
||||
"""
|
||||
This callback will be called when the audio data has been converted.
|
||||
"""
|
||||
if out_f:
|
||||
out_f.write(data)
|
||||
|
||||
if self.stream:
|
||||
get_redis().publish(self.redis_queue, data)
|
||||
|
||||
def _wait_running(self):
|
||||
"""
|
||||
If the stream is in paused state, wait for the state to change.
|
||||
"""
|
||||
while self.state == AudioState.PAUSED:
|
||||
self.paused_changed.wait()
|
||||
|
||||
def main(
|
||||
self,
|
||||
converter: Optional[AudioConverter] = None,
|
||||
out_f: Optional[IO] = None,
|
||||
):
|
||||
"""
|
||||
Main loop.
|
||||
"""
|
||||
self.notify_start()
|
||||
|
||||
self.logger.info(
|
||||
'Started %s on device [%s]', self.__class__.__name__, self.device
|
||||
)
|
||||
self._started_time = time.time()
|
||||
|
||||
while not self.should_stop and (
|
||||
self.duration is None or time.time() - self._started_time < self.duration
|
||||
):
|
||||
self._wait_running()
|
||||
if not converter:
|
||||
self.wait_stop(0.1)
|
||||
continue
|
||||
|
||||
if self.should_stop:
|
||||
break
|
||||
|
||||
timeout = (
|
||||
max(
|
||||
0,
|
||||
min(
|
||||
self.duration - (time.time() - self._started_time),
|
||||
self._converter_timeout,
|
||||
),
|
||||
)
|
||||
if self.duration is not None
|
||||
else self._converter_timeout
|
||||
)
|
||||
|
||||
should_continue = self._process_converted_audio(
|
||||
converter, timeout=timeout, out_f=out_f
|
||||
)
|
||||
|
||||
if not should_continue:
|
||||
break
|
||||
|
||||
def _process_converted_audio(
|
||||
self, converter: AudioConverter, timeout: float, out_f: Optional[IO]
|
||||
) -> bool:
|
||||
"""
|
||||
It reads the converted audio from the converter and passes it downstream.
|
||||
|
||||
:return: True if the process should continue, False if it should terminate.
|
||||
"""
|
||||
data = converter.read(timeout=timeout)
|
||||
if not data:
|
||||
return self._on_converter_timeout(converter)
|
||||
|
||||
self._on_audio_converted(data, out_f)
|
||||
return True
|
||||
|
||||
def _on_converter_timeout(self, converter: AudioConverter) -> bool:
|
||||
"""
|
||||
Callback logic invoked if the converter times out.
|
||||
|
||||
:return: ``True`` (default) if the thread is supposed to continue,
|
||||
``False`` if it should terminate.
|
||||
"""
|
||||
self.logger.debug('Timeout on converter %s', converter.__class__.__name__)
|
||||
# Continue only if the converter hasn't terminated
|
||||
return self._converter_terminated.is_set()
|
||||
|
||||
@override
|
||||
def run(self):
|
||||
"""
|
||||
Wrapper for the main loop that initializes the converter and the stream.
|
||||
"""
|
||||
super().run()
|
||||
self.paused_changed.clear()
|
||||
|
||||
try:
|
||||
with self.open_converter() as converter, self._stream_type(
|
||||
samplerate=self.sample_rate,
|
||||
device=self.device,
|
||||
channels=self.channels,
|
||||
dtype=self.dtype,
|
||||
latency=self.latency,
|
||||
blocksize=self.blocksize,
|
||||
**self._stream_args,
|
||||
) as self.audio_stream, open(
|
||||
self.outfile, 'wb'
|
||||
) as out_f, self._audio_generator():
|
||||
self.main(converter=converter, out_f=out_f)
|
||||
except queue.Empty:
|
||||
self.logger.warning(
|
||||
'Audio callback timeout for %s', self.__class__.__name__
|
||||
)
|
||||
finally:
|
||||
self.notify_stop()
|
||||
|
||||
@contextmanager
|
||||
def _audio_generator(self) -> Generator[Optional[Thread], None, None]:
|
||||
"""
|
||||
:yield: A <Thread, Queue> pair where the thread generates raw audio
|
||||
frames (as numpy arrays) that are sent to the specified queue.
|
||||
"""
|
||||
yield None
|
||||
|
||||
@contextmanager
|
||||
def open_converter(self) -> Generator[Optional[AudioConverter], None, None]:
|
||||
"""
|
||||
Context manager for the converter process.
|
||||
"""
|
||||
if self._audio_converter_type is None:
|
||||
yield None
|
||||
return
|
||||
|
||||
assert not self._converter, 'A converter process is already running'
|
||||
self._converter = self._audio_converter_type(
|
||||
ffmpeg_bin=self.ffmpeg_bin,
|
||||
sample_rate=self.sample_rate,
|
||||
channels=self.channels,
|
||||
volume=self.volume,
|
||||
dtype=self.dtype,
|
||||
chunk_size=self.blocksize,
|
||||
on_exit=self._converter_terminated.set,
|
||||
**self._converter_args,
|
||||
)
|
||||
|
||||
self._converter.start()
|
||||
yield self._converter
|
||||
|
||||
self._converter.stop()
|
||||
self._converter.join(timeout=2)
|
||||
self._converter = None
|
||||
|
||||
@contextmanager
|
||||
def _change_state(self, state: AudioState, event_type: Type[SoundEvent]):
|
||||
"""
|
||||
Changes the state and it emits the specified event if the state has
|
||||
actually changed.
|
||||
|
||||
It uses a context manager pattern, and everything in between will be
|
||||
executed before the events are dispatched.
|
||||
"""
|
||||
with self._state_lock:
|
||||
prev_state = self.state
|
||||
self.state = state
|
||||
|
||||
yield
|
||||
if prev_state != state:
|
||||
self._notify(event_type)
|
||||
|
||||
def _notify(self, event_type: Type[SoundEvent], **kwargs):
|
||||
"""
|
||||
Notifies the specified event.
|
||||
"""
|
||||
get_bus().post(event_type(device=self.device, **kwargs))
|
||||
|
||||
def notify_start(self):
|
||||
"""
|
||||
Notifies the start event.
|
||||
"""
|
||||
with self._change_state(AudioState.RUNNING, self._started_event_type):
|
||||
pass
|
||||
|
||||
def notify_stop(self):
|
||||
"""
|
||||
Notifies the stop event.
|
||||
"""
|
||||
with self._change_state(AudioState.STOPPED, self._stopped_event_type):
|
||||
if self._converter:
|
||||
self._converter.stop()
|
||||
self.paused_changed.set()
|
||||
self.paused_changed.clear()
|
||||
|
||||
def notify_pause(self):
|
||||
"""
|
||||
Notifies a pause toggle event.
|
||||
"""
|
||||
states = {
|
||||
AudioState.PAUSED: AudioState.RUNNING,
|
||||
AudioState.RUNNING: AudioState.PAUSED,
|
||||
}
|
||||
|
||||
with self._state_lock:
|
||||
new_state = states.get(self.state)
|
||||
if not new_state:
|
||||
return
|
||||
|
||||
event_type = (
|
||||
self._paused_event_type
|
||||
if new_state == AudioState.PAUSED
|
||||
else self._resumed_event_type
|
||||
)
|
||||
|
||||
with self._change_state(new_state, event_type):
|
||||
self.paused_changed.set()
|
||||
self.paused_changed.clear()
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""
|
||||
Thread-safe wrapper for the stream state.
|
||||
"""
|
||||
with self._state_lock:
|
||||
return self._state
|
||||
|
||||
@state.setter
|
||||
def state(self, value: AudioState):
|
||||
"""
|
||||
Thread-safe setter for the stream state.
|
||||
"""
|
||||
with self._state_lock:
|
||||
self._state = value
|
||||
|
||||
def asdict(self) -> dict:
|
||||
"""
|
||||
Serialize the thread information.
|
||||
"""
|
||||
return {
|
||||
'device': self.device,
|
||||
'outfile': self.outfile,
|
||||
'infile': self.infile,
|
||||
'direction': self.direction,
|
||||
'ffmpeg_bin': self.ffmpeg_bin,
|
||||
'channels': self.channels,
|
||||
'sample_rate': self.sample_rate,
|
||||
'dtype': self.dtype,
|
||||
'streaming': self.stream,
|
||||
'duration': self.duration,
|
||||
'blocksize': self.blocksize,
|
||||
'latency': self.latency,
|
||||
'redis_queue': self.redis_queue,
|
||||
'audio_pass_through': self.audio_pass_through,
|
||||
'state': self._state.value,
|
||||
'volume': self.volume,
|
||||
'started_time': datetime.fromtimestamp(self._started_time)
|
||||
if self._started_time
|
||||
else None,
|
||||
'stream_index': self.stream_index,
|
||||
'stream_name': self.stream_name,
|
||||
}
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
3
platypush/plugins/sound/_streams/_player/__init__.py
Normal file
3
platypush/plugins/sound/_streams/_player/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from ._base import AudioPlayer
|
||||
|
||||
__all__ = ['AudioPlayer']
|
110
platypush/plugins/sound/_streams/_player/_base.py
Normal file
110
platypush/plugins/sound/_streams/_player/_base.py
Normal file
|
@ -0,0 +1,110 @@
|
|||
from abc import ABC
|
||||
from typing import IO, Iterable, List, Optional, Self, Type, Union
|
||||
from typing_extensions import override
|
||||
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
|
||||
from platypush.message.event.sound import (
|
||||
SoundPlaybackPausedEvent,
|
||||
SoundPlaybackResumedEvent,
|
||||
SoundPlaybackStartedEvent,
|
||||
SoundPlaybackStoppedEvent,
|
||||
)
|
||||
|
||||
from ..._converters import RawOutputAudioConverter
|
||||
from ..._model import StreamType
|
||||
from .._base import AudioThread
|
||||
|
||||
|
||||
class AudioPlayer(AudioThread, ABC):
|
||||
"""
|
||||
Base ``AudioPlayer`` class.
|
||||
|
||||
An ``AudioPlayer`` thread is responsible for playing audio (either from a
|
||||
file/URL or from a synthetic source) to an output device, writing it to the
|
||||
converter process and dispatching the converted audio to the registered
|
||||
consumers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, *args, sound: Optional[Union[dict, Iterable[dict]]] = None, **kwargs
|
||||
):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.sound = sound
|
||||
|
||||
@classmethod
|
||||
def build(
|
||||
cls,
|
||||
infile: Optional[str] = None,
|
||||
sound: Optional[Union[dict, Iterable[dict]]] = None,
|
||||
**kwargs,
|
||||
) -> Self:
|
||||
from ._resource import AudioResourcePlayer
|
||||
from ._synth import AudioSynthPlayer, Sound
|
||||
|
||||
if infile:
|
||||
return AudioResourcePlayer(infile=infile, **kwargs)
|
||||
if sound:
|
||||
sounds: List[dict] = ( # type: ignore
|
||||
[sound] if isinstance(sound, dict) else sound
|
||||
)
|
||||
|
||||
return AudioSynthPlayer(sounds=[Sound.build(**s) for s in sounds], **kwargs)
|
||||
|
||||
raise AssertionError('Either infile or url must be specified')
|
||||
|
||||
@property
|
||||
@override
|
||||
def direction(self) -> StreamType:
|
||||
return StreamType.OUTPUT
|
||||
|
||||
@override
|
||||
def _on_converter_timeout(self, *_, **__) -> bool:
|
||||
return False # break
|
||||
|
||||
@property
|
||||
@override
|
||||
def _stream_type(self) -> Type[sd.RawOutputStream]:
|
||||
return sd.RawOutputStream
|
||||
|
||||
@property
|
||||
@override
|
||||
def _audio_converter_type(self) -> Type[RawOutputAudioConverter]:
|
||||
return RawOutputAudioConverter
|
||||
|
||||
@override
|
||||
def _on_audio_converted(self, data: bytes, out_f: Optional[IO] = None):
|
||||
if self.audio_stream:
|
||||
self.audio_stream.write(
|
||||
np.asarray(
|
||||
self.gain
|
||||
* np.frombuffer(data, dtype=self.dtype).reshape(-1, self.channels),
|
||||
dtype=self.dtype,
|
||||
)
|
||||
)
|
||||
|
||||
super()._on_audio_converted(data, out_f)
|
||||
|
||||
@property
|
||||
@override
|
||||
def _started_event_type(self) -> Type[SoundPlaybackStartedEvent]:
|
||||
return SoundPlaybackStartedEvent
|
||||
|
||||
@property
|
||||
@override
|
||||
def _stopped_event_type(self) -> Type[SoundPlaybackStoppedEvent]:
|
||||
return SoundPlaybackStoppedEvent
|
||||
|
||||
@property
|
||||
@override
|
||||
def _paused_event_type(self) -> Type[SoundPlaybackPausedEvent]:
|
||||
return SoundPlaybackPausedEvent
|
||||
|
||||
@property
|
||||
@override
|
||||
def _resumed_event_type(self) -> Type[SoundPlaybackResumedEvent]:
|
||||
return SoundPlaybackResumedEvent
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
39
platypush/plugins/sound/_streams/_player/_resource.py
Normal file
39
platypush/plugins/sound/_streams/_player/_resource.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
from typing import Optional, Type
|
||||
from typing_extensions import override
|
||||
|
||||
from platypush.message.event.sound import SoundEvent
|
||||
|
||||
from ..._converters import RawOutputAudioFromFileConverter
|
||||
from ._base import AudioPlayer
|
||||
|
||||
|
||||
class AudioResourcePlayer(AudioPlayer):
|
||||
"""
|
||||
A ``AudioResourcePlayer`` thread is responsible for playing an audio
|
||||
resource - either a file or a URL.
|
||||
"""
|
||||
|
||||
@property
|
||||
@override
|
||||
def _audio_converter_type(self) -> Type[RawOutputAudioFromFileConverter]:
|
||||
return RawOutputAudioFromFileConverter
|
||||
|
||||
@property
|
||||
@override
|
||||
def _converter_args(self) -> dict:
|
||||
return {
|
||||
'infile': self.infile,
|
||||
**super()._converter_args,
|
||||
}
|
||||
|
||||
@property
|
||||
@override
|
||||
def _converter_stdin(self) -> Optional[int]:
|
||||
return None
|
||||
|
||||
@override
|
||||
def _notify(self, event_type: Type[SoundEvent], **kwargs):
|
||||
return super()._notify(event_type, resource=self.infile, **kwargs)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -0,0 +1,4 @@
|
|||
from ._player import AudioSynthPlayer
|
||||
from ._sound import Sound
|
||||
|
||||
__all__ = ['AudioSynthPlayer', 'Sound']
|
79
platypush/plugins/sound/_streams/_player/_synth/_base.py
Normal file
79
platypush/plugins/sound/_streams/_player/_synth/_base.py
Normal file
|
@ -0,0 +1,79 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from ._parser import SoundParser
|
||||
|
||||
|
||||
class SoundBase(SoundParser, ABC):
|
||||
"""
|
||||
Base class for synthetic sounds and mixes.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, volume: float = 100, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.volume = volume
|
||||
|
||||
@property
|
||||
def gain(self) -> float:
|
||||
return self.volume / 100
|
||||
|
||||
@gain.setter
|
||||
def gain(self, value: float):
|
||||
self.volume = value * 100
|
||||
|
||||
@abstractmethod
|
||||
def get_wave(
|
||||
self,
|
||||
sample_rate: float,
|
||||
t_start: float = 0,
|
||||
t_end: float = 0,
|
||||
**_,
|
||||
) -> NDArray[np.floating]:
|
||||
"""
|
||||
Get the wave binary data associated to this sound
|
||||
|
||||
:param t_start: Start offset for the wave in seconds. Default: 0
|
||||
:param t_end: End offset for the wave in seconds. Default: 0
|
||||
:param sample_rate: Audio sample rate. Default: 44100 Hz
|
||||
:returns: A ``numpy.ndarray[(t_end-t_start)*sample_rate, 1]``
|
||||
with the raw float values
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def fft(
|
||||
self,
|
||||
sample_rate: float,
|
||||
t_start: float = 0.0,
|
||||
t_end: float = 0.0,
|
||||
freq_range: Optional[Tuple[float, float]] = None,
|
||||
freq_buckets: Optional[int] = None,
|
||||
) -> NDArray[np.floating]:
|
||||
"""
|
||||
Get the real part of the Fourier transform associated to a time-bounded
|
||||
sample of this sound.
|
||||
|
||||
:param t_start: Start offset for the wave in seconds. Default: 0
|
||||
:param t_end: End offset for the wave in seconds. Default: 0
|
||||
:param sample_rate: Audio sample rate. Default: 44100 Hz
|
||||
:param freq_range: FFT frequency range. Default: ``(0, sample_rate/2)``
|
||||
(see`Nyquist-Shannon sampling theorem
|
||||
<https://en.wikipedia.org/wiki/Nyquist%E2%80%93Shannon_sampling_theorem>`_)
|
||||
:param freq_buckets: Number of buckets to subdivide the frequency range.
|
||||
Default: None
|
||||
:returns: A numpy.ndarray[freq_range,1] with the raw float values
|
||||
"""
|
||||
|
||||
if not freq_range:
|
||||
freq_range = (0, int(sample_rate / 2))
|
||||
|
||||
wave = self.get_wave(t_start=t_start, t_end=t_end, sample_rate=sample_rate)
|
||||
fft = np.fft.fft(wave.reshape(len(wave)))
|
||||
fft = fft.real[freq_range[0] : freq_range[1]]
|
||||
|
||||
if freq_buckets is not None:
|
||||
fft = np.histogram(fft, bins=freq_buckets)[0]
|
||||
|
||||
return fft
|
101
platypush/plugins/sound/_streams/_player/_synth/_generator.py
Normal file
101
platypush/plugins/sound/_streams/_player/_synth/_generator.py
Normal file
|
@ -0,0 +1,101 @@
|
|||
from logging import getLogger
|
||||
from queue import Full, Queue
|
||||
from threading import Thread
|
||||
from time import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from ._mix import Mix
|
||||
|
||||
|
||||
class AudioGenerator(Thread):
|
||||
"""
|
||||
The ``AudioGenerator`` class is a thread that generates synthetic raw audio
|
||||
waves and dispatches them to a queue that can be consumed by other players,
|
||||
streamers and converters.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
audio_queue: Queue[NDArray[np.number]],
|
||||
mix: Mix,
|
||||
blocksize: int,
|
||||
sample_rate: int,
|
||||
queue_timeout: Optional[float] = None,
|
||||
should_stop: Callable[[], bool] = lambda: False,
|
||||
wait_running: Callable[[], Any] = lambda: None,
|
||||
on_stop: Callable[[], Any] = lambda: None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._audio_queue = audio_queue
|
||||
self._t_start: float = 0
|
||||
self._blocksize: int = blocksize
|
||||
self._sample_rate: int = sample_rate
|
||||
self._blocktime = self._blocksize / self._sample_rate
|
||||
self._should_stop = should_stop
|
||||
self._queue_timeout = queue_timeout
|
||||
self._wait_running = wait_running
|
||||
self._on_stop = on_stop
|
||||
self.mix = mix
|
||||
self.logger = getLogger(__name__)
|
||||
|
||||
def _next_t(self, t: float) -> float:
|
||||
"""
|
||||
Calculates the next starting time for the wave function.
|
||||
"""
|
||||
return (
|
||||
min(t + self._blocktime, self._duration)
|
||||
if self._duration is not None
|
||||
else t + self._blocktime
|
||||
)
|
||||
|
||||
def should_stop(self) -> bool:
|
||||
"""
|
||||
Stops if the upstream dependencies have signalled to stop or if the
|
||||
duration is set and we have reached it.
|
||||
"""
|
||||
return self._should_stop() or (
|
||||
self._duration is not None and time() - self._t_start >= self._duration
|
||||
)
|
||||
|
||||
@property
|
||||
def _duration(self) -> Optional[float]:
|
||||
"""
|
||||
Proxy to the mix object's duration.
|
||||
"""
|
||||
return self.mix.duration()
|
||||
|
||||
def run(self):
|
||||
super().run()
|
||||
self._t_start = time()
|
||||
t = 0
|
||||
|
||||
while not self.should_stop():
|
||||
self._wait_running()
|
||||
if self.should_stop():
|
||||
break
|
||||
|
||||
next_t = self._next_t(t)
|
||||
|
||||
try:
|
||||
data = self.mix.get_wave(
|
||||
t_start=t, t_end=next_t, sample_rate=self._sample_rate
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning('Could not generate the audio wave: %s', e)
|
||||
break
|
||||
|
||||
try:
|
||||
self._audio_queue.put(data, timeout=self._queue_timeout)
|
||||
t = next_t
|
||||
except Full:
|
||||
self.logger.warning(
|
||||
'The processing queue is full: either the audio consumer is stuck, '
|
||||
'or you may want to increase queue_size'
|
||||
)
|
||||
|
||||
self._on_stop()
|
115
platypush/plugins/sound/_streams/_player/_synth/_mix.py
Normal file
115
platypush/plugins/sound/_streams/_player/_synth/_mix.py
Normal file
|
@ -0,0 +1,115 @@
|
|||
import json
|
||||
import logging
|
||||
from typing import List, Tuple, Union
|
||||
from typing_extensions import override
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import DTypeLike, NDArray
|
||||
|
||||
from ...._utils import convert_nd_array
|
||||
from ._base import SoundBase
|
||||
from ._sound import Sound
|
||||
|
||||
|
||||
class Mix(SoundBase):
|
||||
"""
|
||||
This class models a set of mixed :class:`._sound.Sound` instances that can be played
|
||||
through an audio stream to an audio device
|
||||
"""
|
||||
|
||||
def __init__(self, *sounds, channels: int, dtype: DTypeLike, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._sounds: List[Sound] = []
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.channels = channels
|
||||
self.dtype = np.dtype(dtype)
|
||||
|
||||
for sound in sounds:
|
||||
self.add(sound)
|
||||
|
||||
def __iter__(self):
|
||||
"""
|
||||
Iterate over the object's attributes and return key-pair values.
|
||||
"""
|
||||
for sound in self._sounds:
|
||||
yield dict(sound)
|
||||
|
||||
def __str__(self):
|
||||
"""
|
||||
Return a JSON string representation of the object.
|
||||
"""
|
||||
return json.dumps(list(self))
|
||||
|
||||
def add(self, *sounds: Union[Sound, dict]):
|
||||
"""
|
||||
Add one or more sounds to the mix.
|
||||
"""
|
||||
self._sounds += [Sound.build(sound) for sound in sounds]
|
||||
|
||||
def remove(self, *sound_indices: int):
|
||||
"""
|
||||
Remove one or more sounds from the mix.
|
||||
"""
|
||||
assert self._sounds and all(
|
||||
0 <= sound_index < len(sound_indices) for sound_index in sound_indices
|
||||
), f'Sound indices must be between 0 and {len(self._sounds) - 1}'
|
||||
|
||||
for sound_index in sound_indices[::-1]:
|
||||
self._sounds.pop(sound_index)
|
||||
|
||||
@override
|
||||
def get_wave(
|
||||
self,
|
||||
sample_rate: float,
|
||||
t_start: float = 0,
|
||||
t_end: float = 0,
|
||||
normalize_range: Tuple[float, float] = (-1.0, 1.0),
|
||||
on_clip: str = 'scale',
|
||||
**_,
|
||||
) -> NDArray[np.number]:
|
||||
wave = None
|
||||
|
||||
for sound in self._sounds:
|
||||
sound_wave = sound.get_wave(
|
||||
t_start=t_start, t_end=t_end, sample_rate=sample_rate
|
||||
)
|
||||
|
||||
if wave is None:
|
||||
wave = sound_wave
|
||||
else:
|
||||
wave += sound_wave
|
||||
|
||||
if wave is not None and len(wave):
|
||||
scale_factor = (normalize_range[1] - normalize_range[0]) / (
|
||||
wave.max() - wave.min()
|
||||
)
|
||||
|
||||
if scale_factor < 1.0: # Wave clipping
|
||||
if on_clip == 'scale':
|
||||
wave = scale_factor * wave
|
||||
elif on_clip == 'clip':
|
||||
wave[wave < normalize_range[0]] = normalize_range[0]
|
||||
wave[wave > normalize_range[1]] = normalize_range[1]
|
||||
else:
|
||||
raise RuntimeError(
|
||||
'Supported values for "on_clip": ' + '"scale" or "clip"'
|
||||
)
|
||||
|
||||
assert wave is not None
|
||||
return convert_nd_array(self.gain * wave, dtype=self.dtype)
|
||||
|
||||
def duration(self):
|
||||
"""
|
||||
:returns: The duration of the mix in seconds as duration of its longest
|
||||
sample, or None if the mixed sample have no duration set
|
||||
"""
|
||||
|
||||
# If any sound has no duration specified, then the resulting mix will
|
||||
# have no duration as well.
|
||||
if any(sound.duration is None for sound in self._sounds):
|
||||
return None
|
||||
|
||||
return max(((sound.duration or 0) + sound.delay for sound in self._sounds))
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
79
platypush/plugins/sound/_streams/_player/_synth/_output.py
Normal file
79
platypush/plugins/sound/_streams/_player/_synth/_output.py
Normal file
|
@ -0,0 +1,79 @@
|
|||
from logging import getLogger
|
||||
from queue import Empty, Queue
|
||||
from typing import Callable, Optional
|
||||
|
||||
import sounddevice as sd
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class AudioOutputCallback:
|
||||
"""
|
||||
The ``AudioSynthOutput`` is a functor that wraps the ``sounddevice.Stream``
|
||||
callback and writes raw audio data to the audio device.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
audio_queue: Queue[NDArray[np.number]],
|
||||
channels: int,
|
||||
blocksize: int,
|
||||
should_stop: Callable[[], bool] = lambda: False,
|
||||
is_paused: Callable[[], bool] = lambda: False,
|
||||
queue_timeout: Optional[float] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._audio_queue = audio_queue
|
||||
self._channels = channels
|
||||
self._blocksize = blocksize
|
||||
self._should_stop = should_stop
|
||||
self._is_paused = is_paused
|
||||
self._queue_timeout = queue_timeout
|
||||
self.logger = getLogger(__name__)
|
||||
|
||||
def _check_status(self, frames: int, status):
|
||||
"""
|
||||
Checks the current status of the audio callback and raises errors if
|
||||
the processing shouldn't continue.
|
||||
"""
|
||||
if self._should_stop():
|
||||
raise sd.CallbackStop
|
||||
|
||||
assert frames == self._blocksize, (
|
||||
f'Received {frames} frames, expected blocksize is {self._blocksize}',
|
||||
)
|
||||
|
||||
assert not status.output_underflow, 'Output underflow: increase blocksize?'
|
||||
assert not status, f'Audio callback failed: {status}'
|
||||
|
||||
def _audio_callback(self, outdata: NDArray[np.number], frames: int, status):
|
||||
if self._is_paused():
|
||||
return
|
||||
|
||||
self._check_status(frames, status)
|
||||
|
||||
try:
|
||||
data = self._audio_queue.get_nowait()
|
||||
except Empty as e:
|
||||
raise (
|
||||
sd.CallbackStop
|
||||
if self._should_stop()
|
||||
else AssertionError('Buffer is empty: increase buffersize?')
|
||||
) from e
|
||||
|
||||
if data.shape[0] == 0:
|
||||
raise sd.CallbackStop
|
||||
|
||||
audio_length = min(len(data), len(outdata))
|
||||
outdata[:audio_length] = data[:audio_length]
|
||||
|
||||
# _ = time
|
||||
def __call__(self, outdata: NDArray[np.number], frames: int, _, status):
|
||||
try:
|
||||
self._audio_callback(outdata, frames, status)
|
||||
except AssertionError as e:
|
||||
self.logger.warning(str(e))
|
111
platypush/plugins/sound/_streams/_player/_synth/_parser.py
Normal file
111
platypush/plugins/sound/_streams/_player/_synth/_parser.py
Normal file
|
@ -0,0 +1,111 @@
|
|||
import math
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
|
||||
|
||||
class SoundParser:
|
||||
"""
|
||||
A utility mixin with some methods to parse and convert sound information -
|
||||
e.g. MIDI notes from strings, MIDI notes to frequencies, and the other way
|
||||
around.
|
||||
"""
|
||||
|
||||
_DEFAULT_A4_FREQUENCY = 440.0
|
||||
_MIDI_NOTE_REGEX = re.compile(r'^([A-G])([#b]?)(-?[0-9]+)$')
|
||||
_MID_A_MIDI_NOTE = 69
|
||||
_NOTE_OFFSETS = {
|
||||
'C': 0,
|
||||
'C#': 1,
|
||||
'Db': 1,
|
||||
'D': 2,
|
||||
'D#': 3,
|
||||
'Eb': 3,
|
||||
'E': 4,
|
||||
'F': 5,
|
||||
'F#': 6,
|
||||
'Gb': 6,
|
||||
'G': 7,
|
||||
'G#': 8,
|
||||
'Ab': 8,
|
||||
'A': 9,
|
||||
'A#': 10,
|
||||
'Bb': 10,
|
||||
'B': 11,
|
||||
}
|
||||
|
||||
_ALTERATION_OFFSETS = {
|
||||
'b': -1,
|
||||
'': 0,
|
||||
'#': 1,
|
||||
}
|
||||
|
||||
def __init__(self, *_, ref_frequency: float = _DEFAULT_A4_FREQUENCY, **__) -> None:
|
||||
self._ref_frequency = ref_frequency
|
||||
|
||||
@staticmethod
|
||||
def _get_alteration_offset(alt: str) -> int:
|
||||
"""
|
||||
Calculate the MIDI note offset given by its reported sharp/flat alteration.
|
||||
"""
|
||||
if alt == '#':
|
||||
return 1
|
||||
if alt == 'b':
|
||||
return -1
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def get_midi_note(cls, note: Union[str, int]) -> int:
|
||||
"""
|
||||
Convert a MIDI note given as input (either an integer or a string like
|
||||
'C4') to a MIDI note number.
|
||||
|
||||
:raise: ValueError
|
||||
"""
|
||||
|
||||
if isinstance(note, str):
|
||||
note = note[:1].upper() + note[1:]
|
||||
m = cls._MIDI_NOTE_REGEX.match(note)
|
||||
if not m:
|
||||
raise ValueError(f'Invalid MIDI note: {note}')
|
||||
|
||||
base_note, alteration, octave = m.groups()
|
||||
octave = int(octave)
|
||||
note_offset = cls._NOTE_OFFSETS[base_note] + cls._get_alteration_offset(
|
||||
alteration
|
||||
)
|
||||
|
||||
octave_offset = (octave + 1) * 12
|
||||
note = octave_offset + note_offset
|
||||
|
||||
if isinstance(note, int):
|
||||
if not 0 <= note <= 127:
|
||||
raise ValueError(f'MIDI note out of range: {note}')
|
||||
return note
|
||||
|
||||
raise ValueError(f'Invalid MIDI note: {note}')
|
||||
|
||||
def note_to_freq(
|
||||
self, midi_note: Union[int, str], ref_frequency: Optional[float] = None
|
||||
):
|
||||
"""
|
||||
Converts a MIDI note to its frequency in Hz
|
||||
|
||||
:param midi_note: MIDI note to convert
|
||||
:param ref_frequency: Reference A4 frequency override (default: 440 Hz).
|
||||
"""
|
||||
|
||||
note = self.get_midi_note(midi_note)
|
||||
return (2.0 ** ((note - self._MID_A_MIDI_NOTE) / 12.0)) * (
|
||||
ref_frequency or self._ref_frequency
|
||||
)
|
||||
|
||||
def freq_to_note(self, frequency: float, ref_frequency: Optional[float] = None):
|
||||
"""
|
||||
Converts a frequency in Hz to its closest MIDI note
|
||||
|
||||
:param frequency: Frequency in Hz
|
||||
:param ref_frequency: Reference A4 frequency override (default: 440 Hz).
|
||||
"""
|
||||
|
||||
std_freq = ref_frequency or self._ref_frequency
|
||||
return int(12.0 * math.log(frequency / std_freq, 2) + self._MID_A_MIDI_NOTE)
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue