From 95e5ac7dfe64ccf2cba3acf725592c6f5f1f482b Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Tue, 30 Aug 2016 17:09:39 +0800 Subject: [PATCH 01/25] Update README.md --- README.md | 37 +------------------------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/README.md b/README.md index aa0a45cf8..df5526e98 100644 --- a/README.md +++ b/README.md @@ -1,36 +1 @@ -jasper-client -============= - -[![Build Status](https://travis-ci.org/jasperproject/jasper-client.svg?branch=master)](https://travis-ci.org/jasperproject/jasper-client) [![Coverage Status](https://img.shields.io/coveralls/jasperproject/jasper-client.svg)](https://coveralls.io/r/jasperproject/jasper-client) [![Codacy Badge](https://www.codacy.com/project/badge/3a50e1bc2261419894d76b7e2c1ac694)](https://www.codacy.com/app/jasperproject/jasper-client) - -Client code for the Jasper voice computing platform. Jasper is an open source platform for developing always-on, voice-controlled applications. - -Learn more at [jasperproject.github.io](http://jasperproject.github.io/), where we have assembly and installation instructions, as well as extensive documentation. For the relevant disk image, please visit [SourceForge](http://sourceforge.net/projects/jasperproject/). - -## Contributing - -If you'd like to contribute to Jasper, please read through our **[Contributing Guide](CONTRIBUTING.md)**, which outlines the philosophies to preserve, tests to run, and more. We highly recommend reading through this guide before writing any code. - -The Contributing Guide also outlines some prospective features and areas that could use love. However, for a more thorough overview of Jasper's direction and goals, check out the **[Product Roadmap](https://github.com/jasperproject/jasper-client/wiki/Roadmap)**. - -Thanks in advance for any and all work you contribute to Jasper! - -## Support - -If you run into an issue or require technical support, please first look through the closed and open **[GitHub Issues](https://github.com/jasperproject/jasper-client/issues)**, as you may find a solution there (or some useful advice, at least). - -If you're still having trouble, the next place to look would be the new **[Google Group support forum](https://groups.google.com/forum/#!forum/jasper-support-forum)** or join the `#jasper` IRC channel on **chat.freenode.net**. If your problem remains unsolved, feel free to create a post there describing the issue, the steps you've taken to debug it, etc. - -## Contact - -Jasper's core developers are [Shubhro Saha](http://www.shubhro.com), [Charles Marsh](http://www.crmarsh.com) and [Jan Holthuis](http://homepage.ruhr-uni-bochum.de/Jan.Holthuis/). All of them can be reached by email at [saha@princeton.edu](mailto:saha@princeton.edu), [crmarsh@princeton.edu](mailto:crmarsh@princeton.edu) and [jan.holthuis@ruhr-uni-bochum.de](mailto:jan.holthuis@ruhr-uni-bochum.de) respectively. However, for technical support and other problems, please go through the channels mentioned above. - -For a complete list of code contributors, please see [AUTHORS.md](AUTHORS.md). - -## License - -*Copyright (c) 2014-2015, Charles Marsh, Shubhro Saha & Jan Holthuis. All rights reserved.* - -Jasper is covered by the MIT license, a permissive free software license that lets you do anything you want with the source code, as long as you provide back attribution and ["don't hold \[us\] liable"](http://choosealicense.com). For the full license text see the [LICENSE.md](LICENSE.md) file. - -*Note that this licensing only refers to the Jasper client code (i.e., the code on GitHub) and not to the disk image itself (i.e., the code on SourceForge).* +# Judy - Simplified Jasper, the voice computing platform From 6edd2c03fdc8fe82502cfadd36e8e01913771ede Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Fri, 2 Sep 2016 13:05:34 +0800 Subject: [PATCH 02/25] Greatly simplified. --- .coveragerc | 7 - .gitignore | 4 + .travis.yml | 22 - AUTHORS.md | 6 +- CONTRIBUTING.md | 49 -- README.md | 9 +- boot/boot.py | 11 - boot/boot.sh | 4 - client/__init__.py | 0 client/alteration.py | 21 - client/app_utils.py | 128 ----- client/brain.py | 86 --- client/conversation.py | 49 -- client/diagnose.py | 193 ------- client/g2p.py | 156 ------ client/jasperpath.py | 20 - client/local_mic.py | 32 -- client/main.py | 10 - client/mic.py | 262 --------- client/modules/Birthday.py | 67 --- client/modules/Gmail.py | 138 ----- client/modules/HN.py | 139 ----- client/modules/Joke.py | 66 --- client/modules/Life.py | 34 -- client/modules/MPDControl.py | 413 -------------- client/modules/News.py | 131 ----- client/modules/Notifications.py | 58 -- client/modules/Time.py | 35 -- client/modules/Unclear.py | 31 -- client/modules/Weather.py | 172 ------ client/modules/__init__.py | 0 client/notifier.py | 76 --- client/populate.py | 146 ----- client/requirements.txt | 26 - client/start.sh | 4 - client/stt.py | 661 ---------------------- client/test_mic.py | 33 -- client/tts.py | 711 ------------------------ client/vocabcompiler.py | 563 ------------------- jasper.py | 151 ----- judy.py | 113 ++++ {static => resources}/audio/beep_hi.wav | Bin {static => resources}/audio/beep_lo.wav | Bin resources/lm/0931.dic | 31 ++ resources/lm/0931.lm | 98 ++++ resources/lm/0931.log_pronounce | 20 + resources/lm/0931.sent | 16 + resources/lm/0931.vocab | 19 + resources/lm/sentences.txt | 16 + setup.py | 47 ++ static/audio/jasper.wav | Bin 71724 -> 0 bytes static/audio/say.wav | Bin 151944 -> 0 bytes static/audio/time.wav | Bin 90156 -> 0 bytes static/dictionary_persona.dic | 22 - static/keyword_phrases | 18 - static/languagemodel_persona.lm | 97 ---- static/text/JOKES.txt | 47 -- tests/__init__.py | 0 tests/echo.py | 14 + tests/test_brain.py | 49 -- tests/test_diagnose.py | 12 - tests/test_g2p.py | 77 --- tests/test_modules.py | 96 ---- tests/test_stt.py | 51 -- tests/test_tts.py | 11 - tests/test_vin.py | 22 + tests/test_vocabcompiler.py | 135 ----- tests/test_vout.py | 8 + 68 files changed, 421 insertions(+), 5322 deletions(-) delete mode 100644 .coveragerc delete mode 100644 .travis.yml delete mode 100644 CONTRIBUTING.md delete mode 100755 boot/boot.py delete mode 100755 boot/boot.sh delete mode 100644 client/__init__.py delete mode 100644 client/alteration.py delete mode 100644 client/app_utils.py delete mode 100644 client/brain.py delete mode 100644 client/conversation.py delete mode 100644 client/diagnose.py delete mode 100644 client/g2p.py delete mode 100644 client/jasperpath.py delete mode 100644 client/local_mic.py delete mode 100755 client/main.py delete mode 100644 client/mic.py delete mode 100644 client/modules/Birthday.py delete mode 100644 client/modules/Gmail.py delete mode 100644 client/modules/HN.py delete mode 100644 client/modules/Joke.py delete mode 100644 client/modules/Life.py delete mode 100644 client/modules/MPDControl.py delete mode 100644 client/modules/News.py delete mode 100644 client/modules/Notifications.py delete mode 100644 client/modules/Time.py delete mode 100644 client/modules/Unclear.py delete mode 100644 client/modules/Weather.py delete mode 100755 client/modules/__init__.py delete mode 100644 client/notifier.py delete mode 100644 client/populate.py delete mode 100644 client/requirements.txt delete mode 100755 client/start.sh delete mode 100644 client/stt.py delete mode 100644 client/test_mic.py delete mode 100644 client/tts.py delete mode 100644 client/vocabcompiler.py delete mode 100755 jasper.py create mode 100644 judy.py rename {static => resources}/audio/beep_hi.wav (100%) rename {static => resources}/audio/beep_lo.wav (100%) create mode 100644 resources/lm/0931.dic create mode 100644 resources/lm/0931.lm create mode 100644 resources/lm/0931.log_pronounce create mode 100644 resources/lm/0931.sent create mode 100644 resources/lm/0931.vocab create mode 100644 resources/lm/sentences.txt create mode 100644 setup.py delete mode 100644 static/audio/jasper.wav delete mode 100644 static/audio/say.wav delete mode 100644 static/audio/time.wav delete mode 100644 static/dictionary_persona.dic delete mode 100644 static/keyword_phrases delete mode 100644 static/languagemodel_persona.lm delete mode 100644 static/text/JOKES.txt delete mode 100644 tests/__init__.py create mode 100644 tests/echo.py delete mode 100644 tests/test_brain.py delete mode 100644 tests/test_diagnose.py delete mode 100644 tests/test_g2p.py delete mode 100644 tests/test_modules.py delete mode 100644 tests/test_stt.py delete mode 100644 tests/test_tts.py create mode 100644 tests/test_vin.py delete mode 100644 tests/test_vocabcompiler.py create mode 100644 tests/test_vout.py diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 0dec5e23c..000000000 --- a/.coveragerc +++ /dev/null @@ -1,7 +0,0 @@ -[report] -omit = - */python?.?/* - */site-packages/nose/* - *__init__* -exclude_lines = - if __name__ == .__main__.: diff --git a/.gitignore b/.gitignore index cffc729c6..f585443bf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] +*.pyc # C extensions *.so @@ -65,3 +66,6 @@ target/ # SublimeLinter config file .sublimelinterrc + +# Atom editor remote sync config +.remote-sync.json diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 430948d6b..000000000 --- a/.travis.yml +++ /dev/null @@ -1,22 +0,0 @@ -env: - - ARCH=x86 -language: python -sudo: false -python: - - "2.7" -cache: - directories: - - "$HOME/.pip-cache/" - - "/home/travis/virtualenv/python2.7" -install: - - "pip install -r client/requirements.txt --download-cache $HOME/.pip-cache" - - "pip install python-coveralls --download-cache $HOME/.pip-cache" - - "pip install coverage --download-cache $HOME/.pip-cache" - - "pip install flake8 --download-cache $HOME/.pip-cache" -before_script: - - "flake8 jasper.py client tests" -script: - - "coverage run -m unittest discover" -after_success: - - "coverage report" - - "coveralls" diff --git a/AUTHORS.md b/AUTHORS.md index 9b1ceedfc..20a0cf835 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -20,4 +20,8 @@ Jasper. Thanks a lot! *Please alphabetize new entries* We'd also like to thank all the people who reported bugs, helped -answer newbie questions, and generally made Jasper better. \ No newline at end of file +answer newbie questions, and generally made Jasper better. + +Judy's author: + + Nick Lee diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 6c9f2a1aa..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,49 +0,0 @@ -# Contributing to Jasper - -Want to contribute to Jasper? Great! We're always happy to have more contributors. Before you start developing, though, we ask that you read through this document in-full. It's full of tips and guidelines--if you skip it, you'll likely miss something important (and your pull request will probably be rejected as a result). - -Throughout the process of contributing, there's one thing we'd like you to remember: Jasper was developed (and is maintained) by what might be described as "volunteers". They earn no money for their work on Jasper and give their time solely for the advancement of the software and the enjoyment of its users. While they will do their best to get back to you regarding issues and pull requests, **your patience is appreciated**. - -## Reporting Bugs - -The [bug tracker](https://github.com/jasperproject/jasper-client/issues) at Github is for reporting bugs in Jasper. If encounter problems during installation or compliation of one of Jasper's dependencies for example, do not create a new issue here. Please open a new thread in the [support forum](https://groups.google.com/forum/#!forum/jasper-support-forum) instead. Also, make sure that it's not a usage issue. - -If you think that you found a bug and that you're using the most recent version of Jasper, please include a detailed description what you did and how to reproduce the bug. If Jasper crashes, run it with `--debug` as command line argument and also include the full stacktrace (not just the last line). If you post output, put it into a [fenced code block](https://help.github.com/articles/github-flavored-markdown/#fenced-code-blocks). Last but not least: have a look at [Simon Tatham's "How to Report Bugs Effectively"](http://www.chiark.greenend.org.uk/~sgtatham/bugs.html) to learn how to write a good bug report. - -## Opening Pull Requests - -### Philosophies - -There are a few key philosophies to preserve while designing features for Jasper: - -1. **The core Jasper software (`jasper-client`) must remain decoupled from any third-party web services.** For example, the Jasper core should never depend on Google Translate in any way. This is to avoid unnecessary dependences on web services that might change or become paid over time. -2. **The core Jasper software (`jasper-client`) must remain decoupled from any paid software or services.** Of course, you're free to use whatever you'd like when running Jasper locally or in a fork, but the main branch needs to remain free and open-source. -3. **Jasper should be _usable_ by both beginner and expert programmers.** If you make a radical change, in particular one that requires some sort of setup, try to offer an easy-to-run alternative or tutorial. See, for example, the profile populator ([`jasper-client/client/populate.py`](https://github.com/jasperproject/jasper-client/blob/master/client/populate.py)), which abstracts away the difficulty of correctly formatting and populating the user profile. - -### DOs and DON'Ts - -While developing, you **_should_**: - - -1. **Ensure that the existing unit tests pass.** They can be run via `python2 -m unittest discover` for Jasper's main folder. -2. **Test _every commit_ on a Raspberry Pi**. Testing locally (i.e., on OS X or Windows or whatnot) is insufficient, as you'll often run into semi-unpredictable issues when you port over to the Pi. You should both run the unit tests described above and do some anecdotal testing (i.e., run Jasper, trigger at least one module). -3. **Ensure that your code conforms to [PEP8](http://legacy.python.org/dev/peps/pep-0008/) and our existing code standards.** For example, we used camel case in a few places (this could be changed--send in a pull request!). In general, however, defer to PEP8. We also really like Jeff Knupp's [_Writing Idiomatic Python_](http://www.jeffknupp.com/writing-idiomatic-python-ebook/). We use `flake8` to check this, so run it from Jasper's main folder before committing. -4. Related to the above: **Include docstrings that follow our existing format!** Good documentation is a good thing. -4. **Add any new Python dependencies to requirements.txt.** Make sure that your additional dependencies are dependencies of `jasper-client` and not existing packages on your disk image! -5. **Explain _why_ your change is necessary.** What does it accomplish? Is this something that others will want as well? -6. Once your pull request has received some positive feedback: **Submit a parallel pull request to the [documentation repository](https://github.com/jasperproject/jasperproject.github.io)** to keep the docs in sync. - -On the other hand, you **_should not_**: - -1. **Commit _any_ modules to the _jasper-client_ repository.** The modules included in _jasper-client_ are meant as illustrative examples. Any new modules that you'd like to share should be done so through other means. If you'd like us to [list your module](http://jasperproject.github.io/documentation/modules/) on the web site, [submit a pull request here](https://github.com/jasperproject/jasperproject.github.io/blob/master/documentation/modules/index.md). -2. **_Not_ do any of the DOs!** - -### TODOs - -If you're looking for something to do, here are some suggestions: - -1. Improve unit-testing for `jasper-client`. The Jasper modules and `brain.py` have ample testing, but other Python modules (`conversation.py`, `mic.py`, etc.) do not. -2. Come up with a better way to automate testing on the Pi. This might include spinning up some sort of VM with [Docker](http://docs.docker.io), or take a completely different approach. -3. Buff up the text-refinement functions in [`alteration.py`](https://github.com/jasperproject/jasper-client/blob/master/client/alteration.py). These are meant to convert text to a form that will sound more human-friendly when spoken by your TTS software, but are quite minimal at the moment. -4. Make Jasper more platform-independent. Currently, Jasper is only supported on Raspberry Pi and OS X. -5. Decouple Jasper from any specific Linux audio driver implementation. diff --git a/README.md b/README.md index df5526e98..9b99f647d 100644 --- a/README.md +++ b/README.md @@ -1 +1,8 @@ -# Judy - Simplified Jasper, the voice computing platform +# Judy - Simple Voice Control on Raspberry Pi + +Judy is a simplified sister of [Jasper](http://jasperproject.github.io/), +with a focus on education. It is designed to run on: + +**Raspberry Pi 3** +**Raspbian Jessie** +**Python 2.7** diff --git a/boot/boot.py b/boot/boot.py deleted file mode 100755 index 6ec7137c0..000000000 --- a/boot/boot.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8-*- -# This file exists for backwards compatibility with older versions of jasper. -# It might be removed in future versions. -import os -import sys -import runpy -script_path = os.path.join(os.path.dirname(__file__), os.pardir, "jasper.py") -sys.path.remove(os.path.dirname(__file__)) -sys.path.insert(0, os.path.dirname(script_path)) -runpy.run_path(script_path, run_name="__main__") diff --git a/boot/boot.sh b/boot/boot.sh deleted file mode 100755 index 240a7f794..000000000 --- a/boot/boot.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -# This file exists for backwards compatibility with older versions of Jasper. -# It might be removed in future versions. -"${0%/*}/../jasper.py" diff --git a/client/__init__.py b/client/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/alteration.py b/client/alteration.py deleted file mode 100644 index 91e0ad3e3..000000000 --- a/client/alteration.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8-*- -import re - - -def detectYears(input): - YEAR_REGEX = re.compile(r'(\b)(\d\d)([1-9]\d)(\b)') - return YEAR_REGEX.sub('\g<1>\g<2> \g<3>\g<4>', input) - - -def clean(input): - """ - Manually adjust output text before it's translated into - actual speech by the TTS system. This is to fix minior - idiomatic issues, for example, that 1901 is pronounced - "one thousand, ninehundred and one" rather than - "nineteen oh one". - - Arguments: - input -- original speech text to-be modified - """ - return detectYears(input) diff --git a/client/app_utils.py b/client/app_utils.py deleted file mode 100644 index edc8467ae..000000000 --- a/client/app_utils.py +++ /dev/null @@ -1,128 +0,0 @@ -# -*- coding: utf-8-*- -import smtplib -from email.MIMEText import MIMEText -import urllib2 -import re -from pytz import timezone - - -def sendEmail(SUBJECT, BODY, TO, FROM, SENDER, PASSWORD, SMTP_SERVER): - """Sends an HTML email.""" - for body_charset in 'US-ASCII', 'ISO-8859-1', 'UTF-8': - try: - BODY.encode(body_charset) - except UnicodeError: - pass - else: - break - msg = MIMEText(BODY.encode(body_charset), 'html', body_charset) - msg['From'] = SENDER - msg['To'] = TO - msg['Subject'] = SUBJECT - - SMTP_PORT = 587 - session = smtplib.SMTP(SMTP_SERVER, SMTP_PORT) - session.starttls() - session.login(FROM, PASSWORD) - session.sendmail(SENDER, TO, msg.as_string()) - session.quit() - - -def emailUser(profile, SUBJECT="", BODY=""): - """ - sends an email. - - Arguments: - profile -- contains information related to the user (e.g., email - address) - SUBJECT -- subject line of the email - BODY -- body text of the email - """ - def generateSMSEmail(profile): - """ - Generates an email from a user's phone number based on their carrier. - """ - if profile['carrier'] is None or not profile['phone_number']: - return None - - return str(profile['phone_number']) + "@" + profile['carrier'] - - if profile['prefers_email'] and profile['gmail_address']: - # add footer - if BODY: - BODY = profile['first_name'] + \ - ",

Here are your top headlines:" + BODY - BODY += "
Sent from your Jasper" - - recipient = profile['gmail_address'] - if profile['first_name'] and profile['last_name']: - recipient = profile['first_name'] + " " + \ - profile['last_name'] + " <%s>" % recipient - else: - recipient = generateSMSEmail(profile) - - if not recipient: - return False - - try: - if 'mailgun' in profile: - user = profile['mailgun']['username'] - password = profile['mailgun']['password'] - server = 'smtp.mailgun.org' - else: - user = profile['gmail_address'] - password = profile['gmail_password'] - server = 'smtp.gmail.com' - sendEmail(SUBJECT, BODY, recipient, user, - "Jasper ", password, server) - - return True - except: - return False - - -def getTimezone(profile): - """ - Returns the pytz timezone for a given profile. - - Arguments: - profile -- contains information related to the user (e.g., email - address) - """ - try: - return timezone(profile['timezone']) - except: - return None - - -def generateTinyURL(URL): - """ - Generates a compressed URL. - - Arguments: - URL -- the original URL to-be compressed - """ - target = "http://tinyurl.com/api-create.php?url=" + URL - response = urllib2.urlopen(target) - return response.read() - - -def isNegative(phrase): - """ - Returns True if the input phrase has a negative sentiment. - - Arguments: - phrase -- the input phrase to-be evaluated - """ - return bool(re.search(r'\b(no(t)?|don\'t|stop|end)\b', phrase, - re.IGNORECASE)) - - -def isPositive(phrase): - """ - Returns True if the input phrase has a positive sentiment. - - Arguments: - phrase -- the input phrase to-be evaluated - """ - return bool(re.search(r'\b(sure|yes|yeah|go)\b', phrase, re.IGNORECASE)) diff --git a/client/brain.py b/client/brain.py deleted file mode 100644 index 64f6cb039..000000000 --- a/client/brain.py +++ /dev/null @@ -1,86 +0,0 @@ -# -*- coding: utf-8-*- -import logging -import pkgutil -import jasperpath - - -class Brain(object): - - def __init__(self, mic, profile): - """ - Instantiates a new Brain object, which cross-references user - input with a list of modules. Note that the order of brain.modules - matters, as the Brain will cease execution on the first module - that accepts a given input. - - Arguments: - mic -- used to interact with the user (for both input and output) - profile -- contains information related to the user (e.g., phone - number) - """ - - self.mic = mic - self.profile = profile - self.modules = self.get_modules() - self._logger = logging.getLogger(__name__) - - @classmethod - def get_modules(cls): - """ - Dynamically loads all the modules in the modules folder and sorts - them by the PRIORITY key. If no PRIORITY is defined for a given - module, a priority of 0 is assumed. - """ - - logger = logging.getLogger(__name__) - locations = [jasperpath.PLUGIN_PATH] - logger.debug("Looking for modules in: %s", - ', '.join(["'%s'" % location for location in locations])) - modules = [] - for finder, name, ispkg in pkgutil.walk_packages(locations): - try: - loader = finder.find_module(name) - mod = loader.load_module(name) - except: - logger.warning("Skipped module '%s' due to an error.", name, - exc_info=True) - else: - if hasattr(mod, 'WORDS'): - logger.debug("Found module '%s' with words: %r", name, - mod.WORDS) - modules.append(mod) - else: - logger.warning("Skipped module '%s' because it misses " + - "the WORDS constant.", name) - modules.sort(key=lambda mod: mod.PRIORITY if hasattr(mod, 'PRIORITY') - else 0, reverse=True) - return modules - - def query(self, texts): - """ - Passes user input to the appropriate module, testing it against - each candidate module's isValid function. - - Arguments: - text -- user input, typically speech, to be parsed by a module - """ - for module in self.modules: - for text in texts: - if module.isValid(text): - self._logger.debug("'%s' is a valid phrase for module " + - "'%s'", text, module.__name__) - try: - module.handle(text, self.mic, self.profile) - except Exception: - self._logger.error('Failed to execute module', - exc_info=True) - self.mic.say("I'm sorry. I had some trouble with " + - "that operation. Please try again later.") - else: - self._logger.debug("Handling of phrase '%s' by " + - "module '%s' completed", text, - module.__name__) - finally: - return - self._logger.debug("No module was able to handle any of these " + - "phrases: %r", texts) diff --git a/client/conversation.py b/client/conversation.py deleted file mode 100644 index 6b2fab1a0..000000000 --- a/client/conversation.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8-*- -import logging -from notifier import Notifier -from brain import Brain - - -class Conversation(object): - - def __init__(self, persona, mic, profile): - self._logger = logging.getLogger(__name__) - self.persona = persona - self.mic = mic - self.profile = profile - self.brain = Brain(mic, profile) - self.notifier = Notifier(profile) - - def handleForever(self): - """ - Delegates user input to the handling function when activated. - """ - self._logger.info("Starting to handle conversation with keyword '%s'.", - self.persona) - while True: - # Print notifications until empty - notifications = self.notifier.getAllNotifications() - for notif in notifications: - self._logger.info("Received notification: '%s'", str(notif)) - - self._logger.debug("Started listening for keyword '%s'", - self.persona) - threshold, transcribed = self.mic.passiveListen(self.persona) - self._logger.debug("Stopped listening for keyword '%s'", - self.persona) - - if not transcribed or not threshold: - self._logger.info("Nothing has been said or transcribed.") - continue - self._logger.info("Keyword '%s' has been said!", self.persona) - - self._logger.debug("Started to listen actively with threshold: %r", - threshold) - input = self.mic.activeListenToAllOptions(threshold) - self._logger.debug("Stopped to listen actively with threshold: %r", - threshold) - - if input: - self.brain.query(input) - else: - self.mic.say("Pardon?") diff --git a/client/diagnose.py b/client/diagnose.py deleted file mode 100644 index 06ed9ea2e..000000000 --- a/client/diagnose.py +++ /dev/null @@ -1,193 +0,0 @@ -# -*- coding: utf-8-*- -import os -import sys -import time -import socket -import subprocess -import pkgutil -import logging -import pip.req -import jasperpath -if sys.version_info < (3, 3): - from distutils.spawn import find_executable -else: - from shutil import which as find_executable - -logger = logging.getLogger(__name__) - - -def check_network_connection(server="www.google.com"): - """ - Checks if jasper can connect a network server. - - Arguments: - server -- (optional) the server to connect with (Default: - "www.google.com") - - Returns: - True or False - """ - logger = logging.getLogger(__name__) - logger.debug("Checking network connection to server '%s'...", server) - try: - # see if we can resolve the host name -- tells us if there is - # a DNS listening - host = socket.gethostbyname(server) - # connect to the host -- tells us if the host is actually - # reachable - socket.create_connection((host, 80), 2) - except Exception: - logger.debug("Network connection not working") - return False - else: - logger.debug("Network connection working") - return True - - -def check_executable(executable): - """ - Checks if an executable exists in $PATH. - - Arguments: - executable -- the name of the executable (e.g. "echo") - - Returns: - True or False - """ - logger = logging.getLogger(__name__) - logger.debug("Checking executable '%s'...", executable) - executable_path = find_executable(executable) - found = executable_path is not None - if found: - logger.debug("Executable '%s' found: '%s'", executable, - executable_path) - else: - logger.debug("Executable '%s' not found", executable) - return found - - -def check_python_import(package_or_module): - """ - Checks if a python package or module is importable. - - Arguments: - package_or_module -- the package or module name to check - - Returns: - True or False - """ - logger = logging.getLogger(__name__) - logger.debug("Checking python import '%s'...", package_or_module) - loader = pkgutil.get_loader(package_or_module) - found = loader is not None - if found: - logger.debug("Python %s '%s' found: %r", - "package" if loader.is_package(package_or_module) - else "module", package_or_module, loader.get_filename()) - else: - logger.debug("Python import '%s' not found", package_or_module) - return found - - -def get_pip_requirements(fname=os.path.join(jasperpath.LIB_PATH, - 'requirements.txt')): - """ - Gets the PIP requirements from a text file. If the files does not exists - or is not readable, it returns None - - Arguments: - fname -- (optional) the requirement text file (Default: - "client/requirements.txt") - - Returns: - A list of pip requirement objects or None - """ - logger = logging.getLogger(__name__) - if os.access(fname, os.R_OK): - reqs = list(pip.req.parse_requirements(fname)) - logger.debug("Found %d PIP requirements in file '%s'", len(reqs), - fname) - return reqs - else: - logger.debug("PIP requirements file '%s' not found or not readable", - fname) - - -def get_git_revision(): - """ - Gets the current git revision hash as hex string. If the git executable is - missing or git is unable to get the revision, None is returned - - Returns: - A hex string or None - """ - logger = logging.getLogger(__name__) - if not check_executable('git'): - logger.warning("'git' command not found, git revision not detectable") - return None - output = subprocess.check_output(['git', 'rev-parse', 'HEAD']).strip() - if not output: - logger.warning("Couldn't detect git revision (not a git repository?)") - return None - return output - - -def run(): - """ - Performs a series of checks against the system and writes the results to - the logging system. - - Returns: - The number of failed checks as integer - """ - logger = logging.getLogger(__name__) - - # Set loglevel of this module least to info - loglvl = logger.getEffectiveLevel() - if loglvl == logging.NOTSET or loglvl > logging.INFO: - logger.setLevel(logging.INFO) - - logger.info("Starting jasper diagnostic at %s" % time.strftime("%c")) - logger.info("Git revision: %r", get_git_revision()) - - failed_checks = 0 - - if not check_network_connection(): - failed_checks += 1 - - for executable in ['phonetisaurus-g2p', 'espeak', 'say']: - if not check_executable(executable): - logger.warning("Executable '%s' is missing in $PATH", executable) - failed_checks += 1 - - for req in get_pip_requirements(): - logger.debug("Checking PIP package '%s'...", req.name) - if not req.check_if_exists(): - logger.warning("PIP package '%s' is missing", req.name) - failed_checks += 1 - else: - logger.debug("PIP package '%s' found", req.name) - - for fname in [os.path.join(jasperpath.APP_PATH, os.pardir, "phonetisaurus", - "g014b2b.fst")]: - logger.debug("Checking file '%s'...", fname) - if not os.access(fname, os.R_OK): - logger.warning("File '%s' is missing", fname) - failed_checks += 1 - else: - logger.debug("File '%s' found", fname) - - if not failed_checks: - logger.info("All checks passed") - else: - logger.info("%d checks failed" % failed_checks) - - return failed_checks - - -if __name__ == '__main__': - logging.basicConfig(stream=sys.stdout) - logger = logging.getLogger() - if '--debug' in sys.argv: - logger.setLevel(logging.DEBUG) - run() diff --git a/client/g2p.py b/client/g2p.py deleted file mode 100644 index 7b8022636..000000000 --- a/client/g2p.py +++ /dev/null @@ -1,156 +0,0 @@ -# -*- coding: utf-8-*- -import os -import re -import subprocess -import tempfile -import logging - -import yaml - -import diagnose -import jasperpath - - -class PhonetisaurusG2P(object): - PATTERN = re.compile(r'^(?P.+)\t(?P\d+\.\d+)\t ' + - r'(?P.*) ', re.MULTILINE) - - @classmethod - def execute(cls, fst_model, input, is_file=False, nbest=None): - logger = logging.getLogger(__name__) - - cmd = ['phonetisaurus-g2p', - '--model=%s' % fst_model, - '--input=%s' % input, - '--words'] - - if is_file: - cmd.append('--isfile') - - if nbest is not None: - cmd.extend(['--nbest=%d' % nbest]) - - cmd = [str(x) for x in cmd] - try: - # FIXME: We can't just use subprocess.call and redirect stdout - # and stderr, because it looks like Phonetisaurus can't open - # an already opened file descriptor a second time. This is why - # we have to use this somehow hacky subprocess.Popen approach. - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - stdoutdata, stderrdata = proc.communicate() - except OSError: - logger.error("Error occured while executing command '%s'", - ' '.join(cmd), exc_info=True) - raise - - if stderrdata: - for line in stderrdata.splitlines(): - message = line.strip() - if message: - logger.debug(message) - - if proc.returncode != 0: - logger.error("Command '%s' return with exit status %d", - ' '.join(cmd), proc.returncode) - raise OSError("Command execution failed") - - result = {} - if stdoutdata is not None: - for word, precision, pronounc in cls.PATTERN.findall(stdoutdata): - if word not in result: - result[word] = [] - result[word].append(pronounc) - return result - - @classmethod - def get_config(cls): - # FIXME: Replace this as soon as pull request - # jasperproject/jasper-client#128 has been merged - - conf = {'fst_model': os.path.join(jasperpath.APP_PATH, os.pardir, - 'phonetisaurus', 'g014b2b.fst')} - # Try to get fst_model from config - profile_path = jasperpath.config('profile.yml') - if os.path.exists(profile_path): - with open(profile_path, 'r') as f: - profile = yaml.safe_load(f) - if 'pocketsphinx' in profile: - if 'fst_model' in profile['pocketsphinx']: - conf['fst_model'] = \ - profile['pocketsphinx']['fst_model'] - if 'nbest' in profile['pocketsphinx']: - conf['nbest'] = int(profile['pocketsphinx']['nbest']) - return conf - - def __new__(cls, fst_model=None, *args, **kwargs): - if not diagnose.check_executable('phonetisaurus-g2p'): - raise OSError("Can't find command 'phonetisaurus-g2p'! Please " + - "check if Phonetisaurus is installed and in your " + - "$PATH.") - if fst_model is None or not os.access(fst_model, os.R_OK): - raise OSError(("FST model '%r' does not exist! Can't create " + - "instance.") % fst_model) - inst = object.__new__(cls, fst_model, *args, **kwargs) - return inst - - def __init__(self, fst_model=None, nbest=None): - self._logger = logging.getLogger(__name__) - - self.fst_model = os.path.abspath(fst_model) - self._logger.debug("Using FST model: '%s'", self.fst_model) - - self.nbest = nbest - if self.nbest is not None: - self._logger.debug("Will use the %d best results.", self.nbest) - - def _translate_word(self, word): - return self.execute(self.fst_model, word, nbest=self.nbest) - - def _translate_words(self, words): - with tempfile.NamedTemporaryFile(suffix='.g2p', delete=False) as f: - # The 'delete=False' kwarg is kind of a hack, but Phonetisaurus - # won't work if we remove it, because it seems that I can't open - # a file descriptor a second time. - for word in words: - f.write("%s\n" % word) - tmp_fname = f.name - output = self.execute(self.fst_model, tmp_fname, is_file=True, - nbest=self.nbest) - os.remove(tmp_fname) - return output - - def translate(self, words): - if type(words) is str or len(words) == 1: - self._logger.debug('Converting single word to phonemes') - output = self._translate_word(words if type(words) is str - else words[0]) - else: - self._logger.debug('Converting %d words to phonemes', len(words)) - output = self._translate_words(words) - self._logger.debug('G2P conversion returned phonemes for %d words', - len(output)) - return output - -if __name__ == "__main__": - import pprint - import argparse - parser = argparse.ArgumentParser(description='Phonetisaurus G2P module') - parser.add_argument('fst_model', action='store', - help='Path to the FST Model') - parser.add_argument('--debug', action='store_true', - help='Show debug messages') - args = parser.parse_args() - - logging.basicConfig() - logger = logging.getLogger() - if args.debug: - logger.setLevel(logging.DEBUG) - - words = ['THIS', 'IS', 'A', 'TEST'] - - g2pconv = PhonetisaurusG2P(args.fst_model, nbest=3) - output = g2pconv.translate(words) - - pp = pprint.PrettyPrinter(indent=2) - pp.pprint(output) diff --git a/client/jasperpath.py b/client/jasperpath.py deleted file mode 100644 index 787e2e0ae..000000000 --- a/client/jasperpath.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8-*- -import os - -# Jasper main directory -APP_PATH = os.path.normpath(os.path.join( - os.path.dirname(os.path.abspath(__file__)), os.pardir)) - -DATA_PATH = os.path.join(APP_PATH, "static") -LIB_PATH = os.path.join(APP_PATH, "client") -PLUGIN_PATH = os.path.join(LIB_PATH, "modules") - -CONFIG_PATH = os.path.expanduser(os.getenv('JASPER_CONFIG', '~/.jasper')) - - -def config(*fname): - return os.path.join(CONFIG_PATH, *fname) - - -def data(*fname): - return os.path.join(DATA_PATH, *fname) diff --git a/client/local_mic.py b/client/local_mic.py deleted file mode 100644 index 5c0fed294..000000000 --- a/client/local_mic.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- coding: utf-8-*- -""" -A drop-in replacement for the Mic class that allows for all I/O to occur -over the terminal. Useful for debugging. Unlike with the typical Mic -implementation, Jasper is always active listening with local_mic. -""" - - -class Mic: - prev = None - - def __init__(self, speaker, passive_stt_engine, active_stt_engine): - return - - def passiveListen(self, PERSONA): - return True, "JASPER" - - def activeListenToAllOptions(self, THRESHOLD=None, LISTEN=True, - MUSIC=False): - return [self.activeListen(THRESHOLD=THRESHOLD, LISTEN=LISTEN, - MUSIC=MUSIC)] - - def activeListen(self, THRESHOLD=None, LISTEN=True, MUSIC=False): - if not LISTEN: - return self.prev - - input = raw_input("YOU: ") - self.prev = input - return input - - def say(self, phrase, OPTIONS=None): - print("JASPER: %s" % phrase) diff --git a/client/main.py b/client/main.py deleted file mode 100755 index 629d75f21..000000000 --- a/client/main.py +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8-*- -# This file exists for backwards compatibility with older versions of jasper. -# It might be removed in future versions. -import os -import sys -import runpy -script_path = os.path.join(os.path.dirname(__file__), os.pardir, "jasper.py") -sys.path.insert(0, os.path.dirname(script_path)) -runpy.run_path(script_path, run_name="__main__") diff --git a/client/mic.py b/client/mic.py deleted file mode 100644 index 401cddbd6..000000000 --- a/client/mic.py +++ /dev/null @@ -1,262 +0,0 @@ -# -*- coding: utf-8-*- -""" - The Mic class handles all interactions with the microphone and speaker. -""" -import logging -import tempfile -import wave -import audioop -import pyaudio -import alteration -import jasperpath - - -class Mic: - - speechRec = None - speechRec_persona = None - - def __init__(self, speaker, passive_stt_engine, active_stt_engine): - """ - Initiates the pocketsphinx instance. - - Arguments: - speaker -- handles platform-independent audio output - passive_stt_engine -- performs STT while Jasper is in passive listen - mode - acive_stt_engine -- performs STT while Jasper is in active listen mode - """ - self._logger = logging.getLogger(__name__) - self.speaker = speaker - self.passive_stt_engine = passive_stt_engine - self.active_stt_engine = active_stt_engine - self._logger.info("Initializing PyAudio. ALSA/Jack error messages " + - "that pop up during this process are normal and " + - "can usually be safely ignored.") - self._audio = pyaudio.PyAudio() - self._logger.info("Initialization of PyAudio completed.") - - def __del__(self): - self._audio.terminate() - - def getScore(self, data): - rms = audioop.rms(data, 2) - score = rms / 3 - return score - - def fetchThreshold(self): - - # TODO: Consolidate variables from the next three functions - THRESHOLD_MULTIPLIER = 1.8 - RATE = 16000 - CHUNK = 1024 - - # number of seconds to allow to establish threshold - THRESHOLD_TIME = 1 - - # prepare recording stream - stream = self._audio.open(format=pyaudio.paInt16, - channels=1, - rate=RATE, - input=True, - frames_per_buffer=CHUNK) - - # stores the audio data - frames = [] - - # stores the lastN score values - lastN = [i for i in range(20)] - - # calculate the long run average, and thereby the proper threshold - for i in range(0, RATE / CHUNK * THRESHOLD_TIME): - - data = stream.read(CHUNK) - frames.append(data) - - # save this data point as a score - lastN.pop(0) - lastN.append(self.getScore(data)) - average = sum(lastN) / len(lastN) - - stream.stop_stream() - stream.close() - - # this will be the benchmark to cause a disturbance over! - THRESHOLD = average * THRESHOLD_MULTIPLIER - - return THRESHOLD - - def passiveListen(self, PERSONA): - """ - Listens for PERSONA in everyday sound. Times out after LISTEN_TIME, so - needs to be restarted. - """ - - THRESHOLD_MULTIPLIER = 1.8 - RATE = 16000 - CHUNK = 1024 - - # number of seconds to allow to establish threshold - THRESHOLD_TIME = 1 - - # number of seconds to listen before forcing restart - LISTEN_TIME = 10 - - # prepare recording stream - stream = self._audio.open(format=pyaudio.paInt16, - channels=1, - rate=RATE, - input=True, - frames_per_buffer=CHUNK) - - # stores the audio data - frames = [] - - # stores the lastN score values - lastN = [i for i in range(30)] - - # calculate the long run average, and thereby the proper threshold - for i in range(0, RATE / CHUNK * THRESHOLD_TIME): - - data = stream.read(CHUNK) - frames.append(data) - - # save this data point as a score - lastN.pop(0) - lastN.append(self.getScore(data)) - average = sum(lastN) / len(lastN) - - # this will be the benchmark to cause a disturbance over! - THRESHOLD = average * THRESHOLD_MULTIPLIER - - # save some memory for sound data - frames = [] - - # flag raised when sound disturbance detected - didDetect = False - - # start passively listening for disturbance above threshold - for i in range(0, RATE / CHUNK * LISTEN_TIME): - - data = stream.read(CHUNK) - frames.append(data) - score = self.getScore(data) - - if score > THRESHOLD: - didDetect = True - break - - # no use continuing if no flag raised - if not didDetect: - print "No disturbance detected" - stream.stop_stream() - stream.close() - return (None, None) - - # cutoff any recording before this disturbance was detected - frames = frames[-20:] - - # otherwise, let's keep recording for few seconds and save the file - DELAY_MULTIPLIER = 1 - for i in range(0, RATE / CHUNK * DELAY_MULTIPLIER): - - data = stream.read(CHUNK) - frames.append(data) - - # save the audio data - stream.stop_stream() - stream.close() - - with tempfile.NamedTemporaryFile(mode='w+b') as f: - wav_fp = wave.open(f, 'wb') - wav_fp.setnchannels(1) - wav_fp.setsampwidth(pyaudio.get_sample_size(pyaudio.paInt16)) - wav_fp.setframerate(RATE) - wav_fp.writeframes(''.join(frames)) - wav_fp.close() - f.seek(0) - # check if PERSONA was said - transcribed = self.passive_stt_engine.transcribe(f) - - if any(PERSONA in phrase for phrase in transcribed): - return (THRESHOLD, PERSONA) - - return (False, transcribed) - - def activeListen(self, THRESHOLD=None, LISTEN=True, MUSIC=False): - """ - Records until a second of silence or times out after 12 seconds - - Returns the first matching string or None - """ - - options = self.activeListenToAllOptions(THRESHOLD, LISTEN, MUSIC) - if options: - return options[0] - - def activeListenToAllOptions(self, THRESHOLD=None, LISTEN=True, - MUSIC=False): - """ - Records until a second of silence or times out after 12 seconds - - Returns a list of the matching options or None - """ - - RATE = 16000 - CHUNK = 1024 - LISTEN_TIME = 12 - - # check if no threshold provided - if THRESHOLD is None: - THRESHOLD = self.fetchThreshold() - - self.speaker.play(jasperpath.data('audio', 'beep_hi.wav')) - - # prepare recording stream - stream = self._audio.open(format=pyaudio.paInt16, - channels=1, - rate=RATE, - input=True, - frames_per_buffer=CHUNK) - - frames = [] - # increasing the range # results in longer pause after command - # generation - lastN = [THRESHOLD * 1.2 for i in range(30)] - - for i in range(0, RATE / CHUNK * LISTEN_TIME): - - data = stream.read(CHUNK) - frames.append(data) - score = self.getScore(data) - - lastN.pop(0) - lastN.append(score) - - average = sum(lastN) / float(len(lastN)) - - # TODO: 0.8 should not be a MAGIC NUMBER! - if average < THRESHOLD * 0.8: - break - - self.speaker.play(jasperpath.data('audio', 'beep_lo.wav')) - - # save the audio data - stream.stop_stream() - stream.close() - - with tempfile.SpooledTemporaryFile(mode='w+b') as f: - wav_fp = wave.open(f, 'wb') - wav_fp.setnchannels(1) - wav_fp.setsampwidth(pyaudio.get_sample_size(pyaudio.paInt16)) - wav_fp.setframerate(RATE) - wav_fp.writeframes(''.join(frames)) - wav_fp.close() - f.seek(0) - return self.active_stt_engine.transcribe(f) - - def say(self, phrase, - OPTIONS=" -vdefault+m3 -p 40 -s 160 --stdout > say.wav"): - # alter phrase before speaking - phrase = alteration.clean(phrase) - self.speaker.say(phrase) diff --git a/client/modules/Birthday.py b/client/modules/Birthday.py deleted file mode 100644 index c012237c3..000000000 --- a/client/modules/Birthday.py +++ /dev/null @@ -1,67 +0,0 @@ -# -*- coding: utf-8-*- -import datetime -import re -import facebook -from client.app_utils import getTimezone - -WORDS = ["BIRTHDAY"] - - -def handle(text, mic, profile): - """ - Responds to user-input, typically speech text, by listing the user's - Facebook friends with birthdays today. - - Arguments: - text -- user-input, typically transcribed speech - mic -- used to interact with the user (for both input and output) - profile -- contains information related to the user (e.g., phone - number) - """ - oauth_access_token = profile['keys']["FB_TOKEN"] - - graph = facebook.GraphAPI(oauth_access_token) - - try: - results = graph.request("me/friends", - args={'fields': 'id,name,birthday'}) - except facebook.GraphAPIError: - mic.say("I have not been authorized to query your Facebook. If you " + - "would like to check birthdays in the future, please visit " + - "the Jasper dashboard.") - return - except: - mic.say( - "I apologize, there's a problem with that service at the moment.") - return - - needle = datetime.datetime.now(tz=getTimezone(profile)).strftime("%m/%d") - - people = [] - for person in results['data']: - try: - if needle in person['birthday']: - people.append(person['name']) - except: - continue - - if len(people) > 0: - if len(people) == 1: - output = people[0] + " has a birthday today." - else: - output = "Your friends with birthdays today are " + \ - ", ".join(people[:-1]) + " and " + people[-1] + "." - else: - output = "None of your friends have birthdays today." - - mic.say(output) - - -def isValid(text): - """ - Returns True if the input is related to birthdays. - - Arguments: - text -- user-input, typically transcribed speech - """ - return bool(re.search(r'birthday', text, re.IGNORECASE)) diff --git a/client/modules/Gmail.py b/client/modules/Gmail.py deleted file mode 100644 index 83ed3bda9..000000000 --- a/client/modules/Gmail.py +++ /dev/null @@ -1,138 +0,0 @@ -# -*- coding: utf-8-*- -import imaplib -import email -import re -from dateutil import parser - -WORDS = ["EMAIL", "INBOX"] - - -def getSender(email): - """ - Returns the best-guess sender of an email. - - Arguments: - email -- the email whose sender is desired - - Returns: - Sender of the email. - """ - sender = email['From'] - m = re.match(r'(.*)\s<.*>', sender) - if m: - return m.group(1) - return sender - - -def getDate(email): - return parser.parse(email.get('date')) - - -def getMostRecentDate(emails): - """ - Returns the most recent date of any email in the list provided. - - Arguments: - emails -- a list of emails to check - - Returns: - Date of the most recent email. - """ - dates = [getDate(e) for e in emails] - dates.sort(reverse=True) - if dates: - return dates[0] - return None - - -def fetchUnreadEmails(profile, since=None, markRead=False, limit=None): - """ - Fetches a list of unread email objects from a user's Gmail inbox. - - Arguments: - profile -- contains information related to the user (e.g., Gmail - address) - since -- if provided, no emails before this date will be returned - markRead -- if True, marks all returned emails as read in target inbox - - Returns: - A list of unread email objects. - """ - conn = imaplib.IMAP4_SSL('imap.gmail.com') - conn.debug = 0 - conn.login(profile['gmail_address'], profile['gmail_password']) - conn.select(readonly=(not markRead)) - - msgs = [] - (retcode, messages) = conn.search(None, '(UNSEEN)') - - if retcode == 'OK' and messages != ['']: - numUnread = len(messages[0].split(' ')) - if limit and numUnread > limit: - return numUnread - - for num in messages[0].split(' '): - # parse email RFC822 format - ret, data = conn.fetch(num, '(RFC822)') - msg = email.message_from_string(data[0][1]) - - if not since or getDate(msg) > since: - msgs.append(msg) - conn.close() - conn.logout() - - return msgs - - -def handle(text, mic, profile): - """ - Responds to user-input, typically speech text, with a summary of - the user's Gmail inbox, reporting on the number of unread emails - in the inbox, as well as their senders. - - Arguments: - text -- user-input, typically transcribed speech - mic -- used to interact with the user (for both input and output) - profile -- contains information related to the user (e.g., Gmail - address) - """ - try: - msgs = fetchUnreadEmails(profile, limit=5) - - if isinstance(msgs, int): - response = "You have %d unread emails." % msgs - mic.say(response) - return - - senders = [getSender(e) for e in msgs] - except imaplib.IMAP4.error: - mic.say( - "I'm sorry. I'm not authenticated to work with your Gmail.") - return - - if not senders: - mic.say("You have no unread emails.") - elif len(senders) == 1: - mic.say("You have one unread email from " + senders[0] + ".") - else: - response = "You have %d unread emails" % len( - senders) - unique_senders = list(set(senders)) - if len(unique_senders) > 1: - unique_senders[-1] = 'and ' + unique_senders[-1] - response += ". Senders include: " - response += '...'.join(senders) - else: - response += " from " + unique_senders[0] - - mic.say(response) - - -def isValid(text): - """ - Returns True if the input is related to email. - - Arguments: - text -- user-input, typically transcribed speech - """ - return bool(re.search(r'\bemail\b', text, re.IGNORECASE)) diff --git a/client/modules/HN.py b/client/modules/HN.py deleted file mode 100644 index df74b1789..000000000 --- a/client/modules/HN.py +++ /dev/null @@ -1,139 +0,0 @@ -# -*- coding: utf-8-*- -import urllib2 -import re -import random -from bs4 import BeautifulSoup -from client import app_utils -from semantic.numbers import NumberService - -WORDS = ["HACKER", "NEWS", "YES", "NO", "FIRST", "SECOND", "THIRD"] - -PRIORITY = 4 - -URL = 'http://news.ycombinator.com' - - -class HNStory: - - def __init__(self, title, URL): - self.title = title - self.URL = URL - - -def getTopStories(maxResults=None): - """ - Returns the top headlines from Hacker News. - - Arguments: - maxResults -- if provided, returns a random sample of size maxResults - """ - hdr = {'User-Agent': 'Mozilla/5.0'} - req = urllib2.Request(URL, headers=hdr) - page = urllib2.urlopen(req).read() - soup = BeautifulSoup(page) - matches = soup.findAll('td', class_="title") - matches = [m.a for m in matches if m.a and m.text != u'More'] - matches = [HNStory(m.text, m['href']) for m in matches] - - if maxResults: - num_stories = min(maxResults, len(matches)) - return random.sample(matches, num_stories) - - return matches - - -def handle(text, mic, profile): - """ - Responds to user-input, typically speech text, with a sample of - Hacker News's top headlines, sending them to the user over email - if desired. - - Arguments: - text -- user-input, typically transcribed speech - mic -- used to interact with the user (for both input and output) - profile -- contains information related to the user (e.g., phone - number) - """ - mic.say("Pulling up some stories.") - stories = getTopStories(maxResults=3) - all_titles = '... '.join(str(idx + 1) + ") " + - story.title for idx, story in enumerate(stories)) - - def handleResponse(text): - - def extractOrdinals(text): - output = [] - service = NumberService() - for w in text.split(): - if w in service.__ordinals__: - output.append(service.__ordinals__[w]) - return [service.parse(w) for w in output] - - chosen_articles = extractOrdinals(text) - send_all = not chosen_articles and app_utils.isPositive(text) - - if send_all or chosen_articles: - mic.say("Sure, just give me a moment") - - if profile['prefers_email']: - body = "
    " - - def formatArticle(article): - tiny_url = app_utils.generateTinyURL(article.URL) - - if profile['prefers_email']: - return "
  • %s
  • " % (tiny_url, - article.title) - else: - return article.title + " -- " + tiny_url - - for idx, article in enumerate(stories): - if send_all or (idx + 1) in chosen_articles: - article_link = formatArticle(article) - - if profile['prefers_email']: - body += article_link - else: - if not app_utils.emailUser(profile, SUBJECT="", - BODY=article_link): - mic.say("I'm having trouble sending you these " + - "articles. Please make sure that your " + - "phone number and carrier are correct " + - "on the dashboard.") - return - - # if prefers email, we send once, at the end - if profile['prefers_email']: - body += "
" - if not app_utils.emailUser(profile, - SUBJECT="From the Front Page of " + - "Hacker News", - BODY=body): - mic.say("I'm having trouble sending you these articles. " + - "Please make sure that your phone number and " + - "carrier are correct on the dashboard.") - return - - mic.say("All done.") - - else: - mic.say("OK I will not send any articles") - - if not profile['prefers_email'] and profile['phone_number']: - mic.say("Here are some front-page articles. " + - all_titles + ". Would you like me to send you these? " + - "If so, which?") - handleResponse(mic.activeListen()) - - else: - mic.say("Here are some front-page articles. " + all_titles) - - -def isValid(text): - """ - Returns True if the input is related to Hacker News. - - Arguments: - text -- user-input, typically transcribed speech - """ - return bool(re.search(r'\b(hack(er)?|HN)\b', text, re.IGNORECASE)) diff --git a/client/modules/Joke.py b/client/modules/Joke.py deleted file mode 100644 index a260e5a0d..000000000 --- a/client/modules/Joke.py +++ /dev/null @@ -1,66 +0,0 @@ -# -*- coding: utf-8-*- -import random -import re -from client import jasperpath - -WORDS = ["JOKE", "KNOCK KNOCK"] - - -def getRandomJoke(filename=jasperpath.data('text', 'JOKES.txt')): - jokeFile = open(filename, "r") - jokes = [] - start = "" - end = "" - for line in jokeFile.readlines(): - line = line.replace("\n", "") - - if start == "": - start = line - continue - - if end == "": - end = line - continue - - jokes.append((start, end)) - start = "" - end = "" - - jokes.append((start, end)) - joke = random.choice(jokes) - return joke - - -def handle(text, mic, profile): - """ - Responds to user-input, typically speech text, by telling a joke. - - Arguments: - text -- user-input, typically transcribed speech - mic -- used to interact with the user (for both input and output) - profile -- contains information related to the user (e.g., phone - number) - """ - joke = getRandomJoke() - - mic.say("Knock knock") - - def firstLine(text): - mic.say(joke[0]) - - def punchLine(text): - mic.say(joke[1]) - - punchLine(mic.activeListen()) - - firstLine(mic.activeListen()) - - -def isValid(text): - """ - Returns True if the input is related to jokes/humor. - - Arguments: - text -- user-input, typically transcribed speech - """ - return bool(re.search(r'\bjoke\b', text, re.IGNORECASE)) diff --git a/client/modules/Life.py b/client/modules/Life.py deleted file mode 100644 index 658e5cfb5..000000000 --- a/client/modules/Life.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8-*- -import random -import re - -WORDS = ["MEANING", "OF", "LIFE"] - - -def handle(text, mic, profile): - """ - Responds to user-input, typically speech text, by relaying the - meaning of life. - - Arguments: - text -- user-input, typically transcribed speech - mic -- used to interact with the user (for both input and output) - profile -- contains information related to the user (e.g., phone - number) - """ - messages = ["It's 42, you idiot.", - "It's 42. How many times do I have to tell you?"] - - message = random.choice(messages) - - mic.say(message) - - -def isValid(text): - """ - Returns True if the input is related to the meaning of life. - - Arguments: - text -- user-input, typically transcribed speech - """ - return bool(re.search(r'\bmeaning of life\b', text, re.IGNORECASE)) diff --git a/client/modules/MPDControl.py b/client/modules/MPDControl.py deleted file mode 100644 index 54f479cf8..000000000 --- a/client/modules/MPDControl.py +++ /dev/null @@ -1,413 +0,0 @@ -# -*- coding: utf-8-*- -import re -import logging -import difflib -import mpd -from client.mic import Mic - -# Standard module stuff -WORDS = ["MUSIC", "SPOTIFY"] - - -def handle(text, mic, profile): - """ - Responds to user-input, typically speech text, by telling a joke. - - Arguments: - text -- user-input, typically transcribed speech - mic -- used to interact with the user (for both input and output) - profile -- contains information related to the user (e.g., phone - number) - """ - logger = logging.getLogger(__name__) - - kwargs = {} - if 'mpdclient' in profile: - if 'server' in profile['mpdclient']: - kwargs['server'] = profile['mpdclient']['server'] - if 'port' in profile['mpdclient']: - kwargs['port'] = int(profile['mpdclient']['port']) - - logger.debug("Preparing to start music module") - try: - mpdwrapper = MPDWrapper(**kwargs) - except: - logger.error("Couldn't connect to MPD server", exc_info=True) - mic.say("I'm sorry. It seems that Spotify is not enabled. Please " + - "read the documentation to learn how to configure Spotify.") - return - - mic.say("Please give me a moment, I'm loading your Spotify playlists.") - - # FIXME: Make this configurable - persona = 'JASPER' - - logger.debug("Starting music mode") - music_mode = MusicMode(persona, mic, mpdwrapper) - music_mode.handleForever() - logger.debug("Exiting music mode") - - return - - -def isValid(text): - """ - Returns True if the input is related to jokes/humor. - - Arguments: - text -- user-input, typically transcribed speech - """ - return any(word in text.upper() for word in WORDS) - - -# The interesting part -class MusicMode(object): - - def __init__(self, PERSONA, mic, mpdwrapper): - self._logger = logging.getLogger(__name__) - self.persona = PERSONA - # self.mic - we're actually going to ignore the mic they passed in - self.music = mpdwrapper - - # index spotify playlists into new dictionary and language models - phrases = ["STOP", "CLOSE", "PLAY", "PAUSE", "NEXT", "PREVIOUS", - "LOUDER", "SOFTER", "LOWER", "HIGHER", "VOLUME", - "PLAYLIST"] - phrases.extend(self.music.get_soup_playlist()) - - music_stt_engine = mic.active_stt_engine.get_instance('music', phrases) - - self.mic = Mic(mic.speaker, - mic.passive_stt_engine, - music_stt_engine) - - def delegateInput(self, input): - - command = input.upper() - - # check if input is meant to start the music module - if "PLAYLIST" in command: - command = command.replace("PLAYLIST", "") - elif "STOP" in command: - self.mic.say("Stopping music") - self.music.stop() - return - elif "PLAY" in command: - self.mic.say("Playing %s" % self.music.current_song()) - self.music.play() - return - elif "PAUSE" in command: - self.mic.say("Pausing music") - # not pause because would need a way to keep track of pause/play - # state - self.music.stop() - return - elif any(ext in command for ext in ["LOUDER", "HIGHER"]): - self.mic.say("Louder") - self.music.volume(interval=10) - self.music.play() - return - elif any(ext in command for ext in ["SOFTER", "LOWER"]): - self.mic.say("Softer") - self.music.volume(interval=-10) - self.music.play() - return - elif "NEXT" in command: - self.mic.say("Next song") - self.music.play() # backwards necessary to get mopidy to work - self.music.next() - self.mic.say("Playing %s" % self.music.current_song()) - return - elif "PREVIOUS" in command: - self.mic.say("Previous song") - self.music.play() # backwards necessary to get mopidy to work - self.music.previous() - self.mic.say("Playing %s" % self.music.current_song()) - return - - # SONG SELECTION... requires long-loading dictionary and language model - # songs = self.music.fuzzy_songs(query = command.replace("PLAY", "")) - # if songs: - # self.mic.say("Found songs") - # self.music.play(songs = songs) - - # print("SONG RESULTS") - # print("============") - # for song in songs: - # print("Song: %s Artist: %s" % (song.title, song.artist)) - - # self.mic.say("Playing %s" % self.music.current_song()) - - # else: - # self.mic.say("No songs found. Resuming current song.") - # self.music.play() - - # PLAYLIST SELECTION - playlists = self.music.fuzzy_playlists(query=command) - if playlists: - self.mic.say("Loading playlist %s" % playlists[0]) - self.music.play(playlist_name=playlists[0]) - self.mic.say("Playing %s" % self.music.current_song()) - else: - self.mic.say("No playlists found. Resuming current song.") - self.music.play() - - return - - def handleForever(self): - - self.music.play() - self.mic.say("Playing %s" % self.music.current_song()) - - while True: - - threshold, transcribed = self.mic.passiveListen(self.persona) - - if not transcribed or not threshold: - self._logger.info("Nothing has been said or transcribed.") - continue - - self.music.pause() - - input = self.mic.activeListen(MUSIC=True) - - if input: - if "close" in input.lower(): - self.mic.say("Closing Spotify") - return - self.delegateInput(input) - else: - self.mic.say("Pardon?") - self.music.play() - - -def reconnect(func, *default_args, **default_kwargs): - """ - Reconnects before running - """ - - def wrap(self, *default_args, **default_kwargs): - try: - self.client.connect(self.server, self.port) - except: - pass - - # sometimes not enough to just connect - try: - return func(self, *default_args, **default_kwargs) - except: - self.client = mpd.MPDClient() - self.client.timeout = None - self.client.idletimeout = None - self.client.connect(self.server, self.port) - - return func(self, *default_args, **default_kwargs) - - return wrap - - -class Song(object): - def __init__(self, id, title, artist, album): - - self.id = id - self.title = title - self.artist = artist - self.album = album - - -class MPDWrapper(object): - def __init__(self, server="localhost", port=6600): - """ - Prepare the client and music variables - """ - self.server = server - self.port = port - - # prepare client - self.client = mpd.MPDClient() - self.client.timeout = None - self.client.idletimeout = None - self.client.connect(self.server, self.port) - - # gather playlists - self.playlists = [x["playlist"] for x in self.client.listplaylists()] - - # gather songs - self.client.clear() - for playlist in self.playlists: - self.client.load(playlist) - - self.songs = [] # may have duplicates - # capitalized strings - self.song_titles = [] - self.song_artists = [] - - soup = self.client.playlist() - for i in range(0, len(soup) / 10): - index = i * 10 - id = soup[index].strip() - title = soup[index + 3].strip().upper() - artist = soup[index + 2].strip().upper() - album = soup[index + 4].strip().upper() - - self.songs.append(Song(id, title, artist, album)) - - self.song_titles.append(title) - self.song_artists.append(artist) - - @reconnect - def play(self, songs=False, playlist_name=False): - """ - Plays the current song or accepts a song to play. - - Arguments: - songs -- a list of song objects - playlist_name -- user-defined, something like "Love Song Playlist" - """ - if songs: - self.client.clear() - for song in songs: - try: # for some reason, certain ids don't work - self.client.add(song.id) - except: - pass - - if playlist_name: - self.client.clear() - self.client.load(playlist_name) - - self.client.play() - - @reconnect - def current_song(self): - item = self.client.playlistinfo(int(self.client.status()["song"]))[0] - result = "%s by %s" % (item["title"], item["artist"]) - return result - - @reconnect - def volume(self, level=None, interval=None): - - if level: - self.client.setvol(int(level)) - return - - if interval: - level = int(self.client.status()['volume']) + int(interval) - self.client.setvol(int(level)) - return - - @reconnect - def pause(self): - self.client.pause() - - @reconnect - def stop(self): - self.client.stop() - - @reconnect - def next(self): - self.client.next() - return - - @reconnect - def previous(self): - self.client.previous() - return - - def get_soup(self): - """ - Returns the list of unique words that comprise song and artist titles - """ - - soup = [] - - for song in self.songs: - song_words = song.title.split(" ") - artist_words = song.artist.split(" ") - soup.extend(song_words) - soup.extend(artist_words) - - title_trans = ''.join(chr(c) if chr(c).isupper() or chr(c).islower() - else '_' for c in range(256)) - soup = [x.decode('utf-8').encode("ascii", "ignore").upper().translate( - title_trans).replace("_", "") for x in soup] - soup = [x for x in soup if x != ""] - - return list(set(soup)) - - def get_soup_playlist(self): - """ - Returns the list of unique words that comprise playlist names - """ - - soup = [] - - for name in self.playlists: - soup.extend(name.split(" ")) - - title_trans = ''.join(chr(c) if chr(c).isupper() or chr(c).islower() - else '_' for c in range(256)) - soup = [x.decode('utf-8').encode("ascii", "ignore").upper().translate( - title_trans).replace("_", "") for x in soup] - soup = [x for x in soup if x != ""] - - return list(set(soup)) - - def get_soup_separated(self): - """ - Returns the list of PHRASES that comprise song and artist titles - """ - - title_soup = [song.title for song in self.songs] - artist_soup = [song.artist for song in self.songs] - - soup = list(set(title_soup + artist_soup)) - - title_trans = ''.join(chr(c) if chr(c).isupper() or chr(c).islower() - else '_' for c in range(256)) - soup = [x.decode('utf-8').encode("ascii", "ignore").upper().translate( - title_trans).replace("_", " ") for x in soup] - soup = [re.sub(' +', ' ', x) for x in soup if x != ""] - - return soup - - def fuzzy_songs(self, query): - """ - Returns songs matching a query best as possible on either artist - field, etc - """ - - query = query.upper() - - matched_song_titles = difflib.get_close_matches(query, - self.song_titles) - matched_song_artists = difflib.get_close_matches(query, - self.song_artists) - - # if query is beautifully matched, then forget about everything else - strict_priority_title = [x for x in matched_song_titles if x == query] - strict_priority_artists = [ - x for x in matched_song_artists if x == query] - - if strict_priority_title: - matched_song_titles = strict_priority_title - if strict_priority_artists: - matched_song_artists = strict_priority_artists - - matched_songs_bytitle = [ - song for song in self.songs if song.title in matched_song_titles] - matched_songs_byartist = [ - song for song in self.songs if song.artist in matched_song_artists] - - matches = list(set(matched_songs_bytitle + matched_songs_byartist)) - - return matches - - def fuzzy_playlists(self, query): - """ - returns playlist names that match query best as possible - """ - query = query.upper() - lookup = {n.upper(): n for n in self.playlists} - results = [lookup[r] for r in difflib.get_close_matches(query, lookup)] - return results diff --git a/client/modules/News.py b/client/modules/News.py deleted file mode 100644 index f02c71e49..000000000 --- a/client/modules/News.py +++ /dev/null @@ -1,131 +0,0 @@ -# -*- coding: utf-8-*- -import feedparser -from client import app_utils -import re -from semantic.numbers import NumberService - -WORDS = ["NEWS", "YES", "NO", "FIRST", "SECOND", "THIRD"] - -PRIORITY = 3 - -URL = 'http://news.ycombinator.com' - - -class Article: - - def __init__(self, title, URL): - self.title = title - self.URL = URL - - -def getTopArticles(maxResults=None): - d = feedparser.parse("http://news.google.com/?output=rss") - - count = 0 - articles = [] - for item in d['items']: - articles.append(Article(item['title'], item['link'].split("&url=")[1])) - count += 1 - if maxResults and count > maxResults: - break - - return articles - - -def handle(text, mic, profile): - """ - Responds to user-input, typically speech text, with a summary of - the day's top news headlines, sending them to the user over email - if desired. - - Arguments: - text -- user-input, typically transcribed speech - mic -- used to interact with the user (for both input and output) - profile -- contains information related to the user (e.g., phone - number) - """ - mic.say("Pulling up the news") - articles = getTopArticles(maxResults=3) - titles = [" ".join(x.title.split(" - ")[:-1]) for x in articles] - all_titles = "... ".join(str(idx + 1) + ")" + - title for idx, title in enumerate(titles)) - - def handleResponse(text): - - def extractOrdinals(text): - output = [] - service = NumberService() - for w in text.split(): - if w in service.__ordinals__: - output.append(service.__ordinals__[w]) - return [service.parse(w) for w in output] - - chosen_articles = extractOrdinals(text) - send_all = not chosen_articles and app_utils.isPositive(text) - - if send_all or chosen_articles: - mic.say("Sure, just give me a moment") - - if profile['prefers_email']: - body = "
    " - - def formatArticle(article): - tiny_url = app_utils.generateTinyURL(article.URL) - - if profile['prefers_email']: - return "
  • %s
  • " % (tiny_url, - article.title) - else: - return article.title + " -- " + tiny_url - - for idx, article in enumerate(articles): - if send_all or (idx + 1) in chosen_articles: - article_link = formatArticle(article) - - if profile['prefers_email']: - body += article_link - else: - if not app_utils.emailUser(profile, SUBJECT="", - BODY=article_link): - mic.say("I'm having trouble sending you these " + - "articles. Please make sure that your " + - "phone number and carrier are correct " + - "on the dashboard.") - return - - # if prefers email, we send once, at the end - if profile['prefers_email']: - body += "
" - if not app_utils.emailUser(profile, - SUBJECT="Your Top Headlines", - BODY=body): - mic.say("I'm having trouble sending you these articles. " + - "Please make sure that your phone number and " + - "carrier are correct on the dashboard.") - return - - mic.say("All set") - - else: - - mic.say("OK I will not send any articles") - - if 'phone_number' in profile: - mic.say("Here are the current top headlines. " + all_titles + - ". Would you like me to send you these articles? " + - "If so, which?") - handleResponse(mic.activeListen()) - - else: - mic.say( - "Here are the current top headlines. " + all_titles) - - -def isValid(text): - """ - Returns True if the input is related to the news. - - Arguments: - text -- user-input, typically transcribed speech - """ - return bool(re.search(r'\b(news|headline)\b', text, re.IGNORECASE)) diff --git a/client/modules/Notifications.py b/client/modules/Notifications.py deleted file mode 100644 index 2d413b624..000000000 --- a/client/modules/Notifications.py +++ /dev/null @@ -1,58 +0,0 @@ -# -*- coding: utf-8-*- -import re -import facebook - - -WORDS = ["FACEBOOK", "NOTIFICATION"] - - -def handle(text, mic, profile): - """ - Responds to user-input, typically speech text, with a summary of - the user's Facebook notifications, including a count and details - related to each individual notification. - - Arguments: - text -- user-input, typically transcribed speech - mic -- used to interact with the user (for both input and output) - profile -- contains information related to the user (e.g., phone - number) - """ - oauth_access_token = profile['keys']['FB_TOKEN'] - - graph = facebook.GraphAPI(oauth_access_token) - - try: - results = graph.request("me/notifications") - except facebook.GraphAPIError: - mic.say("I have not been authorized to query your Facebook. If you " + - "would like to check your notifications in the future, " + - "please visit the Jasper dashboard.") - return - except: - mic.say( - "I apologize, there's a problem with that service at the moment.") - - if not len(results['data']): - mic.say("You have no Facebook notifications. ") - return - - updates = [] - for notification in results['data']: - updates.append(notification['title']) - - count = len(results['data']) - mic.say("You have " + str(count) + - " Facebook notifications. " + " ".join(updates) + ". ") - - return - - -def isValid(text): - """ - Returns True if the input is related to Facebook notifications. - - Arguments: - text -- user-input, typically transcribed speech - """ - return bool(re.search(r'\bnotification|Facebook\b', text, re.IGNORECASE)) diff --git a/client/modules/Time.py b/client/modules/Time.py deleted file mode 100644 index 47268394f..000000000 --- a/client/modules/Time.py +++ /dev/null @@ -1,35 +0,0 @@ -# -*- coding: utf-8-*- -import datetime -import re -from client.app_utils import getTimezone -from semantic.dates import DateService - -WORDS = ["TIME"] - - -def handle(text, mic, profile): - """ - Reports the current time based on the user's timezone. - - Arguments: - text -- user-input, typically transcribed speech - mic -- used to interact with the user (for both input and output) - profile -- contains information related to the user (e.g., phone - number) - """ - - tz = getTimezone(profile) - now = datetime.datetime.now(tz=tz) - service = DateService() - response = service.convertTime(now) - mic.say("It is %s right now." % response) - - -def isValid(text): - """ - Returns True if input is related to the time. - - Arguments: - text -- user-input, typically transcribed speech - """ - return bool(re.search(r'\btime\b', text, re.IGNORECASE)) diff --git a/client/modules/Unclear.py b/client/modules/Unclear.py deleted file mode 100644 index 071eea384..000000000 --- a/client/modules/Unclear.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8-*- -from sys import maxint -import random - -WORDS = [] - -PRIORITY = -(maxint + 1) - - -def handle(text, mic, profile): - """ - Reports that the user has unclear or unusable input. - - Arguments: - text -- user-input, typically transcribed speech - mic -- used to interact with the user (for both input and output) - profile -- contains information related to the user (e.g., phone - number) - """ - - messages = ["I'm sorry, could you repeat that?", - "My apologies, could you try saying that again?", - "Say that again?", "I beg your pardon?"] - - message = random.choice(messages) - - mic.say(message) - - -def isValid(text): - return True diff --git a/client/modules/Weather.py b/client/modules/Weather.py deleted file mode 100644 index 3bfad71a9..000000000 --- a/client/modules/Weather.py +++ /dev/null @@ -1,172 +0,0 @@ -# -*- coding: utf-8-*- -import re -import datetime -import struct -import urllib -import feedparser -import requests -import bs4 -from client.app_utils import getTimezone -from semantic.dates import DateService - -WORDS = ["WEATHER", "TODAY", "TOMORROW"] - - -def replaceAcronyms(text): - """ - Replaces some commonly-used acronyms for an improved verbal weather report. - """ - - def parseDirections(text): - words = { - 'N': 'north', - 'S': 'south', - 'E': 'east', - 'W': 'west', - } - output = [words[w] for w in list(text)] - return ' '.join(output) - acronyms = re.findall(r'\b([NESW]+)\b', text) - - for w in acronyms: - text = text.replace(w, parseDirections(w)) - - text = re.sub(r'(\b\d+)F(\b)', '\g<1> Fahrenheit\g<2>', text) - text = re.sub(r'(\b)mph(\b)', '\g<1>miles per hour\g<2>', text) - text = re.sub(r'(\b)in\.', '\g<1>inches', text) - - return text - - -def get_locations(): - r = requests.get('http://www.wunderground.com/about/faq/' + - 'international_cities.asp') - soup = bs4.BeautifulSoup(r.text) - data = soup.find(id="inner-content").find('pre').string - # Data Stucture: - # 00 25 location - # 01 1 - # 02 2 region - # 03 1 - # 04 2 country - # 05 2 - # 06 4 ID - # 07 5 - # 08 7 latitude - # 09 1 - # 10 7 logitude - # 11 1 - # 12 5 elevation - # 13 5 wmo_id - s = struct.Struct("25s1s2s1s2s2s4s5s7s1s7s1s5s5s") - for line in data.splitlines()[3:]: - row = s.unpack_from(line) - info = {'name': row[0].strip(), - 'region': row[2].strip(), - 'country': row[4].strip(), - 'latitude': float(row[8].strip()), - 'logitude': float(row[10].strip()), - 'elevation': int(row[12].strip()), - 'id': row[6].strip(), - 'wmo_id': row[13].strip()} - yield info - - -def get_forecast_by_name(location_name): - entries = feedparser.parse("http://rss.wunderground.com/auto/rss_full/%s" - % urllib.quote(location_name))['entries'] - if entries: - # We found weather data the easy way - return entries - else: - # We try to get weather data via the list of stations - for location in get_locations(): - if location['name'] == location_name: - return get_forecast_by_wmo_id(location['wmo_id']) - - -def get_forecast_by_wmo_id(wmo_id): - return feedparser.parse("http://rss.wunderground.com/auto/" + - "rss_full/global/stations/%s.xml" - % wmo_id)['entries'] - - -def handle(text, mic, profile): - """ - Responds to user-input, typically speech text, with a summary of - the relevant weather for the requested date (typically, weather - information will not be available for days beyond tomorrow). - - Arguments: - text -- user-input, typically transcribed speech - mic -- used to interact with the user (for both input and output) - profile -- contains information related to the user (e.g., phone - number) - """ - forecast = None - if 'wmo_id' in profile: - forecast = get_forecast_by_wmo_id(str(profile['wmo_id'])) - elif 'location' in profile: - forecast = get_forecast_by_name(str(profile['location'])) - - if not forecast: - mic.say("I'm sorry, I can't seem to access that information. Please " + - "make sure that you've set your location on the dashboard.") - return - - tz = getTimezone(profile) - - service = DateService(tz=tz) - date = service.extractDay(text) - if not date: - date = datetime.datetime.now(tz=tz) - weekday = service.__daysOfWeek__[date.weekday()] - - if date.weekday() == datetime.datetime.now(tz=tz).weekday(): - date_keyword = "Today" - elif date.weekday() == ( - datetime.datetime.now(tz=tz).weekday() + 1) % 7: - date_keyword = "Tomorrow" - else: - date_keyword = "On " + weekday - - output = None - - for entry in forecast: - try: - date_desc = entry['title'].split()[0].strip().lower() - if date_desc == 'forecast': - # For global forecasts - date_desc = entry['title'].split()[2].strip().lower() - weather_desc = entry['summary'] - elif date_desc == 'current': - # For first item of global forecasts - continue - else: - # US forecasts - weather_desc = entry['summary'].split('-')[1] - - if weekday == date_desc: - output = date_keyword + \ - ", the weather will be " + weather_desc + "." - break - except: - continue - - if output: - output = replaceAcronyms(output) - mic.say(output) - else: - mic.say( - "I'm sorry. I can't see that far ahead.") - - -def isValid(text): - """ - Returns True if the text is related to the weather. - - Arguments: - text -- user-input, typically transcribed speech - """ - return bool(re.search(r'\b(weathers?|temperature|forecast|outside|hot|' + - r'cold|jacket|coat|rain)\b', text, re.IGNORECASE)) diff --git a/client/modules/__init__.py b/client/modules/__init__.py deleted file mode 100755 index e69de29bb..000000000 diff --git a/client/notifier.py b/client/notifier.py deleted file mode 100644 index a896784bc..000000000 --- a/client/notifier.py +++ /dev/null @@ -1,76 +0,0 @@ -# -*- coding: utf-8-*- -import Queue -import atexit -from modules import Gmail -from apscheduler.schedulers.background import BackgroundScheduler -import logging - - -class Notifier(object): - - class NotificationClient(object): - - def __init__(self, gather, timestamp): - self.gather = gather - self.timestamp = timestamp - - def run(self): - self.timestamp = self.gather(self.timestamp) - - def __init__(self, profile): - self._logger = logging.getLogger(__name__) - self.q = Queue.Queue() - self.profile = profile - self.notifiers = [] - - if 'gmail_address' in profile and 'gmail_password' in profile: - self.notifiers.append(self.NotificationClient( - self.handleEmailNotifications, None)) - else: - self._logger.warning('gmail_address or gmail_password not set ' + - 'in profile, Gmail notifier will not be used') - - sched = BackgroundScheduler(timezone="UTC", daemon=True) - sched.start() - sched.add_job(self.gather, 'interval', seconds=30) - atexit.register(lambda: sched.shutdown(wait=False)) - - def gather(self): - [client.run() for client in self.notifiers] - - def handleEmailNotifications(self, lastDate): - """Places new Gmail notifications in the Notifier's queue.""" - emails = Gmail.fetchUnreadEmails(self.profile, since=lastDate) - if emails: - lastDate = Gmail.getMostRecentDate(emails) - - def styleEmail(e): - return "New email from %s." % Gmail.getSender(e) - - for e in emails: - self.q.put(styleEmail(e)) - - return lastDate - - def getNotification(self): - """Returns a notification. Note that this function is consuming.""" - try: - notif = self.q.get(block=False) - return notif - except Queue.Empty: - return None - - def getAllNotifications(self): - """ - Return a list of notifications in chronological order. - Note that this function is consuming, so consecutive calls - will yield different results. - """ - notifs = [] - - notif = self.getNotification() - while notif: - notifs.append(notif) - notif = self.getNotification() - - return notifs diff --git a/client/populate.py b/client/populate.py deleted file mode 100644 index 756297b1a..000000000 --- a/client/populate.py +++ /dev/null @@ -1,146 +0,0 @@ -# -*- coding: utf-8-*- -import os -import re -from getpass import getpass -import yaml -from pytz import timezone -import feedparser -import jasperpath - - -def run(): - profile = {} - - print("Welcome to the profile populator. If, at any step, you'd prefer " + - "not to enter the requested information, just hit 'Enter' with a " + - "blank field to continue.") - - def simple_request(var, cleanVar, cleanInput=None): - input = raw_input(cleanVar + ": ") - if input: - if cleanInput: - input = cleanInput(input) - profile[var] = input - - # name - simple_request('first_name', 'First name') - simple_request('last_name', 'Last name') - - # gmail - print("\nJasper uses your Gmail to send notifications. Alternatively, " + - "you can skip this step (or just fill in the email address if you " + - "want to receive email notifications) and setup a Mailgun " + - "account, as at http://jasperproject.github.io/documentation/" + - "software/#mailgun.\n") - simple_request('gmail_address', 'Gmail address') - profile['gmail_password'] = getpass() - - # phone number - def clean_number(s): - return re.sub(r'[^0-9]', '', s) - - phone_number = clean_number(raw_input("\nPhone number (no country " + - "code). Any dashes or spaces will " + - "be removed for you: ")) - profile['phone_number'] = phone_number - - # carrier - print("\nPhone carrier (for sending text notifications).") - print("If you have a US phone number, you can enter one of the " + - "following: 'AT&T', 'Verizon', 'T-Mobile' (without the quotes). " + - "If your carrier isn't listed or you have an international " + - "number, go to http://www.emailtextmessages.com and enter the " + - "email suffix for your carrier (e.g., for Virgin Mobile, enter " + - "'vmobl.com'; for T-Mobile Germany, enter 't-d1-sms.de').") - carrier = raw_input('Carrier: ') - if carrier == 'AT&T': - profile['carrier'] = 'txt.att.net' - elif carrier == 'Verizon': - profile['carrier'] = 'vtext.com' - elif carrier == 'T-Mobile': - profile['carrier'] = 'tmomail.net' - else: - profile['carrier'] = carrier - - # location - def verifyLocation(place): - feed = feedparser.parse('http://rss.wunderground.com/auto/rss_full/' + - place) - numEntries = len(feed['entries']) - if numEntries == 0: - return False - else: - print("Location saved as " + feed['feed']['description'][33:]) - return True - - print("\nLocation should be a 5-digit US zipcode (e.g., 08544). If you " + - "are outside the US, insert the name of your nearest big " + - "town/city. For weather requests.") - location = raw_input("Location: ") - while location and not verifyLocation(location): - print("Weather not found. Please try another location.") - location = raw_input("Location: ") - if location: - profile['location'] = location - - # timezone - print("\nPlease enter a timezone from the list located in the TZ* " + - "column at http://en.wikipedia.org/wiki/" + - "List_of_tz_database_time_zones, or none at all.") - tz = raw_input("Timezone: ") - while tz: - try: - timezone(tz) - profile['timezone'] = tz - break - except: - print("Not a valid timezone. Try again.") - tz = raw_input("Timezone: ") - - response = raw_input("\nWould you prefer to have notifications sent by " + - "email (E) or text message (T)? ") - while not response or (response != 'E' and response != 'T'): - response = raw_input("Please choose email (E) or text message (T): ") - profile['prefers_email'] = (response == 'E') - - stt_engines = { - "sphinx": None, - "google": "GOOGLE_SPEECH" - } - - response = raw_input("\nIf you would like to choose a specific STT " + - "engine, please specify which.\nAvailable " + - "implementations: %s. (Press Enter to default " + - "to PocketSphinx): " % stt_engines.keys()) - if (response in stt_engines): - profile["stt_engine"] = response - api_key_name = stt_engines[response] - if api_key_name: - key = raw_input("\nPlease enter your API key: ") - profile["keys"] = {api_key_name: key} - else: - print("Unrecognized STT engine. Available implementations: %s" - % stt_engines.keys()) - profile["stt_engine"] = "sphinx" - - if response == "google": - response = raw_input("\nChoosing Google means every sound " + - "makes a request online. " + - "\nWould you like to process the wake up word " + - "locally with PocketSphinx? (Y) or (N)?") - while not response or (response != 'Y' and response != 'N'): - response = raw_input("Please choose PocketSphinx (Y) " + - "or keep just Google (N): ") - if response == 'Y': - profile['stt_passive_engine'] = "sphinx" - - # write to profile - print("Writing to profile...") - if not os.path.exists(jasperpath.CONFIG_PATH): - os.makedirs(jasperpath.CONFIG_PATH) - outputFile = open(jasperpath.config("profile.yml"), "w") - yaml.dump(profile, outputFile, default_flow_style=False) - print("Done.") - -if __name__ == "__main__": - run() diff --git a/client/requirements.txt b/client/requirements.txt deleted file mode 100644 index f77a11b79..000000000 --- a/client/requirements.txt +++ /dev/null @@ -1,26 +0,0 @@ -# Jasper core dependencies -APScheduler==3.0.1 -argparse==1.2.2 -mock==1.0.1 -pytz==2014.10 -PyYAML==3.11 -requests==2.5.0 - -# Pocketsphinx STT engine -cmuclmtk==0.1.5 - -# HN module -beautifulsoup4==4.3.2 -semantic==1.0.3 - -# Birthday/Notifications modules -facebook-sdk==0.4.0 - -# Weather/News modules -feedparser==5.1.3 - -# Gmail module -python-dateutil==2.3 - -# MPDControl module -python-mpd==0.3.0 diff --git a/client/start.sh b/client/start.sh deleted file mode 100755 index 240a7f794..000000000 --- a/client/start.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -# This file exists for backwards compatibility with older versions of Jasper. -# It might be removed in future versions. -"${0%/*}/../jasper.py" diff --git a/client/stt.py b/client/stt.py deleted file mode 100644 index a48696099..000000000 --- a/client/stt.py +++ /dev/null @@ -1,661 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8-*- -import os -import wave -import json -import tempfile -import logging -import urllib -import urlparse -import re -import subprocess -from abc import ABCMeta, abstractmethod -import requests -import yaml -import jasperpath -import diagnose -import vocabcompiler - - -class AbstractSTTEngine(object): - """ - Generic parent class for all STT engines - """ - - __metaclass__ = ABCMeta - VOCABULARY_TYPE = None - - @classmethod - def get_config(cls): - return {} - - @classmethod - def get_instance(cls, vocabulary_name, phrases): - config = cls.get_config() - if cls.VOCABULARY_TYPE: - vocabulary = cls.VOCABULARY_TYPE(vocabulary_name, - path=jasperpath.config( - 'vocabularies')) - if not vocabulary.matches_phrases(phrases): - vocabulary.compile(phrases) - config['vocabulary'] = vocabulary - instance = cls(**config) - return instance - - @classmethod - def get_passive_instance(cls): - phrases = vocabcompiler.get_keyword_phrases() - return cls.get_instance('keyword', phrases) - - @classmethod - def get_active_instance(cls): - phrases = vocabcompiler.get_all_phrases() - return cls.get_instance('default', phrases) - - @classmethod - @abstractmethod - def is_available(cls): - return True - - @abstractmethod - def transcribe(self, fp): - pass - - -class PocketSphinxSTT(AbstractSTTEngine): - """ - The default Speech-to-Text implementation which relies on PocketSphinx. - """ - - SLUG = 'sphinx' - VOCABULARY_TYPE = vocabcompiler.PocketsphinxVocabulary - - def __init__(self, vocabulary, hmm_dir="/usr/local/share/" + - "pocketsphinx/model/hmm/en_US/hub4wsj_sc_8k"): - - """ - Initiates the pocketsphinx instance. - - Arguments: - vocabulary -- a PocketsphinxVocabulary instance - hmm_dir -- the path of the Hidden Markov Model (HMM) - """ - - self._logger = logging.getLogger(__name__) - - # quirky bug where first import doesn't work - try: - import pocketsphinx as ps - except: - import pocketsphinx as ps - - with tempfile.NamedTemporaryFile(prefix='psdecoder_', - suffix='.log', delete=False) as f: - self._logfile = f.name - - self._logger.debug("Initializing PocketSphinx Decoder with hmm_dir " + - "'%s'", hmm_dir) - - # Perform some checks on the hmm_dir so that we can display more - # meaningful error messages if neccessary - if not os.path.exists(hmm_dir): - msg = ("hmm_dir '%s' does not exist! Please make sure that you " + - "have set the correct hmm_dir in your profile.") % hmm_dir - self._logger.error(msg) - raise RuntimeError(msg) - # Lets check if all required files are there. Refer to: - # http://cmusphinx.sourceforge.net/wiki/acousticmodelformat - # for details - missing_hmm_files = [] - for fname in ('mdef', 'feat.params', 'means', 'noisedict', - 'transition_matrices', 'variances'): - if not os.path.exists(os.path.join(hmm_dir, fname)): - missing_hmm_files.append(fname) - mixweights = os.path.exists(os.path.join(hmm_dir, 'mixture_weights')) - sendump = os.path.exists(os.path.join(hmm_dir, 'sendump')) - if not mixweights and not sendump: - # We only need mixture_weights OR sendump - missing_hmm_files.append('mixture_weights or sendump') - if missing_hmm_files: - self._logger.warning("hmm_dir '%s' is missing files: %s. Please " + - "make sure that you have set the correct " + - "hmm_dir in your profile.", - hmm_dir, ', '.join(missing_hmm_files)) - - self._decoder = ps.Decoder(hmm=hmm_dir, logfn=self._logfile, - **vocabulary.decoder_kwargs) - - def __del__(self): - os.remove(self._logfile) - - @classmethod - def get_config(cls): - # FIXME: Replace this as soon as we have a config module - config = {} - # HMM dir - # Try to get hmm_dir from config - profile_path = jasperpath.config('profile.yml') - - if os.path.exists(profile_path): - with open(profile_path, 'r') as f: - profile = yaml.safe_load(f) - try: - config['hmm_dir'] = profile['pocketsphinx']['hmm_dir'] - except KeyError: - pass - - return config - - def transcribe(self, fp): - """ - Performs STT, transcribing an audio file and returning the result. - - Arguments: - fp -- a file object containing audio data - """ - - fp.seek(44) - - # FIXME: Can't use the Decoder.decode_raw() here, because - # pocketsphinx segfaults with tempfile.SpooledTemporaryFile() - data = fp.read() - self._decoder.start_utt() - self._decoder.process_raw(data, False, True) - self._decoder.end_utt() - - result = self._decoder.get_hyp() - with open(self._logfile, 'r+') as f: - for line in f: - self._logger.debug(line.strip()) - f.truncate() - - transcribed = [result[0]] - self._logger.info('Transcribed: %r', transcribed) - return transcribed - - @classmethod - def is_available(cls): - return diagnose.check_python_import('pocketsphinx') - - -class JuliusSTT(AbstractSTTEngine): - """ - A very basic Speech-to-Text engine using Julius. - """ - - SLUG = 'julius' - VOCABULARY_TYPE = vocabcompiler.JuliusVocabulary - - def __init__(self, vocabulary=None, hmmdefs="/usr/share/voxforge/julius/" + - "acoustic_model_files/hmmdefs", tiedlist="/usr/share/" + - "voxforge/julius/acoustic_model_files/tiedlist"): - self._logger = logging.getLogger(__name__) - self._vocabulary = vocabulary - self._hmmdefs = hmmdefs - self._tiedlist = tiedlist - self._pattern = re.compile(r'sentence(\d+): (.+) ') - - # Inital test run: we run this command once to log errors/warnings - cmd = ['julius', - '-input', 'stdin', - '-dfa', self._vocabulary.dfa_file, - '-v', self._vocabulary.dict_file, - '-h', self._hmmdefs, - '-hlist', self._tiedlist, - '-forcedict'] - cmd = [str(x) for x in cmd] - self._logger.debug('Executing: %r', cmd) - with tempfile.SpooledTemporaryFile() as out_f: - with tempfile.SpooledTemporaryFile() as f: - with tempfile.SpooledTemporaryFile() as err_f: - subprocess.call(cmd, stdin=f, stdout=out_f, stderr=err_f) - out_f.seek(0) - for line in out_f.read().splitlines(): - line = line.strip() - if len(line) > 7 and line[:7].upper() == 'ERROR: ': - if not line[7:].startswith('adin_'): - self._logger.error(line[7:]) - elif len(line) > 9 and line[:9].upper() == 'WARNING: ': - self._logger.warning(line[9:]) - elif len(line) > 6 and line[:6].upper() == 'STAT: ': - self._logger.debug(line[6:]) - - @classmethod - def get_config(cls): - # FIXME: Replace this as soon as we have a config module - config = {} - # HMM dir - # Try to get hmm_dir from config - profile_path = jasperpath.config('profile.yml') - - if os.path.exists(profile_path): - with open(profile_path, 'r') as f: - profile = yaml.safe_load(f) - if 'julius' in profile: - if 'hmmdefs' in profile['julius']: - config['hmmdefs'] = profile['julius']['hmmdefs'] - if 'tiedlist' in profile['julius']: - config['tiedlist'] = profile['julius']['tiedlist'] - return config - - def transcribe(self, fp, mode=None): - cmd = ['julius', - '-quiet', - '-nolog', - '-input', 'stdin', - '-dfa', self._vocabulary.dfa_file, - '-v', self._vocabulary.dict_file, - '-h', self._hmmdefs, - '-hlist', self._tiedlist, - '-forcedict'] - cmd = [str(x) for x in cmd] - self._logger.debug('Executing: %r', cmd) - with tempfile.SpooledTemporaryFile() as out_f: - with tempfile.SpooledTemporaryFile() as err_f: - subprocess.call(cmd, stdin=fp, stdout=out_f, stderr=err_f) - out_f.seek(0) - results = [(int(i), text) for i, text in - self._pattern.findall(out_f.read())] - transcribed = [text for i, text in - sorted(results, key=lambda x: x[0]) - if text] - if not transcribed: - transcribed.append('') - self._logger.info('Transcribed: %r', transcribed) - return transcribed - - @classmethod - def is_available(cls): - return diagnose.check_executable('julius') - - -class GoogleSTT(AbstractSTTEngine): - """ - Speech-To-Text implementation which relies on the Google Speech API. - - This implementation requires a Google API key to be present in profile.yml - - To obtain an API key: - 1. Join the Chromium Dev group: - https://groups.google.com/a/chromium.org/forum/?fromgroups#!forum/chromium-dev - 2. Create a project through the Google Developers console: - https://console.developers.google.com/project - 3. Select your project. In the sidebar, navigate to "APIs & Auth." Activate - the Speech API. - 4. Under "APIs & Auth," navigate to "Credentials." Create a new key for - public API access. - 5. Add your credentials to your profile.yml. Add an entry to the 'keys' - section using the key name 'GOOGLE_SPEECH.' Sample configuration: - 6. Set the value of the 'stt_engine' key in your profile.yml to 'google' - - - Excerpt from sample profile.yml: - - ... - timezone: US/Pacific - stt_engine: google - keys: - GOOGLE_SPEECH: $YOUR_KEY_HERE - - """ - - SLUG = 'google' - - def __init__(self, api_key=None, language='en-us'): - # FIXME: get init args from config - """ - Arguments: - api_key - the public api key which allows access to Google APIs - """ - self._logger = logging.getLogger(__name__) - self._request_url = None - self._language = None - self._api_key = None - self._http = requests.Session() - self.language = language - self.api_key = api_key - - @property - def request_url(self): - return self._request_url - - @property - def language(self): - return self._language - - @language.setter - def language(self, value): - self._language = value - self._regenerate_request_url() - - @property - def api_key(self): - return self._api_key - - @api_key.setter - def api_key(self, value): - self._api_key = value - self._regenerate_request_url() - - def _regenerate_request_url(self): - if self.api_key and self.language: - query = urllib.urlencode({'output': 'json', - 'client': 'chromium', - 'key': self.api_key, - 'lang': self.language, - 'maxresults': 6, - 'pfilter': 2}) - self._request_url = urlparse.urlunparse( - ('https', 'www.google.com', '/speech-api/v2/recognize', '', - query, '')) - else: - self._request_url = None - - @classmethod - def get_config(cls): - # FIXME: Replace this as soon as we have a config module - config = {} - # HMM dir - # Try to get hmm_dir from config - profile_path = jasperpath.config('profile.yml') - if os.path.exists(profile_path): - with open(profile_path, 'r') as f: - profile = yaml.safe_load(f) - if 'keys' in profile and 'GOOGLE_SPEECH' in profile['keys']: - config['api_key'] = profile['keys']['GOOGLE_SPEECH'] - return config - - def transcribe(self, fp): - """ - Performs STT via the Google Speech API, transcribing an audio file and - returning an English string. - - Arguments: - audio_file_path -- the path to the .wav file to be transcribed - """ - - if not self.api_key: - self._logger.critical('API key missing, transcription request ' + - 'aborted.') - return [] - elif not self.language: - self._logger.critical('Language info missing, transcription ' + - 'request aborted.') - return [] - - wav = wave.open(fp, 'rb') - frame_rate = wav.getframerate() - wav.close() - data = fp.read() - - headers = {'content-type': 'audio/l16; rate=%s' % frame_rate} - r = self._http.post(self.request_url, data=data, headers=headers) - try: - r.raise_for_status() - except requests.exceptions.HTTPError: - self._logger.critical('Request failed with http status %d', - r.status_code) - if r.status_code == requests.codes['forbidden']: - self._logger.warning('Status 403 is probably caused by an ' + - 'invalid Google API key.') - return [] - r.encoding = 'utf-8' - try: - # We cannot simply use r.json() because Google sends invalid json - # (i.e. multiple json objects, seperated by newlines. We only want - # the last one). - response = json.loads(list(r.text.strip().split('\n', 1))[-1]) - if len(response['result']) == 0: - # Response result is empty - raise ValueError('Nothing has been transcribed.') - results = [alt['transcript'] for alt - in response['result'][0]['alternative']] - except ValueError as e: - self._logger.warning('Empty response: %s', e.args[0]) - results = [] - except (KeyError, IndexError): - self._logger.warning('Cannot parse response.', exc_info=True) - results = [] - else: - # Convert all results to uppercase - results = tuple(result.upper() for result in results) - self._logger.info('Transcribed: %r', results) - return results - - @classmethod - def is_available(cls): - return diagnose.check_network_connection() - - -class AttSTT(AbstractSTTEngine): - """ - Speech-To-Text implementation which relies on the AT&T Speech API. - - This implementation requires an AT&T app_key/app_secret to be present in - profile.yml. Please sign up at http://developer.att.com/apis/speech and - create a new app. You can then take the app_key/app_secret and put it into - your profile.yml: - ... - stt_engine: att - att-stt: - app_key: 4xxzd6abcdefghijklmnopqrstuvwxyz - app_secret: 6o5jgiabcdefghijklmnopqrstuvwxyz - """ - - SLUG = "att" - - def __init__(self, app_key, app_secret): - self._logger = logging.getLogger(__name__) - self._token = None - self.app_key = app_key - self.app_secret = app_secret - - @classmethod - def get_config(cls): - # FIXME: Replace this as soon as we have a config module - config = {} - # Try to get AT&T app_key/app_secret from config - profile_path = jasperpath.config('profile.yml') - if os.path.exists(profile_path): - with open(profile_path, 'r') as f: - profile = yaml.safe_load(f) - if 'att-stt' in profile: - if 'app_key' in profile['att-stt']: - config['app_key'] = profile['att-stt']['app_key'] - if 'app_secret' in profile['att-stt']: - config['app_secret'] = profile['att-stt']['app_secret'] - return config - - @property - def token(self): - if not self._token: - headers = {'content-type': 'application/x-www-form-urlencoded', - 'accept': 'application/json'} - payload = {'client_id': self.app_key, - 'client_secret': self.app_secret, - 'scope': 'SPEECH', - 'grant_type': 'client_credentials'} - r = requests.post('https://api.att.com/oauth/v4/token', - data=payload, - headers=headers) - self._token = r.json()['access_token'] - return self._token - - def transcribe(self, fp): - data = fp.read() - r = self._get_response(data) - if r.status_code == requests.codes['unauthorized']: - # Request token invalid, retry once with a new token - self._logger.warning('OAuth access token invalid, generating a ' + - 'new one and retrying...') - self._token = None - r = self._get_response(data) - try: - r.raise_for_status() - except requests.exceptions.HTTPError: - self._logger.critical('Request failed with response: %r', - r.text, - exc_info=True) - return [] - except requests.exceptions.RequestException: - self._logger.critical('Request failed.', exc_info=True) - return [] - else: - try: - recognition = r.json()['Recognition'] - if recognition['Status'] != 'OK': - raise ValueError(recognition['Status']) - results = [(x['Hypothesis'], x['Confidence']) - for x in recognition['NBest']] - except ValueError as e: - self._logger.debug('Recognition failed with status: %s', - e.args[0]) - return [] - except KeyError: - self._logger.critical('Cannot parse response.', - exc_info=True) - return [] - else: - transcribed = [x[0].upper() for x in sorted(results, - key=lambda x: x[1], - reverse=True)] - self._logger.info('Transcribed: %r', transcribed) - return transcribed - - def _get_response(self, data): - headers = {'authorization': 'Bearer %s' % self.token, - 'accept': 'application/json', - 'content-type': 'audio/wav'} - return requests.post('https://api.att.com/speech/v3/speechToText', - data=data, - headers=headers) - - @classmethod - def is_available(cls): - return diagnose.check_network_connection() - - -class WitAiSTT(AbstractSTTEngine): - """ - Speech-To-Text implementation which relies on the Wit.ai Speech API. - - This implementation requires an Wit.ai Access Token to be present in - profile.yml. Please sign up at https://wit.ai and copy your instance - token, which can be found under Settings in the Wit console to your - profile.yml: - ... - stt_engine: witai - witai-stt: - access_token: ERJKGE86SOMERANDOMTOKEN23471AB - """ - - SLUG = "witai" - - def __init__(self, access_token): - self._logger = logging.getLogger(__name__) - self.token = access_token - - @classmethod - def get_config(cls): - # FIXME: Replace this as soon as we have a config module - config = {} - # Try to get wit.ai Auth token from config - profile_path = jasperpath.config('profile.yml') - if os.path.exists(profile_path): - with open(profile_path, 'r') as f: - profile = yaml.safe_load(f) - if 'witai-stt' in profile: - if 'access_token' in profile['witai-stt']: - config['access_token'] = \ - profile['witai-stt']['access_token'] - return config - - @property - def token(self): - return self._token - - @token.setter - def token(self, value): - self._token = value - self._headers = {'Authorization': 'Bearer %s' % self.token, - 'accept': 'application/json', - 'Content-Type': 'audio/wav'} - - @property - def headers(self): - return self._headers - - def transcribe(self, fp): - data = fp.read() - r = requests.post('https://api.wit.ai/speech?v=20150101', - data=data, - headers=self.headers) - try: - r.raise_for_status() - text = r.json()['_text'] - except requests.exceptions.HTTPError: - self._logger.critical('Request failed with response: %r', - r.text, - exc_info=True) - return [] - except requests.exceptions.RequestException: - self._logger.critical('Request failed.', exc_info=True) - return [] - except ValueError as e: - self._logger.critical('Cannot parse response: %s', - e.args[0]) - return [] - except KeyError: - self._logger.critical('Cannot parse response.', - exc_info=True) - return [] - else: - transcribed = [] - if text: - transcribed.append(text.upper()) - self._logger.info('Transcribed: %r', transcribed) - return transcribed - - @classmethod - def is_available(cls): - return diagnose.check_network_connection() - - -def get_engine_by_slug(slug=None): - """ - Returns: - An STT Engine implementation available on the current platform - - Raises: - ValueError if no speaker implementation is supported on this platform - """ - - if not slug or type(slug) is not str: - raise TypeError("Invalid slug '%s'", slug) - - selected_engines = filter(lambda engine: hasattr(engine, "SLUG") and - engine.SLUG == slug, get_engines()) - if len(selected_engines) == 0: - raise ValueError("No STT engine found for slug '%s'" % slug) - else: - if len(selected_engines) > 1: - print(("WARNING: Multiple STT engines found for slug '%s'. " + - "This is most certainly a bug.") % slug) - engine = selected_engines[0] - if not engine.is_available(): - raise ValueError(("STT engine '%s' is not available (due to " + - "missing dependencies, missing " + - "dependencies, etc.)") % slug) - return engine - - -def get_engines(): - def get_subclasses(cls): - subclasses = set() - for subclass in cls.__subclasses__(): - subclasses.add(subclass) - subclasses.update(get_subclasses(subclass)) - return subclasses - return [tts_engine for tts_engine in - list(get_subclasses(AbstractSTTEngine)) - if hasattr(tts_engine, 'SLUG') and tts_engine.SLUG] diff --git a/client/test_mic.py b/client/test_mic.py deleted file mode 100644 index 2448d8d0f..000000000 --- a/client/test_mic.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8-*- -""" -A drop-in replacement for the Mic class used during unit testing. -Designed to take pre-arranged inputs as an argument and store any -outputs for inspection. Requires a populated profile (profile.yml). -""" - - -class Mic: - - def __init__(self, inputs): - self.inputs = inputs - self.idx = 0 - self.outputs = [] - - def passiveListen(self, PERSONA): - return True, "JASPER" - - def activeListenToAllOptions(self, THRESHOLD=None, LISTEN=True, - MUSIC=False): - return [self.activeListen(THRESHOLD=THRESHOLD, LISTEN=LISTEN, - MUSIC=MUSIC)] - - def activeListen(self, THRESHOLD=None, LISTEN=True, MUSIC=False): - if not LISTEN: - return self.inputs[self.idx - 1] - - input = self.inputs[self.idx] - self.idx += 1 - return input - - def say(self, phrase, OPTIONS=None): - self.outputs.append(phrase) diff --git a/client/tts.py b/client/tts.py deleted file mode 100644 index dd3327f69..000000000 --- a/client/tts.py +++ /dev/null @@ -1,711 +0,0 @@ -# -*- coding: utf-8-*- -""" -A Speaker handles audio output from Jasper to the user - -Speaker methods: - say - output 'phrase' as speech - play - play the audio in 'filename' - is_available - returns True if the platform supports this implementation -""" -import os -import platform -import re -import tempfile -import subprocess -import pipes -import logging -import wave -import urllib -import urlparse -import requests -from abc import ABCMeta, abstractmethod - -import argparse -import yaml - -try: - import mad -except ImportError: - pass - -try: - import gtts -except ImportError: - pass - -try: - import pyvona -except ImportError: - pass - -import diagnose -import jasperpath - - -class AbstractTTSEngine(object): - """ - Generic parent class for all speakers - """ - __metaclass__ = ABCMeta - - @classmethod - def get_config(cls): - return {} - - @classmethod - def get_instance(cls): - config = cls.get_config() - instance = cls(**config) - return instance - - @classmethod - @abstractmethod - def is_available(cls): - return diagnose.check_executable('aplay') - - def __init__(self, **kwargs): - self._logger = logging.getLogger(__name__) - - @abstractmethod - def say(self, phrase, *args): - pass - - def play(self, filename): - # FIXME: Use platform-independent audio-output here - # See issue jasperproject/jasper-client#188 - cmd = ['aplay', '-D', 'plughw:1,0', str(filename)] - self._logger.debug('Executing %s', ' '.join([pipes.quote(arg) - for arg in cmd])) - with tempfile.TemporaryFile() as f: - subprocess.call(cmd, stdout=f, stderr=f) - f.seek(0) - output = f.read() - if output: - self._logger.debug("Output was: '%s'", output) - - -class AbstractMp3TTSEngine(AbstractTTSEngine): - """ - Generic class that implements the 'play' method for mp3 files - """ - @classmethod - def is_available(cls): - return (super(AbstractMp3TTSEngine, cls).is_available() and - diagnose.check_python_import('mad')) - - def play_mp3(self, filename): - mf = mad.MadFile(filename) - with tempfile.NamedTemporaryFile(suffix='.wav') as f: - wav = wave.open(f, mode='wb') - wav.setframerate(mf.samplerate()) - wav.setnchannels(1 if mf.mode() == mad.MODE_SINGLE_CHANNEL else 2) - # 4L is the sample width of 32 bit audio - wav.setsampwidth(4L) - frame = mf.read() - while frame is not None: - wav.writeframes(frame) - frame = mf.read() - wav.close() - self.play(f.name) - - -class DummyTTS(AbstractTTSEngine): - """ - Dummy TTS engine that logs phrases with INFO level instead of synthesizing - speech. - """ - - SLUG = "dummy-tts" - - @classmethod - def is_available(cls): - return True - - def say(self, phrase): - self._logger.info(phrase) - - def play(self, filename): - self._logger.debug("Playback of file '%s' requested") - pass - - -class EspeakTTS(AbstractTTSEngine): - """ - Uses the eSpeak speech synthesizer included in the Jasper disk image - Requires espeak to be available - """ - - SLUG = "espeak-tts" - - def __init__(self, voice='default+m3', pitch_adjustment=40, - words_per_minute=160): - super(self.__class__, self).__init__() - self.voice = voice - self.pitch_adjustment = pitch_adjustment - self.words_per_minute = words_per_minute - - @classmethod - def get_config(cls): - # FIXME: Replace this as soon as we have a config module - config = {} - # HMM dir - # Try to get hmm_dir from config - profile_path = jasperpath.config('profile.yml') - if os.path.exists(profile_path): - with open(profile_path, 'r') as f: - profile = yaml.safe_load(f) - if 'espeak-tts' in profile: - if 'voice' in profile['espeak-tts']: - config['voice'] = profile['espeak-tts']['voice'] - if 'pitch_adjustment' in profile['espeak-tts']: - config['pitch_adjustment'] = \ - profile['espeak-tts']['pitch_adjustment'] - if 'words_per_minute' in profile['espeak-tts']: - config['words_per_minute'] = \ - profile['espeak-tts']['words_per_minute'] - return config - - @classmethod - def is_available(cls): - return (super(cls, cls).is_available() and - diagnose.check_executable('espeak')) - - def say(self, phrase): - self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG) - with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: - fname = f.name - cmd = ['espeak', '-v', self.voice, - '-p', self.pitch_adjustment, - '-s', self.words_per_minute, - '-w', fname, - phrase] - cmd = [str(x) for x in cmd] - self._logger.debug('Executing %s', ' '.join([pipes.quote(arg) - for arg in cmd])) - with tempfile.TemporaryFile() as f: - subprocess.call(cmd, stdout=f, stderr=f) - f.seek(0) - output = f.read() - if output: - self._logger.debug("Output was: '%s'", output) - self.play(fname) - os.remove(fname) - - -class FestivalTTS(AbstractTTSEngine): - """ - Uses the festival speech synthesizer - Requires festival (text2wave) to be available - """ - - SLUG = 'festival-tts' - - @classmethod - def is_available(cls): - if (super(cls, cls).is_available() and - diagnose.check_executable('text2wave') and - diagnose.check_executable('festival')): - - logger = logging.getLogger(__name__) - cmd = ['festival', '--pipe'] - with tempfile.SpooledTemporaryFile() as out_f: - with tempfile.SpooledTemporaryFile() as in_f: - logger.debug('Executing %s', ' '.join([pipes.quote(arg) - for arg in cmd])) - subprocess.call(cmd, stdin=in_f, stdout=out_f, - stderr=out_f) - out_f.seek(0) - output = out_f.read().strip() - if output: - logger.debug("Output was: '%s'", output) - return ('No default voice found' not in output) - return False - - def say(self, phrase): - self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG) - cmd = ['text2wave'] - with tempfile.NamedTemporaryFile(suffix='.wav') as out_f: - with tempfile.SpooledTemporaryFile() as in_f: - in_f.write(phrase) - in_f.seek(0) - with tempfile.SpooledTemporaryFile() as err_f: - self._logger.debug('Executing %s', - ' '.join([pipes.quote(arg) - for arg in cmd])) - subprocess.call(cmd, stdin=in_f, stdout=out_f, - stderr=err_f) - err_f.seek(0) - output = err_f.read() - if output: - self._logger.debug("Output was: '%s'", output) - self.play(out_f.name) - - -class FliteTTS(AbstractTTSEngine): - """ - Uses the flite speech synthesizer - Requires flite to be available - """ - - SLUG = 'flite-tts' - - def __init__(self, voice=''): - super(self.__class__, self).__init__() - self.voice = voice if voice and voice in self.get_voices() else '' - - @classmethod - def get_voices(cls): - cmd = ['flite', '-lv'] - voices = [] - with tempfile.SpooledTemporaryFile() as out_f: - subprocess.call(cmd, stdout=out_f) - out_f.seek(0) - for line in out_f: - if line.startswith('Voices available: '): - voices.extend([x.strip() for x in line[18:].split() - if x.strip()]) - return voices - - @classmethod - def get_config(cls): - # FIXME: Replace this as soon as we have a config module - config = {} - # HMM dir - # Try to get hmm_dir from config - profile_path = jasperpath.config('profile.yml') - if os.path.exists(profile_path): - with open(profile_path, 'r') as f: - profile = yaml.safe_load(f) - if 'flite-tts' in profile: - if 'voice' in profile['flite-tts']: - config['voice'] = profile['flite-tts']['voice'] - return config - - @classmethod - def is_available(cls): - return (super(cls, cls).is_available() and - diagnose.check_executable('flite') and - len(cls.get_voices()) > 0) - - def say(self, phrase): - self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG) - cmd = ['flite'] - if self.voice: - cmd.extend(['-voice', self.voice]) - cmd.extend(['-t', phrase]) - with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: - fname = f.name - cmd.append(fname) - with tempfile.SpooledTemporaryFile() as out_f: - self._logger.debug('Executing %s', - ' '.join([pipes.quote(arg) - for arg in cmd])) - subprocess.call(cmd, stdout=out_f, stderr=out_f) - out_f.seek(0) - output = out_f.read().strip() - if output: - self._logger.debug("Output was: '%s'", output) - self.play(fname) - os.remove(fname) - - -class MacOSXTTS(AbstractTTSEngine): - """ - Uses the OS X built-in 'say' command - """ - - SLUG = "osx-tts" - - @classmethod - def is_available(cls): - return (platform.system().lower() == 'darwin' and - diagnose.check_executable('say') and - diagnose.check_executable('afplay')) - - def say(self, phrase): - self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG) - cmd = ['say', str(phrase)] - self._logger.debug('Executing %s', ' '.join([pipes.quote(arg) - for arg in cmd])) - with tempfile.TemporaryFile() as f: - subprocess.call(cmd, stdout=f, stderr=f) - f.seek(0) - output = f.read() - if output: - self._logger.debug("Output was: '%s'", output) - - def play(self, filename): - cmd = ['afplay', str(filename)] - self._logger.debug('Executing %s', ' '.join([pipes.quote(arg) - for arg in cmd])) - with tempfile.TemporaryFile() as f: - subprocess.call(cmd, stdout=f, stderr=f) - f.seek(0) - output = f.read() - if output: - self._logger.debug("Output was: '%s'", output) - - -class PicoTTS(AbstractTTSEngine): - """ - Uses the svox-pico-tts speech synthesizer - Requires pico2wave to be available - """ - - SLUG = "pico-tts" - - def __init__(self, language="en-US"): - super(self.__class__, self).__init__() - self.language = language - - @classmethod - def is_available(cls): - return (super(cls, cls).is_available() and - diagnose.check_executable('pico2wave')) - - @classmethod - def get_config(cls): - # FIXME: Replace this as soon as we have a config module - config = {} - # HMM dir - # Try to get hmm_dir from config - profile_path = jasperpath.config('profile.yml') - if os.path.exists(profile_path): - with open(profile_path, 'r') as f: - profile = yaml.safe_load(f) - if 'pico-tts' in profile and 'language' in profile['pico-tts']: - config['language'] = profile['pico-tts']['language'] - - return config - - @property - def languages(self): - cmd = ['pico2wave', '-l', 'NULL', - '-w', os.devnull, - 'NULL'] - with tempfile.SpooledTemporaryFile() as f: - subprocess.call(cmd, stderr=f) - f.seek(0) - output = f.read() - pattern = re.compile(r'Unknown language: NULL\nValid languages:\n' + - r'((?:[a-z]{2}-[A-Z]{2}\n)+)') - matchobj = pattern.match(output) - if not matchobj: - raise RuntimeError("pico2wave: valid languages not detected") - langs = matchobj.group(1).split() - return langs - - def say(self, phrase): - self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG) - with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: - fname = f.name - cmd = ['pico2wave', '--wave', fname] - if self.language not in self.languages: - raise ValueError("Language '%s' not supported by '%s'", - self.language, self.SLUG) - cmd.extend(['-l', self.language]) - cmd.append(phrase) - self._logger.debug('Executing %s', ' '.join([pipes.quote(arg) - for arg in cmd])) - with tempfile.TemporaryFile() as f: - subprocess.call(cmd, stdout=f, stderr=f) - f.seek(0) - output = f.read() - if output: - self._logger.debug("Output was: '%s'", output) - self.play(fname) - os.remove(fname) - - -class GoogleTTS(AbstractMp3TTSEngine): - """ - Uses the Google TTS online translator - Requires pymad and gTTS to be available - """ - - SLUG = "google-tts" - - def __init__(self, language='en'): - super(self.__class__, self).__init__() - self.language = language - - @classmethod - def is_available(cls): - return (super(cls, cls).is_available() and - diagnose.check_python_import('gtts') and - diagnose.check_network_connection()) - - @classmethod - def get_config(cls): - # FIXME: Replace this as soon as we have a config module - config = {} - # HMM dir - # Try to get hmm_dir from config - profile_path = jasperpath.config('profile.yml') - if os.path.exists(profile_path): - with open(profile_path, 'r') as f: - profile = yaml.safe_load(f) - if ('google-tts' in profile and - 'language' in profile['google-tts']): - config['language'] = profile['google-tts']['language'] - - return config - - @property - def languages(self): - langs = ['af', 'sq', 'ar', 'hy', 'ca', 'zh-CN', 'zh-TW', 'hr', 'cs', - 'da', 'nl', 'en', 'eo', 'fi', 'fr', 'de', 'el', 'ht', 'hi', - 'hu', 'is', 'id', 'it', 'ja', 'ko', 'la', 'lv', 'mk', 'no', - 'pl', 'pt', 'ro', 'ru', 'sr', 'sk', 'es', 'sw', 'sv', 'ta', - 'th', 'tr', 'vi', 'cy'] - return langs - - def say(self, phrase): - self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG) - if self.language not in self.languages: - raise ValueError("Language '%s' not supported by '%s'", - self.language, self.SLUG) - tts = gtts.gTTS(text=phrase, lang=self.language) - with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f: - tmpfile = f.name - tts.save(tmpfile) - self.play_mp3(tmpfile) - os.remove(tmpfile) - - -class MaryTTS(AbstractTTSEngine): - """ - Uses the MARY Text-to-Speech System (MaryTTS) - MaryTTS is an open-source, multilingual Text-to-Speech Synthesis platform - written in Java. - Please specify your own server instead of using the demonstration server - (http://mary.dfki.de:59125/) to save bandwidth and to protect your privacy. - """ - - SLUG = "mary-tts" - - def __init__(self, server="mary.dfki.de", port="59125", language="en_GB", - voice="dfki-spike"): - super(self.__class__, self).__init__() - self.server = server - self.port = port - self.netloc = '{server}:{port}'.format(server=self.server, - port=self.port) - self.language = language - self.voice = voice - self.session = requests.Session() - - @property - def languages(self): - try: - r = self.session.get(self._makeurl('/locales')) - r.raise_for_status() - except requests.exceptions.RequestException: - self._logger.critical("Communication with MaryTTS server at %s " + - "failed.", self.netloc) - raise - return r.text.splitlines() - - @property - def voices(self): - r = self.session.get(self._makeurl('/voices')) - r.raise_for_status() - return [line.split()[0] for line in r.text.splitlines()] - - @classmethod - def get_config(cls): - # FIXME: Replace this as soon as we have a config module - config = {} - # HMM dir - # Try to get hmm_dir from config - profile_path = jasperpath.config('profile.yml') - if os.path.exists(profile_path): - with open(profile_path, 'r') as f: - profile = yaml.safe_load(f) - if 'mary-tts' in profile: - if 'server' in profile['mary-tts']: - config['server'] = profile['mary-tts']['server'] - if 'port' in profile['mary-tts']: - config['port'] = profile['mary-tts']['port'] - if 'language' in profile['mary-tts']: - config['language'] = profile['mary-tts']['language'] - if 'voice' in profile['mary-tts']: - config['voice'] = profile['mary-tts']['voice'] - - return config - - @classmethod - def is_available(cls): - return (super(cls, cls).is_available() and - diagnose.check_network_connection()) - - def _makeurl(self, path, query={}): - query_s = urllib.urlencode(query) - urlparts = ('http', self.netloc, path, query_s, '') - return urlparse.urlunsplit(urlparts) - - def say(self, phrase): - self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG) - if self.language not in self.languages: - raise ValueError("Language '%s' not supported by '%s'" - % (self.language, self.SLUG)) - - if self.voice not in self.voices: - raise ValueError("Voice '%s' not supported by '%s'" - % (self.voice, self.SLUG)) - query = {'OUTPUT_TYPE': 'AUDIO', - 'AUDIO': 'WAVE_FILE', - 'INPUT_TYPE': 'TEXT', - 'INPUT_TEXT': phrase, - 'LOCALE': self.language, - 'VOICE': self.voice} - - r = self.session.get(self._makeurl('/process', query=query)) - with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: - f.write(r.content) - tmpfile = f.name - self.play(tmpfile) - os.remove(tmpfile) - - -class IvonaTTS(AbstractMp3TTSEngine): - """ - Uses the Ivona Speech Cloud Services. - Ivona is a multilingual Text-to-Speech synthesis platform developed by - Amazon. - """ - - SLUG = "ivona-tts" - - def __init__(self, access_key='', secret_key='', region=None, - voice=None, speech_rate=None, sentence_break=None): - super(self.__class__, self).__init__() - self._pyvonavoice = pyvona.Voice(access_key, secret_key) - self._pyvonavoice.codec = "mp3" - if region: - self._pyvonavoice.region = region - if voice: - self._pyvonavoice.voice_name = voice - if speech_rate: - self._pyvonavoice.speech_rate = speech_rate - if sentence_break: - self._pyvonavoice.sentence_break = sentence_break - - @classmethod - def get_config(cls): - # FIXME: Replace this as soon as we have a config module - config = {} - # HMM dir - # Try to get hmm_dir from config - profile_path = jasperpath.config('profile.yml') - if os.path.exists(profile_path): - with open(profile_path, 'r') as f: - profile = yaml.safe_load(f) - if 'ivona-tts' in profile: - if 'access_key' in profile['ivona-tts']: - config['access_key'] = \ - profile['ivona-tts']['access_key'] - if 'secret_key' in profile['ivona-tts']: - config['secret_key'] = \ - profile['ivona-tts']['secret_key'] - if 'region' in profile['ivona-tts']: - config['region'] = profile['ivona-tts']['region'] - if 'voice' in profile['ivona-tts']: - config['voice'] = profile['ivona-tts']['voice'] - if 'speech_rate' in profile['ivona-tts']: - config['speech_rate'] = \ - profile['ivona-tts']['speech_rate'] - if 'sentence_break' in profile['ivona-tts']: - config['sentence_break'] = \ - profile['ivona-tts']['sentence_break'] - return config - - @classmethod - def is_available(cls): - return (super(cls, cls).is_available() and - diagnose.check_python_import('pyvona') and - diagnose.check_network_connection()) - - def say(self, phrase): - self._logger.debug("Saying '%s' with '%s'", phrase, self.SLUG) - with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f: - tmpfile = f.name - self._pyvonavoice.fetch_voice(phrase, tmpfile) - self.play_mp3(tmpfile) - os.remove(tmpfile) - - -def get_default_engine_slug(): - return 'osx-tts' if platform.system().lower() == 'darwin' else 'espeak-tts' - - -def get_engine_by_slug(slug=None): - """ - Returns: - A speaker implementation available on the current platform - - Raises: - ValueError if no speaker implementation is supported on this platform - """ - - if not slug or type(slug) is not str: - raise TypeError("Invalid slug '%s'", slug) - - selected_engines = filter(lambda engine: hasattr(engine, "SLUG") and - engine.SLUG == slug, get_engines()) - if len(selected_engines) == 0: - raise ValueError("No TTS engine found for slug '%s'" % slug) - else: - if len(selected_engines) > 1: - print("WARNING: Multiple TTS engines found for slug '%s'. " + - "This is most certainly a bug." % slug) - engine = selected_engines[0] - if not engine.is_available(): - raise ValueError(("TTS engine '%s' is not available (due to " + - "missing dependencies, etc.)") % slug) - return engine - - -def get_engines(): - def get_subclasses(cls): - subclasses = set() - for subclass in cls.__subclasses__(): - subclasses.add(subclass) - subclasses.update(get_subclasses(subclass)) - return subclasses - return [tts_engine for tts_engine in - list(get_subclasses(AbstractTTSEngine)) - if hasattr(tts_engine, 'SLUG') and tts_engine.SLUG] - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description='Jasper TTS module') - parser.add_argument('--debug', action='store_true', - help='Show debug messages') - args = parser.parse_args() - - logging.basicConfig() - if args.debug: - logger = logging.getLogger(__name__) - logger.setLevel(logging.DEBUG) - - engines = get_engines() - available_engines = [] - for engine in get_engines(): - if engine.is_available(): - available_engines.append(engine) - disabled_engines = list(set(engines).difference(set(available_engines))) - print("Available TTS engines:") - for i, engine in enumerate(available_engines, start=1): - print("%d. %s" % (i, engine.SLUG)) - - print("") - print("Disabled TTS engines:") - - for i, engine in enumerate(disabled_engines, start=1): - print("%d. %s" % (i, engine.SLUG)) - - print("") - for i, engine in enumerate(available_engines, start=1): - print("%d. Testing engine '%s'..." % (i, engine.SLUG)) - engine.get_instance().say("This is a test.") - print("Done.") diff --git a/client/vocabcompiler.py b/client/vocabcompiler.py deleted file mode 100644 index 0f8a648a5..000000000 --- a/client/vocabcompiler.py +++ /dev/null @@ -1,563 +0,0 @@ -# -*- coding: utf-8-*- -""" -Iterates over all the WORDS variables in the modules and creates a -vocabulary for the respective stt_engine if needed. -""" - -import os -import tempfile -import logging -import hashlib -import subprocess -import tarfile -import re -import contextlib -import shutil -from abc import ABCMeta, abstractmethod, abstractproperty -import yaml - -import brain -import jasperpath - -from g2p import PhonetisaurusG2P -try: - import cmuclmtk -except ImportError: - logging.getLogger(__name__).error("Error importing CMUCLMTK module. " + - "PocketsphinxVocabulary will not work " + - "correctly.", exc_info=True) - - -class AbstractVocabulary(object): - """ - Abstract base class for Vocabulary classes. - - Please note that subclasses have to implement the compile_vocabulary() - method and set a string as the PATH_PREFIX class attribute. - """ - __metaclass__ = ABCMeta - - @classmethod - def phrases_to_revision(cls, phrases): - """ - Calculates a revision from phrases by using the SHA1 hash function. - - Arguments: - phrases -- a list of phrases - - Returns: - A revision string for given phrases. - """ - sorted_phrases = sorted(phrases) - joined_phrases = '\n'.join(sorted_phrases) - sha1 = hashlib.sha1() - sha1.update(joined_phrases) - return sha1.hexdigest() - - def __init__(self, name='default', path='.'): - """ - Initializes a new Vocabulary instance. - - Optional Arguments: - name -- (optional) the name of the vocabulary (Default: 'default') - path -- (optional) the path in which the vocabulary exists or will - be created (Default: '.') - """ - self.name = name - self.path = os.path.abspath(os.path.join(path, self.PATH_PREFIX, name)) - self._logger = logging.getLogger(__name__) - - @property - def revision_file(self): - """ - Returns: - The path of the the revision file as string - """ - return os.path.join(self.path, 'revision') - - @abstractproperty - def is_compiled(self): - """ - Checks if the vocabulary is compiled by checking if the revision file - is readable. This method should be overridden by subclasses to check - for class-specific additional files, too. - - Returns: - True if the dictionary is compiled, else False - """ - return os.access(self.revision_file, os.R_OK) - - @property - def compiled_revision(self): - """ - Reads the compiled revision from the revision file. - - Returns: - the revision of this vocabulary (i.e. the string - inside the revision file), or None if is_compiled - if False - """ - if not self.is_compiled: - return None - with open(self.revision_file, 'r') as f: - revision = f.read().strip() - self._logger.debug("compiled_revision is '%s'", revision) - return revision - - def matches_phrases(self, phrases): - """ - Convenience method to check if this vocabulary exactly contains the - phrases passed to this method. - - Arguments: - phrases -- a list of phrases - - Returns: - True if phrases exactly matches the phrases inside this - vocabulary. - - """ - return (self.compiled_revision == self.phrases_to_revision(phrases)) - - def compile(self, phrases, force=False): - """ - Compiles this vocabulary. If the force argument is True, compilation - will be forced regardless of necessity (which means that the - preliminary check if the current revision already equals the - revision after compilation will be skipped). - This method is not meant to be overridden by subclasses - use the - _compile_vocabulary()-method instead. - - Arguments: - phrases -- a list of phrases that this vocabulary will contain - force -- (optional) forces compilation (Default: False) - - Returns: - The revision of the compiled vocabulary - """ - revision = self.phrases_to_revision(phrases) - if not force and self.compiled_revision == revision: - self._logger.debug('Compilation not neccessary, compiled ' + - 'version matches phrases.') - return revision - - if not os.path.exists(self.path): - self._logger.debug("Vocabulary dir '%s' does not exist, " + - "creating...", self.path) - try: - os.makedirs(self.path) - except OSError: - self._logger.error("Couldn't create vocabulary dir '%s'", - self.path, exc_info=True) - raise - try: - with open(self.revision_file, 'w') as f: - f.write(revision) - except (OSError, IOError): - self._logger.error("Couldn't write revision file in '%s'", - self.revision_file, exc_info=True) - raise - else: - self._logger.info('Starting compilation...') - try: - self._compile_vocabulary(phrases) - except Exception as e: - self._logger.error("Fatal compilation Error occured, " + - "cleaning up...", exc_info=True) - try: - os.remove(self.revision_file) - except OSError: - pass - raise e - else: - self._logger.info('Compilation done.') - return revision - - @abstractmethod - def _compile_vocabulary(self, phrases): - """ - Abstract method that should be overridden in subclasses with custom - compilation code. - - Arguments: - phrases -- a list of phrases that this vocabulary will contain - """ - - -class DummyVocabulary(AbstractVocabulary): - - PATH_PREFIX = 'dummy-vocabulary' - - @property - def is_compiled(self): - """ - Checks if the vocabulary is compiled by checking if the revision - file is readable. - - Returns: - True if this vocabulary has been compiled, else False - """ - return super(self.__class__, self).is_compiled - - def _compile_vocabulary(self, phrases): - """ - Does nothing (because this is a dummy class for testing purposes). - """ - pass - - -class PocketsphinxVocabulary(AbstractVocabulary): - - PATH_PREFIX = 'pocketsphinx-vocabulary' - - @property - def languagemodel_file(self): - """ - Returns: - The path of the the pocketsphinx languagemodel file as string - """ - return os.path.join(self.path, 'languagemodel') - - @property - def dictionary_file(self): - """ - Returns: - The path of the pocketsphinx dictionary file as string - """ - return os.path.join(self.path, 'dictionary') - - @property - def is_compiled(self): - """ - Checks if the vocabulary is compiled by checking if the revision, - languagemodel and dictionary files are readable. - - Returns: - True if this vocabulary has been compiled, else False - """ - return (super(self.__class__, self).is_compiled and - os.access(self.languagemodel_file, os.R_OK) and - os.access(self.dictionary_file, os.R_OK)) - - @property - def decoder_kwargs(self): - """ - Convenience property to use this Vocabulary with the __init__() method - of the pocketsphinx.Decoder class. - - Returns: - A dict containing kwargs for the pocketsphinx.Decoder.__init__() - method. - - Example: - decoder = pocketsphinx.Decoder(**vocab_instance.decoder_kwargs, - hmm='/path/to/hmm') - - """ - return {'lm': self.languagemodel_file, 'dict': self.dictionary_file} - - def _compile_vocabulary(self, phrases): - """ - Compiles the vocabulary to the Pocketsphinx format by creating a - languagemodel and a dictionary. - - Arguments: - phrases -- a list of phrases that this vocabulary will contain - """ - text = " ".join([(" %s " % phrase) for phrase in phrases]) - self._logger.debug('Compiling languagemodel...') - vocabulary = self._compile_languagemodel(text, self.languagemodel_file) - self._logger.debug('Starting dictionary...') - self._compile_dictionary(vocabulary, self.dictionary_file) - - def _compile_languagemodel(self, text, output_file): - """ - Compiles the languagemodel from a text. - - Arguments: - text -- the text the languagemodel will be generated from - output_file -- the path of the file this languagemodel will - be written to - - Returns: - A list of all unique words this vocabulary contains. - """ - with tempfile.NamedTemporaryFile(suffix='.vocab', delete=False) as f: - vocab_file = f.name - - # Create vocab file from text - self._logger.debug("Creating vocab file: '%s'", vocab_file) - cmuclmtk.text2vocab(text, vocab_file) - - # Create language model from text - self._logger.debug("Creating languagemodel file: '%s'", output_file) - cmuclmtk.text2lm(text, output_file, vocab_file=vocab_file) - - # Get words from vocab file - self._logger.debug("Getting words from vocab file and removing it " + - "afterwards...") - words = [] - with open(vocab_file, 'r') as f: - for line in f: - line = line.strip() - if not line.startswith('#') and line not in ('', ''): - words.append(line) - os.remove(vocab_file) - - return words - - def _compile_dictionary(self, words, output_file): - """ - Compiles the dictionary from a list of words. - - Arguments: - words -- a list of all unique words this vocabulary contains - output_file -- the path of the file this dictionary will - be written to - """ - # create the dictionary - self._logger.debug("Getting phonemes for %d words...", len(words)) - g2pconverter = PhonetisaurusG2P(**PhonetisaurusG2P.get_config()) - phonemes = g2pconverter.translate(words) - - self._logger.debug("Creating dict file: '%s'", output_file) - with open(output_file, "w") as f: - for word, pronounciations in phonemes.items(): - for i, pronounciation in enumerate(pronounciations, start=1): - if i == 1: - line = "%s\t%s\n" % (word, pronounciation) - else: - line = "%s(%d)\t%s\n" % (word, i, pronounciation) - f.write(line) - - -class JuliusVocabulary(AbstractVocabulary): - class VoxForgeLexicon(object): - def __init__(self, fname, membername=None): - self._dict = {} - self.parse(fname, membername) - - @contextlib.contextmanager - def open_dict(self, fname, membername=None): - if tarfile.is_tarfile(fname): - if not membername: - raise ValueError('archive membername not set!') - tf = tarfile.open(fname) - f = tf.extractfile(membername) - yield f - f.close() - tf.close() - else: - with open(fname) as f: - yield f - - def parse(self, fname, membername=None): - pattern = re.compile(r'\[(.+)\]\W(.+)') - with self.open_dict(fname, membername=membername) as f: - for line in f: - matchobj = pattern.search(line) - if matchobj: - word, phoneme = [x.strip() for x in matchobj.groups()] - if word in self._dict: - self._dict[word].append(phoneme) - else: - self._dict[word] = [phoneme] - - def translate_word(self, word): - if word in self._dict: - return self._dict[word] - else: - return [] - - PATH_PREFIX = 'julius-vocabulary' - - @property - def dfa_file(self): - """ - Returns: - The path of the the julius dfa file as string - """ - return os.path.join(self.path, 'dfa') - - @property - def dict_file(self): - """ - Returns: - The path of the the julius dict file as string - """ - return os.path.join(self.path, 'dict') - - @property - def is_compiled(self): - return (super(self.__class__, self).is_compiled and - os.access(self.dfa_file, os.R_OK) and - os.access(self.dict_file, os.R_OK)) - - def _get_grammar(self, phrases): - return {'S': [['NS_B', 'WORD_LOOP', 'NS_E']], - 'WORD_LOOP': [['WORD_LOOP', 'WORD'], ['WORD']]} - - def _get_word_defs(self, lexicon, phrases): - word_defs = {'NS_B': [('', 'sil')], - 'NS_E': [('', 'sil')], - 'WORD': []} - - words = [] - for phrase in phrases: - if ' ' in phrase: - for word in phrase.split(' '): - words.append(word) - else: - words.append(phrase) - - for word in words: - for phoneme in lexicon.translate_word(word): - word_defs['WORD'].append((word, phoneme)) - return word_defs - - def _compile_vocabulary(self, phrases): - prefix = 'jasper' - tmpdir = tempfile.mkdtemp() - - lexicon_file = jasperpath.data('julius-stt', 'VoxForge.tgz') - lexicon_archive_member = 'VoxForge/VoxForgeDict' - profile_path = jasperpath.config('profile.yml') - if os.path.exists(profile_path): - with open(profile_path, 'r') as f: - profile = yaml.safe_load(f) - if 'julius' in profile: - if 'lexicon' in profile['julius']: - lexicon_file = profile['julius']['lexicon'] - if 'lexicon_archive_member' in profile['julius']: - lexicon_archive_member = \ - profile['julius']['lexicon_archive_member'] - - lexicon = JuliusVocabulary.VoxForgeLexicon(lexicon_file, - lexicon_archive_member) - - # Create grammar file - tmp_grammar_file = os.path.join(tmpdir, - os.extsep.join([prefix, 'grammar'])) - with open(tmp_grammar_file, 'w') as f: - grammar = self._get_grammar(phrases) - for definition in grammar.pop('S'): - f.write("%s: %s\n" % ('S', ' '.join(definition))) - for name, definitions in grammar.items(): - for definition in definitions: - f.write("%s: %s\n" % (name, ' '.join(definition))) - - # Create voca file - tmp_voca_file = os.path.join(tmpdir, os.extsep.join([prefix, 'voca'])) - with open(tmp_voca_file, 'w') as f: - for category, words in self._get_word_defs(lexicon, - phrases).items(): - f.write("%% %s\n" % category) - for word, phoneme in words: - f.write("%s\t\t\t%s\n" % (word, phoneme)) - - # mkdfa.pl - olddir = os.getcwd() - os.chdir(tmpdir) - cmd = ['mkdfa.pl', str(prefix)] - with tempfile.SpooledTemporaryFile() as out_f: - subprocess.call(cmd, stdout=out_f, stderr=out_f) - out_f.seek(0) - for line in out_f.read().splitlines(): - line = line.strip() - if line: - self._logger.debug(line) - os.chdir(olddir) - - tmp_dfa_file = os.path.join(tmpdir, os.extsep.join([prefix, 'dfa'])) - tmp_dict_file = os.path.join(tmpdir, os.extsep.join([prefix, 'dict'])) - shutil.move(tmp_dfa_file, self.dfa_file) - shutil.move(tmp_dict_file, self.dict_file) - - shutil.rmtree(tmpdir) - - -def get_phrases_from_module(module): - """ - Gets phrases from a module. - - Arguments: - module -- a module reference - - Returns: - The list of phrases in this module. - """ - return module.WORDS if hasattr(module, 'WORDS') else [] - - -def get_keyword_phrases(): - """ - Gets the keyword phrases from the keywords file in the jasper data dir. - - Returns: - A list of keyword phrases. - """ - phrases = [] - - with open(jasperpath.data('keyword_phrases'), mode="r") as f: - for line in f: - phrase = line.strip() - if phrase: - phrases.append(phrase) - - return phrases - - -def get_all_phrases(): - """ - Gets phrases from all modules. - - Returns: - A list of phrases in all modules plus additional phrases passed to this - function. - """ - phrases = [] - - modules = brain.Brain.get_modules() - for module in modules: - phrases.extend(get_phrases_from_module(module)) - - return sorted(list(set(phrases))) - -if __name__ == '__main__': - import argparse - - parser = argparse.ArgumentParser(description='Vocabcompiler Demo') - parser.add_argument('--base-dir', action='store', - help='the directory in which the vocabulary will be ' + - 'compiled.') - parser.add_argument('--debug', action='store_true', - help='show debug messages') - args = parser.parse_args() - - logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) - base_dir = args.base_dir if args.base_dir else tempfile.mkdtemp() - - phrases = get_all_phrases() - print("Module phrases: %r" % phrases) - - for subclass in AbstractVocabulary.__subclasses__(): - if hasattr(subclass, 'PATH_PREFIX'): - vocab = subclass(path=base_dir) - print("Vocabulary in: %s" % vocab.path) - print("Revision file: %s" % vocab.revision_file) - print("Compiled revision: %s" % vocab.compiled_revision) - print("Is compiled: %r" % vocab.is_compiled) - print("Matches phrases: %r" % vocab.matches_phrases(phrases)) - if not vocab.is_compiled or not vocab.matches_phrases(phrases): - print("Compiling...") - vocab.compile(phrases) - print("") - print("Vocabulary in: %s" % vocab.path) - print("Revision file: %s" % vocab.revision_file) - print("Compiled revision: %s" % vocab.compiled_revision) - print("Is compiled: %r" % vocab.is_compiled) - print("Matches phrases: %r" % vocab.matches_phrases(phrases)) - print("") - if not args.base_dir: - print("Removing temporary directory '%s'..." % base_dir) - shutil.rmtree(base_dir) diff --git a/jasper.py b/jasper.py deleted file mode 100755 index 5056e3eeb..000000000 --- a/jasper.py +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8-*- - -import os -import sys -import shutil -import logging - -import yaml -import argparse - -from client import tts -from client import stt -from client import jasperpath -from client import diagnose -from client.conversation import Conversation - -# Add jasperpath.LIB_PATH to sys.path -sys.path.append(jasperpath.LIB_PATH) - -parser = argparse.ArgumentParser(description='Jasper Voice Control Center') -parser.add_argument('--local', action='store_true', - help='Use text input instead of a real microphone') -parser.add_argument('--no-network-check', action='store_true', - help='Disable the network connection check') -parser.add_argument('--diagnose', action='store_true', - help='Run diagnose and exit') -parser.add_argument('--debug', action='store_true', help='Show debug messages') -args = parser.parse_args() - -if args.local: - from client.local_mic import Mic -else: - from client.mic import Mic - - -class Jasper(object): - def __init__(self): - self._logger = logging.getLogger(__name__) - - # Create config dir if it does not exist yet - if not os.path.exists(jasperpath.CONFIG_PATH): - try: - os.makedirs(jasperpath.CONFIG_PATH) - except OSError: - self._logger.error("Could not create config dir: '%s'", - jasperpath.CONFIG_PATH, exc_info=True) - raise - - # Check if config dir is writable - if not os.access(jasperpath.CONFIG_PATH, os.W_OK): - self._logger.critical("Config dir %s is not writable. Jasper " + - "won't work correctly.", - jasperpath.CONFIG_PATH) - - # FIXME: For backwards compatibility, move old config file to newly - # created config dir - old_configfile = os.path.join(jasperpath.LIB_PATH, 'profile.yml') - new_configfile = jasperpath.config('profile.yml') - if os.path.exists(old_configfile): - if os.path.exists(new_configfile): - self._logger.warning("Deprecated profile file found: '%s'. " + - "Please remove it.", old_configfile) - else: - self._logger.warning("Deprecated profile file found: '%s'. " + - "Trying to copy it to new location '%s'.", - old_configfile, new_configfile) - try: - shutil.copy2(old_configfile, new_configfile) - except shutil.Error: - self._logger.error("Unable to copy config file. " + - "Please copy it manually.", - exc_info=True) - raise - - # Read config - self._logger.debug("Trying to read config file: '%s'", new_configfile) - try: - with open(new_configfile, "r") as f: - self.config = yaml.safe_load(f) - except OSError: - self._logger.error("Can't open config file: '%s'", new_configfile) - raise - - try: - stt_engine_slug = self.config['stt_engine'] - except KeyError: - stt_engine_slug = 'sphinx' - logger.warning("stt_engine not specified in profile, defaulting " + - "to '%s'", stt_engine_slug) - stt_engine_class = stt.get_engine_by_slug(stt_engine_slug) - - try: - slug = self.config['stt_passive_engine'] - stt_passive_engine_class = stt.get_engine_by_slug(slug) - except KeyError: - stt_passive_engine_class = stt_engine_class - - try: - tts_engine_slug = self.config['tts_engine'] - except KeyError: - tts_engine_slug = tts.get_default_engine_slug() - logger.warning("tts_engine not specified in profile, defaulting " + - "to '%s'", tts_engine_slug) - tts_engine_class = tts.get_engine_by_slug(tts_engine_slug) - - # Initialize Mic - self.mic = Mic(tts_engine_class.get_instance(), - stt_passive_engine_class.get_passive_instance(), - stt_engine_class.get_active_instance()) - - def run(self): - if 'first_name' in self.config: - salutation = ("How can I be of service, %s?" - % self.config["first_name"]) - else: - salutation = "How can I be of service?" - self.mic.say(salutation) - - conversation = Conversation("JASPER", self.mic, self.config) - conversation.handleForever() - -if __name__ == "__main__": - - print("*******************************************************") - print("* JASPER - THE TALKING COMPUTER *") - print("* (c) 2015 Shubhro Saha, Charlie Marsh & Jan Holthuis *") - print("*******************************************************") - - logging.basicConfig() - logger = logging.getLogger() - logger.getChild("client.stt").setLevel(logging.INFO) - - if args.debug: - logger.setLevel(logging.DEBUG) - - if not args.no_network_check and not diagnose.check_network_connection(): - logger.warning("Network not connected. This may prevent Jasper from " + - "running properly.") - - if args.diagnose: - failed_checks = diagnose.run() - sys.exit(0 if not failed_checks else 1) - - try: - app = Jasper() - except Exception: - logger.error("Error occured!", exc_info=True) - sys.exit(1) - - app.run() diff --git a/judy.py b/judy.py new file mode 100644 index 000000000..891cb312a --- /dev/null +++ b/judy.py @@ -0,0 +1,113 @@ +import threading +import subprocess +import re +import os +import time +import tempfile +import Queue as queue + +class VoiceIn(threading.Thread): + def __init__(self, adcdev, lm, dict): + super(VoiceIn, self).__init__() + self._params = (adcdev, lm, dict) + self._listening = False + self.phrase_queue = queue.Queue() + + def listen(self, y): + self._listening = y + + def run(self): + # Thanks to the question and answers: + # http://stackoverflow.com/questions/4417546/constantly-print-subprocess-output-while-process-is-running + def execute(cmd): + popen = subprocess.Popen(cmd, stdout=subprocess.PIPE, universal_newlines=True) + stdout_lines = iter(popen.stdout.readline, "") + for stdout_line in stdout_lines: + yield stdout_line + + popen.stdout.close() + return_code = popen.wait() + if return_code != 0: + raise subprocess.CalledProcessError(return_code, cmd) + + cmd = 'pocketsphinx_continuous -adcdev %s -lm %s -dict %s -inmic yes' % self._params + pattern = re.compile('^[0-9]{9}: (.+)') # lines starting with 9 digits + + for out in execute(cmd.split(' ')): + if self._listening: + m = pattern.match(out) + if m: + phrase = m.group(1).strip() + if phrase: + self.phrase_queue.put(phrase) + +class VoiceOut(object): + def __init__(self, device, resources): + self._device = device + + if isinstance(resources, dict): + self._resources = resources + else: + self._resources = {'beep_hi': os.path.join(resources, 'beep_hi.wav'), + 'beep_lo': os.path.join(resources, 'beep_lo.wav')} + + def play(self, path): + cmd = ['aplay', '-D', self._device, path] + subprocess.call(cmd) + + def beep(self, high): + f = self._resources['beep_hi' if high else 'beep_lo'] + self.play(f) + + def say(self, phrase): + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: + fname = f.name + + cmd = ['pico2wave', '--wave', fname, phrase.lower()] + # Ensure lowercase because consecutive uppercases + # sometimes cause it to spell out the letters. + subprocess.call(cmd) + + self.play(fname) + os.remove(fname) + +def listen(vin, vout, callback, + callsign='Judy', attention_span=10, forever=True): + vin.daemon = True + vin.start() + + def loop(): + while 1: + vin.listen(True) + ph = vin.phrase_queue.get(block=True) + + # Does the phrase end with my name? + if ph.split(' ')[-1] == callsign.upper(): + vout.beep(1) # high beep + + try: + ph = vin.phrase_queue.get(block=True, timeout=attention_span) + except queue.Empty: + vout.beep(0) # low beep + else: + vout.beep(0) # low beep + + # Ignore further speech. Flush existing phrases. + vin.listen(False) + while not vin.phrase_queue.empty(): + vin.phrase_queue.get(block=False) + + callback(ph) + + # Have to put loop() in a thread. If I call loop() from within main thread, + # Ctrl-C cannot kill the process because it hangs at queue.get(). + t = threading.Thread(target=loop) + t.daemon = True + t.start() + + if forever: + if type(forever) is str: + print forever + + while 1: + time.sleep(10) diff --git a/static/audio/beep_hi.wav b/resources/audio/beep_hi.wav similarity index 100% rename from static/audio/beep_hi.wav rename to resources/audio/beep_hi.wav diff --git a/static/audio/beep_lo.wav b/resources/audio/beep_lo.wav similarity index 100% rename from static/audio/beep_lo.wav rename to resources/audio/beep_lo.wav diff --git a/resources/lm/0931.dic b/resources/lm/0931.dic new file mode 100644 index 000000000..61c077a4f --- /dev/null +++ b/resources/lm/0931.dic @@ -0,0 +1,31 @@ +ARE AA R +ARE(2) ER +DATE D EY T +FRIDAY F R AY D IY +FRIDAY(2) F R AY D EY +HOW HH AW +JASPER JH AE S P ER +JOHNNY JH AA N IY +JUDY JH UW D IY +MONDAY M AH N D IY +MONDAY(2) M AH N D EY +NEXT N EH K S T +NEXT(2) N EH K S +SATURDAY S AE T ER D IY +SATURDAY(2) S AE T IH D EY +SUNDAY S AH N D EY +SUNDAY(2) S AH N D IY +THURSDAY TH ER Z D EY +THURSDAY(2) TH ER Z D IY +TIME T AY M +TODAY T AH D EY +TODAY(2) T UW D EY +TOMORROW T AH M AA R OW +TOMORROW(2) T UW M AA R OW +TUESDAY T UW Z D IY +TUESDAY(2) T UW Z D EY +TUESDAY(3) T Y UW Z D EY +WEATHER W EH DH ER +WEDNESDAY W EH N Z D IY +WEDNESDAY(2) W EH N Z D EY +YOU Y UW diff --git a/resources/lm/0931.lm b/resources/lm/0931.lm new file mode 100644 index 000000000..e45c61b54 --- /dev/null +++ b/resources/lm/0931.lm @@ -0,0 +1,98 @@ +Language model created by QuickLM on Thu Sep 1 04:41:38 EDT 2016 +Copyright (c) 1996-2010 Carnegie Mellon University and Alexander I. Rudnicky + +The model is in standard ARPA format, designed by Doug Paul while he was at MITRE. + +The code that was used to produce this language model is available in Open Source. +Please visit http://www.speech.cs.cmu.edu/tools/ for more information + +The (fixed) discount mass is 0.5. The backoffs are computed using the ratio method. +This model based on a corpus of 16 sentences and 21 words + +\data\ +ngram 1=21 +ngram 2=35 +ngram 3=19 + +\1-grams: +-0.8045 -0.3010 +-0.8045 -0.2269 +-2.0086 ARE -0.2968 +-2.0086 DATE -0.2269 +-2.0086 FRIDAY -0.2269 +-2.0086 HOW -0.2968 +-2.0086 JASPER -0.2269 +-2.0086 JOHNNY -0.2269 +-2.0086 JUDY -0.2269 +-2.0086 MONDAY -0.2269 +-2.0086 NEXT -0.2269 +-2.0086 SATURDAY -0.2269 +-2.0086 SUNDAY -0.2269 +-2.0086 THURSDAY -0.2269 +-2.0086 TIME -0.2269 +-2.0086 TODAY -0.2269 +-2.0086 TOMORROW -0.2269 +-2.0086 TUESDAY -0.2269 +-2.0086 WEATHER -0.2269 +-2.0086 WEDNESDAY -0.2269 +-2.0086 YOU -0.2968 + +\2-grams: +-1.5051 DATE 0.0000 +-1.5051 FRIDAY 0.0000 +-1.5051 HOW 0.0000 +-1.5051 JASPER 0.0000 +-1.5051 JOHNNY 0.0000 +-1.5051 JUDY 0.0000 +-1.5051 MONDAY 0.0000 +-1.5051 NEXT 0.0000 +-1.5051 SATURDAY 0.0000 +-1.5051 SUNDAY 0.0000 +-1.5051 THURSDAY 0.0000 +-1.5051 TIME 0.0000 +-1.5051 TOMORROW 0.0000 +-1.5051 TUESDAY 0.0000 +-1.5051 WEATHER 0.0000 +-1.5051 WEDNESDAY 0.0000 +-0.3010 ARE YOU 0.0000 +-0.3010 DATE -0.3010 +-0.3010 FRIDAY -0.3010 +-0.3010 HOW ARE 0.0000 +-0.3010 JASPER -0.3010 +-0.3010 JOHNNY -0.3010 +-0.3010 JUDY -0.3010 +-0.3010 MONDAY -0.3010 +-0.3010 NEXT -0.3010 +-0.3010 SATURDAY -0.3010 +-0.3010 SUNDAY -0.3010 +-0.3010 THURSDAY -0.3010 +-0.3010 TIME -0.3010 +-0.3010 TODAY -0.3010 +-0.3010 TOMORROW -0.3010 +-0.3010 TUESDAY -0.3010 +-0.3010 WEATHER -0.3010 +-0.3010 WEDNESDAY -0.3010 +-0.3010 YOU TODAY 0.0000 + +\3-grams: +-0.3010 DATE +-0.3010 FRIDAY +-0.3010 HOW ARE +-0.3010 JASPER +-0.3010 JOHNNY +-0.3010 JUDY +-0.3010 MONDAY +-0.3010 NEXT +-0.3010 SATURDAY +-0.3010 SUNDAY +-0.3010 THURSDAY +-0.3010 TIME +-0.3010 TOMORROW +-0.3010 TUESDAY +-0.3010 WEATHER +-0.3010 WEDNESDAY +-0.3010 ARE YOU TODAY +-0.3010 HOW ARE YOU +-0.3010 YOU TODAY + +\end\ diff --git a/resources/lm/0931.log_pronounce b/resources/lm/0931.log_pronounce new file mode 100644 index 000000000..c72880d8d --- /dev/null +++ b/resources/lm/0931.log_pronounce @@ -0,0 +1,20 @@ +ARE - Main +DATE - Main +FRIDAY - Main +HOW - Main +JASPER - Main +JOHNNY - Main +JUDY - Main +MONDAY - Main +NEXT - Main +SATURDAY - Main +SUNDAY - Main +THURSDAY - Main +TIME - Main +TODAY - Main +TOMORROW - Main +TUESDAY - Main +WEATHER - Main +WEDNESDAY - Main +YOU - Main + diff --git a/resources/lm/0931.sent b/resources/lm/0931.sent new file mode 100644 index 000000000..89647d338 --- /dev/null +++ b/resources/lm/0931.sent @@ -0,0 +1,16 @@ + HOW ARE YOU TODAY + TOMORROW + WEATHER + JUDY + JASPER + JOHNNY + TIME + DATE + NEXT + MONDAY + TUESDAY + WEDNESDAY + THURSDAY + FRIDAY + SATURDAY + SUNDAY diff --git a/resources/lm/0931.vocab b/resources/lm/0931.vocab new file mode 100644 index 000000000..891cf2f0a --- /dev/null +++ b/resources/lm/0931.vocab @@ -0,0 +1,19 @@ +ARE +DATE +FRIDAY +HOW +JASPER +JOHNNY +JUDY +MONDAY +NEXT +SATURDAY +SUNDAY +THURSDAY +TIME +TODAY +TOMORROW +TUESDAY +WEATHER +WEDNESDAY +YOU diff --git a/resources/lm/sentences.txt b/resources/lm/sentences.txt new file mode 100644 index 000000000..c6e7a66bf --- /dev/null +++ b/resources/lm/sentences.txt @@ -0,0 +1,16 @@ +How are you today +tomorrow +weather +Judy +Jasper +Johnny +time +date +next +Monday +Tuesday +Wednesday +Thursday +Friday +Saturday +Sunday diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..26d6ae27b --- /dev/null +++ b/setup.py @@ -0,0 +1,47 @@ +from setuptools import setup + +setup( + name='jasper-judy', + py_modules = ['judy'], + + version='0.1', + + description='Simple Voice Control on Raspberry Pi', + + long_description='', + + # The project's main homepage. + url='https://github.com/nickoala/judy', + + # Author details + author='Nick Lee', + author_email='lee1nick@yahoo.ca', + + # Choose your license + license='MIT', + + # See https://pypi.python.org/pypi?%3Aaction=list_classifiers + classifiers=[ + # How mature is this project? Common values are + # 3 - Alpha + # 4 - Beta + # 5 - Production/Stable + 'Development Status :: 4 - Beta', + + # Indicate who your project is intended for + 'Intended Audience :: Education', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: Multimedia :: Sound/Audio :: Speech', + 'Topic :: Education', + + # Pick your license as you wish (should match "license" above) + 'License :: OSI Approved :: MIT License', + + # Specify the Python versions you support here. In particular, ensure + # that you indicate whether you support Python 2, Python 3 or both. + 'Programming Language :: Python :: 2.7', + ], + + # What does your project relate to? + keywords='speech recognition voice control raspberry pi', +) diff --git a/static/audio/jasper.wav b/static/audio/jasper.wav deleted file mode 100644 index 707235b49095b4976d9477e804284c78e841bc10..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 71724 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

KGW?b!NH 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 diff --git a/static/audio/say.wav b/static/audio/say.wav deleted file mode 100644 index d27c7dc2091cac187a1057ce5911b16b69842e77..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 151944 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

!E9EPVxHXlNmHK;&J)y)i2f< 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%0D4y;wa7X^VVjT*B|%SY3EQ9Z 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 diff --git a/static/dictionary_persona.dic b/static/dictionary_persona.dic deleted file mode 100644 index 8b813ac12..000000000 --- a/static/dictionary_persona.dic +++ /dev/null @@ -1,22 +0,0 @@ -BE B IY -BEING B IY IH NG -BUT B AH T -DID D IH D -FIRST F ER S T -IN IH N -IS IH Z -IT IH T -JASPER JH AE S P ER -NOW N AW -OF AH V -ON AA N -ON(2) AO N -RIGHT R AY T -SAY S EY -WHAT W AH T -WHAT(2) HH W AH T -WHICH W IH CH -WHICH(2) HH W IH CH -WITH W IH DH -WITH(2) W IH TH -WORK W ER K \ No newline at end of file diff --git a/static/keyword_phrases b/static/keyword_phrases deleted file mode 100644 index d118b13ac..000000000 --- a/static/keyword_phrases +++ /dev/null @@ -1,18 +0,0 @@ -BE -BEING -BUT -DID -FIRST -IN -IS -IT -JASPER -NOW -OF -ON -RIGHT -SAY -WHAT -WHICH -WITH -WORK \ No newline at end of file diff --git a/static/languagemodel_persona.lm b/static/languagemodel_persona.lm deleted file mode 100644 index f290a31d5..000000000 --- a/static/languagemodel_persona.lm +++ /dev/null @@ -1,97 +0,0 @@ -Language model created by QuickLM on Wed Sep 4 14:21:33 EDT 2013 -Copyright (c) 1996-2010 Carnegie Mellon University and Alexander I. Rudnicky - -The model is in standard ARPA format, designed by Doug Paul while he was at MITRE. - -The code that was used to produce this language model is available in Open Source. -Please visit http://www.speech.cs.cmu.edu/tools/ for more information - -The (fixed) discount mass is 0.5. The backoffs are computed using the ratio method. -This model based on a corpus of 18 sentences and 20 words - -\data\ -ngram 1=20 -ngram 2=36 -ngram 3=18 - -\1-grams: --0.7782 -0.3010 --0.7782 -0.2218 --2.0334 BE -0.2218 --2.0334 BEING -0.2218 --2.0334 BUT -0.2218 --2.0334 DID -0.2218 --2.0334 FIRST -0.2218 --2.0334 IN -0.2218 --2.0334 IS -0.2218 --2.0334 IT -0.2218 --2.0334 JASPER -0.2218 --2.0334 NOW -0.2218 --2.0334 OF -0.2218 --2.0334 ON -0.2218 --2.0334 RIGHT -0.2218 --2.0334 SAY -0.2218 --2.0334 WHAT -0.2218 --2.0334 WHICH -0.2218 --2.0334 WITH -0.2218 --2.0334 WORK -0.2218 - -\2-grams: --1.5563 BE 0.0000 --1.5563 BEING 0.0000 --1.5563 BUT 0.0000 --1.5563 DID 0.0000 --1.5563 FIRST 0.0000 --1.5563 IN 0.0000 --1.5563 IS 0.0000 --1.5563 IT 0.0000 --1.5563 JASPER 0.0000 --1.5563 NOW 0.0000 --1.5563 OF 0.0000 --1.5563 ON 0.0000 --1.5563 RIGHT 0.0000 --1.5563 SAY 0.0000 --1.5563 WHAT 0.0000 --1.5563 WHICH 0.0000 --1.5563 WITH 0.0000 --1.5563 WORK 0.0000 --0.3010 BE -0.3010 --0.3010 BEING -0.3010 --0.3010 BUT -0.3010 --0.3010 DID -0.3010 --0.3010 FIRST -0.3010 --0.3010 IN -0.3010 --0.3010 IS -0.3010 --0.3010 IT -0.3010 --0.3010 JASPER -0.3010 --0.3010 NOW -0.3010 --0.3010 OF -0.3010 --0.3010 ON -0.3010 --0.3010 RIGHT -0.3010 --0.3010 SAY -0.3010 --0.3010 WHAT -0.3010 --0.3010 WHICH -0.3010 --0.3010 WITH -0.3010 --0.3010 WORK -0.3010 - -\3-grams: --0.3010 BE --0.3010 BEING --0.3010 BUT --0.3010 DID --0.3010 FIRST --0.3010 IN --0.3010 IS --0.3010 IT --0.3010 JASPER --0.3010 NOW --0.3010 OF --0.3010 ON --0.3010 RIGHT --0.3010 SAY --0.3010 WHAT --0.3010 WHICH --0.3010 WITH --0.3010 WORK - -\end\ \ No newline at end of file diff --git a/static/text/JOKES.txt b/static/text/JOKES.txt deleted file mode 100644 index adfb0e3ae..000000000 --- a/static/text/JOKES.txt +++ /dev/null @@ -1,47 +0,0 @@ -little old lady -wow... I didn't know you could yodel... get it... because it sounded like you were yodeling - -oink oink -make up your mind... are you a pig or an owl... get it... because an owl goes who but a pig goes oink... oink - -cows go -no... cows go moo... didn't you know - -jarvis -jarvis... it's me you idiot - -me -no... seriously... it's just me... I was telling a knock knock joke ha ha ha - -madam -my damn foot got stuck in the door, so open it he he he... but actually... I think I hurt my foot - -doris -door is locked... that's why I'm knocking, you idiot - -cash -no thanks... but I would like a peanut instead... get it? because you said cashew... ha ha ha - -orange -orange you glad I am your friend - -alex -I'll ask the questions around here, thank you - -viper -viper nose... it's running - -canoe -can you scratch my back... it itches - -pete -pizza delivery guy... you idiot - -doctor -that is the best show ever - -arizona -arizona room for only one of us in this room - -spider -in spider what everyone says, I still feel like a human diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/echo.py b/tests/echo.py new file mode 100644 index 000000000..b5e2f6574 --- /dev/null +++ b/tests/echo.py @@ -0,0 +1,14 @@ +import judy + +vin = judy.VoiceIn(adcdev='plughw:1,0', + lm='/home/pi/judy/resources/lm/0931.lm', + dict='/home/pi/judy/resources/lm/0931.dic') + +vout = judy.VoiceOut(device='plughw:0,0', + resources='/home/pi/judy/resources/audio') + +def handle(phrase): + print 'Heard:', phrase + vout.say(phrase) + +judy.listen(vin, vout, handle) diff --git a/tests/test_brain.py b/tests/test_brain.py deleted file mode 100644 index 7b4c48296..000000000 --- a/tests/test_brain.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8-*- -import unittest -import mock -from client import brain, test_mic - - -DEFAULT_PROFILE = { - 'prefers_email': False, - 'location': 'Cape Town', - 'timezone': 'US/Eastern', - 'phone_number': '012344321' -} - - -class TestBrain(unittest.TestCase): - - @staticmethod - def _emptyBrain(): - mic = test_mic.Mic([]) - profile = DEFAULT_PROFILE - return brain.Brain(mic, profile) - - def testLog(self): - """Does Brain correctly log errors when raised by modules?""" - my_brain = TestBrain._emptyBrain() - unclear = my_brain.modules[-1] - with mock.patch.object(unclear, 'handle') as mocked_handle: - with mock.patch.object(my_brain._logger, 'error') as mocked_log: - mocked_handle.side_effect = KeyError('foo') - my_brain.query("zzz gibberish zzz") - self.assertTrue(mocked_log.called) - - def testSortByPriority(self): - """Does Brain sort modules by priority?""" - my_brain = TestBrain._emptyBrain() - priorities = filter(lambda m: hasattr(m, 'PRIORITY'), my_brain.modules) - target = sorted(priorities, key=lambda m: m.PRIORITY, reverse=True) - self.assertEqual(target, priorities) - - def testPriority(self): - """Does Brain correctly send query to higher-priority module?""" - my_brain = TestBrain._emptyBrain() - hn_module = 'HN' - hn = filter(lambda m: m.__name__ == hn_module, my_brain.modules)[0] - - with mock.patch.object(hn, 'handle') as mocked_handle: - my_brain.query(["hacker news"]) - self.assertTrue(mocked_handle.called) diff --git a/tests/test_diagnose.py b/tests/test_diagnose.py deleted file mode 100644 index ba49618d6..000000000 --- a/tests/test_diagnose.py +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8-*- -import unittest -from client import diagnose - - -class TestDiagnose(unittest.TestCase): - def testPythonImportCheck(self): - # This a python stdlib module that definitely exists - self.assertTrue(diagnose.check_python_import("os")) - # I sincerly hope nobody will ever create a package with that name - self.assertFalse(diagnose.check_python_import("nonexistant_package")) diff --git a/tests/test_g2p.py b/tests/test_g2p.py deleted file mode 100644 index 4b5fedb86..000000000 --- a/tests/test_g2p.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8-*- -import unittest -import tempfile -import mock -from client import g2p - - -def phonetisaurus_installed(): - try: - g2p.PhonetisaurusG2P(**g2p.PhonetisaurusG2P.get_config()) - except OSError: - return False - else: - return True - - -WORDS = ['GOOD', 'BAD', 'UGLY'] - - -@unittest.skipUnless(phonetisaurus_installed(), - "Phonetisaurus or fst_model not present") -class TestG2P(unittest.TestCase): - - def setUp(self): - self.g2pconv = g2p.PhonetisaurusG2P( - **g2p.PhonetisaurusG2P.get_config()) - - def testTranslateWord(self): - for word in WORDS: - self.assertIn(word, self.g2pconv.translate(word).keys()) - - def testTranslateWords(self): - results = self.g2pconv.translate(WORDS).keys() - for word in WORDS: - self.assertIn(word, results) - - -class TestPatchedG2P(unittest.TestCase): - class DummyProc(object): - def __init__(self, *args, **kwargs): - self.returncode = 0 - - def communicate(self): - return ("GOOD\t9.20477\t G UH D \n" + - "GOOD\t14.4036\t G UW D \n" + - "GOOD\t16.0258\t G UH D IY \n" + - "BAD\t0.7416\t B AE D \n" + - "BAD\t12.5495\t B AA D \n" + - "BAD\t13.6745\t B AH D \n" + - "UGLY\t12.572\t AH G L IY \n" + - "UGLY\t17.9278\t Y UW G L IY \n" + - "UGLY\t18.9617\t AH G L AY \n", "") - - def setUp(self): - with mock.patch('client.g2p.diagnose.check_executable', - return_value=True): - with tempfile.NamedTemporaryFile() as f: - conf = g2p.PhonetisaurusG2P.get_config().items() - with mock.patch.object(g2p.PhonetisaurusG2P, 'get_config', - classmethod(lambda cls: dict(conf + - [('fst_model', f.name)]))): - self.g2pconv = g2p.PhonetisaurusG2P( - **g2p.PhonetisaurusG2P.get_config()) - - def testTranslateWord(self): - with mock.patch('subprocess.Popen', - return_value=TestPatchedG2P.DummyProc()): - for word in WORDS: - self.assertIn(word, self.g2pconv.translate(word).keys()) - - def testTranslateWords(self): - with mock.patch('subprocess.Popen', - return_value=TestPatchedG2P.DummyProc()): - results = self.g2pconv.translate(WORDS).keys() - for word in WORDS: - self.assertIn(word, results) diff --git a/tests/test_modules.py b/tests/test_modules.py deleted file mode 100644 index 24f1c0dce..000000000 --- a/tests/test_modules.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8-*- -import unittest -from client import test_mic, diagnose, jasperpath -from client.modules import Life, Joke, Time, Gmail, HN, News, Weather - -DEFAULT_PROFILE = { - 'prefers_email': False, - 'location': 'Cape Town', - 'timezone': 'US/Eastern', - 'phone_number': '012344321' -} - - -class TestModules(unittest.TestCase): - - def setUp(self): - self.profile = DEFAULT_PROFILE - self.send = False - - def runConversation(self, query, inputs, module): - """Generic method for spoofing conversation. - - Arguments: - query -- The initial input to the server. - inputs -- Additional input, if conversation is extended. - - Returns: - The server's responses, in a list. - """ - self.assertTrue(module.isValid(query)) - mic = test_mic.Mic(inputs) - module.handle(query, mic, self.profile) - return mic.outputs - - def testLife(self): - query = "What is the meaning of life?" - inputs = [] - outputs = self.runConversation(query, inputs, Life) - self.assertEqual(len(outputs), 1) - self.assertTrue("42" in outputs[0]) - - def testJoke(self): - query = "Tell me a joke." - inputs = ["Who's there?", "Random response"] - outputs = self.runConversation(query, inputs, Joke) - self.assertEqual(len(outputs), 3) - allJokes = open(jasperpath.data('text', 'JOKES.txt'), 'r').read() - self.assertTrue(outputs[2] in allJokes) - - def testTime(self): - query = "What time is it?" - inputs = [] - self.runConversation(query, inputs, Time) - - @unittest.skipIf(not diagnose.check_network_connection(), - "No internet connection") - def testGmail(self): - key = 'gmail_password' - if key not in self.profile or not self.profile[key]: - return - - query = "Check my email" - inputs = [] - self.runConversation(query, inputs, Gmail) - - @unittest.skipIf(not diagnose.check_network_connection(), - "No internet connection") - def testHN(self): - query = "find me some of the top hacker news stories" - if self.send: - inputs = ["the first and third"] - else: - inputs = ["no"] - outputs = self.runConversation(query, inputs, HN) - self.assertTrue("front-page articles" in outputs[1]) - - @unittest.skipIf(not diagnose.check_network_connection(), - "No internet connection") - def testNews(self): - query = "find me some of the top news stories" - if self.send: - inputs = ["the first"] - else: - inputs = ["no"] - outputs = self.runConversation(query, inputs, News) - self.assertTrue("top headlines" in outputs[1]) - - @unittest.skipIf(not diagnose.check_network_connection(), - "No internet connection") - def testWeather(self): - query = "what's the weather like tomorrow" - inputs = [] - outputs = self.runConversation(query, inputs, Weather) - self.assertTrue("can't see that far ahead" - in outputs[0] or "Tomorrow" in outputs[0]) diff --git a/tests/test_stt.py b/tests/test_stt.py deleted file mode 100644 index cb33ff6c2..000000000 --- a/tests/test_stt.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8-*- -import unittest -import imp -from client import stt, jasperpath - - -def cmuclmtk_installed(): - try: - imp.find_module('cmuclmtk') - except ImportError: - return False - else: - return True - - -def pocketsphinx_installed(): - try: - imp.find_module('pocketsphinx') - except ImportError: - return False - else: - return True - - -@unittest.skipUnless(cmuclmtk_installed(), "CMUCLMTK not present") -@unittest.skipUnless(pocketsphinx_installed(), "Pocketsphinx not present") -class TestSTT(unittest.TestCase): - - def setUp(self): - self.jasper_clip = jasperpath.data('audio', 'jasper.wav') - self.time_clip = jasperpath.data('audio', 'time.wav') - - self.passive_stt_engine = stt.PocketSphinxSTT.get_passive_instance() - self.active_stt_engine = stt.PocketSphinxSTT.get_active_instance() - - def testTranscribeJasper(self): - """ - Does Jasper recognize his name (i.e., passive listen)? - """ - with open(self.jasper_clip, mode="rb") as f: - transcription = self.passive_stt_engine.transcribe(f) - self.assertIn("JASPER", transcription) - - def testTranscribe(self): - """ - Does Jasper recognize 'time' (i.e., active listen)? - """ - with open(self.time_clip, mode="rb") as f: - transcription = self.active_stt_engine.transcribe(f) - self.assertIn("TIME", transcription) diff --git a/tests/test_tts.py b/tests/test_tts.py deleted file mode 100644 index 989435790..000000000 --- a/tests/test_tts.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8-*- -import unittest -from client import tts - - -class TestTTS(unittest.TestCase): - def testTTS(self): - tts_engine = tts.get_engine_by_slug('dummy-tts') - tts_instance = tts_engine() - tts_instance.say('This is a test.') diff --git a/tests/test_vin.py b/tests/test_vin.py new file mode 100644 index 000000000..8502663c9 --- /dev/null +++ b/tests/test_vin.py @@ -0,0 +1,22 @@ +import judy + +class VoiceOut(object): + def play(self, path): + print 'VoiceOut:Play:', path + + def beep(self, high): + print 'VoiceOut:Beep:', 'HIGH' if high else 'LOW' + + def say(self, phrase): + print 'VoiceOut:Say:', phrase + +vin = judy.VoiceIn(adcdev='plughw:1,0', + lm='/home/pi/judy/resources/lm/0931.lm', + dict='/home/pi/judy/resources/lm/0931.dic') + +vout = VoiceOut() + +def handle(phrase): + print 'Roger:', phrase + +judy.listen(vin, vout, handle) diff --git a/tests/test_vocabcompiler.py b/tests/test_vocabcompiler.py deleted file mode 100644 index 2ce977d26..000000000 --- a/tests/test_vocabcompiler.py +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env python2 -# -*- coding: utf-8-*- -import unittest -import tempfile -import contextlib -import logging -import shutil -import mock -from client import vocabcompiler - - -class TestVocabCompiler(unittest.TestCase): - - def testPhraseExtraction(self): - expected_phrases = ['MOCK'] - - mock_module = mock.Mock() - mock_module.WORDS = ['MOCK'] - - with mock.patch('client.brain.Brain.get_modules', - classmethod(lambda cls: [mock_module])): - extracted_phrases = vocabcompiler.get_all_phrases() - self.assertEqual(expected_phrases, extracted_phrases) - - def testKeywordPhraseExtraction(self): - expected_phrases = ['MOCK'] - - with tempfile.TemporaryFile() as f: - # We can't use mock_open here, because it doesn't seem to work - # with the 'for line in f' syntax - f.write("MOCK\n") - f.seek(0) - with mock.patch('%s.open' % vocabcompiler.__name__, - return_value=f, create=True): - extracted_phrases = vocabcompiler.get_keyword_phrases() - self.assertEqual(expected_phrases, extracted_phrases) - - -class TestVocabulary(unittest.TestCase): - VOCABULARY = vocabcompiler.DummyVocabulary - - @contextlib.contextmanager - def do_in_tempdir(self): - tempdir = tempfile.mkdtemp() - yield tempdir - shutil.rmtree(tempdir) - - def testVocabulary(self): - phrases = ['GOOD BAD UGLY'] - with self.do_in_tempdir() as tempdir: - self.vocab = self.VOCABULARY(path=tempdir) - self.assertIsNone(self.vocab.compiled_revision) - self.assertFalse(self.vocab.is_compiled) - self.assertFalse(self.vocab.matches_phrases(phrases)) - - # We're now testing error handling. To avoid flooding the - # output with error messages that are catched anyway, - # we'll temporarly disable logging. Otherwise, error log - # messages and traceback would be printed so that someone - # might think that tests failed even though they succeeded. - logging.disable(logging.ERROR) - with self.assertRaises(OSError): - with mock.patch('os.makedirs', side_effect=OSError('test')): - self.vocab.compile(phrases) - with self.assertRaises(OSError): - with mock.patch('%s.open' % vocabcompiler.__name__, - create=True, - side_effect=OSError('test')): - self.vocab.compile(phrases) - - class StrangeCompilationError(Exception): - pass - with mock.patch.object(self.vocab, '_compile_vocabulary', - side_effect=StrangeCompilationError('test') - ): - with self.assertRaises(StrangeCompilationError): - self.vocab.compile(phrases) - with self.assertRaises(StrangeCompilationError): - with mock.patch('os.remove', - side_effect=OSError('test')): - self.vocab.compile(phrases) - # Re-enable logging again - logging.disable(logging.NOTSET) - - self.vocab.compile(phrases) - self.assertIsInstance(self.vocab.compiled_revision, str) - self.assertTrue(self.vocab.is_compiled) - self.assertTrue(self.vocab.matches_phrases(phrases)) - self.vocab.compile(phrases) - self.vocab.compile(phrases, force=True) - - -class TestPocketsphinxVocabulary(TestVocabulary): - - VOCABULARY = vocabcompiler.PocketsphinxVocabulary - - @unittest.skipUnless(hasattr(vocabcompiler, 'cmuclmtk'), - "CMUCLMTK not present") - def testVocabulary(self): - super(TestPocketsphinxVocabulary, self).testVocabulary() - self.assertIsInstance(self.vocab.decoder_kwargs, dict) - self.assertIn('lm', self.vocab.decoder_kwargs) - self.assertIn('dict', self.vocab.decoder_kwargs) - - def testPatchedVocabulary(self): - - def write_test_vocab(text, output_file): - with open(output_file, "w") as f: - for word in text.split(' '): - f.write("%s\n" % word) - - def write_test_lm(text, output_file, **kwargs): - with open(output_file, "w") as f: - f.write("TEST") - - class DummyG2P(object): - def __init__(self, *args, **kwargs): - pass - - @classmethod - def get_config(self, *args, **kwargs): - return {} - - def translate(self, *args, **kwargs): - return {'GOOD': ['G UH D', - 'G UW D'], - 'BAD': ['B AE D'], - 'UGLY': ['AH G L IY']} - - with mock.patch('client.vocabcompiler.cmuclmtk', - create=True) as mocked_cmuclmtk: - mocked_cmuclmtk.text2vocab = write_test_vocab - mocked_cmuclmtk.text2lm = write_test_lm - with mock.patch('client.vocabcompiler.PhonetisaurusG2P', DummyG2P): - self.testVocabulary() diff --git a/tests/test_vout.py b/tests/test_vout.py new file mode 100644 index 000000000..0758cb0ee --- /dev/null +++ b/tests/test_vout.py @@ -0,0 +1,8 @@ +import judy + +vout = judy.VoiceOut(device='plughw:0,0', + resources='/home/pi/judy/resources/audio') + +vout.beep(1) +vout.beep(0) +vout.say('How are you today?') From cf7241197c541a37c9c39804351c76ba1cecc202 Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Fri, 2 Sep 2016 14:43:15 +0800 Subject: [PATCH 03/25] Adding docs --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 26d6ae27b..38de82d1f 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ version='0.1', - description='Simple Voice Control on Raspberry Pi', + description='Simplified Voice Control on Raspberry Pi', long_description='', From 837301d207667564b8a69777647c1f3c885abc58 Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Fri, 2 Sep 2016 14:43:23 +0800 Subject: [PATCH 04/25] Adding docs --- README.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9b99f647d..2c7de9d9e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Judy - Simple Voice Control on Raspberry Pi +# Judy - Simplified Voice Control on Raspberry Pi Judy is a simplified sister of [Jasper](http://jasperproject.github.io/), with a focus on education. It is designed to run on: @@ -6,3 +6,23 @@ with a focus on education. It is designed to run on: **Raspberry Pi 3** **Raspbian Jessie** **Python 2.7** + +Unlike Jasper, Judy does *not* try to be cross-platform, does *not* allow you to +pick your favorite Speech-to-Text engine or Text-to-Speech engine, does *not* +come with an API for pluggable modules. Judy tries to keep things simple, lets +you experience the joy of voice control with as little hassle as possible. + +A **Speech-to-Text engine** is a piece of software that interprets human voice into +a string of text. It lets the computer know what is being said. Conversely, +a **Text-to-Speech engine** converts text into sound. It allows the computer to +speak, probably as a response to your command. + +Judy uses: + +- **[Pocketsphinx](http://cmusphinx.sourceforge.net/)** as the Speech-to-Text engine +- **[Pico](https://github.com/DougGore/picopi)** as the Text-to-Speech engine + +Additionally, you are expected to have: + +- a **Speaker** to plug into Raspberry Pi's headphone jack +- a **USB Microphone** From 52e23fdd3c433ac51db6ce46376b13ec8b3d22b4 Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Fri, 2 Sep 2016 14:58:03 +0800 Subject: [PATCH 05/25] Adding docs --- README.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/README.md b/README.md index 2c7de9d9e..b55843fb2 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,44 @@ Additionally, you are expected to have: - a **Speaker** to plug into Raspberry Pi's headphone jack - a **USB Microphone** + +**Plug them in.** Let's go. + +## Know the Sound Cards + +Coming soon ... + +## Record a WAV file + +``` +$ arecord -D plughw:1,0 abc.wav +``` + +## Play a WAV file + +``` +$ aplay -D plughw:0,0 abc.wav +``` + +## Install Pico + +``` +$ sudo apt-get install libttspico-utils +$ pico2wave -w abc.wav "Good morning. How are you today?" +$ aplay -D plughw:0,0 abc.wav +``` + +## Install Pocketsphinx + +``` +$ sudo apt-get install pocketsphinx +$ pocketsphinx_continuous -adcdev plughw:1,0 -inmic yes +``` + +## Configure Pocketsphinx + +Comming soon ... + +## Install Judy + +Comming soon ... From dc2ec4fbbc5f788e08e05cf4dd5d6f66354a6f2e Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Fri, 2 Sep 2016 14:58:54 +0800 Subject: [PATCH 06/25] Adding docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b55843fb2..91c33bd43 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Judy uses: - **[Pocketsphinx](http://cmusphinx.sourceforge.net/)** as the Speech-to-Text engine - **[Pico](https://github.com/DougGore/picopi)** as the Text-to-Speech engine -Additionally, you are expected to have: +Additionally, you need: - a **Speaker** to plug into Raspberry Pi's headphone jack - a **USB Microphone** From 4e69a0e8b386fe697b31102f4d4ed4819d8cf9fb Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Fri, 2 Sep 2016 15:03:30 +0800 Subject: [PATCH 07/25] Adding docs --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 91c33bd43..4fb5c4d7f 100644 --- a/README.md +++ b/README.md @@ -66,4 +66,8 @@ Comming soon ... ## Install Judy -Comming soon ... +``` +$ sudo pip install jasper-judy +``` + +More to come ... From 2da2ca281abcb03e919b4c2e75e97f003466adb7 Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Fri, 2 Sep 2016 16:08:33 +0800 Subject: [PATCH 08/25] Adding docs --- README.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4fb5c4d7f..616fd10de 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,32 @@ Additionally, you need: ## Know the Sound Cards -Coming soon ... +``` +$ more /proc/asound/cards + 0 [ALSA ]: bcm2835 - bcm2835 ALSA + bcm2835 ALSA + 1 [Device ]: USB-Audio - USB PnP Audio Device + USB PnP Audio Device at usb-3f980000.usb-1.4, full speed +``` + +The first is Raspberry Pi's built-in sound card. It has an index of 0. (Note +the word `ALSA`. It means *Advanced Linux Sound Architecture*. Simply put, it +is the sound driver on many Linux systems.) + +The second is the USB device's sound card. It has an index of 1. + +Your settings might be different. But if you are using Pi 3 with Jessie and have +not changed any sound settings, the above situation is likely to match yours. +For the rest of discussions, I am going to assume: + +- Build-in sound card, index **0** → headphone jack → speaker +- USB sound card, index **1** → microphone + +The index is important. It is how you tell Raspberry Pi where to get sound data +from, or where to dump sound data into. + +If your sound card indexes are different from mine, adjust command arguments +accordingly in what follows. ## Record a WAV file From 1d0a073c754e716214f4b80a05c8bd973f85b9a6 Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Fri, 2 Sep 2016 16:16:17 +0800 Subject: [PATCH 09/25] Adding docs --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 616fd10de..546454dc6 100644 --- a/README.md +++ b/README.md @@ -49,21 +49,26 @@ Your settings might be different. But if you are using Pi 3 with Jessie and have not changed any sound settings, the above situation is likely to match yours. For the rest of discussions, I am going to assume: -- Build-in sound card, index **0** → headphone jack → speaker -- USB sound card, index **1** → microphone +- Build-in sound card, **index 0** → headphone jack → speaker +- USB sound card, **index 1** → microphone The index is important. It is how you tell Raspberry Pi where to get sound data from, or where to dump sound data into. -If your sound card indexes are different from mine, adjust command arguments -accordingly in what follows. +*If your sound card indexes are different from mine, adjust command arguments +accordingly in the rest of this page.* ## Record a WAV file +Enter this command, then speak to the mic, press `Ctrl-C` when you are +finished: + ``` $ arecord -D plughw:1,0 abc.wav ``` +`-D plughw:1,0` tells `arecord` where the mic is. It is at index 1. + ## Play a WAV file ``` From 5621dff5270f714a49b4cb3f0550a7f6a845147f Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Fri, 2 Sep 2016 16:26:03 +0800 Subject: [PATCH 10/25] Whatever --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 546454dc6..c53ee3678 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,12 @@ finished: $ arecord -D plughw:1,0 abc.wav ``` -`-D plughw:1,0` tells `arecord` where the mic is. It is at index 1. +`-D plughw:1,0` tells `arecord` where the device is. In this case, device is +the mic. It is at index 1. + +`plughw:1,0` actually refers to "Sound Card index 1, Subdevice 0", because a +sound card may house many subdevices. Here, we don't care about subdevices and +always give it a `0`. The only important index is the sound card's. ## Play a WAV file From 8b733daa4b9e8fb8b4ec3e4cbe4ace5b83853ff9 Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Fri, 2 Sep 2016 16:55:44 +0800 Subject: [PATCH 11/25] Adding docs --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index c53ee3678..6a0e6544e 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,23 @@ always give it a `0`. The only important index is the sound card's. $ aplay -D plughw:0,0 abc.wav ``` +Here, we tell `aplay` to play to `plughw:0,0`, which refers to "Sound Card index 0, +Subdevice 0", which leads to the speaker. + +If you hear nothing, check the speaker's power. After that, try adjusting the +speaker volume with: + +``` +$ alsamixer +``` + +On PuTTY, the function keys (`F1`, `F2`, etc) may not behave as expected. If so, +you need to change PuTTY settings: + +1. Go to **Terminal** / **Keyboard** +2. Look for section **The Function keys and keypad** +3. Select **Xterm R6** + ## Install Pico ``` From 80fd44289794a131a04be32508cb7b820b6a01ee Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Fri, 2 Sep 2016 17:14:18 +0800 Subject: [PATCH 12/25] Adding docs --- README.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6a0e6544e..3905e6021 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,9 @@ you need to change PuTTY settings: 1. Go to **Terminal** / **Keyboard** 2. Look for section **The Function keys and keypad** 3. Select **Xterm R6** +4. Press button **Apply** -## Install Pico +## Install Pico, the Text-to-Speech engine ``` $ sudo apt-get install libttspico-utils @@ -105,13 +106,26 @@ $ pico2wave -w abc.wav "Good morning. How are you today?" $ aplay -D plughw:0,0 abc.wav ``` -## Install Pocketsphinx +## Install Pocketsphinx, the Speech-to-Text engine ``` $ sudo apt-get install pocketsphinx $ pocketsphinx_continuous -adcdev plughw:1,0 -inmic yes ``` +`pocketsphinx_continuous` can interpret speech in *real-time*. It will spill out +a lot of stuff, ending with something like this: + +``` +Warning: Could not find Capture element +READY.... +``` + +Now, **speak into the mic**, and see what it hears. At first, you may find it +funny. After a while, you know it is inaccurate. + +For it to be useful, we have to make it more accurate. + ## Configure Pocketsphinx Comming soon ... From 1ea8d32e9bb9acfdc83d55d457f6e9f0050c5fe8 Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Fri, 2 Sep 2016 18:03:28 +0800 Subject: [PATCH 13/25] Adding docs --- README.md | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3905e6021..15bca5630 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ $ sudo apt-get install pocketsphinx $ pocketsphinx_continuous -adcdev plughw:1,0 -inmic yes ``` -`pocketsphinx_continuous` can interpret speech in *real-time*. It will spill out +`pocketsphinx_continuous` interprets speech in *real-time*. It will spill out a lot of stuff, ending with something like this: ``` @@ -121,14 +121,44 @@ Warning: Could not find Capture element READY.... ``` -Now, **speak into the mic**, and see what it hears. At first, you may find it -funny. After a while, you know it is inaccurate. +Now, **speak into the mic**, and note the results. At first, you may find it +funny. After a while, you realize it is inaccurate. For it to be useful, we have to make it more accurate. ## Configure Pocketsphinx -Comming soon ... +We can make it more accurate by restricting its vocabulary. Think of a bunch of +phrases or words you want it to recognize, and save them in a text file. +For example, the text file may contain: +``` +How are you today +Good morning +night +afternoon +``` + +Go to Carnegie Mellon University's [lmtool page](http://www.speech.cs.cmu.edu/tools/lmtool-new.html), +upload the text file, and compile the "knowledge base". The "knowledge base" is +nothing more than a bunch of files combined into a zip file. Download and unzip +it: + +``` +$ wget +$ tar zxf +``` + +Among the unzipped products, there is a `.lm` and `.dic` file. They basically +define a vocabulary. Pocketsphinx cannot know any words outside of this book. +Supply them to `pocketsphinx_continuous`: + +``` +$ pocketsphinx_continuous -adcdev plughw:1,0 -lm -dic -inmic yes +``` + +Speak into the mic again, but *only those words you have defined*. A much better +accuracy should be achieved. Pocketsphinx finally knows what you are talking +about. ## Install Judy From 746693c7ce6003bafe28c52bb7be0ccb906ec8c0 Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Fri, 2 Sep 2016 20:18:33 +0800 Subject: [PATCH 14/25] Adding docs --- README.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 15bca5630..70d36672a 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ For it to be useful, we have to make it more accurate. We can make it more accurate by restricting its vocabulary. Think of a bunch of phrases or words you want it to recognize, and save them in a text file. -For example, the text file may contain: +For example: ``` How are you today Good morning @@ -156,7 +156,7 @@ Supply them to `pocketsphinx_continuous`: $ pocketsphinx_continuous -adcdev plughw:1,0 -lm -dic -inmic yes ``` -Speak into the mic again, but *only those words you have defined*. A much better +Speak into the mic again, but *only those words you have given*. A much better accuracy should be achieved. Pocketsphinx finally knows what you are talking about. @@ -166,4 +166,53 @@ about. $ sudo pip install jasper-judy ``` -More to come ... +Judy brings Pocketsphinx's listening ability and Pico's speaking ability +together. A Judy program, on hearing her name being called, can verbally answer +your voice command. Imagine the following sequence: + +You: Judy! +Judy: [high beep] +You: Weather next Monday? +Judy: [low beep] +Judy: 23 degrees, partly cloudy + +She can be as smart as you program her to be. + +To get a Judy program running, you need to prepare a few *resources*: + +- a `.lm` and `.dic` file to increase listening accuracy +- a folder in which the [beep] audio files reside + +[Here are some sample resources.](https://github.com/nickoala/judy/tree/master/resources) +Download them if you want. + +A Judy program follows these steps: + +1. Create a `VoiceIn` object. Supply it with the microphone device, +and the `.lm` and `.dic` file. +2. Create a `VoiceOut` object. Supply it with the speaker device, and the folder +in which the [beep] audio files reside. +3. Define a function to handle voice commands. +4. Finally, call the function `listen()`. + +Here is an example that **echoes whatever you say**. Remember, you have to call +"Judy" to get her attention. After a high beep, you can say something (stay +within the vocabulary, please). A low beep indicates she heard you. +Then, she echoes what you have said. + +```python +import judy + +vin = judy.VoiceIn(adcdev='plughw:1,0', + lm='/home/pi/judy/resources/lm/0931.lm', + dict='/home/pi/judy/resources/lm/0931.dic') + +vout = judy.VoiceOut(device='plughw:0,0', + resources='/home/pi/judy/resources/audio') + +def handle(phrase): + print 'Heard:', phrase + vout.say(phrase) + +judy.listen(vin, vout, handle) +``` From cb0c2c038c59ed9714dc8e97a7e020cad66f3c38 Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Fri, 2 Sep 2016 20:23:43 +0800 Subject: [PATCH 15/25] Docs finished --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 70d36672a..9a54f686d 100644 --- a/README.md +++ b/README.md @@ -171,10 +171,10 @@ together. A Judy program, on hearing her name being called, can verbally answer your voice command. Imagine the following sequence: You: Judy! -Judy: [high beep] +*Judy: [high beep]* You: Weather next Monday? -Judy: [low beep] -Judy: 23 degrees, partly cloudy +*Judy: [low beep]* +*Judy: 23 degrees, partly cloudy* She can be as smart as you program her to be. @@ -193,7 +193,7 @@ and the `.lm` and `.dic` file. 2. Create a `VoiceOut` object. Supply it with the speaker device, and the folder in which the [beep] audio files reside. 3. Define a function to handle voice commands. -4. Finally, call the function `listen()`. +4. Call the function `listen()`. Here is an example that **echoes whatever you say**. Remember, you have to call "Judy" to get her attention. After a high beep, you can say something (stay @@ -216,3 +216,6 @@ def handle(phrase): judy.listen(vin, vout, handle) ``` + +It's that simple! Put more stuff in the `handle()` function to make it as smart +as you want her to be. From aafdb74d4f2c20fc65318b3a2bf11cccbc15b80e Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Fri, 2 Sep 2016 20:26:14 +0800 Subject: [PATCH 16/25] Minor change --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9a54f686d..2ed891788 100644 --- a/README.md +++ b/README.md @@ -217,5 +217,5 @@ def handle(phrase): judy.listen(vin, vout, handle) ``` -It's that simple! Put more stuff in the `handle()` function to make it as smart -as you want her to be. +It's that simple! Put more stuff in `handle()`. She can be as smart as you want +her to be. From 2e8a15cec76cac700b55d4bbf2f2c47e3a7729aa Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Sun, 4 Sep 2016 13:08:31 +0800 Subject: [PATCH 17/25] Minor changes --- README.md | 18 ++++++++++-------- judy.py | 3 +++ resources/lm/{sentences.txt => phrases.txt} | 0 setup.py | 2 +- 4 files changed, 14 insertions(+), 9 deletions(-) rename resources/lm/{sentences.txt => phrases.txt} (100%) diff --git a/README.md b/README.md index 2ed891788..86dca5104 100644 --- a/README.md +++ b/README.md @@ -46,16 +46,16 @@ is the sound driver on many Linux systems.) The second is the USB device's sound card. It has an index of 1. Your settings might be different. But if you are using Pi 3 with Jessie and have -not changed any sound settings, the above situation is likely to match yours. +not changed any sound settings, the above situation is likely. For the rest of discussions, I am going to assume: - Build-in sound card, **index 0** → headphone jack → speaker - USB sound card, **index 1** → microphone -The index is important. It is how you tell Raspberry Pi where to get sound data -from, or where to dump sound data into. +The index is important. It is how you tell Raspberry Pi where the speaker and +microphone is. -*If your sound card indexes are different from mine, adjust command arguments +*If your sound card indexes are different, adjust command arguments accordingly in the rest of this page.* ## Record a WAV file @@ -98,6 +98,9 @@ you need to change PuTTY settings: 3. Select **Xterm R6** 4. Press button **Apply** +If you `aplay` and `arecord` successfully, that means the speaker and microphone +are working properly. We can move on to add more capabilities. + ## Install Pico, the Text-to-Speech engine ``` @@ -140,8 +143,7 @@ afternoon Go to Carnegie Mellon University's [lmtool page](http://www.speech.cs.cmu.edu/tools/lmtool-new.html), upload the text file, and compile the "knowledge base". The "knowledge base" is -nothing more than a bunch of files combined into a zip file. Download and unzip -it: +nothing more than a bunch of files. Download and unzip them: ``` $ wget @@ -153,7 +155,7 @@ define a vocabulary. Pocketsphinx cannot know any words outside of this book. Supply them to `pocketsphinx_continuous`: ``` -$ pocketsphinx_continuous -adcdev plughw:1,0 -lm -dic -inmic yes +$ pocketsphinx_continuous -adcdev plughw:1,0 -lm -dict -inmic yes ``` Speak into the mic again, but *only those words you have given*. A much better @@ -168,7 +170,7 @@ $ sudo pip install jasper-judy Judy brings Pocketsphinx's listening ability and Pico's speaking ability together. A Judy program, on hearing her name being called, can verbally answer -your voice command. Imagine the following sequence: +your voice command. Imagine this: You: Judy! *Judy: [high beep]* diff --git a/judy.py b/judy.py index 891cb312a..0814c67db 100644 --- a/judy.py +++ b/judy.py @@ -34,6 +34,9 @@ def execute(cmd): pattern = re.compile('^[0-9]{9}: (.+)') # lines starting with 9 digits for out in execute(cmd.split(' ')): + # Print out the line to give the same experience as + # running pocketsphinx_continuous. + print out, # newline included by the line itself if self._listening: m = pattern.match(out) if m: diff --git a/resources/lm/sentences.txt b/resources/lm/phrases.txt similarity index 100% rename from resources/lm/sentences.txt rename to resources/lm/phrases.txt diff --git a/setup.py b/setup.py index 38de82d1f..39d50be4e 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ name='jasper-judy', py_modules = ['judy'], - version='0.1', + version='0.2', description='Simplified Voice Control on Raspberry Pi', From 67bd2bc790eef291aa2a9f73c2e8d762dce83829 Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Fri, 16 Sep 2016 17:27:53 +0800 Subject: [PATCH 18/25] `VoiceIn` accepts arbitrary arguments --- judy.py | 11 +++++++---- setup.py | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/judy.py b/judy.py index 0814c67db..8545751f6 100644 --- a/judy.py +++ b/judy.py @@ -7,9 +7,9 @@ import Queue as queue class VoiceIn(threading.Thread): - def __init__(self, adcdev, lm, dict): + def __init__(self, **params): super(VoiceIn, self).__init__() - self._params = (adcdev, lm, dict) + self._params = params self._listening = False self.phrase_queue = queue.Queue() @@ -30,10 +30,13 @@ def execute(cmd): if return_code != 0: raise subprocess.CalledProcessError(return_code, cmd) - cmd = 'pocketsphinx_continuous -adcdev %s -lm %s -dict %s -inmic yes' % self._params pattern = re.compile('^[0-9]{9}: (.+)') # lines starting with 9 digits - for out in execute(cmd.split(' ')): + cmd = ['pocketsphinx_continuous', '-inmic', 'yes'] + for k,v in self._params.items(): + cmd.extend(['-'+k, v]) + + for out in execute(cmd): # Print out the line to give the same experience as # running pocketsphinx_continuous. print out, # newline included by the line itself diff --git a/setup.py b/setup.py index 39d50be4e..f46d69ca9 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ name='jasper-judy', py_modules = ['judy'], - version='0.2', + version='0.3', description='Simplified Voice Control on Raspberry Pi', From cd0f2e1782909fe53b47845d536904c855630869 Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Tue, 7 Feb 2017 14:48:46 +0800 Subject: [PATCH 19/25] Added date calculator example and minor changes in README --- README.md | 9 +++--- tests/date_calculator.py | 68 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 tests/date_calculator.py diff --git a/README.md b/README.md index 86dca5104..4b7dd9953 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ READY.... ``` Now, **speak into the mic**, and note the results. At first, you may find it -funny. After a while, you realize it is inaccurate. +funny. After a while, you realize it is horribly inaccurate. For it to be useful, we have to make it more accurate. @@ -151,14 +151,14 @@ $ tar zxf ``` Among the unzipped products, there is a `.lm` and `.dic` file. They basically -define a vocabulary. Pocketsphinx cannot know any words outside of this book. +define a vocabulary. Pocketsphinx cannot know any words outside of this vocabulary. Supply them to `pocketsphinx_continuous`: ``` $ pocketsphinx_continuous -adcdev plughw:1,0 -lm -dict -inmic yes ``` -Speak into the mic again, but *only those words you have given*. A much better +Speak into the mic again, *only those words you have given*. A much better accuracy should be achieved. Pocketsphinx finally knows what you are talking about. @@ -175,8 +175,7 @@ your voice command. Imagine this: You: Judy! *Judy: [high beep]* You: Weather next Monday? -*Judy: [low beep]* -*Judy: 23 degrees, partly cloudy* +*Judy: [low beep] 23 degrees, partly cloudy* She can be as smart as you program her to be. diff --git a/tests/date_calculator.py b/tests/date_calculator.py new file mode 100644 index 000000000..020d8c968 --- /dev/null +++ b/tests/date_calculator.py @@ -0,0 +1,68 @@ +""" +Judy can tell you the dates! + +You: Judy! +Judy: [high beep] +You: Today! +Judy: [low beep] February 07 + +You: Judy! +Judy: [high beep] +You: Tomorrow! +Judy: [low beep] February 08 + +You: Judy! +Judy: [high beep] +You: Next Friday! +Judy: [low beep] February 17 +""" + +from datetime import datetime, timedelta +import judy + +vin = judy.VoiceIn(adcdev='plughw:1,0', + lm='/home/pi/judy/resources/lm/0931.lm', + dict='/home/pi/judy/resources/lm/0931.dic') + +vout = judy.VoiceOut(device='plughw:0,0', + resources='/home/pi/judy/resources/audio') + +def handle(phrase): + if phrase == 'TODAY': + result = datetime.today() + elif phrase == 'TOMORROW': + result = datetime.today() + timedelta(days=1) + else: + next = 0 + target = None + weekdays = {'MONDAY': 0, # Python convention, Monday=0 + 'TUESDAY': 1, + 'WEDNESDAY': 2, + 'THURSDAY': 3, + 'FRIDAY': 4, + 'SATURDAY': 5, + 'SUNDAY': 6} + + # Pick up 'NEXT' and any weekday + for word in phrase.split(' '): + if word == 'NEXT': + next += 1 + + if word in weekdays: + target = weekdays[word] + break + + today = datetime.today() + + # How many days to target weekday? + diff = target - today.weekday() + if diff <= 0: + diff += 7 + + result = today + timedelta(weeks=next, days=diff) + + answer = result.strftime('%B %d') + vout.say(answer) + + +judy.listen(vin, vout, handle) From 522217c1a7430ea1d9cde639d287f68fcc2f09a1 Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Sun, 12 Feb 2017 15:15:14 +0800 Subject: [PATCH 20/25] Check settings --- README.md | 59 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 4b7dd9953..11f22cee9 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Your settings might be different. But if you are using Pi 3 with Jessie and have not changed any sound settings, the above situation is likely. For the rest of discussions, I am going to assume: -- Build-in sound card, **index 0** → headphone jack → speaker +- Built-in sound card, **index 0** → headphone jack → speaker - USB sound card, **index 1** → microphone The index is important. It is how you tell Raspberry Pi where the speaker and @@ -58,6 +58,48 @@ microphone is. *If your sound card indexes are different, adjust command arguments accordingly in the rest of this page.* +## Make sure sound output to headphone jack + +Sound may be output via HDMI or headphone jack. We want to use the headphone +jack. + +Enter `sudo raspi-config`. Select **Advanced Options**, then **Audio**. You are +presented with three options. **Auto** should work. **Force 3.5mm (headphone) jack** +will work with absolute certainty. + +## Turn up the volume + +A lot of times when sound applications seem to fail, it is because we forget to +turn up the volume. + +Volume adjustment can be done with `alsamixer`. This program makes use of some +function keys (`F1`, `F2`, etc). For functions keys to function properly on +PuTTY, we need to change some settings (click on the top-left corner of the PuTTY +window, then select **Change Settings ...**): + +1. Go to **Terminal** / **Keyboard** +2. Look for section **The Function keys and keypad** +3. Select **Xterm R6** +4. Press button **Apply** + +Now, we are ready to turn up the volume, for both the speaker and the mic: + +``` +$ alsamixer +``` +`F6` to select between sound cards +`F3` to select playback volume (for speaker) +`F4` to select capture volume (for mic) +`⬆` `⬇` arrow keys to adjust +`Esc` to exit + +*Important: if you unplug the USB microphone at any moment, all volume settings +(including that of the speaker) may be reset. Make sure to check the volume +again.* + +Hardware all set, let's try to record and play some sounds to make sure they +really work. + ## Record a WAV file Enter this command, then speak to the mic, press `Ctrl-C` when you are @@ -83,21 +125,6 @@ $ aplay -D plughw:0,0 abc.wav Here, we tell `aplay` to play to `plughw:0,0`, which refers to "Sound Card index 0, Subdevice 0", which leads to the speaker. -If you hear nothing, check the speaker's power. After that, try adjusting the -speaker volume with: - -``` -$ alsamixer -``` - -On PuTTY, the function keys (`F1`, `F2`, etc) may not behave as expected. If so, -you need to change PuTTY settings: - -1. Go to **Terminal** / **Keyboard** -2. Look for section **The Function keys and keypad** -3. Select **Xterm R6** -4. Press button **Apply** - If you `aplay` and `arecord` successfully, that means the speaker and microphone are working properly. We can move on to add more capabilities. From 2eeca1b7816b23cac22bc2a3cac5e34077348443 Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Sun, 12 Feb 2017 15:21:05 +0800 Subject: [PATCH 21/25] Corrected spelling --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 11f22cee9..de7a1ab51 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ A lot of times when sound applications seem to fail, it is because we forget to turn up the volume. Volume adjustment can be done with `alsamixer`. This program makes use of some -function keys (`F1`, `F2`, etc). For functions keys to function properly on +function keys (`F1`, `F2`, etc). For function keys to function properly on PuTTY, we need to change some settings (click on the top-left corner of the PuTTY window, then select **Change Settings ...**): From d33a07983e7226c2edafafb29a44d61bbb5a8d48 Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Tue, 14 Feb 2017 00:23:21 +0800 Subject: [PATCH 22/25] Minor changes --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index de7a1ab51..029b09141 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,11 @@ Sound may be output via HDMI or headphone jack. We want to use the headphone jack. Enter `sudo raspi-config`. Select **Advanced Options**, then **Audio**. You are -presented with three options. **Auto** should work. **Force 3.5mm (headphone) jack** -will work with absolute certainty. +presented with three options: + +- `Auto` should work +- `Force 3.5mm (headphone) jack` should definitely work +- `Force HDMI` won't work ## Turn up the volume @@ -93,12 +96,11 @@ $ alsamixer `⬆` `⬇` arrow keys to adjust `Esc` to exit -*Important: if you unplug the USB microphone at any moment, all volume settings +*If you unplug the USB microphone at any moment, all volume settings (including that of the speaker) may be reset. Make sure to check the volume again.* -Hardware all set, let's try to record and play some sounds to make sure they -really work. +Hardware all set, let's test them. ## Record a WAV file From 1fc74a6d20561afb0398870e752cf658dccb56fc Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Sat, 20 May 2017 12:52:29 +0800 Subject: [PATCH 23/25] Added `speaker-test` --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 029b09141..3570a39d7 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,14 @@ again.* Hardware all set, let's test them. +## Test the speaker + +``` +$ speaker-test -t wav +``` + +Press `Ctrl-C` when done. + ## Record a WAV file Enter this command, then speak to the mic, press `Ctrl-C` when you are From 8893049ed8e244707afd878728679aad81d6f302 Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Mon, 11 Sep 2017 21:21:18 +0800 Subject: [PATCH 24/25] No further development --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3570a39d7..7e14daeb6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +#### I am not prepared to maintain this project further. It is left here for historical purposes. + # Judy - Simplified Voice Control on Raspberry Pi Judy is a simplified sister of [Jasper](http://jasperproject.github.io/), @@ -149,7 +151,9 @@ $ aplay -D plughw:0,0 abc.wav ## Install Pocketsphinx, the Speech-to-Text engine ``` -$ sudo apt-get install pocketsphinx +$ sudo apt-get install pocketsphinx # Jessie +$ sudo apt-get install pocketsphinx pocketsphinx-en-us # Stretch + $ pocketsphinx_continuous -adcdev plughw:1,0 -inmic yes ``` From 37fa9513dd755a865248c1cf26f197259b99a1fb Mon Sep 17 00:00:00 2001 From: Nick Lee Date: Sun, 17 Sep 2017 21:55:06 +0800 Subject: [PATCH 25/25] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7e14daeb6..4d5659bb0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -#### I am not prepared to maintain this project further. It is left here for historical purposes. +## I no longer maintain this project. It is left here for historical purposes. # Judy - Simplified Voice Control on Raspberry Pi