Merge pull request #4313 from pipecat-ai/ac/daily-send-dtmf
Add send_dtmf() to DailyTransport
This commit is contained in:
1
changelog/4313.added.2.md
Normal file
1
changelog/4313.added.2.md
Normal 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.
|
||||||
1
changelog/4313.added.3.md
Normal file
1
changelog/4313.added.3.md
Normal 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
1
changelog/4313.added.md
Normal 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).
|
||||||
@@ -730,26 +730,66 @@ class OutputTransportMessageFrame(DataFrame):
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DTMFFrame:
|
class DTMFFrame:
|
||||||
"""Base class for DTMF (Dual-Tone Multi-Frequency) keypad frames.
|
"""Marker base class for DTMF (Dual-Tone Multi-Frequency) keypad frames.
|
||||||
|
|
||||||
Parameters:
|
Used only as a shared tag so that both input and output DTMF frames can
|
||||||
button: The DTMF keypad entry that was pressed.
|
be identified via ``isinstance(frame, DTMFFrame)``. The concrete frames
|
||||||
|
define their own fields.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
button: KeypadEntry
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
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
|
Parameters:
|
||||||
multiple dial-out destinations, use the `transport_destination` field to
|
button: Convenience shortcut for sending a single DTMF keypad
|
||||||
specify where the DTMF keypress should be sent.
|
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):
|
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
|
@dataclass
|
||||||
class InputDTMFFrame(DTMFFrame, SystemFrame):
|
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):
|
def __str__(self):
|
||||||
return f"{self.name}(tone: {self.button.value})"
|
return f"{self.name}(tone: {self.button.value})"
|
||||||
@@ -1242,12 +1288,52 @@ 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
|
Parameters:
|
||||||
supports multiple dial-out destinations, use the `transport_destination`
|
button: Convenience shortcut for sending a single DTMF keypad
|
||||||
field to specify where the DTMF keypress should be sent.
|
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
|
@dataclass
|
||||||
|
|||||||
@@ -275,11 +275,14 @@ class BaseOutputTransport(FrameProcessor):
|
|||||||
Args:
|
Args:
|
||||||
frame: The DTMF frame to write.
|
frame: The DTMF frame to write.
|
||||||
"""
|
"""
|
||||||
dtmf_audio = await load_dtmf_audio(frame.button, sample_rate=self._sample_rate)
|
if not frame.buttons:
|
||||||
dtmf_audio_frame = OutputAudioRawFrame(
|
return
|
||||||
audio=dtmf_audio, sample_rate=self._sample_rate, num_channels=1
|
for button in frame.buttons:
|
||||||
)
|
dtmf_audio = await load_dtmf_audio(button, sample_rate=self._sample_rate)
|
||||||
await self.write_audio_frame(dtmf_audio_frame)
|
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):
|
async def send_audio(self, frame: OutputAudioRawFrame):
|
||||||
"""Send an audio frame downstream.
|
"""Send an audio frame downstream.
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ from pipecat.frames.frames import (
|
|||||||
InputTransportMessageFrame,
|
InputTransportMessageFrame,
|
||||||
InterimTranscriptionFrame,
|
InterimTranscriptionFrame,
|
||||||
OutputAudioRawFrame,
|
OutputAudioRawFrame,
|
||||||
|
OutputDTMFFrame,
|
||||||
|
OutputDTMFUrgentFrame,
|
||||||
OutputImageRawFrame,
|
OutputImageRawFrame,
|
||||||
OutputTransportMessageFrame,
|
OutputTransportMessageFrame,
|
||||||
OutputTransportMessageUrgentFrame,
|
OutputTransportMessageUrgentFrame,
|
||||||
@@ -151,6 +153,52 @@ class DailyUpdateRemoteParticipantsFrame(DataFrame):
|
|||||||
remote_participants: Mapping[str, Any] = field(default_factory=dict)
|
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):
|
class WebRTCVADAnalyzer(VADAnalyzer):
|
||||||
"""Voice Activity Detection analyzer using WebRTC.
|
"""Voice Activity Detection analyzer using WebRTC.
|
||||||
|
|
||||||
@@ -2131,18 +2179,29 @@ class DailyOutputTransport(BaseOutputTransport):
|
|||||||
"""
|
"""
|
||||||
return True
|
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.
|
"""Use Daily's native send_dtmf method for telephone events.
|
||||||
|
|
||||||
Args:
|
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(
|
if not frame.buttons:
|
||||||
{
|
return
|
||||||
"sessionId": frame.transport_destination,
|
|
||||||
"tones": frame.button.value,
|
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):
|
class DailyTransport(BaseTransport):
|
||||||
@@ -2400,6 +2459,22 @@ class DailyTransport(BaseTransport):
|
|||||||
"""
|
"""
|
||||||
return self._client.participant_counts()
|
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]]:
|
async def start_dialout(self, settings=None) -> Tuple[str, Optional[CallClientError]]:
|
||||||
"""Start a dial-out call to a phone number.
|
"""Start a dial-out call to a phone number.
|
||||||
|
|
||||||
|
|||||||
@@ -898,10 +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.buttons`` contains multiple entries only the first one is
|
||||||
|
sent.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
frame: The DTMF frame to write.
|
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:
|
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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user