diff --git a/CHANGELOG.md b/CHANGELOG.md index 3804ba2b..e2f4333f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ reported only starting from v0.20.2. ### Added - Added [Wallabag integration](https://git.platypush.tech/platypush/platypush/issues/224). +- Added [Mimic3 TTS integration](https://git.platypush.tech/platypush/platypush/issues/226). ## [0.23.6] - 2022-09-19 diff --git a/docs/source/platypush/plugins/tts.mimic3.rst b/docs/source/platypush/plugins/tts.mimic3.rst new file mode 100644 index 00000000..d6e280b3 --- /dev/null +++ b/docs/source/platypush/plugins/tts.mimic3.rst @@ -0,0 +1,5 @@ +``tts.mimic3`` +============== + +.. automodule:: platypush.plugins.tts.mimic3 + :members: diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index ba4c0ace..8c424189 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -132,6 +132,7 @@ Plugins platypush/plugins/trello.rst platypush/plugins/tts.rst platypush/plugins/tts.google.rst + platypush/plugins/tts.mimic3.rst platypush/plugins/tv.samsung.ws.rst platypush/plugins/twilio.rst platypush/plugins/udp.rst diff --git a/platypush/backend/http/app/routes/plugins/tts/__init__.py b/platypush/backend/http/app/routes/plugins/tts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/platypush/backend/http/app/routes/plugins/tts/mimic3.py b/platypush/backend/http/app/routes/plugins/tts/mimic3.py new file mode 100644 index 00000000..8bf37f6d --- /dev/null +++ b/platypush/backend/http/app/routes/plugins/tts/mimic3.py @@ -0,0 +1,46 @@ +import requests +from urllib.parse import urljoin + +from flask import abort, request, Blueprint + +from platypush.backend.http.app import template_folder + +mimic3 = Blueprint('mimic3', __name__, template_folder=template_folder) + +# Declare routes list +__routes__ = [ + mimic3, +] + + +@mimic3.route('/tts/mimic3/say', methods=['GET']) +def proxy_tts_request(): + """ + This route is used to proxy the POST request to the Mimic3 TTS server + through a GET, so it can be easily processed as a URL through a media + plugin. + """ + required_args = { + 'text', + 'server_url', + 'voice', + } + + missing_args = required_args.difference(set(request.args.keys())) + if missing_args: + abort(400, f'Missing parameters: {missing_args}') + + args = {arg: request.args[arg] for arg in required_args} + + rs = requests.post( + urljoin(args['server_url'], '/api/tts'), + data=args['text'], + params={ + 'voice': args['voice'], + }, + ) + + return rs.content + + +# vim:sw=4:ts=4:et: diff --git a/platypush/backend/http/app/utils.py b/platypush/backend/http/app/utils.py index 17c2a491..70113b45 100644 --- a/platypush/backend/http/app/utils.py +++ b/platypush/backend/http/app/utils.py @@ -35,13 +35,15 @@ def logger(): 'format': '%(asctime)-15s|%(levelname)5s|%(name)s|%(message)s', } - level = (Config.get('backend.http') or {}).get('logging') or \ - (Config.get('logging') or {}).get('level') + level = (Config.get('backend.http') or {}).get('logging') or ( + Config.get('logging') or {} + ).get('level') filename = (Config.get('backend.http') or {}).get('filename') if level: - log_args['level'] = getattr(logging, level.upper()) \ - if isinstance(level, str) else level + log_args['level'] = ( + getattr(logging, level.upper()) if isinstance(level, str) else level + ) if filename: log_args['filename'] = filename @@ -65,6 +67,7 @@ def get_message_response(msg): # noinspection PyProtectedMember def get_http_port(): from platypush.backend.http import HttpBackend + http_conf = Config.get('backend.http') return http_conf.get('port', HttpBackend._DEFAULT_HTTP_PORT) @@ -72,6 +75,7 @@ def get_http_port(): # noinspection PyProtectedMember def get_websocket_port(): from platypush.backend.http import HttpBackend + http_conf = Config.get('backend.http') return http_conf.get('websocket_port', HttpBackend._DEFAULT_WEBSOCKET_PORT) @@ -89,17 +93,13 @@ def send_message(msg, wait_for_response=True): if isinstance(msg, Request) and wait_for_response: response = get_message_response(msg) - logger().debug('Processing response on the HTTP backend: {}'. - format(response)) + logger().debug('Processing response on the HTTP backend: {}'.format(response)) return response def send_request(action, wait_for_response=True, **kwargs): - msg = { - 'type': 'request', - 'action': action - } + msg = {'type': 'request', 'action': action} if kwargs: msg['args'] = kwargs @@ -113,8 +113,10 @@ def _authenticate_token(): if 'X-Token' in request.headers: user_token = request.headers['X-Token'] - elif 'Authorization' in request.headers and request.headers['Authorization'].startswith('Bearer '): - user_token = request.headers['Authorization'][len('Bearer '):] + elif 'Authorization' in request.headers and request.headers[ + 'Authorization' + ].startswith('Bearer '): + user_token = request.headers['Authorization'][7:] elif 'token' in request.args: user_token = request.args.get('token') else: @@ -176,7 +178,10 @@ def _authenticate_csrf_token(): if user is None: return False - return session.csrf_token is None or request.form.get('csrf_token') == session.csrf_token + return ( + session.csrf_token is None + or request.form.get('csrf_token') == session.csrf_token + ) def authenticate(redirect_page='', skip_auth_methods=None, check_csrf_token=False): @@ -208,7 +213,9 @@ def authenticate(redirect_page='', skip_auth_methods=None, check_csrf_token=Fals if session_auth_ok: return f(*args, **kwargs) - return redirect('/login?redirect=' + (redirect_page or request.url), 307) + return redirect( + '/login?redirect=' + (redirect_page or request.url), 307 + ) # CSRF token check if check_csrf_token: @@ -217,15 +224,22 @@ def authenticate(redirect_page='', skip_auth_methods=None, check_csrf_token=Fals return abort(403, 'Invalid or missing csrf_token') if n_users == 0 and 'session' not in skip_methods: - return redirect('/register?redirect=' + (redirect_page or request.url), 307) + return redirect( + '/register?redirect=' + (redirect_page or request.url), 307 + ) - if ('http' not in skip_methods and http_auth_ok) or \ - ('token' not in skip_methods and token_auth_ok) or \ - ('session' not in skip_methods and session_auth_ok): + if ( + ('http' not in skip_methods and http_auth_ok) + or ('token' not in skip_methods and token_auth_ok) + or ('session' not in skip_methods and session_auth_ok) + ): return f(*args, **kwargs) - return Response('Authentication required', 401, - {'WWW-Authenticate': 'Basic realm="Login required"'}) + return Response( + 'Authentication required', + 401, + {'WWW-Authenticate': 'Basic realm="Login required"'}, + ) return wrapper @@ -233,42 +247,57 @@ def authenticate(redirect_page='', skip_auth_methods=None, check_csrf_token=Fals def get_routes(): - routes_dir = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'routes') + routes_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'routes') routes = [] base_module = '.'.join(__name__.split('.')[:-1]) - for path, dirs, files in os.walk(routes_dir): + for path, _, files in os.walk(routes_dir): for f in files: if f.endswith('.py'): mod_name = '.'.join( - (base_module + '.' + os.path.join(path, f).replace( - os.path.dirname(__file__), '')[1:].replace(os.sep, '.')).split('.') - [:(-2 if f == '__init__.py' else -1)]) + ( + base_module + + '.' + + os.path.join(path, f) + .replace(os.path.dirname(__file__), '')[1:] + .replace(os.sep, '.') + ).split('.')[: (-2 if f == '__init__.py' else -1)] + ) try: mod = importlib.import_module(mod_name) if hasattr(mod, '__routes__'): routes.extend(mod.__routes__) except Exception as e: - logger().warning('Could not import routes from {}/{}: {}: {}'. - format(path, f, type(e), str(e))) + logger().warning( + 'Could not import routes from {}/{}: {}: {}'.format( + path, f, type(e), str(e) + ) + ) return routes def get_local_base_url(): http_conf = Config.get('backend.http') or {} - return '{proto}://localhost:{port}'.format( + bind_address = http_conf.get('bind_address') + if not bind_address or bind_address == '0.0.0.0': + bind_address = 'localhost' + + return '{proto}://{host}:{port}'.format( proto=('https' if http_conf.get('ssl_cert') else 'http'), - port=get_http_port()) + host=bind_address, + port=get_http_port(), + ) def get_remote_base_url(): http_conf = Config.get('backend.http') or {} return '{proto}://{host}:{port}'.format( proto=('https' if http_conf.get('ssl_cert') else 'http'), - host=get_ip_or_hostname(), port=get_http_port()) + host=get_ip_or_hostname(), + port=get_http_port(), + ) # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/media/mpv/__init__.py b/platypush/plugins/media/mpv/__init__.py index f032f328..19853cba 100644 --- a/platypush/plugins/media/mpv/__init__.py +++ b/platypush/plugins/media/mpv/__init__.py @@ -3,8 +3,15 @@ import threading from platypush.context import get_bus from platypush.plugins.media import PlayerState, MediaPlugin -from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \ - MediaPauseEvent, MediaStopEvent, NewPlayingMediaEvent, MediaSeekEvent, MediaResumeEvent +from platypush.message.event.media import ( + MediaPlayEvent, + MediaPlayRequestEvent, + MediaPauseEvent, + MediaStopEvent, + NewPlayingMediaEvent, + MediaSeekEvent, + MediaResumeEvent, +) from platypush.plugins import action @@ -66,29 +73,58 @@ class MediaMpvPlugin(MediaPlugin): def _event_callback(self): def callback(event): - from mpv import MpvEventID as Event - from mpv import MpvEventEndFile as EndFile + from mpv import ( + MpvEvent, + MpvEventID as Event, + MpvEventEndFile as EndFile, + ) self.logger.info('Received mpv event: {}'.format(event)) + if isinstance(event, MpvEvent): + event = event.as_dict() + evt = event.get('event_id') if not evt: return - if (evt == Event.FILE_LOADED or evt == Event.START_FILE) and self._get_current_resource(): + if ( + evt == Event.FILE_LOADED or evt == Event.START_FILE + ) and self._get_current_resource(): self._playback_rebounce_event.set() - self._post_event(NewPlayingMediaEvent, resource=self._get_current_resource(), - title=self._player.filename) + self._post_event( + NewPlayingMediaEvent, + resource=self._get_current_resource(), + title=self._player.filename, + ) elif evt == Event.PLAYBACK_RESTART: self._playback_rebounce_event.set() - self._post_event(MediaPlayEvent, resource=self._get_current_resource(), title=self._player.filename) + self._post_event( + MediaPlayEvent, + resource=self._get_current_resource(), + title=self._player.filename, + ) elif evt == Event.PAUSE: - self._post_event(MediaPauseEvent, resource=self._get_current_resource(), title=self._player.filename) + self._post_event( + MediaPauseEvent, + resource=self._get_current_resource(), + title=self._player.filename, + ) elif evt == Event.UNPAUSE: - self._post_event(MediaResumeEvent, resource=self._get_current_resource(), title=self._player.filename) - elif evt == Event.SHUTDOWN or evt == Event.IDLE or ( - evt == Event.END_FILE and event.get('event', {}).get('reason') in - [EndFile.EOF, EndFile.ABORTED, EndFile.QUIT]): + self._post_event( + MediaResumeEvent, + resource=self._get_current_resource(), + title=self._player.filename, + ) + elif ( + evt == Event.SHUTDOWN + or evt == Event.IDLE + or ( + evt == Event.END_FILE + and event.get('event', {}).get('reason') + in [EndFile.EOF, EndFile.ABORTED, EndFile.QUIT] + ) + ): playback_rebounced = self._playback_rebounce_event.wait(timeout=0.5) if playback_rebounced: self._playback_rebounce_event.clear() @@ -147,7 +183,7 @@ class MediaMpvPlugin(MediaPlugin): @action def pause(self): - """ Toggle the paused state """ + """Toggle the paused state""" if not self._player: return None, 'No mpv instance is running' @@ -156,7 +192,7 @@ class MediaMpvPlugin(MediaPlugin): @action def quit(self): - """ Stop and quit the player """ + """Stop and quit the player""" if not self._player: return None, 'No mpv instance is running' @@ -167,19 +203,19 @@ class MediaMpvPlugin(MediaPlugin): @action def stop(self): - """ Stop and quit the player """ + """Stop and quit the player""" return self.quit() @action def voldown(self, step=10.0): - """ Volume down by (default: 10)% """ + """Volume down by (default: 10)%""" if not self._player: return None, 'No mpv instance is running' return self.set_volume(self._player.volume - step) @action def volup(self, step=10.0): - """ Volume up by (default: 10)% """ + """Volume up by (default: 10)%""" if not self._player: return None, 'No mpv instance is running' return self.set_volume(self._player.volume + step) @@ -211,14 +247,13 @@ class MediaMpvPlugin(MediaPlugin): return None, 'No mpv instance is running' if not self._player.seekable: return None, 'The resource is not seekable' - pos = min(self._player.time_pos + self._player.time_remaining, - max(0, position)) + pos = min(self._player.time_pos + self._player.time_remaining, max(0, position)) self._player.time_pos = pos return self.status() @action def back(self, offset=30.0): - """ Back by (default: 30) seconds """ + """Back by (default: 30) seconds""" if not self._player: return None, 'No mpv instance is running' if not self._player.seekable: @@ -228,47 +263,44 @@ class MediaMpvPlugin(MediaPlugin): @action def forward(self, offset=30.0): - """ Forward by (default: 30) seconds """ + """Forward by (default: 30) seconds""" if not self._player: return None, 'No mpv instance is running' if not self._player.seekable: return None, 'The resource is not seekable' - pos = min(self._player.time_pos + self._player.time_remaining, - self._player.time_pos + offset) + pos = min( + self._player.time_pos + self._player.time_remaining, + self._player.time_pos + offset, + ) return self.seek(pos) @action def next(self): - """ Play the next item in the queue """ + """Play the next item in the queue""" if not self._player: return None, 'No mpv instance is running' self._player.playlist_next() @action def prev(self): - """ Play the previous item in the queue """ + """Play the previous item in the queue""" if not self._player: return None, 'No mpv instance is running' self._player.playlist_prev() @action def toggle_subtitles(self, visible=None): - """ Toggle the subtitles visibility """ + """Toggle the subtitles visibility""" return self.toggle_property('sub_visibility') @action def add_subtitles(self, filename): - """ Add a subtitles file """ + """Add a subtitles file""" return self._player.sub_add(filename) - @action - def remove_subtitles(self, sub_id): - """ Remove a subtitles track by id """ - return self._player.sub_remove(sub_id) - @action def toggle_fullscreen(self): - """ Toggle the fullscreen mode """ + """Toggle the fullscreen mode""" return self.toggle_property('fullscreen') # noinspection PyShadowingBuiltins @@ -319,15 +351,17 @@ class MediaMpvPlugin(MediaPlugin): @action def set_subtitles(self, filename, *args, **kwargs): - """ Sets media subtitles from filename """ + """Sets media subtitles from filename""" # noinspection PyTypeChecker return self.set_property(subfile=filename, sub_visibility=True) @action - def remove_subtitles(self): - """ Removes (hides) the subtitles """ + def remove_subtitles(self, sub_id=None): + """Removes (hides) the subtitles""" if not self._player: return None, 'No mpv instance is running' + if sub_id: + return self._player.sub_remove(sub_id) self._player.sub_visibility = False @action @@ -350,7 +384,7 @@ class MediaMpvPlugin(MediaPlugin): @action def mute(self): - """ Toggle mute state """ + """Toggle mute state""" if not self._player: return None, 'No mpv instance is running' mute = not self._player.mute @@ -382,28 +416,35 @@ class MediaMpvPlugin(MediaPlugin): return {'state': PlayerState.STOP.value} return { - 'audio_channels': getattr(self._player, 'audio_channels'), - 'audio_codec': getattr(self._player, 'audio_codec_name'), - 'delay': getattr(self._player, 'delay'), - 'duration': getattr(self._player, 'playback_time', 0) + getattr(self._player, 'playtime_remaining', 0) - if getattr(self._player, 'playtime_remaining') else None, - 'filename': getattr(self._player, 'filename'), - 'file_size': getattr(self._player, 'file_size'), - 'fullscreen': getattr(self._player, 'fs'), - 'mute': getattr(self._player, 'mute'), - 'name': getattr(self._player, 'name'), - 'pause': getattr(self._player, 'pause'), - 'percent_pos': getattr(self._player, 'percent_pos'), - 'position': getattr(self._player, 'playback_time'), - 'seekable': getattr(self._player, 'seekable'), - 'state': (PlayerState.PAUSE.value if self._player.pause else PlayerState.PLAY.value), - 'title': getattr(self._player, 'media_title') or getattr(self._player, 'filename'), + 'audio_channels': getattr(self._player, 'audio_channels', None), + 'audio_codec': getattr(self._player, 'audio_codec_name', None), + 'delay': getattr(self._player, 'delay', None), + 'duration': getattr(self._player, 'playback_time', 0) + + getattr(self._player, 'playtime_remaining', 0) + if getattr(self._player, 'playtime_remaining', None) + else None, + 'filename': getattr(self._player, 'filename', None), + 'file_size': getattr(self._player, 'file_size', None), + 'fullscreen': getattr(self._player, 'fs', None), + 'mute': getattr(self._player, 'mute', None), + 'name': getattr(self._player, 'name', None), + 'pause': getattr(self._player, 'pause', None), + 'percent_pos': getattr(self._player, 'percent_pos', None), + 'position': getattr(self._player, 'playback_time', None), + 'seekable': getattr(self._player, 'seekable', None), + 'state': ( + PlayerState.PAUSE.value + if self._player.pause + else PlayerState.PLAY.value + ), + 'title': getattr(self._player, 'media_title', None) + or getattr(self._player, 'filename', None), 'url': self._get_current_resource(), - 'video_codec': getattr(self._player, 'video_codec'), - 'video_format': getattr(self._player, 'video_format'), - 'volume': getattr(self._player, 'volume'), - 'volume_max': getattr(self._player, 'volume_max'), - 'width': getattr(self._player, 'width'), + 'video_codec': getattr(self._player, 'video_codec', None), + 'video_format': getattr(self._player, 'video_format', None), + 'volume': getattr(self._player, 'volume', None), + 'volume_max': getattr(self._player, 'volume_max', None), + 'width': getattr(self._player, 'width', None), } def on_stop(self, callback): @@ -413,12 +454,13 @@ class MediaMpvPlugin(MediaPlugin): if not self._player or not self._player.stream_path: return - return ('file://' if os.path.isfile(self._player.stream_path) - else '') + self._player.stream_path + return ( + 'file://' if os.path.isfile(self._player.stream_path) else '' + ) + self._player.stream_path def _get_resource(self, resource): if self._is_youtube_resource(resource): - return resource # mpv can handle YouTube streaming natively + return resource # mpv can handle YouTube streaming natively return super()._get_resource(resource) diff --git a/platypush/plugins/tts/mimic3/__init__.py b/platypush/plugins/tts/mimic3/__init__.py new file mode 100644 index 00000000..6bcf8015 --- /dev/null +++ b/platypush/plugins/tts/mimic3/__init__.py @@ -0,0 +1,119 @@ +import requests +from typing import Optional +from urllib.parse import urljoin, urlencode +from platypush.backend.http.app.utils import get_local_base_url + +from platypush.context import get_backend +from platypush.plugins import action +from platypush.plugins.tts import TtsPlugin +from platypush.schemas.tts.mimic3 import Mimic3VoiceSchema + + +class TtsMimic3Plugin(TtsPlugin): + """ + TTS plugin that uses the `Mimic3 webserver + `_ provided by `Mycroft + `_ as a text-to-speech engine. + + The easiest way to deploy a Mimic3 instance is probably via Docker: + + .. code-block:: bash + + $ mkdir -p "$HOME/.local/share/mycroft/mimic3" + $ chmod a+rwx "$HOME/.local/share/mycroft/mimic3" + $ docker run --rm \ + -p 59125:59125 \ + -v "%h/.local/share/mycroft/mimic3:/home/mimic3/.local/share/mycroft/mimic3" \ + 'mycroftai/mimic3' + + Requires: + + * At least a *media plugin* (see + :class:`platypush.plugins.media.MediaPlugin`) enabled/configured - + used for speech playback. + * The ``http`` backend (:class:`platypush.backend.http.HttpBackend`) + enabled - used for proxying the API calls. + + """ + + def __init__( + self, + server_url: str, + voice: str = 'en_UK/apope_low', + media_plugin: Optional[str] = None, + player_args: Optional[dict] = None, + **kwargs + ): + """ + :param server_url: Base URL of the web server that runs the Mimic3 engine. + :param voice: Default voice to be used (default: ``en_UK/apope_low``). + You can get a full list of the voices available on the server + through :method:`.voices`. + :param media_plugin: Media plugin to be used for audio playback. Supported: + + - ``media.gstreamer`` + - ``media.omxplayer`` + - ``media.mplayer`` + - ``media.mpv`` + - ``media.vlc`` + + :param player_args: Optional arguments that should be passed to the player plugin's + :meth:`platypush.plugins.media.MediaPlugin.play` method. + """ + super().__init__(media_plugin=media_plugin, player_args=player_args, **kwargs) + + self.server_url = server_url + self.voice = voice + + @action + def say( + self, + text: str, + server_url: Optional[str] = None, + voice: Optional[str] = None, + player_args: Optional[dict] = None, + ): + """ + Say some text. + + :param text: Text to say. + :param server_url: Default ``server_url`` override. + :param voice: Default ``voice`` override. + :param player_args: Default ``player_args`` override. + """ + server_url = server_url or self.server_url + voice = voice or self.voice + player_args = player_args or self.player_args + http = get_backend('http') + assert http, 'http backend not configured' + assert self.media_plugin, 'No media plugin configured' + + url = ( + urljoin(get_local_base_url(), '/tts/mimic3/say') + + '?' + + urlencode( + { + 'text': text, + 'server_url': server_url, + 'voice': voice, + } + ) + ) + + self.media_plugin.play(url, **player_args) + + @action + def voices(self, server_url: Optional[str] = None): + """ + List the voices available on the server. + + :param server_url: Default ``server_url`` override. + :return: .. schema:: tts.mimi3.Mimic3VoiceSchema(many=True) + """ + server_url = server_url or self.server_url + rs = requests.get(urljoin(server_url, '/api/voices')) + rs.raise_for_status() + return Mimic3VoiceSchema().dump(rs.json(), many=True) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/tts/mimic3/manifest.yaml b/platypush/plugins/tts/mimic3/manifest.yaml new file mode 100644 index 00000000..4119497e --- /dev/null +++ b/platypush/plugins/tts/mimic3/manifest.yaml @@ -0,0 +1,6 @@ +manifest: + events: {} + install: + pip: [] + package: platypush.plugins.tts.mimic3 + type: plugin diff --git a/platypush/schemas/tts/mimic3.py b/platypush/schemas/tts/mimic3.py new file mode 100644 index 00000000..a5819ff4 --- /dev/null +++ b/platypush/schemas/tts/mimic3.py @@ -0,0 +1,51 @@ +from marshmallow import Schema, fields + + +class Mimic3Schema(Schema): + pass + + +class Mimic3VoiceSchema(Mimic3Schema): + key = fields.String( + required=True, + dump_only=True, + metadata={ + 'description': 'Unique voice ID', + 'example': 'en_UK/apope_low', + }, + ) + + language = fields.String( + required=True, + dump_only=True, + metadata={ + 'example': 'en_UK', + }, + ) + + language_english = fields.String( + metadata={ + 'description': 'Name of the language (in English)', + } + ) + + language_native = fields.String( + metadata={ + 'description': 'Name of the language (in the native language)', + } + ) + + name = fields.String( + metadata={ + 'example': 'apope_low', + } + ) + + sample_text = fields.String( + metadata={ + 'example': 'Some text', + } + ) + + description = fields.String() + aliases = fields.List(fields.String)