12 Commits

Author SHA1 Message Date
Twirre Meulenbelt
ecf9d14a4e test: make audio sender tests pass 2026-02-09 15:51:35 +01:00
Twirre Meulenbelt
0fe5fcf8f8 feat: choose between Qi microphone and local microphone 2026-02-09 15:45:17 +01:00
abd6988d1e feat: multi-channel qi audio possible 2026-02-09 13:40:47 +01:00
Twirre Meulenbelt
31c76ecf84 fix: make QI audio sender working 2026-02-09 09:39:48 +01:00
6e2bedcd32 feat: (almost) qi audio sender 2026-02-04 18:38:40 +01:00
06e3dad25d Merge branch 'fix/send-video' into 'main'
fix: send video

See merge request ics/sp/2025/n25b/pepperplus-ri!28
2026-01-30 19:19:03 +00:00
Storm
fe8bad1f8c Merge branch 'main' into fix/send-video 2026-01-30 17:28:13 +01:00
Storm
8333f2fc2a chore: removed numpy import 2026-01-29 13:09:25 +01:00
Storm
24c7fa216f test: 100% coverage
ref: N25B-393
2026-01-29 12:28:34 +01:00
Storm
56becd84ac test: fixed video_sender tests
ref: N25B-393
2026-01-29 12:16:48 +01:00
Storm
4a2cace1cf chore: changed socket option to set HWM to 3 (max 3 packets in queue 2026-01-29 12:02:28 +01:00
Storm
891ebf5e3f chore: changed video sending to work without cv2 2026-01-27 17:58:06 +01:00
14 changed files with 426 additions and 514 deletions

View File

@@ -6,6 +6,9 @@
# The hostname or IP address of the Control Backend. # The hostname or IP address of the Control Backend.
AGENT__CONTROL_BACKEND_HOST=localhost AGENT__CONTROL_BACKEND_HOST=localhost
# Whether to use Pepper's microphone when Pepper is connected.
AUDIO__USE_PEPPER_MICROPHONE=true
# Variables that are unlikely to be configured, you can probably ignore these: # Variables that are unlikely to be configured, you can probably ignore these:

View File

@@ -7,3 +7,5 @@ sphinx
sphinx_rtd_theme sphinx_rtd_theme
pre-commit pre-commit
python-dotenv python-dotenv
numpy<=1.16.6
enum34

View File

@@ -68,7 +68,7 @@ class VideoConfig(object):
): ):
self.camera_index = get_config(camera_index, "VIDEO__CAMERA_INDEX", 0, int) self.camera_index = get_config(camera_index, "VIDEO__CAMERA_INDEX", 0, int)
self.resolution = get_config(resolution, "VIDEO__RESOLUTION", 2, int) self.resolution = get_config(resolution, "VIDEO__RESOLUTION", 2, int)
self.color_space = get_config(color_space, "VIDEO__COLOR_SPACE", 11, int) self.color_space = get_config(color_space, "VIDEO__COLOR_SPACE", 13, int)
self.fps = get_config(fps, "VIDEO__FPS", 15, int) self.fps = get_config(fps, "VIDEO__FPS", 15, int)
self.stream_name = get_config(stream_name, "VIDEO__STREAM_NAME", "Pepper Video") self.stream_name = get_config(stream_name, "VIDEO__STREAM_NAME", "Pepper Video")
self.image_buffer = get_config(image_buffer, "VIDEO__IMAGE_BUFFER", 6, int) self.image_buffer = get_config(image_buffer, "VIDEO__IMAGE_BUFFER", 6, int)
@@ -78,6 +78,8 @@ class AudioConfig(object):
""" """
Audio configuration constants. Audio configuration constants.
:ivar use_pepper_microphone: Whether to use Pepper's microphone or not, defaults to True.
:vartype use_pepper_microphone: bool
:ivar sample_rate: Audio sampling rate in Hz, defaults to 16000. :ivar sample_rate: Audio sampling rate in Hz, defaults to 16000.
:vartype sample_rate: int :vartype sample_rate: int
:ivar chunk_size: Size of audio chunks to capture/process, defaults to 512. :ivar chunk_size: Size of audio chunks to capture/process, defaults to 512.
@@ -85,7 +87,14 @@ class AudioConfig(object):
:ivar channels: Number of audio channels, defaults to 1. :ivar channels: Number of audio channels, defaults to 1.
:vartype channels: int :vartype channels: int
""" """
def __init__(self, sample_rate=None, chunk_size=None, channels=None): def __init__(
self,
use_pepper_microphone=None,
sample_rate=None,
chunk_size=None,
channels=None,
):
self.use_pepper_microphone = get_config(use_pepper_microphone, "AUDIO__USE_PEPPER_MICROPHONE", True, bool)
self.sample_rate = get_config(sample_rate, "AUDIO__SAMPLE_RATE", 16000, int) self.sample_rate = get_config(sample_rate, "AUDIO__SAMPLE_RATE", 16000, int)
self.chunk_size = get_config(chunk_size, "AUDIO__CHUNK_SIZE", 512, int) self.chunk_size = get_config(chunk_size, "AUDIO__CHUNK_SIZE", 512, int)
self.channels = get_config(channels, "AUDIO__CHANNELS", 1, int) self.channels = get_config(channels, "AUDIO__CHANNELS", 1, int)

View File

@@ -6,9 +6,16 @@ University within the Software Project course.
""" """
from __future__ import unicode_literals # So that `logging` can use Unicode characters in names from __future__ import unicode_literals # So that `logging` can use Unicode characters in names
import audioop
import enum
from abc import ABCMeta, abstractmethod
import threading import threading
import logging import logging
import Queue
import numpy as np
import pyaudio import pyaudio
import zmq import zmq
@@ -20,97 +27,219 @@ from robot_interface.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AudioSender(SocketBase): class AudioCapturer(object):
""" """
Audio sender endpoint, responsible for sending microphone audio data. Interface for audio capturers.
"""
__metaclass__ = ABCMeta
:param zmq_context: The ZeroMQ context to use. @abstractmethod
:type zmq_context: zmq.Context def setup(self):
raise NotImplementedError()
:param port: The port to use. @abstractmethod
:type port: int def stop(self):
raise NotImplementedError()
:ivar thread: Thread used for sending audio. @abstractmethod
:vartype thread: threading.Thread | None def generate_chunk(self):
raise NotImplementedError()
class SampleRate(enum.Enum):
"""
Sample rate to use in Hz.
"""
LOW = 16000
HIGH = 48000
class PepperMicrophone(enum.Enum):
"""
Which of Pepper's microphones to use. In our case, some of the mics were damages/didn't work
well, so we choose to only use the fron right. If you have a Pepper robot with all working mics,
you might wish to use all microphones, to improve overall audio quality.
"""
ALL = 0
LEFT = 1
RIGHT = 2
FRONT_LEFT = 3
FRONT_RIGHT = 4
class QiAudioCapturer(AudioCapturer):
# Some of this class' methods have docstrings as binary strings. Keep them that way, otherwise
# ``qi.Session.registerService`` will give RuntimeErrors.
def __init__(self, sample_rate=SampleRate.LOW, mic=PepperMicrophone.FRONT_RIGHT,
deinterleaved=0):
"""
:raises RuntimeError: If there is no Qi session available.
:raises ValueError: If the given arguments are not compatible.
"""
self.session = state.qi_session
if not self.session:
raise RuntimeError("Cannot capture from qi device, no qi session available.")
if sample_rate == SampleRate.HIGH and mic != PepperMicrophone.ALL:
raise RuntimeError("For 48000 Hz, you must select all microphones.")
if mic == PepperMicrophone.ALL and sample_rate != SampleRate.HIGH:
raise RuntimeError("For using all microphones, 48000 Hz is required.")
self.audio = self.session.service("ALAudioDevice")
self.service_name = "ZmqAudioStreamer"
self.sample_rate = sample_rate
self.mic = mic
self.deinterleaved = deinterleaved
self.overflow = np.empty(0, dtype=np.float32)
self.q = Queue.Queue()
self._rate_state = None
def setup(self):
b"""
:raises RuntimeError: If no Qi session is available or if the session is not compatible with audio streaming.
"""
assert self.session is not None
logger.info("Listening with Pepper's microphone.")
self.session.registerService(self.service_name, self)
self.audio.setClientPreferences(self.service_name, self.sample_rate.value, self.mic.value,
self.deinterleaved)
self.audio.subscribe(self.service_name)
def stop(self):
b"""
Stop the audio capturer.
"""
try:
self.audio.unsubscribe(self.service_name)
except:
pass
def generate_chunk(self):
try:
chunk = self.q.get(True, 0.1)
return chunk
except Queue.Empty:
return None
# Callback invoked by NAOqi
def processRemote(self, nbOfChannels, nbOfSamplesByChannel, timeStamp, inputBuffer):
raw_pcm = bytes(inputBuffer)
pcm_i16 = np.frombuffer(raw_pcm, dtype=np.int16)
# Make mono channel (if it was 4 channels)
pcm_i32_mono = self._make_mono(pcm_i16.astype(np.int32), nbOfChannels)
# Resample (if it was 48k)
pcm_i32_mono_16k, self._rate_state = audioop.ratecv(pcm_i32_mono.tobytes(), 4, 1,
self.sample_rate.value,
SampleRate.LOW.value, self._rate_state)
pcm_f32_mono_16k = (np.frombuffer(pcm_i32_mono_16k, dtype=np.int32).astype(np.float32) /
32768.0)
# Attach overflow
pcm_f32_mono_16k = np.append(self.overflow, pcm_f32_mono_16k)
for i in range(len(pcm_f32_mono_16k) // 512):
self.q.put_nowait(pcm_f32_mono_16k[i * 512 : (i + 1) * 512].tobytes())
self.overflow = pcm_f32_mono_16k[len(pcm_f32_mono_16k) // 512 * 512 :]
def _make_mono(self, frag, channels):
return frag.reshape(-1, channels).mean(axis=1, dtype=np.int32)
class StandaloneAudioCapturer(AudioCapturer):
"""
Audio capturer that uses a microphone from the local device, can be chosen with the
``--microphone`` program argument.
:ivar audio: PyAudio instance. :ivar audio: PyAudio instance.
:vartype audio: pyaudio.PyAudio | None :vartype audio: pyaudio.PyAudio | None
:ivar microphone: Selected microphone information. :ivar microphone: Selected microphone information.
:vartype microphone: dict | None :vartype microphone: dict | None
:ivar stream: PyAudio stream instance. None until ``setup()`` is called, remaining None if setup
fails for any reason.
:vartype stream: pyaudio.Stream | None
""" """
def __init__(self, zmq_context, port=settings.agent_settings.audio_sender_port): def __init__(self):
super(AudioSender, self).__init__(str("audio")) # Convert future's unicode_literal to str self.stream = None
self.create_socket(zmq_context, zmq.PUB, port)
self.thread = None
try: try:
self.audio = pyaudio.PyAudio() self.audio = pyaudio.PyAudio()
self.microphone = choose_mic(self.audio) self.microphone = choose_mic(self.audio)
except IOError as e: except IOError as e:
logger.warning("PyAudio is not available.", exc_info=e) logger.warning("PyAudio is not available. Won't be able to send audio.", exc_info=True)
self.audio = None self.audio = None
self.microphone = None self.microphone = None
def start(self): def setup(self):
""" """
Start sending audio in a different thread. Setup audio stream. Will not if no microphone is available.
Will not start if no microphone is available.
""" """
if not self.microphone: if not self.microphone:
logger.info("Not listening: no microphone available.") logger.info("Not listening: no microphone available.")
return return
logger.info("Listening with microphone \"{}\".".format(self.microphone["name"])) logger.info("Listening with microphone \"{}\".".format(self.microphone["name"]))
self.thread = threading.Thread(target=self._stream) self.stream = self.audio.open(
format=pyaudio.paFloat32,
channels=settings.audio_config.channels,
rate=settings.audio_config.sample_rate,
input=True,
input_device_index=self.microphone["index"],
frames_per_buffer=settings.audio_config.chunk_size,
)
def stop(self):
"""
Close the audio stream.
"""
if not self.stream: return
self.stream.stop_stream()
self.stream.close()
def generate_chunk(self):
"""
:return: Audio frames from the microphone of size ``settings.audio_config.chunk_size``.
:rtype: bytes.
:raises IOError: If reading from the audio stream fails.
"""
return self.stream.read(settings.audio_config.chunk_size)
class AudioSender(SocketBase):
def __init__(self, zmq_context, port=settings.agent_settings.audio_sender_port):
super(AudioSender, self).__init__(str("audio"))
self.create_socket(zmq_context, zmq.PUB, port)
self.thread = threading.Thread(target=self.stream)
self.capturer = self.choose_capturer()
def start(self):
self.capturer.setup()
self.thread.start() self.thread.start()
def wait_until_done(self): def close(self):
""" self.capturer.stop()
Wait until the audio thread is done. super(AudioSender, self).close()
Will block until `state.exit_event` is set. If the thread is not running, does nothing. def stream(self):
""" while not state.exit_event.is_set():
if not self.thread: return chunk = self.capturer.generate_chunk()
self.thread.join()
self.thread = None
def _stream(self): if chunk is None or state.is_speaking:
""" continue
Internal method to continuously read audio from the microphone and send it over the socket.
"""
audio_settings = settings.audio_config
chunk = audio_settings.chunk_size # 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 self.socket.send(chunk)
def open_stream():
return self.audio.open(
format=pyaudio.paFloat32,
channels=audio_settings.channels,
rate=audio_settings.sample_rate,
input=True,
input_device_index=self.microphone["index"],
frames_per_buffer=chunk,
)
stream = None
try: def choose_capturer(self):
# Test in case exit_event was set while waiting if state.qi_session and settings.audio_config.use_pepper_microphone:
if not state.exit_event.is_set(): return QiAudioCapturer()
stream = open_stream()
return StandaloneAudioCapturer()
while not state.exit_event.is_set():
data = stream.read(chunk)
if (state.is_speaking): continue # Do not send audio while the robot is speaking
self.socket.send(data)
except IOError as e:
logger.error("Stopped listening: failed to get audio from microphone.", exc_info=e)
finally:
if stream:
try:
stream.stop_stream()
stream.close()
except IOError:
pass # Ignore errors on closing

View File

@@ -78,30 +78,6 @@ class MainReceiver(ReceiverBase):
return MainReceiver._handle_port_negotiation(message) 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."}
@staticmethod
def _handle_pause(message):
"""
Handle a pause request. Pauses or resumes the video and audio streams.
:param message: The pause request message.
:type message: dict
:return: A response dictionary indicating success.
:rtype: dict[str, str]
"""
if message.get("data"):
if state.active_event.is_set():
state.active_event.clear()
return {"endpoint": "pause", "data": "Streams paused."}
else:
return {"endpoint": "pause", "data": "Streams are already paused."}
else:
if not state.active_event.is_set():
state.active_event.set()
return {"endpoint": "pause", "data": "Streams resumed."}
else:
return {"endpoint": "pause", "data": "Streams are already running."}
def handle_message(self, message): def handle_message(self, message):
""" """
@@ -119,7 +95,5 @@ class MainReceiver(ReceiverBase):
return self._handle_ping(message) return self._handle_ping(message)
elif message["endpoint"].startswith("negotiate"): elif message["endpoint"].startswith("negotiate"):
return self._handle_negotiation(message) return self._handle_negotiation(message)
elif message["endpoint"] == "pause":
return self._handle_pause(message)
return {"endpoint": "error", "data": "The requested endpoint is not supported."} return {"endpoint": "error", "data": "The requested endpoint is not supported."}

View File

@@ -4,13 +4,12 @@ This program has been developed by students from the bachelor Computer Science a
University within the Software Project course. University within the Software Project course.
© Copyright Utrecht University (Department of Information and Computing Sciences) © Copyright Utrecht University (Department of Information and Computing Sciences)
""" """
import struct
import zmq import zmq
import threading import threading
import logging import logging
import numpy as np
import cv2 import struct
from robot_interface.endpoints.socket_base import SocketBase from robot_interface.endpoints.socket_base import SocketBase
from robot_interface.state import state from robot_interface.state import state
@@ -39,9 +38,6 @@ class VideoSender(SocketBase):
""" """
if not state.qi_session: if not state.qi_session:
logging.info("No Qi session available. Not starting video loop.") logging.info("No Qi session available. Not starting video loop.")
logging.info("Starting test video stream from local webcam.")
thread = threading.Thread(target=self.test_video_stream)
thread.start()
return return
video = state.qi_session.service("ALVideoDevice") video = state.qi_session.service("ALVideoDevice")
@@ -55,40 +51,9 @@ class VideoSender(SocketBase):
thread = threading.Thread(target=self.video_rcv_loop, args=(video, vid_stream_name)) thread = threading.Thread(target=self.video_rcv_loop, args=(video, vid_stream_name))
thread.start() thread.start()
def test_video_stream(self):
"""
Test function to send video from a local webcam instead of the robot.
"""
cap = cv2.VideoCapture(0)
if not cap.isOpened():
logging.error("Could not open webcam for video stream test.")
return
while not state.exit_event.is_set():
ret, frame = cap.read()
if not ret:
logging.warning("Failed to read frame from webcam.")
continue
if cv2.waitKey(1) & 0xFF == ord('q'): # << Add this: Updates the window
break
height, width, channels = frame.shape
pixel_data = frame.tobytes()
width_bytes = struct.pack('<I', width)
height_bytes = struct.pack('<I', height)
self.socket.send_multipart([width_bytes, height_bytes, pixel_data])
cap.release()
def video_rcv_loop(self, vid_service, vid_stream_name): def video_rcv_loop(self, vid_service, vid_stream_name):
""" """
The main loop of retrieving video images from the robot. The main loop of retrieving video images from the robot.
Sends the image data over the ZMQ socket in 3 parts: image width, image height and raw image bytes.
:param vid_service: The video service object that the active Qi session is connected to. :param vid_service: The video service object that the active Qi session is connected to.
:type vid_service: Object (Qi service object) :type vid_service: Object (Qi service object)
@@ -101,18 +66,18 @@ class VideoSender(SocketBase):
try: try:
img = vid_service.getImageRemote(vid_stream_name) img = vid_service.getImageRemote(vid_stream_name)
if img is not None: if img is not None:
image_bytes = img[6] raw_data = img[6]
width = img[0] width = img[0]
height = img[1] height = img[1]
width_bytes = struct.pack('<I', width) width_bytes = struct.pack('<I', width)
height_bytes = struct.pack('<I', height) height_bytes = struct.pack('<I', height)
self.socket.send_multipart([width_bytes, height_bytes, image_bytes]) self.socket.send_multipart([width_bytes, height_bytes, raw_data])
except KeyboardInterrupt:
logging.info("Video receiving loop interrupted by user.")
except: except:
logging.warn("Failed to retrieve video image from robot.") logging.warn("Failed to retrieve video image from robot.")
except KeyboardInterrupt:
logging.info("Video receiving loop interrupted by user.")
finally: finally:
vid_service.unsubscribe(vid_stream_name) vid_service.unsubscribe(vid_stream_name)
logging.info("Unsubscribed from video stream.") logging.info("Unsubscribed from video stream.")

View File

@@ -91,7 +91,6 @@ def main():
context = zmq.Context() context = zmq.Context()
state.initialize() state.initialize()
state.active_event.set()
try: try:
main_loop(context) main_loop(context)

View File

@@ -56,8 +56,6 @@ class State(object):
signal.signal(signal.SIGINT, handle_exit) signal.signal(signal.SIGINT, handle_exit)
signal.signal(signal.SIGTERM, handle_exit) signal.signal(signal.SIGTERM, handle_exit)
self.active_event = threading.Event()
self.qi_session = get_qi_session() self.qi_session = get_qi_session()
self.is_initialized = True self.is_initialized = True

View File

@@ -16,6 +16,8 @@ def get_config(value, env, default, cast=None):
Small utility to get a configuration value, returns `value` if it is not None, else it will try to get the Small utility to get a configuration value, returns `value` if it is not None, else it will try to get the
environment variable cast with `cast`. If the environment variable is not set, it will return `default`. environment variable cast with `cast`. If the environment variable is not set, it will return `default`.
Special handling for booleans, which are only true if the value of the variable is "true" or "yes", ignoring capitalization.
:param value: The value to check. :param value: The value to check.
:type value: Any :type value: Any
:param env: The environment variable to check. :param env: The environment variable to check.
@@ -33,7 +35,14 @@ def get_config(value, env, default, cast=None):
env = os.environ.get(env, default) env = os.environ.get(env, default)
if cast is None: if cast is None or env is None:
return env return env
if cast == bool:
if isinstance(env, bool):
return env
if not isinstance(default, bool):
raise ValueError("Default value must be a boolean if the cast type is a boolean.")
return env.lower() == "true" or env.lower() == "yes"
return cast(env) return cast(env)

View File

@@ -14,6 +14,20 @@ except ImportError:
qi = None qi = None
def _get_qi_url():
"""
Get the Qi URL from the command line arguments, or None if not given.
"""
if "--qi-url" in sys.argv:
return sys.argv[sys.argv.index("--qi-url") + 1]
for arg in sys.argv:
if arg.startswith("--qi-url="):
return arg[len("--qi-url="):]
return None
def get_qi_session(): def get_qi_session():
""" """
Create and return a Qi session if available. Create and return a Qi session if available.
@@ -25,12 +39,13 @@ def get_qi_session():
logging.info("Unable to import qi. Running in stand-alone mode.") logging.info("Unable to import qi. Running in stand-alone mode.")
return None return None
if "--qi-url" not in sys.argv: qi_url = _get_qi_url()
if qi_url is None:
logging.info("No Qi URL argument given. Running in stand-alone mode.") logging.info("No Qi URL argument given. Running in stand-alone mode.")
return None return None
try: try:
app = qi.Application() app = qi.Application(["--qi-url", qi_url, "--qi-listen-url", "tcp://0.0.0.0:0"])
app.start() app.start()
return app.session return app.session
except RuntimeError: except RuntimeError:

View File

@@ -33,14 +33,14 @@ def test_no_microphone(zmq_context, mocker):
mock_info_logger = mocker.patch("robot_interface.endpoints.audio_sender.logger.info") mock_info_logger = mocker.patch("robot_interface.endpoints.audio_sender.logger.info")
mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic")
mock_choose_mic.return_value = None mock_choose_mic.return_value = None
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.qi_session = None
sender = AudioSender(zmq_context) sender = AudioSender(zmq_context)
assert sender.microphone is None assert sender.capturer.microphone is None
sender.start() sender.capturer.setup()
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): def test_unicode_mic_name(zmq_context, mocker):
@@ -48,19 +48,18 @@ def test_unicode_mic_name(zmq_context, mocker):
Tests the robustness of the `AudioSender` when handling microphone names Tests the robustness of the `AudioSender` when handling microphone names
that contain Unicode characters. that contain Unicode characters.
""" """
mocker.patch("robot_interface.endpoints.audio_sender.threading")
mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic")
mock_choose_mic.return_value = {"name": u"• Some Unicode name"} mock_choose_mic.return_value = {"name": u"• Some Unicode name", "index": 0L}
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.qi_session = None
sender = AudioSender(zmq_context) sender = AudioSender(zmq_context)
assert sender.microphone is not None assert sender.capturer.microphone is not None
sender.capturer.audio.open = mock.Mock(return_value=mock.Mock())
# `.start()` logs the name of the microphone. It should not give an error if it contains Unicode # `.setup()` logs the name of the microphone. It should not give an error if it contains Unicode
# symbols. # symbols.
sender.start() sender.capturer.setup()
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): def _fake_read(num_frames):
@@ -78,7 +77,8 @@ def test_sending_audio(mocker):
mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L}
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state") mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.exit_event.is_set.side_effect = [False, False, True] mock_state.qi_session = None
mock_state.exit_event.is_set.side_effect = [False, True]
mock_zmq_context = mock.Mock() mock_zmq_context = mock.Mock()
send_socket = mock.Mock() send_socket = mock.Mock()
@@ -90,225 +90,14 @@ def test_sending_audio(mocker):
sender = AudioSender(mock_zmq_context) sender = AudioSender(mock_zmq_context)
sender.socket.send = send_socket sender.socket.send = send_socket
sender.audio.open = mock.Mock() sender.capturer.audio.open = mock.Mock()
sender.audio.open.return_value = stream sender.capturer.audio.open.return_value = stream
sender.start() sender.start()
sender.wait_until_done() sender.thread.join()
send_socket.assert_called() send_socket.assert_called()
# SENDING PAUSE RESUME?
def test_stream_initial_wait_exit(mocker):
mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic")
mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L}
mock_pyaudio_cls = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio.PyAudio")
mock_pyaudio_instance = mock_pyaudio_cls.return_value
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.exit_event.is_set.return_value = True
mock_state.active_event.is_set.return_value = False
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender._stream()
mock_pyaudio_instance.open.assert_not_called()
def test_stream_pause_and_resume(mocker):
mock_stream = mock.Mock()
mock_stream.read.return_value = b"data"
mock_pyaudio_cls = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio.PyAudio")
mock_pyaudio_instance = mock_pyaudio_cls.return_value
mock_pyaudio_instance.open.return_value = mock_stream
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.active_event.is_set.return_value = False
mock_state.exit_event.is_set.side_effect = [False, False, False, True]
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender.socket = mock.Mock()
sender._stream()
assert mock_pyaudio_instance.open.call_count == 2
assert mock_stream.close.call_count == 2
assert mock_stream.stop_stream.call_count == 2
assert mock_state.active_event.wait.called
def test_stream_exit_during_pause(mocker):
mock_stream = mock.Mock()
mock_pyaudio_cls = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio.PyAudio")
mock_pyaudio_instance = mock_pyaudio_cls.return_value
mock_pyaudio_instance.open.return_value = mock_stream
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.active_event.is_set.return_value = False
mock_state.exit_event.is_set.side_effect = [False, False, True]
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender._stream()
assert mock_pyaudio_instance.open.call_count == 1
assert mock_stream.close.call_count == 1
def test_stream_read_error_recovery(mocker):
stream_fail = mock.Mock()
stream_fail.read.side_effect = IOError("Overflow")
stream_ok = mock.Mock()
stream_ok.read.return_value = b"data"
mock_pyaudio_cls = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio.PyAudio")
mock_pyaudio_instance = mock_pyaudio_cls.return_value
mock_pyaudio_instance.open.side_effect = [stream_fail, stream_ok]
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.active_event.is_set.return_value = True
mock_state.exit_event.is_set.side_effect = [False, False, False, True]
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender.socket = mock.Mock()
sender._stream()
stream_fail.close.assert_called()
assert mock_pyaudio_instance.open.call_count == 2
sender.socket.send.assert_called_with(b"data")
def test_stream_fatal_error(mocker):
mock_pyaudio_cls = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio.PyAudio")
mock_pyaudio_instance = mock_pyaudio_cls.return_value
mock_pyaudio_instance.open.side_effect = IOError("Fatal error")
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.active_event.is_set.return_value = True
mock_state.exit_event.is_set.return_value = False
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender._stream()
def test_wait_until_done(mocker):
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender.wait_until_done()
mock_thread = mocker.Mock()
sender.thread = mock_thread
sender.wait_until_done()
mock_thread.join.assert_called_once()
assert sender.thread is None
def test_stream_pause_close_error(mocker):
"""
Tests that an IOError during stream closure (when pausing) is ignored,
covering the 'pass' statement in the pause logic.
"""
mock_stream = mock.Mock()
mock_stream.read.return_value = b"data"
# Raise IOError when stopping the stream during pause
mock_stream.stop_stream.side_effect = IOError("Failed to stop")
mock_pyaudio_cls = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio.PyAudio")
mock_pyaudio_instance = mock_pyaudio_cls.return_value
mock_pyaudio_instance.open.return_value = mock_stream
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
# 1. First False triggers the pause block
# 2. Second True resumes the loop
mock_state.active_event.is_set.side_effect = [False, True]
mock_state.exit_event.is_set.side_effect = [False, False, False, True]
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender.socket = mock.Mock()
sender._stream()
# Verification: The error should be swallowed, and the stream should re-open
assert mock_stream.stop_stream.called
assert mock_pyaudio_instance.open.call_count == 2
def test_stream_finally_close_error(mocker):
"""
Tests that an IOError during stream closure in the finally block is ignored,
covering the 'pass' statement in the finally logic.
"""
mock_stream = mock.Mock()
mock_stream.read.return_value = b"data"
# Raise IOError when stopping the stream at exit
mock_stream.stop_stream.side_effect = IOError("Cleanup failed")
mock_pyaudio_cls = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio.PyAudio")
mock_pyaudio_instance = mock_pyaudio_cls.return_value
mock_pyaudio_instance.open.return_value = mock_stream
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.active_event.is_set.return_value = True
mock_state.exit_event.is_set.side_effect = [False, False, True]
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender.socket = mock.Mock()
# Run
sender._stream()
# Assert: Should finish without raising exception despite the IOError in finally
assert mock_stream.stop_stream.called
def test_stream_recovery_failure(mocker):
"""
Tests the case where recovering from a read error (re-opening stream) also fails.
This ensures the outer try-except catches exceptions from the inner except block.
"""
mock_stream_initial = mock.Mock()
# Trigger the read error logic
mock_stream_initial.read.side_effect = IOError("Read failed")
mock_pyaudio_cls = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio.PyAudio")
mock_pyaudio_instance = mock_pyaudio_cls.return_value
# First open works, Second open (recovery) fails fatally
mock_pyaudio_instance.open.side_effect = [
mock_stream_initial,
IOError("Recovery failed")
]
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.active_event.is_set.return_value = True
mock_state.exit_event.is_set.return_value = False
mock_logger = mocker.patch("robot_interface.endpoints.audio_sender.logger")
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender.socket = mock.Mock()
sender._stream()
# Assert we hit the outer error log
mock_logger.error.assert_called()
def test_no_sending_if_speaking(mocker): def test_no_sending_if_speaking(mocker):
""" """
@@ -318,6 +107,7 @@ def test_no_sending_if_speaking(mocker):
mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L}
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state") mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.qi_session = None
mock_state.exit_event.is_set.side_effect = [False, True] mock_state.exit_event.is_set.side_effect = [False, True]
mock_zmq_context = mock.Mock() mock_zmq_context = mock.Mock()
@@ -331,11 +121,11 @@ def test_no_sending_if_speaking(mocker):
sender = AudioSender(mock_zmq_context) sender = AudioSender(mock_zmq_context)
sender.socket.send = send_socket sender.socket.send = send_socket
sender.audio.open = mock.Mock() sender.capturer.audio.open = mock.Mock()
sender.audio.open.return_value = stream sender.capturer.audio.open.return_value = stream
sender.start() sender.start()
sender.wait_until_done() sender.thread.join()
send_socket.assert_not_called() send_socket.assert_not_called()
@@ -355,6 +145,7 @@ def test_break_microphone(mocker):
mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L}
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state") mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.qi_session = None
mock_state.exit_event.is_set.side_effect = [False, True] mock_state.exit_event.is_set.side_effect = [False, True]
mock_zmq_context = mock.Mock() mock_zmq_context = mock.Mock()
@@ -366,11 +157,11 @@ def test_break_microphone(mocker):
sender = AudioSender(mock_zmq_context) sender = AudioSender(mock_zmq_context)
sender.socket.send = send_socket sender.socket.send = send_socket
sender.audio.open = mock.Mock() sender.capturer.audio.open = mock.Mock()
sender.audio.open.return_value = stream sender.capturer.audio.open.return_value = stream
sender.start() sender.start()
sender.wait_until_done() sender.thread.join()
send_socket.assert_not_called() send_socket.assert_not_called()
@@ -381,6 +172,8 @@ def test_pyaudio_init_failure(mocker, zmq_context):
""" """
# Prevent binding the ZMQ socket # Prevent binding the ZMQ socket
mocker.patch("robot_interface.endpoints.audio_sender.AudioSender.create_socket") mocker.patch("robot_interface.endpoints.audio_sender.AudioSender.create_socket")
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.qi_session = None
# Simulate PyAudio() failing # Simulate PyAudio() failing
mocker.patch( mocker.patch(
@@ -390,5 +183,5 @@ def test_pyaudio_init_failure(mocker, zmq_context):
sender = AudioSender(zmq_context) sender = AudioSender(zmq_context)
assert sender.audio is None assert sender.capturer.audio is None
assert sender.microphone is None assert sender.capturer.microphone is None

View File

@@ -50,3 +50,58 @@ def test_get_config_casts_default_when_env_missing(monkeypatch):
result = get_config(None, "GET_CONFIG_MISSING", "42", int) result = get_config(None, "GET_CONFIG_MISSING", "42", int)
assert result == 42 assert result == 42
def test_get_config_unset_boolean_default(monkeypatch):
"""
When the env var is a boolean, and it's not set, ensure it uses the default value.
"""
monkeypatch.delenv("SOME_BOOLEAN_VARIABLE", raising=False)
result = get_config(None, "SOME_BOOLEAN_VARIABLE", False, bool)
assert result == False
result = get_config(None, "SOME_BOOLEAN_VARIABLE", True, bool)
assert result == True
def test_get_config_true_boolean(monkeypatch):
"""
When the env var is a boolean, and its value is "true", "TRUE", "yes", etc., it should return true.
"""
monkeypatch.setenv("SOME_BOOLEAN_VARIABLE", "TRUE")
result = get_config(None, "SOME_BOOLEAN_VARIABLE", False, bool)
assert result == True
monkeypatch.setenv("SOME_BOOLEAN_VARIABLE", "true")
result = get_config(None, "SOME_BOOLEAN_VARIABLE", False, bool)
assert result == True
monkeypatch.setenv("SOME_BOOLEAN_VARIABLE", "yes")
result = get_config(None, "SOME_BOOLEAN_VARIABLE", False, bool)
assert result == True
monkeypatch.setenv("SOME_BOOLEAN_VARIABLE", "YES")
result = get_config(None, "SOME_BOOLEAN_VARIABLE", False, bool)
assert result == True
monkeypatch.setenv("SOME_BOOLEAN_VARIABLE", "TrUE")
result = get_config(None, "SOME_BOOLEAN_VARIABLE", False, bool)
assert result == True
def test_get_config_false_boolean(monkeypatch):
"""
When the env var is a boolean, and its value is not "true", "TRUE", "yes", etc., it should return False.
"""
monkeypatch.setenv("SOME_BOOLEAN_VARIABLE", "FALSE")
result = get_config(None, "SOME_BOOLEAN_VARIABLE", True, bool)
assert result == False
monkeypatch.setenv("SOME_BOOLEAN_VARIABLE", "false")
result = get_config(None, "SOME_BOOLEAN_VARIABLE", True, bool)
assert result == False
monkeypatch.setenv("SOME_BOOLEAN_VARIABLE", "anything, tbh")
result = get_config(None, "SOME_BOOLEAN_VARIABLE", True, bool)
assert result == False

View File

@@ -62,7 +62,7 @@ def test_get_qi_session_runtime_error(monkeypatch):
raise RuntimeError("boom") raise RuntimeError("boom")
class FakeQi: class FakeQi:
Application = lambda self=None: FakeApp() Application = lambda *args, **kwargs: FakeApp()
reload_qi_utils_with(FakeQi()) reload_qi_utils_with(FakeQi())
@@ -87,7 +87,7 @@ def test_get_qi_session_success(monkeypatch):
return True return True
class FakeQi: class FakeQi:
Application = lambda self=None: FakeApp() Application = lambda *args, **kwargs: FakeApp()
reload_qi_utils_with(FakeQi()) reload_qi_utils_with(FakeQi())

View File

@@ -5,167 +5,128 @@ University within the Software Project course.
© Copyright Utrecht University (Department of Information and Computing Sciences) © Copyright Utrecht University (Department of Information and Computing Sciences)
""" """
import struct
import mock import mock
import pytest import pytest
import zmq import zmq
from robot_interface.endpoints.video_sender import VideoSender from robot_interface.endpoints.video_sender import VideoSender
from robot_interface.state import state
from robot_interface.core.config import settings
@pytest.fixture @pytest.fixture
def zmq_context(): def zmq_context():
""" """Provide a ZMQ context."""
Yields a real ZMQ context for socket creation. yield zmq.Context()
"""
context = zmq.Context()
yield context
context.term()
def test_init_defaults(zmq_context, mocker):
"""
Test initialization of the VideoSender.
"""
# We patch settings to ensure valid port access inside the class logic,
# although the default arg is evaluated at import time.
mocker.patch("robot_interface.endpoints.video_sender.settings")
mock_zmq = mock.Mock()
sender = VideoSender(mock_zmq)
# Verify socket type is PUB
assert sender.identifier == "video"
def test_start_no_qi_session(mocker): def _patch_basics(mocker):
""" """Common patches: prevent real threads, port binds, and state errors."""
Test that the loop does not start if no Qi session is available. mocker.patch("robot_interface.endpoints.socket_base.zmq.Socket.bind")
""" mocker.patch("robot_interface.endpoints.video_sender.threading.Thread")
# Mock state to return None for qi_session mocker.patch.object(state, "is_initialized", True)
mock_state = mocker.patch("robot_interface.endpoints.video_sender.state")
mock_state.qi_session = None
mock_threading = mocker.patch("robot_interface.endpoints.video_sender.threading")
mock_zmq = mock.Mock()
sender = VideoSender(mock_zmq) def _patch_exit_event(mocker):
"""Make exit_event stop the loop after one iteration."""
fake_event = mock.Mock()
fake_event.is_set.side_effect = [False, True]
mocker.patch.object(state, "exit_event", fake_event)
def test_no_qi_session(zmq_context, mocker):
"""Video loop should not start without a qi_session."""
_patch_basics(mocker)
mocker.patch.object(state, "qi_session", None)
sender = VideoSender(zmq_context)
sender.start_video_rcv() sender.start_video_rcv()
# Assertions assert not hasattr(sender, "thread")
mock_threading.Thread.assert_not_called()
def test_start_success(mocker):
""" def test_video_streaming(zmq_context, mocker):
Test successful startup of the video receiver thread. """VideoSender should send retrieved image data."""
""" _patch_basics(mocker)
# Mock the Qi Session and Service _patch_exit_event(mocker)
mock_state = mocker.patch("robot_interface.endpoints.video_sender.state")
mock_session = mock.Mock() # Pepper's image buffer lives at index 6
mock_state.qi_session = mock_session mocker.patch.object(settings.video_config, "image_buffer", 6)
test_width = 320
test_height = 240
mock_video_service = mock.Mock() mock_video_service = mock.Mock()
mock_session.service.return_value = mock_video_service mock_video_service.getImageRemote.return_value = [test_width, test_height, None, None, None, None, b"fake_img"]
mock_video_service.subscribeCamera.return_value = "test_subscriber_id"
# Mock Settings fake_session = mock.Mock()
mock_settings = mocker.patch("robot_interface.endpoints.video_sender.settings") fake_session.service.return_value = mock_video_service
mock_settings.video_config.camera_index = 0 mocker.patch.object(state, "qi_session", fake_session)
mock_settings.video_config.resolution = 2
mock_settings.video_config.color_space = 11
mock_settings.video_config.fps = 30
mock_settings.video_config.stream_name = "test_stream"
mock_threading = mocker.patch("robot_interface.endpoints.video_sender.threading") mocker.patch.object(
fake_session.service("ALVideoDevice"),
"subscribeCamera",
return_value="stream_name"
)
sender = VideoSender(zmq_context)
send_socket = mock.Mock()
sender.socket.send_multipart = send_socket
# Run
mock_zmq = mock.Mock()
sender = VideoSender(mock_zmq)
sender.start_video_rcv() sender.start_video_rcv()
sender.video_rcv_loop(mock_video_service, "stream_name")
# Assertions send_socket.assert_called_with([
mock_session.service.assert_called_with("ALVideoDevice") struct.pack('<I', 320),
mock_video_service.subscribeCamera.assert_called_with("test_stream", 0, 2, 11, 30) struct.pack('<I', 240),
b"fake_img"
])
def test_video_receive_error(zmq_context, mocker):
"""Errors retrieving images should not call send()."""
_patch_basics(mocker)
_patch_exit_event(mocker)
mock_video_service = mock.Mock()
mock_video_service.getImageRemote.side_effect = Exception("boom")
fake_session = mock.Mock()
fake_session.service.return_value = mock_video_service
mocker.patch.object(state, "qi_session", fake_session)
mocker.patch.object(
fake_session.service("ALVideoDevice"),
"subscribeCamera",
return_value="stream_name"
)
sender = VideoSender(zmq_context)
send_socket = mock.Mock()
sender.socket.send_multipart = send_socket
sender.start_video_rcv()
sender.video_rcv_loop(mock_video_service, "stream_name")
send_socket.assert_not_called()
def test_video_loop_keyboard_interrupt(zmq_context, mocker):
"""Video loop should handle KeyboardInterrupt gracefully and unsubscribe."""
_patch_basics(mocker)
_patch_exit_event(mocker)
mock_threading.Thread.assert_called_once() # We mock the video service to raise KeyboardInterrupt when accessed
# Verify arguments passed to the thread target mock_video_service = mock.Mock()
call_args = mock_threading.Thread.call_args[1] mock_video_service.getImageRemote.side_effect = KeyboardInterrupt
assert call_args["target"] == sender.video_rcv_loop
assert call_args["args"] == (mock_video_service, "test_subscriber_id")
# Ensure thread was started # Mock logging to verify the specific interrupt message is logged
mock_threading.Thread.return_value.start.assert_called_once() mock_logger = mocker.patch("robot_interface.endpoints.video_sender.logging")
def test_video_loop_happy_path(mocker): sender = VideoSender(zmq_context)
"""
Test the main loop: Wait -> Get Image -> Send -> Repeat/Exit.
"""
# Mock settings for image buffer index
mock_settings = mocker.patch("robot_interface.endpoints.video_sender.settings")
mock_settings.video_config.image_buffer = 6
# Mock Video Service to return a fake image structure
# Standard NaoQi image is a list, binary data is usually at index 6
fake_image_data = b"binary_jpeg_data"
fake_image_list = [0] * 7
fake_image_list[6] = fake_image_data
mock_service = mock.Mock() # Execute the loop
mock_service.getImageRemote.return_value = fake_image_list sender.video_rcv_loop(mock_video_service, "stream_name")
# Mock Events: # Verify the 'finally' block executed (unsubscribe)
# exit_event: False (start), False (loop once), True (break) mock_video_service.unsubscribe.assert_called_with("stream_name")
mock_state = mocker.patch("robot_interface.endpoints.video_sender.state") mock_logger.info.assert_any_call("Unsubscribed from video stream.")
mock_state.exit_event.is_set.side_effect = [False, False, True]
# Run
mock_zmq = mock.Mock()
sender = VideoSender(mock_zmq)
sender.socket = mock.Mock() # Mock the socket to verify send
sender.video_rcv_loop(mock_service, "sub_id")
# Assertions
mock_state.active_event.wait.assert_called()
mock_service.getImageRemote.assert_called_with("sub_id")
sender.socket.send.assert_called_with(fake_image_data)
def test_video_loop_exit_during_wait(zmq_context, mocker):
"""
Test that the loop breaks immediately if exit_event is set while waiting.
"""
mock_service = mock.Mock()
mock_state = mocker.patch("robot_interface.endpoints.video_sender.state")
# 1. Loop check: False (enter loop)
# 2. Wait happens (mock returns instantly)
# 3. Post-wait check: True (break)
mock_state.exit_event.is_set.side_effect = [False, True]
mock_zqm = mock.Mock()
sender = VideoSender(mock_zqm)
sender.video_rcv_loop(mock_service, "sub_id")
# Assert we never tried to get an image
mock_service.getImageRemote.assert_not_called()
def test_video_loop_exception_handling(zmq_context, mocker):
"""
Test that exceptions during image retrieval are caught and logged,
and do not crash the thread.
"""
mock_settings = mocker.patch("robot_interface.endpoints.video_sender.settings")
mock_service = mock.Mock()
# First call raises Exception, Second call works (if we allowed it, but we exit)
mock_service.getImageRemote.side_effect = Exception("Camera disconnected")
mock_state = mocker.patch("robot_interface.endpoints.video_sender.state")
# Loop runs once then exits
mock_state.exit_event.is_set.side_effect = [False, False, True]
mock_zmq = mock.Mock()
sender = VideoSender(mock_zmq)
sender.socket = mock.Mock()
sender.video_rcv_loop(mock_service, "sub_id")
# Assertions
# Ensure loop didn't crash; it should have completed the iteration and checked exit_event
assert mock_state.exit_event.is_set.call_count >= 2