Merge pull request #4313 from pipecat-ai/ac/daily-send-dtmf

Add send_dtmf() to DailyTransport
This commit is contained in:
Aleix Conchillo Flaqué
2026-04-16 08:57:48 -07:00
committed by GitHub
7 changed files with 200 additions and 27 deletions

View File

@@ -0,0 +1 @@
- 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, and `to_string()` to convert back.

View File

@@ -0,0 +1 @@
- Added `DailyOutputDTMFFrame` and `DailyOutputDTMFUrgentFrame` frames. In addition to the inherited `buttons`, they accept `session_id`, `digit_duration_ms` and `method`, which are forwarded to Daily's `send_dtmf` as `sessionId`, `digitDurationMs` and `method`.

1
changelog/4313.added.md Normal file
View File

@@ -0,0 +1 @@
- 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).

View File

@@ -730,26 +730,66 @@ class OutputTransportMessageFrame(DataFrame):
@dataclass
class DTMFFrame:
"""Base class for DTMF (Dual-Tone Multi-Frequency) keypad frames.
"""Marker base class for DTMF (Dual-Tone Multi-Frequency) keypad frames.
Parameters:
button: The DTMF keypad entry that was pressed.
Used only as a shared tag so that both input and output DTMF frames can
be identified via ``isinstance(frame, DTMFFrame)``. The concrete frames
define their own fields.
"""
button: KeypadEntry
pass
@dataclass
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:
button: Convenience shortcut for sending a single DTMF keypad
entry. Equivalent to ``buttons=[button]``. If both ``buttons``
and ``button`` are provided, ``buttons`` takes precedence.
buttons: Sequence of one or more DTMF keypad buttons to send. Use
:meth:`from_string` to build this from a string like ``"123#"``.
"""
button: Optional[KeypadEntry] = None
buttons: Optional[List[KeypadEntry]] = None
def __post_init__(self):
super().__post_init__()
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}(tone: {self.button})"
return f"{self.name}(buttons: {self.to_string()})"
@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)
def to_string(self) -> str:
"""Return the frame's ``buttons`` as a dial string.
Returns:
A string such as ``"123#"`` formed by concatenating the values
of each :class:`~pipecat.audio.dtmf.types.KeypadEntry` in
``buttons``, or an empty string if ``buttons`` is not set.
"""
return "".join(b.value for b in self.buttons) if self.buttons else ""
#
@@ -1232,7 +1272,13 @@ class AssistantImageRawFrame(OutputImageRawFrame):
@dataclass
class InputDTMFFrame(DTMFFrame, SystemFrame):
"""DTMF keypress input frame from transport."""
"""DTMF keypress input frame from transport.
Parameters:
button: The DTMF keypad entry that was pressed.
"""
button: KeypadEntry
def __str__(self):
return f"{self.name}(tone: {self.button.value})"
@@ -1242,12 +1288,52 @@ 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:
button: Convenience shortcut for sending a single DTMF keypad
entry. Equivalent to ``buttons=[button]``. If both ``buttons``
and ``button`` are provided, ``buttons`` takes precedence.
buttons: Sequence of one or more DTMF keypad buttons to send. Use
:meth:`from_string` to build this from a string like ``"123#"``.
"""
pass
button: Optional[KeypadEntry] = None
buttons: Optional[List[KeypadEntry]] = None
def __post_init__(self):
super().__post_init__()
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}(buttons: {self.to_string()})"
@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)
def to_string(self) -> str:
"""Return the frame's ``buttons`` as a dial string.
Returns:
A string such as ``"123#"`` formed by concatenating the values
of each :class:`~pipecat.audio.dtmf.types.KeypadEntry` in
``buttons``, or an empty string if ``buttons`` is not set.
"""
return "".join(b.value for b in self.buttons) if self.buttons else ""
@dataclass

View File

@@ -275,11 +275,14 @@ class BaseOutputTransport(FrameProcessor):
Args:
frame: The DTMF frame to write.
"""
dtmf_audio = await load_dtmf_audio(frame.button, sample_rate=self._sample_rate)
dtmf_audio_frame = OutputAudioRawFrame(
audio=dtmf_audio, sample_rate=self._sample_rate, num_channels=1
)
await self.write_audio_frame(dtmf_audio_frame)
if not frame.buttons:
return
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
)
await self.write_audio_frame(dtmf_audio_frame)
async def send_audio(self, frame: OutputAudioRawFrame):
"""Send an audio frame downstream.

View File

@@ -36,6 +36,8 @@ from pipecat.frames.frames import (
InputTransportMessageFrame,
InterimTranscriptionFrame,
OutputAudioRawFrame,
OutputDTMFFrame,
OutputDTMFUrgentFrame,
OutputImageRawFrame,
OutputTransportMessageFrame,
OutputTransportMessageUrgentFrame,
@@ -151,6 +153,52 @@ 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. Inherits ``buttons`` from :class:`OutputDTMFFrame`; the
extra fields are forwarded to Daily's ``send_dtmf`` as ``sessionId``,
``digitDurationMs`` and ``method``.
Parameters:
session_id: Target participant session id. When ``None``, Daily
sends the tones to the default destination for the call.
digit_duration_ms: Duration of each DTMF digit in milliseconds.
When ``None``, Daily's default duration is used.
method: DTMF delivery method (e.g. ``"telephone-event"``, ``"sip-info"``
or ``auto``). When ``None``, Daily's default method is used.
"""
session_id: Optional[str] = None
digit_duration_ms: Optional[int] = None
method: Optional[str] = None
@dataclass
class DailyOutputDTMFUrgentFrame(OutputDTMFUrgentFrame):
"""DTMF output frame with Daily-specific options for immediate sending.
A DTMF keypress output that will be sent right away. Inherits
``buttons`` from :class:`OutputDTMFUrgentFrame`; the extra fields are
forwarded to Daily's ``send_dtmf`` as ``sessionId``, ``digitDurationMs``
and ``method``.
Parameters:
session_id: Target participant session id. When ``None``, Daily
sends the tones to the default destination for the call.
digit_duration_ms: Duration of each DTMF digit in milliseconds.
When ``None``, Daily's default duration is used.
method: DTMF delivery method (e.g. ``"telephone-event"``, ``"sip-info"``
or ``auto``). When ``None``, Daily's default method is used.
"""
session_id: Optional[str] = None
digit_duration_ms: Optional[int] = None
method: Optional[str] = None
class WebRTCVADAnalyzer(VADAnalyzer):
"""Voice Activity Detection analyzer using WebRTC.
@@ -2131,18 +2179,29 @@ 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 ``session_id``,
``digit_duration_ms`` and ``method`` fields are also
forwarded to the Daily call client.
"""
await self._client.send_dtmf(
{
"sessionId": frame.transport_destination,
"tones": frame.button.value,
}
)
if not frame.buttons:
return
settings: Dict[str, Any] = {"tones": frame.to_string()}
if isinstance(frame, (DailyOutputDTMFFrame, DailyOutputDTMFUrgentFrame)):
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
if frame.method is not None:
settings["method"] = frame.method
await self._client.send_dtmf(settings)
class DailyTransport(BaseTransport):
@@ -2400,6 +2459,22 @@ class DailyTransport(BaseTransport):
"""
return self._client.participant_counts()
async def send_dtmf(self, settings) -> Optional[CallClientError]:
"""Send DTMF tones during a call.
Args:
settings: DTMF settings including tones and target session.
Returns:
error: An error description or None.
"""
logger.debug(f"Sending DTMF: settings={settings}")
error = await self._client.send_dtmf(settings)
if error:
logger.error(f"Unable to send DTMF: {error}")
return error
async def start_dialout(self, settings=None) -> Tuple[str, Optional[CallClientError]]:
"""Start a dial-out call to a phone number.

View File

@@ -898,10 +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.buttons`` contains multiple entries only the first one is
sent.
Args:
frame: The DTMF frame to write.
"""
await self._client.send_dtmf(frame.button.value)
if not frame.buttons:
return
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."""