diff --git a/CHANGELOG.md b/CHANGELOG.md index 230e9345a..5396a1eb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to **Pipecat** will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## Added + +- `BaseOutputTransport` now implements `write_dtmf()` by loading DTMF audio and + sending it through the transport. This makes sending DTMF generic across all + output transports. + +## Changed + +- `pipecat.frames.frames.KeypadEntry` is deprecated and has been moved to + `pipecat.audio.dtmf.types.KeypadEntry`. + +## Removed + +- `DailyTransport.write_dtmf()` has been removed in favor of the generic + `BaseOutputTransport.write_dtmf()`. + +- Remove deprecated `DailyTransport.send_dtmf()`. + +## Deprecated + +- `pipecat.frames.frames.KeypadEntry` is deprecated use + `pipecat.audio.dtmf.types.KeypadEntry` instead. + ## [0.0.82] - 2025-08-28 ### Added diff --git a/pyproject.toml b/pyproject.toml index 61e3c1a87..35b4c5664 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ classifiers = [ "Topic :: Scientific/Engineering :: Artificial Intelligence" ] dependencies = [ + "aiofiles>=24.1.0,<25", "aiohttp>=3.11.12,<4", "audioop-lts~=0.2.1; python_version>='3.13'", "docstring_parser~=0.16", @@ -137,6 +138,20 @@ where = ["src"] [tool.setuptools.package-data] "pipecat" = ["py.typed"] +"pipecat.audio.dtmf" = [ + "src/pipecat/audio/dtmf/dtmf-0.wav", + "src/pipecat/audio/dtmf/dtmf-1.wav", + "src/pipecat/audio/dtmf/dtmf-2.wav", + "src/pipecat/audio/dtmf/dtmf-3.wav", + "src/pipecat/audio/dtmf/dtmf-4.wav", + "src/pipecat/audio/dtmf/dtmf-5.wav", + "src/pipecat/audio/dtmf/dtmf-6.wav", + "src/pipecat/audio/dtmf/dtmf-7.wav", + "src/pipecat/audio/dtmf/dtmf-8.wav", + "src/pipecat/audio/dtmf/dtmf-9.wav", + "src/pipecat/audio/dtmf/dtmf-pound.wav", + "src/pipecat/audio/dtmf/dtmf-star.wav", +] "pipecat.services.aws_nova_sonic" = ["src/pipecat/services/aws_nova_sonic/ready.wav"] [tool.pytest.ini_options] diff --git a/scripts/dtmf/generate_dtmf.sh b/scripts/dtmf/generate_dtmf.sh new file mode 100755 index 000000000..caca1e128 --- /dev/null +++ b/scripts/dtmf/generate_dtmf.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# DTMF frequency map (low, high) +declare -A DTMF=( + [1]="697 1209" + [2]="697 1336" + [3]="697 1477" + [4]="770 1209" + [5]="770 1336" + [6]="770 1477" + [7]="852 1209" + [8]="852 1336" + [9]="852 1477" + ["star"]="941 1209" + [0]="941 1336" + ["pound"]="941 1477" +) + +# Tone duration (seconds) + gap after +DURATION=0.2 +GAP=0.05 +SAMPLERATE=8000 + +for key in "${!DTMF[@]}"; do + freqs=(${DTMF[$key]}) + low=${freqs[0]} + high=${freqs[1]} + echo "Generating DTMF tone for $key ($low Hz + $high Hz)" + ffmpeg -hide_banner -loglevel error -y \ + -f lavfi -i "sine=frequency=$low:duration=$DURATION:sample_rate=$SAMPLERATE" \ + -f lavfi -i "sine=frequency=$high:duration=$DURATION:sample_rate=$SAMPLERATE" \ + -f lavfi -i "anullsrc=r=$SAMPLERATE:cl=mono:d=$GAP" \ + -filter_complex "[0][1]amix=2[a];[a][2]concat=n=2:v=0:a=1[out]" \ + -map "[out]" -c:a pcm_s16le -ar $SAMPLERATE "dtmf-${key}.wav" +done diff --git a/src/pipecat/audio/dtmf/__init__.py b/src/pipecat/audio/dtmf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pipecat/audio/dtmf/dtmf-0.wav b/src/pipecat/audio/dtmf/dtmf-0.wav new file mode 100644 index 000000000..553eb4974 Binary files /dev/null and b/src/pipecat/audio/dtmf/dtmf-0.wav differ diff --git a/src/pipecat/audio/dtmf/dtmf-1.wav b/src/pipecat/audio/dtmf/dtmf-1.wav new file mode 100644 index 000000000..ac852c451 Binary files /dev/null and b/src/pipecat/audio/dtmf/dtmf-1.wav differ diff --git a/src/pipecat/audio/dtmf/dtmf-2.wav b/src/pipecat/audio/dtmf/dtmf-2.wav new file mode 100644 index 000000000..f4c5ca74c Binary files /dev/null and b/src/pipecat/audio/dtmf/dtmf-2.wav differ diff --git a/src/pipecat/audio/dtmf/dtmf-3.wav b/src/pipecat/audio/dtmf/dtmf-3.wav new file mode 100644 index 000000000..141a7bcf1 Binary files /dev/null and b/src/pipecat/audio/dtmf/dtmf-3.wav differ diff --git a/src/pipecat/audio/dtmf/dtmf-4.wav b/src/pipecat/audio/dtmf/dtmf-4.wav new file mode 100644 index 000000000..59b0cb029 Binary files /dev/null and b/src/pipecat/audio/dtmf/dtmf-4.wav differ diff --git a/src/pipecat/audio/dtmf/dtmf-5.wav b/src/pipecat/audio/dtmf/dtmf-5.wav new file mode 100644 index 000000000..dcee26dfc Binary files /dev/null and b/src/pipecat/audio/dtmf/dtmf-5.wav differ diff --git a/src/pipecat/audio/dtmf/dtmf-6.wav b/src/pipecat/audio/dtmf/dtmf-6.wav new file mode 100644 index 000000000..27f42571b Binary files /dev/null and b/src/pipecat/audio/dtmf/dtmf-6.wav differ diff --git a/src/pipecat/audio/dtmf/dtmf-7.wav b/src/pipecat/audio/dtmf/dtmf-7.wav new file mode 100644 index 000000000..39769f832 Binary files /dev/null and b/src/pipecat/audio/dtmf/dtmf-7.wav differ diff --git a/src/pipecat/audio/dtmf/dtmf-8.wav b/src/pipecat/audio/dtmf/dtmf-8.wav new file mode 100644 index 000000000..e623d805c Binary files /dev/null and b/src/pipecat/audio/dtmf/dtmf-8.wav differ diff --git a/src/pipecat/audio/dtmf/dtmf-9.wav b/src/pipecat/audio/dtmf/dtmf-9.wav new file mode 100644 index 000000000..43ceb9cb7 Binary files /dev/null and b/src/pipecat/audio/dtmf/dtmf-9.wav differ diff --git a/src/pipecat/audio/dtmf/dtmf-pound.wav b/src/pipecat/audio/dtmf/dtmf-pound.wav new file mode 100644 index 000000000..2c3984326 Binary files /dev/null and b/src/pipecat/audio/dtmf/dtmf-pound.wav differ diff --git a/src/pipecat/audio/dtmf/dtmf-star.wav b/src/pipecat/audio/dtmf/dtmf-star.wav new file mode 100644 index 000000000..fa9fdc63a Binary files /dev/null and b/src/pipecat/audio/dtmf/dtmf-star.wav differ diff --git a/src/pipecat/audio/dtmf/types.py b/src/pipecat/audio/dtmf/types.py new file mode 100644 index 000000000..2de5ae917 --- /dev/null +++ b/src/pipecat/audio/dtmf/types.py @@ -0,0 +1,47 @@ +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""This module defines generic type for DTMS. + +It defines the `KeypadEntry` enumeration, representing dual-tone multi-frequency +(DTMF) keypad entries for phone system integration. Each entry corresponds to a +key on the telephone keypad, facilitating the handling of input in +telecommunication applications. +""" + +from enum import Enum + + +class KeypadEntry(str, Enum): + """DTMF keypad entries for phone system integration. + + Parameters: + ONE: Number key 1. + TWO: Number key 2. + THREE: Number key 3. + FOUR: Number key 4. + FIVE: Number key 5. + SIX: Number key 6. + SEVEN: Number key 7. + EIGHT: Number key 8. + NINE: Number key 9. + ZERO: Number key 0. + POUND: Pound/hash key (#). + STAR: Star/asterisk key (*). + """ + + ONE = "1" + TWO = "2" + THREE = "3" + FOUR = "4" + FIVE = "5" + SIX = "6" + SEVEN = "7" + EIGHT = "8" + NINE = "9" + ZERO = "0" + + POUND = "#" + STAR = "*" diff --git a/src/pipecat/audio/dtmf/utils.py b/src/pipecat/audio/dtmf/utils.py new file mode 100644 index 000000000..21855b73c --- /dev/null +++ b/src/pipecat/audio/dtmf/utils.py @@ -0,0 +1,70 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""DTMF audio utilities. + +This module provides functionality to load DTMF (Dual-Tone Multi-Frequency) +audio files corresponding to phone keypad entries. Audio data is cached +in-memory after first load to improve performance on subsequent accesses. +""" + +import asyncio +import io +import wave +from importlib.resources import files +from typing import Dict, Optional + +import aiofiles + +from pipecat.audio.dtmf.types import KeypadEntry +from pipecat.audio.resamplers.base_audio_resampler import BaseAudioResampler +from pipecat.audio.utils import create_file_resampler + +__DTMF_LOCK__ = asyncio.Lock() +__DTMF_AUDIO__: Dict[KeypadEntry, bytes] = {} +__DTMF_RESAMPLER__: Optional[BaseAudioResampler] = None + +__DTMF_FILE_NAME = { + KeypadEntry.POUND: "dtmf-pound.wav", + KeypadEntry.STAR: "dtmf-star.wav", +} + + +async def load_dtmf_audio(button: KeypadEntry, *, sample_rate: int = 8000) -> bytes: + """Load audio for DTMF tones associated with the given button. + + Args: + button (KeypadEntry): The button for which the DTMF audio is to be loaded. + sample_rate (int, optional): The sample rate for the audio. Defaults to 8000. + + Returns: + bytes: The audio data for the DTMF tone as bytes. + """ + global __DTMF_AUDIO__, __DTMF_RESAMPLER__ + + async with __DTMF_LOCK__: + if button in __DTMF_AUDIO__: + return __DTMF_AUDIO__[button] + + if not __DTMF_RESAMPLER__: + __DTMF_RESAMPLER__ = create_file_resampler() + + dtmf_file_name = __DTMF_FILE_NAME.get(button, f"dtmf-{button.value}.wav") + dtmf_file_path = files("pipecat.audio.dtmf").joinpath(dtmf_file_name) + + async with aiofiles.open(dtmf_file_path, "rb") as f: + data = await f.read() + + with io.BytesIO(data) as buffer: + with wave.open(buffer, "rb") as wf: + audio = wf.readframes(wf.getnframes()) + in_sample_rate = wf.getframerate() + resampled_audio = await __DTMF_RESAMPLER__.resample( + audio, in_sample_rate, sample_rate + ) + __DTMF_AUDIO__[button] = resampled_audio + + return __DTMF_AUDIO__[button] diff --git a/src/pipecat/audio/utils.py b/src/pipecat/audio/utils.py index 84f73ebad..41aecf907 100644 --- a/src/pipecat/audio/utils.py +++ b/src/pipecat/audio/utils.py @@ -41,13 +41,15 @@ def create_default_resampler(**kwargs) -> BaseAudioResampler: """ import warnings - warnings.warn( - "`create_default_resampler` is deprecated. " - "Use `create_stream_resampler` for real-time processing scenarios or " - "`create_file_resampler` for batch processing of complete audio files.", - DeprecationWarning, - stacklevel=2, - ) + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "`create_default_resampler` is deprecated. " + "Use `create_stream_resampler` for real-time processing scenarios or " + "`create_file_resampler` for batch processing of complete audio files.", + DeprecationWarning, + stacklevel=2, + ) return SOXRAudioResampler(**kwargs) diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index 484ef36b5..69e79a573 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -12,7 +12,6 @@ and LLM processing. """ from dataclasses import dataclass, field -from enum import Enum from typing import ( TYPE_CHECKING, Any, @@ -28,6 +27,7 @@ from typing import ( ) from pipecat.adapters.schemas.tools_schema import ToolsSchema +from pipecat.audio.dtmf.types import KeypadEntry as NewKeypadEntry from pipecat.audio.interruptions.base_interruption_strategy import BaseInterruptionStrategy from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams from pipecat.audio.vad.vad_analyzer import VADParams @@ -41,9 +41,13 @@ if TYPE_CHECKING: from pipecat.processors.frame_processor import FrameProcessor -class KeypadEntry(str, Enum): +class DeprecatedKeypadEntry: """DTMF keypad entries for phone system integration. + .. deprecated:: 0.0.82 + This class is deprecated and will be removed in a future version. + Instead, use `audio.dtmf.types.KeypadEntry`. + Parameters: ONE: Number key 1. TWO: Number key 2. @@ -59,18 +63,38 @@ class KeypadEntry(str, Enum): STAR: Star/asterisk key (*). """ - ONE = "1" - TWO = "2" - THREE = "3" - FOUR = "4" - FIVE = "5" - SIX = "6" - SEVEN = "7" - EIGHT = "8" - NINE = "9" - ZERO = "0" - POUND = "#" - STAR = "*" + _enum = NewKeypadEntry + + @classmethod + def _warn(cls): + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "`pipecat.frames.frames.KeypadEntry` is deprecated and will be removed in a future version. " + "Use `pipecat.audio.dtmf.types.KeypadEntry` instead.", + DeprecationWarning, + stacklevel=2, + ) + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + """Allow the instance to be called as a function.""" + self._warn() + return self._enum(*args, **kwargs) + + def __getattr__(self, name): + """Retrieve an attribute from the underlying enum.""" + self._warn() + return getattr(self._enum, name) + + def __getitem__(self, name): + """Retrieve an item from the underlying enum.""" + self._warn() + return self._enum[name] + + +KeypadEntry = DeprecatedKeypadEntry() def format_pts(pts: Optional[int]): @@ -526,15 +550,16 @@ class LLMMessagesFrame(DataFrame): super().__post_init__() import warnings - warnings.simplefilter("always") - warnings.warn( - "LLMMessagesFrame is deprecated and will be removed in a future version. " - "Instead, use either " - "`LLMMessagesUpdateFrame` with `run_llm=True`, or " - "`OpenAILLMContextFrame` with desired messages in a new context", - DeprecationWarning, - stacklevel=2, - ) + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "LLMMessagesFrame is deprecated and will be removed in a future version. " + "Instead, use either " + "`LLMMessagesUpdateFrame` with `run_llm=True`, or " + "`OpenAILLMContextFrame` with desired messages in a new context", + DeprecationWarning, + stacklevel=2, + ) @dataclass @@ -668,7 +693,7 @@ class DTMFFrame: button: The DTMF keypad entry that was pressed. """ - button: KeypadEntry + button: NewKeypadEntry @dataclass diff --git a/src/pipecat/processors/aggregators/dtmf_aggregator.py b/src/pipecat/processors/aggregators/dtmf_aggregator.py index 38e1296f6..c50b0d8a8 100644 --- a/src/pipecat/processors/aggregators/dtmf_aggregator.py +++ b/src/pipecat/processors/aggregators/dtmf_aggregator.py @@ -14,13 +14,13 @@ for downstream processing by LLM context aggregators. import asyncio from typing import Optional +from pipecat.audio.dtmf.types import KeypadEntry from pipecat.frames.frames import ( BotInterruptionFrame, CancelFrame, EndFrame, Frame, InputDTMFFrame, - KeypadEntry, StartFrame, TranscriptionFrame, ) diff --git a/src/pipecat/processors/aggregators/llm_response.py b/src/pipecat/processors/aggregators/llm_response.py index 485ffe6aa..d058a4334 100644 --- a/src/pipecat/processors/aggregators/llm_response.py +++ b/src/pipecat/processors/aggregators/llm_response.py @@ -12,7 +12,6 @@ LLM processing, and text-to-speech components in conversational AI pipelines. """ import asyncio -import warnings from abc import abstractmethod from dataclasses import dataclass from typing import Dict, List, Literal, Optional, Set @@ -326,11 +325,15 @@ class LLMContextResponseAggregator(BaseLLMResponseAggregator): Returns: LLMContextFrame containing the current context. """ - warnings.warn( - "get_context_frame() is deprecated and will be removed in a future version. To trigger an LLM response, use LLMRunFrame instead.", - DeprecationWarning, - stacklevel=2, - ) + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "get_context_frame() is deprecated and will be removed in a future version. To trigger an LLM response, use LLMRunFrame instead.", + DeprecationWarning, + stacklevel=2, + ) return self._get_context_frame() def _get_context_frame(self) -> OpenAILLMContextFrame: @@ -1035,12 +1038,16 @@ class LLMUserResponseAggregator(LLMUserContextAggregator): params: Configuration parameters for aggregation behavior. **kwargs: Additional arguments passed to parent class. """ - warnings.warn( - "LLMUserResponseAggregator is deprecated and will be removed in a future version. " - "Use LLMUserContextAggregator or another LLM-specific subclass instead.", - DeprecationWarning, - stacklevel=2, - ) + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "LLMUserResponseAggregator is deprecated and will be removed in a future version. " + "Use LLMUserContextAggregator or another LLM-specific subclass instead.", + DeprecationWarning, + stacklevel=2, + ) super().__init__(context=OpenAILLMContext(messages), params=params, **kwargs) async def _process_aggregation(self): @@ -1078,12 +1085,16 @@ class LLMAssistantResponseAggregator(LLMAssistantContextAggregator): params: Configuration parameters for aggregation behavior. **kwargs: Additional arguments passed to parent class. """ - warnings.warn( - "LLMAssistantResponseAggregator is deprecated and will be removed in a future version. " - "Use LLMAssistantContextAggregator or another LLM-specific subclass instead.", - DeprecationWarning, - stacklevel=2, - ) + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "LLMAssistantResponseAggregator is deprecated and will be removed in a future version. " + "Use LLMAssistantContextAggregator or another LLM-specific subclass instead.", + DeprecationWarning, + stacklevel=2, + ) super().__init__(context=OpenAILLMContext(messages), params=params, **kwargs) async def push_aggregation(self): diff --git a/src/pipecat/processors/frame_processor.py b/src/pipecat/processors/frame_processor.py index ab82f7aa1..b20b655f7 100644 --- a/src/pipecat/processors/frame_processor.py +++ b/src/pipecat/processors/frame_processor.py @@ -452,12 +452,14 @@ class FrameProcessor(BaseObject): """ import warnings - warnings.warn( - "`FrameProcessor.wait_for_task()` is deprecated. " - "Use `await task` or `await asyncio.wait_for(task, timeout)` instead.", - DeprecationWarning, - stacklevel=2, - ) + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "`FrameProcessor.wait_for_task()` is deprecated. " + "Use `await task` or `await asyncio.wait_for(task, timeout)` instead.", + DeprecationWarning, + stacklevel=2, + ) if timeout: await asyncio.wait_for(task, timeout) diff --git a/src/pipecat/runner/daily.py b/src/pipecat/runner/daily.py index 33e3d8066..397a08cc2 100644 --- a/src/pipecat/runner/daily.py +++ b/src/pipecat/runner/daily.py @@ -122,11 +122,13 @@ async def configure_with_args(aiohttp_session: aiohttp.ClientSession, parser=Non """ import warnings - warnings.warn( - "configure_with_args is deprecated. Use configure() instead.", - DeprecationWarning, - stacklevel=2, - ) + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "configure_with_args is deprecated. Use configure() instead.", + DeprecationWarning, + stacklevel=2, + ) room_url, token = await configure(aiohttp_session) return (room_url, token, None) diff --git a/src/pipecat/serializers/exotel.py b/src/pipecat/serializers/exotel.py index 3fd2fb92d..9ed342631 100644 --- a/src/pipecat/serializers/exotel.py +++ b/src/pipecat/serializers/exotel.py @@ -13,13 +13,13 @@ from typing import Optional from loguru import logger from pydantic import BaseModel +from pipecat.audio.dtmf.types import KeypadEntry from pipecat.audio.utils import create_stream_resampler from pipecat.frames.frames import ( AudioRawFrame, Frame, InputAudioRawFrame, InputDTMFFrame, - KeypadEntry, StartFrame, StartInterruptionFrame, TransportMessageFrame, diff --git a/src/pipecat/serializers/plivo.py b/src/pipecat/serializers/plivo.py index 11855d850..aa8b4b27e 100644 --- a/src/pipecat/serializers/plivo.py +++ b/src/pipecat/serializers/plivo.py @@ -13,6 +13,7 @@ from typing import Optional from loguru import logger from pydantic import BaseModel +from pipecat.audio.dtmf.types import KeypadEntry from pipecat.audio.utils import create_stream_resampler, pcm_to_ulaw, ulaw_to_pcm from pipecat.frames.frames import ( AudioRawFrame, @@ -21,7 +22,6 @@ from pipecat.frames.frames import ( Frame, InputAudioRawFrame, InputDTMFFrame, - KeypadEntry, StartFrame, StartInterruptionFrame, TransportMessageFrame, diff --git a/src/pipecat/serializers/telnyx.py b/src/pipecat/serializers/telnyx.py index bf8d5d69f..467c01ba2 100644 --- a/src/pipecat/serializers/telnyx.py +++ b/src/pipecat/serializers/telnyx.py @@ -14,6 +14,7 @@ import aiohttp from loguru import logger from pydantic import BaseModel +from pipecat.audio.dtmf.types import KeypadEntry from pipecat.audio.utils import ( alaw_to_pcm, create_stream_resampler, @@ -28,7 +29,6 @@ from pipecat.frames.frames import ( Frame, InputAudioRawFrame, InputDTMFFrame, - KeypadEntry, StartFrame, StartInterruptionFrame, ) diff --git a/src/pipecat/serializers/twilio.py b/src/pipecat/serializers/twilio.py index 7679e8721..57e7c8dba 100644 --- a/src/pipecat/serializers/twilio.py +++ b/src/pipecat/serializers/twilio.py @@ -13,6 +13,7 @@ from typing import Optional from loguru import logger from pydantic import BaseModel +from pipecat.audio.dtmf.types import KeypadEntry from pipecat.audio.utils import create_stream_resampler, pcm_to_ulaw, ulaw_to_pcm from pipecat.frames.frames import ( AudioRawFrame, @@ -21,7 +22,6 @@ from pipecat.frames.frames import ( Frame, InputAudioRawFrame, InputDTMFFrame, - KeypadEntry, StartFrame, StartInterruptionFrame, TransportMessageFrame, diff --git a/src/pipecat/services/__init__.py b/src/pipecat/services/__init__.py index 0df8d028f..c28257891 100644 --- a/src/pipecat/services/__init__.py +++ b/src/pipecat/services/__init__.py @@ -11,11 +11,11 @@ _warned_modules = set() def _warn_deprecated_access(globals: Dict[str, Any], attr, old: str, new: str): - import warnings - # Only warn once per old->new module pair module_key = (old, new) if module_key not in _warned_modules: + import warnings + with warnings.catch_warnings(): warnings.simplefilter("always") warnings.warn( diff --git a/src/pipecat/services/cartesia/tts.py b/src/pipecat/services/cartesia/tts.py index f8f148a38..5efda600c 100644 --- a/src/pipecat/services/cartesia/tts.py +++ b/src/pipecat/services/cartesia/tts.py @@ -271,11 +271,13 @@ class CartesiaTTSService(AudioContextWordTTSService): voice_config["id"] = self._voice_id if self._settings["emotion"]: - warnings.warn( - "The 'emotion' parameter in __experimental_controls is deprecated and will be removed in a future version.", - DeprecationWarning, - stacklevel=2, - ) + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "The 'emotion' parameter in __experimental_controls is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) voice_config["__experimental_controls"] = {} if self._settings["emotion"]: voice_config["__experimental_controls"]["emotion"] = self._settings["emotion"] @@ -606,11 +608,13 @@ class CartesiaHttpTTSService(TTSService): voice_config = {"mode": "id", "id": self._voice_id} if self._settings["emotion"]: - warnings.warn( - "The 'emotion' parameter in voice.__experimental_controls is deprecated and will be removed in a future version.", - DeprecationWarning, - stacklevel=2, - ) + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "The 'emotion' parameter in voice.__experimental_controls is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) voice_config["__experimental_controls"] = {"emotion": self._settings["emotion"]} await self.start_ttfb_metrics() diff --git a/src/pipecat/services/fish/tts.py b/src/pipecat/services/fish/tts.py index 53f6cb171..305c14884 100644 --- a/src/pipecat/services/fish/tts.py +++ b/src/pipecat/services/fish/tts.py @@ -120,12 +120,14 @@ class FishAudioTTSService(InterruptibleTTSService): if model: import warnings - warnings.warn( - "Parameter 'model' is deprecated and will be removed in a future version. " - "Use 'reference_id' instead.", - DeprecationWarning, - stacklevel=2, - ) + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "Parameter 'model' is deprecated and will be removed in a future version. " + "Use 'reference_id' instead.", + DeprecationWarning, + stacklevel=2, + ) reference_id = model self._api_key = api_key diff --git a/src/pipecat/services/gladia/stt.py b/src/pipecat/services/gladia/stt.py index 316edd91c..ca3600643 100644 --- a/src/pipecat/services/gladia/stt.py +++ b/src/pipecat/services/gladia/stt.py @@ -13,7 +13,6 @@ supporting multiple languages, custom vocabulary, and various audio processing o import asyncio import base64 import json -import warnings from typing import Any, AsyncGenerator, Dict, Literal, Optional import aiohttp @@ -173,12 +172,16 @@ class _InputParamsDescriptor: """Descriptor for backward compatibility with deprecation warning.""" def __get__(self, obj, objtype=None): - warnings.warn( - "GladiaSTTService.InputParams is deprecated and will be removed in a future version. " - "Import and use GladiaInputParams directly instead.", - DeprecationWarning, - stacklevel=2, - ) + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "GladiaSTTService.InputParams is deprecated and will be removed in a future version. " + "Import and use GladiaInputParams directly instead.", + DeprecationWarning, + stacklevel=2, + ) return GladiaInputParams @@ -234,12 +237,14 @@ class GladiaSTTService(STTService): # Warn about deprecated language parameter if it's used if params.language is not None: - warnings.warn( - "The 'language' parameter is deprecated and will be removed in a future version. " - "Use 'language_config' instead.", - DeprecationWarning, - stacklevel=2, - ) + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "The 'language' parameter is deprecated and will be removed in a future version. " + "Use 'language_config' instead.", + DeprecationWarning, + stacklevel=2, + ) self._api_key = api_key self._region = region diff --git a/src/pipecat/services/google/llm_openai.py b/src/pipecat/services/google/llm_openai.py index 2c64f050f..83d90e4ac 100644 --- a/src/pipecat/services/google/llm_openai.py +++ b/src/pipecat/services/google/llm_openai.py @@ -65,12 +65,14 @@ class GoogleLLMOpenAIBetaService(OpenAILLMService): """ import warnings - warnings.warn( - "GoogleLLMOpenAIBetaService is deprecated and will be removed in a future version. " - "Use GoogleLLMService instead for better integration with Google's native API.", - DeprecationWarning, - stacklevel=2, - ) + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "GoogleLLMOpenAIBetaService is deprecated and will be removed in a future version. " + "Use GoogleLLMService instead for better integration with Google's native API.", + DeprecationWarning, + stacklevel=2, + ) super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs) diff --git a/src/pipecat/services/playht/tts.py b/src/pipecat/services/playht/tts.py index 3c03e8335..aa92df055 100644 --- a/src/pipecat/services/playht/tts.py +++ b/src/pipecat/services/playht/tts.py @@ -14,7 +14,6 @@ import io import json import struct import uuid -import warnings from typing import AsyncGenerator, Optional import aiohttp @@ -455,11 +454,15 @@ class PlayHTHttpTTSService(TTSService): # Warn about deprecated protocol parameter if explicitly provided if protocol: - warnings.warn( - "The 'protocol' parameter is deprecated and will be removed in a future version.", - DeprecationWarning, - stacklevel=2, - ) + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "The 'protocol' parameter is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) params = params or PlayHTHttpTTSService.InputParams() diff --git a/src/pipecat/services/sarvam/tts.py b/src/pipecat/services/sarvam/tts.py index 642113158..b7579b26b 100644 --- a/src/pipecat/services/sarvam/tts.py +++ b/src/pipecat/services/sarvam/tts.py @@ -9,7 +9,6 @@ import asyncio import base64 import json -import warnings from typing import Any, AsyncGenerator, Mapping, Optional import aiohttp @@ -356,11 +355,15 @@ class SarvamTTSService(InterruptibleTTSService): ) params = params or SarvamTTSService.InputParams() if aiohttp_session is not None: - warnings.warn( - "The 'aiohttp_session' parameter is deprecated and will be removed in a future version. ", - DeprecationWarning, - stacklevel=2, - ) + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "The 'aiohttp_session' parameter is deprecated and will be removed in a future version. ", + DeprecationWarning, + stacklevel=2, + ) # WebSocket endpoint URL self._websocket_url = f"{url}?model={model}" self._api_key = api_key diff --git a/src/pipecat/services/speechmatics/stt.py b/src/pipecat/services/speechmatics/stt.py index 7ac4c6f2a..7136bbb0c 100644 --- a/src/pipecat/services/speechmatics/stt.py +++ b/src/pipecat/services/speechmatics/stt.py @@ -10,7 +10,6 @@ import asyncio import datetime import os import re -import warnings from dataclasses import dataclass, field from enum import Enum from typing import Any, AsyncGenerator @@ -1107,6 +1106,8 @@ def _check_deprecated_args(kwargs: dict, params: SpeechmaticsSTTService.InputPar # Show deprecation warnings def _deprecation_warning(old: str, new: str | None = None): + import warnings + with warnings.catch_warnings(): warnings.simplefilter("always") if new: diff --git a/src/pipecat/services/tts_service.py b/src/pipecat/services/tts_service.py index 9e34adf25..93800338b 100644 --- a/src/pipecat/services/tts_service.py +++ b/src/pipecat/services/tts_service.py @@ -276,11 +276,13 @@ class TTSService(AIService): """ import warnings - warnings.warn( - "`TTSService.say()` is deprecated. Push a `TTSSpeakFrame` instead.", - DeprecationWarning, - stacklevel=2, - ) + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "`TTSService.say()` is deprecated. Push a `TTSSpeakFrame` instead.", + DeprecationWarning, + stacklevel=2, + ) await self.queue_frame(TTSSpeakFrame(text)) diff --git a/src/pipecat/transports/base_output.py b/src/pipecat/transports/base_output.py index 432908151..53d61e486 100644 --- a/src/pipecat/transports/base_output.py +++ b/src/pipecat/transports/base_output.py @@ -19,6 +19,7 @@ from typing import Any, AsyncGenerator, Dict, List, Mapping, Optional from loguru import logger from PIL import Image +from pipecat.audio.dtmf.utils import load_dtmf_audio from pipecat.audio.mixers.base_audio_mixer import BaseAudioMixer from pipecat.audio.utils import create_stream_resampler, is_silence from pipecat.frames.frames import ( @@ -223,7 +224,12 @@ class BaseOutputTransport(FrameProcessor): Args: frame: The DTMF frame to write. """ - pass + 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 + ) + dtmf_audio_frame.transport_destination = frame.transport_destination + 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/services/daily.py b/src/pipecat/transports/services/daily.py index b8ffe2383..cfc7998ef 100644 --- a/src/pipecat/transports/services/daily.py +++ b/src/pipecat/transports/services/daily.py @@ -31,8 +31,6 @@ from pipecat.frames.frames import ( InputAudioRawFrame, InterimTranscriptionFrame, OutputAudioRawFrame, - OutputDTMFFrame, - OutputDTMFUrgentFrame, OutputImageRawFrame, SpriteFrame, StartFrame, @@ -1676,7 +1674,7 @@ class DailyInputTransport(BaseInputTransport): class DailyOutputTransport(BaseOutputTransport): """Handles outgoing media streams and events to Daily calls. - Manages sending audio, video, DTMF tones, and other data to Daily calls, + Manages sending audio, video and other data to Daily calls, including audio destination registration and message transmission. """ @@ -1783,19 +1781,6 @@ class DailyOutputTransport(BaseOutputTransport): """ await self._client.register_audio_destination(destination) - async def write_dtmf(self, frame: OutputDTMFFrame | OutputDTMFUrgentFrame): - """Write DTMF tones to the call. - - Args: - frame: The DTMF frame containing tone information. - """ - await self._client.send_dtmf( - { - "sessionId": frame.transport_destination, - "tones": frame.button.value, - } - ) - async def write_audio_frame(self, frame: OutputAudioRawFrame): """Write an audio frame to the Daily call. @@ -2022,25 +2007,6 @@ class DailyTransport(BaseTransport): """ await self._client.stop_dialout(participant_id) - async def send_dtmf(self, settings): - """Send DTMF tones during a call (deprecated). - - .. deprecated:: 0.0.69 - Push an `OutputDTMFFrame` or an `OutputDTMFUrgentFrame` instead. - - Args: - settings: DTMF settings including tones and target session. - """ - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("always") - warnings.warn( - "`DailyTransport.send_dtmf()` is deprecated, push an `OutputDTMFFrame` or an `OutputDTMFUrgentFrame` instead.", - DeprecationWarning, - ) - await self._client.send_dtmf(settings) - async def sip_call_transfer(self, settings): """Transfer a SIP call to another destination. diff --git a/tests/test_dtmf_aggregator.py b/tests/test_dtmf_aggregator.py index 697d50d27..5d9d1346a 100644 --- a/tests/test_dtmf_aggregator.py +++ b/tests/test_dtmf_aggregator.py @@ -6,10 +6,10 @@ import unittest +from pipecat.audio.dtmf.types import KeypadEntry from pipecat.frames.frames import ( EndFrame, InputDTMFFrame, - KeypadEntry, TranscriptionFrame, ) from pipecat.processors.aggregators.dtmf_aggregator import DTMFAggregator diff --git a/uv.lock b/uv.lock index 1cc52c997..c5a225107 100644 --- a/uv.lock +++ b/uv.lock @@ -4189,6 +4189,7 @@ wheels = [ name = "pipecat-ai" source = { editable = "." } dependencies = [ + { name = "aiofiles" }, { name = "aiohttp" }, { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, { name = "docstring-parser" }, @@ -4409,6 +4410,7 @@ docs = [ requires-dist = [ { name = "accelerate", marker = "extra == 'moondream'", specifier = "~=1.10.0" }, { name = "aioboto3", marker = "extra == 'aws'", specifier = "~=15.0.0" }, + { name = "aiofiles", specifier = ">=24.1.0,<25" }, { name = "aiohttp", specifier = ">=3.11.12,<4" }, { name = "aiortc", marker = "extra == 'webrtc'", specifier = "~=1.11.0" }, { name = "anthropic", marker = "extra == 'anthropic'", specifier = "~=0.49.0" },