diff --git a/CHANGELOG.md b/CHANGELOG.md index f9effe70..4f2e9878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +v0.4.5 / 2017-07-23 +=================== +- add keyword_entries attribute to CMU Sphinx +- add simplerate attribute to Pico2wav TTS +- API: convert mp3 file to wav automatically +- add sensitivity attribute to snowboy trigger +- add grammar attribute to CMU Sphinx +- add no_voice flag to api +- add possibility to send parameters when using api with run synapse by name + v0.4.4 / 2017-05-20 =================== - Fix: Uppercase in order/parameters/global variables are now handled correctly diff --git a/Docs/rest_api.md b/Docs/rest_api.md index 18ec771b..17e3f56c 100644 --- a/Docs/rest_api.md +++ b/Docs/rest_api.md @@ -147,6 +147,21 @@ Output example: } ``` +The [no_voice flag](#no-voice-flag) can be added to this call. +Curl command: +```bash +curl -i -H "Content-Type: application/json" --user admin:secret -X POST \ +-d '{"no_voice":"true"}' http://127.0.0.1:5000/synapses/start/id/say-hello-fr +``` + +Some neuron inside a synapse will wait for parameters that comes from the order. +You can provide those parameters by adding a `parameters` list of data. +Curl command: +```bash +curl -i -H "Content-Type: application/json" --user admin:secret -X POST \ +-d '{"parameters": {"parameter1": "value1" }}' \ +http://127.0.0.1:5000/synapses/start/id/synapse-id +``` ### Run a synapse from an order @@ -217,6 +232,13 @@ Or return an empty list of matched synapse } ``` +The [no_voice flag](#no-voice-flag) can be added to this call. +Curl command: +```bash +curl -i --user admin:secret -H "Content-Type: application/json" -X POST \ +-d '{"order":"my order", "no_voice":"true"}' http://localhost:5000/synapses/start/order +``` + ### Run a synapse from an audio file Normal response codes: 201 @@ -277,3 +299,16 @@ Or return an empty list of matched synapse "user_order": "not existing order" } ``` + +The [no_voice flag](#no-voice-flag) can be added to this call with a form. +Curl command: +```bash +curl -i --user admin:secret -X POST http://localhost:5000/synapses/start/audio -F "file=@path/to/file.wav" -F no_voice="true" +``` + + +## No voice flag + +When you use the API, by default Kalliope will generate a text and process it into the TTS engine. +Some calls to the API can be done with a flag that will tell Kalliope to only return the generated text without processing it into the audio player. +When `no_voice` is switched to true, Kalliope will not speak out loud on the server side. diff --git a/Docs/trigger.md b/Docs/trigger.md index 6a45921c..8db2bb24 100644 --- a/Docs/trigger.md +++ b/Docs/trigger.md @@ -7,9 +7,13 @@ With Kalliope project, you can set whatever Hotword you want to wake it up. You can create your magic word by connecting to [Snowboy](https://snowboy.kitt.ai/) and then download the trained model file. -Once downloaded: -- place the file in your personal config folder. -- update the path of **pmdl_file** in [your settings](settings.md). +Once downloaded, place the file in your personal config folder and configure snowboy in your [your settings](settings.md) following the table bellow + +| parameter | required | type | default | choices | comment | +|-------------|----------|--------|---------|-----------------|--------------------------------------------------------------------------------------------------| +| pmdl_file | TRUE | string | | | Path to the snowboy model file. The path can be absolute or relative to the brain file | +| sensitivity | FALSE | string | 0.5 | between 0 and 1 | Increasing the sensitivity value lead to better detection rate, but also higher false alarm rate | + If you want to keep "Kalliope" as the name of your bot, we recommend you to __enhance the existing Snowboy model for your language__. diff --git a/README.md b/README.md index 3d922c4e..043e7ac5 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Ka-li-o-pé (French) Demo French : [video](https://www.youtube.com/watch?v=t4J42yO2rkM) Demo English : [video](https://www.youtube.com/watch?v=PcLzo4H18S4) +Android app : [Playstore](https://play.google.com/store/apps/details?id=kalliope.project) ## License diff --git a/Tests/brains/brain_test_api.yml b/Tests/brains/brain_test_api.yml index 83eca48d..8a218c06 100644 --- a/Tests/brains/brain_test_api.yml +++ b/Tests/brains/brain_test_api.yml @@ -17,3 +17,12 @@ - includes: - included_brain_test.yml + + - name: "test4" + signals: + - order: "test_order_with_parameter" + neurons: + - say: + message: + - "test message {{ parameter1 }}" + diff --git a/Tests/test_rest_api.py b/Tests/test_rest_api.py index 11269d0e..dd8f5b6e 100644 --- a/Tests/test_rest_api.py +++ b/Tests/test_rest_api.py @@ -1,16 +1,16 @@ import json import os import unittest -import ast from flask import Flask from flask_testing import LiveServerTestCase +from mock import mock -from kalliope.core import LIFOBuffer -from kalliope.core.Models import Singleton from kalliope._version import version_str +from kalliope.core import LIFOBuffer from kalliope.core.ConfigurationManager import BrainLoader from kalliope.core.ConfigurationManager import SettingLoader +from kalliope.core.Models import Singleton from kalliope.core.RestAPI.FlaskAPI import FlaskAPI @@ -65,74 +65,65 @@ def test_get_main_page(self): expected_content = { "Kalliope version": "%s" % version_str } - self.assertEqual(json.dumps(expected_content, sort_keys=True), json.dumps(json.loads(response.get_data().decode('utf-8')), sort_keys=True)) + self.assertEqual(json.dumps(expected_content, sort_keys=True), + json.dumps(json.loads(response.get_data().decode('utf-8')), sort_keys=True)) def test_get_all_synapses(self): url = self.get_server_url()+"/synapses" response = self.client.get(url) expected_content = { - "synapses": [ - { - "name": "test", - "neurons": [ - { - "name": "say", - "parameters": { - "message": [ - "test message" - ] - } - } - ], - "signals": [ - { - "order": "test_order" - } - ] - }, - { - "name": "test2", - "neurons": [ - { - "name": "say", - "parameters": { - "message": [ - "test message" - ] - } - } - ], - "signals": [ - { - "order": "bonjour" - } - ] - }, - { - "name": "test3", - "neurons": [ - { - "name": "say", - "parameters": { - "message": [ - "test message" - ] - } - } - ], - "signals": [ - { - "order": "test_order_3" - } - ] - } - ] + "synapses": [{ + "name": "test", + "neurons": [{ + "name": "say", + "parameters": { + "message": ["test message"] + } + }], + "signals": [{ + "order": "test_order" + }] + }, { + "name": "test2", + "neurons": [{ + "name": "say", + "parameters": { + "message": ["test message"] + } + }], + "signals": [{ + "order": "bonjour" + }] + }, { + "name": "test4", + "neurons": [{ + "name": "say", + "parameters": { + "message": ["test message {{parameter1}}"] + } + }], + "signals": [{ + "order": "test_order_with_parameter" + }] + }, { + "name": "test3", + "neurons": [{ + "name": "say", + "parameters": { + "message": ["test message"] + } + }], + "signals": [{ + "order": "test_order_3" + }] + }] } # a lot of char ti process self.maxDiff = None self.assertEqual(response.status_code, 200) - self.assertEqual(json.dumps(expected_content, sort_keys=True), json.dumps(json.loads(response.get_data().decode('utf-8')), sort_keys=True)) + self.assertEqual(json.dumps(expected_content, sort_keys=True), + json.dumps(json.loads(response.get_data().decode('utf-8')), sort_keys=True)) def test_get_one_synapse(self): url = self.get_server_url() + "/synapses/test" @@ -158,7 +149,8 @@ def test_get_one_synapse(self): ] } } - self.assertEqual(json.dumps(expected_content, sort_keys=True), json.dumps(json.loads(response.get_data().decode('utf-8')), sort_keys=True)) + self.assertEqual(json.dumps(expected_content, sort_keys=True), + json.dumps(json.loads(response.get_data().decode('utf-8')), sort_keys=True)) def test_get_synapse_not_found(self): url = self.get_server_url() + "/synapses/test-none" @@ -185,7 +177,35 @@ def test_run_synapse_by_name(self): 'synapse_name': 'test'}], 'user_order': None } - self.assertEqual(json.dumps(expected_content, sort_keys=True), json.dumps(json.loads(result.get_data().decode('utf-8')), sort_keys=True)) + self.assertEqual(json.dumps(expected_content, sort_keys=True), + json.dumps(json.loads(result.get_data().decode('utf-8')), sort_keys=True)) + self.assertEqual(result.status_code, 201) + + # run a synapse by its name with parameter + url = self.get_server_url() + "/synapses/start/id/test4" + headers = {"Content-Type": "application/json"} + data = {"parameters": {"parameter1": "replaced_value"}} + result = self.client.post(url, headers=headers, data=json.dumps(data)) + + expected_content = { + "matched_synapses": [ + { + "matched_order": None, + "neuron_module_list": [ + { + "generated_message": "test message replaced_value", + "neuron_name": "Say" + } + ], + "synapse_name": "test4" + } + ], + "status": "complete", + "user_order": None + } + + self.assertEqual(json.dumps(expected_content, sort_keys=True), + json.dumps(json.loads(result.get_data().decode('utf-8')), sort_keys=True)) self.assertEqual(result.status_code, 201) def test_post_synapse_not_found(self): @@ -198,7 +218,8 @@ def test_post_synapse_not_found(self): } } - self.assertEqual(json.dumps(expected_content, sort_keys=True), json.dumps(json.loads(result.get_data().decode('utf-8')), sort_keys=True)) + self.assertEqual(json.dumps(expected_content, sort_keys=True), + json.dumps(json.loads(result.get_data().decode('utf-8')), sort_keys=True)) self.assertEqual(result.status_code, 404) def test_run_synapse_with_order(self): @@ -218,12 +239,13 @@ def test_run_synapse_with_order(self): 'generated_message': 'test message', 'neuron_name': 'Say' } ], - 'synapse_name': 'test' + 'synapse_name': 'test' } ], 'user_order': "test_order" } - self.assertEqual(json.dumps(expected_content, sort_keys=True), json.dumps(json.loads(result.get_data().decode('utf-8')), sort_keys=True)) + self.assertEqual(json.dumps(expected_content, sort_keys=True), + json.dumps(json.loads(result.get_data().decode('utf-8')), sort_keys=True)) self.assertEqual(result.status_code, 201) def test_post_synapse_by_order_not_found(self): @@ -236,7 +258,8 @@ def test_post_synapse_by_order_not_found(self): expected_content = {'status': None, 'matched_synapses': [], 'user_order': u'non existing order'} - self.assertEqual(json.dumps(expected_content, sort_keys=True), json.dumps(json.loads(result.get_data().decode('utf-8')), sort_keys=True)) + self.assertEqual(json.dumps(expected_content, sort_keys=True), + json.dumps(json.loads(result.get_data().decode('utf-8')), sort_keys=True)) self.assertEqual(result.status_code, 201) # TODO this doesn't work on travis but works locally with python -m unittest discover @@ -275,10 +298,29 @@ def test_post_synapse_by_order_not_found(self): # self.assertEqual(json.dumps(expected_content), json.dumps(json.loads(result.get_data()))) # self.assertEqual(result.status_code, 201) + def test_convert_to_wav(self): + """ + Test the api function to convert incoming sound file to wave. + """ + + with mock.patch("os.system") as mock_os_system: + # Scenario 1 : input wav file + temp_file = "/tmp/kalliope/tempfile.wav" # tempfile.NamedTemporaryFile(suffix=".wav") + result_file = FlaskAPI._convert_to_wav(temp_file) + self.assertEqual(temp_file, result_file) + mock_os_system.assert_not_called() + + # Scenario 2 : input not a wav file + temp_file = "/tmp/kalliope/tempfile.amr" # tempfile.NamedTemporaryFile(suffix=".wav") + expected_result = "/tmp/kalliope/tempfile.wav" + result_file = FlaskAPI._convert_to_wav(temp_file) + self.assertEqual(expected_result, result_file) + mock_os_system.assert_called_once_with("avconv -y -i " + temp_file + " " + expected_result) + if __name__ == '__main__': unittest.main() # suite = unittest.TestSuite() - # suite.addTest(TestRestAPI("test_post_synapse_by_order_not_found")) + # suite.addTest(TestRestAPI("test_run_synapse_by_name")) # runner = unittest.TextTestRunner() # runner.run(suite) diff --git a/docker/debian8.dockerfile b/docker/debian8.dockerfile index bf9ae5b7..01dcce79 100644 --- a/docker/debian8.dockerfile +++ b/docker/debian8.dockerfile @@ -16,6 +16,12 @@ RUN apt-get update && apt-get install -y \ RUN wget https://bootstrap.pypa.io/get-pip.py RUN python get-pip.py +# fix install in container +RUN pip install --upgrade pip six +RUN pip install --upgrade pip pyyaml +RUN pip install --upgrade pip SpeechRecognition +RUN pip install --upgrade pip Werkzeug + # add a standart user. tests must not be ran as root RUN useradd -m -u 1000 tester RUN usermod -aG sudo tester diff --git a/docker/ubuntu_16_04.dockerfile b/docker/ubuntu_16_04.dockerfile index 0d959e48..87286da0 100644 --- a/docker/ubuntu_16_04.dockerfile +++ b/docker/ubuntu_16_04.dockerfile @@ -16,6 +16,12 @@ RUN apt-get update && apt-get install -y \ RUN wget https://bootstrap.pypa.io/get-pip.py RUN python get-pip.py +# fix install in container +RUN pip install --upgrade pip six +RUN pip install --upgrade pip pyyaml +RUN pip install --upgrade pip SpeechRecognition +RUN pip install --upgrade pip Werkzeug + # add a standart user. tests must not be ran as root RUN useradd -m -u 1000 tester RUN usermod -aG sudo tester diff --git a/install/files/python_requirements.txt b/install/files/python_requirements.txt index 24edea44..00b0c748 100644 --- a/install/files/python_requirements.txt +++ b/install/files/python_requirements.txt @@ -1,7 +1,7 @@ -six==1.10.0 -SpeechRecognition>=3.5.0 +six>=1.10.0 +SpeechRecognition>=3.7.1 markupsafe==0.23 -pyaudio==0.2.9 +pyaudio==0.2.11 pyasn1>=0.1.8 ansible==2.2.0.0 #python2-pythondialog==3.4.0 @@ -23,4 +23,4 @@ sounddevice>=0.3.7 SoundFile>=0.9.0 pyalsaaudio>=0.8.4 RPi.GPIO>=0.6.3 - +sox>=1.3.0 diff --git a/kalliope/_version.py b/kalliope/_version.py index 868aad22..3a8156bb 100644 --- a/kalliope/_version.py +++ b/kalliope/_version.py @@ -1,2 +1,2 @@ # https://www.python.org/dev/peps/pep-0440/ -version_str = "0.4.4" +version_str = "0.4.5" diff --git a/kalliope/core/LIFOBuffer.py b/kalliope/core/LIFOBuffer.py index 1be9b271..5f5080c9 100644 --- a/kalliope/core/LIFOBuffer.py +++ b/kalliope/core/LIFOBuffer.py @@ -39,6 +39,7 @@ class LIFOBuffer(object): logger.debug("[LIFOBuffer] LIFO buffer created") answer = None is_api_call = False + no_voice = False @classmethod def set_answer(cls, value): @@ -80,7 +81,7 @@ def _return_serialized_api_response(cls): return returned_api_response @classmethod - def execute(cls, answer=None, is_api_call=False): + def execute(cls, answer=None, is_api_call=False, no_voice=False): """ Process the LIFO list. @@ -90,13 +91,15 @@ def execute(cls, answer=None, is_api_call=False): If a neuron add a Synapse list to the lifo, this synapse list is processed before executing the first list in which we were in. - :param answer: String answer to give the the last neuron which whas waiting for an answer + :param answer: String answer to give the the last neuron which was waiting for an answer :param is_api_call: Boolean passed to all neuron in order to let them know if the current call comes from API + :param no_voice: If true, the generated text will not be processed by the TTS engine :return: serialized APIResponse object """ # store the answer if present cls.answer = answer cls.is_api_call = is_api_call + cls.no_voice = no_voice try: # we keep looping over the LIFO til we have synapse list to process in it @@ -165,7 +168,9 @@ def _process_neuron_list(cls, matched_synapse): cls.answer = None # todo fix this when we have a full client/server call. The client would be the voice or api call neuron.parameters["is_api_call"] = cls.is_api_call - logger.debug("[LIFOBuffer] process_neuron_list: is_api_call: %s" % cls.is_api_call) + neuron.parameters["no_voice"] = cls.no_voice + logger.debug("[LIFOBuffer] process_neuron_list: is_api_call: %s, no_voice: %s" % (cls.is_api_call, + cls.no_voice)) # execute the neuron instantiated_neuron = NeuronLauncher.start_neuron(neuron=neuron, parameters_dict=matched_synapse.parameters) diff --git a/kalliope/core/Models/MatchedSynapse.py b/kalliope/core/Models/MatchedSynapse.py index 81333a72..9980c2c0 100644 --- a/kalliope/core/Models/MatchedSynapse.py +++ b/kalliope/core/Models/MatchedSynapse.py @@ -8,11 +8,12 @@ class MatchedSynapse(object): This class represent a synapse that has matched an order send by an User. """ - def __init__(self, matched_synapse=None, matched_order=None, user_order=None): + def __init__(self, matched_synapse=None, matched_order=None, user_order=None, overriding_parameter=None): """ :param matched_synapse: The synapse that has matched in the brain. :param matched_order: The order from the synapse that have matched. :param user_order: The order said by the user. + :param overriding_parameter: If set, those parameters will over """ # create a copy of the synapse. the received synapse come from the brain. @@ -26,6 +27,10 @@ def __init__(self, matched_synapse=None, matched_order=None, user_order=None): if matched_order is not None: self.parameters = NeuronParameterLoader.get_parameters(synapse_order=self.matched_order, user_order=user_order) + if overriding_parameter is not None: + # we suppose that we don't have any parameters. + # We replace the current parameter object with the received one + self.parameters = overriding_parameter # list of Neuron Module self.neuron_module_list = list() diff --git a/kalliope/core/NeuronModule.py b/kalliope/core/NeuronModule.py index 046a77e2..4a3a60d8 100644 --- a/kalliope/core/NeuronModule.py +++ b/kalliope/core/NeuronModule.py @@ -97,6 +97,8 @@ def __init__(self, **kwargs): self.tts_message = None # if the current call is api one self.is_api_call = kwargs.get('is_api_call', False) + # if the current call want to mute kalliope + self.no_voice = kwargs.get('no_voice', False) # boolean to know id the synapse is waiting for an answer self.is_waiting_for_answer = False # the synapse name to add the the buffer @@ -131,43 +133,48 @@ def say(self, message): .. raises:: TTSModuleNotFound """ - logger.debug("NeuronModule Say() called with message: %s" % message) + logger.debug("[NeuronModule] Say() called with message: %s" % message) tts_message = None if isinstance(message, str) or isinstance(message, six.text_type): - logger.debug("message is string") + logger.debug("[NeuronModule] message is string") tts_message = message if isinstance(message, list): - logger.debug("message is list") + logger.debug("[NeuronModule] message is list") tts_message = random.choice(message) if isinstance(message, dict): - logger.debug("message is dict") + logger.debug("[NeuronModule] message is dict") tts_message = self._get_message_from_dict(message) if tts_message is not None: - logger.debug("tts_message to say: %s" % tts_message) + logger.debug("[NeuronModule] tts_message to say: %s" % tts_message) self.tts_message = tts_message Utils.print_success(tts_message) - # get the instance of the TTS module - tts_folder = None - if self.settings.resources: - tts_folder = self.settings.resources.tts_folder - tts_module_instance = Utils.get_dynamic_class_instantiation(package_name="tts", - module_name=self.tts.name, - parameters=self.tts.parameters, - resources_dir=tts_folder) - # Kalliope will talk, turn on the LED - self.switch_on_led_talking(rpi_settings=self.settings.rpi_settings, on=True) - - # generate the audio file and play it - tts_module_instance.say(tts_message) - - # Kalliope has finished to talk, turn off the LED - self.switch_on_led_talking(rpi_settings=self.settings.rpi_settings, on=False) + # process the audio only if the no_voice flag is false + if self.no_voice: + logger.debug("[NeuronModule] no_voice is True, Kalliope is muted") + else: + logger.debug("[NeuronModule] no_voice is False, make Kalliope speaking") + # get the instance of the TTS module + tts_folder = None + if self.settings.resources: + tts_folder = self.settings.resources.tts_folder + tts_module_instance = Utils.get_dynamic_class_instantiation(package_name="tts", + module_name=self.tts.name, + parameters=self.tts.parameters, + resources_dir=tts_folder) + # Kalliope will talk, turn on the LED + self.switch_on_led_talking(rpi_settings=self.settings.rpi_settings, on=True) + + # generate the audio file and play it + tts_module_instance.say(tts_message) + + # Kalliope has finished to talk, turn off the LED + self.switch_on_led_talking(rpi_settings=self.settings.rpi_settings, on=False) def _get_message_from_dict(self, message_dict): """ @@ -284,15 +291,15 @@ def _get_tts_object(tts_name=None, override_parameter=None, settings=None): # create a tts object from the tts the user want to use tts_object = next((x for x in settings.ttss if x.name == tts_name), None) if tts_object is None: - raise TTSModuleNotFound("The tts module name %s does not exist in settings file" % tts_name) + raise TTSModuleNotFound("[NeuronModule] The tts module name %s does not exist in settings file" % tts_name) if override_parameter is not None: # the user want to override the default TTS configuration - logger.debug("args for TTS plugin before update: %s" % str(tts_object.parameters)) + logger.debug("[NeuronModule] args for TTS plugin before update: %s" % str(tts_object.parameters)) for key, value in override_parameter.items(): tts_object.parameters[key] = value - logger.debug("args for TTS plugin after update: %s" % str(tts_object.parameters)) + logger.debug("[NeuronModule] args for TTS plugin after update: %s" % str(tts_object.parameters)) - logger.debug("NeuroneModule: TTS args: %s" % tts_object) + logger.debug("[NeuronModule] TTS args: %s" % tts_object) return tts_object @staticmethod diff --git a/kalliope/core/RestAPI/FlaskAPI.py b/kalliope/core/RestAPI/FlaskAPI.py index 35f80eb4..49ae0318 100644 --- a/kalliope/core/RestAPI/FlaskAPI.py +++ b/kalliope/core/RestAPI/FlaskAPI.py @@ -1,25 +1,22 @@ import logging import os import threading - import time -from kalliope.core.LIFOBuffer import LIFOBuffer -from kalliope.core.Models.MatchedSynapse import MatchedSynapse -from kalliope.core.Utils.FileManager import FileManager - -from kalliope.core.ConfigurationManager import SettingLoader, BrainLoader -from kalliope.core.OrderListener import OrderListener -from werkzeug.utils import secure_filename - from flask import jsonify from flask import request -from flask_restful import abort from flask_cors import CORS +from flask_restful import abort +from werkzeug.utils import secure_filename +from kalliope._version import version_str +from kalliope.core.ConfigurationManager import SettingLoader, BrainLoader +from kalliope.core.LIFOBuffer import LIFOBuffer +from kalliope.core.Models.MatchedSynapse import MatchedSynapse +from kalliope.core.OrderListener import OrderListener from kalliope.core.RestAPI.utils import requires_auth from kalliope.core.SynapseLauncher import SynapseLauncher -from kalliope._version import version_str +from kalliope.core.Utils.FileManager import FileManager logging.basicConfig() logger = logging.getLogger("kalliope") @@ -62,7 +59,10 @@ def __init__(self, app, port=5000, brain=None, allowed_cors_origin=False): self.app.config['JSON_AS_ASCII'] = False if self.allowed_cors_origin is not False: - cors = CORS(app, resources={r"/*": {"origins": allowed_cors_origin}}, supports_credentials=True) + CORS(app, resources={r"/*": {"origins": allowed_cors_origin}}, supports_credentials=True) + + # no voice flag + self.no_voice = False # Add routing rules self.app.add_url_rule('/', view_func=self.get_main_page, methods=['GET']) @@ -76,7 +76,9 @@ def __init__(self, app, port=5000, brain=None, allowed_cors_origin=False): def run(self): self.app.run(host='0.0.0.0', port="%s" % int(self.port), debug=True, threaded=True, use_reloader=False) + @requires_auth def get_main_page(self): + logger.debug("[FlaskAPI] get_main_page") data = { "Kalliope version": "%s" % version_str } @@ -109,6 +111,7 @@ def get_synapses(self): test with curl: curl -i --user admin:secret -X GET http://127.0.0.1:5000/synapses """ + logger.debug("[FlaskAPI] get_synapses: all") data = jsonify(synapses=[e.serialize() for e in self.brain.synapses]) return data, 200 @@ -119,6 +122,7 @@ def get_synapse(self, synapse_name): test with curl: curl --user admin:secret -i -X GET http://127.0.0.1:5000/synapses/say-hello-en """ + logger.debug("[FlaskAPI] get_synapse: synapse_name -> %s" % synapse_name) synapse_target = self._get_synapse_by_name(synapse_name) if synapse_target is not None: data = jsonify(synapses=synapse_target.serialize()) @@ -135,12 +139,29 @@ def run_synapse_by_name(self, synapse_name): Run a synapse by its name test with curl: curl -i --user admin:secret -X POST http://127.0.0.1:5000/synapses/start/id/say-hello-fr - :param synapse_name: + + run a synapse without making kalliope speaking + curl -i -H "Content-Type: application/json" --user admin:secret -X POST \ + -d '{"no_voice":"true"}' http://127.0.0.1:5000/synapses/start/id/say-hello-fr + + Run a synapse by its name and pass order's parameters + curl -i -H "Content-Type: application/json" --user admin:secret -X POST \ + -d '{"no_voice":"true", "parameters": {"parameter1": "value1" }}' \ + http://127.0.0.1:5000/synapses/start/id/say-hello-fr + + :param synapse_name: name(id) of the synapse to execute :return: """ # get a synapse object from the name + logger.debug("[FlaskAPI] run_synapse_by_name: synapse name -> %s" % synapse_name) synapse_target = BrainLoader().get_brain().get_synapse_by_name(synapse_name=synapse_name) + # get no_voice_flag if present + no_voice = self.get_no_voice_flag_from_request(request) + + # get parameters + parameters = self.get_parameters_from_request(request) + if synapse_target is None: data = { "synapse name not found": "%s" % synapse_name @@ -148,13 +169,13 @@ def run_synapse_by_name(self, synapse_name): return jsonify(error=data), 404 else: # generate a MatchedSynapse from the synapse - matched_synapse = MatchedSynapse(matched_synapse=synapse_target) + matched_synapse = MatchedSynapse(matched_synapse=synapse_target, overriding_parameter=parameters) # get the current LIFO buffer lifo_buffer = LIFOBuffer() # this is a new call we clean up the LIFO lifo_buffer.clean() lifo_buffer.add_synapse_list_to_lifo([matched_synapse]) - response = lifo_buffer.execute(is_api_call=True) + response = lifo_buffer.execute(is_api_call=True, no_voice=no_voice) data = jsonify(response) return data, 201 @@ -163,25 +184,36 @@ def run_synapse_by_order(self): """ Give an order to Kalliope via API like it was from a spoken one Test with curl - curl -i --user admin:secret -H "Content-Type: application/json" -X POST -d '{"order":"my order"}' http://localhost:5000/synapses/start/order + curl -i --user admin:secret -H "Content-Type: application/json" -X POST \ + -d '{"order":"my order"}' http://localhost:5000/synapses/start/order + In case of quotes in the order or accents, use a file cat post.json: {"order":"j'aime"} - curl -i --user admin:secret -H "Content-Type: application/json" -X POST --data @post.json http://localhost:5000/order/ + curl -i --user admin:secret -H "Content-Type: application/json" -X POST \ + --data @post.json http://localhost:5000/order/ + + Can be used with no_voice flag + curl -i --user admin:secret -H "Content-Type: application/json" -X POST \ + -d '{"order":"my order", "no_voice":"true"}' http://localhost:5000/synapses/start/order + :return: """ if not request.get_json() or 'order' not in request.get_json(): abort(400) order = request.get_json('order') + # get no_voice_flag if present + no_voice = self.get_no_voice_flag_from_request(request) if order is not None: # get the order order_to_run = order["order"] - + logger.debug("[FlaskAPI] run_synapse_by_order: order to run -> %s" % order_to_run) api_response = SynapseLauncher.run_matching_synapse_from_order(order_to_run, self.brain, self.settings, - is_api_call=True) + is_api_call=True, + no_voice=no_voice) data = jsonify(api_response) return data, 201 @@ -191,13 +223,21 @@ def run_synapse_by_order(self): } return jsonify(error=data), 400 + @requires_auth def run_synapse_by_audio(self): """ Give an order to Kalliope with an audio file Test with curl curl -i --user admin:secret -X POST http://localhost:5000/synapses/start/audio -F "file=@/path/to/input.wav" + + With no_voice flag + curl -i -H "Content-Type: application/json" --user admin:secret -X POST \ + http://localhost:5000/synapses/start/audio -F "file=@path/to/file.wav" -F no_voice="true" :return: """ + # get no_voice_flag if present + self.no_voice = self.str_to_bool(request.form.get("no_voice")) + # check if the post request has the file part if 'file' not in request.files: data = { @@ -205,38 +245,59 @@ def run_synapse_by_audio(self): } return jsonify(error=data), 400 - file = request.files['file'] + uploaded_file = request.files['file'] # if user does not select file, browser also # submit a empty part without filename - if file.filename == '': + if uploaded_file.filename == '': data = { "error": "No file provided" } return jsonify(error=data), 400 - if file and self.allowed_file(file.filename): - # save the file - filename = secure_filename(file.filename) - base_path = os.path.join(self.app.config['UPLOAD_FOLDER']) - file.save(os.path.join(base_path, filename)) - - # now start analyse the audio with STT engine - audio_path = base_path + os.sep + filename - ol = OrderListener(callback=self.audio_analyser_callback, audio_file_path=audio_path) - ol.start() - ol.join() - # wait the Order Analyser processing. We need to wait in this thread to keep the context - while not self.order_analyser_return: - time.sleep(0.1) - self.order_analyser_return = False - if self.api_response is not None and self.api_response: - data = jsonify(self.api_response) - self.api_response = None - return data, 201 - else: - data = { - "error": "The given order doesn't match any synapses" - } - return jsonify(error=data), 400 + # save the file + filename = secure_filename(uploaded_file.filename) + base_path = os.path.join(self.app.config['UPLOAD_FOLDER']) + uploaded_file.save(os.path.join(base_path, filename)) + + # now start analyse the audio with STT engine + audio_path = base_path + os.sep + filename + logger.debug("[FlaskAPI] run_synapse_by_audio: with file path %s" % audio_path) + if not self.allowed_file(audio_path): + audio_path = self._convert_to_wav(audio_file_path=audio_path) + ol = OrderListener(callback=self.audio_analyser_callback, audio_file_path=audio_path) + ol.start() + ol.join() + # wait the Order Analyser processing. We need to wait in this thread to keep the context + while not self.order_analyser_return: + time.sleep(0.1) + self.order_analyser_return = False + if self.api_response is not None and self.api_response: + data = jsonify(self.api_response) + self.api_response = None + logger.debug("[FlaskAPI] run_synapse_by_audio: data %s" % data) + return data, 201 + else: + data = { + "error": "The given order doesn't match any synapses" + } + return jsonify(error=data), 400 + + @staticmethod + def _convert_to_wav(audio_file_path): + """ + If not already .wav, convert an incoming audio file to wav format. Using system avconv (raspberry) + :param audio_file_path: the current full file path + :return: Wave file path + """ + # Not allowed so convert into wav using avconv (raspberry) + base = os.path.splitext(audio_file_path)[0] + extension = os.path.splitext(audio_file_path)[1] + if extension != ".wav": + current_file_path = audio_file_path + audio_file_path = base + ".wav" + os.system("avconv -y -i " + current_file_path + " " + audio_file_path) # --> deprecated + # subprocess.call(['avconv', '-y', '-i', audio_path, new_file_path], shell=True) # Not working ... + + return audio_file_path @requires_auth def shutdown_server(self): @@ -257,12 +318,61 @@ def audio_analyser_callback(self, order): :param order: string order to analyse :return: """ - logger.debug("order to process %s" % order) + logger.debug("[FlaskAPI] audio_analyser_callback: order to process -> %s" % order) api_response = SynapseLauncher.run_matching_synapse_from_order(order, self.brain, self.settings, - is_api_call=True) + is_api_call=True, + no_voice=self.no_voice) self.api_response = api_response # this boolean will notify the main process that the order have been processed self.order_analyser_return = True + + def get_no_voice_flag_from_request(self, http_request): + """ + Get the no_voice flag from the request if exist + :param http_request: + :return: + """ + + no_voice = False + try: + received_json = http_request.get_json(force=True, silent=True, cache=True) + if 'no_voice' in received_json: + no_voice = self.str_to_bool(received_json['no_voice']) + except TypeError: + # no json received + pass + logger.debug("[FlaskAPI] no_voice: %s" % no_voice) + return no_voice + + @staticmethod + def str_to_bool(s): + if isinstance(s, bool): # do not convert if already a boolean + return s + else: + if s == 'True' or s == 'true' or s == '1': + return True + elif s == 'False' or s == 'false' or s == '0': + return False + else: + return False + + @staticmethod + def get_parameters_from_request(http_request): + """ + Get "parameters" object from the + :param http_request: + :return: + """ + parameters = None + try: + received_json = http_request.get_json(silent=False, force=True) + if 'parameters' in received_json: + parameters = received_json['parameters'] + except TypeError: + pass + logger.debug("[FlaskAPI] Overridden parameters: %s" % parameters) + + return parameters diff --git a/kalliope/core/SynapseLauncher.py b/kalliope/core/SynapseLauncher.py index 8821ee40..c208470a 100644 --- a/kalliope/core/SynapseLauncher.py +++ b/kalliope/core/SynapseLauncher.py @@ -47,13 +47,14 @@ def start_synapse_by_name(cls, name, brain=None): return lifo_buffer.execute(is_api_call=True) @classmethod - def run_matching_synapse_from_order(cls, order_to_process, brain, settings, is_api_call=False): + def run_matching_synapse_from_order(cls, order_to_process, brain, settings, is_api_call=False, no_voice=False): """ :param order_to_process: the spoken order sent by the user :param brain: Brain object :param settings: Settings object :param is_api_call: if True, the current call come from the API. This info must be known by launched Neuron + :param no_voice: If true, the generated text will not be processed by the TTS engine :return: list of matched synapse """ @@ -63,7 +64,7 @@ def run_matching_synapse_from_order(cls, order_to_process, brain, settings, is_a # if the LIFO is not empty, so, the current order is passed to the current processing synapse as an answer if len(lifo_buffer.lifo_list) > 0: # the LIFO is not empty, this is an answer to a previous call - return lifo_buffer.execute(answer=order_to_process, is_api_call=is_api_call) + return lifo_buffer.execute(answer=order_to_process, is_api_call=is_api_call, no_voice=no_voice) else: # the LIFO is empty, this is a new call # get a list of matched synapse from the order @@ -85,4 +86,4 @@ def run_matching_synapse_from_order(cls, order_to_process, brain, settings, is_a lifo_buffer.add_synapse_list_to_lifo(list_synapse_to_process) lifo_buffer.api_response.user_order = order_to_process - return lifo_buffer.execute(is_api_call=is_api_call) + return lifo_buffer.execute(is_api_call=is_api_call, no_voice=no_voice) diff --git a/kalliope/stt/cmusphinx/README.md b/kalliope/stt/cmusphinx/README.md index 56f41b20..8d829727 100644 --- a/kalliope/stt/cmusphinx/README.md +++ b/kalliope/stt/cmusphinx/README.md @@ -15,11 +15,35 @@ Then install the python lib sudo pip install pocketsphinx ``` -Then, declare it as usual in your settings +#### Parameters + +| parameter | requiered | type | default | choices | comment | +|-----------------|-----------|--------|---------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------| +| language | no | string | en-US | | [Installing other languages](https://github.com/Uberi/speech_recognition/blob/master/reference/pocketsphinx.rst#installing-other-languages) | +| keyword_entries | no | list | | | List of tuples of the form (keyword, sensitivity), where keyword is a phrase, and sensitivity is how sensitive to this phrase the recognizer should be | +| grammar_file | no | string | | | FSG or JSGF grammars file path. Note: If `keyword_entries` are passed, `grammar_file` will be ignored | + +Settings example +```YAML +default_speech_to_text: "cmusphinx" + +speech_to_text: + - cmusphinx: + language: "en-US" # [Installing other languages](https://github.com/Uberi/speech_recognition/blob/master/reference/pocketsphinx.rst#installing-other-languages) +``` + +#### Using keywords + +Sphinx usually operates in 'transcription mode' and will return whatever words it recognizes. +Adding `keyword_entries` to the settings narrows down its search space and is more accurate than just looking for those same keywords in non-keyword-based transcriptions, because Sphinx knows specifically what sounds to look for. +The parameter `keyword_entries` expects a list of tuples consisting of a phrase and a sensitivity level defining how sensitive to this phrase the recognizer should be, on a scale from 0 (very insensitive, more false negatives) to 1 (very sensitive, more false positives). ```YAML default_speech_to_text: "cmusphinx" speech_to_text: - - cmusphinx - language: "en-US" # [Installing other languages](https://github.com/Uberi/speech_recognition/blob/master/reference/pocketsphinx.rst#installing-other-languages) + - cmusphinx: + language: "en-US" + keyword_entries: + - ["hello", 0.8] + - ["stop the music", 0.6] ``` diff --git a/kalliope/stt/cmusphinx/cmusphinx.py b/kalliope/stt/cmusphinx/cmusphinx.py index d47397b0..1d4d45db 100644 --- a/kalliope/stt/cmusphinx/cmusphinx.py +++ b/kalliope/stt/cmusphinx/cmusphinx.py @@ -18,6 +18,9 @@ def __init__(self, callback=None, **kwargs): # callback function to call after the translation speech/tex self.main_controller_callback = callback self.language = kwargs.get('language', "en-US") + self.keyword_entries = kwargs.get('keyword_entries', None) + # ge the grammar file if exist + self.grammar_file = kwargs.get('grammar_file', None) # start listening in the background self.set_callback(self.sphinx_callback) @@ -29,7 +32,10 @@ def sphinx_callback(self, recognizer, audio): called from the background thread """ try: - captured_audio = recognizer.recognize_sphinx(audio, language=self.language) + captured_audio = recognizer.recognize_sphinx(audio, + language=self.language, + keyword_entries=self.keyword_entries, + grammar=self.grammar_file) Utils.print_success("Sphinx Speech Recognition thinks you said %s" % captured_audio) self._analyse_audio(captured_audio) diff --git a/kalliope/trigger/snowboy/snowboy.py b/kalliope/trigger/snowboy/snowboy.py index 696581dc..5862b275 100644 --- a/kalliope/trigger/snowboy/snowboy.py +++ b/kalliope/trigger/snowboy/snowboy.py @@ -27,6 +27,9 @@ def __init__(self, **kwargs): self.interrupted = False self.kill_received = False + # get the sensitivity if set by the user + self.sensitivity = kwargs.get('sensitivity', 0.5) + # callback function to call when hotword caught self.callback = kwargs.get('callback', None) if self.callback is None: @@ -41,7 +44,9 @@ def __init__(self, **kwargs): if not os.path.isfile(self.pmdl_path): raise SnowboyModelNotFounfd("The snowboy model file %s does not exist" % self.pmdl_path) - self.detector = snowboydecoder.HotwordDetector(self.pmdl_path, sensitivity=0.5, detected_callback=self.callback, + self.detector = snowboydecoder.HotwordDetector(self.pmdl_path, + sensitivity=self.sensitivity, + detected_callback=self.callback, interrupt_check=self.interrupt_callback, sleep_time=0.03) @@ -106,4 +111,4 @@ def _ignore_stderr(): try: stdio.__stderrp = devnull except KeyError: - stdio.fclose(devnull) \ No newline at end of file + stdio.fclose(devnull) diff --git a/kalliope/tts/pico2wave/README.md b/kalliope/tts/pico2wave/README.md index 37077492..592fdeb1 100644 --- a/kalliope/tts/pico2wave/README.md +++ b/kalliope/tts/pico2wave/README.md @@ -6,6 +6,7 @@ This TTS is based on the SVOX picoTTS engine |------------|----------|---------|--------------|-------------------------------------------------| | language | YES | | 6 languages | List of supported languages in the Note section | | cache | No | TRUE | True / False | True if you want to use the cache with this TTS | +| samplerate | No | None | int | Pico2wave creates 16 khz files but not all USB devices support this. Set a value to convert to a specific samplerate. For Example: 44100| #### Notes : @@ -17,4 +18,4 @@ Supported languages : - French fr-FR - Spanish es-ES - German de-DE -- Italian it-IT \ No newline at end of file +- Italian it-IT diff --git a/kalliope/tts/pico2wave/pico2wave.py b/kalliope/tts/pico2wave/pico2wave.py index a1cdbf8d..ecaf0d35 100644 --- a/kalliope/tts/pico2wave/pico2wave.py +++ b/kalliope/tts/pico2wave/pico2wave.py @@ -2,6 +2,8 @@ import subprocess from kalliope.core.TTS.TTSModule import TTSModule +import sox + import logging import sys @@ -13,6 +15,7 @@ class Pico2wave(TTSModule): def __init__(self, **kwargs): super(Pico2wave, self).__init__(**kwargs) + self.samplerate = kwargs.get('samplerate', None) def say(self, words): """ @@ -44,6 +47,13 @@ def _generate_audio_file(self): # generate the file with pico2wav subprocess.call(final_command, stderr=sys.stderr) - + + # convert samplerate + if self.samplerate is not None: + tfm = sox.Transformer() + tfm.rate(samplerate=self.samplerate) + tfm.build(str(tmp_path), str(tmp_path)+("tmp_name.wav")) + os.rename(str(tmp_path)+("tmp_name.wav"), tmp_path) + # remove the extension .wav os.rename(tmp_path, self.file_path) diff --git a/setup.py b/setup.py index c83bb204..32e17a08 100644 --- a/setup.py +++ b/setup.py @@ -66,10 +66,10 @@ def read_version_py(file_name): # required libs install_requires=[ 'pyyaml>=3.12', - 'six==1.10.0', - 'SpeechRecognition>=3.6.0', + 'six>=1.10.0', + 'SpeechRecognition>=3.7.1', 'markupsafe>=1.0', - 'pyaudio>=0.2.10', + 'pyaudio>=0.2.11', 'pyasn1>=0.2.3', 'ansible>=2.3', py2_prefix + 'pythondialog>=3.4.0', @@ -90,7 +90,8 @@ def read_version_py(file_name): 'sounddevice>=0.3.7', 'SoundFile>=0.9.0', 'pyalsaaudio>=0.8.4', - 'RPi.GPIO>=0.6.3' + 'RPi.GPIO>=0.6.3', + 'sox>=1.3.0' ],