From c6a1013051db9266a6d346425063497cd236bbbd Mon Sep 17 00:00:00 2001 From: James Hush Date: Wed, 1 Oct 2025 13:52:58 +0800 Subject: [PATCH] Pre-recorded message example --- examples/foundational/07-interruptible.py | 7 ++ .../prerecorded_message_processor.py | 114 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 examples/foundational/prerecorded_message_processor.py diff --git a/examples/foundational/07-interruptible.py b/examples/foundational/07-interruptible.py index 81ba692c7..cd6bb2b42 100644 --- a/examples/foundational/07-interruptible.py +++ b/examples/foundational/07-interruptible.py @@ -8,6 +8,7 @@ import os from dotenv import load_dotenv from loguru import logger +from prerecorded_message_processor import PrerecordedMessageProcessor from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 @@ -77,12 +78,18 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): context = LLMContext(messages) context_aggregator = LLMContextAggregatorPair(context) + # Create the prerecorded message processor + prerecorded_processor = PrerecordedMessageProcessor( + audio_file_path=os.path.join(os.path.dirname(__file__), "assets", "ding1.wav") + ) + pipeline = Pipeline( [ transport.input(), # Transport user input stt, context_aggregator.user(), # User responses llm, # LLM + prerecorded_processor, # Check for prerecorded message trigger tts, # TTS transport.output(), # Transport bot output context_aggregator.assistant(), # Assistant spoken responses diff --git a/examples/foundational/prerecorded_message_processor.py b/examples/foundational/prerecorded_message_processor.py new file mode 100644 index 000000000..c0773a369 --- /dev/null +++ b/examples/foundational/prerecorded_message_processor.py @@ -0,0 +1,114 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Prerecorded message processor for playing audio instead of TTS.""" + +import wave + +from pipecat.frames.frames import ( + Frame, + LLMFullResponseEndFrame, + LLMFullResponseStartFrame, + LLMTextFrame, + OutputAudioRawFrame, +) +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor + + +class PrerecordedMessageProcessor(FrameProcessor): + """Processor that intercepts specific LLM text and plays prerecorded audio. + + This processor checks incoming LLMTextFrame instances for a specific text pattern. + When the pattern "Your pre-recorded message" is detected, it replaces the text + with a prerecorded audio message by pushing LLMFullResponseStartFrame, the audio + data as OutputAudioRawFrame, and LLMFullResponseEndFrame. Other frames pass through + unchanged. + + Parameters: + audio_file_path: Path to the WAV file containing the prerecorded message. + + Example:: + + processor = PrerecordedMessageProcessor( + audio_file_path="path/to/message.wav" + ) + + pipeline = Pipeline([ + transport.input(), + stt, + context_aggregator.user(), + llm, + processor, # Insert before TTS + tts, + transport.output(), + context_aggregator.assistant(), + ]) + """ + + def __init__( + self, + *, + audio_file_path: str, + **kwargs, + ): + """Initialize the prerecorded message processor. + + Args: + audio_file_path: Path to the WAV file containing the prerecorded message. + **kwargs: Additional arguments passed to FrameProcessor. + """ + super().__init__(**kwargs) + self._audio_file_path = audio_file_path + self._audio_data = None + self._sample_rate = None + self._num_channels = None + self._load_audio() + + def _load_audio(self) -> None: + """Load the prerecorded audio file into memory.""" + try: + with wave.open(self._audio_file_path, "rb") as wav_file: + self._sample_rate = wav_file.getframerate() + self._num_channels = wav_file.getnchannels() + self._audio_data = wav_file.readframes(wav_file.getnframes()) + except Exception as e: + raise ValueError(f"Failed to load audio file {self._audio_file_path}: {e}") + + # Ensure audio was loaded successfully + if self._audio_data is None or self._sample_rate is None or self._num_channels is None: + raise ValueError(f"Failed to load audio data from {self._audio_file_path}") + + async def process_frame(self, frame: Frame, direction: FrameDirection) -> None: + """Process incoming frames and replace specific text with prerecorded audio. + + Args: + frame: The frame to process. + direction: Direction of the frame flow. + """ + await super().process_frame(frame, direction) + + # Check if this is an LLMTextFrame with our trigger text + if isinstance(frame, LLMTextFrame) and frame.text == "Your pre-recorded message": + # Ensure audio data is loaded (should always be true after __init__) + if self._audio_data is None or self._sample_rate is None or self._num_channels is None: + raise RuntimeError("Audio data not loaded") + + # Push the prerecorded message sequence + await self.push_frame(LLMFullResponseStartFrame(), direction) + await self.push_frame(frame, direction) # Keep the text frame for context + + # Push the prerecorded audio + audio_frame = OutputAudioRawFrame( + audio=self._audio_data, + sample_rate=self._sample_rate, + num_channels=self._num_channels, + ) + await self.push_frame(audio_frame, direction) + + await self.push_frame(LLMFullResponseEndFrame(), direction) + else: + # Pass through all other frames unchanged + await self.push_frame(frame, direction)