diff --git a/changelog/4313.added.2.md b/changelog/4313.added.2.md new file mode 100644 index 000000000..2093e0a8a --- /dev/null +++ b/changelog/4313.added.2.md @@ -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. diff --git a/changelog/4313.added.3.md b/changelog/4313.added.3.md new file mode 100644 index 000000000..8dc2c89f1 --- /dev/null +++ b/changelog/4313.added.3.md @@ -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`. diff --git a/changelog/4313.added.md b/changelog/4313.added.md new file mode 100644 index 000000000..36e4b4f2e --- /dev/null +++ b/changelog/4313.added.md @@ -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). diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index 00f38cab8..21780d6cb 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -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 diff --git a/src/pipecat/transports/base_output.py b/src/pipecat/transports/base_output.py index d14daecbd..fe1044f51 100644 --- a/src/pipecat/transports/base_output.py +++ b/src/pipecat/transports/base_output.py @@ -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. diff --git a/src/pipecat/transports/daily/transport.py b/src/pipecat/transports/daily/transport.py index ae91db7b5..b58701a0b 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,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. diff --git a/src/pipecat/transports/livekit/transport.py b/src/pipecat/transports/livekit/transport.py index f3b0574b8..04e99f4c0 100644 --- a/src/pipecat/transports/livekit/transport.py +++ b/src/pipecat/transports/livekit/transport.py @@ -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."""