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.
Files changed (23) hide show
  1. pipecat_sprag-0.2.0.dev1/CHANGELOG.md +25 -0
  2. pipecat_sprag-0.2.0.dev1/PKG-INFO +155 -0
  3. pipecat_sprag-0.2.0.dev1/README.md +140 -0
  4. {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/pyproject.toml +5 -5
  5. {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/src/pipecat_sprag/common/__init__.py +2 -0
  6. {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/src/pipecat_sprag/stt/service.py +24 -1
  7. pipecat_sprag-0.2.0.dev1/src/pipecat_sprag/turns/__init__.py +8 -0
  8. pipecat_sprag-0.2.0.dev1/src/pipecat_sprag/turns/analyzer.py +159 -0
  9. pipecat_sprag-0.2.0.dev1/src/pipecat_sprag/turns/context.py +50 -0
  10. pipecat_sprag-0.2.0.dev1/src/pipecat_sprag/turns/detector.py +160 -0
  11. pipecat_sprag-0.2.0.dev1/src/pipecat_sprag/turns/session.py +279 -0
  12. pipecat_sprag-0.1.0/CHANGELOG.md +0 -10
  13. pipecat_sprag-0.1.0/PKG-INFO +0 -88
  14. pipecat_sprag-0.1.0/README.md +0 -73
  15. {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/.gitignore +0 -0
  16. {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/LICENSE +0 -0
  17. {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/src/pipecat_sprag/__init__.py +0 -0
  18. {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/src/pipecat_sprag/llm/__init__.py +0 -0
  19. {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/src/pipecat_sprag/llm/service.py +0 -0
  20. {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/src/pipecat_sprag/py.typed +0 -0
  21. {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/src/pipecat_sprag/stt/__init__.py +0 -0
  22. {pipecat_sprag-0.1.0 → pipecat_sprag-0.2.0.dev1}/src/pipecat_sprag/tts/__init__.py +0 -0
  23. {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.1.0"
8
- description = "Sprag speech and language models for Pipecat: Rhythm STT, Symphony LLM, and Chorus TTS"
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.8,<2"]
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.8,<2",
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})
@@ -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.
@@ -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).
@@ -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).