37 Commits

Author SHA1 Message Date
b4814d431f feat: ignore own speech
When we detect that Pepper is talking we stop sending audio.

ref: N25B-214
2025-10-27 13:08:57 +01:00
Twirre Meulenbelt
230ab5d5cc test: add case for microphone failure
When the microphone fails, it will raise an IOError during the `read`. This is simulated with a new test.

ref: N25B-119
2025-10-22 15:38:30 +02:00
Twirre Meulenbelt
0499cd8a24 feat: send audio
AudioSender runs in a separate thread to send audio from the microphone.

ref: N25B-119
2025-10-22 15:10:27 +02:00
Twirre Meulenbelt
f8db719bfa test: unit test mock PyAudio, integration test use real
Make unit tests use a mock version of PyAudio, while making integration tests using the real version. If no real microphone is available, these integration tests are skipped.

ref: N25B-119
2025-10-22 13:27:35 +02:00
Twirre Meulenbelt
1e3e077029 fix: disallow selecting non-microphone audio device
Previously any audio device was allowed to be selected as microphone. Now, only ones with at least one input channel can be selected.

ref: N25B-119
2025-10-22 13:24:46 +02:00
Twirre Meulenbelt
0f60f67ab9 feat: add microphone selection utils
Providing two functions, one to choose the default microphone, the other to choose a microphone interactively. With tests.

ref: N25B-119
2025-10-22 11:44:51 +02:00
Pim Hutting
4da83a0a7e Merge branch 'feat/actuation-receiver' into 'dev'
Implement negotiation and actuation endpoints

See merge request ics/sp/2025/n25b/pepperplus-ri!5
2025-10-22 08:49:03 +00:00
Twirre Meulenbelt
9d728f78fe Merge remote-tracking branch 'origin/dev' into feat/actuation-receiver
# Conflicts:
#	README.md
2025-10-21 13:56:57 +02:00
Twirre Meulenbelt
5631a55697 test: convert to pytest
Instead of built-in `unittest`, now use `pytest`. Find versions that work, convert tests.

ref: N25B-168
2025-10-21 13:55:06 +02:00
2584433
5dce0e3438 Merge branch 'fix/githook-mac' into 'dev'
fix: fixed githooks

See merge request ics/sp/2025/n25b/pepperplus-ri!7
2025-10-17 14:27:58 +00:00
2584433
670d1f0a6a fix: fixed githooks 2025-10-17 14:27:58 +00:00
Twirre Meulenbelt
45be0366ba style: correct and clarify docs and comments
ref: N25B-168
2025-10-16 22:03:50 +02:00
Twirre Meulenbelt
4c3aa3a911 feat: adapt actuation receiver to state's qi_session
Makes actuation tests pass. In main, the timing of the socket no longer contains the time to receive and send data, but only the processing time of the message handler.

ref: N25B-168
2025-10-16 21:46:46 +02:00
Twirre Meulenbelt
56c804b7eb test: add unit tests for main and actuation receivers
Exhaustive test cases for both classes, with 100% coverage. Adds `mock` dependency. Tests for actuation receiver do not yet pass.

ref: N25B-168
2025-10-16 21:43:24 +02:00
Twirre Meulenbelt
55483808ff fix: use qi session from state in actuation receiver
ref: N25B-168
2025-10-16 18:09:01 +02:00
Twirre Meulenbelt
c10fbc7c90 fix: use different port, fix endpoint name matching
ref: N25B-168
2025-10-16 17:37:01 +02:00
Twirre Meulenbelt
23c3379bfb refactor: use new port negotiation style
As changed in the API document, this now uses the new port negotiation style.

ref: N25B-168
2025-10-16 17:22:04 +02:00
Twirre Meulenbelt
e12d88726d Merge remote-tracking branch 'origin/dev' into feat/actuation-receiver
# Conflicts:
#	src/robot_interface/endpoints/socket_base.py
2025-10-16 17:01:16 +02:00
Twirre
785756683e Merge branch 'feat/ri-receive-video' into 'dev'
Implemented receiving video in RI from robot

See merge request ics/sp/2025/n25b/pepperplus-ri!4
2025-10-16 14:41:02 +00:00
Luijkx,S.O.H. (Storm)
0b55d5c221 style: fixed docstrings
close: N25B-171
2025-10-16 14:06:31 +00:00
Twirre Meulenbelt
308a19bff2 fix: correct negotiate endpoint name
Was previously "negotiation/", but the API document described it as "negotiate/". It is now "negotiate/" in the implementation as well.

ref: N25B-168
2025-10-16 15:02:01 +02:00
Storm
0c5b47ae16 refactor: removed hardcoded IP and port and moved video functions from main to the VideoSender class
ref: N25B-171
2025-10-16 14:57:53 +02:00
Storm
a408fafc7c docs: minor type correction in documentation start_video_rcv and video_rcv_loop 2025-10-15 17:55:29 +02:00
Storm
e3663e1327 feat: implemented receiving video image from robot
The functionality is implemented in main.py in the functions start_video_rcv and video_rcv_loop.

close: N25B-171
2025-10-15 17:52:59 +02:00
Twirre Meulenbelt
df985a8cbc fix: log speech commands even when Pepper SDK is not connected
Previously, the `_handle_speech` function had an early return when no Pepper session was available, causing incoming messages not to get logged. Now messages are logged even when there is no session with the Pepper SDK.

ref: N25B-168
2025-10-15 14:58:31 +02:00
Twirre Meulenbelt
ff6abbfea1 feat: implement actuation receiver
The ActuationReceiver connects to the Pepper robot using the Qi library. The endpoint is automatically negotiated.

ref: N25B-168
2025-10-13 22:08:43 +02:00
Twirre Meulenbelt
c6916470e9 feat: implement negotiation
By implementing SocketBase and adding the socket to the state, the negotiation will automatically give the right endpoints.

ref: N25B-168
2025-10-13 22:06:27 +02:00
Luijkx,S.O.H. (Storm)
828871a2ad Merge branch 'feat/comm-standardization' into 'dev'
Implementation of standardized CB<->RI communication API

See merge request ics/sp/2025/n25b/pepperplus-ri!3
2025-10-09 16:01:30 +00:00
Twirre Meulenbelt
7cfa6b44e8 chore: add usage instructions
Describes how to run the main program.

ref: N25B-168
2025-10-09 17:36:25 +02:00
Twirre Meulenbelt
c95d4abd77 chore: re-add the installation instructions
These installation instructions come from the feat/cb2ri-communication branch which has been replaced by this branch.

ref: N25B-168
2025-10-09 17:28:03 +02:00
Twirre Meulenbelt
e9c6b918e0 refactor: rename EndpointBase to SocketBase
Because 'endpoint' is also used in the messages, the name 'socket' is more descriptive.

ref: N25B-168
2025-10-09 16:24:31 +02:00
Twirre Meulenbelt
23805812d5 feat: abstract base classes for endpoints
Introduces EndpointBase and ReceiverBase abstract base classes. Implements a ReceiverBase with the MainReceiver.

ref: N25B-168
2025-10-09 16:04:18 +02:00
Twirre Meulenbelt
c4530f0c3a feat: basic implementation of standardized CB2RI communication API
Based on the N25B-A-14 article, this is a stub implementation of the RI2CB communication API. It implements the ping endpoint and provides a stub for the negotiation endpoint.

ref: N25B-168
2025-10-09 13:54:34 +02:00
2584433
bc26c76437 Merge branch 'chore/correct-branch-name-regex' into 'dev'
Correct branch hook regex

See merge request ics/sp/2025/n25b/pepperplus-ri!2
2025-10-08 15:06:00 +00:00
Twirre Meulenbelt
99776480e8 chore: correct commit hook regex
Previously all branch names had to have two dashes. Now it can have one to six words.

ref: N25B-89
2025-10-08 16:25:51 +02:00
2584433
b7c6269435 Merge branch 'feat/git-automatic-hooks' into 'dev'
Added githooks

See merge request ics/sp/2025/n25b/pepperplus-ri!1
2025-10-07 14:55:04 +00:00
2584433
eb091968a6 Added githooks 2025-10-07 14:55:04 +00:00
32 changed files with 1400 additions and 186 deletions

16
.githooks/commit-msg Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
commit_msg_file=$1
commit_msg=$(cat "$commit_msg_file")
if echo "$commit_msg" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert): .+"; then
if echo "$commit_msg" | grep -Eq "^(ref|close):\sN25B-.+"; then
exit 0
else
echo "❌ Commit message invalid! Must end with [ref/close]: N25B-000"
exit 1
fi
else
echo "❌ Commit message invalid! Must start with <type>: <description>"
exit 1
fi

17
.githooks/pre-commit Normal file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
# Get current branch
branch=$(git rev-parse --abbrev-ref HEAD)
if echo "$branch" | grep -Eq "(dev|main)"; then
echo 0
fi
# allowed pattern <type/>
if echo "$branch" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert)\/\w+(-\w+){0,5}$"; then
exit 0
else
echo "❌ Invalid branch name: $branch"
echo "Branch must be named <type>/<description-of-branch> (must have one to six words separated by a dash)"
exit 1
fi

View File

@@ -0,0 +1,9 @@
#!/bin/sh
echo "#<type>: <description>
#[optional body]
#[optional footer(s)]
#[ref/close]: <issue identifier>" > $1

219
.gitignore vendored Normal file
View File

@@ -0,0 +1,219 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
# Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
# poetry.lock
# poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
# pdm.lock
# pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
# pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# Redis
*.rdb
*.aof
*.pid
# RabbitMQ
mnesia/
rabbitmq/
rabbitmq-data/
# ActiveMQ
activemq-data/
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml
.DS_Store

View File

@@ -1,13 +1,23 @@
## Development environment
# PepperPlus-RI
The robot interface is a high-level API for controlling the robot. It implements the API as designed: https://utrechtuniversity.youtrack.cloud/articles/N25B-A-14/RI-CB-Communication.
This is an implementation for the Pepper robot, using the Pepper SDK and Python 2.7 as required by the SDK.
## Installation
### Linux (or WSL)
Start off by installing [Pyenv](https://github.com/pyenv/pyenv?tab=readme-ov-file#installation) and walk through the steps outlined there (be sure to also add it to PATH). Also install the [Python build requirements](https://github.com/pyenv/pyenv/wiki#suggested-build-environment). Afterwards, install Python 2.7 and activate it for your current shell:
Start off by installing [Pyenv](https://github.com/pyenv/pyenv?tab=readme-ov-file#installation) and walk through the steps outlined there (be sure to also add it to PATH). Also install the [Python build requirements](https://github.com/pyenv/pyenv/wiki#suggested-build-environment). Afterwards, install Python 2.7 and activate it for your current shell:
```bash
pyenv install 2.7
pyenv shell 2.7
```
You can check that this worked by typing
You can check that this worked by typing
```bash
python -V
@@ -24,13 +34,7 @@ python -m virtualenv .venv
source .venv/bin/activate
```
To be able to install the PyAudio Python package, you'll need to have the `portaudio` system package installed. On Debian or Ubuntu:
```shell
sudo apt install portaudio19-dev
```
Then you can install the required packages with
Install the required packages with
```bash
pip install -r requirements.txt
@@ -64,21 +68,67 @@ python -c "import qi; print(qi)"
You should now be able to run this project.
### MacOS
### macOS
On ARM CPU's, pyenv doesn't want to install Python 2. You can download and install it from [the Python website](https://www.python.org/downloads/release/python-2718/).
Similar to Linux, but don't bother installing `pyenv` as it won't be able to install Python 2 on Apple Silicon. Instead, install Python 2.7.18 from the [Python website](https://www.python.org/downloads/release/python-2718/).
Create a virtual environment as described in the Linux section.
Create the virtual environment as described above in the Linux section. Stop at the point where it shows you how to download the NaoQi SDK. Instead, use:
Then build `portaudio` for x86_64 CPU's.
Then follow the remaining installation instructions in the Linux section.
## Running
Assuming you have the virtual environment activated (`source .venv/bin/activate` on Linux) and that you have a virtual robot running on localhost you should be able to run this project by typing
```bash
python main.py --qi-url tcp://localhost:<port>
```shell
curl -OL https://community-static.aldebaran.com/resources/2.5.10/Python%20SDK/pynaoqi-python2.7-2.5.7.1-mac64.tar.gz
```
where `<port>` is the port on which your robot is running.
Then resume the steps from above.
## Usage
On Linux and macOS:
```shell
PYTHONPATH=src python -m robot_interface.main
```
On Windows:
```shell
$env:PYTHONPATH="src"; python -m robot_interface.main
```
With both, if you want to connect to the actual robot (or simulator), pass the `--qi-url` argument.
## Testing
To run the unit tests, on Linux and macOS:
```shell
PYTHONPATH=src pytest test/
```
On Windows:
```shell
$env:PYTHONPATH="src"; pytest test/
```
### Coverage
For coverage, add `--cov=robot_interface` as an argument to `pytest`.
## GitHooks
To activate automatic commits/branch name checks run:
```shell
git config --local core.hooksPath .githooks
```
If your commit fails its either:
branch name != <type>/description-of-branch ,
commit name != <type>: description of the commit.
<ref>: N25B-Num's

69
main.py
View File

@@ -1,69 +0,0 @@
import sys
import logging
logging.
import zmq
from src.audio_streaming import AudioStreaming
from state import state
def say(session, message):
tts = session.service("ALTextToSpeech")
tts.say(message)
def listen_for_messages(session):
context = zmq.Context()
socket = context.socket(zmq.SUB)
socket.connect("tcp://localhost:5556")
socket.setsockopt_string(zmq.SUBSCRIBE, u"") # u because Python 2 shenanigans
poller = zmq.Poller()
poller.register(socket, zmq.POLLIN)
logging.info("Listening for messages")
while not state.exit_event.is_set():
if not poller.poll(200): continue # At most 200 ms delay after CTRL+C
# We now know there's a message waiting for us
message = socket.recv_string()
logging.debug("Received message: {}".format(message))
if session: say(session, message)
def get_session():
if "--qi-url" not in sys.argv:
logging.info("No Qi URL argument given. Running in stand-alone mode.")
return None
try:
import qi
except ImportError:
logging.info("Unable to import qi. Running in stand-alone mode.")
return None
try:
app = qi.Application()
app.start()
return app.session
except RuntimeError:
logging.info("Unable to connect to the robot. Running in stand-alone mode.")
return None
def main():
session = get_session()
audio_streamer = AudioStreaming()
audio_streamer.run()
listen_for_messages(session) # Runs indefinitely, until CTRL+C
if __name__ == "__main__":
try:
state.initialize()
main()
finally:
state.deinitialize()

View File

@@ -1,2 +1,5 @@
pyzmq<16
pyaudio<=0.2.11
pyaudio<=0.2.11
pytest<5
pytest-mock<3.0.0
pytest-cov<3.0.0

View File

@@ -1,93 +0,0 @@
import threading
import pyaudio
import zmq
from state import state
def choose_mic_interactive(audio):
"""Choose a microphone to use. The `audio` parameter is an instance of PyAudio. Returns a dict."""
device_count = audio.get_device_count()
print("Found {} audio devices:".format(device_count))
for i in range(device_count):
print("- {}: {}".format(i, audio.get_device_info_by_index(i)["name"]))
microphone_index = None
while microphone_index is None:
chosen = input("Which device would you like to use?\n> ")
try:
chosen = int(chosen)
if chosen < 0 or chosen > device_count: raise ValueError()
microphone_index = chosen
except ValueError:
print("Please enter a number between 0 and {}".format(device_count))
chosen_microphone = audio.get_device_info_by_index(microphone_index)
print("Chose microphone \"{}\"".format(chosen_microphone["name"]))
return chosen_microphone
def choose_mic_default(audio):
"""Choose a microphone to use based on defaults. The `audio` parameter is a PyAudio. Returns a dict."""
default_device = audio.get_default_input_device_info()
return default_device
class AudioStreaming:
def __init__(self, port=5557):
self.port = port
self.audio = pyaudio.PyAudio()
self.microphone = choose_mic_default(self.audio)
self.thread = None
def run(self):
self.thread = threading.Thread(target=self._stream)
self.thread.start()
def wait_until_done(self):
if not self.thread: return
self.thread.join()
def _stream(self):
context = zmq.Context()
socket = context.socket(zmq.PUB)
socket.bind("tcp://*:{}".format(self.port))
chunk = 512 # 320 at 16000 Hz is 20ms, 512 is required for Silero-VAD
stream = self.audio.open(
format=pyaudio.paFloat32,
channels=1,
rate=16000,
input=True,
input_device_index=self.microphone["index"],
frames_per_buffer=chunk,
)
try:
while not state.exit_event.is_set():
data = stream.read(chunk)
socket.send(data)
finally:
stream.stop_stream()
stream.close()
if __name__ == "__main__":
state.initialize()
try:
audio = AudioStreaming()
print("Starting audio streaming...")
audio.run()
import time
end = time.time() + 10
while not state.exit_event.is_set() and time.time() < end:
print "\rExiting in {:.2f} seconds".format(end - time.time()),
time.sleep(0.05)
state.exit_event.set()
audio.wait_until_done()
finally:
state.deinitialize()

View File

@@ -0,0 +1,66 @@
import logging
import zmq
from robot_interface.endpoints.receiver_base import ReceiverBase
from robot_interface.state import state
class ActuationReceiver(ReceiverBase):
def __init__(self, zmq_context, port=5557):
"""
The actuation receiver endpoint, responsible for handling speech and gesture requests.
:param zmq_context: The ZeroMQ context to use.
:type zmq_context: zmq.Context
:param port: The port to use.
:type port: int
"""
super(ActuationReceiver, self).__init__("actuation")
self.create_socket(zmq_context, zmq.SUB, port)
self.socket.setsockopt_string(zmq.SUBSCRIBE, u"") # Causes block if given in options
self._tts_service = None
self._al_memory = None
def _handle_speech(self, message):
text = message.get("data")
if not text:
logging.warn("Received message to speak, but it lacks data.")
return
if not isinstance(text, (str, unicode)):
logging.warn("Received message to speak but it is not a string.")
return
logging.debug("Received message to speak: {}".format(text))
if not state.qi_session: return
# If state has a qi_session, we know that we can import qi
import qi # Takes a while only the first time it's imported
if not self._tts_service:
self._tts_service = state.qi_session.service("ALTextToSpeech")
if not self._al_memory:
self._al_memory = state.qi_session.service("ALMemory")
# Subscribe to speech end event
self.status_subscriber = self._al_memory.subscriber("ALTextToSpeech/Status") # self because garbage collect
self.status_subscriber.signal.connect(self._on_status_changed)
# Returns instantly. Messages received while speaking will be queued.
qi.async(self._tts_service.say, text)
@staticmethod
def _on_status_changed(value): # value will contain either 'enqueued', 'started' or 'done' depending on the status
"""Callback function for when the speaking status changes. Will change the is_speaking value of the state."""
if "started" in value:
logging.debug("Started speaking.")
state.is_speaking = True
if "done" in value:
logging.debug("Done speaking.")
state.is_speaking = False
def handle_message(self, message):
if message["endpoint"] == "actuate/speech":
self._handle_speech(message)

View File

@@ -0,0 +1,73 @@
from __future__ import unicode_literals # So that `logging` can use Unicode characters in names
import threading
import logging
import pyaudio
import zmq
from robot_interface.endpoints.socket_base import SocketBase
from robot_interface.state import state
from robot_interface.utils.microphone import choose_mic_default
logger = logging.getLogger(__name__)
class AudioSender(SocketBase):
def __init__(self, zmq_context, port=5558):
super(AudioSender, self).__init__(str("audio")) # Convert future's unicode_literal to str
self.create_socket(zmq_context, zmq.PUB, port)
self.audio = pyaudio.PyAudio()
self.microphone = choose_mic_default(self.audio)
self.thread = None
def start(self):
"""
Start sending audio in a different thread.
"""
if not self.microphone:
logger.info("Not listening: no microphone available.")
return
logger.info("Listening with microphone \"{}\".".format(self.microphone["name"]))
self.thread = threading.Thread(target=self._stream)
self.thread.start()
def wait_until_done(self):
"""
Wait until the audio thread is done. Will only be done if `state.exit_event` is set, so
make sure to set that before calling this method or it will block.
"""
if not self.thread: return
self.thread.join()
self.thread = None
def _stream(self):
chunk = 512 # 320 at 16000 Hz is 20ms, 512 is required for Silero-VAD
# Docs say this only raises an error if neither `input` nor `output` is True
stream = self.audio.open(
format=pyaudio.paFloat32,
channels=1,
rate=16000,
input=True,
input_device_index=self.microphone["index"],
frames_per_buffer=chunk,
)
try:
while not state.exit_event.is_set():
# Don't send audio if Pepper is speaking
if state.is_speaking:
if stream.is_active(): stream.stop_stream()
continue
if stream.is_stopped(): stream.start_stream()
data = stream.read(chunk)
self.socket.send(data)
except IOError as e:
logger.error("Stopped listening: failed to get audio from microphone.", exc_info=e)
finally:
stream.stop_stream()
stream.close()

View File

@@ -0,0 +1,56 @@
import zmq
from robot_interface.endpoints.receiver_base import ReceiverBase
from robot_interface.state import state
class MainReceiver(ReceiverBase):
def __init__(self, zmq_context, port=5555):
"""
The main receiver endpoint, responsible for handling ping and negotiation requests.
:param zmq_context: The ZeroMQ context to use.
:type zmq_context: zmq.Context
:param port: The port to use.
:type port: int
"""
super(MainReceiver, self).__init__("main")
self.create_socket(zmq_context, zmq.REP, port, bind=False)
@staticmethod
def _handle_ping(message):
"""A simple ping endpoint. Returns the provided data."""
return {"endpoint": "ping", "data": message.get("data")}
@staticmethod
def _handle_port_negotiation(message):
endpoints = [socket.endpoint_description() for socket in state.sockets]
return {"endpoint": "negotiate/ports", "data": endpoints}
@staticmethod
def _handle_negotiation(message):
"""
Handle a negotiation request. Will respond with ports that can be used to connect to the robot.
:param message: The negotiation request message.
:type message: dict
:return: A response dictionary with a 'ports' key containing a list of ports and their function.
:rtype: dict[str, list[dict]]
"""
# In the future, the sender could send information like the robot's IP address, etc.
if message["endpoint"] == "negotiate/ports":
return MainReceiver._handle_port_negotiation(message)
return {"endpoint": "negotiate/error", "data": "The requested endpoint is not implemented."}
def handle_message(self, message):
if message["endpoint"] == "ping":
return self._handle_ping(message)
elif message["endpoint"].startswith("negotiate"):
return self._handle_negotiation(message)
return {"endpoint": "error", "data": "The requested endpoint is not supported."}

View File

@@ -0,0 +1,21 @@
from abc import ABCMeta, abstractmethod
from robot_interface.endpoints.socket_base import SocketBase
class ReceiverBase(SocketBase, object):
"""Associated with a ZeroMQ socket."""
__metaclass__ = ABCMeta
@abstractmethod
def handle_message(self, message):
"""
Handle a message with the receiver.
:param message: The message to handle, must contain properties "endpoint" and "data".
:type message: dict
:return: A response message or None if this type of receiver doesn't publish.
:rtype: dict | None
"""
raise NotImplementedError()

View File

@@ -0,0 +1,72 @@
from abc import ABCMeta
import zmq
class SocketBase(object):
__metaclass__ = ABCMeta
name = None
socket = None
def __init__(self, identifier):
"""
:param identifier: The identifier of the endpoint.
:type identifier: str
"""
self.identifier = identifier
self.port = None # Set later by `create_socket`
self.socket = None # Set later by `create_socket`
self.bound = None # Set later by `create_socket`
def create_socket(self, zmq_context, socket_type, port, options=[], bind=True):
"""
Create a ZeroMQ socket.
:param zmq_context: The ZeroMQ context to use.
:type zmq_context: zmq.Context
:param socket_type: The type of socket to create. Use zmq constants, e.g. zmq.SUB or zmq.REP.
:type socket_type: int
:param port: The port to use.
:type port: int
:param options: A list of options to be set on the socket. The list contains tuples where the first element contains the option
and the second the value, for example (zmq.CONFLATE, 1).
:type options: list[tuple[int, int]]
:param bind: Whether to bind the socket or connect to it.
:type bind: bool
"""
self.port = port
self.socket = zmq_context.socket(socket_type)
for option, arg in options:
self.socket.setsockopt(option,arg)
self.bound = bind
if bind:
self.socket.bind("tcp://*:{}".format(port))
else:
self.socket.connect("tcp://localhost:{}".format(port))
def close(self):
"""Close the ZeroMQ socket."""
if not self.socket: return
self.socket.close()
self.socket = None
def endpoint_description(self):
"""
Description of the endpoint. Used for negotiation.
:return: A dictionary with the following keys: id, port, bind. See API specification at:
https://utrechtuniversity.youtrack.cloud/articles/N25B-A-14/RI-CB-Communication#negotiation
:rtype: dict
"""
return {
"id": self.identifier,
"port": self.port,
"bind": not self.bound
}

View File

@@ -0,0 +1,49 @@
import zmq
import threading
import qi
import logging
from robot_interface.endpoints.socket_base import SocketBase
from robot_interface.state import state
class VideoSender(SocketBase):
def __init__(self, zmq_context, port=5556):
super(VideoSender, self).__init__("video")
self.create_socket(zmq_context, zmq.PUB, port, [(zmq.CONFLATE,1)])
def start_video_rcv(self):
"""
Prepares arguments for retrieving video images from Pepper and starts video loop on a separate thread.
"""
if not state.qi_session:
logging.info("No Qi session available. Not starting video loop.")
return
video = state.qi_session.service("ALVideoDevice")
camera_index = 0
kQVGA = 2
kRGB = 11
FPS = 15
vid_stream_name = video.subscribeCamera("Pepper Video", camera_index, kQVGA, kRGB, FPS)
thread = threading.Thread(target=self.video_rcv_loop, args=(video, vid_stream_name))
thread.start()
def video_rcv_loop(self, vid_service, vid_stream_name):
"""
The main loop of retrieving video images from the robot.
:param vid_service: The video service object that the active Qi session is connected to.
:type vid_service: Object (Qi service object)
:param vid_stream_name: The name of a camera subscription on the video service object vid_service
:type vid_stream_name: String
"""
while not state.exit_event.is_set():
try:
img = vid_service.getImageRemote(vid_stream_name)
#Possibly limit images sent if queuing issues arise
self.socket.send(img[6])
except:
logging.warn("Failed to retrieve video image from robot.")

View File

@@ -0,0 +1,95 @@
import logging
from robot_interface.endpoints.audio_sender import AudioSender
logging.basicConfig(level=logging.DEBUG)
import zmq
from robot_interface.endpoints.actuation_receiver import ActuationReceiver
from robot_interface.endpoints.main_receiver import MainReceiver
from robot_interface.endpoints.video_sender import VideoSender
from robot_interface.state import state
from robot_interface.utils.timeblock import TimeBlock
def main_loop(context):
"""
Run the main loop, handling all incoming requests like pings, negotiation, actuation, etc.
:param context: The ZeroMQ context to use.
:type context: zmq.Context
"""
# When creating sockets, remember to add them to the `sockets` list of the state to ensure they're deinitialized
main_receiver = MainReceiver(context)
state.sockets.append(main_receiver)
actuation_receiver = ActuationReceiver(context)
state.sockets.append(actuation_receiver)
video_sender = VideoSender(context)
state.sockets.append(video_sender)
audio_sender = AudioSender(context)
state.sockets.append(audio_sender)
video_sender.start_video_rcv()
audio_sender.start()
# Sockets that can run on the main thread. These sockets' endpoints should not block for long (say 50 ms at most).
receivers = [main_receiver, actuation_receiver]
poller = zmq.Poller()
for receiver in receivers:
poller.register(receiver.socket, zmq.POLLIN)
logging.debug("Starting main loop.")
import schedule
test_speaking_message = {"data": "Hi, my name is Pepper, and this is quite a long message."}
def test_speak():
logging.debug("Testing speech.")
actuation_receiver._handle_speech(test_speaking_message)
schedule.every(10).seconds.do(test_speak)
while True:
if state.exit_event.is_set(): break
schedule.run_pending()
socks = dict(poller.poll(100))
for receiver in receivers:
if receiver.socket not in socks: continue
message = receiver.socket.recv_json()
if not isinstance(message, dict) or "endpoint" not in message or "data" not in message:
logging.error("Received message of unexpected format: {}".format(message))
continue
def overtime_callback(time_ms):
logging.warn("Endpoint \"%s\" took too long (%.2f ms) on the main thread.",
message["endpoint"], time_ms)
with TimeBlock(overtime_callback, 50):
response = receiver.handle_message(message)
if receiver.socket.getsockopt(zmq.TYPE) == zmq.REP:
receiver.socket.send_json(response)
def main():
context = zmq.Context()
state.initialize()
try:
main_loop(context)
except KeyboardInterrupt:
logging.info("User interrupted.")
finally:
state.deinitialize()
context.term()
if __name__ == "__main__":
main()

View File

@@ -2,6 +2,8 @@ import logging
import signal
import threading
from robot_interface.utils.qi_utils import get_qi_session
class State(object):
"""
@@ -14,6 +16,9 @@ class State(object):
def __init__(self):
self.is_initialized = False
self.exit_event = None
self.sockets = [] # type: List[SocketBase]
self.qi_session = None # type: None | ssl.SSLSession
self.is_speaking = False # type: Boolean
def initialize(self):
if self.is_initialized:
@@ -27,10 +32,16 @@ class State(object):
signal.signal(signal.SIGINT, handle_exit)
signal.signal(signal.SIGTERM, handle_exit)
self.qi_session = get_qi_session()
self.is_initialized = True
def deinitialize(self):
if not self.is_initialized: return
for socket in self.sockets:
socket.close()
self.is_initialized = False
def __getattribute__(self, name):

View File

View File

@@ -0,0 +1,69 @@
from __future__ import unicode_literals # So that `print` can print Unicode characters in names
import logging
logger = logging.getLogger(__name__)
def get_microphones(audio):
"""
Get audio devices which have input channels.
:param audio: An instance of PyAudio to use.
:type audio: pyaudio.PyAudio
:return: An interator of PaAudio dicts containing information about the microphone devices.
:rtype: Iterator[dict]
"""
for i in range(audio.get_device_count()):
device = audio.get_device_info_by_index(i)
if device["maxInputChannels"] > 0:
yield device
def choose_mic_interactive(audio):
"""
Choose a microphone to use, interactively in the CLI.
:param audio: An instance of PyAudio to use.
:type audio: pyaudio.PyAudio
:return: A dictionary from PyAudio containing information about the microphone to use, or None
if there is no microphone.
:rtype: dict | None
"""
microphones = list(get_microphones(audio))
if len(microphones) == 0: return None
print("Found {} microphones:".format(len(microphones)))
for i, mic in enumerate(microphones):
print("- {}: {}".format(i, mic["name"]))
chosen_microphone = None
while chosen_microphone is None:
chosen = raw_input("Which device would you like to use?\n> ")
try:
chosen = int(chosen)
if chosen < 0 or chosen >= len(microphones): raise ValueError()
chosen_microphone = microphones[chosen]
except ValueError:
print("Please enter a number between 0 and {}".format(len(microphones)-1))
logger.info("Chose microphone \"{}\"".format(chosen_microphone["name"]))
return chosen_microphone
def choose_mic_default(audio):
"""
Get the system's default microphone to use.
:param audio: An instance of PyAudio to use.
:type audio: pyaudio.PyAudio
:return: A dictionary from PyAudio containing information about the microphone to use, or None
if there is no microphone.
:rtype: dict | None
"""
try:
return audio.get_default_input_device_info()
except IOError:
return None

View File

@@ -0,0 +1,25 @@
import logging
import sys
try:
import qi
except ImportError:
qi = None
def get_qi_session():
if qi is None:
logging.info("Unable to import qi. Running in stand-alone mode.")
return None
if "--qi-url" not in sys.argv:
logging.info("No Qi URL argument given. Running in stand-alone mode.")
return None
try:
app = qi.Application()
app.start()
return app.session
except RuntimeError:
logging.info("Unable to connect to the robot. Running in stand-alone mode.")
return None

View File

@@ -0,0 +1,31 @@
import time
class TimeBlock(object):
"""
A context manager that times the execution of the block it contains. If execution exceeds the
limit, or if no limit is given, the callback will be called with the time that the block took.
"""
def __init__(self, callback, limit_ms=None):
"""
:param callback: The callback function that is called when the block of code is over,
unless the code block did not exceed the time limit.
:type callback: Callable[[float], None]
:param limit_ms: The number of milliseconds the block of code is allowed to take. If it
exceeds this time, or if it's None, the callback function will be called with the time the
block took.
:type limit_ms: int | None
"""
self.limit_ms = float(limit_ms) if limit_ms is not None else None
self.callback = callback
self.start = None
def __enter__(self):
self.start = time.time()
return self
def __exit__(self, exc_type, exc_value, traceback):
elapsed = (time.time() - self.start) * 1000.0 # ms
if self.limit_ms is None or elapsed > self.limit_ms:
self.callback(elapsed)

0
test/common/__init__.py Normal file
View File

View File

@@ -0,0 +1,95 @@
import random
import sys
from StringIO import StringIO
from robot_interface.utils.microphone import choose_mic_default, choose_mic_interactive, get_microphones
class MicrophoneUtils(object):
"""Shared tests for any PyAudio-like implementation, e.g. mock and real."""
def test_choose_mic_default(self, pyaudio_instance):
"""
The result must contain at least "index", as this is used to identify the microphone.
The "name" is used for logging, so it should also exist.
It must have one or more channels.
Lastly it must be capable of sending at least 16000 samples per second.
"""
result = choose_mic_default(pyaudio_instance)
assert "index" in result
assert isinstance(result["index"], (int, long))
assert "name" in result
assert isinstance(result["name"], (str, unicode))
assert "maxInputChannels" in result
assert isinstance(result["maxInputChannels"], (int, long))
assert result["maxInputChannels"] > 0
assert "defaultSampleRate" in result
assert isinstance(result["defaultSampleRate"], float)
assert result["defaultSampleRate"] >= 16000
def test_choose_mic_interactive_input_not_int(self, pyaudio_instance, mocker):
"""
First mock an input that's not an integer, then a valid integer. There should be no errors.
"""
mock_input = mocker.patch("__builtin__.raw_input", side_effect=["not an integer", "0"])
fake_out = StringIO()
mocker.patch.object(sys, "stdout", fake_out)
result = choose_mic_interactive(pyaudio_instance)
assert "index" in result
assert isinstance(result["index"], (int, long))
assert result["index"] == 0
assert mock_input.called
assert any(p.startswith("Please enter a number") for p in fake_out.getvalue().splitlines())
def test_choose_mic_interactive_negative_index(self, pyaudio_instance, mocker):
"""
Make sure that the interactive method does not allow negative integers as input.
"""
mock_input = mocker.patch("__builtin__.raw_input", side_effect=["-1", "0"])
fake_out = StringIO()
mocker.patch.object(sys, "stdout", fake_out)
result = choose_mic_interactive(pyaudio_instance)
assert "index" in result
assert isinstance(result["index"], (int, long))
assert result["index"] == 0
assert mock_input.called
assert any(p.startswith("Please enter a number") for p in fake_out.getvalue().splitlines())
def test_choose_mic_interactive_index_too_high(self, pyaudio_instance, mocker):
"""
Make sure that the interactive method does not allow indices higher than the highest mic index.
"""
real_count = len(list(get_microphones(pyaudio_instance)))
mock_input = mocker.patch("__builtin__.raw_input", side_effect=[str(real_count), "0"])
fake_out = StringIO()
mocker.patch.object(sys, "stdout", fake_out)
result = choose_mic_interactive(pyaudio_instance)
assert "index" in result
assert isinstance(result["index"], (int, long))
assert mock_input.called
assert any(p.startswith("Please enter a number") for p in fake_out.getvalue().splitlines())
def test_choose_mic_interactive_random_index(self, pyaudio_instance, mocker):
"""
Get a random index from the list of available mics, make sure it's correct.
"""
microphones = list(get_microphones(pyaudio_instance))
random_index = random.randrange(len(microphones))
mocker.patch("__builtin__.raw_input", side_effect=[str(random_index)])
result = choose_mic_interactive(pyaudio_instance)
assert "index" in result
assert isinstance(result["index"], (int, long))
assert result["index"] == microphones[random_index]["index"]

View File

View File

@@ -0,0 +1,20 @@
import pyaudio
import pytest
from common.microphone_utils import MicrophoneUtils
@pytest.fixture
def pyaudio_instance():
audio = pyaudio.PyAudio()
try:
audio.get_default_input_device_info()
return audio
except IOError:
pytest.skip("No microphone available to test with.")
class TestAudioIntegration(MicrophoneUtils):
"""Run shared audio behavior tests with the mock implementation."""
pass

0
test/unit/__init__.py Normal file
View File

View File

@@ -0,0 +1,74 @@
import sys
import mock
import pytest
import zmq
from robot_interface.endpoints.actuation_receiver import ActuationReceiver
@pytest.fixture
def zmq_context():
context = zmq.Context()
yield context
def test_handle_unimplemented_endpoint(zmq_context):
receiver = ActuationReceiver(zmq_context)
# Should not error
receiver.handle_message({
"endpoint": "some_endpoint_that_definitely_does_not_exist",
"data": None,
})
def test_speech_message_no_data(zmq_context, mocker):
mock_warn = mocker.patch("logging.warn")
receiver = ActuationReceiver(zmq_context)
receiver.handle_message({"endpoint": "actuate/speech", "data": ""})
mock_warn.assert_called_with(mock.ANY)
def test_speech_message_invalid_data(zmq_context, mocker):
mock_warn = mocker.patch("logging.warn")
receiver = ActuationReceiver(zmq_context)
receiver.handle_message({"endpoint": "actuate/speech", "data": True})
mock_warn.assert_called_with(mock.ANY)
def test_speech_no_qi(zmq_context, mocker):
mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state")
mock_qi_session = mock.PropertyMock(return_value=None)
type(mock_state).qi_session = mock_qi_session
receiver = ActuationReceiver(zmq_context)
receiver._handle_speech({"endpoint": "actuate/speech", "data": "Some message to speak."})
mock_qi_session.assert_called()
def test_speech(zmq_context, mocker):
mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state")
mock_qi = mock.Mock()
sys.modules["qi"] = mock_qi
mock_tts_service = mock.Mock()
mock_state.qi_session = mock.Mock()
mock_state.qi_session.service.return_value = mock_tts_service
receiver = ActuationReceiver(zmq_context)
receiver._tts_service = None
receiver._handle_speech({"endpoint": "actuate/speech", "data": "Some message to speak."})
mock_state.qi_session.service.assert_called_once_with("ALTextToSpeech")
mock_qi.async.assert_called_once()
call_args = mock_qi.async.call_args[0]
assert call_args[0] == mock_tts_service.say
assert call_args[1] == "Some message to speak."

View File

@@ -0,0 +1,104 @@
# coding=utf-8
import os
import time
import mock
import pytest
import zmq
from robot_interface.endpoints.audio_sender import AudioSender
@pytest.fixture
def zmq_context():
context = zmq.Context()
yield context
def test_no_microphone(zmq_context, mocker):
mock_info_logger = mocker.patch("robot_interface.endpoints.audio_sender.logger.info")
mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic_default")
mock_choose_mic.return_value = None
sender = AudioSender(zmq_context)
assert sender.microphone is None
sender.start()
assert sender.thread is None
mock_info_logger.assert_called()
sender.wait_until_done() # Should return early because we didn't start a thread
def test_unicode_mic_name(zmq_context, mocker):
mocker.patch("robot_interface.endpoints.audio_sender.threading")
mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic_default")
mock_choose_mic.return_value = {"name": u"• Some Unicode name"}
sender = AudioSender(zmq_context)
assert sender.microphone is not None
# `.start()` logs the name of the microphone. It should not give an error if it contains Unicode
# symbols.
sender.start()
assert sender.thread is not None
sender.wait_until_done() # Should return instantly because we didn't start a real thread
def _fake_read(num_frames):
return os.urandom(num_frames * 4)
def test_sending_audio(mocker):
mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic_default")
mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L}
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.exit_event.is_set.side_effect = [False, True]
mock_zmq_context = mock.Mock()
send_socket = mock.Mock()
# If there's something wrong with the microphone, it will raise an IOError when `read`ing.
stream = mock.Mock()
stream.read = _fake_read
sender = AudioSender(mock_zmq_context)
sender.socket.send = send_socket
sender.audio.open = mock.Mock()
sender.audio.open.return_value = stream
sender.start()
sender.wait_until_done()
send_socket.assert_called()
def _fake_read_error(num_frames):
raise IOError()
def test_break_microphone(mocker):
mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic_default")
mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L}
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.exit_event.is_set.side_effect = [False, True]
mock_zmq_context = mock.Mock()
send_socket = mock.Mock()
# If there's something wrong with the microphone, it will raise an IOError when `read`ing.
stream = mock.Mock()
stream.read = _fake_read_error
sender = AudioSender(mock_zmq_context)
sender.socket.send = send_socket
sender.audio.open = mock.Mock()
sender.audio.open.return_value = stream
sender.start()
sender.wait_until_done()
send_socket.assert_not_called()

View File

@@ -0,0 +1,79 @@
import mock
import pytest
import zmq
from robot_interface.endpoints.main_receiver import MainReceiver
@pytest.fixture
def zmq_context():
context = zmq.Context()
yield context
def test_handle_ping(zmq_context):
receiver = MainReceiver(zmq_context)
response = receiver.handle_message({"endpoint": "ping", "data": "pong"})
assert "endpoint" in response
assert response["endpoint"] == "ping"
assert "data" in response
assert response["data"] == "pong"
def test_handle_ping_none(zmq_context):
receiver = MainReceiver(zmq_context)
response = receiver.handle_message({"endpoint": "ping", "data": None})
assert "endpoint" in response
assert response["endpoint"] == "ping"
assert "data" in response
assert response["data"] == None
@mock.patch("robot_interface.endpoints.main_receiver.state")
def test_handle_negotiate_ports(mock_state, zmq_context):
receiver = MainReceiver(zmq_context)
mock_state.sockets = [receiver]
response = receiver.handle_message({"endpoint": "negotiate/ports", "data": None})
assert "endpoint" in response
assert response["endpoint"] == "negotiate/ports"
assert "data" in response
assert isinstance(response["data"], list)
for port in response["data"]:
assert "id" in port
assert isinstance(port["id"], str)
assert "port" in port
assert isinstance(port["port"], int)
assert "bind" in port
assert isinstance(port["bind"], bool)
assert any(port["id"] == "main" for port in response["data"])
def test_handle_unimplemented_endpoint(zmq_context):
receiver = MainReceiver(zmq_context)
response = receiver.handle_message({
"endpoint": "some_endpoint_that_definitely_does_not_exist",
"data": None,
})
assert "endpoint" in response
assert response["endpoint"] == "error"
assert "data" in response
assert isinstance(response["data"], str)
def test_handle_unimplemented_negotiation_endpoint(zmq_context):
receiver = MainReceiver(zmq_context)
response = receiver.handle_message({
"endpoint": "negotiate/but_some_subpath_that_definitely_does_not_exist",
"data": None,
})
assert "endpoint" in response
assert response["endpoint"] == "negotiate/error"
assert "data" in response
assert isinstance(response["data"], str)

View File

@@ -0,0 +1,85 @@
# coding=utf-8
import mock
import pytest
from common.microphone_utils import MicrophoneUtils
from robot_interface.utils.microphone import choose_mic_default, choose_mic_interactive
class MockPyAudio:
def __init__(self):
# You can predefine fake device info here
self.devices = [
{
"index": 0,
"name": u"Someones Microphone", # Using a Unicode character
"maxInputChannels": 2,
"maxOutputChannels": 0,
"defaultSampleRate": 44100.0,
"defaultLowInputLatency": 0.01,
"defaultLowOutputLatency": 0.01,
"defaultHighInputLatency": 0.1,
"defaultHighOutputLatency": 0.1,
"hostApi": 0,
},
{
"index": 1,
"name": u"Mock Speaker 1",
"maxInputChannels": 0,
"maxOutputChannels": 2,
"defaultSampleRate": 48000.0,
"defaultLowInputLatency": 0.01,
"defaultLowOutputLatency": 0.01,
"defaultHighInputLatency": 0.1,
"defaultHighOutputLatency": 0.1,
"hostApi": 0,
},
]
def get_device_count(self):
"""Return the number of available mock devices."""
return len(self.devices)
def get_device_info_by_index(self, index):
"""Return information for a given mock device index."""
if 0 <= index < len(self.devices):
return self.devices[index]
else:
raise IOError("Invalid device index: {}".format(index))
def get_default_input_device_info(self):
"""Return info for a default mock input device."""
for device in self.devices:
if device.get("maxInputChannels", 0) > 0:
return device
raise IOError("No default input device found")
@pytest.fixture
def pyaudio_instance():
return MockPyAudio()
def _raise_io_error():
raise IOError()
class TestAudioUnit(MicrophoneUtils):
"""Run shared audio behavior tests with the mock implementation."""
def test_choose_mic_default_no_mic(self):
mock_pyaudio = mock.Mock()
mock_pyaudio.get_device_count = mock.Mock(return_value=0L)
mock_pyaudio.get_default_input_device_info = _raise_io_error
result = choose_mic_default(mock_pyaudio)
assert result is None
def test_choose_mic_interactive_no_mic(self):
mock_pyaudio = mock.Mock()
mock_pyaudio.get_device_count = mock.Mock(return_value=0L)
mock_pyaudio.get_default_input_device_info = _raise_io_error
result = choose_mic_interactive(mock_pyaudio)
assert result is None

View File

@@ -0,0 +1,37 @@
import time
import mock
from robot_interface.utils.timeblock import TimeBlock
class AnyFloat(object):
def __eq__(self, other):
return isinstance(other, float)
def test_no_limit():
callback = mock.Mock()
with TimeBlock(callback):
pass
callback.assert_called_once_with(AnyFloat())
def test_exceed_limit():
callback = mock.Mock()
with TimeBlock(callback, 0):
time.sleep(0.001)
callback.assert_called_once_with(AnyFloat())
def test_within_limit():
callback = mock.Mock()
with TimeBlock(callback, 5):
pass
callback.assert_not_called()