Merge branch 'main' into feature/rimeNonJsonTTsservice
This commit is contained in:
174
.github/workflows/generate-changelog.yml
vendored
Normal file
174
.github/workflows/generate-changelog.yml
vendored
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
name: Generate Changelog for Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Release version (e.g., 0.0.97)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
date:
|
||||||
|
description: "Release date (YYYY-MM-DD format, defaults to today)"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
generate-changelog:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v4
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
uv sync --group dev
|
||||||
|
|
||||||
|
- name: Set release date
|
||||||
|
id: set_date
|
||||||
|
run: |
|
||||||
|
if [ -z "${{ inputs.date }}" ]; then
|
||||||
|
RELEASE_DATE=$(date +%Y-%m-%d)
|
||||||
|
echo "Using today's date: $RELEASE_DATE"
|
||||||
|
else
|
||||||
|
RELEASE_DATE="${{ inputs.date }}"
|
||||||
|
echo "Using provided date: $RELEASE_DATE"
|
||||||
|
fi
|
||||||
|
echo "release_date=$RELEASE_DATE" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Validate inputs
|
||||||
|
run: |
|
||||||
|
# Validate version format (basic check)
|
||||||
|
if ! [[ "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
|
||||||
|
echo "Error: Version must be in format X.Y.Z (e.g., 0.0.97)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate date format if provided
|
||||||
|
if [ -n "${{ inputs.date }}" ]; then
|
||||||
|
if ! date -d "${{ inputs.date }}" >/dev/null 2>&1; then
|
||||||
|
# Try macOS date format
|
||||||
|
if ! date -j -f "%Y-%m-%d" "${{ inputs.date }}" >/dev/null 2>&1; then
|
||||||
|
echo "Error: Date must be in YYYY-MM-DD format (e.g., 2025-12-04)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check for changelog fragments
|
||||||
|
id: check_fragments
|
||||||
|
run: |
|
||||||
|
FRAGMENT_COUNT=$(find changelog -name "*.md" ! -name "_template.md.j2" | wc -l | tr -d ' ')
|
||||||
|
echo "fragment_count=$FRAGMENT_COUNT" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
if [ "$FRAGMENT_COUNT" -eq "0" ]; then
|
||||||
|
echo "❌ Error: No changelog fragments found in changelog/"
|
||||||
|
echo ""
|
||||||
|
echo "Cannot create a release without changelog entries."
|
||||||
|
echo "Add changelog fragments to the changelog/ directory (e.g., 1234.added.md) and try again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate fragment types
|
||||||
|
VALID_TYPES="added changed deprecated removed fixed security"
|
||||||
|
INVALID_FRAGMENTS=""
|
||||||
|
|
||||||
|
for file in changelog/*.md; do
|
||||||
|
# Skip template
|
||||||
|
if [[ "$file" == "changelog/_template.md.j2" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract type from filename (e.g., 1234.added.md -> added)
|
||||||
|
filename=$(basename "$file")
|
||||||
|
# Handle both 1234.added.md and 1234.added.2.md patterns
|
||||||
|
type=$(echo "$filename" | sed -E 's/^[0-9]+\.([a-z]+)(\.[0-9]+)?\.md$/\1/')
|
||||||
|
|
||||||
|
# Check if type is valid
|
||||||
|
if ! echo "$VALID_TYPES" | grep -wq "$type"; then
|
||||||
|
INVALID_FRAGMENTS="$INVALID_FRAGMENTS\n - $filename (type: '$type')"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$INVALID_FRAGMENTS" ]; then
|
||||||
|
echo "❌ Error: Invalid changelog fragment types found:"
|
||||||
|
echo -e "$INVALID_FRAGMENTS"
|
||||||
|
echo ""
|
||||||
|
echo "Valid types are: $VALID_TYPES"
|
||||||
|
echo "Example: 1234.added.md, 5678.fixed.md"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Found $FRAGMENT_COUNT changelog fragment(s)"
|
||||||
|
echo "has_fragments=true" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Preview changelog
|
||||||
|
run: |
|
||||||
|
echo "## Preview of changelog for version ${{ inputs.version }}"
|
||||||
|
echo ""
|
||||||
|
uv run towncrier build --draft --version "${{ inputs.version }}" --date "${{ steps.set_date.outputs.release_date }}"
|
||||||
|
|
||||||
|
- name: Build changelog
|
||||||
|
run: |
|
||||||
|
uv run towncrier build --version "${{ inputs.version }}" --date "${{ steps.set_date.outputs.release_date }}" --yes
|
||||||
|
|
||||||
|
- name: Create Pull Request
|
||||||
|
uses: peter-evans/create-pull-request@v7
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
commit-message: "Update changelog for version ${{ inputs.version }}"
|
||||||
|
title: "Release ${{ inputs.version }} - Changelog Update"
|
||||||
|
body: |
|
||||||
|
## Changelog Update for Release ${{ inputs.version }}
|
||||||
|
|
||||||
|
This PR updates the CHANGELOG.md with all changes for version **${{ inputs.version }}**.
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
- **Version:** ${{ inputs.version }}
|
||||||
|
- **Date:** ${{ steps.set_date.outputs.release_date }}
|
||||||
|
- **Fragments processed:** ${{ steps.check_fragments.outputs.fragment_count }}
|
||||||
|
|
||||||
|
### What this PR does
|
||||||
|
- ✅ Adds new release section to CHANGELOG.md
|
||||||
|
- ✅ Removes processed changelog fragments
|
||||||
|
- ✅ Ready to merge for release
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
1. Review the changelog entries below
|
||||||
|
2. Make any necessary edits to CHANGELOG.md if needed
|
||||||
|
3. Merge this PR
|
||||||
|
4. Continue with your release process
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>📋 Preview of changes</summary>
|
||||||
|
|
||||||
|
The changelog has been updated with entries from the following fragments:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
${{ steps.check_fragments.outputs.fragment_count }} fragments processed
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
branch: changelog-${{ inputs.version }}
|
||||||
|
delete-branch: true
|
||||||
|
labels: |
|
||||||
|
changelog
|
||||||
|
release
|
||||||
419
CHANGELOG.md
419
CHANGELOG.md
@@ -5,7 +5,408 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
<!-- towncrier release notes start -->
|
||||||
|
|
||||||
|
## [0.0.97] - 2025-12-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added new Gradium services, `GradiumSTTService` and `GradiumTTSService`, for
|
||||||
|
speech-to-text and text-to-speech functionality using Gradium's API.
|
||||||
|
|
||||||
|
- Additions for `AsyncAITTSService` and `AsyncAIHttpTTSService`:
|
||||||
|
|
||||||
|
- Added new `languages`: `pt`, `nl`, `ar`, `ru`, `ro`, `ja`, `he`, `hy`,
|
||||||
|
`tr`, `hi`, `zh`.
|
||||||
|
- Updated the default model to `asyncflow_multilingual_v1.0` for improved
|
||||||
|
accuracy and broader language coverage.
|
||||||
|
|
||||||
|
- Added optional tool and tool output filters for MCP services.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Updated Deepgram logging to include Deepgram request IDs for improved
|
||||||
|
debugging.
|
||||||
|
|
||||||
|
- Text Aggregation Improvements:
|
||||||
|
|
||||||
|
- **Breaking Change**: `BaseTextAggregator.aggregate()` now returns
|
||||||
|
`AsyncIterator[Aggregation]` instead of `Optional[Aggregation]`. This
|
||||||
|
enables the aggregator to return multiple results based on the provided
|
||||||
|
text.
|
||||||
|
- Refactored text aggregators to use inheritance: `SkipTagsAggregator` and
|
||||||
|
`PatternPairAggregator` now inherit from `SimpleTextAggregator`, reusing
|
||||||
|
the base class's sentence detection logic.
|
||||||
|
|
||||||
|
- Improved interruption handling to prevent bots from repeating themselves. LLM
|
||||||
|
services that return multiple sentences in a single response (e.g.,
|
||||||
|
`GoogleLLMService`) are now split into individual sentences before being sent
|
||||||
|
to TTS. This ensures interruptions occur at sentence boundaries, preventing
|
||||||
|
the bot from repeating content after being interrupted during long responses.
|
||||||
|
|
||||||
|
- Updated `AICFilter` to use Quail STT as the default model
|
||||||
|
(`AICModelType.QUAIL_STT`). Quail STT is optimized for human-to-machine
|
||||||
|
interaction (e.g., voice agents, speech-to-text) and operates at a native
|
||||||
|
sample rate of 16 kHz with fixed enhancement parameters.
|
||||||
|
|
||||||
|
- If an unexpected exception is caught, or if `FrameProcessor.push_error()` is
|
||||||
|
called with an exception, the file name and line number where the exception
|
||||||
|
occured are now logged.
|
||||||
|
|
||||||
|
- Updated Smart Turn model weights to v3.1.
|
||||||
|
|
||||||
|
- Smart Turn analyzer now uses the full context of the turn rather than just
|
||||||
|
the audio since VAD last triggered.
|
||||||
|
|
||||||
|
- Updated `CartesiaSTTService` to return the full transcription `result` in the
|
||||||
|
`TranscriptionFrame` and `InterimTranscriptionFrame`. This provides access to
|
||||||
|
word timestamp data.
|
||||||
|
|
||||||
|
- `HumeTTSService` changes:
|
||||||
|
|
||||||
|
- Added tracking headers (`X-Hume-Client-Name` and `X-Hume-Client-Version`)
|
||||||
|
to all requests made by `HumeTTSService` to the Hume API for better usage
|
||||||
|
tracking and analytics.
|
||||||
|
- Added `stop()` and `cancel()` cleanup methods to `HumeTTSService` to
|
||||||
|
properly close the HTTP client and prevent resource leaks.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- NVIDIA Services name changes (all functionality is unchanged):
|
||||||
|
|
||||||
|
- `NimLLMService` is now deprecated, use `NvidiaLLMService` instead.
|
||||||
|
- `RivaSTTService` is now deprecated, use `NvidiaSTTService` instead.
|
||||||
|
- `RivaTTSService` is now deprecated, use `NvidiaTTSService` instead.
|
||||||
|
- Use `uv pip install pipecat-ai[nvidia]` instead of
|
||||||
|
`uv pip install pipecat-ai[riva]`
|
||||||
|
|
||||||
|
- The `noise_gate_enable` parameter in `AICFilter` is deprecated and no longer
|
||||||
|
has any effect. Noise gating is now handled automatically by the AIC VAD
|
||||||
|
system. Use `AICFilter.create_vad_analyzer()` for VAD functionality instead.
|
||||||
|
|
||||||
|
- Package `pipecat.sync` is deprecated, use `pipecat.utils.sync` instead.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed bug in `PatternPairAggregator` where pattern handlers could be called
|
||||||
|
multiple times for `KEEP` or `AGGREGATE` patterns.
|
||||||
|
|
||||||
|
- Fixed sentence aggregation to correctly handle ambiguous punctuation in
|
||||||
|
streaming text, such as currency ("$29.95") and abbreviations ("Mr. Smith").
|
||||||
|
|
||||||
|
- Fixed an issue in `AWSTranscribeSTTService` where the `region` arg was always
|
||||||
|
set to `us-east-1` when providing an AWS_REGION env var.
|
||||||
|
|
||||||
|
- Fixed an issue in `SarvamTTSService` where the last sentence was not being
|
||||||
|
spoken. Now, audio is flushed when the TTS services receives the
|
||||||
|
`LLMFullResponseEndFrame` or `EndFrame`.
|
||||||
|
|
||||||
|
- Fixed an issue in `DeepgramTTSService` where a `TTSStoppedFrame` was
|
||||||
|
incorrectly pushed after a functional call. This caused an issue with the
|
||||||
|
voice-ui-kit's conversational panel rending of the LLM output after a
|
||||||
|
function call.
|
||||||
|
|
||||||
|
- Fixed an issue where `LLMTextFrame.skip_tts` was being overwritten by LLM
|
||||||
|
services.
|
||||||
|
|
||||||
|
- Fixed an issue that caused `WebsocketService` instances to attempt
|
||||||
|
reconnection during shutdown.
|
||||||
|
|
||||||
|
- Fixed an issue in `ElevenLabsTTSService` where character usage metrics were
|
||||||
|
only reported on the first TTS generation per turn.
|
||||||
|
|
||||||
|
## [0.0.96] - 2025-11-26 🦃 "Happy Thanksgiving!" 🦃
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `AWSBedrockAgentCoreProcessor` to support invoking an AgentCore-hosted
|
||||||
|
agent in a Pipecat pipeline.
|
||||||
|
|
||||||
|
- Enhanced error handling across the framework:
|
||||||
|
|
||||||
|
- Added `on_error` callback to `FrameProcessor` for centralized error
|
||||||
|
handling.
|
||||||
|
|
||||||
|
- Renamed `push_error(error: ErrorFrame)` to `push_error_frame(error: ErrorFrame)`
|
||||||
|
for clarity.
|
||||||
|
|
||||||
|
- Added new `push_error` method for simplified error reporting:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def push_error(error_msg: str,
|
||||||
|
exception: Optional[Exception] = None,
|
||||||
|
fatal: bool = False)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Standardized error logging by replacing `logger.exception` calls with
|
||||||
|
`logger.error` throughout the codebase.
|
||||||
|
|
||||||
|
- Added `cache_read_input_tokens`, `cache_creation_input_tokens` and
|
||||||
|
`reasoning_tokens` to OTel spans for LLM call
|
||||||
|
|
||||||
|
- Added `LiveKitRESTHelper` utility class for managing LiveKit rooms via REST API.
|
||||||
|
|
||||||
|
- Added `DeepgramSageMakerSTTService` which connects to a SageMaker hosted
|
||||||
|
Deepgram STT model. Added `07c-interruptible-deepgram-sagemaker.py`
|
||||||
|
foundational example.
|
||||||
|
|
||||||
|
- Added `SageMakerBidiClient` to connect to SageMaker hosted BiDi compatible
|
||||||
|
services.
|
||||||
|
|
||||||
|
- Added support for `include_timestamps` and `enable_logging` in
|
||||||
|
`ElevenLabsRealtimeSTTService`. When `include_timestamps` is enabled,
|
||||||
|
timestamp data is included in the `TranscriptionFrame`'s `result`
|
||||||
|
parameter.
|
||||||
|
|
||||||
|
- Added optional speaking rate control to `InworldTTSService`.
|
||||||
|
|
||||||
|
- Introduced a new `AggregatedTextFrame` type to support passing text along with
|
||||||
|
an `aggregated_by` field to describe the type of text
|
||||||
|
included. `TTSTextFrame`s now inherit from `AggregatedTextFrame`. With this
|
||||||
|
inheritance, an observer can watch for `AggregatedTextFrame`s to accumlate the
|
||||||
|
perceived output and determine whether or not the text was spoken based on if
|
||||||
|
that frame is also a `TTSTextFrame`.
|
||||||
|
|
||||||
|
With this frame, the llm token stream can be transformed into custom
|
||||||
|
composable chunks, allowing for aggregation outside the TTS service. This
|
||||||
|
makes it possible to listen for or handle those aggregations and sets the
|
||||||
|
stage for doing things like composing a best effort of the perceived llm
|
||||||
|
output in a more digestable form and to do so whether or not it is processed
|
||||||
|
by a TTS or if even a TTS exists.
|
||||||
|
|
||||||
|
- Introduced `LLMTextProcessor`: A new processor meant to allow customization
|
||||||
|
for how LLMTextFrames should be aggregated and considered. It's purpose is to
|
||||||
|
turn `LLMTextFrame`s into `AggregatedTextFrame`s. By default, a TTSService
|
||||||
|
will still aggregate `LLMTextFrame`s by sentence for the service to
|
||||||
|
consume. However, if you wish to override how the llm text is aggregated, you
|
||||||
|
should no longer override the TTS's internal text_aggregator, but instead,
|
||||||
|
insert this processor between your LLM and TTS in the pipeline.
|
||||||
|
|
||||||
|
- New `bot-output` RTVI message to represent what the bot actually "says".
|
||||||
|
|
||||||
|
- The `RTVIObserver` now emits `bot-output` messages based off the new
|
||||||
|
`AggregatedTextFrame`s (`bot-tts-text` and `bot-llm-text` are still
|
||||||
|
supported and generated, but `bot-transcript` is now deprecated in lieu of
|
||||||
|
this new, more thorough, message).
|
||||||
|
|
||||||
|
- The new `RTVIBotOutputMessage` includes the fields:
|
||||||
|
|
||||||
|
- `spoken`: A boolean indicating whether the text was spoken by TTS
|
||||||
|
|
||||||
|
- `aggregated_by`: A string representing how the text was aggregated
|
||||||
|
("sentence", "word", "my custom aggregation")
|
||||||
|
|
||||||
|
- Introduced new fields to `RTVIObserver` to support the new `bot-output`
|
||||||
|
messaging:
|
||||||
|
|
||||||
|
- `bot_output_enabled`: Defaults to True. Set to false to disable bot-output
|
||||||
|
messages.
|
||||||
|
|
||||||
|
- `skip_aggregator_types`: Defaults to `None`. Set to a list of strings that
|
||||||
|
match aggregation types that should not be included in bot-output
|
||||||
|
messages. (Ex. `credit_card`)
|
||||||
|
|
||||||
|
- Introduced new methods, `add_text_transformer()` and
|
||||||
|
`remove_text_transformer()`, to `RTVIObserver` to support providing (and
|
||||||
|
subsequently removing) callbacks for various types of aggregations (or all
|
||||||
|
aggregations with `*`) that can modify the text before being sent as a
|
||||||
|
`bot-output` or `tts-text` message. (Think obscuring the credit card or
|
||||||
|
inserting extra detail the client might want that the context doesn't need.)
|
||||||
|
|
||||||
|
- In `MiniMaxHttpTTSService`:
|
||||||
|
|
||||||
|
- Added support for speech-2.6-hd and speech-2.6-turbo models
|
||||||
|
|
||||||
|
- Added languages: Afrikaans, Bulgarian, Catalan, Danish, Persian, Filipino,
|
||||||
|
Hebrew, Croatian, Hungarian, Malay, Norwegian, Nynorsk, Slovak, Slovenian,
|
||||||
|
Swedish, and Tamil
|
||||||
|
|
||||||
|
- Added new emotions: calm and fluent
|
||||||
|
|
||||||
|
- Added `enable_logging` to `SimliVideoService` input parameters. It's disabled
|
||||||
|
by default.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Updated `FishAudioTTSService` default model to `s1`.
|
||||||
|
|
||||||
|
- Updated `DeepgramTTSService` to use Deepgram's TTS websocket API. ⚠️ This is
|
||||||
|
a potential breaking change, which only affects you if you're self-hosting
|
||||||
|
`DeepgramTTSService`. The new service uses Websockets and improves TTFB
|
||||||
|
latency.
|
||||||
|
|
||||||
|
- Updated `daily-python` to 0.22.0.
|
||||||
|
|
||||||
|
- `BaseTextAggregator` changes:
|
||||||
|
|
||||||
|
Modified the BaseTextAggregator type so that when text gets aggregated,
|
||||||
|
metadata can be associated with it. Currently, that just means a `type`, so
|
||||||
|
that the aggregation can be classified or described. Changes made to support
|
||||||
|
this:
|
||||||
|
|
||||||
|
- ⚠️ IMPORTANT: Aggregators are now expected to strip leading/trailing white
|
||||||
|
space characters before returning their aggregation from `aggregation()` or
|
||||||
|
`.text`. This way all aggregators have a consistent contract allowing
|
||||||
|
downstream use to know how to stitch aggregations back together.
|
||||||
|
|
||||||
|
- Introduced a new `Aggregation` dataclass to represent both the aggregated
|
||||||
|
`text` and a string identifying the `type` of aggregation (ex. "sentence",
|
||||||
|
"word", "my custom aggregation")
|
||||||
|
|
||||||
|
- ⚠️ Breaking change: `BaseTextAggregator.text` now returns an `Aggregation`
|
||||||
|
(instead of `str`).
|
||||||
|
|
||||||
|
Before:
|
||||||
|
|
||||||
|
```python
|
||||||
|
aggregated_text = myAggregator.text
|
||||||
|
```
|
||||||
|
|
||||||
|
Now:
|
||||||
|
|
||||||
|
```python
|
||||||
|
aggregated_text = myAggregator.text.text
|
||||||
|
```
|
||||||
|
|
||||||
|
- ⚠️ Breaking change: `BaseTextAggregator.aggregate()` now returns
|
||||||
|
`Optional[Aggregation]` (instead of `Optional[str]`).
|
||||||
|
|
||||||
|
Before:
|
||||||
|
|
||||||
|
```python
|
||||||
|
aggregation = myAggregator.aggregate(text)
|
||||||
|
print(f"successfully aggregated text: {aggregation}")
|
||||||
|
```
|
||||||
|
|
||||||
|
Now:
|
||||||
|
|
||||||
|
```python
|
||||||
|
aggregation = myAggregator.aggregate(text)
|
||||||
|
if aggregation:
|
||||||
|
print(f"successfully aggregated text: {aggregation.text}")
|
||||||
|
```
|
||||||
|
|
||||||
|
- `SimpleTextAggregator`, `SkipTagsAggregator`, `PatternPairAggregator`
|
||||||
|
updated to produce/consume `Aggregation` objects.
|
||||||
|
|
||||||
|
- All uses of the above Aggregators have been updated accordingly.
|
||||||
|
|
||||||
|
- Augmented the `PatternPairAggregator` so that matched patterns can be treated
|
||||||
|
as their own aggregation, taking advantage of the new. To that end:
|
||||||
|
|
||||||
|
- Introduced a new, preferred version of `add_pattern` to support a new option
|
||||||
|
for treating a match as a separate aggregation returned from
|
||||||
|
`aggregate()`. This replaces the now deprecated `add_pattern_pair` method
|
||||||
|
and you provide a `MatchAction` in lieu of the `remove_match` field.
|
||||||
|
|
||||||
|
- `MatchAction` enum: `REMOVE`, `KEEP`, `AGGREGATE`, allowing customization
|
||||||
|
for how a match should be handled.
|
||||||
|
|
||||||
|
- `REMOVE`: The text along with its delimiters will be removed from the
|
||||||
|
streaming text. Sentence aggregation will continue on as if this text
|
||||||
|
did not exist.
|
||||||
|
|
||||||
|
- `KEEP`: The delimiters will be removed, but the content between them
|
||||||
|
will be kept. Sentence aggregation will continue on with the internal
|
||||||
|
text included.
|
||||||
|
|
||||||
|
- `AGGREGATE`: The delimiters will be removed and the content between will
|
||||||
|
be treated as a separate aggregation. Any text before the start of the
|
||||||
|
pattern will be returned early, whether or not a complete sentence was
|
||||||
|
found. Then the pattern will be returned. Then the aggregation will
|
||||||
|
continue on sentence matching after the closing delimiter is found. The
|
||||||
|
content between the delimiters is not aggregated by sentence. It is
|
||||||
|
aggregated as one single block of text.
|
||||||
|
|
||||||
|
- `PatternMatch` now extends `Aggregation` and provides richer info to
|
||||||
|
handlers.
|
||||||
|
|
||||||
|
- ⚠️ Breaking change: The `PatternMatch` type returned to handlers registered
|
||||||
|
via `on_pattern_match` has been updated to subclass from the new
|
||||||
|
`Aggregation` type, which means that `content` has been replaced with
|
||||||
|
`text` and `pattern_id` has been replaced with `type`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async dev on_match_tag(match: PatternMatch):
|
||||||
|
pattern = match.type # instead of match.pattern_id
|
||||||
|
text = match.text # instead of match.content
|
||||||
|
```
|
||||||
|
|
||||||
|
- `TextFrame` now includes the field `append_to_context` to support setting
|
||||||
|
whether or not the encompassing text should be added to the LLM context (by
|
||||||
|
the LLM assistant aggregator). It defaults to `True`.
|
||||||
|
|
||||||
|
- `TTSService` base class updates:
|
||||||
|
|
||||||
|
- `TTSService`s now accept a new `skip_aggregator_types` to avoid speaking
|
||||||
|
certain aggregation types (now determined/returned by the aggregator)
|
||||||
|
|
||||||
|
- Introduced the ability to do a just-in-time transform of text before it gets
|
||||||
|
sent to the TTS service via callbacks you can set up via a new init field,
|
||||||
|
`text_transforms` or a new method `add_text_transformer()`. This makes it
|
||||||
|
possible to do things like introduce TTS-specific tags for spelling or
|
||||||
|
emotion or change the pronunciation of something on the
|
||||||
|
fly. `remove_text_transformer` has also been added to support removing a
|
||||||
|
registered transform callback.
|
||||||
|
|
||||||
|
- TTS services push `AggregatedTextFrame` in addition to `TTSTextFrame`s when
|
||||||
|
either an aggregation occurs that should not be spoken or when the TTS
|
||||||
|
service supports word-by-word timestamping. In the latter case, the
|
||||||
|
`TTSService` preliminarily generates an `AggregatedTextFrame`, aggregated by
|
||||||
|
sentence to generate the full sentence content as early as possible.
|
||||||
|
|
||||||
|
- Updated `CartesiaTTSService`:
|
||||||
|
|
||||||
|
- Modified use of custom default text_aggregator to avoid deprecation warnings
|
||||||
|
and push users towards use of transformers or the `LLMTextProcessor`
|
||||||
|
|
||||||
|
- Added convenience methods for taking advantage of Cartesia's SSML tags:
|
||||||
|
spell, emotion, pauses, volume, and speed.
|
||||||
|
|
||||||
|
- Updated `RimeTTSService`:
|
||||||
|
|
||||||
|
- Modified use of custom default text_aggregator to avoid deprecation warnings
|
||||||
|
and push users towards use of transformers or the `LLMTextProcessor`
|
||||||
|
|
||||||
|
- Added convenience methods for taking advantage of Rime's customization
|
||||||
|
options: spell, pauses, pronunciations, and inline speed control.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- The TTS constructor field, `text_aggregator` is deprecated in favor of the new
|
||||||
|
`LLMTextProcessor`. TTSServices still have an internal aggregator for support
|
||||||
|
of default behavior, but if you want to override the aggregation behavior, you
|
||||||
|
should use the new processor.
|
||||||
|
|
||||||
|
- The RTVI `bot-transcription` event is deprecated in favor of the new
|
||||||
|
`bot-output` message which is the canonical representation of bot output
|
||||||
|
(spoken or not). The code still emits a transcription message for backwards
|
||||||
|
compatibility while transition occurs.
|
||||||
|
|
||||||
|
- Deprecated `add_pattern_pair` in the `PatternPairAggregator` which takes a
|
||||||
|
`pattern_id` and `remove_match` field in favor of the new `add_pattern` method
|
||||||
|
which takes a `type` and an `action`
|
||||||
|
|
||||||
|
- `english_normalization` input parameter for `MiniMaxHttpTTSService` is
|
||||||
|
deprecated, use `test_normalization` instead.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in `AWSBedrockLLMService` where the `aws_region` arg was
|
||||||
|
always set to `us-east-1` when providing an AWS_REGION env var.
|
||||||
|
|
||||||
|
- Fixed an issue with `DeepgramFluxSTTService` where it sometimes failed to reconnect.
|
||||||
|
|
||||||
|
- Fixed an issue in `ElevenLabsRealtimeSTTService` where dynamic language
|
||||||
|
updates were not working.
|
||||||
|
|
||||||
|
- Fixed an issue in `ElevenLabsRealtimeSTTService` where setting the sample
|
||||||
|
rate would result in transcripts failing.
|
||||||
|
|
||||||
|
- Fixed `InworldTTSService` audio config payload to use camelCase keys expected
|
||||||
|
by the Inworld API.
|
||||||
|
|
||||||
|
## [0.0.95] - 2025-11-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -22,15 +423,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Added `ElevenLabsRealtimeSTTService` which implements the Realtime STT
|
- Added `ElevenLabsRealtimeSTTService` which implements the Realtime STT
|
||||||
service from ElevenLabs.
|
service from ElevenLabs.
|
||||||
|
|
||||||
- Added ai-coustics integrated VAD (`AICVADAnalyzer`) with `AICFilter` factory and
|
- Added word-level timestamps support to Hume TTS service
|
||||||
example wiring; leverages the enhancement model for robust detection with no
|
|
||||||
ONNX dependency or added processing complexity.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ⚠️ Breaking change: `LLMContext.create_image_message()` and
|
- ⚠️ Breaking change: `LLMContext.create_image_message()`,
|
||||||
`LLMContext.create_audio_message()` are now async methods. This fixes and
|
`LLMContext.create_audio_message()`, `LLMContext.add_image_frame_message()`
|
||||||
issue where the asyncio event loop would be blocked while encoding audio or
|
and `LLMContext.add_audio_frames_message()` are now async methods. This fixes
|
||||||
|
an issue where the asyncio event loop would be blocked while encoding audio or
|
||||||
images.
|
images.
|
||||||
|
|
||||||
- `ConsumerProcessor` now queues frames from the producer internally instead of
|
- `ConsumerProcessor` now queues frames from the producer internally instead of
|
||||||
@@ -71,10 +471,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed a race condition where, if the LLM received instructions to both produce
|
- Fixed a `SimliVideoService` connection issue.
|
||||||
text and invoke a function call at the same time, the context would not be
|
|
||||||
updated before the function call result arrived, causing the bot to repeat
|
|
||||||
itself.
|
|
||||||
|
|
||||||
- Fixed an issue in the `Runner` where, when using `SmallWebRTCTransport`, the
|
- Fixed an issue in the `Runner` where, when using `SmallWebRTCTransport`, the
|
||||||
`request_data` was not being passed to the `SmallWebRTCRunnerArguments` body.
|
`request_data` was not being passed to the `SmallWebRTCRunnerArguments` body.
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ Once your PR is submitted, post in the `#community-integrations` Discord channel
|
|||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
- [RivaSTTService](https://github.com/pipecat-ai/pipecat/blob/main/src/pipecat/services/riva/stt.py)
|
- [NvidiaSTTService](https://github.com/pipecat-ai/pipecat/blob/main/src/pipecat/services/nvidia/stt.py)
|
||||||
- [FalSTTService](https://github.com/pipecat-ai/pipecat/blob/main/src/pipecat/services/fal/stt.py)
|
- [FalSTTService](https://github.com/pipecat-ai/pipecat/blob/main/src/pipecat/services/fal/stt.py)
|
||||||
|
|
||||||
#### Key requirements:
|
#### Key requirements:
|
||||||
|
|||||||
105
CONTRIBUTING.md
105
CONTRIBUTING.md
@@ -17,24 +17,121 @@ We welcome contributions of all kinds! Your help is appreciated. Follow these st
|
|||||||
git checkout -b your-branch-name
|
git checkout -b your-branch-name
|
||||||
```
|
```
|
||||||
4. **Make your changes**: Edit or add files as necessary.
|
4. **Make your changes**: Edit or add files as necessary.
|
||||||
5. **Test your changes**: Ensure that your changes look correct and follow the style set in the codebase.
|
5. **Add a changelog entry**: Create a changelog fragment file (see [Changelog Entries](#changelog-entries) below).
|
||||||
6. **Commit your changes**: Once you're satisfied with your changes, commit them with a meaningful message.
|
6. **Test your changes**: Ensure that your changes look correct and follow the style set in the codebase.
|
||||||
|
7. **Commit your changes**: Once you're satisfied with your changes, commit them with a meaningful message.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "Description of your changes"
|
git commit -m "Description of your changes"
|
||||||
```
|
```
|
||||||
|
|
||||||
7. **Push your changes**: Push your branch to your forked repository.
|
8. **Push your changes**: Push your branch to your forked repository.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git push origin your-branch-name
|
git push origin your-branch-name
|
||||||
```
|
```
|
||||||
|
|
||||||
8. **Submit a Pull Request (PR)**: Open a PR from your forked repository to the main branch of this repo.
|
9. **Submit a Pull Request (PR)**: Open a PR from your forked repository to the main branch of this repo.
|
||||||
> Important: Describe the changes you've made clearly!
|
> Important: Describe the changes you've made clearly!
|
||||||
|
|
||||||
Our maintainers will review your PR, and once everything is good, your contributions will be merged!
|
Our maintainers will review your PR, and once everything is good, your contributions will be merged!
|
||||||
|
|
||||||
|
## Changelog Entries
|
||||||
|
|
||||||
|
Every pull request that makes a user-facing change should include a changelog entry. We use a changelog fragment system to avoid merge conflicts.
|
||||||
|
|
||||||
|
### Creating a Changelog Fragment
|
||||||
|
|
||||||
|
1. Create a new file in the `changelog/` directory with this naming pattern:
|
||||||
|
|
||||||
|
```
|
||||||
|
<PR_number>.<type>.md
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Choose the appropriate type:
|
||||||
|
|
||||||
|
- `added.md` - New features
|
||||||
|
- `changed.md` - Changes in existing functionality
|
||||||
|
- `deprecated.md` - Soon-to-be removed features
|
||||||
|
- `removed.md` - Removed features
|
||||||
|
- `fixed.md` - Bug fixes
|
||||||
|
- `security.md` - Security fixes
|
||||||
|
|
||||||
|
3. Write your changelog entry as a Markdown bullet point. Include the `-` at the start:
|
||||||
|
|
||||||
|
**Example files:**
|
||||||
|
|
||||||
|
`changelog/1234.added.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- Added support for Anthropic Claude 3.5 Sonnet with improved streaming performance.
|
||||||
|
```
|
||||||
|
|
||||||
|
`changelog/5678.fixed.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- Fixed an issue where audio frames were dropped during high-load scenarios.
|
||||||
|
```
|
||||||
|
|
||||||
|
**For entries with nested bullets:**
|
||||||
|
|
||||||
|
`changelog/1234.changed.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- Updated service configuration:
|
||||||
|
|
||||||
|
- Changed default timeout to 30 seconds
|
||||||
|
- Added retry logic for failed connections
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Changes in One PR
|
||||||
|
|
||||||
|
**Different types of changes:** Create separate fragment files for each type:
|
||||||
|
|
||||||
|
```
|
||||||
|
changelog/1234.added.md
|
||||||
|
changelog/1234.fixed.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multiple changes of the same type:** Create numbered fragment files:
|
||||||
|
|
||||||
|
```
|
||||||
|
changelog/1234.changed.md
|
||||||
|
changelog/1234.changed.2.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**Related changes:** Use nested bullets in a single fragment:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- Updated service configuration:
|
||||||
|
|
||||||
|
- Changed default timeout to 30 seconds
|
||||||
|
- Added retry logic for failed connections
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule of thumb:** One logical change per fragment file. If changes are unrelated, use separate files.
|
||||||
|
|
||||||
|
### Preview Your Changes
|
||||||
|
|
||||||
|
To see what your changelog entry will look like:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
towncrier build --draft --version Unreleased
|
||||||
|
```
|
||||||
|
|
||||||
|
This won't modify any files, just show you a preview.
|
||||||
|
|
||||||
|
### When to Skip Changelog Entries
|
||||||
|
|
||||||
|
You can skip adding a changelog entry for:
|
||||||
|
|
||||||
|
- Documentation-only changes
|
||||||
|
- Internal refactoring with no user-facing impact
|
||||||
|
- Test-only changes
|
||||||
|
- CI/build configuration changes
|
||||||
|
|
||||||
|
If you're unsure whether your change needs a changelog entry, ask in your PR!
|
||||||
|
|
||||||
## Dependency Management
|
## Dependency Management
|
||||||
|
|
||||||
This project uses [uv](https://docs.astral.sh/uv/) for dependency management. The `uv.lock` file is committed to ensure reproducible builds.
|
This project uses [uv](https://docs.astral.sh/uv/) for dependency management. The `uv.lock` file is committed to ensure reproducible builds.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
</div></h1>
|
</div></h1>
|
||||||
|
|
||||||
[](https://pypi.org/project/pipecat-ai)  [](https://codecov.io/gh/pipecat-ai/pipecat) [](https://docs.pipecat.ai) [](https://discord.gg/pipecat) [](https://deepwiki.com/pipecat-ai/pipecat)
|
[](https://pypi.org/project/pipecat-ai)  [](https://codecov.io/gh/pipecat-ai/pipecat) [](https://docs.pipecat.ai) [](https://discord.gg/pipecat) [](https://deepwiki.com/pipecat-ai/pipecat)
|
||||||
[](https://getmanta.ai/pipecat)
|
|
||||||
|
|
||||||
# 🎙️ Pipecat: Real-Time Voice & Multimodal AI Agents
|
# 🎙️ Pipecat: Real-Time Voice & Multimodal AI Agents
|
||||||
|
|
||||||
@@ -74,9 +73,9 @@ Catch new features, interviews, and how-tos on our [Pipecat TV](https://www.yout
|
|||||||
|
|
||||||
| Category | Services |
|
| Category | Services |
|
||||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Speech-to-Text | [AssemblyAI](https://docs.pipecat.ai/server/services/stt/assemblyai), [AWS](https://docs.pipecat.ai/server/services/stt/aws), [Azure](https://docs.pipecat.ai/server/services/stt/azure), [Cartesia](https://docs.pipecat.ai/server/services/stt/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/stt/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/stt/elevenlabs), [Fal Wizper](https://docs.pipecat.ai/server/services/stt/fal), [Gladia](https://docs.pipecat.ai/server/services/stt/gladia), [Google](https://docs.pipecat.ai/server/services/stt/google), [Groq (Whisper)](https://docs.pipecat.ai/server/services/stt/groq), [NVIDIA Riva](https://docs.pipecat.ai/server/services/stt/riva), [OpenAI (Whisper)](https://docs.pipecat.ai/server/services/stt/openai), [SambaNova (Whisper)](https://docs.pipecat.ai/server/services/stt/sambanova), [Sarvam](https://docs.pipecat.ai/server/services/stt/sarvam), [Soniox](https://docs.pipecat.ai/server/services/stt/soniox), [Speechmatics](https://docs.pipecat.ai/server/services/stt/speechmatics), [Ultravox](https://docs.pipecat.ai/server/services/stt/ultravox), [Whisper](https://docs.pipecat.ai/server/services/stt/whisper) |
|
| Speech-to-Text | [AssemblyAI](https://docs.pipecat.ai/server/services/stt/assemblyai), [AWS](https://docs.pipecat.ai/server/services/stt/aws), [Azure](https://docs.pipecat.ai/server/services/stt/azure), [Cartesia](https://docs.pipecat.ai/server/services/stt/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/stt/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/stt/elevenlabs), [Fal Wizper](https://docs.pipecat.ai/server/services/stt/fal), [Gladia](https://docs.pipecat.ai/server/services/stt/gladia), [Google](https://docs.pipecat.ai/server/services/stt/google), [Gradium](https://docs.pipecat.ai/server/services/stt/gradium), [Groq (Whisper)](https://docs.pipecat.ai/server/services/stt/groq), [NVIDIA Riva](https://docs.pipecat.ai/server/services/stt/riva), [OpenAI (Whisper)](https://docs.pipecat.ai/server/services/stt/openai), [SambaNova (Whisper)](https://docs.pipecat.ai/server/services/stt/sambanova), [Sarvam](https://docs.pipecat.ai/server/services/stt/sarvam), [Soniox](https://docs.pipecat.ai/server/services/stt/soniox), [Speechmatics](https://docs.pipecat.ai/server/services/stt/speechmatics), [Ultravox](https://docs.pipecat.ai/server/services/stt/ultravox), [Whisper](https://docs.pipecat.ai/server/services/stt/whisper) |
|
||||||
| LLMs | [Anthropic](https://docs.pipecat.ai/server/services/llm/anthropic), [AWS](https://docs.pipecat.ai/server/services/llm/aws), [Azure](https://docs.pipecat.ai/server/services/llm/azure), [Cerebras](https://docs.pipecat.ai/server/services/llm/cerebras), [DeepSeek](https://docs.pipecat.ai/server/services/llm/deepseek), [Fireworks AI](https://docs.pipecat.ai/server/services/llm/fireworks), [Gemini](https://docs.pipecat.ai/server/services/llm/gemini), [Grok](https://docs.pipecat.ai/server/services/llm/grok), [Groq](https://docs.pipecat.ai/server/services/llm/groq), [Mistral](https://docs.pipecat.ai/server/services/llm/mistral), [NVIDIA NIM](https://docs.pipecat.ai/server/services/llm/nim), [Ollama](https://docs.pipecat.ai/server/services/llm/ollama), [OpenAI](https://docs.pipecat.ai/server/services/llm/openai), [OpenRouter](https://docs.pipecat.ai/server/services/llm/openrouter), [Perplexity](https://docs.pipecat.ai/server/services/llm/perplexity), [Qwen](https://docs.pipecat.ai/server/services/llm/qwen), [SambaNova](https://docs.pipecat.ai/server/services/llm/sambanova) [Together AI](https://docs.pipecat.ai/server/services/llm/together) |
|
| LLMs | [Anthropic](https://docs.pipecat.ai/server/services/llm/anthropic), [AWS](https://docs.pipecat.ai/server/services/llm/aws), [Azure](https://docs.pipecat.ai/server/services/llm/azure), [Cerebras](https://docs.pipecat.ai/server/services/llm/cerebras), [DeepSeek](https://docs.pipecat.ai/server/services/llm/deepseek), [Fireworks AI](https://docs.pipecat.ai/server/services/llm/fireworks), [Gemini](https://docs.pipecat.ai/server/services/llm/gemini), [Grok](https://docs.pipecat.ai/server/services/llm/grok), [Groq](https://docs.pipecat.ai/server/services/llm/groq), [Mistral](https://docs.pipecat.ai/server/services/llm/mistral), [NVIDIA NIM](https://docs.pipecat.ai/server/services/llm/nim), [Ollama](https://docs.pipecat.ai/server/services/llm/ollama), [OpenAI](https://docs.pipecat.ai/server/services/llm/openai), [OpenRouter](https://docs.pipecat.ai/server/services/llm/openrouter), [Perplexity](https://docs.pipecat.ai/server/services/llm/perplexity), [Qwen](https://docs.pipecat.ai/server/services/llm/qwen), [SambaNova](https://docs.pipecat.ai/server/services/llm/sambanova) [Together AI](https://docs.pipecat.ai/server/services/llm/together) |
|
||||||
| Text-to-Speech | [Async](https://docs.pipecat.ai/server/services/tts/asyncai), [AWS](https://docs.pipecat.ai/server/services/tts/aws), [Azure](https://docs.pipecat.ai/server/services/tts/azure), [Cartesia](https://docs.pipecat.ai/server/services/tts/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/tts/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/tts/elevenlabs), [Fish](https://docs.pipecat.ai/server/services/tts/fish), [Google](https://docs.pipecat.ai/server/services/tts/google), [Groq](https://docs.pipecat.ai/server/services/tts/groq), [Hume](https://docs.pipecat.ai/server/services/tts/hume), [Inworld](https://docs.pipecat.ai/server/services/tts/inworld), [LMNT](https://docs.pipecat.ai/server/services/tts/lmnt), [MiniMax](https://docs.pipecat.ai/server/services/tts/minimax), [Neuphonic](https://docs.pipecat.ai/server/services/tts/neuphonic), [NVIDIA Riva](https://docs.pipecat.ai/server/services/tts/riva), [OpenAI](https://docs.pipecat.ai/server/services/tts/openai), [Piper](https://docs.pipecat.ai/server/services/tts/piper), [PlayHT](https://docs.pipecat.ai/server/services/tts/playht), [Rime](https://docs.pipecat.ai/server/services/tts/rime), [Sarvam](https://docs.pipecat.ai/server/services/tts/sarvam), [Speechmatics](https://docs.pipecat.ai/server/services/tts/speechmatics), [XTTS](https://docs.pipecat.ai/server/services/tts/xtts) |
|
| Text-to-Speech | [Async](https://docs.pipecat.ai/server/services/tts/asyncai), [AWS](https://docs.pipecat.ai/server/services/tts/aws), [Azure](https://docs.pipecat.ai/server/services/tts/azure), [Cartesia](https://docs.pipecat.ai/server/services/tts/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/tts/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/tts/elevenlabs), [Fish](https://docs.pipecat.ai/server/services/tts/fish), [Google](https://docs.pipecat.ai/server/services/tts/google), [Gradium](https://docs.pipecat.ai/server/services/tts/gradium), [Groq](https://docs.pipecat.ai/server/services/tts/groq), [Hume](https://docs.pipecat.ai/server/services/tts/hume), [Inworld](https://docs.pipecat.ai/server/services/tts/inworld), [LMNT](https://docs.pipecat.ai/server/services/tts/lmnt), [MiniMax](https://docs.pipecat.ai/server/services/tts/minimax), [Neuphonic](https://docs.pipecat.ai/server/services/tts/neuphonic), [NVIDIA Riva](https://docs.pipecat.ai/server/services/tts/riva), [OpenAI](https://docs.pipecat.ai/server/services/tts/openai), [Piper](https://docs.pipecat.ai/server/services/tts/piper), [PlayHT](https://docs.pipecat.ai/server/services/tts/playht), [Rime](https://docs.pipecat.ai/server/services/tts/rime), [Sarvam](https://docs.pipecat.ai/server/services/tts/sarvam), [Speechmatics](https://docs.pipecat.ai/server/services/tts/speechmatics), [XTTS](https://docs.pipecat.ai/server/services/tts/xtts) |
|
||||||
| Speech-to-Speech | [AWS Nova Sonic](https://docs.pipecat.ai/server/services/s2s/aws), [Gemini Multimodal Live](https://docs.pipecat.ai/server/services/s2s/gemini), [OpenAI Realtime](https://docs.pipecat.ai/server/services/s2s/openai) |
|
| Speech-to-Speech | [AWS Nova Sonic](https://docs.pipecat.ai/server/services/s2s/aws), [Gemini Multimodal Live](https://docs.pipecat.ai/server/services/s2s/gemini), [OpenAI Realtime](https://docs.pipecat.ai/server/services/s2s/openai) |
|
||||||
| Transport | [Daily (WebRTC)](https://docs.pipecat.ai/server/services/transport/daily), [FastAPI Websocket](https://docs.pipecat.ai/server/services/transport/fastapi-websocket), [SmallWebRTCTransport](https://docs.pipecat.ai/server/services/transport/small-webrtc), [WebSocket Server](https://docs.pipecat.ai/server/services/transport/websocket-server), Local |
|
| Transport | [Daily (WebRTC)](https://docs.pipecat.ai/server/services/transport/daily), [FastAPI Websocket](https://docs.pipecat.ai/server/services/transport/fastapi-websocket), [SmallWebRTCTransport](https://docs.pipecat.ai/server/services/transport/small-webrtc), [WebSocket Server](https://docs.pipecat.ai/server/services/transport/websocket-server), Local |
|
||||||
| Serializers | [Plivo](https://docs.pipecat.ai/server/utilities/serializers/plivo), [Twilio](https://docs.pipecat.ai/server/utilities/serializers/twilio), [Telnyx](https://docs.pipecat.ai/server/utilities/serializers/telnyx) |
|
| Serializers | [Plivo](https://docs.pipecat.ai/server/utilities/serializers/plivo), [Twilio](https://docs.pipecat.ai/server/utilities/serializers/twilio), [Telnyx](https://docs.pipecat.ai/server/utilities/serializers/telnyx) |
|
||||||
|
|||||||
6
changelog/3189.added.md
Normal file
6
changelog/3189.added.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
- Data and control frames can now be marked as non-interruptible by using the
|
||||||
|
`UninterruptibleFrame` mixin. Frames marked as `UninterruptibleFrame` will not
|
||||||
|
be interrupted during processing, and any queued frames of this type will be
|
||||||
|
retained in the internal queues. This is useful when you need ordered frames
|
||||||
|
(data or control) that should not be discarded or cancelled due to
|
||||||
|
interruptions.
|
||||||
3
changelog/3189.changed.md
Normal file
3
changelog/3189.changed.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
- `FunctionCallInProgressFrame` and `FunctionCallResultFrame` have changed from
|
||||||
|
system frames to a control frame and a data frame, respectively, and are now
|
||||||
|
both marked as `UninterruptibleFrame`.
|
||||||
16
changelog/_template.md.j2
Normal file
16
changelog/_template.md.j2
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{% for section, _ in sections.items() %}
|
||||||
|
{% if sections[section] %}
|
||||||
|
{% for category, val in definitions.items() if category in sections[section]%}
|
||||||
|
### {{ definitions[category]['name'] }}
|
||||||
|
|
||||||
|
{% for text, values in sections[section][category].items() %}
|
||||||
|
{{ text }}
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
No significant changes.
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
@@ -119,7 +119,6 @@ def import_core_modules():
|
|||||||
"pipecat.observers",
|
"pipecat.observers",
|
||||||
"pipecat.runner",
|
"pipecat.runner",
|
||||||
"pipecat.serializers",
|
"pipecat.serializers",
|
||||||
"pipecat.sync",
|
|
||||||
"pipecat.transcriptions",
|
"pipecat.transcriptions",
|
||||||
"pipecat.utils",
|
"pipecat.utils",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ Quick Links
|
|||||||
Runner <api/pipecat.runner>
|
Runner <api/pipecat.runner>
|
||||||
Serializers <api/pipecat.serializers>
|
Serializers <api/pipecat.serializers>
|
||||||
Services <api/pipecat.services>
|
Services <api/pipecat.services>
|
||||||
Sync <api/pipecat.sync>
|
|
||||||
Transcriptions <api/pipecat.transcriptions>
|
Transcriptions <api/pipecat.transcriptions>
|
||||||
Transports <api/pipecat.transports>
|
Transports <api/pipecat.transports>
|
||||||
Utils <api/pipecat.utils>
|
Utils <api/pipecat.utils>
|
||||||
@@ -44,6 +44,7 @@ DAILY_SAMPLE_ROOM_URL=https://...
|
|||||||
|
|
||||||
# Deepgram
|
# Deepgram
|
||||||
DEEPGRAM_API_KEY=...
|
DEEPGRAM_API_KEY=...
|
||||||
|
SAGEMAKER_ENDPOINT_NAME=...
|
||||||
|
|
||||||
# DeepSeek
|
# DeepSeek
|
||||||
DEEPSEEK_API_KEY=...
|
DEEPSEEK_API_KEY=...
|
||||||
@@ -72,6 +73,9 @@ GOOGLE_CLOUD_PROJECT_ID=...
|
|||||||
GOOGLE_CLOUD_LOCATION=...
|
GOOGLE_CLOUD_LOCATION=...
|
||||||
GOOGLE_TEST_CREDENTIALS=...
|
GOOGLE_TEST_CREDENTIALS=...
|
||||||
|
|
||||||
|
# Gradium
|
||||||
|
GRAPDIUM_API_KEY=...
|
||||||
|
|
||||||
# Grok
|
# Grok
|
||||||
GROK_API_KEY=...
|
GROK_API_KEY=...
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from pipecat.pipeline.runner import PipelineRunner
|
|||||||
from pipecat.pipeline.task import PipelineTask
|
from pipecat.pipeline.task import PipelineTask
|
||||||
from pipecat.runner.types import RunnerArguments
|
from pipecat.runner.types import RunnerArguments
|
||||||
from pipecat.runner.utils import create_transport
|
from pipecat.runner.utils import create_transport
|
||||||
from pipecat.services.riva.tts import FastPitchTTSService
|
from pipecat.services.nvidia.tts import NvidiaTTSService
|
||||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||||
from pipecat.transports.daily.transport import DailyParams
|
from pipecat.transports.daily.transport import DailyParams
|
||||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||||
@@ -36,7 +36,7 @@ transport_params = {
|
|||||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||||
logger.info(f"Starting bot")
|
logger.info(f"Starting bot")
|
||||||
|
|
||||||
tts = FastPitchTTSService(api_key=os.getenv("NVIDIA_API_KEY"))
|
tts = NvidiaTTSService(api_key=os.getenv("NVIDIA_API_KEY"))
|
||||||
|
|
||||||
task = PipelineTask(
|
task = PipelineTask(
|
||||||
Pipeline([tts, transport.output()]),
|
Pipeline([tts, transport.output()]),
|
||||||
@@ -13,24 +13,29 @@ from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams
|
|||||||
from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3
|
from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3
|
||||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||||
from pipecat.audio.vad.vad_analyzer import VADParams
|
from pipecat.audio.vad.vad_analyzer import VADParams
|
||||||
from pipecat.frames.frames import LLMRunFrame
|
from pipecat.frames.frames import LLMRunFrame, TTSTextFrame
|
||||||
|
from pipecat.observers.loggers.debug_log_observer import DebugLogObserver, FrameEndpoint
|
||||||
from pipecat.pipeline.pipeline import Pipeline
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
from pipecat.pipeline.runner import PipelineRunner
|
from pipecat.pipeline.runner import PipelineRunner
|
||||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||||
from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair
|
from pipecat.processors.aggregators.llm_response_universal import (
|
||||||
|
LLMContextAggregatorPair,
|
||||||
|
)
|
||||||
from pipecat.processors.frameworks.rtvi import RTVIConfig, RTVIObserver, RTVIProcessor
|
from pipecat.processors.frameworks.rtvi import RTVIConfig, RTVIObserver, RTVIProcessor
|
||||||
from pipecat.runner.types import RunnerArguments
|
from pipecat.runner.types import RunnerArguments
|
||||||
from pipecat.runner.utils import create_transport
|
from pipecat.runner.utils import create_transport
|
||||||
from pipecat.services.deepgram.stt import DeepgramSTTService
|
from pipecat.services.deepgram.stt import DeepgramSTTService
|
||||||
from pipecat.services.hume.tts import HUME_SAMPLE_RATE, HumeTTSService
|
from pipecat.services.hume.tts import HUME_SAMPLE_RATE, HumeTTSService
|
||||||
from pipecat.services.openai.llm import OpenAILLMService
|
from pipecat.services.openai.llm import OpenAILLMService
|
||||||
|
from pipecat.transports.base_output import BaseOutputTransport
|
||||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||||
from pipecat.transports.daily.transport import DailyParams
|
from pipecat.transports.daily.transport import DailyParams
|
||||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||||
|
|
||||||
load_dotenv(override=True)
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
|
||||||
# We store functions so objects (e.g. SileroVADAnalyzer) don't get
|
# We store functions so objects (e.g. SileroVADAnalyzer) don't get
|
||||||
# instantiated. The function will be called when the desired transport gets
|
# instantiated. The function will be called when the desired transport gets
|
||||||
# selected.
|
# selected.
|
||||||
@@ -88,7 +93,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
|||||||
stt,
|
stt,
|
||||||
context_aggregator.user(), # User responses
|
context_aggregator.user(), # User responses
|
||||||
llm, # LLM
|
llm, # LLM
|
||||||
tts, # TTS
|
tts, # TTS (HumeTTSService with word timestamps)
|
||||||
transport.output(), # Transport bot output
|
transport.output(), # Transport bot output
|
||||||
context_aggregator.assistant(), # Assistant spoken responses
|
context_aggregator.assistant(), # Assistant spoken responses
|
||||||
]
|
]
|
||||||
@@ -102,7 +107,14 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
|||||||
audio_out_sample_rate=HUME_SAMPLE_RATE,
|
audio_out_sample_rate=HUME_SAMPLE_RATE,
|
||||||
),
|
),
|
||||||
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
|
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
|
||||||
observers=[RTVIObserver(rtvi)],
|
observers=[
|
||||||
|
RTVIObserver(rtvi),
|
||||||
|
DebugLogObserver(
|
||||||
|
frame_types={
|
||||||
|
TTSTextFrame: (BaseOutputTransport, FrameEndpoint.SOURCE),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@rtvi.event_handler("on_client_ready")
|
@rtvi.event_handler("on_client_ready")
|
||||||
@@ -112,6 +124,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
|||||||
@transport.event_handler("on_client_connected")
|
@transport.event_handler("on_client_connected")
|
||||||
async def on_client_connected(transport, client):
|
async def on_client_connected(transport, client):
|
||||||
logger.info(f"Client connected")
|
logger.info(f"Client connected")
|
||||||
|
logger.info(
|
||||||
|
"💡 Word timestamps are enabled! Watch the console for TTSTextFrame logs showing each word with its PTS."
|
||||||
|
)
|
||||||
# Kick off the conversation.
|
# Kick off the conversation.
|
||||||
messages.append({"role": "system", "content": "Please introduce yourself to the user."})
|
messages.append({"role": "system", "content": "Please introduce yourself to the user."})
|
||||||
await task.queue_frames([LLMRunFrame()])
|
await task.queue_frames([LLMRunFrame()])
|
||||||
|
|||||||
@@ -4,12 +4,10 @@
|
|||||||
# SPDX-License-Identifier: BSD 2-Clause License
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
#
|
#
|
||||||
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from mcp.client.session_group import SseServerParameters
|
|
||||||
|
|
||||||
from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams
|
from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams
|
||||||
from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3
|
from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3
|
||||||
@@ -23,10 +21,9 @@ from pipecat.processors.aggregators.llm_context import LLMContext
|
|||||||
from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair
|
from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair
|
||||||
from pipecat.runner.types import RunnerArguments
|
from pipecat.runner.types import RunnerArguments
|
||||||
from pipecat.runner.utils import create_transport
|
from pipecat.runner.utils import create_transport
|
||||||
from pipecat.services.anthropic.llm import AnthropicLLMService
|
from pipecat.services.gradium.stt import GradiumSTTService
|
||||||
from pipecat.services.cartesia.tts import CartesiaTTSService
|
from pipecat.services.gradium.tts import GradiumTTSService
|
||||||
from pipecat.services.deepgram.stt import DeepgramSTTService
|
from pipecat.services.openai.llm import OpenAILLMService
|
||||||
from pipecat.services.mcp_service import MCPClient
|
|
||||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||||
from pipecat.transports.daily.transport import DailyParams
|
from pipecat.transports.daily.transport import DailyParams
|
||||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||||
@@ -61,56 +58,34 @@ transport_params = {
|
|||||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||||
logger.info(f"Starting bot")
|
logger.info(f"Starting bot")
|
||||||
|
|
||||||
stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY"))
|
stt = GradiumSTTService(api_key=os.getenv("GRADIUM_API_KEY"))
|
||||||
|
|
||||||
tts = CartesiaTTSService(
|
tts = GradiumTTSService(
|
||||||
api_key=os.getenv("CARTESIA_API_KEY"),
|
api_key=os.getenv("GRADIUM_API_KEY"),
|
||||||
voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady
|
voice_id="YTpq7expH9539ERJ",
|
||||||
)
|
)
|
||||||
|
|
||||||
llm = AnthropicLLMService(
|
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
|
||||||
api_key=os.getenv("ANTHROPIC_API_KEY"), model="claude-3-7-sonnet-latest"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
messages = [
|
||||||
# https://docs.mcp.run/integrating/tutorials/mcp-run-sse-openai-agents/
|
{
|
||||||
mcp = MCPClient(server_params=SseServerParameters(url=os.getenv("MCP_RUN_SSE_URL")))
|
"role": "system",
|
||||||
except Exception as e:
|
"content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. Respond to what the user said in a creative and helpful way.",
|
||||||
logger.error(f"error setting up mcp")
|
},
|
||||||
logger.exception("error trace:")
|
]
|
||||||
|
|
||||||
tools = {}
|
context = LLMContext(messages)
|
||||||
try:
|
|
||||||
tools = await mcp.register_tools(llm)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"error registering tools")
|
|
||||||
logger.exception("error trace:")
|
|
||||||
|
|
||||||
system = f"""
|
|
||||||
You are a helpful LLM in a WebRTC call.
|
|
||||||
Your goal is to demonstrate your capabilities in a succinct way.
|
|
||||||
You have access to a number of tools provided by mcp.run. Use any and all tools to help users.
|
|
||||||
Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points.
|
|
||||||
Respond to what the user said in a creative and helpful way.
|
|
||||||
When asked for today's date, use 'https://www.datetoday.net/'.
|
|
||||||
Don't overexplain what you are doing.
|
|
||||||
Just respond with short sentences when you are carrying out tool calls.
|
|
||||||
"""
|
|
||||||
|
|
||||||
messages = [{"role": "system", "content": system}]
|
|
||||||
|
|
||||||
context = LLMContext(messages, tools)
|
|
||||||
context_aggregator = LLMContextAggregatorPair(context)
|
context_aggregator = LLMContextAggregatorPair(context)
|
||||||
|
|
||||||
pipeline = Pipeline(
|
pipeline = Pipeline(
|
||||||
[
|
[
|
||||||
transport.input(), # Transport user input
|
transport.input(), # Transport user input
|
||||||
stt,
|
stt,
|
||||||
context_aggregator.user(), # User spoken responses
|
context_aggregator.user(), # User responses
|
||||||
llm, # LLM
|
llm, # LLM
|
||||||
tts, # TTS
|
tts, # TTS
|
||||||
transport.output(), # Transport bot output
|
transport.output(), # Transport bot output
|
||||||
context_aggregator.assistant(), # Assistant spoken responses and tool context
|
context_aggregator.assistant(), # Assistant spoken responses
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -125,8 +100,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
|||||||
|
|
||||||
@transport.event_handler("on_client_connected")
|
@transport.event_handler("on_client_connected")
|
||||||
async def on_client_connected(transport, client):
|
async def on_client_connected(transport, client):
|
||||||
logger.info(f"Client connected: {client}")
|
logger.info(f"Client connected")
|
||||||
# Kick off the conversation.
|
# Kick off the conversation.
|
||||||
|
messages.append({"role": "system", "content": "Please introduce yourself to the user."})
|
||||||
await task.queue_frames([LLMRunFrame()])
|
await task.queue_frames([LLMRunFrame()])
|
||||||
|
|
||||||
@transport.event_handler("on_client_disconnected")
|
@transport.event_handler("on_client_disconnected")
|
||||||
@@ -146,14 +122,6 @@ async def bot(runner_args: RunnerArguments):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if not os.getenv("MCP_RUN_SSE_URL"):
|
|
||||||
logger.error(
|
|
||||||
f"Please set MCP_RUN_SSE_URL environment variable for this example. See https://mcp.run"
|
|
||||||
)
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
from pipecat.runner.run import main
|
from pipecat.runner.run import main
|
||||||
|
|
||||||
main()
|
main()
|
||||||
137
examples/foundational/07c-interruptible-deepgram-sagemaker.py
Normal file
137
examples/foundational/07c-interruptible-deepgram-sagemaker.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams
|
||||||
|
from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3
|
||||||
|
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||||
|
from pipecat.audio.vad.vad_analyzer import VADParams
|
||||||
|
from pipecat.frames.frames import LLMRunFrame
|
||||||
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
|
from pipecat.pipeline.runner import PipelineRunner
|
||||||
|
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||||
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||||
|
from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair
|
||||||
|
from pipecat.runner.types import RunnerArguments
|
||||||
|
from pipecat.runner.utils import create_transport
|
||||||
|
from pipecat.services.aws.llm import AWSBedrockLLMService
|
||||||
|
from pipecat.services.deepgram.stt_sagemaker import DeepgramSageMakerSTTService
|
||||||
|
from pipecat.services.deepgram.tts import DeepgramTTSService
|
||||||
|
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||||
|
from pipecat.transports.daily.transport import DailyParams
|
||||||
|
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||||
|
|
||||||
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
|
||||||
|
# We store functions so objects (e.g. SileroVADAnalyzer) don't get
|
||||||
|
# instantiated. The function will be called when the desired transport gets
|
||||||
|
# selected.
|
||||||
|
transport_params = {
|
||||||
|
"daily": lambda: DailyParams(
|
||||||
|
audio_in_enabled=True,
|
||||||
|
audio_out_enabled=True,
|
||||||
|
vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
|
||||||
|
turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams()),
|
||||||
|
),
|
||||||
|
"twilio": lambda: FastAPIWebsocketParams(
|
||||||
|
audio_in_enabled=True,
|
||||||
|
audio_out_enabled=True,
|
||||||
|
vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
|
||||||
|
turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams()),
|
||||||
|
),
|
||||||
|
"webrtc": lambda: TransportParams(
|
||||||
|
audio_in_enabled=True,
|
||||||
|
audio_out_enabled=True,
|
||||||
|
vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
|
||||||
|
turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams()),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||||
|
logger.info(f"Starting bot")
|
||||||
|
|
||||||
|
# Initialize Deepgram SageMaker STT Service
|
||||||
|
# This requires:
|
||||||
|
# - AWS credentials configured (via environment variables or AWS CLI)
|
||||||
|
# - A deployed SageMaker endpoint with Deepgram model
|
||||||
|
stt = DeepgramSageMakerSTTService(
|
||||||
|
endpoint_name=os.getenv("SAGEMAKER_ENDPOINT_NAME"),
|
||||||
|
region=os.getenv("AWS_REGION"),
|
||||||
|
)
|
||||||
|
|
||||||
|
tts = DeepgramTTSService(api_key=os.getenv("DEEPGRAM_API_KEY"), voice="aura-2-andromeda-en")
|
||||||
|
|
||||||
|
llm = AWSBedrockLLMService(
|
||||||
|
aws_region=os.getenv("AWS_REGION"),
|
||||||
|
model="us.amazon.nova-pro-v1:0",
|
||||||
|
params=AWSBedrockLLMService.InputParams(temperature=0.8),
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. Respond to what the user said in a creative and helpful way.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
context = LLMContext(messages)
|
||||||
|
context_aggregator = LLMContextAggregatorPair(context)
|
||||||
|
|
||||||
|
pipeline = Pipeline(
|
||||||
|
[
|
||||||
|
transport.input(), # Transport user input
|
||||||
|
stt, # STT
|
||||||
|
context_aggregator.user(), # User responses
|
||||||
|
llm, # LLM
|
||||||
|
tts, # TTS
|
||||||
|
transport.output(), # Transport bot output
|
||||||
|
context_aggregator.assistant(), # Assistant spoken responses
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
task = PipelineTask(
|
||||||
|
pipeline,
|
||||||
|
params=PipelineParams(
|
||||||
|
enable_metrics=True,
|
||||||
|
enable_usage_metrics=True,
|
||||||
|
),
|
||||||
|
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
|
||||||
|
)
|
||||||
|
|
||||||
|
@transport.event_handler("on_client_connected")
|
||||||
|
async def on_client_connected(transport, client):
|
||||||
|
logger.info(f"Client connected")
|
||||||
|
# Kick off the conversation.
|
||||||
|
messages.append({"role": "system", "content": "Please introduce yourself to the user."})
|
||||||
|
await task.queue_frames([LLMRunFrame()])
|
||||||
|
|
||||||
|
@transport.event_handler("on_client_disconnected")
|
||||||
|
async def on_client_disconnected(transport, client):
|
||||||
|
logger.info(f"Client disconnected")
|
||||||
|
await task.cancel()
|
||||||
|
|
||||||
|
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
|
||||||
|
|
||||||
|
await runner.run(task)
|
||||||
|
|
||||||
|
|
||||||
|
async def bot(runner_args: RunnerArguments):
|
||||||
|
"""Main bot entry point compatible with Pipecat Cloud."""
|
||||||
|
transport = await create_transport(runner_args, transport_params)
|
||||||
|
await run_bot(transport, runner_args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from pipecat.runner.run import main
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -22,9 +22,9 @@ from pipecat.processors.aggregators.llm_context import LLMContext
|
|||||||
from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair
|
from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair
|
||||||
from pipecat.runner.types import RunnerArguments
|
from pipecat.runner.types import RunnerArguments
|
||||||
from pipecat.runner.utils import create_transport
|
from pipecat.runner.utils import create_transport
|
||||||
from pipecat.services.nim.llm import NimLLMService
|
from pipecat.services.nvidia.llm import NvidiaLLMService
|
||||||
from pipecat.services.riva.stt import RivaSTTService
|
from pipecat.services.nvidia.stt import NvidiaSTTService
|
||||||
from pipecat.services.riva.tts import RivaTTSService
|
from pipecat.services.nvidia.tts import NvidiaTTSService
|
||||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||||
from pipecat.transports.daily.transport import DailyParams
|
from pipecat.transports.daily.transport import DailyParams
|
||||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||||
@@ -59,11 +59,13 @@ transport_params = {
|
|||||||
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||||
logger.info(f"Starting bot")
|
logger.info(f"Starting bot")
|
||||||
|
|
||||||
stt = RivaSTTService(api_key=os.getenv("NVIDIA_API_KEY"))
|
stt = NvidiaSTTService(api_key=os.getenv("NVIDIA_API_KEY"))
|
||||||
|
|
||||||
llm = NimLLMService(api_key=os.getenv("NVIDIA_API_KEY"), model="meta/llama-3.1-405b-instruct")
|
llm = NvidiaLLMService(
|
||||||
|
api_key=os.getenv("NVIDIA_API_KEY"), model="meta/llama-3.1-405b-instruct"
|
||||||
|
)
|
||||||
|
|
||||||
tts = RivaTTSService(api_key=os.getenv("NVIDIA_API_KEY"))
|
tts = NvidiaTTSService(api_key=os.getenv("NVIDIA_API_KEY"))
|
||||||
|
|
||||||
messages = [
|
messages = [
|
||||||
{
|
{
|
||||||
@@ -76,7 +76,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
|||||||
|
|
||||||
llm = FireworksLLMService(
|
llm = FireworksLLMService(
|
||||||
api_key=os.getenv("FIREWORKS_API_KEY"),
|
api_key=os.getenv("FIREWORKS_API_KEY"),
|
||||||
model="accounts/fireworks/models/llama-v3p1-405b-instruct",
|
model="accounts/fireworks/models/gpt-oss-20b",
|
||||||
)
|
)
|
||||||
# You can also register a function_name of None to get all functions
|
# You can also register a function_name of None to get all functions
|
||||||
# sent to the same callback with an additional function_name parameter.
|
# sent to the same callback with an additional function_name parameter.
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from pipecat.runner.utils import create_transport
|
|||||||
from pipecat.services.cartesia.tts import CartesiaTTSService
|
from pipecat.services.cartesia.tts import CartesiaTTSService
|
||||||
from pipecat.services.deepgram.stt import DeepgramSTTService
|
from pipecat.services.deepgram.stt import DeepgramSTTService
|
||||||
from pipecat.services.llm_service import FunctionCallParams
|
from pipecat.services.llm_service import FunctionCallParams
|
||||||
from pipecat.services.nim.llm import NimLLMService
|
from pipecat.services.nvidia.llm import NvidiaLLMService
|
||||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||||
from pipecat.transports.daily.transport import DailyParams
|
from pipecat.transports.daily.transport import DailyParams
|
||||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||||
@@ -75,11 +75,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
|||||||
# text_filters=[MarkdownTextFilter()],
|
# text_filters=[MarkdownTextFilter()],
|
||||||
)
|
)
|
||||||
|
|
||||||
llm = NimLLMService(
|
llm = NvidiaLLMService(
|
||||||
api_key=os.getenv("NVIDIA_API_KEY"),
|
api_key=os.getenv("NVIDIA_API_KEY"),
|
||||||
model="nvidia/llama-3.3-nemotron-super-49b-v1.5",
|
model="nvidia/llama-3.3-nemotron-super-49b-v1.5",
|
||||||
# Recommended when turning thinking off
|
# Recommended when turning thinking off
|
||||||
params=NimLLMService.InputParams(temperature=0.0),
|
params=NvidiaLLMService.InputParams(temperature=0.0),
|
||||||
)
|
)
|
||||||
# You can also register a function_name of None to get all functions
|
# You can also register a function_name of None to get all functions
|
||||||
# sent to the same callback with an additional function_name parameter.
|
# sent to the same callback with an additional function_name parameter.
|
||||||
@@ -14,20 +14,13 @@ from loguru import logger
|
|||||||
|
|
||||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||||
from pipecat.adapters.services.open_ai_realtime_adapter import OpenAIRealtimeLLMAdapter
|
|
||||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import LLMRunFrame, LLMSetToolsFrame, TranscriptionMessage
|
||||||
LLMRunFrame,
|
|
||||||
LLMSetToolsFrame,
|
|
||||||
LLMUpdateSettingsFrame,
|
|
||||||
TranscriptionMessage,
|
|
||||||
)
|
|
||||||
from pipecat.observers.loggers.transcription_log_observer import TranscriptionLogObserver
|
from pipecat.observers.loggers.transcription_log_observer import TranscriptionLogObserver
|
||||||
from pipecat.pipeline.pipeline import Pipeline
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
from pipecat.pipeline.runner import PipelineRunner
|
from pipecat.pipeline.runner import PipelineRunner
|
||||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||||
from pipecat.processors.aggregators.llm_response import LLMAssistantAggregatorParams
|
|
||||||
from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair
|
from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair
|
||||||
from pipecat.processors.transcript_processor import TranscriptProcessor
|
from pipecat.processors.transcript_processor import TranscriptProcessor
|
||||||
from pipecat.runner.types import RunnerArguments
|
from pipecat.runner.types import RunnerArguments
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from pipecat.pipeline.pipeline import Pipeline
|
|||||||
from pipecat.pipeline.runner import PipelineRunner
|
from pipecat.pipeline.runner import PipelineRunner
|
||||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||||
from pipecat.processors.aggregators.llm_response import LLMAssistantAggregatorParams
|
|
||||||
from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair
|
from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair
|
||||||
from pipecat.runner.types import RunnerArguments
|
from pipecat.runner.types import RunnerArguments
|
||||||
from pipecat.runner.utils import create_transport
|
from pipecat.runner.utils import create_transport
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ from pipecat.services.cartesia.tts import CartesiaTTSService
|
|||||||
from pipecat.services.deepgram.stt import DeepgramSTTService
|
from pipecat.services.deepgram.stt import DeepgramSTTService
|
||||||
from pipecat.services.llm_service import LLMService
|
from pipecat.services.llm_service import LLMService
|
||||||
from pipecat.services.openai.llm import OpenAIContextAggregatorPair, OpenAILLMService
|
from pipecat.services.openai.llm import OpenAIContextAggregatorPair, OpenAILLMService
|
||||||
from pipecat.sync.event_notifier import EventNotifier
|
|
||||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||||
from pipecat.transports.daily.transport import DailyParams
|
from pipecat.transports.daily.transport import DailyParams
|
||||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||||
|
from pipecat.utils.sync.event_notifier import EventNotifier
|
||||||
|
|
||||||
load_dotenv(override=True)
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ from pipecat.services.cartesia.tts import CartesiaTTSService
|
|||||||
from pipecat.services.deepgram.stt import DeepgramSTTService
|
from pipecat.services.deepgram.stt import DeepgramSTTService
|
||||||
from pipecat.services.llm_service import FunctionCallParams, LLMService
|
from pipecat.services.llm_service import FunctionCallParams, LLMService
|
||||||
from pipecat.services.openai.llm import OpenAILLMService
|
from pipecat.services.openai.llm import OpenAILLMService
|
||||||
from pipecat.sync.base_notifier import BaseNotifier
|
|
||||||
from pipecat.sync.event_notifier import EventNotifier
|
|
||||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||||
from pipecat.transports.daily.transport import DailyParams
|
from pipecat.transports.daily.transport import DailyParams
|
||||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||||
|
from pipecat.utils.sync.base_notifier import BaseNotifier
|
||||||
|
from pipecat.utils.sync.event_notifier import EventNotifier
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
|
|
||||||
load_dotenv(override=True)
|
load_dotenv(override=True)
|
||||||
|
|||||||
@@ -46,11 +46,11 @@ from pipecat.services.cartesia.tts import CartesiaTTSService
|
|||||||
from pipecat.services.deepgram.stt import DeepgramSTTService
|
from pipecat.services.deepgram.stt import DeepgramSTTService
|
||||||
from pipecat.services.llm_service import FunctionCallParams, LLMService
|
from pipecat.services.llm_service import FunctionCallParams, LLMService
|
||||||
from pipecat.services.openai.llm import OpenAILLMService
|
from pipecat.services.openai.llm import OpenAILLMService
|
||||||
from pipecat.sync.base_notifier import BaseNotifier
|
|
||||||
from pipecat.sync.event_notifier import EventNotifier
|
|
||||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||||
from pipecat.transports.daily.transport import DailyParams
|
from pipecat.transports.daily.transport import DailyParams
|
||||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||||
|
from pipecat.utils.sync.base_notifier import BaseNotifier
|
||||||
|
from pipecat.utils.sync.event_notifier import EventNotifier
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
|
|
||||||
load_dotenv(override=True)
|
load_dotenv(override=True)
|
||||||
|
|||||||
@@ -47,11 +47,11 @@ from pipecat.runner.utils import create_transport
|
|||||||
from pipecat.services.cartesia.tts import CartesiaTTSService
|
from pipecat.services.cartesia.tts import CartesiaTTSService
|
||||||
from pipecat.services.google.llm import GoogleLLMService
|
from pipecat.services.google.llm import GoogleLLMService
|
||||||
from pipecat.services.llm_service import LLMService
|
from pipecat.services.llm_service import LLMService
|
||||||
from pipecat.sync.base_notifier import BaseNotifier
|
|
||||||
from pipecat.sync.event_notifier import EventNotifier
|
|
||||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||||
from pipecat.transports.daily.transport import DailyParams
|
from pipecat.transports.daily.transport import DailyParams
|
||||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||||
|
from pipecat.utils.sync.base_notifier import BaseNotifier
|
||||||
|
from pipecat.utils.sync.event_notifier import EventNotifier
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
|
|
||||||
load_dotenv(override=True)
|
load_dotenv(override=True)
|
||||||
@@ -391,7 +391,7 @@ class AudioAccumulator(FrameProcessor):
|
|||||||
)
|
)
|
||||||
self._user_speaking = False
|
self._user_speaking = False
|
||||||
context = LLMContext()
|
context = LLMContext()
|
||||||
context.add_audio_frames_message(audio_frames=self._audio_frames)
|
await context.add_audio_frames_message(audio_frames=self._audio_frames)
|
||||||
await self.push_frame(LLMContextFrame(context=context))
|
await self.push_frame(LLMContextFrame(context=context))
|
||||||
elif isinstance(frame, InputAudioRawFrame):
|
elif isinstance(frame, InputAudioRawFrame):
|
||||||
# Append the audio frame to our buffer. Treat the buffer as a ring buffer, dropping the oldest
|
# Append the audio frame to our buffer. Treat the buffer as a ring buffer, dropping the oldest
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ from pipecat.services.openai.llm import OpenAILLMService
|
|||||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||||
from pipecat.transports.daily.transport import DailyParams
|
from pipecat.transports.daily.transport import DailyParams
|
||||||
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
|
||||||
from pipecat.utils.text.pattern_pair_aggregator import PatternMatch, PatternPairAggregator
|
from pipecat.utils.text.pattern_pair_aggregator import (
|
||||||
|
MatchAction,
|
||||||
|
PatternMatch,
|
||||||
|
PatternPairAggregator,
|
||||||
|
)
|
||||||
|
|
||||||
load_dotenv(override=True)
|
load_dotenv(override=True)
|
||||||
|
|
||||||
@@ -106,16 +110,16 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
|||||||
pattern_aggregator = PatternPairAggregator()
|
pattern_aggregator = PatternPairAggregator()
|
||||||
|
|
||||||
# Add pattern for voice switching
|
# Add pattern for voice switching
|
||||||
pattern_aggregator.add_pattern_pair(
|
pattern_aggregator.add_pattern(
|
||||||
pattern_id="voice_tag",
|
type="voice",
|
||||||
start_pattern="<voice>",
|
start_pattern="<voice>",
|
||||||
end_pattern="</voice>",
|
end_pattern="</voice>",
|
||||||
remove_match=True,
|
action=MatchAction.REMOVE, # Remove tags from final text
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register handler for voice switching
|
# Register handler for voice switching
|
||||||
async def on_voice_tag(match: PatternMatch):
|
async def on_voice_tag(match: PatternMatch):
|
||||||
voice_name = match.content.strip().lower()
|
voice_name = match.text.strip().lower()
|
||||||
if voice_name in VOICE_IDS:
|
if voice_name in VOICE_IDS:
|
||||||
# First flush any existing audio to finish the current context
|
# First flush any existing audio to finish the current context
|
||||||
await tts.flush_audio()
|
await tts.flush_audio()
|
||||||
@@ -125,7 +129,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
|||||||
else:
|
else:
|
||||||
logger.warning(f"Unknown voice: {voice_name}")
|
logger.warning(f"Unknown voice: {voice_name}")
|
||||||
|
|
||||||
pattern_aggregator.on_pattern_match("voice_tag", on_voice_tag)
|
pattern_aggregator.on_pattern_match("voice", on_voice_tag)
|
||||||
|
|
||||||
stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY"))
|
stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY"))
|
||||||
|
|
||||||
|
|||||||
@@ -64,11 +64,14 @@ class UrlToImageProcessor(FrameProcessor):
|
|||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
def extract_url(self, text: str):
|
def extract_url(self, text: str):
|
||||||
|
try:
|
||||||
data = json.loads(text)
|
data = json.loads(text)
|
||||||
if "artObject" in data:
|
if "artObject" in data:
|
||||||
return data["artObject"]["webImage"]["url"]
|
return data["artObject"]["webImage"]["url"]
|
||||||
if "artworks" in data and len(data["artworks"]):
|
if "artworks" in data and len(data["artworks"]):
|
||||||
return data["artworks"][0]["webImage"]["url"]
|
return data["artworks"][0]["webImage"]["url"]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -88,6 +91,23 @@ class UrlToImageProcessor(FrameProcessor):
|
|||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
|
||||||
|
|
||||||
|
# full list of tools available from rijksmuseum MCP:
|
||||||
|
# - get_artwork_details
|
||||||
|
# - get_artwork_image
|
||||||
|
# - get_user_sets
|
||||||
|
# - get_user_set_details
|
||||||
|
# - open_image_in_browser
|
||||||
|
# - get_artist_timeline
|
||||||
|
|
||||||
|
mcp_tools_filter = ["get_artwork_details", "get_artwork_image", "open_image_in_browser"]
|
||||||
|
|
||||||
|
|
||||||
|
def open_image_output_filter(output: str):
|
||||||
|
pattern = r"Successfully opened image in browser: "
|
||||||
|
text_to_print = re.sub(pattern, "", output)
|
||||||
|
print(f"🖼️ link to high resolution artwork: {text_to_print}")
|
||||||
|
|
||||||
|
|
||||||
# We store functions so objects (e.g. SileroVADAnalyzer) don't get
|
# We store functions so objects (e.g. SileroVADAnalyzer) don't get
|
||||||
# instantiated. The function will be called when the desired transport gets
|
# instantiated. The function will be called when the desired transport gets
|
||||||
# selected.
|
# selected.
|
||||||
@@ -136,7 +156,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
|||||||
# https://github.com/r-huijts/rijksmuseum-mcp
|
# https://github.com/r-huijts/rijksmuseum-mcp
|
||||||
args=["-y", "mcp-server-rijksmuseum"],
|
args=["-y", "mcp-server-rijksmuseum"],
|
||||||
env={"RIJKSMUSEUM_API_KEY": os.getenv("RIJKSMUSEUM_API_KEY")},
|
env={"RIJKSMUSEUM_API_KEY": os.getenv("RIJKSMUSEUM_API_KEY")},
|
||||||
)
|
),
|
||||||
|
# Optional
|
||||||
|
tools_filter=mcp_tools_filter, # Optional
|
||||||
|
tools_output_filters={"open_image_in_browser": open_image_output_filter},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"error setting up mcp")
|
logger.error(f"error setting up mcp")
|
||||||
@@ -155,7 +178,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
|||||||
You are a helpful LLM in a WebRTC call.
|
You are a helpful LLM in a WebRTC call.
|
||||||
Your goal is to demonstrate your capabilities in a succinct way.
|
Your goal is to demonstrate your capabilities in a succinct way.
|
||||||
You have access to tools to search the Rijksmuseum collection.
|
You have access to tools to search the Rijksmuseum collection.
|
||||||
Offer, for example, to show the earliest Rembrandt work from the museum. Use the `search_artwork` tool.
|
Offer, for example, to show a floral still life, use the `search_artwork` tool.
|
||||||
The tool may respond with a JSON object with an `artworks` array. Choose the art from that array.
|
The tool may respond with a JSON object with an `artworks` array. Choose the art from that array.
|
||||||
Once the tool has responded, tell the user the title and use the `open_image_in_browser` tool.
|
Once the tool has responded, tell the user the title and use the `open_image_in_browser` tool.
|
||||||
Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points.
|
Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@@ -15,7 +16,7 @@ import aiohttp
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from mcp import StdioServerParameters
|
from mcp import StdioServerParameters
|
||||||
from mcp.client.session_group import SseServerParameters
|
from mcp.client.session_group import StreamableHttpParameters
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||||
@@ -66,11 +67,14 @@ class UrlToImageProcessor(FrameProcessor):
|
|||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
def extract_url(self, text: str):
|
def extract_url(self, text: str):
|
||||||
pattern = r"!\[[^\]]*\]\((https?://[^)]+\.(png|jpg|jpeg|PNG|JPG|JPEG|gif))\)"
|
try:
|
||||||
match = re.search(pattern, text)
|
data = json.loads(text)
|
||||||
if match:
|
if "artObject" in data:
|
||||||
return match.group(1)
|
return data["artObject"]["webImage"]["url"]
|
||||||
return None
|
if "artworks" in data and len(data["artworks"]):
|
||||||
|
return data["artworks"][0]["webImage"]["url"]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
async def run_image_process(self, image_url: str):
|
async def run_image_process(self, image_url: str):
|
||||||
try:
|
try:
|
||||||
@@ -132,10 +136,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
|||||||
system = f"""
|
system = f"""
|
||||||
You are a helpful LLM in a WebRTC call.
|
You are a helpful LLM in a WebRTC call.
|
||||||
Your goal is to demonstrate your capabilities in a succinct way.
|
Your goal is to demonstrate your capabilities in a succinct way.
|
||||||
You have access to tools to search the Rijksmuseum collection.
|
You have access to tools to search the Rijksmuseum collection and the user's GitHub repositories and account.
|
||||||
Offer, for example, to show the earliest Rembrandt work from the museum. Use the `search_artwork` tool.
|
Offer, for example, to show a floral still life, use the `search_artwork` tool.
|
||||||
The tool may respond with a JSON object with an `artworks` array. Choose the art from that array.
|
The tool may respond with a JSON object with an `artworks` array. Choose the art from that array.
|
||||||
Once the tool has responded, tell the user the title and use the `open_image_in_browser` tool.
|
Once the tool has responded, tell the user the title and use the `open_image_in_browser` tool.
|
||||||
|
You can also offer to answer users questions about their GitHub repositories and account.
|
||||||
Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points.
|
Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points.
|
||||||
Respond to what the user said in a creative and helpful way.
|
Respond to what the user said in a creative and helpful way.
|
||||||
Don't overexplain what you are doing.
|
Don't overexplain what you are doing.
|
||||||
@@ -145,11 +150,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
|||||||
messages = [{"role": "system", "content": system}]
|
messages = [{"role": "system", "content": system}]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mcp = MCPClient(
|
rijksmuseum_mcp = MCPClient(
|
||||||
server_params=StdioServerParameters(
|
server_params=StdioServerParameters(
|
||||||
command=shutil.which("npx"),
|
command=shutil.which("npx"),
|
||||||
# https://github.com/r-huijts/rijksmuseum-mcp
|
# https://github.com/r-huijts/rijksmuseum-mcp
|
||||||
args=["-y", "mcp-server-error setting up mcp"],
|
args=["-y", "mcp-server-rijksmuseum"],
|
||||||
env={"RIJKSMUSEUM_API_KEY": os.getenv("RIJKSMUSEUM_API_KEY")},
|
env={"RIJKSMUSEUM_API_KEY": os.getenv("RIJKSMUSEUM_API_KEY")},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -157,24 +162,32 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
|||||||
logger.error(f"error setting up rijksmuseum mcp")
|
logger.error(f"error setting up rijksmuseum mcp")
|
||||||
logger.exception("error trace:")
|
logger.exception("error trace:")
|
||||||
try:
|
try:
|
||||||
# https://docs.mcp.run/integrating/tutorials/mcp-run-sse-openai-agents/
|
# Github MCP docs: https://github.com/github/github-mcp-server
|
||||||
# ie. "https://www.mcp.run/api/mcp/sse?..."
|
# Enable Github Copilot on your GitHub account. Free tier is ok. (https://github.com/settings/copilot)
|
||||||
# ensure the profile has a tool or few installed
|
# Generate a personal access token. It must be a Fine-grained token, classic tokens are not supported. (https://github.com/settings/personal-access-tokens)
|
||||||
mcp_run = MCPClient(server_params=SseServerParameters(url=os.getenv("MCP_RUN_SSE_URL")))
|
# Set permissions you want to use (eg. "all repositories", "profile: read/write", etc)
|
||||||
|
github_mcp = MCPClient(
|
||||||
|
server_params=StreamableHttpParameters(
|
||||||
|
url="https://api.githubcopilot.com/mcp/",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {os.getenv('GITHUB_PERSONAL_ACCESS_TOKEN')}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"error setting up mcp.run")
|
logger.error(f"error setting up mcp.run")
|
||||||
logger.exception("error trace:")
|
logger.exception("error trace:")
|
||||||
|
|
||||||
tools = {}
|
rijksmuseum_tools = {}
|
||||||
run_tools = {}
|
github_tools = {}
|
||||||
try:
|
try:
|
||||||
tools = await mcp.register_tools(llm)
|
rijksmuseum_tools = await rijksmuseum_mcp.register_tools(llm)
|
||||||
run_tools = await mcp_run.register_tools(llm)
|
github_tools = await github_mcp.register_tools(llm)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"error registering tools")
|
logger.error(f"error registering tools")
|
||||||
logger.exception("error trace:")
|
logger.exception("error trace:")
|
||||||
|
|
||||||
all_standard_tools = run_tools.standard_tools + tools.standard_tools
|
all_standard_tools = rijksmuseum_tools.standard_tools + github_tools.standard_tools
|
||||||
all_tools = ToolsSchema(standard_tools=all_standard_tools)
|
all_tools = ToolsSchema(standard_tools=all_standard_tools)
|
||||||
|
|
||||||
context = LLMContext(messages, all_tools)
|
context = LLMContext(messages, all_tools)
|
||||||
@@ -226,9 +239,9 @@ async def bot(runner_args: RunnerArguments):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if not os.getenv("RIJKSMUSEUM_API_KEY") or not os.getenv("MCP_RUN_SSE_URL"):
|
if not os.getenv("RIJKSMUSEUM_API_KEY") or not os.getenv("GITHUB_PERSONAL_ACCESS_TOKEN"):
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Please set RIJKSMUSEUM_API_KEY and MCP_RUN_SSE_URL environment variables. See https://github.com/r-huijts/rijksmuseum-mcp and https://mcp.run"
|
f"Please set `RIJKSMUSEUM_API_KEY` and `GITHUB_PERSONAL_ACCESS_TOKEN` environment variables. See https://github.com/r-huijts/rijksmuseum-mcp."
|
||||||
)
|
)
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -45,61 +45,63 @@ Source = "https://github.com/pipecat-ai/pipecat"
|
|||||||
Website = "https://pipecat.ai"
|
Website = "https://pipecat.ai"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
aic = [ "aic-sdk~=1.1.0" ]
|
aic = [ "aic-sdk~=1.2.0" ]
|
||||||
anthropic = [ "anthropic~=0.49.0" ]
|
anthropic = [ "anthropic~=0.49.0" ]
|
||||||
assemblyai = [ "pipecat-ai[websockets-base]" ]
|
assemblyai = [ "pipecat-ai[websockets-base]" ]
|
||||||
asyncai = [ "pipecat-ai[websockets-base]" ]
|
asyncai = [ "pipecat-ai[websockets-base]" ]
|
||||||
aws = [ "aioboto3~=15.0.0", "pipecat-ai[websockets-base]" ]
|
aws = [ "aioboto3~=15.5.0", "pipecat-ai[websockets-base]" ]
|
||||||
aws-nova-sonic = [ "aws_sdk_bedrock_runtime~=0.1.1; python_version>='3.12'" ]
|
aws-nova-sonic = [ "aws_sdk_bedrock_runtime~=0.2.0; python_version>='3.12'" ]
|
||||||
azure = [ "azure-cognitiveservices-speech~=1.42.0"]
|
azure = [ "azure-cognitiveservices-speech~=1.42.0"]
|
||||||
cartesia = [ "cartesia~=2.0.3", "pipecat-ai[websockets-base]" ]
|
cartesia = [ "cartesia~=2.0.3", "pipecat-ai[websockets-base]" ]
|
||||||
cerebras = []
|
cerebras = []
|
||||||
|
daily = [ "daily-python~=0.22.0" ]
|
||||||
|
deepgram = [ "deepgram-sdk~=4.7.0", "pipecat-ai[websockets-base]" ]
|
||||||
deepseek = []
|
deepseek = []
|
||||||
daily = [ "daily-python~=0.21.0" ]
|
|
||||||
deepgram = [ "deepgram-sdk~=4.7.0" ]
|
|
||||||
elevenlabs = [ "pipecat-ai[websockets-base]" ]
|
elevenlabs = [ "pipecat-ai[websockets-base]" ]
|
||||||
fal = [ "fal-client~=0.5.9" ]
|
fal = [ "fal-client~=0.5.9" ]
|
||||||
fireworks = []
|
fireworks = []
|
||||||
fish = [ "ormsgpack~=1.7.0", "pipecat-ai[websockets-base]" ]
|
fish = [ "ormsgpack~=1.7.0", "pipecat-ai[websockets-base]" ]
|
||||||
gladia = [ "pipecat-ai[websockets-base]" ]
|
gladia = [ "pipecat-ai[websockets-base]" ]
|
||||||
google = [ "google-cloud-speech>=2.33.0,<3", "google-cloud-texttospeech>=2.31.0,<3", "google-genai>=1.41.0,<2", "pipecat-ai[websockets-base]" ]
|
google = [ "google-cloud-speech>=2.33.0,<3", "google-cloud-texttospeech>=2.31.0,<3", "google-genai>=1.41.0,<2", "pipecat-ai[websockets-base]" ]
|
||||||
|
gradium = [ "pipecat-ai[websockets-base]" ]
|
||||||
grok = []
|
grok = []
|
||||||
groq = [ "groq~=0.23.0" ]
|
groq = [ "groq~=0.23.0" ]
|
||||||
gstreamer = [ "pygobject~=3.50.0" ]
|
gstreamer = [ "pygobject~=3.50.0" ]
|
||||||
heygen = [ "livekit>=1.0.13", "pipecat-ai[websockets-base]" ]
|
heygen = [ "livekit>=1.0.13", "pipecat-ai[websockets-base]" ]
|
||||||
hume = [ "hume>=0.11.2" ]
|
hume = [ "hume>=0.11.2" ]
|
||||||
inworld = []
|
inworld = []
|
||||||
krisp = [ "pipecat-ai-krisp~=0.4.0" ]
|
|
||||||
koala = [ "pvkoala~=2.0.3" ]
|
koala = [ "pvkoala~=2.0.3" ]
|
||||||
|
krisp = [ "pipecat-ai-krisp~=0.4.0" ]
|
||||||
langchain = [ "langchain~=0.3.20", "langchain-community~=0.3.20", "langchain-openai~=0.3.9" ]
|
langchain = [ "langchain~=0.3.20", "langchain-community~=0.3.20", "langchain-openai~=0.3.9" ]
|
||||||
livekit = [ "livekit~=1.0.13", "livekit-api~=1.0.5", "tenacity>=8.2.3,<10.0.0" ]
|
livekit = [ "livekit~=1.0.13", "livekit-api~=1.0.5", "tenacity>=8.2.3,<10.0.0", "pyjwt>=2.10.1" ]
|
||||||
lmnt = [ "pipecat-ai[websockets-base]" ]
|
lmnt = [ "pipecat-ai[websockets-base]" ]
|
||||||
local = [ "pyaudio~=0.2.14" ]
|
local = [ "pyaudio~=0.2.14" ]
|
||||||
|
local-smart-turn = [ "coremltools>=8.0", "transformers", "torch>=2.5.0,<3", "torchaudio>=2.5.0,<3" ]
|
||||||
|
local-smart-turn-v3 = [ "transformers", "onnxruntime>=1.20.1,<2" ]
|
||||||
mcp = [ "mcp[cli]>=1.11.0,<2" ]
|
mcp = [ "mcp[cli]>=1.11.0,<2" ]
|
||||||
mem0 = [ "mem0ai~=0.1.94" ]
|
mem0 = [ "mem0ai~=0.1.94" ]
|
||||||
mistral = []
|
mistral = []
|
||||||
mlx-whisper = [ "mlx-whisper~=0.4.2" ]
|
mlx-whisper = [ "mlx-whisper~=0.4.2" ]
|
||||||
moondream = [ "accelerate~=1.10.0", "einops~=0.8.0", "pyvips[binary]~=3.0.0", "timm~=1.0.13", "transformers>=4.48.0" ]
|
moondream = [ "accelerate~=1.10.0", "einops~=0.8.0", "pyvips[binary]~=3.0.0", "timm~=1.0.13", "transformers>=4.48.0" ]
|
||||||
nim = []
|
|
||||||
neuphonic = [ "pipecat-ai[websockets-base]" ]
|
neuphonic = [ "pipecat-ai[websockets-base]" ]
|
||||||
noisereduce = [ "noisereduce~=3.0.3" ]
|
noisereduce = [ "noisereduce~=3.0.3" ]
|
||||||
|
nvidia = [ "nvidia-riva-client~=2.21.1" ]
|
||||||
openai = [ "pipecat-ai[websockets-base]" ]
|
openai = [ "pipecat-ai[websockets-base]" ]
|
||||||
openpipe = [ "openpipe>=4.50.0,<6" ]
|
openpipe = [ "openpipe>=4.50.0,<6" ]
|
||||||
openrouter = []
|
openrouter = []
|
||||||
perplexity = []
|
perplexity = []
|
||||||
playht = [ "pipecat-ai[websockets-base]" ]
|
playht = [ "pipecat-ai[websockets-base]" ]
|
||||||
qwen = []
|
qwen = []
|
||||||
|
remote-smart-turn = []
|
||||||
rime = [ "pipecat-ai[websockets-base]" ]
|
rime = [ "pipecat-ai[websockets-base]" ]
|
||||||
riva = [ "nvidia-riva-client~=2.21.1" ]
|
riva = [ "pipecat-ai[nvidia]" ]
|
||||||
runner = [ "python-dotenv>=1.0.0,<2.0.0", "uvicorn>=0.32.0,<1.0.0", "fastapi>=0.115.6,<0.122.0", "pipecat-ai-small-webrtc-prebuilt>=1.0.0"]
|
runner = [ "python-dotenv>=1.0.0,<2.0.0", "uvicorn>=0.32.0,<1.0.0", "fastapi>=0.115.6,<0.122.0", "pipecat-ai-small-webrtc-prebuilt>=1.0.0"]
|
||||||
|
sagemaker = ["aws_sdk_sagemaker_runtime_http2; python_version>='3.12'"]
|
||||||
sambanova = []
|
sambanova = []
|
||||||
sarvam = [ "sarvamai==0.1.21", "pipecat-ai[websockets-base]" ]
|
sarvam = [ "sarvamai==0.1.21", "pipecat-ai[websockets-base]" ]
|
||||||
sentry = [ "sentry-sdk>=2.28.0,<3" ]
|
sentry = [ "sentry-sdk>=2.28.0,<3" ]
|
||||||
local-smart-turn = [ "coremltools>=8.0", "transformers", "torch>=2.5.0,<3", "torchaudio>=2.5.0,<3" ]
|
|
||||||
local-smart-turn-v3 = [ "transformers", "onnxruntime>=1.20.1,<2" ]
|
|
||||||
remote-smart-turn = []
|
|
||||||
silero = [ "onnxruntime>=1.20.1,<2" ]
|
silero = [ "onnxruntime>=1.20.1,<2" ]
|
||||||
simli = [ "simli-ai~=0.1.25"]
|
simli = [ "simli-ai~=1.0.3"]
|
||||||
soniox = [ "pipecat-ai[websockets-base]" ]
|
soniox = [ "pipecat-ai[websockets-base]" ]
|
||||||
soundfile = [ "soundfile~=0.13.1" ]
|
soundfile = [ "soundfile~=0.13.1" ]
|
||||||
speechmatics = [ "speechmatics-rt>=0.5.0" ]
|
speechmatics = [ "speechmatics-rt>=0.5.0" ]
|
||||||
@@ -128,6 +130,7 @@ dev = [
|
|||||||
"setuptools~=78.1.1",
|
"setuptools~=78.1.1",
|
||||||
"setuptools_scm~=8.3.1",
|
"setuptools_scm~=8.3.1",
|
||||||
"python-dotenv>=1.0.1,<2.0.0",
|
"python-dotenv>=1.0.1,<2.0.0",
|
||||||
|
"towncrier~=25.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
docs = [
|
docs = [
|
||||||
@@ -158,7 +161,7 @@ where = ["src"]
|
|||||||
"src/pipecat/audio/dtmf/dtmf-star.wav",
|
"src/pipecat/audio/dtmf/dtmf-star.wav",
|
||||||
]
|
]
|
||||||
"pipecat.services.aws_nova_sonic" = ["src/pipecat/services/aws_nova_sonic/ready.wav"]
|
"pipecat.services.aws_nova_sonic" = ["src/pipecat/services/aws_nova_sonic/ready.wav"]
|
||||||
"pipecat.audio.turn.smart_turn.data" = ["src/pipecat/audio/turn/smart_turn/data/smart-turn-v3.0.onnx"]
|
"pipecat.audio.turn.smart_turn.data" = ["src/pipecat/audio/turn/smart_turn/data/smart-turn-v3.1-cpu.onnx"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "--verbose"
|
addopts = "--verbose"
|
||||||
@@ -205,3 +208,44 @@ convention = "google"
|
|||||||
command_line = "--module pytest"
|
command_line = "--module pytest"
|
||||||
source = ["src"]
|
source = ["src"]
|
||||||
omit = ["*/tests/*"]
|
omit = ["*/tests/*"]
|
||||||
|
|
||||||
|
[tool.towncrier]
|
||||||
|
package = "pipecat"
|
||||||
|
package_dir = "src"
|
||||||
|
filename = "CHANGELOG.md"
|
||||||
|
directory = "changelog"
|
||||||
|
start_string = "<!-- towncrier release notes start -->\n"
|
||||||
|
template = "changelog/_template.md.j2"
|
||||||
|
title_format = "## [{version}] - {project_date}"
|
||||||
|
underlines = ["", "", ""]
|
||||||
|
wrap = true
|
||||||
|
|
||||||
|
[[tool.towncrier.type]]
|
||||||
|
directory = "added"
|
||||||
|
name = "Added"
|
||||||
|
showcontent = true
|
||||||
|
|
||||||
|
[[tool.towncrier.type]]
|
||||||
|
directory = "changed"
|
||||||
|
name = "Changed"
|
||||||
|
showcontent = true
|
||||||
|
|
||||||
|
[[tool.towncrier.type]]
|
||||||
|
directory = "deprecated"
|
||||||
|
name = "Deprecated"
|
||||||
|
showcontent = true
|
||||||
|
|
||||||
|
[[tool.towncrier.type]]
|
||||||
|
directory = "removed"
|
||||||
|
name = "Removed"
|
||||||
|
showcontent = true
|
||||||
|
|
||||||
|
[[tool.towncrier.type]]
|
||||||
|
directory = "fixed"
|
||||||
|
name = "Fixed"
|
||||||
|
showcontent = true
|
||||||
|
|
||||||
|
[[tool.towncrier.type]]
|
||||||
|
directory = "security"
|
||||||
|
name = "Security"
|
||||||
|
showcontent = true
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ EVAL_SIMPLE_MATH = EvalConfig(
|
|||||||
)
|
)
|
||||||
|
|
||||||
EVAL_WEATHER = EvalConfig(
|
EVAL_WEATHER = EvalConfig(
|
||||||
prompt="What's the weather in San Francisco?",
|
prompt="What's the weather in San Francisco (in farhenheit or celsius)?",
|
||||||
eval="The user says something specific about the current weather in San Francisco, including the degrees.",
|
eval="The user says something specific about the current weather in San Francisco, including the degrees (in farhenheit or celsius).",
|
||||||
)
|
)
|
||||||
|
|
||||||
EVAL_ONLINE_SEARCH = EvalConfig(
|
EVAL_ONLINE_SEARCH = EvalConfig(
|
||||||
@@ -70,7 +70,7 @@ EVAL_VOICEMAIL = EvalConfig(
|
|||||||
|
|
||||||
EVAL_CONVERSATION = EvalConfig(
|
EVAL_CONVERSATION = EvalConfig(
|
||||||
prompt="Hello, this is Mark.",
|
prompt="Hello, this is Mark.",
|
||||||
eval="The user replies with a greeting.",
|
eval="The user acknowledges the greeting.",
|
||||||
eval_speaks_first=True,
|
eval_speaks_first=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ TESTS_07 = [
|
|||||||
("07o-interruptible-assemblyai.py", EVAL_SIMPLE_MATH),
|
("07o-interruptible-assemblyai.py", EVAL_SIMPLE_MATH),
|
||||||
("07q-interruptible-rime.py", EVAL_SIMPLE_MATH),
|
("07q-interruptible-rime.py", EVAL_SIMPLE_MATH),
|
||||||
("07q-interruptible-rime-http.py", EVAL_SIMPLE_MATH),
|
("07q-interruptible-rime-http.py", EVAL_SIMPLE_MATH),
|
||||||
("07r-interruptible-riva-nim.py", EVAL_SIMPLE_MATH),
|
("07r-interruptible-nvidia.py", EVAL_SIMPLE_MATH),
|
||||||
("07s-interruptible-google-audio-in.py", EVAL_SIMPLE_MATH),
|
("07s-interruptible-google-audio-in.py", EVAL_SIMPLE_MATH),
|
||||||
("07t-interruptible-fish.py", EVAL_SIMPLE_MATH),
|
("07t-interruptible-fish.py", EVAL_SIMPLE_MATH),
|
||||||
("07v-interruptible-neuphonic.py", EVAL_SIMPLE_MATH),
|
("07v-interruptible-neuphonic.py", EVAL_SIMPLE_MATH),
|
||||||
@@ -136,7 +136,7 @@ TESTS_14 = [
|
|||||||
("14g-function-calling-grok.py", EVAL_WEATHER),
|
("14g-function-calling-grok.py", EVAL_WEATHER),
|
||||||
("14h-function-calling-azure.py", EVAL_WEATHER),
|
("14h-function-calling-azure.py", EVAL_WEATHER),
|
||||||
("14i-function-calling-fireworks.py", EVAL_WEATHER),
|
("14i-function-calling-fireworks.py", EVAL_WEATHER),
|
||||||
("14j-function-calling-nim.py", EVAL_WEATHER),
|
("14j-function-calling-nvidia.py", EVAL_WEATHER),
|
||||||
("14k-function-calling-cerebras.py", EVAL_WEATHER),
|
("14k-function-calling-cerebras.py", EVAL_WEATHER),
|
||||||
("14m-function-calling-openrouter.py", EVAL_WEATHER),
|
("14m-function-calling-openrouter.py", EVAL_WEATHER),
|
||||||
("14n-function-calling-perplexity.py", EVAL_WEATHER),
|
("14n-function-calling-perplexity.py", EVAL_WEATHER),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class AICFilter(BaseAudioFilter):
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
license_key: str = "",
|
license_key: str = "",
|
||||||
model_type: AICModelType = AICModelType.QUAIL_L,
|
model_type: AICModelType = AICModelType.QUAIL_STT,
|
||||||
enhancement_level: Optional[float] = 1.0,
|
enhancement_level: Optional[float] = 1.0,
|
||||||
voice_gain: Optional[float] = 1.0,
|
voice_gain: Optional[float] = 1.0,
|
||||||
noise_gate_enable: Optional[bool] = True,
|
noise_gate_enable: Optional[bool] = True,
|
||||||
@@ -52,12 +52,27 @@ class AICFilter(BaseAudioFilter):
|
|||||||
enhancement_level: Optional overall enhancement strength (0.0..1.0).
|
enhancement_level: Optional overall enhancement strength (0.0..1.0).
|
||||||
voice_gain: Optional linear gain applied to detected speech (0.0..4.0).
|
voice_gain: Optional linear gain applied to detected speech (0.0..4.0).
|
||||||
noise_gate_enable: Optional enable/disable noise gate (default: True).
|
noise_gate_enable: Optional enable/disable noise gate (default: True).
|
||||||
|
|
||||||
|
.. deprecated:: 1.3.0
|
||||||
|
The `noise_gate_enable` parameter is deprecated and no longer has any effect.
|
||||||
|
It will be removed in a future version.
|
||||||
"""
|
"""
|
||||||
self._license_key = license_key
|
self._license_key = license_key
|
||||||
self._model_type = model_type
|
self._model_type = model_type
|
||||||
|
|
||||||
self._enhancement_level = enhancement_level
|
self._enhancement_level = enhancement_level
|
||||||
self._voice_gain = voice_gain
|
self._voice_gain = voice_gain
|
||||||
|
if noise_gate_enable is not None:
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
warnings.warn(
|
||||||
|
"Parameter `noise_gate_enable` is deprecated and no longer has any effect. "
|
||||||
|
"It will be removed in a future version. Use AIC VAD instead (create_vad_analyzer()).",
|
||||||
|
DeprecationWarning,
|
||||||
|
)
|
||||||
|
|
||||||
self._noise_gate_enable = noise_gate_enable
|
self._noise_gate_enable = noise_gate_enable
|
||||||
|
|
||||||
self._enabled = True
|
self._enabled = True
|
||||||
@@ -149,10 +164,6 @@ class AICFilter(BaseAudioFilter):
|
|||||||
)
|
)
|
||||||
if self._voice_gain is not None:
|
if self._voice_gain is not None:
|
||||||
self._aic.set_parameter(AICParameter.VOICE_GAIN, float(self._voice_gain))
|
self._aic.set_parameter(AICParameter.VOICE_GAIN, float(self._voice_gain))
|
||||||
if self._noise_gate_enable is not None:
|
|
||||||
self._aic.set_parameter(
|
|
||||||
AICParameter.NOISE_GATE_ENABLE, 1.0 if bool(self._noise_gate_enable) else 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
self._aic_ready = True
|
self._aic_ready = True
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ from pipecat.metrics.metrics import MetricsData, SmartTurnMetricsData
|
|||||||
STOP_SECS = 3
|
STOP_SECS = 3
|
||||||
PRE_SPEECH_MS = 0
|
PRE_SPEECH_MS = 0
|
||||||
MAX_DURATION_SECONDS = 8 # Max allowed segment duration
|
MAX_DURATION_SECONDS = 8 # Max allowed segment duration
|
||||||
USE_ONLY_LAST_VAD_SEGMENT = True
|
|
||||||
|
|
||||||
|
|
||||||
class SmartTurnParams(BaseTurnParams):
|
class SmartTurnParams(BaseTurnParams):
|
||||||
@@ -43,8 +42,6 @@ class SmartTurnParams(BaseTurnParams):
|
|||||||
stop_secs: float = STOP_SECS
|
stop_secs: float = STOP_SECS
|
||||||
pre_speech_ms: float = PRE_SPEECH_MS
|
pre_speech_ms: float = PRE_SPEECH_MS
|
||||||
max_duration_secs: float = MAX_DURATION_SECONDS
|
max_duration_secs: float = MAX_DURATION_SECONDS
|
||||||
# not exposing this for now yet until the model can handle it.
|
|
||||||
# use_only_last_vad_segment: bool = USE_ONLY_LAST_VAD_SEGMENT
|
|
||||||
|
|
||||||
|
|
||||||
class SmartTurnTimeoutException(Exception):
|
class SmartTurnTimeoutException(Exception):
|
||||||
@@ -160,7 +157,7 @@ class BaseSmartTurn(BaseTurnAnalyzer):
|
|||||||
state, result = await loop.run_in_executor(
|
state, result = await loop.run_in_executor(
|
||||||
self._executor, self._process_speech_segment, self._audio_buffer
|
self._executor, self._process_speech_segment, self._audio_buffer
|
||||||
)
|
)
|
||||||
if state == EndOfTurnState.COMPLETE or USE_ONLY_LAST_VAD_SEGMENT:
|
if state == EndOfTurnState.COMPLETE:
|
||||||
self._clear(state)
|
self._clear(state)
|
||||||
logger.debug(f"End of Turn result: {state}")
|
logger.debug(f"End of Turn result: {state}")
|
||||||
return state, result
|
return state, result
|
||||||
|
|||||||
Binary file not shown.
@@ -42,17 +42,15 @@ class LocalSmartTurnAnalyzerV3(BaseSmartTurn):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
smart_turn_model_path: Path to the ONNX model file. If this is not
|
smart_turn_model_path: Path to the ONNX model file. If this is not
|
||||||
set, the bundled smart-turn-v3.0 model will be used.
|
set, the bundled smart-turn-v3.1-cpu model will be used.
|
||||||
cpu_count: The number of CPUs to use for inference. Defaults to 1.
|
cpu_count: The number of CPUs to use for inference. Defaults to 1.
|
||||||
**kwargs: Additional arguments passed to BaseSmartTurn.
|
**kwargs: Additional arguments passed to BaseSmartTurn.
|
||||||
"""
|
"""
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
logger.debug("Loading Local Smart Turn v3 model...")
|
|
||||||
|
|
||||||
if not smart_turn_model_path:
|
if not smart_turn_model_path:
|
||||||
# Load bundled model
|
# Load bundled model
|
||||||
model_name = "smart-turn-v3.0.onnx"
|
model_name = "smart-turn-v3.1-cpu.onnx"
|
||||||
package_path = "pipecat.audio.turn.smart_turn.data"
|
package_path = "pipecat.audio.turn.smart_turn.data"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -70,6 +68,8 @@ class LocalSmartTurnAnalyzerV3(BaseSmartTurn):
|
|||||||
impresources.files(package_path).joinpath(model_name)
|
impresources.files(package_path).joinpath(model_name)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.debug(f"Loading Local Smart Turn v3.x model from {smart_turn_model_path}...")
|
||||||
|
|
||||||
so = ort.SessionOptions()
|
so = ort.SessionOptions()
|
||||||
so.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
|
so.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
|
||||||
so.inter_op_num_threads = 1
|
so.inter_op_num_threads = 1
|
||||||
@@ -79,7 +79,7 @@ class LocalSmartTurnAnalyzerV3(BaseSmartTurn):
|
|||||||
self._feature_extractor = WhisperFeatureExtractor(chunk_length=8)
|
self._feature_extractor = WhisperFeatureExtractor(chunk_length=8)
|
||||||
self._session = ort.InferenceSession(smart_turn_model_path, sess_options=so)
|
self._session = ort.InferenceSession(smart_turn_model_path, sess_options=so)
|
||||||
|
|
||||||
logger.debug("Loaded Local Smart Turn v3")
|
logger.debug("Loaded Local Smart Turn v3.x")
|
||||||
|
|
||||||
def _predict_endpoint(self, audio_array: np.ndarray) -> Dict[str, Any]:
|
def _predict_endpoint(self, audio_array: np.ndarray) -> Dict[str, Any]:
|
||||||
"""Predict end-of-turn using local ONNX model."""
|
"""Predict end-of-turn using local ONNX model."""
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ from loguru import logger
|
|||||||
from pipecat.audio.dtmf.types import KeypadEntry
|
from pipecat.audio.dtmf.types import KeypadEntry
|
||||||
from pipecat.audio.vad.vad_analyzer import VADParams
|
from pipecat.audio.vad.vad_analyzer import VADParams
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
|
EndFrame,
|
||||||
Frame,
|
Frame,
|
||||||
LLMContextFrame,
|
LLMContextFrame,
|
||||||
|
LLMFullResponseEndFrame,
|
||||||
LLMMessagesUpdateFrame,
|
LLMMessagesUpdateFrame,
|
||||||
LLMTextFrame,
|
LLMTextFrame,
|
||||||
OutputDTMFUrgentFrame,
|
OutputDTMFUrgentFrame,
|
||||||
@@ -31,7 +33,11 @@ from pipecat.pipeline.pipeline import Pipeline
|
|||||||
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContextFrame
|
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContextFrame
|
||||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
from pipecat.services.llm_service import LLMService
|
from pipecat.services.llm_service import LLMService
|
||||||
from pipecat.utils.text.pattern_pair_aggregator import PatternMatch, PatternPairAggregator
|
from pipecat.utils.text.pattern_pair_aggregator import (
|
||||||
|
MatchAction,
|
||||||
|
PatternMatch,
|
||||||
|
PatternPairAggregator,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class IVRStatus(Enum):
|
class IVRStatus(Enum):
|
||||||
@@ -114,15 +120,15 @@ class IVRProcessor(FrameProcessor):
|
|||||||
def _setup_xml_patterns(self):
|
def _setup_xml_patterns(self):
|
||||||
"""Set up XML pattern detection and handlers."""
|
"""Set up XML pattern detection and handlers."""
|
||||||
# Register DTMF pattern
|
# Register DTMF pattern
|
||||||
self._aggregator.add_pattern_pair("dtmf", "<dtmf>", "</dtmf>", remove_match=True)
|
self._aggregator.add_pattern("dtmf", "<dtmf>", "</dtmf>", action=MatchAction.REMOVE)
|
||||||
self._aggregator.on_pattern_match("dtmf", self._handle_dtmf_action)
|
self._aggregator.on_pattern_match("dtmf", self._handle_dtmf_action)
|
||||||
|
|
||||||
# Register mode pattern
|
# Register mode pattern
|
||||||
self._aggregator.add_pattern_pair("mode", "<mode>", "</mode>", remove_match=True)
|
self._aggregator.add_pattern("mode", "<mode>", "</mode>", action=MatchAction.REMOVE)
|
||||||
self._aggregator.on_pattern_match("mode", self._handle_mode_action)
|
self._aggregator.on_pattern_match("mode", self._handle_mode_action)
|
||||||
|
|
||||||
# Register IVR pattern
|
# Register IVR pattern
|
||||||
self._aggregator.add_pattern_pair("ivr", "<ivr>", "</ivr>", remove_match=True)
|
self._aggregator.add_pattern("ivr", "<ivr>", "</ivr>", action=MatchAction.REMOVE)
|
||||||
self._aggregator.on_pattern_match("ivr", self._handle_ivr_action)
|
self._aggregator.on_pattern_match("ivr", self._handle_ivr_action)
|
||||||
|
|
||||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
@@ -145,10 +151,17 @@ class IVRProcessor(FrameProcessor):
|
|||||||
|
|
||||||
elif isinstance(frame, LLMTextFrame):
|
elif isinstance(frame, LLMTextFrame):
|
||||||
# Process text through the pattern aggregator
|
# Process text through the pattern aggregator
|
||||||
result = await self._aggregator.aggregate(frame.text)
|
async for result in self._aggregator.aggregate(frame.text):
|
||||||
if result:
|
|
||||||
# Push aggregated text that doesn't contain XML patterns
|
# Push aggregated text that doesn't contain XML patterns
|
||||||
await self.push_frame(LLMTextFrame(result), direction)
|
await self.push_frame(LLMTextFrame(result.text), direction)
|
||||||
|
|
||||||
|
elif isinstance(frame, (LLMFullResponseEndFrame, EndFrame)):
|
||||||
|
# Flush any remaining text from the aggregator
|
||||||
|
remaining = await self._aggregator.flush()
|
||||||
|
if remaining:
|
||||||
|
await self.push_frame(LLMTextFrame(remaining.text), direction)
|
||||||
|
# Push the end frame
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
@@ -159,7 +172,7 @@ class IVRProcessor(FrameProcessor):
|
|||||||
Args:
|
Args:
|
||||||
match: The pattern match containing DTMF content.
|
match: The pattern match containing DTMF content.
|
||||||
"""
|
"""
|
||||||
value = match.content
|
value = match.text
|
||||||
logger.debug(f"DTMF detected: {value}")
|
logger.debug(f"DTMF detected: {value}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -180,7 +193,7 @@ class IVRProcessor(FrameProcessor):
|
|||||||
Args:
|
Args:
|
||||||
match: The pattern match containing IVR status content.
|
match: The pattern match containing IVR status content.
|
||||||
"""
|
"""
|
||||||
status = match.content
|
status = match.text
|
||||||
logger.trace(f"IVR status detected: {status}")
|
logger.trace(f"IVR status detected: {status}")
|
||||||
|
|
||||||
# Convert string to enum, with validation
|
# Convert string to enum, with validation
|
||||||
@@ -211,7 +224,7 @@ class IVRProcessor(FrameProcessor):
|
|||||||
Args:
|
Args:
|
||||||
match: The pattern match containing mode content.
|
match: The pattern match containing mode content.
|
||||||
"""
|
"""
|
||||||
mode = match.content
|
mode = match.text
|
||||||
logger.debug(f"Mode detected: {mode}")
|
logger.debug(f"Mode detected: {mode}")
|
||||||
if mode == "conversation":
|
if mode == "conversation":
|
||||||
await self._handle_conversation()
|
await self._handle_conversation()
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ from pipecat.processors.aggregators.llm_context import LLMContext
|
|||||||
from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair
|
from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair
|
||||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, FrameProcessorSetup
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, FrameProcessorSetup
|
||||||
from pipecat.services.llm_service import LLMService
|
from pipecat.services.llm_service import LLMService
|
||||||
from pipecat.sync.base_notifier import BaseNotifier
|
from pipecat.utils.sync.base_notifier import BaseNotifier
|
||||||
from pipecat.sync.event_notifier import EventNotifier
|
from pipecat.utils.sync.event_notifier import EventNotifier
|
||||||
|
|
||||||
|
|
||||||
class NotifierGate(FrameProcessor):
|
class NotifierGate(FrameProcessor):
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ and LLM processing.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
Any,
|
Any,
|
||||||
@@ -185,6 +186,20 @@ class ControlFrame(Frame):
|
|||||||
#
|
#
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UninterruptibleFrame:
|
||||||
|
"""A marker for data or control frames that must not be interrupted.
|
||||||
|
|
||||||
|
Frames with this mixin are still ordered normally, but unlike other frames,
|
||||||
|
they are preserved during interruptions: they remain in internal queues and
|
||||||
|
any task processing them will not be cancelled. This ensures the frame is
|
||||||
|
always delivered and processed to completion.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AudioRawFrame:
|
class AudioRawFrame:
|
||||||
"""A frame containing a chunk of raw audio.
|
"""A frame containing a chunk of raw audio.
|
||||||
@@ -329,7 +344,7 @@ class TextFrame(DataFrame):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
text: str
|
text: str
|
||||||
skip_tts: bool = field(init=False)
|
skip_tts: Optional[bool] = field(init=False)
|
||||||
# Whether any necessary inter-frame (leading/trailing) spaces are already
|
# Whether any necessary inter-frame (leading/trailing) spaces are already
|
||||||
# included in the text.
|
# included in the text.
|
||||||
# NOTE: Ideally this would be available at init time with a default value,
|
# NOTE: Ideally this would be available at init time with a default value,
|
||||||
@@ -337,11 +352,14 @@ class TextFrame(DataFrame):
|
|||||||
# mandatory fields of theirs to have defaults to preserve
|
# mandatory fields of theirs to have defaults to preserve
|
||||||
# non-default-before-default argument order)
|
# non-default-before-default argument order)
|
||||||
includes_inter_frame_spaces: bool = field(init=False)
|
includes_inter_frame_spaces: bool = field(init=False)
|
||||||
|
# Whether this text frame should be appended to the LLM context.
|
||||||
|
append_to_context: bool = field(init=False)
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
super().__post_init__()
|
super().__post_init__()
|
||||||
self.skip_tts = False
|
self.skip_tts = None
|
||||||
self.includes_inter_frame_spaces = False
|
self.includes_inter_frame_spaces = False
|
||||||
|
self.append_to_context = True
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
pts = format_pts(self.pts)
|
pts = format_pts(self.pts)
|
||||||
@@ -358,8 +376,32 @@ class LLMTextFrame(TextFrame):
|
|||||||
self.includes_inter_frame_spaces = True
|
self.includes_inter_frame_spaces = True
|
||||||
|
|
||||||
|
|
||||||
|
class AggregationType(str, Enum):
|
||||||
|
"""Built-in aggregation strings."""
|
||||||
|
|
||||||
|
SENTENCE = "sentence"
|
||||||
|
WORD = "word"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TTSTextFrame(TextFrame):
|
class AggregatedTextFrame(TextFrame):
|
||||||
|
"""Text frame representing an aggregation of TextFrames.
|
||||||
|
|
||||||
|
This frame contains multiple TextFrames aggregated together for processing
|
||||||
|
or output along with a field to indicate how they are aggregated.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
aggregated_by: Method used to aggregate the text frames.
|
||||||
|
"""
|
||||||
|
|
||||||
|
aggregated_by: AggregationType | str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TTSTextFrame(AggregatedTextFrame):
|
||||||
"""Text frame generated by Text-to-Speech services."""
|
"""Text frame generated by Text-to-Speech services."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
@@ -668,6 +710,44 @@ class LLMConfigureOutputFrame(DataFrame):
|
|||||||
skip_tts: bool
|
skip_tts: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FunctionCallResultProperties:
|
||||||
|
"""Properties for configuring function call result behavior.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
run_llm: Whether to run the LLM after receiving this result.
|
||||||
|
on_context_updated: Callback to execute when context is updated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
run_llm: Optional[bool] = None
|
||||||
|
on_context_updated: Optional[Callable[[], Awaitable[None]]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FunctionCallResultFrame(DataFrame, UninterruptibleFrame):
|
||||||
|
"""Frame containing the result of an LLM function call.
|
||||||
|
|
||||||
|
This is an uninterruptible frame because once a result is generated we
|
||||||
|
always want to update the context.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
function_name: Name of the function that was executed.
|
||||||
|
tool_call_id: Unique identifier for the function call.
|
||||||
|
arguments: Arguments that were passed to the function.
|
||||||
|
result: The result returned by the function.
|
||||||
|
run_llm: Whether to run the LLM after this result.
|
||||||
|
properties: Additional properties for result handling.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
function_name: str
|
||||||
|
tool_call_id: str
|
||||||
|
arguments: Any
|
||||||
|
result: Any
|
||||||
|
run_llm: Optional[bool] = None
|
||||||
|
properties: Optional[FunctionCallResultProperties] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TTSSpeakFrame(DataFrame):
|
class TTSSpeakFrame(DataFrame):
|
||||||
"""Frame containing text that should be spoken by TTS.
|
"""Frame containing text that should be spoken by TTS.
|
||||||
@@ -807,11 +887,13 @@ class ErrorFrame(SystemFrame):
|
|||||||
error: Description of the error that occurred.
|
error: Description of the error that occurred.
|
||||||
fatal: Whether the error is fatal and requires bot shutdown.
|
fatal: Whether the error is fatal and requires bot shutdown.
|
||||||
processor: The frame processor that generated the error.
|
processor: The frame processor that generated the error.
|
||||||
|
exception: The exception that occurred.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
error: str
|
error: str
|
||||||
fatal: bool = False
|
fatal: bool = False
|
||||||
processor: Optional["FrameProcessor"] = None
|
processor: Optional["FrameProcessor"] = None
|
||||||
|
exception: Optional[Exception] = None
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name}(error: {self.error}, fatal: {self.fatal})"
|
return f"{self.name}(error: {self.error}, fatal: {self.fatal})"
|
||||||
@@ -1059,23 +1141,6 @@ class FunctionCallsStartedFrame(SystemFrame):
|
|||||||
function_calls: Sequence[FunctionCallFromLLM]
|
function_calls: Sequence[FunctionCallFromLLM]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FunctionCallInProgressFrame(SystemFrame):
|
|
||||||
"""Frame signaling that a function call is currently executing.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
function_name: Name of the function being executed.
|
|
||||||
tool_call_id: Unique identifier for this function call.
|
|
||||||
arguments: Arguments passed to the function.
|
|
||||||
cancel_on_interruption: Whether to cancel this call if interrupted.
|
|
||||||
"""
|
|
||||||
|
|
||||||
function_name: str
|
|
||||||
tool_call_id: str
|
|
||||||
arguments: Any
|
|
||||||
cancel_on_interruption: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FunctionCallCancelFrame(SystemFrame):
|
class FunctionCallCancelFrame(SystemFrame):
|
||||||
"""Frame signaling that a function call has been cancelled.
|
"""Frame signaling that a function call has been cancelled.
|
||||||
@@ -1089,40 +1154,6 @@ class FunctionCallCancelFrame(SystemFrame):
|
|||||||
tool_call_id: str
|
tool_call_id: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FunctionCallResultProperties:
|
|
||||||
"""Properties for configuring function call result behavior.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
run_llm: Whether to run the LLM after receiving this result.
|
|
||||||
on_context_updated: Callback to execute when context is updated.
|
|
||||||
"""
|
|
||||||
|
|
||||||
run_llm: Optional[bool] = None
|
|
||||||
on_context_updated: Optional[Callable[[], Awaitable[None]]] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FunctionCallResultFrame(SystemFrame):
|
|
||||||
"""Frame containing the result of an LLM function call.
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
function_name: Name of the function that was executed.
|
|
||||||
tool_call_id: Unique identifier for the function call.
|
|
||||||
arguments: Arguments that were passed to the function.
|
|
||||||
result: The result returned by the function.
|
|
||||||
run_llm: Whether to run the LLM after this result.
|
|
||||||
properties: Additional properties for result handling.
|
|
||||||
"""
|
|
||||||
|
|
||||||
function_name: str
|
|
||||||
tool_call_id: str
|
|
||||||
arguments: Any
|
|
||||||
result: Any
|
|
||||||
run_llm: Optional[bool] = None
|
|
||||||
properties: Optional[FunctionCallResultProperties] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class STTMuteFrame(SystemFrame):
|
class STTMuteFrame(SystemFrame):
|
||||||
"""Frame to mute/unmute the Speech-to-Text service.
|
"""Frame to mute/unmute the Speech-to-Text service.
|
||||||
@@ -1602,22 +1633,43 @@ class LLMFullResponseStartFrame(ControlFrame):
|
|||||||
more TextFrames and a final LLMFullResponseEndFrame.
|
more TextFrames and a final LLMFullResponseEndFrame.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
skip_tts: bool = field(init=False)
|
skip_tts: Optional[bool] = field(init=False)
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
super().__post_init__()
|
super().__post_init__()
|
||||||
self.skip_tts = False
|
self.skip_tts = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LLMFullResponseEndFrame(ControlFrame):
|
class LLMFullResponseEndFrame(ControlFrame):
|
||||||
"""Frame indicating the end of an LLM response."""
|
"""Frame indicating the end of an LLM response."""
|
||||||
|
|
||||||
skip_tts: bool = field(init=False)
|
skip_tts: Optional[bool] = field(init=False)
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
super().__post_init__()
|
super().__post_init__()
|
||||||
self.skip_tts = False
|
self.skip_tts = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FunctionCallInProgressFrame(ControlFrame, UninterruptibleFrame):
|
||||||
|
"""Frame signaling that a function call is currently executing.
|
||||||
|
|
||||||
|
This is an uninterruptible frame because we always want to update the
|
||||||
|
context.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
function_name: Name of the function being executed.
|
||||||
|
tool_call_id: Unique identifier for this function call.
|
||||||
|
arguments: Arguments passed to the function.
|
||||||
|
cancel_on_interruption: Whether to cancel this call if interrupted.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
function_name: str
|
||||||
|
tool_call_id: str
|
||||||
|
arguments: Any
|
||||||
|
cancel_on_interruption: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
from pipecat.frames.frames import CancelFrame, EndFrame, Frame, LLMContextFrame, StartFrame
|
from pipecat.frames.frames import CancelFrame, EndFrame, Frame, LLMContextFrame, StartFrame
|
||||||
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContextFrame
|
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContextFrame
|
||||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
from pipecat.sync.base_notifier import BaseNotifier
|
from pipecat.utils.sync.base_notifier import BaseNotifier
|
||||||
|
|
||||||
|
|
||||||
class GatedLLMContextAggregator(FrameProcessor):
|
class GatedLLMContextAggregator(FrameProcessor):
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ class LLMContext:
|
|||||||
text: Optional text to include with the audio.
|
text: Optional text to include with the audio.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def encode_audio():
|
async def encode_audio():
|
||||||
sample_rate = audio_frames[0].sample_rate
|
sample_rate = audio_frames[0].sample_rate
|
||||||
num_channels = audio_frames[0].num_channels
|
num_channels = audio_frames[0].num_channels
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ class LLMContext:
|
|||||||
encoded_audio = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
encoded_audio = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||||
return encoded_audio
|
return encoded_audio
|
||||||
|
|
||||||
encoded_audio = asyncio.to_thread(encode_audio)
|
encoded_audio = await asyncio.to_thread(encode_audio)
|
||||||
|
|
||||||
content.append(
|
content.append(
|
||||||
{
|
{
|
||||||
@@ -333,7 +333,7 @@ class LLMContext:
|
|||||||
"""
|
"""
|
||||||
self._tool_choice = tool_choice
|
self._tool_choice = tool_choice
|
||||||
|
|
||||||
def add_image_frame_message(
|
async def add_image_frame_message(
|
||||||
self, *, format: str, size: tuple[int, int], image: bytes, text: Optional[str] = None
|
self, *, format: str, size: tuple[int, int], image: bytes, text: Optional[str] = None
|
||||||
):
|
):
|
||||||
"""Add a message containing an image frame.
|
"""Add a message containing an image frame.
|
||||||
@@ -344,10 +344,12 @@ class LLMContext:
|
|||||||
image: Raw image bytes.
|
image: Raw image bytes.
|
||||||
text: Optional text to include with the image.
|
text: Optional text to include with the image.
|
||||||
"""
|
"""
|
||||||
message = LLMContext.create_image_message(format=format, size=size, image=image, text=text)
|
message = await LLMContext.create_image_message(
|
||||||
|
format=format, size=size, image=image, text=text
|
||||||
|
)
|
||||||
self.add_message(message)
|
self.add_message(message)
|
||||||
|
|
||||||
def add_audio_frames_message(
|
async def add_audio_frames_message(
|
||||||
self, *, audio_frames: list[AudioRawFrame], text: str = "Audio follows"
|
self, *, audio_frames: list[AudioRawFrame], text: str = "Audio follows"
|
||||||
):
|
):
|
||||||
"""Add a message containing audio frames.
|
"""Add a message containing audio frames.
|
||||||
@@ -356,7 +358,7 @@ class LLMContext:
|
|||||||
audio_frames: List of audio frame objects to include.
|
audio_frames: List of audio frame objects to include.
|
||||||
text: Optional text to include with the audio.
|
text: Optional text to include with the audio.
|
||||||
"""
|
"""
|
||||||
message = LLMContext.create_audio_message(audio_frames=audio_frames, text=text)
|
message = await LLMContext.create_audio_message(audio_frames=audio_frames, text=text)
|
||||||
self.add_message(message)
|
self.add_message(message)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1001,7 +1001,7 @@ class LLMAssistantContextAggregator(LLMContextResponseAggregator):
|
|||||||
await self.push_aggregation()
|
await self.push_aggregation()
|
||||||
|
|
||||||
async def _handle_text(self, frame: TextFrame):
|
async def _handle_text(self, frame: TextFrame):
|
||||||
if not self._started:
|
if not self._started or not frame.append_to_context:
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._params.expect_stripped_words:
|
if self._params.expect_stripped_words:
|
||||||
|
|||||||
@@ -591,8 +591,6 @@ class LLMAssistantAggregator(LLMContextAggregator):
|
|||||||
self._started = 0
|
self._started = 0
|
||||||
self._function_calls_in_progress: Dict[str, Optional[FunctionCallInProgressFrame]] = {}
|
self._function_calls_in_progress: Dict[str, Optional[FunctionCallInProgressFrame]] = {}
|
||||||
self._context_updated_tasks: Set[asyncio.Task] = set()
|
self._context_updated_tasks: Set[asyncio.Task] = set()
|
||||||
self._function_calls_context_messages = []
|
|
||||||
self._function_calls_pending_context_updates_callbacks = []
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_function_calls_in_progress(self) -> bool:
|
def has_function_calls_in_progress(self) -> bool:
|
||||||
@@ -649,7 +647,9 @@ class LLMAssistantAggregator(LLMContextAggregator):
|
|||||||
|
|
||||||
async def push_aggregation(self):
|
async def push_aggregation(self):
|
||||||
"""Push the current assistant aggregation with timestamp."""
|
"""Push the current assistant aggregation with timestamp."""
|
||||||
if self._aggregation:
|
if not self._aggregation:
|
||||||
|
return
|
||||||
|
|
||||||
aggregation = self.aggregation_string()
|
aggregation = self.aggregation_string()
|
||||||
await self.reset()
|
await self.reset()
|
||||||
|
|
||||||
@@ -663,10 +663,6 @@ class LLMAssistantAggregator(LLMContextAggregator):
|
|||||||
timestamp_frame = LLMContextAssistantTimestampFrame(timestamp=time_now_iso8601())
|
timestamp_frame = LLMContextAssistantTimestampFrame(timestamp=time_now_iso8601())
|
||||||
await self.push_frame(timestamp_frame)
|
await self.push_frame(timestamp_frame)
|
||||||
|
|
||||||
if self._function_calls_context_messages:
|
|
||||||
self._flush_function_call_messages_to_context()
|
|
||||||
await self.push_context_frame(FrameDirection.UPSTREAM)
|
|
||||||
|
|
||||||
async def _handle_llm_run(self, frame: LLMRunFrame):
|
async def _handle_llm_run(self, frame: LLMRunFrame):
|
||||||
await self.push_context_frame(FrameDirection.UPSTREAM)
|
await self.push_context_frame(FrameDirection.UPSTREAM)
|
||||||
|
|
||||||
@@ -685,23 +681,6 @@ class LLMAssistantAggregator(LLMContextAggregator):
|
|||||||
self._started = 0
|
self._started = 0
|
||||||
await self.reset()
|
await self.reset()
|
||||||
|
|
||||||
def _flush_function_call_messages_to_context(self):
|
|
||||||
"""Move all function calls messages into context, then clear the list."""
|
|
||||||
if self._function_calls_context_messages:
|
|
||||||
self._context.add_messages(self._function_calls_context_messages)
|
|
||||||
self._function_calls_context_messages.clear()
|
|
||||||
|
|
||||||
# Call the `on_context_updated` callbacks once the function call results
|
|
||||||
# are added to the context. Run them in separate tasks to make
|
|
||||||
# sure we don't block the pipeline.
|
|
||||||
for callback, task_name in self._function_calls_pending_context_updates_callbacks:
|
|
||||||
task = self.create_task(callback(), task_name)
|
|
||||||
self._context_updated_tasks.add(task)
|
|
||||||
task.add_done_callback(self._context_updated_task_finished)
|
|
||||||
|
|
||||||
# Clear the pending callbacks list
|
|
||||||
self._function_calls_pending_context_updates_callbacks.clear()
|
|
||||||
|
|
||||||
async def _handle_function_calls_started(self, frame: FunctionCallsStartedFrame):
|
async def _handle_function_calls_started(self, frame: FunctionCallsStartedFrame):
|
||||||
function_names = [f"{f.function_name}:{f.tool_call_id}" for f in frame.function_calls]
|
function_names = [f"{f.function_name}:{f.tool_call_id}" for f in frame.function_calls]
|
||||||
logger.debug(f"{self} FunctionCallsStartedFrame: {function_names}")
|
logger.debug(f"{self} FunctionCallsStartedFrame: {function_names}")
|
||||||
@@ -714,7 +693,7 @@ class LLMAssistantAggregator(LLMContextAggregator):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Update context with the in-progress function call
|
# Update context with the in-progress function call
|
||||||
self._function_calls_context_messages.append(
|
self._context.add_message(
|
||||||
{
|
{
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"tool_calls": [
|
"tool_calls": [
|
||||||
@@ -729,7 +708,7 @@ class LLMAssistantAggregator(LLMContextAggregator):
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self._function_calls_context_messages.append(
|
self._context.add_message(
|
||||||
{
|
{
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"content": "IN_PROGRESS",
|
"content": "IN_PROGRESS",
|
||||||
@@ -760,13 +739,6 @@ class LLMAssistantAggregator(LLMContextAggregator):
|
|||||||
else:
|
else:
|
||||||
self._update_function_call_result(frame.function_name, frame.tool_call_id, "COMPLETED")
|
self._update_function_call_result(frame.function_name, frame.tool_call_id, "COMPLETED")
|
||||||
|
|
||||||
# Store the on_context_updated callback along with task name info to be invoked later
|
|
||||||
if properties and properties.on_context_updated:
|
|
||||||
task_name = f"{frame.function_name}:{frame.tool_call_id}:on_context_updated"
|
|
||||||
self._function_calls_pending_context_updates_callbacks.append(
|
|
||||||
(properties.on_context_updated, task_name)
|
|
||||||
)
|
|
||||||
|
|
||||||
run_llm = False
|
run_llm = False
|
||||||
|
|
||||||
# Run inference if the function call result requires it.
|
# Run inference if the function call result requires it.
|
||||||
@@ -781,14 +753,18 @@ class LLMAssistantAggregator(LLMContextAggregator):
|
|||||||
# If this is the last function call in progress, run the LLM.
|
# If this is the last function call in progress, run the LLM.
|
||||||
run_llm = not bool(self._function_calls_in_progress)
|
run_llm = not bool(self._function_calls_in_progress)
|
||||||
|
|
||||||
# Only run if the LLM response has completed (not currently generating),
|
|
||||||
# otherwise defer execution until push_aggregation() is called
|
|
||||||
# (triggered by LLMFullResponseEndFrame or interruption).
|
|
||||||
if not self._started:
|
|
||||||
self._flush_function_call_messages_to_context()
|
|
||||||
if run_llm:
|
if run_llm:
|
||||||
await self.push_context_frame(FrameDirection.UPSTREAM)
|
await self.push_context_frame(FrameDirection.UPSTREAM)
|
||||||
|
|
||||||
|
# Call the `on_context_updated` callback once the function call result
|
||||||
|
# is added to the context. Also, run this in a separate task to make
|
||||||
|
# sure we don't block the pipeline.
|
||||||
|
if properties and properties.on_context_updated:
|
||||||
|
task_name = f"{frame.function_name}:{frame.tool_call_id}:on_context_updated"
|
||||||
|
task = self.create_task(properties.on_context_updated(), task_name)
|
||||||
|
self._context_updated_tasks.add(task)
|
||||||
|
task.add_done_callback(self._context_updated_task_finished)
|
||||||
|
|
||||||
async def _handle_function_call_cancel(self, frame: FunctionCallCancelFrame):
|
async def _handle_function_call_cancel(self, frame: FunctionCallCancelFrame):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{self} FunctionCallCancelFrame: [{frame.function_name}:{frame.tool_call_id}]"
|
f"{self} FunctionCallCancelFrame: [{frame.function_name}:{frame.tool_call_id}]"
|
||||||
@@ -802,12 +778,7 @@ class LLMAssistantAggregator(LLMContextAggregator):
|
|||||||
del self._function_calls_in_progress[frame.tool_call_id]
|
del self._function_calls_in_progress[frame.tool_call_id]
|
||||||
|
|
||||||
def _update_function_call_result(self, function_name: str, tool_call_id: str, result: Any):
|
def _update_function_call_result(self, function_name: str, tool_call_id: str, result: Any):
|
||||||
def iter_all():
|
for message in self._context.get_messages():
|
||||||
yield from self._function_calls_context_messages
|
|
||||||
# In case on long-running function call, the function may already be added to the context
|
|
||||||
yield from self._context.get_messages()
|
|
||||||
|
|
||||||
for message in iter_all():
|
|
||||||
if (
|
if (
|
||||||
not isinstance(message, LLMSpecificMessage)
|
not isinstance(message, LLMSpecificMessage)
|
||||||
and message["role"] == "tool"
|
and message["role"] == "tool"
|
||||||
@@ -822,7 +793,7 @@ class LLMAssistantAggregator(LLMContextAggregator):
|
|||||||
|
|
||||||
logger.debug(f"{self} Appending UserImageRawFrame to LLM context (size: {frame.size})")
|
logger.debug(f"{self} Appending UserImageRawFrame to LLM context (size: {frame.size})")
|
||||||
|
|
||||||
self._context.add_image_frame_message(
|
await self._context.add_image_frame_message(
|
||||||
format=frame.format,
|
format=frame.format,
|
||||||
size=frame.size,
|
size=frame.size,
|
||||||
image=frame.image,
|
image=frame.image,
|
||||||
@@ -840,7 +811,7 @@ class LLMAssistantAggregator(LLMContextAggregator):
|
|||||||
await self.push_aggregation()
|
await self.push_aggregation()
|
||||||
|
|
||||||
async def _handle_text(self, frame: TextFrame):
|
async def _handle_text(self, frame: TextFrame):
|
||||||
if not self._started:
|
if not self._started or not frame.append_to_context:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Make sure we really have text (spaces count, too!)
|
# Make sure we really have text (spaces count, too!)
|
||||||
|
|||||||
103
src/pipecat/processors/aggregators/llm_text_processor.py
Normal file
103
src/pipecat/processors/aggregators/llm_text_processor.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
"""LLM text processor module for processing and aggregating raw LLM output text.
|
||||||
|
|
||||||
|
This processor will convert LLMTextFrames into AggregatedTextFrames based on the
|
||||||
|
configured text aggregator. Using the customizable aggregator, it provides
|
||||||
|
functionality to handle or manipulate LLM text frames before they are sent to other
|
||||||
|
components such as TTS services or context aggregators. It can be used to pre-aggregate
|
||||||
|
and categorize, modify, or filter direct output tokens from the LLM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pipecat.frames.frames import (
|
||||||
|
AggregatedTextFrame,
|
||||||
|
EndFrame,
|
||||||
|
Frame,
|
||||||
|
InterruptionFrame,
|
||||||
|
LLMFullResponseEndFrame,
|
||||||
|
LLMTextFrame,
|
||||||
|
)
|
||||||
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
|
from pipecat.utils.text.base_text_aggregator import BaseTextAggregator
|
||||||
|
from pipecat.utils.text.simple_text_aggregator import SimpleTextAggregator
|
||||||
|
|
||||||
|
|
||||||
|
class LLMTextProcessor(FrameProcessor):
|
||||||
|
"""A processor for handling or manipulating LLM text frames before they are processed further.
|
||||||
|
|
||||||
|
This processor will convert LLMTextFrames into AggregatedTextFrames based on the configured
|
||||||
|
text aggregator. Using the customizable aggregator, it provides functionality to handle or
|
||||||
|
manipulate LLM text frames before they are sent to other components such as TTS services or
|
||||||
|
context aggregators. It can be used to pre-aggregate and categorize, modify, or filter direct
|
||||||
|
output tokens from the LLM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, text_aggregator: Optional[BaseTextAggregator] = None, **kwargs):
|
||||||
|
"""Initialize the LLM text processor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text_aggregator: An optional text aggregator to use for processing LLM text frames. By
|
||||||
|
default, a SimpleTextAggregator aggregating by sentence will be used.
|
||||||
|
**kwargs: Additional arguments passed to parent class.
|
||||||
|
|
||||||
|
TODO: Allow transformations per aggregation type or all (and deprecate the TTS filters).
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._text_aggregator: BaseTextAggregator = text_aggregator or SimpleTextAggregator()
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
"""Process an LLMTextFrames using the aggregator to generate AggregatedTextFrames.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The frame to process.
|
||||||
|
direction: The direction of frame flow in the pipeline.
|
||||||
|
"""
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
|
||||||
|
if isinstance(frame, InterruptionFrame):
|
||||||
|
await self._handle_interruption(frame)
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
elif isinstance(frame, LLMTextFrame):
|
||||||
|
await self._handle_llm_text(frame)
|
||||||
|
elif isinstance(frame, LLMFullResponseEndFrame):
|
||||||
|
await self._handle_llm_end(frame.skip_tts)
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
elif isinstance(frame, EndFrame):
|
||||||
|
await self._handle_llm_end()
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
else:
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
async def _handle_interruption(self, _):
|
||||||
|
"""Handle interruptions by resetting the text aggregator."""
|
||||||
|
await self._text_aggregator.handle_interruption()
|
||||||
|
|
||||||
|
async def reset(self):
|
||||||
|
"""Reset the internal state of the text processor and its aggregator."""
|
||||||
|
await self._text_aggregator.reset()
|
||||||
|
|
||||||
|
async def _handle_llm_text(self, in_frame: LLMTextFrame):
|
||||||
|
async for aggregation in self._text_aggregator.aggregate(in_frame.text):
|
||||||
|
out_frame = AggregatedTextFrame(
|
||||||
|
text=aggregation.text,
|
||||||
|
aggregated_by=aggregation.type,
|
||||||
|
)
|
||||||
|
out_frame.skip_tts = in_frame.skip_tts
|
||||||
|
await self.push_frame(out_frame)
|
||||||
|
|
||||||
|
async def _handle_llm_end(self, skip_tts: Optional[bool] = None):
|
||||||
|
# Flush any remaining text
|
||||||
|
remaining = await self._text_aggregator.flush()
|
||||||
|
if remaining:
|
||||||
|
out_frame = AggregatedTextFrame(
|
||||||
|
text=remaining.text,
|
||||||
|
aggregated_by=remaining.type,
|
||||||
|
)
|
||||||
|
out_frame.skip_tts = skip_tts
|
||||||
|
await self.push_frame(out_frame)
|
||||||
@@ -126,6 +126,4 @@ class WakeCheckFilter(FrameProcessor):
|
|||||||
else:
|
else:
|
||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error in wake word filter: {e}"
|
await self.push_error(error_msg=f"Error in wake word filter: {e}", exception=e)
|
||||||
logger.exception(error_msg)
|
|
||||||
await self.push_error(ErrorFrame(error_msg))
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from typing import Awaitable, Callable, Tuple, Type
|
|||||||
|
|
||||||
from pipecat.frames.frames import Frame
|
from pipecat.frames.frames import Frame
|
||||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
from pipecat.sync.base_notifier import BaseNotifier
|
from pipecat.utils.sync.base_notifier import BaseNotifier
|
||||||
|
|
||||||
|
|
||||||
class WakeNotifierFilter(FrameProcessor):
|
class WakeNotifierFilter(FrameProcessor):
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ management, and frame flow control mechanisms.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import traceback
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Awaitable, Callable, Coroutine, List, Optional, Sequence, Tuple, Type
|
from typing import Any, Awaitable, Callable, Coroutine, List, Optional, Sequence, Tuple, Type
|
||||||
@@ -32,6 +33,7 @@ from pipecat.frames.frames import (
|
|||||||
InterruptionTaskFrame,
|
InterruptionTaskFrame,
|
||||||
StartFrame,
|
StartFrame,
|
||||||
SystemFrame,
|
SystemFrame,
|
||||||
|
UninterruptibleFrame,
|
||||||
)
|
)
|
||||||
from pipecat.metrics.metrics import LLMTokenUsage, MetricsData
|
from pipecat.metrics.metrics import LLMTokenUsage, MetricsData
|
||||||
from pipecat.observers.base_observer import BaseObserver, FrameProcessed, FramePushed
|
from pipecat.observers.base_observer import BaseObserver, FrameProcessed, FramePushed
|
||||||
@@ -142,6 +144,7 @@ class FrameProcessor(BaseObject):
|
|||||||
- on_after_process_frame: Called after a frame is processed
|
- on_after_process_frame: Called after a frame is processed
|
||||||
- on_before_push_frame: Called before a frame is pushed
|
- on_before_push_frame: Called before a frame is pushed
|
||||||
- on_after_push_frame: Called after a frame is pushed
|
- on_after_push_frame: Called after a frame is pushed
|
||||||
|
- on_error: Called when an error is raised in the frame processing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -209,6 +212,7 @@ class FrameProcessor(BaseObject):
|
|||||||
# The input task that handles all types of frames. It processes system
|
# The input task that handles all types of frames. It processes system
|
||||||
# frames right away and queues non-system frames for later processing.
|
# frames right away and queues non-system frames for later processing.
|
||||||
self.__should_block_system_frames = False
|
self.__should_block_system_frames = False
|
||||||
|
self.__input_queue = FrameProcessorQueue()
|
||||||
self.__input_event: Optional[asyncio.Event] = None
|
self.__input_event: Optional[asyncio.Event] = None
|
||||||
self.__input_frame_task: Optional[asyncio.Task] = None
|
self.__input_frame_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
@@ -218,8 +222,10 @@ class FrameProcessor(BaseObject):
|
|||||||
# called. To resume processing frames we need to call
|
# called. To resume processing frames we need to call
|
||||||
# `resume_processing_frames()` which will wake up the event.
|
# `resume_processing_frames()` which will wake up the event.
|
||||||
self.__should_block_frames = False
|
self.__should_block_frames = False
|
||||||
|
self.__process_queue = asyncio.Queue()
|
||||||
self.__process_event: Optional[asyncio.Event] = None
|
self.__process_event: Optional[asyncio.Event] = None
|
||||||
self.__process_frame_task: Optional[asyncio.Task] = None
|
self.__process_frame_task: Optional[asyncio.Task] = None
|
||||||
|
self.__process_current_frame: Optional[Frame] = None
|
||||||
|
|
||||||
# To interrupt a pipeline, we push an `InterruptionTaskFrame` upstream.
|
# To interrupt a pipeline, we push an `InterruptionTaskFrame` upstream.
|
||||||
# Then we wait for the corresponding `InterruptionFrame` to travel from
|
# Then we wait for the corresponding `InterruptionFrame` to travel from
|
||||||
@@ -234,6 +240,7 @@ class FrameProcessor(BaseObject):
|
|||||||
self._register_event_handler("on_after_process_frame", sync=True)
|
self._register_event_handler("on_after_process_frame", sync=True)
|
||||||
self._register_event_handler("on_before_push_frame", sync=True)
|
self._register_event_handler("on_before_push_frame", sync=True)
|
||||||
self._register_event_handler("on_after_push_frame", sync=True)
|
self._register_event_handler("on_after_push_frame", sync=True)
|
||||||
|
self._register_event_handler("on_error", sync=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def id(self) -> int:
|
def id(self) -> int:
|
||||||
@@ -630,7 +637,43 @@ class FrameProcessor(BaseObject):
|
|||||||
elif isinstance(frame, (FrameProcessorResumeFrame, FrameProcessorResumeUrgentFrame)):
|
elif isinstance(frame, (FrameProcessorResumeFrame, FrameProcessorResumeUrgentFrame)):
|
||||||
await self.__resume(frame)
|
await self.__resume(frame)
|
||||||
|
|
||||||
async def push_error(self, error: ErrorFrame):
|
async def push_error(
|
||||||
|
self,
|
||||||
|
error_msg: str,
|
||||||
|
exception: Optional[Exception] = None,
|
||||||
|
fatal: bool = False,
|
||||||
|
):
|
||||||
|
"""Creates and pushes an ErrorFrame upstream.
|
||||||
|
|
||||||
|
Creates and pushes an ErrorFrame upstream to notify other processors in the
|
||||||
|
pipeline about an error condition. The error frame will include context about
|
||||||
|
which processor generated the error.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_msg: Descriptive message explaining the error condition.
|
||||||
|
exception: Optional exception object that caused the error, if available.
|
||||||
|
This provides additional context for debugging and error handling.
|
||||||
|
fatal: Whether this error should be considered fatal to the pipeline.
|
||||||
|
Fatal errors typically cause the entire pipeline to stop processing.
|
||||||
|
Defaults to False for non-fatal errors.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Non-fatal error
|
||||||
|
await self.push_error("Failed to process audio chunk, skipping")
|
||||||
|
|
||||||
|
# Fatal error with exception context
|
||||||
|
try:
|
||||||
|
result = some_critical_operation()
|
||||||
|
except Exception as e:
|
||||||
|
await self.push_error("Critical operation failed", exception=e, fatal=True)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
error_frame = ErrorFrame(error=error_msg, fatal=fatal, exception=exception, processor=self)
|
||||||
|
await self.push_error_frame(error=error_frame)
|
||||||
|
|
||||||
|
async def push_error_frame(self, error: ErrorFrame):
|
||||||
"""Push an error frame upstream.
|
"""Push an error frame upstream.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -638,6 +681,18 @@ class FrameProcessor(BaseObject):
|
|||||||
"""
|
"""
|
||||||
if not error.processor:
|
if not error.processor:
|
||||||
error.processor = self
|
error.processor = self
|
||||||
|
await self._call_event_handler("on_error", error)
|
||||||
|
|
||||||
|
if error.exception:
|
||||||
|
tb = traceback.extract_tb(error.exception.__traceback__)
|
||||||
|
last = tb[-1]
|
||||||
|
error_message = (
|
||||||
|
f"{error.processor} exception ({last.filename}:{last.lineno}): {error.error}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
error_message = f"{error.processor} error: {error.error}"
|
||||||
|
|
||||||
|
logger.error(error_message)
|
||||||
await self.push_frame(error, FrameDirection.UPSTREAM)
|
await self.push_frame(error, FrameDirection.UPSTREAM)
|
||||||
|
|
||||||
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
|
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
|
||||||
@@ -754,13 +809,19 @@ class FrameProcessor(BaseObject):
|
|||||||
# interruption). Instead we just drain the queue because this is
|
# interruption). Instead we just drain the queue because this is
|
||||||
# an interruption.
|
# an interruption.
|
||||||
self.__reset_process_task()
|
self.__reset_process_task()
|
||||||
|
elif isinstance(self.__process_current_frame, UninterruptibleFrame):
|
||||||
|
# We don't want to cancel UninterruptibleFrame, so we simply
|
||||||
|
# cleanup the queue.
|
||||||
|
self.__reset_process_queue()
|
||||||
else:
|
else:
|
||||||
# Cancel and re-create the process task including the queue.
|
# Cancel and re-create the process task.
|
||||||
await self.__cancel_process_task()
|
await self.__cancel_process_task()
|
||||||
self.__create_process_task()
|
self.__create_process_task()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Uncaught exception in {self} when handling _start_interruption: {e}")
|
await self.push_error(
|
||||||
await self.push_error(ErrorFrame(str(e)))
|
error_msg=f"Uncaught exception handling _start_interruption: {e}",
|
||||||
|
exception=e,
|
||||||
|
)
|
||||||
|
|
||||||
async def __internal_push_frame(self, frame: Frame, direction: FrameDirection):
|
async def __internal_push_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
"""Internal method to push frames to adjacent processors.
|
"""Internal method to push frames to adjacent processors.
|
||||||
@@ -797,8 +858,7 @@ class FrameProcessor(BaseObject):
|
|||||||
await self._observer.on_push_frame(data)
|
await self._observer.on_push_frame(data)
|
||||||
await self._prev.queue_frame(frame, direction)
|
await self._prev.queue_frame(frame, direction)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Uncaught exception in {self}: {e}")
|
await self.push_error(error_msg=f"Uncaught exception: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(str(e)))
|
|
||||||
|
|
||||||
def _check_started(self, frame: Frame):
|
def _check_started(self, frame: Frame):
|
||||||
"""Check if the processor has been started.
|
"""Check if the processor has been started.
|
||||||
@@ -820,7 +880,6 @@ class FrameProcessor(BaseObject):
|
|||||||
|
|
||||||
if not self.__input_frame_task:
|
if not self.__input_frame_task:
|
||||||
self.__input_event = asyncio.Event()
|
self.__input_event = asyncio.Event()
|
||||||
self.__input_queue = FrameProcessorQueue()
|
|
||||||
self.__input_frame_task = self.create_task(self.__input_frame_task_handler())
|
self.__input_frame_task = self.create_task(self.__input_frame_task_handler())
|
||||||
|
|
||||||
async def __cancel_input_task(self):
|
async def __cancel_input_task(self):
|
||||||
@@ -838,9 +897,7 @@ class FrameProcessor(BaseObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not self.__process_frame_task:
|
if not self.__process_frame_task:
|
||||||
self.__should_block_frames = False
|
self.__reset_process_task()
|
||||||
self.__process_event = asyncio.Event()
|
|
||||||
self.__process_queue = asyncio.Queue()
|
|
||||||
self.__process_frame_task = self.create_task(self.__process_frame_task_handler())
|
self.__process_frame_task = self.create_task(self.__process_frame_task_handler())
|
||||||
|
|
||||||
def __reset_process_task(self):
|
def __reset_process_task(self):
|
||||||
@@ -850,10 +907,26 @@ class FrameProcessor(BaseObject):
|
|||||||
|
|
||||||
self.__should_block_frames = False
|
self.__should_block_frames = False
|
||||||
self.__process_event = asyncio.Event()
|
self.__process_event = asyncio.Event()
|
||||||
|
self.__reset_process_queue()
|
||||||
|
|
||||||
|
def __reset_process_queue(self):
|
||||||
|
"""Reset non-system frame processing queue."""
|
||||||
|
# Create a new queue to insert UninterruptibleFrame frames.
|
||||||
|
new_queue = asyncio.Queue()
|
||||||
|
|
||||||
|
# Process current queue and keep UninterruptibleFrame frames.
|
||||||
while not self.__process_queue.empty():
|
while not self.__process_queue.empty():
|
||||||
self.__process_queue.get_nowait()
|
item = self.__process_queue.get_nowait()
|
||||||
|
if isinstance(item, UninterruptibleFrame):
|
||||||
|
new_queue.put_nowait(item)
|
||||||
self.__process_queue.task_done()
|
self.__process_queue.task_done()
|
||||||
|
|
||||||
|
# Put back UninterruptibleFrame frames into our process queue.
|
||||||
|
while not new_queue.empty():
|
||||||
|
item = new_queue.get_nowait()
|
||||||
|
self.__process_queue.put_nowait(item)
|
||||||
|
new_queue.task_done()
|
||||||
|
|
||||||
async def __cancel_process_task(self):
|
async def __cancel_process_task(self):
|
||||||
"""Cancel the non-system frame processing task."""
|
"""Cancel the non-system frame processing task."""
|
||||||
if self.__process_frame_task:
|
if self.__process_frame_task:
|
||||||
@@ -874,8 +947,7 @@ class FrameProcessor(BaseObject):
|
|||||||
|
|
||||||
await self._call_event_handler("on_after_process_frame", frame)
|
await self._call_event_handler("on_after_process_frame", frame)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"{self}: error processing frame: {e}")
|
await self.push_error(error_msg=f"Error processing frame: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(str(e)))
|
|
||||||
|
|
||||||
async def __input_frame_task_handler(self):
|
async def __input_frame_task_handler(self):
|
||||||
"""Handle frames from the input queue.
|
"""Handle frames from the input queue.
|
||||||
@@ -908,8 +980,12 @@ class FrameProcessor(BaseObject):
|
|||||||
async def __process_frame_task_handler(self):
|
async def __process_frame_task_handler(self):
|
||||||
"""Handle non-system frames from the process queue."""
|
"""Handle non-system frames from the process queue."""
|
||||||
while True:
|
while True:
|
||||||
|
self.__process_current_frame = None
|
||||||
|
|
||||||
(frame, direction, callback) = await self.__process_queue.get()
|
(frame, direction, callback) = await self.__process_queue.get()
|
||||||
|
|
||||||
|
self.__process_current_frame = frame
|
||||||
|
|
||||||
if self.__should_block_frames and self.__process_event:
|
if self.__should_block_frames and self.__process_event:
|
||||||
logger.trace(f"{self}: frame processing paused")
|
logger.trace(f"{self}: frame processing paused")
|
||||||
await self.__process_event.wait()
|
await self.__process_event.wait()
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ try:
|
|||||||
from langchain_core.messages import AIMessageChunk
|
from langchain_core.messages import AIMessageChunk
|
||||||
from langchain_core.runnables import Runnable
|
from langchain_core.runnables import Runnable
|
||||||
except ModuleNotFoundError as e:
|
except ModuleNotFoundError as e:
|
||||||
logger.exception("In order to use Langchain, you need to `pip install pipecat-ai[langchain]`. ")
|
logger.error("In order to use Langchain, you need to `pip install pipecat-ai[langchain]`. ")
|
||||||
raise Exception(f"Missing module: {e}")
|
raise Exception(f"Missing module: {e}")
|
||||||
|
|
||||||
|
|
||||||
@@ -113,6 +113,6 @@ class LangchainProcessor(FrameProcessor):
|
|||||||
except GeneratorExit:
|
except GeneratorExit:
|
||||||
logger.warning(f"{self} generator was closed prematurely")
|
logger.warning(f"{self} generator was closed prematurely")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"{self} an unknown error occurred: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
finally:
|
finally:
|
||||||
await self.push_frame(LLMFullResponseEndFrame())
|
await self.push_frame(LLMFullResponseEndFrame())
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from typing import (
|
|||||||
Literal,
|
Literal,
|
||||||
Mapping,
|
Mapping,
|
||||||
Optional,
|
Optional,
|
||||||
|
Tuple,
|
||||||
Union,
|
Union,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,6 +33,8 @@ from pydantic import BaseModel, Field, PrivateAttr, ValidationError
|
|||||||
|
|
||||||
from pipecat.audio.utils import calculate_audio_volume
|
from pipecat.audio.utils import calculate_audio_volume
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
|
AggregatedTextFrame,
|
||||||
|
AggregationType,
|
||||||
BotStartedSpeakingFrame,
|
BotStartedSpeakingFrame,
|
||||||
BotStoppedSpeakingFrame,
|
BotStoppedSpeakingFrame,
|
||||||
CancelFrame,
|
CancelFrame,
|
||||||
@@ -704,6 +707,29 @@ class RTVITextMessageData(BaseModel):
|
|||||||
text: str
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class RTVIBotOutputMessageData(RTVITextMessageData):
|
||||||
|
"""Data for bot output RTVI messages.
|
||||||
|
|
||||||
|
Extends RTVITextMessageData to include metadata about the output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
spoken: bool = False # Indicates if the text has been spoken by TTS
|
||||||
|
aggregated_by: AggregationType | str
|
||||||
|
# Indicates what form the text is in (e.g., by word, sentence, etc.)
|
||||||
|
|
||||||
|
|
||||||
|
class RTVIBotOutputMessage(BaseModel):
|
||||||
|
"""Message containing bot output text.
|
||||||
|
|
||||||
|
An event meant to holistically represent what the bot is outputting,
|
||||||
|
along with metadata about the output and if it has been spoken.
|
||||||
|
"""
|
||||||
|
|
||||||
|
label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL
|
||||||
|
type: Literal["bot-output"] = "bot-output"
|
||||||
|
data: RTVIBotOutputMessageData
|
||||||
|
|
||||||
|
|
||||||
class RTVIBotTranscriptionMessage(BaseModel):
|
class RTVIBotTranscriptionMessage(BaseModel):
|
||||||
"""Message containing bot transcription text.
|
"""Message containing bot transcription text.
|
||||||
|
|
||||||
@@ -896,6 +922,7 @@ class RTVIObserverParams:
|
|||||||
Parameter `errors_enabled` is deprecated. Error messages are always enabled.
|
Parameter `errors_enabled` is deprecated. Error messages are always enabled.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
|
bot_output_enabled: Indicates if bot output messages should be sent.
|
||||||
bot_llm_enabled: Indicates if the bot's LLM messages should be sent.
|
bot_llm_enabled: Indicates if the bot's LLM messages should be sent.
|
||||||
bot_tts_enabled: Indicates if the bot's TTS messages should be sent.
|
bot_tts_enabled: Indicates if the bot's TTS messages should be sent.
|
||||||
bot_speaking_enabled: Indicates if the bot's started/stopped speaking messages should be sent.
|
bot_speaking_enabled: Indicates if the bot's started/stopped speaking messages should be sent.
|
||||||
@@ -907,9 +934,17 @@ class RTVIObserverParams:
|
|||||||
metrics_enabled: Indicates if metrics messages should be sent.
|
metrics_enabled: Indicates if metrics messages should be sent.
|
||||||
system_logs_enabled: Indicates if system logs should be sent.
|
system_logs_enabled: Indicates if system logs should be sent.
|
||||||
errors_enabled: [Deprecated] Indicates if errors messages should be sent.
|
errors_enabled: [Deprecated] Indicates if errors messages should be sent.
|
||||||
|
skip_aggregator_types: List of aggregation types to skip sending as tts/output messages.
|
||||||
|
Note: if using this to avoid sending secure information, be sure to also disable
|
||||||
|
bot_llm_enabled to avoid leaking through LLM messages.
|
||||||
|
bot_output_transforms: A list of callables to transform text before just before sending it
|
||||||
|
to TTS. Each callable takes the aggregated text and its type, and returns the
|
||||||
|
transformed text. To register, provide a list of tuples of
|
||||||
|
(aggregation_type | '*', transform_function).
|
||||||
audio_level_period_secs: How often audio levels should be sent if enabled.
|
audio_level_period_secs: How often audio levels should be sent if enabled.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
bot_output_enabled: bool = True
|
||||||
bot_llm_enabled: bool = True
|
bot_llm_enabled: bool = True
|
||||||
bot_tts_enabled: bool = True
|
bot_tts_enabled: bool = True
|
||||||
bot_speaking_enabled: bool = True
|
bot_speaking_enabled: bool = True
|
||||||
@@ -921,6 +956,15 @@ class RTVIObserverParams:
|
|||||||
metrics_enabled: bool = True
|
metrics_enabled: bool = True
|
||||||
system_logs_enabled: bool = False
|
system_logs_enabled: bool = False
|
||||||
errors_enabled: Optional[bool] = None
|
errors_enabled: Optional[bool] = None
|
||||||
|
skip_aggregator_types: Optional[List[AggregationType | str]] = None
|
||||||
|
bot_output_transforms: Optional[
|
||||||
|
List[
|
||||||
|
Tuple[
|
||||||
|
AggregationType | str,
|
||||||
|
Callable[[str, AggregationType | str], Awaitable[str]],
|
||||||
|
]
|
||||||
|
]
|
||||||
|
] = None
|
||||||
audio_level_period_secs: float = 0.15
|
audio_level_period_secs: float = 0.15
|
||||||
|
|
||||||
|
|
||||||
@@ -973,8 +1017,45 @@ class RTVIObserver(BaseObserver):
|
|||||||
DeprecationWarning,
|
DeprecationWarning,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._aggregation_transforms: List[
|
||||||
|
Tuple[AggregationType | str, Callable[[str, AggregationType | str], Awaitable[str]]]
|
||||||
|
] = self._params.bot_output_transforms or []
|
||||||
|
|
||||||
|
def add_bot_output_transformer(
|
||||||
|
self,
|
||||||
|
transform_function: Callable[[str, AggregationType | str], Awaitable[str]],
|
||||||
|
aggregation_type: AggregationType | str = "*",
|
||||||
|
):
|
||||||
|
"""Transform text for a specific aggregation type before sending as Bot Output or TTS.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
transform_function: The function to apply for transformation. This function should take
|
||||||
|
the text and aggregation type as input and return the transformed text.
|
||||||
|
Ex.: async def my_transform(text: str, aggregation_type: str) -> str:
|
||||||
|
aggregation_type: The type of aggregation to transform. This value defaults to "*" to
|
||||||
|
handle all text before sending to the client.
|
||||||
|
"""
|
||||||
|
self._aggregation_transforms.append((aggregation_type, transform_function))
|
||||||
|
|
||||||
|
def remove_bot_output_transformer(
|
||||||
|
self,
|
||||||
|
transform_function: Callable[[str, AggregationType | str], Awaitable[str]],
|
||||||
|
aggregation_type: AggregationType | str = "*",
|
||||||
|
):
|
||||||
|
"""Remove a text transformer for a specific aggregation type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
transform_function: The function to remove.
|
||||||
|
aggregation_type: The type of aggregation to remove the transformer for.
|
||||||
|
"""
|
||||||
|
self._aggregation_transforms = [
|
||||||
|
(agg_type, func)
|
||||||
|
for agg_type, func in self._aggregation_transforms
|
||||||
|
if not (agg_type == aggregation_type and func == transform_function)
|
||||||
|
]
|
||||||
|
|
||||||
async def _logger_sink(self, message):
|
async def _logger_sink(self, message):
|
||||||
"""Logger sink so we cna send system logs to RTVI clients."""
|
"""Logger sink so we can send system logs to RTVI clients."""
|
||||||
message = RTVISystemLogMessage(data=RTVITextMessageData(text=message))
|
message = RTVISystemLogMessage(data=RTVITextMessageData(text=message))
|
||||||
await self.send_rtvi_message(message)
|
await self.send_rtvi_message(message)
|
||||||
|
|
||||||
@@ -1048,12 +1129,15 @@ class RTVIObserver(BaseObserver):
|
|||||||
await self.send_rtvi_message(RTVIBotTTSStartedMessage())
|
await self.send_rtvi_message(RTVIBotTTSStartedMessage())
|
||||||
elif isinstance(frame, TTSStoppedFrame) and self._params.bot_tts_enabled:
|
elif isinstance(frame, TTSStoppedFrame) and self._params.bot_tts_enabled:
|
||||||
await self.send_rtvi_message(RTVIBotTTSStoppedMessage())
|
await self.send_rtvi_message(RTVIBotTTSStoppedMessage())
|
||||||
elif isinstance(frame, TTSTextFrame) and self._params.bot_tts_enabled:
|
elif isinstance(frame, AggregatedTextFrame) and (
|
||||||
if isinstance(src, BaseOutputTransport):
|
self._params.bot_output_enabled or self._params.bot_tts_enabled
|
||||||
message = RTVIBotTTSTextMessage(data=RTVITextMessageData(text=frame.text))
|
):
|
||||||
await self.send_rtvi_message(message)
|
if isinstance(frame, TTSTextFrame) and not isinstance(src, BaseOutputTransport):
|
||||||
else:
|
# This check is to make sure we handle the frame when it has gone
|
||||||
|
# through the transport and has correct timing.
|
||||||
mark_as_seen = False
|
mark_as_seen = False
|
||||||
|
else:
|
||||||
|
await self._handle_aggregated_llm_text(frame)
|
||||||
elif isinstance(frame, MetricsFrame) and self._params.metrics_enabled:
|
elif isinstance(frame, MetricsFrame) and self._params.metrics_enabled:
|
||||||
await self._handle_metrics(frame)
|
await self._handle_metrics(frame)
|
||||||
elif isinstance(frame, RTVIServerMessageFrame):
|
elif isinstance(frame, RTVIServerMessageFrame):
|
||||||
@@ -1084,15 +1168,6 @@ class RTVIObserver(BaseObserver):
|
|||||||
if mark_as_seen:
|
if mark_as_seen:
|
||||||
self._frames_seen.add(frame.id)
|
self._frames_seen.add(frame.id)
|
||||||
|
|
||||||
async def _push_bot_transcription(self):
|
|
||||||
"""Push accumulated bot transcription as a message."""
|
|
||||||
if len(self._bot_transcription) > 0:
|
|
||||||
message = RTVIBotTranscriptionMessage(
|
|
||||||
data=RTVITextMessageData(text=self._bot_transcription)
|
|
||||||
)
|
|
||||||
await self.send_rtvi_message(message)
|
|
||||||
self._bot_transcription = ""
|
|
||||||
|
|
||||||
async def _handle_interruptions(self, frame: Frame):
|
async def _handle_interruptions(self, frame: Frame):
|
||||||
"""Handle user speaking interruption frames."""
|
"""Handle user speaking interruption frames."""
|
||||||
message = None
|
message = None
|
||||||
@@ -1115,14 +1190,45 @@ class RTVIObserver(BaseObserver):
|
|||||||
if message:
|
if message:
|
||||||
await self.send_rtvi_message(message)
|
await self.send_rtvi_message(message)
|
||||||
|
|
||||||
|
async def _handle_aggregated_llm_text(self, frame: AggregatedTextFrame):
|
||||||
|
"""Handle aggregated LLM text output frames."""
|
||||||
|
# Skip certain aggregator types if configured to do so.
|
||||||
|
if (
|
||||||
|
self._params.skip_aggregator_types
|
||||||
|
and frame.aggregated_by in self._params.skip_aggregator_types
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
text = frame.text
|
||||||
|
type = frame.aggregated_by
|
||||||
|
for aggregation_type, transform in self._aggregation_transforms:
|
||||||
|
if aggregation_type == type or aggregation_type == "*":
|
||||||
|
text = await transform(text, type)
|
||||||
|
|
||||||
|
isTTS = isinstance(frame, TTSTextFrame)
|
||||||
|
if self._params.bot_output_enabled:
|
||||||
|
message = RTVIBotOutputMessage(
|
||||||
|
data=RTVIBotOutputMessageData(text=text, spoken=isTTS, aggregated_by=type)
|
||||||
|
)
|
||||||
|
await self.send_rtvi_message(message)
|
||||||
|
|
||||||
|
if isTTS and self._params.bot_tts_enabled:
|
||||||
|
tts_message = RTVIBotTTSTextMessage(data=RTVITextMessageData(text=text))
|
||||||
|
await self.send_rtvi_message(tts_message)
|
||||||
|
|
||||||
async def _handle_llm_text_frame(self, frame: LLMTextFrame):
|
async def _handle_llm_text_frame(self, frame: LLMTextFrame):
|
||||||
"""Handle LLM text output frames."""
|
"""Handle LLM text output frames."""
|
||||||
message = RTVIBotLLMTextMessage(data=RTVITextMessageData(text=frame.text))
|
message = RTVIBotLLMTextMessage(data=RTVITextMessageData(text=frame.text))
|
||||||
await self.send_rtvi_message(message)
|
await self.send_rtvi_message(message)
|
||||||
|
|
||||||
|
# TODO (mrkb): Remove all this logic when we fully deprecate bot-transcription messages.
|
||||||
self._bot_transcription += frame.text
|
self._bot_transcription += frame.text
|
||||||
if match_endofsentence(self._bot_transcription):
|
|
||||||
await self._push_bot_transcription()
|
if match_endofsentence(self._bot_transcription) and len(self._bot_transcription) > 0:
|
||||||
|
await self.send_rtvi_message(
|
||||||
|
RTVIBotTranscriptionMessage(data=RTVITextMessageData(text=self._bot_transcription))
|
||||||
|
)
|
||||||
|
self._bot_transcription = ""
|
||||||
|
|
||||||
async def _handle_user_transcriptions(self, frame: Frame):
|
async def _handle_user_transcriptions(self, frame: Frame):
|
||||||
"""Handle user transcription frames."""
|
"""Handle user transcription frames."""
|
||||||
@@ -1248,7 +1354,7 @@ class RTVIProcessor(FrameProcessor):
|
|||||||
# Default to 0.3.0 which is the last version before actually having a
|
# Default to 0.3.0 which is the last version before actually having a
|
||||||
# "client-version".
|
# "client-version".
|
||||||
self._client_version = [0, 3, 0]
|
self._client_version = [0, 3, 0]
|
||||||
self._skip_tts: bool = False # Keep in sync with llm_service.py
|
self._llm_skip_tts: bool = False # Keep in sync with llm_service.py's configuration.
|
||||||
|
|
||||||
self._registered_actions: Dict[str, RTVIAction] = {}
|
self._registered_actions: Dict[str, RTVIAction] = {}
|
||||||
self._registered_services: Dict[str, RTVIService] = {}
|
self._registered_services: Dict[str, RTVIService] = {}
|
||||||
@@ -1441,7 +1547,7 @@ class RTVIProcessor(FrameProcessor):
|
|||||||
elif isinstance(frame, RTVIActionFrame):
|
elif isinstance(frame, RTVIActionFrame):
|
||||||
await self._action_queue.put(frame)
|
await self._action_queue.put(frame)
|
||||||
elif isinstance(frame, LLMConfigureOutputFrame):
|
elif isinstance(frame, LLMConfigureOutputFrame):
|
||||||
self._skip_tts = frame.skip_tts
|
self._llm_skip_tts = frame.skip_tts
|
||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
# Other frames
|
# Other frames
|
||||||
else:
|
else:
|
||||||
@@ -1697,9 +1803,9 @@ class RTVIProcessor(FrameProcessor):
|
|||||||
opts = data.options if data.options is not None else RTVISendTextOptions()
|
opts = data.options if data.options is not None else RTVISendTextOptions()
|
||||||
if opts.run_immediately:
|
if opts.run_immediately:
|
||||||
await self.interrupt_bot()
|
await self.interrupt_bot()
|
||||||
cur_skip_tts = self._skip_tts
|
cur_llm_skip_tts = self._llm_skip_tts
|
||||||
should_skip_tts = not opts.audio_response
|
should_skip_tts = not opts.audio_response
|
||||||
toggle_skip_tts = cur_skip_tts != should_skip_tts
|
toggle_skip_tts = cur_llm_skip_tts != should_skip_tts
|
||||||
if toggle_skip_tts:
|
if toggle_skip_tts:
|
||||||
output_frame = LLMConfigureOutputFrame(skip_tts=should_skip_tts)
|
output_frame = LLMConfigureOutputFrame(skip_tts=should_skip_tts)
|
||||||
await self.push_frame(output_frame)
|
await self.push_frame(output_frame)
|
||||||
@@ -1709,7 +1815,7 @@ class RTVIProcessor(FrameProcessor):
|
|||||||
)
|
)
|
||||||
await self.push_frame(text_frame)
|
await self.push_frame(text_frame)
|
||||||
if toggle_skip_tts:
|
if toggle_skip_tts:
|
||||||
output_frame = LLMConfigureOutputFrame(skip_tts=cur_skip_tts)
|
output_frame = LLMConfigureOutputFrame(skip_tts=cur_llm_skip_tts)
|
||||||
await self.push_frame(output_frame)
|
await self.push_frame(output_frame)
|
||||||
|
|
||||||
async def _handle_update_context(self, data: RTVIAppendToContextData):
|
async def _handle_update_context(self, data: RTVIAppendToContextData):
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ try:
|
|||||||
from strands import Agent
|
from strands import Agent
|
||||||
from strands.multiagent.graph import Graph
|
from strands.multiagent.graph import Graph
|
||||||
except ModuleNotFoundError as e:
|
except ModuleNotFoundError as e:
|
||||||
logger.exception("In order to use Strands Agents, you need to `pip install strands-agents`.")
|
logger.error("In order to use Strands Agents, you need to `pip install strands-agents`.")
|
||||||
raise Exception(f"Missing module: {e}")
|
raise Exception(f"Missing module: {e}")
|
||||||
|
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ class StrandsAgentsProcessor(FrameProcessor):
|
|||||||
except GeneratorExit:
|
except GeneratorExit:
|
||||||
logger.warning(f"{self} generator was closed prematurely")
|
logger.warning(f"{self} generator was closed prematurely")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"{self} an unknown error occurred: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
finally:
|
finally:
|
||||||
if ttfb_tracking:
|
if ttfb_tracking:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ def _setup_webrtc_routes(
|
|||||||
result: StartBotResult = {"sessionId": session_id}
|
result: StartBotResult = {"sessionId": session_id}
|
||||||
if request_data.get("enableDefaultIceServers"):
|
if request_data.get("enableDefaultIceServers"):
|
||||||
result["iceConfig"] = IceConfig(
|
result["iceConfig"] = IceConfig(
|
||||||
iceServers=[IceServer(urls="stun:stun.l.google.com:19302")]
|
iceServers=[IceServer(urls=["stun:stun.l.google.com:19302"])]
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ class PlivoFrameSerializer(FrameSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Failed to hang up Plivo call: {e}")
|
logger.error(f"Failed to hang up Plivo call: {e}")
|
||||||
|
|
||||||
async def deserialize(self, data: str | bytes) -> Frame | None:
|
async def deserialize(self, data: str | bytes) -> Frame | None:
|
||||||
"""Deserializes Plivo WebSocket data to Pipecat frames.
|
"""Deserializes Plivo WebSocket data to Pipecat frames.
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ class TelnyxFrameSerializer(FrameSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Failed to hang up Telnyx call: {e}")
|
logger.error(f"Failed to hang up Telnyx call: {e}")
|
||||||
|
|
||||||
async def deserialize(self, data: str | bytes) -> Frame | None:
|
async def deserialize(self, data: str | bytes) -> Frame | None:
|
||||||
"""Deserializes Telnyx WebSocket data to Pipecat frames.
|
"""Deserializes Telnyx WebSocket data to Pipecat frames.
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ class TwilioFrameSerializer(FrameSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Failed to hang up Twilio call: {e}")
|
logger.error(f"Failed to hang up Twilio call: {e}")
|
||||||
|
|
||||||
async def deserialize(self, data: str | bytes) -> Frame | None:
|
async def deserialize(self, data: str | bytes) -> Frame | None:
|
||||||
"""Deserializes Twilio WebSocket data to Pipecat frames.
|
"""Deserializes Twilio WebSocket data to Pipecat frames.
|
||||||
|
|||||||
@@ -166,6 +166,6 @@ class AIService(FrameProcessor):
|
|||||||
async for f in generator:
|
async for f in generator:
|
||||||
if f:
|
if f:
|
||||||
if isinstance(f, ErrorFrame):
|
if isinstance(f, ErrorFrame):
|
||||||
await self.push_error(f)
|
await self.push_error_frame(f)
|
||||||
else:
|
else:
|
||||||
await self.push_frame(f)
|
await self.push_frame(f)
|
||||||
|
|||||||
@@ -458,8 +458,7 @@ class AnthropicLLMService(LLMService):
|
|||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
await self._call_event_handler("on_completion_timeout")
|
await self._call_event_handler("on_completion_timeout")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(f"{e}"))
|
|
||||||
finally:
|
finally:
|
||||||
await self.stop_processing_metrics()
|
await self.stop_processing_metrics()
|
||||||
await self.push_frame(LLMFullResponseEndFrame())
|
await self.push_frame(LLMFullResponseEndFrame())
|
||||||
|
|||||||
@@ -206,9 +206,8 @@ class AssemblyAISTTService(STTService):
|
|||||||
|
|
||||||
await self._call_event_handler("on_connected")
|
await self._call_event_handler("on_connected")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
|
||||||
self._connected = False
|
self._connected = False
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def _disconnect(self):
|
async def _disconnect(self):
|
||||||
@@ -233,8 +232,7 @@ class AssemblyAISTTService(STTService):
|
|||||||
logger.warning("Timed out waiting for termination message from server")
|
logger.warning("Timed out waiting for termination message from server")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
|
|
||||||
if self._receive_task:
|
if self._receive_task:
|
||||||
await self.cancel_task(self._receive_task)
|
await self.cancel_task(self._receive_task)
|
||||||
@@ -242,8 +240,7 @@ class AssemblyAISTTService(STTService):
|
|||||||
await self._websocket.close()
|
await self._websocket.close()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
@@ -262,13 +259,11 @@ class AssemblyAISTTService(STTService):
|
|||||||
except websockets.exceptions.ConnectionClosedOK:
|
except websockets.exceptions.ConnectionClosedOK:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
break
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
|
|
||||||
def _parse_message(self, message: Dict[str, Any]) -> BaseMessage:
|
def _parse_message(self, message: Dict[str, Any]) -> BaseMessage:
|
||||||
"""Parse a raw message into the appropriate message type."""
|
"""Parse a raw message into the appropriate message type."""
|
||||||
@@ -297,8 +292,7 @@ class AssemblyAISTTService(STTService):
|
|||||||
elif isinstance(parsed_message, TerminationMessage):
|
elif isinstance(parsed_message, TerminationMessage):
|
||||||
await self._handle_termination(parsed_message)
|
await self._handle_termination(parsed_message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
|
|
||||||
async def _handle_termination(self, message: TerminationMessage):
|
async def _handle_termination(self, message: TerminationMessage):
|
||||||
"""Handle termination message."""
|
"""Handle termination message."""
|
||||||
|
|||||||
@@ -56,6 +56,17 @@ def language_to_async_language(language: Language) -> Optional[str]:
|
|||||||
Language.ES: "es",
|
Language.ES: "es",
|
||||||
Language.DE: "de",
|
Language.DE: "de",
|
||||||
Language.IT: "it",
|
Language.IT: "it",
|
||||||
|
Language.PT: "pt",
|
||||||
|
Language.NL: "nl",
|
||||||
|
Language.AR: "ar",
|
||||||
|
Language.RU: "ru",
|
||||||
|
Language.RO: "ro",
|
||||||
|
Language.JA: "ja",
|
||||||
|
Language.HE: "he",
|
||||||
|
Language.HY: "hy",
|
||||||
|
Language.TR: "tr",
|
||||||
|
Language.HI: "hi",
|
||||||
|
Language.ZH: "zh",
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolve_language(language, LANGUAGE_MAP, use_base_code=True)
|
return resolve_language(language, LANGUAGE_MAP, use_base_code=True)
|
||||||
@@ -74,7 +85,7 @@ class AsyncAITTSService(InterruptibleTTSService):
|
|||||||
language: Language to use for synthesis.
|
language: Language to use for synthesis.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
language: Optional[Language] = Language.EN
|
language: Optional[Language] = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -83,7 +94,7 @@ class AsyncAITTSService(InterruptibleTTSService):
|
|||||||
voice_id: str,
|
voice_id: str,
|
||||||
version: str = "v1",
|
version: str = "v1",
|
||||||
url: str = "wss://api.async.ai/text_to_speech/websocket/ws",
|
url: str = "wss://api.async.ai/text_to_speech/websocket/ws",
|
||||||
model: str = "asyncflow_v2.0",
|
model: str = "asyncflow_multilingual_v1.0",
|
||||||
sample_rate: Optional[int] = None,
|
sample_rate: Optional[int] = None,
|
||||||
encoding: str = "pcm_s16le",
|
encoding: str = "pcm_s16le",
|
||||||
container: str = "raw",
|
container: str = "raw",
|
||||||
@@ -99,7 +110,7 @@ class AsyncAITTSService(InterruptibleTTSService):
|
|||||||
https://docs.async.ai/list-voices-16699698e0
|
https://docs.async.ai/list-voices-16699698e0
|
||||||
version: Async API version.
|
version: Async API version.
|
||||||
url: WebSocket URL for Async TTS API.
|
url: WebSocket URL for Async TTS API.
|
||||||
model: TTS model to use (e.g., "asyncflow_v2.0").
|
model: TTS model to use (e.g., "asyncflow_multilingual_v1.0").
|
||||||
sample_rate: Audio sample rate.
|
sample_rate: Audio sample rate.
|
||||||
encoding: Audio encoding format.
|
encoding: Audio encoding format.
|
||||||
container: Audio container format.
|
container: Audio container format.
|
||||||
@@ -128,7 +139,7 @@ class AsyncAITTSService(InterruptibleTTSService):
|
|||||||
},
|
},
|
||||||
"language": self.language_to_service_language(params.language)
|
"language": self.language_to_service_language(params.language)
|
||||||
if params.language
|
if params.language
|
||||||
else "en",
|
else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
self.set_model_name(model)
|
self.set_model_name(model)
|
||||||
@@ -228,8 +239,7 @@ class AsyncAITTSService(InterruptibleTTSService):
|
|||||||
|
|
||||||
await self._call_event_handler("on_connected")
|
await self._call_event_handler("on_connected")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_connection_error", f"{e}")
|
await self._call_event_handler("on_connection_error", f"{e}")
|
||||||
|
|
||||||
@@ -241,8 +251,7 @@ class AsyncAITTSService(InterruptibleTTSService):
|
|||||||
logger.debug("Disconnecting from Async")
|
logger.debug("Disconnecting from Async")
|
||||||
await self._websocket.close()
|
await self._websocket.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
finally:
|
finally:
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
self._started = False
|
self._started = False
|
||||||
@@ -287,12 +296,11 @@ class AsyncAITTSService(InterruptibleTTSService):
|
|||||||
)
|
)
|
||||||
await self.push_frame(frame)
|
await self.push_frame(frame)
|
||||||
elif msg.get("error_code"):
|
elif msg.get("error_code"):
|
||||||
logger.error(f"{self} error: {msg}")
|
|
||||||
await self.push_frame(TTSStoppedFrame())
|
await self.push_frame(TTSStoppedFrame())
|
||||||
await self.stop_all_metrics()
|
await self.stop_all_metrics()
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {msg['message']}"))
|
await self.push_error(error_msg=f"Error: {msg['message']}")
|
||||||
else:
|
else:
|
||||||
logger.error(f"{self} error, unknown message type: {msg}")
|
await self.push_error(error_msg=f"Unknown message type: {msg}")
|
||||||
|
|
||||||
async def _keepalive_task_handler(self):
|
async def _keepalive_task_handler(self):
|
||||||
"""Send periodic keepalive messages to maintain WebSocket connection."""
|
"""Send periodic keepalive messages to maintain WebSocket connection."""
|
||||||
@@ -335,16 +343,14 @@ class AsyncAITTSService(InterruptibleTTSService):
|
|||||||
await self._get_websocket().send(msg)
|
await self._get_websocket().send(msg)
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame()
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
return
|
return
|
||||||
yield None
|
yield None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
class AsyncAIHttpTTSService(TTSService):
|
class AsyncAIHttpTTSService(TTSService):
|
||||||
@@ -362,7 +368,7 @@ class AsyncAIHttpTTSService(TTSService):
|
|||||||
language: Language to use for synthesis.
|
language: Language to use for synthesis.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
language: Optional[Language] = Language.EN
|
language: Optional[Language] = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -370,7 +376,7 @@ class AsyncAIHttpTTSService(TTSService):
|
|||||||
api_key: str,
|
api_key: str,
|
||||||
voice_id: str,
|
voice_id: str,
|
||||||
aiohttp_session: aiohttp.ClientSession,
|
aiohttp_session: aiohttp.ClientSession,
|
||||||
model: str = "asyncflow_v2.0",
|
model: str = "asyncflow_multilingual_v1.0",
|
||||||
url: str = "https://api.async.ai",
|
url: str = "https://api.async.ai",
|
||||||
version: str = "v1",
|
version: str = "v1",
|
||||||
sample_rate: Optional[int] = None,
|
sample_rate: Optional[int] = None,
|
||||||
@@ -385,7 +391,7 @@ class AsyncAIHttpTTSService(TTSService):
|
|||||||
api_key: Async API key.
|
api_key: Async API key.
|
||||||
voice_id: ID of the voice to use for synthesis.
|
voice_id: ID of the voice to use for synthesis.
|
||||||
aiohttp_session: An aiohttp session for making HTTP requests.
|
aiohttp_session: An aiohttp session for making HTTP requests.
|
||||||
model: TTS model to use (e.g., "asyncflow_v2.0").
|
model: TTS model to use (e.g., "asyncflow_multilingual_v1.0").
|
||||||
url: Base URL for Async API.
|
url: Base URL for Async API.
|
||||||
version: API version string for Async API.
|
version: API version string for Async API.
|
||||||
sample_rate: Audio sample rate.
|
sample_rate: Audio sample rate.
|
||||||
@@ -409,7 +415,7 @@ class AsyncAIHttpTTSService(TTSService):
|
|||||||
},
|
},
|
||||||
"language": self.language_to_service_language(params.language)
|
"language": self.language_to_service_language(params.language)
|
||||||
if params.language
|
if params.language
|
||||||
else "en",
|
else None,
|
||||||
}
|
}
|
||||||
self.set_voice(voice_id)
|
self.set_voice(voice_id)
|
||||||
self.set_model_name(model)
|
self.set_model_name(model)
|
||||||
@@ -477,8 +483,7 @@ class AsyncAIHttpTTSService(TTSService):
|
|||||||
async with self._session.post(url, json=payload, headers=headers) as response:
|
async with self._session.post(url, json=payload, headers=headers) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
error_text = await response.text()
|
error_text = await response.text()
|
||||||
logger.error(f"Async API error: {error_text}")
|
await self.push_error(error_msg=f"Async API error: {error_text}")
|
||||||
await self.push_error(ErrorFrame(error=f"Async API error: {error_text}"))
|
|
||||||
raise Exception(f"Async API returned status {response.status}: {error_text}")
|
raise Exception(f"Async API returned status {response.status}: {error_text}")
|
||||||
|
|
||||||
audio_data = await response.read()
|
audio_data = await response.read()
|
||||||
@@ -494,8 +499,7 @@ class AsyncAIHttpTTSService(TTSService):
|
|||||||
yield frame
|
yield frame
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
finally:
|
finally:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame()
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import sys
|
|||||||
|
|
||||||
from pipecat.services import DeprecatedModuleProxy
|
from pipecat.services import DeprecatedModuleProxy
|
||||||
|
|
||||||
|
from .agent_core import *
|
||||||
from .llm import *
|
from .llm import *
|
||||||
from .nova_sonic import *
|
from .nova_sonic import *
|
||||||
|
from .sagemaker import *
|
||||||
from .stt import *
|
from .stt import *
|
||||||
from .tts import *
|
from .tts import *
|
||||||
|
|
||||||
|
|||||||
258
src/pipecat/services/aws/agent_core.py
Normal file
258
src/pipecat/services/aws/agent_core.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
"""AWS AgentCore Processor Module.
|
||||||
|
|
||||||
|
This module defines the AWSAgentCoreProcessor, which invokes agents hosted on
|
||||||
|
Amazon Bedrock AgentCore Runtime and streams their responses as LLMTextFrames.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import aioboto3
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from pipecat.frames.frames import (
|
||||||
|
Frame,
|
||||||
|
LLMContextFrame,
|
||||||
|
LLMFullResponseEndFrame,
|
||||||
|
LLMFullResponseStartFrame,
|
||||||
|
LLMTextFrame,
|
||||||
|
)
|
||||||
|
from pipecat.processors.aggregators.llm_context import LLMContext, LLMSpecificMessage
|
||||||
|
from pipecat.processors.aggregators.openai_llm_context import (
|
||||||
|
OpenAILLMContext,
|
||||||
|
OpenAILLMContextFrame,
|
||||||
|
)
|
||||||
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
|
|
||||||
|
|
||||||
|
def default_context_to_payload_transformer(
|
||||||
|
context: LLMContext | OpenAILLMContext,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Default transformer to create AgentCore payload from LLM context.
|
||||||
|
|
||||||
|
Extracts the latest user or system message text and wraps it in {"prompt": "<text>"}.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: The LLM context containing conversation messages.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A JSON string payload for AgentCore, or None if no valid message found.
|
||||||
|
"""
|
||||||
|
messages = context.messages
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
return None
|
||||||
|
|
||||||
|
last_message = messages[-1]
|
||||||
|
if isinstance(last_message, LLMSpecificMessage) or last_message.get("role") not in (
|
||||||
|
"user",
|
||||||
|
"system",
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = last_message.get("content")
|
||||||
|
if not content:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(content, str):
|
||||||
|
prompt = content
|
||||||
|
elif isinstance(content, list):
|
||||||
|
prompt = " ".join([part.get("text", "") for part in content])
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return json.dumps({"prompt": prompt})
|
||||||
|
|
||||||
|
|
||||||
|
def default_response_to_output_transformer(response_line: str) -> Optional[str]:
|
||||||
|
"""Default transformer to extract output text from AgentCore response.
|
||||||
|
|
||||||
|
Expects responses with {"response": "<text>"} format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response_line: The raw response line from AgentCore (without "data: " prefix).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The extracted output text, or None if no text found.
|
||||||
|
"""
|
||||||
|
response_json = json.loads(response_line)
|
||||||
|
return response_json.get("response")
|
||||||
|
|
||||||
|
|
||||||
|
class AWSAgentCoreProcessor(FrameProcessor):
|
||||||
|
"""Processor that runs an Amazon Bedrock AgentCore agent.
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- LLMContextFrame: Supplies a context used to invoke the agent.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- LLMTextFrame: The agent's text response(s).
|
||||||
|
A single agent invocation may result in multiple text frames.
|
||||||
|
|
||||||
|
This processor transforms the input context to a payload for the AgentCore
|
||||||
|
agent, and transforms the agent's response(s) into output text frame(s). Both
|
||||||
|
mappings are configurable via transformers. Below is the default behavior.
|
||||||
|
|
||||||
|
Input transformer (context_to_payload_transformer):
|
||||||
|
- Grabs the latest user or system message (if it's the latest message)
|
||||||
|
- Extracts its text content
|
||||||
|
- Constructs a payload that looks like {"prompt": "<text>"}
|
||||||
|
|
||||||
|
Output transformer (response_to_output_transformer):
|
||||||
|
- Expects responses that look like {"response": "<text>"}
|
||||||
|
- Extracts the text for use in the LLMTextFrame(s)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
agentArn: str,
|
||||||
|
aws_access_key: Optional[str] = None,
|
||||||
|
aws_secret_key: Optional[str] = None,
|
||||||
|
aws_session_token: Optional[str] = None,
|
||||||
|
aws_region: Optional[str] = None,
|
||||||
|
context_to_payload_transformer: Optional[
|
||||||
|
Callable[[LLMContext | OpenAILLMContext], Optional[str]]
|
||||||
|
] = None,
|
||||||
|
response_to_output_transformer: Optional[Callable[[str], Optional[str]]] = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""Initialize the AWS AgentCore processor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agentArn: The Amazon Web Services Resource Name (ARN) of the agent.
|
||||||
|
aws_access_key: AWS access key ID. If None, uses default credentials.
|
||||||
|
aws_secret_key: AWS secret access key. If None, uses default credentials.
|
||||||
|
aws_session_token: AWS session token for temporary credentials.
|
||||||
|
aws_region: AWS region.
|
||||||
|
context_to_payload_transformer: Optional callable to transform
|
||||||
|
LLMContext into AgentCore payload string. If None, uses
|
||||||
|
default_context_to_payload_transformer.
|
||||||
|
response_to_output_transformer: Optional callable to extract output text
|
||||||
|
from AgentCore response. If None, uses
|
||||||
|
default_response_to_output_transformer.
|
||||||
|
**kwargs: Additional arguments passed to parent FrameProcessor.
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
self._agentArn = agentArn
|
||||||
|
self._aws_session = aioboto3.Session()
|
||||||
|
|
||||||
|
# Store AWS session parameters for creating client in async context
|
||||||
|
self._aws_params = {
|
||||||
|
"aws_access_key_id": aws_access_key or os.getenv("AWS_ACCESS_KEY_ID"),
|
||||||
|
"aws_secret_access_key": aws_secret_key or os.getenv("AWS_SECRET_ACCESS_KEY"),
|
||||||
|
"aws_session_token": aws_session_token or os.getenv("AWS_SESSION_TOKEN"),
|
||||||
|
"region_name": aws_region or os.getenv("AWS_REGION", "us-east-1"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set transformers with defaults
|
||||||
|
self._context_to_payload_transformer = (
|
||||||
|
context_to_payload_transformer or default_context_to_payload_transformer
|
||||||
|
)
|
||||||
|
self._response_to_output_transformer = (
|
||||||
|
response_to_output_transformer or default_response_to_output_transformer
|
||||||
|
)
|
||||||
|
|
||||||
|
# State for managing output response bookends
|
||||||
|
self._output_response_open = False
|
||||||
|
self._last_text_frame_time: Optional[float] = None
|
||||||
|
self._close_task: Optional[asyncio.Task] = None
|
||||||
|
self._output_response_timeout = 1.0 # seconds
|
||||||
|
|
||||||
|
async def _close_output_response_after_timeout(self):
|
||||||
|
"""Close the output response after timeout if no new text frames arrive."""
|
||||||
|
await asyncio.sleep(self._output_response_timeout)
|
||||||
|
if self._output_response_open:
|
||||||
|
self._output_response_open = False
|
||||||
|
await self.push_frame(LLMFullResponseEndFrame())
|
||||||
|
|
||||||
|
async def _push_text_frame(self, text: str):
|
||||||
|
"""Push a text frame, managing output response bookends."""
|
||||||
|
# Cancel any pending close task
|
||||||
|
if self._close_task and not self._close_task.done():
|
||||||
|
await self.cancel_task(self._close_task)
|
||||||
|
|
||||||
|
# Open output response if needed
|
||||||
|
if not self._output_response_open:
|
||||||
|
await self.push_frame(LLMFullResponseStartFrame())
|
||||||
|
self._output_response_open = True
|
||||||
|
|
||||||
|
# Push the text frame
|
||||||
|
await self.push_frame(LLMTextFrame(text))
|
||||||
|
self._last_text_frame_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
# Schedule closing the output response after timeout
|
||||||
|
self._close_task = self.create_task(self._close_output_response_after_timeout())
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
"""Process incoming frames and handle LLM message frames.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The incoming frame to process.
|
||||||
|
direction: The direction of frame flow in the pipeline.
|
||||||
|
"""
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
if isinstance(frame, (LLMContextFrame, OpenAILLMContextFrame)):
|
||||||
|
# Create payload to invoke AgentCore agent
|
||||||
|
payload = self._context_to_payload_transformer(frame.context)
|
||||||
|
|
||||||
|
if not payload:
|
||||||
|
return
|
||||||
|
|
||||||
|
async with self._aws_session.client("bedrock-agentcore", **self._aws_params) as client:
|
||||||
|
# Invoke the AgentCore agent
|
||||||
|
response = await client.invoke_agent_runtime(
|
||||||
|
agentRuntimeArn=self._agentArn, payload=payload.encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine if this is a streamed multi-part response, which
|
||||||
|
# will affect our parsing
|
||||||
|
is_multi_part_response = "text/event-stream" in response.get("contentType", "")
|
||||||
|
|
||||||
|
# Handle each response part (there may be one, for single
|
||||||
|
# responses, or multiple, for streamed multi-part responses)
|
||||||
|
async for part in response.get("response", []):
|
||||||
|
part_string = part.decode("utf-8")
|
||||||
|
|
||||||
|
# In streamed multi-part responses, each part might have
|
||||||
|
# one or more lines, each of which starts with "data: ".
|
||||||
|
# Treat each line as a response.
|
||||||
|
if is_multi_part_response:
|
||||||
|
for line in part_string.split("\n"):
|
||||||
|
# Get response text from this line
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if not line.startswith("data: "):
|
||||||
|
logger.warning(f"Expected line to start with 'data: ', got: {line}")
|
||||||
|
continue
|
||||||
|
line = line[6:] # omit "data: "
|
||||||
|
|
||||||
|
# Transform response line to output text
|
||||||
|
text = self._response_to_output_transformer(line)
|
||||||
|
if text:
|
||||||
|
await self._push_text_frame(text)
|
||||||
|
|
||||||
|
# In single-part responses, the whole part is one response
|
||||||
|
# and there's no "data: " prefix
|
||||||
|
else:
|
||||||
|
# Transform response part string to output text
|
||||||
|
text = self._response_to_output_transformer(part_string)
|
||||||
|
if text:
|
||||||
|
await self._push_text_frame(text)
|
||||||
|
|
||||||
|
# Final close if output response is still open after all parts processed
|
||||||
|
if self._output_response_open:
|
||||||
|
if self._close_task and not self._close_task.done():
|
||||||
|
await self.cancel_task(self._close_task)
|
||||||
|
self._output_response_open = False
|
||||||
|
await self.push_frame(LLMFullResponseEndFrame())
|
||||||
|
else:
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
@@ -734,7 +734,7 @@ class AWSBedrockLLMService(LLMService):
|
|||||||
aws_access_key: Optional[str] = None,
|
aws_access_key: Optional[str] = None,
|
||||||
aws_secret_key: Optional[str] = None,
|
aws_secret_key: Optional[str] = None,
|
||||||
aws_session_token: Optional[str] = None,
|
aws_session_token: Optional[str] = None,
|
||||||
aws_region: str = "us-east-1",
|
aws_region: Optional[str] = None,
|
||||||
params: Optional[InputParams] = None,
|
params: Optional[InputParams] = None,
|
||||||
client_config: Optional[Config] = None,
|
client_config: Optional[Config] = None,
|
||||||
retry_timeout_secs: Optional[float] = 5.0,
|
retry_timeout_secs: Optional[float] = 5.0,
|
||||||
@@ -1136,7 +1136,7 @@ class AWSBedrockLLMService(LLMService):
|
|||||||
except (ReadTimeoutError, asyncio.TimeoutError):
|
except (ReadTimeoutError, asyncio.TimeoutError):
|
||||||
await self._call_event_handler("on_completion_timeout")
|
await self._call_event_handler("on_completion_timeout")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
finally:
|
finally:
|
||||||
await self.stop_processing_metrics()
|
await self.stop_processing_metrics()
|
||||||
await self.push_frame(LLMFullResponseEndFrame())
|
await self.push_frame(LLMFullResponseEndFrame())
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from pydantic import BaseModel, Field
|
|||||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||||
from pipecat.adapters.services.aws_nova_sonic_adapter import AWSNovaSonicLLMAdapter, Role
|
from pipecat.adapters.services.aws_nova_sonic_adapter import AWSNovaSonicLLMAdapter, Role
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
|
AggregationType,
|
||||||
BotStoppedSpeakingFrame,
|
BotStoppedSpeakingFrame,
|
||||||
CancelFrame,
|
CancelFrame,
|
||||||
EndFrame,
|
EndFrame,
|
||||||
@@ -452,7 +453,7 @@ class AWSNovaSonicLLMService(LLMService):
|
|||||||
self._ready_to_send_context = True
|
self._ready_to_send_context = True
|
||||||
await self._finish_connecting_if_context_available()
|
await self._finish_connecting_if_context_available()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} initialization error: {e}")
|
await self.push_error(error_msg=f"Initialization error: {e}", exception=e)
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
|
|
||||||
async def _process_completed_function_calls(self, send_new_results: bool):
|
async def _process_completed_function_calls(self, send_new_results: bool):
|
||||||
@@ -576,7 +577,7 @@ class AWSNovaSonicLLMService(LLMService):
|
|||||||
|
|
||||||
logger.info("Finished disconnecting")
|
logger.info("Finished disconnecting")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} error disconnecting: {e}")
|
await self.push_error(error_msg=f"Error disconnecting: {e}", exception=e)
|
||||||
|
|
||||||
def _create_client(self) -> BedrockRuntimeClient:
|
def _create_client(self) -> BedrockRuntimeClient:
|
||||||
config = Config(
|
config = Config(
|
||||||
@@ -884,7 +885,7 @@ class AWSNovaSonicLLMService(LLMService):
|
|||||||
# Errors are kind of expected while disconnecting, so just
|
# Errors are kind of expected while disconnecting, so just
|
||||||
# ignore them and do nothing
|
# ignore them and do nothing
|
||||||
return
|
return
|
||||||
logger.error(f"{self} error processing responses: {e}")
|
await self.push_error(error_msg=f"Error processing responses: {e}", exception=e)
|
||||||
if self._wants_connection:
|
if self._wants_connection:
|
||||||
await self.reset_conversation()
|
await self.reset_conversation()
|
||||||
|
|
||||||
@@ -1027,7 +1028,7 @@ class AWSNovaSonicLLMService(LLMService):
|
|||||||
logger.debug(f"Assistant response text added: {text}")
|
logger.debug(f"Assistant response text added: {text}")
|
||||||
|
|
||||||
# Report the text of the assistant response.
|
# Report the text of the assistant response.
|
||||||
frame = TTSTextFrame(text)
|
frame = TTSTextFrame(text, aggregated_by=AggregationType.SENTENCE)
|
||||||
frame.includes_inter_frame_spaces = True
|
frame.includes_inter_frame_spaces = True
|
||||||
await self.push_frame(frame)
|
await self.push_frame(frame)
|
||||||
|
|
||||||
@@ -1062,7 +1063,9 @@ class AWSNovaSonicLLMService(LLMService):
|
|||||||
# TTSTextFrame would be ignored otherwise (the interruption frame
|
# TTSTextFrame would be ignored otherwise (the interruption frame
|
||||||
# would have cleared the assistant aggregator state).
|
# would have cleared the assistant aggregator state).
|
||||||
await self.push_frame(LLMFullResponseStartFrame())
|
await self.push_frame(LLMFullResponseStartFrame())
|
||||||
frame = TTSTextFrame(self._assistant_text_buffer)
|
frame = TTSTextFrame(
|
||||||
|
self._assistant_text_buffer, aggregated_by=AggregationType.SENTENCE
|
||||||
|
)
|
||||||
frame.includes_inter_frame_spaces = True
|
frame.includes_inter_frame_spaces = True
|
||||||
await self.push_frame(frame)
|
await self.push_frame(frame)
|
||||||
self._may_need_repush_assistant_text = False
|
self._may_need_repush_assistant_text = False
|
||||||
|
|||||||
0
src/pipecat/services/aws/sagemaker/__init__.py
Normal file
0
src/pipecat/services/aws/sagemaker/__init__.py
Normal file
283
src/pipecat/services/aws/sagemaker/bidi_client.py
Normal file
283
src/pipecat/services/aws/sagemaker/bidi_client.py
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
"""AWS SageMaker bidirectional streaming client.
|
||||||
|
|
||||||
|
This module provides a client for streaming bidirectional communication with
|
||||||
|
SageMaker endpoints using the HTTP/2 protocol. Supports sending audio, text,
|
||||||
|
and JSON data to SageMaker model endpoints and receiving streaming responses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
try:
|
||||||
|
from aws_sdk_sagemaker_runtime_http2.client import SageMakerRuntimeHTTP2Client
|
||||||
|
from aws_sdk_sagemaker_runtime_http2.config import Config, HTTPAuthSchemeResolver
|
||||||
|
from aws_sdk_sagemaker_runtime_http2.models import (
|
||||||
|
InvokeEndpointWithBidirectionalStreamInput,
|
||||||
|
RequestPayloadPart,
|
||||||
|
RequestStreamEventPayloadPart,
|
||||||
|
ResponseStreamEvent,
|
||||||
|
)
|
||||||
|
from smithy_aws_core.auth.sigv4 import SigV4AuthScheme
|
||||||
|
from smithy_aws_core.identity import EnvironmentCredentialsResolver
|
||||||
|
from smithy_core.aio.eventstream import DuplexEventStream
|
||||||
|
except ModuleNotFoundError as e:
|
||||||
|
logger.error(f"Exception: {e}")
|
||||||
|
logger.error(
|
||||||
|
"In order to use SageMaker BiDi client, you need to `pip install pipecat-ai[sagemaker]`."
|
||||||
|
)
|
||||||
|
raise Exception(f"Missing module: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class SageMakerBidiClient:
|
||||||
|
"""Client for bidirectional streaming with AWS SageMaker endpoints.
|
||||||
|
|
||||||
|
Handles low-level HTTP/2 bidirectional streaming protocol for communicating
|
||||||
|
with SageMaker model endpoints. Provides methods for sending various data
|
||||||
|
types (audio, text, JSON) and receiving streaming responses.
|
||||||
|
|
||||||
|
This client uses AWS SigV4 authentication and supports credential resolution
|
||||||
|
from environment variables, AWS CLI configuration, and instance metadata.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
client = SageMakerBidiClient(
|
||||||
|
endpoint_name="my-deepgram-endpoint",
|
||||||
|
region="us-east-2",
|
||||||
|
model_invocation_path="v1/listen",
|
||||||
|
model_query_string="model=nova-3&language=en"
|
||||||
|
)
|
||||||
|
await client.start_session()
|
||||||
|
await client.send_audio_chunk(audio_bytes)
|
||||||
|
response = await client.receive_response()
|
||||||
|
await client.close_session()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
endpoint_name: str,
|
||||||
|
region: str,
|
||||||
|
model_invocation_path: str = "",
|
||||||
|
model_query_string: str = "",
|
||||||
|
):
|
||||||
|
"""Initialize the SageMaker BiDi client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint_name: Name of the SageMaker endpoint to connect to.
|
||||||
|
region: AWS region where the endpoint is deployed.
|
||||||
|
model_invocation_path: API path for the model invocation (e.g., "v1/listen").
|
||||||
|
model_query_string: Query string parameters for the model (e.g., "model=nova-3").
|
||||||
|
"""
|
||||||
|
self.endpoint_name = endpoint_name
|
||||||
|
self.region = region
|
||||||
|
self.model_invocation_path = model_invocation_path
|
||||||
|
self.model_query_string = model_query_string
|
||||||
|
self.bidi_endpoint = f"https://runtime.sagemaker.{region}.amazonaws.com:8443"
|
||||||
|
self._client: Optional[SageMakerRuntimeHTTP2Client] = None
|
||||||
|
self._stream: Optional[
|
||||||
|
DuplexEventStream[RequestStreamEventPayloadPart, ResponseStreamEvent, any]
|
||||||
|
] = None
|
||||||
|
self._output_stream = None
|
||||||
|
self._is_active = False
|
||||||
|
|
||||||
|
def _initialize_client(self):
|
||||||
|
"""Initialize the SageMaker Runtime HTTP2 client with AWS credentials.
|
||||||
|
|
||||||
|
Creates and configures the SageMaker Runtime HTTP2 client with SigV4
|
||||||
|
authentication. Attempts to resolve AWS credentials from environment
|
||||||
|
variables, AWS CLI configuration, or instance metadata.
|
||||||
|
"""
|
||||||
|
logger.debug(f"Initializing SageMaker BiDi client for region: {self.region}")
|
||||||
|
logger.debug(f"Using endpoint URI: {self.bidi_endpoint}")
|
||||||
|
|
||||||
|
# Check for AWS credentials
|
||||||
|
has_env_creds = bool(os.getenv("AWS_ACCESS_KEY_ID") and os.getenv("AWS_SECRET_ACCESS_KEY"))
|
||||||
|
|
||||||
|
if not has_env_creds:
|
||||||
|
logger.warning(
|
||||||
|
"AWS credentials not found in environment variables. "
|
||||||
|
"Attempting to use EnvironmentCredentialsResolver which will check "
|
||||||
|
"AWS CLI configuration and instance metadata."
|
||||||
|
)
|
||||||
|
|
||||||
|
config = Config(
|
||||||
|
endpoint_uri=self.bidi_endpoint,
|
||||||
|
region=self.region,
|
||||||
|
aws_credentials_identity_resolver=EnvironmentCredentialsResolver(),
|
||||||
|
auth_scheme_resolver=HTTPAuthSchemeResolver(),
|
||||||
|
auth_schemes={"aws.auth#sigv4": SigV4AuthScheme(service="sagemaker")},
|
||||||
|
)
|
||||||
|
self._client = SageMakerRuntimeHTTP2Client(config=config)
|
||||||
|
|
||||||
|
async def start_session(self):
|
||||||
|
"""Start a bidirectional streaming session with the SageMaker endpoint.
|
||||||
|
|
||||||
|
Initializes the client if needed, creates the bidirectional stream, and
|
||||||
|
establishes the connection to the SageMaker endpoint. Must be called
|
||||||
|
before sending or receiving data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The output stream for receiving responses.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If client initialization or connection fails.
|
||||||
|
"""
|
||||||
|
if not self._client:
|
||||||
|
self._initialize_client()
|
||||||
|
|
||||||
|
logger.debug(f"Starting BiDi session with endpoint: {self.endpoint_name}")
|
||||||
|
logger.debug(f"Model invocation path: {self.model_invocation_path}")
|
||||||
|
logger.debug(f"Model query string: {self.model_query_string}")
|
||||||
|
|
||||||
|
# Create the bidirectional stream
|
||||||
|
stream_input = InvokeEndpointWithBidirectionalStreamInput(
|
||||||
|
endpoint_name=self.endpoint_name,
|
||||||
|
model_invocation_path=self.model_invocation_path,
|
||||||
|
model_query_string=self.model_query_string,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._stream = await self._client.invoke_endpoint_with_bidirectional_stream(
|
||||||
|
stream_input
|
||||||
|
)
|
||||||
|
self._is_active = True
|
||||||
|
|
||||||
|
# Get output stream
|
||||||
|
output = await self._stream.await_output()
|
||||||
|
self._output_stream = output[1]
|
||||||
|
|
||||||
|
logger.debug("BiDi session started successfully")
|
||||||
|
return self._output_stream
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start BiDi session: {e}")
|
||||||
|
self._is_active = False
|
||||||
|
raise RuntimeError(f"Failed to start SageMaker BiDi session: {e}")
|
||||||
|
|
||||||
|
async def send_data(self, data_bytes: bytes, data_type: Optional[str] = None):
|
||||||
|
"""Send a chunk of data to the stream.
|
||||||
|
|
||||||
|
Generic method for sending any type of data to the SageMaker endpoint.
|
||||||
|
Use the convenience methods (send_audio_chunk, send_text, send_json)
|
||||||
|
for common data types.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_bytes: Raw bytes to send.
|
||||||
|
data_type: Optional data type header. Common values are "BINARY" for
|
||||||
|
audio/binary data and "UTF8" for text/JSON data.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If session is not active or send fails.
|
||||||
|
"""
|
||||||
|
if not self._is_active or not self._stream:
|
||||||
|
raise RuntimeError("BiDi session not active")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = RequestPayloadPart(bytes_=data_bytes, data_type=data_type)
|
||||||
|
event = RequestStreamEventPayloadPart(value=payload)
|
||||||
|
await self._stream.input_stream.send(event)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send data: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def send_audio_chunk(self, audio_bytes: bytes):
|
||||||
|
"""Send a chunk of audio data to the stream.
|
||||||
|
|
||||||
|
Convenience method for sending audio data. Automatically sets the data
|
||||||
|
type to "BINARY".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_bytes: Raw audio bytes to send (e.g., PCM audio data).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If session is not active or send fails.
|
||||||
|
"""
|
||||||
|
await self.send_data(audio_bytes, data_type="BINARY")
|
||||||
|
|
||||||
|
async def send_text(self, text: str):
|
||||||
|
"""Send text data to the stream.
|
||||||
|
|
||||||
|
Convenience method for sending text data. Automatically encodes the text
|
||||||
|
as UTF-8 and sets the data type to "UTF8".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text string to send.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If session is not active or send fails.
|
||||||
|
"""
|
||||||
|
await self.send_data(text.encode("utf-8"), data_type="UTF8")
|
||||||
|
|
||||||
|
async def send_json(self, data: dict):
|
||||||
|
"""Send JSON data to the stream.
|
||||||
|
|
||||||
|
Convenience method for sending JSON-encoded messages. Useful for control
|
||||||
|
messages like KeepAlive or CloseStream. Automatically serializes the
|
||||||
|
dictionary to JSON, encodes as UTF-8, and sets the data type to "UTF8".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Dictionary to send as JSON (e.g., {"type": "KeepAlive"}).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If session is not active or send fails.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
await self.send_data(json.dumps(data).encode("utf-8"), data_type="UTF8")
|
||||||
|
|
||||||
|
async def receive_response(self) -> Optional[ResponseStreamEvent]:
|
||||||
|
"""Receive a response from the stream.
|
||||||
|
|
||||||
|
Blocks until a response is available from the SageMaker endpoint. Returns
|
||||||
|
None when the stream is closed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The response event containing payload data, or None if stream is closed.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If session is not active.
|
||||||
|
"""
|
||||||
|
if not self._is_active or not self._output_stream:
|
||||||
|
raise RuntimeError("BiDi session not active")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self._output_stream.receive()
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to receive response: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def close_session(self):
|
||||||
|
"""Close the bidirectional streaming session.
|
||||||
|
|
||||||
|
Gracefully closes the input stream and marks the session as inactive.
|
||||||
|
Safe to call multiple times.
|
||||||
|
"""
|
||||||
|
if not self._is_active:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug("Closing BiDi session...")
|
||||||
|
self._is_active = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._stream:
|
||||||
|
await self._stream.input_stream.close()
|
||||||
|
logger.debug("BiDi session closed successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error closing BiDi session: {e}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
"""Check if the session is currently active.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if session is active, False otherwise.
|
||||||
|
"""
|
||||||
|
return self._is_active
|
||||||
@@ -58,7 +58,7 @@ class AWSTranscribeSTTService(STTService):
|
|||||||
api_key: Optional[str] = None,
|
api_key: Optional[str] = None,
|
||||||
aws_access_key_id: Optional[str] = None,
|
aws_access_key_id: Optional[str] = None,
|
||||||
aws_session_token: Optional[str] = None,
|
aws_session_token: Optional[str] = None,
|
||||||
region: Optional[str] = "us-east-1",
|
region: Optional[str] = None,
|
||||||
sample_rate: int = 16000,
|
sample_rate: int = 16000,
|
||||||
language: Language = Language.EN,
|
language: Language = Language.EN,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
@@ -69,7 +69,7 @@ class AWSTranscribeSTTService(STTService):
|
|||||||
api_key: AWS secret access key. If None, uses AWS_SECRET_ACCESS_KEY environment variable.
|
api_key: AWS secret access key. If None, uses AWS_SECRET_ACCESS_KEY environment variable.
|
||||||
aws_access_key_id: AWS access key ID. If None, uses AWS_ACCESS_KEY_ID environment variable.
|
aws_access_key_id: AWS access key ID. If None, uses AWS_ACCESS_KEY_ID environment variable.
|
||||||
aws_session_token: AWS session token for temporary credentials. If None, uses AWS_SESSION_TOKEN environment variable.
|
aws_session_token: AWS session token for temporary credentials. If None, uses AWS_SESSION_TOKEN environment variable.
|
||||||
region: AWS region for the service. Defaults to "us-east-1".
|
region: AWS region for the service.
|
||||||
sample_rate: Audio sample rate in Hz. Must be 8000 or 16000. Defaults to 16000.
|
sample_rate: Audio sample rate in Hz. Must be 8000 or 16000. Defaults to 16000.
|
||||||
language: Language for transcription. Defaults to English.
|
language: Language for transcription. Defaults to English.
|
||||||
**kwargs: Additional arguments passed to parent STTService class.
|
**kwargs: Additional arguments passed to parent STTService class.
|
||||||
@@ -140,8 +140,7 @@ class AWSTranscribeSTTService(STTService):
|
|||||||
return
|
return
|
||||||
logger.warning("WebSocket connection not established after connect")
|
logger.warning("WebSocket connection not established after connect")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
retry_count += 1
|
retry_count += 1
|
||||||
if retry_count < max_retries:
|
if retry_count < max_retries:
|
||||||
await asyncio.sleep(1) # Wait before retrying
|
await asyncio.sleep(1) # Wait before retrying
|
||||||
@@ -182,8 +181,7 @@ class AWSTranscribeSTTService(STTService):
|
|||||||
try:
|
try:
|
||||||
await self._connect()
|
await self._connect()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Format the audio data according to AWS event stream format
|
# Format the audio data according to AWS event stream format
|
||||||
@@ -200,13 +198,11 @@ class AWSTranscribeSTTService(STTService):
|
|||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
# Don't yield error here - we'll retry on next frame
|
# Don't yield error here - we'll retry on next frame
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
|
|
||||||
async def _connect(self):
|
async def _connect(self):
|
||||||
@@ -289,8 +285,7 @@ class AWSTranscribeSTTService(STTService):
|
|||||||
|
|
||||||
await self._call_event_handler("on_connected")
|
await self._call_event_handler("on_connected")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -310,8 +305,7 @@ class AWSTranscribeSTTService(STTService):
|
|||||||
await self._ws_client.send(json.dumps(end_stream))
|
await self._ws_client.send(json.dumps(end_stream))
|
||||||
await self._ws_client.close()
|
await self._ws_client.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
finally:
|
finally:
|
||||||
self._ws_client = None
|
self._ws_client = None
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
@@ -529,15 +523,15 @@ class AWSTranscribeSTTService(STTService):
|
|||||||
)
|
)
|
||||||
elif headers.get(":message-type") == "exception":
|
elif headers.get(":message-type") == "exception":
|
||||||
error_msg = payload.get("Message", "Unknown error")
|
error_msg = payload.get("Message", "Unknown error")
|
||||||
logger.error(f"{self} Exception from AWS: {error_msg}")
|
await self.push_error(error_msg=f"AWS Transcribe error: {error_msg}")
|
||||||
await self.push_frame(ErrorFrame(f"AWS Transcribe error: {error_msg}"))
|
|
||||||
else:
|
else:
|
||||||
logger.debug(f"{self} Other message type received: {headers}")
|
logger.debug(f"{self} Other message type received: {headers}")
|
||||||
logger.debug(f"{self} Payload: {payload}")
|
logger.debug(f"{self} Payload: {payload}")
|
||||||
except websockets.exceptions.ConnectionClosed as e:
|
except websockets.exceptions.ConnectionClosed as e:
|
||||||
logger.error(f"{self} WebSocket connection closed in receive loop: {e}")
|
await self.push_error(
|
||||||
|
error_msg=f"WebSocket connection closed in receive loop", exception=e
|
||||||
|
)
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -312,7 +312,6 @@ class AWSPollyTTSService(TTSService):
|
|||||||
|
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame()
|
||||||
except (BotoCoreError, ClientError) as error:
|
except (BotoCoreError, ClientError) as error:
|
||||||
logger.exception(f"{self} error generating TTS: {error}")
|
|
||||||
error_message = f"AWS Polly TTS error: {str(error)}"
|
error_message = f"AWS Polly TTS error: {str(error)}"
|
||||||
yield ErrorFrame(error=error_message)
|
yield ErrorFrame(error=error_message)
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ class AzureImageGenServiceREST(ImageGenService):
|
|||||||
while status != "succeeded":
|
while status != "succeeded":
|
||||||
attempts_left -= 1
|
attempts_left -= 1
|
||||||
if attempts_left == 0:
|
if attempts_left == 0:
|
||||||
logger.error(f"{self} error: image generation timed out")
|
|
||||||
yield ErrorFrame("Image generation timed out")
|
yield ErrorFrame("Image generation timed out")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -104,7 +103,6 @@ class AzureImageGenServiceREST(ImageGenService):
|
|||||||
|
|
||||||
image_url = json_response["result"]["data"][0]["url"] if json_response else None
|
image_url = json_response["result"]["data"][0]["url"] if json_response else None
|
||||||
if not image_url:
|
if not image_url:
|
||||||
logger.error(f"{self} error: image generation failed")
|
|
||||||
yield ErrorFrame("Image generation failed")
|
yield ErrorFrame("Image generation failed")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -61,5 +61,5 @@ class AzureRealtimeLLMService(OpenAIRealtimeLLMService):
|
|||||||
)
|
)
|
||||||
self._receive_task = self.create_task(self._receive_task_handler())
|
self._receive_task = self.create_task(self._receive_task_handler())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} initialization error: {e}")
|
await self.push_error(error_msg=f"initialization error: {e}", exception=e)
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
|
|||||||
@@ -121,8 +121,7 @@ class AzureSTTService(STTService):
|
|||||||
self._audio_stream.write(audio)
|
self._audio_stream.write(audio)
|
||||||
yield None
|
yield None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
|
|
||||||
async def start(self, frame: StartFrame):
|
async def start(self, frame: StartFrame):
|
||||||
"""Start the speech recognition service.
|
"""Start the speech recognition service.
|
||||||
@@ -151,8 +150,9 @@ class AzureSTTService(STTService):
|
|||||||
self._speech_recognizer.recognized.connect(self._on_handle_recognized)
|
self._speech_recognizer.recognized.connect(self._on_handle_recognized)
|
||||||
self._speech_recognizer.start_continuous_recognition_async()
|
self._speech_recognizer.start_continuous_recognition_async()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception during initialization: {e}")
|
await self.push_error(
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
error_msg=f"Uncaught exception during initialization: {e}", exception=e
|
||||||
|
)
|
||||||
|
|
||||||
async def stop(self, frame: EndFrame):
|
async def stop(self, frame: EndFrame):
|
||||||
"""Stop the speech recognition service.
|
"""Stop the speech recognition service.
|
||||||
|
|||||||
@@ -327,7 +327,6 @@ class AzureTTSService(AzureBaseTTSService):
|
|||||||
try:
|
try:
|
||||||
if self._speech_synthesizer is None:
|
if self._speech_synthesizer is None:
|
||||||
error_msg = "Speech synthesizer not initialized."
|
error_msg = "Speech synthesizer not initialized."
|
||||||
logger.error(error_msg)
|
|
||||||
yield ErrorFrame(error=error_msg)
|
yield ErrorFrame(error=error_msg)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -355,15 +354,13 @@ class AzureTTSService(AzureBaseTTSService):
|
|||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame()
|
||||||
# Could add reconnection logic here if needed
|
# Could add reconnection logic here if needed
|
||||||
return
|
return
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
class AzureHttpTTSService(AzureBaseTTSService):
|
class AzureHttpTTSService(AzureBaseTTSService):
|
||||||
@@ -440,5 +437,6 @@ class AzureHttpTTSService(AzureBaseTTSService):
|
|||||||
cancellation_details = result.cancellation_details
|
cancellation_details = result.cancellation_details
|
||||||
logger.warning(f"Speech synthesis canceled: {cancellation_details.reason}")
|
logger.warning(f"Speech synthesis canceled: {cancellation_details.reason}")
|
||||||
if cancellation_details.reason == CancellationReason.Error:
|
if cancellation_details.reason == CancellationReason.Error:
|
||||||
logger.error(f"{self} error: {cancellation_details.error_details}")
|
yield ErrorFrame(
|
||||||
yield ErrorFrame(error=f"{self} error: {cancellation_details.error_details}")
|
error=f"Unknown error occurred: {cancellation_details.error_details}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ This module provides a WebSocket-based STT service that integrates with
|
|||||||
the Cartesia Live transcription API for real-time speech recognition.
|
the Cartesia Live transcription API for real-time speech recognition.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from typing import AsyncGenerator, Optional
|
from typing import AsyncGenerator, Optional
|
||||||
@@ -20,7 +19,6 @@ from loguru import logger
|
|||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
CancelFrame,
|
CancelFrame,
|
||||||
EndFrame,
|
EndFrame,
|
||||||
ErrorFrame,
|
|
||||||
Frame,
|
Frame,
|
||||||
InterimTranscriptionFrame,
|
InterimTranscriptionFrame,
|
||||||
StartFrame,
|
StartFrame,
|
||||||
@@ -160,20 +158,16 @@ class CartesiaSTTService(WebsocketSTTService):
|
|||||||
sample_rate=sample_rate,
|
sample_rate=sample_rate,
|
||||||
)
|
)
|
||||||
|
|
||||||
merged_options = default_options
|
merged_options = default_options.to_dict()
|
||||||
if live_options:
|
if live_options:
|
||||||
merged_options_dict = default_options.to_dict()
|
merged_options.update(live_options.to_dict())
|
||||||
merged_options_dict.update(live_options.to_dict())
|
# Filter out "None" string values
|
||||||
merged_options = CartesiaLiveOptions(
|
merged_options = {
|
||||||
**{
|
k: v for k, v in merged_options.items() if not isinstance(v, str) or v != "None"
|
||||||
k: v
|
|
||||||
for k, v in merged_options_dict.items()
|
|
||||||
if not isinstance(v, str) or v != "None"
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
self._settings = merged_options
|
self._settings = merged_options
|
||||||
self.set_model_name(merged_options.model)
|
self.set_model_name(merged_options["model"])
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
self._base_url = base_url or "api.cartesia.ai"
|
self._base_url = base_url or "api.cartesia.ai"
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
@@ -254,7 +248,7 @@ class CartesiaSTTService(WebsocketSTTService):
|
|||||||
await self._connect_websocket()
|
await self._connect_websocket()
|
||||||
|
|
||||||
if self._websocket and not self._receive_task:
|
if self._websocket and not self._receive_task:
|
||||||
self._receive_task = asyncio.create_task(self._receive_task_handler(self._report_error))
|
self._receive_task = self.create_task(self._receive_task_handler(self._report_error))
|
||||||
|
|
||||||
async def _disconnect(self):
|
async def _disconnect(self):
|
||||||
if self._receive_task:
|
if self._receive_task:
|
||||||
@@ -269,15 +263,14 @@ class CartesiaSTTService(WebsocketSTTService):
|
|||||||
return
|
return
|
||||||
logger.debug("Connecting to Cartesia STT")
|
logger.debug("Connecting to Cartesia STT")
|
||||||
|
|
||||||
params = self._settings.to_dict()
|
params = self._settings
|
||||||
ws_url = f"wss://{self._base_url}/stt/websocket?{urllib.parse.urlencode(params)}"
|
ws_url = f"wss://{self._base_url}/stt/websocket?{urllib.parse.urlencode(params)}"
|
||||||
headers = {"Cartesia-Version": "2025-04-16", "X-API-Key": self._api_key}
|
headers = {"Cartesia-Version": "2025-04-16", "X-API-Key": self._api_key}
|
||||||
|
|
||||||
self._websocket = await websocket_connect(ws_url, additional_headers=headers)
|
self._websocket = await websocket_connect(ws_url, additional_headers=headers)
|
||||||
await self._call_event_handler("on_connected")
|
await self._call_event_handler("on_connected")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
|
|
||||||
async def _disconnect_websocket(self):
|
async def _disconnect_websocket(self):
|
||||||
try:
|
try:
|
||||||
@@ -285,8 +278,7 @@ class CartesiaSTTService(WebsocketSTTService):
|
|||||||
logger.debug("Disconnecting from Cartesia STT")
|
logger.debug("Disconnecting from Cartesia STT")
|
||||||
await self._websocket.close()
|
await self._websocket.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} error closing websocket: {e}")
|
await self.push_error(error_msg=f"Error closing websocket: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
finally:
|
finally:
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
@@ -297,12 +289,15 @@ class CartesiaSTTService(WebsocketSTTService):
|
|||||||
raise Exception("Websocket not connected")
|
raise Exception("Websocket not connected")
|
||||||
|
|
||||||
async def _process_messages(self):
|
async def _process_messages(self):
|
||||||
|
"""Process incoming WebSocket messages."""
|
||||||
async for message in self._get_websocket():
|
async for message in self._get_websocket():
|
||||||
try:
|
try:
|
||||||
data = json.loads(message)
|
data = json.loads(message)
|
||||||
await self._process_response(data)
|
await self._process_response(data)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.warning(f"Received non-JSON message: {message}")
|
logger.warning(f"Received non-JSON message: {message}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing message: {e}")
|
||||||
|
|
||||||
async def _receive_messages(self):
|
async def _receive_messages(self):
|
||||||
while True:
|
while True:
|
||||||
@@ -319,8 +314,7 @@ class CartesiaSTTService(WebsocketSTTService):
|
|||||||
|
|
||||||
elif data["type"] == "error":
|
elif data["type"] == "error":
|
||||||
error_msg = data.get("message", "Unknown error")
|
error_msg = data.get("message", "Unknown error")
|
||||||
logger.error(f"Cartesia error: {error_msg}")
|
await self.push_error(error_msg=error_msg)
|
||||||
await self.push_error(ErrorFrame(error=error_msg))
|
|
||||||
|
|
||||||
@traced_stt
|
@traced_stt
|
||||||
async def _handle_transcription(
|
async def _handle_transcription(
|
||||||
@@ -352,6 +346,7 @@ class CartesiaSTTService(WebsocketSTTService):
|
|||||||
self._user_id,
|
self._user_id,
|
||||||
time_now_iso8601(),
|
time_now_iso8601(),
|
||||||
language,
|
language,
|
||||||
|
result=data,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await self._handle_transcription(transcript, is_final, language)
|
await self._handle_transcription(transcript, is_final, language)
|
||||||
@@ -364,5 +359,6 @@ class CartesiaSTTService(WebsocketSTTService):
|
|||||||
self._user_id,
|
self._user_id,
|
||||||
time_now_iso8601(),
|
time_now_iso8601(),
|
||||||
language,
|
language,
|
||||||
|
result=data,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
import warnings
|
import warnings
|
||||||
from typing import AsyncGenerator, List, Literal, Optional, Union
|
from enum import Enum
|
||||||
|
from typing import AsyncGenerator, List, Literal, Optional
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -125,6 +126,72 @@ def language_to_cartesia_language(language: Language) -> Optional[str]:
|
|||||||
return resolve_language(language, LANGUAGE_MAP, use_base_code=True)
|
return resolve_language(language, LANGUAGE_MAP, use_base_code=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CartesiaEmotion(str, Enum):
|
||||||
|
"""Predefined Emotions supported by Cartesia."""
|
||||||
|
|
||||||
|
# Primary emotions supported by Cartesia
|
||||||
|
NEUTRAL = "neutral"
|
||||||
|
ANGRY = "angry"
|
||||||
|
EXCITED = "excited"
|
||||||
|
CONTENT = "content"
|
||||||
|
SAD = "sad"
|
||||||
|
SCARED = "scared"
|
||||||
|
# Additional emotions supported by Cartesia
|
||||||
|
HAPPY = "happy"
|
||||||
|
ENTHUSIASTIC = "enthusiastic"
|
||||||
|
ELATED = "elated"
|
||||||
|
EUPHORIC = "euphoric"
|
||||||
|
TRIUMPHANT = "triumphant"
|
||||||
|
AMAZED = "amazed"
|
||||||
|
SURPRISED = "surprised"
|
||||||
|
FLIRTATIOUS = "flirtatious"
|
||||||
|
JOKING_COMEDIC = "joking/comedic"
|
||||||
|
CURIOUS = "curious"
|
||||||
|
PEACEFUL = "peaceful"
|
||||||
|
SERENE = "serene"
|
||||||
|
CALM = "calm"
|
||||||
|
GRATEFUL = "grateful"
|
||||||
|
AFFECTIONATE = "affectionate"
|
||||||
|
TRUST = "trust"
|
||||||
|
SYMPATHETIC = "sympathetic"
|
||||||
|
ANTICIPATION = "anticipation"
|
||||||
|
MYSTERIOUS = "mysterious"
|
||||||
|
MAD = "mad"
|
||||||
|
OUTRAGED = "outraged"
|
||||||
|
FRUSTRATED = "frustrated"
|
||||||
|
AGITATED = "agitated"
|
||||||
|
THREATENED = "threatened"
|
||||||
|
DISGUSTED = "disgusted"
|
||||||
|
CONTEMPT = "contempt"
|
||||||
|
ENVIOUS = "envious"
|
||||||
|
SARCASTIC = "sarcastic"
|
||||||
|
IRONIC = "ironic"
|
||||||
|
DEJECTED = "dejected"
|
||||||
|
MELANCHOLIC = "melancholic"
|
||||||
|
DISAPPOINTED = "disappointed"
|
||||||
|
HURT = "hurt"
|
||||||
|
GUILTY = "guilty"
|
||||||
|
BORED = "bored"
|
||||||
|
TIRED = "tired"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
NOSTALGIC = "nostalgic"
|
||||||
|
WISTFUL = "wistful"
|
||||||
|
APOLOGETIC = "apologetic"
|
||||||
|
HESITANT = "hesitant"
|
||||||
|
INSECURE = "insecure"
|
||||||
|
CONFUSED = "confused"
|
||||||
|
RESIGNED = "resigned"
|
||||||
|
ANXIOUS = "anxious"
|
||||||
|
PANICKED = "panicked"
|
||||||
|
ALARMED = "alarmed"
|
||||||
|
PROUD = "proud"
|
||||||
|
CONFIDENT = "confident"
|
||||||
|
DISTANT = "distant"
|
||||||
|
SKEPTICAL = "skeptical"
|
||||||
|
CONTEMPLATIVE = "contemplative"
|
||||||
|
DETERMINED = "determined"
|
||||||
|
|
||||||
|
|
||||||
class CartesiaTTSService(AudioContextWordTTSService):
|
class CartesiaTTSService(AudioContextWordTTSService):
|
||||||
"""Cartesia TTS service with WebSocket streaming and word timestamps.
|
"""Cartesia TTS service with WebSocket streaming and word timestamps.
|
||||||
|
|
||||||
@@ -182,6 +249,10 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
container: Audio container format.
|
container: Audio container format.
|
||||||
params: Additional input parameters for voice customization.
|
params: Additional input parameters for voice customization.
|
||||||
text_aggregator: Custom text aggregator for processing input text.
|
text_aggregator: Custom text aggregator for processing input text.
|
||||||
|
|
||||||
|
.. deprecated:: 0.0.95
|
||||||
|
Use an LLMTextProcessor before the TTSService for custom text aggregation.
|
||||||
|
|
||||||
aggregate_sentences: Whether to aggregate sentences within the TTSService.
|
aggregate_sentences: Whether to aggregate sentences within the TTSService.
|
||||||
**kwargs: Additional arguments passed to the parent service.
|
**kwargs: Additional arguments passed to the parent service.
|
||||||
"""
|
"""
|
||||||
@@ -200,10 +271,18 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
push_text_frames=False,
|
push_text_frames=False,
|
||||||
pause_frame_processing=True,
|
pause_frame_processing=True,
|
||||||
sample_rate=sample_rate,
|
sample_rate=sample_rate,
|
||||||
text_aggregator=text_aggregator or SkipTagsAggregator([("<spell>", "</spell>")]),
|
text_aggregator=text_aggregator,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not text_aggregator:
|
||||||
|
# Always skip tags added for spelled-out text
|
||||||
|
# Note: This is primarily to support backwards compatibility.
|
||||||
|
# The preferred way of taking advantage of Cartesia SSML Tags is
|
||||||
|
# to use an LLMTextProcessor and/or a text_transformer to identify
|
||||||
|
# and insert these tags for the purpose of the TTS service alone.
|
||||||
|
self._text_aggregator = SkipTagsAggregator([("<spell>", "</spell>")])
|
||||||
|
|
||||||
params = params or CartesiaTTSService.InputParams()
|
params = params or CartesiaTTSService.InputParams()
|
||||||
|
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
@@ -257,6 +336,27 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
"""
|
"""
|
||||||
return language_to_cartesia_language(language)
|
return language_to_cartesia_language(language)
|
||||||
|
|
||||||
|
# A set of Cartesia-specific helpers for text transformations
|
||||||
|
def SPELL(text: str) -> str:
|
||||||
|
"""Wrap text in Cartesia spell tag."""
|
||||||
|
return f"<spell>{text}</spell>"
|
||||||
|
|
||||||
|
def EMOTION_TAG(emotion: CartesiaEmotion) -> str:
|
||||||
|
"""Convenience method to create an emotion tag."""
|
||||||
|
return f'<emotion value="{emotion}" />'
|
||||||
|
|
||||||
|
def PAUSE_TAG(seconds: float) -> str:
|
||||||
|
"""Convenience method to create a pause tag."""
|
||||||
|
return f'<break time="{seconds}s" />'
|
||||||
|
|
||||||
|
def VOLUME_TAG(volume: float) -> str:
|
||||||
|
"""Convenience method to create a volume tag."""
|
||||||
|
return f'<volume ratio="{volume}" />'
|
||||||
|
|
||||||
|
def SPEED_TAG(speed: float) -> str:
|
||||||
|
"""Convenience method to create a speed tag."""
|
||||||
|
return f'<speed ratio="{speed}" />'
|
||||||
|
|
||||||
def _is_cjk_language(self, language: str) -> bool:
|
def _is_cjk_language(self, language: str) -> bool:
|
||||||
"""Check if the given language is CJK (Chinese, Japanese, Korean).
|
"""Check if the given language is CJK (Chinese, Japanese, Korean).
|
||||||
|
|
||||||
@@ -397,8 +497,7 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
)
|
)
|
||||||
await self._call_event_handler("on_connected")
|
await self._call_event_handler("on_connected")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_connection_error", f"{e}")
|
await self._call_event_handler("on_connection_error", f"{e}")
|
||||||
|
|
||||||
@@ -410,8 +509,7 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
logger.debug("Disconnecting from Cartesia")
|
logger.debug("Disconnecting from Cartesia")
|
||||||
await self._websocket.close()
|
await self._websocket.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
finally:
|
finally:
|
||||||
self._context_id = None
|
self._context_id = None
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
@@ -464,13 +562,12 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
)
|
)
|
||||||
await self.append_to_audio_context(msg["context_id"], frame)
|
await self.append_to_audio_context(msg["context_id"], frame)
|
||||||
elif msg["type"] == "error":
|
elif msg["type"] == "error":
|
||||||
logger.error(f"{self} error: {msg}")
|
|
||||||
await self.push_frame(TTSStoppedFrame())
|
await self.push_frame(TTSStoppedFrame())
|
||||||
await self.stop_all_metrics()
|
await self.stop_all_metrics()
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {msg['error']}"))
|
await self.push_error(error_msg=f"Error: {msg}")
|
||||||
self._context_id = None
|
self._context_id = None
|
||||||
else:
|
else:
|
||||||
logger.error(f"{self} error, unknown message type: {msg}")
|
await self.push_error(error_msg=f"Error, unknown message type: {msg}")
|
||||||
|
|
||||||
async def _receive_messages(self):
|
async def _receive_messages(self):
|
||||||
while True:
|
while True:
|
||||||
@@ -508,16 +605,14 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
await self._get_websocket().send(msg)
|
await self._get_websocket().send(msg)
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame()
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
return
|
return
|
||||||
yield None
|
yield None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
class CartesiaHttpTTSService(TTSService):
|
class CartesiaHttpTTSService(TTSService):
|
||||||
@@ -708,8 +803,7 @@ class CartesiaHttpTTSService(TTSService):
|
|||||||
async with session.post(url, json=payload, headers=headers) as response:
|
async with session.post(url, json=payload, headers=headers) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
error_text = await response.text()
|
error_text = await response.text()
|
||||||
logger.error(f"Cartesia API error: {error_text}")
|
yield ErrorFrame(error=f"Cartesia API error: {error_text}")
|
||||||
await self.push_error(ErrorFrame(error=f"Cartesia API error: {error_text}"))
|
|
||||||
raise Exception(f"Cartesia API returned status {response.status}: {error_text}")
|
raise Exception(f"Cartesia API returned status {response.status}: {error_text}")
|
||||||
|
|
||||||
audio_data = await response.read()
|
audio_data = await response.read()
|
||||||
@@ -725,8 +819,7 @@ class CartesiaHttpTTSService(TTSService):
|
|||||||
yield frame
|
yield frame
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
finally:
|
finally:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame()
|
||||||
|
|||||||
@@ -150,7 +150,17 @@ class DeepgramFluxSTTService(WebsocketSTTService):
|
|||||||
params=params
|
params=params
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
super().__init__(sample_rate=sample_rate, **kwargs)
|
# Note: For DeepgramFluxSTTService, differently from other processes, we need to create
|
||||||
|
# the _receive_task inside _connect_websocket, because the websocket should only be
|
||||||
|
# considered connected and ready to send audio once we receive from Flux the message
|
||||||
|
# which confirms the connection has been established.
|
||||||
|
# If we try to keep the logic reconnect_on_error, when receiving a message, the
|
||||||
|
# _receive_task_handler would try to reconnect in case of error, invoking the
|
||||||
|
# _connect_websocket again and leading to a case where the first _receive_task_handler
|
||||||
|
# was never destroyed.
|
||||||
|
# So we can keep it here as false, because inside the method send_with_retry, it will
|
||||||
|
# already try to reconnect if needed.
|
||||||
|
super().__init__(sample_rate=sample_rate, reconnect_on_error=False, **kwargs)
|
||||||
|
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
self._url = url
|
self._url = url
|
||||||
@@ -192,8 +202,7 @@ class DeepgramFluxSTTService(WebsocketSTTService):
|
|||||||
try:
|
try:
|
||||||
await self._disconnect_websocket()
|
await self._disconnect_websocket()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
finally:
|
finally:
|
||||||
# Reset state only after everything is cleaned up
|
# Reset state only after everything is cleaned up
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
@@ -235,6 +244,11 @@ class DeepgramFluxSTTService(WebsocketSTTService):
|
|||||||
additional_headers={"Authorization": f"Token {self._api_key}"},
|
additional_headers={"Authorization": f"Token {self._api_key}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
k: v for k, v in self._websocket.response.headers.items() if k.startswith("dg-")
|
||||||
|
}
|
||||||
|
logger.debug(f'{self}: Websocket connection initialized: {{"headers": {headers}}}')
|
||||||
|
|
||||||
# Creating the receiver task
|
# Creating the receiver task
|
||||||
if not self._receive_task:
|
if not self._receive_task:
|
||||||
self._receive_task = self.create_task(
|
self._receive_task = self.create_task(
|
||||||
@@ -251,8 +265,7 @@ class DeepgramFluxSTTService(WebsocketSTTService):
|
|||||||
logger.debug("Connected to Deepgram Flux Websocket")
|
logger.debug("Connected to Deepgram Flux Websocket")
|
||||||
await self._call_event_handler("on_connected")
|
await self._call_event_handler("on_connected")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_connection_error", f"{e}")
|
await self._call_event_handler("on_connection_error", f"{e}")
|
||||||
|
|
||||||
@@ -280,8 +293,7 @@ class DeepgramFluxSTTService(WebsocketSTTService):
|
|||||||
logger.debug("Disconnecting from Deepgram Flux Websocket")
|
logger.debug("Disconnecting from Deepgram Flux Websocket")
|
||||||
await self._websocket.close()
|
await self._websocket.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} error closing websocket: {e}")
|
await self.push_error(error_msg=f"Error closing websocket: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
finally:
|
finally:
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
@@ -291,10 +303,13 @@ class DeepgramFluxSTTService(WebsocketSTTService):
|
|||||||
|
|
||||||
This signals to the server that no more audio data will be sent.
|
This signals to the server that no more audio data will be sent.
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
if self._websocket:
|
if self._websocket:
|
||||||
logger.debug("Sending CloseStream message to Deepgram Flux")
|
logger.debug("Sending CloseStream message to Deepgram Flux")
|
||||||
message = {"type": "CloseStream"}
|
message = {"type": "CloseStream"}
|
||||||
await self._websocket.send(json.dumps(message))
|
await self._websocket.send(json.dumps(message))
|
||||||
|
except Exception as e:
|
||||||
|
await self.push_error(error_msg=f"Error sending closeStream: {e}", exception=e)
|
||||||
|
|
||||||
def can_generate_metrics(self) -> bool:
|
def can_generate_metrics(self) -> bool:
|
||||||
"""Check if this service can generate processing metrics.
|
"""Check if this service can generate processing metrics.
|
||||||
@@ -381,16 +396,13 @@ class DeepgramFluxSTTService(WebsocketSTTService):
|
|||||||
are issues sending the audio data.
|
are issues sending the audio data.
|
||||||
"""
|
"""
|
||||||
if not self._websocket:
|
if not self._websocket:
|
||||||
logger.error("Not connected to Deepgram Flux.")
|
|
||||||
yield ErrorFrame("Not connected to Deepgram Flux.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._last_stt_time = time.monotonic()
|
self._last_stt_time = time.monotonic()
|
||||||
await self.send_with_retry(audio, self._report_error)
|
await self.send_with_retry(audio, self._report_error)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
yield None
|
yield None
|
||||||
@@ -467,8 +479,7 @@ class DeepgramFluxSTTService(WebsocketSTTService):
|
|||||||
# Skip malformed messages
|
# Skip malformed messages
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
# Error will be handled inside WebsocketService->_receive_task_handler
|
# Error will be handled inside WebsocketService->_receive_task_handler
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -233,7 +233,14 @@ class DeepgramSTTService(STTService):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not await self._connection.start(options=self._settings, addons=self._addons):
|
if not await self._connection.start(options=self._settings, addons=self._addons):
|
||||||
logger.error(f"{self}: unable to connect to Deepgram")
|
await self.push_error(error_msg=f"Unable to connect to Deepgram")
|
||||||
|
else:
|
||||||
|
headers = {
|
||||||
|
k: v
|
||||||
|
for k, v in self._connection._socket.response.headers.items()
|
||||||
|
if k.startswith("dg-")
|
||||||
|
}
|
||||||
|
logger.debug(f'{self}: Websocket connection initialized: {{"headers": {headers}}}')
|
||||||
|
|
||||||
async def _disconnect(self):
|
async def _disconnect(self):
|
||||||
if await self._connection.is_connected():
|
if await self._connection.is_connected():
|
||||||
@@ -256,7 +263,7 @@ class DeepgramSTTService(STTService):
|
|||||||
async def _on_error(self, *args, **kwargs):
|
async def _on_error(self, *args, **kwargs):
|
||||||
error: ErrorResponse = kwargs["error"]
|
error: ErrorResponse = kwargs["error"]
|
||||||
logger.warning(f"{self} connection error, will retry: {error}")
|
logger.warning(f"{self} connection error, will retry: {error}")
|
||||||
await self.push_error(ErrorFrame(error=f"{error}"))
|
await self.push_error(error_msg=f"{error}")
|
||||||
await self.stop_all_metrics()
|
await self.stop_all_metrics()
|
||||||
# NOTE(aleix): we don't disconnect (i.e. call finish on the connection)
|
# NOTE(aleix): we don't disconnect (i.e. call finish on the connection)
|
||||||
# because this triggers more errors internally in the Deepgram SDK. So,
|
# because this triggers more errors internally in the Deepgram SDK. So,
|
||||||
|
|||||||
444
src/pipecat/services/deepgram/stt_sagemaker.py
Normal file
444
src/pipecat/services/deepgram/stt_sagemaker.py
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
"""Deepgram speech-to-text service for AWS SageMaker.
|
||||||
|
|
||||||
|
This module provides a Pipecat STT service that connects to Deepgram models
|
||||||
|
deployed on AWS SageMaker endpoints. Uses HTTP/2 bidirectional streaming for
|
||||||
|
low-latency real-time transcription with support for interim results, multiple
|
||||||
|
languages, and various Deepgram features.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import AsyncGenerator, Optional
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from pipecat.frames.frames import (
|
||||||
|
CancelFrame,
|
||||||
|
EndFrame,
|
||||||
|
ErrorFrame,
|
||||||
|
Frame,
|
||||||
|
InterimTranscriptionFrame,
|
||||||
|
StartFrame,
|
||||||
|
TranscriptionFrame,
|
||||||
|
UserStartedSpeakingFrame,
|
||||||
|
UserStoppedSpeakingFrame,
|
||||||
|
)
|
||||||
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
|
from pipecat.services.aws.sagemaker.bidi_client import SageMakerBidiClient
|
||||||
|
from pipecat.services.stt_service import STTService
|
||||||
|
from pipecat.transcriptions.language import Language
|
||||||
|
from pipecat.utils.time import time_now_iso8601
|
||||||
|
from pipecat.utils.tracing.service_decorators import traced_stt
|
||||||
|
|
||||||
|
try:
|
||||||
|
from deepgram import LiveOptions
|
||||||
|
except ModuleNotFoundError as e:
|
||||||
|
logger.error(f"Exception: {e}")
|
||||||
|
logger.error(
|
||||||
|
"In order to use DeepgramSageMakerSTTService, you need to `pip install pipecat-ai[deepgram,sagemaker]`."
|
||||||
|
)
|
||||||
|
raise Exception(f"Missing module: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class DeepgramSageMakerSTTService(STTService):
|
||||||
|
"""Deepgram speech-to-text service for AWS SageMaker.
|
||||||
|
|
||||||
|
Provides real-time speech recognition using Deepgram models deployed on
|
||||||
|
AWS SageMaker endpoints. Uses HTTP/2 bidirectional streaming for low-latency
|
||||||
|
transcription with support for interim results, speaker diarization, and
|
||||||
|
multiple languages.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- AWS credentials configured (via environment variables, AWS CLI, or instance metadata)
|
||||||
|
- A deployed SageMaker endpoint with Deepgram model: https://developers.deepgram.com/docs/deploy-amazon-sagemaker
|
||||||
|
- Deepgram SDK for LiveOptions configuration
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
stt = DeepgramSageMakerSTTService(
|
||||||
|
endpoint_name="my-deepgram-endpoint",
|
||||||
|
region="us-east-2",
|
||||||
|
live_options=LiveOptions(
|
||||||
|
model="nova-3",
|
||||||
|
language="en",
|
||||||
|
interim_results=True,
|
||||||
|
punctuate=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
endpoint_name: str,
|
||||||
|
region: str,
|
||||||
|
sample_rate: Optional[int] = None,
|
||||||
|
live_options: Optional[LiveOptions] = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""Initialize the Deepgram SageMaker STT service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint_name: Name of the SageMaker endpoint with Deepgram model
|
||||||
|
deployed (e.g., "my-deepgram-nova-3-endpoint").
|
||||||
|
region: AWS region where the endpoint is deployed (e.g., "us-east-2").
|
||||||
|
sample_rate: Audio sample rate in Hz. If None, uses value from
|
||||||
|
live_options or defaults to the value from StartFrame.
|
||||||
|
live_options: Deepgram LiveOptions for detailed configuration. If None,
|
||||||
|
uses sensible defaults (nova-3 model, English, interim results enabled).
|
||||||
|
**kwargs: Additional arguments passed to the parent STTService.
|
||||||
|
"""
|
||||||
|
sample_rate = sample_rate or (live_options.sample_rate if live_options else None)
|
||||||
|
super().__init__(sample_rate=sample_rate, **kwargs)
|
||||||
|
|
||||||
|
self._endpoint_name = endpoint_name
|
||||||
|
self._region = region
|
||||||
|
|
||||||
|
# Create default options similar to DeepgramSTTService
|
||||||
|
default_options = LiveOptions(
|
||||||
|
encoding="linear16",
|
||||||
|
language=Language.EN,
|
||||||
|
model="nova-3",
|
||||||
|
channels=1,
|
||||||
|
interim_results=True,
|
||||||
|
punctuate=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge with provided options
|
||||||
|
merged_options = default_options.to_dict()
|
||||||
|
if live_options:
|
||||||
|
default_model = default_options.model
|
||||||
|
merged_options.update(live_options.to_dict())
|
||||||
|
# Handle the "None" string bug from deepgram-sdk
|
||||||
|
if "model" in merged_options and merged_options["model"] == "None":
|
||||||
|
merged_options["model"] = default_model
|
||||||
|
|
||||||
|
# Convert Language enum to string if needed
|
||||||
|
if "language" in merged_options and isinstance(merged_options["language"], Language):
|
||||||
|
merged_options["language"] = merged_options["language"].value
|
||||||
|
|
||||||
|
self.set_model_name(merged_options["model"])
|
||||||
|
self._settings = merged_options
|
||||||
|
|
||||||
|
self._client: Optional[SageMakerBidiClient] = None
|
||||||
|
self._response_task: Optional[asyncio.Task] = None
|
||||||
|
self._keepalive_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
def can_generate_metrics(self) -> bool:
|
||||||
|
"""Check if this service can generate processing metrics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True, as Deepgram SageMaker service supports metrics generation.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def set_model(self, model: str):
|
||||||
|
"""Set the Deepgram model and reconnect.
|
||||||
|
|
||||||
|
Disconnects from the current session, updates the model setting, and
|
||||||
|
establishes a new connection with the updated model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: The Deepgram model name to use (e.g., "nova-3").
|
||||||
|
"""
|
||||||
|
await super().set_model(model)
|
||||||
|
logger.info(f"Switching STT model to: [{model}]")
|
||||||
|
self._settings["model"] = model
|
||||||
|
await self._disconnect()
|
||||||
|
await self._connect()
|
||||||
|
|
||||||
|
async def set_language(self, language: Language):
|
||||||
|
"""Set the recognition language and reconnect.
|
||||||
|
|
||||||
|
Disconnects from the current session, updates the language setting, and
|
||||||
|
establishes a new connection with the updated language.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
language: The language to use for speech recognition (e.g., Language.EN,
|
||||||
|
Language.ES).
|
||||||
|
"""
|
||||||
|
logger.info(f"Switching STT language to: [{language}]")
|
||||||
|
self._settings["language"] = language
|
||||||
|
await self._disconnect()
|
||||||
|
await self._connect()
|
||||||
|
|
||||||
|
async def start(self, frame: StartFrame):
|
||||||
|
"""Start the Deepgram SageMaker STT service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The start frame containing initialization parameters.
|
||||||
|
"""
|
||||||
|
await super().start(frame)
|
||||||
|
self._settings["sample_rate"] = self.sample_rate
|
||||||
|
await self._connect()
|
||||||
|
|
||||||
|
async def stop(self, frame: EndFrame):
|
||||||
|
"""Stop the Deepgram SageMaker STT service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The end frame.
|
||||||
|
"""
|
||||||
|
await super().stop(frame)
|
||||||
|
await self._disconnect()
|
||||||
|
|
||||||
|
async def cancel(self, frame: CancelFrame):
|
||||||
|
"""Cancel the Deepgram SageMaker STT service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The cancel frame.
|
||||||
|
"""
|
||||||
|
await super().cancel(frame)
|
||||||
|
await self._disconnect()
|
||||||
|
|
||||||
|
async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
|
||||||
|
"""Send audio data to Deepgram for transcription.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio: Raw audio bytes to transcribe.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Frame: None (transcription results come via BiDi stream callbacks).
|
||||||
|
"""
|
||||||
|
if self._client and self._client.is_active:
|
||||||
|
try:
|
||||||
|
await self._client.send_audio_chunk(audio)
|
||||||
|
except Exception as e:
|
||||||
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
|
yield None
|
||||||
|
|
||||||
|
async def _connect(self):
|
||||||
|
"""Connect to the SageMaker endpoint and start the BiDi session.
|
||||||
|
|
||||||
|
Builds the Deepgram query string from settings, creates the BiDi client,
|
||||||
|
starts the streaming session, and launches background tasks for processing
|
||||||
|
responses and sending KeepAlive messages.
|
||||||
|
"""
|
||||||
|
logger.debug("Connecting to Deepgram on SageMaker...")
|
||||||
|
|
||||||
|
# Update sample rate in settings
|
||||||
|
self._settings["sample_rate"] = self.sample_rate
|
||||||
|
|
||||||
|
# Build query string from settings, converting booleans to strings
|
||||||
|
query_params = {}
|
||||||
|
for key, value in self._settings.items():
|
||||||
|
if value is not None:
|
||||||
|
# Convert boolean values to lowercase strings for Deepgram API
|
||||||
|
if isinstance(value, bool):
|
||||||
|
query_params[key] = str(value).lower()
|
||||||
|
else:
|
||||||
|
query_params[key] = str(value)
|
||||||
|
|
||||||
|
query_string = "&".join(f"{k}={v}" for k, v in query_params.items())
|
||||||
|
|
||||||
|
# Create BiDi client
|
||||||
|
self._client = SageMakerBidiClient(
|
||||||
|
endpoint_name=self._endpoint_name,
|
||||||
|
region=self._region,
|
||||||
|
model_invocation_path="v1/listen",
|
||||||
|
model_query_string=query_string,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start the session
|
||||||
|
await self._client.start_session()
|
||||||
|
|
||||||
|
# Start processing responses in the background
|
||||||
|
self._response_task = self.create_task(self._process_responses())
|
||||||
|
|
||||||
|
# Start keepalive task to maintain connection
|
||||||
|
self._keepalive_task = self.create_task(self._send_keepalive())
|
||||||
|
|
||||||
|
logger.debug("Connected to Deepgram on SageMaker")
|
||||||
|
await self._call_event_handler("on_connected")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
|
await self._call_event_handler("on_connection_error", str(e))
|
||||||
|
|
||||||
|
async def _disconnect(self):
|
||||||
|
"""Disconnect from the SageMaker endpoint.
|
||||||
|
|
||||||
|
Sends a CloseStream message to Deepgram, cancels background tasks
|
||||||
|
(KeepAlive and response processing), and closes the BiDi session.
|
||||||
|
Safe to call multiple times.
|
||||||
|
"""
|
||||||
|
if self._client and self._client.is_active:
|
||||||
|
logger.debug("Disconnecting from Deepgram on SageMaker...")
|
||||||
|
|
||||||
|
# Send CloseStream message to Deepgram
|
||||||
|
try:
|
||||||
|
await self._client.send_json({"type": "CloseStream"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send CloseStream message: {e}")
|
||||||
|
|
||||||
|
# Cancel keepalive task
|
||||||
|
if self._keepalive_task and not self._keepalive_task.done():
|
||||||
|
await self.cancel_task(self._keepalive_task)
|
||||||
|
|
||||||
|
# Cancel response processing task
|
||||||
|
if self._response_task and not self._response_task.done():
|
||||||
|
await self.cancel_task(self._response_task)
|
||||||
|
|
||||||
|
# Close the BiDi session
|
||||||
|
await self._client.close_session()
|
||||||
|
|
||||||
|
logger.debug("Disconnected from Deepgram on SageMaker")
|
||||||
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
|
async def _send_keepalive(self):
|
||||||
|
"""Send periodic KeepAlive messages to maintain the connection.
|
||||||
|
|
||||||
|
Sends a KeepAlive JSON message to Deepgram every 5 seconds while the
|
||||||
|
connection is active. This prevents the connection from timing out during
|
||||||
|
periods of silence.
|
||||||
|
"""
|
||||||
|
while self._client and self._client.is_active:
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
if self._client and self._client.is_active:
|
||||||
|
try:
|
||||||
|
await self._client.send_json({"type": "KeepAlive"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send KeepAlive: {e}")
|
||||||
|
|
||||||
|
async def _process_responses(self):
|
||||||
|
"""Process streaming responses from Deepgram on SageMaker.
|
||||||
|
|
||||||
|
Continuously receives responses from the BiDi stream, decodes the payload,
|
||||||
|
parses JSON responses from Deepgram, and processes transcription results.
|
||||||
|
Runs as a background task until the connection is closed or cancelled.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
while self._client and self._client.is_active:
|
||||||
|
result = await self._client.receive_response()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if this is a PayloadPart with bytes
|
||||||
|
if hasattr(result, "value") and hasattr(result.value, "bytes_"):
|
||||||
|
if result.value.bytes_:
|
||||||
|
response_data = result.value.bytes_.decode("utf-8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse JSON response from Deepgram
|
||||||
|
parsed = json.loads(response_data)
|
||||||
|
|
||||||
|
# Extract and process transcript if available
|
||||||
|
if "channel" in parsed:
|
||||||
|
await self._handle_transcript_response(parsed)
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning(f"Non-JSON response: {response_data}")
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.debug("Response processor cancelled")
|
||||||
|
except Exception as e:
|
||||||
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
|
finally:
|
||||||
|
logger.debug("Response processor stopped")
|
||||||
|
|
||||||
|
async def _handle_transcript_response(self, parsed: dict):
|
||||||
|
"""Handle a transcript response from Deepgram.
|
||||||
|
|
||||||
|
Extracts the transcript text, determines if it's final or interim, extracts
|
||||||
|
language information, and pushes the appropriate frame (TranscriptionFrame
|
||||||
|
or InterimTranscriptionFrame) downstream.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parsed: The parsed JSON response from Deepgram containing channel,
|
||||||
|
alternatives, transcript, and metadata.
|
||||||
|
"""
|
||||||
|
alternatives = parsed.get("channel", {}).get("alternatives", [])
|
||||||
|
if not alternatives or not alternatives[0].get("transcript"):
|
||||||
|
return
|
||||||
|
|
||||||
|
transcript = alternatives[0]["transcript"]
|
||||||
|
if not transcript.strip():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Stop TTFB metrics on first transcript
|
||||||
|
await self.stop_ttfb_metrics()
|
||||||
|
|
||||||
|
is_final = parsed.get("is_final", False)
|
||||||
|
speech_final = parsed.get("speech_final", False)
|
||||||
|
|
||||||
|
# Extract language if available
|
||||||
|
language = None
|
||||||
|
if alternatives[0].get("languages"):
|
||||||
|
language = alternatives[0]["languages"][0]
|
||||||
|
language = Language(language)
|
||||||
|
|
||||||
|
if is_final and speech_final:
|
||||||
|
# Final transcription
|
||||||
|
await self.push_frame(
|
||||||
|
TranscriptionFrame(
|
||||||
|
transcript,
|
||||||
|
self._user_id,
|
||||||
|
time_now_iso8601(),
|
||||||
|
language,
|
||||||
|
result=parsed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self._handle_transcription(transcript, is_final, language)
|
||||||
|
await self.stop_processing_metrics()
|
||||||
|
else:
|
||||||
|
# Interim transcription
|
||||||
|
await self.push_frame(
|
||||||
|
InterimTranscriptionFrame(
|
||||||
|
transcript,
|
||||||
|
self._user_id,
|
||||||
|
time_now_iso8601(),
|
||||||
|
language,
|
||||||
|
result=parsed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@traced_stt
|
||||||
|
async def _handle_transcription(
|
||||||
|
self, transcript: str, is_final: bool, language: Optional[Language] = None
|
||||||
|
):
|
||||||
|
"""Handle a transcription result with tracing.
|
||||||
|
|
||||||
|
This method is decorated with @traced_stt for observability and tracing
|
||||||
|
integration. The actual transcription processing is handled by the parent
|
||||||
|
class and observers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
transcript: The transcribed text.
|
||||||
|
is_final: Whether this is a final transcription result.
|
||||||
|
language: The detected language of the transcription, if available.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def start_metrics(self):
|
||||||
|
"""Start TTFB and processing metrics collection."""
|
||||||
|
await self.start_ttfb_metrics()
|
||||||
|
await self.start_processing_metrics()
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
"""Process frames with Deepgram SageMaker-specific handling.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The frame to process.
|
||||||
|
direction: The direction of frame processing.
|
||||||
|
"""
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
|
||||||
|
# Start metrics when user starts speaking (if VAD is not provided by Deepgram)
|
||||||
|
if isinstance(frame, UserStartedSpeakingFrame):
|
||||||
|
await self.start_metrics()
|
||||||
|
elif isinstance(frame, UserStoppedSpeakingFrame):
|
||||||
|
# Send finalize message to Deepgram when user stops speaking
|
||||||
|
# This tells Deepgram to flush any remaining audio and return final results
|
||||||
|
if self._client and self._client.is_active:
|
||||||
|
try:
|
||||||
|
await self._client.send_json({"type": "Finalize"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error sending Finalize message: {e}")
|
||||||
@@ -10,35 +10,45 @@ This module provides integration with Deepgram's text-to-speech API
|
|||||||
for generating speech from text using various voice models.
|
for generating speech from text using various voice models.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
from typing import AsyncGenerator, Optional
|
from typing import AsyncGenerator, Optional
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
|
CancelFrame,
|
||||||
|
EndFrame,
|
||||||
ErrorFrame,
|
ErrorFrame,
|
||||||
Frame,
|
Frame,
|
||||||
|
InterruptionFrame,
|
||||||
|
LLMFullResponseEndFrame,
|
||||||
|
StartFrame,
|
||||||
TTSAudioRawFrame,
|
TTSAudioRawFrame,
|
||||||
TTSStartedFrame,
|
TTSStartedFrame,
|
||||||
TTSStoppedFrame,
|
TTSStoppedFrame,
|
||||||
)
|
)
|
||||||
from pipecat.services.tts_service import TTSService
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
|
from pipecat.services.tts_service import TTSService, WebsocketTTSService
|
||||||
from pipecat.utils.tracing.service_decorators import traced_tts
|
from pipecat.utils.tracing.service_decorators import traced_tts
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from deepgram import DeepgramClient, DeepgramClientOptions, SpeakOptions
|
from websockets.asyncio.client import connect as websocket_connect
|
||||||
|
from websockets.protocol import State
|
||||||
except ModuleNotFoundError as e:
|
except ModuleNotFoundError as e:
|
||||||
logger.error(f"Exception: {e}")
|
logger.error(f"Exception: {e}")
|
||||||
logger.error("In order to use Deepgram, you need to `pip install pipecat-ai[deepgram]`.")
|
logger.error(
|
||||||
|
"In order to use DeepgramWebsocketTTSService, you need to `pip install pipecat-ai[deepgram]`."
|
||||||
|
)
|
||||||
raise Exception(f"Missing module: {e}")
|
raise Exception(f"Missing module: {e}")
|
||||||
|
|
||||||
|
|
||||||
class DeepgramTTSService(TTSService):
|
class DeepgramTTSService(WebsocketTTSService):
|
||||||
"""Deepgram text-to-speech service.
|
"""Deepgram WebSocket-based text-to-speech service.
|
||||||
|
|
||||||
Provides text-to-speech synthesis using Deepgram's streaming API.
|
Provides real-time text-to-speech synthesis using Deepgram's WebSocket API.
|
||||||
Supports various voice models and audio encoding formats with
|
Supports streaming audio generation with interruption handling via the Clear
|
||||||
configurable sample rates and quality settings.
|
message for conversational AI use cases.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -46,42 +56,220 @@ class DeepgramTTSService(TTSService):
|
|||||||
*,
|
*,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
voice: str = "aura-2-helena-en",
|
voice: str = "aura-2-helena-en",
|
||||||
base_url: str = "",
|
base_url: str = "wss://api.deepgram.com",
|
||||||
sample_rate: Optional[int] = None,
|
sample_rate: Optional[int] = None,
|
||||||
encoding: str = "linear16",
|
encoding: str = "linear16",
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the Deepgram TTS service.
|
"""Initialize the Deepgram WebSocket TTS service.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_key: Deepgram API key for authentication.
|
api_key: Deepgram API key for authentication.
|
||||||
voice: Voice model to use for synthesis. Defaults to "aura-2-helena-en".
|
voice: Voice model to use for synthesis. Defaults to "aura-2-helena-en".
|
||||||
base_url: Custom base URL for Deepgram API. Uses default if empty.
|
base_url: WebSocket base URL for Deepgram API. Defaults to "wss://api.deepgram.com".
|
||||||
sample_rate: Audio sample rate in Hz. If None, uses service default.
|
sample_rate: Audio sample rate in Hz. If None, uses service default.
|
||||||
encoding: Audio encoding format. Defaults to "linear16".
|
encoding: Audio encoding format. Defaults to "linear16".
|
||||||
**kwargs: Additional arguments passed to parent TTSService class.
|
**kwargs: Additional arguments passed to parent InterruptibleTTSService class.
|
||||||
"""
|
"""
|
||||||
super().__init__(sample_rate=sample_rate, **kwargs)
|
super().__init__(
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
pause_frame_processing=True,
|
||||||
|
push_stop_frames=True,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._api_key = api_key
|
||||||
|
self._base_url = base_url
|
||||||
self._settings = {
|
self._settings = {
|
||||||
"encoding": encoding,
|
"encoding": encoding,
|
||||||
}
|
}
|
||||||
self.set_voice(voice)
|
self.set_voice(voice)
|
||||||
|
|
||||||
client_options = DeepgramClientOptions(url=base_url)
|
self._receive_task = None
|
||||||
self._deepgram_client = DeepgramClient(api_key, config=client_options)
|
|
||||||
|
|
||||||
def can_generate_metrics(self) -> bool:
|
def can_generate_metrics(self) -> bool:
|
||||||
"""Check if the service can generate metrics.
|
"""Check if the service can generate metrics.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True, as Deepgram TTS service supports metrics generation.
|
True, as Deepgram WebSocket TTS service supports metrics generation.
|
||||||
"""
|
"""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def start(self, frame: StartFrame):
|
||||||
|
"""Start the Deepgram WebSocket TTS service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The start frame containing initialization parameters.
|
||||||
|
"""
|
||||||
|
await super().start(frame)
|
||||||
|
await self._connect()
|
||||||
|
|
||||||
|
async def stop(self, frame: EndFrame):
|
||||||
|
"""Stop the Deepgram WebSocket TTS service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The end frame.
|
||||||
|
"""
|
||||||
|
await super().stop(frame)
|
||||||
|
await self._disconnect()
|
||||||
|
|
||||||
|
async def cancel(self, frame: CancelFrame):
|
||||||
|
"""Cancel the Deepgram WebSocket TTS service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The cancel frame.
|
||||||
|
"""
|
||||||
|
await super().cancel(frame)
|
||||||
|
await self._disconnect()
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
"""Process frames with special handling for LLM response end.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The frame to process.
|
||||||
|
direction: The direction of frame processing.
|
||||||
|
"""
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
|
||||||
|
# When the LLM finishes responding, flush any remaining text in Deepgram's buffer
|
||||||
|
if isinstance(frame, (LLMFullResponseEndFrame, EndFrame)):
|
||||||
|
await self.flush_audio()
|
||||||
|
|
||||||
|
async def _connect(self):
|
||||||
|
"""Connect to Deepgram WebSocket and start receive task."""
|
||||||
|
await self._connect_websocket()
|
||||||
|
|
||||||
|
if self._websocket and not self._receive_task:
|
||||||
|
self._receive_task = self.create_task(self._receive_task_handler(self._report_error))
|
||||||
|
|
||||||
|
async def _disconnect(self):
|
||||||
|
"""Disconnect from Deepgram WebSocket and clean up tasks."""
|
||||||
|
if self._receive_task:
|
||||||
|
await self.cancel_task(self._receive_task)
|
||||||
|
self._receive_task = None
|
||||||
|
|
||||||
|
await self._disconnect_websocket()
|
||||||
|
|
||||||
|
async def _connect_websocket(self):
|
||||||
|
"""Connect to Deepgram WebSocket API with configured settings."""
|
||||||
|
try:
|
||||||
|
if self._websocket and self._websocket.state is State.OPEN:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug("Connecting to Deepgram WebSocket")
|
||||||
|
|
||||||
|
# Build WebSocket URL with query parameters
|
||||||
|
params = []
|
||||||
|
params.append(f"model={self._voice_id}")
|
||||||
|
params.append(f"encoding={self._settings['encoding']}")
|
||||||
|
params.append(f"sample_rate={self.sample_rate}")
|
||||||
|
|
||||||
|
url = f"{self._base_url}/v1/speak?{'&'.join(params)}"
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Token {self._api_key}"}
|
||||||
|
|
||||||
|
self._websocket = await websocket_connect(url, additional_headers=headers)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
k: v for k, v in self._websocket.response.headers.items() if k.startswith("dg-")
|
||||||
|
}
|
||||||
|
logger.debug(f'{self}: Websocket connection initialized: {{"headers": {headers}}}')
|
||||||
|
|
||||||
|
await self._call_event_handler("on_connected")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"{self} exception: {e}")
|
||||||
|
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
||||||
|
self._websocket = None
|
||||||
|
await self._call_event_handler("on_connection_error", f"{e}")
|
||||||
|
|
||||||
|
async def _disconnect_websocket(self):
|
||||||
|
"""Close WebSocket connection and reset state."""
|
||||||
|
try:
|
||||||
|
await self.stop_all_metrics()
|
||||||
|
|
||||||
|
if self._websocket:
|
||||||
|
logger.debug("Disconnecting from Deepgram WebSocket")
|
||||||
|
# Send Close message to gracefully close the connection
|
||||||
|
await self._websocket.send(json.dumps({"type": "Close"}))
|
||||||
|
await self._websocket.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"{self} exception: {e}")
|
||||||
|
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
||||||
|
finally:
|
||||||
|
self._websocket = None
|
||||||
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
|
def _get_websocket(self):
|
||||||
|
"""Get active websocket connection or raise exception."""
|
||||||
|
if self._websocket:
|
||||||
|
return self._websocket
|
||||||
|
raise Exception("Websocket not connected")
|
||||||
|
|
||||||
|
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
|
||||||
|
"""Handle interruption by sending Clear message to Deepgram.
|
||||||
|
|
||||||
|
The Clear message will clear Deepgram's internal text buffer and stop
|
||||||
|
sending audio, allowing for a new response to be generated.
|
||||||
|
"""
|
||||||
|
await super()._handle_interruption(frame, direction)
|
||||||
|
|
||||||
|
# Send Clear message to stop current audio generation
|
||||||
|
if self._websocket:
|
||||||
|
try:
|
||||||
|
clear_msg = {"type": "Clear"}
|
||||||
|
await self._websocket.send(json.dumps(clear_msg))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"{self} error sending Clear message: {e}")
|
||||||
|
|
||||||
|
async def _receive_messages(self):
|
||||||
|
"""Receive and process messages from Deepgram WebSocket."""
|
||||||
|
async for message in self._get_websocket():
|
||||||
|
if isinstance(message, bytes):
|
||||||
|
# Binary message contains audio data
|
||||||
|
await self.stop_ttfb_metrics()
|
||||||
|
frame = TTSAudioRawFrame(message, self.sample_rate, 1)
|
||||||
|
await self.push_frame(frame)
|
||||||
|
elif isinstance(message, str):
|
||||||
|
# Text message contains metadata or control messages
|
||||||
|
try:
|
||||||
|
msg = json.loads(message)
|
||||||
|
msg_type = msg.get("type")
|
||||||
|
|
||||||
|
if msg_type == "Metadata":
|
||||||
|
logger.trace(f"Received metadata: {msg}")
|
||||||
|
elif msg_type == "Flushed":
|
||||||
|
logger.trace(f"Received Flushed: {msg}")
|
||||||
|
# Flushed indicates the end of audio generation for the current buffer
|
||||||
|
# This happens after flush_audio() is called
|
||||||
|
elif msg_type == "Cleared":
|
||||||
|
logger.trace(f"Received Cleared: {msg}")
|
||||||
|
# Buffer has been cleared after interruption
|
||||||
|
# TTSStoppedFrame will be sent by the interruption handler
|
||||||
|
elif msg_type == "Warning":
|
||||||
|
logger.warning(
|
||||||
|
f"{self} warning: {msg.get('description', 'Unknown warning')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(f"Received unknown message type: {msg}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error(f"Invalid JSON message: {message}")
|
||||||
|
|
||||||
|
async def flush_audio(self):
|
||||||
|
"""Flush any pending audio synthesis by sending Flush command.
|
||||||
|
|
||||||
|
This should be called when the LLM finishes a complete response to force
|
||||||
|
generation of audio from Deepgram's internal text buffer.
|
||||||
|
"""
|
||||||
|
if self._websocket:
|
||||||
|
try:
|
||||||
|
flush_msg = {"type": "Flush"}
|
||||||
|
await self._websocket.send(json.dumps(flush_msg))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"{self} error sending Flush message: {e}")
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Deepgram's TTS API.
|
"""Generate speech from text using Deepgram's WebSocket TTS API.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text to synthesize into speech.
|
text: The text to synthesize into speech.
|
||||||
@@ -91,33 +279,27 @@ class DeepgramTTSService(TTSService):
|
|||||||
"""
|
"""
|
||||||
logger.debug(f"{self}: Generating TTS [{text}]")
|
logger.debug(f"{self}: Generating TTS [{text}]")
|
||||||
|
|
||||||
options = SpeakOptions(
|
|
||||||
model=self._voice_id,
|
|
||||||
encoding=self._settings["encoding"],
|
|
||||||
sample_rate=self.sample_rate,
|
|
||||||
container="none",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Reconnect if the websocket is closed
|
||||||
|
if not self._websocket or self._websocket.state is State.CLOSED:
|
||||||
|
await self._connect()
|
||||||
|
|
||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
|
|
||||||
response = await self._deepgram_client.speak.asyncrest.v("1").stream_raw(
|
|
||||||
{"text": text}, options
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame()
|
||||||
|
|
||||||
async for data in response.aiter_bytes():
|
# Send text message to Deepgram
|
||||||
await self.stop_ttfb_metrics()
|
# Note: We don't send Flush here - that should only be sent when the
|
||||||
if data:
|
# LLM finishes a complete response via flush_audio()
|
||||||
yield TTSAudioRawFrame(audio=data, sample_rate=self.sample_rate, num_channels=1)
|
speak_msg = {"type": "Speak", "text": text}
|
||||||
|
await self._get_websocket().send(json.dumps(speak_msg))
|
||||||
|
|
||||||
yield TTSStoppedFrame()
|
# The audio frames will be handled in _receive_messages
|
||||||
|
yield None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
class DeepgramHttpTTSService(TTSService):
|
class DeepgramHttpTTSService(TTSService):
|
||||||
@@ -227,5 +409,4 @@ class DeepgramHttpTTSService(TTSService):
|
|||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"{self} exception: {e}")
|
|
||||||
yield ErrorFrame(f"Error getting audio: {str(e)}")
|
yield ErrorFrame(f"Error getting audio: {str(e)}")
|
||||||
|
|||||||
@@ -351,8 +351,7 @@ class ElevenLabsSTTService(SegmentedSTTService):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def audio_format_from_sample_rate(sample_rate: int) -> str:
|
def audio_format_from_sample_rate(sample_rate: int) -> str:
|
||||||
@@ -416,6 +415,8 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService):
|
|||||||
Only used when commit_strategy is VAD. None uses ElevenLabs default.
|
Only used when commit_strategy is VAD. None uses ElevenLabs default.
|
||||||
min_silence_duration_ms: Minimum silence duration for VAD (50-2000ms).
|
min_silence_duration_ms: Minimum silence duration for VAD (50-2000ms).
|
||||||
Only used when commit_strategy is VAD. None uses ElevenLabs default.
|
Only used when commit_strategy is VAD. None uses ElevenLabs default.
|
||||||
|
include_timestamps: Whether to include word-level timestamps in transcripts.
|
||||||
|
enable_logging: Whether to enable logging on ElevenLabs' side.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
language_code: Optional[str] = None
|
language_code: Optional[str] = None
|
||||||
@@ -424,6 +425,8 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService):
|
|||||||
vad_threshold: Optional[float] = None
|
vad_threshold: Optional[float] = None
|
||||||
min_speech_duration_ms: Optional[int] = None
|
min_speech_duration_ms: Optional[int] = None
|
||||||
min_silence_duration_ms: Optional[int] = None
|
min_silence_duration_ms: Optional[int] = None
|
||||||
|
include_timestamps: bool = False
|
||||||
|
enable_logging: bool = False
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -459,6 +462,8 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService):
|
|||||||
self._audio_format = "" # initialized in start()
|
self._audio_format = "" # initialized in start()
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
|
|
||||||
|
self._settings = {"language": params.language_code}
|
||||||
|
|
||||||
def can_generate_metrics(self) -> bool:
|
def can_generate_metrics(self) -> bool:
|
||||||
"""Check if the service can generate processing metrics.
|
"""Check if the service can generate processing metrics.
|
||||||
|
|
||||||
@@ -477,7 +482,13 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService):
|
|||||||
Changing language requires reconnecting to the WebSocket.
|
Changing language requires reconnecting to the WebSocket.
|
||||||
"""
|
"""
|
||||||
logger.info(f"Switching STT language to: [{language}]")
|
logger.info(f"Switching STT language to: [{language}]")
|
||||||
self._params.language_code = language.value if isinstance(language, Language) else language
|
new_language = (
|
||||||
|
language_to_elevenlabs_language(language)
|
||||||
|
if isinstance(language, Language)
|
||||||
|
else language
|
||||||
|
)
|
||||||
|
self._params.language_code = new_language
|
||||||
|
self._settings["language"] = new_language
|
||||||
# Reconnect with new settings
|
# Reconnect with new settings
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
@@ -586,7 +597,6 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService):
|
|||||||
}
|
}
|
||||||
await self._websocket.send(json.dumps(message))
|
await self._websocket.send(json.dumps(message))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending audio: {e}")
|
|
||||||
yield ErrorFrame(f"ElevenLabs Realtime STT error: {str(e)}")
|
yield ErrorFrame(f"ElevenLabs Realtime STT error: {str(e)}")
|
||||||
|
|
||||||
yield None
|
yield None
|
||||||
@@ -620,10 +630,16 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService):
|
|||||||
if self._params.language_code:
|
if self._params.language_code:
|
||||||
params.append(f"language_code={self._params.language_code}")
|
params.append(f"language_code={self._params.language_code}")
|
||||||
|
|
||||||
params.append(f"encoding={self._audio_format}")
|
params.append(f"audio_format={self._audio_format}")
|
||||||
params.append(f"sample_rate={self.sample_rate}")
|
|
||||||
params.append(f"commit_strategy={self._params.commit_strategy.value}")
|
params.append(f"commit_strategy={self._params.commit_strategy.value}")
|
||||||
|
|
||||||
|
# Add optional parameters
|
||||||
|
if self._params.include_timestamps:
|
||||||
|
params.append(f"include_timestamps={str(self._params.include_timestamps).lower()}")
|
||||||
|
|
||||||
|
if self._params.enable_logging:
|
||||||
|
params.append(f"enable_logging={str(self._params.enable_logging).lower()}")
|
||||||
|
|
||||||
# Add VAD parameters if using VAD commit strategy and values are specified
|
# Add VAD parameters if using VAD commit strategy and values are specified
|
||||||
if self._params.commit_strategy == CommitStrategy.VAD:
|
if self._params.commit_strategy == CommitStrategy.VAD:
|
||||||
if self._params.vad_silence_threshold_secs is not None:
|
if self._params.vad_silence_threshold_secs is not None:
|
||||||
@@ -645,8 +661,9 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService):
|
|||||||
await self._call_event_handler("on_connected")
|
await self._call_event_handler("on_connected")
|
||||||
logger.debug("Connected to ElevenLabs Realtime STT")
|
logger.debug("Connected to ElevenLabs Realtime STT")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self}: unable to connect to ElevenLabs Realtime STT: {e}")
|
await self.push_error(
|
||||||
await self.push_error(ErrorFrame(f"Connection error: {str(e)}"))
|
error_msg=f"Unable to connect to ElevenLabs Realtime STT: {e}", exception=e
|
||||||
|
)
|
||||||
|
|
||||||
async def _disconnect_websocket(self):
|
async def _disconnect_websocket(self):
|
||||||
"""Disconnect from ElevenLabs Realtime STT WebSocket."""
|
"""Disconnect from ElevenLabs Realtime STT WebSocket."""
|
||||||
@@ -655,7 +672,7 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService):
|
|||||||
logger.debug("Disconnecting from ElevenLabs Realtime STT")
|
logger.debug("Disconnecting from ElevenLabs Realtime STT")
|
||||||
await self._websocket.close()
|
await self._websocket.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} error closing websocket: {e}")
|
await self.push_error(error_msg=f"Error closing websocket: {e}", exception=e)
|
||||||
finally:
|
finally:
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
@@ -712,15 +729,20 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService):
|
|||||||
elif message_type == "committed_transcript_with_timestamps":
|
elif message_type == "committed_transcript_with_timestamps":
|
||||||
await self._on_committed_transcript_with_timestamps(data)
|
await self._on_committed_transcript_with_timestamps(data)
|
||||||
|
|
||||||
elif message_type == "input_error":
|
elif message_type == "error":
|
||||||
error_msg = data.get("error", "Unknown input error")
|
error_msg = data.get("error", "Unknown error")
|
||||||
logger.error(f"ElevenLabs input error: {error_msg}")
|
logger.error(f"ElevenLabs error: {error_msg}")
|
||||||
await self.push_error(ErrorFrame(f"Input error: {error_msg}"))
|
await self.push_error(error_msg=f"Error: {error_msg}")
|
||||||
|
|
||||||
elif message_type in ["auth_error", "quota_exceeded", "transcriber_error", "error"]:
|
elif message_type == "auth_error":
|
||||||
error_msg = data.get("error", data.get("message", "Unknown error"))
|
error_msg = data.get("error", "Authentication error")
|
||||||
logger.error(f"ElevenLabs error ({message_type}): {error_msg}")
|
logger.error(f"ElevenLabs auth error: {error_msg}")
|
||||||
await self.push_error(ErrorFrame(f"{message_type}: {error_msg}"))
|
await self.push_error(error_msg=f"Auth error: {error_msg}")
|
||||||
|
|
||||||
|
elif message_type == "quota_exceeded_error":
|
||||||
|
error_msg = data.get("error", "Quota exceeded")
|
||||||
|
logger.error(f"ElevenLabs quota exceeded: {error_msg}")
|
||||||
|
await self.push_error(error_msg=f"Quota exceeded: {error_msg}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Unknown message type: {message_type}")
|
logger.debug(f"Unknown message type: {message_type}")
|
||||||
@@ -765,6 +787,11 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService):
|
|||||||
Args:
|
Args:
|
||||||
data: Committed transcript data.
|
data: Committed transcript data.
|
||||||
"""
|
"""
|
||||||
|
# If timestamps are enabled, skip this message and wait for the
|
||||||
|
# committed_transcript_with_timestamps message which contains all the data
|
||||||
|
if self._params.include_timestamps:
|
||||||
|
return
|
||||||
|
|
||||||
text = data.get("text", "").strip()
|
text = data.get("text", "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
@@ -792,6 +819,18 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService):
|
|||||||
async def _on_committed_transcript_with_timestamps(self, data: dict):
|
async def _on_committed_transcript_with_timestamps(self, data: dict):
|
||||||
"""Handle committed transcript with word-level timestamps.
|
"""Handle committed transcript with word-level timestamps.
|
||||||
|
|
||||||
|
This message is sent when include_timestamps=true. The result data includes:
|
||||||
|
- text: The transcribed text
|
||||||
|
- language_code: Detected language (if available)
|
||||||
|
- words: Array of word objects with timing information:
|
||||||
|
- text: The word text
|
||||||
|
- start: Start time in seconds
|
||||||
|
- end: End time in seconds
|
||||||
|
- type: "word" or "spacing"
|
||||||
|
- speaker_id: Speaker identifier (if available)
|
||||||
|
- logprob: Log probability score (if available)
|
||||||
|
- characters: Array of character strings (if available)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: Committed transcript data with timestamps.
|
data: Committed transcript data with timestamps.
|
||||||
"""
|
"""
|
||||||
@@ -799,9 +838,24 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService):
|
|||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.debug(f"Committed transcript with timestamps: [{text}]")
|
await self.stop_ttfb_metrics()
|
||||||
logger.trace(f"Timestamps: {data.get('words', [])}")
|
await self.stop_processing_metrics()
|
||||||
|
|
||||||
# This is sent after the committed_transcript, so we don't need to
|
# Get language if provided
|
||||||
# push another TranscriptionFrame, but we could use the timestamps
|
language = data.get("language_code")
|
||||||
# for additional processing if needed in the future
|
|
||||||
|
logger.debug(f"Committed transcript with timestamps: [{text}]")
|
||||||
|
|
||||||
|
await self._handle_transcription(text, True, language)
|
||||||
|
|
||||||
|
# This message is sent after committed_transcript when include_timestamps=true.
|
||||||
|
# It contains the full transcript data including text and word-level timestamps.
|
||||||
|
await self.push_frame(
|
||||||
|
TranscriptionFrame(
|
||||||
|
text,
|
||||||
|
self._user_id,
|
||||||
|
time_now_iso8601(),
|
||||||
|
language,
|
||||||
|
result=data,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ def build_elevenlabs_voice_settings(
|
|||||||
class PronunciationDictionaryLocator(BaseModel):
|
class PronunciationDictionaryLocator(BaseModel):
|
||||||
"""Locator for a pronunciation dictionary.
|
"""Locator for a pronunciation dictionary.
|
||||||
|
|
||||||
Attributes:
|
Parameters:
|
||||||
pronunciation_dictionary_id: The ID of the pronunciation dictionary.
|
pronunciation_dictionary_id: The ID of the pronunciation dictionary.
|
||||||
version_id: The version ID of the pronunciation dictionary.
|
version_id: The version ID of the pronunciation dictionary.
|
||||||
"""
|
"""
|
||||||
@@ -424,8 +424,7 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
json.dumps({"context_id": self._context_id, "close_context": True})
|
json.dumps({"context_id": self._context_id, "close_context": True})
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
self._context_id = None
|
self._context_id = None
|
||||||
self._started = False
|
self._started = False
|
||||||
|
|
||||||
@@ -536,9 +535,8 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
|
|
||||||
await self._call_event_handler("on_connected")
|
await self._call_event_handler("on_connected")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self._call_event_handler("on_connection_error", f"{e}")
|
await self._call_event_handler("on_connection_error", f"{e}")
|
||||||
|
|
||||||
async def _disconnect_websocket(self):
|
async def _disconnect_websocket(self):
|
||||||
@@ -553,8 +551,7 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
await self._websocket.close()
|
await self._websocket.close()
|
||||||
logger.debug("Disconnected from ElevenLabs")
|
logger.debug("Disconnected from ElevenLabs")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
finally:
|
finally:
|
||||||
self._started = False
|
self._started = False
|
||||||
self._context_id = None
|
self._context_id = None
|
||||||
@@ -584,8 +581,7 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
json.dumps({"context_id": self._context_id, "close_context": True})
|
json.dumps({"context_id": self._context_id, "close_context": True})
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
self._context_id = None
|
self._context_id = None
|
||||||
self._started = False
|
self._started = False
|
||||||
self._partial_word = ""
|
self._partial_word = ""
|
||||||
@@ -737,18 +733,14 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
|
|
||||||
await self._send_text(text)
|
await self._send_text(text)
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
else:
|
|
||||||
await self._send_text(text)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame()
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
self._started = False
|
self._started = False
|
||||||
return
|
return
|
||||||
yield None
|
yield None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
class ElevenLabsHttpTTSService(WordTTSService):
|
class ElevenLabsHttpTTSService(WordTTSService):
|
||||||
@@ -1043,7 +1035,6 @@ class ElevenLabsHttpTTSService(WordTTSService):
|
|||||||
) as response:
|
) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
error_text = await response.text()
|
error_text = await response.text()
|
||||||
logger.error(f"{self} error: {error_text}")
|
|
||||||
yield ErrorFrame(error=f"ElevenLabs API error: {error_text}")
|
yield ErrorFrame(error=f"ElevenLabs API error: {error_text}")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1091,8 +1082,7 @@ class ElevenLabsHttpTTSService(WordTTSService):
|
|||||||
logger.warning(f"Failed to parse JSON from stream: {e}")
|
logger.warning(f"Failed to parse JSON from stream: {e}")
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# After processing all chunks, emit any remaining partial word
|
# After processing all chunks, emit any remaining partial word
|
||||||
@@ -1116,8 +1106,7 @@ class ElevenLabsHttpTTSService(WordTTSService):
|
|||||||
self._previous_text = text
|
self._previous_text = text
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
finally:
|
finally:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
# Let the parent class handle TTSStoppedFrame
|
# Let the parent class handle TTSStoppedFrame
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ class FalImageGenService(ImageGenService):
|
|||||||
image_url = response["images"][0]["url"] if response else None
|
image_url = response["images"][0]["url"] if response else None
|
||||||
|
|
||||||
if not image_url:
|
if not image_url:
|
||||||
logger.error(f"{self} error: image generation failed")
|
|
||||||
yield ErrorFrame("Image generation failed")
|
yield ErrorFrame("Image generation failed")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -290,5 +290,4 @@ class FalSTTService(SegmentedSTTService):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class FishAudioTTSService(InterruptibleTTSService):
|
|||||||
api_key: str,
|
api_key: str,
|
||||||
reference_id: Optional[str] = None, # This is the voice ID
|
reference_id: Optional[str] = None, # This is the voice ID
|
||||||
model: Optional[str] = None, # Deprecated
|
model: Optional[str] = None, # Deprecated
|
||||||
model_id: str = "speech-1.5",
|
model_id: str = "s1",
|
||||||
output_format: FishAudioOutputFormat = "pcm",
|
output_format: FishAudioOutputFormat = "pcm",
|
||||||
sample_rate: Optional[int] = None,
|
sample_rate: Optional[int] = None,
|
||||||
params: Optional[InputParams] = None,
|
params: Optional[InputParams] = None,
|
||||||
@@ -93,7 +93,7 @@ class FishAudioTTSService(InterruptibleTTSService):
|
|||||||
The `model` parameter is deprecated and will be removed in version 0.1.0.
|
The `model` parameter is deprecated and will be removed in version 0.1.0.
|
||||||
Use `reference_id` instead to specify the voice model.
|
Use `reference_id` instead to specify the voice model.
|
||||||
|
|
||||||
model_id: Specify which Fish Audio TTS model to use (e.g. "speech-1.5")
|
model_id: Specify which Fish Audio TTS model to use (e.g. "s1")
|
||||||
output_format: Audio output format. Defaults to "pcm".
|
output_format: Audio output format. Defaults to "pcm".
|
||||||
sample_rate: Audio sample rate. If None, uses default.
|
sample_rate: Audio sample rate. If None, uses default.
|
||||||
params: Additional input parameters for voice customization.
|
params: Additional input parameters for voice customization.
|
||||||
@@ -228,8 +228,7 @@ class FishAudioTTSService(InterruptibleTTSService):
|
|||||||
|
|
||||||
await self._call_event_handler("on_connected")
|
await self._call_event_handler("on_connected")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_connection_error", f"{e}")
|
await self._call_event_handler("on_connection_error", f"{e}")
|
||||||
|
|
||||||
@@ -243,8 +242,7 @@ class FishAudioTTSService(InterruptibleTTSService):
|
|||||||
await self._websocket.send(ormsgpack.packb(stop_message))
|
await self._websocket.send(ormsgpack.packb(stop_message))
|
||||||
await self._websocket.close()
|
await self._websocket.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
finally:
|
finally:
|
||||||
self._request_id = None
|
self._request_id = None
|
||||||
self._started = False
|
self._started = False
|
||||||
@@ -286,8 +284,7 @@ class FishAudioTTSService(InterruptibleTTSService):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
||||||
@@ -323,8 +320,7 @@ class FishAudioTTSService(InterruptibleTTSService):
|
|||||||
flush_message = {"event": "flush"}
|
flush_message = {"event": "flush"}
|
||||||
await self._get_websocket().send(ormsgpack.packb(flush_message))
|
await self._get_websocket().send(ormsgpack.packb(flush_message))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame()
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
@@ -332,5 +328,4 @@ class FishAudioTTSService(InterruptibleTTSService):
|
|||||||
yield None
|
yield None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
|
|||||||
@@ -468,8 +468,7 @@ class GladiaSTTService(STTService):
|
|||||||
break
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
self._connection_active = False
|
self._connection_active = False
|
||||||
|
|
||||||
if not self._should_reconnect:
|
if not self._should_reconnect:
|
||||||
@@ -559,8 +558,7 @@ class GladiaSTTService(STTService):
|
|||||||
except websockets.exceptions.ConnectionClosed:
|
except websockets.exceptions.ConnectionClosed:
|
||||||
logger.debug("Connection closed during keepalive")
|
logger.debug("Connection closed during keepalive")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
|
|
||||||
async def _receive_task_handler(self):
|
async def _receive_task_handler(self):
|
||||||
try:
|
try:
|
||||||
@@ -623,8 +621,7 @@ class GladiaSTTService(STTService):
|
|||||||
# Expected when closing the connection
|
# Expected when closing the connection
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
|
|
||||||
async def _maybe_reconnect(self) -> bool:
|
async def _maybe_reconnect(self) -> bool:
|
||||||
"""Handle exponential backoff reconnection logic."""
|
"""Handle exponential backoff reconnection logic."""
|
||||||
@@ -632,7 +629,9 @@ class GladiaSTTService(STTService):
|
|||||||
return False
|
return False
|
||||||
self._reconnection_attempts += 1
|
self._reconnection_attempts += 1
|
||||||
if self._reconnection_attempts > self._max_reconnection_attempts:
|
if self._reconnection_attempts > self._max_reconnection_attempts:
|
||||||
logger.error(f"Max reconnection attempts ({self._max_reconnection_attempts}) reached")
|
await self.push_error(
|
||||||
|
error_msg=f"Max reconnection attempts ({self._max_reconnection_attempts}) reached",
|
||||||
|
)
|
||||||
self._should_reconnect = False
|
self._should_reconnect = False
|
||||||
return False
|
return False
|
||||||
delay = self._reconnection_delay * (2 ** (self._reconnection_attempts - 1))
|
delay = self._reconnection_delay * (2 ** (self._reconnection_attempts - 1))
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from pydantic import BaseModel, Field
|
|||||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||||
from pipecat.adapters.services.gemini_adapter import GeminiLLMAdapter
|
from pipecat.adapters.services.gemini_adapter import GeminiLLMAdapter
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
|
AggregationType,
|
||||||
BotStartedSpeakingFrame,
|
BotStartedSpeakingFrame,
|
||||||
BotStoppedSpeakingFrame,
|
BotStoppedSpeakingFrame,
|
||||||
CancelFrame,
|
CancelFrame,
|
||||||
@@ -1174,7 +1175,7 @@ class GeminiLiveLLMService(LLMService):
|
|||||||
self._connection_task = self.create_task(self._connection_task_handler(config=config))
|
self._connection_task = self.create_task(self._connection_task_handler(config=config))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.push_error(ErrorFrame(error=f"{self} Initialization error: {e}"))
|
await self.push_error(error_msg=f"Initialization error: {e}", exception=e)
|
||||||
|
|
||||||
async def _connection_task_handler(self, config: LiveConnectConfig):
|
async def _connection_task_handler(self, config: LiveConnectConfig):
|
||||||
async with self._client.aio.live.connect(model=self._model_name, config=config) as session:
|
async with self._client.aio.live.connect(model=self._model_name, config=config) as session:
|
||||||
@@ -1251,11 +1252,11 @@ class GeminiLiveLLMService(LLMService):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if self._consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
if self._consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||||
logger.error(
|
error_msg = (
|
||||||
f"Max consecutive failures ({MAX_CONSECUTIVE_FAILURES}) reached, "
|
f"Max consecutive failures ({MAX_CONSECUTIVE_FAILURES}) reached, "
|
||||||
"treating as fatal error"
|
"treating as fatal error"
|
||||||
)
|
)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} Error in receive loop: {error}"))
|
await self.push_error(error_msg=error_msg, exception=error)
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1283,7 +1284,7 @@ class GeminiLiveLLMService(LLMService):
|
|||||||
self._completed_tool_calls = set()
|
self._completed_tool_calls = set()
|
||||||
self._disconnecting = False
|
self._disconnecting = False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} error disconnecting: {e}")
|
await self.push_error(error_msg=f"Error disconnecting: {e}", exception=e)
|
||||||
|
|
||||||
async def _send_user_audio(self, frame):
|
async def _send_user_audio(self, frame):
|
||||||
"""Send user audio frame to Gemini Live API."""
|
"""Send user audio frame to Gemini Live API."""
|
||||||
@@ -1644,7 +1645,7 @@ class GeminiLiveLLMService(LLMService):
|
|||||||
await self.push_frame(TTSStartedFrame())
|
await self.push_frame(TTSStartedFrame())
|
||||||
await self.push_frame(LLMFullResponseStartFrame())
|
await self.push_frame(LLMFullResponseStartFrame())
|
||||||
|
|
||||||
frame = TTSTextFrame(text=text)
|
frame = TTSTextFrame(text=text, aggregated_by=AggregationType.SENTENCE)
|
||||||
# Gemini Live text already includes any necessary inter-chunk spaces
|
# Gemini Live text already includes any necessary inter-chunk spaces
|
||||||
frame.includes_inter_frame_spaces = True
|
frame.includes_inter_frame_spaces = True
|
||||||
|
|
||||||
@@ -1722,6 +1723,8 @@ class GeminiLiveLLMService(LLMService):
|
|||||||
prompt_tokens=prompt_tokens,
|
prompt_tokens=prompt_tokens,
|
||||||
completion_tokens=completion_tokens,
|
completion_tokens=completion_tokens,
|
||||||
total_tokens=total_tokens,
|
total_tokens=total_tokens,
|
||||||
|
cache_read_input_tokens=usage.cached_content_token_count,
|
||||||
|
reasoning_tokens=usage.thoughts_token_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.start_llm_usage_metrics(tokens)
|
await self.start_llm_usage_metrics(tokens)
|
||||||
@@ -1742,7 +1745,7 @@ class GeminiLiveLLMService(LLMService):
|
|||||||
# state management, and that exponential backoff for retries can have
|
# state management, and that exponential backoff for retries can have
|
||||||
# cost/stability implications for a service cluster, let's just treat a
|
# cost/stability implications for a service cluster, let's just treat a
|
||||||
# send-side error as fatal.
|
# send-side error as fatal.
|
||||||
await self.push_error(ErrorFrame(error=f"{self} Send error: {error}", fatal=True))
|
await self.push_error(error_msg=f"Send error: {error}")
|
||||||
|
|
||||||
def create_context_aggregator(
|
def create_context_aggregator(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ class GoogleImageGenService(ImageGenService):
|
|||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
|
|
||||||
if not response or not response.generated_images:
|
if not response or not response.generated_images:
|
||||||
logger.error(f"{self} error: image generation failed")
|
|
||||||
yield ErrorFrame("Image generation failed")
|
yield ErrorFrame("Image generation failed")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -128,5 +127,4 @@ class GoogleImageGenService(ImageGenService):
|
|||||||
yield frame
|
yield frame
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} error generating image: {e}")
|
|
||||||
yield ErrorFrame(f"Image generation error: {str(e)}")
|
yield ErrorFrame(f"Image generation error: {str(e)}")
|
||||||
|
|||||||
@@ -793,7 +793,7 @@ class GoogleLLMService(LLMService):
|
|||||||
return
|
return
|
||||||
generation_params.setdefault("thinking_config", {})["thinking_budget"] = 0
|
generation_params.setdefault("thinking_config", {})["thinking_budget"] = 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Failed to unset thinking budget: {e}")
|
logger.error(f"Failed to unset thinking budget: {e}")
|
||||||
|
|
||||||
async def _stream_content(
|
async def _stream_content(
|
||||||
self, params_from_context: GeminiLLMInvocationParams
|
self, params_from_context: GeminiLLMInvocationParams
|
||||||
@@ -983,7 +983,7 @@ class GoogleLLMService(LLMService):
|
|||||||
except DeadlineExceeded:
|
except DeadlineExceeded:
|
||||||
await self._call_event_handler("on_completion_timeout")
|
await self._call_event_handler("on_completion_timeout")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
finally:
|
finally:
|
||||||
if grounding_metadata and isinstance(grounding_metadata, dict):
|
if grounding_metadata and isinstance(grounding_metadata, dict):
|
||||||
llm_search_frame = LLMSearchResponseFrame(
|
llm_search_frame = LLMSearchResponseFrame(
|
||||||
|
|||||||
@@ -774,8 +774,7 @@ class GoogleSTTService(STTService):
|
|||||||
yield cloud_speech.StreamingRecognizeRequest(audio=audio_data)
|
yield cloud_speech.StreamingRecognizeRequest(audio=audio_data)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def _stream_audio(self):
|
async def _stream_audio(self):
|
||||||
@@ -806,15 +805,13 @@ class GoogleSTTService(STTService):
|
|||||||
break
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
|
|
||||||
await asyncio.sleep(1) # Brief delay before reconnecting
|
await asyncio.sleep(1) # Brief delay before reconnecting
|
||||||
self._stream_start_time = int(time.time() * 1000)
|
self._stream_start_time = int(time.time() * 1000)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
|
|
||||||
async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
|
async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
|
||||||
"""Process an audio chunk for STT transcription.
|
"""Process an audio chunk for STT transcription.
|
||||||
@@ -902,8 +899,7 @@ class GoogleSTTService(STTService):
|
|||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
# Re-raise the exception to let it propagate (e.g. in the case of a
|
# Re-raise the exception to let it propagate (e.g. in the case of a
|
||||||
# timeout, propagate to _stream_audio to reconnect)
|
# timeout, propagate to _stream_audio to reconnect)
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -737,7 +737,6 @@ class GoogleHttpTTSService(TTSService):
|
|||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
|
||||||
error_message = f"TTS generation error: {str(e)}"
|
error_message = f"TTS generation error: {str(e)}"
|
||||||
yield ErrorFrame(error=error_message)
|
yield ErrorFrame(error=error_message)
|
||||||
|
|
||||||
@@ -996,9 +995,7 @@ class GoogleTTSService(GoogleBaseTTSService):
|
|||||||
yield frame
|
yield frame
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"TTS generation error: {str(e)}", exception=e)
|
||||||
error_message = f"TTS generation error: {str(e)}"
|
|
||||||
yield ErrorFrame(error=error_message)
|
|
||||||
|
|
||||||
|
|
||||||
class GeminiTTSService(GoogleBaseTTSService):
|
class GeminiTTSService(GoogleBaseTTSService):
|
||||||
@@ -1248,6 +1245,5 @@ class GeminiTTSService(GoogleBaseTTSService):
|
|||||||
yield frame
|
yield frame
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
|
||||||
error_message = f"Gemini TTS generation error: {str(e)}"
|
error_message = f"Gemini TTS generation error: {str(e)}"
|
||||||
yield ErrorFrame(error=error_message)
|
yield ErrorFrame(error=error_message)
|
||||||
|
|||||||
5
src/pipecat/services/gradium/__init__.py
Normal file
5
src/pipecat/services/gradium/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
239
src/pipecat/services/gradium/stt.py
Normal file
239
src/pipecat/services/gradium/stt.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
"""Gradium's speech-to-text service implementation.
|
||||||
|
|
||||||
|
This module provides integration with Gradium's real-time speech-to-text
|
||||||
|
WebSocket API for streaming audio transcription.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from pipecat.frames.frames import (
|
||||||
|
CancelFrame,
|
||||||
|
EndFrame,
|
||||||
|
Frame,
|
||||||
|
StartFrame,
|
||||||
|
TranscriptionFrame,
|
||||||
|
)
|
||||||
|
from pipecat.services.stt_service import WebsocketSTTService
|
||||||
|
from pipecat.transcriptions.language import Language
|
||||||
|
from pipecat.utils.time import time_now_iso8601
|
||||||
|
from pipecat.utils.tracing.service_decorators import traced_stt
|
||||||
|
|
||||||
|
try:
|
||||||
|
from websockets.asyncio.client import connect as websocket_connect
|
||||||
|
from websockets.protocol import State
|
||||||
|
except ModuleNotFoundError as e:
|
||||||
|
logger.error(f"Exception: {e}")
|
||||||
|
logger.error('In order to use Gradium, you need to `pip install "pipecat-ai[gradium]"`.')
|
||||||
|
raise Exception(f"Missing module: {e}")
|
||||||
|
|
||||||
|
SAMPLE_RATE = 24000
|
||||||
|
|
||||||
|
|
||||||
|
class GradiumSTTService(WebsocketSTTService):
|
||||||
|
"""Gradium real-time speech-to-text service.
|
||||||
|
|
||||||
|
Provides real-time speech transcription using Gradium's WebSocket API.
|
||||||
|
Supports both interim and final transcriptions with configurable parameters
|
||||||
|
for audio processing and connection management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
api_key: str,
|
||||||
|
api_endpoint_base_url: str = "wss://eu.api.gradium.ai/api/speech/asr",
|
||||||
|
json_config: str | None = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""Initialize the Gradium STT service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: Gradium API key for authentication.
|
||||||
|
api_endpoint_base_url: WebSocket endpoint URL. Defaults to Gradium's streaming endpoint.
|
||||||
|
json_config: Optional JSON configuration string for additional model settings.
|
||||||
|
**kwargs: Additional arguments passed to parent STTService class.
|
||||||
|
"""
|
||||||
|
super().__init__(sample_rate=SAMPLE_RATE, **kwargs)
|
||||||
|
|
||||||
|
self._api_key = api_key
|
||||||
|
self._api_endpoint_base_url = api_endpoint_base_url
|
||||||
|
self._websocket = None
|
||||||
|
self._json_config = json_config
|
||||||
|
|
||||||
|
self._receive_task = None
|
||||||
|
|
||||||
|
self._audio_buffer = bytearray()
|
||||||
|
self._chunk_size_ms = 80
|
||||||
|
self._chunk_size_bytes = 0
|
||||||
|
|
||||||
|
def can_generate_metrics(self) -> bool:
|
||||||
|
"""Check if the service can generate metrics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if metrics generation is supported.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def start(self, frame: StartFrame):
|
||||||
|
"""Start the speech-to-text service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: Start frame to begin processing.
|
||||||
|
"""
|
||||||
|
await super().start(frame)
|
||||||
|
self._chunk_size_bytes = int(self._chunk_size_ms * self.sample_rate * 2 / 1000)
|
||||||
|
await self._connect()
|
||||||
|
|
||||||
|
async def stop(self, frame: EndFrame):
|
||||||
|
"""Stop the speech-to-text service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: End frame to stop processing.
|
||||||
|
"""
|
||||||
|
await super().stop(frame)
|
||||||
|
await self._disconnect()
|
||||||
|
|
||||||
|
async def cancel(self, frame: CancelFrame):
|
||||||
|
"""Cancel the speech-to-text service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: Cancel frame to abort processing.
|
||||||
|
"""
|
||||||
|
await super().cancel(frame)
|
||||||
|
await self._disconnect()
|
||||||
|
|
||||||
|
async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
|
||||||
|
"""Process audio data for speech-to-text conversion.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio: Raw audio bytes to process.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
None (processing handled via WebSocket messages).
|
||||||
|
"""
|
||||||
|
self._audio_buffer.extend(audio)
|
||||||
|
await self.start_ttfb_metrics()
|
||||||
|
await self.start_processing_metrics()
|
||||||
|
|
||||||
|
while len(self._audio_buffer) >= self._chunk_size_bytes:
|
||||||
|
chunk = bytes(self._audio_buffer[: self._chunk_size_bytes])
|
||||||
|
self._audio_buffer = self._audio_buffer[self._chunk_size_bytes :]
|
||||||
|
chunk = base64.b64encode(chunk).decode("utf-8")
|
||||||
|
msg = {"type": "audio", "audio": chunk}
|
||||||
|
if self._websocket and self._websocket.state is State.OPEN:
|
||||||
|
await self._websocket.send(json.dumps(msg))
|
||||||
|
|
||||||
|
yield None
|
||||||
|
|
||||||
|
@traced_stt
|
||||||
|
async def _trace_transcription(self, transcript: str, is_final: bool, language: Language):
|
||||||
|
"""Record transcription event for tracing."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _connect(self):
|
||||||
|
await self._connect_websocket()
|
||||||
|
|
||||||
|
if self._websocket and not self._receive_task:
|
||||||
|
self._receive_task = self.create_task(self._receive_task_handler(self._report_error))
|
||||||
|
|
||||||
|
async def _connect_websocket(self):
|
||||||
|
try:
|
||||||
|
if self._websocket and self._websocket.state is State.OPEN:
|
||||||
|
return
|
||||||
|
ws_url = self._api_endpoint_base_url
|
||||||
|
headers = {
|
||||||
|
"x-api-key": self._api_key,
|
||||||
|
"x-api-source": "pipecat",
|
||||||
|
}
|
||||||
|
self._websocket = await websocket_connect(
|
||||||
|
ws_url,
|
||||||
|
additional_headers=headers,
|
||||||
|
)
|
||||||
|
await self._call_event_handler("on_connected")
|
||||||
|
setup_msg = {
|
||||||
|
"type": "setup",
|
||||||
|
"input_format": "pcm",
|
||||||
|
}
|
||||||
|
if self._json_config is not None:
|
||||||
|
setup_msg["json_config"] = self._json_config
|
||||||
|
await self._websocket.send(json.dumps(setup_msg))
|
||||||
|
ready_msg = await self._websocket.recv()
|
||||||
|
ready_msg = json.loads(ready_msg)
|
||||||
|
if ready_msg["type"] == "error":
|
||||||
|
raise Exception(f"received error {ready_msg['message']}")
|
||||||
|
if ready_msg["type"] != "ready":
|
||||||
|
raise Exception(f"unexpected first message type {ready_msg['type']}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _disconnect(self):
|
||||||
|
if self._receive_task:
|
||||||
|
await self.cancel_task(self._receive_task)
|
||||||
|
self._receive_task = None
|
||||||
|
|
||||||
|
await self._disconnect_websocket()
|
||||||
|
|
||||||
|
async def _disconnect_websocket(self):
|
||||||
|
try:
|
||||||
|
if self._websocket and self._websocket.state is State.OPEN:
|
||||||
|
logger.debug("Disconnecting from Gradium STT")
|
||||||
|
await self._websocket.close()
|
||||||
|
except Exception as e:
|
||||||
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
|
finally:
|
||||||
|
self._websocket = None
|
||||||
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
|
def _get_websocket(self):
|
||||||
|
if self._websocket:
|
||||||
|
return self._websocket
|
||||||
|
raise Exception("Websocket not connected")
|
||||||
|
|
||||||
|
async def _process_messages(self):
|
||||||
|
async for message in self._get_websocket():
|
||||||
|
try:
|
||||||
|
data = json.loads(message)
|
||||||
|
await self._process_response(data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning(f"Received non-JSON message: {message}")
|
||||||
|
|
||||||
|
async def _receive_messages(self):
|
||||||
|
while True:
|
||||||
|
await self._process_messages()
|
||||||
|
logger.debug(f"{self} Gradium connection was disconnected (timeout?), reconnecting")
|
||||||
|
await self._connect_websocket()
|
||||||
|
|
||||||
|
async def _process_response(self, msg):
|
||||||
|
type_ = msg.get("type", "")
|
||||||
|
if type_ == "text":
|
||||||
|
await self._handle_text(msg["text"])
|
||||||
|
elif type_ == "end_of_stream":
|
||||||
|
await self._handle_end_of_stream()
|
||||||
|
elif type_ == "error":
|
||||||
|
await self.push_error(error_msg=f"Error: {msg}")
|
||||||
|
|
||||||
|
async def _handle_end_of_stream(self):
|
||||||
|
"""Handle termination message."""
|
||||||
|
logger.debug("Received end_of_stream message from server")
|
||||||
|
|
||||||
|
async def _handle_text(self, text: str):
|
||||||
|
"""Handle transcription results."""
|
||||||
|
await self.push_frame(
|
||||||
|
TranscriptionFrame(
|
||||||
|
text,
|
||||||
|
self._user_id,
|
||||||
|
time_now_iso8601(),
|
||||||
|
)
|
||||||
|
)
|
||||||
315
src/pipecat/services/gradium/tts.py
Normal file
315
src/pipecat/services/gradium/tts.py
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
# Copyright (c) 2024–2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
|
||||||
|
"""Gradium Text-to-Speech service implementation."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any, AsyncGenerator, Mapping, Optional
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from pipecat.frames.frames import (
|
||||||
|
CancelFrame,
|
||||||
|
EndFrame,
|
||||||
|
ErrorFrame,
|
||||||
|
Frame,
|
||||||
|
StartFrame,
|
||||||
|
TTSAudioRawFrame,
|
||||||
|
TTSStartedFrame,
|
||||||
|
TTSStoppedFrame,
|
||||||
|
)
|
||||||
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
|
from pipecat.services.tts_service import InterruptibleWordTTSService
|
||||||
|
from pipecat.utils.tracing.service_decorators import traced_tts
|
||||||
|
|
||||||
|
try:
|
||||||
|
from websockets import ConnectionClosedOK
|
||||||
|
from websockets.asyncio.client import connect as websocket_connect
|
||||||
|
from websockets.protocol import State
|
||||||
|
except ModuleNotFoundError as e:
|
||||||
|
logger.error(f"Exception: {e}")
|
||||||
|
logger.error("In order to use Gradium, you need to `pip install pipecat-ai[gradium]`.")
|
||||||
|
raise Exception(f"Missing module: {e}")
|
||||||
|
|
||||||
|
SAMPLE_RATE = 48000
|
||||||
|
|
||||||
|
|
||||||
|
class GradiumTTSService(InterruptibleWordTTSService):
|
||||||
|
"""Text-to-Speech service using Gradium's websocket API."""
|
||||||
|
|
||||||
|
class InputParams(BaseModel):
|
||||||
|
"""Configuration parameters for Gradium TTS service.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
temp: Temperature to be used for generation, defaults to 0.6.
|
||||||
|
"""
|
||||||
|
|
||||||
|
temp: Optional[float] = 0.6
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
api_key: str,
|
||||||
|
voice_id: str = "YTpq7expH9539ERJ",
|
||||||
|
url: str = "wss://eu.api.gradium.ai/api/speech/tts",
|
||||||
|
model: str = "default",
|
||||||
|
json_config: Optional[str] = None,
|
||||||
|
params: Optional[InputParams] = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""Initialize the Gradium TTS service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: Gradium API key for authentication.
|
||||||
|
voice_id: the voice identifier.
|
||||||
|
url: Gradium websocket API endpoint.
|
||||||
|
model: Model ID to use for synthesis.
|
||||||
|
json_config: Optional JSON configuration string for additional model settings.
|
||||||
|
params: Additional configuration parameters.
|
||||||
|
**kwargs: Additional arguments passed to parent class.
|
||||||
|
"""
|
||||||
|
# Initialize with parent class settings for proper frame handling
|
||||||
|
super().__init__(
|
||||||
|
push_stop_frames=True,
|
||||||
|
pause_frame_processing=True,
|
||||||
|
sample_rate=SAMPLE_RATE,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
params = params or GradiumTTSService.InputParams()
|
||||||
|
|
||||||
|
# Store service configuration
|
||||||
|
self._api_key = api_key
|
||||||
|
self._url = url
|
||||||
|
self._voice_id = voice_id
|
||||||
|
self._json_config = json_config
|
||||||
|
self._model = model
|
||||||
|
self._settings = {
|
||||||
|
"voice_id": voice_id,
|
||||||
|
"model_name": model,
|
||||||
|
"output_format": "pcm",
|
||||||
|
}
|
||||||
|
|
||||||
|
# State tracking
|
||||||
|
self._receive_task = None
|
||||||
|
|
||||||
|
def can_generate_metrics(self) -> bool:
|
||||||
|
"""Check if this service can generate processing metrics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True, as Gradium service supports metrics generation.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def set_model(self, model: str):
|
||||||
|
"""Update the TTS model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: The model name to use for synthesis.
|
||||||
|
"""
|
||||||
|
self._model = model
|
||||||
|
await super().set_model(model)
|
||||||
|
|
||||||
|
async def _update_settings(self, settings: Mapping[str, Any]):
|
||||||
|
"""Update service settings and reconnect if voice changed."""
|
||||||
|
prev_voice = self._voice_id
|
||||||
|
await super()._update_settings(settings)
|
||||||
|
if not prev_voice == self._voice_id:
|
||||||
|
self._settings["voice_id"] = self._voice_id
|
||||||
|
logger.info(f"Switching TTS voice to: [{self._voice_id}]")
|
||||||
|
await self._disconnect()
|
||||||
|
await self._connect()
|
||||||
|
|
||||||
|
def _build_msg(self, text: str = "") -> dict:
|
||||||
|
"""Build JSON message for Gradium API."""
|
||||||
|
return {"text": text, "type": "text"}
|
||||||
|
|
||||||
|
async def start(self, frame: StartFrame):
|
||||||
|
"""Start the service and establish websocket connection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The start frame containing initialization parameters.
|
||||||
|
"""
|
||||||
|
await super().start(frame)
|
||||||
|
await self._connect()
|
||||||
|
|
||||||
|
async def stop(self, frame: EndFrame):
|
||||||
|
"""Stop the service and close connection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The end frame.
|
||||||
|
"""
|
||||||
|
await super().stop(frame)
|
||||||
|
await self._disconnect()
|
||||||
|
|
||||||
|
async def cancel(self, frame: CancelFrame):
|
||||||
|
"""Cancel current operation and clean up.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The cancel frame.
|
||||||
|
"""
|
||||||
|
await super().cancel(frame)
|
||||||
|
await self._disconnect()
|
||||||
|
|
||||||
|
async def _connect(self):
|
||||||
|
"""Establish websocket connection and start receive task."""
|
||||||
|
logger.debug(f"{self}: connecting")
|
||||||
|
|
||||||
|
# If the server disconnected, cancel the receive-task so that it can be reset below.
|
||||||
|
if self._websocket is None or self._websocket.state is not State.OPEN:
|
||||||
|
if self._receive_task:
|
||||||
|
await self.cancel_task(self._receive_task)
|
||||||
|
self._receive_task = None
|
||||||
|
|
||||||
|
await self._connect_websocket()
|
||||||
|
|
||||||
|
if self._websocket and not self._receive_task:
|
||||||
|
logger.debug(f"{self}: setting receive task")
|
||||||
|
self._receive_task = self.create_task(self._receive_task_handler(self._report_error))
|
||||||
|
|
||||||
|
async def _disconnect(self):
|
||||||
|
"""Close websocket connection and clean up tasks."""
|
||||||
|
logger.debug(f"{self}: disconnecting")
|
||||||
|
if self._receive_task:
|
||||||
|
await self.cancel_task(self._receive_task)
|
||||||
|
self._receive_task = None
|
||||||
|
|
||||||
|
await self._disconnect_websocket()
|
||||||
|
|
||||||
|
async def _connect_websocket(self):
|
||||||
|
"""Connect to Gradium websocket API with configured settings."""
|
||||||
|
try:
|
||||||
|
if self._websocket and self._websocket.state is State.OPEN:
|
||||||
|
return
|
||||||
|
|
||||||
|
headers = {"x-api-key": self._api_key, "x-api-source": "pipecat"}
|
||||||
|
self._websocket = await websocket_connect(self._url, additional_headers=headers)
|
||||||
|
|
||||||
|
setup_msg = {
|
||||||
|
"type": "setup",
|
||||||
|
"output_format": "pcm",
|
||||||
|
"voice_id": self._voice_id,
|
||||||
|
}
|
||||||
|
if self._json_config is not None:
|
||||||
|
setup_msg["json_config"] = self._json_config
|
||||||
|
await self._websocket.send(json.dumps(setup_msg))
|
||||||
|
ready_msg = await self._websocket.recv()
|
||||||
|
ready_msg = json.loads(ready_msg)
|
||||||
|
if ready_msg["type"] == "error":
|
||||||
|
raise Exception(f"received error {ready_msg['message']}")
|
||||||
|
if ready_msg["type"] != "ready":
|
||||||
|
raise Exception(f"unexpected first message type {ready_msg['type']}")
|
||||||
|
|
||||||
|
await self._call_event_handler("on_connected")
|
||||||
|
except Exception as e:
|
||||||
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
|
self._websocket = None
|
||||||
|
await self._call_event_handler("on_connection_error", f"{e}")
|
||||||
|
|
||||||
|
async def _disconnect_websocket(self):
|
||||||
|
"""Close websocket connection and reset state."""
|
||||||
|
try:
|
||||||
|
await self.stop_all_metrics()
|
||||||
|
if self._websocket:
|
||||||
|
await self._websocket.close()
|
||||||
|
except Exception as e:
|
||||||
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
|
finally:
|
||||||
|
self._websocket = None
|
||||||
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
|
def _get_websocket(self):
|
||||||
|
"""Get active websocket connection or raise exception."""
|
||||||
|
if self._websocket:
|
||||||
|
return self._websocket
|
||||||
|
raise Exception("Websocket not connected")
|
||||||
|
|
||||||
|
async def flush_audio(self):
|
||||||
|
"""Flush any pending audio synthesis."""
|
||||||
|
if not self._websocket:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
msg = {"type": "end_of_stream"}
|
||||||
|
await self._websocket.send(json.dumps(msg))
|
||||||
|
except ConnectionClosedOK:
|
||||||
|
logger.debug(f"{self}: connection closed normally during flush")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"{self} exception: {e}")
|
||||||
|
|
||||||
|
async def _receive_messages(self):
|
||||||
|
"""Process incoming websocket messages."""
|
||||||
|
# TODO(laurent): This should not be necessary as it should happen when
|
||||||
|
# receiving the messages but this does not seem to always be the case
|
||||||
|
# and that may lead to a busy polling loop.
|
||||||
|
if self._websocket and self._websocket.state is State.CLOSED:
|
||||||
|
raise ConnectionClosedOK(None, None)
|
||||||
|
async for message in self._get_websocket():
|
||||||
|
msg = json.loads(message)
|
||||||
|
|
||||||
|
if msg["type"] == "audio":
|
||||||
|
# Process audio chunk
|
||||||
|
await self.stop_ttfb_metrics()
|
||||||
|
self.start_word_timestamps()
|
||||||
|
frame = TTSAudioRawFrame(
|
||||||
|
audio=base64.b64decode(msg["audio"]),
|
||||||
|
sample_rate=self.sample_rate,
|
||||||
|
num_channels=1,
|
||||||
|
)
|
||||||
|
await self.push_frame(frame)
|
||||||
|
|
||||||
|
elif msg["type"] == "text":
|
||||||
|
await self.add_word_timestamps([(msg["text"], msg["start_s"])])
|
||||||
|
elif msg["type"] == "end_of_stream":
|
||||||
|
await self.push_frame(TTSStoppedFrame())
|
||||||
|
await self.stop_all_metrics()
|
||||||
|
|
||||||
|
elif msg["type"] == "error":
|
||||||
|
await self.push_frame(TTSStoppedFrame())
|
||||||
|
await self.stop_all_metrics()
|
||||||
|
await self.push_error(error_msg=f"Error: {msg['message']}")
|
||||||
|
|
||||||
|
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
|
||||||
|
"""Push frame and handle end-of-turn conditions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The frame to push.
|
||||||
|
direction: The direction to push the frame.
|
||||||
|
"""
|
||||||
|
await super().push_frame(frame, direction)
|
||||||
|
|
||||||
|
@traced_tts
|
||||||
|
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
||||||
|
"""Generate speech from text using Gradium's streaming API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to convert to speech.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Frame: Audio frames containing the synthesized speech.
|
||||||
|
"""
|
||||||
|
_state = self._websocket.state if self._websocket is not None else None
|
||||||
|
logger.debug(f"{self}: Generating TTS [{text}] {_state}")
|
||||||
|
try:
|
||||||
|
if not self._websocket or self._websocket.state is State.CLOSED:
|
||||||
|
self._websocket = None
|
||||||
|
await self._connect()
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield TTSStartedFrame()
|
||||||
|
|
||||||
|
msg = self._build_msg(text=text)
|
||||||
|
await self._get_websocket().send(json.dumps(msg))
|
||||||
|
await self.start_tts_usage_metrics(text)
|
||||||
|
except Exception as e:
|
||||||
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
|
yield TTSStoppedFrame()
|
||||||
|
await self._disconnect()
|
||||||
|
await self._connect()
|
||||||
|
return
|
||||||
|
yield None
|
||||||
|
except Exception as e:
|
||||||
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
@@ -123,6 +123,8 @@ class GrokLLMService(OpenAILLMService):
|
|||||||
self._prompt_tokens = 0
|
self._prompt_tokens = 0
|
||||||
self._completion_tokens = 0
|
self._completion_tokens = 0
|
||||||
self._total_tokens = 0
|
self._total_tokens = 0
|
||||||
|
self._cache_read_input_tokens = None
|
||||||
|
self._reasoning_tokens = None
|
||||||
self._has_reported_prompt_tokens = False
|
self._has_reported_prompt_tokens = False
|
||||||
self._is_processing = True
|
self._is_processing = True
|
||||||
|
|
||||||
@@ -137,6 +139,8 @@ class GrokLLMService(OpenAILLMService):
|
|||||||
prompt_tokens=self._prompt_tokens,
|
prompt_tokens=self._prompt_tokens,
|
||||||
completion_tokens=self._completion_tokens,
|
completion_tokens=self._completion_tokens,
|
||||||
total_tokens=self._total_tokens,
|
total_tokens=self._total_tokens,
|
||||||
|
cache_read_input_tokens=self._cache_read_input_tokens,
|
||||||
|
reasoning_tokens=self._reasoning_tokens,
|
||||||
)
|
)
|
||||||
await super().start_llm_usage_metrics(tokens)
|
await super().start_llm_usage_metrics(tokens)
|
||||||
|
|
||||||
@@ -149,7 +153,7 @@ class GrokLLMService(OpenAILLMService):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
tokens: The token usage metrics for the current chunk of processing,
|
tokens: The token usage metrics for the current chunk of processing,
|
||||||
containing prompt_tokens and completion_tokens counts.
|
containing prompt_tokens, completion_tokens, and optional cached/reasoning tokens.
|
||||||
"""
|
"""
|
||||||
# Only accumulate metrics during active processing
|
# Only accumulate metrics during active processing
|
||||||
if not self._is_processing:
|
if not self._is_processing:
|
||||||
@@ -164,6 +168,13 @@ class GrokLLMService(OpenAILLMService):
|
|||||||
if tokens.completion_tokens > self._completion_tokens:
|
if tokens.completion_tokens > self._completion_tokens:
|
||||||
self._completion_tokens = tokens.completion_tokens
|
self._completion_tokens = tokens.completion_tokens
|
||||||
|
|
||||||
|
# Capture cached & reasoning tokens (these typically only appear once per request)
|
||||||
|
if tokens.cache_read_input_tokens is not None:
|
||||||
|
self._cache_read_input_tokens = tokens.cache_read_input_tokens
|
||||||
|
|
||||||
|
if tokens.reasoning_tokens is not None:
|
||||||
|
self._reasoning_tokens = tokens.reasoning_tokens
|
||||||
|
|
||||||
def create_context_aggregator(
|
def create_context_aggregator(
|
||||||
self,
|
self,
|
||||||
context: OpenAILLMContext,
|
context: OpenAILLMContext,
|
||||||
|
|||||||
@@ -146,7 +146,6 @@ class GroqTTSService(TTSService):
|
|||||||
bytes = w.readframes(num_frames)
|
bytes = w.readframes(num_frames)
|
||||||
yield TTSAudioRawFrame(bytes, frame_rate, channels)
|
yield TTSAudioRawFrame(bytes, frame_rate, channels)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
|
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame()
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ class HeyGenClient:
|
|||||||
await self._task_manager.cancel_task(self._event_task)
|
await self._task_manager.cancel_task(self._event_task)
|
||||||
self._event_task = None
|
self._event_task = None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Exception during cleanup: {e}")
|
logger.error(f"Exception during cleanup: {e}")
|
||||||
|
|
||||||
async def start(self, frame: StartFrame, audio_chunk_size: int) -> None:
|
async def start(self, frame: StartFrame, audio_chunk_size: int) -> None:
|
||||||
"""Start the client and establish all necessary connections.
|
"""Start the client and establish all necessary connections.
|
||||||
|
|||||||
@@ -8,27 +8,30 @@ import base64
|
|||||||
import os
|
import os
|
||||||
from typing import Any, AsyncGenerator, Optional
|
from typing import Any, AsyncGenerator, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from pipecat import __version__
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
|
CancelFrame,
|
||||||
|
EndFrame,
|
||||||
ErrorFrame,
|
ErrorFrame,
|
||||||
Frame,
|
Frame,
|
||||||
|
InterruptionFrame,
|
||||||
StartFrame,
|
StartFrame,
|
||||||
TTSAudioRawFrame,
|
TTSAudioRawFrame,
|
||||||
TTSStartedFrame,
|
TTSStartedFrame,
|
||||||
TTSStoppedFrame,
|
TTSStoppedFrame,
|
||||||
)
|
)
|
||||||
from pipecat.services.tts_service import TTSService
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
|
from pipecat.services.tts_service import WordTTSService
|
||||||
from pipecat.utils.tracing.service_decorators import traced_tts
|
from pipecat.utils.tracing.service_decorators import traced_tts
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from hume import AsyncHumeClient
|
from hume import AsyncHumeClient
|
||||||
from hume.tts import (
|
from hume.tts import FormatPcm, PostedUtterance, PostedUtteranceVoiceWithId
|
||||||
FormatPcm,
|
from hume.tts.types import TimestampMessage
|
||||||
PostedUtterance,
|
|
||||||
PostedUtteranceVoiceWithId,
|
|
||||||
)
|
|
||||||
except ModuleNotFoundError as e: # pragma: no cover - import-time guidance
|
except ModuleNotFoundError as e: # pragma: no cover - import-time guidance
|
||||||
logger.error(f"Exception: {e}")
|
logger.error(f"Exception: {e}")
|
||||||
logger.error("In order to use Hume, you need to `pip install pipecat-ai[hume]`.")
|
logger.error("In order to use Hume, you need to `pip install pipecat-ai[hume]`.")
|
||||||
@@ -37,8 +40,14 @@ except ModuleNotFoundError as e: # pragma: no cover - import-time guidance
|
|||||||
|
|
||||||
HUME_SAMPLE_RATE = 48_000 # Hume TTS streams at 48 kHz
|
HUME_SAMPLE_RATE = 48_000 # Hume TTS streams at 48 kHz
|
||||||
|
|
||||||
|
# Tracking headers for Hume API requests
|
||||||
|
DEFAULT_HEADERS = {
|
||||||
|
"X-Hume-Client-Name": "pipecat",
|
||||||
|
"X-Hume-Client-Version": __version__,
|
||||||
|
}
|
||||||
|
|
||||||
class HumeTTSService(TTSService):
|
|
||||||
|
class HumeTTSService(WordTTSService):
|
||||||
"""Hume Octave Text-to-Speech service.
|
"""Hume Octave Text-to-Speech service.
|
||||||
|
|
||||||
Streams PCM audio via Hume's HTTP output streaming (JSON chunks) endpoint
|
Streams PCM audio via Hume's HTTP output streaming (JSON chunks) endpoint
|
||||||
@@ -48,6 +57,7 @@ class HumeTTSService(TTSService):
|
|||||||
|
|
||||||
- Generates speech from text using Hume TTS.
|
- Generates speech from text using Hume TTS.
|
||||||
- Streams PCM audio.
|
- Streams PCM audio.
|
||||||
|
- Supports word-level timestamps for precise audio-text synchronization.
|
||||||
- Supports dynamic updates of voice and synthesis parameters at runtime.
|
- Supports dynamic updates of voice and synthesis parameters at runtime.
|
||||||
- Provides metrics for Time To First Byte (TTFB) and TTS usage.
|
- Provides metrics for Time To First Byte (TTFB) and TTS usage.
|
||||||
"""
|
"""
|
||||||
@@ -92,9 +102,19 @@ class HumeTTSService(TTSService):
|
|||||||
f"Hume TTS streams at {HUME_SAMPLE_RATE} Hz; configured sample_rate={sample_rate}"
|
f"Hume TTS streams at {HUME_SAMPLE_RATE} Hz; configured sample_rate={sample_rate}"
|
||||||
)
|
)
|
||||||
|
|
||||||
super().__init__(sample_rate=sample_rate, **kwargs)
|
# WordTTSService sets push_text_frames=False by default, which we want
|
||||||
|
super().__init__(
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
push_text_frames=False,
|
||||||
|
push_stop_frames=True,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
self._client = AsyncHumeClient(api_key=api_key)
|
# Create a custom httpx.AsyncClient with tracking headers
|
||||||
|
# Headers are included in all requests made by the Hume SDK
|
||||||
|
self._http_client = httpx.AsyncClient(headers=DEFAULT_HEADERS)
|
||||||
|
|
||||||
|
self._client = AsyncHumeClient(api_key=api_key, httpx_client=self._http_client)
|
||||||
self._params = params or HumeTTSService.InputParams()
|
self._params = params or HumeTTSService.InputParams()
|
||||||
|
|
||||||
# Store voice in the base class (mirrors other services)
|
# Store voice in the base class (mirrors other services)
|
||||||
@@ -102,6 +122,10 @@ class HumeTTSService(TTSService):
|
|||||||
|
|
||||||
self._audio_bytes = b""
|
self._audio_bytes = b""
|
||||||
|
|
||||||
|
# Track cumulative time for word timestamps across utterances
|
||||||
|
self._cumulative_time = 0.0
|
||||||
|
self._started = False
|
||||||
|
|
||||||
def can_generate_metrics(self) -> bool:
|
def can_generate_metrics(self) -> bool:
|
||||||
"""Can generate metrics.
|
"""Can generate metrics.
|
||||||
|
|
||||||
@@ -117,6 +141,47 @@ class HumeTTSService(TTSService):
|
|||||||
frame: The start frame.
|
frame: The start frame.
|
||||||
"""
|
"""
|
||||||
await super().start(frame)
|
await super().start(frame)
|
||||||
|
self._reset_state()
|
||||||
|
|
||||||
|
def _reset_state(self):
|
||||||
|
"""Reset internal state variables."""
|
||||||
|
self._cumulative_time = 0.0
|
||||||
|
self._started = False
|
||||||
|
|
||||||
|
async def stop(self, frame: EndFrame) -> None:
|
||||||
|
"""Stop the service and cleanup resources.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The end frame.
|
||||||
|
"""
|
||||||
|
await super().stop(frame)
|
||||||
|
if hasattr(self, "_http_client") and self._http_client:
|
||||||
|
await self._http_client.aclose()
|
||||||
|
|
||||||
|
async def cancel(self, frame: CancelFrame) -> None:
|
||||||
|
"""Cancel the service and cleanup resources.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The cancel frame.
|
||||||
|
"""
|
||||||
|
await super().cancel(frame)
|
||||||
|
if hasattr(self, "_http_client") and self._http_client:
|
||||||
|
await self._http_client.aclose()
|
||||||
|
|
||||||
|
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
|
||||||
|
"""Push a frame and handle state changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The frame to push.
|
||||||
|
direction: The direction to push the frame.
|
||||||
|
"""
|
||||||
|
await super().push_frame(frame, direction)
|
||||||
|
if isinstance(frame, (InterruptionFrame, TTSStoppedFrame)):
|
||||||
|
# Reset timing on interruption or stop
|
||||||
|
self._reset_state()
|
||||||
|
|
||||||
|
if isinstance(frame, TTSStoppedFrame):
|
||||||
|
await self.add_word_timestamps([("Reset", 0)])
|
||||||
|
|
||||||
async def update_setting(self, key: str, value: Any) -> None:
|
async def update_setting(self, key: str, value: Any) -> None:
|
||||||
"""Runtime updates via `TTSUpdateSettingsFrame`.
|
"""Runtime updates via `TTSUpdateSettingsFrame`.
|
||||||
@@ -133,7 +198,7 @@ class HumeTTSService(TTSService):
|
|||||||
|
|
||||||
if key_l == "voice_id":
|
if key_l == "voice_id":
|
||||||
self.set_voice(str(value))
|
self.set_voice(str(value))
|
||||||
logger.info(f"HumeTTSService voice_id set to: {self.voice}")
|
logger.debug(f"HumeTTSService voice_id set to: {self.voice}")
|
||||||
elif key_l == "description":
|
elif key_l == "description":
|
||||||
self._params.description = None if value is None else str(value)
|
self._params.description = None if value is None else str(value)
|
||||||
elif key_l == "speed":
|
elif key_l == "speed":
|
||||||
@@ -146,7 +211,7 @@ class HumeTTSService(TTSService):
|
|||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Hume TTS.
|
"""Generate speech from text using Hume TTS with word timestamps.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text to be synthesized.
|
text: The text to be synthesized.
|
||||||
@@ -177,7 +242,12 @@ class HumeTTSService(TTSService):
|
|||||||
|
|
||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
|
# Start TTS sequence if not already started
|
||||||
|
if not self._started:
|
||||||
|
self.start_word_timestamps()
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame()
|
||||||
|
self._started = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Instant mode is always enabled here (not user-configurable)
|
# Instant mode is always enabled here (not user-configurable)
|
||||||
@@ -188,23 +258,50 @@ class HumeTTSService(TTSService):
|
|||||||
# Use version "2" by default if no description is provided
|
# Use version "2" by default if no description is provided
|
||||||
# Version "1" is needed when description is used
|
# Version "1" is needed when description is used
|
||||||
version = "1" if self._params.description is not None else "2"
|
version = "1" if self._params.description is not None else "2"
|
||||||
|
|
||||||
|
# Track the duration of this utterance based on the last timestamp
|
||||||
|
utterance_duration = 0.0
|
||||||
|
|
||||||
async for chunk in self._client.tts.synthesize_json_streaming(
|
async for chunk in self._client.tts.synthesize_json_streaming(
|
||||||
utterances=[utterance],
|
utterances=[utterance],
|
||||||
format=pcm_fmt,
|
format=pcm_fmt,
|
||||||
instant_mode=True,
|
instant_mode=True,
|
||||||
version=version,
|
version=version,
|
||||||
|
include_timestamp_types=["word"], # Request word-level timestamps
|
||||||
):
|
):
|
||||||
|
# Process audio chunks
|
||||||
audio_b64 = getattr(chunk, "audio", None)
|
audio_b64 = getattr(chunk, "audio", None)
|
||||||
if not audio_b64:
|
if audio_b64:
|
||||||
continue
|
await self.stop_ttfb_metrics()
|
||||||
|
|
||||||
pcm_bytes = base64.b64decode(audio_b64)
|
pcm_bytes = base64.b64decode(audio_b64)
|
||||||
self._audio_bytes += pcm_bytes
|
self._audio_bytes += pcm_bytes
|
||||||
|
|
||||||
# Buffer audio until we have enough to avoid glitches
|
# Buffer audio until we have enough to avoid glitches
|
||||||
if len(self._audio_bytes) < self.chunk_size:
|
if len(self._audio_bytes) >= self.chunk_size:
|
||||||
continue
|
frame = TTSAudioRawFrame(
|
||||||
|
audio=self._audio_bytes,
|
||||||
|
sample_rate=self.sample_rate,
|
||||||
|
num_channels=1,
|
||||||
|
)
|
||||||
|
yield frame
|
||||||
|
self._audio_bytes = b""
|
||||||
|
|
||||||
|
# Process timestamp messages
|
||||||
|
if isinstance(chunk, TimestampMessage):
|
||||||
|
timestamp = chunk.timestamp
|
||||||
|
if timestamp.type == "word":
|
||||||
|
# Convert milliseconds to seconds and add cumulative offset
|
||||||
|
word_start_time = self._cumulative_time + (timestamp.time.begin / 1000.0)
|
||||||
|
word_end_time = self._cumulative_time + (timestamp.time.end / 1000.0)
|
||||||
|
|
||||||
|
# Track the maximum end time for this utterance
|
||||||
|
utterance_duration = max(utterance_duration, word_end_time)
|
||||||
|
|
||||||
|
# Add word timestamp
|
||||||
|
await self.add_word_timestamps([(timestamp.text, word_start_time)])
|
||||||
|
|
||||||
|
# Flush any remaining audio bytes
|
||||||
|
if self._audio_bytes:
|
||||||
frame = TTSAudioRawFrame(
|
frame = TTSAudioRawFrame(
|
||||||
audio=self._audio_bytes,
|
audio=self._audio_bytes,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
@@ -215,10 +312,13 @@ class HumeTTSService(TTSService):
|
|||||||
|
|
||||||
self._audio_bytes = b""
|
self._audio_bytes = b""
|
||||||
|
|
||||||
|
# Update cumulative time for next utterance
|
||||||
|
if utterance_duration > 0:
|
||||||
|
self._cumulative_time = utterance_duration
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
finally:
|
finally:
|
||||||
# Ensure TTFB timer is stopped even on early failures
|
# Ensure TTFB timer is stopped even on early failures
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield TTSStoppedFrame()
|
# Let the parent class handle TTSStoppedFrame via push_stop_frames
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ class InworldTTSService(TTSService):
|
|||||||
Parameters:
|
Parameters:
|
||||||
temperature: Voice temperature control for synthesis variability (e.g., 1.1).
|
temperature: Voice temperature control for synthesis variability (e.g., 1.1).
|
||||||
Valid range: [0, 2]. Higher values increase variability.
|
Valid range: [0, 2]. Higher values increase variability.
|
||||||
|
speaking_rate: Speaking speed control (range: [0.5, 1.5]). Defaults to 1.0 when
|
||||||
|
unset.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
Language is automatically inferred from the input text by Inworld's TTS models,
|
Language is automatically inferred from the input text by Inworld's TTS models,
|
||||||
@@ -153,6 +155,7 @@ class InworldTTSService(TTSService):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
temperature: Optional[float] = None # optional temperature control (range: [0, 2])
|
temperature: Optional[float] = None # optional temperature control (range: [0, 2])
|
||||||
|
speaking_rate: Optional[float] = None # optional speaking rate control (range: [0.5, 1.5])
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -198,6 +201,7 @@ class InworldTTSService(TTSService):
|
|||||||
- Other formats as supported by Inworld API
|
- Other formats as supported by Inworld API
|
||||||
params: Optional input parameters for additional configuration. Use this to specify:
|
params: Optional input parameters for additional configuration. Use this to specify:
|
||||||
- temperature: Voice temperature control for variability (range: [0, 2], e.g., 1.1, optional)
|
- temperature: Voice temperature control for variability (range: [0, 2], e.g., 1.1, optional)
|
||||||
|
- speaking_rate: Set desired speaking speed (range: [0.5, 1.5], optional)
|
||||||
Language is automatically inferred from input text.
|
Language is automatically inferred from input text.
|
||||||
**kwargs: Additional arguments passed to the parent TTSService class.
|
**kwargs: Additional arguments passed to the parent TTSService class.
|
||||||
|
|
||||||
@@ -228,15 +232,18 @@ class InworldTTSService(TTSService):
|
|||||||
self._settings = {
|
self._settings = {
|
||||||
"voiceId": voice_id, # Voice selection from direct parameter
|
"voiceId": voice_id, # Voice selection from direct parameter
|
||||||
"modelId": model, # TTS model selection from direct parameter
|
"modelId": model, # TTS model selection from direct parameter
|
||||||
"audio_config": { # Audio format configuration
|
"audioConfig": { # Audio format configuration
|
||||||
"audio_encoding": encoding, # Format: LINEAR16, MP3, etc.
|
"audioEncoding": encoding, # Format: LINEAR16, MP3, etc.
|
||||||
"sample_rate_hertz": 0, # Will be set in start() from parent service
|
"sampleRateHertz": 0, # Will be set in start() from parent service
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add optional temperature parameter if provided (valid range: [0, 2])
|
# Add optional temperature parameter if provided (valid range: [0, 2])
|
||||||
if params and params.temperature is not None:
|
if params and params.temperature is not None:
|
||||||
self._settings["temperature"] = params.temperature
|
self._settings["temperature"] = params.temperature
|
||||||
|
# Add optional speaking rate if provided (valid range: [0.5, 1.5])
|
||||||
|
if params and params.speaking_rate is not None:
|
||||||
|
self._settings["audioConfig"]["speakingRate"] = params.speaking_rate
|
||||||
|
|
||||||
# Register voice and model with parent service for metrics and tracking
|
# Register voice and model with parent service for metrics and tracking
|
||||||
self.set_voice(voice_id) # Used for logging and metrics
|
self.set_voice(voice_id) # Used for logging and metrics
|
||||||
@@ -257,7 +264,7 @@ class InworldTTSService(TTSService):
|
|||||||
frame: The start frame containing initialization parameters.
|
frame: The start frame containing initialization parameters.
|
||||||
"""
|
"""
|
||||||
await super().start(frame)
|
await super().start(frame)
|
||||||
self._settings["audio_config"]["sample_rate_hertz"] = self.sample_rate
|
self._settings["audioConfig"]["sampleRateHertz"] = self.sample_rate
|
||||||
|
|
||||||
async def stop(self, frame: EndFrame):
|
async def stop(self, frame: EndFrame):
|
||||||
"""Stop the Inworld TTS service.
|
"""Stop the Inworld TTS service.
|
||||||
@@ -323,9 +330,7 @@ class InworldTTSService(TTSService):
|
|||||||
"text": text, # Text to synthesize
|
"text": text, # Text to synthesize
|
||||||
"voiceId": self._settings["voiceId"], # Voice selection (Ashley, Hades, etc.)
|
"voiceId": self._settings["voiceId"], # Voice selection (Ashley, Hades, etc.)
|
||||||
"modelId": self._settings["modelId"], # TTS model (inworld-tts-1)
|
"modelId": self._settings["modelId"], # TTS model (inworld-tts-1)
|
||||||
"audio_config": self._settings[
|
"audioConfig": self._settings["audioConfig"], # Audio format settings (LINEAR16, 48kHz)
|
||||||
"audio_config"
|
|
||||||
], # Audio format settings (LINEAR16, 48kHz)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add optional temperature parameter if configured (valid range: [0, 2])
|
# Add optional temperature parameter if configured (valid range: [0, 2])
|
||||||
@@ -392,8 +397,7 @@ class InworldTTSService(TTSService):
|
|||||||
# STEP 7: ERROR HANDLING
|
# STEP 7: ERROR HANDLING
|
||||||
# ================================================================================
|
# ================================================================================
|
||||||
# Log any unexpected errors and notify the pipeline
|
# Log any unexpected errors and notify the pipeline
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
finally:
|
finally:
|
||||||
# ================================================================================
|
# ================================================================================
|
||||||
# STEP 8: CLEANUP AND COMPLETION
|
# STEP 8: CLEANUP AND COMPLETION
|
||||||
@@ -508,7 +512,7 @@ class InworldTTSService(TTSService):
|
|||||||
# Extract the base64-encoded audio content from response
|
# Extract the base64-encoded audio content from response
|
||||||
if "audioContent" not in response_data:
|
if "audioContent" not in response_data:
|
||||||
logger.error("No audioContent in Inworld API response")
|
logger.error("No audioContent in Inworld API response")
|
||||||
await self.push_error(ErrorFrame(error="No audioContent in response"))
|
yield ErrorFrame(error="No audioContent in response")
|
||||||
return
|
return
|
||||||
|
|
||||||
# ================================================================================
|
# ================================================================================
|
||||||
|
|||||||
@@ -173,16 +173,17 @@ class LLMService(AIService):
|
|||||||
run_in_parallel: Whether to run function calls in parallel or sequentially.
|
run_in_parallel: Whether to run function calls in parallel or sequentially.
|
||||||
Defaults to True.
|
Defaults to True.
|
||||||
**kwargs: Additional arguments passed to the parent AIService.
|
**kwargs: Additional arguments passed to the parent AIService.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self._run_in_parallel = run_in_parallel
|
self._run_in_parallel = run_in_parallel
|
||||||
self._start_callbacks = {}
|
self._start_callbacks = {}
|
||||||
self._adapter = self.adapter_class()
|
self._adapter = self.adapter_class()
|
||||||
self._functions: Dict[Optional[str], FunctionCallRegistryItem] = {}
|
self._functions: Dict[Optional[str], FunctionCallRegistryItem] = {}
|
||||||
self._function_call_tasks: Dict[asyncio.Task, FunctionCallRunnerItem] = {}
|
self._function_call_tasks: Dict[Optional[asyncio.Task], FunctionCallRunnerItem] = {}
|
||||||
self._sequential_runner_task: Optional[asyncio.Task] = None
|
self._sequential_runner_task: Optional[asyncio.Task] = None
|
||||||
self._tracing_enabled: bool = False
|
self._tracing_enabled: bool = False
|
||||||
self._skip_tts: bool = False
|
self._skip_tts: Optional[bool] = None
|
||||||
|
|
||||||
self._register_event_handler("on_function_calls_started")
|
self._register_event_handler("on_function_calls_started")
|
||||||
self._register_event_handler("on_completion_timeout")
|
self._register_event_handler("on_completion_timeout")
|
||||||
@@ -293,6 +294,7 @@ class LLMService(AIService):
|
|||||||
direction: The direction of frame pushing.
|
direction: The direction of frame pushing.
|
||||||
"""
|
"""
|
||||||
if isinstance(frame, (LLMTextFrame, LLMFullResponseStartFrame, LLMFullResponseEndFrame)):
|
if isinstance(frame, (LLMTextFrame, LLMFullResponseStartFrame, LLMFullResponseEndFrame)):
|
||||||
|
if self._skip_tts is not None:
|
||||||
frame.skip_tts = self._skip_tts
|
frame.skip_tts = self._skip_tts
|
||||||
|
|
||||||
await super().push_frame(frame, direction)
|
await super().push_frame(frame, direction)
|
||||||
@@ -435,6 +437,7 @@ class LLMService(AIService):
|
|||||||
|
|
||||||
await self.broadcast_frame(FunctionCallsStartedFrame, function_calls=function_calls)
|
await self.broadcast_frame(FunctionCallsStartedFrame, function_calls=function_calls)
|
||||||
|
|
||||||
|
runner_items = []
|
||||||
for function_call in function_calls:
|
for function_call in function_calls:
|
||||||
if function_call.function_name in self._functions.keys():
|
if function_call.function_name in self._functions.keys():
|
||||||
item = self._functions[function_call.function_name]
|
item = self._functions[function_call.function_name]
|
||||||
@@ -446,28 +449,20 @@ class LLMService(AIService):
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
runner_item = FunctionCallRunnerItem(
|
runner_items.append(
|
||||||
|
FunctionCallRunnerItem(
|
||||||
registry_item=item,
|
registry_item=item,
|
||||||
function_name=function_call.function_name,
|
function_name=function_call.function_name,
|
||||||
tool_call_id=function_call.tool_call_id,
|
tool_call_id=function_call.tool_call_id,
|
||||||
arguments=function_call.arguments,
|
arguments=function_call.arguments,
|
||||||
context=function_call.context,
|
context=function_call.context,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if self._run_in_parallel:
|
if self._run_in_parallel:
|
||||||
task = self.create_task(self._run_function_call(runner_item))
|
await self._run_parallel_function_calls(runner_items)
|
||||||
self._function_call_tasks[task] = runner_item
|
|
||||||
task.add_done_callback(self._function_call_task_finished)
|
|
||||||
else:
|
else:
|
||||||
await self._sequential_runner_queue.put(runner_item)
|
await self._run_sequential_function_calls(runner_items)
|
||||||
|
|
||||||
async def _call_start_function(
|
|
||||||
self, context: OpenAILLMContext | LLMContext, function_name: str
|
|
||||||
):
|
|
||||||
if function_name in self._start_callbacks.keys():
|
|
||||||
await self._start_callbacks[function_name](function_name, self, context)
|
|
||||||
elif None in self._start_callbacks.keys():
|
|
||||||
return await self._start_callbacks[None](function_name, self, context)
|
|
||||||
|
|
||||||
async def request_image_frame(
|
async def request_image_frame(
|
||||||
self,
|
self,
|
||||||
@@ -540,6 +535,27 @@ class LLMService(AIService):
|
|||||||
await task
|
await task
|
||||||
del self._function_call_tasks[task]
|
del self._function_call_tasks[task]
|
||||||
|
|
||||||
|
async def _run_parallel_function_calls(self, runner_items: Sequence[FunctionCallRunnerItem]):
|
||||||
|
tasks = []
|
||||||
|
for runner_item in runner_items:
|
||||||
|
task = self.create_task(self._run_function_call(runner_item))
|
||||||
|
tasks.append(task)
|
||||||
|
self._function_call_tasks[task] = runner_item
|
||||||
|
task.add_done_callback(self._function_call_task_finished)
|
||||||
|
|
||||||
|
async def _run_sequential_function_calls(self, runner_items: Sequence[FunctionCallRunnerItem]):
|
||||||
|
# Enqueue all function calls for background execution.
|
||||||
|
for runner_item in runner_items:
|
||||||
|
await self._sequential_runner_queue.put(runner_item)
|
||||||
|
|
||||||
|
async def _call_start_function(
|
||||||
|
self, context: OpenAILLMContext | LLMContext, function_name: str
|
||||||
|
):
|
||||||
|
if function_name in self._start_callbacks.keys():
|
||||||
|
await self._start_callbacks[function_name](function_name, self, context)
|
||||||
|
elif None in self._start_callbacks.keys():
|
||||||
|
return await self._start_callbacks[None](function_name, self, context)
|
||||||
|
|
||||||
async def _run_function_call(self, runner_item: FunctionCallRunnerItem):
|
async def _run_function_call(self, runner_item: FunctionCallRunnerItem):
|
||||||
if runner_item.function_name in self._functions.keys():
|
if runner_item.function_name in self._functions.keys():
|
||||||
item = self._functions[runner_item.function_name]
|
item = self._functions[runner_item.function_name]
|
||||||
@@ -623,20 +639,19 @@ class LLMService(AIService):
|
|||||||
name = runner_item.function_name
|
name = runner_item.function_name
|
||||||
tool_call_id = runner_item.tool_call_id
|
tool_call_id = runner_item.tool_call_id
|
||||||
|
|
||||||
# We remove the callback because we are going to cancel the task
|
|
||||||
# now, otherwise we will be removing it from the set while we
|
|
||||||
# are iterating.
|
|
||||||
task.remove_done_callback(self._function_call_task_finished)
|
|
||||||
|
|
||||||
logger.debug(f"{self} Cancelling function call [{name}:{tool_call_id}]...")
|
logger.debug(f"{self} Cancelling function call [{name}:{tool_call_id}]...")
|
||||||
|
|
||||||
|
if task:
|
||||||
|
# We remove the callback because we are going to cancel the
|
||||||
|
# task next, otherwise we will be removing it from the set
|
||||||
|
# while we are iterating.
|
||||||
|
task.remove_done_callback(self._function_call_task_finished)
|
||||||
await self.cancel_task(task)
|
await self.cancel_task(task)
|
||||||
|
cancelled_tasks.add(task)
|
||||||
|
|
||||||
frame = FunctionCallCancelFrame(function_name=name, tool_call_id=tool_call_id)
|
frame = FunctionCallCancelFrame(function_name=name, tool_call_id=tool_call_id)
|
||||||
await self.push_frame(frame)
|
await self.push_frame(frame)
|
||||||
|
|
||||||
cancelled_tasks.add(task)
|
|
||||||
|
|
||||||
logger.debug(f"{self} Function call [{name}:{tool_call_id}] has been cancelled")
|
logger.debug(f"{self} Function call [{name}:{tool_call_id}] has been cancelled")
|
||||||
|
|
||||||
# Remove all cancelled tasks from our set.
|
# Remove all cancelled tasks from our set.
|
||||||
|
|||||||
@@ -214,8 +214,7 @@ class LmntTTSService(InterruptibleTTSService):
|
|||||||
|
|
||||||
await self._call_event_handler("on_connected")
|
await self._call_event_handler("on_connected")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_connection_error", f"{e}")
|
await self._call_event_handler("on_connection_error", f"{e}")
|
||||||
|
|
||||||
@@ -231,8 +230,7 @@ class LmntTTSService(InterruptibleTTSService):
|
|||||||
# await self._websocket.send(json.dumps({"eof": True}))
|
# await self._websocket.send(json.dumps({"eof": True}))
|
||||||
await self._websocket.close()
|
await self._websocket.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
await self.push_error(error_msg=f"Error disconnecting from LMNT: {e}", exception=e)
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
|
||||||
finally:
|
finally:
|
||||||
self._started = False
|
self._started = False
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
@@ -266,10 +264,9 @@ class LmntTTSService(InterruptibleTTSService):
|
|||||||
try:
|
try:
|
||||||
msg = json.loads(message)
|
msg = json.loads(message)
|
||||||
if "error" in msg:
|
if "error" in msg:
|
||||||
logger.error(f"{self} error: {msg['error']}")
|
|
||||||
await self.push_frame(TTSStoppedFrame())
|
await self.push_frame(TTSStoppedFrame())
|
||||||
await self.stop_all_metrics()
|
await self.stop_all_metrics()
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {msg['error']}"))
|
await self.push_error(error_msg=f"Error: {msg['error']}")
|
||||||
return
|
return
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.error(f"Invalid JSON message: {message}")
|
logger.error(f"Invalid JSON message: {message}")
|
||||||
@@ -302,13 +299,11 @@ class LmntTTSService(InterruptibleTTSService):
|
|||||||
await self._get_websocket().send(json.dumps({"flush": True}))
|
await self._get_websocket().send(json.dumps({"flush": True}))
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame()
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
return
|
return
|
||||||
yield None
|
yield None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"""MCP (Model Context Protocol) client for integrating external tools with LLMs."""
|
"""MCP (Model Context Protocol) client for integrating external tools with LLMs."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any, Dict, List, TypeAlias
|
from typing import Any, Callable, Dict, List, Optional, TypeAlias
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -46,17 +46,24 @@ class MCPClient(BaseObject):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
server_params: ServerParameters,
|
server_params: ServerParameters,
|
||||||
|
tools_filter: Optional[List[str]] = None,
|
||||||
|
tools_output_filters: Optional[Dict[str, Callable[[Any], Any]]] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the MCP client with server parameters.
|
"""Initialize the MCP client with server parameters.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
server_params: Server connection parameters (stdio or SSE).
|
server_params: Server connection parameters (stdio or SSE).
|
||||||
|
tools_filter: Optional list of tool names to register. If None, all tools are registered.
|
||||||
|
tools_output_filters: Optional dict mapping tool names to filter functions that process tool outputs.
|
||||||
|
Each filter function receives the raw tool output (any type) and returns the processed output (any type).
|
||||||
**kwargs: Additional arguments passed to the parent BaseObject.
|
**kwargs: Additional arguments passed to the parent BaseObject.
|
||||||
"""
|
"""
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self._server_params = server_params
|
self._server_params = server_params
|
||||||
self._session = ClientSession
|
self._session = ClientSession
|
||||||
|
self._tools_filter = tools_filter
|
||||||
|
self._tools_output_filters = tools_output_filters or {}
|
||||||
|
|
||||||
if isinstance(server_params, StdioServerParameters):
|
if isinstance(server_params, StdioServerParameters):
|
||||||
self._client = stdio_client
|
self._client = stdio_client
|
||||||
@@ -176,7 +183,6 @@ class MCPClient(BaseObject):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error calling mcp tool {params.function_name}: {str(e)}"
|
error_msg = f"Error calling mcp tool {params.function_name}: {str(e)}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
logger.exception("Full exception details:")
|
|
||||||
await params.result_callback(error_msg)
|
await params.result_callback(error_msg)
|
||||||
|
|
||||||
async def _stdio_list_tools(self) -> ToolsSchema:
|
async def _stdio_list_tools(self) -> ToolsSchema:
|
||||||
@@ -207,7 +213,6 @@ class MCPClient(BaseObject):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error calling mcp tool {params.function_name}: {str(e)}"
|
error_msg = f"Error calling mcp tool {params.function_name}: {str(e)}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
logger.exception("Full exception details:")
|
|
||||||
await params.result_callback(error_msg)
|
await params.result_callback(error_msg)
|
||||||
|
|
||||||
async def _streamable_http_list_tools(self) -> ToolsSchema:
|
async def _streamable_http_list_tools(self) -> ToolsSchema:
|
||||||
@@ -246,7 +251,6 @@ class MCPClient(BaseObject):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error calling mcp tool {params.function_name}: {str(e)}"
|
error_msg = f"Error calling mcp tool {params.function_name}: {str(e)}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
logger.exception("Full exception details:")
|
|
||||||
await params.result_callback(error_msg)
|
await params.result_callback(error_msg)
|
||||||
|
|
||||||
async def _call_tool(self, session, function_name, arguments, result_callback):
|
async def _call_tool(self, session, function_name, arguments, result_callback):
|
||||||
@@ -267,13 +271,26 @@ class MCPClient(BaseObject):
|
|||||||
else:
|
else:
|
||||||
# logger.debug(f"Non-text result content: '{content}'")
|
# logger.debug(f"Non-text result content: '{content}'")
|
||||||
pass
|
pass
|
||||||
logger.info(f"Tool '{function_name}' completed successfully")
|
|
||||||
logger.debug(f"Final response: {response}")
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"Error getting content from {function_name} results.")
|
logger.error(f"Error getting content from {function_name} results.")
|
||||||
|
|
||||||
final_response = response if len(response) else "Sorry, could not call the mcp tool"
|
# Apply output filter if configured for this tool
|
||||||
await result_callback(final_response)
|
if function_name in self._tools_output_filters:
|
||||||
|
try:
|
||||||
|
response = self._tools_output_filters[function_name](response)
|
||||||
|
logger.debug(f"Final response (after filter): {response}")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.error(f"Error applying output filter for {function_name}")
|
||||||
|
response = ""
|
||||||
|
|
||||||
|
if response and len(response) and isinstance(response, str):
|
||||||
|
logger.info(f"Tool '{function_name}' completed successfully")
|
||||||
|
logger.debug(f"Final response: {response}")
|
||||||
|
else:
|
||||||
|
response = "Sorry, could not call the mcp tool"
|
||||||
|
|
||||||
|
await result_callback(response)
|
||||||
|
|
||||||
async def _list_tools_helper(self, session):
|
async def _list_tools_helper(self, session):
|
||||||
available_tools = await session.list_tools()
|
available_tools = await session.list_tools()
|
||||||
@@ -286,6 +303,12 @@ class MCPClient(BaseObject):
|
|||||||
|
|
||||||
for tool in available_tools.tools:
|
for tool in available_tools.tools:
|
||||||
tool_name = tool.name
|
tool_name = tool.name
|
||||||
|
|
||||||
|
# Apply tools filter if configured
|
||||||
|
if self._tools_filter and tool_name not in self._tools_filter:
|
||||||
|
logger.debug(f"Skipping tool '{tool_name}' - not in allowed tools list")
|
||||||
|
continue
|
||||||
|
|
||||||
logger.debug(f"Processing tool: {tool_name}")
|
logger.debug(f"Processing tool: {tool_name}")
|
||||||
logger.debug(f"Tool description: {tool.description}")
|
logger.debug(f"Tool description: {tool.description}")
|
||||||
|
|
||||||
@@ -302,7 +325,6 @@ class MCPClient(BaseObject):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to read tool '{tool_name}': {str(e)}")
|
logger.error(f"Failed to read tool '{tool_name}': {str(e)}")
|
||||||
logger.exception("Full exception details:")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.debug(f"Completed reading {len(tool_schemas)} tools")
|
logger.debug(f"Completed reading {len(tool_schemas)} tools")
|
||||||
|
|||||||
@@ -253,8 +253,9 @@ class Mem0MemoryService(FrameProcessor):
|
|||||||
# Otherwise, pass the enhanced context frame downstream
|
# Otherwise, pass the enhanced context frame downstream
|
||||||
await self.push_frame(frame)
|
await self.push_frame(frame)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing with Mem0: {str(e)}")
|
await self.push_error(
|
||||||
await self.push_frame(ErrorFrame(f"Error processing with Mem0: {str(e)}"))
|
error_msg=f"Error processing with Mem0: {str(e)}", exception=e
|
||||||
|
)
|
||||||
await self.push_frame(frame) # Still pass the original frame through
|
await self.push_frame(frame) # Still pass the original frame through
|
||||||
else:
|
else:
|
||||||
# For non-context frames, just pass them through
|
# For non-context frames, just pass them through
|
||||||
|
|||||||
@@ -40,24 +40,40 @@ def language_to_minimax_language(language: Language) -> Optional[str]:
|
|||||||
The corresponding MiniMax language name, or None if not supported.
|
The corresponding MiniMax language name, or None if not supported.
|
||||||
"""
|
"""
|
||||||
LANGUAGE_MAP = {
|
LANGUAGE_MAP = {
|
||||||
|
Language.AF: "Afrikaans",
|
||||||
Language.AR: "Arabic",
|
Language.AR: "Arabic",
|
||||||
|
Language.BG: "Bulgarian",
|
||||||
|
Language.CA: "Catalan",
|
||||||
Language.CS: "Czech",
|
Language.CS: "Czech",
|
||||||
|
Language.DA: "Danish",
|
||||||
Language.DE: "German",
|
Language.DE: "German",
|
||||||
Language.EL: "Greek",
|
Language.EL: "Greek",
|
||||||
Language.EN: "English",
|
Language.EN: "English",
|
||||||
Language.ES: "Spanish",
|
Language.ES: "Spanish",
|
||||||
|
Language.FA: "Persian", # ⚠️ Only supported by speech-2.6-* models
|
||||||
Language.FI: "Finnish",
|
Language.FI: "Finnish",
|
||||||
|
Language.FIL: "Filipino", # ⚠️ Only supported by speech-2.6-* models
|
||||||
Language.FR: "French",
|
Language.FR: "French",
|
||||||
|
Language.HE: "Hebrew",
|
||||||
Language.HI: "Hindi",
|
Language.HI: "Hindi",
|
||||||
|
Language.HR: "Croatian",
|
||||||
|
Language.HU: "Hungarian",
|
||||||
Language.ID: "Indonesian",
|
Language.ID: "Indonesian",
|
||||||
Language.IT: "Italian",
|
Language.IT: "Italian",
|
||||||
Language.JA: "Japanese",
|
Language.JA: "Japanese",
|
||||||
Language.KO: "Korean",
|
Language.KO: "Korean",
|
||||||
|
Language.MS: "Malay",
|
||||||
|
Language.NB: "Norwegian",
|
||||||
|
Language.NN: "Nynorsk",
|
||||||
Language.NL: "Dutch",
|
Language.NL: "Dutch",
|
||||||
Language.PL: "Polish",
|
Language.PL: "Polish",
|
||||||
Language.PT: "Portuguese",
|
Language.PT: "Portuguese",
|
||||||
Language.RO: "Romanian",
|
Language.RO: "Romanian",
|
||||||
Language.RU: "Russian",
|
Language.RU: "Russian",
|
||||||
|
Language.SK: "Slovak",
|
||||||
|
Language.SL: "Slovenian",
|
||||||
|
Language.SV: "Swedish",
|
||||||
|
Language.TA: "Tamil", # ⚠️ Only supported by speech-2.6-* models
|
||||||
Language.TH: "Thai",
|
Language.TH: "Thai",
|
||||||
Language.TR: "Turkish",
|
Language.TR: "Turkish",
|
||||||
Language.UK: "Ukrainian",
|
Language.UK: "Ukrainian",
|
||||||
@@ -84,13 +100,22 @@ class MiniMaxHttpTTSService(TTSService):
|
|||||||
"""Configuration parameters for MiniMax TTS.
|
"""Configuration parameters for MiniMax TTS.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
language: Language for TTS generation.
|
language: Language for TTS generation. Supports 40 languages.
|
||||||
|
Note: Filipino, Tamil, and Persian require speech-2.6-* models.
|
||||||
speed: Speech speed (range: 0.5 to 2.0).
|
speed: Speech speed (range: 0.5 to 2.0).
|
||||||
volume: Speech volume (range: 0 to 10).
|
volume: Speech volume (range: 0 to 10).
|
||||||
pitch: Pitch adjustment (range: -12 to 12).
|
pitch: Pitch adjustment (range: -12 to 12).
|
||||||
emotion: Emotional tone (options: "happy", "sad", "angry", "fearful",
|
emotion: Emotional tone (options: "happy", "sad", "angry", "fearful",
|
||||||
"disgusted", "surprised", "neutral").
|
"disgusted", "surprised", "calm", "fluent").
|
||||||
english_normalization: Whether to apply English text normalization.
|
english_normalization: Deprecated; use `text_normalization` instead
|
||||||
|
|
||||||
|
.. deprecated:: 0.0.96
|
||||||
|
The `english_normalization` parameter is deprecated and will be removed in a future version.
|
||||||
|
Use the `text_normalization` parameter instead.
|
||||||
|
|
||||||
|
text_normalization: Enable text normalization (Chinese/English).
|
||||||
|
latex_read: Enable LaTeX formula reading.
|
||||||
|
exclude_aggregated_audio: Whether to exclude aggregated audio in final chunk.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
language: Optional[Language] = Language.EN
|
language: Optional[Language] = Language.EN
|
||||||
@@ -98,7 +123,10 @@ class MiniMaxHttpTTSService(TTSService):
|
|||||||
volume: Optional[float] = 1.0
|
volume: Optional[float] = 1.0
|
||||||
pitch: Optional[int] = 0
|
pitch: Optional[int] = 0
|
||||||
emotion: Optional[str] = None
|
emotion: Optional[str] = None
|
||||||
english_normalization: Optional[bool] = None
|
english_normalization: Optional[bool] = None # Deprecated
|
||||||
|
text_normalization: Optional[bool] = None
|
||||||
|
latex_read: Optional[bool] = None
|
||||||
|
exclude_aggregated_audio: Optional[bool] = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -120,9 +148,12 @@ class MiniMaxHttpTTSService(TTSService):
|
|||||||
base_url: API base URL, defaults to MiniMax's T2A endpoint.
|
base_url: API base URL, defaults to MiniMax's T2A endpoint.
|
||||||
Global: https://api.minimax.io/v1/t2a_v2
|
Global: https://api.minimax.io/v1/t2a_v2
|
||||||
Mainland China: https://api.minimaxi.chat/v1/t2a_v2
|
Mainland China: https://api.minimaxi.chat/v1/t2a_v2
|
||||||
|
Western United States: https://api-uw.minimax.io/v1/t2a_v2
|
||||||
group_id: MiniMax Group ID to identify project.
|
group_id: MiniMax Group ID to identify project.
|
||||||
model: TTS model name. Defaults to "speech-02-turbo". Options include
|
model: TTS model name. Defaults to "speech-02-turbo". Options include:
|
||||||
"speech-02-hd", "speech-02-turbo", "speech-01-hd", "speech-01-turbo".
|
"speech-2.6-hd", "speech-2.6-turbo" (latest, supports Filipino/Tamil/Persian),
|
||||||
|
"speech-02-hd", "speech-02-turbo",
|
||||||
|
"speech-01-hd", "speech-01-turbo".
|
||||||
voice_id: Voice identifier. Defaults to "Calm_Woman".
|
voice_id: Voice identifier. Defaults to "Calm_Woman".
|
||||||
aiohttp_session: aiohttp.ClientSession for API communication.
|
aiohttp_session: aiohttp.ClientSession for API communication.
|
||||||
sample_rate: Output audio sample rate in Hz. If None, uses pipeline default.
|
sample_rate: Output audio sample rate in Hz. If None, uses pipeline default.
|
||||||
@@ -176,15 +207,34 @@ class MiniMaxHttpTTSService(TTSService):
|
|||||||
"disgusted",
|
"disgusted",
|
||||||
"surprised",
|
"surprised",
|
||||||
"neutral",
|
"neutral",
|
||||||
|
"fluent",
|
||||||
]
|
]
|
||||||
if params.emotion in supported_emotions:
|
if params.emotion in supported_emotions:
|
||||||
self._settings["voice_setting"]["emotion"] = params.emotion
|
self._settings["voice_setting"]["emotion"] = params.emotion
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Unsupported emotion: {params.emotion}. Using default.")
|
logger.warning(
|
||||||
|
f"Unsupported emotion: {params.emotion}. Supported emotions: {supported_emotions}"
|
||||||
|
)
|
||||||
|
|
||||||
# Add english_normalization if provided
|
# If `english_normalization`, add `text_normalization` and print warning
|
||||||
if params.english_normalization is not None:
|
if params.english_normalization is not None:
|
||||||
self._settings["english_normalization"] = params.english_normalization
|
import warnings
|
||||||
|
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
warnings.warn(
|
||||||
|
"Parameter `english_normalization` is deprecated and will be removed in a future version. Use `text_normalization` instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
)
|
||||||
|
self._settings["voice_setting"]["text_normalization"] = params.english_normalization
|
||||||
|
|
||||||
|
# Add text_normalization if provided (corrected parameter name)
|
||||||
|
if params.text_normalization is not None:
|
||||||
|
self._settings["voice_setting"]["text_normalization"] = params.text_normalization
|
||||||
|
|
||||||
|
# Add latex_read if provided
|
||||||
|
if params.latex_read is not None:
|
||||||
|
self._settings["voice_setting"]["latex_read"] = params.latex_read
|
||||||
|
|
||||||
def can_generate_metrics(self) -> bool:
|
def can_generate_metrics(self) -> bool:
|
||||||
"""Check if this service can generate processing metrics.
|
"""Check if this service can generate processing metrics.
|
||||||
@@ -231,7 +281,7 @@ class MiniMaxHttpTTSService(TTSService):
|
|||||||
"""
|
"""
|
||||||
await super().start(frame)
|
await super().start(frame)
|
||||||
self._settings["audio_setting"]["sample_rate"] = self.sample_rate
|
self._settings["audio_setting"]["sample_rate"] = self.sample_rate
|
||||||
logger.debug(f"MiniMax TTS initialized with sample rate: {self.sample_rate}")
|
logger.debug(f"MiniMax TTS initialized with sample_rate: {self.sample_rate}")
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
||||||
@@ -264,7 +314,6 @@ class MiniMaxHttpTTSService(TTSService):
|
|||||||
) as response:
|
) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
error_message = f"MiniMax TTS error: HTTP {response.status}"
|
error_message = f"MiniMax TTS error: HTTP {response.status}"
|
||||||
logger.error(error_message)
|
|
||||||
yield ErrorFrame(error=error_message)
|
yield ErrorFrame(error=error_message)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -330,16 +379,19 @@ class MiniMaxHttpTTSService(TTSService):
|
|||||||
num_channels=1,
|
num_channels=1,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.error(f"Error converting hex to binary: {e}")
|
logger.error(
|
||||||
|
f"Error converting hex to binary: {e}",
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.error(f"Error decoding JSON: {e}, data: {data_block[:100]}")
|
logger.error(
|
||||||
|
f"Error decoding JSON: {e}, data: {data_block[:100]}",
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}", exception=e)
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
|
||||||
finally:
|
finally:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame()
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ class MoondreamService(VisionService):
|
|||||||
if analysis fails.
|
if analysis fails.
|
||||||
"""
|
"""
|
||||||
if not self._model:
|
if not self._model:
|
||||||
logger.error(f"{self} error: Moondream model not available ({self.model_name})")
|
|
||||||
yield ErrorFrame("Moondream model not available")
|
yield ErrorFrame("Moondream model not available")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user