Skip to content

335 head wobbler is now handled on the core side - #336

Merged
alozowski merged 15 commits into
mainfrom
335-head-wobbler-is-now-handled-on-the-core-side
Jun 4, 2026
Merged

335 head wobbler is now handled on the core side#336
alozowski merged 15 commits into
mainfrom
335-head-wobbler-is-now-handled-on-the-core-side

Conversation

@RemiFabre

@RemiFabre RemiFabre commented Apr 29, 2026

Copy link
Copy Markdown
Member

Summary

Drop the in-app head wobbler, use the core daemon's instead.

The reachy_mini SDK now ships its own audio-reactive head wobbler that runs inside the daemon's media pipeline. Since we already usedpush_audio_sample, this PR is very simple. The local DSP, threading, scheduling, and offset-application code we maintained in the app are no longer needed.

Roughly 970 deletions vs 11 insertions.

What changes

  • main.py: replace the local HeadWobbler lifecycle with robot.enable_wobbling() / robot.disable_wobbling().
  • Backends (gemini_live.py, openai_realtime.py): drop the if deps.head_wobbler is not None: blocks that called .reset(), .request_reset_after_current_audio(), and .feed(). The daemon's wobbler reacts to the audio stream itself, so explicit reset hooks are unnecessary.
  • console.py: drop the head_wobbler.feed_pcm call and the _estimate_pending_playback_seconds helper that only existed to compute its start_delay_s.
  • Delete src/reachy_mini_conversation_app/audio/: head_wobbler.py, speech_tapper.py, and the package itself.
  • MovementManager: drop set_speech_offsets and the supporting state (_pending_speech_offsets, _speech_offsets_lock, _speech_offsets_dirty, state.speech_offsets, the speech term in _apply_pending_offsets and _get_secondary_pose). Speech offsets are now composed server-side via SetSpeechOffsetsCmd; the local composition path is gone.
  • ToolDependencies: drop the head_wobbler field.
  • Tests: drop tests/audio/test_head_wobbler.py, the two head-wobbler-only tests in tests/test_openai_realtime.py, the playback-delay test in tests/test_console.py, and update tests/test_gemini_live.py's remaining wobbler-aware test to drop the wobbler mock while keeping its transcript and listening-state coverage.

Mathematical equivalence

Side note: the implementation on the core side is almost identical to the one we had here. The only change is an improvement, basically:
final_new = compose_world_offset( compose_world_offset(primary, face), speech )
final_old = compose_world_offset( primary, single_matrix(speech + face) )

-> The impact should not be noticeable but it's still an improvement.

Behaviour change to be aware of

In --gradio mode, audio is rendered in the browser and never reaches the daemon's media pipeline, so the head no longer wobbles in that path. Production (non-gradio) paths use push_audio_sample, which routes audio through the daemon's pipeline and the wobbler works.

Test plan

  • pytest tests/test_console.py tests/test_openai_realtime.py passes (24 / 24).
  • Manual test on a lite robot in non-gradio mode

Notes

linked PR from the SDK side: pollen-robotics/reachy_mini#1001

The reachy_mini SDK now ships its own audio-reactive head wobbler that
runs inside the daemon's GStreamer pipeline. Audio pushed by the
conversation app via push_audio_sample already flows through that same
pipeline, so the daemon can drive head motion directly with no need
for the app to compute speech offsets in-process.

Replace the local HeadWobbler instance with calls to
mini.enable_wobbling() / mini.disable_wobbling() at startup and
shutdown. Drop the head_wobbler argument from ToolDependencies()
construction; consumers that still reference deps.head_wobbler will
see None and no-op via their existing guards (those guards are
removed in follow-up commits).

Behaviour change: in --gradio mode, audio is rendered in the browser
and never reaches the daemon's pipeline, so the head no longer wobbles
during gradio playback. The non-gradio (production) path is unchanged
in spirit and gains the new wobbler's stricter silence handling.
The local HeadWobbler is gone; deps.head_wobbler is always None. Remove
the now-dead "if head_wobbler is not None" blocks that called .reset(),
.request_reset_after_current_audio(), and .feed() during turn-handling
and audio-delta dispatch. The daemon's wobbler reacts to the audio
stream itself, so explicit reset hooks are no longer needed; silence
in the audio stream produces silence in motion automatically.

Also drops two tests that were exercising those exact call sites:
  - test_tool_completion_does_not_reset_head_wobbler
  - test_output_audio_done_schedules_head_wobbler_reset
Both asserted on MagicMock head_wobbler interactions that no longer
happen.
LocalStream's audio path no longer needs to feed a local wobbler with a
playback-delay-aware start offset; the daemon's wobbler analyses the
same audio that we hand off via push_audio_sample. Remove the
"if head_wobbler is not None" block and the now-unused
_estimate_pending_playback_seconds helper that only existed to compute
that start_delay_s.

Drops the matching test
test_play_loop_feeds_head_wobbler_with_local_playback_delay in
tests/test_console.py.
Removes everything that exists only to support the in-process speech
tapper that the daemon now replaces:

  - Delete src/reachy_mini_conversation_app/audio/{head_wobbler.py,
    speech_tapper.py,__init__.py} and the now-empty audio/ package.
  - Delete tests/audio/test_head_wobbler.py and the audio/ test dir.
  - Drop head_wobbler: Any | None = None from ToolDependencies.
  - In MovementManager, drop set_speech_offsets() and the supporting
    state (state.speech_offsets, _pending_speech_offsets,
    _speech_offsets_lock, _speech_offsets_dirty, the speech-side of
    _apply_pending_offsets, the speech term in _get_secondary_pose).
    Speech-induced offsets are now produced by the daemon's wobbler
    and composed server-side via SetSpeechOffsetsCmd, so the local
    composition path is no longer needed.

Updates the only remaining test that mocked head_wobbler:
test_gemini_turn_buffers_transcripts (was
test_gemini_turn_buffers_transcripts_and_schedules_motion_reset).
Drops the wobbler mock and assertions, keeps the transcript and
listening-state assertions which still describe real behaviour, and
swaps the wait-for predicate to use output_queue size and listening
call count instead of wobbler interaction.
@RemiFabre RemiFabre linked an issue Apr 29, 2026 that may be closed by this pull request
@RemiFabre RemiFabre self-assigned this Apr 29, 2026
@RemiFabre RemiFabre added the enhancement New feature or improvement request label Apr 29, 2026
@github-actions

github-actions Bot commented Apr 29, 2026

Copy link
Copy Markdown

🗑️ Preview space removed — PR was merged.

Drop three imports that became unused once the head_wobbler-only test
cases were removed (numpy and pytest in tests/test_console.py, base64
in tests/test_openai_realtime.py). Re-collapse a wrapped lambda in
tests/test_gemini_live.py back onto one line per ruff format.

Pure tooling fix; no behaviour change. CI ruff check now passes.
@RemiFabre
RemiFabre force-pushed the 335-head-wobbler-is-now-handled-on-the-core-side branch from 9be0334 to 5f56e1a Compare April 29, 2026 13:59
@RemiFabre
RemiFabre marked this pull request as ready for review April 29, 2026 14:00
The merge of main brought in base_realtime.py and huggingface_realtime
tests authored against the previous ToolDependencies.head_wobbler field,
which this branch had already deleted. Remove the three lifted blocks
in base_realtime.py, drop the now-meaningless wobbler test in
test_huggingface_realtime.py, fix the dangling head_wobbler symbol in
test_gemini_live.py, and clear unused imports the merge resolution
left in test_console.py.

Assisted-by: Claude:claude-opus-4-7
Assisted-by: Claude:claude-opus-4-7

@alozowski alozowski left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's nice to see how cleaner the project is with this work! The main thing before merging: Gradio wobbling is silently broken – let's fix it minimally, even despite we are going to remove it very soon

Comment thread src/reachy_mini_conversation_app/base_realtime.py
@@ -810,8 +806,6 @@ async def _run_realtime_session(self) -> None:
if event.type == "response.output_audio.delta":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Gradio mode, we used to feed each audio chunk to the head wobbler here, but with the current changes, the head won't wobble during the robot's speech in Gradio mode. Let's restore the Gradio mode functionality, please (though it will be removed soon)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is now a _tap_audio_for_daemon_wobbler function that foward audio samples to reachy mini so the wobbling works (and the audio output of the robot is muted)

Comment thread src/reachy_mini_conversation_app/main.py Outdated
Comment thread src/reachy_mini_conversation_app/moves.py Outdated
@alozowski alozowski moved this to In review in Reachy Mini May 21, 2026
Comment on lines -1 to -7
"""Moves head given audio samples."""

import time
import queue
import base64
import logging
import threading

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RIP

In Gradio mode the assistant audio plays in the browser and never
reaches the daemon's media pipeline, so the daemon-side wobbler had
nothing to react to and the head sat still. Push each decoded delta
through robot.media.push_audio_sample so the wobbler can tap it, and
start the playback pipeline (LocalStream did this in headless mode;
nothing was doing it in Gradio mode, leaving the gstreamer appsrc in
NULL state). Silence the robot speaker for the session via SetVolumeCmd
to avoid double playback, and restore the previous volume on shutdown.

Also addresses two review comments on PR #336: narrows the
enable_wobbling() comment to describe both modes, and trims the
past-change sentence from MovementManager._get_secondary_pose's
docstring.

Assisted-by: Claude:claude-opus-4-7
@FabienDanieau

Copy link
Copy Markdown
Contributor

It's nice to see how cleaner the project is with this work! The main thing before merging: Gradio wobbling is silently broken – let's fix it minimally, even despite we are going to remove it very soon

My solution is to also push audio sample to a mute reachy mini in the case of gradio. So we still use the core wobbler

The merge of main reintroduced test code wiring a head_wobbler into
ToolDependencies and asserting the OpenAI handler drives it. This branch
moved head wobbling to the core side: ToolDependencies no longer has that
field and the handler never touches a wobbler. Drop the head_wobbler
param, the ToolDependencies kwarg, the obsolete reset test, and the now
unused base64 import.

Assisted-by: Claude:claude-opus-4-8
requests is used directly in main.py but ships no type stubs, so mypy
fails with import-untyped in the project environment. Add types-requests
to the dev group.

Assisted-by: Claude:claude-opus-4-8

@alozowski alozowski left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

@alozowski
alozowski merged commit d631c67 into main Jun 4, 2026
9 checks passed
@github-project-automation github-project-automation Bot moved this from In review to Done in Reachy Mini Jun 4, 2026
@alozowski
alozowski deleted the 335-head-wobbler-is-now-handled-on-the-core-side branch June 4, 2026 13:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or improvement request

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Head wobbler is now handled on the core side

4 participants