forked from platypush/platypush
Refactored Tornado routes for native pub/sub support.
The Redis pub/sub mechanism is now a native feature for Tornado routes through the `PubSubMixin`. (Plus, lint/black chore for the sound plugin)
This commit is contained in:
parent
8b5eb82497
commit
d7208c6bbc
10 changed files with 657 additions and 322 deletions
|
@ -17,11 +17,10 @@ from tornado.web import Application, FallbackHandler
|
||||||
from platypush.backend import Backend
|
from platypush.backend import Backend
|
||||||
from platypush.backend.http.app import application
|
from platypush.backend.http.app import application
|
||||||
from platypush.backend.http.app.utils import get_streaming_routes, get_ws_routes
|
from platypush.backend.http.app.utils import get_streaming_routes, get_ws_routes
|
||||||
from platypush.backend.http.app.ws.events import events_redis_topic
|
from platypush.backend.http.app.ws.events import WSEventProxy
|
||||||
|
|
||||||
from platypush.bus.redis import RedisBus
|
from platypush.bus.redis import RedisBus
|
||||||
from platypush.config import Config
|
from platypush.config import Config
|
||||||
from platypush.utils import get_redis
|
|
||||||
|
|
||||||
|
|
||||||
class HttpBackend(Backend):
|
class HttpBackend(Backend):
|
||||||
|
@ -286,7 +285,7 @@ class HttpBackend(Backend):
|
||||||
|
|
||||||
def notify_web_clients(self, event):
|
def notify_web_clients(self, event):
|
||||||
"""Notify all the connected web clients (over websocket) of a new 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):
|
def _get_secret_key(self, _create=False):
|
||||||
if _create:
|
if _create:
|
||||||
|
|
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:
|
|
@ -9,18 +9,23 @@ from tornado.web import RequestHandler, stream_request_body
|
||||||
|
|
||||||
from platypush.backend.http.app.utils.auth import AuthStatus, get_auth_status
|
from platypush.backend.http.app.utils.auth import AuthStatus, get_auth_status
|
||||||
|
|
||||||
|
from ..mixins import PubSubMixin
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@stream_request_body
|
@stream_request_body
|
||||||
class StreamingRoute(RequestHandler, ABC):
|
class StreamingRoute(RequestHandler, PubSubMixin, ABC):
|
||||||
"""
|
"""
|
||||||
Base class for Tornado streaming routes.
|
Base class for Tornado streaming routes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def prepare(self):
|
def prepare(self):
|
||||||
# Perform authentication
|
"""
|
||||||
|
Request preparation logic. It performs user authentication if
|
||||||
|
``auth_required`` returns True, and it can be extended/overridden.
|
||||||
|
"""
|
||||||
if self.auth_required:
|
if self.auth_required:
|
||||||
auth_status = get_auth_status(self.request)
|
auth_status = get_auth_status(self.request)
|
||||||
if auth_status != AuthStatus.OK:
|
if auth_status != AuthStatus.OK:
|
||||||
|
@ -33,18 +38,28 @@ class StreamingRoute(RequestHandler, ABC):
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def write_error(self, status_code: int, error: Optional[str] = None, **_):
|
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.set_header("Content-Type", "application/json")
|
||||||
self.finish(
|
self.finish(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{"status": status_code, "error": error or responses[status_code]}
|
{"status": status_code, "error": error or responses.get(status_code)}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def path(cls) -> str:
|
def path(cls) -> str:
|
||||||
|
"""
|
||||||
|
Path/URL pattern for this route.
|
||||||
|
"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auth_required(self):
|
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
|
return True
|
||||||
|
|
|
@ -56,6 +56,8 @@ class CameraRoute(StreamingRoute):
|
||||||
camera.stream.ready.wait(timeout=timeout)
|
camera.stream.ready.wait(timeout=timeout)
|
||||||
return camera.stream.frame
|
return camera.stream.frame
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def _should_stop(self):
|
def _should_stop(self):
|
||||||
if self._finished:
|
if self._finished:
|
||||||
return True
|
return True
|
||||||
|
|
|
@ -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
|
from abc import ABC, abstractmethod
|
||||||
import json
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from threading import RLock, Thread
|
from threading import Thread
|
||||||
from typing import Any, Generator, Iterable, Optional, Union
|
|
||||||
from typing_extensions import override
|
from typing_extensions import override
|
||||||
|
|
||||||
from redis import ConnectionError as RedisConnectionError
|
|
||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
from tornado.websocket import WebSocketHandler
|
from tornado.websocket import WebSocketHandler
|
||||||
|
|
||||||
from platypush.backend.http.app.utils.auth import AuthStatus, get_auth_status
|
from platypush.backend.http.app.utils.auth import AuthStatus, get_auth_status
|
||||||
from platypush.config import Config
|
|
||||||
from platypush.message import Message
|
from ..mixins import MessageType, PubSubMixin
|
||||||
from platypush.utils import get_redis
|
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def pubsub_redis_topic(topic: str) -> str:
|
class WSRoute(WebSocketHandler, Thread, PubSubMixin, ABC):
|
||||||
return f'_platypush/{Config.get("device_id")}/{topic}' # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
class WSRoute(WebSocketHandler, Thread, ABC):
|
|
||||||
"""
|
"""
|
||||||
Base class for Tornado websocket endpoints.
|
Base class for Tornado websocket endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, redis_topics: Optional[Iterable[str]] = None, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
WebSocketHandler.__init__(self, *args)
|
||||||
self._redis_topics = set(redis_topics or [])
|
PubSubMixin.__init__(self, **kwargs)
|
||||||
self._sub = get_redis().pubsub()
|
Thread.__init__(self)
|
||||||
self._io_loop = IOLoop.current()
|
self._io_loop = IOLoop.current()
|
||||||
self._sub_lock = RLock()
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def open(self, *_, **__):
|
def open(self, *_, **__):
|
||||||
|
@ -51,10 +42,11 @@ class WSRoute(WebSocketHandler, Thread, ABC):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def on_message(self, message): # type: ignore
|
def on_message(self, message):
|
||||||
pass
|
return message
|
||||||
|
|
||||||
@abstractclassmethod
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
def app_name(cls) -> str:
|
def app_name(cls) -> str:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@ -66,55 +58,25 @@ class WSRoute(WebSocketHandler, Thread, ABC):
|
||||||
def auth_required(self):
|
def auth_required(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def subscribe(self, *topics: str) -> None:
|
def send(self, msg: MessageType) -> 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)
|
|
||||||
|
|
||||||
self._io_loop.asyncio_loop.call_soon_threadsafe( # type: ignore
|
self._io_loop.asyncio_loop.call_soon_threadsafe( # type: ignore
|
||||||
self.write_message, msg
|
self.write_message, self._serialize(msg)
|
||||||
)
|
)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
super().run()
|
super().run()
|
||||||
for topic in self._redis_topics:
|
self.subscribe(*self._subscriptions)
|
||||||
self._sub.subscribe(topic)
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def on_close(self):
|
def on_close(self):
|
||||||
topics = self._redis_topics.copy()
|
super().on_close()
|
||||||
for topic in topics:
|
for channel in self._subscriptions.copy():
|
||||||
self.unsubscribe(topic)
|
self.unsubscribe(channel)
|
||||||
|
|
||||||
|
if self._pubsub:
|
||||||
|
self._pubsub.close()
|
||||||
|
|
||||||
self._sub.close()
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'Client %s disconnected from %s, reason=%s, message=%s',
|
'Client %s disconnected from %s, reason=%s, message=%s',
|
||||||
self.request.remote_ip,
|
self.request.remote_ip,
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
from typing_extensions import override
|
from typing_extensions import override
|
||||||
|
|
||||||
|
from platypush.backend.http.app.mixins import MessageType
|
||||||
from platypush.message.event import Event
|
from platypush.message.event import Event
|
||||||
|
|
||||||
from . import WSRoute, logger, pubsub_redis_topic
|
from . import WSRoute, logger
|
||||||
from ..utils import send_message
|
from ..utils import send_message
|
||||||
|
|
||||||
events_redis_topic = pubsub_redis_topic('events')
|
|
||||||
|
|
||||||
|
|
||||||
class WSEventProxy(WSRoute):
|
class WSEventProxy(WSRoute):
|
||||||
"""
|
"""
|
||||||
|
@ -14,14 +13,23 @@ class WSEventProxy(WSRoute):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, subscriptions=[self.events_channel], **kwargs)
|
||||||
self.subscribe(events_redis_topic)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@override
|
@override
|
||||||
def app_name(cls) -> str:
|
def app_name(cls) -> str:
|
||||||
return 'events'
|
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
|
@override
|
||||||
def on_message(self, message):
|
def on_message(self, message):
|
||||||
try:
|
try:
|
||||||
|
@ -38,9 +46,9 @@ class WSEventProxy(WSRoute):
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
for msg in self.listen():
|
for msg in self.listen():
|
||||||
try:
|
try:
|
||||||
evt = Event.build(msg)
|
evt = Event.build(msg.data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning('Error parsing event: %s: %s', msg, e)
|
logger.warning('Error parsing event: %s: %s', msg, e)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.send(str(evt))
|
self.send(evt)
|
||||||
|
|
|
@ -5,7 +5,11 @@ from typing import Optional, Union, Tuple, Set
|
||||||
|
|
||||||
import numpy as np
|
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
|
from platypush.plugins.camera.model.writer.preview import PreviewWriter
|
||||||
|
|
||||||
|
|
||||||
|
@ -32,7 +36,7 @@ class CameraInfo:
|
||||||
stream_format: Optional[str] = None
|
stream_format: Optional[str] = None
|
||||||
vertical_flip: bool = False
|
vertical_flip: bool = False
|
||||||
warmup_frames: int = 0
|
warmup_frames: int = 0
|
||||||
warmup_seconds: float = 0.
|
warmup_seconds: float = 0.0
|
||||||
|
|
||||||
def set(self, **kwargs):
|
def set(self, **kwargs):
|
||||||
for k, v in kwargs.items():
|
for k, v in kwargs.items():
|
||||||
|
@ -97,10 +101,15 @@ class Camera:
|
||||||
return writers
|
return writers
|
||||||
|
|
||||||
def effective_resolution(self) -> Tuple[int, int]:
|
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
|
rot = (self.info.rotate or 0) * math.pi / 180
|
||||||
sin = math.sin(rot)
|
sin = math.sin(rot)
|
||||||
cos = math.cos(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]]])
|
resolution = np.array([[self.info.resolution[0], self.info.resolution[1]]])
|
||||||
rot_matrix = np.array([[sin, cos], [cos, sin]])
|
rot_matrix = np.array([[sin, cos], [cos, sin]])
|
||||||
resolution = (scale * abs(np.cross(rot_matrix, resolution)))[0]
|
resolution = (scale * abs(np.cross(rot_matrix, resolution)))[0]
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
"""
|
|
||||||
.. moduleauthor:: Fabio Manganiello <blacklight86@gmail.com>
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import stat
|
import stat
|
||||||
|
@ -10,25 +6,28 @@ import time
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from threading import Thread, Event, RLock
|
from threading import Thread, Event, RLock
|
||||||
|
from typing import Optional
|
||||||
from .core import Sound, Mix
|
|
||||||
|
|
||||||
from platypush.context import get_bus
|
from platypush.context import get_bus
|
||||||
from platypush.message.event.sound import \
|
from platypush.message.event.sound import (
|
||||||
SoundRecordingStartedEvent, SoundRecordingStoppedEvent
|
SoundRecordingStartedEvent,
|
||||||
|
SoundRecordingStoppedEvent,
|
||||||
|
)
|
||||||
|
|
||||||
from platypush.plugins import Plugin, action
|
from platypush.plugins import Plugin, action
|
||||||
|
|
||||||
|
from .core import Sound, Mix
|
||||||
|
|
||||||
|
|
||||||
class PlaybackState(Enum):
|
class PlaybackState(Enum):
|
||||||
STOPPED = 'STOPPED',
|
STOPPED = 'STOPPED'
|
||||||
PLAYING = 'PLAYING',
|
PLAYING = 'PLAYING'
|
||||||
PAUSED = 'PAUSED'
|
PAUSED = 'PAUSED'
|
||||||
|
|
||||||
|
|
||||||
class RecordingState(Enum):
|
class RecordingState(Enum):
|
||||||
STOPPED = 'STOPPED',
|
STOPPED = 'STOPPED'
|
||||||
RECORDING = 'RECORDING',
|
RECORDING = 'RECORDING'
|
||||||
PAUSED = 'PAUSED'
|
PAUSED = 'PAUSED'
|
||||||
|
|
||||||
|
|
||||||
|
@ -55,10 +54,14 @@ class SoundPlugin(Plugin):
|
||||||
_STREAM_NAME_PREFIX = 'platypush-stream-'
|
_STREAM_NAME_PREFIX = 'platypush-stream-'
|
||||||
_default_input_stream_fifo = os.path.join(tempfile.gettempdir(), 'inputstream')
|
_default_input_stream_fifo = os.path.join(tempfile.gettempdir(), 'inputstream')
|
||||||
|
|
||||||
# noinspection PyProtectedMember
|
def __init__(
|
||||||
def __init__(self, input_device=None, output_device=None,
|
self,
|
||||||
input_blocksize=Sound._DEFAULT_BLOCKSIZE,
|
input_device=None,
|
||||||
output_blocksize=Sound._DEFAULT_BLOCKSIZE, **kwargs):
|
output_device=None,
|
||||||
|
input_blocksize=Sound._DEFAULT_BLOCKSIZE,
|
||||||
|
output_blocksize=Sound._DEFAULT_BLOCKSIZE,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
:param input_device: Index or name of the default input device. Use
|
:param input_device: Index or name of the default input device. Use
|
||||||
:meth:`platypush.plugins.sound.query_devices` to get the
|
:meth:`platypush.plugins.sound.query_devices` to get the
|
||||||
|
@ -110,6 +113,7 @@ class SoundPlugin(Plugin):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sounddevice as sd
|
import sounddevice as sd
|
||||||
|
|
||||||
return sd.query_hostapis()[0].get('default_' + category.lower() + '_device')
|
return sd.query_hostapis()[0].get('default_' + category.lower() + '_device')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
@ -174,17 +178,18 @@ class SoundPlugin(Plugin):
|
||||||
self.playback_paused_changed[stream_index].wait()
|
self.playback_paused_changed[stream_index].wait()
|
||||||
|
|
||||||
if frames != blocksize:
|
if frames != blocksize:
|
||||||
self.logger.warning('Received {} frames, expected blocksize is {}'.
|
self.logger.warning(
|
||||||
format(frames, blocksize))
|
'Received %d frames, expected blocksize is %d', frames, blocksize
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if status.output_underflow:
|
if status.output_underflow:
|
||||||
self.logger.warning('Output underflow: increase blocksize?')
|
self.logger.warning('Output underflow: increase blocksize?')
|
||||||
outdata[:] = (b'\x00' if is_raw_stream else 0.) * len(outdata)
|
outdata[:] = (b'\x00' if is_raw_stream else 0.0) * len(outdata)
|
||||||
return
|
return
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
self.logger.warning('Audio callback failed: {}'.format(status))
|
self.logger.warning('Audio callback failed: %s', status)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = q.get_nowait()
|
data = q.get_nowait()
|
||||||
|
@ -193,18 +198,28 @@ class SoundPlugin(Plugin):
|
||||||
raise sd.CallbackStop
|
raise sd.CallbackStop
|
||||||
|
|
||||||
if len(data) < len(outdata):
|
if len(data) < len(outdata):
|
||||||
outdata[:len(data)] = data
|
outdata[: len(data)] = data
|
||||||
outdata[len(data):] = (b'\x00' if is_raw_stream else 0.) * \
|
outdata[len(data) :] = (b'\x00' if is_raw_stream else 0.0) * (
|
||||||
(len(outdata) - len(data))
|
len(outdata) - len(data)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
outdata[:] = data
|
outdata[:] = data
|
||||||
|
|
||||||
return audio_callback
|
return audio_callback
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def play(self, file=None, sound=None, device=None, blocksize=None,
|
def play(
|
||||||
bufsize=None, samplerate=None, channels=None, stream_name=None,
|
self,
|
||||||
stream_index=None):
|
file=None,
|
||||||
|
sound=None,
|
||||||
|
device=None,
|
||||||
|
blocksize=None,
|
||||||
|
bufsize=None,
|
||||||
|
samplerate=None,
|
||||||
|
channels=None,
|
||||||
|
stream_name=None,
|
||||||
|
stream_index=None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Plays a sound file (support formats: wav, raw) or a synthetic sound.
|
Plays a sound file (support formats: wav, raw) or a synthetic sound.
|
||||||
|
|
||||||
|
@ -258,8 +273,9 @@ class SoundPlugin(Plugin):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not file and not sound:
|
if not file and not sound:
|
||||||
raise RuntimeError('Please specify either a file to play or a ' +
|
raise RuntimeError(
|
||||||
'list of sound objects')
|
'Please specify either a file to play or a ' + 'list of sound objects'
|
||||||
|
)
|
||||||
|
|
||||||
import sounddevice as sd
|
import sounddevice as sd
|
||||||
|
|
||||||
|
@ -274,7 +290,7 @@ class SoundPlugin(Plugin):
|
||||||
|
|
||||||
q = queue.Queue(maxsize=bufsize)
|
q = queue.Queue(maxsize=bufsize)
|
||||||
f = None
|
f = None
|
||||||
t = 0.
|
t = 0.0
|
||||||
|
|
||||||
if file:
|
if file:
|
||||||
file = os.path.abspath(os.path.expanduser(file))
|
file = os.path.abspath(os.path.expanduser(file))
|
||||||
|
@ -286,6 +302,7 @@ class SoundPlugin(Plugin):
|
||||||
|
|
||||||
if file:
|
if file:
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
|
|
||||||
f = sf.SoundFile(file)
|
f = sf.SoundFile(file)
|
||||||
if not samplerate:
|
if not samplerate:
|
||||||
samplerate = f.samplerate if f else Sound._DEFAULT_SAMPLERATE
|
samplerate = f.samplerate if f else Sound._DEFAULT_SAMPLERATE
|
||||||
|
@ -295,7 +312,8 @@ class SoundPlugin(Plugin):
|
||||||
mix = None
|
mix = None
|
||||||
with self.playback_state_lock:
|
with self.playback_state_lock:
|
||||||
stream_index, is_new_stream = self._get_or_allocate_stream_index(
|
stream_index, is_new_stream = self._get_or_allocate_stream_index(
|
||||||
stream_index=stream_index, stream_name=stream_name)
|
stream_index=stream_index, stream_name=stream_name
|
||||||
|
)
|
||||||
|
|
||||||
if sound and stream_index in self.stream_mixes:
|
if sound and stream_index in self.stream_mixes:
|
||||||
mix = self.stream_mixes[stream_index]
|
mix = self.stream_mixes[stream_index]
|
||||||
|
@ -304,9 +322,12 @@ class SoundPlugin(Plugin):
|
||||||
if not mix:
|
if not mix:
|
||||||
return None, "Unable to allocate the stream"
|
return None, "Unable to allocate the stream"
|
||||||
|
|
||||||
self.logger.info(('Starting playback of {} to sound device [{}] ' +
|
self.logger.info(
|
||||||
'on stream [{}]').format(
|
'Starting playback of %s to sound device [%s] on stream [%s]',
|
||||||
file or sound, device, stream_index))
|
file or sound,
|
||||||
|
device,
|
||||||
|
stream_index,
|
||||||
|
)
|
||||||
|
|
||||||
if not is_new_stream:
|
if not is_new_stream:
|
||||||
return # Let the existing callback handle the new mix
|
return # Let the existing callback handle the new mix
|
||||||
|
@ -323,8 +344,11 @@ class SoundPlugin(Plugin):
|
||||||
else:
|
else:
|
||||||
duration = mix.duration()
|
duration = mix.duration()
|
||||||
blocktime = float(blocksize / samplerate)
|
blocktime = float(blocksize / samplerate)
|
||||||
next_t = min(t + blocktime, duration) \
|
next_t = (
|
||||||
if duration is not None else t + blocktime
|
min(t + blocktime, duration)
|
||||||
|
if duration is not None
|
||||||
|
else t + blocktime
|
||||||
|
)
|
||||||
|
|
||||||
data = mix.get_wave(t_start=t, t_end=next_t, samplerate=samplerate)
|
data = mix.get_wave(t_start=t, t_end=next_t, samplerate=samplerate)
|
||||||
t = next_t
|
t = next_t
|
||||||
|
@ -339,14 +363,20 @@ class SoundPlugin(Plugin):
|
||||||
|
|
||||||
if stream is None:
|
if stream is None:
|
||||||
streamtype = sd.RawOutputStream if file else sd.OutputStream
|
streamtype = sd.RawOutputStream if file else sd.OutputStream
|
||||||
stream = streamtype(samplerate=samplerate, blocksize=blocksize,
|
stream = streamtype(
|
||||||
device=device, channels=channels,
|
samplerate=samplerate,
|
||||||
dtype='float32',
|
blocksize=blocksize,
|
||||||
callback=self._play_audio_callback(
|
device=device,
|
||||||
q=q, blocksize=blocksize,
|
channels=channels,
|
||||||
streamtype=streamtype,
|
dtype='float32',
|
||||||
stream_index=stream_index),
|
callback=self._play_audio_callback(
|
||||||
finished_callback=completed_callback_event.set)
|
q=q,
|
||||||
|
blocksize=blocksize,
|
||||||
|
streamtype=streamtype,
|
||||||
|
stream_index=stream_index,
|
||||||
|
),
|
||||||
|
finished_callback=completed_callback_event.set,
|
||||||
|
)
|
||||||
|
|
||||||
self._start_playback(stream_index=stream_index, stream=stream)
|
self._start_playback(stream_index=stream_index, stream=stream)
|
||||||
|
|
||||||
|
@ -356,8 +386,9 @@ class SoundPlugin(Plugin):
|
||||||
timeout = blocksize * bufsize / samplerate
|
timeout = blocksize * bufsize / samplerate
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
while self._get_playback_state(stream_index) == \
|
while (
|
||||||
PlaybackState.PAUSED:
|
self._get_playback_state(stream_index) == PlaybackState.PAUSED
|
||||||
|
):
|
||||||
self.playback_paused_changed[stream_index].wait()
|
self.playback_paused_changed[stream_index].wait()
|
||||||
|
|
||||||
if f:
|
if f:
|
||||||
|
@ -367,31 +398,38 @@ class SoundPlugin(Plugin):
|
||||||
else:
|
else:
|
||||||
duration = mix.duration()
|
duration = mix.duration()
|
||||||
blocktime = float(blocksize / samplerate)
|
blocktime = float(blocksize / samplerate)
|
||||||
next_t = min(t + blocktime, duration) \
|
next_t = (
|
||||||
if duration is not None else t + blocktime
|
min(t + blocktime, duration)
|
||||||
|
if duration is not None
|
||||||
|
else t + blocktime
|
||||||
|
)
|
||||||
|
|
||||||
data = mix.get_wave(t_start=t, t_end=next_t,
|
data = mix.get_wave(
|
||||||
samplerate=samplerate)
|
t_start=t, t_end=next_t, samplerate=samplerate
|
||||||
|
)
|
||||||
t = next_t
|
t = next_t
|
||||||
|
|
||||||
if duration is not None and t >= duration:
|
if duration is not None and t >= duration:
|
||||||
break
|
break
|
||||||
|
|
||||||
if self._get_playback_state(stream_index) == \
|
if self._get_playback_state(stream_index) == PlaybackState.STOPPED:
|
||||||
PlaybackState.STOPPED:
|
|
||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
q.put(data, timeout=timeout)
|
q.put(data, timeout=timeout)
|
||||||
except queue.Full as e:
|
except queue.Full as e:
|
||||||
if self._get_playback_state(stream_index) != \
|
if (
|
||||||
PlaybackState.PAUSED:
|
self._get_playback_state(stream_index)
|
||||||
|
!= PlaybackState.PAUSED
|
||||||
|
):
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
completed_callback_event.wait()
|
completed_callback_event.wait()
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
if stream_index is None or \
|
if (
|
||||||
self._get_playback_state(stream_index) != PlaybackState.STOPPED:
|
stream_index is None
|
||||||
|
or self._get_playback_state(stream_index) != PlaybackState.STOPPED
|
||||||
|
):
|
||||||
self.logger.warning('Playback timeout: audio callback failed?')
|
self.logger.warning('Playback timeout: audio callback failed?')
|
||||||
finally:
|
finally:
|
||||||
if f and not f.closed:
|
if f and not f.closed:
|
||||||
|
@ -400,35 +438,34 @@ class SoundPlugin(Plugin):
|
||||||
self.stop_playback([stream_index])
|
self.stop_playback([stream_index])
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def stream_recording(self, device=None, fifo=None, duration=None, sample_rate=None,
|
def stream_recording(
|
||||||
dtype='float32', blocksize=None, latency=0, channels=1):
|
self,
|
||||||
|
device: Optional[str] = None,
|
||||||
|
fifo: Optional[str] = None,
|
||||||
|
duration: Optional[float] = None,
|
||||||
|
sample_rate: Optional[int] = None,
|
||||||
|
dtype: Optional[str] = 'float32',
|
||||||
|
blocksize: Optional[int] = None,
|
||||||
|
latency: float = 0,
|
||||||
|
channels: int = 1,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Return audio data from an audio source
|
Return audio data from an audio source
|
||||||
|
|
||||||
:param device: Input device (default: default configured device or system default audio input if not configured)
|
:param device: Input device (default: default configured device or
|
||||||
:type device: int or str
|
system default audio input if not configured)
|
||||||
|
:param fifo: Path of the FIFO that will be used to exchange audio
|
||||||
:param fifo: Path of the FIFO that will be used to exchange audio samples (default: /tmp/inputstream)
|
samples (default: /tmp/inputstream)
|
||||||
:type fifo: str
|
:param duration: Recording duration in seconds (default: record until
|
||||||
|
stop event)
|
||||||
:param duration: Recording duration in seconds (default: record until stop event)
|
|
||||||
:type duration: float
|
|
||||||
|
|
||||||
:param sample_rate: Recording sample rate (default: device default rate)
|
:param sample_rate: Recording sample rate (default: device default rate)
|
||||||
:type sample_rate: int
|
|
||||||
|
|
||||||
:param dtype: Data type for the audio samples. Supported types:
|
:param dtype: Data type for the audio samples. Supported types:
|
||||||
'float64', 'float32', 'int32', 'int16', 'int8', 'uint8'. Default: float32
|
'float64', 'float32', 'int32', 'int16', 'int8', 'uint8'. Default:
|
||||||
:type dtype: str
|
float32
|
||||||
|
:param blocksize: Audio block size (default: configured
|
||||||
:param blocksize: Audio block size (default: configured `input_blocksize` or 2048)
|
`input_blocksize` or 2048)
|
||||||
:type blocksize: int
|
|
||||||
|
|
||||||
:param latency: Device latency in seconds (default: 0)
|
:param latency: Device latency in seconds (default: 0)
|
||||||
:type latency: float
|
|
||||||
|
|
||||||
:param channels: Number of channels (default: 1)
|
:param channels: Number of channels (default: 1)
|
||||||
:type channels: int
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sounddevice as sd
|
import sounddevice as sd
|
||||||
|
@ -452,43 +489,55 @@ class SoundPlugin(Plugin):
|
||||||
|
|
||||||
q = queue.Queue()
|
q = queue.Queue()
|
||||||
|
|
||||||
# noinspection PyUnusedLocal
|
def audio_callback(indata, frames, time_duration, status): # noqa
|
||||||
def audio_callback(indata, frames, time_duration, status):
|
|
||||||
while self._get_recording_state() == RecordingState.PAUSED:
|
while self._get_recording_state() == RecordingState.PAUSED:
|
||||||
self.recording_paused_changed.wait()
|
self.recording_paused_changed.wait()
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
self.logger.warning('Recording callback status: {}'.format(str(status)))
|
self.logger.warning('Recording callback status: %s', status)
|
||||||
|
|
||||||
q.put(indata.copy())
|
q.put(indata.copy())
|
||||||
|
|
||||||
def streaming_thread():
|
def streaming_thread():
|
||||||
try:
|
try:
|
||||||
with sd.InputStream(samplerate=sample_rate, device=device,
|
with sd.InputStream(
|
||||||
channels=channels, callback=audio_callback,
|
samplerate=sample_rate,
|
||||||
dtype=dtype, latency=latency, blocksize=blocksize):
|
device=device,
|
||||||
with open(fifo, 'wb') as audio_queue:
|
channels=channels,
|
||||||
self.start_recording()
|
callback=audio_callback,
|
||||||
get_bus().post(SoundRecordingStartedEvent())
|
dtype=dtype,
|
||||||
self.logger.info('Started recording from device [{}]'.format(device))
|
latency=latency,
|
||||||
recording_started_time = time.time()
|
blocksize=blocksize,
|
||||||
|
), open(fifo, 'wb') as audio_queue:
|
||||||
|
self.start_recording()
|
||||||
|
get_bus().post(SoundRecordingStartedEvent())
|
||||||
|
self.logger.info('Started recording from device [%s]', device)
|
||||||
|
recording_started_time = time.time()
|
||||||
|
|
||||||
while self._get_recording_state() != RecordingState.STOPPED \
|
while self._get_recording_state() != RecordingState.STOPPED and (
|
||||||
and (duration is None or
|
duration is None
|
||||||
time.time() - recording_started_time < duration):
|
or time.time() - recording_started_time < duration
|
||||||
while self._get_recording_state() == RecordingState.PAUSED:
|
):
|
||||||
self.recording_paused_changed.wait()
|
while self._get_recording_state() == RecordingState.PAUSED:
|
||||||
|
self.recording_paused_changed.wait()
|
||||||
|
|
||||||
get_args = {
|
get_args = (
|
||||||
|
{
|
||||||
'block': True,
|
'block': True,
|
||||||
'timeout': max(0, duration - (time.time() - recording_started_time)),
|
'timeout': max(
|
||||||
} if duration is not None else {}
|
0,
|
||||||
|
duration - (time.time() - recording_started_time),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if duration is not None
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
|
||||||
data = q.get(**get_args)
|
data = q.get(**get_args)
|
||||||
if not len(data):
|
if not len(data):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
audio_queue.write(data)
|
audio_queue.write(data)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
self.logger.warning('Recording timeout: audio callback failed?')
|
self.logger.warning('Recording timeout: audio callback failed?')
|
||||||
finally:
|
finally:
|
||||||
|
@ -497,17 +546,29 @@ class SoundPlugin(Plugin):
|
||||||
|
|
||||||
if os.path.exists(fifo):
|
if os.path.exists(fifo):
|
||||||
if stat.S_ISFIFO(os.stat(fifo).st_mode):
|
if stat.S_ISFIFO(os.stat(fifo).st_mode):
|
||||||
self.logger.info('Removing previous input stream FIFO {}'.format(fifo))
|
self.logger.info('Removing previous input stream FIFO %s', fifo)
|
||||||
os.unlink(fifo)
|
os.unlink(fifo)
|
||||||
else:
|
else:
|
||||||
raise RuntimeError('{} exists and is not a FIFO. Please remove it or rename it'.format(fifo))
|
raise RuntimeError(
|
||||||
|
f'{fifo} exists and is not a FIFO. Please remove it or rename it'
|
||||||
|
)
|
||||||
|
|
||||||
os.mkfifo(fifo, 0o644)
|
os.mkfifo(fifo, 0o644)
|
||||||
Thread(target=streaming_thread).start()
|
Thread(target=streaming_thread).start()
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def record(self, outfile=None, duration=None, device=None, sample_rate=None,
|
def record(
|
||||||
format=None, blocksize=None, latency=0, channels=1, subtype='PCM_24'):
|
self,
|
||||||
|
outfile=None,
|
||||||
|
duration=None,
|
||||||
|
device=None,
|
||||||
|
sample_rate=None,
|
||||||
|
format=None,
|
||||||
|
blocksize=None,
|
||||||
|
latency=0,
|
||||||
|
channels=1,
|
||||||
|
subtype='PCM_24',
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Records audio to a sound file (support formats: wav, raw)
|
Records audio to a sound file (support formats: wav, raw)
|
||||||
|
|
||||||
|
@ -535,12 +596,23 @@ class SoundPlugin(Plugin):
|
||||||
:param channels: Number of channels (default: 1)
|
:param channels: Number of channels (default: 1)
|
||||||
:type channels: int
|
:type channels: int
|
||||||
|
|
||||||
:param subtype: Recording subtype - see `Soundfile docs - Subtypes <https://pysoundfile.readthedocs.io/en/0.9.0/#soundfile.available_subtypes>`_ for a list of the available subtypes (default: PCM_24)
|
:param subtype: Recording subtype - see `Soundfile docs - Subtypes
|
||||||
|
<https://pysoundfile.readthedocs.io/en/0.9.0/#soundfile.available_subtypes>`_
|
||||||
|
for a list of the available subtypes (default: PCM_24)
|
||||||
:type subtype: str
|
:type subtype: str
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def recording_thread(outfile, duration, device, sample_rate, format,
|
def recording_thread(
|
||||||
blocksize, latency, channels, subtype):
|
outfile,
|
||||||
|
duration,
|
||||||
|
device,
|
||||||
|
sample_rate,
|
||||||
|
format,
|
||||||
|
blocksize,
|
||||||
|
latency,
|
||||||
|
channels,
|
||||||
|
subtype,
|
||||||
|
):
|
||||||
import sounddevice as sd
|
import sounddevice as sd
|
||||||
|
|
||||||
self.recording_paused_changed.clear()
|
self.recording_paused_changed.clear()
|
||||||
|
@ -548,12 +620,15 @@ class SoundPlugin(Plugin):
|
||||||
if outfile:
|
if outfile:
|
||||||
outfile = os.path.abspath(os.path.expanduser(outfile))
|
outfile = os.path.abspath(os.path.expanduser(outfile))
|
||||||
if os.path.isfile(outfile):
|
if os.path.isfile(outfile):
|
||||||
self.logger.info('Removing existing audio file {}'.format(outfile))
|
self.logger.info('Removing existing audio file %s', outfile)
|
||||||
os.unlink(outfile)
|
os.unlink(outfile)
|
||||||
else:
|
else:
|
||||||
outfile = tempfile.NamedTemporaryFile(
|
outfile = tempfile.NamedTemporaryFile(
|
||||||
prefix='recording_', suffix='.wav', delete=False,
|
prefix='recording_',
|
||||||
dir=tempfile.gettempdir()).name
|
suffix='.wav',
|
||||||
|
delete=False,
|
||||||
|
dir=tempfile.gettempdir(),
|
||||||
|
).name
|
||||||
|
|
||||||
if device is None:
|
if device is None:
|
||||||
device = self.input_device
|
device = self.input_device
|
||||||
|
@ -574,42 +649,68 @@ class SoundPlugin(Plugin):
|
||||||
self.recording_paused_changed.wait()
|
self.recording_paused_changed.wait()
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
self.logger.warning('Recording callback status: {}'.format(
|
self.logger.warning('Recording callback status: %s', status)
|
||||||
str(status)))
|
|
||||||
|
|
||||||
q.put({
|
q.put(
|
||||||
'timestamp': time.time(),
|
{
|
||||||
'frames': frames,
|
'timestamp': time.time(),
|
||||||
'time': duration,
|
'frames': frames,
|
||||||
'data': indata.copy()
|
'time': duration,
|
||||||
})
|
'data': indata.copy(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import numpy
|
|
||||||
|
|
||||||
with sf.SoundFile(outfile, mode='w', samplerate=sample_rate,
|
with sf.SoundFile(
|
||||||
format=format, channels=channels, subtype=subtype) as f:
|
outfile,
|
||||||
with sd.InputStream(samplerate=sample_rate, device=device,
|
mode='w',
|
||||||
channels=channels, callback=audio_callback,
|
samplerate=sample_rate,
|
||||||
latency=latency, blocksize=blocksize):
|
format=format,
|
||||||
|
channels=channels,
|
||||||
|
subtype=subtype,
|
||||||
|
) as f:
|
||||||
|
with sd.InputStream(
|
||||||
|
samplerate=sample_rate,
|
||||||
|
device=device,
|
||||||
|
channels=channels,
|
||||||
|
callback=audio_callback,
|
||||||
|
latency=latency,
|
||||||
|
blocksize=blocksize,
|
||||||
|
):
|
||||||
self.start_recording()
|
self.start_recording()
|
||||||
get_bus().post(SoundRecordingStartedEvent(filename=outfile))
|
get_bus().post(SoundRecordingStartedEvent(filename=outfile))
|
||||||
self.logger.info('Started recording from device [{}] to [{}]'.
|
self.logger.info(
|
||||||
format(device, outfile))
|
'Started recording from device [%s] to [%s]',
|
||||||
|
device,
|
||||||
|
outfile,
|
||||||
|
)
|
||||||
|
|
||||||
recording_started_time = time.time()
|
recording_started_time = time.time()
|
||||||
|
|
||||||
while self._get_recording_state() != RecordingState.STOPPED \
|
while (
|
||||||
and (duration is None or
|
self._get_recording_state() != RecordingState.STOPPED
|
||||||
time.time() - recording_started_time < duration):
|
and (
|
||||||
|
duration is None
|
||||||
|
or time.time() - recording_started_time < duration
|
||||||
|
)
|
||||||
|
):
|
||||||
while self._get_recording_state() == RecordingState.PAUSED:
|
while self._get_recording_state() == RecordingState.PAUSED:
|
||||||
self.recording_paused_changed.wait()
|
self.recording_paused_changed.wait()
|
||||||
|
|
||||||
get_args = {
|
get_args = (
|
||||||
'block': True,
|
{
|
||||||
'timeout': max(0, duration - (time.time() - recording_started_time)),
|
'block': True,
|
||||||
} if duration is not None else {}
|
'timeout': max(
|
||||||
|
0,
|
||||||
|
duration
|
||||||
|
- (time.time() - recording_started_time),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if duration is not None
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
|
||||||
data = q.get(**get_args)
|
data = q.get(**get_args)
|
||||||
if data and time.time() - data.get('timestamp') <= 1.0:
|
if data and time.time() - data.get('timestamp') <= 1.0:
|
||||||
|
@ -624,24 +725,45 @@ class SoundPlugin(Plugin):
|
||||||
self.stop_recording()
|
self.stop_recording()
|
||||||
get_bus().post(SoundRecordingStoppedEvent(filename=outfile))
|
get_bus().post(SoundRecordingStoppedEvent(filename=outfile))
|
||||||
|
|
||||||
Thread(target=recording_thread,
|
Thread(
|
||||||
args=(
|
target=recording_thread,
|
||||||
outfile, duration, device, sample_rate, format, blocksize, latency, channels, subtype)
|
args=(
|
||||||
).start()
|
outfile,
|
||||||
|
duration,
|
||||||
|
device,
|
||||||
|
sample_rate,
|
||||||
|
format,
|
||||||
|
blocksize,
|
||||||
|
latency,
|
||||||
|
channels,
|
||||||
|
subtype,
|
||||||
|
),
|
||||||
|
).start()
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def recordplay(self, duration=None, input_device=None, output_device=None,
|
def recordplay(
|
||||||
sample_rate=None, blocksize=None, latency=0, channels=1, dtype=None):
|
self,
|
||||||
|
duration=None,
|
||||||
|
input_device=None,
|
||||||
|
output_device=None,
|
||||||
|
sample_rate=None,
|
||||||
|
blocksize=None,
|
||||||
|
latency=0,
|
||||||
|
channels=1,
|
||||||
|
dtype=None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Records audio and plays it on an output sound device (audio pass-through)
|
Records audio and plays it on an output sound device (audio pass-through)
|
||||||
|
|
||||||
:param duration: Recording duration in seconds (default: record until stop event)
|
:param duration: Recording duration in seconds (default: record until stop event)
|
||||||
:type duration: float
|
:type duration: float
|
||||||
|
|
||||||
:param input_device: Input device (default: default configured device or system default audio input if not configured)
|
:param input_device: Input device (default: default configured device
|
||||||
|
or system default audio input if not configured)
|
||||||
:type input_device: int or str
|
:type input_device: int or str
|
||||||
|
|
||||||
:param output_device: Output device (default: default configured device or system default audio output if not configured)
|
:param output_device: Output device (default: default configured device
|
||||||
|
or system default audio output if not configured)
|
||||||
:type output_device: int or str
|
:type output_device: int or str
|
||||||
|
|
||||||
:param sample_rate: Recording sample rate (default: device default rate)
|
:param sample_rate: Recording sample rate (default: device default rate)
|
||||||
|
@ -656,7 +778,10 @@ class SoundPlugin(Plugin):
|
||||||
:param channels: Number of channels (default: 1)
|
:param channels: Number of channels (default: 1)
|
||||||
:type channels: int
|
:type channels: int
|
||||||
|
|
||||||
:param dtype: Data type for the recording - see `Soundfile docs - Recording <https://python-sounddevice.readthedocs.io/en/0.3.12/_modules/sounddevice.html#rec>`_ for available types (default: input device default)
|
:param dtype: Data type for the recording - see `Soundfile docs -
|
||||||
|
Recording
|
||||||
|
<https://python-sounddevice.readthedocs.io/en/0.3.12/_modules/sounddevice.html#rec>`_
|
||||||
|
for available types (default: input device default)
|
||||||
:type dtype: str
|
:type dtype: str
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -687,35 +812,37 @@ class SoundPlugin(Plugin):
|
||||||
self.recording_paused_changed.wait()
|
self.recording_paused_changed.wait()
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
self.logger.warning('Recording callback status: {}'.format(
|
self.logger.warning('Recording callback status: %s', status)
|
||||||
str(status)))
|
|
||||||
|
|
||||||
outdata[:] = indata
|
outdata[:] = indata
|
||||||
|
|
||||||
stream_index = None
|
stream_index = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import soundfile as sf
|
|
||||||
import numpy
|
|
||||||
|
|
||||||
stream_index = self._allocate_stream_index()
|
stream_index = self._allocate_stream_index()
|
||||||
stream = sd.Stream(samplerate=sample_rate, channels=channels,
|
stream = sd.Stream(
|
||||||
blocksize=blocksize, latency=latency,
|
samplerate=sample_rate,
|
||||||
device=(input_device, output_device),
|
channels=channels,
|
||||||
dtype=dtype, callback=audio_callback)
|
blocksize=blocksize,
|
||||||
|
latency=latency,
|
||||||
|
device=(input_device, output_device),
|
||||||
|
dtype=dtype,
|
||||||
|
callback=audio_callback,
|
||||||
|
)
|
||||||
self.start_recording()
|
self.start_recording()
|
||||||
self._start_playback(stream_index=stream_index,
|
self._start_playback(stream_index=stream_index, stream=stream)
|
||||||
stream=stream)
|
|
||||||
|
|
||||||
self.logger.info('Started recording pass-through from device ' +
|
self.logger.info(
|
||||||
'[{}] to sound device [{}]'.
|
'Started recording pass-through from device [%s] to sound device [%s]',
|
||||||
format(input_device, output_device))
|
input_device,
|
||||||
|
output_device,
|
||||||
|
)
|
||||||
|
|
||||||
recording_started_time = time.time()
|
recording_started_time = time.time()
|
||||||
|
|
||||||
while self._get_recording_state() != RecordingState.STOPPED \
|
while self._get_recording_state() != RecordingState.STOPPED and (
|
||||||
and (duration is None or
|
duration is None or time.time() - recording_started_time < duration
|
||||||
time.time() - recording_started_time < duration):
|
):
|
||||||
while self._get_recording_state() == RecordingState.PAUSED:
|
while self._get_recording_state() == RecordingState.PAUSED:
|
||||||
self.recording_paused_changed.wait()
|
self.recording_paused_changed.wait()
|
||||||
|
|
||||||
|
@ -736,24 +863,35 @@ class SoundPlugin(Plugin):
|
||||||
streams = {
|
streams = {
|
||||||
i: {
|
i: {
|
||||||
attr: getattr(stream, attr)
|
attr: getattr(stream, attr)
|
||||||
for attr in ['active', 'closed', 'stopped', 'blocksize',
|
for attr in [
|
||||||
'channels', 'cpu_load', 'device', 'dtype',
|
'active',
|
||||||
'latency', 'samplerate', 'samplesize']
|
'closed',
|
||||||
|
'stopped',
|
||||||
|
'blocksize',
|
||||||
|
'channels',
|
||||||
|
'cpu_load',
|
||||||
|
'device',
|
||||||
|
'dtype',
|
||||||
|
'latency',
|
||||||
|
'samplerate',
|
||||||
|
'samplesize',
|
||||||
|
]
|
||||||
if hasattr(stream, attr)
|
if hasattr(stream, attr)
|
||||||
} for i, stream in self.active_streams.items()
|
}
|
||||||
|
for i, stream in self.active_streams.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, stream in streams.items():
|
for i, stream in streams.items():
|
||||||
stream['playback_state'] = self.playback_state[i].name
|
stream['playback_state'] = self.playback_state[i].name
|
||||||
stream['name'] = self.stream_index_to_name.get(i)
|
stream['name'] = self.stream_index_to_name.get(i)
|
||||||
if i in self.stream_mixes:
|
if i in self.stream_mixes:
|
||||||
stream['mix'] = {j: sound for j, sound in
|
stream['mix'] = dict(enumerate(list(self.stream_mixes[i])))
|
||||||
enumerate(list(self.stream_mixes[i]))}
|
|
||||||
|
|
||||||
return streams
|
return streams
|
||||||
|
|
||||||
def _get_or_allocate_stream_index(self, stream_index=None, stream_name=None,
|
def _get_or_allocate_stream_index(
|
||||||
completed_callback_event=None):
|
self, stream_index=None, stream_name=None, completed_callback_event=None
|
||||||
|
):
|
||||||
stream = None
|
stream = None
|
||||||
|
|
||||||
with self.playback_state_lock:
|
with self.playback_state_lock:
|
||||||
|
@ -762,22 +900,26 @@ class SoundPlugin(Plugin):
|
||||||
stream_index = self.stream_name_to_index.get(stream_name)
|
stream_index = self.stream_name_to_index.get(stream_name)
|
||||||
else:
|
else:
|
||||||
if stream_name is not None:
|
if stream_name is not None:
|
||||||
raise RuntimeError('Redundant specification of both ' +
|
raise RuntimeError(
|
||||||
'stream_name and stream_index')
|
'Redundant specification of both '
|
||||||
|
+ 'stream_name and stream_index'
|
||||||
|
)
|
||||||
|
|
||||||
if stream_index is not None:
|
if stream_index is not None:
|
||||||
stream = self.active_streams.get(stream_index)
|
stream = self.active_streams.get(stream_index)
|
||||||
|
|
||||||
if not stream:
|
if not stream:
|
||||||
return (self._allocate_stream_index(stream_name=stream_name,
|
return (
|
||||||
completed_callback_event=
|
self._allocate_stream_index(
|
||||||
completed_callback_event),
|
stream_name=stream_name,
|
||||||
True)
|
completed_callback_event=completed_callback_event,
|
||||||
|
),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
return stream_index, False
|
return stream_index, False
|
||||||
|
|
||||||
def _allocate_stream_index(self, stream_name=None,
|
def _allocate_stream_index(self, stream_name=None, completed_callback_event=None):
|
||||||
completed_callback_event=None):
|
|
||||||
stream_index = None
|
stream_index = None
|
||||||
|
|
||||||
with self.playback_state_lock:
|
with self.playback_state_lock:
|
||||||
|
@ -796,8 +938,9 @@ class SoundPlugin(Plugin):
|
||||||
self.stream_mixes[stream_index] = Mix()
|
self.stream_mixes[stream_index] = Mix()
|
||||||
self.stream_index_to_name[stream_index] = stream_name
|
self.stream_index_to_name[stream_index] = stream_name
|
||||||
self.stream_name_to_index[stream_name] = stream_index
|
self.stream_name_to_index[stream_name] = stream_index
|
||||||
self.completed_callback_events[stream_index] = \
|
self.completed_callback_events[stream_index] = (
|
||||||
completed_callback_event if completed_callback_event else Event()
|
completed_callback_event if completed_callback_event else Event()
|
||||||
|
)
|
||||||
|
|
||||||
return stream_index
|
return stream_index
|
||||||
|
|
||||||
|
@ -811,8 +954,7 @@ class SoundPlugin(Plugin):
|
||||||
else:
|
else:
|
||||||
self.playback_paused_changed[stream_index] = Event()
|
self.playback_paused_changed[stream_index] = Event()
|
||||||
|
|
||||||
self.logger.info('Playback started on stream index {}'.
|
self.logger.info('Playback started on stream index %d', stream_index)
|
||||||
format(stream_index))
|
|
||||||
|
|
||||||
return stream_index
|
return stream_index
|
||||||
|
|
||||||
|
@ -835,8 +977,7 @@ class SoundPlugin(Plugin):
|
||||||
i = self.stream_name_to_index.get(i)
|
i = self.stream_name_to_index.get(i)
|
||||||
stream = self.active_streams.get(i)
|
stream = self.active_streams.get(i)
|
||||||
if not stream:
|
if not stream:
|
||||||
self.logger.info('No such stream index or name: {}'.
|
self.logger.info('No such stream index or name: %d', i)
|
||||||
format(i))
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if self.completed_callback_events[i]:
|
if self.completed_callback_events[i]:
|
||||||
|
@ -859,9 +1000,10 @@ class SoundPlugin(Plugin):
|
||||||
if name in self.stream_name_to_index:
|
if name in self.stream_name_to_index:
|
||||||
del self.stream_name_to_index[name]
|
del self.stream_name_to_index[name]
|
||||||
|
|
||||||
self.logger.info('Playback stopped on streams [{}]'.format(
|
self.logger.info(
|
||||||
', '.join([str(stream) for stream in
|
'Playback stopped on streams [%s]',
|
||||||
completed_callback_events.keys()])))
|
', '.join([str(stream) for stream in completed_callback_events]),
|
||||||
|
)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def pause_playback(self, streams=None):
|
def pause_playback(self, streams=None):
|
||||||
|
@ -881,8 +1023,7 @@ class SoundPlugin(Plugin):
|
||||||
i = self.stream_name_to_index.get(i)
|
i = self.stream_name_to_index.get(i)
|
||||||
stream = self.active_streams.get(i)
|
stream = self.active_streams.get(i)
|
||||||
if not stream:
|
if not stream:
|
||||||
self.logger.info('No such stream index or name: {}'.
|
self.logger.info('No such stream index or name: %d', i)
|
||||||
format(i))
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if self.playback_state[i] == PlaybackState.PAUSED:
|
if self.playback_state[i] == PlaybackState.PAUSED:
|
||||||
|
@ -894,8 +1035,10 @@ class SoundPlugin(Plugin):
|
||||||
|
|
||||||
self.playback_paused_changed[i].set()
|
self.playback_paused_changed[i].set()
|
||||||
|
|
||||||
self.logger.info('Playback pause toggled on streams [{}]'.format(
|
self.logger.info(
|
||||||
', '.join([str(stream) for stream in streams])))
|
'Playback pause toggled on streams [%s]',
|
||||||
|
', '.join([str(stream) for stream in streams]),
|
||||||
|
)
|
||||||
|
|
||||||
def start_recording(self):
|
def start_recording(self):
|
||||||
with self.recording_state_lock:
|
with self.recording_state_lock:
|
||||||
|
@ -921,8 +1064,14 @@ class SoundPlugin(Plugin):
|
||||||
self.recording_paused_changed.set()
|
self.recording_paused_changed.set()
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def release(self, stream_index=None, stream_name=None,
|
def release(
|
||||||
sound_index=None, midi_note=None, frequency=None):
|
self,
|
||||||
|
stream_index=None,
|
||||||
|
stream_name=None,
|
||||||
|
sound_index=None,
|
||||||
|
midi_note=None,
|
||||||
|
frequency=None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Remove a sound from an active stream, either by sound index (use
|
Remove a sound from an active stream, either by sound index (use
|
||||||
:meth:`platypush.sound.plugin.SoundPlugin.query_streams` to get
|
:meth:`platypush.sound.plugin.SoundPlugin.query_streams` to get
|
||||||
|
@ -949,25 +1098,26 @@ class SoundPlugin(Plugin):
|
||||||
|
|
||||||
if stream_name:
|
if stream_name:
|
||||||
if stream_index:
|
if stream_index:
|
||||||
raise RuntimeError('stream_index and stream name are ' +
|
raise RuntimeError(
|
||||||
'mutually exclusive')
|
'stream_index and stream name are ' + 'mutually exclusive'
|
||||||
|
)
|
||||||
stream_index = self.stream_name_to_index.get(stream_name)
|
stream_index = self.stream_name_to_index.get(stream_name)
|
||||||
|
|
||||||
mixes = {
|
mixes = (
|
||||||
i: mix for i, mix in self.stream_mixes.items()
|
self.stream_mixes.copy()
|
||||||
} if stream_index is None else {
|
if stream_index is None
|
||||||
stream_index: self.stream_mixes[stream_index]
|
else {stream_index: self.stream_mixes[stream_index]}
|
||||||
}
|
)
|
||||||
|
|
||||||
streams_to_stop = []
|
streams_to_stop = []
|
||||||
|
|
||||||
for i, mix in mixes.items():
|
for i, mix in mixes.items():
|
||||||
for j, sound in enumerate(mix):
|
for j, sound in enumerate(mix):
|
||||||
if (sound_index is not None and j == sound_index) or \
|
if (
|
||||||
(midi_note is not None
|
(sound_index is not None and j == sound_index)
|
||||||
and sound.get('midi_note') == midi_note) or \
|
or (midi_note is not None and sound.get('midi_note') == midi_note)
|
||||||
(frequency is not None
|
or (frequency is not None and sound.get('frequency') == frequency)
|
||||||
and sound.get('frequency') == frequency):
|
):
|
||||||
if len(list(mix)) == 1:
|
if len(list(mix)) == 1:
|
||||||
# Last sound in the mix
|
# Last sound in the mix
|
||||||
streams_to_stop.append(i)
|
streams_to_stop.append(i)
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
"""
|
|
||||||
.. moduleauthor:: Fabio Manganiello <blacklight86@gmail.com>
|
|
||||||
"""
|
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
@ -15,7 +11,7 @@ class WaveShape(enum.Enum):
|
||||||
TRIANG = 'triang'
|
TRIANG = 'triang'
|
||||||
|
|
||||||
|
|
||||||
class Sound(object):
|
class Sound:
|
||||||
"""
|
"""
|
||||||
Models a basic synthetic sound that can be played through an audio device
|
Models a basic synthetic sound that can be played through an audio device
|
||||||
"""
|
"""
|
||||||
|
@ -34,9 +30,16 @@ class Sound(object):
|
||||||
duration = None
|
duration = None
|
||||||
shape = None
|
shape = None
|
||||||
|
|
||||||
def __init__(self, midi_note=midi_note, frequency=None, phase=phase,
|
def __init__(
|
||||||
gain=gain, duration=duration, shape=WaveShape.SIN,
|
self,
|
||||||
A_frequency=STANDARD_A_FREQUENCY):
|
midi_note=midi_note,
|
||||||
|
frequency=None,
|
||||||
|
phase=phase,
|
||||||
|
gain=gain,
|
||||||
|
duration=duration,
|
||||||
|
shape=WaveShape.SIN,
|
||||||
|
A_frequency=STANDARD_A_FREQUENCY,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
You can construct a sound either from a MIDI note or a base frequency
|
You can construct a sound either from a MIDI note or a base frequency
|
||||||
|
|
||||||
|
@ -67,20 +70,24 @@ class Sound(object):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if midi_note and frequency:
|
if midi_note and frequency:
|
||||||
raise RuntimeError('Please specify either a MIDI note or a base ' +
|
raise RuntimeError(
|
||||||
'frequency')
|
'Please specify either a MIDI note or a base ' + 'frequency'
|
||||||
|
)
|
||||||
|
|
||||||
if midi_note:
|
if midi_note:
|
||||||
self.midi_note = midi_note
|
self.midi_note = midi_note
|
||||||
self.frequency = self.note_to_freq(midi_note=midi_note,
|
self.frequency = self.note_to_freq(
|
||||||
A_frequency=A_frequency)
|
midi_note=midi_note, A_frequency=A_frequency
|
||||||
|
)
|
||||||
elif frequency:
|
elif frequency:
|
||||||
self.frequency = frequency
|
self.frequency = frequency
|
||||||
self.midi_note = self.freq_to_note(frequency=frequency,
|
self.midi_note = self.freq_to_note(
|
||||||
A_frequency=A_frequency)
|
frequency=frequency, A_frequency=A_frequency
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise RuntimeError('Please specify either a MIDI note or a base ' +
|
raise RuntimeError(
|
||||||
'frequency')
|
'Please specify either a MIDI note or a base ' + 'frequency'
|
||||||
|
)
|
||||||
|
|
||||||
self.phase = phase
|
self.phase = phase
|
||||||
self.gain = gain
|
self.gain = gain
|
||||||
|
@ -99,8 +106,7 @@ class Sound(object):
|
||||||
:type A_frequency: float
|
:type A_frequency: float
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return (2.0 ** ((midi_note - cls.STANDARD_A_MIDI_NOTE) / 12.0)) \
|
return (2.0 ** ((midi_note - cls.STANDARD_A_MIDI_NOTE) / 12.0)) * A_frequency
|
||||||
* A_frequency
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def freq_to_note(cls, frequency, A_frequency=STANDARD_A_FREQUENCY):
|
def freq_to_note(cls, frequency, A_frequency=STANDARD_A_FREQUENCY):
|
||||||
|
@ -116,10 +122,11 @@ class Sound(object):
|
||||||
|
|
||||||
# TODO return also the offset in % between the provided frequency
|
# TODO return also the offset in % between the provided frequency
|
||||||
# and the standard MIDI note frequency
|
# and the standard MIDI note frequency
|
||||||
return int(12.0 * math.log(frequency / A_frequency, 2)
|
return int(
|
||||||
+ cls.STANDARD_A_MIDI_NOTE)
|
12.0 * math.log(frequency / A_frequency, 2) + cls.STANDARD_A_MIDI_NOTE
|
||||||
|
)
|
||||||
|
|
||||||
def get_wave(self, t_start=0., t_end=0., samplerate=_DEFAULT_SAMPLERATE):
|
def get_wave(self, t_start=0.0, t_end=0.0, samplerate=_DEFAULT_SAMPLERATE):
|
||||||
"""
|
"""
|
||||||
Get the wave binary data associated to this sound
|
Get the wave binary data associated to this sound
|
||||||
|
|
||||||
|
@ -137,6 +144,7 @@ class Sound(object):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
x = np.linspace(t_start, t_end, int((t_end - t_start) * samplerate))
|
x = np.linspace(t_start, t_end, int((t_end - t_start) * samplerate))
|
||||||
|
|
||||||
x = x.reshape(len(x), 1)
|
x = x.reshape(len(x), 1)
|
||||||
|
@ -148,8 +156,7 @@ class Sound(object):
|
||||||
wave[wave < 0] = -1
|
wave[wave < 0] = -1
|
||||||
wave[wave >= 0] = 1
|
wave[wave >= 0] = 1
|
||||||
elif self.shape == WaveShape.SAWTOOTH or self.shape == WaveShape.TRIANG:
|
elif self.shape == WaveShape.SAWTOOTH or self.shape == WaveShape.TRIANG:
|
||||||
wave = 2 * (self.frequency * x -
|
wave = 2 * (self.frequency * x - np.floor(0.5 + self.frequency * x))
|
||||||
np.floor(0.5 + self.frequency * x))
|
|
||||||
if self.shape == WaveShape.TRIANG:
|
if self.shape == WaveShape.TRIANG:
|
||||||
wave = 2 * np.abs(wave) - 1
|
wave = 2 * np.abs(wave) - 1
|
||||||
else:
|
else:
|
||||||
|
@ -157,8 +164,14 @@ class Sound(object):
|
||||||
|
|
||||||
return self.gain * wave
|
return self.gain * wave
|
||||||
|
|
||||||
def fft(self, t_start=0., t_end=0., samplerate=_DEFAULT_SAMPLERATE,
|
def fft(
|
||||||
freq_range=None, freq_buckets=None):
|
self,
|
||||||
|
t_start=0.0,
|
||||||
|
t_end=0.0,
|
||||||
|
samplerate=_DEFAULT_SAMPLERATE,
|
||||||
|
freq_range=None,
|
||||||
|
freq_buckets=None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Get the real part of the Fourier transform associated to a time-bounded
|
Get the real part of the Fourier transform associated to a time-bounded
|
||||||
sample of this sound
|
sample of this sound
|
||||||
|
@ -173,7 +186,8 @@ class Sound(object):
|
||||||
:type samplerate: int
|
:type samplerate: int
|
||||||
|
|
||||||
:param freq_range: FFT frequency range. Default: ``(0, samplerate/2)``
|
:param freq_range: FFT frequency range. Default: ``(0, samplerate/2)``
|
||||||
(see `Nyquist-Shannon sampling theorem <https://en.wikipedia.org/wiki/Nyquist%E2%80%93Shannon_sampling_theorem>`_)
|
(see`Nyquist-Shannon sampling theorem
|
||||||
|
<https://en.wikipedia.org/wiki/Nyquist%E2%80%93Shannon_sampling_theorem>`_)
|
||||||
:type freq_range: list or tuple with 2 int elements (range)
|
:type freq_range: list or tuple with 2 int elements (range)
|
||||||
|
|
||||||
:param freq_buckets: Number of buckets to subdivide the frequency range.
|
:param freq_buckets: Number of buckets to subdivide the frequency range.
|
||||||
|
@ -190,7 +204,7 @@ class Sound(object):
|
||||||
|
|
||||||
wave = self.get_wave(t_start=t_start, t_end=t_end, samplerate=samplerate)
|
wave = self.get_wave(t_start=t_start, t_end=t_end, samplerate=samplerate)
|
||||||
fft = np.fft.fft(wave.reshape(len(wave)))
|
fft = np.fft.fft(wave.reshape(len(wave)))
|
||||||
fft = fft.real[freq_range[0]:freq_range[1]]
|
fft = fft.real[freq_range[0] : freq_range[1]]
|
||||||
|
|
||||||
if freq_buckets is not None:
|
if freq_buckets is not None:
|
||||||
fft = np.histogram(fft, bins=freq_buckets)
|
fft = np.histogram(fft, bins=freq_buckets)
|
||||||
|
@ -224,7 +238,7 @@ class Sound(object):
|
||||||
raise RuntimeError('Usage: {}'.format(__doc__))
|
raise RuntimeError('Usage: {}'.format(__doc__))
|
||||||
|
|
||||||
|
|
||||||
class Mix(object):
|
class Mix:
|
||||||
"""
|
"""
|
||||||
This class models a set of mixed :class:`Sound` instances that can be played
|
This class models a set of mixed :class:`Sound` instances that can be played
|
||||||
through an audio stream to an audio device
|
through an audio stream to an audio device
|
||||||
|
@ -251,15 +265,22 @@ class Mix(object):
|
||||||
|
|
||||||
def remove(self, sound_index):
|
def remove(self, sound_index):
|
||||||
if sound_index >= len(self._sounds):
|
if sound_index >= len(self._sounds):
|
||||||
self.logger.error('No such sound index: {} in mix {}'.format(
|
self.logger.error(
|
||||||
sound_index, list(self)))
|
'No such sound index: {} in mix {}'.format(sound_index, list(self))
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self._sounds.pop(sound_index)
|
self._sounds.pop(sound_index)
|
||||||
|
|
||||||
# noinspection PyProtectedMember
|
# noinspection PyProtectedMember
|
||||||
def get_wave(self, t_start=0., t_end=0., normalize_range=(-1.0, 1.0),
|
def get_wave(
|
||||||
on_clip='scale', samplerate=Sound._DEFAULT_SAMPLERATE):
|
self,
|
||||||
|
t_start=0.0,
|
||||||
|
t_end=0.0,
|
||||||
|
normalize_range=(-1.0, 1.0),
|
||||||
|
on_clip='scale',
|
||||||
|
samplerate=Sound._DEFAULT_SAMPLERATE,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Get the wave binary data associated to this mix
|
Get the wave binary data associated to this mix
|
||||||
|
|
||||||
|
@ -289,8 +310,9 @@ class Mix(object):
|
||||||
wave = None
|
wave = None
|
||||||
|
|
||||||
for sound in self._sounds:
|
for sound in self._sounds:
|
||||||
sound_wave = sound.get_wave(t_start=t_start, t_end=t_end,
|
sound_wave = sound.get_wave(
|
||||||
samplerate=samplerate)
|
t_start=t_start, t_end=t_end, samplerate=samplerate
|
||||||
|
)
|
||||||
|
|
||||||
if wave is None:
|
if wave is None:
|
||||||
wave = sound_wave
|
wave = sound_wave
|
||||||
|
@ -298,8 +320,9 @@ class Mix(object):
|
||||||
wave += sound_wave
|
wave += sound_wave
|
||||||
|
|
||||||
if normalize_range and len(wave):
|
if normalize_range and len(wave):
|
||||||
scale_factor = (normalize_range[1] - normalize_range[0]) / \
|
scale_factor = (normalize_range[1] - normalize_range[0]) / (
|
||||||
(wave.max() - wave.min())
|
wave.max() - wave.min()
|
||||||
|
)
|
||||||
|
|
||||||
if scale_factor < 1.0: # Wave clipping
|
if scale_factor < 1.0: # Wave clipping
|
||||||
if on_clip == 'scale':
|
if on_clip == 'scale':
|
||||||
|
@ -308,14 +331,21 @@ class Mix(object):
|
||||||
wave[wave < normalize_range[0]] = normalize_range[0]
|
wave[wave < normalize_range[0]] = normalize_range[0]
|
||||||
wave[wave > normalize_range[1]] = normalize_range[1]
|
wave[wave > normalize_range[1]] = normalize_range[1]
|
||||||
else:
|
else:
|
||||||
raise RuntimeError('Supported values for "on_clip": ' +
|
raise RuntimeError(
|
||||||
'"scale" or "clip"')
|
'Supported values for "on_clip": ' + '"scale" or "clip"'
|
||||||
|
)
|
||||||
|
|
||||||
return wave
|
return wave
|
||||||
|
|
||||||
# noinspection PyProtectedMember
|
# noinspection PyProtectedMember
|
||||||
def fft(self, t_start=0., t_end=0., samplerate=Sound._DEFAULT_SAMPLERATE,
|
def fft(
|
||||||
freq_range=None, freq_buckets=None):
|
self,
|
||||||
|
t_start=0.0,
|
||||||
|
t_end=0.0,
|
||||||
|
samplerate=Sound._DEFAULT_SAMPLERATE,
|
||||||
|
freq_range=None,
|
||||||
|
freq_buckets=None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Get the real part of the Fourier transform associated to a time-bounded
|
Get the real part of the Fourier transform associated to a time-bounded
|
||||||
sample of this mix
|
sample of this mix
|
||||||
|
@ -330,7 +360,8 @@ class Mix(object):
|
||||||
:type samplerate: int
|
:type samplerate: int
|
||||||
|
|
||||||
:param freq_range: FFT frequency range. Default: ``(0, samplerate/2)``
|
:param freq_range: FFT frequency range. Default: ``(0, samplerate/2)``
|
||||||
(see `Nyquist-Shannon sampling theorem <https://en.wikipedia.org/wiki/Nyquist%E2%80%93Shannon_sampling_theorem>`_)
|
(see `Nyquist-Shannon sampling theorem
|
||||||
|
<https://en.wikipedia.org/wiki/Nyquist%E2%80%93Shannon_sampling_theorem>`_)
|
||||||
:type freq_range: list or tuple with 2 int elements (range)
|
:type freq_range: list or tuple with 2 int elements (range)
|
||||||
|
|
||||||
:param freq_buckets: Number of buckets to subdivide the frequency range.
|
:param freq_buckets: Number of buckets to subdivide the frequency range.
|
||||||
|
@ -347,7 +378,7 @@ class Mix(object):
|
||||||
|
|
||||||
wave = self.get_wave(t_start=t_start, t_end=t_end, samplerate=samplerate)
|
wave = self.get_wave(t_start=t_start, t_end=t_end, samplerate=samplerate)
|
||||||
fft = np.fft.fft(wave.reshape(len(wave)))
|
fft = np.fft.fft(wave.reshape(len(wave)))
|
||||||
fft = fft.real[freq_range[0]:freq_range[1]]
|
fft = fft.real[freq_range[0] : freq_range[1]]
|
||||||
|
|
||||||
if freq_buckets is not None:
|
if freq_buckets is not None:
|
||||||
fft = np.histogram(fft, bins=freq_buckets)
|
fft = np.histogram(fft, bins=freq_buckets)
|
||||||
|
@ -370,4 +401,5 @@ class Mix(object):
|
||||||
|
|
||||||
return duration
|
return duration
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
# vim:sw=4:ts=4:et:
|
||||||
|
|
Loading…
Reference in a new issue