├── src └── hermes_audio_server │ ├── __init__.py │ ├── about.py │ ├── exceptions.py │ ├── logger.py │ ├── config │ ├── vad.py │ ├── __init__.py │ └── mqtt.py │ ├── cli.py │ ├── mqtt.py │ ├── player.py │ └── recorder.py ├── .pylintrc ├── .gitignore ├── requirements.txt ├── etc └── systemd │ └── system │ ├── hermes-audio-player.service │ └── hermes-audio-recorder.service ├── .travis.yml ├── bin ├── hermes-audio-player └── hermes-audio-recorder ├── LICENSE ├── setup.py └── README.md /src/hermes_audio_server/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | init-hook='import sys; sys.path.append("src")' 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | build 3 | dist 4 | hermes_audio_server.egg-info 5 | venv 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorlog 2 | humanfriendly 3 | paho-mqtt 4 | plac 5 | # Needs sudo apt install portaudio19-dev on Raspbian/Debian/Ubuntu 6 | pyaudio 7 | python-daemon 8 | webrtcvad 9 | -------------------------------------------------------------------------------- /etc/systemd/system/hermes-audio-player.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Hermes Audio Player 3 | After=network.target 4 | 5 | [Service] 6 | User=hermes-audio-server 7 | Group=hermes-audio-server 8 | ExecStart=/usr/local/bin/hermes-audio-player -d 9 | Restart=always 10 | RestartSec=10 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /etc/systemd/system/hermes-audio-recorder.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Hermes Audio Recorder 3 | After=network.target 4 | 5 | [Service] 6 | User=hermes-audio-server 7 | Group=hermes-audio-server 8 | ExecStart=/usr/local/bin/hermes-audio-recorder -d 9 | Restart=always 10 | RestartSec=10 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | 3 | language: python 4 | python: 5 | - 3.5 6 | - 3.6 7 | - 3.7 8 | 9 | addons: 10 | apt: 11 | packages: 12 | - portaudio19-dev 13 | 14 | install: 15 | - pip3 install pylint 16 | - pip3 install -r requirements.txt 17 | 18 | script: 19 | - pylint -E src 20 | - pylint -E bin 21 | - python3 setup.py sdist bdist_wheel 22 | - python3 setup.py install 23 | -------------------------------------------------------------------------------- /bin/hermes-audio-player: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import plac 3 | 4 | from hermes_audio_server.about import PLAYER 5 | from hermes_audio_server import cli 6 | from hermes_audio_server.config import DEFAULT_CONFIG 7 | 8 | 9 | def main(verbose: ('use verbose output', 'flag', 'v'), 10 | version: ('print version information and exit', 'flag', 'V'), 11 | config: ('configuration file [default: {}]'.format(DEFAULT_CONFIG), 12 | 'option', 'c'), 13 | daemon: ('run as daemon', 'flag', 'd')): 14 | """hermes-audio-player is an audio server implementing the playback part of 15 | the Hermes protocol.""" 16 | cli.main(PLAYER, verbose, version, config, daemon) 17 | 18 | 19 | if __name__ == '__main__': 20 | plac.call(main) 21 | -------------------------------------------------------------------------------- /bin/hermes-audio-recorder: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import plac 3 | 4 | from hermes_audio_server.about import RECORDER 5 | from hermes_audio_server import cli 6 | from hermes_audio_server.config import DEFAULT_CONFIG 7 | 8 | 9 | def main(verbose: ('use verbose output', 'flag', 'v'), 10 | version: ('print version information and exit', 'flag', 'V'), 11 | config: ('configuration file [default: {}]'.format(DEFAULT_CONFIG), 12 | 'option', 'c'), 13 | daemon: ('run as daemon', 'flag', 'd')): 14 | """hermes-audio-recorder is an audio server implementing the recording part 15 | of the Hermes protocol.""" 16 | cli.main(RECORDER, verbose, version, config, daemon) 17 | 18 | 19 | if __name__ == '__main__': 20 | plac.call(main) 21 | -------------------------------------------------------------------------------- /src/hermes_audio_server/about.py: -------------------------------------------------------------------------------- 1 | """This module specifies some information about the package.""" 2 | PROJECT = 'hermes-audio-server' 3 | DESCRIPTION = 'An open source implementation of the audio server part of the Hermes protocol' 4 | KEYWORDS = 'hermes snips python3 rhasspy audio-player audio-recorder audio-server snips-audio-server hermes-protocol voice voice-control' 5 | AUTHOR = 'Koen Vervloesem' 6 | EMAIL = 'koen@vervloesem.eu' 7 | LICENSE = 'MIT License' 8 | GITHUB_URL = 'https://github.com/koenvervloesem/hermes-audio-server' 9 | DOC_URL = 'https://github.com/koenvervloesem/hermes-audio-server' 10 | TRACKER_URL = 'https://github.com/koenvervloesem/hermes-audio-server/issues' 11 | PLAYER = 'hermes-audio-player' 12 | RECORDER = 'hermes-audio-recorder' 13 | VERSION = '0.3.0-dev' 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Koen Vervloesem 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /src/hermes_audio_server/exceptions.py: -------------------------------------------------------------------------------- 1 | """This module contains exceptions defined for Hermes Audio Server.""" 2 | 3 | 4 | class HermesAudioServerError(Exception): 5 | """Base class for exceptions raised by Hermes Audio Server code. 6 | 7 | By catching this exception type, you catch all exceptions that are 8 | defined by the Hermes Audio Server code.""" 9 | 10 | 11 | class ConfigurationFileNotFoundError(HermesAudioServerError): 12 | """Raised when the configuration file is not found.""" 13 | 14 | def __init__(self, filename): 15 | """Initialize the exception with a string representing the filename.""" 16 | self.filename = filename 17 | 18 | 19 | class NoDefaultAudioDeviceError(HermesAudioServerError): 20 | """Raised when there's no default audio device available.""" 21 | 22 | def __init__(self, inout): 23 | """Initialize the exception with a string representing input or output. 24 | """ 25 | self.inout = inout 26 | 27 | 28 | class UnsupportedPlatformError(HermesAudioServerError): 29 | """Raised when the platform Hermes Audio Server is running on is not 30 | supported.""" 31 | 32 | def __init__(self, platform): 33 | """Initialize the exception with a string representing the platform.""" 34 | self.platform = platform 35 | -------------------------------------------------------------------------------- /src/hermes_audio_server/logger.py: -------------------------------------------------------------------------------- 1 | """This module contains helper functions to log messages from Hermes Audio 2 | Server.""" 3 | import logging 4 | from logging.handlers import SysLogHandler 5 | import sys 6 | 7 | import colorlog 8 | 9 | from hermes_audio_server.exceptions import UnsupportedPlatformError 10 | 11 | DAEMON_FORMAT = '{}[%(process)d]: %(message)s' 12 | INTERACTIVE_FORMAT = '%(asctime)s %(log_color)s%(levelname)-8s%(reset)s %(message)s' 13 | LOG_COLORS = {'DEBUG': 'white', 14 | 'INFO': 'green', 15 | 'WARNING': 'yellow', 16 | 'ERROR': 'red', 17 | 'CRITICAL': 'bold_red'} 18 | 19 | 20 | def get_domain_socket(): 21 | """Get the default domain socket for syslog on this platform.""" 22 | if sys.platform.startswith('linux'): # Linux 23 | return '/dev/log' 24 | if sys.platform.startswith('darwin'): # macOS 25 | return '/var/run/syslog' 26 | # Unsupported platform 27 | raise UnsupportedPlatformError(sys.platform) 28 | 29 | 30 | def get_logger(command, verbose, daemon): 31 | """Return a Logger object with the right level, formatter and handler.""" 32 | 33 | if daemon: 34 | handler = SysLogHandler(address=get_domain_socket()) 35 | formatter = logging.Formatter(fmt=DAEMON_FORMAT.format(command)) 36 | logger = logging.getLogger(command) 37 | else: 38 | handler = colorlog.StreamHandler(stream=sys.stdout) 39 | formatter = colorlog.ColoredFormatter(INTERACTIVE_FORMAT, 40 | log_colors=LOG_COLORS) 41 | logger = colorlog.getLogger(command) 42 | 43 | if verbose: 44 | logger.setLevel(logging.DEBUG) 45 | else: 46 | logger.setLevel(logging.INFO) 47 | 48 | handler.setFormatter(formatter) 49 | logger.addHandler(handler) 50 | 51 | return logger 52 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from pathlib import Path 3 | from setuptools import setup, find_packages 4 | 5 | SRC_ROOT = 'src' 6 | BIN_ROOT = 'bin/' 7 | 8 | about = import_module(SRC_ROOT + '.hermes_audio_server.about') 9 | 10 | with Path('README.md').open('r') as fh: 11 | long_description = fh.read() 12 | 13 | with Path('requirements.txt').open('r') as fh: 14 | requirements = fh.read().splitlines() 15 | requirements = [requirement for requirement in requirements 16 | if not requirement.startswith('#')] 17 | 18 | binaries = [BIN_ROOT + about.PLAYER, BIN_ROOT + about.RECORDER] 19 | 20 | setup( 21 | name=about.PROJECT, 22 | version=about.VERSION, 23 | description=about.DESCRIPTION, 24 | long_description=long_description, 25 | long_description_content_type='text/markdown', 26 | license=about.LICENSE, 27 | author=about.AUTHOR, 28 | author_email=about.EMAIL, 29 | url=about.GITHUB_URL, 30 | project_urls={ 31 | 'Documentation': about.DOC_URL, 32 | 'Source': about.GITHUB_URL, 33 | 'Tracker': about.TRACKER_URL, 34 | }, 35 | packages=find_packages(SRC_ROOT), 36 | package_dir={'': SRC_ROOT}, 37 | install_requires=requirements, 38 | python_requires='>=3', 39 | include_package_data=True, 40 | zip_safe=False, 41 | classifiers=[ 42 | 'Development Status :: 3 - Alpha', 43 | 'Environment :: Console', 44 | 'License :: OSI Approved :: MIT License', 45 | 'Operating System :: POSIX', 46 | 'Programming Language :: Python', 47 | 'Programming Language :: Python :: 3 :: Only', 48 | 'Programming Language :: Python :: 3.5', 49 | 'Programming Language :: Python :: 3.6', 50 | 'Programming Language :: Python :: 3.7', 51 | 'Programming Language :: Python :: Implementation :: CPython', 52 | 'Topic :: Home Automation', 53 | 'Topic :: Multimedia :: Sound/Audio :: Capture/Recording', 54 | 'Topic :: Multimedia :: Sound/Audio :: Players' 55 | ], 56 | keywords=about.KEYWORDS, 57 | scripts=binaries) 58 | -------------------------------------------------------------------------------- /src/hermes_audio_server/config/vad.py: -------------------------------------------------------------------------------- 1 | """Class for the VAD configuration of hermes-audio-server.""" 2 | 3 | # Default values 4 | DEFAULT_MODE = 0 5 | DEFAULT_SILENCE = 2 6 | DEFAULT_STATUS_MESSAGES = False 7 | 8 | # Keys in the JSON configuration file 9 | MODE = 'mode' 10 | SILENCE = 'silence' 11 | STATUS_MESSAGES = 'status_messages' 12 | 13 | 14 | # TODO: Define __str__() for each class with explicit settings for debugging. 15 | class VADConfig: 16 | """This class represents the VAD settings for Hermes Audio Recorder. 17 | 18 | Attributes: 19 | enabled (bool): Whether or not VAD is enabled. 20 | mode (int): Aggressiveness mode for VAD. 0 is the least aggressive 21 | about filtering out non-speech, 3 is the most aggressive. 22 | silence (int): How much silence (no speech detected) in seconds has 23 | to go by before Hermes Audio Recorder considers it the end of a 24 | voice message. 25 | status_messages (bool): Whether or not Hermes Audio Recorder sends 26 | messages on MQTT when it detects the start or end of a voice 27 | message. 28 | """ 29 | 30 | def __init__(self, enabled=False, mode=0, silence=2, status_messages=False): 31 | """Initialize a :class:`.VADConfig` object. 32 | 33 | Args: 34 | enabled (bool): Whether or not VAD is enabled. Defaults to False. 35 | mode (int): Aggressiveness mode for VAD. Defaults to 0. 36 | silence (int): How much silence (no speech detected) in seconds has 37 | to go by before Hermes Audio Recorder considers it the end of a 38 | voice message. Defaults to 2. 39 | status_messages (bool): Whether or not Hermes Audio Recorder sends 40 | messages on MQTT when it detects the start or end of a voice 41 | message. Defaults to False. 42 | 43 | All arguments are optional. 44 | """ 45 | self.enabled = enabled 46 | self.mode = mode 47 | self.silence = silence 48 | self.status_messages = status_messages 49 | 50 | @classmethod 51 | def from_json(cls, json_object=None): 52 | """Initialize a :class:`.VADConfig` object with settings from a 53 | JSON object. 54 | 55 | Args: 56 | json_object (optional): The JSON object with the VAD settings. 57 | Defaults to {}. 58 | 59 | Returns: 60 | :class:`.VADConfig`: An object with the VAD settings. 61 | 62 | The JSON object should have the following format: 63 | 64 | { 65 | "mode": 0, 66 | "silence": 2, 67 | "status_messages": true 68 | } 69 | """ 70 | if json_object is None: 71 | ret = cls(enabled=False) 72 | else: 73 | ret = cls(enabled=True, 74 | mode=json_object.get(MODE, DEFAULT_MODE), 75 | silence=json_object.get(SILENCE, DEFAULT_SILENCE), 76 | status_messages=json_object.get(STATUS_MESSAGES, 77 | DEFAULT_STATUS_MESSAGES)) 78 | 79 | return ret 80 | -------------------------------------------------------------------------------- /src/hermes_audio_server/cli.py: -------------------------------------------------------------------------------- 1 | """This module contains the main function run by the CLI commands 2 | hermes-audio-player and hermes-audio-recorder. 3 | """ 4 | from json import JSONDecodeError 5 | import signal 6 | import sys 7 | 8 | from daemon import DaemonContext 9 | 10 | from hermes_audio_server.about import VERSION 11 | from hermes_audio_server.config import ServerConfig, DEFAULT_CONFIG 12 | from hermes_audio_server.exceptions import ConfigurationFileNotFoundError, \ 13 | NoDefaultAudioDeviceError, UnsupportedPlatformError 14 | from hermes_audio_server.logger import get_logger 15 | from hermes_audio_server.player import AudioPlayer 16 | from hermes_audio_server.recorder import AudioRecorder 17 | 18 | SERVER = {'hermes-audio-player': AudioPlayer, 19 | 'hermes-audio-recorder': AudioRecorder} 20 | 21 | 22 | def main(command, verbose, version, config, daemon): 23 | """The main function run by the CLI command. 24 | 25 | Args: 26 | command (str): The command to run. 27 | verbose (bool): Use verbose output if True. 28 | version (bool): Print version information and exit if True. 29 | config (str): Configuration file. 30 | daemon (bool): Run as a daemon if True. 31 | """ 32 | # Define signal handler to cleanly exit the program. 33 | def exit_process(signal_number, frame): 34 | # pylint: disable=no-member 35 | logger.info('Received %s signal. Exiting...', 36 | signal.Signals(signal_number).name) 37 | server.stop() 38 | sys.exit(0) 39 | 40 | # Register signals. 41 | signal.signal(signal.SIGQUIT, exit_process) 42 | signal.signal(signal.SIGTERM, exit_process) 43 | 44 | try: 45 | 46 | logger = get_logger(command, verbose, daemon) 47 | logger.info('%s %s', command, VERSION) 48 | 49 | # Start the program as a daemon. 50 | if daemon: 51 | logger.debug('Starting daemon...') 52 | context = DaemonContext(files_preserve=[logger.handlers[0].socket]) 53 | context.signal_map = {signal.SIGQUIT: exit_process, 54 | signal.SIGTERM: exit_process} 55 | context.open() 56 | 57 | if not version: 58 | if not config: 59 | logger.debug('Using default configuration file.') 60 | config = DEFAULT_CONFIG 61 | 62 | server_class = SERVER[command] 63 | logger.debug('Creating %s object...', server_class.__name__) 64 | server = server_class(ServerConfig.from_json_file(config), 65 | verbose, 66 | logger) 67 | 68 | server.start() 69 | except ConfigurationFileNotFoundError as error: 70 | logger.critical('Configuration file %s not found. Exiting...', error.filename) 71 | sys.exit(1) 72 | except JSONDecodeError as error: 73 | logger.critical('%s is not a valid JSON file. Parsing failed at line %s and column %s. Exiting...', config, error.lineno, error.colno) 74 | sys.exit(1) 75 | except KeyboardInterrupt: 76 | logger.info('Received SIGINT signal. Shutting down %s...', command) 77 | server.stop() 78 | sys.exit(0) 79 | except NoDefaultAudioDeviceError as error: 80 | logger.critical('No default audio %s device available. Exiting...', 81 | error.inout) 82 | sys.exit(1) 83 | except PermissionError as error: 84 | logger.critical('Can\'t read file %s. Make sure you have read permissions. Exiting...', error.filename) 85 | sys.exit(1) 86 | except UnsupportedPlatformError as error: 87 | # Don't use logger because this exception is thrown while logging. 88 | print('Error: {} is not a supported platform.'.format(error.platform)) 89 | sys.exit(1) 90 | -------------------------------------------------------------------------------- /src/hermes_audio_server/mqtt.py: -------------------------------------------------------------------------------- 1 | """Module with an MQTT client. Both the audio player and audio recorder class 2 | inherit from this class. 3 | """ 4 | from paho.mqtt.client import Client 5 | import pyaudio 6 | 7 | 8 | class MQTTClient: 9 | """This class represents an MQTT client for Hermes Audio Server. 10 | 11 | This is an abstract base class. You don't instantiate an object of this 12 | class, but an object of one of its subclasses. 13 | """ 14 | 15 | def __init__(self, config, verbose, logger): 16 | """Initialize an MQTT client. 17 | 18 | Args: 19 | config (:class:`.ServerConfig`): The configuration of 20 | the MQTT client. 21 | verbose (bool): Whether or not the MQTT client runs in verbose 22 | mode. 23 | logger (:class:`logging.Logger`): The Logger object for logging 24 | messages. 25 | """ 26 | self.config = config 27 | self.verbose = verbose 28 | self.logger = logger 29 | self.mqtt = Client() 30 | self.logger.debug('Using %s', pyaudio.get_portaudio_version_text()) 31 | self.logger.debug('Creating PyAudio object...') 32 | self.audio = pyaudio.PyAudio() 33 | 34 | self.initialize() 35 | 36 | self.mqtt.on_connect = self.on_connect 37 | self.mqtt.on_disconnect = self.on_disconnect 38 | self.connect() 39 | 40 | def connect(self): 41 | """Connect to the MQTT broker defined in the configuration.""" 42 | # Set up MQTT authentication. 43 | if self.config.mqtt.auth.enabled: 44 | self.logger.debug('Setting username and password for MQTT broker.') 45 | self.mqtt.username_pw_set(self.config.mqtt.auth.username, 46 | self.config.mqtt.auth.password) 47 | 48 | # Set up an MQTT TLS connection. 49 | if self.config.mqtt.tls.enabled: 50 | self.logger.debug('Setting TLS connection settings for MQTT broker.') 51 | self.mqtt.tls_set(ca_certs=self.config.mqtt.tls.ca_certs, 52 | certfile=self.config.mqtt.tls.client_cert, 53 | keyfile=self.config.mqtt.tls.client_key) 54 | 55 | self.logger.debug('Connecting to MQTT broker %s:%s...', 56 | self.config.mqtt.host, 57 | self.config.mqtt.port) 58 | self.mqtt.connect(self.config.mqtt.host, self.config.mqtt.port) 59 | 60 | def initialize(self): 61 | """Initialize the MQTT client.""" 62 | 63 | def start(self): 64 | """Start the event loop to the MQTT broker so the audio server starts 65 | listening to MQTT topics and the callback methods are called. 66 | """ 67 | self.logger.debug('Starting MQTT event loop...') 68 | self.mqtt.loop_forever() 69 | 70 | def stop(self): 71 | """Disconnect from the MQTT broker and terminate the audio connection. 72 | """ 73 | self.logger.debug('Disconnecting from MQTT broker...') 74 | self.mqtt.disconnect() 75 | self.logger.debug('Terminating PyAudio object...') 76 | self.audio.terminate() 77 | 78 | def on_connect(self, client, userdata, flags, result_code): 79 | """Callback that is called when the client connects to the MQTT broker. 80 | """ 81 | self.logger.info('Connected to MQTT broker %s:%s' 82 | ' with result code %s.', 83 | self.config.mqtt.host, 84 | self.config.mqtt.port, 85 | result_code) 86 | 87 | def on_disconnect(self, client, userdata, flags, result_code): 88 | """Callback that is called when the client connects from the MQTT 89 | broker.""" 90 | # This callback doesn't seem to be called. 91 | self.logger.info('Disconnected with result code %s.', result_code) 92 | -------------------------------------------------------------------------------- /src/hermes_audio_server/config/__init__.py: -------------------------------------------------------------------------------- 1 | """Classes for the configuration of hermes-audio-server.""" 2 | import json 3 | from pathlib import Path 4 | 5 | from hermes_audio_server.config.mqtt import MQTTConfig 6 | from hermes_audio_server.config.vad import VADConfig 7 | from hermes_audio_server.exceptions import ConfigurationFileNotFoundError 8 | 9 | 10 | # Default values 11 | DEFAULT_CONFIG = '/etc/hermes-audio-server.json' 12 | DEFAULT_SITE = 'default' 13 | 14 | # Keys in the JSON configuration file 15 | SITE = 'site' 16 | MQTT = 'mqtt' 17 | VAD = 'vad' 18 | 19 | 20 | # TODO: Define __str__() with explicit settings for debugging. 21 | class ServerConfig: 22 | """This class represents the configuration of a Hermes audio server. 23 | 24 | Attributes: 25 | site (str): The site ID of the audio server. 26 | mqtt (:class:`.MQTTConfig`): The MQTT options of the configuration. 27 | vad (:class:`.VADConfig`): The VAD options of the configuration. 28 | """ 29 | 30 | def __init__(self, site='default', mqtt=None, vad=None): 31 | """Initialize a :class:`.ServerConfig` object. 32 | 33 | Args: 34 | site (str): The site ID of the Hermes audio server. Defaults 35 | to 'default'. 36 | mqtt (:class:`.MQTTConfig`, optional): The MQTT connection 37 | settings. Defaults to a default :class:`.MQTTConfig` object. 38 | vad (:class:`.VADConfig`, optional): The VAD settings. Defaults 39 | to a default :class:`.VADConfig` object, which disables voice 40 | activity detection. 41 | """ 42 | if mqtt is None: 43 | self.mqtt = MQTTConfig() 44 | else: 45 | self.mqtt = mqtt 46 | 47 | if vad is None: 48 | self.vad = VADConfig() 49 | else: 50 | self.vad = vad 51 | 52 | self.site = site 53 | 54 | @classmethod 55 | def from_json_file(cls, filename=None): 56 | """Initialize a :class:`.ServerConfig` object with settings 57 | from a JSON file. 58 | 59 | Args: 60 | filename (str): The filename of a JSON file with the settings. 61 | Defaults to '/etc/hermes-audio-server'. 62 | 63 | Returns: 64 | :class:`.ServerConfig`: An object with the settings 65 | of the Hermes Audio Server. 66 | 67 | The :attr:`mqtt` attribute of the :class:`.ServerConfig` 68 | object is initialized with the MQTT connection settings from the 69 | configuration file, or the default values (hostname 'localhost' and 70 | port number 1883) if the settings are not specified. 71 | 72 | The :attr:`site` attribute of the :class:`.ServerConfig` 73 | object is initialized with the setting from the configuration file, 74 | or 'default' is the setting is not specified. 75 | 76 | The :attr:`vad` attribute of the :class:`.ServerConfig` object is 77 | initialized with the settings from the configuration file, or not 78 | enabled when not specified. 79 | 80 | Raises: 81 | :exc:`ConfigurationFileNotFoundError`: If :attr:`filename` doesn't 82 | exist. 83 | 84 | :exc:`PermissionError`: If we have no read permissions for 85 | :attr:`filename`. 86 | 87 | :exc:`JSONDecodeError`: If :attr:`filename` doesn't have a valid 88 | JSON syntax. 89 | 90 | The JSON file should have the following format: 91 | 92 | { 93 | "site": "default", 94 | "mqtt": { 95 | "host": "localhost", 96 | "port": 1883, 97 | "authentication": { 98 | "username": "foobar", 99 | "password": "secretpassword" 100 | }, 101 | "tls": { 102 | "ca_certificates": "", 103 | "client_certificate": "", 104 | "client_key": "" 105 | } 106 | }, 107 | "vad": { 108 | "mode": 0, 109 | "silence": 2, 110 | "status_messages": true 111 | } 112 | } 113 | """ 114 | if not filename: 115 | filename = DEFAULT_CONFIG 116 | 117 | try: 118 | with Path(filename).open('r') as json_file: 119 | configuration = json.load(json_file) 120 | except FileNotFoundError as error: 121 | raise ConfigurationFileNotFoundError(error.filename) 122 | 123 | return cls(site=configuration.get(SITE, DEFAULT_SITE), 124 | mqtt=MQTTConfig.from_json(configuration.get(MQTT)), 125 | vad=VADConfig.from_json(configuration.get(VAD))) 126 | -------------------------------------------------------------------------------- /src/hermes_audio_server/player.py: -------------------------------------------------------------------------------- 1 | """Module with the Hermes audio player class.""" 2 | import io 3 | import json 4 | import wave 5 | 6 | from humanfriendly import format_size 7 | 8 | from hermes_audio_server.exceptions import NoDefaultAudioDeviceError 9 | from hermes_audio_server.mqtt import MQTTClient 10 | 11 | PLAY_BYTES = 'hermes/audioServer/{}/playBytes/+' 12 | PLAY_FINISHED = 'hermes/audioServer/{}/playFinished' 13 | CHUNK = 256 14 | 15 | 16 | class AudioPlayer(MQTTClient): 17 | """This class creates an MQTT client that acts as an audio player for the 18 | Hermes protocol. 19 | """ 20 | 21 | def initialize(self): 22 | """Initialize a Hermes audio player.""" 23 | self.logger.debug('Probing for available output devices...') 24 | for index in range(self.audio.get_device_count()): 25 | device = self.audio.get_device_info_by_index(index) 26 | name = device['name'] 27 | channels = device['maxOutputChannels'] 28 | if channels: 29 | self.logger.debug('[%d] %s', index, name) 30 | try: 31 | self.audio_out = self.audio.get_default_output_device_info()['name'] 32 | except OSError: 33 | raise NoDefaultAudioDeviceError('output') 34 | self.logger.info('Connected to audio output %s.', self.audio_out) 35 | 36 | def on_connect(self, client, userdata, flags, result_code): 37 | """Callback that is called when the audio player connects to the MQTT 38 | broker.""" 39 | super().on_connect(client, userdata, flags, result_code) 40 | # Listen to the MQTT topic defined in the Hermes protocol to play a WAV 41 | # file. 42 | # See https://docs.snips.ai/reference/hermes#playing-a-wav-sound 43 | play_bytes = PLAY_BYTES.format(self.config.site) 44 | self.mqtt.subscribe(play_bytes) 45 | self.mqtt.message_callback_add(play_bytes, self.on_play_bytes) 46 | self.logger.info('Subscribed to %s topic.', play_bytes) 47 | 48 | def on_play_bytes(self, client, userdata, message): 49 | """Callback that is called when the audio player receives a PLAY_BYTES 50 | message on MQTT. 51 | """ 52 | request_id = message.topic.split('/')[4] 53 | length = format_size(len(message.payload), binary=True) 54 | self.logger.info('Received an audio message of length %s' 55 | ' with request id %s on site %s.', 56 | length, 57 | request_id, 58 | self.config.site) 59 | 60 | with io.BytesIO(message.payload) as wav_buffer: 61 | try: 62 | with wave.open(wav_buffer, 'rb') as wav: 63 | sample_width = wav.getsampwidth() 64 | sample_format = self.audio.get_format_from_width(sample_width) 65 | n_channels = wav.getnchannels() 66 | frame_rate = wav.getframerate() 67 | 68 | self.logger.debug('Sample width: %s', sample_width) 69 | self.logger.debug('Channels: %s', n_channels) 70 | self.logger.debug('Frame rate: %s', frame_rate) 71 | 72 | self.logger.debug('Opening audio output stream...') 73 | stream = self.audio.open(format=sample_format, 74 | channels=n_channels, 75 | rate=frame_rate, 76 | output=True) 77 | 78 | self.logger.debug('Playing WAV buffer on audio output...') 79 | data = wav.readframes(CHUNK) 80 | 81 | while data: 82 | stream.write(data) 83 | data = wav.readframes(CHUNK) 84 | 85 | stream.stop_stream() 86 | self.logger.debug('Closing audio output stream...') 87 | stream.close() 88 | 89 | self.logger.info('Finished playing audio message with id %s' 90 | ' on device %s on site %s.', 91 | request_id, 92 | self.audio_out, 93 | self.config.site) 94 | 95 | # Publish a message that the audio service has finished 96 | # playing the sound. 97 | # See https://docs.snips.ai/reference/hermes#being-notified-when-sound-has-finished-playing 98 | # This implementation doesn't publish a session ID. 99 | play_finished_topic = PLAY_FINISHED.format(self.config.site) 100 | play_finished_message = json.dumps({'id': request_id, 101 | 'siteId': self.config.site}) 102 | self.mqtt.publish(play_finished_topic, 103 | play_finished_message) 104 | self.logger.debug('Published message on MQTT topic:') 105 | self.logger.debug('Topic: %s', play_finished_topic) 106 | self.logger.debug('Message: %s', play_finished_message) 107 | except wave.Error as error: 108 | self.logger.warning('%s', str(error)) 109 | except EOFError: 110 | self.logger.warning('End of WAV buffer') 111 | -------------------------------------------------------------------------------- /src/hermes_audio_server/recorder.py: -------------------------------------------------------------------------------- 1 | """Module with the Hermes audio recorder class.""" 2 | import io 3 | import json 4 | from threading import Thread 5 | import wave 6 | 7 | import pyaudio 8 | import webrtcvad 9 | 10 | from hermes_audio_server.exceptions import NoDefaultAudioDeviceError 11 | from hermes_audio_server.mqtt import MQTTClient 12 | 13 | AUDIO_FRAME = 'hermes/audioServer/{}/audioFrame' 14 | CHANNELS = 1 15 | CHUNK = 320 # = FRAME_RATE * 20 / 1000 (20 ms) 16 | FRAME_RATE = 16000 17 | SAMPLE_WIDTH = 2 18 | 19 | VAD_DOWN = 'hermes/voiceActivity/{}/vadDown' 20 | VAD_UP = 'hermes/voiceActivity/{}/vadUp' 21 | 22 | 23 | # TODO: Call stream.stop_stream() and stream.close() 24 | class AudioRecorder(MQTTClient): 25 | """This class creates an MQTT client that acts as an audio recorder for the 26 | Hermes protocol. 27 | """ 28 | 29 | def initialize(self): 30 | """Initialize a Hermes audio recorder.""" 31 | self.logger.debug('Probing for available input devices...') 32 | for index in range(self.audio.get_device_count()): 33 | device = self.audio.get_device_info_by_index(index) 34 | name = device['name'] 35 | channels = device['maxInputChannels'] 36 | if channels: 37 | self.logger.debug('[%d] %s', index, name) 38 | try: 39 | self.audio_in = self.audio.get_default_input_device_info()['name'] 40 | except OSError: 41 | raise NoDefaultAudioDeviceError('input') 42 | self.logger.info('Connected to audio input %s.', self.audio_in) 43 | 44 | if self.config.vad.enabled: 45 | self.logger.info('Voice Activity Detection enabled with mode %s.', 46 | self.config.vad.mode) 47 | self.vad = webrtcvad.Vad(self.config.vad.mode) 48 | 49 | def start(self): 50 | """Start the event loop to the MQTT broker and start the audio 51 | recording.""" 52 | self.logger.debug('Starting audio thread...') 53 | Thread(target=self.send_audio_frames, daemon=True).start() 54 | super().start() 55 | 56 | def publish_frames(self, frames): 57 | """Publish frames on MQTT.""" 58 | with io.BytesIO() as wav_buffer: 59 | with wave.open(wav_buffer, 'wb') as wav: 60 | # pylint: disable=no-member 61 | wav.setnchannels(CHANNELS) 62 | wav.setsampwidth(SAMPLE_WIDTH) 63 | wav.setframerate(FRAME_RATE) 64 | wav.writeframes(frames) 65 | 66 | audio_frame_topic = AUDIO_FRAME.format(self.config.site) 67 | audio_frame_message = wav_buffer.getvalue() 68 | self.mqtt.publish(audio_frame_topic, audio_frame_message) 69 | self.logger.debug('Published message on MQTT topic:') 70 | self.logger.debug('Topic: %s', audio_frame_topic) 71 | self.logger.debug('Message: %d bytes', len(audio_frame_message)) 72 | 73 | def publish_vad_status_message(self, message): 74 | """Publish a status message about the VAD on MQTT.""" 75 | if self.config.vad.status_messages: 76 | vad_status_topic = message.format(self.config.site) 77 | vad_status_message = json.dumps({'siteId': self.config.site, 78 | 'signalMs': 0}) # Not used 79 | self.mqtt.publish(vad_status_topic, vad_status_message) 80 | self.logger.debug('Published message on MQTT topic:') 81 | self.logger.debug('Topic: %s', vad_status_topic) 82 | self.logger.debug('Message: %s', vad_status_message) 83 | 84 | def send_audio_frames(self): 85 | """Send the recorded audio frames continuously in AUDIO_FRAME 86 | messages on MQTT. 87 | """ 88 | self.logger.debug('Opening audio input stream...') 89 | stream = self.audio.open(format=pyaudio.paInt16, channels=CHANNELS, 90 | rate=FRAME_RATE, input=True, 91 | frames_per_buffer=CHUNK) 92 | 93 | self.logger.info('Starting broadcasting audio from device %s' 94 | ' on site %s...', self.audio_in, self.config.site) 95 | 96 | in_speech = False 97 | silence_frames = int(FRAME_RATE / CHUNK * self.config.vad.silence) 98 | 99 | # TODO: Simplify if ... if ... 100 | while True: 101 | frames = stream.read(CHUNK, exception_on_overflow=False) 102 | if self.config.vad.enabled and self.vad.is_speech(frames, FRAME_RATE): 103 | if not in_speech: 104 | in_speech = True 105 | silence_frames = int(FRAME_RATE / CHUNK * self.config.vad.silence) 106 | self.logger.info('Voice activity started on site %s.', 107 | self.config.site) 108 | self.publish_vad_status_message(VAD_UP) 109 | self.publish_frames(frames) 110 | elif self.config.vad.enabled: 111 | if in_speech and silence_frames > 0: 112 | self.publish_frames(frames) 113 | silence_frames -= 1 114 | elif in_speech: 115 | in_speech = False 116 | self.logger.info('Voice activity stopped on site %s.', 117 | self.config.site) 118 | self.publish_vad_status_message(VAD_DOWN) 119 | else: 120 | self.publish_frames(frames) 121 | -------------------------------------------------------------------------------- /src/hermes_audio_server/config/mqtt.py: -------------------------------------------------------------------------------- 1 | """Classes for the configuration of hermes-audio-server.""" 2 | 3 | # Default values 4 | DEFAULT_HOST = 'localhost' 5 | DEFAULT_PORT = 1883 6 | 7 | # Keys in the JSON configuration file 8 | HOST = 'host' 9 | PORT = 'port' 10 | AUTH = 'authentication' 11 | USERNAME = 'username' 12 | PASSWORD = 'password' 13 | TLS = 'tls' 14 | CA_CERTS = 'ca_certificates' 15 | CLIENT_CERT = 'client_certificate' 16 | CLIENT_KEY = 'client_key' 17 | 18 | 19 | # TODO: Define __str__() for each class with explicit settings for debugging. 20 | class MQTTAuthConfig: 21 | """This class represents the authentication settings for a connection to an 22 | MQTT broker. 23 | 24 | Attributes: 25 | username (str): The username to authenticate to the MQTT broker. `None` 26 | if there's no authentication. 27 | password (str): The password to authenticate to the MQTT broker. Can be 28 | `None`. 29 | """ 30 | 31 | def __init__(self, username=None, password=None): 32 | """Initialize a :class:`.MQTTAuthConfig` object. 33 | 34 | Args: 35 | username (str, optional): The username to authenticate to the MQTT 36 | broker. `None` if there's no authentication. 37 | password (str, optional): The password to authenticate to the MQTT 38 | broker. Can be `None`. 39 | 40 | All arguments are optional. 41 | """ 42 | self.username = username 43 | self.password = password 44 | 45 | @classmethod 46 | def from_json(cls, json_object=None): 47 | """Initialize a :class:`.MQTTAuthConfig` object with settings from a 48 | JSON object. 49 | 50 | Args: 51 | json_object (optional): The JSON object with the MQTT 52 | authentication settings. Defaults to {}. 53 | 54 | Returns: 55 | :class:`.MQTTAuthConfig`: An object with the MQTT authentication 56 | settings. 57 | 58 | The JSON object should have the following format: 59 | 60 | { 61 | "username": "foobar", 62 | "password": "secretpassword" 63 | } 64 | """ 65 | if json_object is None: 66 | json_object = {} 67 | 68 | return cls(username=json_object.get(USERNAME), 69 | password=json_object.get(PASSWORD)) 70 | 71 | @property 72 | def enabled(self): 73 | """Check whether authentication is enabled. 74 | 75 | Returns: 76 | bool: True if the username is not `None`. 77 | """ 78 | return self.username is not None 79 | 80 | 81 | class MQTTTLSConfig: 82 | """This class represents the TLS settings for a connection to an MQTT 83 | broker. 84 | 85 | Attributes: 86 | enabled (bool): Whether or not TLS is enabled. 87 | ca_certs (str): Path to the Certificate Authority file. If `None`, the 88 | default certification authority of the system is used. 89 | client_key (str): Path to an PEM encoded private key file. If `None`, 90 | there will be no client authentication. 91 | client_cert (str): Path to a PEM encoded client certificate file. If 92 | `None`, there will be no client authentication. 93 | """ 94 | 95 | def __init__(self, enabled=False, ca_certs=None, client_key=None, 96 | client_cert=None): 97 | """Initialize a :class:`.MQTTTLSConfig` object. 98 | 99 | Args: 100 | enabled (bool, optional): Whether or not TLS is enabled. The 101 | default value is `False`. 102 | ca_certs (str, optional): Path to the Certificate Authority file. 103 | If `None`, the default certification authority of the system is 104 | used. 105 | client_key (str, optional): Path to a PEM encoded private key file. 106 | If `None`, there will be no client authentication. 107 | client_cert (str, optional): Path to a PEM encoded client 108 | certificate file. If `None`, there will be no client 109 | authentication. 110 | 111 | All arguments are optional. 112 | """ 113 | self.enabled = enabled 114 | self.ca_certs = ca_certs 115 | self.client_key = client_key 116 | self.client_cert = client_cert 117 | 118 | @classmethod 119 | def from_json(cls, json_object=None): 120 | """Initialize a :class:`.MQTTTLSConfig` object with settings from a 121 | JSON object. 122 | 123 | Args: 124 | json_object (optional): The JSON object with the MQTT TLS settings. 125 | Defaults to {}. 126 | 127 | Returns: 128 | :class:`.MQTTTLSConfig`: An object with the MQTT TLS settings. 129 | 130 | The JSON object should have the following format: 131 | 132 | { 133 | "ca_certificates": "", 134 | "client_certificate": "", 135 | "client_key": "" 136 | } 137 | """ 138 | if json_object is None: 139 | ret = cls(enabled=False) 140 | else: 141 | ret = cls(enabled=True, 142 | ca_certs=json_object.get(CA_CERTS), 143 | client_key=json_object.get(CLIENT_KEY), 144 | client_cert=json_object.get(CLIENT_CERT)) 145 | 146 | return ret 147 | 148 | 149 | class MQTTConfig: 150 | """This class represents the configuration for a connection to an 151 | MQTT broker. 152 | 153 | Attributes: 154 | host (str): The hostname or IP address of the MQTT broker. 155 | port (int): The port number of the MQTT broker. 156 | auth (:class:`.MQTTAuthConfig`, optional): The authentication 157 | settings (username and password) for the MQTT broker. 158 | tls (:class:`.MQTTTLSConfig`, optional): The TLS settings for the MQTT 159 | broker. 160 | """ 161 | 162 | def __init__(self, host=DEFAULT_HOST, port=DEFAULT_PORT, auth=None, 163 | tls=None): 164 | """Initialize a :class:`.MQTTConfig` object. 165 | 166 | Args: 167 | host (str, optional): The hostname or IP address of the MQTT 168 | broker. 169 | port (int, optional): The port number of the MQTT broker. 170 | auth (:class:`.MQTTAuthConfig`, optional): The authentication 171 | settings (username and password) for the MQTT broker. Defaults 172 | to a default :class:`.MQTTAuthConfig` object. 173 | tls (:class:`.MQTTTLSConfig`, optional): The TLS settings for the 174 | MQTT broker. Defaults to a default :class:`.MQTTTLSConfig` 175 | object. 176 | 177 | All arguments are optional. 178 | """ 179 | self.host = host 180 | self.port = port 181 | 182 | if auth is None: 183 | self.auth = MQTTAuthConfig() 184 | else: 185 | self.auth = auth 186 | 187 | if tls is None: 188 | self.tls = MQTTTLSConfig() 189 | else: 190 | self.tls = tls 191 | 192 | @classmethod 193 | def from_json(cls, json_object=None): 194 | """Initialize a :class:`.MQTTConfig` object with settings from a JSON 195 | object. 196 | 197 | Args: 198 | json_object (optional): The JSON object with the MQTT settings. 199 | Defaults to {}. 200 | 201 | Returns: 202 | :class:`.MQTTConfig`: An object with the MQTT settings. 203 | 204 | The JSON object should have the following format: 205 | 206 | { 207 | "host": "localhost", 208 | "port": 1883, 209 | "authentication": { 210 | "username": "foobar", 211 | "password": "secretpassword" 212 | }, 213 | "tls": { 214 | "ca_certificates": "", 215 | "client_certificate": "", 216 | "client_key": "" 217 | } 218 | } 219 | """ 220 | if json_object is None: 221 | json_object = {} 222 | 223 | return cls(host=json_object.get(HOST, DEFAULT_HOST), 224 | port=json_object.get(PORT, DEFAULT_PORT), 225 | auth=MQTTAuthConfig.from_json(json_object.get(AUTH)), 226 | tls=MQTTTLSConfig.from_json(json_object.get(TLS))) 227 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hermes Audio Server 2 | 3 | [![Build status](https://api.travis-ci.com/koenvervloesem/hermes-audio-server.svg?branch=master)](https://travis-ci.com/koenvervloesem/hermes-audio-server) [![Maintainability](https://api.codeclimate.com/v1/badges/9ae3a46a15a85c8b44f3/maintainability)](https://codeclimate.com/github/koenvervloesem/hermes-audio-server/maintainability) [![Code quality](https://api.codacy.com/project/badge/Grade/02647c1d9d214b8a97ed124ccf48839f)](https://www.codacy.com/app/koenvervloesem/hermes-audio-server) [![Python versions](https://img.shields.io/badge/python-3.5|3.6|3.7-blue.svg)](https://www.python.org) [![PyPI package version](https://img.shields.io/pypi/v/hermes-audio-server.svg)](https://pypi.python.org/pypi/hermes-audio-server) [![GitHub license](https://img.shields.io/github/license/koenvervloesem/hermes-audio-server.svg)](https://github.com/koenvervloesem/hermes-audio-server/blob/master/LICENSE) 4 | 5 | **Important information: I consider Hermes Audio Server deprecated now that Rhasspy has been modularized. I recommend to use [rhasspy-speakers-cli-hermes](https://github.com/rhasspy/rhasspy-speakers-cli-hermes) and [rhasspy-microphone-cli-hermes](https://github.com/rhasspy/rhasspy-microphone-cli-hermes) instead.** 6 | 7 | Hermes Audio server implements the audio server part of the [Hermes protocol](https://docs.snips.ai/reference/hermes) defined by [Snips](http://snips.ai). 8 | 9 | It's meant to be used with [Rhasspy](https://rhasspy.readthedocs.io), an offline, multilingual voice assistant toolkit that works with [Home Assistant](https://www.home-assistant.io) and is completely open source. 10 | 11 | With Hermes Audio Server, you can use the microphone and speaker of your computer (such as a Raspberry Pi) as remote audio input and output for a Rhasspy system. 12 | 13 | ## System requirements 14 | 15 | Hermes Audio Server requires Python 3. It has been tested on a Raspberry Pi running Raspbian 9.8 and an x86_64 laptop with Ubuntu 19.04, but in principle it should be cross-platform. Please [open an issue](https://github.com/koenvervloesem/hermes-audio-server/issues) on GitHub when you encounter problems or when the software exits with the message that your platform is not supported. 16 | 17 | ## Installation 18 | 19 | You can install Hermes Audio Server and its dependencies like this: 20 | 21 | ```shell 22 | sudo apt install portaudio19-dev 23 | sudo pip3 install hermes-audio-server 24 | ``` 25 | 26 | Note: this installs Hermes Audio Server globally. If you want to install Hermes Audio Server in a Python virtual environment, drop the `sudo`. 27 | 28 | ## Configuration 29 | 30 | Hermes Audio Server is configured in the JSON file `/etc/hermes-audio-server.json`, which has the following format: 31 | 32 | ```json 33 | { 34 | "site": "default", 35 | "mqtt": { 36 | "host": "localhost", 37 | "port": 1883, 38 | "authentication": { 39 | "username": "foobar", 40 | "password": "secretpassword" 41 | }, 42 | "tls": { 43 | "ca_certificates": "", 44 | "client_certificate": "", 45 | "client_key": "" 46 | } 47 | }, 48 | "vad": { 49 | "mode": 0, 50 | "silence": 2, 51 | "status_messages": true 52 | } 53 | } 54 | ``` 55 | 56 | Note that this supposes that you're using authentication and TLS for the connection to your MQTT broker and that this enables the experimental Voice Activity Detection (see below). 57 | 58 | All keys in the configuration file are optional. The default behaviour is to connect with `localhost:1883` without authentication and TLS and to use `default` as the site ID and disable Voice Activity Detection. A configuration file for this situation would like like this: 59 | 60 | ```json 61 | { 62 | "site": "default", 63 | "mqtt": { 64 | "host": "localhost", 65 | "port": 1883 66 | } 67 | } 68 | ``` 69 | 70 | Currently Hermes Audio Server uses the system's default microphone and speaker. In a future version this will be configurable. 71 | 72 | ### Voice Activity Detection 73 | Voice Activity Detection is an experimental feature in Hermes Audio Server, which is disabled by default. It is based on [py-webrtcvad](https://github.com/wiseman/py-webrtcvad) and tries to suppress sending audio frames when there's no speech. Note that the success of this attempt highly depends on your microphone, your environment and your configuration of the VAD feature. Voice Activity Detection in Hermes Audio Server should not be considered a privacy feature, but a feature to save network bandwidth. If you really don't want to send audio frames on your network except when giving voice commands, you should run a wake word service on your device and only then start streaming audio to your Rhasspy server until the end of the command. 74 | 75 | If the `vad` key is not specified in the configuration file, Voice Activity Detection is not enabled and all recorded audio frames are streamed continuously on the network. If you don't want this, specify the `vad` key to only stream audio when voice activity is detected. You can configure the VAD feature with the following subkeys: 76 | 77 | * `mode`: This should be an integer between 0 and 3. 0 is the least aggressive about filtering out non-speech, 3 is the most aggressive. Defaults to 0. 78 | * `silence`: This defines how much silence (no speech detected) in seconds has to go by before Hermes Audio Recorder considers it the end of a voice message. Defaults to 2. Make sure that this value is higher than or equal to `min_sec` [in the configuration of WebRTCVAD](https://rhasspy.readthedocs.io/en/latest/command-listener/#webrtcvad) for the command listener of Rhasspy, otherwise the audio stream for the command listener could be aborted too soon. 79 | * `status_messages`: This is a boolean: `true` or `false`. Specifies whether or not Hermes Audio Recorder sends messages on MQTT when it detects the start or end of a voice message. Defaults to `false`. This is useful for debugging, when you want to find the right values for `mode` and `silence`. 80 | 81 | ## Running Hermes Audio Server 82 | 83 | Hermes Audio Server consists of two commands: Hermes Audio Player that receives WAV files on MQTT and plays them on the speaker, and Hermes Audio Recorder that records WAV files from the microphone and sends them as audio frames on MQTT. 84 | 85 | You can run the Hermes Audio Player like this: 86 | 87 | ```shell 88 | hermes-audio-player 89 | ``` 90 | 91 | You can run the Hermes Audio Recorder like this: 92 | 93 | ```shell 94 | hermes-audio-recorder 95 | ``` 96 | 97 | You can run both, or only one of them if you only want to use the speaker or microphone. 98 | 99 | ## Usage 100 | 101 | Both commands know the `--help` option that gives you more information about the recognized options. For instance: 102 | 103 | ```shell 104 | usage: hermes-audio-player [-h] [-v] [-V] [-c CONFIG] 105 | 106 | hermes-audio-player is an audio server implementing the playback part of 107 | the Hermes protocol. 108 | 109 | optional arguments: 110 | -h, --help show this help message and exit 111 | -v, --verbose use verbose output 112 | -V, --version print version information and exit 113 | -c CONFIG, --config CONFIG 114 | configuration file [default: /etc/hermes-audio- 115 | server.json] 116 | -d, --daemon run as daemon 117 | ``` 118 | 119 | ## Running as a service 120 | After you have verified that Hermes Audio Server works by running the player and recorder manually, possibly in verbose mode, it's better to run both commands as services. 121 | 122 | It's recommended to run the Hermes Audio Server commands as a system user. Create this user without a login shell and without creating a home directory for the user: 123 | 124 | ```shell 125 | sudo useradd -r -s /bin/false hermes-audio-server 126 | ``` 127 | 128 | This user also needs access to your audio devices, so add them to the `audio` group: 129 | 130 | ```shell 131 | sudo usermod -a -G audio hermes-audio-server 132 | ``` 133 | 134 | Then create [systemd service files](https://github.com/koenvervloesem/hermes-audio-server/tree/master/etc/systemd/system) for the `hermes-audio-player` and `hermes-audio-recorder` commands and copy them to `/etc/systemd/system`. 135 | 136 | If you want to run the commands as another user, then cange the lines with `User` and `Group`. 137 | 138 | After this, you can start the player and recorder as services: 139 | 140 | ```shell 141 | sudo systemctl start hermes-audio-player.service 142 | sudo systemctl start hermes-audio-recorder.service 143 | ``` 144 | 145 | If you want them to start automatically after booting the computer, enable the services with: 146 | 147 | ```shell 148 | sudo systemctl enable hermes-audio-player.service 149 | sudo systemctl enable hermes-audio-recorder.service 150 | ``` 151 | 152 | ## Known issues / TODO list 153 | 154 | * You can't choose the audio devices yet: the commands use the system's default microphone and speaker. 155 | * This project is really a minimal implementation of the audio server part of the Hermes protocol, meant to be used with Rhasspy. It's not a drop-in replacement for snips-audio-server, as it lacks [additional metadata](https://github.com/snipsco/snips-issues/issues/144#issuecomment-494054082) in the WAV frames. 156 | 157 | ## Changelog 158 | 159 | * 0.2.0 (2019-06-04): Added logging and a daemon mode. 160 | * 0.1.1 (2019-05-30): Made the audio player more robust when receiving an incorrect WAV file. 161 | * 0.1.0 (2019-05-16): Added Voice Activity Detection option. 162 | * 0.0.2 (2019-05-11): First public version. 163 | 164 | ## Other interesting projects 165 | 166 | If you find Hermes Audio Server interesting, have a look at the following projects too: 167 | 168 | * [Rhasspy](https://rhasspy.readthedocs.io): An offline, multilingual voice assistant toolkit that works with [Home Assistant](https://www.home-assistant.io) and is completely open source. 169 | * [Snips Led Control](https://github.com/Psychokiller1888/snipsLedControl): An easy way to control the leds of your Snips-compatible device, with led patterns when the hotword is detected, the device is listening, speaking, idle, ... 170 | * [Matrix-Voice-ESP32-MQTT-Audio-Streamer](https://github.com/Romkabouter/Matrix-Voice-ESP32-MQTT-Audio-Streamer): The equivalent of Hermes Audio Server for a Matrix Voice ESP32 board, including LED control and OTA updates. 171 | * [OpenSnips](https://github.com/syntithenai/opensnips): A collection of open source projects related to the Snips voice platform. 172 | 173 | ## License 174 | 175 | This project is provided by [Koen Vervloesem](mailto:koen@vervloesem.eu) as open source software with the MIT license. See the LICENSE file for more information. 176 | --------------------------------------------------------------------------------