Merge pull request #1048 from pipecat-ai/aleix/add-unittest-utils
tests: add some initial run_test() utilities
This commit is contained in:
4
.github/workflows/tests.yaml
vendored
4
.github/workflows/tests.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: test
|
||||
name: tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -49,4 +49,4 @@ jobs:
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pytest --ignore-glob="*to_be_updated*" --ignore-glob=*pipeline_source* src tests
|
||||
pytest
|
||||
|
||||
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- It is now possible to specify the period of the `PipelineTask` heartbeat
|
||||
frames with `heartbeats_period_secs`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a type error when using `voice_settings` in `ElevenLabsHttpTTSService`.
|
||||
|
||||
10
README.md
10
README.md
@@ -2,7 +2,7 @@
|
||||
<img alt="pipecat" width="300px" height="auto" src="https://raw.githubusercontent.com/pipecat-ai/pipecat/main/pipecat.png">
|
||||
</div></h1>
|
||||
|
||||
[](https://pypi.org/project/pipecat-ai) [](https://docs.pipecat.ai) [](https://discord.gg/pipecat) <a href="https://app.commanddash.io/agent/github_pipecat-ai_pipecat"><img src="https://img.shields.io/badge/AI-Code%20Agent-EB9FDA"></a>
|
||||
[](https://pypi.org/project/pipecat-ai)  [](https://docs.pipecat.ai) [](https://discord.gg/pipecat) <a href="https://app.commanddash.io/agent/github_pipecat-ai_pipecat"><img src="https://img.shields.io/badge/AI-Code%20Agent-EB9FDA"></a>
|
||||
|
||||
Pipecat is an open source Python framework for building voice and multimodal conversational agents. It handles the complex orchestration of AI services, network transport, audio processing, and multimodal interactions, letting you focus on creating engaging experiences.
|
||||
|
||||
@@ -53,12 +53,6 @@ To keep things lightweight, only the core framework is included by default. If y
|
||||
pip install "pipecat-ai[option,...]"
|
||||
```
|
||||
|
||||
Or you can install all of them with:
|
||||
|
||||
```shell
|
||||
pip install "pipecat-ai[all]"
|
||||
```
|
||||
|
||||
Available options include:
|
||||
|
||||
| Category | Services | Install Command Example |
|
||||
@@ -195,7 +189,7 @@ pip install "path_to_this_repo[option,...]"
|
||||
From the root directory, run:
|
||||
|
||||
```shell
|
||||
pytest --doctest-modules --ignore-glob="*to_be_updated*" --ignore-glob=*pipeline_source* src tests
|
||||
pytest
|
||||
```
|
||||
|
||||
## Setting up your editor
|
||||
|
||||
@@ -4,6 +4,7 @@ pip-tools~=7.4.1
|
||||
pre-commit~=4.0.1
|
||||
pyright~=1.1.392
|
||||
pytest~=8.3.4
|
||||
pytest-asyncio~=0.25.2
|
||||
ruff~=0.9.1
|
||||
setuptools~=75.8.0
|
||||
setuptools_scm~=8.1.0
|
||||
|
||||
@@ -85,7 +85,13 @@ openrouter = [ "openai~=1.59.6" ]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--verbose"
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = [
|
||||
"ignore:'audioop' is deprecated:DeprecationWarning",
|
||||
]
|
||||
|
||||
[tool.setuptools_scm]
|
||||
local_scheme = "no-local-version"
|
||||
|
||||
56
src/pipecat/pipeline/base_task.py
Normal file
56
src/pipecat/pipeline/base_task.py
Normal file
@@ -0,0 +1,56 @@
|
||||
#
|
||||
# Copyright (c) 2024–2025, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import AsyncIterable, Iterable
|
||||
|
||||
from pipecat.frames.frames import Frame
|
||||
|
||||
|
||||
class BaseTask(ABC):
|
||||
@abstractmethod
|
||||
def has_finished(self) -> bool:
|
||||
"""Indicates whether the tasks has finished. That is, all processors
|
||||
have stopped.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def stop_when_done(self):
|
||||
"""This is a helper function that sends an EndFrame to the pipeline in
|
||||
order to stop the task after everything in it has been processed.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def cancel(self):
|
||||
"""
|
||||
Stops the running pipeline immediately.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def run(self):
|
||||
"""
|
||||
Starts running the given pipeline.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def queue_frame(self, frame: Frame):
|
||||
"""
|
||||
Queue a frame to be pushed down the pipeline.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def queue_frames(self, frames: Iterable[Frame] | AsyncIterable[Frame]):
|
||||
"""
|
||||
Queues multiple frames to be pushed down the pipeline.
|
||||
"""
|
||||
pass
|
||||
@@ -27,6 +27,7 @@ from pipecat.frames.frames import (
|
||||
from pipecat.metrics.metrics import ProcessingMetricsData, TTFBMetricsData
|
||||
from pipecat.observers.base_observer import BaseObserver
|
||||
from pipecat.pipeline.base_pipeline import BasePipeline
|
||||
from pipecat.pipeline.base_task import BaseTask
|
||||
from pipecat.pipeline.task_observer import TaskObserver
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
from pipecat.utils.utils import obj_count, obj_id
|
||||
@@ -45,6 +46,7 @@ class PipelineParams(BaseModel):
|
||||
send_initial_empty_metrics: bool = True
|
||||
report_only_initial_ttfb: bool = False
|
||||
observers: List[BaseObserver] = []
|
||||
heartbeats_period_secs: float = HEARTBEAT_SECONDS
|
||||
|
||||
|
||||
class Source(FrameProcessor):
|
||||
@@ -85,7 +87,7 @@ class Sink(FrameProcessor):
|
||||
await self._down_queue.put(frame)
|
||||
|
||||
|
||||
class PipelineTask:
|
||||
class PipelineTask(BaseTask):
|
||||
def __init__(
|
||||
self,
|
||||
pipeline: BasePipeline,
|
||||
@@ -121,7 +123,7 @@ class PipelineTask:
|
||||
|
||||
self._observer = TaskObserver(params.observers)
|
||||
|
||||
def has_finished(self):
|
||||
def has_finished(self) -> bool:
|
||||
"""Indicates whether the tasks has finished. That is, all processors
|
||||
have stopped.
|
||||
|
||||
@@ -315,7 +317,7 @@ class PipelineTask:
|
||||
|
||||
async def _heartbeat_push_handler(self):
|
||||
"""
|
||||
This tasks pushes a heartbeat frame every HEARTBEAT_SECONDS.
|
||||
This tasks pushes a heartbeat frame every heartbeat period.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
@@ -323,7 +325,7 @@ class PipelineTask:
|
||||
# task will just stop waiting for the pipeline to finish not
|
||||
# allowing more frames to be pushed.
|
||||
await self._source.queue_frame(HeartbeatFrame(timestamp=self._clock.get_time()))
|
||||
await asyncio.sleep(HEARTBEAT_SECONDS)
|
||||
await asyncio.sleep(self._params.heartbeats_period_secs)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
|
||||
|
||||
class FrameFilter(FrameProcessor):
|
||||
def __init__(self, types: Tuple[Type[Frame]]):
|
||||
def __init__(self, types: Tuple[Type[Frame], ...]):
|
||||
super().__init__()
|
||||
self._types = types
|
||||
|
||||
|
||||
@@ -293,8 +293,7 @@ class FrameProcessor:
|
||||
await self.__input_frame_task
|
||||
|
||||
async def __input_frame_task_handler(self):
|
||||
running = True
|
||||
while running:
|
||||
while True:
|
||||
try:
|
||||
if self.__should_block_frames:
|
||||
logger.trace(f"{self}: frame processing paused")
|
||||
@@ -311,8 +310,6 @@ class FrameProcessor:
|
||||
if callback:
|
||||
await callback(self, frame, direction)
|
||||
|
||||
running = not isinstance(frame, EndFrame)
|
||||
|
||||
self.__input_queue.task_done()
|
||||
except asyncio.CancelledError:
|
||||
logger.trace(f"{self}: cancelled input task")
|
||||
@@ -330,12 +327,10 @@ class FrameProcessor:
|
||||
await self.__push_frame_task
|
||||
|
||||
async def __push_frame_task_handler(self):
|
||||
running = True
|
||||
while running:
|
||||
while True:
|
||||
try:
|
||||
(frame, direction) = await self.__push_queue.get()
|
||||
await self.__internal_push_frame(frame, direction)
|
||||
running = not isinstance(frame, EndFrame)
|
||||
self.__push_queue.task_done()
|
||||
except asyncio.CancelledError:
|
||||
logger.trace(f"{self}: cancelled push task")
|
||||
|
||||
@@ -93,11 +93,11 @@ class ProtobufFrameSerializer(FrameSerializer):
|
||||
id = getattr(args, "id", None)
|
||||
name = getattr(args, "name", None)
|
||||
pts = getattr(args, "pts", None)
|
||||
if not id and "id" in args_dict:
|
||||
if "id" in args_dict:
|
||||
del args_dict["id"]
|
||||
if not name and "name" in args_dict:
|
||||
if "name" in args_dict:
|
||||
del args_dict["name"]
|
||||
if not pts and "pts" in args_dict:
|
||||
if "pts" in args_dict:
|
||||
del args_dict["pts"]
|
||||
|
||||
# Create the instance
|
||||
@@ -105,10 +105,10 @@ class ProtobufFrameSerializer(FrameSerializer):
|
||||
|
||||
# Set special fields
|
||||
if id:
|
||||
setattr(instance, "id", getattr(args, "id", None))
|
||||
setattr(instance, "id", id)
|
||||
if name:
|
||||
setattr(instance, "name", getattr(args, "name", None))
|
||||
setattr(instance, "name", name)
|
||||
if pts:
|
||||
setattr(instance, "pts", getattr(args, "pts", None))
|
||||
setattr(instance, "pts", pts)
|
||||
|
||||
return instance
|
||||
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
@@ -1,122 +1,70 @@
|
||||
import asyncio
|
||||
import doctest
|
||||
import functools
|
||||
#
|
||||
# Copyright (c) 2024-2025 Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import unittest
|
||||
|
||||
from pipecat.frames.frames import (
|
||||
AudioRawFrame,
|
||||
EndFrame,
|
||||
Frame,
|
||||
ImageRawFrame,
|
||||
LLMFullResponseEndFrame,
|
||||
LLMFullResponseStartFrame,
|
||||
OutputAudioRawFrame,
|
||||
OutputImageRawFrame,
|
||||
TextFrame,
|
||||
)
|
||||
from pipecat.pipeline.parallel_pipeline import ParallelPipeline
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.processors.aggregators.gated import GatedAggregator
|
||||
from pipecat.processors.aggregators.sentence import SentenceAggregator
|
||||
from pipecat.processors.text_transformer import StatelessTextTransformer
|
||||
from tests.utils import run_test
|
||||
|
||||
|
||||
class TestDailyFrameAggregators(unittest.IsolatedAsyncioTestCase):
|
||||
@unittest.skip("FIXME: This test is failing")
|
||||
class TestSentenceAggregator(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_sentence_aggregator(self):
|
||||
sentence = "Hello, world. How are you? I am fine"
|
||||
expected_sentences = ["Hello, world.", " How are you?", " I am fine "]
|
||||
aggregator = SentenceAggregator()
|
||||
|
||||
sentence = "Hello, world. How are you? I am fine!"
|
||||
|
||||
frames_to_send = []
|
||||
for word in sentence.split(" "):
|
||||
async for sentence in aggregator.process_frame(TextFrame(word + " ")):
|
||||
self.assertIsInstance(sentence, TextFrame)
|
||||
if isinstance(sentence, TextFrame):
|
||||
self.assertEqual(sentence.text, expected_sentences.pop(0))
|
||||
frames_to_send.append(TextFrame(text=word + " "))
|
||||
|
||||
async for sentence in aggregator.process_frame(EndFrame()):
|
||||
if len(expected_sentences):
|
||||
self.assertIsInstance(sentence, TextFrame)
|
||||
if isinstance(sentence, TextFrame):
|
||||
self.assertEqual(sentence.text, expected_sentences.pop(0))
|
||||
else:
|
||||
self.assertIsInstance(sentence, EndFrame)
|
||||
expected_returned_frames = [TextFrame, TextFrame, TextFrame]
|
||||
|
||||
self.assertEqual(expected_sentences, [])
|
||||
(received_down, _) = await run_test(aggregator, frames_to_send, expected_returned_frames)
|
||||
assert received_down[-3].text == "Hello, world. "
|
||||
assert received_down[-2].text == "How are you? "
|
||||
assert received_down[-1].text == "I am fine! "
|
||||
|
||||
@unittest.skip("FIXME: This test is failing")
|
||||
async def test_gated_accumulator(self):
|
||||
|
||||
class TestGatedAggregator(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_gated_aggregator(self):
|
||||
gated_aggregator = GatedAggregator(
|
||||
gate_open_fn=lambda frame: isinstance(frame, ImageRawFrame),
|
||||
gate_close_fn=lambda frame: isinstance(frame, LLMFullResponseStartFrame),
|
||||
start_open=False,
|
||||
)
|
||||
|
||||
frames = [
|
||||
frames_to_send = [
|
||||
LLMFullResponseStartFrame(),
|
||||
TextFrame("Hello, "),
|
||||
TextFrame("world."),
|
||||
AudioRawFrame(b"hello"),
|
||||
ImageRawFrame(b"image", (0, 0)),
|
||||
AudioRawFrame(b"world"),
|
||||
OutputAudioRawFrame(audio=b"hello", sample_rate=16000, num_channels=1),
|
||||
OutputImageRawFrame(image=b"image", size=(0, 0), format="RGB"),
|
||||
OutputAudioRawFrame(audio=b"world", sample_rate=16000, num_channels=1),
|
||||
LLMFullResponseEndFrame(),
|
||||
]
|
||||
|
||||
expected_output_frames = [
|
||||
ImageRawFrame(b"image", (0, 0)),
|
||||
LLMFullResponseStartFrame(),
|
||||
TextFrame("Hello, "),
|
||||
TextFrame("world."),
|
||||
AudioRawFrame(b"hello"),
|
||||
AudioRawFrame(b"world"),
|
||||
LLMFullResponseEndFrame(),
|
||||
expected_returned_frames = [
|
||||
OutputImageRawFrame,
|
||||
LLMFullResponseStartFrame,
|
||||
TextFrame,
|
||||
TextFrame,
|
||||
OutputAudioRawFrame,
|
||||
OutputAudioRawFrame,
|
||||
LLMFullResponseEndFrame,
|
||||
]
|
||||
for frame in frames:
|
||||
async for out_frame in gated_aggregator.process_frame(frame):
|
||||
self.assertEqual(out_frame, expected_output_frames.pop(0))
|
||||
self.assertEqual(expected_output_frames, [])
|
||||
|
||||
@unittest.skip("FIXME: This test is failing")
|
||||
async def test_parallel_pipeline(self):
|
||||
async def slow_add(sleep_time: float, name: str, x: str):
|
||||
await asyncio.sleep(sleep_time)
|
||||
return ":".join([x, name])
|
||||
|
||||
pipe1_annotation = StatelessTextTransformer(functools.partial(slow_add, 0.1, "pipe1"))
|
||||
pipe2_annotation = StatelessTextTransformer(functools.partial(slow_add, 0.2, "pipe2"))
|
||||
sentence_aggregator = SentenceAggregator()
|
||||
add_dots = StatelessTextTransformer(lambda x: x + ".")
|
||||
|
||||
source = asyncio.Queue()
|
||||
sink = asyncio.Queue()
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
ParallelPipeline([[pipe1_annotation], [sentence_aggregator, pipe2_annotation]]),
|
||||
add_dots,
|
||||
],
|
||||
source,
|
||||
sink,
|
||||
(received_down, _) = await run_test(
|
||||
gated_aggregator, frames_to_send, expected_returned_frames
|
||||
)
|
||||
|
||||
frames = [TextFrame("Hello, "), TextFrame("world."), EndFrame()]
|
||||
|
||||
expected_output_frames: list[Frame] = [
|
||||
TextFrame(text="Hello, :pipe1."),
|
||||
TextFrame(text="world.:pipe1."),
|
||||
TextFrame(text="Hello, world.:pipe2."),
|
||||
EndFrame(),
|
||||
]
|
||||
|
||||
for frame in frames:
|
||||
await source.put(frame)
|
||||
|
||||
await pipeline.run_pipeline()
|
||||
|
||||
while not sink.empty():
|
||||
frame = await sink.get()
|
||||
self.assertEqual(frame, expected_output_frames.pop(0))
|
||||
|
||||
|
||||
def load_tests(loader, tests, ignore):
|
||||
"""Run doctests on the aggregators module."""
|
||||
from pipecat.processors import aggregators
|
||||
|
||||
tests.addTests(doctest.DocTestSuite(aggregators))
|
||||
return tests
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
#
|
||||
# Copyright (c) 2024-2025 Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
|
||||
94
tests/test_filters.py
Normal file
94
tests/test_filters.py
Normal file
@@ -0,0 +1,94 @@
|
||||
#
|
||||
# Copyright (c) 2024-2025 Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
from pipecat.frames.frames import (
|
||||
EndFrame,
|
||||
Frame,
|
||||
TextFrame,
|
||||
TranscriptionFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
)
|
||||
from pipecat.processors.filters.frame_filter import FrameFilter
|
||||
from pipecat.processors.filters.function_filter import FunctionFilter
|
||||
from pipecat.processors.filters.identity_filter import IdentityFilter
|
||||
from pipecat.processors.filters.wake_check_filter import WakeCheckFilter
|
||||
from tests.utils import EndTestFrame, run_test
|
||||
|
||||
|
||||
class TestIdentifyFilter(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_identity(self):
|
||||
filter = IdentityFilter()
|
||||
frames_to_send = [UserStartedSpeakingFrame(), UserStoppedSpeakingFrame()]
|
||||
expected_returned_frames = [UserStartedSpeakingFrame, UserStoppedSpeakingFrame]
|
||||
await run_test(filter, frames_to_send, expected_returned_frames)
|
||||
|
||||
|
||||
class TestFrameFilter(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_text_frame(self):
|
||||
filter = FrameFilter(types=(TextFrame, EndTestFrame))
|
||||
frames_to_send = [TextFrame(text="Hello Pipecat!")]
|
||||
expected_returned_frames = [TextFrame]
|
||||
await run_test(filter, frames_to_send, expected_returned_frames)
|
||||
|
||||
async def test_end_frame(self):
|
||||
filter = FrameFilter(types=(EndFrame, EndTestFrame))
|
||||
frames_to_send = [EndFrame()]
|
||||
expected_returned_frames = [EndFrame]
|
||||
await run_test(filter, frames_to_send, expected_returned_frames)
|
||||
|
||||
async def test_system_frame(self):
|
||||
filter = FrameFilter(types=(EndTestFrame,))
|
||||
frames_to_send = [UserStartedSpeakingFrame()]
|
||||
expected_returned_frames = [UserStartedSpeakingFrame]
|
||||
await run_test(filter, frames_to_send, expected_returned_frames)
|
||||
|
||||
|
||||
class TestFunctionFilter(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_passthrough(self):
|
||||
async def passthrough(frame: Frame):
|
||||
return True
|
||||
|
||||
filter = FunctionFilter(filter=passthrough)
|
||||
frames_to_send = [TextFrame(text="Hello Pipecat!")]
|
||||
expected_returned_frames = [TextFrame]
|
||||
await run_test(filter, frames_to_send, expected_returned_frames)
|
||||
|
||||
async def test_no_passthrough(self):
|
||||
async def no_passthrough(frame: Frame):
|
||||
return False
|
||||
|
||||
filter = FunctionFilter(filter=no_passthrough)
|
||||
frames_to_send = [TextFrame(text="Hello Pipecat!")]
|
||||
expected_returned_frames = [TextFrame]
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
run_test(filter, frames_to_send, expected_returned_frames), timeout=0.5
|
||||
)
|
||||
assert False
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
|
||||
class TestWakeCheckFilter(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_no_wake_word(self):
|
||||
filter = WakeCheckFilter(wake_phrases=["Hey, Pipecat"])
|
||||
frames_to_send = [TranscriptionFrame(user_id="test", text="Phrase 1", timestamp="")]
|
||||
expected_returned_frames = []
|
||||
await run_test(filter, frames_to_send, expected_returned_frames)
|
||||
|
||||
async def test_wake_word(self):
|
||||
filter = WakeCheckFilter(wake_phrases=["Hey, Pipecat"])
|
||||
frames_to_send = [
|
||||
TranscriptionFrame(user_id="test", text="Hey, Pipecat", timestamp=""),
|
||||
TranscriptionFrame(user_id="test", text="Phrase 1", timestamp=""),
|
||||
]
|
||||
expected_returned_frames = [TranscriptionFrame, TranscriptionFrame]
|
||||
(received_down, _) = await run_test(filter, frames_to_send, expected_returned_frames)
|
||||
assert received_down[-1].text == "Phrase 1"
|
||||
@@ -93,7 +93,3 @@ class TestLangchain(unittest.IsolatedAsyncioTestCase):
|
||||
# This next one would fail with:
|
||||
# AssertionError: ' H e l l o d e a r h u m a n' != 'Hello dear human'
|
||||
# self.assertEqual(tma_out.messages[-1]["content"], self.expected_response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
import openai
|
||||
import pyaudio
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from pipecat.frames.frames import AudioRawFrame, ErrorFrame
|
||||
from pipecat.services.openai import OpenAITTSService
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class TestWhisperOpenAIService(unittest.IsolatedAsyncioTestCase):
|
||||
@unittest.skip("FIXME: This test is failing")
|
||||
async def test_whisper_tts(self):
|
||||
pa = pyaudio.PyAudio()
|
||||
stream = pa.open(format=pyaudio.paInt16, channels=1, rate=24_000, output=True)
|
||||
|
||||
tts = OpenAITTSService(voice="nova")
|
||||
|
||||
async for frame in tts.run_tts("Hello, there. Nice to meet you, seems to work well"):
|
||||
self.assertIsInstance(frame, AudioRawFrame)
|
||||
stream.write(frame.audio)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
stream.stop_stream()
|
||||
pa.terminate()
|
||||
|
||||
tts = OpenAITTSService(voice="invalid_voice")
|
||||
with self.assertRaises(openai.BadRequestError):
|
||||
async for frame in tts.run_tts("wont work"):
|
||||
self.assertIsInstance(frame, ErrorFrame)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,116 +1,92 @@
|
||||
#
|
||||
# Copyright (c) 2024-2025 Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
from pipecat.frames.frames import EndFrame, TextFrame
|
||||
from pipecat.frames.frames import EndFrame, HeartbeatFrame, TextFrame
|
||||
from pipecat.pipeline.parallel_pipeline import ParallelPipeline
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.processors.aggregators.sentence import SentenceAggregator
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
from pipecat.processors.filters.identity_filter import IdentityFilter
|
||||
from pipecat.processors.frame_processor import FrameProcessor
|
||||
from pipecat.processors.text_transformer import StatelessTextTransformer
|
||||
from tests.utils import HeartbeatsObserver, run_test
|
||||
|
||||
|
||||
class TestDailyPipeline(unittest.IsolatedAsyncioTestCase):
|
||||
@unittest.skip("FIXME: This test is failing")
|
||||
async def test_pipeline_simple(self):
|
||||
aggregator = SentenceAggregator()
|
||||
class TestPipeline(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_pipeline_single(self):
|
||||
pipeline = Pipeline([IdentityFilter()])
|
||||
|
||||
outgoing_queue = asyncio.Queue()
|
||||
incoming_queue = asyncio.Queue()
|
||||
pipeline = Pipeline([aggregator], incoming_queue, outgoing_queue)
|
||||
frames_to_send = [TextFrame(text="Hello from Pipecat!")]
|
||||
expected_returned_frames = [TextFrame]
|
||||
await run_test(pipeline, frames_to_send, expected_returned_frames)
|
||||
|
||||
await incoming_queue.put(TextFrame("Hello, "))
|
||||
await incoming_queue.put(TextFrame("world."))
|
||||
await incoming_queue.put(EndFrame())
|
||||
async def test_pipeline_multiple(self):
|
||||
identity1 = IdentityFilter()
|
||||
identity2 = IdentityFilter()
|
||||
identity3 = IdentityFilter()
|
||||
|
||||
await pipeline.run_pipeline()
|
||||
pipeline = Pipeline([identity1, identity2, identity3])
|
||||
|
||||
self.assertEqual(await outgoing_queue.get(), TextFrame("Hello, world."))
|
||||
self.assertIsInstance(await outgoing_queue.get(), EndFrame)
|
||||
frames_to_send = [TextFrame(text="Hello from Pipecat!")]
|
||||
expected_returned_frames = [TextFrame]
|
||||
await run_test(pipeline, frames_to_send, expected_returned_frames)
|
||||
|
||||
@unittest.skip("FIXME: This test is failing")
|
||||
async def test_pipeline_multiple_stages(self):
|
||||
sentence_aggregator = SentenceAggregator()
|
||||
to_upper = StatelessTextTransformer(lambda x: x.upper())
|
||||
add_space = StatelessTextTransformer(lambda x: x + " ")
|
||||
|
||||
outgoing_queue = asyncio.Queue()
|
||||
incoming_queue = asyncio.Queue()
|
||||
pipeline = Pipeline(
|
||||
[add_space, sentence_aggregator, to_upper], incoming_queue, outgoing_queue
|
||||
class TestParallelPipeline(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_parallel_single(self):
|
||||
pipeline = ParallelPipeline([IdentityFilter()])
|
||||
|
||||
frames_to_send = [TextFrame(text="Hello from Pipecat!")]
|
||||
expected_returned_frames = [TextFrame]
|
||||
await run_test(pipeline, frames_to_send, expected_returned_frames)
|
||||
|
||||
async def test_parallel_multiple(self):
|
||||
"""Should only passthrough one instance of TextFrame."""
|
||||
pipeline = ParallelPipeline([IdentityFilter()], [IdentityFilter()])
|
||||
|
||||
frames_to_send = [TextFrame(text="Hello from Pipecat!")]
|
||||
expected_returned_frames = [TextFrame]
|
||||
await run_test(pipeline, frames_to_send, expected_returned_frames)
|
||||
|
||||
|
||||
class TestPipelineTask(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_task_single(self):
|
||||
pipeline = Pipeline([IdentityFilter()])
|
||||
task = PipelineTask(pipeline)
|
||||
|
||||
await task.queue_frame(TextFrame(text="Hello!"))
|
||||
await task.queue_frames([TextFrame(text="Bye!"), EndFrame()])
|
||||
await task.run()
|
||||
assert task.has_finished()
|
||||
|
||||
async def test_task_heartbeats(self):
|
||||
heartbeats_counter = 0
|
||||
|
||||
async def heartbeat_received(processor: FrameProcessor, heartbeat: HeartbeatFrame):
|
||||
nonlocal heartbeats_counter
|
||||
heartbeats_counter += 1
|
||||
|
||||
identity = IdentityFilter()
|
||||
pipeline = Pipeline([identity])
|
||||
heartbeats_observer = HeartbeatsObserver(
|
||||
target=identity, heartbeat_callback=heartbeat_received
|
||||
)
|
||||
task = PipelineTask(
|
||||
pipeline,
|
||||
params=PipelineParams(
|
||||
enable_heartbeats=True, heartbeats_period_secs=0.2, observers=[heartbeats_observer]
|
||||
),
|
||||
)
|
||||
|
||||
sentence = "Hello, world. It's me, a pipeline."
|
||||
for c in sentence:
|
||||
await incoming_queue.put(TextFrame(c))
|
||||
await incoming_queue.put(EndFrame())
|
||||
expected_heartbeats = 1.0 / 0.2
|
||||
|
||||
await pipeline.run_pipeline()
|
||||
|
||||
self.assertEqual(await outgoing_queue.get(), TextFrame("H E L L O , W O R L D ."))
|
||||
self.assertEqual(
|
||||
await outgoing_queue.get(),
|
||||
TextFrame(" I T ' S M E , A P I P E L I N E ."),
|
||||
)
|
||||
# leftover little bit because of the spacing
|
||||
self.assertEqual(
|
||||
await outgoing_queue.get(),
|
||||
TextFrame(" "),
|
||||
)
|
||||
self.assertIsInstance(await outgoing_queue.get(), EndFrame)
|
||||
|
||||
|
||||
class TestLogFrame(unittest.TestCase):
|
||||
class MockProcessor(FrameProcessor):
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def setUp(self):
|
||||
self.processor1 = self.MockProcessor("processor1")
|
||||
self.processor2 = self.MockProcessor("processor2")
|
||||
self.pipeline = Pipeline(processors=[self.processor1, self.processor2])
|
||||
self.pipeline._name = "MyClass"
|
||||
self.pipeline._logger = Mock()
|
||||
|
||||
@unittest.skip("FIXME: This test is failing")
|
||||
def test_log_frame_from_source(self):
|
||||
frame = Mock(__class__=Mock(__name__="MyFrame"))
|
||||
self.pipeline._log_frame(frame, depth=1)
|
||||
self.pipeline._logger.debug.assert_called_once_with(
|
||||
"MyClass source -> MyFrame -> processor1"
|
||||
)
|
||||
|
||||
@unittest.skip("FIXME: This test is failing")
|
||||
def test_log_frame_to_sink(self):
|
||||
frame = Mock(__class__=Mock(__name__="MyFrame"))
|
||||
self.pipeline._log_frame(frame, depth=3)
|
||||
self.pipeline._logger.debug.assert_called_once_with(
|
||||
"MyClass processor2 -> MyFrame -> sink"
|
||||
)
|
||||
|
||||
@unittest.skip("FIXME: This test is failing")
|
||||
def test_log_frame_repeated_log(self):
|
||||
frame = Mock(__class__=Mock(__name__="MyFrame"))
|
||||
self.pipeline._log_frame(frame, depth=2)
|
||||
self.pipeline._logger.debug.assert_called_once_with(
|
||||
"MyClass processor1 -> MyFrame -> processor2"
|
||||
)
|
||||
self.pipeline._log_frame(frame, depth=2)
|
||||
self.pipeline._logger.debug.assert_called_with("MyClass ... repeated")
|
||||
|
||||
@unittest.skip("FIXME: This test is failing")
|
||||
def test_log_frame_reset_repeated_log(self):
|
||||
frame1 = Mock(__class__=Mock(__name__="MyFrame1"))
|
||||
frame2 = Mock(__class__=Mock(__name__="MyFrame2"))
|
||||
self.pipeline._log_frame(frame1, depth=2)
|
||||
self.pipeline._logger.debug.assert_called_once_with(
|
||||
"MyClass processor1 -> MyFrame1 -> processor2"
|
||||
)
|
||||
self.pipeline._log_frame(frame1, depth=2)
|
||||
self.pipeline._logger.debug.assert_called_with("MyClass ... repeated")
|
||||
self.pipeline._log_frame(frame2, depth=2)
|
||||
self.pipeline._logger.debug.assert_called_with(
|
||||
"MyClass processor1 -> MyFrame2 -> processor2"
|
||||
)
|
||||
await task.queue_frame(TextFrame(text="Hello!"))
|
||||
try:
|
||||
await asyncio.wait_for(task.run(), timeout=1.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
assert heartbeats_counter == expected_heartbeats
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
#
|
||||
# Copyright (c) 2024-2025 Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import unittest
|
||||
|
||||
from pipecat.frames.frames import AudioRawFrame, TextFrame, TranscriptionFrame
|
||||
from pipecat.frames.frames import (
|
||||
OutputAudioRawFrame,
|
||||
TextFrame,
|
||||
TranscriptionFrame,
|
||||
)
|
||||
from pipecat.serializers.protobuf import ProtobufFrameSerializer
|
||||
|
||||
|
||||
@@ -8,22 +18,19 @@ class TestProtobufFrameSerializer(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.serializer = ProtobufFrameSerializer()
|
||||
|
||||
@unittest.skip("FIXME: This test is failing")
|
||||
async def test_roundtrip(self):
|
||||
text_frame = TextFrame(text="hello world")
|
||||
frame = self.serializer.deserialize(self.serializer.serialize(text_frame))
|
||||
self.assertEqual(frame, TextFrame(text="hello world"))
|
||||
self.assertEqual(text_frame, frame)
|
||||
|
||||
transcription_frame = TranscriptionFrame(
|
||||
text="Hello there!", participantId="123", timestamp="2021-01-01"
|
||||
text="Hello there!", user_id="123", timestamp="2021-01-01"
|
||||
)
|
||||
frame = self.serializer.deserialize(self.serializer.serialize(transcription_frame))
|
||||
self.assertEqual(frame, transcription_frame)
|
||||
|
||||
audio_frame = AudioRawFrame(data=b"1234567890")
|
||||
audio_frame = OutputAudioRawFrame(audio=b"1234567890", sample_rate=16000, num_channels=1)
|
||||
frame = self.serializer.deserialize(self.serializer.serialize(audio_frame))
|
||||
self.assertEqual(frame, audio_frame)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
self.assertEqual(frame.audio, audio_frame.audio)
|
||||
self.assertEqual(frame.sample_rate, audio_frame.sample_rate)
|
||||
self.assertEqual(frame.num_channels, audio_frame.num_channels)
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
#
|
||||
# Copyright (c) 2024-2025 Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import unittest
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from pipecat.frames.frames import EndFrame, Frame, TextFrame
|
||||
from pipecat.services.ai_services import AIService, match_endofsentence
|
||||
from pipecat.utils.string import match_endofsentence
|
||||
|
||||
|
||||
class SimpleAIService(AIService):
|
||||
async def process_frame(self, frame: Frame) -> AsyncGenerator[Frame, None]:
|
||||
yield frame
|
||||
|
||||
|
||||
class TestBaseAIService(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_simple_processing(self):
|
||||
service = SimpleAIService()
|
||||
|
||||
input_frames = [TextFrame("hello"), EndFrame()]
|
||||
|
||||
output_frames = []
|
||||
for input_frame in input_frames:
|
||||
async for output_frame in service.process_frame(input_frame):
|
||||
output_frames.append(output_frame)
|
||||
|
||||
self.assertEqual(input_frames, output_frames)
|
||||
|
||||
class TestUtilsString(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_endofsentence(self):
|
||||
assert match_endofsentence("This is a sentence.")
|
||||
assert match_endofsentence("This is a sentence! ")
|
||||
@@ -51,7 +38,3 @@ class TestBaseAIService(unittest.IsolatedAsyncioTestCase):
|
||||
for i in chinese_sentences:
|
||||
assert match_endofsentence(i)
|
||||
assert not match_endofsentence("你好,")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,3 +1,9 @@
|
||||
#
|
||||
# Copyright (c) 2024-2025 Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
# import asyncio
|
||||
# import unittest
|
||||
# from unittest.mock import AsyncMock, patch, Mock
|
||||
|
||||
120
tests/utils.py
Normal file
120
tests/utils.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#
|
||||
# Copyright (c) 2024-2025 Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable, Sequence, Tuple
|
||||
|
||||
from pipecat.clocks.system_clock import SystemClock
|
||||
from pipecat.frames.frames import (
|
||||
ControlFrame,
|
||||
Frame,
|
||||
HeartbeatFrame,
|
||||
StartFrame,
|
||||
)
|
||||
from pipecat.observers.base_observer import BaseObserver
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
|
||||
|
||||
@dataclass
|
||||
class EndTestFrame(ControlFrame):
|
||||
pass
|
||||
|
||||
|
||||
class HeartbeatsObserver(BaseObserver):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
target: FrameProcessor,
|
||||
heartbeat_callback: Callable[[FrameProcessor, HeartbeatFrame], Awaitable[None]],
|
||||
):
|
||||
self._target = target
|
||||
self._callback = heartbeat_callback
|
||||
|
||||
async def on_push_frame(
|
||||
self,
|
||||
src: FrameProcessor,
|
||||
dst: FrameProcessor,
|
||||
frame: Frame,
|
||||
direction: FrameDirection,
|
||||
timestamp: int,
|
||||
):
|
||||
if src == self._target and isinstance(frame, HeartbeatFrame):
|
||||
await self._callback(self._target, frame)
|
||||
|
||||
|
||||
class QueuedFrameProcessor(FrameProcessor):
|
||||
def __init__(self, queue: asyncio.Queue, ignore_start: bool = True):
|
||||
super().__init__()
|
||||
self._queue = queue
|
||||
self._ignore_start = ignore_start
|
||||
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
if self._ignore_start and isinstance(frame, StartFrame):
|
||||
return
|
||||
await self._queue.put(frame)
|
||||
|
||||
|
||||
async def run_test(
|
||||
processor: FrameProcessor,
|
||||
frames_to_send: Sequence[Frame],
|
||||
expected_down_frames: Sequence[type],
|
||||
expected_up_frames: Sequence[type] = [],
|
||||
) -> Tuple[Sequence[Frame], Sequence[Frame]]:
|
||||
received_up = asyncio.Queue()
|
||||
received_down = asyncio.Queue()
|
||||
up_processor = QueuedFrameProcessor(received_up)
|
||||
down_processor = QueuedFrameProcessor(received_down)
|
||||
|
||||
up_processor.link(processor)
|
||||
processor.link(down_processor)
|
||||
|
||||
await processor.queue_frame(StartFrame(clock=SystemClock()))
|
||||
|
||||
for frame in frames_to_send:
|
||||
await processor.process_frame(frame, FrameDirection.DOWNSTREAM)
|
||||
|
||||
await processor.queue_frame(EndTestFrame())
|
||||
await processor.queue_frame(EndTestFrame(), FrameDirection.UPSTREAM)
|
||||
|
||||
#
|
||||
# Down frames
|
||||
#
|
||||
received_down_frames: Sequence[Frame] = []
|
||||
running = True
|
||||
while running:
|
||||
frame = await received_down.get()
|
||||
running = not isinstance(frame, EndTestFrame)
|
||||
if running:
|
||||
received_down_frames.append(frame)
|
||||
|
||||
print("received DOWN frames =", received_down_frames)
|
||||
|
||||
assert len(received_down_frames) == len(expected_down_frames)
|
||||
|
||||
for real, expected in zip(received_down_frames, expected_down_frames):
|
||||
assert isinstance(real, expected)
|
||||
|
||||
#
|
||||
# Up frames
|
||||
#
|
||||
received_up_frames: Sequence[Frame] = []
|
||||
running = True
|
||||
while running:
|
||||
frame = await received_up.get()
|
||||
running = not isinstance(frame, EndTestFrame)
|
||||
if running:
|
||||
received_up_frames.append(frame)
|
||||
|
||||
print("received UP frames =", received_up_frames)
|
||||
|
||||
assert len(received_up_frames) == len(expected_up_frames)
|
||||
|
||||
for real, expected in zip(received_up_frames, expected_up_frames):
|
||||
assert isinstance(real, expected)
|
||||
|
||||
return (received_down_frames, received_up_frames)
|
||||
Reference in New Issue
Block a user