From 30f39d7395baf231817529bcd6eed3bc00e620c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 15 Apr 2026 14:20:08 -0700 Subject: [PATCH] Add DailyOutputDTMFFrame and DailyOutputDTMFUrgentFrame Introduces Daily-specific DTMF output frames that carry explicit `tones`, `session_id` and `digit_duration_ms` fields, forwarded to Daily's `send_dtmf` as `tones`, `sessionId` and `digitDurationMs`. The inherited `button` and `transport_destination` fields are ignored for these frames in the Daily transport. --- changelog/4313.added.md | 3 +- src/pipecat/transports/daily/transport.py | 103 ++++++++++++++++++++-- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/changelog/4313.added.md b/changelog/4313.added.md index 6a3cd2b1c..6bd4f22b2 100644 --- a/changelog/4313.added.md +++ b/changelog/4313.added.md @@ -1 +1,2 @@ -- Added `DailyTransport.send_dtmf()` for sending DTMF tones during a call (e.g. IVR navigation). +- Added `DailyTransport.send_dtmf()` to expose the Daily call client's DTMF sending capability, enabling applications to send tones during a call (e.g. IVR navigation). +- Added `DailyOutputDTMFFrame` and `DailyOutputDTMFUrgentFrame` frames for sending DTMF through the Daily transport pipeline. Both carry explicit `tones`, `session_id` and `digit_duration_ms` fields that are forwarded to Daily's `send_dtmf` as `tones`, `sessionId` and `digitDurationMs`. When the Daily transport processes these frames, the inherited `button` and `transport_destination` fields are ignored. diff --git a/src/pipecat/transports/daily/transport.py b/src/pipecat/transports/daily/transport.py index fe53f16f3..31b96ae97 100644 --- a/src/pipecat/transports/daily/transport.py +++ b/src/pipecat/transports/daily/transport.py @@ -36,6 +36,8 @@ from pipecat.frames.frames import ( InputTransportMessageFrame, InterimTranscriptionFrame, OutputAudioRawFrame, + OutputDTMFFrame, + OutputDTMFUrgentFrame, OutputImageRawFrame, OutputTransportMessageFrame, OutputTransportMessageUrgentFrame, @@ -151,6 +153,78 @@ class DailyUpdateRemoteParticipantsFrame(DataFrame): remote_participants: Mapping[str, Any] = field(default_factory=dict) +@dataclass +class DailyOutputDTMFFrame(OutputDTMFFrame): + """DTMF output frame with Daily-specific options for transport queuing. + + A DTMF keypress output that will be queued after any preceding audio has + finished playing. When this frame is processed by the Daily transport, + the inherited ``button`` and ``transport_destination`` fields are ignored + in favor of the explicit ``tones``, ``session_id`` and + ``digit_duration_ms`` fields below. + + Parameters: + tones: String of one or more DTMF tones to send (e.g. ``"1"`` or + ``"123#"``). Forwarded to Daily's ``send_dtmf`` as ``tones``. + session_id: Target participant session id. Forwarded to Daily's + ``send_dtmf`` as ``sessionId``. When ``None``, Daily sends the + tones to the default destination for the call. + digit_duration_ms: Duration of each DTMF digit in milliseconds. + Forwarded to Daily's ``send_dtmf`` as ``digitDurationMs``. When + ``None``, Daily's default duration is used. + """ + + # Override the inherited `button` to be optional: Daily's send_dtmf takes + # a multi-character `tones` string, so a single KeypadEntry is not + # required here. + button: Optional[KeypadEntry] = None # pyright: ignore[reportIncompatibleVariableOverride] + tones: Optional[str] = None + session_id: Optional[str] = None + digit_duration_ms: Optional[int] = None + + def __post_init__(self): + super().__post_init__() + if not self.tones: + raise ValueError(f"{self.__class__.__name__} requires `tones` to be set") + + def __str__(self): + return f"{self.name}(tones: {self.tones})" + + +@dataclass +class DailyOutputDTMFUrgentFrame(OutputDTMFUrgentFrame): + """DTMF output frame with Daily-specific options for immediate sending. + + A DTMF keypress output that will be sent right away. When this frame is + processed by the Daily transport, the inherited ``button`` and + ``transport_destination`` fields are ignored in favor of the explicit + ``tones``, ``session_id`` and ``digit_duration_ms`` fields below. + + Parameters: + tones: String of one or more DTMF tones to send (e.g. ``"1"`` or + ``"123#"``). Forwarded to Daily's ``send_dtmf`` as ``tones``. + session_id: Target participant session id. Forwarded to Daily's + ``send_dtmf`` as ``sessionId``. When ``None``, Daily sends the + tones to the default destination for the call. + digit_duration_ms: Duration of each DTMF digit in milliseconds. + Forwarded to Daily's ``send_dtmf`` as ``digitDurationMs``. When + ``None``, Daily's default duration is used. + """ + + button: Optional[KeypadEntry] = None # pyright: ignore[reportIncompatibleVariableOverride] + tones: Optional[str] = None + session_id: Optional[str] = None + digit_duration_ms: Optional[int] = None + + def __post_init__(self): + super().__post_init__() + if not self.tones: + raise ValueError(f"{self.__class__.__name__} requires `tones` to be set") + + def __str__(self): + return f"{self.name}(tones: {self.tones})" + + class WebRTCVADAnalyzer(VADAnalyzer): """Voice Activity Detection analyzer using WebRTC. @@ -2131,18 +2205,31 @@ class DailyOutputTransport(BaseOutputTransport): """ return True - async def _write_dtmf_native(self, frame): + async def _write_dtmf_native(self, frame: OutputDTMFFrame | OutputDTMFUrgentFrame): """Use Daily's native send_dtmf method for telephone events. Args: - frame: The DTMF frame to write. + frame: The DTMF frame to write. When it is a + :class:`DailyOutputDTMFFrame` or + :class:`DailyOutputDTMFUrgentFrame`, the explicit ``tones``, + ``session_id`` and ``digit_duration_ms`` fields are forwarded + to the Daily call client (and the inherited ``button`` / + ``transport_destination`` fields are ignored). """ - await self._client.send_dtmf( - { - "sessionId": frame.transport_destination, - "tones": frame.button.value, - } - ) + if isinstance(frame, (DailyOutputDTMFFrame, DailyOutputDTMFUrgentFrame)): + settings: Dict[str, Any] = {"tones": frame.tones} + if frame.session_id is not None: + settings["sessionId"] = frame.session_id + if frame.digit_duration_ms is not None: + settings["digitDurationMs"] = frame.digit_duration_ms + await self._client.send_dtmf(settings) + else: + await self._client.send_dtmf( + { + "sessionId": frame.transport_destination, + "tones": frame.button.value, + } + ) class DailyTransport(BaseTransport):