From c856d5682e2bade284168f00ca7d233e52a9ace7 Mon Sep 17 00:00:00 2001 From: Stefan Sauer Date: Wed, 3 May 2017 15:20:55 +0200 Subject: [PATCH] Initial commit --- .gitignore | 3 + .pylintrc | 424 ++++++++++++++++++++++++ CONTRIBUTING.md | 25 ++ HACKING.md | 68 ++++ LICENSE | 202 ++++++++++++ Makefile | 14 + README.md | 7 + checkpoints/check_audio.py | 203 ++++++++++++ checkpoints/check_cloud.py | 101 ++++++ checkpoints/check_wifi.py | 80 +++++ checkpoints/load_test.py | 187 +++++++++++ checkpoints/test_hello.raw | Bin 0 -> 96000 bytes config/status-led.ini.default | 5 + config/voice-recognizer.ini.default | 22 ++ po/de.po | 127 ++++++++ po/de/LC_MESSAGES/voice-recognizer.mo | Bin 0 -> 2069 bytes po/voice-recognizer.pot | 124 +++++++ scripts/asound.conf | 30 ++ scripts/install-alsa-config.sh | 34 ++ scripts/install-deps.sh | 34 ++ scripts/install-services.sh | 34 ++ scripts/pre-commit | 18 ++ shortcuts/check_audio.desktop | 7 + shortcuts/check_cloud.desktop | 7 + shortcuts/check_wifi.desktop | 7 + src/action.py | 251 +++++++++++++++ src/actionbase.py | 63 ++++ src/audio.py | 251 +++++++++++++++ src/i18n.py | 51 +++ src/led.py | 160 +++++++++ src/main.py | 300 +++++++++++++++++ src/speech.py | 445 ++++++++++++++++++++++++++ src/status-monitor.py | 72 +++++ src/triggers/__init__.py | 0 src/triggers/clap.py | 52 +++ src/triggers/gpio.py | 61 ++++ src/triggers/trigger.py | 29 ++ src/tts.py | 126 ++++++++ systemd/alsa-init.service | 16 + systemd/ntpdate.service | 14 + systemd/status-led-off.service | 12 + systemd/status-led-on.service | 12 + systemd/status-led.service | 16 + systemd/status-monitor.service | 16 + systemd/voice-recognizer.service | 16 + tests/test_time_to_str.py | 59 ++++ 46 files changed, 3785 insertions(+) create mode 100644 .gitignore create mode 100644 .pylintrc create mode 100644 CONTRIBUTING.md create mode 100644 HACKING.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100755 checkpoints/check_audio.py create mode 100755 checkpoints/check_cloud.py create mode 100755 checkpoints/check_wifi.py create mode 100755 checkpoints/load_test.py create mode 100644 checkpoints/test_hello.raw create mode 100644 config/status-led.ini.default create mode 100644 config/voice-recognizer.ini.default create mode 100644 po/de.po create mode 100644 po/de/LC_MESSAGES/voice-recognizer.mo create mode 100644 po/voice-recognizer.pot create mode 100755 scripts/asound.conf create mode 100755 scripts/install-alsa-config.sh create mode 100755 scripts/install-deps.sh create mode 100755 scripts/install-services.sh create mode 100755 scripts/pre-commit create mode 100644 shortcuts/check_audio.desktop create mode 100644 shortcuts/check_cloud.desktop create mode 100644 shortcuts/check_wifi.desktop create mode 100644 src/action.py create mode 100644 src/actionbase.py create mode 100644 src/audio.py create mode 100644 src/i18n.py create mode 100644 src/led.py create mode 100755 src/main.py create mode 100644 src/speech.py create mode 100755 src/status-monitor.py create mode 100644 src/triggers/__init__.py create mode 100644 src/triggers/clap.py create mode 100644 src/triggers/gpio.py create mode 100644 src/triggers/trigger.py create mode 100644 src/tts.py create mode 100644 systemd/alsa-init.service create mode 100644 systemd/ntpdate.service create mode 100644 systemd/status-led-off.service create mode 100644 systemd/status-led-on.service create mode 100644 systemd/status-led.service create mode 100644 systemd/status-monitor.service create mode 100644 systemd/voice-recognizer.service create mode 100644 tests/test_time_to_str.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..1b726da6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +env +__pycache__ +*.pyc diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 00000000..9eb3a1b5 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,424 @@ +# This file was mostly generated with pylint --generate-rcfile. To see changes +# from the default values, search for "DEFAULT" in this file. + +[MASTER] + +# Specify a configuration file. +#rcfile= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Pickle collected data for later comparisons. +persistent=yes + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Use multiple processes to speed up Pylint. +jobs=1 + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + +# Allow optimization of some AST trees. This will activate a peephole AST +# optimizer, which will apply various small optimizations. For instance, it can +# be used to obtain the result of joining multiple strings with the addition +# operator. Joining a lot of strings can lead to a maximum recursion error in +# Pylint and this flag can prevent that. It has one side effect, the resulting +# AST will be different than the one from reality. This option is deprecated +# and it will be removed in Pylint 2.0. +optimize-ast=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +# DEFAULT: without fixme,invalid-name,no-self-use +# RATIONALE: (fixme) It generates warnings for TODOs. +# RATIONALE: (invalid-name) It rejects class-level constants and short names. +# RATIONALE: (no-self-use) @staticmethod is discouraged by Google style. +disable=backtick,reduce-builtin,nonzero-method,long-suffix,file-builtin,indexing-exception,buffer-builtin,standarderror-builtin,apply-builtin,delslice-method,unicode-builtin,suppressed-message,zip-builtin-not-iterating,intern-builtin,old-octal-literal,old-division,range-builtin-not-iterating,useless-suppression,print-statement,filter-builtin-not-iterating,cmp-builtin,coerce-builtin,input-builtin,setslice-method,execfile-builtin,long-builtin,raising-string,getslice-method,cmp-method,coerce-method,next-method-called,raw_input-builtin,oct-method,import-star-module-level,unichr-builtin,round-builtin,parameter-unpacking,map-builtin-not-iterating,unpacking-in-except,dict-view-method,dict-iter-method,hex-method,old-raise-syntax,basestring-builtin,metaclass-assignment,using-cmp-argument,no-absolute-import,xrange-builtin,old-ne-operator,reload-builtin,fixme,invalid-name,no-self-use + + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html. You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Put messages in a separate file for each module / package specified on the +# command line instead of printing them on stdout. Reports (if any) will be +# written in a file name "pylint_global.[txt|html]". This option is deprecated +# and it will be removed in Pylint 2.0. +files-output=no + +# Tells whether to display a full report or only the messages +reports=yes + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=100 + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma,dict-separator + +# Maximum number of lines in a module +max-module-lines=1000 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME,XXX,TODO + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[TYPECHECK] + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules=configargparse,google,grpc,numpy,oauth2client,RPi.GPIO,scipy,googlesamples + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members=.*Response.* + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + + +[BASIC] + +# Good variable names which should always be accepted, separated by a comma +good-names=i,j,k,ex,Run,_ + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo,bar,baz,toto,tutu,tata + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Regular expression matching correct variable names +variable-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for variable names +variable-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct constant names +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Naming hint for constant names +const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Regular expression matching correct class attribute names +class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Naming hint for class attribute names +class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + +# Naming hint for inline iteration names +inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ + +# Regular expression matching correct function names +function-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for function names +function-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct class names +class-rgx=[A-Z_][a-zA-Z0-9]+$ + +# Naming hint for class names +class-name-hint=[A-Z_][a-zA-Z0-9]+$ + +# Regular expression matching correct attribute names +attr-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for attribute names +attr-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct argument names +argument-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for argument names +argument-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct module names +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Naming hint for module names +module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Regular expression matching correct method names +method-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for method names +method-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match function or class names that do +# not require a docstring. +# DEFAULT: ^_ +# RATIONALE: Docstring for main would duplicate file docstring. +no-docstring-rgx=^_|^main$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +# DEFAULT: -1 +# RATIONALE: Lots of short methods, where docstrings would be repetitive. +docstring-min-length=10 + + +[ELIF] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=(_+[a-zA-Z0-9]*?$)|dummy + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +# DEFAULT: none +# RATIONALE: Provided by gettext. +additional-builtins=_ + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,future.builtins + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__,__new__,setUp + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make + + +[DESIGN] + +# Maximum number of arguments for function / method +# DEFAULT: 5 +# RATIONALE: Keyword arguments make this manageable. +max-args=10 + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.* + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of statements in function / method body +max-statements=50 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Minimum number of public methods for a class (see R0903). +# DEFAULT: 2 +# RATIONALE: Classes can have docstrings, namedtuples can't. +min-public-methods=0 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=optparse + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library=audioop + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..5e286bd7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# How to contribute + +We'd love to accept your patches and contributions to this project. There are +just a few small guidelines you need to follow. + +## Contributor License Agreement + +Contributions to this project must be accompanied by a Contributor License +Agreement. You (or your employer) retain the copyright to your contribution, +this simply gives us permission to use and redistribute your contributions as +part of the project. Head over to to see +your current agreements on file or to sign a new one. + +You generally only need to submit a CLA once, so if you've already submitted one +(even if it was for a different project), you probably don't need to do it +again. + +## Code reviews + +All submissions, including submissions by project members, require review. We +use GitHub pull requests for this purpose. Consult [GitHub Help] for more +information on using pull requests. + +[GitHub Help]: https://help.github.com/articles/about-pull-requests/ + diff --git a/HACKING.md b/HACKING.md new file mode 100644 index 00000000..71527ce0 --- /dev/null +++ b/HACKING.md @@ -0,0 +1,68 @@ +# Setting up the image + +We recommend using [the images](https://aiyprojects.withgoogle.com/voice) we +provide. Those images are based on [Raspbian](https://www.raspberrypi.org/downloads/raspbian/), +with a few customizations and are tested on the Raspberry Pi 3. + +If you prefer to set up Raspbian yourself, add a source for `stretch`, the +testing version of Raspbian: +``` shell +echo "deb http://archive.raspbian.org/raspbian/ stretch main" | sudo tee /etc/apt/sources.list.d/stretch.list >/dev/null +echo 'APT::Default-Release "jessie";' | sudo tee /etc/apt/apt.conf.d/default-release >/dev/null +sudo apt-get update +sudo apt-get upgrade +sudo rpi-update +sudo reboot +``` + +Next install the project dependencies and setup services and the ALSA +configuration for the VoiceHAT hardware: +``` shell +cd ~/voice-recognizer-raspi +scripts/install-deps.sh +scripts/install-services.sh +scripts/install-alsa-config.sh +``` + +## Get service credentials + +To access the cloud services you need to register a project and generate +credentials for cloud APIs. This is documented in the +[setup instructions](https://aiyprojects.withgoogle.com/voice) on the +webpage. + +# Making code changes + +If you edit the code on a different computer, you can deploy it to your +Raspberry Pi by running: + +``` shell +make deploy +``` +To execute the script on the Raspberry Pi run, login to it and run: +``` shell +cd ~/voice-recognizer-raspi +source env/bin/activate +python3 src/main.py +``` + +# I18N + +Strings wrapped with `_()` are marked for translation. + +``` shell +# update catalog after string changed +pygettext3 -d voice-recognizer -p po src/main.py src/action.py + +# add new language +msgmerge po/de.po po/voice-recognizer.pot +# now edit po/de.po + +# update language +msgmerge -U po/de.po po/voice-recognizer.pot +# now edit po/de.po + +# create language bundle +mkdir po/de/LC_MESSAGES/ +msgfmt po/de.po -o po/de/LC_MESSAGES/voice-recognizer.mo +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..8bbe7184 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +PI ?= raspberrypi.local + +SHORTCUTS = $(wildcard shortcuts/*.desktop) + +check: + PYTHONPATH=$$PWD/src python3 -m unittest discover tests + +deploy_scripts: + git ls-files | rsync -avz --exclude=".*" --exclude="*.desktop" --files-from - . pi@$(PI):~/voice-recognizer-raspi + +deploy_shortcuts: + scp $(SHORTCUTS) pi@$(PI):~/Desktop + +deploy: deploy_scripts deploy_shortcuts diff --git a/README.md b/README.md new file mode 100644 index 00000000..0fc7a5e9 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +This repository contains the source code for the AIYProjects "Voice Kit". See +https://aiyprojects.withgoogle.com/voice/ + +## Troubleshooting + +The scripts in the `checkpoints` directory verify the Raspberry Pi's setup. +They can be run from the desktop shortcuts or from the terminal. diff --git a/checkpoints/check_audio.py b/checkpoints/check_audio.py new file mode 100755 index 00000000..596aa4c4 --- /dev/null +++ b/checkpoints/check_audio.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Check that the voiceHAT audio input and output are both working. +""" + +import os +import subprocess +import tempfile +import textwrap +import time +import traceback + +CARDS_PATH = '/proc/asound/cards' +VOICEHAT_ID = 'googlevoicehat' + +SERVICE_NAME = 'voice-recognizer' +ACTIVE_STR = 'ActiveState=active' +INACTIVE_STR = 'ActiveState=inactive' + +STOP_DELAY = 1.0 + +VOICE_RECOGNIZER_PATH = '/home/pi/voice-recognizer-raspi' +PYTHON3 = 'python3' +AUDIO_PY = VOICE_RECOGNIZER_PATH + '/src/audio.py' + +TEST_SOUND_PATH = '/usr/share/sounds/alsa/Front_Center.wav' + +RECORD_DURATION_SECONDS = '3' + + +def get_sound_cards(): + """Read a dictionary of ALSA cards from /proc, indexed by number.""" + cards = {} + + with open(CARDS_PATH) as f: # pylint: disable=invalid-name + for line in f.read().splitlines(): + try: + index = int(line.strip().split()[0]) + except (IndexError, ValueError): + continue + + cards[index] = line + + return cards + + +def is_service_active(): + """Returns True if the voice-recognizer service is active.""" + output = subprocess.check_output(['systemctl', 'show', SERVICE_NAME]).decode('utf-8') + + if ACTIVE_STR in output: + return True + elif INACTIVE_STR in output: + return False + else: + print('WARNING: failed to parse output:') + print(output) + return False + + +def play_wav(wav_path): + """Play a WAV file.""" + subprocess.check_call([PYTHON3, AUDIO_PY, 'play', wav_path], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def ask(prompt): + """Get a yes or no answer from the user.""" + ans = input(prompt + ' (y/n) ') + + while not ans or ans[0].lower() not in 'yn': + ans = input('Please enter y or n: ') + + return ans[0].lower() == 'y' + + +def stop_service(): + """Stop the voice-recognizer so we can use the mic. + + Returns: + True if the service has been stopped. + """ + if not is_service_active(): + return False + + subprocess.check_call(['sudo', 'systemctl', 'stop', SERVICE_NAME], stdout=subprocess.PIPE) + time.sleep(STOP_DELAY) + if is_service_active(): + print('WARNING: failed to stop service, mic may not work.') + return False + + return True + + +def start_service(): + """Start the voice-recognizer again.""" + subprocess.check_call(['sudo', 'systemctl', 'start', SERVICE_NAME], stdout=subprocess.PIPE) + + +def check_voicehat_present(): + """Check that the voiceHAT is present.""" + + return any(VOICEHAT_ID in card for card in get_sound_cards().values()) + + +def check_voicehat_is_first_card(): + """Check that the voiceHAT is the first card on the system.""" + + cards = get_sound_cards() + + return 0 in cards and VOICEHAT_ID in cards[0] + + +def check_speaker_works(): + """Check the speaker makes a sound.""" + print('Playing a test sound...') + play_wav(TEST_SOUND_PATH) + + return ask('Did you hear the test sound?') + + +def check_mic_works(): + """Check the microphone records correctly.""" + temp_file, temp_path = tempfile.mkstemp(suffix='.wav') + os.close(temp_file) + + try: + input("When you're ready, press enter and say 'Testing, 1 2 3'...") + print('Recording...') + subprocess.check_call( + [PYTHON3, AUDIO_PY, 'dump', temp_path, + '-d', RECORD_DURATION_SECONDS], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + print('Playing back recorded audio...') + play_wav(temp_path) + finally: + try: + os.unlink(temp_path) + except FileNotFoundError: + pass + + return ask('Did you hear your own voice?') + + +def do_checks(): + """Run all audio checks and print status.""" + if not check_voicehat_present(): + print(textwrap.fill( + """Failed to find the voiceHAT soundcard. Please try reinstalling the +voiceHAT driver:""")) + print(' cd ~/drivers-raspi && sudo ./install.sh && sudo reboot') + return + + if not check_voicehat_is_first_card(): + print(textwrap.fill( + """The voiceHAT not the first sound device, so the voice recognizer +may be unable to find it. Please try removing other sound drivers.""")) + return + + if not check_speaker_works(): + print(textwrap.fill( + """There may be a problem with your speaker. Check that it's +connected properly.""")) + return + + if not check_mic_works(): + print(textwrap.fill( + """There may be a problem with your microphone. Check that it's +connected properly.""")) + return + + print('The audio seems to be working.') + + +def main(): + """Run all checks, stopping the voice-recognizer if necessary.""" + should_restart = stop_service() + + do_checks() + + if should_restart: + start_service() + +if __name__ == '__main__': + try: + main() + input('Press Enter to close...') + except: # pylint: disable=bare-except + traceback.print_exc() + input('Press Enter to close...') diff --git a/checkpoints/check_cloud.py b/checkpoints/check_cloud.py new file mode 100755 index 00000000..2b55beb2 --- /dev/null +++ b/checkpoints/check_cloud.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Check that the Cloud Speech API can be used. +""" + +import json +import os +import subprocess +import traceback + +if os.path.exists('/home/pi/credentials.json'): + # Legacy fallback: old location of credentials. + CREDENTIALS_PATH = '/home/pi/credentials.json' +else: + CREDENTIALS_PATH = '/home/pi/cloud_speech.json' + +VOICE_RECOGNIZER_PATH = '/home/pi/voice-recognizer-raspi' +PYTHON3 = VOICE_RECOGNIZER_PATH + '/env/bin/python3' +SPEECH_PY = VOICE_RECOGNIZER_PATH + '/src/speech.py' +SPEECH_PY_ENV = { + 'VIRTUAL_ENV': VOICE_RECOGNIZER_PATH + '/env', + 'PATH': VOICE_RECOGNIZER_PATH + '/env/bin:' + os.getenv('PATH'), +} +TEST_AUDIO = VOICE_RECOGNIZER_PATH + '/checkpoints/test_hello.raw' +RECOGNIZED_TEXT = 'hello' + + +def check_credentials_valid(): + """Check the credentials are JSON service credentials.""" + try: + obj = json.load(open(CREDENTIALS_PATH)) + except ValueError: + return False + + return 'type' in obj and obj['type'] == 'service_account' + + +def check_speech_reco(): + """Try to test the speech reco code from voice-recognizer-raspi.""" + print('Testing the Google Cloud Speech API...') + p = subprocess.Popen( # pylint: disable=invalid-name + [PYTHON3, SPEECH_PY, TEST_AUDIO], env=SPEECH_PY_ENV, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + output = p.communicate()[0].decode('utf-8') + + if p.returncode: + print('Speech recognition failed with', p.returncode) + print(output) + return False + else: + # speech.py succeeded, check the text was recognized + if RECOGNIZED_TEXT in output: + return True + else: + print('Speech recognition output not as expected:') + print(output) + print('Expected:', RECOGNIZED_TEXT) + return False + + +def main(): + """Run all checks and print status.""" + if not os.path.exists(CREDENTIALS_PATH): + print( + """Please follow these instructions to get Google Cloud credentials: +https://cloud.google.com/speech/docs/getting-started#set_up_your_project +and save them to""", CREDENTIALS_PATH) + return + + if not check_credentials_valid(): + print( + CREDENTIALS_PATH, """is not valid, please check that you have downloaded JSON +service credentials.""") + return + + if not check_speech_reco(): + print('Failed to test the Cloud Speech API. Please see error above.') + return + + print("Everything's set up to use the Google Cloud.") + +if __name__ == '__main__': + try: + main() + input('Press Enter to close...') + except: # pylint: disable=bare-except + traceback.print_exc() + input('Press Enter to close...') diff --git a/checkpoints/check_wifi.py b/checkpoints/check_wifi.py new file mode 100755 index 00000000..a70096b1 --- /dev/null +++ b/checkpoints/check_wifi.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Check that the WiFi is working. +""" + +import socket +import subprocess +import traceback + +WPA_CONF_PATH = '/etc/wpa_supplicant/wpa_supplicant.conf' +GOOGLE_SERVER_ADDRESS = ('speech.googleapis.com', 443) + + +def check_wifi_is_configured(): + """Check wpa_supplicant.conf has at least one network configured.""" + output = subprocess.check_output(['sudo', 'cat', WPA_CONF_PATH]).decode('utf-8') + + return 'network=' in output + + +def check_wifi_is_connected(): + """Check wlan0 has an IP address.""" + output = subprocess.check_output(['ifconfig', 'wlan0']).decode('utf-8') + + return 'inet addr' in output + + +def check_can_reach_google_server(): + """Check the API server is reachable on port 443.""" + print("Trying to contact Google's servers...") + try: + sock = socket.create_connection(GOOGLE_SERVER_ADDRESS, timeout=10) + sock.close() + return True + except: # Many exceptions can come from sockets. pylint: disable=bare-except + return False + + +def main(): + """Run all checks and print status.""" + print('Checking the WiFi connection...') + + if not check_wifi_is_configured(): + print('Please click the WiFi icon at the top right to set up a WiFi network.') + return + + if not check_wifi_is_connected(): + print( + """You are not connected to WiFi. Please click the WiFi icon at the top right +to check your settings.""") + return + + if not check_can_reach_google_server(): + print( + """Failed to reach Google servers. Please check that your WiFi network is +connected to the internet.""") + return + + print('The WiFi connection seems to be working.') + +if __name__ == '__main__': + try: + main() + input('Press Enter to close...') + except: # pylint: disable=bare-except + traceback.print_exc() + input('Press Enter to close...') diff --git a/checkpoints/load_test.py b/checkpoints/load_test.py new file mode 100755 index 00000000..5cbeccd5 --- /dev/null +++ b/checkpoints/load_test.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Synthetic load test simillar to running the actual app. +""" + +import json +import os +import subprocess +import tempfile +import time +import traceback + +if os.path.exists('/home/pi/credentials.json'): + # Legacy fallback: old location of credentials. + CREDENTIALS_PATH = '/home/pi/credentials.json' +else: + CREDENTIALS_PATH = '/home/pi/cloud_speech.json' + +SERVICE_NAME = 'voice-recognizer' +ACTIVE_STR = 'ActiveState=active' +INACTIVE_STR = 'ActiveState=inactive' + +STOP_DELAY = 1.0 + +VOICE_RECOGNIZER_PATH = '/home/pi/voice-recognizer-raspi' +PYTHON3 = VOICE_RECOGNIZER_PATH + '/env/bin/python3' +AUDIO_PY = VOICE_RECOGNIZER_PATH + '/src/audio.py' +SPEECH_PY = VOICE_RECOGNIZER_PATH + '/src/speech.py' +SPEECH_PY_ENV = { + 'VIRTUAL_ENV': VOICE_RECOGNIZER_PATH + '/env', + 'PATH': VOICE_RECOGNIZER_PATH + '/env/bin:' + os.getenv('PATH'), +} +TEST_AUDIO = '/usr/share/sounds/alsa/Front_Center.wav' +LED_FIFO = '/tmp/status-led' + +RECORD_DURATION_SECONDS = '3' + + +def check_credentials_valid(): + """Check the credentials are JSON service credentials.""" + try: + obj = json.load(open(CREDENTIALS_PATH)) + except ValueError: + return False + + return 'type' in obj and obj['type'] == 'service_account' + + +def is_service_active(): + """Returns True if the voice-recognizer service is active.""" + output = subprocess.check_output(['systemctl', 'show', SERVICE_NAME]).decode('utf-8') + + if ACTIVE_STR in output: + return True + elif INACTIVE_STR in output: + return False + else: + print('WARNING: failed to parse output:') + print(output) + return False + + +def stop_service(): + """Stop the voice-recognizer so we can use the mic. + + Returns: + True if the service has been stopped. + """ + if not is_service_active(): + return False + + subprocess.check_call(['sudo', 'systemctl', 'stop', SERVICE_NAME], stdout=subprocess.PIPE) + time.sleep(STOP_DELAY) + if is_service_active(): + print('WARNING: failed to stop service, mic may not work.') + return False + + return True + + +def start_service(): + """Start the voice-recognizer again.""" + subprocess.check_call(['sudo', 'systemctl', 'start', SERVICE_NAME], stdout=subprocess.PIPE) + + +def check_speech_reco(): + """Try to test the speech reco code from voice-recognizer-raspi.""" + p = subprocess.Popen( # pylint: disable=invalid-name + [PYTHON3, SPEECH_PY, TEST_AUDIO], env=SPEECH_PY_ENV, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + p.communicate()[0].decode('utf-8') + + if p.returncode: + return False + else: + return True + + +def play_wav(): + """Play a WAV file.""" + subprocess.check_call([PYTHON3, AUDIO_PY, 'play', TEST_AUDIO], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def record_wav(): + """Record a wav file.""" + temp_file, temp_path = tempfile.mkstemp(suffix='.wav') + os.close(temp_file) + subprocess.check_call( + [PYTHON3, AUDIO_PY, 'dump', temp_path, + '-d', RECORD_DURATION_SECONDS], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + os.unlink(temp_path) + except FileNotFoundError: + pass + + +def led_status(status): + with open(LED_FIFO, 'w') as led: + led.write(status + '\n') + + +def run_test(): + print('Running test forever - press Ctrl+C to stop...') + try: + while True: + print('\rrecognizing', end='') + led_status('listening') + check_speech_reco() + time.sleep(0.5) + print('\rrecording ', end='') + led_status('thinking') + record_wav() + time.sleep(0.5) + print('\rplaying ', end='') + led_status('ready') + play_wav() + time.sleep(0.5) + except KeyboardInterrupt: + led_status('power-off') + print('\nTest finished') + + +def main(): + """Run all checks and print status.""" + if not os.path.exists(CREDENTIALS_PATH): + print( + """Please follow these instructions to get Google Cloud credentials: +https://cloud.google.com/speech/docs/getting-started#set_up_your_project +and save them to""", CREDENTIALS_PATH) + return + + if not check_credentials_valid(): + print( + CREDENTIALS_PATH, """is not valid, please check that you have downloaded JSON +service credentials.""") + return + + should_restart = stop_service() + + run_test() + + if should_restart: + start_service() + + +if __name__ == '__main__': + try: + main() + input('Press Enter to close...') + except: # pylint: disable=bare-except + traceback.print_exc() + input('Press Enter to close...') diff --git a/checkpoints/test_hello.raw b/checkpoints/test_hello.raw new file mode 100644 index 0000000000000000000000000000000000000000..42538f819bc173a87c087ca1c5fa6fb6bac2db39 GIT binary patch literal 96000 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, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: voice-recognizer-1.0\n" +"POT-Creation-Date: 2017-04-21 14:33+CEST\n" +"PO-Revision-Date: 2017-02-16 09:26+0100\n" +"Last-Translator: Stefan Sauer \n" +"Language-Team: \n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" +"X-Generator: Poedit 1.5.4\n" + +#: src/action.py:172 +msgid "Volume at %d %%." +msgstr "Lautstärke auf %d %%." + +#: src/action.py:202 +msgid "ip address" +msgstr "IP Adresse" + +#: src/action.py:204 +msgid "I do not have an ip address assigned to me." +msgstr "Ich habe keine IP Adresse zugewiesen bekommen." + +#: src/action.py:206 +msgid "volume up" +msgstr "Lautstärke hoch" + +#: src/action.py:207 +msgid "volume down" +msgstr "Lautstärke runter" + +#: src/action.py:208 +msgid "max volume" +msgstr "volle Lautstärke" + +#: src/action.py:210 src/action.py:211 +msgid "repeat after me" +msgstr "sprich mir nach" + +#: src/action.py:225 +msgid "We've been friends since we were both starter projects" +msgstr "Da wir beide ganz neu sind, verstehen wir uns prima." + +#: src/action.py:229 +msgid "clap" +msgstr "klatsche" + +#: src/action.py:229 +msgid "clap clap" +msgstr "klatsch klatsch" + +#: src/action.py:230 +msgid "She taught me everything I know." +msgstr "Sie hat mir alles im Leben beigebracht." + +#: src/action.py:231 +msgid "hello" +msgstr "Hallo" + +#: src/action.py:231 +msgid "hello to you too" +msgstr "Hallo auch zu dir" + +#: src/action.py:232 +msgid "tell me a joke" +msgstr "Erzähl mir einen Witz" + +#: src/action.py:233 +msgid "What do you call an alligator in a vest? An investigator." +msgstr "Was hat vier Beine und kann fliegen? Zwei Vögel." + +#: src/action.py:234 +msgid "three laws of robotics" +msgstr "" + +#: src/action.py:235 +msgid "" +"The laws of robotics are\n" +"0: A robot may not injure a human being or, through inaction, allow a human\n" +"being to come to harm.\n" +"1: A robot must obey orders given it by human beings except where such " +"orders\n" +"would conflict with the First Law.\n" +"2: A robot must protect its own existence as long as such protection does " +"not\n" +"conflict with the First or Second Law." +msgstr "" + +#: src/action.py:242 +msgid "A galaxy far, far, just kidding. I'm from Seattle." +msgstr "" +"Aus einer weit, weit entfernten Galaxie, Ach Quatsch ich komm aus Seattle." + +#: src/action.py:242 +msgid "where are you from" +msgstr "Woher kommst du" + +#: src/action.py:243 +msgid "A machine has no name" +msgstr "Eine Maschine hat keinen Namen." + +#: src/action.py:243 +msgid "your name" +msgstr "dein Name" + +#: src/action.py:245 +msgid "time" +msgstr "Zeit" + +#: src/main.py:279 +msgid "Unexpected error. Try again or check the logs." +msgstr "Unerwarteter Fehler. Probiere nocheinmal oder kontrolliere die Logs." + +#: src/main.py:292 +msgid "I don’t know how to answer that." +msgstr "Das weis ich nicht." + +#: src/main.py:295 +msgid "Could you try that again?" +msgstr "Bitte probiere es nocheinmal." diff --git a/po/de/LC_MESSAGES/voice-recognizer.mo b/po/de/LC_MESSAGES/voice-recognizer.mo new file mode 100644 index 0000000000000000000000000000000000000000..0226877ae69a3ed3c263421ab4cbaec81717755f GIT binary patch literal 2069 zcmZ9Mzi%8x6vrnJeqH#H1i}vq!~@AnVejV7F(El1!Lc1;CjKPEVDs>n<37!I1 z!870o;6d;!@FDOU@ILT+Q0#vI8{p3~{s}&Y_uJs3-~lX_^@lMW0*`^R&kNvDaDMiF z7QBWSE`o1j{tGyH9=r`c2Ohb9s`CW+INsj}kAogu1wR9y1b+jUz`wy3IRAiB4}#Y~ z(bWS*{}4O^eh7;Gk7w^UK(YH0l>NR1AxZrRir$|Lz}riGJ}Z`W{9X9nzkdqK-?n;%Jpxrc~#8 zcT7F)7pBAYstnXONn&go)B5p(dfpYZ#X5wXV=O6jH#3$g(rJYAa(3x=OQv6~sgK;{Y`&t=DT}b=mUnfV%;^viHu%v<*eI7;>Jv8F%~Giz#<$ z8LKNUudosH&LuQA7pomU4xJrli@*)rq@>c=F4IVU9-myus0`W%h#Giz9nMO%BOxPF zNkLcVxkL(>R0nu9c%dPe;T$a^16CfVpJLU`^`IW8e#$t{ovKF(zD}ugYCmhL(7QCO zWh>7EoD1omXu&ZAES9`zy6*aTHJF>4LfgphJy!!Y$|M$zE*|8F6(0QbG&iSFMe9j) zg?(w9ZNsDKa^$(|QfnscqgLE(Y`(o6t$0X)Nwlg1w`sB2dO2z?MT;$JEwxXcoL^bn z+J<5DE)R`tb(h^@)H+4YGwsDw^UYSX+1Su!h_*eBJeS0^X)ACKsoc^PB7Bu?>8$C# zo;sJ}GIm{Y7E5ee>6D{w)PsI5Eg^Q(wW= z(7xpG+U4yF(dm6V5z|AwYqsm8DB5(oV?yH%w(Jpc5}hBnX)sPX$Y$|iOs)8(#(UAs zK!VtGoR}c<@lr!AS0ynD$m2eluuzW#NpK?4=nW}J!wazlM(*Co@W zN_k|s#7-R+x3bwP>K%&9S_@7g3n)h;{{yH+wJ(0(PiMKa!G> zi+X{xoKcv#W>a1JqR1sM=;MsML-mO01THq}THVl9D8sFrzK@$*_4X=uRnrLWJ8Tlh z|CybT$uNaRnIjkxK7y(kLK5iru&3g|tn@|@KxSNiUg`7?*i1unfpHg=@qS)83O6Y*8QYRyk>-ONzD sn3x{njtLWW6*olvXC3(pcf{W{mElO$jzdA$Hw1s*Nu?5OQr`mgFYrAqaR2}S literal 0 HcmV?d00001 diff --git a/po/voice-recognizer.pot b/po/voice-recognizer.pot new file mode 100644 index 00000000..2bce8ab8 --- /dev/null +++ b/po/voice-recognizer.pot @@ -0,0 +1,124 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2017-04-21 14:33+CEST\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: pygettext.py 1.5\n" + + +#: src/action.py:172 +msgid "Volume at %d %%." +msgstr "" + +#: src/action.py:202 +msgid "ip address" +msgstr "" + +#: src/action.py:204 +msgid "I do not have an ip address assigned to me." +msgstr "" + +#: src/action.py:206 +msgid "volume up" +msgstr "" + +#: src/action.py:207 +msgid "volume down" +msgstr "" + +#: src/action.py:208 +msgid "max volume" +msgstr "" + +#: src/action.py:210 src/action.py:211 +msgid "repeat after me" +msgstr "" + +#: src/action.py:225 +msgid "We've been friends since we were both starter projects" +msgstr "" + +#: src/action.py:229 +msgid "clap" +msgstr "" + +#: src/action.py:229 +msgid "clap clap" +msgstr "" + +#: src/action.py:230 +msgid "She taught me everything I know." +msgstr "" + +#: src/action.py:231 +msgid "hello" +msgstr "" + +#: src/action.py:231 +msgid "hello to you too" +msgstr "" + +#: src/action.py:232 +msgid "tell me a joke" +msgstr "" + +#: src/action.py:233 +msgid "What do you call an alligator in a vest? An investigator." +msgstr "" + +#: src/action.py:234 +msgid "three laws of robotics" +msgstr "" + +#: src/action.py:235 +msgid "" +"The laws of robotics are\n" +"0: A robot may not injure a human being or, through inaction, allow a human\n" +"being to come to harm.\n" +"1: A robot must obey orders given it by human beings except where such orders\n" +"would conflict with the First Law.\n" +"2: A robot must protect its own existence as long as such protection does not\n" +"conflict with the First or Second Law." +msgstr "" + +#: src/action.py:242 +msgid "A galaxy far, far, just kidding. I'm from Seattle." +msgstr "" + +#: src/action.py:242 +msgid "where are you from" +msgstr "" + +#: src/action.py:243 +msgid "A machine has no name" +msgstr "" + +#: src/action.py:243 +msgid "your name" +msgstr "" + +#: src/action.py:245 +msgid "time" +msgstr "" + +#: src/main.py:279 +msgid "Unexpected error. Try again or check the logs." +msgstr "" + +#: src/main.py:292 +msgid "I don’t know how to answer that." +msgstr "" + +#: src/main.py:295 +msgid "Could you try that again?" +msgstr "" + diff --git a/scripts/asound.conf b/scripts/asound.conf new file mode 100755 index 00000000..e5b102f8 --- /dev/null +++ b/scripts/asound.conf @@ -0,0 +1,30 @@ +options snd_rpi_googlemihat_soundcard index=0 + +pcm.softvol { + type softvol + slave.pcm dmix + control { + name Master + card 0 + } +} + +pcm.micboost { + type route + slave.pcm dsnoop + ttable { + 0.0 30.0 + 1.1 30.0 + } +} + +pcm.!default { + type asym + playback.pcm "plug:softvol" + capture.pcm "plug:micboost" +} + +ctl.!default { + type hw + card 0 +} diff --git a/scripts/install-alsa-config.sh b/scripts/install-alsa-config.sh new file mode 100755 index 00000000..2ba86816 --- /dev/null +++ b/scripts/install-alsa-config.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# +# Replace the Raspberry Pi's default ALSA config with one for the voiceHAT. +# +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -o errexit + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +asoundrc=/home/pi/.asoundrc +global_asoundrc=/etc/asound.conf + +for rcfile in "$asoundrc" "$global_asoundrc"; do + if [[ -f "$rcfile" ]] ; then + echo "Renaming $rcfile to $rcfile.bak..." + mv "$rcfile" "$rcfile.bak" + fi +done + +cp scripts/asound.conf "$global_asoundrc" +echo "Installed voiceHAT ALSA config at $global_asoundrc" diff --git a/scripts/install-deps.sh b/scripts/install-deps.sh new file mode 100755 index 00000000..9d65b94a --- /dev/null +++ b/scripts/install-deps.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +RUN_AS="pi" + +set -o errexit + +if [ "$USER" != "$RUN_AS" ] +then + echo "This script must run as $RUN_AS, trying to change user..." + exec sudo -u $RUN_AS $0 +fi + +sudo apt-get -y install alsa-utils python3-all-dev python3-pip python3-numpy \ + python3-scipy python3-virtualenv rsync sox libttspico-utils ntpdate +sudo apt-get -y install -t stretch python3-httplib2 python3-configargparse +sudo pip3 install --upgrade pip virtualenv + +cd ~/voice-recognizer-raspi +virtualenv --system-site-packages -p python3 env +env/bin/pip install google-assistant-sdk[auth_helpers]==0.1.0 \ + grpc-google-cloud-speech-v1beta1==0.14.0 protobuf==3.1.0 diff --git a/scripts/install-services.sh b/scripts/install-services.sh new file mode 100755 index 00000000..40d54882 --- /dev/null +++ b/scripts/install-services.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# +# Install systemd service files for running on startup. +# +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -o errexit + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +for service in systemd/*.service; do + cp $service /lib/systemd/system/ +done + +# voice-recognizer is not enabled by default, as it doesn't work until +# credentials are set up, so we explicitly enable the other services. +sudo systemctl enable alsa-init.service +sudo systemctl enable ntpdate.service +sudo systemctl enable status-led.service +sudo systemctl enable status-led-on.service +sudo systemctl enable status-led-off.service +sudo systemctl enable status-monitor.service diff --git a/scripts/pre-commit b/scripts/pre-commit new file mode 100755 index 00000000..8e0fe2e7 --- /dev/null +++ b/scripts/pre-commit @@ -0,0 +1,18 @@ +#!/bin/sh +# git hook to ensure code style +# cp scripts/pre-commit .git/hooks/ + +which >/dev/null pep8 || (echo "please install pep8"; exit 1) +files=$(git diff --name-only --staged --diff-filter=ACMRTUXB | egrep "*.py$") + +if test -n "$files"; then + pep8 --max-line-length=120 $files + res=$? + if [ $res -ne 0 ]; then + echo + autopep8 --max-line-length=120 --diff $files + echo + echo "To fix run: autopep8 --max-line-length=120 -i $files" + fi + exit $res +fi diff --git a/shortcuts/check_audio.desktop b/shortcuts/check_audio.desktop new file mode 100644 index 00000000..4f54c918 --- /dev/null +++ b/shortcuts/check_audio.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Encoding=UTF-8 +Type=Application +Name=Check audio +Comment=Check that the voiceHAT audio input and output are both working. +Exec=/home/pi/voice-recognizer-raspi/checkpoints/check_audio.py +Terminal=true diff --git a/shortcuts/check_cloud.desktop b/shortcuts/check_cloud.desktop new file mode 100644 index 00000000..0d8566e6 --- /dev/null +++ b/shortcuts/check_cloud.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Encoding=UTF-8 +Type=Application +Name=Check Cloud +Comment=Check that the Cloud Speech API can be used. +Exec=/home/pi/voice-recognizer-raspi/checkpoints/check_cloud.py +Terminal=true diff --git a/shortcuts/check_wifi.desktop b/shortcuts/check_wifi.desktop new file mode 100644 index 00000000..1c62c99c --- /dev/null +++ b/shortcuts/check_wifi.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Encoding=UTF-8 +Type=Application +Name=Check WiFi +Comment=Check that the WiFi is working. +Exec=/home/pi/voice-recognizer-raspi/checkpoints/check_wifi.py +Terminal=true diff --git a/src/action.py b/src/action.py new file mode 100644 index 00000000..b149d221 --- /dev/null +++ b/src/action.py @@ -0,0 +1,251 @@ +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Carry out voice commands by recognising keywords.""" + +import datetime +import logging +import subprocess + +import actionbase + +# ============================================================================= +# +# Hey, Makers! +# +# This file contains some examples of voice commands that are handled locally, +# right on your Raspberry Pi. +# +# Do you want to add a new voice command? Check out the instructions at: +# https://aiyprojects.withgoogle.com/voice/#makers-guide-3-3--create-a-new-voice-command-or-action +# (MagPi readers - watch out! You should switch to the instructions in the link +# above, since there's a mistake in the MagPi instructions.) +# +# In order to make a new voice command, you need to do two things. First, make a +# new action where it says: +# "Implement your own actions here" +# Secondly, add your new voice command to the actor near the bottom of the file, +# where it says: +# "Add your own voice commands here" +# +# ============================================================================= + +# Actions might not use the user's command. pylint: disable=unused-argument + + +# Example: Say a simple response +# ================================ +# +# This example will respond to the user by saying something. You choose what it +# says when you add the command below - look for SpeakAction at the bottom of +# the file. +# +# There are two functions: +# __init__ is called when the voice commands are configured, and stores +# information about how the action should work: +# - self.say is a function that says some text aloud. +# - self.words are the words to use as the response. +# run is called when the voice command is used. It gets the user's exact voice +# command as a parameter. + +class SpeakAction(object): + + """Says the given text via TTS.""" + + def __init__(self, say, words): + self.say = say + self.words = words + + def run(self, voice_command): + self.say(self.words) + + +# Example: Tell the current time +# ============================== +# +# This example will tell the time aloud. The to_str function will turn the time +# into helpful text (for example, "It is twenty past four."). The run function +# uses to_str say it aloud. + +class SpeakTime(object): + + """Says the current local time with TTS.""" + + def __init__(self, say): + self.say = say + + def run(self, voice_command): + time_str = self.to_str(datetime.datetime.now()) + self.say(time_str) + + def to_str(self, dt): + """Convert a datetime to a human-readable string.""" + HRS_TEXT = ['midnight', 'one', 'two', 'three', 'four', 'five', 'six', + 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve'] + MINS_TEXT = ["five", "ten", "quarter", "twenty", "twenty-five", "half"] + hour = dt.hour + minute = dt.minute + + # convert to units of five minutes to the nearest hour + minute_rounded = (minute + 2) // 5 + minute_is_inverted = minute_rounded > 6 + if minute_is_inverted: + minute_rounded = 12 - minute_rounded + hour = (hour + 1) % 24 + + # convert time from 24-hour to 12-hour + if hour > 12: + hour -= 12 + + if minute_rounded == 0: + if hour == 0: + return 'It is midnight.' + return "It is %s o'clock." % HRS_TEXT[hour] + + if minute_is_inverted: + return 'It is %s to %s.' % (MINS_TEXT[minute_rounded - 1], HRS_TEXT[hour]) + return 'It is %s past %s.' % (MINS_TEXT[minute_rounded - 1], HRS_TEXT[hour]) + + +# Example: Run a shell command and say its output +# =============================================== +# +# This example will use a shell command to work out what to say. You choose the +# shell command when you add the voice command below - look for the example +# below where it says the IP address of the Raspberry Pi. + +class SpeakShellCommandOutput(object): + + """Speaks out the output of a shell command.""" + + def __init__(self, say, shell_command, failure_text): + self.say = say + self.shell_command = shell_command + self.failure_text = failure_text + + def run(self, voice_command): + output = subprocess.check_output(self.shell_command, shell=True).strip() + if output: + self.say(output) + elif self.failure_text: + self.say(self.failure_text) + + +# Example: Change the volume +# ========================== +# +# This example will can change the speaker volume of the Raspberry Pi. It uses +# the shell command SET_VOLUME to change the volume, and then GET_VOLUME gets +# the new volume. The example says the new volume aloud after changing the +# volume. + +class VolumeControl(object): + + """Changes the volume and says the new level.""" + + GET_VOLUME = r'amixer get Master | grep "Front Left:" | sed "s/.*\[\([0-9]\+\)%\].*/\1/"' + SET_VOLUME = 'amixer -q set Master %d%%' + + def __init__(self, say, change): + self.say = say + self.change = change + + def run(self, voice_command): + res = subprocess.check_output(VolumeControl.GET_VOLUME, shell=True).strip() + try: + logging.info("volume: %s", res) + vol = int(res) + self.change + vol = max(0, min(100, vol)) + subprocess.call(VolumeControl.SET_VOLUME % vol, shell=True) + self.say(_('Volume at %d %%.') % vol) + except (ValueError, subprocess.CalledProcessError): + logging.exception("Error using amixer to adjust volume.") + + +# Example: Repeat after me +# ======================== +# +# This example will repeat what the user said. It shows how you can access what +# the user said, and change what you do or how you respond. + +class RepeatAfterMe(object): + + """Repeats the user's command.""" + + def __init__(self, say, keyword): + self.say = say + self.keyword = keyword + + def run(self, voice_command): + # The command still has the 'repeat after me' keyword, so we need to + # remove it before saying whatever is left. + to_repeat = voice_command.replace(self.keyword, '', 1) + self.say(to_repeat) + + +# ========================================= +# Makers! Implement your own actions here. +# ========================================= + + +def make_actor(say): + """Create an actor to carry out the user's commands.""" + + actor = actionbase.Actor() + + actor.add_keyword( + _('ip address'), SpeakShellCommandOutput( + say, "ip -4 route get 1 | head -1 | cut -d' ' -f8", + _('I do not have an ip address assigned to me.'))) + + actor.add_keyword(_('volume up'), VolumeControl(say, 10)) + actor.add_keyword(_('volume down'), VolumeControl(say, -10)) + actor.add_keyword(_('max volume'), VolumeControl(say, 100)) + + actor.add_keyword(_('repeat after me'), + RepeatAfterMe(say, _('repeat after me'))) + + # ========================================= + # Makers! Add your own voice commands here. + # ========================================= + + return actor + + +def add_commands_just_for_cloud_speech_api(actor, say): + """Add simple commands that are only used with the Cloud Speech API.""" + def simple_command(keyword, response): + actor.add_keyword(keyword, SpeakAction(say, response)) + + simple_command('alexa', _("We've been friends since we were both starter projects")) + simple_command( + 'beatbox', + 'pv zk pv pv zk pv zk kz zk pv pv pv zk pv zk zk pzk pzk pvzkpkzvpvzk kkkkkk bsch') + simple_command(_('clap'), _('clap clap')) + simple_command('google home', _('She taught me everything I know.')) + simple_command(_('hello'), _('hello to you too')) + simple_command(_('tell me a joke'), + _('What do you call an alligator in a vest? An investigator.')) + simple_command(_('three laws of robotics'), + _("""The laws of robotics are +0: A robot may not injure a human being or, through inaction, allow a human +being to come to harm. +1: A robot must obey orders given it by human beings except where such orders +would conflict with the First Law. +2: A robot must protect its own existence as long as such protection does not +conflict with the First or Second Law.""")) + simple_command(_('where are you from'), _("A galaxy far, far, just kidding. I'm from Seattle.")) + simple_command(_('your name'), _('A machine has no name')) + + actor.add_keyword(_('time'), SpeakTime(say)) diff --git a/src/actionbase.py b/src/actionbase.py new file mode 100644 index 00000000..7e802a4c --- /dev/null +++ b/src/actionbase.py @@ -0,0 +1,63 @@ +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Handle voice commands locally. + +This code lets you link keywords to actions. The actions are declared in +action.py. +""" + + +class Actor(object): + + """Passes commands on to a list of action handlers.""" + + def __init__(self): + self.handlers = [] + + def add_keyword(self, keyword, action): + self.handlers.append(KeywordHandler(keyword, action)) + + def get_phrases(self): + """Get a list of all phrases that are expected by the handlers.""" + return [phrase for h in self.handlers for phrase in h.get_phrases()] + + def handle(self, command): + """Pass command to handlers, stopping after one has handled the command. + + Returns True if the command was handled.""" + + for handler in self.handlers: + if handler.handle(command): + return True + return False + + +class KeywordHandler(object): + + """Perform the action when the given keyword is in the command.""" + + def __init__(self, keyword, action): + self.keyword = keyword.lower() + self.action = action + + def get_phrases(self): + return [self.keyword] + + def handle(self, command): + if self.keyword in command.lower(): + self.action.run(command) + return True + else: + return False diff --git a/src/audio.py b/src/audio.py new file mode 100644 index 00000000..c3e3fa34 --- /dev/null +++ b/src/audio.py @@ -0,0 +1,251 @@ +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Wraps the audio backend with a simple Python interface for recording and +playback. +""" + +import logging +import os +import subprocess +import threading +import wave + +logger = logging.getLogger('audio') + + +def sample_width_to_string(sample_width): + """Convert sample width (bytes) to ALSA format string.""" + return {1: 's8', 2: 's16', 4: 's32'}[sample_width] + + +class Recorder(threading.Thread): + + """Stream audio from microphone in a background thread and run processing + callbacks. It reads audio in a configurable format from the microphone, + then converts it to a known format before passing it to the processors. + """ + + CHUNK_S = 0.1 + + def __init__(self, input_device='default', + channels=1, bytes_per_sample=2, sample_rate_hz=16000): + """Create a Recorder with the given audio format. + + The Recorder will not start until start() is called. start() is called + automatically if the Recorder is used in a `with`-statement. + + - input_device: name of ALSA device (for a list, run `arecord -L`) + - channels: number of channels in audio read from the mic + - bytes_per_sample: sample width in bytes (eg 2 for 16-bit audio) + - sample_rate_hz: sample rate in hertz + """ + + super().__init__() + + self._processors = [] + + self._chunk_bytes = int(self.CHUNK_S * sample_rate_hz) * channels * bytes_per_sample + + self._cmd = [ + 'arecord', + '-q', + '-t', 'raw', + '-D', input_device, + '-c', str(channels), + '-f', sample_width_to_string(bytes_per_sample), + '-r', str(sample_rate_hz), + ] + self._arecord = None + self._closed = False + + def add_processor(self, processor): + self._processors.append(processor) + + def del_processor(self, processor): + self._processors.remove(processor) + + def run(self): + """Reads data from arecord and passes to processors.""" + + self._arecord = subprocess.Popen(self._cmd, stdout=subprocess.PIPE) + logger.info("started recording") + + # check for race-condition when __exit__ is called at the same time as + # the process is started by the background thread + if self._closed: + self._arecord.kill() + return + + this_chunk = b'' + + while True: + input_data = self._arecord.stdout.read(self._chunk_bytes) + if not input_data: + break + + this_chunk += input_data + if len(this_chunk) >= self._chunk_bytes: + self._handle_chunk(this_chunk[:self._chunk_bytes]) + this_chunk = this_chunk[self._chunk_bytes:] + + if not self._closed: + logger.error('Microphone recorder died unexpectedly, aborting...') + # sys.exit doesn't work from background threads, so use os._exit as + # an emergency measure. + logging.shutdown() + os._exit(1) # pylint: disable=protected-access + + def _handle_chunk(self, chunk): + """Send audio chunk to all processors. + """ + for p in self._processors: + p.add_data(chunk) + + def __enter__(self): + self.start() + return self + + def __exit__(self, *args): + self._closed = True + if self._arecord: + self._arecord.kill() + + +class Player(object): + + """Plays short audio clips from a buffer or file.""" + + def __init__(self, output_device='default'): + self._output_device = output_device + + def play_bytes(self, audio_bytes, sample_rate, sample_width=2): + """Play audio from the given bytes-like object. + + audio_bytes: audio data (mono) + sample_rate: sample rate in Hertz (24 kHz by default) + sample_width: sample width in bytes (eg 2 for 16-bit audio) + """ + + cmd = [ + 'aplay', + '-q', + '-t', 'raw', + '-D', self._output_device, + '-c', '1', + '-f', sample_width_to_string(sample_width), + '-r', str(sample_rate), + ] + + aplay = subprocess.Popen(cmd, stdin=subprocess.PIPE) + aplay.stdin.write(audio_bytes) + aplay.stdin.close() + retcode = aplay.wait() + + if retcode: + logger.error('aplay failed with %d', retcode) + + def play_wav(self, wav_path): + """Play audio from the given WAV file. The file should be mono and + small enough to load into memory. + + wav_path: path to wav file + """ + + with wave.open(wav_path, 'r') as wav: + if wav.getnchannels() != 1: + raise ValueError(wav_path + 'is not a mono file') + + frames = wav.readframes(wav.getnframes()) + self.play_bytes(frames, wav.getframerate(), wav.getsampwidth()) + + +class WavDump(object): + + """A processor that logs to a WAV file, for testing audio recording.""" + + def __init__(self, path, duration, + channels, bytes_per_sample, sample_rate_hz): + self._wav = wave.open(path, 'wb') + self._wav.setnchannels(channels) + self._wav.setsampwidth(bytes_per_sample) + self._wav.setframerate(sample_rate_hz) + + self._n_bytes = 0 + self._total_bytes = int(duration * sample_rate_hz) * channels * bytes_per_sample + + def add_data(self, data): + """Write frames to the file if they fit within the total size.""" + max_bytes = self._total_bytes - self._n_bytes + data = data[:max_bytes] + self._n_bytes += len(data) + + if data: + self._wav.writeframes(data) + + def is_done(self): + return self._n_bytes >= self._total_bytes + + def __enter__(self): + return self + + def __exit__(self, *args): + self._wav.close() + + +def main(): + logging.basicConfig(level=logging.INFO) + + import argparse + import time + + parser = argparse.ArgumentParser(description="Test audio wrapper") + parser.add_argument('action', choices=['dump', 'play'], + help='What to do with the audio') + parser.add_argument('-I', '--input-device', default='default', + help='Name of the audio input device') + parser.add_argument('-c', '--channels', type=int, default=1, + help='Number of channels') + parser.add_argument('-f', '--bytes-per-sample', type=int, default=2, + help='Sample width in bytes') + parser.add_argument('-r', '--rate', type=int, default=16000, + help='Sample rate in Hertz') + parser.add_argument('-O', '--output-device', default='default', + help='Name of the audio output device') + parser.add_argument('-d', '--duration', default=2, type=float, + help='Dump duration in seconds (default: 2)') + parser.add_argument('filename', help='Path to WAV file') + args = parser.parse_args() + + if args.action == 'dump': + recorder = Recorder( + input_device=args.input_device, + channels=args.channels, + bytes_per_sample=args.bytes_per_sample, + sample_rate_hz=args.rate) + + dumper = WavDump(args.filename, args.duration, args.channels, + args.bytes_per_sample, args.rate) + + with recorder, dumper: + recorder.add_processor(dumper) + + while not dumper.is_done(): + time.sleep(0.1) + + elif args.action == 'play': + Player(args.output_device).play_wav(args.filename) + +if __name__ == '__main__': + main() diff --git a/src/i18n.py b/src/i18n.py new file mode 100644 index 00000000..9b321a16 --- /dev/null +++ b/src/i18n.py @@ -0,0 +1,51 @@ +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Internationalization helpers.""" + +import gettext +import os + +DEFAULT_LANGUAGE_CODE = 'en-US' + +LOCALE_DIR = os.path.realpath( + os.path.join(os.path.abspath(os.path.dirname(__file__)), '../po')) +LOCALE_DOMAIN = 'voice-recognizer' + +_language_code = DEFAULT_LANGUAGE_CODE + + +def set_language_code(code, gettext_install=False): + """Set the BCP-47 language code that the speech systems should use. + + Args: + gettext_install: if True, gettext's _() will be installed in as a builtin. + As this has global effect, it should only be done by applications. + """ + global _language_code # pylint: disable=global-statement + _language_code = code.replace('_', '-') + + if gettext_install: + language_id = code.replace('-', '_') + t = gettext.translation(LOCALE_DOMAIN, LOCALE_DIR, [language_id], fallback=True) + t.install() + + +def get_language_code(): + """Returns the BCP-47 language code that the speech systems should use. + + We don't use the system locale because the Assistant API only supports + en-US at launch, so that should be used by default in all environments. + """ + return _language_code diff --git a/src/led.py b/src/led.py new file mode 100644 index 00000000..dca70ab5 --- /dev/null +++ b/src/led.py @@ -0,0 +1,160 @@ +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +'''Signal states on a LED''' + +import itertools +import logging +import os +import threading +import time + +import RPi.GPIO as GPIO + +logger = logging.getLogger('led') + +CONFIG_DIR = os.getenv('XDG_CONFIG_HOME') or os.path.expanduser('~/.config') +CONFIG_FILES = [ + '/etc/status-led.ini', + os.path.join(CONFIG_DIR, 'status-led.ini') +] + + +class LED: + + """Starts a background thread to show patterns with the LED.""" + + def __init__(self, channel): + self.animator = threading.Thread(target=self._animate) + self.channel = channel + self.iterator = None + self.running = False + self.state = None + self.sleep = 0 + + GPIO.setup(channel, GPIO.OUT) + self.pwm = GPIO.PWM(channel, 100) + + def start(self): + self.pwm.start(0) # off by default + self.running = True + self.animator.start() + + def stop(self): + self.running = False + self.animator.join() + self.pwm.stop() + GPIO.output(self.channel, GPIO.LOW) + + def set_state(self, state): + self.state = state + + def _animate(self): + # TODO(ensonic): refactor or add justification + # pylint: disable=too-many-branches + while self.running: + if self.state: + if self.state == 'on': + self.iterator = None + self.sleep = 0.0 + self.pwm.ChangeDutyCycle(100) + elif self.state == 'off': + self.iterator = None + self.sleep = 0.0 + self.pwm.ChangeDutyCycle(0) + elif self.state == 'blink': + self.iterator = itertools.cycle([0, 100]) + self.sleep = 0.5 + elif self.state == 'blink-3': + self.iterator = itertools.cycle([0, 100] * 3 + [0, 0]) + self.sleep = 0.25 + elif self.state == 'beacon': + self.iterator = itertools.cycle( + itertools.chain([30] * 100, [100] * 8, range(100, 30, -5))) + self.sleep = 0.05 + elif self.state == 'beacon-dark': + self.iterator = itertools.cycle( + itertools.chain([0] * 100, range(0, 30, 3), range(30, 0, -3))) + self.sleep = 0.05 + elif self.state == 'decay': + self.iterator = itertools.cycle(range(100, 0, -2)) + self.sleep = 0.05 + elif self.state == 'pulse-slow': + self.iterator = itertools.cycle( + itertools.chain(range(0, 100, 2), range(100, 0, -2))) + self.sleep = 0.1 + elif self.state == 'pulse-quick': + self.iterator = itertools.cycle( + itertools.chain(range(0, 100, 5), range(100, 0, -5))) + self.sleep = 0.05 + else: + logger.warning("unsupported state: %s", self.state) + self.state = None + if self.iterator: + self.pwm.ChangeDutyCycle(next(self.iterator)) + time.sleep(self.sleep) + else: + time.sleep(1) + + +def main(): + logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s] %(levelname)s:%(name)s:%(message)s" + ) + + import configargparse + parser = configargparse.ArgParser( + default_config_files=CONFIG_FILES, + description="Status LED daemon") + parser.add_argument('-G', '--gpio-pin', default=25, type=int, + help='GPIO pin for the LED (default: 25)') + args = parser.parse_args() + + led = None + state_map = { + "starting": "pulse-quick", + "ready": "beacon-dark", + "listening": "on", + "thinking": "pulse-quick", + "stopping": "pulse-quick", + "power-off": "off", + "error": "blink-3", + } + try: + GPIO.setmode(GPIO.BCM) + + led = LED(args.gpio_pin) + led.start() + while True: + try: + state = input() + if not state: + continue + if state not in state_map: + logger.warning("unsupported state: %s, must be one of: %s", + state, ",".join(state_map.keys())) + continue + + led.set_state(state_map[state]) + except EOFError: + time.sleep(1) + except KeyboardInterrupt: + pass + finally: + led.stop() + GPIO.cleanup() + +if __name__ == '__main__': + main() diff --git a/src/main.py b/src/main.py new file mode 100755 index 00000000..88e1712d --- /dev/null +++ b/src/main.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Main recognizer loop: wait for a trigger then perform and handle +recognition.""" + +import logging +import os +import sys +import threading +import time + +import configargparse +from googlesamples.assistant import auth_helpers + +import audio +import action +import i18n +import speech +import tts + +# ============================================================================= +# +# Hey, Makers! +# +# Are you looking for actor.add_keyword? Do you want to add a new command? +# You need to edit src/action.py. Check out the instructions at: +# https://aiyprojects.withgoogle.com/voice/#makers-guide-3-3--create-a-new-voice-command-or-action +# +# ============================================================================= + +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s] %(levelname)s:%(name)s:%(message)s" +) +logger = logging.getLogger('main') + +CACHE_DIR = os.getenv('XDG_CACHE_HOME') or os.path.expanduser('~/.cache') +VR_CACHE_DIR = os.path.join(CACHE_DIR, 'voice-recognizer') + +CONFIG_DIR = os.getenv('XDG_CONFIG_HOME') or os.path.expanduser('~/.config') +CONFIG_FILES = [ + '/etc/voice-recognizer.ini', + os.path.join(CONFIG_DIR, 'voice-recognizer.ini') +] + +# Legacy fallback: old locations of secrets/credentials. +OLD_CLIENT_SECRETS = os.path.expanduser('~/client_secrets.json') +OLD_SERVICE_CREDENTIALS = os.path.expanduser('~/credentials.json') + +ASSISTANT_CREDENTIALS = os.path.join(VR_CACHE_DIR, 'assistant_credentials.json') +ASSISTANT_OAUTH_SCOPE = 'https://www.googleapis.com/auth/assistant-sdk-prototype' + +PID_FILE = '/run/user/%d/voice-recognizer.pid' % os.getuid() + + +def try_to_get_credentials(client_secrets): + """Try to get credentials, or print an error and quit on failure.""" + + if os.path.exists(ASSISTANT_CREDENTIALS): + return auth_helpers.load_credentials( + ASSISTANT_CREDENTIALS, scopes=[ASSISTANT_OAUTH_SCOPE]) + + if not os.path.exists(VR_CACHE_DIR): + os.mkdir(VR_CACHE_DIR) + + if not os.path.exists(client_secrets) and os.path.exists(OLD_CLIENT_SECRETS): + client_secrets = OLD_CLIENT_SECRETS + + if not os.path.exists(client_secrets): + print('You need client secrets to use the Assistant API.') + print('Follow these instructions:') + print(' https://developers.google.com/api-client-library/python/auth/installed-app' + '#creatingcred') + print('and put the file at', client_secrets) + sys.exit(1) + + if not os.getenv('DISPLAY') and not sys.stdout.isatty(): + print(""" +To use the Assistant API, manually start the application from the dev terminal. +See the "Turn on the Assistant API" section of the Voice Recognizer +User's Guide for more info.""") + sys.exit(1) + + credentials = auth_helpers.credentials_flow_interactive( + client_secrets, scopes=[ASSISTANT_OAUTH_SCOPE]) + auth_helpers.save_credentials(ASSISTANT_CREDENTIALS, credentials) + logging.info('OAuth credentials initialized: %s', ASSISTANT_CREDENTIALS) + return credentials + + +def create_pid_file(file_name): + with open(file_name, 'w') as pid_file: + pid_file.write("%d" % os.getpid()) + + +def main(): + parser = configargparse.ArgParser( + default_config_files=CONFIG_FILES, + description="Act on voice commands using Google's speech recognition") + parser.add_argument('-I', '--input-device', default='default', + help='Name of the audio input device') + parser.add_argument('-O', '--output-device', default='default', + help='Name of the audio output device') + parser.add_argument('-T', '--trigger', default='gpio', + help='Trigger to use {\'clap\', \'gpio\'}') + parser.add_argument('--cloud-speech', action='store_true', + help='Use the Cloud Speech API instead of the Assistant API') + parser.add_argument('-L', '--language', default='en-US', + help='Language code to use for speech (default: en-US)') + parser.add_argument('-l', '--led-fifo', default='/tmp/status-led', + help='Status led control fifo') + parser.add_argument('-p', '--pid-file', default=PID_FILE, + help='File containing our process id for monitoring') + parser.add_argument('--audio-logging', action='store_true', + help='Log all requests and responses to WAV files in /tmp') + parser.add_argument('--assistant-secrets', + help='Path to client secrets for the Assistant API') + parser.add_argument('--cloud-speech-secrets', + help='Path to service account credentials for the ' + 'Cloud Speech API') + + args = parser.parse_args() + + create_pid_file(args.pid_file) + i18n.set_language_code(args.language, gettext_install=True) + + player = audio.Player(args.output_device) + + if args.cloud_speech: + credentials_file = os.path.expanduser(args.cloud_speech_secrets) + if not os.path.exists(credentials_file) and os.path.exists(OLD_SERVICE_CREDENTIALS): + credentials_file = OLD_SERVICE_CREDENTIALS + recognizer = speech.CloudSpeechRequest(credentials_file) + else: + credentials = try_to_get_credentials( + os.path.expanduser(args.assistant_secrets)) + recognizer = speech.AssistantSpeechRequest(credentials) + + recorder = audio.Recorder( + input_device=args.input_device, channels=1, + bytes_per_sample=speech.AUDIO_SAMPLE_SIZE, + sample_rate_hz=speech.AUDIO_SAMPLE_RATE_HZ) + with recorder: + do_recognition(args, recorder, recognizer, player) + + +def do_recognition(args, recorder, recognizer, player): + """Configure and run the recognizer.""" + say = tts.create_say(player) + + actor = action.make_actor(say) + + if args.cloud_speech: + action.add_commands_just_for_cloud_speech_api(actor, say) + + recognizer.add_phrases(actor) + recognizer.set_audio_logging_enabled(args.audio_logging) + + if args.trigger == 'gpio': + import triggers.gpio + triggerer = triggers.gpio.GpioTrigger(channel=23) + msg = 'Press the button on GPIO 23' + elif args.trigger == 'clap': + import triggers.clap + triggerer = triggers.clap.ClapTrigger(recorder) + msg = 'Clap your hands' + else: + logger.error("Unknown trigger '%s'", args.trigger) + return + + mic_recognizer = SyncMicRecognizer( + actor, recognizer, recorder, player, say, triggerer, led_fifo=args.led_fifo) + + with mic_recognizer: + if sys.stdout.isatty(): + print(msg + ' then speak, or press Ctrl+C to quit...') + + # wait for KeyboardInterrupt + while True: + time.sleep(1) + + +class SyncMicRecognizer(object): + + """Detects triggers and runs recognition in a background thread. + + This is a context manager, so it will clean up the background thread if the + main program is interrupted. + """ + + # pylint: disable=too-many-instance-attributes + + def __init__(self, actor, recognizer, recorder, player, say, triggerer, led_fifo): + self.actor = actor + self.player = player + self.recognizer = recognizer + self.recognizer.set_endpointer_cb(self.endpointer_cb) + self.recorder = recorder + self.say = say + self.triggerer = triggerer + self.triggerer.set_callback(self.recognize) + + self.running = False + + if led_fifo and os.path.exists(led_fifo): + self.led_fifo = led_fifo + else: + if led_fifo: + logger.warning( + 'File %s specified for --led-fifo does not exist.', + led_fifo) + self.led_fifo = None + self.recognizer_event = threading.Event() + + def __enter__(self): + self.running = True + threading.Thread(target=self._recognize).start() + self.triggerer.start() + self._status('ready') + + def __exit__(self, *args): + self.running = False + self.recognizer_event.set() + + self.recognizer.end_audio() + + def _status(self, status): + if self.led_fifo: + with open(self.led_fifo, 'w') as led: + led.write(status + '\n') + logger.info('%s...', status) + + def recognize(self): + if self.recognizer_event.is_set(): + # Duplicate trigger (eg multiple button presses) + return + + self.recognizer.reset() + self.recorder.add_processor(self.recognizer) + self._status('listening') + # Tell recognizer to run + self.recognizer_event.set() + + def endpointer_cb(self): + self.recorder.del_processor(self.recognizer) + self._status('thinking') + + def _recognize(self): + while self.running: + self.recognizer_event.wait() + if not self.running: + break + + logger.info('recognizing...') + try: + self._handle_result(self.recognizer.do_request()) + except speech.Error: + logger.exception('Unexpected error') + self.say(_('Unexpected error. Try again or check the logs.')) + + self.recognizer_event.clear() + self.triggerer.start() + self._status('ready') + + def _handle_result(self, result): + if result.transcript and self.actor.handle(result.transcript): + logger.info('handled local command: %s', result.transcript) + elif result.response_audio: + self._play_assistant_response(result.response_audio) + elif result.transcript: + logger.warning('%r was not handled', result.transcript) + self.say(_("I don’t know how to answer that.")) + else: + logger.warning('no command recognized') + self.say(_("Could you try that again?")) + + def _play_assistant_response(self, audio_bytes): + bytes_per_sample = speech.AUDIO_SAMPLE_SIZE + sample_rate_hz = speech.AUDIO_SAMPLE_RATE_HZ + logger.info('Playing %.4f seconds of audio...', + len(audio_bytes) / (bytes_per_sample * sample_rate_hz)) + self.player.play_bytes(audio_bytes, sample_width=bytes_per_sample, + sample_rate=sample_rate_hz) + + +if __name__ == '__main__': + main() diff --git a/src/speech.py b/src/speech.py new file mode 100644 index 00000000..6a8894d5 --- /dev/null +++ b/src/speech.py @@ -0,0 +1,445 @@ +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Classes for speech interaction.""" + +from abc import abstractmethod +import collections +import logging +import os +import tempfile +import wave + +import google.auth +import google.auth.exceptions +import google.auth.transport.grpc +import google.auth.transport.requests +from google.cloud.grpc.speech.v1beta1 import cloud_speech_pb2 as cloud_speech +from google.rpc import code_pb2 as error_code +from google.assistant.embedded.v1alpha1 import embedded_assistant_pb2 +import grpc +from six.moves import queue + +import i18n + +logger = logging.getLogger('speech') + +AUDIO_SAMPLE_SIZE = 2 # bytes per sample +AUDIO_SAMPLE_RATE_HZ = 16000 + + +_Result = collections.namedtuple('_Result', ['transcript', 'response_audio']) + + +class Error(Exception): + pass + + +class _ChannelFactory(object): + + """Creates gRPC channels with a given configuration.""" + + def __init__(self, api_host, credentials): + self._api_host = api_host + self._credentials = credentials + + self._checked = False + + def make_channel(self): + """Creates a secure channel.""" + + request = google.auth.transport.requests.Request() + target = self._api_host + ':443' + + if not self._checked: + # Refresh now, to catch any errors early. Otherwise, they'll be + # raised and swallowed somewhere inside gRPC. + self._credentials.refresh(request) + self._checked = True + + return google.auth.transport.grpc.secure_authorized_channel( + self._credentials, request, target) + + +class GenericSpeechRequest(object): + + """Common base class for Cloud Speech and Assistant APIs.""" + + # TODO(rodrigoq): Refactor audio logging. + # pylint: disable=attribute-defined-outside-init,too-many-instance-attributes + + DEADLINE_SECS = 185 + + def __init__(self, api_host, credentials): + self._audio_queue = queue.Queue() + self._phrases = [] + self._channel_factory = _ChannelFactory(api_host, credentials) + self._endpointer_cb = None + self._audio_logging_enabled = False + self._request_log_wav = None + + def add_phrases(self, phrases): + """Makes the recognition more likely to recognize the given phrase(s). + phrases: an object with a method get_phrases() that returns a list of + phrases. + """ + + self._phrases.extend(phrases.get_phrases()) + + def set_endpointer_cb(self, cb): + """Callback to invoke on end of speech.""" + self._endpointer_cb = cb + + def set_audio_logging_enabled(self, audio_logging_enabled=True): + self._audio_logging_enabled = audio_logging_enabled + + if audio_logging_enabled: + self._audio_log_dir = tempfile.mkdtemp() + self._audio_log_ix = 0 + + def reset(self): + while True: + try: + self._audio_queue.get(False) + except queue.Empty: + return + + def add_data(self, data): + self._audio_queue.put(data) + + def end_audio(self): + self.add_data(None) + + def _get_speech_context(self): + """Return a SpeechContext instance to bias recognition towards certain + phrases. + """ + return cloud_speech.SpeechContext( + phrases=self._phrases, + ) + + @abstractmethod + def _make_service(self, channel): + """Create a service stub. + """ + return + + @abstractmethod + def _create_config_request(self): + """Create a config request for the given endpoint. + + This is sent first to the server to configure the speech recognition. + """ + return + + @abstractmethod + def _create_audio_request(self, data): + """Create an audio request for the given endpoint. + + This is sent to the server with audio to be recognized. + """ + return + + def _request_stream(self): + """Yields a config request followed by requests constructed from the + audio queue. + """ + yield self._create_config_request() + + while True: + data = self._audio_queue.get() + + if not data: + return + + if self._request_log_wav: + self._request_log_wav.writeframes(data) + + yield self._create_audio_request(data) + + @abstractmethod + def _create_response_stream(self, service, request_stream, deadline): + """Given a request stream, start the gRPC call to get the response + stream. + """ + return + + @abstractmethod + def _stop_sending_audio(self, resp): + """Return true if this response says user has stopped speaking. + + This stops the request from sending further audio. + """ + return + + @abstractmethod + def _handle_response(self, resp): + """Handle a response from the remote API. + + Args: + resp: StreamingRecognizeResponse instance + """ + return + + def _end_audio_request(self): + self.end_audio() + if self._endpointer_cb: + self._endpointer_cb() + + def _handle_response_stream(self, response_stream): + for resp in response_stream: + if resp.error.code != error_code.OK: + self._end_audio_request() + raise Error('Server error: ' + resp.error.message) + + if self._stop_sending_audio(resp): + self._end_audio_request() + + self._handle_response(resp) + + # Server has closed the connection + return self._finish_request() or '' + + def _start_logging_request(self): + """Open a WAV file to log the request audio.""" + self._audio_log_ix += 1 + request_filename = '%s/request.%03d.wav' % ( + self._audio_log_dir, self._audio_log_ix) + logger.info('Writing request to %s', request_filename) + + self._request_log_wav = wave.open(request_filename, 'w') + + self._request_log_wav.setnchannels(1) + self._request_log_wav.setsampwidth(AUDIO_SAMPLE_SIZE) + self._request_log_wav.setframerate(AUDIO_SAMPLE_RATE_HZ) + + def _finish_request(self): + """Called after the final response is received.""" + + if self._request_log_wav: + self._request_log_wav.close() + + return _Result(None, None) + + def do_request(self): + """Establishes a connection and starts sending audio to the cloud + endpoint. Responses are handled by the subclass until one returns a + result. + + Returns: + namedtuple with the following fields: + transcript: string with transcript of user query + response_audio: optionally, an audio response from the server + + Raises speech.Error on error. + """ + try: + service = self._make_service(self._channel_factory.make_channel()) + + response_stream = self._create_response_stream( + service, self._request_stream(), self.DEADLINE_SECS) + + if self._audio_logging_enabled: + self._start_logging_request() + + return self._handle_response_stream(response_stream) + except ( + google.auth.exceptions.GoogleAuthError, + grpc.RpcError, + ) as exc: + raise Error('Exception in speech request') from exc + + +class CloudSpeechRequest(GenericSpeechRequest): + + """A transcription request to the Cloud Speech API. + + Args: + credentials_file: path to service account credentials JSON file + """ + + SCOPE = 'https://www.googleapis.com/auth/cloud-platform' + + def __init__(self, credentials_file): + os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = credentials_file + credentials, _ = google.auth.default(scopes=[self.SCOPE]) + + super().__init__('speech.googleapis.com', credentials) + + self.language_code = i18n.get_language_code() + + if not hasattr(cloud_speech, 'StreamingRecognizeRequest'): + raise ValueError("cloud_speech_pb2.py doesn't have StreamingRecognizeRequest.") + + self._transcript = None + + def reset(self): + super().reset() + self._transcript = None + + def _make_service(self, channel): + return cloud_speech.SpeechStub(channel) + + def _create_config_request(self): + recognition_config = cloud_speech.RecognitionConfig( + # There are a bunch of config options you can specify. See + # https://goo.gl/KPZn97 for the full list. + encoding='LINEAR16', # raw 16-bit signed LE samples + sample_rate=AUDIO_SAMPLE_RATE_HZ, + # For a list of supported languages see: + # https://cloud.google.com/speech/docs/languages. + language_code=self.language_code, # a BCP-47 language tag + speech_context=self._get_speech_context(), + ) + streaming_config = cloud_speech.StreamingRecognitionConfig( + config=recognition_config, + single_utterance=True, # TODO(rodrigoq): find a way to handle pauses + ) + + return cloud_speech.StreamingRecognizeRequest( + streaming_config=streaming_config) + + def _create_audio_request(self, data): + return cloud_speech.StreamingRecognizeRequest(audio_content=data) + + def _create_response_stream(self, service, request_stream, deadline): + return service.StreamingRecognize(request_stream, deadline) + + def _stop_sending_audio(self, resp): + """Check the endpointer type to see if an utterance has ended.""" + + if resp.endpointer_type: + endpointer_type = cloud_speech.StreamingRecognizeResponse.EndpointerType.Name( + resp.endpointer_type) + logger.info('endpointer_type: %s', endpointer_type) + + END_OF_AUDIO = cloud_speech.StreamingRecognizeResponse.EndpointerType.Value('END_OF_AUDIO') + return resp.endpointer_type == END_OF_AUDIO + + def _handle_response(self, resp): + """Store the last transcript we received.""" + if resp.results: + self._transcript = ' '.join( + result.alternatives[0].transcript for result in resp.results) + logger.info('transcript: %s', self._transcript) + + def _finish_request(self): + super()._finish_request() + return _Result(self._transcript, None) + + +class AssistantSpeechRequest(GenericSpeechRequest): + + """A request to the Assistant API, which returns audio and text.""" + + def __init__(self, credentials): + + super().__init__('embeddedassistant.googleapis.com', credentials) + + self._response_audio = b'' + self._transcript = None + + def reset(self): + super().reset() + self._response_audio = b'' + self._transcript = None + + def _make_service(self, channel): + return embedded_assistant_pb2.EmbeddedAssistantStub(channel) + + def _create_config_request(self): + audio_in_config = embedded_assistant_pb2.AudioInConfig( + encoding='LINEAR16', + sample_rate_hertz=AUDIO_SAMPLE_RATE_HZ, + ) + audio_out_config = embedded_assistant_pb2.AudioOutConfig( + encoding='LINEAR16', + sample_rate_hertz=AUDIO_SAMPLE_RATE_HZ, + volume_percentage=50, + ) + converse_config = embedded_assistant_pb2.ConverseConfig( + audio_in_config=audio_in_config, + audio_out_config=audio_out_config, + ) + + return embedded_assistant_pb2.ConverseRequest(config=converse_config) + + def _create_audio_request(self, data): + return embedded_assistant_pb2.ConverseRequest(audio_in=data) + + def _create_response_stream(self, service, request_stream, deadline): + return service.Converse(request_stream, deadline) + + def _stop_sending_audio(self, resp): + if resp.event_type: + logger.info('event_type: %s', resp.event_type) + + return (resp.event_type == + embedded_assistant_pb2.ConverseResponse.END_OF_UTTERANCE) + + def _handle_response(self, resp): + """Accumulate audio and text from the remote end. It will be handled + in _finish_request(). + """ + + if resp.result.spoken_request_text: + logger.info('transcript: %s', resp.result.spoken_request_text) + self._transcript = resp.result.spoken_request_text + + self._response_audio += resp.audio_out.audio_data + + def _finish_request(self): + super()._finish_request() + + if self._response_audio and self._audio_logging_enabled: + self._log_audio_out(self._response_audio) + + return _Result(self._transcript, self._response_audio) + + def _log_audio_out(self, frames): + response_filename = '%s/response.%03d.wav' % ( + self._audio_log_dir, self._audio_log_ix) + logger.info('Writing response to %s', response_filename) + + response_wav = wave.open(response_filename, 'w') + response_wav.setnchannels(1) + response_wav.setsampwidth(AUDIO_SAMPLE_SIZE) + response_wav.setframerate(AUDIO_SAMPLE_RATE_HZ) + response_wav.writeframes(frames) + response_wav.close() + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO) + + # for testing: use audio from a file + import argparse + parser = argparse.ArgumentParser() + parser.add_argument('file', nargs='?', default='test_speech.raw') + args = parser.parse_args() + + if os.path.exists('/home/pi/credentials.json'): + # Legacy fallback: old location of credentials. + req = CloudSpeechRequest('/home/pi/credentials.json') + else: + req = CloudSpeechRequest('/home/pi/cloud_speech.json') + + with open(args.file, 'rb') as f: + while True: + chunk = f.read(64000) + if not chunk: + break + req.add_data(chunk) + req.end_audio() + + print('down response:', req.do_request()) diff --git a/src/status-monitor.py b/src/status-monitor.py new file mode 100755 index 00000000..0d5c9ecf --- /dev/null +++ b/src/status-monitor.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Script to monitor liveness of processes and update led status.""" + +import argparse +import logging +import os +import time + +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s] %(levelname)s:%(name)s:%(message)s" +) +logger = logging.getLogger('status-monitor') + +PID_FILE = '/run/user/%d/voice-recognizer.pid' % os.getuid() + + +def get_pid(pid_file): + try: + with open(pid_file, 'r') as pid: + return int(pid.read()) + except IOError: + return None + + +def set_led_status(led_fifo): + with open(led_fifo, 'w') as led: + led.write('power-off\n') + + +def check_liveness(pid_file, led_fifo): + pid = get_pid(pid_file) + if pid: + if not os.path.exists("/proc/%d" % pid): + logger.info("monitored process not running") + set_led_status(led_fifo) + try: + os.unlink(pid_file) + except IOError: + pass + + +def main(): + parser = argparse.ArgumentParser( + description="Monitor liveness of processes and update led status.") + parser.add_argument('-l', '--led-fifo', default='/tmp/status-led', + help='Status led control fifo') + parser.add_argument('-p', '--pid-file', default=PID_FILE, + help='File containing our process id for monitoring') + args = parser.parse_args() + + while True: + check_liveness(args.pid_file, args.led_fifo) + time.sleep(1) + + +if __name__ == '__main__': + main() diff --git a/src/triggers/__init__.py b/src/triggers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/triggers/clap.py b/src/triggers/clap.py new file mode 100644 index 00000000..ec731eaf --- /dev/null +++ b/src/triggers/clap.py @@ -0,0 +1,52 @@ +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Detect claps in the audio stream.""" + +import logging +import numpy as np + +from triggers.trigger import Trigger + +logger = logging.getLogger('trigger') + + +class ClapTrigger(Trigger): + + """Detect claps in the audio stream.""" + + def __init__(self, recorder): + super().__init__() + + self.have_clap = True # don't start yet + self.prev_sample = 0 + recorder.add_processor(self) + + def start(self): + self.prev_sample = 0 + self.have_clap = False + + def add_data(self, data): + """ audio is mono 16bit signed at 16kHz """ + audio = np.fromstring(data, 'int16') + if not self.have_clap: + # alternative: np.abs(audio).sum() > thresh + shifted = np.roll(audio, 1) + shifted[0] = self.prev_sample + val = np.max(np.abs(shifted - audio)) + if val > (65536 // 4): # quarter max delta + logger.info("clap detected") + self.have_clap = True + self.callback() + self.prev_sample = audio[-1] diff --git a/src/triggers/gpio.py b/src/triggers/gpio.py new file mode 100644 index 00000000..67b928da --- /dev/null +++ b/src/triggers/gpio.py @@ -0,0 +1,61 @@ +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Detect edges on the given GPIO channel.""" + +import time + +import RPi.GPIO as GPIO + +from triggers.trigger import Trigger + + +class GpioTrigger(Trigger): + + """Detect edges on the given GPIO channel.""" + + DEBOUNCE_TIME = 0.05 + + def __init__(self, channel, polarity=GPIO.FALLING, + pull_up_down=GPIO.PUD_UP): + super().__init__() + + self.channel = channel + self.polarity = polarity + + if polarity not in [GPIO.FALLING, GPIO.RISING]: + raise ValueError('polarity must be GPIO.FALLING or GPIO.RISING') + + self.expected_value = polarity == GPIO.RISING + self.event_detect_added = False + + GPIO.setmode(GPIO.BCM) + GPIO.setup(channel, GPIO.IN, pull_up_down=pull_up_down) + + def start(self): + if not self.event_detect_added: + GPIO.add_event_detect(self.channel, self.polarity, callback=self.debounce) + self.event_detect_added = True + + def debounce(self, _): + """Check that the input holds the expected value for the debounce period, + to avoid false trigger on short pulses.""" + + start = time.time() + while time.time() < start + self.DEBOUNCE_TIME: + if GPIO.input(self.channel) != self.expected_value: + return + time.sleep(0.01) + + self.callback() diff --git a/src/triggers/trigger.py b/src/triggers/trigger.py new file mode 100644 index 00000000..4cc363e1 --- /dev/null +++ b/src/triggers/trigger.py @@ -0,0 +1,29 @@ +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Detect trigger events that start voice recognition requests.""" + + +class Trigger(object): + + """Base class for a Trigger.""" + + def __init__(self): + self.callback = None + + def set_callback(self, callback): + self.callback = callback + + def start(self): + pass diff --git a/src/tts.py b/src/tts.py new file mode 100644 index 00000000..317aca24 --- /dev/null +++ b/src/tts.py @@ -0,0 +1,126 @@ +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Wrapper around a TTS system.""" + +import functools +import logging +import os +import subprocess +import tempfile +import wave + +import numpy as np +from scipy import signal + +import i18n + +# Path to a tmpfs directory to avoid SD card wear +TMP_DIR = '/run/user/%d' % os.getuid() + +# Expected sample rate from the TTS tool +SAMPLE_RATE = 16000 + +# Parameters for the equalization filter. These remove low-frequency sound +# from the result, avoiding resonance on the speaker and making the TTS easier +# to understand. Calculated with: +# python3 src/tts.py --hpf-order 4 --hpf-freq-hz 1400 --hpf-gain-db 8 +FILTER_A = np.array([1., -3.28274474, 4.09441957, -2.29386174, 0.48627065]) +FILTER_B = np.array([1.75161639, -7.00646555, 10.50969833, -7.00646555, 1.75161639]) + +logger = logging.getLogger('tts') + + +def print_eq_coefficients(hpf_order, hpf_freq_hz, hpf_gain_db): + """Calculate and print the coefficients of the equalization filter.""" + b, a = signal.butter(hpf_order, hpf_freq_hz / SAMPLE_RATE, 'highpass') + gain_factor = pow(10, hpf_gain_db / 20) + + print('FILTER_A = np.%r' % a) + print('FILTER_B = np.%r' % (b * gain_factor)) + + +def create_eq_filter(): + """Return a function that applies equalization to a numpy array.""" + + def eq_filter(raw_audio): + return signal.lfilter(FILTER_B, FILTER_A, raw_audio) + + return eq_filter + + +def create_say(player): + """Return a function say(words) for the given player, using the default EQ + filter. + """ + lang = i18n.get_language_code() + return functools.partial(say, player, eq_filter=create_eq_filter(), lang=lang) + + +def say(player, words, eq_filter=None, lang='en-US'): + """Say the given words with TTS.""" + + try: + (fd, raw_wav) = tempfile.mkstemp(suffix='.wav', dir=TMP_DIR) + except IOError: + logger.exception('Using fallback directory for TTS output') + (fd, raw_wav) = tempfile.mkstemp(suffix='.wav') + + os.close(fd) + + try: + subprocess.call(['pico2wave', '-l', lang, '-w', raw_wav, words]) + with wave.open(raw_wav, 'rb') as f: + raw_bytes = f.readframes(f.getnframes()) + finally: + os.unlink(raw_wav) + + # Deserialize and apply equalization filter + eq_audio = np.frombuffer(raw_bytes, dtype=np.int16) + if eq_filter: + eq_audio = eq_filter(eq_audio) + + # Clip and serialize + int16_info = np.iinfo(np.int16) + eq_audio = np.clip(eq_audio, int16_info.min, int16_info.max) + eq_bytes = eq_audio.astype(np.int16).tostring() + + player.play_bytes(eq_bytes, sample_rate=SAMPLE_RATE) + + +def main(): + import argparse + + import audio + + logging.basicConfig(level=logging.INFO) + + parser = argparse.ArgumentParser(description='Test TTS wrapper') + parser.add_argument('words', nargs='*', help='Words to say') + parser.add_argument('--hpf-order', type=int, help='Order of high-pass filter') + parser.add_argument('--hpf-freq-hz', type=int, help='Corner frequency of high-pass filter') + parser.add_argument('--hpf-gain-db', type=int, help='High-frequency gain of filter') + args = parser.parse_args() + + if args.words: + words = ' '.join(args.words) + player = audio.Player() + create_say(player)(words) + + if args.hpf_order: + print_eq_coefficients(args.hpf_order, args.hpf_freq_hz, args.hpf_gain_db) + + +if __name__ == '__main__': + main() diff --git a/systemd/alsa-init.service b/systemd/alsa-init.service new file mode 100644 index 00000000..5d87326d --- /dev/null +++ b/systemd/alsa-init.service @@ -0,0 +1,16 @@ +# Play 1 s of silence if asound.state does not exists so lxpanel's volumealsa +# can initialize properly. + +[Unit] +Description=alsa init service +ConditionPathExists=!/etc/alsa/state-daemon.conf +ConditionPathExists=!/var/lib/alsa/asound.state +DefaultDependencies=no +After=local-fs.target sysinit.target + +[Service] +Type=oneshot +ExecStart=/usr/bin/aplay -q -d 1 -c 1 -t raw -f S32_LE /dev/zero + +[Install] +WantedBy=basic.target diff --git a/systemd/ntpdate.service b/systemd/ntpdate.service new file mode 100644 index 00000000..2720f174 --- /dev/null +++ b/systemd/ntpdate.service @@ -0,0 +1,14 @@ +[Unit] +Description=Set time with ntpdate +After=network.target + +[Service] +# use -u to avoid conflict with ntpd +ExecStart=/usr/sbin/ntpdate -u pool.ntp.org + +# we may not have network yet, so retry until success +Restart=on-failure +RestartSec=3s + +[Install] +WantedBy=multi-user.target diff --git a/systemd/status-led-off.service b/systemd/status-led-off.service new file mode 100644 index 00000000..bf44d748 --- /dev/null +++ b/systemd/status-led-off.service @@ -0,0 +1,12 @@ +[Unit] +Description=status led startup update +DefaultDependencies=no +Before=shutdown.target +Requires=status-led.service + +[Service] +Type=oneshot +ExecStart=/bin/bash -c '/bin/echo "stopping" >/tmp/status-led' + +[Install] +WantedBy=reboot.target halt.target poweroff.target diff --git a/systemd/status-led-on.service b/systemd/status-led-on.service new file mode 100644 index 00000000..78795854 --- /dev/null +++ b/systemd/status-led-on.service @@ -0,0 +1,12 @@ +[Unit] +Description=status led startup update +DefaultDependencies=no +After=status-led.service +Requires=status-led.service + +[Service] +Type=oneshot +ExecStart=/bin/bash -c '/bin/echo "starting" >/tmp/status-led' + +[Install] +WantedBy=basic.target diff --git a/systemd/status-led.service b/systemd/status-led.service new file mode 100644 index 00000000..93cf8af9 --- /dev/null +++ b/systemd/status-led.service @@ -0,0 +1,16 @@ +[Unit] +Description=status led service +DefaultDependencies=no +After=local-fs.target sysinit.target + +[Service] +ExecStartPre=/bin/bash -c 'test -p /tmp/status-led || /bin/mknod /tmp/status-led p' +ExecStart=/bin/bash -c '/usr/bin/python3 -u src/led.py