Skip to content

Commit 8c428a1

Browse files
Clear queued playback audio on WebRTC backend (barge-in) (#1186)
* feat(media): clear queued playback audio on WebRTC backend (barge-in) GstWebRTCClient.clear_output_buffer() was a no-op, so realtime conversation barge-in could not stop already-buffered robot speech. Add a real clear_player() to GstWebRTCClient that flushes the local audio send chain and POSTs /api/media/clear_incoming_audio so the daemon also flushes its incoming-audio playback pipeline (where most of the buffered audio actually sits). Deprecate clear_output_buffer() on both the WebRTC and local GStreamer backends (warn-only). Wire the daemon flush through both transports: a new REST endpoint and a ClearIncomingAudioCmd data-channel command, both routed to Backend.clear_incoming_audio() -> GstMediaServer.clear_incoming_audio(). Flush the incoming-audio appsrc with reset_time=False to keep the shared-clock timeline intact. Expose clearIncomingAudio() in the TypeScript SDK and document it. Fixes #1108 Assisted-by: Claude:claude-opus-4-8 * refactor(media): make clear_player() part of the AudioBase contract Promote clear_player() to an abstract method on AudioBase now that both GStreamerAudio and GstWebRTCClient implement it, so callers can invoke it without hasattr() guards. Tidy the deprecated clear_output_buffer() docstrings to imperative mood (ruff D401). Assisted-by: Claude:claude-opus-4-8 * regenerate api * feat(webrtc): continuous audio send chain via audiomixer Insert an audiomixer (fed by a silent live audiotestsrc on a second pad) into the WebRTC audio send chain so it emits a continuous output stream between utterances, keeping the Opus encoder / webrtcbin warm. This drops the 0.5 s warm-up-silence hack and the first-word swallowing it worked around. A capsfilter after the mixer pins the output to SAMPLE_RATE / CHANNELS so opusenc/rtpopuspay advertise sprop-maxcapturerate and stereo encoding-params matching the negotiated webrtcbin OPUS sink pad (the mixer otherwise defaults to 48 kHz / mono and webrtcbin rejects it). Push path now marks the first buffer of a cue DISCONT with a running-time PTS and leaves follow-up buffers untimestamped, letting the mixer place them contiguously by byte offset. Handle LATENCY bus messages to redistribute latency after the live elements are added dynamically. Assisted-by: Claude:claude-opus-4-8 * removing extra latency on the mixer
1 parent 3d52f7d commit 8c428a1

11 files changed

Lines changed: 244 additions & 42 deletions

File tree

docs/source/API/openapi.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,29 @@
13141314
}
13151315
}
13161316
},
1317+
"/api/media/clear_incoming_audio": {
1318+
"post": {
1319+
"summary": "Clear Incoming Audio",
1320+
"description": "Drop audio received from WebRTC clients that is queued for the speaker.\n\nUsed for barge-in so the robot stops speaking already-buffered audio.",
1321+
"operationId": "clear_incoming_audio_api_media_clear_incoming_audio_post",
1322+
"responses": {
1323+
"200": {
1324+
"description": "Successful Response",
1325+
"content": {
1326+
"application/json": {
1327+
"schema": {
1328+
"additionalProperties": {
1329+
"type": "string"
1330+
},
1331+
"type": "object",
1332+
"title": "Response Clear Incoming Audio Api Media Clear Incoming Audio Post"
1333+
}
1334+
}
1335+
}
1336+
}
1337+
}
1338+
}
1339+
},
13171340
"/api/media/wobbling/enable": {
13181341
"post": {
13191342
"summary": "Enable Wobbling",

docs/source/SDK/javascript-sdk.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ new ReachyMini({
109109
| `setAntennasDeg(right, left)` | `boolean` | Set antenna positions in degrees (wraps `setTarget`) |
110110
| `setBodyYawDeg(yaw)` | `boolean` | Set body yaw in degrees (wraps `setTarget`) |
111111
| `playSound(filename)` | `boolean` | Play a sound file on the robot |
112+
| `clearIncomingAudio()` | `boolean` | Drop audio queued for the robot speaker (barge-in) |
112113
| `sendRaw(data)` | `boolean` | Send arbitrary JSON via data channel |
113114
| `requestState()` | `boolean` | Request a state snapshot |
114115
| `setAudioMuted(muted)` || Mute/unmute robot speaker (local) |

src/reachy_mini/daemon/app/routers/media.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,22 @@ async def stop_sound(
9595
return {"status": "ok"}
9696

9797

98+
@router.post("/clear_incoming_audio")
99+
async def clear_incoming_audio(
100+
daemon: Daemon = Depends(get_daemon),
101+
) -> dict[str, str]:
102+
"""Drop audio received from WebRTC clients that is queued for the speaker.
103+
104+
Used for barge-in so the robot stops speaking already-buffered audio.
105+
"""
106+
backend = daemon.backend
107+
if backend is None or not backend.ready.is_set():
108+
raise HTTPException(status_code=503, detail="Backend not running")
109+
110+
backend.clear_incoming_audio()
111+
return {"status": "ok"}
112+
113+
98114
@router.post("/wobbling/enable")
99115
async def enable_wobbling(
100116
daemon: Daemon = Depends(get_daemon),

src/reachy_mini/daemon/backend/abstract.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
ApplyAudioConfigCmd,
3131
CancelAudioCmd,
3232
CancelMoveCmd,
33+
ClearIncomingAudioCmd,
3334
GetHardwareIdCmd,
3435
GetMicrophoneVolumeCmd,
3536
GetMotorModeCmd,
@@ -901,6 +902,15 @@ def stop_sound(self) -> None:
901902
if self._media_server is not None:
902903
self._media_server.stop_sound()
903904

905+
def clear_incoming_audio(self) -> None:
906+
"""Flush incoming WebRTC audio queued for the speaker (barge-in).
907+
908+
Delegates to the media server. If the server is not available
909+
(no_media mode), this is a no-op.
910+
"""
911+
if self._media_server is not None:
912+
self._media_server.clear_incoming_audio()
913+
904914
# Basic move definitions
905915
INIT_HEAD_POSE = np.eye(4)
906916

@@ -1149,6 +1159,10 @@ def _maybe_ignore(field: str) -> bool:
11491159
self.play_sound(cmd.file)
11501160
send_response({"status": "ok", "command": "play_sound"})
11511161

1162+
elif isinstance(cmd, ClearIncomingAudioCmd):
1163+
self.clear_incoming_audio()
1164+
send_response({"status": "ok", "command": "clear_incoming_audio"})
1165+
11521166
elif isinstance(cmd, SetSpeechOffsetsCmd):
11531167
offsets = cmd.offsets
11541168
if len(offsets) == 6:

src/reachy_mini/io/protocol.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
upload_move_start, upload_move_chunk, upload_move_finish,
1313
upload_audio_start, upload_audio_chunk, upload_audio_finish,
1414
play_uploaded_move, cancel_move,
15-
play_uploaded_audio, cancel_audio,
15+
play_uploaded_audio, cancel_audio, clear_incoming_audio,
1616
apply_audio_config, read_audio_parameter
1717
1818
Server->Client message types:
@@ -644,6 +644,17 @@ class CancelAudioCmd(BaseModel):
644644
upload_id: str
645645

646646

647+
class ClearIncomingAudioCmd(BaseModel):
648+
"""Drop incoming WebRTC audio queued for the speaker (barge-in). Fire-and-forget.
649+
650+
Flushes the daemon's incoming-audio playback pipeline so audio already
651+
received from a WebRTC client stops playing promptly. No-op if no audio
652+
is currently being received.
653+
"""
654+
655+
type: Literal["clear_incoming_audio"] = "clear_incoming_audio"
656+
657+
647658
AnyCommand = Annotated[
648659
SetTargetCmd
649660
| SetHeadJointsCmd
@@ -684,6 +695,7 @@ class CancelAudioCmd(BaseModel):
684695
| CancelMoveCmd
685696
| PlayUploadedAudioCmd
686697
| CancelAudioCmd
698+
| ClearIncomingAudioCmd
687699
| ApplyAudioConfigCmd
688700
| ReadAudioParameterCmd,
689701
Field(discriminator="type"),

src/reachy_mini/media/audio_base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- ``start_recording()``, ``stop_recording()``
1212
- ``start_playing()``, ``stop_playing()``
1313
- ``push_audio_sample()``
14+
- ``clear_player()``
1415
- ``play_sound()``
1516
1617
"""
@@ -218,6 +219,11 @@ def push_audio_sample(self, data: npt.NDArray[np.float32]) -> None:
218219
"""Push audio data to the output."""
219220
...
220221

222+
@abstractmethod
223+
def clear_player(self) -> None:
224+
"""Drop any queued playback audio immediately (barge-in)."""
225+
...
226+
221227
@abstractmethod
222228
def play_sound(self, sound_file: str) -> None:
223229
"""Play a sound file."""

src/reachy_mini/media/audio_gstreamer.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
import os
5555
import platform
5656
import time
57+
import warnings
5758
from collections.abc import Callable
5859
from threading import Thread
5960
from typing import Optional
@@ -84,13 +85,14 @@
8485
class GStreamerAudio(AudioBase):
8586
"""Audio implementation using GStreamer.
8687
87-
Extends ``AudioBase`` with two GStreamer-specific helpers:
88+
Extends ``AudioBase`` with a GStreamer-specific helper:
8889
89-
- ``clear_output_buffer()``: flush queued playback data without stopping
90-
the pipeline (no-op by default; useful before refilling the buffer).
9190
- ``clear_player()``: flush the playback appsrc immediately via GStreamer
9291
flush events, dropping any queued audio.
9392
93+
(``clear_output_buffer()`` is deprecated and does nothing; use
94+
``clear_player()`` instead.)
95+
9496
"""
9597

9698
PLAYBACK_SINK_BUFFER_TIME_US = 50_000
@@ -430,13 +432,13 @@ def stop_playing(self) -> None:
430432
self._playbin = None
431433

432434
def clear_output_buffer(self) -> None:
433-
"""Flush queued playback data so it is not played.
434-
435-
A low ``set_max_output_buffers`` value may make this unnecessary
436-
for most use-cases.
437-
438-
"""
439-
pass # subclasses or future implementations can override
435+
"""Use :meth:`clear_player` instead. Deprecated; does nothing."""
436+
warnings.warn(
437+
"clear_output_buffer() is deprecated; use clear_player().",
438+
DeprecationWarning,
439+
stacklevel=2,
440+
)
441+
self.logger.warning("clear_output_buffer() is deprecated; use clear_player().")
440442

441443
def clear_player(self) -> None:
442444
"""Flush the player's appsrc to drop any queued audio immediately."""

src/reachy_mini/media/media_server.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ class GstMediaServer:
138138
# converts whatever the source produces down to this rate before delivery.
139139
WOBBLER_SAMPLE_RATE = 16_000
140140

141+
# Name of the appsrc feeding the incoming-audio playback pipeline; used
142+
# both when building the pipeline and when flushing it (clear_incoming_audio).
143+
INCOMING_AUDIO_SRC_NAME = "audio_in"
144+
141145
def __init__(
142146
self,
143147
log_level: str = "INFO",
@@ -387,7 +391,7 @@ def _on_consumer_pad_added(
387391
self._pipeline_playback.use_clock(sender_clock)
388392
self._pipeline_playback.set_start_time(Gst.CLOCK_TIME_NONE)
389393

390-
appsrc = Gst.ElementFactory.make("appsrc", "audio_in")
394+
appsrc = Gst.ElementFactory.make("appsrc", self.INCOMING_AUDIO_SRC_NAME)
391395
appsrc.set_property("format", Gst.Format.TIME)
392396
appsrc.set_property("is-live", True)
393397
appsrc.set_property("caps", caps)
@@ -500,6 +504,32 @@ def _cleanup_incoming_audio(self, peer_id: str) -> None:
500504
playback_pipe.set_state(Gst.State.NULL)
501505
self._logger.info(f"Cleaned up incoming audio for peer {peer_id}")
502506

507+
def clear_incoming_audio(self) -> None:
508+
"""Flush queued/rendering audio in the incoming-audio playback pipeline.
509+
510+
Used for barge-in: drops audio already received from a WebRTC client
511+
and queued for the robot's speaker so the robot stops speaking promptly.
512+
513+
The playback pipeline shares the sender clock + base-time, so incoming
514+
buffer PTS live in that shared running-time; we flush with
515+
``reset_time=False`` to keep the timeline intact (``reset_time=True``
516+
would strand future-stamped buffers and stall playback). The pad probe
517+
keeps pushing new RTP buffers into the appsrc, which resume in sync.
518+
"""
519+
pipeline = self._pipeline_playback
520+
if pipeline is None:
521+
self._logger.info("No incoming-audio pipeline to clear.")
522+
return
523+
appsrc = pipeline.get_by_name(self.INCOMING_AUDIO_SRC_NAME)
524+
if appsrc is None:
525+
self._logger.warning("Incoming-audio appsrc not found; nothing to flush.")
526+
return
527+
appsrc.send_event(Gst.Event.new_flush_start())
528+
appsrc.send_event(Gst.Event.new_flush_stop(reset_time=False))
529+
if self._head_wobbler is not None:
530+
self._head_wobbler.reset()
531+
self._logger.info("Flushed incoming audio playback")
532+
503533
@property
504534
def resolution(self) -> tuple[int, int]:
505535
"""Get the current camera resolution as a tuple (width, height)."""

0 commit comments

Comments
 (0)