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 aa0a45cf8..4d5659bb0 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,263 @@ -jasper-client -============= +## I no longer maintain this project. It is left here for historical purposes. -[![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) +# Judy - Simplified Voice Control on Raspberry Pi -Client code for the Jasper voice computing platform. Jasper is an open source platform for developing always-on, voice-controlled applications. +Judy is a simplified sister of [Jasper](http://jasperproject.github.io/), +with a focus on education. It is designed to run on: -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/). +**Raspberry Pi 3** +**Raspbian Jessie** +**Python 2.7** -## Contributing +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. -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. +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. -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)**. +Judy uses: -Thanks in advance for any and all work you contribute to Jasper! +- **[Pocketsphinx](http://cmusphinx.sourceforge.net/)** as the Speech-to-Text engine +- **[Pico](https://github.com/DougGore/picopi)** as the Text-to-Speech engine -## Support +Additionally, you need: -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). +- a **Speaker** to plug into Raspberry Pi's headphone jack +- a **USB Microphone** -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. +**Plug them in.** Let's go. -## Contact +## Know the Sound Cards -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. +``` +$ 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 +``` -For a complete list of code contributors, please see [AUTHORS.md](AUTHORS.md). +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.) -## License +The second is the USB device's sound card. It has an index of 1. -*Copyright (c) 2014-2015, Charles Marsh, Shubhro Saha & Jan Holthuis. All rights reserved.* +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: -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. +- Built-in sound card, **index 0** → headphone jack → speaker +- USB sound card, **index 1** → microphone -*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).* +The index is important. It is how you tell Raspberry Pi where the speaker and +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` should definitely work +- `Force HDMI` won't work + +## 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 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 ...**): + +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 + +*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 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 +finished: + +``` +$ arecord -D plughw:1,0 abc.wav +``` + +`-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 + +``` +$ 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 `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 + +``` +$ 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, the Speech-to-Text engine + +``` +$ sudo apt-get install pocketsphinx # Jessie +$ sudo apt-get install pocketsphinx pocketsphinx-en-us # Stretch + +$ pocketsphinx_continuous -adcdev plughw:1,0 -inmic yes +``` + +`pocketsphinx_continuous` interprets 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 note the results. At first, you may find it +funny. After a while, you realize it is horribly inaccurate. + +For it to be useful, we have to make it more accurate. + +## Configure Pocketsphinx + +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: +``` +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. Download and unzip them: + +``` +$ 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 vocabulary. +Supply them to `pocketsphinx_continuous`: + +``` +$ pocketsphinx_continuous -adcdev plughw:1,0 -lm -dict -inmic yes +``` + +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. + +## Install Judy + +``` +$ 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 this: + +You: Judy! +*Judy: [high beep]* +You: Weather next Monday? +*Judy: [low beep] 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. 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) +``` + +It's that simple! Put more stuff in `handle()`. She can be as smart as you want +her to be. 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..8545751f6 --- /dev/null +++ b/judy.py @@ -0,0 +1,119 @@ +import threading +import subprocess +import re +import os +import time +import tempfile +import Queue as queue + +class VoiceIn(threading.Thread): + def __init__(self, **params): + super(VoiceIn, self).__init__() + self._params = params + 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) + + pattern = re.compile('^[0-9]{9}: (.+)') # lines starting with 9 digits + + 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 + 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/phrases.txt b/resources/lm/phrases.txt new file mode 100644 index 000000000..c6e7a66bf --- /dev/null +++ b/resources/lm/phrases.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..f46d69ca9 --- /dev/null +++ b/setup.py @@ -0,0 +1,47 @@ +from setuptools import setup + +setup( + name='jasper-judy', + py_modules = ['judy'], + + version='0.3', + + description='Simplified 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 707235b49..000000000 Binary files a/static/audio/jasper.wav and /dev/null differ diff --git a/static/audio/say.wav b/static/audio/say.wav deleted file mode 100644 index d27c7dc20..000000000 Binary files a/static/audio/say.wav and /dev/null differ diff --git a/static/audio/time.wav b/static/audio/time.wav deleted file mode 100644 index cbc6c2a98..000000000 Binary files a/static/audio/time.wav and /dev/null differ 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/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) 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?')