├── .travis.yml ├── LICENSE ├── README.md ├── clean.sh ├── client ├── __init__.py ├── app_utils.py ├── brain.py ├── conversation.py ├── diagnose.py ├── g2p.py ├── jasperpath.py ├── local_mic.py ├── main.py ├── mic.py ├── modules │ ├── Unclear.py │ └── __init__.py ├── populate.py ├── stt.py ├── test_mic.py ├── tts.py └── vocabcompiler.py ├── conf └── profile.yml ├── jasper.py ├── requirements.txt ├── start.sh ├── static ├── audio │ ├── beep_hi.wav │ ├── beep_lo.wav │ ├── jasper.wav │ ├── say.wav │ ├── time.wav │ └── weather_zh.wav ├── dictionary_persona.dic ├── keyword_phrases ├── languagemodel_persona.lm └── text │ └── JOKES.txt └── tests ├── __init__.py ├── test_brain.py ├── test_diagnose.py └── test_modules.py /.travis.yml: -------------------------------------------------------------------------------- 1 | env: 2 | - ARCH=x86 3 | language: python 4 | sudo: false 5 | python: 6 | - "3.4" 7 | cache: 8 | directories: 9 | - "$HOME/.pip-cache/" 10 | - "/home/travis/virtualenv/python3.4" 11 | install: 12 | - "pip install -r requirements.txt" 13 | - "pip install python-coveralls" 14 | - "pip install coverage" 15 | - "pip install flake8" 16 | before_script: 17 | - "flake8 jasper.py client tests" 18 | script: 19 | - "coverage run -m unittest discover" 20 | after_success: 21 | - "coverage report" 22 | - "coveralls" 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Junjie Wang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 来宝人工智能:基于树莓派的语音对话机器人 2 | ================= 3 | 4 | [![Build Status](https://travis-ci.org/jjwang/laibot-client.svg?branch=master)](https://travis-ci.org/jjwang/laibot-client) [![Python3](https://img.shields.io/badge/python-3-blue.svg 5 | )](https://www.python.org) 6 | 7 | 如果你喜欢此项目,请给我打星。 8 | 9 | ## 1. 必读 10 | - [演示视频](http://v.youku.com/v_show/id_XMzIzNDUyNjQ5Mg==.html?spm=a2h3j.8428770.3416059.1) 11 | - [项目Wiki](https://github.com/jjwang/laibot-client/wiki) 12 | 13 | ## 2. 由来 14 | 15 | 来宝项目的由来是因为2017年智能音箱太火了!就智能音箱这个方向来说,众多巨头涌入的是一个2亿的小市场,小市场的由来是因为用户接受度不高(画外音:什么?天猫双十一卖了一百万台?OMG,我可什么都没说)。归根结底,就是现在的汉语普通话智能音箱方案用户体验不佳,说是智能、经常情况下是智障。如果方案在用户体验上没有任何优势,集成了再多的服务实际上用处并不大。 16 | 17 | 说了这么多,那么来宝想干嘛?其实很简单,来宝就是想试一下基于当前可用的开源软硬件和免费语音服务,能打造的语音助理最好能到什么样子?好吧,这就是来宝的由来!能做到什么程度,说实在的,我也不知道,走走看吧!来宝基于Jasper。 18 | 19 | ## 3. 规矩 20 | 21 | - 确保通过单元测试。 22 | 23 | python3 -m unittest discover 24 | - Python 代码需符合 PEP 8 编程规范,检查工具使用 flake8。 25 | 26 | -------------------------------------------------------------------------------- /clean.sh: -------------------------------------------------------------------------------- 1 | find . -name "*.pyc" | xargs rm -rf 2 | find . -name "__pycache__" | xargs rm -rf 3 | rm -r -f conf/vocabularies 4 | -------------------------------------------------------------------------------- /client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjwang/laibot-client/cc4a07617dd5316f32d391eba7e2e51ad01925bd/client/__init__.py -------------------------------------------------------------------------------- /client/app_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8-*- 2 | import smtplib 3 | from email.MIMEText import MIMEText 4 | import urllib2 5 | import re 6 | from pytz import timezone 7 | 8 | 9 | def sendEmail(SUBJECT, BODY, TO, FROM, SENDER, PASSWORD, SMTP_SERVER): 10 | """Sends an HTML email.""" 11 | for body_charset in 'US-ASCII', 'ISO-8859-1', 'UTF-8': 12 | try: 13 | BODY.encode(body_charset) 14 | except UnicodeError: 15 | pass 16 | else: 17 | break 18 | msg = MIMEText(BODY.encode(body_charset), 'html', body_charset) 19 | msg['From'] = SENDER 20 | msg['To'] = TO 21 | msg['Subject'] = SUBJECT 22 | 23 | SMTP_PORT = 587 24 | session = smtplib.SMTP(SMTP_SERVER, SMTP_PORT) 25 | session.starttls() 26 | session.login(FROM, PASSWORD) 27 | session.sendmail(SENDER, TO, msg.as_string()) 28 | session.quit() 29 | 30 | 31 | def emailUser(profile, SUBJECT="", BODY=""): 32 | """ 33 | sends an email. 34 | 35 | Arguments: 36 | profile -- contains information related to the user (e.g., email 37 | address) 38 | SUBJECT -- subject line of the email 39 | BODY -- body text of the email 40 | """ 41 | def generateSMSEmail(profile): 42 | """ 43 | Generates an email from a user's phone number based on their carrier. 44 | """ 45 | if profile['carrier'] is None or not profile['phone_number']: 46 | return None 47 | 48 | return str(profile['phone_number']) + "@" + profile['carrier'] 49 | 50 | if profile['prefers_email'] and profile['gmail_address']: 51 | # add footer 52 | if BODY: 53 | BODY = profile['first_name'] + \ 54 | ",

Here are your top headlines:" + BODY 55 | BODY += "
Sent from your Jasper" 56 | 57 | recipient = profile['gmail_address'] 58 | if profile['first_name'] and profile['last_name']: 59 | recipient = profile['first_name'] + " " + \ 60 | profile['last_name'] + " <%s>" % recipient 61 | else: 62 | recipient = generateSMSEmail(profile) 63 | 64 | if not recipient: 65 | return False 66 | 67 | try: 68 | if 'mailgun' in profile: 69 | user = profile['mailgun']['username'] 70 | password = profile['mailgun']['password'] 71 | server = 'smtp.mailgun.org' 72 | else: 73 | user = profile['gmail_address'] 74 | password = profile['gmail_password'] 75 | server = 'smtp.gmail.com' 76 | sendEmail(SUBJECT, BODY, recipient, user, 77 | "Jasper ", password, server) 78 | 79 | return True 80 | except Exception: 81 | return False 82 | 83 | 84 | def getTimezone(profile): 85 | """ 86 | Returns the pytz timezone for a given profile. 87 | 88 | Arguments: 89 | profile -- contains information related to the user (e.g., email 90 | address) 91 | """ 92 | try: 93 | return timezone(profile['timezone']) 94 | except Exception: 95 | return None 96 | 97 | 98 | def generateTinyURL(URL): 99 | """ 100 | Generates a compressed URL. 101 | 102 | Arguments: 103 | URL -- the original URL to-be compressed 104 | """ 105 | target = "http://tinyurl.com/api-create.php?url=" + URL 106 | response = urllib2.urlopen(target) 107 | return response.read() 108 | 109 | 110 | def isNegative(phrase): 111 | """ 112 | Returns True if the input phrase has a negative sentiment. 113 | 114 | Arguments: 115 | phrase -- the input phrase to-be evaluated 116 | """ 117 | return bool(re.search(r'\b(no(t)?|don\'t|stop|end)\b', phrase, 118 | re.IGNORECASE)) 119 | 120 | 121 | def isPositive(phrase): 122 | """ 123 | Returns True if the input phrase has a positive sentiment. 124 | 125 | Arguments: 126 | phrase -- the input phrase to-be evaluated 127 | """ 128 | return bool(re.search(r'\b(sure|yes|yeah|go)\b', phrase, re.IGNORECASE)) 129 | -------------------------------------------------------------------------------- /client/brain.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8-*- 2 | import logging 3 | import pkgutil 4 | import client.jasperpath as jasperpath 5 | 6 | 7 | class Brain(object): 8 | 9 | def __init__(self, mic, profile): 10 | """ 11 | Instantiates a new Brain object, which cross-references user 12 | input with a list of modules. Note that the order of brain.modules 13 | matters, as the Brain will cease execution on the first module 14 | that accepts a given input. 15 | 16 | Arguments: 17 | mic -- used to interact with the user (for both input and output) 18 | profile -- contains information related to the user (e.g., phone 19 | number) 20 | """ 21 | 22 | self.mic = mic 23 | self.profile = profile 24 | self.modules = self.get_modules() 25 | self._logger = logging.getLogger(__name__) 26 | 27 | @classmethod 28 | def get_modules(cls): 29 | """ 30 | Dynamically loads all the modules in the modules folder and sorts 31 | them by the PRIORITY key. If no PRIORITY is defined for a given 32 | module, a priority of 0 is assumed. 33 | """ 34 | 35 | logger = logging.getLogger(__name__) 36 | locations = [jasperpath.PLUGIN_PATH] 37 | logger.debug("Looking for modules in: %s", 38 | ', '.join(["'%s'" % location for location in locations])) 39 | modules = [] 40 | for finder, name, ispkg in pkgutil.walk_packages(locations): 41 | try: 42 | loader = finder.find_module(name) 43 | mod = loader.load_module(name) 44 | except Exception: 45 | logger.warning("Skipped module '%s' due to an error.", name, 46 | exc_info=True) 47 | else: 48 | if hasattr(mod, 'WORDS'): 49 | logger.debug("Found module '%s' with words: %r", name, 50 | mod.WORDS) 51 | modules.append(mod) 52 | else: 53 | logger.warning("Skipped module '%s' because it misses " + 54 | "the WORDS constant.", name) 55 | modules.sort(key=lambda mod: mod.PRIORITY if hasattr(mod, 'PRIORITY') 56 | else 0, reverse=True) 57 | return modules 58 | 59 | def query(self, texts): 60 | """ 61 | Passes user input to the appropriate module, testing it against 62 | each candidate module's isValid function. 63 | 64 | Arguments: 65 | text -- user input, typically speech, to be parsed by a module 66 | """ 67 | for module in self.modules: 68 | for text in texts: 69 | if module.isValid(text): 70 | self._logger.debug("'%s' is a valid phrase for module " + 71 | "'%s'", text, module.__name__) 72 | try: 73 | module.handle(text, self.mic, self.profile) 74 | except Exception: 75 | self._logger.error('Failed to execute module', 76 | exc_info=True) 77 | self.mic.say("I'm sorry. I had some trouble with " + 78 | "that operation. Please try again later.") 79 | else: 80 | self._logger.debug("Handling of phrase '%s' by " + 81 | "module '%s' completed", text, 82 | module.__name__) 83 | finally: 84 | return 85 | self._logger.debug("No module was able to handle any of these " + 86 | "phrases: %r", texts) 87 | -------------------------------------------------------------------------------- /client/conversation.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8-*- 2 | import logging 3 | from client.brain import Brain 4 | 5 | 6 | class Conversation(object): 7 | 8 | def __init__(self, persona, mic, profile): 9 | self._logger = logging.getLogger(__name__) 10 | self.persona = persona 11 | self.mic = mic 12 | self.profile = profile 13 | self.brain = Brain(mic, profile) 14 | 15 | def handleForever(self): 16 | """ 17 | Delegates user input to the handling function when activated. 18 | """ 19 | self._logger.info("Starting to handle conversation with keyword '%s'.", 20 | self.persona) 21 | while True: 22 | self._logger.debug("Started listening for keyword '%s'", 23 | self.persona) 24 | threshold, transcribed = self.mic.passiveListen(self.persona) 25 | self._logger.debug("Stopped listening for keyword '%s'", 26 | self.persona) 27 | 28 | if not transcribed or not threshold: 29 | self._logger.info("Nothing has been said or transcribed.") 30 | continue 31 | self._logger.info("Keyword '%s' has been said!", self.persona) 32 | 33 | self._logger.debug("Started to listen actively with threshold: %r", 34 | threshold) 35 | input = self.mic.activeListen() 36 | self._logger.debug("Stopped to listen actively with threshold: %r", 37 | threshold) 38 | 39 | if input: 40 | self.brain.query(input) 41 | else: 42 | self.mic.say("请重说") 43 | -------------------------------------------------------------------------------- /client/diagnose.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8-*- 2 | import os 3 | import sys 4 | import time 5 | import socket 6 | import subprocess 7 | import pkgutil 8 | import logging 9 | import pip.req 10 | from client import jasperpath 11 | if sys.version_info < (3, 3): 12 | from distutils.spawn import find_executable 13 | else: 14 | from shutil import which as find_executable 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | def check_network_connection(server="www.baidu.com"): 20 | """ 21 | Checks if jasper can connect a network server. 22 | 23 | Arguments: 24 | server -- (optional) the server to connect with (Default: 25 | "www.google.com") 26 | 27 | Returns: 28 | True or False 29 | """ 30 | logger = logging.getLogger(__name__) 31 | logger.debug("Checking network connection to server '%s'...", server) 32 | try: 33 | # see if we can resolve the host name -- tells us if there is 34 | # a DNS listening 35 | host = socket.gethostbyname(server) 36 | # connect to the host -- tells us if the host is actually 37 | # reachable 38 | socket.create_connection((host, 80), 2) 39 | except Exception: 40 | logger.debug("Network connection not working") 41 | return False 42 | else: 43 | logger.debug("Network connection working") 44 | return True 45 | 46 | 47 | def check_executable(executable): 48 | """ 49 | Checks if an executable exists in $PATH. 50 | 51 | Arguments: 52 | executable -- the name of the executable (e.g. "echo") 53 | 54 | Returns: 55 | True or False 56 | """ 57 | logger = logging.getLogger(__name__) 58 | logger.debug("Checking executable '%s'...", executable) 59 | executable_path = find_executable(executable) 60 | found = executable_path is not None 61 | if found: 62 | logger.debug("Executable '%s' found: '%s'", executable, 63 | executable_path) 64 | else: 65 | logger.debug("Executable '%s' not found", executable) 66 | return found 67 | 68 | 69 | def check_python_import(package_or_module): 70 | """ 71 | Checks if a python package or module is importable. 72 | 73 | Arguments: 74 | package_or_module -- the package or module name to check 75 | 76 | Returns: 77 | True or False 78 | """ 79 | logger = logging.getLogger(__name__) 80 | logger.debug("Checking python import '%s'...", package_or_module) 81 | loader = pkgutil.get_loader(package_or_module) 82 | found = loader is not None 83 | if found: 84 | logger.debug("Python %s '%s' found: %r", 85 | "package" if loader.is_package(package_or_module) 86 | else "module", package_or_module, loader.get_filename()) 87 | else: 88 | logger.debug("Python import '%s' not found", package_or_module) 89 | return found 90 | 91 | 92 | def get_pip_requirements(fname=os.path.join(jasperpath.LIB_PATH, 93 | 'requirements.txt')): 94 | """ 95 | Gets the PIP requirements from a text file. If the files does not exists 96 | or is not readable, it returns None 97 | 98 | Arguments: 99 | fname -- (optional) the requirement text file (Default: 100 | "client/requirements.txt") 101 | 102 | Returns: 103 | A list of pip requirement objects or None 104 | """ 105 | logger = logging.getLogger(__name__) 106 | if os.access(fname, os.R_OK): 107 | reqs = list(pip.req.parse_requirements(fname)) 108 | logger.debug("Found %d PIP requirements in file '%s'", len(reqs), 109 | fname) 110 | return reqs 111 | else: 112 | logger.debug("PIP requirements file '%s' not found or not readable", 113 | fname) 114 | 115 | 116 | def get_git_revision(): 117 | """ 118 | Gets the current git revision hash as hex string. If the git executable is 119 | missing or git is unable to get the revision, None is returned 120 | 121 | Returns: 122 | A hex string or None 123 | """ 124 | logger = logging.getLogger(__name__) 125 | if not check_executable('git'): 126 | logger.warning("'git' command not found, git revision not detectable") 127 | return None 128 | output = subprocess.check_output(['git', 'rev-parse', 'HEAD']).strip() 129 | if not output: 130 | logger.warning("Couldn't detect git revision (not a git repository?)") 131 | return None 132 | return output 133 | 134 | 135 | def run(): 136 | """ 137 | Performs a series of checks against the system and writes the results to 138 | the logging system. 139 | 140 | Returns: 141 | The number of failed checks as integer 142 | """ 143 | logger = logging.getLogger(__name__) 144 | 145 | # Set loglevel of this module least to info 146 | loglvl = logger.getEffectiveLevel() 147 | if loglvl == logging.NOTSET or loglvl > logging.INFO: 148 | logger.setLevel(logging.INFO) 149 | 150 | logger.info("Starting jasper diagnostic at %s" % time.strftime("%c")) 151 | logger.info("Git revision: %r", get_git_revision()) 152 | 153 | failed_checks = 0 154 | 155 | if not check_network_connection(): 156 | failed_checks += 1 157 | 158 | for executable in ['phonetisaurus-g2p', 'espeak', 'say']: 159 | if not check_executable(executable): 160 | logger.warning("Executable '%s' is missing in $PATH", executable) 161 | failed_checks += 1 162 | 163 | for req in get_pip_requirements(): 164 | logger.debug("Checking PIP package '%s'...", req.name) 165 | if not req.check_if_exists(): 166 | logger.warning("PIP package '%s' is missing", req.name) 167 | failed_checks += 1 168 | else: 169 | logger.debug("PIP package '%s' found", req.name) 170 | 171 | for fname in [os.path.join(jasperpath.APP_PATH, os.pardir, "phonetisaurus", 172 | "g014b2b.fst")]: 173 | logger.debug("Checking file '%s'...", fname) 174 | if not os.access(fname, os.R_OK): 175 | logger.warning("File '%s' is missing", fname) 176 | failed_checks += 1 177 | else: 178 | logger.debug("File '%s' found", fname) 179 | 180 | if not failed_checks: 181 | logger.info("All checks passed") 182 | else: 183 | logger.info("%d checks failed" % failed_checks) 184 | 185 | return failed_checks 186 | 187 | 188 | if __name__ == '__main__': 189 | logging.basicConfig(stream=sys.stdout) 190 | logger = logging.getLogger() 191 | if '--debug' in sys.argv: 192 | logger.setLevel(logging.DEBUG) 193 | run() 194 | -------------------------------------------------------------------------------- /client/g2p.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8-*- 2 | import os 3 | import re 4 | import subprocess 5 | import tempfile 6 | import logging 7 | 8 | import yaml 9 | 10 | import client.diagnose as diagnose 11 | import client.jasperpath as jasperpath 12 | 13 | 14 | class PhonetisaurusG2P(object): 15 | PATTERN = re.compile(r'^(?P.+)\t(?P\d+\.\d+)\t ' + 16 | r'(?P.*) ', re.MULTILINE) 17 | 18 | @classmethod 19 | def execute(cls, fst_model, input, is_file=False, nbest=None): 20 | logger = logging.getLogger(__name__) 21 | 22 | cmd = ['phonetisaurus-g2p', 23 | '--model=%s' % fst_model, 24 | '--input=%s' % input, 25 | '--words'] 26 | 27 | if is_file: 28 | cmd.append('--isfile') 29 | 30 | if nbest is not None: 31 | cmd.extend(['--nbest=%d' % nbest]) 32 | 33 | cmd = [str(x) for x in cmd] 34 | try: 35 | # FIXME: We can't just use subprocess.call and redirect stdout 36 | # and stderr, because it looks like Phonetisaurus can't open 37 | # an already opened file descriptor a second time. This is why 38 | # we have to use this somehow hacky subprocess.Popen approach. 39 | proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, 40 | stderr=subprocess.PIPE) 41 | stdoutdata, stderrdata = proc.communicate() 42 | except OSError: 43 | logger.error("Error occured while executing command '%s'", 44 | ' '.join(cmd), exc_info=True) 45 | raise 46 | 47 | if stderrdata: 48 | for line in stderrdata.splitlines(): 49 | message = line.strip() 50 | if message: 51 | logger.debug(message) 52 | 53 | if proc.returncode != 0: 54 | logger.error("Command '%s' return with exit status %d", 55 | ' '.join(cmd), proc.returncode) 56 | raise OSError("Command execution failed") 57 | 58 | result = {} 59 | if stdoutdata is not None: 60 | for word, precision, pronounc in \ 61 | cls.PATTERN.findall(stdoutdata.decode('utf-8')): 62 | if word not in result: 63 | result[word] = [] 64 | result[word].append(pronounc) 65 | return result 66 | 67 | @classmethod 68 | def get_config(cls): 69 | # FIXME: Replace this as soon as pull request 70 | # jasperproject/jasper-client#128 has been merged 71 | 72 | conf = {'fst_model': os.path.join(jasperpath.APP_PATH, os.pardir, 73 | 'phonetisaurus', 'g014b2b.fst')} 74 | # Try to get fst_model from config 75 | profile_path = jasperpath.config('profile.yml') 76 | if os.path.exists(profile_path): 77 | with open(profile_path, 'r') as f: 78 | profile = yaml.safe_load(f) 79 | if 'pocketsphinx' in profile: 80 | if 'fst_model' in profile['pocketsphinx']: 81 | conf['fst_model'] = \ 82 | profile['pocketsphinx']['fst_model'] 83 | if 'nbest' in profile['pocketsphinx']: 84 | conf['nbest'] = int(profile['pocketsphinx']['nbest']) 85 | return conf 86 | 87 | def __new__(cls, fst_model=None, *args, **kwargs): 88 | if not diagnose.check_executable('phonetisaurus-g2p'): 89 | raise OSError("Can't find command 'phonetisaurus-g2p'! Please " + 90 | "check if Phonetisaurus is installed and in your " + 91 | "$PATH.") 92 | if fst_model is None or not os.access(fst_model, os.R_OK): 93 | raise OSError(("FST model '%r' does not exist! Can't create " + 94 | "instance.") % fst_model) 95 | inst = object.__new__(cls, *args, **kwargs) 96 | return inst 97 | 98 | def __init__(self, fst_model=None, nbest=None): 99 | self._logger = logging.getLogger(__name__) 100 | 101 | self.fst_model = os.path.abspath(fst_model) 102 | self._logger.debug("Using FST model: '%s'", self.fst_model) 103 | 104 | self.nbest = nbest 105 | if self.nbest is not None: 106 | self._logger.debug("Will use the %d best results.", self.nbest) 107 | 108 | def _translate_word(self, word): 109 | return self.execute(self.fst_model, word, nbest=self.nbest) 110 | 111 | def _translate_words(self, words): 112 | with tempfile.NamedTemporaryFile(suffix='.g2p', delete=False) as f: 113 | # The 'delete=False' kwarg is kind of a hack, but Phonetisaurus 114 | # won't work if we remove it, because it seems that I can't open 115 | # a file descriptor a second time. 116 | for word in words: 117 | f.write(('%s\n' % word).encode('utf-8')) 118 | tmp_fname = f.name 119 | output = self.execute(self.fst_model, tmp_fname, is_file=True, 120 | nbest=self.nbest) 121 | os.remove(tmp_fname) 122 | return output 123 | 124 | def translate(self, words): 125 | if type(words) is str or len(words) == 1: 126 | self._logger.debug('Converting single word to phonemes') 127 | output = self._translate_word(words if type(words) is str 128 | else words[0]) 129 | else: 130 | self._logger.debug('Converting %d words to phonemes', len(words)) 131 | output = self._translate_words(words) 132 | self._logger.debug('G2P conversion returned phonemes for %d words', 133 | len(output)) 134 | return output 135 | 136 | 137 | if __name__ == "__main__": 138 | import pprint 139 | import argparse 140 | parser = argparse.ArgumentParser(description='Phonetisaurus G2P module') 141 | parser.add_argument('fst_model', action='store', 142 | help='Path to the FST Model') 143 | parser.add_argument('--debug', action='store_true', 144 | help='Show debug messages') 145 | args = parser.parse_args() 146 | 147 | logging.basicConfig() 148 | logger = logging.getLogger() 149 | if args.debug: 150 | logger.setLevel(logging.DEBUG) 151 | 152 | words = ['THIS', 'IS', 'A', 'TEST'] 153 | 154 | g2pconv = PhonetisaurusG2P(args.fst_model, nbest=3) 155 | output = g2pconv.translate(words) 156 | 157 | pp = pprint.PrettyPrinter(indent=2) 158 | pp.pprint(output) 159 | -------------------------------------------------------------------------------- /client/jasperpath.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8-*- 2 | import os 3 | 4 | # Jasper main directory 5 | APP_PATH = os.path.normpath(os.path.join( 6 | os.path.dirname(os.path.abspath(__file__)), os.pardir)) 7 | 8 | DATA_PATH = os.path.join(APP_PATH, "static") 9 | LIB_PATH = os.path.join(APP_PATH, "client") 10 | TOOLS_PATH = os.path.join(APP_PATH, "tools") 11 | 12 | PLUGIN_PATH = os.path.join(LIB_PATH, "modules") 13 | CONFIG_PATH = os.path.join(APP_PATH, 'conf') 14 | TJBOT_PATH = os.path.join(APP_PATH, '../tjbot/bootstrap/tests/') 15 | 16 | 17 | def config(*fname): 18 | return os.path.join(CONFIG_PATH, *fname) 19 | 20 | 21 | def data(*fname): 22 | return os.path.join(DATA_PATH, *fname) 23 | 24 | 25 | def tjbot(*fname): 26 | return os.path.join(TJBOT_PATH, *fname) 27 | 28 | 29 | def hotword(): 30 | return 'OKEY TOMMY' 31 | -------------------------------------------------------------------------------- /client/local_mic.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8-*- 2 | """ 3 | A drop-in replacement for the Mic class that allows for all I/O to occur 4 | over the terminal. Useful for debugging. Unlike with the typical Mic 5 | implementation, Jasper is always active listening with local_mic. 6 | """ 7 | 8 | 9 | class Mic: 10 | prev = None 11 | 12 | def __init__(self, speaker, passive_stt_engine, active_stt_engine): 13 | return 14 | 15 | def passiveListen(self, PERSONA): 16 | return True, "JASPER" 17 | 18 | def activeListenToAllOptions(self, THRESHOLD=None, LISTEN=True, 19 | MUSIC=False): 20 | return [self.activeListen(THRESHOLD=THRESHOLD, LISTEN=LISTEN, 21 | MUSIC=MUSIC)] 22 | 23 | def activeListen(self, THRESHOLD=None, LISTEN=True, MUSIC=False): 24 | if not LISTEN: 25 | return self.prev 26 | 27 | input = input("YOU: ") 28 | self.prev = input 29 | return input 30 | 31 | def say(self, phrase, OPTIONS=None): 32 | print("JASPER: %s" % phrase) 33 | -------------------------------------------------------------------------------- /client/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8-*- 3 | # This file exists for backwards compatibility with older versions of jasper. 4 | # It might be removed in future versions. 5 | import os 6 | import sys 7 | import runpy 8 | script_path = os.path.join(os.path.dirname(__file__), os.pardir, "jasper.py") 9 | sys.path.insert(0, os.path.dirname(script_path)) 10 | runpy.run_path(script_path, run_name="__main__") 11 | -------------------------------------------------------------------------------- /client/mic.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8-*- 2 | """ 3 | The Mic class handles all interactions with the microphone and speaker. 4 | """ 5 | import logging 6 | import tempfile 7 | import wave 8 | import os 9 | import pyaudio 10 | import client.jasperpath as jasperpath 11 | import sys 12 | 13 | import webrtcvad 14 | import collections 15 | import signal 16 | from array import array 17 | import time 18 | 19 | RATE = 16000 20 | 21 | gotOneSentence = False 22 | leaveRecord = False 23 | 24 | 25 | def handle_int(sig, chunk): 26 | global leaveRecord, gotOneSentence 27 | leaveRecord = True 28 | gotOneSentence = True 29 | 30 | 31 | def normalize(snd_data): 32 | "Average the volume out" 33 | MAXIMUM = 32767 # 16384 34 | times = float(MAXIMUM) / max(abs(i) for i in snd_data) 35 | r = array('h') 36 | for i in snd_data: 37 | r.append(int(i * times)) 38 | return r 39 | 40 | 41 | class Mic: 42 | 43 | speechRec = None 44 | speechRec_persona = None 45 | 46 | def __init__(self, speaker, passive_stt_engine, active_stt_engine): 47 | """ 48 | Initiates the pocketsphinx instance. 49 | 50 | Arguments: 51 | speaker -- handles platform-independent audio output 52 | passive_stt_engine -- performs STT while Jasper is in passive listen 53 | mode 54 | acive_stt_engine -- performs STT while Jasper is in active listen mode 55 | """ 56 | self._logger = logging.getLogger(__name__) 57 | self.speaker = speaker 58 | self.passive_stt_engine = passive_stt_engine 59 | self.active_stt_engine = active_stt_engine 60 | self._logger.info("Initializing PyAudio. ALSA/Jack error messages " + 61 | "that pop up during this process are normal and " + 62 | "can usually be safely ignored.") 63 | self._audio = pyaudio.PyAudio() 64 | self._logger.info("Initialization of PyAudio completed.") 65 | 66 | def __del__(self): 67 | self._audio.terminate() 68 | 69 | def passiveListen(self, PERSONA): 70 | """ 71 | Listens for PERSONA in everyday sound. Times out after LISTEN_TIME, so 72 | needs to be restarted. 73 | """ 74 | transcribed = self.listenVoice(False, PERSONA) 75 | if any(PERSONA in phrase for phrase in transcribed): 76 | return (True, PERSONA) 77 | 78 | return (False, transcribed) 79 | 80 | def activeListen(self): 81 | """ 82 | Records until a second of silence or times out after 12 seconds 83 | 84 | Returns the first matching string or None 85 | """ 86 | return self.listenVoice(True) 87 | 88 | def listenVoice(self, ACTIVE=True, PERSONA=''): 89 | FORMAT = pyaudio.paInt16 90 | CHANNELS = 1 91 | CHUNK_DURATION_MS = 30 # supports 10, 20 and 30 (ms) 92 | PADDING_DURATION_MS = 1500 # 1 sec jugement 93 | CHUNK_SIZE = int(RATE * CHUNK_DURATION_MS / 1000) # chunk to read 94 | NUM_PADDING_CHUNKS = int(PADDING_DURATION_MS / CHUNK_DURATION_MS) 95 | NUM_WINDOW_CHUNKS = int(400 / CHUNK_DURATION_MS) # 400ms/30ms 96 | NUM_WINDOW_CHUNKS_END = NUM_WINDOW_CHUNKS * 2 97 | 98 | global leaveRecord, gotOneSentence 99 | if leaveRecord: 100 | if os.path.exists(jasperpath.tjbot('shine.led.js')): 101 | os.system("node " + jasperpath.tjbot('shine.led.js') + " off") 102 | return None 103 | 104 | # prepare recording stream 105 | stream = self._audio.open(format=FORMAT, 106 | channels=CHANNELS, 107 | rate=RATE, 108 | input=True, 109 | start=False, 110 | frames_per_buffer=CHUNK_SIZE) 111 | vad = webrtcvad.Vad(1) 112 | signal.signal(signal.SIGINT, handle_int) 113 | if not ACTIVE: 114 | if os.path.exists(jasperpath.tjbot('shine.led.js')): 115 | os.system("node " + jasperpath.tjbot('shine.led.js') + 116 | " white") 117 | stream.start_stream() 118 | raw_data = array('h') 119 | start_point = 0 120 | end_point = 0 121 | 122 | # loop for passive listening 123 | while not leaveRecord: 124 | gotOneSentence = False 125 | 126 | if ACTIVE: 127 | self.speaker.play(jasperpath.data('audio', 'beep_hi.wav')) 128 | else: 129 | self.passive_stt_engine.utt_start() 130 | # Process buffered voice data 131 | if end_point > 0: 132 | raw_data.reverse() 133 | for index in range(end_point - CHUNK_SIZE * 20): 134 | raw_data.pop() 135 | raw_data.reverse() 136 | print("* process buffered voice data...") 137 | transcribed = self.passive_stt_engine.utt_transcribe( 138 | raw_data) 139 | # if voice trigger is included in results, return directly 140 | if any(PERSONA in phrase for phrase in transcribed): 141 | if os.path.exists(jasperpath.tjbot('shine.led.js')): 142 | os.system("node " + 143 | jasperpath.tjbot('shine.led.js') + 144 | " off") 145 | self.passive_stt_engine.utt_end() 146 | stream.stop_stream() 147 | stream.close() 148 | return transcribed 149 | 150 | ring_buffer = collections.deque(maxlen=NUM_PADDING_CHUNKS) 151 | triggered = False 152 | ring_buffer_flags = [0] * NUM_WINDOW_CHUNKS 153 | ring_buffer_index = 0 154 | 155 | ring_buffer_flags_end = [0] * NUM_WINDOW_CHUNKS_END 156 | ring_buffer_index_end = 0 157 | index = 0 158 | start_point = 0 159 | end_point = 0 160 | StartTime = time.time() 161 | print("* recording: ") 162 | raw_data = array('h') 163 | if ACTIVE: 164 | if os.path.exists(jasperpath.tjbot('shine.led.js')): 165 | os.system("node " + jasperpath.tjbot('shine.led.js') + 166 | " blue") 167 | stream.start_stream() 168 | 169 | # stop recording when EOS is detected 170 | while not gotOneSentence and not leaveRecord: 171 | chunk = stream.read(CHUNK_SIZE, exception_on_overflow=False) 172 | if not ACTIVE: 173 | transcribed = self.passive_stt_engine.utt_transcribe(chunk) 174 | if any(PERSONA in phrase for phrase in transcribed): 175 | triggered = False 176 | gotOneSentence = True 177 | end_point = index 178 | break 179 | # add WangS 180 | raw_data.extend(array('h', chunk)) 181 | index += CHUNK_SIZE 182 | TimeUse = time.time() - StartTime 183 | 184 | active = vad.is_speech(chunk, RATE) 185 | 186 | if ACTIVE: 187 | sys.stdout.write('I' if active else '_') 188 | ring_buffer_flags[ring_buffer_index] = 1 if active else 0 189 | ring_buffer_index += 1 190 | ring_buffer_index %= NUM_WINDOW_CHUNKS 191 | 192 | ring_buffer_flags_end[ring_buffer_index_end] = \ 193 | 1 if active else 0 194 | ring_buffer_index_end += 1 195 | ring_buffer_index_end %= NUM_WINDOW_CHUNKS_END 196 | 197 | # start point detection 198 | if not triggered: 199 | ring_buffer.append(chunk) 200 | num_voiced = sum(ring_buffer_flags) 201 | if num_voiced > 0.8 * NUM_WINDOW_CHUNKS: 202 | if ACTIVE: 203 | sys.stdout.write('[OPEN]') 204 | triggered = True 205 | start_point = index - CHUNK_SIZE * 20 # start point 206 | # voiced_frames.extend(ring_buffer) 207 | ring_buffer.clear() 208 | # end point detection 209 | else: 210 | # voiced_frames.append(chunk) 211 | ring_buffer.append(chunk) 212 | num_unvoiced = NUM_WINDOW_CHUNKS_END \ 213 | - sum(ring_buffer_flags_end) 214 | if num_unvoiced > 0.90 * NUM_WINDOW_CHUNKS_END \ 215 | or TimeUse > 10: 216 | if ACTIVE: 217 | sys.stdout.write('[CLOSE]') 218 | triggered = False 219 | gotOneSentence = True 220 | end_point = index 221 | sys.stdout.flush() 222 | 223 | sys.stdout.write('\n') 224 | 225 | # result processing for passive and active listening respectively 226 | print("* done recording") 227 | if leaveRecord: 228 | if os.path.exists(jasperpath.tjbot('shine.led.js')): 229 | os.system("node " + 230 | jasperpath.tjbot('shine.led.js') + 231 | " off") 232 | break 233 | if ACTIVE: 234 | if os.path.exists(jasperpath.tjbot('shine.led.js')): 235 | os.system("node " + jasperpath.tjbot('shine.led.js') + 236 | " off") 237 | self.speaker.play(jasperpath.data('audio', 'beep_lo.wav')) 238 | # write to file 239 | raw_data.reverse() 240 | for index in range(start_point): 241 | raw_data.pop() 242 | raw_data.reverse() 243 | raw_data = normalize(raw_data) 244 | 245 | stream.stop_stream() 246 | stream.close() 247 | 248 | # save the audio data 249 | with tempfile.SpooledTemporaryFile(mode='w+b') as f: 250 | wav_fp = wave.open(f, 'wb') 251 | wav_fp.setnchannels(1) 252 | wav_fp.setsampwidth( 253 | pyaudio.get_sample_size(pyaudio.paInt16)) 254 | wav_fp.setframerate(RATE) 255 | wav_fp.writeframes(raw_data) 256 | wav_fp.close() 257 | f.seek(0) 258 | return self.active_stt_engine.transcribe(f) 259 | else: 260 | # read one more chunks in EOS 261 | chunk = stream.read(CHUNK_SIZE, exception_on_overflow=False) 262 | transcribed = self.passive_stt_engine.utt_transcribe(chunk) 263 | self.passive_stt_engine.utt_end() 264 | # if voice trigger is included in results, return directly 265 | if any(PERSONA in phrase for phrase in transcribed): 266 | if os.path.exists(jasperpath.tjbot('shine.led.js')): 267 | os.system("node " + 268 | jasperpath.tjbot('shine.led.js') + 269 | " off") 270 | stream.stop_stream() 271 | stream.close() 272 | return transcribed 273 | # if voice trigger is not included in results, start another 274 | # cycle 275 | 276 | # exit 277 | if ACTIVE: 278 | stream.close() 279 | return None 280 | 281 | def say(self, phrase, 282 | OPTIONS=" -vdefault+m3 -p 40 -s 160 --stdout > say.wav"): 283 | # alter phrase before speaking 284 | self.speaker.say(phrase) 285 | -------------------------------------------------------------------------------- /client/modules/Unclear.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8-*- 2 | import random 3 | import sys # Laibot 4 | import urllib 5 | import re 6 | import os 7 | import client.jasperpath as jasperpath 8 | 9 | WORDS = [] 10 | 11 | PRIORITY = -(sys.maxsize + 1) 12 | 13 | 14 | def handle(text, mic, profile): 15 | """ 16 | Reports that the user has unclear or unusable input. 17 | 18 | Arguments: 19 | text -- user-input, typically transcribed speech 20 | mic -- used to interact with the user (for both input and output) 21 | profile -- contains information related to the user (e.g., phone 22 | number) 23 | """ 24 | 25 | try: 26 | url = 'http://laibot.applinzi.com/chat?' 27 | pat = '[\s+\.\!\/_,$%^*(+\"\']+|[+——!,。?、~@]' 28 | req = re.sub(pat, '', text) 29 | url = url + urllib.parse.urlencode({'req': req}) 30 | http_response = urllib.request.urlopen(url) 31 | ans = http_response.read().decode('utf-8') 32 | if ans.find('//shakehand') >= 0: 33 | ans = ans.replace('//shakehand', '') 34 | if os.path.exists(jasperpath.tjbot('shakehand.servo.js')): 35 | os.system("node " + jasperpath.tjbot('shakehand.servo.js')) 36 | os.system("node " + jasperpath.tjbot('shakehand.servo.js')) 37 | mic.say(ans) 38 | except Exception as err: 39 | print(str(err)) 40 | 41 | messages = ["我没有听清楚", 42 | "请再说一遍"] 43 | message = random.choice(messages) 44 | mic.say(message) 45 | 46 | 47 | def isValid(text): 48 | return True 49 | -------------------------------------------------------------------------------- /client/modules/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjwang/laibot-client/cc4a07617dd5316f32d391eba7e2e51ad01925bd/client/modules/__init__.py -------------------------------------------------------------------------------- /client/populate.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8-*- 2 | import os 3 | import re 4 | from getpass import getpass 5 | import yaml 6 | from pytz import timezone 7 | import feedparser 8 | import jasperpath 9 | 10 | 11 | def run(): 12 | profile = {} 13 | 14 | print("Welcome to the profile populator. If, at any step, you'd prefer " + 15 | "not to enter the requested information, just hit 'Enter' with a " + 16 | "blank field to continue.") 17 | 18 | def simple_request(var, cleanVar, cleanInput=None): 19 | input = input(cleanVar + ": ") 20 | if input: 21 | if cleanInput: 22 | input = cleanInput(input) 23 | profile[var] = input 24 | 25 | # name 26 | simple_request('first_name', 'First name') 27 | simple_request('last_name', 'Last name') 28 | 29 | # gmail 30 | print("\nJasper uses your Gmail to send notifications. Alternatively, " + 31 | "you can skip this step (or just fill in the email address if you " + 32 | "want to receive email notifications) and setup a Mailgun " + 33 | "account, as at http://jasperproject.github.io/documentation/" + 34 | "software/#mailgun.\n") 35 | simple_request('gmail_address', 'Gmail address') 36 | profile['gmail_password'] = getpass() 37 | 38 | # phone number 39 | def clean_number(s): 40 | return re.sub(r'[^0-9]', '', s) 41 | 42 | phone_number = clean_number(input("\nPhone number (no country " + 43 | "code). Any dashes or spaces will " + 44 | "be removed for you: ")) 45 | profile['phone_number'] = phone_number 46 | 47 | # carrier 48 | print("\nPhone carrier (for sending text notifications).") 49 | print("If you have a US phone number, you can enter one of the " + 50 | "following: 'AT&T', 'Verizon', 'T-Mobile' (without the quotes). " + 51 | "If your carrier isn't listed or you have an international " + 52 | "number, go to http://www.emailtextmessages.com and enter the " + 53 | "email suffix for your carrier (e.g., for Virgin Mobile, enter " + 54 | "'vmobl.com'; for T-Mobile Germany, enter 't-d1-sms.de').") 55 | carrier = input('Carrier: ') 56 | if carrier == 'AT&T': 57 | profile['carrier'] = 'txt.att.net' 58 | elif carrier == 'Verizon': 59 | profile['carrier'] = 'vtext.com' 60 | elif carrier == 'T-Mobile': 61 | profile['carrier'] = 'tmomail.net' 62 | else: 63 | profile['carrier'] = carrier 64 | 65 | # location 66 | def verifyLocation(place): 67 | feed = feedparser.parse('http://rss.wunderground.com/auto/rss_full/' + 68 | place) 69 | numEntries = len(feed['entries']) 70 | if numEntries == 0: 71 | return False 72 | else: 73 | print("Location saved as " + feed['feed']['description'][33:]) 74 | return True 75 | 76 | print("\nLocation should be a 5-digit US zipcode (e.g., 08544). If you " + 77 | "are outside the US, insert the name of your nearest big " + 78 | "town/city. For weather requests.") 79 | location = input("Location: ") 80 | while location and not verifyLocation(location): 81 | print("Weather not found. Please try another location.") 82 | location = input("Location: ") 83 | if location: 84 | profile['location'] = location 85 | 86 | # timezone 87 | print("\nPlease enter a timezone from the list located in the TZ* " + 88 | "column at http://en.wikipedia.org/wiki/" + 89 | "List_of_tz_database_time_zones, or none at all.") 90 | tz = input("Timezone: ") 91 | while tz: 92 | try: 93 | timezone(tz) 94 | profile['timezone'] = tz 95 | break 96 | except Exception: 97 | print("Not a valid timezone. Try again.") 98 | tz = input("Timezone: ") 99 | 100 | response = input("\nWould you prefer to have notifications sent by " + 101 | "email (E) or text message (T)? ") 102 | while not response or (response != 'E' and response != 'T'): 103 | response = input("Please choose email (E) or text message (T): ") 104 | profile['prefers_email'] = (response == 'E') 105 | 106 | stt_engines = { 107 | "sphinx": None, 108 | "google": "GOOGLE_SPEECH" 109 | } 110 | 111 | response = input("\nIf you would like to choose a specific STT " + 112 | "engine, please specify which.\nAvailable " + 113 | "implementations: %s. (Press Enter to default " + 114 | "to PocketSphinx): " % stt_engines.keys()) 115 | if (response in stt_engines): 116 | profile["stt_engine"] = response 117 | api_key_name = stt_engines[response] 118 | if api_key_name: 119 | key = input("\nPlease enter your API key: ") 120 | profile["keys"] = {api_key_name: key} 121 | else: 122 | print("Unrecognized STT engine. Available implementations: %s" 123 | % stt_engines.keys()) 124 | profile["stt_engine"] = "sphinx" 125 | 126 | if response == "google": 127 | response = input("\nChoosing Google means every sound " + 128 | "makes a request online. " + 129 | "\nWould you like to process the wake up word " + 130 | "locally with PocketSphinx? (Y) or (N)?") 131 | while not response or (response != 'Y' and response != 'N'): 132 | response = input("Please choose PocketSphinx (Y) " + 133 | "or keep just Google (N): ") 134 | if response == 'Y': 135 | profile['stt_passive_engine'] = "sphinx" 136 | 137 | # write to profile 138 | print("Writing to profile...") 139 | if not os.path.exists(jasperpath.CONFIG_PATH): 140 | os.makedirs(jasperpath.CONFIG_PATH) 141 | outputFile = open(jasperpath.config("profile.yml"), "w") 142 | yaml.dump(profile, outputFile, default_flow_style=False) 143 | print("Done.") 144 | 145 | 146 | if __name__ == "__main__": 147 | run() 148 | -------------------------------------------------------------------------------- /client/stt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8-*- 3 | import os 4 | import wave 5 | import json 6 | import tempfile 7 | import logging 8 | import urllib 9 | from abc import ABCMeta, abstractmethod 10 | import requests 11 | import yaml 12 | import client.jasperpath as jasperpath 13 | import client.diagnose as diagnose 14 | import client.vocabcompiler as vocabcompiler 15 | 16 | import base64 # Baidu TTS 17 | from uuid import getnode as get_mac 18 | 19 | 20 | class AbstractSTTEngine(object): 21 | """ 22 | Generic parent class for all STT engines 23 | """ 24 | 25 | __metaclass__ = ABCMeta 26 | VOCABULARY_TYPE = None 27 | 28 | @classmethod 29 | def get_config(cls): 30 | return {} 31 | 32 | @classmethod 33 | def get_instance(cls, vocabulary_name, phrases): 34 | config = cls.get_config() 35 | if cls.VOCABULARY_TYPE: 36 | vocabulary = cls.VOCABULARY_TYPE(vocabulary_name, 37 | path=jasperpath.config( 38 | 'vocabularies')) 39 | if not vocabulary.matches_phrases(phrases): 40 | vocabulary.compile(phrases) 41 | config['vocabulary'] = vocabulary 42 | instance = cls(**config) 43 | return instance 44 | 45 | @classmethod 46 | def get_passive_instance(cls): 47 | phrases = vocabcompiler.get_keyword_phrases() 48 | return cls.get_instance('keyword', phrases) 49 | 50 | @classmethod 51 | def get_active_instance(cls): 52 | phrases = vocabcompiler.get_all_phrases() 53 | return cls.get_instance('default', phrases) 54 | 55 | @classmethod 56 | @abstractmethod 57 | def is_available(cls): 58 | return True 59 | 60 | @abstractmethod 61 | def transcribe(self, fp): 62 | pass 63 | 64 | @abstractmethod 65 | def utt_start(self): 66 | pass 67 | 68 | @abstractmethod 69 | def utt_end(self): 70 | pass 71 | 72 | @abstractmethod 73 | def utt_transcribe(self, data): 74 | pass 75 | 76 | 77 | class BaiduSTT(AbstractSTTEngine): 78 | """ 79 | Baidu's STT engine 80 | To leverage this engile, please register a developer account at 81 | yuyin.baidu.com. Then new an application, get API Key and Secret 82 | Key in management console. Fill them in profile.yml. 83 | ... 84 | baidu_yuyin: 85 | api_key: 'xxx............................................' 86 | secret_key: 'xxx.........................................' 87 | ... 88 | """ 89 | 90 | SLUG = "baidu-stt" 91 | 92 | def __init__(self, api_key='', secret_key=''): 93 | self._logger = logging.getLogger(__name__) 94 | self.api_key = api_key 95 | self.secret_key = secret_key 96 | self.token = '' 97 | 98 | @classmethod 99 | def get_config(cls): 100 | # FIXME: Replace this as soon as we have a config module 101 | config = {} 102 | # Try to get baidu_yuyin config from config 103 | profile_path = jasperpath.config('profile.yml') 104 | if os.path.exists(profile_path): 105 | with open(profile_path, 'r') as f: 106 | profile = yaml.safe_load(f) 107 | if 'baidu_yuyin' in profile: 108 | if 'api_key' in profile['baidu_yuyin']: 109 | config['api_key'] = \ 110 | profile['baidu_yuyin']['api_key'] 111 | if 'secret_key' in profile['baidu_yuyin']: 112 | config['secret_key'] = \ 113 | profile['baidu_yuyin']['secret_key'] 114 | return config 115 | 116 | def get_token(self): 117 | URL = 'http://openapi.baidu.com/oauth/2.0/token' 118 | params = urllib.parse.urlencode({'grant_type': 'client_credentials', 119 | 'client_id': self.api_key, 120 | 'client_secret': self.secret_key}) 121 | r = requests.get(URL, params=params) 122 | try: 123 | r.raise_for_status() 124 | token = r.json()['access_token'] 125 | return token 126 | except requests.exceptions.HTTPError: 127 | self._logger.critical('Token request failed with response: %r', 128 | r.text, 129 | exc_info=True) 130 | return '' 131 | 132 | def transcribe(self, fp): 133 | try: 134 | wav_file = wave.open(fp, 'rb') 135 | except IOError: 136 | self._logger.critical('wav file not found: %s', 137 | fp, 138 | exc_info=True) 139 | return [] 140 | n_frames = wav_file.getnframes() 141 | frame_rate = wav_file.getframerate() 142 | audio = wav_file.readframes(n_frames) 143 | base_data = base64.b64encode(audio) 144 | if self.token == '': 145 | self.token = self.get_token() 146 | data = {"format": "wav", 147 | "token": self.token, 148 | "len": len(audio), 149 | "rate": frame_rate, 150 | "speech": base_data.decode(encoding="utf-8"), 151 | "cuid": str(get_mac())[:32], 152 | "channel": 1} 153 | data = json.dumps(data) 154 | r = requests.post('http://vop.baidu.com/server_api', 155 | data=data, 156 | headers={'content-type': 'application/json'}) 157 | try: 158 | r.raise_for_status() 159 | text = '' 160 | if 'result' in r.json(): 161 | text = r.json()['result'][0] 162 | except requests.exceptions.HTTPError: 163 | self._logger.critical('Request failed with response: %r', 164 | r.text, 165 | exc_info=True) 166 | return [] 167 | except requests.exceptions.RequestException: 168 | self._logger.critical('Request failed.', exc_info=True) 169 | return [] 170 | except ValueError as e: 171 | self._logger.critical('Cannot parse response: %s', 172 | e.args[0]) 173 | return [] 174 | except KeyError: 175 | self._logger.critical('Cannot parse response.', 176 | exc_info=True) 177 | return [] 178 | else: 179 | transcribed = [] 180 | if text: 181 | transcribed.append(text.upper()) 182 | self._logger.info(u'Baidu STT outputs: %s' % text) 183 | return transcribed 184 | 185 | @classmethod 186 | def is_available(cls): 187 | return diagnose.check_network_connection() 188 | 189 | def utt_start(self): 190 | return True 191 | 192 | def utt_end(self): 193 | return True 194 | 195 | def utt_transcribe(self, data): 196 | return '' 197 | 198 | 199 | class PocketSphinxSTT(AbstractSTTEngine): 200 | """ 201 | The default Speech-to-Text implementation which relies on PocketSphinx. 202 | """ 203 | 204 | SLUG = 'sphinx' 205 | VOCABULARY_TYPE = vocabcompiler.PocketsphinxVocabulary 206 | 207 | _pocketsphinx_v5 = False 208 | _previous_decoding_output = '' 209 | 210 | def __init__(self, vocabulary, hmm_dir="/usr/local/share/" + 211 | "pocketsphinx/model/hmm/en_US/hub4wsj_sc_8k"): 212 | 213 | """ 214 | Initiates the pocketsphinx instance. 215 | 216 | Arguments: 217 | vocabulary -- a PocketsphinxVocabulary instance 218 | hmm_dir -- the path of the Hidden Markov Model (HMM) 219 | """ 220 | 221 | self._logger = logging.getLogger(__name__) 222 | 223 | # quirky bug where first import doesn't work 224 | try: 225 | import pocketsphinx as ps 226 | except Exception: 227 | import pocketsphinx as ps 228 | 229 | with tempfile.NamedTemporaryFile(prefix='psdecoder_', 230 | suffix='.log', delete=False) as f: 231 | self._logfile = f.name 232 | 233 | self._logger.debug("Initializing PocketSphinx Decoder with hmm_dir " + 234 | "'%s'", hmm_dir) 235 | 236 | # Perform some checks on the hmm_dir so that we can display more 237 | # meaningful error messages if neccessary 238 | if not os.path.exists(hmm_dir): 239 | msg = ("hmm_dir '%s' does not exist! Please make sure that you " + 240 | "have set the correct hmm_dir in your profile.") % hmm_dir 241 | self._logger.error(msg) 242 | raise RuntimeError(msg) 243 | # Lets check if all required files are there. Refer to: 244 | # http://cmusphinx.sourceforge.net/wiki/acousticmodelformat 245 | # for details 246 | missing_hmm_files = [] 247 | for fname in ('mdef', 'feat.params', 'means', 'noisedict', 248 | 'transition_matrices', 'variances'): 249 | if not os.path.exists(os.path.join(hmm_dir, fname)): 250 | missing_hmm_files.append(fname) 251 | mixweights = os.path.exists(os.path.join(hmm_dir, 'mixture_weights')) 252 | sendump = os.path.exists(os.path.join(hmm_dir, 'sendump')) 253 | if not mixweights and not sendump: 254 | # We only need mixture_weights OR sendump 255 | missing_hmm_files.append('mixture_weights or sendump') 256 | if missing_hmm_files: 257 | self._logger.warning("hmm_dir '%s' is missing files: %s. Please " + 258 | "make sure that you have set the correct " + 259 | "hmm_dir in your profile.", 260 | hmm_dir, ', '.join(missing_hmm_files)) 261 | self._pocketsphinx_v5 = hasattr(ps.Decoder, 'default_config') 262 | if self._pocketsphinx_v5: 263 | # Pocketsphinx v5 264 | config = ps.Decoder.default_config() 265 | config.set_string('-hmm', hmm_dir) 266 | config.set_string('-lm', vocabulary.languagemodel_file) 267 | config.set_string('-dict', vocabulary.dictionary_file) 268 | config.set_string('-logfn', self._logfile) 269 | self._decoder = ps.Decoder(config) 270 | else: 271 | self._decoder = ps.Decoder(hmm=hmm_dir, logfn=self._logfile, 272 | lm=vocabulary.languagemodel_file, 273 | dict=vocabulary.dictionary_file) 274 | 275 | def __del__(self): 276 | os.remove(self._logfile) 277 | 278 | @classmethod 279 | def get_config(cls): 280 | # FIXME: Replace this as soon as we have a config module 281 | config = {} 282 | # HMM dir 283 | # Try to get hmm_dir from config 284 | profile_path = jasperpath.config('profile.yml') 285 | 286 | if os.path.exists(profile_path): 287 | with open(profile_path, 'r') as f: 288 | profile = yaml.safe_load(f) 289 | try: 290 | config['hmm_dir'] = profile['pocketsphinx']['hmm_dir'] 291 | except KeyError: 292 | pass 293 | 294 | return config 295 | 296 | def transcribe(self, fp): 297 | """ 298 | Performs STT, transcribing an audio file and returning the result. 299 | 300 | Arguments: 301 | fp -- a file object containing audio data 302 | """ 303 | 304 | fp.seek(44) 305 | 306 | # FIXME: Can't use the Decoder.decode_raw() here, because 307 | # pocketsphinx segfaults with tempfile.SpooledTemporaryFile() 308 | data = fp.read() 309 | self._decoder.start_utt() 310 | self._decoder.process_raw(data, False, True) 311 | self._decoder.end_utt() 312 | 313 | if self._pocketsphinx_v5: 314 | hyp = self._decoder.hyp() 315 | result = hyp.hypstr if hyp is not None else '' 316 | else: 317 | result = self._decoder.get_hyp()[0] 318 | if self._logfile is not None: 319 | with open(self._logfile, 'r+') as f: 320 | for line in f: 321 | self._logger.debug(line.strip()) 322 | f.truncate() 323 | 324 | transcribed = [result] if result != '' else [] 325 | self._logger.info('Transcribed: %r', transcribed) 326 | return transcribed 327 | 328 | @classmethod 329 | def is_available(cls): 330 | return diagnose.check_python_import('pocketsphinx') 331 | 332 | def utt_start(self): 333 | self._decoder.start_utt() 334 | return True 335 | 336 | def utt_end(self): 337 | self._decoder.end_utt() 338 | return True 339 | 340 | def utt_transcribe(self, data): 341 | self._decoder.process_raw(data, False, False) 342 | if self._pocketsphinx_v5: 343 | hyp = self._decoder.hyp() 344 | result = hyp.hypstr if hyp is not None else '' 345 | else: 346 | result = self._decoder.get_hyp()[0] 347 | transcribed = [result] if result != '' else [] 348 | if self._previous_decoding_output != transcribed: 349 | self._logger.info('Partial: %r', transcribed) 350 | self._previous_decoding_output = transcribed 351 | return transcribed 352 | 353 | 354 | def get_engine_by_slug(slug=None): 355 | """ 356 | Returns: 357 | An STT Engine implementation available on the current platform 358 | 359 | Raises: 360 | ValueError if no speaker implementation is supported on this platform 361 | """ 362 | 363 | if not slug or type(slug) is not str: 364 | raise TypeError("Invalid slug '%s'", slug) 365 | 366 | selected_filter = filter(lambda engine: hasattr(engine, "SLUG") and 367 | engine.SLUG == slug, get_engines()) 368 | selected_engines = [engine for engine in selected_filter] 369 | if len(selected_engines) == 0: 370 | raise ValueError("No STT engine found for slug '%s'" % slug) 371 | else: 372 | if len(selected_engines) > 1: 373 | print(("WARNING: Multiple STT engines found for slug '%s'. " + 374 | "This is most certainly a bug.") % slug) 375 | engine = selected_engines[0] 376 | if not engine.is_available(): 377 | raise ValueError(("STT engine '%s' is not available (due to " + 378 | "missing dependencies, missing " + 379 | "dependencies, etc.)") % slug) 380 | return engine 381 | 382 | 383 | def get_engines(): 384 | def get_subclasses(cls): 385 | subclasses = set() 386 | for subclass in cls.__subclasses__(): 387 | subclasses.add(subclass) 388 | subclasses.update(get_subclasses(subclass)) 389 | return subclasses 390 | return [stt_engine for stt_engine in 391 | list(get_subclasses(AbstractSTTEngine)) 392 | if hasattr(stt_engine, 'SLUG') and stt_engine.SLUG] 393 | -------------------------------------------------------------------------------- /client/test_mic.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8-*- 2 | """ 3 | A drop-in replacement for the Mic class used during unit testing. 4 | Designed to take pre-arranged inputs as an argument and store any 5 | outputs for inspection. Requires a populated profile (profile.yml). 6 | """ 7 | 8 | 9 | class Mic: 10 | 11 | def __init__(self, inputs): 12 | self.inputs = inputs 13 | self.idx = 0 14 | self.outputs = [] 15 | 16 | def passiveListen(self, PERSONA): 17 | return True, "JASPER" 18 | 19 | def activeListenToAllOptions(self, THRESHOLD=None, LISTEN=True, 20 | MUSIC=False): 21 | return [self.activeListen(THRESHOLD=THRESHOLD, LISTEN=LISTEN, 22 | MUSIC=MUSIC)] 23 | 24 | def activeListen(self, THRESHOLD=None, LISTEN=True, MUSIC=False): 25 | if not LISTEN: 26 | return self.inputs[self.idx - 1] 27 | 28 | input = self.inputs[self.idx] 29 | self.idx += 1 30 | return input 31 | 32 | def say(self, phrase, OPTIONS=None): 33 | self.outputs.append(phrase) 34 | -------------------------------------------------------------------------------- /client/tts.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8-*- 2 | """ 3 | A Speaker handles audio output from Jasper to the user 4 | 5 | Speaker methods: 6 | say - output 'phrase' as speech 7 | play - play the audio in 'filename' 8 | is_available - returns True if the platform supports this implementation 9 | """ 10 | import os 11 | import platform 12 | import tempfile 13 | import subprocess 14 | import pipes 15 | import logging 16 | import urllib 17 | import requests 18 | from abc import ABCMeta, abstractmethod 19 | from uuid import getnode as get_mac # Import for Baidu TTS 20 | 21 | import argparse 22 | import yaml 23 | 24 | from client import diagnose 25 | from client import jasperpath 26 | 27 | 28 | class AbstractTTSEngine(object): 29 | """ 30 | Generic parent class for all speakers 31 | """ 32 | __metaclass__ = ABCMeta 33 | 34 | @classmethod 35 | def get_config(cls): 36 | return {} 37 | 38 | @classmethod 39 | def get_instance(cls): 40 | config = cls.get_config() 41 | instance = cls(**config) 42 | return instance 43 | 44 | @classmethod 45 | @abstractmethod 46 | def is_available(cls): 47 | return diagnose.check_executable('aplay') 48 | 49 | def __init__(self, **kwargs): 50 | self._logger = logging.getLogger(__name__) 51 | 52 | @abstractmethod 53 | def say(self, phrase, *args): 54 | pass 55 | 56 | def play(self, filename): 57 | # FIXME: Use platform-independent audio-output here 58 | # See issue jasperproject/jasper-client#188 59 | cmd = ['aplay', '-D', 'plughw:1,0', str(filename)] 60 | self._logger.debug('Executing %s', ' '.join([pipes.quote(arg) 61 | for arg in cmd])) 62 | with tempfile.TemporaryFile() as f: 63 | subprocess.call(cmd, stdout=f, stderr=f) 64 | f.seek(0) 65 | output = f.read() 66 | if output: 67 | self._logger.debug("Output was: '%s'", output) 68 | 69 | 70 | class AbstractMp3TTSEngine(AbstractTTSEngine): 71 | """ 72 | Generic class that implements the 'play' method for mp3 files 73 | """ 74 | @classmethod 75 | def is_available(cls): 76 | return (super(AbstractMp3TTSEngine, cls).is_available() and 77 | diagnose.check_python_import('mad')) 78 | 79 | def play_mp3(self, filename): 80 | with tempfile.NamedTemporaryFile(suffix='.wav') as f: 81 | os.system('sox "%s" "%s"' % (filename, f.name)) 82 | self.play(f.name) 83 | 84 | 85 | class DummyTTS(AbstractTTSEngine): 86 | """ 87 | Dummy TTS engine that logs phrases with INFO level instead of synthesizing 88 | speech. 89 | """ 90 | 91 | SLUG = "dummy-tts" 92 | 93 | @classmethod 94 | def is_available(cls): 95 | return True 96 | 97 | def say(self, phrase): 98 | self._logger.info(phrase) 99 | 100 | def play(self, filename): 101 | self._logger.debug("Playback of file '%s' requested") 102 | pass 103 | 104 | 105 | class BaiduTTS(AbstractMp3TTSEngine): 106 | SLUG = "baidu-tts" 107 | 108 | def __init__(self, api_key='', secret_key='', per=0): 109 | self._logger = logging.getLogger(__name__) 110 | self.api_key = api_key 111 | self.secret_key = secret_key 112 | self.per = per 113 | self.token = '' 114 | 115 | @classmethod 116 | def get_config(cls): 117 | # FIXME: Replace this as soon as we have a config module 118 | config = {} 119 | # Try to get baidu_yuyin config from config 120 | profile_path = jasperpath.config('profile.yml') 121 | if os.path.exists(profile_path): 122 | with open(profile_path, 'r') as f: 123 | profile = yaml.safe_load(f) 124 | if 'baidu_yuyin' in profile: 125 | if 'api_key' in profile['baidu_yuyin']: 126 | config['api_key'] = \ 127 | profile['baidu_yuyin']['api_key'] 128 | if 'secret_key' in profile['baidu_yuyin']: 129 | config['secret_key'] = \ 130 | profile['baidu_yuyin']['secret_key'] 131 | if 'per' in profile['baidu_yuyin']: 132 | config['per'] = \ 133 | profile['baidu_yuyin']['per'] 134 | else: 135 | print("Cannot find config file - %s\n" % profile_path) 136 | return config 137 | 138 | @classmethod 139 | def is_available(cls): 140 | return (super(cls, cls).is_available() and 141 | diagnose.check_network_connection()) 142 | 143 | def get_token(self): 144 | URL = 'http://openapi.baidu.com/oauth/2.0/token' 145 | params = urllib.parse.urlencode({'grant_type': 'client_credentials', 146 | 'client_id': self.api_key, 147 | 'client_secret': self.secret_key}) 148 | r = requests.get(URL, params=params) 149 | try: 150 | r.raise_for_status() 151 | token = r.json()['access_token'] 152 | return token 153 | except requests.exceptions.HTTPError: 154 | self._logger.critical('Token request failed with response: %r', 155 | r.text, 156 | exc_info=True) 157 | return '' 158 | 159 | def split_sentences(self, text): 160 | punctuations = ['.', '。', ';', ';', '\n'] 161 | for i in punctuations: 162 | text = text.replace(i, '@@@') 163 | return text.split('@@@') 164 | 165 | def get_speech(self, phrase): 166 | if self.token == '': 167 | self.token = self.get_token() 168 | if self.token == '': # Try once more 169 | self.token = self.get_token() 170 | if self.token == '': # Try again 171 | self.token = self.get_token() 172 | query = {'tex': phrase, 173 | 'lan': 'zh', 174 | 'tok': self.token, 175 | 'ctp': 1, 176 | 'cuid': str(get_mac())[:32], 177 | 'per': self.per 178 | } 179 | r = requests.post('http://tsn.baidu.com/text2audio', 180 | data=query, 181 | headers={'content-type': 'application/json'}) 182 | try: 183 | r.raise_for_status() 184 | if r.json()['err_msg'] is not None: 185 | self._logger.critical('Baidu TTS failed with response: %r', 186 | r.json()['err_msg'], 187 | exc_info=True) 188 | return None 189 | except Exception: 190 | pass 191 | with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f: 192 | f.write(r.content) 193 | tmpfile = f.name 194 | return tmpfile 195 | 196 | def say(self, phrase): 197 | self._logger.debug(u"Saying '%s' with '%s'", phrase, self.SLUG) 198 | tmpfile = self.get_speech(phrase) 199 | if tmpfile is not None: 200 | self.play_mp3(tmpfile) 201 | os.remove(tmpfile) 202 | 203 | 204 | def get_default_engine_slug(): 205 | return 'osx-tts' if platform.system().lower() == 'darwin' else 'espeak-tts' 206 | 207 | 208 | def get_engine_by_slug(slug=None): 209 | """ 210 | Returns: 211 | A speaker implementation available on the current platform 212 | 213 | Raises: 214 | ValueError if no speaker implementation is supported on this platform 215 | """ 216 | 217 | if not slug or type(slug) is not str: 218 | raise TypeError("Invalid slug '%s'", slug) 219 | 220 | selected_filter = filter(lambda engine: hasattr(engine, "SLUG") and 221 | engine.SLUG == slug, get_engines()) 222 | selected_engines = [engine for engine in selected_filter] 223 | if len(selected_engines) == 0: 224 | raise ValueError("No TTS engine found for slug '%s'" % slug) 225 | else: 226 | if len(selected_engines) > 1: 227 | print("WARNING: Multiple TTS engines found for slug '%s'. " + 228 | "This is most certainly a bug." % slug) 229 | engine = selected_engines[0] 230 | if not engine.is_available(): 231 | raise ValueError(("TTS engine '%s' is not available (due to " + 232 | "missing dependencies, etc.)") % slug) 233 | return engine 234 | 235 | 236 | def get_engines(): 237 | def get_subclasses(cls): 238 | subclasses = set() 239 | for subclass in cls.__subclasses__(): 240 | subclasses.add(subclass) 241 | subclasses.update(get_subclasses(subclass)) 242 | return subclasses 243 | return [tts_engine for tts_engine in 244 | list(get_subclasses(AbstractTTSEngine)) 245 | if hasattr(tts_engine, 'SLUG') and tts_engine.SLUG] 246 | 247 | 248 | if __name__ == '__main__': 249 | parser = argparse.ArgumentParser(description='Jasper TTS module') 250 | parser.add_argument('--debug', action='store_true', 251 | help='Show debug messages') 252 | args = parser.parse_args() 253 | 254 | logging.basicConfig() 255 | if args.debug: 256 | logger = logging.getLogger(__name__) 257 | logger.setLevel(logging.DEBUG) 258 | 259 | engines = get_engines() 260 | available_engines = [] 261 | for engine in get_engines(): 262 | if engine.is_available(): 263 | available_engines.append(engine) 264 | disabled_engines = list(set(engines).difference(set(available_engines))) 265 | print("Available TTS engines:") 266 | for i, engine in enumerate(available_engines, start=1): 267 | print("%d. %s" % (i, engine.SLUG)) 268 | 269 | print("") 270 | print("Disabled TTS engines:") 271 | 272 | for i, engine in enumerate(disabled_engines, start=1): 273 | print("%d. %s" % (i, engine.SLUG)) 274 | 275 | print("") 276 | for i, engine in enumerate(available_engines, start=1): 277 | print("%d. Testing engine '%s'..." % (i, engine.SLUG)) 278 | engine.get_instance().say("This is a test.") 279 | 280 | print("Done.") 281 | -------------------------------------------------------------------------------- /client/vocabcompiler.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8-*- 2 | """ 3 | Iterates over all the WORDS variables in the modules and creates a 4 | vocabulary for the respective stt_engine if needed. 5 | """ 6 | 7 | import os 8 | import tempfile 9 | import logging 10 | import hashlib 11 | import shutil 12 | from abc import ABCMeta, abstractmethod, abstractproperty 13 | 14 | import client.brain as brain 15 | import client.jasperpath as jasperpath 16 | 17 | from client.g2p import PhonetisaurusG2P 18 | try: 19 | import cmuclmtk 20 | except ImportError: 21 | logging.getLogger(__name__).error("Error importing CMUCLMTK module. " + 22 | "PocketsphinxVocabulary will not work " + 23 | "correctly.", exc_info=True) 24 | 25 | 26 | class AbstractVocabulary(object): 27 | """ 28 | Abstract base class for Vocabulary classes. 29 | 30 | Please note that subclasses have to implement the compile_vocabulary() 31 | method and set a string as the PATH_PREFIX class attribute. 32 | """ 33 | __metaclass__ = ABCMeta 34 | 35 | @classmethod 36 | def phrases_to_revision(cls, phrases): 37 | """ 38 | Calculates a revision from phrases by using the SHA1 hash function. 39 | 40 | Arguments: 41 | phrases -- a list of phrases 42 | 43 | Returns: 44 | A revision string for given phrases. 45 | """ 46 | sorted_phrases = sorted(phrases) 47 | joined_phrases = '\n'.join(sorted_phrases) 48 | sha1 = hashlib.sha1() 49 | sha1.update(joined_phrases.encode('ascii')) 50 | return sha1.hexdigest() 51 | 52 | def __init__(self, name='default', path='.'): 53 | """ 54 | Initializes a new Vocabulary instance. 55 | 56 | Optional Arguments: 57 | name -- (optional) the name of the vocabulary (Default: 'default') 58 | path -- (optional) the path in which the vocabulary exists or will 59 | be created (Default: '.') 60 | """ 61 | self.name = name 62 | self.path = os.path.abspath(os.path.join(path, self.PATH_PREFIX, name)) 63 | self._logger = logging.getLogger(__name__) 64 | 65 | @property 66 | def revision_file(self): 67 | """ 68 | Returns: 69 | The path of the the revision file as string 70 | """ 71 | return os.path.join(self.path, 'revision') 72 | 73 | @abstractproperty 74 | def is_compiled(self): 75 | """ 76 | Checks if the vocabulary is compiled by checking if the revision file 77 | is readable. This method should be overridden by subclasses to check 78 | for class-specific additional files, too. 79 | 80 | Returns: 81 | True if the dictionary is compiled, else False 82 | """ 83 | return os.access(self.revision_file, os.R_OK) 84 | 85 | @property 86 | def compiled_revision(self): 87 | """ 88 | Reads the compiled revision from the revision file. 89 | 90 | Returns: 91 | the revision of this vocabulary (i.e. the string 92 | inside the revision file), or None if is_compiled 93 | if False 94 | """ 95 | if not self.is_compiled: 96 | return None 97 | with open(self.revision_file, 'r') as f: 98 | revision = f.read().strip() 99 | self._logger.debug("compiled_revision is '%s'", revision) 100 | return revision 101 | 102 | def matches_phrases(self, phrases): 103 | """ 104 | Convenience method to check if this vocabulary exactly contains the 105 | phrases passed to this method. 106 | 107 | Arguments: 108 | phrases -- a list of phrases 109 | 110 | Returns: 111 | True if phrases exactly matches the phrases inside this 112 | vocabulary. 113 | 114 | """ 115 | return (self.compiled_revision == self.phrases_to_revision(phrases)) 116 | 117 | def compile(self, phrases, force=False): 118 | """ 119 | Compiles this vocabulary. If the force argument is True, compilation 120 | will be forced regardless of necessity (which means that the 121 | preliminary check if the current revision already equals the 122 | revision after compilation will be skipped). 123 | This method is not meant to be overridden by subclasses - use the 124 | _compile_vocabulary()-method instead. 125 | 126 | Arguments: 127 | phrases -- a list of phrases that this vocabulary will contain 128 | force -- (optional) forces compilation (Default: False) 129 | 130 | Returns: 131 | The revision of the compiled vocabulary 132 | """ 133 | revision = self.phrases_to_revision(phrases) 134 | if not force and self.compiled_revision == revision: 135 | self._logger.debug('Compilation not neccessary, compiled ' + 136 | 'version matches phrases.') 137 | return revision 138 | 139 | if not os.path.exists(self.path): 140 | self._logger.debug("Vocabulary dir '%s' does not exist, " + 141 | "creating...", self.path) 142 | try: 143 | os.makedirs(self.path) 144 | except OSError: 145 | self._logger.error("Couldn't create vocabulary dir '%s'", 146 | self.path, exc_info=True) 147 | raise 148 | try: 149 | with open(self.revision_file, 'w') as f: 150 | f.write(revision) 151 | except (OSError, IOError): 152 | self._logger.error("Couldn't write revision file in '%s'", 153 | self.revision_file, exc_info=True) 154 | raise 155 | else: 156 | self._logger.info('Starting compilation...') 157 | try: 158 | self._compile_vocabulary(phrases) 159 | except Exception as e: 160 | self._logger.error("Fatal compilation Error occured, " + 161 | "cleaning up...", exc_info=True) 162 | try: 163 | os.remove(self.revision_file) 164 | except OSError: 165 | pass 166 | raise e 167 | else: 168 | self._logger.info('Compilation done.') 169 | return revision 170 | 171 | @abstractmethod 172 | def _compile_vocabulary(self, phrases): 173 | """ 174 | Abstract method that should be overridden in subclasses with custom 175 | compilation code. 176 | 177 | Arguments: 178 | phrases -- a list of phrases that this vocabulary will contain 179 | """ 180 | 181 | 182 | class DummyVocabulary(AbstractVocabulary): 183 | 184 | PATH_PREFIX = 'dummy-vocabulary' 185 | 186 | @property 187 | def is_compiled(self): 188 | """ 189 | Checks if the vocabulary is compiled by checking if the revision 190 | file is readable. 191 | 192 | Returns: 193 | True if this vocabulary has been compiled, else False 194 | """ 195 | return super(self.__class__, self).is_compiled 196 | 197 | def _compile_vocabulary(self, phrases): 198 | """ 199 | Does nothing (because this is a dummy class for testing purposes). 200 | """ 201 | pass 202 | 203 | 204 | class PocketsphinxVocabulary(AbstractVocabulary): 205 | 206 | PATH_PREFIX = 'pocketsphinx-vocabulary' 207 | 208 | @property 209 | def languagemodel_file(self): 210 | """ 211 | Returns: 212 | The path of the the pocketsphinx languagemodel file as string 213 | """ 214 | return os.path.join(self.path, 'languagemodel') 215 | 216 | @property 217 | def dictionary_file(self): 218 | """ 219 | Returns: 220 | The path of the pocketsphinx dictionary file as string 221 | """ 222 | return os.path.join(self.path, 'dictionary') 223 | 224 | @property 225 | def is_compiled(self): 226 | """ 227 | Checks if the vocabulary is compiled by checking if the revision, 228 | languagemodel and dictionary files are readable. 229 | 230 | Returns: 231 | True if this vocabulary has been compiled, else False 232 | """ 233 | return (super(self.__class__, self).is_compiled and 234 | os.access(self.languagemodel_file, os.R_OK) and 235 | os.access(self.dictionary_file, os.R_OK)) 236 | 237 | @property 238 | def decoder_kwargs(self): 239 | """ 240 | Convenience property to use this Vocabulary with the __init__() method 241 | of the pocketsphinx.Decoder class. 242 | 243 | Returns: 244 | A dict containing kwargs for the pocketsphinx.Decoder.__init__() 245 | method. 246 | 247 | Example: 248 | decoder = pocketsphinx.Decoder(**vocab_instance.decoder_kwargs, 249 | hmm='/path/to/hmm') 250 | 251 | """ 252 | return {'lm': self.languagemodel_file, 'dict': self.dictionary_file} 253 | 254 | def _compile_vocabulary(self, phrases): 255 | """ 256 | Compiles the vocabulary to the Pocketsphinx format by creating a 257 | languagemodel and a dictionary. 258 | 259 | Arguments: 260 | phrases -- a list of phrases that this vocabulary will contain 261 | """ 262 | text = " ".join([(" %s " % phrase) for phrase in phrases]) 263 | self._logger.debug('Compiling languagemodel...') 264 | vocabulary = self._compile_languagemodel(text, self.languagemodel_file) 265 | self._logger.debug('Starting dictionary...') 266 | self._compile_dictionary(vocabulary, self.dictionary_file) 267 | 268 | def _compile_languagemodel(self, text, output_file): 269 | """ 270 | Compiles the languagemodel from a text. 271 | 272 | Arguments: 273 | text -- the text the languagemodel will be generated from 274 | output_file -- the path of the file this languagemodel will 275 | be written to 276 | 277 | Returns: 278 | A list of all unique words this vocabulary contains. 279 | """ 280 | with tempfile.NamedTemporaryFile(suffix='.vocab', delete=False) as f: 281 | vocab_file = f.name 282 | 283 | # Create vocab file from text 284 | self._logger.debug("Creating vocab file: '%s'", vocab_file) 285 | cmuclmtk.text2vocab(text, vocab_file) 286 | 287 | # Create language model from text 288 | self._logger.debug("Creating languagemodel file: '%s'", output_file) 289 | cmuclmtk.text2lm(text, output_file, vocab_file=vocab_file) 290 | 291 | # Get words from vocab file 292 | self._logger.debug("Getting words from vocab file and removing it " + 293 | "afterwards...") 294 | words = [] 295 | with open(vocab_file, 'r') as f: 296 | for line in f: 297 | line = line.strip() 298 | if not line.startswith('#') and line not in ('', ''): 299 | words.append(line) 300 | os.remove(vocab_file) 301 | 302 | return words 303 | 304 | def _compile_dictionary(self, words, output_file): 305 | """ 306 | Compiles the dictionary from a list of words. 307 | 308 | Arguments: 309 | words -- a list of all unique words this vocabulary contains 310 | output_file -- the path of the file this dictionary will 311 | be written to 312 | """ 313 | # create the dictionary 314 | self._logger.debug("Getting phonemes for %d words...", len(words)) 315 | g2pconverter = PhonetisaurusG2P(**PhonetisaurusG2P.get_config()) 316 | phonemes = g2pconverter.translate(words) 317 | 318 | self._logger.debug("Creating dict file: '%s'", output_file) 319 | with open(output_file, "w") as f: 320 | for word, pronounciations in phonemes.items(): 321 | for i, pronounciation in enumerate(pronounciations, start=1): 322 | if i == 1: 323 | line = "%s\t%s\n" % (word, pronounciation) 324 | else: 325 | line = "%s(%d)\t%s\n" % (word, i, pronounciation) 326 | f.write(line) 327 | 328 | 329 | def get_phrases_from_module(module): 330 | """ 331 | Gets phrases from a module. 332 | 333 | Arguments: 334 | module -- a module reference 335 | 336 | Returns: 337 | The list of phrases in this module. 338 | """ 339 | return module.WORDS if hasattr(module, 'WORDS') else [] 340 | 341 | 342 | def get_keyword_phrases(): 343 | """ 344 | Gets the keyword phrases from the keywords file in the jasper data dir. 345 | 346 | Returns: 347 | A list of keyword phrases. 348 | """ 349 | phrases = [] 350 | 351 | with open(jasperpath.data('keyword_phrases'), mode="r") as f: 352 | for line in f: 353 | phrase = line.strip() 354 | if phrase: 355 | phrases.append(phrase) 356 | 357 | return phrases 358 | 359 | 360 | def get_all_phrases(): 361 | """ 362 | Gets phrases from all modules. 363 | 364 | Returns: 365 | A list of phrases in all modules plus additional phrases passed to this 366 | function. 367 | """ 368 | phrases = [] 369 | 370 | modules = brain.Brain.get_modules() 371 | for module in modules: 372 | phrases.extend(get_phrases_from_module(module)) 373 | 374 | return sorted(list(set(phrases))) 375 | 376 | 377 | if __name__ == '__main__': 378 | import argparse 379 | 380 | parser = argparse.ArgumentParser(description='Vocabcompiler Demo') 381 | parser.add_argument('--base-dir', action='store', 382 | help='the directory in which the vocabulary will be ' + 383 | 'compiled.') 384 | parser.add_argument('--debug', action='store_true', 385 | help='show debug messages') 386 | args = parser.parse_args() 387 | 388 | logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) 389 | base_dir = args.base_dir if args.base_dir else tempfile.mkdtemp() 390 | 391 | phrases = get_all_phrases() 392 | print("Module phrases: %r" % phrases) 393 | 394 | for subclass in AbstractVocabulary.__subclasses__(): 395 | if hasattr(subclass, 'PATH_PREFIX'): 396 | vocab = subclass(path=base_dir) 397 | print("Vocabulary in: %s" % vocab.path) 398 | print("Revision file: %s" % vocab.revision_file) 399 | print("Compiled revision: %s" % vocab.compiled_revision) 400 | print("Is compiled: %r" % vocab.is_compiled) 401 | print("Matches phrases: %r" % vocab.matches_phrases(phrases)) 402 | if not vocab.is_compiled or not vocab.matches_phrases(phrases): 403 | print("Compiling...") 404 | vocab.compile(phrases) 405 | print("") 406 | print("Vocabulary in: %s" % vocab.path) 407 | print("Revision file: %s" % vocab.revision_file) 408 | print("Compiled revision: %s" % vocab.compiled_revision) 409 | print("Is compiled: %r" % vocab.is_compiled) 410 | print("Matches phrases: %r" % vocab.matches_phrases(phrases)) 411 | print("") 412 | if not args.base_dir: 413 | print("Removing temporary directory '%s'..." % base_dir) 414 | shutil.rmtree(base_dir) 415 | -------------------------------------------------------------------------------- /conf/profile.yml: -------------------------------------------------------------------------------- 1 | carrier: '' 2 | first_name: Tommy 3 | gmail_password: '' 4 | last_name: Wang 5 | location: Beijing 6 | phone_number: '' 7 | prefers_email: false 8 | 9 | stt_engine: baidu-stt 10 | tts_engine: baidu-tts 11 | stt_passive_engine: sphinx 12 | 13 | pocketsphinx: 14 | fst_model: '/home/pi/g014b2b.fst' 15 | hmm_dir: '/usr/local/share/pocketsphinx/model/hmm/en_US/hub4wsj_sc_8k' 16 | 17 | baidu_yuyin: 18 | api_key: D4C4GcF1fwqqhPaGPuXSMLC8 19 | secret_key: 95ea4357542d01a490559500b85a1eff 20 | -------------------------------------------------------------------------------- /jasper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8-*- 3 | 4 | import os 5 | import shutil 6 | import logging 7 | 8 | import yaml 9 | import argparse 10 | 11 | from client import tts 12 | from client import stt 13 | from client import jasperpath 14 | from client import diagnose 15 | from client.conversation import Conversation 16 | import sys 17 | 18 | # Add jasperpath.LIB_PATH to sys.path 19 | sys.path.append(jasperpath.LIB_PATH) 20 | 21 | parser = argparse.ArgumentParser(description='Jasper Voice Control Center') 22 | parser.add_argument('--local', action='store_true', 23 | help='Use text input instead of a real microphone') 24 | parser.add_argument('--no-network-check', action='store_true', 25 | help='Disable the network connection check') 26 | parser.add_argument('--diagnose', action='store_true', 27 | help='Run diagnose and exit') 28 | parser.add_argument('--debug', action='store_true', help='Show debug messages') 29 | args = parser.parse_args() 30 | 31 | if args.local: 32 | from client.local_mic import Mic 33 | else: 34 | from client.mic import Mic 35 | 36 | 37 | class Jasper(object): 38 | def __init__(self): 39 | self._logger = logging.getLogger(__name__) 40 | 41 | # Create config dir if it does not exist yet 42 | if not os.path.exists(jasperpath.CONFIG_PATH): 43 | try: 44 | os.makedirs(jasperpath.CONFIG_PATH) 45 | except OSError: 46 | self._logger.error("Could not create config dir: '%s'", 47 | jasperpath.CONFIG_PATH, exc_info=True) 48 | raise 49 | 50 | # Check if config dir is writable 51 | if not os.access(jasperpath.CONFIG_PATH, os.W_OK): 52 | self._logger.critical("Config dir %s is not writable. Jasper " + 53 | "won't work correctly.", 54 | jasperpath.CONFIG_PATH) 55 | 56 | # FIXME: For backwards compatibility, move old config file to newly 57 | # created config dir 58 | old_configfile = os.path.join(jasperpath.LIB_PATH, 'profile.yml') 59 | new_configfile = jasperpath.config('profile.yml') 60 | if os.path.exists(old_configfile): 61 | if os.path.exists(new_configfile): 62 | self._logger.warning("Deprecated profile file found: '%s'. " + 63 | "Please remove it.", old_configfile) 64 | else: 65 | self._logger.warning("Deprecated profile file found: '%s'. " + 66 | "Trying to copy it to new location '%s'.", 67 | old_configfile, new_configfile) 68 | try: 69 | shutil.copy2(old_configfile, new_configfile) 70 | except shutil.Error: 71 | self._logger.error("Unable to copy config file. " + 72 | "Please copy it manually.", 73 | exc_info=True) 74 | raise 75 | 76 | # Read config 77 | self._logger.debug("Trying to read config file: '%s'", new_configfile) 78 | try: 79 | with open(new_configfile, "r") as f: 80 | self.config = yaml.safe_load(f) 81 | except OSError: 82 | self._logger.error("Can't open config file: '%s'", new_configfile) 83 | raise 84 | 85 | try: 86 | stt_engine_slug = self.config['stt_engine'] 87 | except KeyError: 88 | stt_engine_slug = 'sphinx' 89 | logger.warning("stt_engine not specified in profile, defaulting " + 90 | "to '%s'", stt_engine_slug) 91 | stt_engine_class = stt.get_engine_by_slug(stt_engine_slug) 92 | 93 | try: 94 | slug = self.config['stt_passive_engine'] 95 | stt_passive_engine_class = stt.get_engine_by_slug(slug) 96 | except KeyError: 97 | stt_passive_engine_class = stt_engine_class 98 | 99 | try: 100 | tts_engine_slug = self.config['tts_engine'] 101 | except KeyError: 102 | tts_engine_slug = tts.get_default_engine_slug() 103 | logger.warning("tts_engine not specified in profile, defaulting " + 104 | "to '%s'", tts_engine_slug) 105 | tts_engine_class = tts.get_engine_by_slug(tts_engine_slug) 106 | 107 | # Initialize Mic 108 | self.mic = Mic(tts_engine_class.get_instance(), 109 | stt_passive_engine_class.get_passive_instance(), 110 | stt_engine_class.get_active_instance()) 111 | 112 | def run(self): 113 | if 'first_name' in self.config: 114 | salutation = ("%s 很高兴为你服务" 115 | % self.config["first_name"]) 116 | else: 117 | salutation = "很高兴为你服务" 118 | if os.path.exists(jasperpath.tjbot('shakehand.servo.js')): 119 | os.system("node " + jasperpath.tjbot('shakehand.servo.js')) 120 | self.mic.say(salutation) 121 | 122 | conversation = Conversation(jasperpath.hotword(), 123 | self.mic, self.config) 124 | conversation.handleForever() 125 | 126 | 127 | if __name__ == "__main__": 128 | 129 | print("*******************************************************") 130 | print("* LAIBOT - THE TALKING COMPUTER *") 131 | print("* (c) 2017 Thomas J.J Wang *") 132 | print("* (c) 2015 Shubhro Saha, Charlie Marsh & Jan Holthuis *") 133 | print("*******************************************************") 134 | 135 | logging.basicConfig() 136 | logger = logging.getLogger() 137 | logger.getChild("client.stt").setLevel(logging.INFO) 138 | 139 | if args.debug: 140 | logger.setLevel(logging.DEBUG) 141 | 142 | if not args.no_network_check and not diagnose.check_network_connection(): 143 | logger.warning("Network not connected. This may prevent Jasper from " + 144 | "running properly.") 145 | 146 | if args.diagnose: 147 | failed_checks = diagnose.run() 148 | sys.exit(0 if not failed_checks else 1) 149 | 150 | try: 151 | app = Jasper() 152 | except Exception: 153 | logger.error("Error occured!", exc_info=True) 154 | sys.exit(1) 155 | 156 | app.run() 157 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytz==2017.3 2 | mock==2.0.0 3 | cmuclmtk==0.1.5 4 | feedparser==5.2.1 5 | requests==2.4.3 6 | pip==1.5.6 7 | gtts==1.2.2 8 | mad==0.2.2 9 | pyvona==1.2.1 10 | PyYAML==3.12 11 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | BASE_DIR=$(cd `dirname $0`; pwd) 3 | TJBOT_DIR=$BASE_DIR/../tjbot 4 | 5 | if [ -x $TJBOT_DIR ]; then 6 | cd $TJBOT_DIR/bootstrap/tests 7 | echo "Installing support libraries for TJBot. This may take few minutes." 8 | npm install > install.log 2>&1 9 | 10 | cd $BASE_DIR 11 | fi 12 | 13 | python3 jasper.py --debug 14 | -------------------------------------------------------------------------------- /static/audio/beep_hi.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjwang/laibot-client/cc4a07617dd5316f32d391eba7e2e51ad01925bd/static/audio/beep_hi.wav -------------------------------------------------------------------------------- /static/audio/beep_lo.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjwang/laibot-client/cc4a07617dd5316f32d391eba7e2e51ad01925bd/static/audio/beep_lo.wav -------------------------------------------------------------------------------- /static/audio/jasper.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjwang/laibot-client/cc4a07617dd5316f32d391eba7e2e51ad01925bd/static/audio/jasper.wav -------------------------------------------------------------------------------- /static/audio/say.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjwang/laibot-client/cc4a07617dd5316f32d391eba7e2e51ad01925bd/static/audio/say.wav -------------------------------------------------------------------------------- /static/audio/time.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjwang/laibot-client/cc4a07617dd5316f32d391eba7e2e51ad01925bd/static/audio/time.wav -------------------------------------------------------------------------------- /static/audio/weather_zh.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjwang/laibot-client/cc4a07617dd5316f32d391eba7e2e51ad01925bd/static/audio/weather_zh.wav -------------------------------------------------------------------------------- /static/dictionary_persona.dic: -------------------------------------------------------------------------------- 1 | BE B IY 2 | DID D IH D 3 | FIRST F ER S T 4 | IN IH N 5 | JASPER JH AE S P ER 6 | NOW N AW 7 | OKEY OW K IY 8 | RIGHT R AY T 9 | SAY S EY 10 | TOMMY T AA M IY 11 | WHAT W AH T 12 | WHAT(2) HH W AH T 13 | 14 | -------------------------------------------------------------------------------- /static/keyword_phrases: -------------------------------------------------------------------------------- 1 | BE 2 | DID 3 | FIRST 4 | IN 5 | OKEY 6 | TOMMY 7 | JASPER 8 | NOW 9 | RIGHT 10 | SAY 11 | WHAT 12 | OKEY TOMMY 13 | OKEY TOMMY 14 | OKEY TOMMY 15 | OKEY TOMMY 16 | OKEY TOMMY 17 | OKEY TOMMY 18 | OKEY TOMMY 19 | OKEY TOMMY 20 | OKEY TOMMY 21 | OKEY TOMMY 22 | 23 | -------------------------------------------------------------------------------- /static/languagemodel_persona.lm: -------------------------------------------------------------------------------- 1 | Language model created by QuickLM on Sun Nov 19 06:58:16 EST 2017 2 | Copyright (c) 1996-2010 Carnegie Mellon University and Alexander I. Rudnicky 3 | 4 | The model is in standard ARPA format, designed by Doug Paul while he was at MITRE. 5 | 6 | The code that was used to produce this language model is available in Open Source. 7 | Please visit http://www.speech.cs.cmu.edu/tools/ for more information 8 | 9 | The (fixed) discount mass is 0.5. The backoffs are computed using the ratio method. 10 | This model based on a corpus of 21 sentences and 13 words 11 | 12 | \data\ 13 | ngram 1=13 14 | ngram 2=23 15 | ngram 3=13 16 | 17 | \1-grams: 18 | -0.8421 -0.3010 19 | -0.8421 -0.1974 20 | -2.1644 BE -0.2336 21 | -2.1644 DID -0.2336 22 | -2.1644 FIRST -0.2336 23 | -2.1644 IN -0.2336 24 | -2.1644 JASPER -0.2336 25 | -2.1644 NOW -0.2336 26 | -1.1230 OKEY -0.1936 27 | -2.1644 RIGHT -0.2336 28 | -2.1644 SAY -0.2336 29 | -1.1230 TOMMY -0.2336 30 | -2.1644 WHAT -0.2336 31 | 32 | \2-grams: 33 | -1.6232 BE 0.0000 34 | -1.6232 DID 0.0000 35 | -1.6232 FIRST 0.0000 36 | -1.6232 IN 0.0000 37 | -1.6232 JASPER 0.0000 38 | -1.6232 NOW 0.0000 39 | -0.5819 OKEY 0.0000 40 | -1.6232 RIGHT 0.0000 41 | -1.6232 SAY 0.0000 42 | -1.6232 TOMMY 0.0000 43 | -1.6232 WHAT 0.0000 44 | -0.3010 BE -0.3010 45 | -0.3010 DID -0.3010 46 | -0.3010 FIRST -0.3010 47 | -0.3010 IN -0.3010 48 | -0.3010 JASPER -0.3010 49 | -0.3010 NOW -0.3010 50 | -1.3424 OKEY -0.3010 51 | -0.3424 OKEY TOMMY 0.0000 52 | -0.3010 RIGHT -0.3010 53 | -0.3010 SAY -0.3010 54 | -0.3010 TOMMY -0.3010 55 | -0.3010 WHAT -0.3010 56 | 57 | \3-grams: 58 | -0.3010 BE 59 | -0.3010 DID 60 | -0.3010 FIRST 61 | -0.3010 IN 62 | -0.3010 JASPER 63 | -0.3010 NOW 64 | -1.3424 OKEY 65 | -0.3424 OKEY TOMMY 66 | -0.3010 RIGHT 67 | -0.3010 SAY 68 | -0.3010 TOMMY 69 | -0.3010 WHAT 70 | -0.3010 OKEY TOMMY 71 | 72 | \end\ 73 | 74 | -------------------------------------------------------------------------------- /static/text/JOKES.txt: -------------------------------------------------------------------------------- 1 | little old lady 2 | wow... I didn't know you could yodel... get it... because it sounded like you were yodeling 3 | 4 | oink oink 5 | make up your mind... are you a pig or an owl... get it... because an owl goes who but a pig goes oink... oink 6 | 7 | cows go 8 | no... cows go moo... didn't you know 9 | 10 | jarvis 11 | jarvis... it's me you idiot 12 | 13 | me 14 | no... seriously... it's just me... I was telling a knock knock joke ha ha ha 15 | 16 | madam 17 | my damn foot got stuck in the door, so open it he he he... but actually... I think I hurt my foot 18 | 19 | doris 20 | door is locked... that's why I'm knocking, you idiot 21 | 22 | cash 23 | no thanks... but I would like a peanut instead... get it? because you said cashew... ha ha ha 24 | 25 | orange 26 | orange you glad I am your friend 27 | 28 | alex 29 | I'll ask the questions around here, thank you 30 | 31 | viper 32 | viper nose... it's running 33 | 34 | canoe 35 | can you scratch my back... it itches 36 | 37 | pete 38 | pizza delivery guy... you idiot 39 | 40 | doctor 41 | that is the best show ever 42 | 43 | arizona 44 | arizona room for only one of us in this room 45 | 46 | spider 47 | in spider what everyone says, I still feel like a human 48 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjwang/laibot-client/cc4a07617dd5316f32d391eba7e2e51ad01925bd/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_brain.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8-*- 3 | import unittest 4 | import mock 5 | from client import brain, test_mic 6 | 7 | 8 | DEFAULT_PROFILE = { 9 | 'prefers_email': False, 10 | 'location': 'Cape Town', 11 | 'timezone': 'US/Eastern', 12 | 'phone_number': '012344321' 13 | } 14 | 15 | 16 | class TestBrain(unittest.TestCase): 17 | 18 | @staticmethod 19 | def _emptyBrain(): 20 | mic = test_mic.Mic([]) 21 | profile = DEFAULT_PROFILE 22 | return brain.Brain(mic, profile) 23 | 24 | def testLog(self): 25 | """Does Brain correctly log errors when raised by modules?""" 26 | my_brain = TestBrain._emptyBrain() 27 | unclear = my_brain.modules[-1] 28 | with mock.patch.object(unclear, 'handle') as mocked_handle: 29 | with mock.patch.object(my_brain._logger, 'error') as mocked_log: 30 | mocked_handle.side_effect = KeyError('foo') 31 | my_brain.query("zzz gibberish zzz") 32 | self.assertTrue(mocked_log.called) 33 | -------------------------------------------------------------------------------- /tests/test_diagnose.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8-*- 3 | import unittest 4 | from client import diagnose 5 | 6 | 7 | class TestDiagnose(unittest.TestCase): 8 | def testPythonImportCheck(self): 9 | # This a python stdlib module that definitely exists 10 | self.assertTrue(diagnose.check_python_import("os")) 11 | # I sincerly hope nobody will ever create a package with that name 12 | self.assertFalse(diagnose.check_python_import("nonexistant_package")) 13 | -------------------------------------------------------------------------------- /tests/test_modules.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | # -*- coding: utf-8-*- 3 | import unittest 4 | from client import test_mic 5 | from client.modules import Unclear 6 | 7 | DEFAULT_PROFILE = { 8 | 'prefers_email': False, 9 | 'location': 'Cape Town', 10 | 'timezone': 'US/Eastern', 11 | 'phone_number': '012344321' 12 | } 13 | 14 | 15 | class TestModules(unittest.TestCase): 16 | 17 | def setUp(self): 18 | self.profile = DEFAULT_PROFILE 19 | self.send = False 20 | 21 | def runConversation(self, query, inputs, module): 22 | """Generic method for spoofing conversation. 23 | 24 | Arguments: 25 | query -- The initial input to the server. 26 | inputs -- Additional input, if conversation is extended. 27 | 28 | Returns: 29 | The server's responses, in a list. 30 | """ 31 | self.assertTrue(module.isValid(query)) 32 | mic = test_mic.Mic(inputs) 33 | module.handle(query, mic, self.profile) 34 | return mic.outputs 35 | 36 | def testUnclear(self): 37 | query = "What time is it?" 38 | inputs = [] 39 | self.runConversation(query, inputs, Unclear) 40 | --------------------------------------------------------------------------------