Module scenario.voice.adapters.pipecat

PipecatAgentAdapter: WebSocket client to a user-run Pipecat bot.

Source §5.1. Pipecat is a framework for BUILDING voice agents. The user runs their Pipecat bot separately (e.g. python bot.py -t twilio --port 8765) and this adapter connects as a client to exchange audio with it.

Wire protocol. A pipecat bot configured with the -t twilio transport uses TwilioFrameSerializer on its WebSocket — the same Twilio Media Streams JSON protocol scenario's TwilioAgentAdapter already speaks. Scenario impersonates Twilio: sends a synthetic start event with fake stream/call SIDs, then exchanges media events carrying base64-encoded µ-law 8kHz audio.

transport="webrtc" (SmallWebRTC) is not implemented in this PR — it stays on PendingTransportError and is tracked in a follow-up issue.

Expand source code
"""
PipecatAgentAdapter: WebSocket client to a user-run Pipecat bot.

Source §5.1. Pipecat is a framework for BUILDING voice agents. The user
runs their Pipecat bot separately (e.g. ``python bot.py -t twilio --port 8765``)
and this adapter connects as a client to exchange audio with it.

**Wire protocol.** A pipecat bot configured with the ``-t twilio`` transport
uses ``TwilioFrameSerializer`` on its WebSocket — the same Twilio Media
Streams JSON protocol scenario's ``TwilioAgentAdapter`` already speaks.
Scenario impersonates Twilio: sends a synthetic ``start`` event with fake
stream/call SIDs, then exchanges ``media`` events carrying base64-encoded
µ-law 8kHz audio.

``transport="webrtc"`` (SmallWebRTC) is not implemented in this PR — it
stays on ``PendingTransportError`` and is tracked in a follow-up issue.
"""

from __future__ import annotations

import asyncio
import json
import logging
import uuid
from typing import Any, ClassVar, Literal, Optional

from opentelemetry.context import Context

from ..adapter import AgentStreamEndedError, VoiceAgentAdapter
from ..audio_chunk import AudioChunk
from ..capabilities import AdapterCapabilities
from .._telemetry import voice_span
from ._twilio_shared import (
    TWILIO_FRAME_MS,
    build_clear_frame,
    build_mark_frame,
    build_media_frame,
    iter_mulaw_frames,
    mulaw8k_to_pcm16_24k,
    parse_media_stream_frame,
    pcm16_24k_to_mulaw8k,
)


logger = logging.getLogger("scenario.voice.pipecat")


_RECV_LOOP_DONE = object()
"""Sentinel pushed onto the inbound queue when _recv_loop terminates, so a
waiting recv_audio wakes immediately and surfaces the terminal cause instead of
blocking until its caller's timeout fires on a queue nothing will fill (#498)."""


class PipecatRecvError(AgentStreamEndedError):
    """recv_audio could get no audio because the background _recv_loop ended.

    Names the real reason — a crash in the read loop (decode/transport error,
    chained via __cause__) or a clean WebSocket close by the bot — so the #498
    2nd-turn hang surfaces an attributable error instead of a blind
    response_timeout with an empty body.
    """


class PipecatAgentAdapter(VoiceAgentAdapter):
    """
    Test a running Pipecat bot via its exposed WebSocket endpoint.

    Transport is selected by the ``transport`` argument:
        - ``"websocket"`` (default): Twilio Media Streams protocol over WS.
          Scenario sends a synthetic ``start`` event, then ``media`` frames.
          Pipecat's ``TwilioFrameSerializer`` on the bot side handles the
          wire format.
        - ``"webrtc"``: SmallWebRTC-style negotiation. Raises
          ``PendingTransportError``; tracked as a follow-up.
    """

    capabilities: ClassVar[AdapterCapabilities] = AdapterCapabilities(
        # The Twilio Media Streams wire protocol carries audio frames only,
        # never transcript events. Advertising streaming_transcripts made
        # interrupt(after_words=N) poll a ``streaming_transcript`` attribute
        # this adapter never populates, hanging the script step forever.
        # Turn-level transcripts come from the base call()'s runtime STT.
        streaming_transcripts=False,
        native_vad=True,
        dtmf=False,
        # Pipecat over the Twilio WS transport speaks the Twilio Media Streams
        # protocol; the ``clear`` event drops all buffered outbound audio on
        # the bot side. That's first-class interrupt — no VAD timing race.
        interruption=True,
        input_formats=["pcm16/24000", "mulaw/8000", "opus"],
        output_formats=["pcm16/24000", "mulaw/8000", "opus"],
    )

    def __init__(
        self,
        url: Optional[str] = None,
        *,
        signaling_url: Optional[str] = None,
        transport: Literal["websocket", "webrtc"] = "websocket",
        audio_format: str = "mulaw",
        sample_rate: int = 8000,
        stream_sid: Optional[str] = None,
        call_sid: Optional[str] = None,
    ) -> None:
        super().__init__()
        if transport == "websocket" and url is None:
            raise ValueError("PipecatAgentAdapter(transport='websocket') requires url=")
        if transport == "webrtc" and signaling_url is None:
            raise ValueError("PipecatAgentAdapter(transport='webrtc') requires signaling_url=")

        self.url = url
        self.signaling_url = signaling_url
        self.transport = transport
        self.audio_format = audio_format
        self.sample_rate = sample_rate
        # Synthetic SIDs pipecat's TwilioFrameSerializer needs in the `start`
        # event. If caller doesn't supply them, we fabricate UUIDs. Pipecat
        # uses them for logging and the auto-hangup REST call; both are no-ops
        # when we're not actually going through Twilio.
        self.stream_sid = stream_sid
        self.call_sid = call_sid

        self._ws: Any = None
        self._recv_task: Optional[asyncio.Task] = None
        # Carries AudioChunks plus the _RECV_LOOP_DONE sentinel (hence Any), so a
        # waiting recv_audio learns the loop ended instead of blocking forever.
        self._inbound_queue: Optional[asyncio.Queue[Any]] = None
        # Set by _recv_loop when it crashes; recv_audio reads it to name the root
        # cause (chained via __cause__) on the PipecatRecvError it raises (#498).
        self._recv_loop_exc: Optional[BaseException] = None
        # Set True when _recv_loop terminates (crash or clean close). Lets
        # recv_audio fail fast on a drained queue without re-reading it (#498).
        self._recv_loop_done: bool = False
        # The turn context we last emitted a background ``voice.audio.receive``
        # span for — so ``_deliver`` spans only the FIRST wire delivery of each
        # turn (one span/turn, no per-100ms-chunk flood). Reset implicitly: the
        # next turn publishes a distinct ``_voice_turn_context`` object (#774).
        self._bg_span_turn_context: Optional[Context] = None
        # Serialises concurrent send_audio() calls — without it two paced
        # senders would interleave 20-ms mulaw frames on the wire and the
        # bot would receive corrupted audio. Used for the interruption case
        # where the executor calls send_audio() while a previous turn's
        # send is still in flight.
        self._send_lock: Optional[asyncio.Lock] = None

    @property
    def transport_format(self) -> str:
        return f"{self.audio_format}/{self.sample_rate}"

    # ------------------------------------------------------------------ lifecycle

    async def connect(self) -> None:
        if self.transport == "webrtc":
            from ._stub import PendingTransportError

            raise PendingTransportError(
                "PipecatAgentAdapter(transport='webrtc')"
            )

        # Lazy import so `import scenario` doesn't require websockets at the
        # top of the module-load path (it's already a hard dep, but being
        # consistent with the Twilio adapter style).
        import websockets

        assert self.url is not None  # validated in __init__
        self._ws = await websockets.connect(
            self.url, ping_interval=None, ping_timeout=None
        )
        self._inbound_queue = asyncio.Queue()
        self._recv_loop_exc = None  # reset per fresh connection
        self._recv_loop_done = False
        self._send_lock = asyncio.Lock()

        # Send the synthetic `start` event that pipecat's TwilioFrameSerializer
        # requires to learn the stream/call SIDs and start deserializing
        # media frames.
        if self.stream_sid is None:
            self.stream_sid = f"MZ{uuid.uuid4().hex}"
        if self.call_sid is None:
            self.call_sid = f"CA{uuid.uuid4().hex}"

        await self._ws.send(json.dumps({"event": "connected", "protocol": "Call", "version": "1.0.0"}))
        await self._ws.send(
            json.dumps(
                {
                    "event": "start",
                    "streamSid": self.stream_sid,
                    "start": {
                        "streamSid": self.stream_sid,
                        "callSid": self.call_sid,
                        "mediaFormat": {
                            "encoding": "audio/x-mulaw",
                            "sampleRate": 8000,
                            "channels": 1,
                        },
                    },
                }
            )
        )

        # Stamp Pipecat transport attrs onto the active ``voice.adapter.connect``
        # span (opened by the executor connect loop). Base spans are name-owned;
        # the adapter contributes attributes, never a parallel span name — mirror
        # ElevenLabs' ``voice.elevenlabs.agent_id`` seam (``adapters/elevenlabs.py``).
        from opentelemetry import trace as _otel_trace
        from .._telemetry import set_span_attributes

        set_span_attributes(
            _otel_trace.get_current_span(),
            {
                "voice.pipecat.transport": self.transport,
                "voice.pipecat.transport_format": self.transport_format,
            },
        )

        self._recv_task = asyncio.create_task(self._recv_loop())
        logger.debug("PipecatAgentAdapter: connected to %s (stream=%s)", self.url, self.stream_sid)

    async def disconnect(self) -> None:
        ws = self._ws
        if ws is None:
            return

        # Send `stop` event so the bot can clean up its pipeline gracefully.
        try:
            if self.stream_sid:
                await ws.send(json.dumps({"event": "stop", "streamSid": self.stream_sid}))
        except Exception:
            logger.debug("PipecatAgentAdapter: failed to send stop frame", exc_info=True)

        if self._recv_task is not None:
            self._recv_task.cancel()
            try:
                await self._recv_task
            except asyncio.CancelledError:
                # Expected: we just cancelled it.
                pass
            except Exception:
                # Unexpected teardown error — already logging enough context
                # elsewhere; disconnect() is best-effort.
                logger.debug("PipecatAgentAdapter: recv_task raised during cancel", exc_info=True)
            self._recv_task = None

        try:
            await ws.close()
        except Exception:
            # WS may already be closed by the peer; disconnect() is best-effort.
            logger.debug("PipecatAgentAdapter: ws.close() raised", exc_info=True)

        self._ws = None
        self._inbound_queue = None
        self.stream_sid = None
        self.call_sid = None

    # ------------------------------------------------------------------ I/O

    async def send_audio(self, chunk: AudioChunk) -> None:
        # Pace at real-time (TWILIO_FRAME_MS/1000s per 20-ms frame). Matches what
        # a real caller produces over a PSTN line — the SUT sees normal speech
        # rhythm, not a synthetic dump.
        #
        # After the last frame we send a Twilio ``mark`` named "utterance_end".
        # Real-time pacing means TTS-induced inter-phrase pauses survive on the
        # wire, and a stateless inactivity-timer on the receiver can't
        # distinguish "speaker paused after a comma" from "speaker finished
        # their turn." The mark is an explicit, non-ambiguous end-of-turn
        # signal: cooperating SUTs flush on the mark; legacy SUTs fall back to
        # VAD timing.
        self._assert_connected()
        assert self._ws is not None and self.stream_sid is not None and self._send_lock is not None
        mulaw = pcm16_24k_to_mulaw8k(chunk.data)
        frame_secs = TWILIO_FRAME_MS / 1000
        async with self._send_lock:
            for frame in iter_mulaw_frames(mulaw):
                if not frame:
                    continue
                await self._ws.send(build_media_frame(self.stream_sid, frame))
                await asyncio.sleep(frame_secs)
            await self._ws.send(build_mark_frame(self.stream_sid, "utterance_end"))

    async def recv_audio(self, timeout: float) -> AudioChunk:
        self._assert_connected()
        assert self._inbound_queue is not None
        # The loop already terminated and its queue is drained → fail fast with
        # the terminal cause instead of blocking for `timeout` on a queue nothing
        # will fill. The flag + drained-queue check is the terminal state for the
        # rest of this connection (no await, no re-pushed sentinel to leak).
        if self._recv_loop_done and self._inbound_queue.empty():
            raise self._recv_loop_ended_error() from self._recv_loop_exc
        item = await asyncio.wait_for(self._inbound_queue.get(), timeout=timeout)
        if item is _RECV_LOOP_DONE:
            raise self._recv_loop_ended_error() from self._recv_loop_exc
        return item

    def _recv_loop_ended_error(self) -> PipecatRecvError:
        # Chaining is done at the raise site (``raise ... from self._recv_loop_exc``)
        # so ``__suppress_context__`` is set correctly and the clean-close branch
        # (exc is None → ``from None``) gets a true empty cause.
        exc = self._recv_loop_exc
        if exc is not None:
            return PipecatRecvError(
                "pipecat recv loop crashed; no further audio will arrive: "
                f"{type(exc).__name__}: {exc}"
            )
        return PipecatRecvError(
            "pipecat bot closed the WebSocket; no further audio will arrive — the "
            "bot hung up or its pipeline stopped without responding"
        )

    async def interrupt(self) -> None:
        """Send a Twilio ``clear`` frame — the bot drops all buffered outbound
        audio immediately. Cooperating Pipecat bots (and any code wired to
        the Media Streams protocol) treat ``clear`` as "stop talking now."
        Use this in preference to timing-based barge-in when the SUT
        supports it: it's deterministic, doesn't depend on VAD detection
        windows, and matches the same protocol used in production.
        """
        self._assert_connected()
        assert self._ws is not None and self.stream_sid is not None
        await self._ws.send(build_clear_frame(self.stream_sid))
        logger.debug("PipecatAgentAdapter: sent clear frame (interrupt)")

    # ------------------------------------------------------------------ background

    async def _recv_loop(self) -> None:
        """Read frames from pipecat, decode µ-law → PCM16 24k, enqueue."""
        assert self._ws is not None and self._inbound_queue is not None
        queue = self._inbound_queue
        buffered_mulaw = bytearray()
        BATCH_MS = 100

        try:
            async for raw in self._ws:
                if isinstance(raw, bytes):
                    # pipecat sometimes emits binary frames for audio; treat
                    # as raw µ-law payload if we see one.
                    buffered_mulaw.extend(raw)
                    if len(buffered_mulaw) >= (BATCH_MS * 8):
                        self._deliver(bytes(buffered_mulaw))
                        buffered_mulaw.clear()
                    continue

                frame = parse_media_stream_frame(raw)
                if frame is None:
                    continue
                if frame.event == "media" and frame.payload_mulaw:
                    buffered_mulaw.extend(frame.payload_mulaw)
                    if len(buffered_mulaw) >= (BATCH_MS * 8):
                        self._deliver(bytes(buffered_mulaw))
                        buffered_mulaw.clear()
                elif frame.event == "stop":
                    if buffered_mulaw:
                        self._deliver(bytes(buffered_mulaw))
                        buffered_mulaw.clear()
                    return
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            # #498: do NOT swallow. A crash here (decode error, transport reset,
            # bot pipeline failure) used to only log + fall through, leaving the
            # inbound queue silent so recv_audio blocked the full response_timeout
            # with no attributable cause. Record it; recv_audio raises
            # PipecatRecvError naming this as the root cause (chained via __cause__).
            self._recv_loop_exc = exc
            logger.warning("PipecatAgentAdapter: recv loop crashed", exc_info=True)
        finally:
            # Mark terminal, then wake any pending recv_audio: no more audio will
            # arrive on this connection. The flag lets later recv_audio calls fail
            # fast on the drained queue; the sentinel unblocks a getter currently
            # awaiting an empty queue. Together they turn an indefinite wait into
            # an immediate, attributable PipecatRecvError. (No await between the
            # two lines, so a waiter can't observe a half-set terminal state.)
            self._recv_loop_done = True
            queue.put_nowait(_RECV_LOOP_DONE)

    def _deliver(self, mulaw: bytes) -> None:
        """Decode a coalesced µ-law batch to PCM16/24k and enqueue it.

        On the FIRST wire delivery of a turn — the base ``call()`` published its
        OTel context via :attr:`VoiceAgentAdapter._voice_turn_context` — wrap the
        decode+enqueue in a ``voice.audio.receive`` span parented to THAT turn
        (#774), so the background ``_recv_loop`` task's receive work is attributed
        to the turn instead of the task's frozen connect-time context (a
        now-closed span).

        This is a producer-side **delivery marker** (it carries the delivered
        byte count); the consumer-side wait and the timeout/ERROR semantics (P3)
        live on the base ``voice.audio.receive`` span that wraps ``recv_audio``.
        The span is disambiguated from that base span by
        ``voice.pipecat.recv.source=background_loop``.

        Emitted at most ONCE per turn (only the first delivery under a given turn
        context) — matching the base's one-receive-span-per-turn granularity and
        the epic's no-per-tick-flood rule (the EL pump H1), so a multi-second turn
        of 100 ms chunks is not exploded into dozens of sibling spans. Between
        turns the context is ``None`` and we enqueue WITHOUT a span, so no
        detached/closed-parent span can leak from the background task. The body is
        synchronous (``put_nowait`` on the unbounded queue), so a ``disconnect()``
        cancel can never land mid-span.

        Coverage limits (by design — this is a marker, not exhaustive accounting):
        - ``voice.audio.bytes`` is the DELIVERED coalesced-chunk size, which may
          fold in a sub-100 ms µ-law tail carried over from the prior turn (the
          recv-loop coalesces to 800-byte batches across the turn boundary — a
          pre-existing property the base drain chunks share; not corrected here
          because flushing a partial batch at the boundary would drop that audio).
        - A turn whose agent audio was ALREADY buffered before ``call()`` published
          the turn context (an opening greeting delivered during ``connect``) is
          drained from the queue without a fresh wire delivery, so it gets no
          background marker — the base ``voice.audio.receive`` span still covers
          its consumption. The marker records in-turn wire deliveries, not every
          turn that consumes audio (the deterministic turn-liveness gate).
        """
        assert self._inbound_queue is not None
        parent = self._voice_turn_context
        if parent is None or parent is self._bg_span_turn_context:
            self._inbound_queue.put_nowait(AudioChunk(data=mulaw8k_to_pcm16_24k(mulaw)))
            return
        self._bg_span_turn_context = parent
        with voice_span(
            "voice.audio.receive",
            {
                "voice.adapter.class": type(self).__name__,
                "voice.pipecat.recv.source": "background_loop",
            },
            parent=parent,
        ) as span:
            pcm = mulaw8k_to_pcm16_24k(mulaw)
            span.set_attribute("voice.audio.bytes", len(pcm))
            self._inbound_queue.put_nowait(AudioChunk(data=pcm))

    # ------------------------------------------------------------------ assertions

    def _assert_connected(self) -> None:
        if self._ws is None:
            raise RuntimeError(
                "PipecatAgentAdapter: not connected. Did you forget to call connect()?"
            )

Classes

class PipecatAgentAdapter (url: Optional[str] = None, *, signaling_url: Optional[str] = None, transport: "Literal['websocket', 'webrtc']" = 'websocket', audio_format: str = 'mulaw', sample_rate: int = 8000, stream_sid: Optional[str] = None, call_sid: Optional[str] = None)

Test a running Pipecat bot via its exposed WebSocket endpoint.

Transport is selected by the transport argument: - "websocket" (default): Twilio Media Streams protocol over WS. Scenario sends a synthetic start event, then media frames. Pipecat's TwilioFrameSerializer on the bot side handles the wire format. - "webrtc": SmallWebRTC-style negotiation. Raises PendingTransportError; tracked as a follow-up.

Expand source code
class PipecatAgentAdapter(VoiceAgentAdapter):
    """
    Test a running Pipecat bot via its exposed WebSocket endpoint.

    Transport is selected by the ``transport`` argument:
        - ``"websocket"`` (default): Twilio Media Streams protocol over WS.
          Scenario sends a synthetic ``start`` event, then ``media`` frames.
          Pipecat's ``TwilioFrameSerializer`` on the bot side handles the
          wire format.
        - ``"webrtc"``: SmallWebRTC-style negotiation. Raises
          ``PendingTransportError``; tracked as a follow-up.
    """

    capabilities: ClassVar[AdapterCapabilities] = AdapterCapabilities(
        # The Twilio Media Streams wire protocol carries audio frames only,
        # never transcript events. Advertising streaming_transcripts made
        # interrupt(after_words=N) poll a ``streaming_transcript`` attribute
        # this adapter never populates, hanging the script step forever.
        # Turn-level transcripts come from the base call()'s runtime STT.
        streaming_transcripts=False,
        native_vad=True,
        dtmf=False,
        # Pipecat over the Twilio WS transport speaks the Twilio Media Streams
        # protocol; the ``clear`` event drops all buffered outbound audio on
        # the bot side. That's first-class interrupt — no VAD timing race.
        interruption=True,
        input_formats=["pcm16/24000", "mulaw/8000", "opus"],
        output_formats=["pcm16/24000", "mulaw/8000", "opus"],
    )

    def __init__(
        self,
        url: Optional[str] = None,
        *,
        signaling_url: Optional[str] = None,
        transport: Literal["websocket", "webrtc"] = "websocket",
        audio_format: str = "mulaw",
        sample_rate: int = 8000,
        stream_sid: Optional[str] = None,
        call_sid: Optional[str] = None,
    ) -> None:
        super().__init__()
        if transport == "websocket" and url is None:
            raise ValueError("PipecatAgentAdapter(transport='websocket') requires url=")
        if transport == "webrtc" and signaling_url is None:
            raise ValueError("PipecatAgentAdapter(transport='webrtc') requires signaling_url=")

        self.url = url
        self.signaling_url = signaling_url
        self.transport = transport
        self.audio_format = audio_format
        self.sample_rate = sample_rate
        # Synthetic SIDs pipecat's TwilioFrameSerializer needs in the `start`
        # event. If caller doesn't supply them, we fabricate UUIDs. Pipecat
        # uses them for logging and the auto-hangup REST call; both are no-ops
        # when we're not actually going through Twilio.
        self.stream_sid = stream_sid
        self.call_sid = call_sid

        self._ws: Any = None
        self._recv_task: Optional[asyncio.Task] = None
        # Carries AudioChunks plus the _RECV_LOOP_DONE sentinel (hence Any), so a
        # waiting recv_audio learns the loop ended instead of blocking forever.
        self._inbound_queue: Optional[asyncio.Queue[Any]] = None
        # Set by _recv_loop when it crashes; recv_audio reads it to name the root
        # cause (chained via __cause__) on the PipecatRecvError it raises (#498).
        self._recv_loop_exc: Optional[BaseException] = None
        # Set True when _recv_loop terminates (crash or clean close). Lets
        # recv_audio fail fast on a drained queue without re-reading it (#498).
        self._recv_loop_done: bool = False
        # The turn context we last emitted a background ``voice.audio.receive``
        # span for — so ``_deliver`` spans only the FIRST wire delivery of each
        # turn (one span/turn, no per-100ms-chunk flood). Reset implicitly: the
        # next turn publishes a distinct ``_voice_turn_context`` object (#774).
        self._bg_span_turn_context: Optional[Context] = None
        # Serialises concurrent send_audio() calls — without it two paced
        # senders would interleave 20-ms mulaw frames on the wire and the
        # bot would receive corrupted audio. Used for the interruption case
        # where the executor calls send_audio() while a previous turn's
        # send is still in flight.
        self._send_lock: Optional[asyncio.Lock] = None

    @property
    def transport_format(self) -> str:
        return f"{self.audio_format}/{self.sample_rate}"

    # ------------------------------------------------------------------ lifecycle

    async def connect(self) -> None:
        if self.transport == "webrtc":
            from ._stub import PendingTransportError

            raise PendingTransportError(
                "PipecatAgentAdapter(transport='webrtc')"
            )

        # Lazy import so `import scenario` doesn't require websockets at the
        # top of the module-load path (it's already a hard dep, but being
        # consistent with the Twilio adapter style).
        import websockets

        assert self.url is not None  # validated in __init__
        self._ws = await websockets.connect(
            self.url, ping_interval=None, ping_timeout=None
        )
        self._inbound_queue = asyncio.Queue()
        self._recv_loop_exc = None  # reset per fresh connection
        self._recv_loop_done = False
        self._send_lock = asyncio.Lock()

        # Send the synthetic `start` event that pipecat's TwilioFrameSerializer
        # requires to learn the stream/call SIDs and start deserializing
        # media frames.
        if self.stream_sid is None:
            self.stream_sid = f"MZ{uuid.uuid4().hex}"
        if self.call_sid is None:
            self.call_sid = f"CA{uuid.uuid4().hex}"

        await self._ws.send(json.dumps({"event": "connected", "protocol": "Call", "version": "1.0.0"}))
        await self._ws.send(
            json.dumps(
                {
                    "event": "start",
                    "streamSid": self.stream_sid,
                    "start": {
                        "streamSid": self.stream_sid,
                        "callSid": self.call_sid,
                        "mediaFormat": {
                            "encoding": "audio/x-mulaw",
                            "sampleRate": 8000,
                            "channels": 1,
                        },
                    },
                }
            )
        )

        # Stamp Pipecat transport attrs onto the active ``voice.adapter.connect``
        # span (opened by the executor connect loop). Base spans are name-owned;
        # the adapter contributes attributes, never a parallel span name — mirror
        # ElevenLabs' ``voice.elevenlabs.agent_id`` seam (``adapters/elevenlabs.py``).
        from opentelemetry import trace as _otel_trace
        from .._telemetry import set_span_attributes

        set_span_attributes(
            _otel_trace.get_current_span(),
            {
                "voice.pipecat.transport": self.transport,
                "voice.pipecat.transport_format": self.transport_format,
            },
        )

        self._recv_task = asyncio.create_task(self._recv_loop())
        logger.debug("PipecatAgentAdapter: connected to %s (stream=%s)", self.url, self.stream_sid)

    async def disconnect(self) -> None:
        ws = self._ws
        if ws is None:
            return

        # Send `stop` event so the bot can clean up its pipeline gracefully.
        try:
            if self.stream_sid:
                await ws.send(json.dumps({"event": "stop", "streamSid": self.stream_sid}))
        except Exception:
            logger.debug("PipecatAgentAdapter: failed to send stop frame", exc_info=True)

        if self._recv_task is not None:
            self._recv_task.cancel()
            try:
                await self._recv_task
            except asyncio.CancelledError:
                # Expected: we just cancelled it.
                pass
            except Exception:
                # Unexpected teardown error — already logging enough context
                # elsewhere; disconnect() is best-effort.
                logger.debug("PipecatAgentAdapter: recv_task raised during cancel", exc_info=True)
            self._recv_task = None

        try:
            await ws.close()
        except Exception:
            # WS may already be closed by the peer; disconnect() is best-effort.
            logger.debug("PipecatAgentAdapter: ws.close() raised", exc_info=True)

        self._ws = None
        self._inbound_queue = None
        self.stream_sid = None
        self.call_sid = None

    # ------------------------------------------------------------------ I/O

    async def send_audio(self, chunk: AudioChunk) -> None:
        # Pace at real-time (TWILIO_FRAME_MS/1000s per 20-ms frame). Matches what
        # a real caller produces over a PSTN line — the SUT sees normal speech
        # rhythm, not a synthetic dump.
        #
        # After the last frame we send a Twilio ``mark`` named "utterance_end".
        # Real-time pacing means TTS-induced inter-phrase pauses survive on the
        # wire, and a stateless inactivity-timer on the receiver can't
        # distinguish "speaker paused after a comma" from "speaker finished
        # their turn." The mark is an explicit, non-ambiguous end-of-turn
        # signal: cooperating SUTs flush on the mark; legacy SUTs fall back to
        # VAD timing.
        self._assert_connected()
        assert self._ws is not None and self.stream_sid is not None and self._send_lock is not None
        mulaw = pcm16_24k_to_mulaw8k(chunk.data)
        frame_secs = TWILIO_FRAME_MS / 1000
        async with self._send_lock:
            for frame in iter_mulaw_frames(mulaw):
                if not frame:
                    continue
                await self._ws.send(build_media_frame(self.stream_sid, frame))
                await asyncio.sleep(frame_secs)
            await self._ws.send(build_mark_frame(self.stream_sid, "utterance_end"))

    async def recv_audio(self, timeout: float) -> AudioChunk:
        self._assert_connected()
        assert self._inbound_queue is not None
        # The loop already terminated and its queue is drained → fail fast with
        # the terminal cause instead of blocking for `timeout` on a queue nothing
        # will fill. The flag + drained-queue check is the terminal state for the
        # rest of this connection (no await, no re-pushed sentinel to leak).
        if self._recv_loop_done and self._inbound_queue.empty():
            raise self._recv_loop_ended_error() from self._recv_loop_exc
        item = await asyncio.wait_for(self._inbound_queue.get(), timeout=timeout)
        if item is _RECV_LOOP_DONE:
            raise self._recv_loop_ended_error() from self._recv_loop_exc
        return item

    def _recv_loop_ended_error(self) -> PipecatRecvError:
        # Chaining is done at the raise site (``raise ... from self._recv_loop_exc``)
        # so ``__suppress_context__`` is set correctly and the clean-close branch
        # (exc is None → ``from None``) gets a true empty cause.
        exc = self._recv_loop_exc
        if exc is not None:
            return PipecatRecvError(
                "pipecat recv loop crashed; no further audio will arrive: "
                f"{type(exc).__name__}: {exc}"
            )
        return PipecatRecvError(
            "pipecat bot closed the WebSocket; no further audio will arrive — the "
            "bot hung up or its pipeline stopped without responding"
        )

    async def interrupt(self) -> None:
        """Send a Twilio ``clear`` frame — the bot drops all buffered outbound
        audio immediately. Cooperating Pipecat bots (and any code wired to
        the Media Streams protocol) treat ``clear`` as "stop talking now."
        Use this in preference to timing-based barge-in when the SUT
        supports it: it's deterministic, doesn't depend on VAD detection
        windows, and matches the same protocol used in production.
        """
        self._assert_connected()
        assert self._ws is not None and self.stream_sid is not None
        await self._ws.send(build_clear_frame(self.stream_sid))
        logger.debug("PipecatAgentAdapter: sent clear frame (interrupt)")

    # ------------------------------------------------------------------ background

    async def _recv_loop(self) -> None:
        """Read frames from pipecat, decode µ-law → PCM16 24k, enqueue."""
        assert self._ws is not None and self._inbound_queue is not None
        queue = self._inbound_queue
        buffered_mulaw = bytearray()
        BATCH_MS = 100

        try:
            async for raw in self._ws:
                if isinstance(raw, bytes):
                    # pipecat sometimes emits binary frames for audio; treat
                    # as raw µ-law payload if we see one.
                    buffered_mulaw.extend(raw)
                    if len(buffered_mulaw) >= (BATCH_MS * 8):
                        self._deliver(bytes(buffered_mulaw))
                        buffered_mulaw.clear()
                    continue

                frame = parse_media_stream_frame(raw)
                if frame is None:
                    continue
                if frame.event == "media" and frame.payload_mulaw:
                    buffered_mulaw.extend(frame.payload_mulaw)
                    if len(buffered_mulaw) >= (BATCH_MS * 8):
                        self._deliver(bytes(buffered_mulaw))
                        buffered_mulaw.clear()
                elif frame.event == "stop":
                    if buffered_mulaw:
                        self._deliver(bytes(buffered_mulaw))
                        buffered_mulaw.clear()
                    return
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            # #498: do NOT swallow. A crash here (decode error, transport reset,
            # bot pipeline failure) used to only log + fall through, leaving the
            # inbound queue silent so recv_audio blocked the full response_timeout
            # with no attributable cause. Record it; recv_audio raises
            # PipecatRecvError naming this as the root cause (chained via __cause__).
            self._recv_loop_exc = exc
            logger.warning("PipecatAgentAdapter: recv loop crashed", exc_info=True)
        finally:
            # Mark terminal, then wake any pending recv_audio: no more audio will
            # arrive on this connection. The flag lets later recv_audio calls fail
            # fast on the drained queue; the sentinel unblocks a getter currently
            # awaiting an empty queue. Together they turn an indefinite wait into
            # an immediate, attributable PipecatRecvError. (No await between the
            # two lines, so a waiter can't observe a half-set terminal state.)
            self._recv_loop_done = True
            queue.put_nowait(_RECV_LOOP_DONE)

    def _deliver(self, mulaw: bytes) -> None:
        """Decode a coalesced µ-law batch to PCM16/24k and enqueue it.

        On the FIRST wire delivery of a turn — the base ``call()`` published its
        OTel context via :attr:`VoiceAgentAdapter._voice_turn_context` — wrap the
        decode+enqueue in a ``voice.audio.receive`` span parented to THAT turn
        (#774), so the background ``_recv_loop`` task's receive work is attributed
        to the turn instead of the task's frozen connect-time context (a
        now-closed span).

        This is a producer-side **delivery marker** (it carries the delivered
        byte count); the consumer-side wait and the timeout/ERROR semantics (P3)
        live on the base ``voice.audio.receive`` span that wraps ``recv_audio``.
        The span is disambiguated from that base span by
        ``voice.pipecat.recv.source=background_loop``.

        Emitted at most ONCE per turn (only the first delivery under a given turn
        context) — matching the base's one-receive-span-per-turn granularity and
        the epic's no-per-tick-flood rule (the EL pump H1), so a multi-second turn
        of 100 ms chunks is not exploded into dozens of sibling spans. Between
        turns the context is ``None`` and we enqueue WITHOUT a span, so no
        detached/closed-parent span can leak from the background task. The body is
        synchronous (``put_nowait`` on the unbounded queue), so a ``disconnect()``
        cancel can never land mid-span.

        Coverage limits (by design — this is a marker, not exhaustive accounting):
        - ``voice.audio.bytes`` is the DELIVERED coalesced-chunk size, which may
          fold in a sub-100 ms µ-law tail carried over from the prior turn (the
          recv-loop coalesces to 800-byte batches across the turn boundary — a
          pre-existing property the base drain chunks share; not corrected here
          because flushing a partial batch at the boundary would drop that audio).
        - A turn whose agent audio was ALREADY buffered before ``call()`` published
          the turn context (an opening greeting delivered during ``connect``) is
          drained from the queue without a fresh wire delivery, so it gets no
          background marker — the base ``voice.audio.receive`` span still covers
          its consumption. The marker records in-turn wire deliveries, not every
          turn that consumes audio (the deterministic turn-liveness gate).
        """
        assert self._inbound_queue is not None
        parent = self._voice_turn_context
        if parent is None or parent is self._bg_span_turn_context:
            self._inbound_queue.put_nowait(AudioChunk(data=mulaw8k_to_pcm16_24k(mulaw)))
            return
        self._bg_span_turn_context = parent
        with voice_span(
            "voice.audio.receive",
            {
                "voice.adapter.class": type(self).__name__,
                "voice.pipecat.recv.source": "background_loop",
            },
            parent=parent,
        ) as span:
            pcm = mulaw8k_to_pcm16_24k(mulaw)
            span.set_attribute("voice.audio.bytes", len(pcm))
            self._inbound_queue.put_nowait(AudioChunk(data=pcm))

    # ------------------------------------------------------------------ assertions

    def _assert_connected(self) -> None:
        if self._ws is None:
            raise RuntimeError(
                "PipecatAgentAdapter: not connected. Did you forget to call connect()?"
            )

Ancestors

Class variables

var capabilities : ClassVar[AdapterCapabilities]

Instance variables

var transport_format : str
Expand source code
@property
def transport_format(self) -> str:
    return f"{self.audio_format}/{self.sample_rate}"

Methods

async def interrupt(self) ‑> None

Send a Twilio clear frame — the bot drops all buffered outbound audio immediately. Cooperating Pipecat bots (and any code wired to the Media Streams protocol) treat clear as "stop talking now." Use this in preference to timing-based barge-in when the SUT supports it: it's deterministic, doesn't depend on VAD detection windows, and matches the same protocol used in production.

Expand source code
async def interrupt(self) -> None:
    """Send a Twilio ``clear`` frame — the bot drops all buffered outbound
    audio immediately. Cooperating Pipecat bots (and any code wired to
    the Media Streams protocol) treat ``clear`` as "stop talking now."
    Use this in preference to timing-based barge-in when the SUT
    supports it: it's deterministic, doesn't depend on VAD detection
    windows, and matches the same protocol used in production.
    """
    self._assert_connected()
    assert self._ws is not None and self.stream_sid is not None
    await self._ws.send(build_clear_frame(self.stream_sid))
    logger.debug("PipecatAgentAdapter: sent clear frame (interrupt)")

Inherited members

class PipecatRecvError (*args, **kwargs)

recv_audio could get no audio because the background _recv_loop ended.

Names the real reason — a crash in the read loop (decode/transport error, chained via cause) or a clean WebSocket close by the bot — so the #498 2nd-turn hang surfaces an attributable error instead of a blind response_timeout with an empty body.

Expand source code
class PipecatRecvError(AgentStreamEndedError):
    """recv_audio could get no audio because the background _recv_loop ended.

    Names the real reason — a crash in the read loop (decode/transport error,
    chained via __cause__) or a clean WebSocket close by the bot — so the #498
    2nd-turn hang surfaces an attributable error instead of a blind
    response_timeout with an empty body.
    """

Ancestors