pipecat-sprag 0.1.0__tar.gz → 0.2.0.dev1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pipecat_sprag-0.2.0.dev1/CHANGELOG.md +25 -0
- pipecat_sprag-0.2.0.dev1/PKG-INFO +155 -0
- pipecat_sprag-0.2.0.dev1/README.md +140 -0
- {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/pyproject.toml +5 -5
- {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/src/pipecat_sprag/common/__init__.py +2 -0
- {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/src/pipecat_sprag/stt/service.py +24 -1
- pipecat_sprag-0.2.0.dev1/src/pipecat_sprag/turns/__init__.py +8 -0
- pipecat_sprag-0.2.0.dev1/src/pipecat_sprag/turns/analyzer.py +159 -0
- pipecat_sprag-0.2.0.dev1/src/pipecat_sprag/turns/context.py +50 -0
- pipecat_sprag-0.2.0.dev1/src/pipecat_sprag/turns/detector.py +160 -0
- pipecat_sprag-0.2.0.dev1/src/pipecat_sprag/turns/session.py +279 -0
- pipecat_sprag-0.1.0/CHANGELOG.md +0 -10
- pipecat_sprag-0.1.0/PKG-INFO +0 -88
- pipecat_sprag-0.1.0/README.md +0 -73
- {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/.gitignore +0 -0
- {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/LICENSE +0 -0
- {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/src/pipecat_sprag/__init__.py +0 -0
- {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/src/pipecat_sprag/llm/__init__.py +0 -0
- {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/src/pipecat_sprag/llm/service.py +0 -0
- {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/src/pipecat_sprag/py.typed +0 -0
- {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/src/pipecat_sprag/stt/__init__.py +0 -0
- {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/src/pipecat_sprag/tts/__init__.py +0 -0
- {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/src/pipecat_sprag/tts/service.py +0 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0.dev1
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `SpragSTTService(commit_on_turn_end=True)`: transcribes each turn when `SpragTurnDetector` ends it, so turn detection needs no VAD.
|
|
8
|
+
- `examples/demo.py`: the voice agent behind a Sprag-branded browser client, with turns decided by Sprag's turn model.
|
|
9
|
+
- `examples/turn_detection.py`: a voice agent whose turns are decided by Sprag's turn model.
|
|
10
|
+
- `SpragTurnDetector`: Sprag's turn model as a processor that proposes the user's turn boundaries, for `ExternalUserTurnStrategies`, reporting `TurnMetricsData` for each turn.
|
|
11
|
+
- `SpragTurnAnalyzer`: Sprag's turn model as a streaming turn analyzer.
|
|
12
|
+
- `SpragAgentTextObserver`: gives either one the played words of the agent's last reply as context.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Requires `pipecat-ai` 1.12 or later.
|
|
17
|
+
|
|
18
|
+
## 0.1.0
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `SpragSTTService`: streaming transcription with Rhythm.
|
|
23
|
+
- `SpragLLMService`: Symphony, with function calling and token usage metrics.
|
|
24
|
+
- `SpragTTSService`: streaming speech with Chorus.
|
|
25
|
+
- `examples/voice_agent.py`: a voice agent running entirely on Sprag.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pipecat-sprag
|
|
3
|
+
Version: 0.2.0.dev1
|
|
4
|
+
Summary: Sprag speech and language models for Pipecat: Rhythm STT, Symphony LLM, Chorus TTS, and turn detection
|
|
5
|
+
Project-URL: Homepage, https://sprag.ai
|
|
6
|
+
Project-URL: Source, https://github.com/sprag-ai/pipecat-sprag
|
|
7
|
+
Project-URL: Changelog, https://github.com/sprag-ai/pipecat-sprag/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Sprag AI Inc.
|
|
9
|
+
License-Expression: BSD-2-Clause
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai,llm,pipecat,sprag,stt,tts,turn-detection,voice
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Requires-Dist: pipecat-ai<2,>=1.12
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# pipecat-sprag
|
|
17
|
+
|
|
18
|
+
[Sprag](https://sprag.ai/) speech and language models for [Pipecat](https://github.com/pipecat-ai/pipecat).
|
|
19
|
+
|
|
20
|
+
This integration is maintained by Sprag.
|
|
21
|
+
|
|
22
|
+
| Service | Import | Model |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| `SpragSTTService` | `pipecat_sprag.stt` | Rhythm (speech to text) |
|
|
25
|
+
| `SpragLLMService` | `pipecat_sprag.llm` | Symphony (LLM) |
|
|
26
|
+
| `SpragTTSService` | `pipecat_sprag.tts` | Chorus (text to speech) |
|
|
27
|
+
| `SpragTurnDetector`, `SpragTurnAnalyzer` | `pipecat_sprag.turns` | Sprag Turns (end of turn) |
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv add pipecat-sprag
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Each service reads your API key from the `SPRAG_API_KEY` environment variable, or takes it as `api_key=`. You can generate a key from [API keys](https://sprag.ai/app/api-keys) in the Sprag app.
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
Each service drops into a Pipecat pipeline in place of any other STT, LLM, or TTS service:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from pipecat_sprag.llm import SpragLLMService
|
|
43
|
+
from pipecat_sprag.stt import SpragSTTService
|
|
44
|
+
from pipecat_sprag.tts import SpragTTSService
|
|
45
|
+
|
|
46
|
+
stt = SpragSTTService()
|
|
47
|
+
llm = SpragLLMService()
|
|
48
|
+
tts = SpragTTSService(settings=SpragTTSService.Settings(voice="wade"))
|
|
49
|
+
|
|
50
|
+
pipeline = Pipeline(
|
|
51
|
+
[transport.input(), stt, user_aggregator, llm, tts, transport.output(), assistant_aggregator]
|
|
52
|
+
)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- **`SpragSTTService`** streams audio to Rhythm over Sprag's streaming transcription socket (`wss://api.sprag.ai/v1/audio/transcriptions`). By default your pipeline's VAD decides when a turn ends; pass `turn_detection={"type": "server_vad"}` to let Sprag detect turns instead, or `commit_on_turn_end=True` to transcribe each turn when `SpragTurnDetector` ends it.
|
|
56
|
+
- **`SpragLLMService`** runs Symphony through Sprag's chat completions API, with function calling and token usage metrics.
|
|
57
|
+
- **`SpragTTSService`** streams Chorus speech over Sprag's streaming speech socket (`wss://api.sprag.ai/v1/audio/speech`), one sentence at a time. It defaults to the `chorus` model and the `wade` voice; the available voices are listed in [voice presets](https://sprag.ai/docs/tts/voices/presets).
|
|
58
|
+
|
|
59
|
+
Each service takes `settings=<Service>.Settings(...)` for its model and other options, and `api_key=` if you don't use the `SPRAG_API_KEY` environment variable.
|
|
60
|
+
|
|
61
|
+
## Turn detection
|
|
62
|
+
|
|
63
|
+
Sprag's turn model reads the caller's audio, plus what your agent last said, and decides when the caller has finished their turn. It streams over Sprag's turns socket (`wss://api.sprag.ai/v1/turns`). You can use it in either of two ways.
|
|
64
|
+
|
|
65
|
+
**`SpragTurnDetector`: the model decides the turn.** Place the detector after the transport input, and let the user aggregator follow it with Pipecat's external turn strategies. The model's `turn.started` starts the user's turn and interrupts the bot. Its `turn.ended` commits the turn's audio to Rhythm, and the turn ends once that transcript arrives. No VAD is needed:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from pipecat.processors.aggregators.llm_response_universal import LLMUserAggregatorParams
|
|
69
|
+
from pipecat.turns.user_start import ExternalUserTurnStartStrategy
|
|
70
|
+
from pipecat.turns.user_stop import ExternalUserTurnStopStrategy
|
|
71
|
+
from pipecat.turns.user_turn_strategies import UserTurnStrategies
|
|
72
|
+
from pipecat_sprag.stt import SpragSTTService
|
|
73
|
+
from pipecat_sprag.turns import SpragAgentTextObserver, SpragTurnDetector
|
|
74
|
+
|
|
75
|
+
turns = SpragTurnDetector()
|
|
76
|
+
stt = SpragSTTService(commit_on_turn_end=True)
|
|
77
|
+
user_params = LLMUserAggregatorParams(
|
|
78
|
+
user_turn_strategies=UserTurnStrategies(
|
|
79
|
+
start=[ExternalUserTurnStartStrategy()],
|
|
80
|
+
stop=[ExternalUserTurnStopStrategy(timeout=0.1)],
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
pipeline = Pipeline(
|
|
85
|
+
[
|
|
86
|
+
transport.input(),
|
|
87
|
+
turns,
|
|
88
|
+
stt,
|
|
89
|
+
user_aggregator,
|
|
90
|
+
llm,
|
|
91
|
+
tts,
|
|
92
|
+
transport.output(),
|
|
93
|
+
assistant_aggregator,
|
|
94
|
+
]
|
|
95
|
+
)
|
|
96
|
+
worker = PipelineWorker(pipeline, observers=[SpragAgentTextObserver(turns)])
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The stop strategy's `timeout` is how long it waits for further transcripts after one arrives; Pipecat's default is 0.5 s.
|
|
100
|
+
|
|
101
|
+
**`SpragTurnAnalyzer`: a drop-in turn analyzer.** It takes the place of `LocalSmartTurnAnalyzerV3`, alongside your VAD. Like other streaming analyzers, it reports the turn complete as soon as the model ends it. Create one analyzer for each pipeline:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy
|
|
105
|
+
from pipecat_sprag.turns import SpragAgentTextObserver, SpragTurnAnalyzer
|
|
106
|
+
|
|
107
|
+
analyzer = SpragTurnAnalyzer()
|
|
108
|
+
stop = [TurnAnalyzerUserTurnStopStrategy(turn_analyzer=analyzer)]
|
|
109
|
+
worker = PipelineWorker(pipeline, observers=[SpragAgentTextObserver(analyzer)])
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`SpragAgentTextObserver` gives the model the words of the agent's last reply that were played, as context. Without it, the model judges the caller's audio alone. Both classes connect in the background and reconnect if the socket drops. Audio that arrives while they are not connected is not sent. Each turn the model ends is reported as a `TurnMetricsData` with the model's end-of-turn probability and the time from the VAD's last speech stop. The detector reports it when the pipeline enables metrics. Both take `params=SpragTurnParams(...)`:
|
|
113
|
+
|
|
114
|
+
- `eot_threshold`: the end-of-turn probability that ends the turn. Default `0.6`.
|
|
115
|
+
- `min_silence_ms`: the silence a turn needs before the model can end it. Default `200`.
|
|
116
|
+
- `eot_timeout_ms`: the silence that ends the turn whatever the model scores. Default `3000`.
|
|
117
|
+
|
|
118
|
+
## Example
|
|
119
|
+
|
|
120
|
+
[`examples/voice_agent.py`](https://github.com/sprag-ai/pipecat-sprag/blob/main/examples/voice_agent.py) is a complete voice agent running on Rhythm, Symphony, and Chorus. It uses Pipecat's development runner:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
uv sync --group examples
|
|
124
|
+
SPRAG_API_KEY=... uv run examples/voice_agent.py
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Then open the printed URL in your browser and talk to it.
|
|
128
|
+
|
|
129
|
+
[`examples/demo.py`](https://github.com/sprag-ai/pipecat-sprag/blob/main/examples/demo.py) runs the same pipeline behind a Sprag-branded browser client that shows the live transcript, which service is working, and each service's time to first byte:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
uv sync --group examples
|
|
133
|
+
SPRAG_API_KEY=... uv run examples/demo.py
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Then open http://localhost:7860.
|
|
137
|
+
|
|
138
|
+
## Compatibility
|
|
139
|
+
|
|
140
|
+
Requires `pipecat-ai>=1.8,<2`. Tested with Pipecat v1.12.0.
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
uv sync
|
|
146
|
+
uv run ruff check && uv run ruff format --check
|
|
147
|
+
uv run mypy src
|
|
148
|
+
uv run pytest
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Live tests call api.sprag.ai and are skipped by default: `SPRAG_API_KEY=... uv run pytest -m live`.
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
BSD 2-Clause. See [LICENSE](https://github.com/sprag-ai/pipecat-sprag/blob/main/LICENSE).
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# pipecat-sprag
|
|
2
|
+
|
|
3
|
+
[Sprag](https://sprag.ai/) speech and language models for [Pipecat](https://github.com/pipecat-ai/pipecat).
|
|
4
|
+
|
|
5
|
+
This integration is maintained by Sprag.
|
|
6
|
+
|
|
7
|
+
| Service | Import | Model |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `SpragSTTService` | `pipecat_sprag.stt` | Rhythm (speech to text) |
|
|
10
|
+
| `SpragLLMService` | `pipecat_sprag.llm` | Symphony (LLM) |
|
|
11
|
+
| `SpragTTSService` | `pipecat_sprag.tts` | Chorus (text to speech) |
|
|
12
|
+
| `SpragTurnDetector`, `SpragTurnAnalyzer` | `pipecat_sprag.turns` | Sprag Turns (end of turn) |
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
uv add pipecat-sprag
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Each service reads your API key from the `SPRAG_API_KEY` environment variable, or takes it as `api_key=`. You can generate a key from [API keys](https://sprag.ai/app/api-keys) in the Sprag app.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
Each service drops into a Pipecat pipeline in place of any other STT, LLM, or TTS service:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from pipecat_sprag.llm import SpragLLMService
|
|
28
|
+
from pipecat_sprag.stt import SpragSTTService
|
|
29
|
+
from pipecat_sprag.tts import SpragTTSService
|
|
30
|
+
|
|
31
|
+
stt = SpragSTTService()
|
|
32
|
+
llm = SpragLLMService()
|
|
33
|
+
tts = SpragTTSService(settings=SpragTTSService.Settings(voice="wade"))
|
|
34
|
+
|
|
35
|
+
pipeline = Pipeline(
|
|
36
|
+
[transport.input(), stt, user_aggregator, llm, tts, transport.output(), assistant_aggregator]
|
|
37
|
+
)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- **`SpragSTTService`** streams audio to Rhythm over Sprag's streaming transcription socket (`wss://api.sprag.ai/v1/audio/transcriptions`). By default your pipeline's VAD decides when a turn ends; pass `turn_detection={"type": "server_vad"}` to let Sprag detect turns instead, or `commit_on_turn_end=True` to transcribe each turn when `SpragTurnDetector` ends it.
|
|
41
|
+
- **`SpragLLMService`** runs Symphony through Sprag's chat completions API, with function calling and token usage metrics.
|
|
42
|
+
- **`SpragTTSService`** streams Chorus speech over Sprag's streaming speech socket (`wss://api.sprag.ai/v1/audio/speech`), one sentence at a time. It defaults to the `chorus` model and the `wade` voice; the available voices are listed in [voice presets](https://sprag.ai/docs/tts/voices/presets).
|
|
43
|
+
|
|
44
|
+
Each service takes `settings=<Service>.Settings(...)` for its model and other options, and `api_key=` if you don't use the `SPRAG_API_KEY` environment variable.
|
|
45
|
+
|
|
46
|
+
## Turn detection
|
|
47
|
+
|
|
48
|
+
Sprag's turn model reads the caller's audio, plus what your agent last said, and decides when the caller has finished their turn. It streams over Sprag's turns socket (`wss://api.sprag.ai/v1/turns`). You can use it in either of two ways.
|
|
49
|
+
|
|
50
|
+
**`SpragTurnDetector`: the model decides the turn.** Place the detector after the transport input, and let the user aggregator follow it with Pipecat's external turn strategies. The model's `turn.started` starts the user's turn and interrupts the bot. Its `turn.ended` commits the turn's audio to Rhythm, and the turn ends once that transcript arrives. No VAD is needed:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from pipecat.processors.aggregators.llm_response_universal import LLMUserAggregatorParams
|
|
54
|
+
from pipecat.turns.user_start import ExternalUserTurnStartStrategy
|
|
55
|
+
from pipecat.turns.user_stop import ExternalUserTurnStopStrategy
|
|
56
|
+
from pipecat.turns.user_turn_strategies import UserTurnStrategies
|
|
57
|
+
from pipecat_sprag.stt import SpragSTTService
|
|
58
|
+
from pipecat_sprag.turns import SpragAgentTextObserver, SpragTurnDetector
|
|
59
|
+
|
|
60
|
+
turns = SpragTurnDetector()
|
|
61
|
+
stt = SpragSTTService(commit_on_turn_end=True)
|
|
62
|
+
user_params = LLMUserAggregatorParams(
|
|
63
|
+
user_turn_strategies=UserTurnStrategies(
|
|
64
|
+
start=[ExternalUserTurnStartStrategy()],
|
|
65
|
+
stop=[ExternalUserTurnStopStrategy(timeout=0.1)],
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
pipeline = Pipeline(
|
|
70
|
+
[
|
|
71
|
+
transport.input(),
|
|
72
|
+
turns,
|
|
73
|
+
stt,
|
|
74
|
+
user_aggregator,
|
|
75
|
+
llm,
|
|
76
|
+
tts,
|
|
77
|
+
transport.output(),
|
|
78
|
+
assistant_aggregator,
|
|
79
|
+
]
|
|
80
|
+
)
|
|
81
|
+
worker = PipelineWorker(pipeline, observers=[SpragAgentTextObserver(turns)])
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The stop strategy's `timeout` is how long it waits for further transcripts after one arrives; Pipecat's default is 0.5 s.
|
|
85
|
+
|
|
86
|
+
**`SpragTurnAnalyzer`: a drop-in turn analyzer.** It takes the place of `LocalSmartTurnAnalyzerV3`, alongside your VAD. Like other streaming analyzers, it reports the turn complete as soon as the model ends it. Create one analyzer for each pipeline:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy
|
|
90
|
+
from pipecat_sprag.turns import SpragAgentTextObserver, SpragTurnAnalyzer
|
|
91
|
+
|
|
92
|
+
analyzer = SpragTurnAnalyzer()
|
|
93
|
+
stop = [TurnAnalyzerUserTurnStopStrategy(turn_analyzer=analyzer)]
|
|
94
|
+
worker = PipelineWorker(pipeline, observers=[SpragAgentTextObserver(analyzer)])
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`SpragAgentTextObserver` gives the model the words of the agent's last reply that were played, as context. Without it, the model judges the caller's audio alone. Both classes connect in the background and reconnect if the socket drops. Audio that arrives while they are not connected is not sent. Each turn the model ends is reported as a `TurnMetricsData` with the model's end-of-turn probability and the time from the VAD's last speech stop. The detector reports it when the pipeline enables metrics. Both take `params=SpragTurnParams(...)`:
|
|
98
|
+
|
|
99
|
+
- `eot_threshold`: the end-of-turn probability that ends the turn. Default `0.6`.
|
|
100
|
+
- `min_silence_ms`: the silence a turn needs before the model can end it. Default `200`.
|
|
101
|
+
- `eot_timeout_ms`: the silence that ends the turn whatever the model scores. Default `3000`.
|
|
102
|
+
|
|
103
|
+
## Example
|
|
104
|
+
|
|
105
|
+
[`examples/voice_agent.py`](https://github.com/sprag-ai/pipecat-sprag/blob/main/examples/voice_agent.py) is a complete voice agent running on Rhythm, Symphony, and Chorus. It uses Pipecat's development runner:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
uv sync --group examples
|
|
109
|
+
SPRAG_API_KEY=... uv run examples/voice_agent.py
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Then open the printed URL in your browser and talk to it.
|
|
113
|
+
|
|
114
|
+
[`examples/demo.py`](https://github.com/sprag-ai/pipecat-sprag/blob/main/examples/demo.py) runs the same pipeline behind a Sprag-branded browser client that shows the live transcript, which service is working, and each service's time to first byte:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
uv sync --group examples
|
|
118
|
+
SPRAG_API_KEY=... uv run examples/demo.py
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Then open http://localhost:7860.
|
|
122
|
+
|
|
123
|
+
## Compatibility
|
|
124
|
+
|
|
125
|
+
Requires `pipecat-ai>=1.8,<2`. Tested with Pipecat v1.12.0.
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
uv sync
|
|
131
|
+
uv run ruff check && uv run ruff format --check
|
|
132
|
+
uv run mypy src
|
|
133
|
+
uv run pytest
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Live tests call api.sprag.ai and are skipped by default: `SPRAG_API_KEY=... uv run pytest -m live`.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
BSD 2-Clause. See [LICENSE](https://github.com/sprag-ai/pipecat-sprag/blob/main/LICENSE).
|
|
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pipecat-sprag"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "Sprag speech and language models for Pipecat: Rhythm STT, Symphony LLM, and
|
|
7
|
+
version = "0.2.0.dev1"
|
|
8
|
+
description = "Sprag speech and language models for Pipecat: Rhythm STT, Symphony LLM, Chorus TTS, and turn detection"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "BSD-2-Clause"
|
|
11
11
|
requires-python = ">=3.11"
|
|
12
12
|
authors = [{ name = "Sprag AI Inc." }]
|
|
13
|
-
keywords = ["pipecat", "sprag", "voice", "ai", "stt", "tts", "llm"]
|
|
14
|
-
dependencies = ["pipecat-ai>=1.
|
|
13
|
+
keywords = ["pipecat", "sprag", "voice", "ai", "stt", "tts", "llm", "turn-detection"]
|
|
14
|
+
dependencies = ["pipecat-ai>=1.12,<2"]
|
|
15
15
|
|
|
16
16
|
[project.urls]
|
|
17
17
|
Homepage = "https://sprag.ai"
|
|
@@ -26,7 +26,7 @@ include = ["src/pipecat_sprag", "README.md", "CHANGELOG.md", "LICENSE"]
|
|
|
26
26
|
|
|
27
27
|
[dependency-groups]
|
|
28
28
|
examples = [
|
|
29
|
-
"pipecat-ai[daily,runner,webrtc]>=1.
|
|
29
|
+
"pipecat-ai[daily,runner,webrtc]>=1.12,<2",
|
|
30
30
|
"python-dotenv>=1.0",
|
|
31
31
|
]
|
|
32
32
|
dev = [
|
|
@@ -8,6 +8,8 @@ __version__ = version("pipecat-sprag")
|
|
|
8
8
|
SPRAG_BASE_URL = "https://api.sprag.ai/v1"
|
|
9
9
|
SPRAG_TRANSCRIPTION_URL = "wss://api.sprag.ai/v1/audio/transcriptions"
|
|
10
10
|
SPRAG_SPEECH_URL = "wss://api.sprag.ai/v1/audio/speech"
|
|
11
|
+
SPRAG_TURNS_URL = "wss://api.sprag.ai/v1/turns"
|
|
12
|
+
SPRAG_TURNS_MODEL = "sprag-turns"
|
|
11
13
|
ATTRIBUTION_HEADERS = {"X-Sprag-Integration": f"pipecat/{__version__}"}
|
|
12
14
|
|
|
13
15
|
|
|
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from typing import Any, Literal
|
|
7
7
|
|
|
8
|
+
from pipecat.frames.frames import Frame, ProposedUserStoppedSpeakingFrame
|
|
9
|
+
from pipecat.processors.frame_processor import FrameDirection
|
|
8
10
|
from pipecat.services.openai.stt import OpenAIRealtimeSTTService, OpenAIRealtimeSTTSettings
|
|
9
11
|
|
|
10
12
|
from pipecat_sprag.common import SPRAG_TRANSCRIPTION_URL, resolve_api_key
|
|
@@ -19,7 +21,9 @@ class SpragSTTService(OpenAIRealtimeSTTService):
|
|
|
19
21
|
"""Streaming transcription with Rhythm over Sprag's streaming transcription socket.
|
|
20
22
|
|
|
21
23
|
By default the pipeline's own VAD decides when a turn ends. Pass
|
|
22
|
-
``turn_detection={"type": "server_vad"}`` to have Sprag detect turns instead
|
|
24
|
+
``turn_detection={"type": "server_vad"}`` to have Sprag detect turns instead, or
|
|
25
|
+
``commit_on_turn_end=True`` to transcribe each turn when a turn model such as
|
|
26
|
+
``SpragTurnDetector`` proposes its end.
|
|
23
27
|
|
|
24
28
|
Unlike the LLM and TTS services, it sends no ``X-Sprag-Integration`` header: Pipecat's
|
|
25
29
|
realtime STT connects with a fixed set of headers.
|
|
@@ -34,6 +38,7 @@ class SpragSTTService(OpenAIRealtimeSTTService):
|
|
|
34
38
|
api_key: str | None = None,
|
|
35
39
|
base_url: str = SPRAG_TRANSCRIPTION_URL,
|
|
36
40
|
turn_detection: dict[str, Any] | Literal[False] | None = False,
|
|
41
|
+
commit_on_turn_end: bool = False,
|
|
37
42
|
settings: SpragSTTSettings | None = None,
|
|
38
43
|
**kwargs: Any,
|
|
39
44
|
) -> None:
|
|
@@ -46,6 +51,8 @@ class SpragSTTService(OpenAIRealtimeSTTService):
|
|
|
46
51
|
turn_detection: ``False`` (the default) leaves turn detection to the
|
|
47
52
|
pipeline's VAD. A dict such as ``{"type": "server_vad"}`` enables
|
|
48
53
|
Sprag's server-side VAD.
|
|
54
|
+
commit_on_turn_end: Whether a ``ProposedUserStoppedSpeakingFrame`` commits the
|
|
55
|
+
audio for transcription. Applies only without server-side VAD.
|
|
49
56
|
settings: Runtime-updatable settings. The model defaults to ``rhythm``.
|
|
50
57
|
**kwargs: Additional arguments passed to OpenAIRealtimeSTTService.
|
|
51
58
|
"""
|
|
@@ -60,3 +67,19 @@ class SpragSTTService(OpenAIRealtimeSTTService):
|
|
|
60
67
|
settings=default_settings,
|
|
61
68
|
**kwargs,
|
|
62
69
|
)
|
|
70
|
+
self._commit_on_turn_end = commit_on_turn_end
|
|
71
|
+
|
|
72
|
+
async def process_frame(self, frame: Frame, direction: FrameDirection) -> None:
|
|
73
|
+
"""Process a frame, committing the audio when a proposed turn end arrives.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
frame: The frame to process.
|
|
77
|
+
direction: The frame's direction.
|
|
78
|
+
"""
|
|
79
|
+
await super().process_frame(frame, direction)
|
|
80
|
+
if (
|
|
81
|
+
self._commit_on_turn_end
|
|
82
|
+
and not self._server_vad_enabled
|
|
83
|
+
and isinstance(frame, ProposedUserStoppedSpeakingFrame)
|
|
84
|
+
):
|
|
85
|
+
await self._commit_audio_buffer()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Sprag's turn model for Pipecat: a turn detector, a turn analyzer, and an agent-text observer."""
|
|
2
|
+
|
|
3
|
+
from pipecat_sprag.turns.analyzer import SpragTurnAnalyzer
|
|
4
|
+
from pipecat_sprag.turns.context import SpragAgentTextObserver
|
|
5
|
+
from pipecat_sprag.turns.detector import SpragTurnDetector
|
|
6
|
+
from pipecat_sprag.turns.session import SpragTurnParams
|
|
7
|
+
|
|
8
|
+
__all__ = ["SpragAgentTextObserver", "SpragTurnAnalyzer", "SpragTurnDetector", "SpragTurnParams"]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Sprag's turn model as a Pipecat turn analyzer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from loguru import logger
|
|
10
|
+
from pipecat.audio.turn.base_turn_analyzer import BaseTurnAnalyzer, EndOfTurnState
|
|
11
|
+
from pipecat.metrics.metrics import MetricsData, TurnMetricsData
|
|
12
|
+
|
|
13
|
+
from pipecat_sprag.common import SPRAG_TURNS_MODEL, SPRAG_TURNS_URL, resolve_api_key
|
|
14
|
+
from pipecat_sprag.turns.session import SpragTurnParams, TurnsSession, error_message
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SpragTurnAnalyzer(BaseTurnAnalyzer):
|
|
18
|
+
"""Streams the caller's audio to Sprag's turn model; the turn is complete on its ``turn.ended``.
|
|
19
|
+
|
|
20
|
+
Pass it as ``TurnAnalyzerUserTurnStopStrategy(turn_analyzer=...)``, one analyzer per
|
|
21
|
+
pipeline. It is a streaming analyzer: ``append_audio`` returns ``COMPLETE`` once the model
|
|
22
|
+
has ended the turn, and ``analyze_end_of_turn`` keeps reporting ``COMPLETE`` until the turn
|
|
23
|
+
is cleared or the model opens a new one. The analyzer sees no agent text of its own; call
|
|
24
|
+
``set_agent_text`` with what the agent last said, for example from
|
|
25
|
+
``SpragAgentTextObserver``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
api_key: str | None = None,
|
|
32
|
+
url: str = SPRAG_TURNS_URL,
|
|
33
|
+
model: str = SPRAG_TURNS_MODEL,
|
|
34
|
+
params: SpragTurnParams | None = None,
|
|
35
|
+
connect_timeout: float = 10.0,
|
|
36
|
+
sample_rate: int | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Initialize the analyzer.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
api_key: Your Sprag API key. Defaults to the ``SPRAG_API_KEY`` environment variable.
|
|
42
|
+
url: The Sprag turns WebSocket URL.
|
|
43
|
+
model: The turn model.
|
|
44
|
+
params: The model's thresholds. Defaults to ``SpragTurnParams()``.
|
|
45
|
+
connect_timeout: Seconds the session may take to open, and again to be configured.
|
|
46
|
+
sample_rate: The input sample rate; defaults to the pipeline's.
|
|
47
|
+
"""
|
|
48
|
+
super().__init__(sample_rate=sample_rate)
|
|
49
|
+
self._params = params or SpragTurnParams()
|
|
50
|
+
self._session = TurnsSession(
|
|
51
|
+
api_key=resolve_api_key(api_key),
|
|
52
|
+
url=url,
|
|
53
|
+
model=model,
|
|
54
|
+
params=self._params,
|
|
55
|
+
on_event=self._on_event,
|
|
56
|
+
connect_timeout=connect_timeout,
|
|
57
|
+
)
|
|
58
|
+
self._connection: asyncio.Task[None] | None = None
|
|
59
|
+
self._speech_triggered = False
|
|
60
|
+
self._turn_ended = False
|
|
61
|
+
self._turn_reported = False
|
|
62
|
+
self._last_metrics: TurnMetricsData | None = None
|
|
63
|
+
self._speech_stopped_at: float | None = None
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def speech_triggered(self) -> bool:
|
|
67
|
+
"""Whether the model has opened a turn that has not ended yet."""
|
|
68
|
+
return self._speech_triggered
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def params(self) -> SpragTurnParams:
|
|
72
|
+
"""The model's thresholds."""
|
|
73
|
+
return self._params
|
|
74
|
+
|
|
75
|
+
async def set_agent_text(self, text: str) -> None:
|
|
76
|
+
"""Set what the agent last said, which the model reads as context for later readings.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
text: The agent's last utterance; empty clears it.
|
|
80
|
+
"""
|
|
81
|
+
await self._session.send_agent_text(text)
|
|
82
|
+
|
|
83
|
+
def append_audio(self, buffer: bytes, is_speech: bool) -> EndOfTurnState:
|
|
84
|
+
"""Queue caller audio for the model and report whether it has ended the turn.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
buffer: Mono PCM16 audio at the analyzer's sample rate.
|
|
88
|
+
is_speech: Whether the pipeline's VAD marks the audio as speech.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
``COMPLETE`` once after the model ends a turn, otherwise ``INCOMPLETE``.
|
|
92
|
+
"""
|
|
93
|
+
if self._connection is None:
|
|
94
|
+
self._connection = asyncio.get_running_loop().create_task(
|
|
95
|
+
self._session.maintain(self._report_error)
|
|
96
|
+
)
|
|
97
|
+
if is_speech:
|
|
98
|
+
self._speech_stopped_at = None
|
|
99
|
+
elif self._speech_stopped_at is None:
|
|
100
|
+
self._speech_stopped_at = time.perf_counter()
|
|
101
|
+
self._session.push_audio(buffer, self.sample_rate)
|
|
102
|
+
if self._turn_ended and not self._turn_reported:
|
|
103
|
+
self._turn_reported = True
|
|
104
|
+
return EndOfTurnState.COMPLETE
|
|
105
|
+
return EndOfTurnState.INCOMPLETE
|
|
106
|
+
|
|
107
|
+
async def analyze_end_of_turn(self) -> tuple[EndOfTurnState, MetricsData | None]:
|
|
108
|
+
"""Report whether the model has ended the turn, with its ending reading's metrics.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
``COMPLETE`` from the model's ``turn.ended`` until ``clear`` or its next
|
|
112
|
+
``turn.started``, and the ending reading's metrics on the first call that reports it.
|
|
113
|
+
"""
|
|
114
|
+
state = EndOfTurnState.COMPLETE if self._turn_ended else EndOfTurnState.INCOMPLETE
|
|
115
|
+
metrics, self._last_metrics = self._last_metrics, None
|
|
116
|
+
return state, metrics
|
|
117
|
+
|
|
118
|
+
def clear(self) -> None:
|
|
119
|
+
"""Forget the current turn's state; the session stays open."""
|
|
120
|
+
self._speech_triggered = False
|
|
121
|
+
self._turn_ended = False
|
|
122
|
+
self._turn_reported = False
|
|
123
|
+
self._last_metrics = None
|
|
124
|
+
self._speech_stopped_at = None
|
|
125
|
+
|
|
126
|
+
async def cleanup(self) -> None:
|
|
127
|
+
"""Close the session; the next ``append_audio`` opens a new one."""
|
|
128
|
+
connection, self._connection = self._connection, None
|
|
129
|
+
if connection is not None:
|
|
130
|
+
connection.cancel()
|
|
131
|
+
await asyncio.gather(connection, return_exceptions=True)
|
|
132
|
+
await self._session.close()
|
|
133
|
+
self.clear()
|
|
134
|
+
|
|
135
|
+
async def _report_error(self, error: Exception) -> None:
|
|
136
|
+
logger.error(f"Error connecting to Sprag turns: {error}")
|
|
137
|
+
|
|
138
|
+
async def _on_event(self, event: dict[str, Any]) -> None:
|
|
139
|
+
event_type = event.get("type")
|
|
140
|
+
if event_type == "turn.started":
|
|
141
|
+
stopped = self._speech_stopped_at
|
|
142
|
+
self.clear()
|
|
143
|
+
self._speech_stopped_at = stopped
|
|
144
|
+
self._speech_triggered = True
|
|
145
|
+
elif event_type == "turn.ended":
|
|
146
|
+
self._speech_triggered = False
|
|
147
|
+
self._turn_ended = True
|
|
148
|
+
self._turn_reported = False
|
|
149
|
+
stopped = self._speech_stopped_at
|
|
150
|
+
self._last_metrics = TurnMetricsData(
|
|
151
|
+
processor="SpragTurnAnalyzer",
|
|
152
|
+
is_complete=True,
|
|
153
|
+
probability=float(event.get("p_eot") or 0.0),
|
|
154
|
+
e2e_processing_time_ms=(time.perf_counter() - stopped) * 1000
|
|
155
|
+
if stopped is not None
|
|
156
|
+
else 0.0,
|
|
157
|
+
)
|
|
158
|
+
elif event_type == "error":
|
|
159
|
+
logger.error(f"Sprag turns error: {error_message(event)}")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""An observer that hands the agent's last utterance to a Sprag turn detector or analyzer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pipecat.frames.frames import BotStartedSpeakingFrame, BotStoppedSpeakingFrame, TTSTextFrame
|
|
6
|
+
from pipecat.observers.base_observer import BaseObserver, FramePushed
|
|
7
|
+
from pipecat.processors.frame_processor import FrameDirection
|
|
8
|
+
from pipecat.transports.base_output import BaseOutputTransport
|
|
9
|
+
|
|
10
|
+
from pipecat_sprag.turns.analyzer import SpragTurnAnalyzer
|
|
11
|
+
from pipecat_sprag.turns.detector import SpragTurnDetector
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SpragAgentTextObserver(BaseObserver):
|
|
15
|
+
"""Sets the text the bot spoke as the turn model's context each time the bot stops speaking.
|
|
16
|
+
|
|
17
|
+
Pass it to the pipeline worker's ``observers``. Text is collected as the output transport
|
|
18
|
+
plays it, so an interrupted reply sets only the words that were played, and a reply with
|
|
19
|
+
no text clears the context.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, target: SpragTurnDetector | SpragTurnAnalyzer) -> None:
|
|
23
|
+
"""Initialize the observer.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
target: The detector or analyzer whose context this observer keeps current.
|
|
27
|
+
"""
|
|
28
|
+
super().__init__()
|
|
29
|
+
self._target = target
|
|
30
|
+
self._spoken: list[str] = []
|
|
31
|
+
|
|
32
|
+
async def on_push_frame(self, data: FramePushed) -> None:
|
|
33
|
+
"""Track the text the output transport plays across one reply.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
data: The pushed frame and its route.
|
|
37
|
+
"""
|
|
38
|
+
if data.direction is not FrameDirection.DOWNSTREAM:
|
|
39
|
+
return
|
|
40
|
+
if not isinstance(data.source, BaseOutputTransport):
|
|
41
|
+
return
|
|
42
|
+
frame = data.frame
|
|
43
|
+
if isinstance(frame, BotStartedSpeakingFrame):
|
|
44
|
+
self._spoken = []
|
|
45
|
+
elif isinstance(frame, TTSTextFrame):
|
|
46
|
+
self._spoken.append(frame.text)
|
|
47
|
+
elif isinstance(frame, BotStoppedSpeakingFrame):
|
|
48
|
+
text = " ".join(part.strip() for part in self._spoken if part.strip())
|
|
49
|
+
self._spoken = []
|
|
50
|
+
await self._target.set_agent_text(text)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Sprag's turn model as a Pipecat processor that proposes the user's turn boundaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from pipecat.frames.frames import (
|
|
11
|
+
CancelFrame,
|
|
12
|
+
EndFrame,
|
|
13
|
+
Frame,
|
|
14
|
+
InputAudioRawFrame,
|
|
15
|
+
MetricsFrame,
|
|
16
|
+
ProposedUserStartedSpeakingFrame,
|
|
17
|
+
ProposedUserStoppedSpeakingFrame,
|
|
18
|
+
StartFrame,
|
|
19
|
+
VADUserStartedSpeakingFrame,
|
|
20
|
+
VADUserStoppedSpeakingFrame,
|
|
21
|
+
)
|
|
22
|
+
from pipecat.metrics.metrics import TurnMetricsData
|
|
23
|
+
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
|
24
|
+
|
|
25
|
+
from pipecat_sprag.common import SPRAG_TURNS_MODEL, SPRAG_TURNS_URL, resolve_api_key
|
|
26
|
+
from pipecat_sprag.turns.session import SpragTurnParams, TurnsSession, error_message
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SpragTurnDetector(FrameProcessor):
|
|
30
|
+
"""Streams caller audio to Sprag's turn model and proposes the user's turn boundaries.
|
|
31
|
+
|
|
32
|
+
Place it after the transport input and use Pipecat's ``ExternalUserTurnStrategies``: the
|
|
33
|
+
model's ``turn.started`` becomes a ``ProposedUserStartedSpeakingFrame`` and its
|
|
34
|
+
``turn.ended`` a ``ProposedUserStoppedSpeakingFrame``, which the user aggregator closes on
|
|
35
|
+
once the STT's transcript arrives. Every frame passes through unchanged, and the
|
|
36
|
+
connection runs in the background so audio is never held back while it opens. Its
|
|
37
|
+
``on_turn_update`` event receives each end-of-turn reading and its ``on_turn_ended`` event
|
|
38
|
+
each closed turn. With metrics enabled, it pushes a ``TurnMetricsData`` for each closed turn.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
api_key: str | None = None,
|
|
45
|
+
url: str = SPRAG_TURNS_URL,
|
|
46
|
+
model: str = SPRAG_TURNS_MODEL,
|
|
47
|
+
params: SpragTurnParams | None = None,
|
|
48
|
+
connect_timeout: float = 10.0,
|
|
49
|
+
**kwargs: Any,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Initialize the detector.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
api_key: Your Sprag API key. Defaults to the ``SPRAG_API_KEY`` environment variable.
|
|
55
|
+
url: The Sprag turns WebSocket URL.
|
|
56
|
+
model: The turn model.
|
|
57
|
+
params: The model's thresholds. Defaults to ``SpragTurnParams()``.
|
|
58
|
+
connect_timeout: Seconds the session may take to open, and again to be configured.
|
|
59
|
+
**kwargs: Additional arguments passed to FrameProcessor.
|
|
60
|
+
"""
|
|
61
|
+
super().__init__(**kwargs)
|
|
62
|
+
self._session = TurnsSession(
|
|
63
|
+
api_key=resolve_api_key(api_key),
|
|
64
|
+
url=url,
|
|
65
|
+
model=model,
|
|
66
|
+
params=params or SpragTurnParams(),
|
|
67
|
+
on_event=self._on_event,
|
|
68
|
+
connect_timeout=connect_timeout,
|
|
69
|
+
)
|
|
70
|
+
self._connection: asyncio.Task[None] | None = None
|
|
71
|
+
self._model = model
|
|
72
|
+
self._speech_stopped_at: float | None = None
|
|
73
|
+
self._register_event_handler("on_turn_update")
|
|
74
|
+
self._register_event_handler("on_turn_ended")
|
|
75
|
+
|
|
76
|
+
async def set_agent_text(self, text: str) -> None:
|
|
77
|
+
"""Set what the agent last said, which the model reads as context for later readings.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
text: The agent's last utterance; empty clears it.
|
|
81
|
+
"""
|
|
82
|
+
await self._session.send_agent_text(text)
|
|
83
|
+
|
|
84
|
+
async def process_frame(self, frame: Frame, direction: FrameDirection) -> None:
|
|
85
|
+
"""Stream input audio to the model and pass every frame on.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
frame: The frame to process.
|
|
89
|
+
direction: The frame's direction.
|
|
90
|
+
"""
|
|
91
|
+
await super().process_frame(frame, direction)
|
|
92
|
+
if isinstance(frame, StartFrame):
|
|
93
|
+
await self.push_frame(frame, direction)
|
|
94
|
+
if self._connection is None:
|
|
95
|
+
self._connection = self.create_task(self._session.maintain(self._report_error))
|
|
96
|
+
return
|
|
97
|
+
if isinstance(frame, (EndFrame, CancelFrame)):
|
|
98
|
+
await self._stop()
|
|
99
|
+
elif isinstance(frame, InputAudioRawFrame):
|
|
100
|
+
self._session.push_audio(_mono(frame), frame.sample_rate)
|
|
101
|
+
elif isinstance(frame, VADUserStartedSpeakingFrame):
|
|
102
|
+
self._speech_stopped_at = None
|
|
103
|
+
elif isinstance(frame, VADUserStoppedSpeakingFrame):
|
|
104
|
+
self._speech_stopped_at = time.perf_counter()
|
|
105
|
+
await self.push_frame(frame, direction)
|
|
106
|
+
|
|
107
|
+
async def cleanup(self) -> None:
|
|
108
|
+
"""Close the session."""
|
|
109
|
+
await super().cleanup()
|
|
110
|
+
await self._stop()
|
|
111
|
+
|
|
112
|
+
async def _stop(self) -> None:
|
|
113
|
+
if self._connection is not None:
|
|
114
|
+
await self.cancel_task(self._connection)
|
|
115
|
+
self._connection = None
|
|
116
|
+
await self._session.close()
|
|
117
|
+
|
|
118
|
+
def can_generate_metrics(self) -> bool:
|
|
119
|
+
"""Report that the detector produces turn metrics."""
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
async def _push_turn_metrics(self, event: dict[str, Any]) -> None:
|
|
123
|
+
stopped, self._speech_stopped_at = self._speech_stopped_at, None
|
|
124
|
+
if not self.metrics_enabled:
|
|
125
|
+
return
|
|
126
|
+
metrics = TurnMetricsData(
|
|
127
|
+
processor=self.name,
|
|
128
|
+
model=self._model,
|
|
129
|
+
is_complete=True,
|
|
130
|
+
probability=float(event.get("p_eot") or 0.0),
|
|
131
|
+
e2e_processing_time_ms=(time.perf_counter() - stopped) * 1000
|
|
132
|
+
if stopped is not None
|
|
133
|
+
else 0.0,
|
|
134
|
+
)
|
|
135
|
+
await self.push_frame(MetricsFrame(data=[metrics]))
|
|
136
|
+
|
|
137
|
+
async def _report_error(self, error: Exception) -> None:
|
|
138
|
+
await self.push_error(
|
|
139
|
+
error_msg=f"Error connecting to Sprag turns: {error}", exception=error
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def _on_event(self, event: dict[str, Any]) -> None:
|
|
143
|
+
event_type = event.get("type")
|
|
144
|
+
if event_type == "turn.started":
|
|
145
|
+
await self.broadcast_frame(ProposedUserStartedSpeakingFrame)
|
|
146
|
+
elif event_type == "turn.update":
|
|
147
|
+
await self._call_event_handler("on_turn_update", event)
|
|
148
|
+
elif event_type == "turn.ended":
|
|
149
|
+
await self.broadcast_frame(ProposedUserStoppedSpeakingFrame)
|
|
150
|
+
await self._push_turn_metrics(event)
|
|
151
|
+
await self._call_event_handler("on_turn_ended", event)
|
|
152
|
+
elif event_type == "error":
|
|
153
|
+
await self.push_error(error_msg=f"Sprag turns error: {error_message(event)}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _mono(frame: InputAudioRawFrame) -> bytes:
|
|
157
|
+
if frame.num_channels == 1:
|
|
158
|
+
return frame.audio
|
|
159
|
+
samples = np.frombuffer(frame.audio, dtype=np.int16).reshape(-1, frame.num_channels)
|
|
160
|
+
return bytes(samples.mean(axis=1).astype(np.int16).tobytes())
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""A client for Sprag's ``/v1/turns`` socket, shared by the turn detector and the turn analyzer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
|
10
|
+
|
|
11
|
+
from loguru import logger
|
|
12
|
+
from pipecat.audio.turn.base_turn_analyzer import BaseTurnParams
|
|
13
|
+
from pipecat.audio.utils import create_stream_resampler
|
|
14
|
+
from pydantic import Field
|
|
15
|
+
from websockets.asyncio.client import ClientConnection, connect
|
|
16
|
+
from websockets.exceptions import ConnectionClosed
|
|
17
|
+
|
|
18
|
+
from pipecat_sprag.common import ATTRIBUTION_HEADERS
|
|
19
|
+
|
|
20
|
+
SAMPLE_RATE = 16000
|
|
21
|
+
RECONNECT_INTERVAL_SECS = 2.0
|
|
22
|
+
MAX_RECONNECT_INTERVAL_SECS = 30.0
|
|
23
|
+
AUDIO_QUEUE_FRAMES = 25
|
|
24
|
+
MAX_MESSAGE_BYTES = 2**16
|
|
25
|
+
CLOSE_TIMEOUT_SECS = 2.0
|
|
26
|
+
|
|
27
|
+
EventHandler = Callable[[dict[str, Any]], Awaitable[None]]
|
|
28
|
+
ErrorHandler = Callable[[Exception], Awaitable[None]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SpragTurnParams(BaseTurnParams):
|
|
32
|
+
"""Thresholds the Sprag turn model applies to its readings."""
|
|
33
|
+
|
|
34
|
+
eot_threshold: float = Field(
|
|
35
|
+
default=0.6, ge=0.0, le=1.0, description="end-of-turn probability that ends the turn"
|
|
36
|
+
)
|
|
37
|
+
min_silence_ms: int = Field(
|
|
38
|
+
default=200, ge=0, description="silence the turn needs before the model can end it"
|
|
39
|
+
)
|
|
40
|
+
eot_timeout_ms: int = Field(
|
|
41
|
+
default=3000,
|
|
42
|
+
ge=100,
|
|
43
|
+
le=60000,
|
|
44
|
+
description="silence that ends the turn whatever the model scores",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TurnsSession:
|
|
49
|
+
"""One ``/v1/turns`` session: streams caller audio and agent context, and reports turn events.
|
|
50
|
+
|
|
51
|
+
Audio of any sample rate is resampled to the 16 kHz PCM16 the socket reads. Events reach
|
|
52
|
+
``on_event`` in the order the server sends them. The agent text is kept across reconnects
|
|
53
|
+
and sent again on every new connection.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
api_key: str,
|
|
60
|
+
url: str,
|
|
61
|
+
model: str,
|
|
62
|
+
params: SpragTurnParams,
|
|
63
|
+
on_event: EventHandler,
|
|
64
|
+
connect_timeout: float = 10.0,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Prepare a session; nothing is opened until ``connect`` or ``maintain``.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
api_key: The Sprag API key.
|
|
70
|
+
url: The ``/v1/turns`` WebSocket URL.
|
|
71
|
+
model: The turn model to open the session on, unless ``url`` already names one.
|
|
72
|
+
params: The thresholds sent as the session's configuration.
|
|
73
|
+
on_event: Called with every server event after the session is configured.
|
|
74
|
+
connect_timeout: Seconds the socket may take to open, and again to be configured.
|
|
75
|
+
"""
|
|
76
|
+
self._api_key = api_key
|
|
77
|
+
self._url = url
|
|
78
|
+
self._model = model
|
|
79
|
+
self._params = params
|
|
80
|
+
self._on_event = on_event
|
|
81
|
+
self._connect_timeout = connect_timeout
|
|
82
|
+
self._websocket: ClientConnection | None = None
|
|
83
|
+
self._tasks: list[asyncio.Task[None]] = []
|
|
84
|
+
self._closing: asyncio.Task[None] | None = None
|
|
85
|
+
self._audio: asyncio.Queue[tuple[bytes, int]] = asyncio.Queue(maxsize=AUDIO_QUEUE_FRAMES)
|
|
86
|
+
self._lost = asyncio.Event()
|
|
87
|
+
self._agent_text = ""
|
|
88
|
+
self._resampler = create_stream_resampler()
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def connected(self) -> bool:
|
|
92
|
+
"""Whether the session is open and configured."""
|
|
93
|
+
return self._websocket is not None
|
|
94
|
+
|
|
95
|
+
async def maintain(self, on_error: ErrorHandler) -> None:
|
|
96
|
+
"""Keep the session connected until the calling task is cancelled.
|
|
97
|
+
|
|
98
|
+
A failed attempt goes to ``on_error`` and is retried after a delay that starts at
|
|
99
|
+
``RECONNECT_INTERVAL_SECS`` and doubles up to ``MAX_RECONNECT_INTERVAL_SECS``.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
on_error: Called with the error of each failed connection attempt.
|
|
103
|
+
"""
|
|
104
|
+
delay = RECONNECT_INTERVAL_SECS
|
|
105
|
+
while True:
|
|
106
|
+
try:
|
|
107
|
+
await self.connect()
|
|
108
|
+
except Exception as e:
|
|
109
|
+
await on_error(e)
|
|
110
|
+
await asyncio.sleep(delay)
|
|
111
|
+
delay = min(delay * 2, MAX_RECONNECT_INTERVAL_SECS)
|
|
112
|
+
continue
|
|
113
|
+
delay = RECONNECT_INTERVAL_SECS
|
|
114
|
+
await self._lost.wait()
|
|
115
|
+
|
|
116
|
+
async def connect(self) -> None:
|
|
117
|
+
"""Open the socket, apply the session's thresholds and send the agent text.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
RuntimeError: The server refused the configuration.
|
|
121
|
+
ConnectionError: The socket closed before the session was configured.
|
|
122
|
+
TimeoutError: The session took longer than ``connect_timeout`` to configure.
|
|
123
|
+
"""
|
|
124
|
+
if self._websocket is not None:
|
|
125
|
+
return
|
|
126
|
+
websocket = await connect(
|
|
127
|
+
self._session_url(),
|
|
128
|
+
additional_headers={"Authorization": f"Bearer {self._api_key}", **ATTRIBUTION_HEADERS},
|
|
129
|
+
open_timeout=self._connect_timeout,
|
|
130
|
+
close_timeout=CLOSE_TIMEOUT_SECS,
|
|
131
|
+
max_size=MAX_MESSAGE_BYTES,
|
|
132
|
+
)
|
|
133
|
+
try:
|
|
134
|
+
await asyncio.wait_for(self._configure(websocket), timeout=self._connect_timeout)
|
|
135
|
+
if self._agent_text:
|
|
136
|
+
await websocket.send(_context_update(self._agent_text))
|
|
137
|
+
except BaseException:
|
|
138
|
+
await websocket.close()
|
|
139
|
+
raise
|
|
140
|
+
while not self._audio.empty():
|
|
141
|
+
self._audio.get_nowait()
|
|
142
|
+
self._resampler = create_stream_resampler()
|
|
143
|
+
self._websocket = websocket
|
|
144
|
+
self._lost.clear()
|
|
145
|
+
self._tasks = [
|
|
146
|
+
asyncio.create_task(self._receive(websocket)),
|
|
147
|
+
asyncio.create_task(self._send_audio(websocket)),
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
def push_audio(self, audio: bytes, sample_rate: int) -> None:
|
|
151
|
+
"""Queue caller audio for the socket without waiting on it.
|
|
152
|
+
|
|
153
|
+
Audio is dropped while the session is not connected, and the oldest queued audio is
|
|
154
|
+
dropped once ``AUDIO_QUEUE_FRAMES`` buffers are waiting.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
audio: Mono PCM16 little-endian samples.
|
|
158
|
+
sample_rate: The audio's sample rate in hertz.
|
|
159
|
+
"""
|
|
160
|
+
if self._websocket is None:
|
|
161
|
+
return
|
|
162
|
+
if self._audio.full():
|
|
163
|
+
self._audio.get_nowait()
|
|
164
|
+
self._audio.put_nowait((audio, sample_rate))
|
|
165
|
+
|
|
166
|
+
async def send_agent_text(self, text: str) -> None:
|
|
167
|
+
"""Set what the agent last said, which every later reading takes as context.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
text: The agent's last utterance; empty clears the context.
|
|
171
|
+
"""
|
|
172
|
+
self._agent_text = text
|
|
173
|
+
websocket = self._websocket
|
|
174
|
+
if websocket is None:
|
|
175
|
+
return
|
|
176
|
+
try:
|
|
177
|
+
await websocket.send(_context_update(text))
|
|
178
|
+
except ConnectionClosed:
|
|
179
|
+
self._drop(websocket)
|
|
180
|
+
|
|
181
|
+
async def close(self) -> None:
|
|
182
|
+
"""End the session and close the socket; a later ``connect`` opens a new one."""
|
|
183
|
+
websocket, self._websocket = self._websocket, None
|
|
184
|
+
tasks, self._tasks = self._tasks, []
|
|
185
|
+
current = asyncio.current_task()
|
|
186
|
+
others = [task for task in tasks if task is not current]
|
|
187
|
+
for task in others:
|
|
188
|
+
task.cancel()
|
|
189
|
+
await asyncio.gather(*others, return_exceptions=True)
|
|
190
|
+
if websocket is not None:
|
|
191
|
+
try:
|
|
192
|
+
await websocket.send(json.dumps({"type": "session.close"}))
|
|
193
|
+
except ConnectionClosed:
|
|
194
|
+
pass
|
|
195
|
+
await websocket.close()
|
|
196
|
+
|
|
197
|
+
def _session_url(self) -> str:
|
|
198
|
+
parts = urlsplit(self._url)
|
|
199
|
+
if parts.scheme == "ws" and parts.hostname not in ("localhost", "127.0.0.1", "::1"):
|
|
200
|
+
logger.warning("The Sprag turns URL is not encrypted; the API key is sent in plaintext")
|
|
201
|
+
if "model" in parse_qs(parts.query):
|
|
202
|
+
return self._url
|
|
203
|
+
query = "&".join(q for q in (parts.query, urlencode({"model": self._model})) if q)
|
|
204
|
+
return urlunsplit(parts._replace(query=query))
|
|
205
|
+
|
|
206
|
+
async def _configure(self, websocket: ClientConnection) -> None:
|
|
207
|
+
await websocket.send(
|
|
208
|
+
json.dumps({"type": "session.update", "session": self._params.model_dump()})
|
|
209
|
+
)
|
|
210
|
+
async for message in websocket:
|
|
211
|
+
if not isinstance(message, str):
|
|
212
|
+
continue
|
|
213
|
+
event = json.loads(message)
|
|
214
|
+
if event.get("type") == "session.updated":
|
|
215
|
+
return
|
|
216
|
+
if event.get("type") == "error":
|
|
217
|
+
raise RuntimeError(error_message(event))
|
|
218
|
+
raise ConnectionError("the turns session closed before it was configured")
|
|
219
|
+
|
|
220
|
+
async def _receive(self, websocket: ClientConnection) -> None:
|
|
221
|
+
try:
|
|
222
|
+
async for message in websocket:
|
|
223
|
+
if not isinstance(message, str):
|
|
224
|
+
continue
|
|
225
|
+
try:
|
|
226
|
+
event = json.loads(message)
|
|
227
|
+
if isinstance(event, dict):
|
|
228
|
+
await self._on_event(event)
|
|
229
|
+
except Exception:
|
|
230
|
+
logger.exception("Error handling a Sprag turns event")
|
|
231
|
+
except ConnectionClosed:
|
|
232
|
+
pass
|
|
233
|
+
finally:
|
|
234
|
+
self._drop(websocket)
|
|
235
|
+
|
|
236
|
+
async def _send_audio(self, websocket: ClientConnection) -> None:
|
|
237
|
+
try:
|
|
238
|
+
while True:
|
|
239
|
+
audio, sample_rate = await self._audio.get()
|
|
240
|
+
if sample_rate != SAMPLE_RATE:
|
|
241
|
+
audio = await self._resampler.resample(audio, sample_rate, SAMPLE_RATE)
|
|
242
|
+
if audio:
|
|
243
|
+
await websocket.send(audio)
|
|
244
|
+
except ConnectionClosed:
|
|
245
|
+
pass
|
|
246
|
+
except Exception:
|
|
247
|
+
logger.exception("Error streaming audio to Sprag turns")
|
|
248
|
+
finally:
|
|
249
|
+
self._drop(websocket)
|
|
250
|
+
|
|
251
|
+
def _drop(self, websocket: ClientConnection) -> None:
|
|
252
|
+
if self._websocket is not websocket:
|
|
253
|
+
return
|
|
254
|
+
logger.warning("Sprag turns session lost")
|
|
255
|
+
self._websocket = None
|
|
256
|
+
current = asyncio.current_task()
|
|
257
|
+
for task in self._tasks:
|
|
258
|
+
if task is not current:
|
|
259
|
+
task.cancel()
|
|
260
|
+
self._tasks = []
|
|
261
|
+
self._closing = asyncio.create_task(websocket.close())
|
|
262
|
+
self._lost.set()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def error_message(event: dict[str, Any]) -> str:
|
|
266
|
+
"""Describe a server ``error`` event.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
event: The server's ``error`` event.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
The error's message, else its code, else a generic description.
|
|
273
|
+
"""
|
|
274
|
+
error = event.get("error") or {}
|
|
275
|
+
return str(error.get("message") or error.get("code") or "the turns session reported an error")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _context_update(text: str) -> str:
|
|
279
|
+
return json.dumps({"type": "context.update", "agent_text": text})
|
pipecat_sprag-0.1.0/CHANGELOG.md
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.1.0
|
|
4
|
-
|
|
5
|
-
### Added
|
|
6
|
-
|
|
7
|
-
- `SpragSTTService`: streaming transcription with Rhythm.
|
|
8
|
-
- `SpragLLMService`: Symphony, with function calling and token usage metrics.
|
|
9
|
-
- `SpragTTSService`: streaming speech with Chorus.
|
|
10
|
-
- `examples/voice_agent.py`: a voice agent running entirely on Sprag.
|
pipecat_sprag-0.1.0/PKG-INFO
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: pipecat-sprag
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: Sprag speech and language models for Pipecat: Rhythm STT, Symphony LLM, and Chorus TTS
|
|
5
|
-
Project-URL: Homepage, https://sprag.ai
|
|
6
|
-
Project-URL: Source, https://github.com/sprag-ai/pipecat-sprag
|
|
7
|
-
Project-URL: Changelog, https://github.com/sprag-ai/pipecat-sprag/blob/main/CHANGELOG.md
|
|
8
|
-
Author: Sprag AI Inc.
|
|
9
|
-
License-Expression: BSD-2-Clause
|
|
10
|
-
License-File: LICENSE
|
|
11
|
-
Keywords: ai,llm,pipecat,sprag,stt,tts,voice
|
|
12
|
-
Requires-Python: >=3.11
|
|
13
|
-
Requires-Dist: pipecat-ai<2,>=1.8
|
|
14
|
-
Description-Content-Type: text/markdown
|
|
15
|
-
|
|
16
|
-
# pipecat-sprag
|
|
17
|
-
|
|
18
|
-
[Sprag](https://sprag.ai/) speech and language models for [Pipecat](https://github.com/pipecat-ai/pipecat).
|
|
19
|
-
|
|
20
|
-
This integration is maintained by Sprag.
|
|
21
|
-
|
|
22
|
-
| Service | Import | Model |
|
|
23
|
-
| --- | --- | --- |
|
|
24
|
-
| `SpragSTTService` | `pipecat_sprag.stt` | Rhythm (speech to text) |
|
|
25
|
-
| `SpragLLMService` | `pipecat_sprag.llm` | Symphony (LLM) |
|
|
26
|
-
| `SpragTTSService` | `pipecat_sprag.tts` | Chorus (text to speech) |
|
|
27
|
-
|
|
28
|
-
## Installation
|
|
29
|
-
|
|
30
|
-
```bash
|
|
31
|
-
uv add pipecat-sprag
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
Each service reads your API key from the `SPRAG_API_KEY` environment variable, or takes it as `api_key=`. You can generate a key from [API keys](https://sprag.ai/app/api-keys) in the Sprag app.
|
|
35
|
-
|
|
36
|
-
## Usage
|
|
37
|
-
|
|
38
|
-
Each service drops into a Pipecat pipeline in place of any other STT, LLM, or TTS service:
|
|
39
|
-
|
|
40
|
-
```python
|
|
41
|
-
from pipecat_sprag.llm import SpragLLMService
|
|
42
|
-
from pipecat_sprag.stt import SpragSTTService
|
|
43
|
-
from pipecat_sprag.tts import SpragTTSService
|
|
44
|
-
|
|
45
|
-
stt = SpragSTTService()
|
|
46
|
-
llm = SpragLLMService()
|
|
47
|
-
tts = SpragTTSService(settings=SpragTTSService.Settings(voice="wade"))
|
|
48
|
-
|
|
49
|
-
pipeline = Pipeline(
|
|
50
|
-
[transport.input(), stt, user_aggregator, llm, tts, transport.output(), assistant_aggregator]
|
|
51
|
-
)
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
- **`SpragSTTService`** streams audio to Rhythm over Sprag's streaming transcription socket (`wss://api.sprag.ai/v1/audio/transcriptions`). By default your pipeline's VAD decides when a turn ends; pass `turn_detection={"type": "server_vad"}` to let Sprag detect turns instead.
|
|
55
|
-
- **`SpragLLMService`** runs Symphony through Sprag's chat completions API, with function calling and token usage metrics.
|
|
56
|
-
- **`SpragTTSService`** streams Chorus speech over Sprag's streaming speech socket (`wss://api.sprag.ai/v1/audio/speech`), one sentence at a time. It defaults to the `chorus` model and the `wade` voice; the available voices are listed in [voice presets](https://sprag.ai/docs/tts/voices/presets).
|
|
57
|
-
|
|
58
|
-
Each service takes `settings=<Service>.Settings(...)` for its model and other options, and `api_key=` if you don't use the `SPRAG_API_KEY` environment variable.
|
|
59
|
-
|
|
60
|
-
## Example
|
|
61
|
-
|
|
62
|
-
[`examples/voice_agent.py`](https://github.com/sprag-ai/pipecat-sprag/blob/main/examples/voice_agent.py) is a complete voice agent running on Rhythm, Symphony, and Chorus. It uses Pipecat's development runner:
|
|
63
|
-
|
|
64
|
-
```bash
|
|
65
|
-
uv sync --group examples
|
|
66
|
-
SPRAG_API_KEY=... uv run examples/voice_agent.py
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
Then open the printed URL in your browser and talk to it.
|
|
70
|
-
|
|
71
|
-
## Compatibility
|
|
72
|
-
|
|
73
|
-
Requires `pipecat-ai>=1.8,<2`. Tested with Pipecat v1.12.0.
|
|
74
|
-
|
|
75
|
-
## Development
|
|
76
|
-
|
|
77
|
-
```bash
|
|
78
|
-
uv sync
|
|
79
|
-
uv run ruff check && uv run ruff format --check
|
|
80
|
-
uv run mypy src
|
|
81
|
-
uv run pytest
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
Live tests call api.sprag.ai and are skipped by default: `SPRAG_API_KEY=... uv run pytest -m live`.
|
|
85
|
-
|
|
86
|
-
## License
|
|
87
|
-
|
|
88
|
-
BSD 2-Clause. See [LICENSE](https://github.com/sprag-ai/pipecat-sprag/blob/main/LICENSE).
|
pipecat_sprag-0.1.0/README.md
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
# pipecat-sprag
|
|
2
|
-
|
|
3
|
-
[Sprag](https://sprag.ai/) speech and language models for [Pipecat](https://github.com/pipecat-ai/pipecat).
|
|
4
|
-
|
|
5
|
-
This integration is maintained by Sprag.
|
|
6
|
-
|
|
7
|
-
| Service | Import | Model |
|
|
8
|
-
| --- | --- | --- |
|
|
9
|
-
| `SpragSTTService` | `pipecat_sprag.stt` | Rhythm (speech to text) |
|
|
10
|
-
| `SpragLLMService` | `pipecat_sprag.llm` | Symphony (LLM) |
|
|
11
|
-
| `SpragTTSService` | `pipecat_sprag.tts` | Chorus (text to speech) |
|
|
12
|
-
|
|
13
|
-
## Installation
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
uv add pipecat-sprag
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
Each service reads your API key from the `SPRAG_API_KEY` environment variable, or takes it as `api_key=`. You can generate a key from [API keys](https://sprag.ai/app/api-keys) in the Sprag app.
|
|
20
|
-
|
|
21
|
-
## Usage
|
|
22
|
-
|
|
23
|
-
Each service drops into a Pipecat pipeline in place of any other STT, LLM, or TTS service:
|
|
24
|
-
|
|
25
|
-
```python
|
|
26
|
-
from pipecat_sprag.llm import SpragLLMService
|
|
27
|
-
from pipecat_sprag.stt import SpragSTTService
|
|
28
|
-
from pipecat_sprag.tts import SpragTTSService
|
|
29
|
-
|
|
30
|
-
stt = SpragSTTService()
|
|
31
|
-
llm = SpragLLMService()
|
|
32
|
-
tts = SpragTTSService(settings=SpragTTSService.Settings(voice="wade"))
|
|
33
|
-
|
|
34
|
-
pipeline = Pipeline(
|
|
35
|
-
[transport.input(), stt, user_aggregator, llm, tts, transport.output(), assistant_aggregator]
|
|
36
|
-
)
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
- **`SpragSTTService`** streams audio to Rhythm over Sprag's streaming transcription socket (`wss://api.sprag.ai/v1/audio/transcriptions`). By default your pipeline's VAD decides when a turn ends; pass `turn_detection={"type": "server_vad"}` to let Sprag detect turns instead.
|
|
40
|
-
- **`SpragLLMService`** runs Symphony through Sprag's chat completions API, with function calling and token usage metrics.
|
|
41
|
-
- **`SpragTTSService`** streams Chorus speech over Sprag's streaming speech socket (`wss://api.sprag.ai/v1/audio/speech`), one sentence at a time. It defaults to the `chorus` model and the `wade` voice; the available voices are listed in [voice presets](https://sprag.ai/docs/tts/voices/presets).
|
|
42
|
-
|
|
43
|
-
Each service takes `settings=<Service>.Settings(...)` for its model and other options, and `api_key=` if you don't use the `SPRAG_API_KEY` environment variable.
|
|
44
|
-
|
|
45
|
-
## Example
|
|
46
|
-
|
|
47
|
-
[`examples/voice_agent.py`](https://github.com/sprag-ai/pipecat-sprag/blob/main/examples/voice_agent.py) is a complete voice agent running on Rhythm, Symphony, and Chorus. It uses Pipecat's development runner:
|
|
48
|
-
|
|
49
|
-
```bash
|
|
50
|
-
uv sync --group examples
|
|
51
|
-
SPRAG_API_KEY=... uv run examples/voice_agent.py
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
Then open the printed URL in your browser and talk to it.
|
|
55
|
-
|
|
56
|
-
## Compatibility
|
|
57
|
-
|
|
58
|
-
Requires `pipecat-ai>=1.8,<2`. Tested with Pipecat v1.12.0.
|
|
59
|
-
|
|
60
|
-
## Development
|
|
61
|
-
|
|
62
|
-
```bash
|
|
63
|
-
uv sync
|
|
64
|
-
uv run ruff check && uv run ruff format --check
|
|
65
|
-
uv run mypy src
|
|
66
|
-
uv run pytest
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
Live tests call api.sprag.ai and are skipped by default: `SPRAG_API_KEY=... uv run pytest -m live`.
|
|
70
|
-
|
|
71
|
-
## License
|
|
72
|
-
|
|
73
|
-
BSD 2-Clause. See [LICENSE](https://github.com/sprag-ai/pipecat-sprag/blob/main/LICENSE).
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|