├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── ada ├── __init__.py ├── __main__.py ├── conversation.py ├── homeassistant.py ├── hotword.py ├── microphone.py ├── options.py ├── speech.py └── voice.py ├── pylintrc ├── requirements.txt ├── requirements_tests.txt ├── setup.cfg ├── setup.py └── tox.ini /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7 2 | 3 | WORKDIR /workspaces 4 | 5 | # Install Python dependencies from requirements.txt if it exists 6 | COPY requirements_tests.txt . 7 | RUN pip3 install -r requirements_tests.txt tox \ 8 | && rm -f requirements_tests.txt 9 | 10 | # Set the default shell to bash instead of sh 11 | ENV SHELL /bin/bash 12 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // See https://aka.ms/vscode-remote/devcontainer.json for format details. 2 | { 3 | "name": "Ada Dev", 4 | "context": "..", 5 | "dockerFile": "Dockerfile", 6 | "postCreateCommand": "pip3 install -e .", 7 | "runArgs": ["-e", "GIT_EDTIOR=code --wait"], 8 | "extensions": [ 9 | "ms-python.python", 10 | "visualstudioexptteam.vscodeintellicode", 11 | "ms-azure-devops.azure-pipelines", 12 | "esbenp.prettier-vscode" 13 | ], 14 | "settings": { 15 | "python.pythonPath": "/usr/local/bin/python", 16 | "python.linting.pylintEnabled": true, 17 | "python.linting.enabled": true, 18 | "python.formatting.provider": "black", 19 | "python.formatting.blackArgs": ["--target-version", "py37"], 20 | "editor.formatOnPaste": false, 21 | "editor.formatOnSave": true, 22 | "editor.formatOnType": true, 23 | "files.trimTrailingWhitespace": true, 24 | "terminal.integrated.shell.linux": "/bin/bash" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hey Ada! 2 | 3 | Home Assistant featured voice assistant. 4 | -------------------------------------------------------------------------------- /ada/__init__.py: -------------------------------------------------------------------------------- 1 | """The Ada Module.""" 2 | import logging 3 | 4 | from .conversation import Conversation 5 | from .homeassistant import HomeAssistant 6 | from .hotword import Hotword 7 | from .microphone import Microphone 8 | from .options import Options 9 | from .speech import Speech 10 | from .voice import Voice 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class Ada: 16 | """Hey Ada assistant.""" 17 | 18 | def __init__(self, options: Options): 19 | """Initialize ada.""" 20 | self.homeassistant = HomeAssistant(options) 21 | self.hotword: Hotword = Hotword() 22 | self.speech: Speech = Speech(self.homeassistant) 23 | self.conversation: Conversation = Conversation(self.homeassistant) 24 | self.voice: Voice = Voice(self.homeassistant, options) 25 | self.microphone: Microphone = Microphone( 26 | self.hotword.frame_length, self.hotword.sample_rate 27 | ) 28 | 29 | def run(self) -> None: 30 | """Run Ada in a loop.""" 31 | self.microphone.start() 32 | try: 33 | self._run() 34 | finally: 35 | self.microphone.stop() 36 | 37 | def _run(self) -> None: 38 | """Internal Runner.""" 39 | while True: 40 | pcm = self.microphone.get_frame() 41 | 42 | if not self.hotword.process(pcm): 43 | continue 44 | _LOGGER.info("Detect hotword") 45 | 46 | # Start conversation 47 | wait_time = 2 48 | while True: 49 | text = self.speech.process(self.microphone, wait_time) 50 | if not text or text == "Stop.": 51 | break 52 | 53 | answer = self.conversation.process(text) 54 | if not answer: 55 | break 56 | 57 | if not self.voice.process(answer): 58 | break 59 | wait_time = 3 60 | -------------------------------------------------------------------------------- /ada/__main__.py: -------------------------------------------------------------------------------- 1 | """Ada assistant.""" 2 | import logging 3 | 4 | import click 5 | 6 | from . import Ada 7 | from .options import Options 8 | 9 | 10 | def init_logger(): 11 | """Initialize python logger.""" 12 | logging.basicConfig(level=logging.INFO) 13 | fmt = "%(asctime)s %(levelname)s (%(threadName)s) " "[%(name)s] %(message)s" 14 | datefmt = "%Y-%m-%d %H:%M:%S" 15 | 16 | # stdout handler 17 | logging.getLogger().handlers[0].setFormatter( 18 | logging.Formatter(fmt, datefmt=datefmt) 19 | ) 20 | 21 | 22 | @click.command() 23 | @click.option("--url", required=True, type=str, help="URL to Home Assistant.") 24 | @click.option( 25 | "--key", required=True, type=str, help="API access Key to Home Assistant." 26 | ) 27 | @click.option( 28 | "--stt", required=True, type=str, help="Name of Home Assistant STT provider." 29 | ) 30 | @click.option( 31 | "--tts", required=True, type=str, help="Name of Home Assistant TTS provider." 32 | ) 33 | def main(url, key, stt, tts): 34 | """Run Application.""" 35 | init_logger() 36 | 37 | options = Options( 38 | hass_api_url=url, hass_token=key, stt_platform=stt, tts_platform=tts, 39 | ) 40 | 41 | ada = Ada(options) 42 | ada.run() 43 | 44 | 45 | if __name__ == "__main__": 46 | # pylint: disable=no-value-for-parameter 47 | main() 48 | -------------------------------------------------------------------------------- /ada/conversation.py: -------------------------------------------------------------------------------- 1 | """Conversation handler.""" 2 | import logging 3 | from typing import Optional 4 | 5 | from .homeassistant import HomeAssistant 6 | 7 | _LOGGER = logging.getLogger(__name__) 8 | 9 | 10 | class Conversation: 11 | """Conversation handler.""" 12 | 13 | def __init__(self, homeassistant: HomeAssistant) -> None: 14 | """Initialize conversation processing.""" 15 | self.homeassistant: HomeAssistant = homeassistant 16 | 17 | def process(self, text: str) -> Optional[str]: 18 | """Process Speech to Text.""" 19 | answer = self.homeassistant.send_conversation(text) 20 | 21 | if not answer: 22 | _LOGGER.error("Failed processing conversation") 23 | return None 24 | conversation = answer["speech"]["plain"]["speech"] 25 | 26 | _LOGGER.info("Conversation answer: %s", conversation) 27 | return conversation 28 | -------------------------------------------------------------------------------- /ada/homeassistant.py: -------------------------------------------------------------------------------- 1 | """Handle Home Assistant requests.""" 2 | import logging 3 | from typing import Dict, Optional, Generator 4 | 5 | import requests 6 | 7 | from .options import Options 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | class HomeAssistant: 13 | """Handle Home Assistant API requests.""" 14 | 15 | def __init__(self, options: Options): 16 | """Initialize Home Assistant API.""" 17 | self.options = options 18 | self.headers = {"Authorization": f"Bearer {options.hass_token}"} 19 | 20 | def send_stt( 21 | self, data_gen: Generator[bytes, None, None] 22 | ) -> Optional[Dict[str, Optional[str]]]: 23 | """Send audio stream to STT handler.""" 24 | headers = { 25 | **self.headers, 26 | "X-Speech-Content": "format=wav; codec=pcm; sample_rate=16000; bit_rate=16; channel=1; language=en-US", 27 | } 28 | 29 | _LOGGER.info("Sending audio stream to Home Assistant STT") 30 | req = requests.post( 31 | f"{self.options.hass_api_url}/stt/{self.options.stt_platform}", 32 | data=data_gen, 33 | headers=headers, 34 | ) 35 | 36 | if req.status_code != 200: 37 | return None 38 | return req.json() 39 | 40 | def send_conversation(self, text: str) -> Optional[dict]: 41 | """Send Conversation text to API.""" 42 | _LOGGER.info("Send text to Home Assistant conversation") 43 | req = requests.post( 44 | f"{self.options.hass_api_url}/conversation/process", 45 | json={"text": text, "conversation_id": "ada"}, 46 | headers=self.headers, 47 | ) 48 | 49 | if req.status_code != 200: 50 | return None 51 | return req.json() 52 | 53 | def send_tts(self, text: str) -> Optional[dict]: 54 | """Send a text for TTS.""" 55 | _LOGGER.info("Send text to Home Assistant TTS") 56 | req = requests.post( 57 | f"{self.options.hass_api_url}/tts_get_url", 58 | json={"platform": self.options.tts_platform, "message": text}, 59 | headers=self.headers, 60 | ) 61 | 62 | if req.status_code != 200: 63 | return None 64 | return req.json() 65 | -------------------------------------------------------------------------------- /ada/hotword.py: -------------------------------------------------------------------------------- 1 | """Ada hotword enginge.""" 2 | import sys 3 | from pathlib import Path 4 | import platform 5 | from typing import TYPE_CHECKING, List, cast 6 | 7 | import pyaudio 8 | import numpy as np 9 | import importlib_metadata 10 | 11 | if TYPE_CHECKING: 12 | from pvporcupine.binding.python.porcupine import Porcupine 13 | 14 | 15 | class Hotword: 16 | """Hotword processing.""" 17 | 18 | def __init__(self) -> None: 19 | """Initialize Hotword processing.""" 20 | loader = PorcupineLoader() 21 | self.porcupine = loader.load() 22 | 23 | @property 24 | def frame_length(self) -> int: 25 | """Return frame length for processing hotword.""" 26 | return self.porcupine.frame_length 27 | 28 | @property 29 | def sample_rate(self) -> int: 30 | """Return sample rate for recording.""" 31 | return self.porcupine.sample_rate 32 | 33 | @property 34 | def bit_rate(self) -> int: 35 | """Return bit rate for recording.""" 36 | return pyaudio.paInt16 37 | 38 | @property 39 | def channel(self) -> int: 40 | """Return channel for recording.""" 41 | return 1 42 | 43 | def process(self, pcm: np.ndarray) -> bool: 44 | """Process audio frame.""" 45 | return self.porcupine.process(pcm) 46 | 47 | 48 | class PorcupineLoader: 49 | """Class to help loading Porcupine.""" 50 | 51 | def load(self) -> "Porcupine": 52 | """Load Porcupine object.""" 53 | dist = importlib_metadata.distribution("pvporcupine") 54 | porcupine_paths = [ 55 | f 56 | for f in cast(List[importlib_metadata.PackagePath], dist.files) 57 | if f.name == "porcupine.py" 58 | ] 59 | 60 | if not porcupine_paths: 61 | raise RuntimeError("Unable to find porcupine.py in pvporcupine package") 62 | 63 | porcupine_path = porcupine_paths[0].locate().parent 64 | lib_path = porcupine_path.parent.parent 65 | 66 | sys.path.append(str(porcupine_path)) 67 | 68 | if not TYPE_CHECKING: 69 | # pylint: disable=import-outside-toplevel, import-error 70 | from porcupine import Porcupine 71 | 72 | return Porcupine( 73 | library_path=str(self._library_path(lib_path)), 74 | model_file_path=str(self._model_file_path(lib_path)), 75 | keyword_file_path=str(self._keyword_file_path(lib_path)), 76 | sensitivity=0.5, 77 | ) 78 | 79 | @staticmethod 80 | def _library_path(lib_path: Path) -> Path: 81 | """Return Path to library.""" 82 | machine = platform.machine() 83 | 84 | if machine == "x86_64": 85 | return lib_path / "lib/linux/x86_64/libpv_porcupine.so" 86 | if machine in ("armv7l", "aarch64"): 87 | return lib_path / "lib/raspberry-pi/cortex-a53/libpv_porcupine.so" 88 | if machine == "armv6l": 89 | return lib_path / "lib/raspberry-pi/arm11/libpv_porcupine.so" 90 | 91 | raise RuntimeError("Architecture is not supported by Hotword") 92 | 93 | @staticmethod 94 | def _model_file_path(lib_path: Path) -> Path: 95 | """Return Path to Model file.""" 96 | return lib_path / "lib/common/porcupine_params.pv" 97 | 98 | @staticmethod 99 | def _keyword_file_path(lib_path: Path) -> Path: 100 | """Return Path to hotword keyfile.""" 101 | machine = platform.machine() 102 | 103 | if machine == "x86_64": 104 | return lib_path / "resources/keyword_files/linux/hey pico_linux.ppn" 105 | if machine in ("armv7l", "aarch64", "armv6l"): 106 | return ( 107 | lib_path 108 | / "resources/keyword_files/raspberrypi/hey pico_raspberrypi.ppn" 109 | ) 110 | 111 | raise RuntimeError("Architecture is not supported by Hotword") 112 | -------------------------------------------------------------------------------- /ada/microphone.py: -------------------------------------------------------------------------------- 1 | """Microphone handler.""" 2 | from typing import Optional 3 | 4 | import pyaudio 5 | import numpy as np 6 | import webrtcvad 7 | 8 | 9 | class Microphone: 10 | """Hotword processing.""" 11 | 12 | def __init__(self, frame_length: int, sample_rate: int) -> None: 13 | """Initialize Microphone processing.""" 14 | self.audio = pyaudio.PyAudio() 15 | self.vad = webrtcvad.Vad(1) 16 | self.stream: Optional[pyaudio.Stream] = None 17 | 18 | self._frame_length = frame_length 19 | self._sample_rate = sample_rate 20 | self._last_frame: Optional[np.ndarray] = None 21 | 22 | @property 23 | def frame_length(self) -> int: 24 | """Return frame length for processing hotword.""" 25 | return self._frame_length 26 | 27 | @property 28 | def sample_rate(self) -> int: 29 | """Return sample rate for recording.""" 30 | return self._sample_rate 31 | 32 | @property 33 | def bit_rate(self) -> int: 34 | """Return bit rate for recording.""" 35 | return pyaudio.paInt16 36 | 37 | @property 38 | def channel(self) -> int: 39 | """Return channel for recording.""" 40 | return 1 41 | 42 | def start(self): 43 | """Open Audio stream.""" 44 | self.stream = self.audio.open( 45 | rate=self.sample_rate, 46 | channels=self.channel, 47 | format=self.bit_rate, 48 | input=True, 49 | frames_per_buffer=self.frame_length, 50 | ) 51 | 52 | def stop(self): 53 | """Close Audio stream.""" 54 | self.stream.close() 55 | self.stream = None 56 | 57 | def get_frame(self) -> np.ndarray: 58 | """Read from audio stream.""" 59 | raw = self.stream.read(self.frame_length, exception_on_overflow=False) 60 | 61 | self._last_frame = np.fromstring(raw, dtype=np.int16) 62 | return self._last_frame.copy() 63 | 64 | def detect_silent(self) -> bool: 65 | """Return True if on last frame it detect silent.""" 66 | return not self.vad.is_speech( 67 | self._last_frame[0:480].tostring(), self.sample_rate 68 | ) 69 | -------------------------------------------------------------------------------- /ada/options.py: -------------------------------------------------------------------------------- 1 | """Helper to hold options for Ada.""" 2 | from collections import namedtuple 3 | 4 | 5 | Options = namedtuple( 6 | "Options", ["hass_api_url", "hass_token", "stt_platform", "tts_platform"] 7 | ) 8 | -------------------------------------------------------------------------------- /ada/speech.py: -------------------------------------------------------------------------------- 1 | """Ada speech enginge.""" 2 | import io 3 | import logging 4 | import wave 5 | from time import monotonic 6 | from typing import Generator, Optional 7 | 8 | import pyaudio 9 | 10 | from .homeassistant import HomeAssistant 11 | from .microphone import Microphone 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | class Speech: 17 | """Speech processing.""" 18 | 19 | def __init__(self, homeassistant: HomeAssistant) -> None: 20 | """Initialize Audio processing.""" 21 | self.homeassistant: HomeAssistant = homeassistant 22 | 23 | @property 24 | def sample_rate(self) -> int: 25 | """Return sample rate for recording.""" 26 | return 16000 27 | 28 | @property 29 | def bit_rate(self) -> int: 30 | """Return bit rate for recording.""" 31 | return pyaudio.paInt16 32 | 33 | @property 34 | def channel(self) -> int: 35 | """Return channel for recording.""" 36 | return 1 37 | 38 | def _get_voice_data( 39 | self, microphone: Microphone, wait_time: int 40 | ) -> Generator[bytes, None, None]: 41 | """Process voice speech.""" 42 | silent_time = None 43 | 44 | # Send Wave header 45 | wave_buffer = io.BytesIO() 46 | wav = wave.open(wave_buffer, "wb") 47 | wav.setnchannels(self.channel) 48 | wav.setsampwidth(2) 49 | wav.setframerate(self.sample_rate) 50 | wav.close() 51 | yield wave_buffer.getvalue() 52 | 53 | # Process audio stream 54 | while True: 55 | pcm = microphone.get_frame().tostring() 56 | 57 | # Handle silent 58 | if microphone.detect_silent(): 59 | if silent_time is None: 60 | silent_time = monotonic() 61 | elif monotonic() - silent_time > wait_time: 62 | _LOGGER.info("Voice command ended") 63 | return 64 | else: 65 | wait_time = 1 66 | silent_time = None 67 | 68 | yield pcm 69 | 70 | def process(self, microphone: Microphone, wait_time: int) -> Optional[str]: 71 | """Process Speech to Text.""" 72 | speech = self.homeassistant.send_stt( 73 | self._get_voice_data(microphone, wait_time) 74 | ) 75 | 76 | if not speech or speech["result"] != "success": 77 | _LOGGER.error("Can't detect speech on audio stream") 78 | return None 79 | if not speech["text"]: 80 | _LOGGER.info("No new command given") 81 | return None 82 | 83 | _LOGGER.info("Retrieved text: %s", speech["text"]) 84 | return speech["text"] 85 | -------------------------------------------------------------------------------- /ada/voice.py: -------------------------------------------------------------------------------- 1 | """Voice of Ada.""" 2 | import logging 3 | import subprocess 4 | 5 | from .homeassistant import HomeAssistant 6 | from .options import Options 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | 11 | class Voice: 12 | """Voice of ada.""" 13 | 14 | def __init__(self, homeassistant: HomeAssistant, options: Options) -> None: 15 | """Initialize Voice output processing.""" 16 | self.homeassistant: HomeAssistant = homeassistant 17 | self.options: Options = options 18 | 19 | def _play(self, audio_url: str) -> bool: 20 | """Play Audio file from buffer.""" 21 | play = subprocess.Popen( 22 | [ 23 | "mplayer", 24 | "-quiet", 25 | "-prefer-ipv4", 26 | "-http-header-fields", 27 | f"Authorization: Bearer {self.options.hass_token}", 28 | audio_url, 29 | ], 30 | stdin=None, 31 | stderr=None, 32 | stdout=None, 33 | ) 34 | 35 | play.wait() 36 | return play.returncode == 0 37 | 38 | def process(self, answer: str) -> bool: 39 | """Process text to voice.""" 40 | url = self.homeassistant.send_tts(answer) 41 | if not url: 42 | _LOGGER.warning("Not able to get a TTS URL") 43 | return False 44 | 45 | filename = url["url"].split("/")[-1] 46 | _LOGGER.info("TTS is available as %s", filename) 47 | 48 | return self._play(f"{self.options.hass_api_url}/tts_proxy/{filename}") 49 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | reports=no 3 | 4 | # Reasons disabled: 5 | # locally-disabled - it spams too much 6 | # duplicate-code - unavoidable 7 | # cyclic-import - doesn't test if both import on load 8 | # abstract-class-little-used - prevents from setting right foundation 9 | # abstract-class-not-used - is flaky, should not show up but does 10 | # unused-argument - generic callbacks and setup methods create a lot of warnings 11 | # global-statement - used for the on-demand requirement installation 12 | # redefined-variable-type - this is Python, we're duck typing! 13 | # too-many-* - are not enforced for the sake of readability 14 | # too-few-* - same as too-many-* 15 | # abstract-method - with intro of async there are always methods missing 16 | 17 | disable= 18 | abstract-class-little-used, 19 | abstract-class-not-used, 20 | abstract-method, 21 | cyclic-import, 22 | duplicate-code, 23 | global-statement, 24 | locally-disabled, 25 | not-context-manager, 26 | redefined-variable-type, 27 | too-few-public-methods, 28 | too-many-arguments, 29 | too-many-branches, 30 | too-many-instance-attributes, 31 | too-many-lines, 32 | too-many-locals, 33 | too-many-public-methods, 34 | too-many-return-statements, 35 | too-many-statements, 36 | unused-argument, 37 | line-too-long, 38 | bad-continuation, 39 | too-few-public-methods, 40 | no-self-use, 41 | not-async-context-manager, 42 | too-many-locals, 43 | too-many-branches, 44 | no-else-return 45 | 46 | [EXCEPTIONS] 47 | overgeneral-exceptions=Exception 48 | 49 | 50 | [TYPECHECK] 51 | ignored-modules = distutils 52 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pvporcupine==1.6.3 2 | numpy==1.17.3 3 | PyAudio==0.2.11 4 | requests==2.22.0 5 | webrtcvad==2.0.10 6 | click==7.0 7 | importlib-metadata==0.23 8 | -------------------------------------------------------------------------------- /requirements_tests.txt: -------------------------------------------------------------------------------- 1 | flake8==3.7.8 2 | pylint==2.4.3 3 | pytest==5.2.2 4 | pytest-timeout==1.3.3 5 | black==19.10b0 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | multi_line_output = 3 3 | include_trailing_comma=True 4 | force_grid_wrap=0 5 | line_length=88 6 | indent = " " 7 | not_skip = __init__.py 8 | force_sort_within_sections = true 9 | sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 10 | default_section = THIRDPARTY 11 | forced_separate = tests 12 | combine_as_imports = true 13 | use_parentheses = true 14 | 15 | [flake8] 16 | max-line-length = 88 17 | ignore = E501, W503 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Hey Ada!""" 2 | from pathlib import Path 3 | from setuptools import setup 4 | 5 | 6 | setup( 7 | name="ada", 8 | version="0.8", 9 | license="Apache License 2.0", 10 | author="The Home Assistant Authors", 11 | author_email="hello@home-assistant.io", 12 | url="https://home-assistant.io/", 13 | description="Hey Ada!", 14 | long_description=Path("README.md").read_text(), 15 | long_description_content_type="text/markdown", 16 | classifiers=[ 17 | "Intended Audience :: End Users/Desktop", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: Apache Software License", 20 | "Operating System :: OS Independent", 21 | "Topic :: Home Automation", 22 | "Topic :: Software Development :: Libraries :: Python Modules", 23 | "Topic :: Scientific/Engineering :: Atmospheric Science", 24 | "Development Status :: 5 - Production/Stable", 25 | "Intended Audience :: Developers", 26 | "Programming Language :: Python :: 3.7", 27 | ], 28 | keywords=["voice", "home-assistant", "personal"], 29 | zip_safe=False, 30 | platforms="any", 31 | packages=["ada"], 32 | install_requires=[ 33 | "requests", 34 | "numpy", 35 | "webrtcvad", 36 | "pyaudio", 37 | "importlib_metadata", 38 | "click" 39 | ], 40 | include_package_data=True, 41 | ) 42 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = lint, tests 3 | 4 | [testenv] 5 | basepython = python3 6 | deps = 7 | -r{toxinidir}/requirements_tests.txt 8 | 9 | [testenv:lint] 10 | ignore_errors = True 11 | commands = 12 | flake8 ada 13 | pylint --rcfile pylintrc ada 14 | 15 | [testenv:tests] 16 | commands = 17 | pytest tests 18 | 19 | [testenv:black] 20 | commands = 21 | black --target-version py37 --check ada setup.py 22 | --------------------------------------------------------------------------------