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.
This commit is contained in:
Aleix Conchillo Flaqué
2026-04-15 15:05:45 -07:00
parent 675b7df408
commit d8b0e78bc8
6 changed files with 67 additions and 48 deletions

View File

@@ -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 `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 `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 `tones`, they accept `session_id` and `digit_duration_ms`, which are forwarded to Daily's `send_dtmf` as `sessionId` and `digitDurationMs`. - 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`.

View File

@@ -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.

View File

@@ -744,33 +744,45 @@ class DTMFFrame:
class OutputDTMFFrame(DTMFFrame, DataFrame): class OutputDTMFFrame(DTMFFrame, DataFrame):
"""DTMF keypress output frame for transport queuing. """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: Parameters:
tones: String of one or more DTMF tones to send (e.g. ``"1"`` or buttons: Sequence of one or more DTMF keypad buttons to send. Use
``"123#"``). Valid characters are the values of :meth:`from_string` to build this from a string like ``"123#"``.
:class:`~pipecat.audio.dtmf.types.KeypadEntry`.
button: A single DTMF keypad entry to send. button: A single DTMF keypad entry to send.
.. deprecated:: 1.1.0 .. deprecated:: 1.1.0
Use ``tones`` instead. When only ``button`` is set, Use ``buttons`` instead. When only ``button`` is set, it is
``button.value`` is used as a single-tone ``tones`` string. used as a single-entry ``buttons`` list.
""" """
button: Optional[KeypadEntry] = None button: Optional[KeypadEntry] = None
tones: Optional[str] = None buttons: Optional[List[KeypadEntry]] = None
def __post_init__(self): def __post_init__(self):
super().__post_init__() super().__post_init__()
if self.tones is None and self.button is not None: if self.buttons is None and self.button is not None:
self.tones = self.button.value self.buttons = [self.button]
if not self.tones: if not self.buttons:
raise ValueError(f"{self.__class__.__name__} requires `tones` or `button` to be set") raise ValueError(f"{self.__class__.__name__} requires `buttons` or `button` to be set")
def __str__(self): 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): class OutputDTMFUrgentFrame(DTMFFrame, SystemFrame):
"""DTMF keypress output frame for immediate sending. """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: Parameters:
tones: String of one or more DTMF tones to send (e.g. ``"1"`` or buttons: Sequence of one or more DTMF keypad buttons to send. Use
``"123#"``). Valid characters are the values of :meth:`from_string` to build this from a string like ``"123#"``.
:class:`~pipecat.audio.dtmf.types.KeypadEntry`.
button: A single DTMF keypad entry to send. button: A single DTMF keypad entry to send.
.. deprecated:: 1.1.0 .. deprecated:: 1.1.0
Use ``tones`` instead. When only ``button`` is set, Use ``buttons`` instead. When only ``button`` is set, it is
``button.value`` is used as a single-tone ``tones`` string. used as a single-entry ``buttons`` list.
""" """
button: Optional[KeypadEntry] = None button: Optional[KeypadEntry] = None
tones: Optional[str] = None buttons: Optional[List[KeypadEntry]] = None
def __post_init__(self): def __post_init__(self):
super().__post_init__() super().__post_init__()
if self.tones is None and self.button is not None: if self.buttons is None and self.button is not None:
self.tones = self.button.value self.buttons = [self.button]
if not self.tones: if not self.buttons:
raise ValueError(f"{self.__class__.__name__} requires `tones` or `button` to be set") raise ValueError(f"{self.__class__.__name__} requires `buttons` or `button` to be set")
def __str__(self): 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 @dataclass

View File

@@ -19,7 +19,6 @@ from typing import Any, AsyncGenerator, Dict, List, Mapping, Optional
from loguru import logger from loguru import logger
from PIL import Image from PIL import Image
from pipecat.audio.dtmf.types import KeypadEntry
from pipecat.audio.dtmf.utils import load_dtmf_audio from pipecat.audio.dtmf.utils import load_dtmf_audio
from pipecat.audio.mixers.base_audio_mixer import BaseAudioMixer from pipecat.audio.mixers.base_audio_mixer import BaseAudioMixer
from pipecat.audio.utils import create_stream_resampler, is_silence from pipecat.audio.utils import create_stream_resampler, is_silence
@@ -276,15 +275,10 @@ class BaseOutputTransport(FrameProcessor):
Args: Args:
frame: The DTMF frame to write. frame: The DTMF frame to write.
""" """
if not frame.tones: if not frame.buttons:
return return
for char in frame.tones: for button in frame.buttons:
try: dtmf_audio = await load_dtmf_audio(button, sample_rate=self._sample_rate)
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)
dtmf_audio_frame = OutputAudioRawFrame( dtmf_audio_frame = OutputAudioRawFrame(
audio=dtmf_audio, sample_rate=self._sample_rate, num_channels=1 audio=dtmf_audio, sample_rate=self._sample_rate, num_channels=1
) )

View File

@@ -2182,10 +2182,10 @@ class DailyOutputTransport(BaseOutputTransport):
``digit_duration_ms`` fields are also forwarded to the Daily ``digit_duration_ms`` fields are also forwarded to the Daily
call client. call client.
""" """
if not frame.tones: if not frame.buttons:
return 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 isinstance(frame, (DailyOutputDTMFFrame, DailyOutputDTMFUrgentFrame)):
if frame.session_id is not None: if frame.session_id is not None:
settings["sessionId"] = frame.session_id settings["sessionId"] = frame.session_id

View File

@@ -898,15 +898,16 @@ class LiveKitOutputTransport(BaseOutputTransport):
async def _write_dtmf_native(self, frame: OutputDTMFFrame | OutputDTMFUrgentFrame): async def _write_dtmf_native(self, frame: OutputDTMFFrame | OutputDTMFUrgentFrame):
"""Use LiveKit's native publish_dtmf method for telephone events. """Use LiveKit's native publish_dtmf method for telephone events.
LiveKit's DTMF API sends a single tone per call, so when ``frame.tones`` LiveKit's DTMF API sends a single tone per call, so when
contains multiple characters only the first one is sent. ``frame.buttons`` contains multiple entries only the first one is
sent.
Args: Args:
frame: The DTMF frame to write. frame: The DTMF frame to write.
""" """
if not frame.tones: if not frame.buttons:
return 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: def _convert_pipecat_audio_to_livekit(self, pipecat_audio: bytes) -> rtc.AudioFrame:
"""Convert Pipecat audio data to LiveKit audio frame.""" """Convert Pipecat audio data to LiveKit audio frame."""