From d8b0e78bc8cb5e7465cd2993bf50eb668de95507 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 15 Apr 2026 15:05:45 -0700 Subject: [PATCH] Represent DTMF sequences as list[KeypadEntry] via `buttons` field Replaces the string-based `tones` field with a type-safe `buttons: list[KeypadEntry]` on `OutputDTMFFrame` and `OutputDTMFUrgentFrame`, matching the existing singular `button` field on `InputDTMFFrame`. A `from_string` classmethod builds the list from a dial string like `"123#"` (invalid characters raise ValueError from the `KeypadEntry` constructor). The base output audio fallback now iterates `frame.buttons` directly, LiveKit sends `frame.buttons[0].value`, and the Daily transport joins the button values into the single string Daily's `send_dtmf` expects. --- changelog/4313.added.md | 4 +- changelog/4313.deprecated.md | 2 +- src/pipecat/frames/frames.py | 84 +++++++++++++-------- src/pipecat/transports/base_output.py | 12 +-- src/pipecat/transports/daily/transport.py | 4 +- src/pipecat/transports/livekit/transport.py | 9 ++- 6 files changed, 67 insertions(+), 48 deletions(-) diff --git a/changelog/4313.added.md b/changelog/4313.added.md index c64bbc12e..1c704a3a7 100644 --- a/changelog/4313.added.md +++ b/changelog/4313.added.md @@ -1,3 +1,3 @@ - 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 `tones` field to `OutputDTMFFrame` and `OutputDTMFUrgentFrame` for sending multi-digit DTMF sequences (e.g. `"123#"`). Valid characters are the values of `KeypadEntry`. -- Added `DailyOutputDTMFFrame` and `DailyOutputDTMFUrgentFrame` frames. In addition to the inherited `tones`, they accept `session_id` and `digit_duration_ms`, which are forwarded to Daily's `send_dtmf` as `sessionId` and `digitDurationMs`. +- Added `buttons` field to `OutputDTMFFrame` and `OutputDTMFUrgentFrame` for sending multi-key DTMF sequences as a `list[KeypadEntry]`. Use `OutputDTMFFrame.from_string("123#")` (or the equivalent on `OutputDTMFUrgentFrame`) to build one from a dial string. +- Added `DailyOutputDTMFFrame` and `DailyOutputDTMFUrgentFrame` frames. In addition to the inherited `buttons`, they accept `session_id` and `digit_duration_ms`, which are forwarded to Daily's `send_dtmf` as `sessionId` and `digitDurationMs`. diff --git a/changelog/4313.deprecated.md b/changelog/4313.deprecated.md index fdf9249d4..58b826c6f 100644 --- a/changelog/4313.deprecated.md +++ b/changelog/4313.deprecated.md @@ -1 +1 @@ -- Deprecated the `button` field on `OutputDTMFFrame` and `OutputDTMFUrgentFrame`. Use the new `tones` field instead. When only `button` is set, `button.value` is used as a single-tone `tones` string for backward compatibility. +- Deprecated the `button` field on `OutputDTMFFrame` and `OutputDTMFUrgentFrame`. Use the new `buttons` field (a `list[KeypadEntry]`) instead. When only `button` is set, it is wrapped in a single-entry list for backward compatibility. diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index d02bfcd90..68574b6a5 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -744,33 +744,45 @@ class DTMFFrame: class OutputDTMFFrame(DTMFFrame, DataFrame): """DTMF keypress output frame for transport queuing. - A DTMF keypress output that will be queued. If your transport supports - multiple dial-out destinations, use the ``transport_destination`` field - to specify where the DTMF keypress should be sent. - Parameters: - tones: String of one or more DTMF tones to send (e.g. ``"1"`` or - ``"123#"``). Valid characters are the values of - :class:`~pipecat.audio.dtmf.types.KeypadEntry`. + buttons: Sequence of one or more DTMF keypad buttons to send. Use + :meth:`from_string` to build this from a string like ``"123#"``. button: A single DTMF keypad entry to send. .. deprecated:: 1.1.0 - Use ``tones`` instead. When only ``button`` is set, - ``button.value`` is used as a single-tone ``tones`` string. + Use ``buttons`` instead. When only ``button`` is set, it is + used as a single-entry ``buttons`` list. """ button: Optional[KeypadEntry] = None - tones: Optional[str] = None + buttons: Optional[List[KeypadEntry]] = None def __post_init__(self): super().__post_init__() - if self.tones is None and self.button is not None: - self.tones = self.button.value - if not self.tones: - raise ValueError(f"{self.__class__.__name__} requires `tones` or `button` to be set") + if self.buttons is None and self.button is not None: + self.buttons = [self.button] + if not self.buttons: + raise ValueError(f"{self.__class__.__name__} requires `buttons` or `button` to be set") def __str__(self): - return f"{self.name}(tones: {self.tones})" + buttons_str = "".join(b.value for b in self.buttons) if self.buttons else "" + return f"{self.name}(buttons: {buttons_str})" + + @classmethod + def from_string(cls, buttons: str, **kwargs) -> "OutputDTMFFrame": + """Build an ``OutputDTMFFrame`` from a string of DTMF characters. + + Args: + buttons: A string like ``"123#"``. Each character must be a + valid :class:`~pipecat.audio.dtmf.types.KeypadEntry` value. + **kwargs: Additional keyword arguments forwarded to the frame + constructor. + + Returns: + A frame of type ``cls`` with ``buttons`` populated as a list of + :class:`~pipecat.audio.dtmf.types.KeypadEntry`. + """ + return cls(buttons=[KeypadEntry(c) for c in buttons], **kwargs) # @@ -1269,33 +1281,45 @@ class InputDTMFFrame(DTMFFrame, SystemFrame): class OutputDTMFUrgentFrame(DTMFFrame, SystemFrame): """DTMF keypress output frame for immediate sending. - A DTMF keypress output that will be sent right away. If your transport - supports multiple dial-out destinations, use the ``transport_destination`` - field to specify where the DTMF keypress should be sent. - Parameters: - tones: String of one or more DTMF tones to send (e.g. ``"1"`` or - ``"123#"``). Valid characters are the values of - :class:`~pipecat.audio.dtmf.types.KeypadEntry`. + buttons: Sequence of one or more DTMF keypad buttons to send. Use + :meth:`from_string` to build this from a string like ``"123#"``. button: A single DTMF keypad entry to send. .. deprecated:: 1.1.0 - Use ``tones`` instead. When only ``button`` is set, - ``button.value`` is used as a single-tone ``tones`` string. + Use ``buttons`` instead. When only ``button`` is set, it is + used as a single-entry ``buttons`` list. """ button: Optional[KeypadEntry] = None - tones: Optional[str] = None + buttons: Optional[List[KeypadEntry]] = None def __post_init__(self): super().__post_init__() - if self.tones is None and self.button is not None: - self.tones = self.button.value - if not self.tones: - raise ValueError(f"{self.__class__.__name__} requires `tones` or `button` to be set") + if self.buttons is None and self.button is not None: + self.buttons = [self.button] + if not self.buttons: + raise ValueError(f"{self.__class__.__name__} requires `buttons` or `button` to be set") def __str__(self): - return f"{self.name}(tones: {self.tones})" + buttons_str = "".join(b.value for b in self.buttons) if self.buttons else "" + return f"{self.name}(buttons: {buttons_str})" + + @classmethod + def from_string(cls, buttons: str, **kwargs) -> "OutputDTMFUrgentFrame": + """Build an ``OutputDTMFUrgentFrame`` from a string of DTMF characters. + + Args: + buttons: A string like ``"123#"``. Each character must be a + valid :class:`~pipecat.audio.dtmf.types.KeypadEntry` value. + **kwargs: Additional keyword arguments forwarded to the frame + constructor. + + Returns: + A frame of type ``cls`` with ``buttons`` populated as a list of + :class:`~pipecat.audio.dtmf.types.KeypadEntry`. + """ + return cls(buttons=[KeypadEntry(c) for c in buttons], **kwargs) @dataclass diff --git a/src/pipecat/transports/base_output.py b/src/pipecat/transports/base_output.py index 8a6e41331..fe1044f51 100644 --- a/src/pipecat/transports/base_output.py +++ b/src/pipecat/transports/base_output.py @@ -19,7 +19,6 @@ from typing import Any, AsyncGenerator, Dict, List, Mapping, Optional from loguru import logger from PIL import Image -from pipecat.audio.dtmf.types import KeypadEntry from pipecat.audio.dtmf.utils import load_dtmf_audio from pipecat.audio.mixers.base_audio_mixer import BaseAudioMixer from pipecat.audio.utils import create_stream_resampler, is_silence @@ -276,15 +275,10 @@ class BaseOutputTransport(FrameProcessor): Args: frame: The DTMF frame to write. """ - if not frame.tones: + if not frame.buttons: return - for char in frame.tones: - try: - keypad_entry = KeypadEntry(char) - except ValueError: - logger.warning(f"Skipping invalid DTMF tone: {char!r}") - continue - dtmf_audio = await load_dtmf_audio(keypad_entry, sample_rate=self._sample_rate) + for button in frame.buttons: + dtmf_audio = await load_dtmf_audio(button, sample_rate=self._sample_rate) dtmf_audio_frame = OutputAudioRawFrame( audio=dtmf_audio, sample_rate=self._sample_rate, num_channels=1 ) diff --git a/src/pipecat/transports/daily/transport.py b/src/pipecat/transports/daily/transport.py index 739d47b59..779a7e622 100644 --- a/src/pipecat/transports/daily/transport.py +++ b/src/pipecat/transports/daily/transport.py @@ -2182,10 +2182,10 @@ class DailyOutputTransport(BaseOutputTransport): ``digit_duration_ms`` fields are also forwarded to the Daily call client. """ - if not frame.tones: + if not frame.buttons: return - settings: Dict[str, Any] = {"tones": frame.tones} + settings: Dict[str, Any] = {"tones": "".join(b.value for b in frame.buttons)} if isinstance(frame, (DailyOutputDTMFFrame, DailyOutputDTMFUrgentFrame)): if frame.session_id is not None: settings["sessionId"] = frame.session_id diff --git a/src/pipecat/transports/livekit/transport.py b/src/pipecat/transports/livekit/transport.py index 0e904066a..04e99f4c0 100644 --- a/src/pipecat/transports/livekit/transport.py +++ b/src/pipecat/transports/livekit/transport.py @@ -898,15 +898,16 @@ class LiveKitOutputTransport(BaseOutputTransport): async def _write_dtmf_native(self, frame: OutputDTMFFrame | OutputDTMFUrgentFrame): """Use LiveKit's native publish_dtmf method for telephone events. - LiveKit's DTMF API sends a single tone per call, so when ``frame.tones`` - contains multiple characters only the first one is sent. + LiveKit's DTMF API sends a single tone per call, so when + ``frame.buttons`` contains multiple entries only the first one is + sent. Args: frame: The DTMF frame to write. """ - if not frame.tones: + if not frame.buttons: return - await self._client.send_dtmf(frame.tones[0]) + await self._client.send_dtmf(frame.buttons[0].value) def _convert_pipecat_audio_to_livekit(self, pipecat_audio: bytes) -> rtc.AudioFrame: """Convert Pipecat audio data to LiveKit audio frame."""