8. Interactive Secondary Development Guide
8. Interactive Secondary Development Guide
There are two development modes for interaction-related secondary development on the device side:
- Simple extension of AgiBot interaction
- AgiBot interaction capabilities are retained. AgiBot provides basic interfaces such as TTS and motion/emoticon playback. These interfaces can be used to operate the robot for basic showroom guidance, presentations, and similar flows, while the robot continues to use the standard AgiBot voice interaction solution.
- Full takeover by secondary development
- AgiBot interaction capabilities are disabled. AgiBot only provides robot microphone input after onboard noise reduction, and the entire voice interaction flow is fully taken over by the secondary development program.
The following table compares the two modes in detail:
| Mode | Are AgiBot interaction capabilities retained? | Interfaces provided by AgiBot | Network requirement | Typical scenarios | Development difficulty and workload |
|---|---|---|---|---|---|
| Simple extension of AgiBot interaction | Yes | TTS playback, speaker playback | Both the AgiBot interaction solution and the TTS interface require network access | Developing simple showroom guidance or demo flows with fixed process orchestration | Relatively simple. Workload depends on workflow complexity and is usually small. |
| Full takeover by secondary development | No | Noise-reduced audio, speaker | Network is required during robot initialization, but later use does not require network access | Developing a complete custom interaction solution | More difficult, requiring a complete related R&D team and a larger workload |
8.1 Simple Extension of AgiBot Interaction
In this mode, you can use interfaces provided by AgiBot such as emoticon playback, motion playback, TTS playback, and face recognition to implement certain features. However, most interfaces are not yet open, and more details will be provided after the related interfaces are released.
8.2 Full Takeover by Secondary Development
Interactive secondary development in this mode involves three parts: disabling AgiBot interaction capabilities, obtaining microphone audio, and using the speaker. The following sections describe these three parts.
8.2.1 Disable AgiBot Interaction Capabilities
In this mode, the AgiBot interaction program must exit the interaction chain, only output microphone audio after onboard noise reduction and face recognition results, and release control of the speaker. Two operations are required:
- Set the
agentmodule toonly_voiceorvoice_facemode by using theSetAgentPropertiesRequestinterface in the microphone management section.
only_voice: only outputs noise-reduced microphone audio from/agent/process_audio_output, and all downstream links are disconnected.voice_face: outputs noise-reduced microphone audio from/agent/process_audio_outputand face recognition results from/agent/vision/face_id, and all downstream links are disconnected.
For example, the following RPC sets the mode toonly_voice:bashcurl -i \ -H 'content-type:application/json' \ -X POST 'http://10.42.10.10:59301/rpc/aimdk.protocol.AgentControlService/SetAgentPropertiesRequest' \ -d '{ "contents": { "properties": { "2": "only_voice" } } }'
The change takes effect only after the robot is restarted. You can wait until the second modification is complete and restart once at the end. After the restart, you can use theGetAgentPropertiesRequestinterface in the microphone management section to query the current interaction mode and verify whether the change succeeded. To restore the original mode, change"only_voice"or"voice_face"in theSetAgentPropertiesRequestinterface back to"normal".
8.2.2 Obtain Microphone Audio
Note: To obtain the following audio, the robot must remain online for at least 2 minutes after boot so that audio-related authentication can complete. Otherwise, no raw audio will be output. If offline use is required, first make sure this interface has audio output and then disconnect the network.
After exiting the interaction chain, you can use the noise-reduced microphone audio Topic interface /agent/process_audio_output to obtain microphone audio after onboard noise reduction. An example program for obtaining onboard noise-reduced microphone audio is shown below:
#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from rclpy.qos import QoSHistoryPolicy, QoSProfile, QoSReliabilityPolicy
from ros2_plugin_proto.msg import RosMsgWrapper
from aimdk.protocol_pb2 import ProcessedAudioOutput, AudioVADState
import datetime
import os
class AudioSubscriber(Node):
def __init__(self):
super().__init__("audio_subscriber")
# Audio buffers, stored separately by stream_id
self.audio_buffers = {} # {stream_id: bytearray()}
self.recording_state = {} # {stream_id: bool}, whether recording is active
# Create the output directory for audio files
self.audio_output_dir = "audio_recordings"
os.makedirs(self.audio_output_dir, exist_ok=True)
qos_profile = QoSProfile(
history=QoSHistoryPolicy.KEEP_LAST,
depth=10,
reliability=QoSReliabilityPolicy.BEST_EFFORT,
)
self.subscription = self.create_subscription(
RosMsgWrapper,
"/agent/process_audio_output/pb_3Aaimdk_2Eprotocol_2EProcessedAudioOutput",
self.audio_callback,
qos_profile,
)
self.get_logger().info("Started subscribing to noise-reduced audio data...")
def audio_callback(self, msg):
try:
# Check whether the serialization type is pb
if msg.serialization_type != "pb":
self.get_logger().warn(f"Unsupported serialization type: {msg.serialization_type}")
return
# Convert the data field from list[bytes] to bytes
audio_data_bytes = b"".join(msg.data)
# Parse the message using the generated protobuf class
processed_audio = ProcessedAudioOutput()
processed_audio.ParseFromString(audio_data_bytes)
self.get_logger().info(
f"Received audio data: stream_id={processed_audio.stream_id}, "
f"vad_state={processed_audio.vad_state}, "
f"audio_size={len(processed_audio.audio_data)} bytes"
)
# Process audio according to the VAD state
self.handle_vad_state(processed_audio)
except Exception as e:
self.get_logger().error(f"Error while processing audio message: {e}")
def handle_vad_state(self, processed_audio):
"""Handle different VAD states."""
vad_state = processed_audio.vad_state
stream_id = processed_audio.stream_id
audio_data = processed_audio.audio_data
# Initialize the buffer for this stream_id if it does not exist
if stream_id not in self.audio_buffers:
self.audio_buffers[stream_id] = bytearray()
self.recording_state[stream_id] = False
# Mapping of VAD state names
vad_state_names = {
AudioVADState.AUDIO_VAD_STATE_NONE: "No speech",
AudioVADState.AUDIO_VAD_STATE_BEGIN: "Speech begin",
AudioVADState.AUDIO_VAD_STATE_PROCESSING: "Speech processing",
AudioVADState.AUDIO_VAD_STATE_END: "Speech end",
}
stream_names = {1: "Internal microphone", 2: "External microphone"}
self.get_logger().info(
f"[{stream_names.get(stream_id, f'Unknown stream {stream_id}')}] "
f"VAD state: {vad_state_names.get(vad_state, f'Unknown state {vad_state}')} "
f"audio data: {len(audio_data)} bytes"
)
# Process audio data according to the VAD state
if vad_state == AudioVADState.AUDIO_VAD_STATE_BEGIN:
self.get_logger().info("Speech start detected")
# Start a new recording and clear the buffer
self.audio_buffers[stream_id].clear()
self.recording_state[stream_id] = True
# Append the current audio data
if len(audio_data) > 0:
self.audio_buffers[stream_id].extend(audio_data)
elif vad_state == AudioVADState.AUDIO_VAD_STATE_PROCESSING:
self.get_logger().info("Speech is being processed...")
# If recording is active, continue appending audio data
if self.recording_state[stream_id] and len(audio_data) > 0:
self.audio_buffers[stream_id].extend(audio_data)
elif vad_state == AudioVADState.AUDIO_VAD_STATE_END:
self.get_logger().info("Speech ended")
# Append the final chunk of audio data
if self.recording_state[stream_id] and len(audio_data) > 0:
self.audio_buffers[stream_id].extend(audio_data)
# Save the complete audio segment
if (
self.recording_state[stream_id]
and len(self.audio_buffers[stream_id]) > 0
):
self.save_audio_segment(bytes(self.audio_buffers[stream_id]), stream_id)
# Stop recording
self.recording_state[stream_id] = False
elif vad_state == AudioVADState.AUDIO_VAD_STATE_NONE:
# No speech state, do not record
if self.recording_state[stream_id]:
self.get_logger().info("Recording state reset")
self.recording_state[stream_id] = False
# Output the current buffer status
if stream_id in self.audio_buffers:
buffer_size = len(self.audio_buffers[stream_id])
recording = self.recording_state[stream_id]
self.get_logger().debug(
f"[Stream {stream_id}] Buffer size: {buffer_size} bytes, recording: {recording}"
)
def save_audio_segment(self, audio_data, stream_id):
"""Save the audio segment as 16 kHz, 16-bit, mono PCM."""
if len(audio_data) > 0:
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
# Create a subdirectory by stream_id
stream_dir = os.path.join(self.audio_output_dir, f"stream_{stream_id}")
os.makedirs(stream_dir, exist_ok=True)
# Generate the file name
stream_names = {1: "internal_mic", 2: "external_mic"}
stream_name = stream_names.get(stream_id, f"stream_{stream_id}")
filename = f"{stream_name}_{timestamp}.pcm"
filepath = os.path.join(stream_dir, filename)
try:
with open(filepath, "wb") as f:
f.write(audio_data)
self.get_logger().info(
f"Audio segment saved: {filepath} (size: {len(audio_data)} bytes)"
)
# Calculate the audio duration (assuming 16 kHz, 16-bit, mono)
sample_rate = 16000
bits_per_sample = 16
channels = 1
bytes_per_sample = bits_per_sample // 8
total_samples = len(audio_data) // (bytes_per_sample * channels)
duration_seconds = total_samples / sample_rate
self.get_logger().info(
f"Audio duration: {duration_seconds:.2f} s ({total_samples} samples)"
)
except Exception as e:
self.get_logger().error(f"Failed to save audio file: {e}")
def get_buffer_info(self):
"""Get information about all buffers for debugging."""
info = {}
for stream_id in self.audio_buffers:
info[stream_id] = {
"buffer_size": len(self.audio_buffers[stream_id]),
"recording": self.recording_state[stream_id],
}
return info
def main(args=None):
rclpy.init(args=args)
audio_subscriber = AudioSubscriber()
try:
audio_subscriber.get_logger().info("Listening for noise-reduced audio data. Press Ctrl+C to exit...")
rclpy.spin(audio_subscriber)
except KeyboardInterrupt:
audio_subscriber.get_logger().info("Exit signal received, shutting down...")
finally:
audio_subscriber.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()
The above program depends on the Python protocol package a3_aimdk and the ROS2 protocol package ros2_plugin_proto. These packages are already included in the prebuilt directory of the AimDK development package. Install the Python package with pip install prebuilt/a3_aimdk-3.0.0-py3-none-any.whl, and use the ROS2 package after running source prebuilt/ros2_plugin_proto_aarch64/share/ros2_plugin_proto/local_setup.bash.
Note that the above program receives ROS2 messages, so the following environment variables must be set:
source /agibot/software/v0/entry/env/env.sh
The audio data uses a 16 kHz sample rate and 16-bit mono PCM format in little-endian order. The output audio is clean human speech that has already been processed with noise reduction and echo cancellation, and it can be used directly for ASR.
The ProcessedAudioOutput message contains the following fields:
| Field Name | Type | Description |
|---|---|---|
| header | Header | Common message header containing the timestamp and message ID |
| stream_id | uint32 | Audio stream ID (1: internal microphone, 2: external microphone) |
| vad_state | AudioVADState | Voice activity detection state |
| audio_data | bytes | Noise-reduced PCM audio data |
Definition of the AudioVADState enum:
| Enum Value | Numeric Value | Description |
|---|---|---|
| AUDIO_VAD_STATE_NONE | 0 | No speech |
| AUDIO_VAD_STATE_BEGIN | 1 | Speech begin |
| AUDIO_VAD_STATE_PROCESSING | 2 | Speech processing |
| AUDIO_VAD_STATE_END | 3 | Speech end |
Note: In the current version, the vad_state output of the external microphone interface is incorrect. For one voice input, the expected state sequence is 122222222223, but the actual output is 0111111111112. This issue occurs only for the external microphone scenario; the internal microphone works normally. It is planned to be fixed in a future version. For the current version, it is recommended to manually apply a +1 compensation to the state.
8.2.3 Speaker Audio Playback
To use the speaker, you must first acquire audio focus:
ros2 service call /audio_5Fmsgs/srv/RequestAudioFocus audio_msgs/srv/RequestAudioFocus "{focus_requester: {pkg_name: audio_examples_sender, priority: 6, priority_weight: 1}}"
pkg_name is the ROS package name, which means the audio must ultimately be sent from within a ROS package project. priority can be fixed to 6, and priority_weight is the audio weight, which can be set to any value from 1 to 100.
After audio focus is acquired, the PCM stream returned by your own TTS can be played through the audio streaming playback interface. The following is an example of speaker streaming audio playback, which needs to be packaged as a ros_pkg.
#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from builtin_interfaces.msg import Time as TimeMsg
from audio_msgs.msg import AudioPlayback,AudioInfo,AudioData # Make sure audio messages can be imported
class AudioPlaybackPublisher(Node):
def __init__(self):
super().__init__('audio_playback_publisher')
self.pub = self.create_publisher(AudioPlayback, '/audiohal/audio/playback', 10)
self.timer = self.create_timer(1.0, self.timer_callback) # Send once per second
def timer_callback(self):
msg = AudioPlayback()
# Fill in the timestamp (the field name in attachments is stamps)
now = self.get_clock().now().to_msg()
# Compatibility: prefer stamps, otherwise try stamp
if hasattr(msg, 'stamps'):
msg.stamps = now
elif hasattr(msg, 'stamp'):
msg.stamp = now
# Fill in info (AudioInfo)
info = AudioInfo()
info.channels = 1 # Mono
info.sample_rate = 16000 # 16 kHz
info.sample_format = 'S16LE'
info.coding_format = 'pcm'
msg.info = info
# Fill in data (AudioData), example raw bytes
sample_bytes = b'\x00\x01\x02\x03\x04\x05\x06\x07' # Example data
msg.data = AudioData(data=list(sample_bytes))
msg.pkg_name = 'audio_examples_sender'
msg.token_id = 'token-0001'
self.pub.publish(msg)
self.get_logger().info(
f'Published AudioPlayback pkg={msg.pkg_name} token={msg.token_id} '
f'data_len={len(msg.data.data)} sample_rate={msg.info.sample_rate}'
)
def main(args=None):
rclpy.init(args=args)
node = AudioPlaybackPublisher()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
The above program is provided in the agent directory of the AimDK development package. It depends on the Python protocol package a3_aimdk and the ROS2 protocol package ros2_plugin_proto. These packages are already placed in the prebuilt directory of the AimDK development package. Install the Python package with pip install prebuilt/a3_aimdk-3.0.0-py3-none-any.whl, and use the ROS2 package after running source prebuilt/audio_msgs_proto_aarch64/share/audio_msgs/local_setup.bash.
8.3 Wake-Up Result Reporting
If agent is set to only_voice mode, AgiBot also provides wake-up result reporting. The Topic interface is /agent/wakeup/pb_3Aaimdk_2Eprotocol_2EWakeUpResult.
An example program for obtaining wake-up result reports is shown below:
#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from rclpy.qos import (
QoSDurabilityPolicy,
QoSHistoryPolicy,
QoSProfile,
QoSReliabilityPolicy,
)
from ros2_plugin_proto.msg import RosMsgWrapper
from aimdk.protocol_pb2 import WakeUpResult
TOPIC = "/agent/wakeup/pb_3Aaimdk_2Eprotocol_2EWakeUpResult"
class WakeUpSubscriber(Node):
def __init__(self):
super().__init__("wakeup_result_subscriber")
qos_profile = QoSProfile(
history=QoSHistoryPolicy.KEEP_LAST,
depth=10,
reliability=QoSReliabilityPolicy.RELIABLE,
durability=QoSDurabilityPolicy.VOLATILE,
)
self.subscription = self.create_subscription(
RosMsgWrapper,
TOPIC,
self.wakeup_callback,
qos_profile,
)
self.get_logger().info(f"Started subscribing to WakeUpResult: {TOPIC}")
def wakeup_callback(self, msg):
try:
if msg.serialization_type != "pb":
self.get_logger().warn(
f"Received unsupported serialization type: {msg.serialization_type}"
)
return
# Join bytes
raw_bytes = b"".join(msg.data)
# Parse protobuf
wakeup_result = WakeUpResult()
wakeup_result.ParseFromString(raw_bytes)
# Log output
import json
from google.protobuf.json_format import MessageToDict
self.get_logger().info(
f"WakeUpResult: {json.dumps(MessageToDict(wakeup_result, preserving_proto_field_name=True), ensure_ascii=False, indent=2)}"
)
except Exception as e:
self.get_logger().error(f"Error while parsing WakeUpResult data: {e}")
def main(args=None):
rclpy.init(args=args)
node = WakeUpSubscriber()
try:
node.get_logger().info("Listening for WakeUpResult. Press Ctrl+C to exit...")
rclpy.spin(node)
except KeyboardInterrupt:
node.get_logger().info("Exiting...")
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()
The above program depends on the Python protocol package a3_aimdk and the ROS2 protocol package ros2_plugin_proto. These packages are already included in the prebuilt directory of the AimDK development package. Install the Python package with pip install prebuilt/a3_aimdk-3.0.0-py3-none-any.whl, and use the ROS2 package after running source prebuilt/ros2_plugin_proto_aarch64/share/ros2_plugin_proto/local_setup.bash.
Note that the above program receives ROS2 messages, so the following environment variables must be set:
source /agibot/software/v0/entry/env/env.sh
8.4 Face Recognition Results
If agent is set to voice_face mode, the face recognition result Topic interface /agent/vision/face_id is returned in addition to the noise-reduced microphone audio Topic interface. This message is not available in only_voice mode.
An example program for obtaining face recognition results is shown below:
#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from rclpy.qos import QoSHistoryPolicy, QoSProfile, QoSReliabilityPolicy
from ros2_plugin_proto.msg import RosMsgWrapper
from aimdk.protocol_pb2 import FaceIdResult
class FaceIdSubscriber(Node):
def __init__(self):
super().__init__("face_id_subscriber")
qos_profile = QoSProfile(
history=QoSHistoryPolicy.KEEP_LAST,
depth=10,
reliability=QoSReliabilityPolicy.BEST_EFFORT,
)
self.subscription = self.create_subscription(
RosMsgWrapper,
"/agent/vision/face_id/pb_3Aaimdk_2Eprotocol_2EFaceIdResult",
self.face_id_callback,
qos_profile,
)
self.get_logger().info("Started subscribing to FaceID data...")
def face_id_callback(self, msg):
try:
if msg.serialization_type != "pb":
self.get_logger().warn(
f"Received unsupported serialization type: {msg.serialization_type}"
)
return
# Join bytes
raw_bytes = b"".join(msg.data)
# Parse protobuf
face_id_result = FaceIdResult()
face_id_result.ParseFromString(raw_bytes)
# Log output
import json
from google.protobuf.json_format import MessageToDict
self.get_logger().info(
f"FaceID result: {json.dumps(MessageToDict(face_id_result, preserving_proto_field_name=True), ensure_ascii=False, indent=2)}")
except Exception as e:
self.get_logger().error(f"Error while parsing FaceID data: {e}")
def main(args=None):
rclpy.init(args=args)
node = FaceIdSubscriber()
try:
node.get_logger().info("Listening for FaceID data. Press Ctrl+C to exit...")
rclpy.spin(node)
except KeyboardInterrupt:
node.get_logger().info("Exiting...")
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()
The above program depends on the Python protocol package a3_aimdk and the ROS2 protocol package ros2_plugin_proto. These packages are already included in the prebuilt directory of the AimDK development package. Install the Python package with pip install prebuilt/a3_aimdk-3.0.0-py3-none-any.whl, and use the ROS2 package after running source prebuilt/ros2_plugin_proto_aarch64/share/ros2_plugin_proto/local_setup.bash.
Note that the above program receives ROS2 messages, so the following environment variables must be set:
source /agibot/software/v0/entry/env/env.sh