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.
This commit is contained in:
Aleix Conchillo Flaqué
2026-04-15 14:20:08 -07:00
parent fe2ef9c712
commit 30f39d7395
2 changed files with 97 additions and 9 deletions

View File

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

View File

@@ -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):