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:
Fabio Manganiello 2023-05-30 11:08:27 +02:00
parent 8b5eb82497
commit d7208c6bbc
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
10 changed files with 657 additions and 322 deletions

View file

@ -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:

View 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:

View file

@ -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

View file

@ -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

View file

@ -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']

View file

@ -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,

View file

@ -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)

View file

@ -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]

View file

@ -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_device=None,
output_device=None,
input_blocksize=Sound._DEFAULT_BLOCKSIZE, input_blocksize=Sound._DEFAULT_BLOCKSIZE,
output_blocksize=Sound._DEFAULT_BLOCKSIZE, **kwargs): 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,
blocksize=blocksize,
device=device,
channels=channels,
dtype='float32', dtype='float32',
callback=self._play_audio_callback( callback=self._play_audio_callback(
q=q, blocksize=blocksize, q=q,
blocksize=blocksize,
streamtype=streamtype, streamtype=streamtype,
stream_index=stream_index), stream_index=stream_index,
finished_callback=completed_callback_event.set) ),
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,37 +489,49 @@ 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,
callback=audio_callback,
dtype=dtype,
latency=latency,
blocksize=blocksize,
), open(fifo, 'wb') as audio_queue:
self.start_recording() self.start_recording()
get_bus().post(SoundRecordingStartedEvent()) get_bus().post(SoundRecordingStartedEvent())
self.logger.info('Started recording from device [{}]'.format(device)) self.logger.info('Started recording from device [%s]', 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
time.time() - recording_started_time < duration): 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, '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):
@ -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(), 'timestamp': time.time(),
'frames': frames, 'frames': frames,
'time': duration, 'time': duration,
'data': indata.copy() '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, '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 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(
target=recording_thread,
args=( args=(
outfile, duration, device, sample_rate, format, blocksize, latency, channels, subtype) outfile,
duration,
device,
sample_rate,
format,
blocksize,
latency,
channels,
subtype,
),
).start() ).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,
channels=channels,
blocksize=blocksize,
latency=latency,
device=(input_device, output_device), device=(input_device, output_device),
dtype=dtype, callback=audio_callback) 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)

View file

@ -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: