9 Commits

Author SHA1 Message Date
eb106e97e7 build: add qi lib to Docker
Uses Pyenv to build Python 2 from source, while keeping Debian for
needed packages. Might move to two build stages in the future.

ref: N25B-284
2025-11-18 17:12:30 +01:00
ec0e8e3504 build: add Docker container
Some changes (like `asound.conf`) are not portable and only work on my
machine. This will be fixed in the future.

ref: N25B-284
2025-11-14 14:15:00 +01:00
Pim Hutting
c037eb7ec2 Merge branch 'feat/stream-audio' into 'dev'
Implement audio streaming

See merge request ics/sp/2025/n25b/pepperplus-ri!8
2025-11-05 12:08:28 +00:00
Twirre Meulenbelt
8a095323ec docs: describe extra WSL installation step
ref: N25B-119
2025-11-02 16:35:15 +01:00
Twirre Meulenbelt
854a14bf0c docs: describe --microphone program parameter
ref: N25B-119
2025-11-02 16:16:43 +01:00
Twirre Meulenbelt
fab5127cac feat: add application parameter to choose a custom microphone
ref: N25B-119
2025-11-02 16:12:56 +01:00
Twirre Meulenbelt
5912ac606a docs: add installation instructions for the portaudio dependency
ref: N25B-119
2025-11-02 15:01:18 +01:00
Twirre Meulenbelt
9ea446275e fix: allow speaking text with Unicode characters
When speaking, the actuation receiver logs the message to speak. If the message includes Unicode characters, it will now no longer crash.

ref: N25B-119
2025-11-02 14:59:16 +01:00
Twirre Meulenbelt
a6a12a5886 fix: remove unused qi import
It had already been made so that the VideoSender does not depend on `qi`, but the import was not yet removed.

ref: N25B-119
2025-11-02 14:58:32 +01:00
13 changed files with 209 additions and 22 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git
.githooks/
.idea/
.venv/
test/
typings/
.dockerignore
.gitignore
README.md

37
Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
FROM debian:trixie AS build
WORKDIR /app
COPY requirements.txt .
RUN apt-get update; apt-get install -y portaudio19-dev libzmq3-dev make build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev curl git libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev alsa-utils musl-dev
ENV HOME="/root"
RUN git clone --depth=1 https://github.com/pyenv/pyenv.git ${HOME}/.pyenv
ENV PYENV_ROOT="${HOME}/.pyenv"
ENV PATH="${PYENV_ROOT}/shims:${PYENV_ROOT}/bin:${PATH}"
ENV PYTHON_VERSION=2.7.18
RUN pyenv install ${PYTHON_VERSION}; pyenv global ${PYTHON_VERSION}
RUN python -m pip install virtualenv; python -m virtualenv .venv
RUN /usr/bin/env bash -c 'source .venv/bin/activate && pip install -r ./requirements.txt'
# RUN eval "$(pyenv init - bash)"; pyenv install 2.7; pyenv shell 2.7; python -m pip install virtualenv; python -m virtualenv .venv; source .venv/bin/activate; pip install -r requirements.txt
# FROM debian:trixie
#
# WORKDIR /app
#
# COPY --from=build /app/.venv /app/.venv
WORKDIR /app/.venv/lib/python2.7/site-packages
RUN /usr/bin/env bash -c 'apt-get install -y wget && wget https://community-static.aldebaran.com/resources/2.5.10/Python%20SDK/pynaoqi-python2.7-2.5.7.1-linux64.tar.gz && tar xvfz ./pynaoqi-python2.7-2.5.7.1-linux64.tar.gz && rm pynaoqi-python2.7-2.5.7.1-linux64.tar.gz'
RUN echo /app/.venv/lib/python2.7/site-packages/pynaoqi-python2.7-2.5.7.1-linux64/lib/python2.7/site-packages/ > pynaoqi-python2.7.pth
WORKDIR /app
COPY . .
ENV PYTHONPATH=src
CMD [ "/bin/bash", "-c", "source .venv/bin/activate && python -m robot_interface.main --qi-url tcp://172.17.0.1:43305" ]

View File

@@ -34,6 +34,18 @@ python -m virtualenv .venv
source .venv/bin/activate
```
We depend on PortAudio for the `pyaudio` package, so install it with:
```bash
sudo apt install -y portaudio19-dev
```
On WSL, also install:
```bash
sudo apt install -y libasound2-plugins
```
Install the required packages with
```bash
@@ -98,6 +110,8 @@ $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.
There's also a `--microphone` argument that can be used to choose a microphone to use. If not given, the program will try the default microphone. If you don't know the name of the microphone, pass the argument with any value, and it will list the names of available microphones.
## Testing

9
asound.conf Normal file
View File

@@ -0,0 +1,9 @@
pcm.!default {
type hw
card 2
}
ctl.!default {
type hw
card 2
}

4
install_deps.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
apk add portaudio-dev libzmq gcc musl-dev g++ alsa-utils
pip install -r requirements.txt

View File

@@ -1,3 +1,4 @@
from __future__ import unicode_literals # So that we can log texts with Unicode characters
import logging
import zmq

View File

@@ -7,7 +7,7 @@ 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
from robot_interface.utils.microphone import choose_mic
logger = logging.getLogger(__name__)
@@ -17,10 +17,16 @@ 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
try:
self.audio = pyaudio.PyAudio()
self.microphone = choose_mic(self.audio)
except IOError as e:
logger.warning("PyAudio is not available.", exc_info=e)
self.audio = None
self.microphone = None
def start(self):
"""
Start sending audio in a different thread.

View File

@@ -45,7 +45,10 @@ class MainReceiver(ReceiverBase):
if message["endpoint"] == "negotiate/ports":
return MainReceiver._handle_port_negotiation(message)
return {"endpoint": "negotiate/error", "data": "The requested endpoint is not implemented."}
return {
"endpoint": "negotiate/error",
"data": "The requested endpoint is not implemented.",
}
def handle_message(self, message):
if message["endpoint"] == "ping":

View File

@@ -1,6 +1,5 @@
from abc import ABCMeta
import zmq
import os
class SocketBase(object):
@@ -19,7 +18,7 @@ class SocketBase(object):
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):
def create_socket(self, zmq_context, socket_type, port, options=[], bind=False):
"""
Create a ZeroMQ socket.
@@ -43,17 +42,19 @@ class SocketBase(object):
self.socket = zmq_context.socket(socket_type)
for option, arg in options:
self.socket.setsockopt(option,arg)
self.socket.setsockopt(option, arg)
self.bound = bind
host = os.environ.get("CB_HOST", "localhost")
if bind:
self.socket.bind("tcp://*:{}".format(port))
self.socket.bind("tcp://{}:{}".format(host, port))
else:
self.socket.connect("tcp://localhost:{}".format(port))
self.socket.connect("tcp://{}:{}".format(host, port))
def close(self):
"""Close the ZeroMQ socket."""
if not self.socket: return
if not self.socket:
return
self.socket.close()
self.socket = None
@@ -65,8 +66,4 @@ class SocketBase(object):
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
}
return {"id": self.identifier, "port": self.port, "bind": not self.bound}

View File

@@ -1,6 +1,5 @@
import zmq
import threading
import qi
import logging
from robot_interface.endpoints.socket_base import SocketBase

View File

@@ -1,5 +1,6 @@
from __future__ import unicode_literals # So that `print` can print Unicode characters in names
import logging
import sys
logger = logging.getLogger(__name__)
@@ -67,3 +68,53 @@ def choose_mic_default(audio):
return audio.get_default_input_device_info()
except IOError:
return None
def choose_mic_arguments(audio):
"""
Get a microphone to use from command line arguments.
: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 satisfied by the arguments.
:rtype: dict | None
"""
microphone_name = None
for i, arg in enumerate(sys.argv):
if arg == "--microphone" and len(sys.argv) > i+1:
microphone_name = sys.argv[i+1].strip()
if arg.startswith("--microphone="):
microphone_name = arg[13:].strip()
if not microphone_name: return None
available_mics = list(get_microphones(audio))
for mic in available_mics:
if mic["name"] == microphone_name:
return mic
available_mic_names = [mic["name"] for mic in available_mics]
logger.warning("Microphone \"{}\" not found. Choose one of {}"
.format(microphone_name, available_mic_names))
return None
def choose_mic(audio):
"""
Get a microphone to use. Firstly, tries to see if there's an application argument specifying the
microphone to use. If not, get the default microphone.
: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
"""
chosen_mic = choose_mic_arguments(audio)
if chosen_mic: return chosen_mic
return choose_mic_default(audio)

View File

@@ -1,8 +1,15 @@
from __future__ import unicode_literals # So that we can format strings with Unicode characters
import random
import sys
from StringIO import StringIO
from robot_interface.utils.microphone import choose_mic_default, choose_mic_interactive, get_microphones
from robot_interface.utils.microphone import (
choose_mic_default,
choose_mic_interactive,
choose_mic_arguments,
choose_mic,
get_microphones,
)
class MicrophoneUtils(object):
@@ -93,3 +100,53 @@ class MicrophoneUtils(object):
assert "index" in result
assert isinstance(result["index"], (int, long))
assert result["index"] == microphones[random_index]["index"]
def test_choose_mic_no_arguments(self, pyaudio_instance, mocker):
mocker.patch.object(sys, "argv", [])
result = choose_mic_arguments(pyaudio_instance)
assert result is None
def test_choose_mic_arguments(self, pyaudio_instance, mocker):
for mic in get_microphones(pyaudio_instance):
mocker.patch.object(sys, "argv", ["--microphone", mic["name"]])
result = choose_mic_arguments(pyaudio_instance)
assert result is not None
assert result == mic
def test_choose_mic_arguments_eq(self, pyaudio_instance, mocker):
for mic in get_microphones(pyaudio_instance):
mocker.patch.object(sys, "argv", ["--microphone={}".format(mic["name"])])
result = choose_mic_arguments(pyaudio_instance)
assert result is not None
assert result == mic
def test_choose_mic_arguments_not_exits(self, pyaudio_instance, mocker):
mocker.patch.object(sys, "argv", ["--microphone", "Surely this microphone doesn't exist"])
result = choose_mic_arguments(pyaudio_instance)
assert result is None
def test_choose_mic_with_argument(self, pyaudio_instance, mocker):
mic = next(get_microphones(pyaudio_instance))
mocker.patch.object(sys, "argv", ["--microphone", mic["name"]])
result = choose_mic(pyaudio_instance)
assert result is not None
assert result == mic
def test_choose_mic_no_argument(self, pyaudio_instance, mocker):
default_mic = choose_mic_default(pyaudio_instance)
mocker.patch.object(sys, "argv", [])
result = choose_mic(pyaudio_instance)
assert result is not None
assert result == default_mic

View File

@@ -17,7 +17,7 @@ def zmq_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 = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic")
mock_choose_mic.return_value = None
sender = AudioSender(zmq_context)
@@ -32,7 +32,7 @@ def test_no_microphone(zmq_context, mocker):
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 = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic")
mock_choose_mic.return_value = {"name": u"• Some Unicode name"}
sender = AudioSender(zmq_context)
@@ -51,7 +51,7 @@ def _fake_read(num_frames):
def test_sending_audio(mocker):
mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic_default")
mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic")
mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L}
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
@@ -80,7 +80,7 @@ def _fake_read_error(num_frames):
def test_break_microphone(mocker):
mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic_default")
mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic")
mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L}
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")