The brain PC · Chapter 27 · Time: 3 hours · Level: Intermediate · Status: Partly test-built
The robot's reSpeaker XVF3800 array becomes Buddy's ears and mouth. Its speech beam streams over Wi-Fi into bigbuddy as the PipeWire source robot_mic, Buddy's replies come back to the array's speaker, and a 4-second measurement tells you whether sound is really arriving.
Buddy's brain runs on bigbuddy, but Buddy listens and speaks through the robot. A Seeed reSpeaker XVF3800 microphone
array sits on the robot's USB, with a small speaker on the array's own connector. The robot's API streams what the
array hears to bigbuddy and plays whatever bigbuddy sends back. It is built this way because the robot is
meant to be Buddy's body (decision 2026-10-06): the ears and the mouth go where the body is, and the GPU work stays
on bigbuddy until the robot can carry it.
This chapter builds the chain hop by hop, in the order that works on a fresh install. Every step was done on this
robot between 2026-10-06 and 2026-10-07. Two things were not: pinning the Seeed tools to a commit with a full clone,
and switching Buddy's ears back to bigbuddy since the last change to bigbuddy's mic setting. Both are marked.
| Hop | Machine | What it is | Where it is defined |
|---|---|---|---|
| 1 | robot | the array: 4 mics, on-board beamformer, USB audio card "Array" | Seeed firmware 2.1.1 |
| 2 | robot | PulseAudio for user burgerbarn, running without a login | stock Ubuntu package + loginctl enable-linger |
| 3 | robot | GET /audio: the right USB channel as a raw stream |
buddy_link/robot_api.py stream_audio() |
| 4 | bigbuddy | robot-mic.service: curl writes the stream into a FIFO |
~/.config/systemd/user/robot-mic.service (live only) |
| 5 | bigbuddy | robot_mic: PipeWire reads the FIFO as a microphone |
~/.config/pipewire/pipewire.conf.d/55-robot-mic.conf (live only) |
| 6 | bigbuddy | Buddy reads robot_mic |
drop-in 60-mic.conf, voice/buddy_voice.py Mic |
| 7 | bigbuddy, robot | Buddy's reply goes to POST /play, which plays it on the array |
buddy_voice.py _out(), robot_api.py normalize_wav() |
New idea: a microphone array
One microphone hears everything around it equally. An array has several microphones a few centimetres apart.
Sound from one direction reaches each microphone at a slightly different time. A chip on the board (an XMOS
XVF3800) uses those differences to "point" a virtual microphone, a beam, at the person talking and to
suppress sound from other directions. The same timing gives a direction of arrival (DoA), the angle the
voice came from. The array also has an echo canceller: if it is given a copy of what its own speaker plays
(the reference), it subtracts that from what the microphones hear. The XVF3800 sends two processed channels
over USB. On this robot the right channel is the speech-recognition (ASR) output of the automatically chosen
beam, and that is the only channel Buddy uses.
New idea: raw audio, rms and dBFS
Digital audio here is a list of 16-bit signed numbers (s16le: signed 16-bit, little-endian), 16,000 of them
per second per channel (16 kHz). Mono 16 kHz s16le is 32,000 bytes per second. The rms (root mean square)
of a stretch of samples is its average loudness in the same units: 0 is silence, 32,767 is the loudest
possible. dBFS is the same thing on a log scale, relative to full scale: 0 dBFS is the maximum, -48 dBFS is
a quiet room, and about -93 dBFS is an rms of 0.7, which is digital silence. You will use rms in this chapter
to tell "the chain works but the room is quiet" from "nothing is coming through".
The array is USB ID 2886:001a. The speaker plugs into the JST connector on the array itself, not into the robot.
Do not hold the Mute button while plugging in
Holding Mute while the array powers up boots its factory safe image: USBbcdDevice 0.03, no sound card, a
flashing red LED, and every host command fails with "Check the audio loop is active." Unplug it and plug it in
again without touching Mute.
On the robot:
lsusb | grep Seeed
cat /proc/asound/cards
Check
lsusblists the array and/proc/asound/cardshas a card calledArray. The second line of the card entry
says the USB speed. Output on 2026-10-07:Bus 001 Device 012: ID 2886:001a Seeed Technology Co., Ltd. reSpeaker XVF3800 4-Mic Array 0 [Array ]: USB-Audio - reSpeaker XVF3800 4-Mic Array Seeed Studio reSpeaker XVF3800 4-Mic Array at usb-3610000.usb-2.3, high speed
high speedis USB 2.0 at 480 Mb/s, which is what the array needs.usb-2.3is the port; on this robot the
array's sysfs path is/sys/bus/usb/devices/1-2.3. Find yours in the kernel log with
journalctl -k -b --no-pager -o cat | grep -i "seeed\|2886".
The robot runs the stock Ubuntu PulseAudio (pulseaudio 1:15.99.1+dfsg1-1ubuntu2.2, with pactl, parec and
paplay from pulseaudio-utils). Nothing extra is installed. The robot API records and plays through it, so the
array is never opened twice.
New idea: a per-user audio server and linger
PulseAudio is not a system service. It runs inside the user's own systemd instance (systemctl --user), which
normally starts when the user logs in and stops when they log out. The robot boots to a text console with
nobody logged in (chapter 6), so without help there is no user systemd and no PulseAudio. Linger tells
systemd to start the user's instance at boot and keep it running with no login. The robot API is a system
service running asburgerbarn; it finds that user's PulseAudio through/run/user/1000/pulse/native.
On the robot:
sudo loginctl enable-linger burgerbarn
loginctl show-user burgerbarn -p Linger
Check
Linger=yesThen confirm PulseAudio runs and sees the array. In an ssh session, set
XDG_RUNTIME_DIRfirst; every audio
command in the record does:On the robot:
export XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user is-active pulseaudio.socket pulseaudio.service pactl list short sources | cut -f2,5Expected (2026-10-07):
activetwice, and among the sources
alsa_input.usb-Seeed_Studio_reSpeaker_XVF3800_4-Mic_Array_101991441262500220-00.analog-stereowith state
RUNNINGwhile the robot API streams it (on this robot that is all the time once chapter 27 is done, because
bigbuddy'srobot-mic.servicekeeps the stream open). The number in the name is the array's serial; yours will
differ.
Why linger, and why no desktop
Until 2026-10-06 the robot booted into the GNOME login screen. The greeter cost about 340 MB and its own
PulseAudio held the reSpeaker's control device (docs/decisions.md2026-10-06). The robot now boots to
multi-user.target, and linger (enabled 2026-10-06 17:24 UTC) givesburgerbarnan audio server without a
login.
Seeed publishes the host tools and firmware in one GitHub repository. Three things come from it: xvf_host, a
compiled control tool for the Jetson; python_control/xvf_host.py, a Python control tool that knows the newer
commands; and the firmware images.
This is the command that was run on 2026-10-06. It is a shallow clone of whatever was newest that day:
On the robot:
mkdir -p ~/tools && cd ~/tools && { [ -d reSpeaker_XVF3800_USB_4MIC_ARRAY ] || git clone -q --depth 1 https://github.com/respeaker/reSpeaker_XVF3800_USB_4MIC_ARRAY.git; }
git -C ~/tools/reSpeaker_XVF3800_USB_4MIC_ARRAY log -1 --format="%h %ci %s"
Check
The clone on the robot is at this commit (read 2026-10-07):4b49bfd 2026-09-29 11:17:55 +0800 docs: document six-channel USB output routing in Python host README
Not test-built: pinning the commit
The shallow clone gets Seeed's newest commit, which may no longer be4b49bfdwhen you run it. To get exactly
what this robot has, clone in full and check out that commit:
git clone https://github.com/respeaker/reSpeaker_XVF3800_USB_4MIC_ARRAY.git && git -C reSpeaker_XVF3800_USB_4MIC_ARRAY checkout 4b49bfd.
That form was not run on this robot. No script in the repo pins it (survey_live_robot.md, "Pin the
hand-cloned sources").
On the robot:
cd ~/tools/reSpeaker_XVF3800_USB_4MIC_ARRAY/host_control/jetson && chmod +x xvf_host
sudo ./xvf_host VERSION
Check
The tool talks to the array over USB and needssudoon the robot (there is no udev rule for the array
there). A new array prints its factory firmware:Device (USB)::device_init() -- Found device VID: 10374 PID: 26 interface: 3 VERSION 2 0 610374 and 26 are
0x2886and0x001ain decimal. Both arrays this robot has had arrived at 2.0.6.
The Jetson xvf_host binary predates the codec level commands that firmware 2.1.1 added. Only the Python tool knows
them, and it needs two packages that system Python does not have:
ModuleNotFoundError: No module named 'usb'
That was the first try, 2026-10-06. Give the tool its own venv. The robot already has python3.10-venv from the
voice work on 2026-09-28; if yours does not, install it first.
On the robot:
sudo apt-get install -y python3.10-venv
cd ~/tools && { [ -d xvfenv ] || python3 -m venv xvfenv; } && ./xvfenv/bin/pip install -q pyusb libusb-package
~/tools/xvfenv/bin/pip list 2>/dev/null | grep -i usb
Check
libusb-package 1.0.30.0 pyusb 1.3.1
If it fails
xvf_host.py: error: unrecognized arguments: 9: the Python tool takes values with--values, not as a
bare argument. Writexvf_host.py AIC3104_HP_LEVEL --values 9.- A permission error from pyusb: run the Python tool with
sudo, assudo ~/tools/xvfenv/bin/python xvf_host.py ....
On the robot:
sudo apt-get install -y -q dfu-util
dpkg-query -W dfu-util
Check
dfu-util 0.9-1
Firmware 2.1.1 adds the codec output level commands (AIC3104_HP_LEVEL, AIC3104_LINEOUT_LEVEL, range 0 to 9).
On 2.0.6 the codec output is fixed at level 6; on 2.1.1 you can set 9, the maximum, which is what this robot uses.
New idea: DFU
DFU (Device Firmware Upgrade) is a standard USB way to replace a device's firmware while it stays plugged in.
The array exposes two DFU targets: alt setting 0 is the factory image, alt setting 1 is the upgrade
slot.dfu-util -a 1writes to the upgrade slot,-edetaches the device into DFU mode first, and
-Rresets it afterwards so it boots the new image.
The folder xmos_firmwares/usb/ has three 2.1.1 files. Use the plain respeaker_xvf3800_usb_dfu_firmware_v2.1.1.bin
(933,888 bytes). That is the one flashed here; it gives the 2-channel 16 kHz card the rest of the chain expects.
The _16k6ch and _48k2ch variants were not used.
Confirm the array is visible to dfu-util:
On the robot:
sudo dfu-util -l 2>&1 | grep -i 2886
Output for array #2 before its flash:
Found DFU: [2886:001a] ver=0206, devnum=10, cfg=1, intf=4, path="1-2.3", alt=1, name="reSpeaker DFU Upgrade", serial="101991441262500220"
Found DFU: [2886:001a] ver=0206, devnum=10, cfg=1, intf=4, path="1-2.3", alt=0, name="reSpeaker DFU Factory", serial="101991441262500220"
Stop everything that has the array open: the robot API and PulseAudio. Then check that nothing holds the
sound devices.
On the robot:
export XDG_RUNTIME_DIR=/run/user/$(id -u)
sudo systemctl stop rosorin-api
systemctl --user stop pulseaudio.socket pulseaudio.service; sleep 2
sudo fuser -v /dev/snd/* 2>&1 | grep -v "^ *$" | grep -v USER || echo "nothing holds /dev/snd"
Flash:
On the robot:
sudo dfu-util -R -e -a 1 -D ~/tools/reSpeaker_XVF3800_USB_4MIC_ARRAY/xmos_firmwares/usb/respeaker_xvf3800_usb_dfu_firmware_v2.1.1.bin
Start PulseAudio and the API again:
On the robot:
systemctl --user start pulseaudio.socket pulseaudio.service
sudo systemctl start rosorin-api
Read the version and the USB speed:
On the robot:
sudo ~/tools/reSpeaker_XVF3800_USB_4MIC_ARRAY/host_control/jetson/xvf_host VERSION
cat /sys/bus/usb/devices/1-2.3/speed
Check
The flash ends like this (2026-10-07, array #2):nothing holds /dev/snd Copying data from PC to DFU device state(7) = dfuMANIFEST, status(0) = No error condition is present state(2) = dfuIDLE, status(0) = No error condition is present Done! Resetting USB to switch back to runtime modeThen
VERSION 2 1 1. The speed must read480. On this robot it read12after three of the recorded flashes
(two on 2026-10-06, one on 2026-10-07); see below.
If it fails
- The flash stops at 25 % with
dfu-util: Error during download get_status. PulseAudio still had the
capture open. On 2026-10-07 the first attempt stopped only the API;fuserthen showed
/dev/snd/pcmC0D0c: burgerbarn 51772 F...m pulseaudio. The array stayed on 2.0.6 and kept working. Stop
pulseaudio.socketandpulseaudio.servicetoo (the socket, or PulseAudio restarts on the next client)
and flash again.- The speed reads 12 after the flash. The array came back at USB full speed (12 Mb/s). The kernel says
usb 1-2.3: not running at top speed; connect to a high speed hub, thencannot submit urb 0, error -90.
Playback underruns and capture runs at half rate. On 2026-10-07 a software port reset
(unbind/bindon the usb driver) did not help; a physical re-plug brought it back to 480 at once. On
2026-10-06 a port reset worked once, on the older 2.0.9 image. Ask for a re-plug; do not unbind the array
(docs/lessons.md2026-10-06 night).- Flashing red LED, no sound card,
bcdDevice 0.03: the array booted its safe image because Mute was
held at plug-in. Re-plug without Mute.- To go back, older images are in
xmos_firmwares/usb/old_firmwares/(on the robot: 2.0.6, 2.0.7, 2.0.9,
2.0.10, 2.1.0 and channel variants).docs/hardware.mdsays 2.0.6 is not there; the clone on the robot has
respeaker_xvf3800_usb_dfu_firmware_v2.0.6.bin. Of these, only 2.0.9 was ever flashed here (2026-10-06).
Safety
The one failed flash on this robot (stopped at 25 %) left the array on 2.0.6, still working, and-a 1
writes only the upgrade slot, not the factory image. Still, do not unplug the array or reboot the robot while
dfu-util is writing.
Four separate settings decide how loud the array hears and speaks. They live in different places, so one script
sets them all.
New idea: gain stages
Sound passes through several volume controls in a row, and each one can make it too quiet or clip it. On the
array: the microphone gain inside the XVF3800 (how much the mic signal is amplified before the beam),
two ALSA playback controls on the USB card (digital volume before the codec), and the codec level of
the TLV320AIC3104 chip that drives the speaker amplifier. The array has two ALSA controls with the same name
"PCM Playback Volume": numid 5 (stereo) and numid 6 (mono), each -60 to 0 dB in 60 steps. numid 6 ships at 40
(-20 dB), andamixer sset PCM 100%does not touch it; that was the "very low" speaker of 2026-10-06.
The script is scripts/ops/audio_levels.sh, run from your laptop. Read it in pieces.
The first lines pick the machine and the tool. On the robot the Jetson binary runs with sudo; on bigbuddy the x86
binary runs without (bigbuddy has a udev rule for the array, see the last section):
On your laptop:
H=${1:-robot}; G=${2:-135}; L=${3:-2}
[ "$H" = robot ] && T=rosorin-wifi && TOOL='sudo ~/tools/reSpeaker_XVF3800_USB_4MIC_ARRAY/host_control/jetson/xvf_host' || { T=bigbuddy; TOOL='~/tools/reSpeaker_XVF3800_USB_4MIC_ARRAY/host_control/linux_x86_64/xvf_host'; }
On the target it finds the ALSA card number of the array, sets both playback controls to 60 (0 dB) and saves them
with alsactl store, so they survive a reboot:
On your laptop:
C=\$(grep -i Array /proc/asound/cards | head -1 | awk '{print \$1}')
[ -z "\$C" ] && { echo "no array on $H"; exit 1; }
amixer -c \$C cset numid=5 60,60 >/dev/null; amixer -c \$C cset numid=6 60 >/dev/null; sudo -n alsactl store 2>/dev/null
Then the microphone gain (default 135) and the LED effect with the Jetson tool, and the two codec levels with the
Python tool, followed by SAVE_CONFIGURATION, which writes the array's current settings to its own flash:
On your laptop:
$TOOL AUDIO_MGR_MIC_GAIN $G >/dev/null 2>&1; $TOOL LED_EFFECT $L >/dev/null 2>&1
P=~/tools/xvfenv/bin/python; cd ~/tools/reSpeaker_XVF3800_USB_4MIC_ARRAY/python_control 2>/dev/null && { [ "$H" = robot ] && S=sudo || S=; \$S \$P xvf_host.py AIC3104_HP_LEVEL --values 9 >/dev/null 2>&1; \$S \$P xvf_host.py AIC3104_LINEOUT_LEVEL --values 9 >/dev/null 2>&1; \$S \$P xvf_host.py SAVE_CONFIGURATION --values 1 >/dev/null 2>&1; }
The last line reads everything back in one line. The whole file, as it is in the repo:
On your laptop:
#!/bin/bash
# Tier 1. Put the reSpeaker XVF3800's levels where the record says (hardware.md 2026-10-06), on whichever machine it is
# plugged into. Usage: audio_levels.sh robot|bigbuddy [mic_gain=135] [leds: 0 off|1 breath|2 rainbow|3 colour|4 doa|5 ring, default 2 (owner 2026-10-07)]
H=${1:-robot}; G=${2:-135}; L=${3:-2}
[ "$H" = robot ] && T=rosorin-wifi && TOOL='sudo ~/tools/reSpeaker_XVF3800_USB_4MIC_ARRAY/host_control/jetson/xvf_host' || { T=bigbuddy; TOOL='~/tools/reSpeaker_XVF3800_USB_4MIC_ARRAY/host_control/linux_x86_64/xvf_host'; }
ssh -o ConnectTimeout=8 $T 'bash -s' <<EOS
C=\$(grep -i Array /proc/asound/cards | head -1 | awk '{print \$1}')
[ -z "\$C" ] && { echo "no array on $H"; exit 1; }
amixer -c \$C cset numid=5 60,60 >/dev/null; amixer -c \$C cset numid=6 60 >/dev/null; sudo -n alsactl store 2>/dev/null
$TOOL AUDIO_MGR_MIC_GAIN $G >/dev/null 2>&1; $TOOL LED_EFFECT $L >/dev/null 2>&1
P=~/tools/xvfenv/bin/python; cd ~/tools/reSpeaker_XVF3800_USB_4MIC_ARRAY/python_control 2>/dev/null && { [ "$H" = robot ] && S=sudo || S=; \$S \$P xvf_host.py AIC3104_HP_LEVEL --values 9 >/dev/null 2>&1; \$S \$P xvf_host.py AIC3104_LINEOUT_LEVEL --values 9 >/dev/null 2>&1; \$S \$P xvf_host.py SAVE_CONFIGURATION --values 1 >/dev/null 2>&1; }
echo "array on $H: fw \$($TOOL VERSION 2>/dev/null | tail -1) | alsa \$(amixer -c \$C cget numid=5 | grep -o 'values=[0-9,]*' | tail -1) \$(amixer -c \$C cget numid=6 | grep -o 'values=[0-9,]*' | tail -1) | mic gain \$($TOOL AUDIO_MGR_MIC_GAIN 2>/dev/null | tail -1) | LED \$($TOOL LED_EFFECT 2>/dev/null | tail -1) | usb speed \$(cat /sys/bus/usb/devices/*/speed 2>/dev/null | sort -u | paste -sd,) (12 = re-plug it)"
EOS
The LED effect is the third argument:
LED_EFFECT |
Ring shows |
|---|---|
| 0 | off |
| 1 | breathing |
| 2 | rainbow (the owner's choice since 2026-10-07; the script's default) |
| 3 | one colour (LED_COLOR) |
| 4 | direction of arrival (a new array ships in this mode) |
| 5 | ring (listed as new in Seeed's firmware readme) |
Run it after every re-plug, reboot or new array:
On your laptop:
cd ~/CCode/rosorin-pro && bash scripts/ops/audio_levels.sh robot 135 2
ssh rosorin-wifi 'cd ~/tools/reSpeaker_XVF3800_USB_4MIC_ARRAY/python_control && for c in AIC3104_HP_LEVEL AIC3104_LINEOUT_LEVEL LED_EFFECT LED_BRIGHTNESS; do sudo ~/tools/xvfenv/bin/python xvf_host.py $c 2>&1 | grep -v "^Done" | tail -1; done; echo "array speed $(cat /sys/bus/usb/devices/1-2.3/speed)"'
Check
Output on 2026-10-07, right after array #2 was re-plugged:array on robot: fw VERSION 2 1 1 | alsa values=60,60 values=60 | mic gain AUDIO_MGR_MIC_GAIN 135 | LED LED_EFFECT 2 | usb speed 10000,12,480 (12 = re-plug it) AIC3104_HP_LEVEL: [9] AIC3104_LINEOUT_LEVEL: [9] LED_EFFECT: [2] LED_BRIGHTNESS: [127] array speed 480The
usb speedfield of the script lists every USB device on the robot, so a12there can be the LiDAR's
CH340 adapter (a full-speed device at1-2.2.4), not the array. The runbook's "12 means re-plug" applies to
the array's own port only: read/sys/bus/usb/devices/1-2.3/speed, or thehigh speed/full speedword
in/proc/asound/cards.
If it fails
no array on robot: the card is missing. Checklsusband the Mute warning above.- Mic gain reads 90: that is the factory value of a new array; the script was not run, or it ran before the
array was back on USB.- The speaker is too quiet with every control at maximum: on 2026-10-06 that was numid 6 sitting at -20 dB.
The script now sets both controls.
How loud Buddy's voice comes out is not set here. The PulseAudio sink volume of the array is the same hardware
control as numid 5 (measured 2026-10-07: Pulse 90 % gives numid 5 = 58,58), and this script puts it back to 60. Buddy's
loudness is SPEECH_OUT_DB in robot_api.py (see "The mouth" below).
The docs differ on one point: docs/hardware.md says the robot API sets the mic gain to 135 at every start, but
buddy_link/robot_api.py (identical on the robot) has no such call. This script is what sets it.
Not measured: do the array settings survive a power cycle?
SAVE_CONFIGURATIONwrites the array's settings to its flash, and the codec levels read 9/9 after re-plugs.
Whether the mic gain and the LED effect survive a full power cycle was not measured separately. The runbook
says to run the script after every re-plug and reboot; do that.
The array has a Mute button. With the built-in mute function on (the default), one press mutes the microphones
and lights the mute LED; another press unmutes. A muted array still streams: the robot API sends audio, bigbuddy's
robot_mic is RUNNING, Buddy logs ready. Every sample is close to zero. Nothing in the chain reports an error.
This is what happened on 2026-10-07. The owner said "yo buddy" after rebooting the robot, repeatedly, with no
answer. Every service was active. The measurement in the last section of this chapter gave rms 0.7 on
robot_mic, and the same near-zero level on both of the array's channels on the robot itself. The array was muted.
Read the button and the mute output from the robot:
On the robot:
cd ~/tools/reSpeaker_XVF3800_USB_4MIC_ARRAY/python_control
for c in GPO_READ_VALUES GPI_READ_VALUES; do sudo ~/tools/xvfenv/bin/python xvf_host.py $c 2>&1 | grep -v "^Done" | tail -1; done
GPI_READ_VALUES is the logic level of three input pins; the first is X1D09, the on-board Mute button.
GPO_READ_VALUES is five output pins in the order X0D11, X0D30, X0D31, X0D33, X0D39; X0D30 drives the mute LED
and the microphone mute, X0D31 enables the speaker amplifier (low = on).
Check
While muted (2026-10-07 19:27 UTC, array #2):GPO_READ_VALUES: [0, 1, 0, 0, 0] GPI_READ_VALUES: [1, 0, 0]The second GPO value (X0D30) was
0in an unmuted reading on 2026-10-06 (array #1). If X0D30 reads 1, or the
rms below is near 0, press Mute once and measure again.
Not recorded: the button pin when unmuted
The record has the Mute button pin (first GPI value) only in the muted state, where it read 1. Its unmuted
value was never read back. Seeed's tool describesGPI_VALUE(afterGPI_INDEX --values 0) as "1 = active";
it also read 1 while muted. Use the rms measurement as the deciding check.
Why this gets its own section
A muted array looks exactly like a working chain in a quiet room, unless you measure the level. The owner
pressed Mute on purpose. The diagnosis started from service states and stream connections, which all looked
healthy. Measure the level first.
The robot API (buddy_link/robot_api.py, chapter 22) serves the array as a stream. The handler records from the
robot's PulseAudio rather than from the ALSA card, so the API never takes the card away from anything else:
On the robot:
def stream_audio(self):
"""Raw s16le mono 16 kHz: the right USB channel of the reSpeaker XVF3800 (ASR output of the auto-selected
beam, read from the device 2026-10-05), taken from the robot's own PulseAudio so nothing else loses the
card. One listener at a time; parec ends when the listener goes."""
if not AUDIO_LOCK.acquire(blocking=False):
return self.send(409, {'error': 'another client has the audio stream'})
p = None
try:
src = subprocess.run(['pactl', 'list', 'short', 'sources'], capture_output=True, text=True, env=PULSE_ENV,
timeout=5).stdout
src = next((l.split('\t')[1] for l in src.splitlines() if 'alsa_input.usb-Seeed' in l), None)
if src is None:
return self.send(503, {'error': 'reSpeaker array not found'})
p = subprocess.Popen(['parec', f'--device={src}', '--rate=16000', '--channels=2', '--format=s16le',
'--raw', '--latency-msec=40'], stdout=subprocess.PIPE, env=PULSE_ENV)
self.send_response(200)
self.send_header('Content-Type', 'audio/L16; rate=16000; channels=1')
self.send_header('Cache-Control', 'no-cache'); self.end_headers()
while True:
buf = p.stdout.read(2560) # 640 stereo frames = 40 ms
if not buf:
break
mono = np.frombuffer(buf[:len(buf) // 4 * 4], np.int16)[1::2].tobytes()
self.wfile.write(mono); self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
return
finally:
if p is not None:
p.kill()
AUDIO_LOCK.release()
What it does, in order:
AUDIO_LOCK allows one listener. A second one gets 409.alsa_input.usb-Seeed...), so a new array with a new serial503.PULSE_ENV (defined near the top of the file) points parec at /run/user/1000/pulse/native, the PulseAudio[1::2] keeps every second 16-bit sample,audio/L16: 32,000 bytes per second.The right channel is the array's output AUDIO_MGR_OP_R, read on the robot on 2026-10-07 as
MUX_AEC_RESIDUALS[7] 3: category 7, source 3, the ASR output of the auto-selected beam. The left channel
(AUDIO_MGR_OP_L, MUX_USER_CHOSEN_CHANNELS[8] 0) carries the conference processing with noise suppression and
automatic gain; Buddy does not use it.
Buddy's side needs the robot API token. Chapter 22 creates it on the robot. Copy it to bigbuddy from your laptop;
scp -3 copies between the two machines through the laptop without printing the file:
On your laptop:
scp -3 -q rosorin-wifi:.config/rosorin/api_token bigbuddy:voice/robot_api_token && ssh bigbuddy 'chmod 600 ~/voice/robot_api_token'
Then pull four seconds of the stream from bigbuddy and count the bytes. Do this before robot-mic.service exists;
once it runs, it holds the one listener slot.
On bigbuddy:
n=$(timeout 4 curl -sN -H "X-Robot-Token: $(cat ~/voice/robot_api_token)" http://192.168.1.108:8296/audio | wc -c); echo "bytes from /audio in ~4 s: $n (expect ~128000 = 32 kB/s)"
Check
First run, 2026-10-06:bytes from /audio in ~4 s: 105128 (expect ~128000 = 32 kB/s)Somewhat under 128,000 is normal: curl needs a moment to connect before the four seconds of data start.
If it fails
{"error": "another client has the audio stream"}(409):robot-mic.serviceon bigbuddy, or a test you
left running, already has the stream.{"error": "reSpeaker array not found"}(503): PulseAudio has no Seeed source. Check the card, linger and
PulseAudio as above.{"error": "missing or wrong X-Robot-Token"}(401): the token file on bigbuddy is not the robot's.- Restarting the API while a client holds
/audiohangs a plain stop.scripts/ops/deploy.sh apikills it
with SIGKILL, and the robot has a hand-made drop-inrosorin-api.service.d/10-fast-stop.confwith
TimeoutStopSec=5(chapter 22).
PipeWire on bigbuddy cannot read an HTTP stream itself, but it can read a named pipe. A small user service keeps a
curl connection to /audio open and writes everything it receives into that pipe.
New idea: a named pipe (FIFO)
A FIFO is a file that is really a pipe. One program opens it and writes; another opens it and reads; the
bytes go straight from one to the other and nothing is stored on disk.mkfifocreates one. Here curl is
the writer and PipeWire is the reader.
bigbuddy's user is uid 1001, not 1000
The robot'sburgerbarnis uid 1000; bigbuddy's is 1001, so its runtime directory is/run/user/1001.
Check before you write any path:loginctl show-user burgerbarn -p RuntimePathon bigbuddy prints
RuntimePath=/run/user/1001. On 2026-10-06 the first version of these files used/run/user/1000: PipeWire
exited on start, its socket hit the restart limit, and bigbuddy had no sound for about a minute.
Build the unit in three parts. The [Unit] part only says what it is and to start after PipeWire:
On bigbuddy:
[Unit]
Description=Robot microphone stream (robot API /audio) -> PipeWire source robot_mic
After=pipewire.service
ExecStartPre runs once before the main command. It writes the token into a header file that only you can read
(umask 077) and creates the FIFO if it does not exist. curl then reads the header with -H @file, so the token
is not in curl's command line. %h is your home directory. % is special in unit files, so the printf format
must be written %%s:
On bigbuddy:
ExecStartPre=/bin/bash -c 'umask 077; printf "X-Robot-Token: %%s" "$(cat %h/voice/robot_api_token)" > %h/voice/robot_api_header; [ -p /run/user/1001/robot_mic.fifo ] || mkfifo /run/user/1001/robot_mic.fifo'
ExecStart is a loop: curl streams /audio into the FIFO; when the connection drops (robot reboot, Wi-Fi gap) it
waits 2 s and connects again. -N turns off curl's buffering, so audio is passed on as it arrives:
On bigbuddy:
ExecStart=/bin/bash -c 'while true; do curl -sN -H @%h/voice/robot_api_header http://192.168.1.108:8296/audio > /run/user/1001/robot_mic.fifo; sleep 2; done'
Restart=always
RestartSec=3
Create ~/.config/systemd/user/robot-mic.service with the whole file, as it is live on bigbuddy:
On bigbuddy:
[Unit]
Description=Robot microphone stream (robot API /audio) -> PipeWire source robot_mic
After=pipewire.service
[Service]
ExecStartPre=/bin/bash -c 'umask 077; printf "X-Robot-Token: %%s" "$(cat %h/voice/robot_api_token)" > %h/voice/robot_api_header; [ -p /run/user/1001/robot_mic.fifo ] || mkfifo /run/user/1001/robot_mic.fifo'
ExecStart=/bin/bash -c 'while true; do curl -sN -H @%h/voice/robot_api_header http://192.168.1.108:8296/audio > /run/user/1001/robot_mic.fifo; sleep 2; done'
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
The address is the robot's Wi-Fi address, 192.168.1.108. Do not start the service yet; PipeWire needs its config
first.
If it fails
robot-mic.service: Control process exited, code=exited, status=1/FAILUREstraight away: on 2026-10-06 the
first version hadprintf "X-Robot-Token: %s". systemd replaced%swith its own value and the
ExecStartPreline broke. Write%%s.- The unit is
activatingand neveractive, whilepipewire.socketshows
Failed with result 'service-start-limit-hit': a wrong/run/user/<uid>path in this file or in the
PipeWire config below. Fix the path, then
systemctl --user reset-failed pipewire.socket pipewire-pulse.socket pipewire.service pipewire-pulse.service wireplumber.service robot-mic.service
and start the sockets and services again.
New idea: PipeWire nodes
PipeWire is bigbuddy's audio server (it also answers programs that speak the PulseAudio protocol, sopactl,
parecandpaplaywork). Everything in it is a node: a source produces audio (a microphone), a
sink consumes it (a speaker), and modules create nodes from config. Files in
~/.config/pipewire/pipewire.conf.d/are loaded in name order when PipeWire starts.
The pipe-tunnel module in source mode reads raw audio from a FIFO and presents it as a source. The format must
match exactly what the robot sends: s16le, 16 kHz, one channel. Create
~/.config/pipewire/pipewire.conf.d/55-robot-mic.conf (live text, 2026-10-07):
On bigbuddy:
# The robot's ears (2026-10-06): the reSpeaker XVF3800 moved from bigbuddy to the robot. The robot API streams the
# array's ASR beam (raw s16le mono 16 kHz, GET /audio); robot-mic.service pulls it into this FIFO; PipeWire turns the
# FIFO into the source "robot_mic". Buddy's "xvf_asr" loopback (60-buddy-echo-cancel.conf) now reads robot_mic.
# Undo: delete this file, restore 60-buddy-echo-cancel.conf.bak-20261006-robotmic, restart pipewire.
context.modules = [
{ name = libpipewire-module-pipe-tunnel
args = {
tunnel.mode = source
pipe.filename = "/run/user/1001/robot_mic.fifo"
audio.format = S16LE
audio.rate = 16000
audio.channels = 1
audio.position = [ MONO ]
stream.props = {
node.name = "robot_mic"
node.description = "Robot mic (reSpeaker XVF3800 over WiFi)"
}
}
}
]
This file and the unit are not in the repo. They exist only on bigbuddy (survey_bigbuddy.md §4).
Buddy's echo-cancel config dates from 2026-10-05, when the array was plugged into bigbuddy. It does three things: a loopback that turns one channel of the array into the mono source xvf_asr; a WebRTC echo canceller
that reads xvf_asr and produces buddy_aec_source; and the sink buddy_aec_sink, which forwards to the Evo 150
amplifier and serves as the echo reference. Music (voice/music.py plays to buddy_aec_sink) and the kiosk
browser (chapter 28) send their sound to that sink.
Install it even though Buddy no longer listens through it: without it, buddy_aec_sink does not exist and music
has nowhere to play. Copy the repo version first and keep a copy of it under the name the switch script expects:
On your laptop:
scp -q ~/CCode/rosorin-pro/voice/60-buddy-echo-cancel.conf bigbuddy:.config/pipewire/pipewire.conf.d/
ssh bigbuddy 'cd ~/.config/pipewire/pipewire.conf.d && cp -n 60-buddy-echo-cancel.conf 60-buddy-echo-cancel.conf.bak-20261006-robotmic'
The repo file is the array-on-bigbuddy variant. Its loopback reads the right channel (FR) of an array plugged
into bigbuddy:
On bigbuddy:
capture.props = {
node.name = "xvf_asr_capture"
target.object = "alsa_input.usb-Seeed_Studio_reSpeaker_XVF3800_4-Mic_Array_114993701262800721-00.analog-stereo"
audio.channels = 1
audio.position = [ FR ]
stream.dont-remix = true
node.passive = true
}
The robot_mic variant changes only that block, so xvf_asr reads robot_mic instead. Edit the active file so
the block reads like this (live text):
On bigbuddy:
capture.props = {
node.name = "xvf_asr_capture"
# 2026-10-06: the array is on the robot now; its ASR beam (right channel) arrives as "robot_mic"
# (55-robot-mic.conf). Before: target.object = "alsa_input.usb-Seeed_Studio_reSpeaker_XVF3800_4-Mic_Array_114993701262800721-00.analog-stereo", audio.position = [ FR ], stream.dont-remix = true
target.object = "robot_mic"
audio.channels = 1
audio.position = [ MONO ]
node.passive = true
}
Then save that state under the second name the switch script expects:
On bigbuddy:
cd ~/.config/pipewire/pipewire.conf.d && cp 60-buddy-echo-cancel.conf 60-buddy-echo-cancel.conf.robotmic-20261006
Now load everything and start the pull service. This is the sequence from 2026-10-06, with the corrected paths:
On bigbuddy:
systemctl --user daemon-reload
systemctl --user restart pipewire pipewire-pulse wireplumber
sleep 3
systemctl --user enable --now robot-mic.service
pactl list short sources | grep -i "robot_mic\|xvf_asr\|buddy_aec_source"
pactl info | grep -i "server name"
Check
Sources on 2026-10-07 (columns: name, format, state):robot_mic s16le 1ch 16000Hz RUNNING xvf_asr float32le 1ch 48000Hz RUNNING buddy_aec_source float32le 1ch 48000Hz IDLE
pactl infomust answer instead of failing to connect. After any restart of the audio stack on bigbuddy, also
check that the Evo sink is back:pactl list short sinks | grep Evo(docs/lessons.md
2026-10-06).
If it fails
pactlcannot connect andsystemctl --user is-active pipewiresaysfailed: a syntax error or a wrong
path in apipewire.conf.dfile stops PipeWire, andpipewire-pulseandwireplumbergo down with it.
Readjournalctl --user -u pipewire --since "-5 min", fix the file,reset-failedas above.robot_micis missing but PipeWire runs: the 55 file is not inpipewire.conf.d, or its name does not end
in.conf.robot_micisSUSPENDEDorIDLE: no data. Checksystemctl --user status robot-micand the robot API.
Buddy's voice loop (chapter 26) reads its microphone with parec from the source named in BUDDY_MIC. The code
default is buddy_aec_source, the echo-cancelled chain. Live, a systemd drop-in points it at robot_mic directly.
Create ~/.config/systemd/user/buddy-voice.service.d/60-mic.conf:
On bigbuddy:
[Service]
Environment=BUDDY_MIC=robot_mic
This is the command that created it on 2026-10-07 01:29 UTC, together with setting robot_mic to 0 dB:
On bigbuddy:
export XDG_RUNTIME_DIR=/run/user/$(id -u)
printf "[Service]\nEnvironment=BUDDY_MIC=robot_mic\n" > ~/.config/systemd/user/buddy-voice.service.d/60-mic.conf
pactl set-source-volume robot_mic 0dB
systemctl --user daemon-reload; systemctl --user restart buddy-voice; sleep 12; systemctl --user is-active buddy-voice
journalctl --user -u buddy-voice --since "-14s" --no-pager -o cat | grep ready | tail -1 | cut -c1-120
Check
active 18:29:45 ready: wake yo_buddy_v3 @ 0.5, STT on GPU, LLM google/gemma-4-12b, voice am_onyx, mic robot_micThe
ready:line must end inmic robot_mic.pactl get-source-volume robot_micreads
Volume: mono: 65536 / 100% / 0.00 dB.
The reader in voice/buddy_voice.py is a thread that runs parec continuously, so nothing queues up inside
PipeWire while Buddy talks, and passes the audio through a 100 Hz high-pass filter in 80 ms frames:
On bigbuddy:
def __init__(self):
self.p = subprocess.Popen(['parec', f'--device={MIC}', '--rate=16000', '--channels=1',
'--format=s16le', '--raw', '--latency-msec=40'], stdout=subprocess.PIPE)
self.sos = ss.butter(4, 100, btype='high', fs=SR, output='sos')
Why Buddy skips the echo canceller
The record does not say why in a decision. What it shows: the description of the 2026-10-07 01:29 UTC command
is "Point Buddy's microphone at the robot's array output directly with no boost". Since 2026-10-06 Buddy's
voice comes out of the array's own speaker, and/playfeeds the same audio to the array, so the array's echo
canceller has its reference. Buddy's voice no longer goes throughbuddy_aec_sink, so the PipeWire canceller
on bigbuddy has no copy of it to subtract. A day earlier the two +12 dB boosts in a row (robot_micand
buddy_aec_source) had put Buddy's input at -7 dBFS, clipping 9 % of the time (docs/lessons.md).
The docs differ from the live machine in four places. docs/decisions.md (2026-10-06) says Buddy reads
buddy_aec_source through xvf_asr; live he reads robot_mic. docs/runbook.md and docs/hardware.md give a wake
threshold of 0.4 from 20-wake.conf; live it is 0.5, and that drop-in was moved to ~/voice/reverted-20261006/.
docs/hardware.md says robot_mic +12 dB; live it is 0 dB, and no file sets any gain on it. run_buddy_voice.sh
still sets buddy_aec_source to +12 dB at every start; nothing reads that source.
Two more drop-ins in the same folder shape what Buddy hears (chapter 26 explains them): 40-echo-tail.conf
(BUDDY_ECHO_TAIL_S=1.0) and 50-speech-db.conf (BUDDY_SPEECH_DB=12).
If it fails
RuntimeError: mic stream endedin Buddy's log, then a restart 5 s later: PipeWire was restarted under him
(for example bybuddy_ears.sh), soparecended.Restart=on-failurebrings him back; the nextready:
line should follow within about 10 s.
Buddy's replies are Kokoro WAV files (chapter 26). One function sends all of them out, _out() in
voice/buddy_voice.py:
On bigbuddy:
MIC = E('BUDDY_MIC', 'buddy_aec_source')
SPK = E('BUDDY_SPK', 'buddy_aec_sink')
MOUTH = E('BUDDY_MOUTH', 'robot') # 'robot': Buddy speaks out of the robot (robot API /play, 2026-10-06);
# 'evo': through SPK on this machine. Falls back to SPK if the robot does not answer.
def _out(wav_bytes):
"""Buddy's voice: the robot's speaker (reSpeaker XVF3800, whose echo canceller then has its reference), else SPK."""
if MOUTH == 'robot':
try:
import robot_eyes
r = requests.post(robot_eyes.ROBOT_URL + '/play', data=wav_bytes, headers=robot_eyes._h(),
timeout=120)
if r.ok:
return
log(f'robot mouth: {r.status_code} {r.text[:80]} - falling back to {SPK}')
except Exception as e:
log(f'robot mouth unreachable ({str(e)[:60]}) - falling back to {SPK}')
subprocess.run(['paplay', f'--device={SPK}'], input=wav_bytes, check=False)
The default is the robot. If the robot does not answer, Buddy speaks through the Evo 150 instead and logs why.
robot_eyes._h() reads the token from ~/.config/rosorin/api_token on bigbuddy. That file has the same content as
~/voice/robot_api_token (checked with cmp on 2026-10-01); the command that created it is not recorded, and
chapter 22 covers the token.
On the robot, /play first runs normalize_wav(). The array's amplifier is small, so the clip is made as loud as
it can be without clipping. The settings come first; every one can be overridden with an environment variable:
On the robot:
SPEECH_TARGET_DBFS = float(os.environ.get('ROBOT_SPEECH_TARGET_DBFS', '-6')) # loudness target (RMS of the loud parts)
SPEECH_MAX_BOOST_DB = float(os.environ.get('ROBOT_SPEECH_MAX_BOOST_DB', '24'))
TONE_PEAK_DBFS = float(os.environ.get('ROBOT_TONE_PEAK_DBFS', '-20')) # short clips (Buddy's chime / cue): peak level, owner 2026-10-06 "WAY down"
SPEECH_GAIN_DB = float(os.environ.get('ROBOT_SPEECH_GAIN_DB', '12')) # fixed gain stage before the limiter (owner: "high gain")
SPEECH_OUT_DB = float(os.environ.get('ROBOT_SPEECH_OUT_DB', '-2.75')) # after the limiter (owner 2026-10-07: voice down 10 % = PulseAudio's 90 %)
The processing, numbered as in the code comments:
On the robot:
if len(a) < params.framerate * params.nchannels * 1.2: # a chime or cue, not speech: set its peak and stop
a = a * (10 ** (TONE_PEAK_DBFS / 20) / float(np.abs(a).max()))
out = io.BytesIO(); w2 = wave.open(out, 'wb'); w2.setparams(params)
w2.writeframes((a * 32767).astype(np.int16).tobytes()); w2.close()
return out.getvalue(), TONE_PEAK_DBFS
ceiling = 10 ** (-1 / 20)
a = a * (ceiling / float(np.abs(a).max())) # 1. peak at -1 dBFS
hop = max(1, params.framerate * params.nchannels // 50) # 2. 20 ms frames: level of the loud parts
n = len(a) // hop * hop
rms = np.sqrt((a[:n].reshape(-1, hop) ** 2).mean(1) + 1e-12)
loud = float(np.percentile(rms[rms > 0.01], 80)) if (rms > 0.01).any() else float(rms.max())
want = 10 ** (SPEECH_TARGET_DBFS / 20)
boost = min(10 ** (SPEECH_MAX_BOOST_DB / 20), max(1.0, want / loud))
# per-frame gain: full boost where the frame is quiet, less where it would exceed the ceiling; smoothed
g = np.minimum(boost, ceiling / np.maximum(rms * 3.0, 1e-6)) # 3 x RMS ~ speech peaks
g = np.convolve(np.r_[g[0], g, g[-1]], np.ones(5) / 5, mode='same')[1:-1]
gain = np.repeat(np.maximum(1.0, g), hop)
gain = np.r_[gain, np.full(len(a) - len(gain), gain[-1])] if len(gain) < len(a) else gain[:len(a)]
a = a * gain * 10 ** (SPEECH_GAIN_DB / 20) # 3. the gain stage (owner 2026-10-06: not conservative)
a = ceiling * np.tanh(a / ceiling) # 4. soft limiter: peaks fold into the ceiling, no hard clip
a = a * 10 ** (SPEECH_OUT_DB / 20) # 5. output level (the sink stays at 100 %: it is the ALSA control)
tanh soft limiter folds peaks under -1 dBFS instead of clipping them;SPEECH_OUT_DB, thenscripts/ops/deploy.sh api (docs/runbook.md). Do not turn the PulseAudio sink down instead: it is the ALSAaudio_levels.sh resets to 100 %.Then the handler plays the result with paplay on every non-Seeed USB sink (the kit's WonderEcho box, if one is
plugged in, as a louder speaker) and on the array's own sink, so the array's echo canceller has its reference. On
2026-10-07 the robot had only the array's sink. The full handler is in buddy_link/robot_api.py, do_POST, the
/play branch.
Test it from bigbuddy with Buddy's own code and token. This posts a three-tone chime (2026-10-06):
On bigbuddy:
cd ~/voice && python3 - <<'PY'
import io, wave, math, struct, requests, robot_eyes
sr=16000; frames=b''.join(struct.pack('<h', int(0.9*32767*math.sin(2*math.pi*f*i/sr)*min(1,i/400,(int(sr*0.25)-i)/400))) for f in (660,880,1100) for i in range(int(sr*0.25)))
buf=io.BytesIO(); w=wave.open(buf,'wb'); w.setnchannels(1); w.setsampwidth(2); w.setframerate(sr); w.writeframes(frames); w.close()
r=requests.post(robot_eyes.ROBOT_URL+'/play', data=buf.getvalue(), headers=robot_eyes._h(), timeout=30)
print('bigbuddy -> robot /play:', r.status_code, r.text[:60])
PY
Check
You hear three rising tones from the robot, and:bigbuddy -> robot /play: 200 {"played": true, "bytes": 24044}That output is from 2026-10-06, before the API added two fields. The current API also returns
gain_dband
speakers; a speech-length clip on 2026-10-06 returned
{"played": true, "bytes": 80044, "gain_db": 18.5, "speakers": ["usb-Seeed_Studio_reSpeaker_XVF3800_4-Mic"]}.
If it fails
/playhangs and the robot's API log showsFailed to drain stream: Timeout, with apaplayprocess stuck
on the array's sink. This happened after a robot reboot on 2026-10-06 night: playback stalled while capture
kept working. A software USB unbind/bind brought the array back at 12 Mb/s, which was worse. Only a physical
re-plug fixed it (480, chime and/playworking again). Ask for a re-plug.{"error": "WAV body expected"}(400): the body does not start withRIFF.- Buddy logs
robot mouth unreachable ... falling back to buddy_aec_sink: the robot API is down or the Wi-Fi
dropped; Buddy speaks through the Evo meanwhile.
Why Buddy ignores the microphone while he talks
The robot's microphones hear Buddy's own voice clearly. Measured onrobot_micon 2026-10-07 01:27 UTC while
a test phrase played: room floor -47.5 dBFS, during the phrase median -30.6 dBFS, peaks -3.4 dBFS. The
array's echo canceller reduces that but does not remove it. So Buddy drops every frame that arrives until
BUDDY_ECHO_TAIL_S(1.0 s live) after playback ends; the chime lands about 0.5 to 0.75 s after/play
returns.
While array #1 was being replaced (2026-10-06 evening), the array sat on bigbuddy and Buddy heard and spoke there.
scripts/ops/buddy_ears.sh switches between the two set-ups from your laptop. It copies one of the two saved
echo-cancel variants over the active file, adds or removes the drop-in 30-mouth.conf (BUDDY_MOUTH=evo),
enables or disables robot-mic.service, restarts the audio stack, then the pull service, then Buddy, and prints
the sources and Buddy's ready: line:
On your laptop:
#!/bin/bash
# Tier 2. Where Buddy hears and speaks. Usage: buddy_ears.sh robot|bigbuddy (status.md 2026-10-06 evening)
# robot: array on the robot; bigbuddy pulls its stream (robot-mic.service), Buddy's voice goes to the robot (/play)
# bigbuddy: array on bigbuddy; local xvf_asr, Buddy's voice on the Evo
set -e; M=${1:?robot|bigbuddy}
ssh -o ConnectTimeout=8 bigbuddy 'bash -s' <<EOS
set -e; cd ~/.config/pipewire/pipewire.conf.d
if [ "$M" = robot ]; then cp 60-buddy-echo-cancel.conf.robotmic-20261006 60-buddy-echo-cancel.conf; rm -f ~/.config/systemd/user/buddy-voice.service.d/30-mouth.conf; systemctl --user enable robot-mic.service >/dev/null 2>&1
else cp 60-buddy-echo-cancel.conf.bak-20261006-robotmic 60-buddy-echo-cancel.conf; mkdir -p ~/.config/systemd/user/buddy-voice.service.d; printf '[Service]\nEnvironment=BUDDY_MOUTH=evo\n' > ~/.config/systemd/user/buddy-voice.service.d/30-mouth.conf; systemctl --user disable --now robot-mic.service >/dev/null 2>&1 || true; fi
systemctl --user daemon-reload; systemctl --user restart pipewire pipewire-pulse wireplumber; sleep 4
[ "$M" = robot ] && systemctl --user restart robot-mic.service; systemctl --user restart buddy-voice.service; sleep 9
echo "ears on $M: \$(pactl list short sources | grep -i 'robot_mic\|xvf_asr\|buddy_aec_source' | awk -F'\t' '{printf "%s(%s) ", \$2, \$5}')"
journalctl --user -u buddy-voice --since '-20 s' --no-pager -o cat | grep -v -i token | grep ready | tail -1 | cut -c1-110
EOS
On your laptop:
cd ~/CCode/rosorin-pro && bash scripts/ops/buddy_ears.sh robot
Check
Last run, 2026-10-07 17:42 UTC:ears on robot: robot_mic(RUNNING) xvf_asr(RUNNING) buddy_aec_source(IDLE) 10:42:05 ready: wake yo_buddy_v3 @ 0.5, STT on GPU, LLM google/gemma-4-12b, voice am_onyx, mic robot_mic
Not test-built: buddy_ears.sh bigbuddy since 60-mic.conf
The script was written before60-mic.confexisted, and it does not touch that drop-in. Run as
buddy_ears.sh bigbuddytoday, it would stoprobot-mic.serviceand leave Buddy readingrobot_mic, which
then carries nothing. Switching to bigbuddy also needs the array on bigbuddy, Seeed's tools there
(~/tools, thelinux_x86_64host tool, its ownxvfenv), and the udev rule
/etc/udev/rules.d/60-respeaker-xvf3800.ruleswith
SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="001a", MODE="0666"(made by hand on 2026-10-05,
withdnf install -y python3-pyusb). The bigbuddy direction has not been run since 2026-10-06 evening.
Service states and connections do not tell you whether there is sound in the pipe. Four seconds of audio do. This
is the measurement that found the muted array on 2026-10-07. It uses only Python's standard library, because
bigbuddy's system python3 has no numpy (ModuleNotFoundError: No module named 'numpy', 2026-10-06).
On bigbuddy:
timeout 4 parec -d robot_mic --raw --format=s16le --rate=16000 --channels=1 2>/dev/null | python3 -c "
import sys,array,math; b=sys.stdin.buffer.read(); a=array.array('h',b[:len(b)//2*2]);
print('robot_mic bytes', len(b), 'rms', round(math.sqrt(sum(x*x for x in a)/max(1,len(a))),1) if a else 'NO DATA')"
How it works: parec records robot_mic as raw 16-bit mono for four seconds; array('h', ...) turns the bytes
into signed 16-bit numbers; the rms is the square root of the mean of the squares.
Check
Two readings from this robot:
Situation Result array muted, 2026-10-07 19:26 UTC robot_mic bytes 64000 rms 0.7(about -93 dBFS)quiet room, 2026-10-07 01:27 UTC room floor -47.5 dBFS on robot_mic(an rms of about 140 in these units)A quiet room is more than a hundred times louder than a muted array. Speak while you measure and the rms
rises far above the room value.
NO DATA: nothing flows. Checkrobot-mic.serviceand the robot API.- Bytes flow but rms is near 1: the array sends silence. Check the Mute state first, then the USB speed.
To split the problem between the robot and bigbuddy, measure the array's two channels on the robot itself (this
is the command from 2026-10-07; it reads both channels while the API keeps streaming):
On the robot:
export XDG_RUNTIME_DIR=/run/user/$(id -u)
SRC=$(pactl list short sources | grep -i "input.usb-Seeed" | cut -f2); echo "source: $SRC state: $(pactl list short sources | grep -i input.usb-Seeed | cut -f5)"
timeout 4 parec -d "$SRC" --raw --format=s16le --rate=16000 --channels=2 2>/dev/null | python3 -c "
import sys,array,math; b=sys.stdin.buffer.read(); a=array.array('h',b[:len(b)//4*4]); L=a[0::2]; R=a[1::2]
r=lambda x: round(math.sqrt(sum(v*v for v in x)/max(1,len(x))),1)
print('pulse source: bytes',len(b),'rms L',r(L),'R',r(R))"
Check
Muted array, 2026-10-07:source: alsa_input.usb-Seeed_Studio_reSpeaker_XVF3800_4-Mic_Array_101991441262500220-00.analog-stereo state: RUNNING pulse source: bytes 255552 rms L 0.8 R 0.7Both channels near zero on the robot means the array itself is silent (Mute). A healthy level here but
silence on bigbuddy means the problem is in between: the API, Wi-Fi,robot-mic.service, or PipeWire.
lsusb shows 2886:001a and /proc/asound/cards says high speed for the array.audio_levels.sh robot reports fw VERSION 2 1 1, alsa values=60,60 values=60, mic gain 135, and the codec[9] and [9].pactl list short sources shows robot_mic ... RUNNING, and Buddy's latest ready: line ends inmic robot_mic.robot_mic gives an rms well above 1 in a quiet room, and rises when you speak."played": true.GPO_READ_VALUES, second value) and what a muted array measures.Where this comes from
Repo (github.com/burgerbarn/rosorin-pro, branchrebuild, HEADbfb61d8):buddy_link/robot_api.py
(stream_audio,normalize_wav,/play),voice/buddy_voice.py(MIC,MOUTH,_out,Mic),
voice/60-buddy-echo-cancel.conf,voice/run_buddy_voice.sh,scripts/ops/audio_levels.sh,
scripts/ops/buddy_ears.sh,scripts/ops/deploy.sh,docs/hardware.md(reSpeaker entries 2026-10-06/07),
docs/lessons.md2026-10-06 (uid 1001,%%s, USB speed, playback stall, double boost),docs/runbook.md
(array firmware, levels),docs/decisions.md2026-10-06 (Buddy's presence on the robot, no desktop at boot),
docs/status.md2026-10-06 (milestone,/audio32 kB/s). Live reads 2026-10-07 (read-only): robot
PulseAudio units, linger, packages, sources, Seeed clone commit, xvfenv packages; bigbuddy
55-robot-mic.conf,robot-mic.service, thebuddy-voice.service.ddrop-ins, the active echo-cancel block,
sources.survey_bigbuddy.md§1, §3, §4;survey_software.md§2.8-2.9;survey_live_robot.md.
Command logs: clone and tools 2026-10-06 16:45 UTC, linger 17:24 UTC, robot-mic and PipeWire 16:57-16:58 UTC,
chime 17:06 UTC, first flash 19:27 UTC, xvfenv 19:30 UTC;60-mic.conf2026-10-07 01:29 UTC; flash of array
#2 2026-10-07 17:33-17:35 UTC, re-plug and levels 17:41 UTC,buddy_ears.sh robot17:42 UTC; Mute diagnosis
19:26-19:27 UTC.
← Buddy's voice · Contents · Face, screen and media (optional) →