diff --git a/docs/source/backends.rst b/docs/source/backends.rst
index 2a43daeec..4171e8825 100644
--- a/docs/source/backends.rst
+++ b/docs/source/backends.rst
@@ -10,6 +10,4 @@ Backends
     platypush/backend/midi.rst
     platypush/backend/nodered.rst
     platypush/backend/redis.rst
-    platypush/backend/stt.picovoice.hotword.rst
-    platypush/backend/stt.picovoice.speech.rst
     platypush/backend/tcp.rst
diff --git a/docs/source/platypush/backend/stt.picovoice.hotword.rst b/docs/source/platypush/backend/stt.picovoice.hotword.rst
deleted file mode 100644
index 858386889..000000000
--- a/docs/source/platypush/backend/stt.picovoice.hotword.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-``stt.picovoice.hotword``
-===========================================
-
-.. automodule:: platypush.backend.stt.picovoice.hotword
-    :members:
diff --git a/docs/source/platypush/backend/stt.picovoice.speech.rst b/docs/source/platypush/backend/stt.picovoice.speech.rst
deleted file mode 100644
index 8b5809662..000000000
--- a/docs/source/platypush/backend/stt.picovoice.speech.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-``stt.picovoice.speech``
-==========================================
-
-.. automodule:: platypush.backend.stt.picovoice.speech
-    :members:
diff --git a/docs/source/platypush/plugins/picovoice.rst b/docs/source/platypush/plugins/picovoice.rst
new file mode 100644
index 000000000..f1f8acded
--- /dev/null
+++ b/docs/source/platypush/plugins/picovoice.rst
@@ -0,0 +1,5 @@
+``picovoice``
+=============
+
+.. automodule:: platypush.plugins.picovoice
+    :members:
diff --git a/docs/source/platypush/plugins/stt.picovoice.hotword.rst b/docs/source/platypush/plugins/stt.picovoice.hotword.rst
deleted file mode 100644
index 11eb37dd5..000000000
--- a/docs/source/platypush/plugins/stt.picovoice.hotword.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-``stt.picovoice.hotword``
-===========================================
-
-.. automodule:: platypush.plugins.stt.picovoice.hotword
-    :members:
diff --git a/docs/source/platypush/plugins/stt.picovoice.speech.rst b/docs/source/platypush/plugins/stt.picovoice.speech.rst
deleted file mode 100644
index 890c904cc..000000000
--- a/docs/source/platypush/plugins/stt.picovoice.speech.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-``stt.picovoice.speech``
-==========================================
-
-.. automodule:: platypush.plugins.stt.picovoice.speech
-    :members:
diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst
index 5e583f5e5..783cb841e 100644
--- a/docs/source/plugins.rst
+++ b/docs/source/plugins.rst
@@ -95,6 +95,7 @@ Plugins
     platypush/plugins/nmap.rst
     platypush/plugins/ntfy.rst
     platypush/plugins/otp.rst
+    platypush/plugins/picovoice.rst
     platypush/plugins/pihole.rst
     platypush/plugins/ping.rst
     platypush/plugins/printer.cups.rst
@@ -119,8 +120,6 @@ Plugins
     platypush/plugins/smartthings.rst
     platypush/plugins/sound.rst
     platypush/plugins/ssh.rst
-    platypush/plugins/stt.picovoice.hotword.rst
-    platypush/plugins/stt.picovoice.speech.rst
     platypush/plugins/sun.rst
     platypush/plugins/switch.tplink.rst
     platypush/plugins/switch.wemo.rst
diff --git a/platypush/backend/stt/__init__.py b/platypush/backend/stt/__init__.py
deleted file mode 100644
index 624c2b72f..000000000
--- a/platypush/backend/stt/__init__.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import time
-
-from platypush.backend import Backend
-from platypush.context import get_plugin
-from platypush.plugins.stt import SttPlugin
-
-
-class SttBackend(Backend):
-    """
-    Base class for speech-to-text backends.
-    """
-
-    def __init__(self, plugin_name: str, retry_sleep: float = 5.0, *args, **kwargs):
-        """
-        :param plugin_name: Plugin name of the class that will be used for speech detection. Must be an instance of
-            :class:`platypush.plugins.stt.SttPlugin`.
-        :param retry_sleep: Number of seconds the backend will wait on failure before re-initializing the plugin
-            (default: 5 seconds).
-        """
-        super().__init__(*args, **kwargs)
-        self.plugin_name = plugin_name
-        self.retry_sleep = retry_sleep
-
-    def run(self):
-        super().run()
-        self.logger.info('Starting {} speech-to-text backend'.format(self.__class__.__name__))
-
-        while not self.should_stop():
-            try:
-                plugin: SttPlugin = get_plugin(self.plugin_name)
-                with plugin:
-                    # noinspection PyProtectedMember
-                    plugin._detection_thread.join()
-            except Exception as e:
-                self.logger.exception(e)
-                self.logger.warning('Encountered an unexpected error, retrying in {} seconds'.format(self.retry_sleep))
-                time.sleep(self.retry_sleep)
-
-
-# vim:sw=4:ts=4:et:
diff --git a/platypush/backend/stt/picovoice/__init__.py b/platypush/backend/stt/picovoice/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/platypush/backend/stt/picovoice/hotword/__init__.py b/platypush/backend/stt/picovoice/hotword/__init__.py
deleted file mode 100644
index 9dc6ae63a..000000000
--- a/platypush/backend/stt/picovoice/hotword/__init__.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from platypush.backend.stt import SttBackend
-
-
-class SttPicovoiceHotwordBackend(SttBackend):
-    """
-    Backend for the PicoVoice hotword detection plugin. Set this plugin to ``enabled`` if you
-    want to run the hotword engine continuously instead of programmatically using
-    ``start_detection`` and ``stop_detection``.
-
-    Requires:
-
-        - The :class:`platypush.plugins.stt.deepspeech.SttPicovoiceHotwordPlugin` plugin configured and its dependencies
-          installed.
-
-    """
-
-    def __init__(self, *args, **kwargs):
-        super().__init__('stt.picovoice.hotword', *args, **kwargs)
-
-
-# vim:sw=4:ts=4:et:
diff --git a/platypush/backend/stt/picovoice/hotword/manifest.yaml b/platypush/backend/stt/picovoice/hotword/manifest.yaml
deleted file mode 100644
index 0527afcaa..000000000
--- a/platypush/backend/stt/picovoice/hotword/manifest.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-manifest:
-  events: {}
-  install:
-    pip: []
-  package: platypush.backend.stt.picovoice.hotword
-  type: backend
diff --git a/platypush/backend/stt/picovoice/speech/__init__.py b/platypush/backend/stt/picovoice/speech/__init__.py
deleted file mode 100644
index 28a4b0b1a..000000000
--- a/platypush/backend/stt/picovoice/speech/__init__.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from platypush.backend.stt import SttBackend
-
-
-class SttPicovoiceSpeechBackend(SttBackend):
-    """
-    Backend for the PicoVoice speech detection plugin. Set this plugin to ``enabled`` if you
-    want to run the speech engine continuously instead of programmatically using
-    ``start_detection`` and ``stop_detection``.
-
-    Requires:
-
-        - The :class:`platypush.plugins.stt.deepspeech.SttPicovoiceSpeechPlugin` plugin configured and its dependencies
-          installed.
-
-    """
-
-    def __init__(self, *args, **kwargs):
-        super().__init__('stt.picovoice.speech', *args, **kwargs)
-
-
-# vim:sw=4:ts=4:et:
diff --git a/platypush/backend/stt/picovoice/speech/manifest.yaml b/platypush/backend/stt/picovoice/speech/manifest.yaml
deleted file mode 100644
index fc68a467e..000000000
--- a/platypush/backend/stt/picovoice/speech/manifest.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-manifest:
-  events: {}
-  install:
-    pip: []
-  package: platypush.backend.stt.picovoice.speech
-  type: backend
diff --git a/platypush/plugins/assistant/__init__.py b/platypush/plugins/assistant/__init__.py
index bb5b25e48..97e98f30f 100644
--- a/platypush/plugins/assistant/__init__.py
+++ b/platypush/plugins/assistant/__init__.py
@@ -9,21 +9,22 @@ from platypush.context import get_bus, get_plugin
 from platypush.entities.assistants import Assistant
 from platypush.entities.managers.assistants import AssistantEntityManager
 from platypush.message.event.assistant import (
-    AssistantEvent,
-    ConversationStartEvent,
-    ConversationEndEvent,
-    ConversationTimeoutEvent,
-    ResponseEvent,
-    NoResponseEvent,
-    SpeechRecognizedEvent,
-    AlarmStartedEvent,
     AlarmEndEvent,
-    TimerStartedEvent,
-    TimerEndEvent,
-    AlertStartedEvent,
+    AlarmStartedEvent,
     AlertEndEvent,
+    AlertStartedEvent,
+    AssistantEvent,
+    ConversationEndEvent,
+    ConversationStartEvent,
+    ConversationTimeoutEvent,
+    HotwordDetectedEvent,
     MicMutedEvent,
     MicUnmutedEvent,
+    NoResponseEvent,
+    ResponseEvent,
+    SpeechRecognizedEvent,
+    TimerEndEvent,
+    TimerStartedEvent,
 )
 from platypush.plugins import Plugin, action
 from platypush.utils import get_plugin_name_by_class
@@ -235,6 +236,9 @@ class AssistantPlugin(Plugin, AssistantEntityManager, ABC):
             self.stop_conversation()
             tts.say(text=text, **self.tts_plugin_args)
 
+    def _on_hotword_detected(self, hotword: Optional[str]):
+        self._send_event(HotwordDetectedEvent, hotword=hotword)
+
     def _on_speech_recognized(self, phrase: Optional[str]):
         phrase = (phrase or '').lower().strip()
         self._last_query = phrase
diff --git a/platypush/plugins/assistant/picovoice/__init__.py b/platypush/plugins/assistant/picovoice/__init__.py
new file mode 100644
index 000000000..9426ba608
--- /dev/null
+++ b/platypush/plugins/assistant/picovoice/__init__.py
@@ -0,0 +1,234 @@
+from typing import Optional, Sequence
+
+from platypush.plugins import RunnablePlugin, action
+from platypush.plugins.assistant import AssistantPlugin
+
+from ._assistant import Assistant
+from ._state import AssistantState
+
+
+# pylint: disable=too-many-ancestors
+class AssistantPicovoicePlugin(AssistantPlugin, RunnablePlugin):
+    """
+    A voice assistant that runs on your device, based on the `Picovoice
+    <https://picovoice.ai/>`_ engine.
+
+    .. note:: You will need a PicoVoice account and a personal access key to
+        use this integration.
+
+    You can get your personal access key by signing up at the `Picovoice
+    console <https://console.picovoice.ai/>`_. You may be asked to submit a
+    reason for using the service (feel free to mention a personal Platypush
+    integration), and you will receive your personal access key.
+
+    You may also be asked to select which products you want to use. The default
+    configuration of this plugin requires the following:
+
+        * **Porcupine**: wake-word engine, if you want the device to listen for
+          a specific wake word in order to start the assistant.
+
+        * **Cheetah**: speech-to-text engine, if you want your voice
+          interactions to be transcribed into free text - either programmatically
+          or when triggered by the wake word. Or:
+
+        * **Rhino**: intent recognition engine, if you want to extract *intents*
+          out of your voice commands - for instance, the phrase "set the living
+          room temperature to 20 degrees" could be mapped to the intent with the
+          following parameters: ``intent``: ``set_temperature``, ``room``:
+          ``living_room``, ``temperature``: ``20``.
+
+        * **Leopard**: speech-to-text engine aimed at offline transcription of
+          audio files rather than real-time transcription.
+
+        * **Orca**: text-to-speech engine, if you want to create your custom
+          logic to respond to user's voice commands and render the responses as
+          audio.
+
+    """
+
+    def __init__(
+        self,
+        access_key: str,
+        hotword_enabled: bool = True,
+        stt_enabled: bool = True,
+        intent_enabled: bool = False,
+        keywords: Optional[Sequence[str]] = None,
+        keyword_paths: Optional[Sequence[str]] = None,
+        keyword_model_path: Optional[str] = None,
+        speech_model_path: Optional[str] = None,
+        endpoint_duration: Optional[float] = 0.5,
+        enable_automatic_punctuation: bool = False,
+        start_conversation_on_hotword: bool = True,
+        audio_queue_size: int = 100,
+        conversation_timeout: Optional[float] = 7.5,
+        **kwargs,
+    ):
+        """
+        :param access_key: Your Picovoice access key. You can get it by signing
+            up at the `Picovoice console <https://console.picovoice.ai/>`.
+        :param hotword_enabled: Enable the wake-word engine (default: True).
+            **Note**: The wake-word engine requires you to add Porcupine to the
+            products available in your Picovoice account.
+        :param stt_enabled: Enable the speech-to-text engine (default: True).
+            **Note**: The speech-to-text engine requires you to add Cheetah to
+            the products available in your Picovoice account.
+        :param intent_enabled: Enable the intent recognition engine (default:
+            False).
+            **Note**: The intent recognition engine requires you to add Rhino
+            to the products available in your Picovoice account.
+        :param keywords: List of keywords to listen for (e.g. ``alexa``, ``ok
+            google``...). This is required if the wake-word engine is enabled.
+            See the `Picovoice repository
+            <https://github.com/Picovoice/porcupine/tree/master/resources/keyword_files>`_).
+            for a list of the stock keywords available. If you have a custom
+            model, you can pass its path to the ``keyword_paths`` parameter and
+            its filename (without the path and the platform extension) here.
+        :param keyword_paths: List of paths to the keyword files to listen for.
+            Custom keyword files can be created using the `Picovoice console
+            <https://console.picovoice.ai/ppn>`_ and downloaded from the
+            console itself.
+        :param keyword_model_path: If you are using a keyword file in a
+            non-English language, you can provide the path to the model file
+            for its language. Model files are available for all the supported
+            languages through the `Picovoice repository
+            <https://github.com/Picovoice/porcupine/tree/master/lib/common>`_.
+        :param speech_model_path: Path to the speech model file. If you are
+            using a language other than English, you can provide the path to the
+            model file for that language. Model files are available for all the
+            supported languages through the `Picovoice repository
+            <https://github.com/Picovoice/porcupine/tree/master/lib/common>`_.
+        :param endpoint_duration: If set, the assistant will stop listening when
+            no speech is detected for the specified duration (in seconds) after
+            the end of an utterance.
+        :param enable_automatic_punctuation: Enable automatic punctuation
+            insertion.
+        :param start_conversation_on_hotword: If set to True (default), a speech
+            detection session will be started when the hotword is detected. If
+            set to False, you may want to start the conversation programmatically
+            by calling the :meth:`.start_conversation` method instead, or run any
+            custom logic hotword detection logic. This can be particularly useful
+            when you want to run the assistant in a push-to-talk mode, or when you
+            want different hotwords to trigger conversations with different models
+            or languages.
+        :param audio_queue_size: Maximum number of audio frames to hold in the
+            processing queue. You may want to increase this value if you are
+            running this integration on a slow device and/or the logs report
+            audio frame drops too often. Keep in mind that increasing this value
+            will increase the memory usage of the integration. Also, a higher
+            value may result in higher accuracy at the cost of higher latency.
+        :param conversation_timeout: Maximum time to wait for some speech to be
+            detected after the hotword is detected. If no speech is detected
+            within this time, the conversation will time out and the plugin will
+            go back into hotword detection mode, if the mode is enabled. Default:
+            7.5 seconds.
+        """
+        super().__init__(**kwargs)
+        self._assistant = None
+        self._assistant_args = {
+            'stop_event': self._should_stop,
+            'access_key': access_key,
+            'hotword_enabled': hotword_enabled,
+            'stt_enabled': stt_enabled,
+            'intent_enabled': intent_enabled,
+            'keywords': keywords,
+            'keyword_paths': keyword_paths,
+            'keyword_model_path': keyword_model_path,
+            'speech_model_path': speech_model_path,
+            'endpoint_duration': endpoint_duration,
+            'enable_automatic_punctuation': enable_automatic_punctuation,
+            'start_conversation_on_hotword': start_conversation_on_hotword,
+            'audio_queue_size': audio_queue_size,
+            'conversation_timeout': conversation_timeout,
+            'on_conversation_start': self._on_conversation_start,
+            'on_conversation_end': self._on_conversation_end,
+            'on_conversation_timeout': self._on_conversation_timeout,
+            'on_speech_recognized': self._on_speech_recognized,
+            'on_hotword_detected': self._on_hotword_detected,
+        }
+
+    @action
+    def start_conversation(self, *_, **__):
+        """
+        Programmatically start a conversation with the assistant
+        """
+        if not self._assistant:
+            self.logger.warning('Assistant not initialized')
+            return
+
+        self._assistant.state = AssistantState.DETECTING_SPEECH
+
+    @action
+    def stop_conversation(self, *_, **__):
+        """
+        Programmatically stop a running conversation with the assistant
+        """
+        if not self._assistant:
+            self.logger.warning('Assistant not initialized')
+            return
+
+        if self._assistant.hotword_enabled:
+            self._assistant.state = AssistantState.DETECTING_HOTWORD
+        else:
+            self._assistant.state = AssistantState.IDLE
+
+    @action
+    def mute(self, *_, **__):
+        """
+        Mute the microphone. Alias for :meth:`.set_mic_mute` with
+        ``muted=True``.
+        """
+
+    @action
+    def unmute(self, *_, **__):
+        """
+        Unmute the microphone. Alias for :meth:`.set_mic_mute` with
+        ``muted=False``.
+        """
+
+    @action
+    def set_mic_mute(self, muted: bool):
+        """
+        Programmatically mute/unmute the microphone.
+
+        :param muted: Set to True or False.
+        """
+
+    @action
+    def toggle_mute(self, *_, **__):
+        """
+        Toggle the mic mute state.
+        """
+
+    @action
+    def send_text_query(self, *_, query: str, **__):
+        """
+        Send a text query to the assistant.
+
+        This is equivalent to saying something to the assistant.
+
+        :param query: Query to be sent.
+        """
+
+    def main(self):
+        while not self.should_stop():
+            self.logger.info('Starting Picovoice assistant')
+            with Assistant(**self._assistant_args) as self._assistant:
+                try:
+                    for event in self._assistant:
+                        self.logger.debug('Picovoice assistant event: %s', event)
+                except KeyboardInterrupt:
+                    break
+                except Exception as e:
+                    self.logger.error('Picovoice assistant error: %s', e, exc_info=True)
+                    self.wait_stop(5)
+
+    def stop(self):
+        try:
+            self.stop_conversation()
+        except RuntimeError:
+            pass
+
+        super().stop()
+
+
+# vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/assistant/picovoice/_assistant.py b/platypush/plugins/assistant/picovoice/_assistant.py
new file mode 100644
index 000000000..25142f336
--- /dev/null
+++ b/platypush/plugins/assistant/picovoice/_assistant.py
@@ -0,0 +1,308 @@
+import logging
+import os
+from threading import Event, RLock
+from time import time
+from typing import Any, Dict, Optional, Sequence
+
+import pvcheetah
+import pvleopard
+import pvporcupine
+import pvrhino
+
+from platypush.message.event.assistant import (
+    ConversationTimeoutEvent,
+    HotwordDetectedEvent,
+    SpeechRecognizedEvent,
+)
+
+from ._context import ConversationContext
+from ._recorder import AudioRecorder
+from ._state import AssistantState
+
+
+class Assistant:
+    """
+    A facade class that wraps the Picovoice engines under an assistant API.
+    """
+
+    def _default_callback(*_, **__):
+        pass
+
+    def __init__(
+        self,
+        access_key: str,
+        stop_event: Event,
+        hotword_enabled: bool = True,
+        stt_enabled: bool = True,
+        intent_enabled: bool = False,
+        keywords: Optional[Sequence[str]] = None,
+        keyword_paths: Optional[Sequence[str]] = None,
+        keyword_model_path: Optional[str] = None,
+        frame_expiration: float = 3.0,  # Don't process audio frames older than this
+        speech_model_path: Optional[str] = None,
+        endpoint_duration: Optional[float] = None,
+        enable_automatic_punctuation: bool = False,
+        start_conversation_on_hotword: bool = False,
+        audio_queue_size: int = 100,
+        conversation_timeout: Optional[float] = None,
+        on_conversation_start=_default_callback,
+        on_conversation_end=_default_callback,
+        on_conversation_timeout=_default_callback,
+        on_speech_recognized=_default_callback,
+        on_hotword_detected=_default_callback,
+    ):
+        self._access_key = access_key
+        self._stop_event = stop_event
+        self.logger = logging.getLogger(__name__)
+        self.hotword_enabled = hotword_enabled
+        self.stt_enabled = stt_enabled
+        self.intent_enabled = intent_enabled
+        self.keywords = list(keywords or [])
+        self.keyword_paths = None
+        self.keyword_model_path = None
+        self.frame_expiration = frame_expiration
+        self.speech_model_path = speech_model_path
+        self.endpoint_duration = endpoint_duration
+        self.enable_automatic_punctuation = enable_automatic_punctuation
+        self.start_conversation_on_hotword = start_conversation_on_hotword
+        self.audio_queue_size = audio_queue_size
+
+        self._on_conversation_start = on_conversation_start
+        self._on_conversation_end = on_conversation_end
+        self._on_conversation_timeout = on_conversation_timeout
+        self._on_speech_recognized = on_speech_recognized
+        self._on_hotword_detected = on_hotword_detected
+
+        self._recorder = None
+        self._state = AssistantState.IDLE
+        self._state_lock = RLock()
+        self._ctx = ConversationContext(timeout=conversation_timeout)
+
+        if hotword_enabled:
+            if not keywords:
+                raise ValueError(
+                    'You need to provide a list of keywords if the wake-word engine is enabled'
+                )
+
+            if keyword_paths:
+                keyword_paths = [os.path.expanduser(path) for path in keyword_paths]
+                missing_paths = [
+                    path for path in keyword_paths if not os.path.isfile(path)
+                ]
+                if missing_paths:
+                    raise FileNotFoundError(f'Keyword files not found: {missing_paths}')
+
+                self.keyword_paths = keyword_paths
+
+            if keyword_model_path:
+                keyword_model_path = os.path.expanduser(keyword_model_path)
+                if not os.path.isfile(keyword_model_path):
+                    raise FileNotFoundError(
+                        f'Keyword model file not found: {keyword_model_path}'
+                    )
+
+                self.keyword_model_path = keyword_model_path
+
+        self._cheetah: Optional[pvcheetah.Cheetah] = None
+        self._leopard: Optional[pvleopard.Leopard] = None
+        self._porcupine: Optional[pvporcupine.Porcupine] = None
+        self._rhino: Optional[pvrhino.Rhino] = None
+
+    def should_stop(self):
+        return self._stop_event.is_set()
+
+    def wait_stop(self):
+        self._stop_event.wait()
+
+    @property
+    def state(self) -> AssistantState:
+        with self._state_lock:
+            return self._state
+
+    @state.setter
+    def state(self, state: AssistantState):
+        with self._state_lock:
+            prev_state = self._state
+            self._state = state
+            new_state = self.state
+
+        if prev_state == new_state:
+            return
+
+        if prev_state == AssistantState.DETECTING_SPEECH:
+            self._ctx.stop()
+            self._on_conversation_end()
+        elif new_state == AssistantState.DETECTING_SPEECH:
+            self._ctx.start()
+            self._on_conversation_start()
+
+    @property
+    def porcupine(self) -> Optional[pvporcupine.Porcupine]:
+        if not self.hotword_enabled:
+            return None
+
+        if not self._porcupine:
+            args: Dict[str, Any] = {'access_key': self._access_key}
+            if self.keywords:
+                args['keywords'] = self.keywords
+            if self.keyword_paths:
+                args['keyword_paths'] = self.keyword_paths
+            if self.keyword_model_path:
+                args['model_path'] = self.keyword_model_path
+
+            self._porcupine = pvporcupine.create(**args)
+
+        return self._porcupine
+
+    @property
+    def cheetah(self) -> Optional[pvcheetah.Cheetah]:
+        if not self.stt_enabled:
+            return None
+
+        if not self._cheetah:
+            args: Dict[str, Any] = {'access_key': self._access_key}
+            if self.speech_model_path:
+                args['model_path'] = self.speech_model_path
+            if self.endpoint_duration:
+                args['endpoint_duration_sec'] = self.endpoint_duration
+            if self.enable_automatic_punctuation:
+                args['enable_automatic_punctuation'] = self.enable_automatic_punctuation
+
+            self._cheetah = pvcheetah.create(**args)
+
+        return self._cheetah
+
+    def __enter__(self):
+        if self.should_stop():
+            return self
+
+        if self._recorder:
+            self.logger.info('A recording stream already exists')
+        elif self.porcupine or self.cheetah:
+            sample_rate = (self.porcupine or self.cheetah).sample_rate  # type: ignore
+            frame_length = (self.porcupine or self.cheetah).frame_length  # type: ignore
+
+            self._recorder = AudioRecorder(
+                stop_event=self._stop_event,
+                sample_rate=sample_rate,
+                frame_size=frame_length,
+                queue_size=self.audio_queue_size,
+                channels=1,
+            )
+
+            self._recorder.__enter__()
+
+            if self.porcupine:
+                self.state = AssistantState.DETECTING_HOTWORD
+            else:
+                self.state = AssistantState.DETECTING_SPEECH
+
+        return self
+
+    def __exit__(self, *_):
+        if self._recorder:
+            self._recorder.__exit__(*_)
+            self._recorder = None
+
+        self.state = AssistantState.IDLE
+
+        if self._cheetah:
+            self._cheetah.delete()
+            self._cheetah = None
+
+        if self._leopard:
+            self._leopard.delete()
+            self._leopard = None
+
+        if self._porcupine:
+            self._porcupine.delete()
+            self._porcupine = None
+
+        if self._rhino:
+            self._rhino.delete()
+            self._rhino = None
+
+    def __iter__(self):
+        return self
+
+    def __next__(self):
+        has_data = False
+        if self.should_stop() or not self._recorder:
+            raise StopIteration
+
+        while not (self.should_stop() or has_data):
+            data = self._recorder.read()
+            if data is None:
+                continue
+
+            frame, t = data
+            if time() - t > self.frame_expiration:
+                self.logger.info(
+                    'Skipping audio frame older than %ss', self.frame_expiration
+                )
+                continue  # The audio frame is too old
+
+            if self.porcupine and self.state == AssistantState.DETECTING_HOTWORD:
+                return self._process_hotword(frame)
+
+            if self.cheetah and self.state == AssistantState.DETECTING_SPEECH:
+                return self._process_speech(frame)
+
+        raise StopIteration
+
+    def _process_hotword(self, frame):
+        if not self.porcupine:
+            return None
+
+        keyword_index = self.porcupine.process(frame)
+        if keyword_index is None:
+            return None  # No keyword detected
+
+        if keyword_index >= 0 and self.keywords:
+            if self.start_conversation_on_hotword:
+                self.state = AssistantState.DETECTING_SPEECH
+
+            self._on_hotword_detected(hotword=self.keywords[keyword_index])
+            return HotwordDetectedEvent(hotword=self.keywords[keyword_index])
+
+        return None
+
+    def _process_speech(self, frame):
+        if not self.cheetah:
+            return None
+
+        event = None
+        partial_transcript, self._ctx.is_final = self.cheetah.process(frame)
+
+        if partial_transcript:
+            self._ctx.partial_transcript += partial_transcript
+            self.logger.info(
+                'Partial transcript: %s, is_final: %s',
+                self._ctx.partial_transcript,
+                self._ctx.is_final,
+            )
+
+        if self._ctx.is_final or self._ctx.timed_out:
+            phrase = ''
+            if self.cheetah:
+                phrase = self.cheetah.flush()
+
+            self._ctx.partial_transcript += phrase
+            phrase = self._ctx.partial_transcript
+            phrase = phrase[:1].lower() + phrase[1:]
+
+            if self._ctx.is_final or phrase:
+                event = SpeechRecognizedEvent(phrase=phrase)
+                self._on_speech_recognized(phrase=phrase)
+            else:
+                event = ConversationTimeoutEvent()
+                self._on_conversation_timeout()
+
+            self._ctx.reset()
+            if self.hotword_enabled:
+                self.state = AssistantState.DETECTING_HOTWORD
+
+        return event
+
+
+# vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/assistant/picovoice/_context.py b/platypush/plugins/assistant/picovoice/_context.py
new file mode 100644
index 000000000..1a5340739
--- /dev/null
+++ b/platypush/plugins/assistant/picovoice/_context.py
@@ -0,0 +1,43 @@
+from dataclasses import dataclass
+from time import time
+from typing import Optional
+
+
+@dataclass
+class ConversationContext:
+    """
+    Context of the conversation process.
+    """
+
+    partial_transcript: str = ''
+    is_final: bool = False
+    timeout: Optional[float] = None
+    t_start: Optional[float] = None
+    t_end: Optional[float] = None
+
+    def start(self):
+        self.reset()
+        self.t_start = time()
+
+    def stop(self):
+        self.reset()
+        self.t_end = time()
+
+    def reset(self):
+        self.partial_transcript = ''
+        self.is_final = False
+        self.t_start = None
+        self.t_end = None
+
+    @property
+    def timed_out(self):
+        return (
+            not self.partial_transcript
+            and not self.is_final
+            and self.timeout
+            and self.t_start
+            and time() - self.t_start > self.timeout
+        )
+
+
+# vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/assistant/picovoice/_recorder.py b/platypush/plugins/assistant/picovoice/_recorder.py
new file mode 100644
index 000000000..e0c23a8e9
--- /dev/null
+++ b/platypush/plugins/assistant/picovoice/_recorder.py
@@ -0,0 +1,76 @@
+from collections import namedtuple
+from logging import getLogger
+from queue import Full, Queue
+from threading import Event
+from time import time
+from typing import Optional
+
+import sounddevice as sd
+
+from platypush.utils import wait_for_either
+
+
+AudioFrame = namedtuple('AudioFrame', ['data', 'timestamp'])
+
+
+class AudioRecorder:
+    """
+    Audio recorder component that uses the sounddevice library to record audio
+    from the microphone.
+    """
+
+    def __init__(
+        self,
+        stop_event: Event,
+        sample_rate: int,
+        frame_size: int,
+        channels: int,
+        dtype: str = 'int16',
+        queue_size: int = 100,
+    ):
+        self.logger = getLogger(__name__)
+        self._audio_queue: Queue[AudioFrame] = Queue(maxsize=queue_size)
+        self.frame_size = frame_size
+        self._stop_event = Event()
+        self._upstream_stop_event = stop_event
+        self.stream = sd.InputStream(
+            samplerate=sample_rate,
+            channels=channels,
+            dtype=dtype,
+            blocksize=frame_size,
+            callback=self._audio_callback,
+        )
+
+    def __enter__(self):
+        self._stop_event.clear()
+        self.stream.start()
+        return self
+
+    def __exit__(self, *_):
+        self.stop()
+
+    def _audio_callback(self, indata, *_):
+        if self.should_stop():
+            return
+
+        try:
+            self._audio_queue.put_nowait(AudioFrame(indata.reshape(-1), time()))
+        except Full:
+            self.logger.warning('Audio queue is full, dropping audio frame')
+
+    def read(self, timeout: Optional[float] = None):
+        try:
+            return self._audio_queue.get(timeout=timeout)
+        except TimeoutError:
+            self.logger.debug('Audio queue is empty')
+            return None
+
+    def stop(self):
+        self._stop_event.set()
+        self.stream.stop()
+
+    def should_stop(self):
+        return self._stop_event.is_set() or self._upstream_stop_event.is_set()
+
+    def wait(self, timeout: Optional[float] = None):
+        wait_for_either(self._stop_event, self._upstream_stop_event, timeout=timeout)
diff --git a/platypush/plugins/assistant/picovoice/_state.py b/platypush/plugins/assistant/picovoice/_state.py
new file mode 100644
index 000000000..e0eb7e719
--- /dev/null
+++ b/platypush/plugins/assistant/picovoice/_state.py
@@ -0,0 +1,14 @@
+from enum import Enum
+
+
+class AssistantState(Enum):
+    """
+    Possible states of the assistant.
+    """
+
+    IDLE = 'idle'
+    DETECTING_HOTWORD = 'detecting_hotword'
+    DETECTING_SPEECH = 'detecting_speech'
+
+
+# vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/assistant/picovoice/manifest.yaml b/platypush/plugins/assistant/picovoice/manifest.yaml
new file mode 100644
index 000000000..a44bbccdf
--- /dev/null
+++ b/platypush/plugins/assistant/picovoice/manifest.yaml
@@ -0,0 +1,23 @@
+manifest:
+  package: platypush.plugins.assistant.picovoice
+  type: plugin
+  events:
+    - platypush.message.event.assistant.ConversationEndEvent
+    - platypush.message.event.assistant.ConversationStartEvent
+    - platypush.message.event.assistant.ConversationTimeoutEvent
+    - platypush.message.event.assistant.HotwordDetectedEvent
+    - platypush.message.event.assistant.MicMutedEvent
+    - platypush.message.event.assistant.MicUnmutedEvent
+    - platypush.message.event.assistant.NoResponseEvent
+    - platypush.message.event.assistant.ResponseEvent
+    - platypush.message.event.assistant.SpeechRecognizedEvent
+  install:
+    pacman:
+      - python-sounddevice
+    pip:
+      - pvcheetah
+      - pvleopard
+      - pvorca
+      - pvporcupine
+      - pvrhino
+      - sounddevice
diff --git a/platypush/plugins/sound/_manager/_main.py b/platypush/plugins/sound/_manager/_main.py
index 9a1d96cfb..4dea95df1 100644
--- a/platypush/plugins/sound/_manager/_main.py
+++ b/platypush/plugins/sound/_manager/_main.py
@@ -247,9 +247,11 @@ class AudioManager:
         wait_start = time()
         for audio_thread in streams_to_stop:
             audio_thread.join(
-                timeout=max(0, timeout - (time() - wait_start))
-                if timeout is not None
-                else None
+                timeout=(
+                    max(0, timeout - (time() - wait_start))
+                    if timeout is not None
+                    else None
+                )
             )
 
         # Remove references
diff --git a/platypush/plugins/stt/__init__.py b/platypush/plugins/stt/__init__.py
deleted file mode 100644
index 1df2ae451..000000000
--- a/platypush/plugins/stt/__init__.py
+++ /dev/null
@@ -1,336 +0,0 @@
-import queue
-import threading
-from abc import ABC, abstractmethod
-from typing import Optional, Union, List
-
-import sounddevice as sd
-
-from platypush.context import get_bus
-from platypush.message.event.stt import (
-    SpeechDetectionStartedEvent,
-    SpeechDetectionStoppedEvent,
-    SpeechStartedEvent,
-    SpeechDetectedEvent,
-    HotwordDetectedEvent,
-    ConversationDetectedEvent,
-)
-from platypush.message.response.stt import SpeechDetectedResponse
-from platypush.plugins import Plugin, action
-
-
-class SttPlugin(ABC, Plugin):
-    """
-    Abstract class for speech-to-text plugins.
-    """
-
-    _thread_stop_timeout = 10.0
-    rate = 16000
-    channels = 1
-
-    def __init__(
-        self,
-        input_device: Optional[Union[int, str]] = None,
-        hotword: Optional[str] = None,
-        hotwords: Optional[List[str]] = None,
-        conversation_timeout: Optional[float] = 10.0,
-        block_duration: float = 1.0,
-    ):
-        """
-        :param input_device: PortAudio device index or name that will be used for recording speech (default: default
-            system audio input device).
-        :param hotword: When this word is detected, the plugin will trigger a
-            :class:`platypush.message.event.stt.HotwordDetectedEvent` instead of a
-            :class:`platypush.message.event.stt.SpeechDetectedEvent` event. You can use these events for hooking other
-            assistants.
-        :param hotwords: Use a list of hotwords instead of a single one.
-        :param conversation_timeout: If ``hotword`` or ``hotwords`` are set and ``conversation_timeout`` is set,
-            the next speech detected event will trigger a :class:`platypush.message.event.stt.ConversationDetectedEvent`
-            instead of a :class:`platypush.message.event.stt.SpeechDetectedEvent` event. You can hook custom hooks
-            here to run any logic depending on the detected speech - it can emulate a kind of
-            "OK, Google. Turn on the lights" interaction without using an external assistant (default: 10 seconds).
-        :param block_duration: Duration of the acquired audio blocks (default: 1 second).
-        """
-
-        super().__init__()
-        self.input_device = input_device
-        self.conversation_timeout = conversation_timeout
-        self.block_duration = block_duration
-
-        self.hotwords = set(hotwords or [])
-        if hotword:
-            self.hotwords = {hotword}
-
-        self._conversation_event = threading.Event()
-        self._input_stream: Optional[sd.InputStream] = None
-        self._recording_thread: Optional[threading.Thread] = None
-        self._detection_thread: Optional[threading.Thread] = None
-        self._audio_queue: Optional[queue.Queue] = None
-        self._current_text = ''
-
-    def _get_input_device(self, device: Optional[Union[int, str]] = None) -> int:
-        """
-        Get the index of the input device by index or name.
-
-        :param device: Device index or name. If None is set then the function will return the index of the
-            default audio input device.
-        :return: Index of the audio input device.
-        """
-        if not device:
-            device = self.input_device
-        if not device:
-            return sd.query_hostapis()[0].get('default_input_device')
-
-        if isinstance(device, int):
-            assert device <= len(sd.query_devices())
-            return device
-
-        for i, dev in enumerate(sd.query_devices()):
-            if dev['name'] == device:
-                return i
-
-        raise AssertionError('Device {} not found'.format(device))
-
-    def on_speech_detected(self, speech: str) -> None:
-        """
-        Hook called when speech is detected. Triggers the right event depending on the current context.
-
-        :param speech: Detected speech.
-        """
-        speech = speech.strip()
-
-        if speech in self.hotwords:
-            event = HotwordDetectedEvent(hotword=speech)
-            if self.conversation_timeout:
-                self._conversation_event.set()
-                threading.Timer(
-                    self.conversation_timeout, lambda: self._conversation_event.clear()
-                ).start()
-        elif self._conversation_event.is_set():
-            event = ConversationDetectedEvent(speech=speech)
-        else:
-            event = SpeechDetectedEvent(speech=speech)
-
-        get_bus().post(event)
-
-    @staticmethod
-    def convert_frames(frames: bytes) -> bytes:
-        """
-        Conversion method for raw audio frames. It just returns the input frames as bytes. Override it if required
-        by your logic.
-
-        :param frames: Input audio frames, as bytes.
-        :return: The audio frames as passed on the input. Override if required.
-        """
-        return frames
-
-    def on_detection_started(self) -> None:
-        """
-        Method called when the ``detection_thread`` starts. Initialize your context variables and models here if
-        required.
-        """
-        pass
-
-    def on_detection_ended(self) -> None:
-        """
-        Method called when the ``detection_thread`` stops. Clean up your context variables and models here.
-        """
-        pass
-
-    def before_recording(self) -> None:
-        """
-        Method called when the ``recording_thread`` starts. Put here any logic that you may want to run before the
-        recording thread starts.
-        """
-        pass
-
-    def on_recording_started(self) -> None:
-        """
-        Method called after the ``recording_thread`` opens the audio device. Put here any logic that you may want to
-        run after the recording starts.
-        """
-        pass
-
-    def on_recording_ended(self) -> None:
-        """
-        Method called when the ``recording_thread`` stops. Put here any logic that you want to run after the audio
-        device is closed.
-        """
-        pass
-
-    @abstractmethod
-    def detect_speech(self, frames) -> str:
-        """
-        Method called within the ``detection_thread`` when new audio frames have been captured. Must be implemented
-        by the derived classes.
-
-        :param frames: Audio frames, as returned by ``convert_frames``.
-        :return: Detected text, as a string. Returns an empty string if no text has been detected.
-        """
-        raise NotImplementedError
-
-    def process_text(self, text: str) -> None:
-        if (not text and self._current_text) or (text and text == self._current_text):
-            self.on_speech_detected(self._current_text)
-            self._current_text = ''
-        else:
-            if text:
-                if not self._current_text:
-                    get_bus().post(SpeechStartedEvent())
-                self.logger.info('Intermediate speech results: [{}]'.format(text))
-
-            self._current_text = text
-
-    def detection_thread(self) -> None:
-        """
-        This thread reads frames from ``_audio_queue``, performs the speech-to-text detection and calls
-        """
-        self._current_text = ''
-        self.logger.debug('Detection thread started')
-        self.on_detection_started()
-
-        while self._audio_queue:
-            try:
-                frames = self._audio_queue.get()
-                frames = self.convert_frames(frames)
-            except Exception as e:
-                self.logger.warning(
-                    'Error while feeding audio to the model: {}'.format(str(e))
-                )
-                continue
-
-            text = self.detect_speech(frames).strip()
-            self.process_text(text)
-
-        self.on_detection_ended()
-        self.logger.debug('Detection thread terminated')
-
-    def recording_thread(
-        self,
-        block_duration: Optional[float] = None,
-        block_size: Optional[int] = None,
-        input_device: Optional[str] = None,
-    ) -> None:
-        """
-        Recording thread. It reads raw frames from the audio device and dispatches them to ``detection_thread``.
-
-        :param block_duration: Audio blocks duration. Specify either ``block_duration`` or ``block_size``.
-        :param block_size: Size of the audio blocks. Specify either ``block_duration`` or ``block_size``.
-        :param input_device: Input device
-        """
-        assert (block_duration or block_size) and not (
-            block_duration and block_size
-        ), 'Please specify either block_duration or block_size'
-
-        if not block_size:
-            block_size = int(self.rate * self.channels * block_duration)
-
-        self.before_recording()
-        self.logger.debug('Recording thread started')
-        device = self._get_input_device(input_device)
-        self._input_stream = sd.InputStream(
-            samplerate=self.rate,
-            device=device,
-            channels=self.channels,
-            dtype='int16',
-            latency=0,
-            blocksize=block_size,
-        )
-        self._input_stream.start()
-        self.on_recording_started()
-        get_bus().post(SpeechDetectionStartedEvent())
-
-        while self._input_stream:
-            try:
-                frames = self._input_stream.read(block_size)[0]
-            except Exception as e:
-                self.logger.warning(
-                    'Error while reading from the audio input: {}'.format(str(e))
-                )
-                continue
-
-            self._audio_queue.put(frames)
-
-        get_bus().post(SpeechDetectionStoppedEvent())
-        self.on_recording_ended()
-        self.logger.debug('Recording thread terminated')
-
-    @abstractmethod
-    @action
-    def detect(self, audio_file: str) -> SpeechDetectedResponse:
-        """
-        Perform speech-to-text analysis on an audio file. Must be implemented by the derived classes.
-
-        :param audio_file: Path to the audio file.
-        """
-        raise NotImplementedError
-
-    def __enter__(self):
-        """
-        Context manager enter. Starts detection and returns self.
-        """
-        self.start_detection()
-        return self
-
-    def __exit__(self, exc_type, exc_val, exc_tb):
-        """
-        Context manager exit. Stops detection.
-        """
-        self.stop_detection()
-
-    @action
-    def start_detection(
-        self,
-        input_device: Optional[str] = None,
-        seconds: Optional[float] = None,
-        block_duration: Optional[float] = None,
-    ) -> None:
-        """
-        Start the speech detection engine.
-
-        :param input_device: Audio input device name/index override
-        :param seconds: If set, then the detection engine will stop after this many seconds, otherwise it'll
-            start running until ``stop_detection`` is called or application stop.
-        :param block_duration: ``block_duration`` override.
-        """
-        assert (
-            not self._input_stream and not self._recording_thread
-        ), 'Speech detection is already running'
-        block_duration = block_duration or self.block_duration
-        input_device = input_device if input_device is not None else self.input_device
-        self._audio_queue = queue.Queue()
-        self._recording_thread = threading.Thread(
-            target=lambda: self.recording_thread(
-                block_duration=block_duration, input_device=input_device
-            )
-        )
-
-        self._recording_thread.start()
-        self._detection_thread = threading.Thread(
-            target=lambda: self.detection_thread()
-        )
-        self._detection_thread.start()
-
-        if seconds:
-            threading.Timer(seconds, lambda: self.stop_detection()).start()
-
-    @action
-    def stop_detection(self) -> None:
-        """
-        Stop the speech detection engine.
-        """
-        assert self._input_stream, 'Speech detection is not running'
-        self._input_stream.stop(ignore_errors=True)
-        self._input_stream.close(ignore_errors=True)
-        self._input_stream = None
-
-        if self._recording_thread:
-            self._recording_thread.join(timeout=self._thread_stop_timeout)
-            self._recording_thread = None
-
-        self._audio_queue = None
-        if self._detection_thread:
-            self._detection_thread.join(timeout=self._thread_stop_timeout)
-            self._detection_thread = None
-
-
-# vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/stt/picovoice/__init__.py b/platypush/plugins/stt/picovoice/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/platypush/plugins/stt/picovoice/hotword/__init__.py b/platypush/plugins/stt/picovoice/hotword/__init__.py
deleted file mode 100644
index 5c7767833..000000000
--- a/platypush/plugins/stt/picovoice/hotword/__init__.py
+++ /dev/null
@@ -1,120 +0,0 @@
-import os
-import struct
-from typing import Optional, List
-
-from platypush.message.response.stt import SpeechDetectedResponse
-from platypush.plugins import action
-from platypush.plugins.stt import SttPlugin
-
-
-class SttPicovoiceHotwordPlugin(SttPlugin):
-    """
-    This plugin performs hotword detection using `PicoVoice <https://github.com/Picovoice>`_.
-    """
-
-    def __init__(
-        self,
-        library_path: Optional[str] = None,
-        model_file_path: Optional[str] = None,
-        keyword_file_paths: Optional[List[str]] = None,
-        sensitivity: float = 0.5,
-        sensitivities: Optional[List[float]] = None,
-        *args,
-        **kwargs
-    ):
-        from pvporcupine import Porcupine
-        from pvporcupine.resources.util.python.util import (
-            LIBRARY_PATH,
-            MODEL_FILE_PATH,
-            KEYWORD_FILE_PATHS,
-        )
-
-        super().__init__(*args, **kwargs)
-
-        self.hotwords = list(self.hotwords)
-        self._hotword_engine: Optional[Porcupine] = None
-        self._library_path = os.path.abspath(
-            os.path.expanduser(library_path or LIBRARY_PATH)
-        )
-        self._model_file_path = os.path.abspath(
-            os.path.expanduser(model_file_path or MODEL_FILE_PATH)
-        )
-
-        if not keyword_file_paths:
-            hotwords = KEYWORD_FILE_PATHS
-            assert all(
-                hotword in hotwords for hotword in self.hotwords
-            ), 'Not all the hotwords could be found. Available hotwords: {}'.format(
-                list(hotwords.keys())
-            )
-
-            self._keyword_file_paths = [
-                os.path.abspath(os.path.expanduser(hotwords[hotword]))
-                for hotword in self.hotwords
-            ]
-        else:
-            self._keyword_file_paths = [
-                os.path.abspath(os.path.expanduser(p)) for p in keyword_file_paths
-            ]
-
-        self._sensitivities = []
-        if sensitivities:
-            assert len(self._keyword_file_paths) == len(
-                sensitivities
-            ), 'Please specify as many sensitivities as the number of configured hotwords'
-
-            self._sensitivities = sensitivities
-        else:
-            self._sensitivities = [sensitivity] * len(self._keyword_file_paths)
-
-    def convert_frames(self, frames: bytes) -> tuple:
-        assert self._hotword_engine, 'The hotword engine is not running'
-        return struct.unpack_from("h" * self._hotword_engine.frame_length, frames)
-
-    def on_detection_ended(self) -> None:
-        if self._hotword_engine:
-            self._hotword_engine.delete()
-        self._hotword_engine = None
-
-    def detect_speech(self, frames: tuple) -> str:
-        index = self._hotword_engine.process(frames)
-        if index < 0:
-            return ''
-
-        if index is True:
-            index = 0
-        return self.hotwords[index]
-
-    @action
-    def detect(self, audio_file: str) -> SpeechDetectedResponse:
-        """
-        Perform speech-to-text analysis on an audio file.
-
-        :param audio_file: Path to the audio file.
-        """
-        pass
-
-    def recording_thread(
-        self, input_device: Optional[str] = None, *args, **kwargs
-    ) -> None:
-        assert self._hotword_engine, 'The hotword engine has not yet been initialized'
-        super().recording_thread(
-            block_size=self._hotword_engine.frame_length, input_device=input_device
-        )
-
-    @action
-    def start_detection(self, *args, **kwargs) -> None:
-        from pvporcupine import Porcupine
-
-        self._hotword_engine = Porcupine(
-            library_path=self._library_path,
-            model_file_path=self._model_file_path,
-            keyword_file_paths=self._keyword_file_paths,
-            sensitivities=self._sensitivities,
-        )
-
-        self.rate = self._hotword_engine.sample_rate
-        super().start_detection(*args, **kwargs)
-
-
-# vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/stt/picovoice/hotword/manifest.yaml b/platypush/plugins/stt/picovoice/hotword/manifest.yaml
deleted file mode 100644
index f8e9d210a..000000000
--- a/platypush/plugins/stt/picovoice/hotword/manifest.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-manifest:
-  events: {}
-  install:
-    pip:
-    - pvporcupine
-  package: platypush.plugins.stt.picovoice.hotword
-  type: plugin
diff --git a/platypush/plugins/stt/picovoice/speech/__init__.py b/platypush/plugins/stt/picovoice/speech/__init__.py
deleted file mode 100644
index 4043ec530..000000000
--- a/platypush/plugins/stt/picovoice/speech/__init__.py
+++ /dev/null
@@ -1,154 +0,0 @@
-import inspect
-import os
-import platform
-import struct
-import threading
-from typing import Optional
-
-from platypush.message.event.stt import SpeechStartedEvent
-
-from platypush.context import get_bus
-from platypush.message.response.stt import SpeechDetectedResponse
-from platypush.plugins import action
-from platypush.plugins.stt import SttPlugin
-
-
-class SttPicovoiceSpeechPlugin(SttPlugin):
-    """
-    This plugin performs speech detection using `PicoVoice <https://github.com/Picovoice>`_.
-    NOTE: The PicoVoice product used for real-time speech-to-text (Cheetah) can be used freely for
-    personal applications on x86_64 Linux. Other architectures and operating systems require a commercial license.
-    You can ask for a license `here <https://picovoice.ai/contact.html>`_.
-    """
-
-    def __init__(
-        self,
-        library_path: Optional[str] = None,
-        acoustic_model_path: Optional[str] = None,
-        language_model_path: Optional[str] = None,
-        license_path: Optional[str] = None,
-        end_of_speech_timeout: int = 1,
-        *args,
-        **kwargs
-    ):
-        """
-        :param library_path: Path to the Cheetah binary library for your OS
-            (default: ``CHEETAH_INSTALL_DIR/lib/OS/ARCH/libpv_cheetah.EXT``).
-        :param acoustic_model_path: Path to the acoustic speech model
-            (default: ``CHEETAH_INSTALL_DIR/lib/common/acoustic_model.pv``).
-        :param language_model_path:  Path to the language model
-            (default: ``CHEETAH_INSTALL_DIR/lib/common/language_model.pv``).
-        :param license_path: Path to your PicoVoice license
-            (default: ``CHEETAH_INSTALL_DIR/resources/license/cheetah_eval_linux_public.lic``).
-        :param end_of_speech_timeout: Number of seconds of silence during speech recognition before considering
-            a phrase over (default: 1).
-        """
-        from pvcheetah import Cheetah
-
-        super().__init__(*args, **kwargs)
-
-        self._basedir = os.path.abspath(
-            os.path.join(inspect.getfile(Cheetah), '..', '..', '..')
-        )
-        if not library_path:
-            library_path = self._get_library_path()
-        if not language_model_path:
-            language_model_path = os.path.join(
-                self._basedir, 'lib', 'common', 'language_model.pv'
-            )
-        if not acoustic_model_path:
-            acoustic_model_path = os.path.join(
-                self._basedir, 'lib', 'common', 'acoustic_model.pv'
-            )
-        if not license_path:
-            license_path = os.path.join(
-                self._basedir, 'resources', 'license', 'cheetah_eval_linux_public.lic'
-            )
-
-        self._library_path = library_path
-        self._language_model_path = language_model_path
-        self._acoustic_model_path = acoustic_model_path
-        self._license_path = license_path
-        self._end_of_speech_timeout = end_of_speech_timeout
-        self._stt_engine: Optional[Cheetah] = None
-        self._speech_in_progress = threading.Event()
-
-    def _get_library_path(self) -> str:
-        path = os.path.join(
-            self._basedir, 'lib', platform.system().lower(), platform.machine()
-        )
-        return os.path.join(
-            path, [f for f in os.listdir(path) if f.startswith('libpv_cheetah.')][0]
-        )
-
-    def convert_frames(self, frames: bytes) -> tuple:
-        assert self._stt_engine, 'The speech engine is not running'
-        return struct.unpack_from("h" * self._stt_engine.frame_length, frames)
-
-    def on_detection_ended(self) -> None:
-        if self._stt_engine:
-            self._stt_engine.delete()
-        self._stt_engine = None
-
-    def detect_speech(self, frames: tuple) -> str:
-        text, is_endpoint = self._stt_engine.process(frames)
-        text = text.strip()
-
-        if text:
-            if not self._speech_in_progress.is_set():
-                self._speech_in_progress.set()
-                get_bus().post(SpeechStartedEvent())
-
-            self._current_text += ' ' + text.strip()
-
-        if is_endpoint:
-            text = self._stt_engine.flush().strip().strip()
-            if text:
-                self._current_text += ' ' + text
-
-            self._speech_in_progress.clear()
-            if self._current_text:
-                self.on_speech_detected(self._current_text)
-
-            self._current_text = ''
-
-        return self._current_text
-
-    def process_text(self, text: str) -> None:
-        pass
-
-    @action
-    def detect(self, audio_file: str) -> SpeechDetectedResponse:
-        """
-        Perform speech-to-text analysis on an audio file.
-
-        :param audio_file: Path to the audio file.
-        """
-        pass
-
-    def recording_thread(
-        self, input_device: Optional[str] = None, *args, **kwargs
-    ) -> None:
-        assert self._stt_engine, 'The hotword engine has not yet been initialized'
-        super().recording_thread(
-            block_size=self._stt_engine.frame_length, input_device=input_device
-        )
-
-    @action
-    def start_detection(self, *args, **kwargs) -> None:
-        from pvcheetah import Cheetah
-
-        self._stt_engine = Cheetah(
-            library_path=self._library_path,
-            acoustic_model_path=self._acoustic_model_path,
-            language_model_path=self._language_model_path,
-            license_path=self._license_path,
-            endpoint_duration_sec=self._end_of_speech_timeout,
-        )
-
-        self.rate = self._stt_engine.sample_rate
-        self._speech_in_progress.clear()
-        super().start_detection(*args, **kwargs)
-
-
-# vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/stt/picovoice/speech/manifest.yaml b/platypush/plugins/stt/picovoice/speech/manifest.yaml
deleted file mode 100644
index 0e7a01a8a..000000000
--- a/platypush/plugins/stt/picovoice/speech/manifest.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-manifest:
-  events: {}
-  install:
-    pip:
-    - cheetah
-  package: platypush.plugins.stt.picovoice.speech
-  type: plugin
diff --git a/platypush/utils/mock/modules.py b/platypush/utils/mock/modules.py
index 6af743558..e83fd377e 100644
--- a/platypush/utils/mock/modules.py
+++ b/platypush/utils/mock/modules.py
@@ -83,7 +83,10 @@ mock_imports = [
     "pmw3901",
     "psutil",
     "pvcheetah",
-    "pvporcupine ",
+    "pvleopard",
+    "pvorca",
+    "pvporcupine",
+    "pvrhino",
     "pyHS100",
     "pyaudio",
     "pychromecast",