Skip to content

Commit 3e927d7

Browse files
authored
Merge pull request #5220 from pipecat-ai/pk/tts-processing-metrics-handoff
Stop reporting TTS processing metrics that time just the send instead of the real work
2 parents b343b51 + 67050d1 commit 3e927d7

4 files changed

Lines changed: 97 additions & 5 deletions

File tree

changelog/5220.changed.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
- ⚠️ `WebsocketTTSService` subclasses and `DeepgramSageMakerTTSService` no longer report processing metrics, which were meaninglessly reporting zero on every turn. The metric is `ProcessingMetricsData` in `MetricsFrame`, surfaced as the `processing` field of RTVI's `metrics` message. TTS services whose processing time was a real number are unaffected.
2+
3+
Processing time is measured around `run_tts`. For a service that requests audio and waits for it in that call, that covers the real work. `WebsocketTTSService` subclasses instead push the text onto the socket and return, leaving the audio to arrive on a separate receive task, so the measurement only ever covered the send. `DeepgramSageMakerTTSService` does the same over bidirectional HTTP/2. TTFB and TTFA measure the latency that matters for all of them, and are unaffected.
4+
5+
There's a new `TTSService.supports_processing_metrics` property, which defaults to `True`. Set it to `False` on a custom service whose `run_tts` returns before synthesis finishes, or back to `True` on a `WebsocketTTSService` subclass that waits for the server to signal the end.

src/pipecat/services/deepgram/sagemaker/tts.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,17 @@ def can_generate_metrics(self) -> bool:
144144
"""
145145
return True
146146

147+
@property
148+
def supports_processing_metrics(self) -> bool:
149+
"""Whether this service has a meaningful processing-time metric.
150+
151+
False: the SageMaker endpoint is driven over a bidirectional HTTP/2
152+
stream, so ``run_tts`` sends the text and returns while audio arrives
153+
on the receive task — the same handoff the websocket services make,
154+
over a different transport.
155+
"""
156+
return False
157+
147158
async def start(self, frame: StartFrame):
148159
"""Start the Deepgram SageMaker TTS service.
149160

src/pipecat/services/tts_service.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,19 @@ def _is_streaming_tokens(self) -> bool:
400400
"""Whether the service is streaming tokens directly without sentence aggregation."""
401401
return self._text_aggregation_mode == TextAggregationMode.TOKEN
402402

403+
@property
404+
def supports_processing_metrics(self) -> bool:
405+
"""Whether this service has a meaningful processing-time metric.
406+
407+
Processing time is measured around :meth:`run_tts`, so it only means
408+
something when synthesis finishes before ``run_tts`` returns. Services
409+
that hand the text off and receive audio elsewhere — anything holding a
410+
persistent connection with its own receive task — return False, since
411+
the measurement would cover the send and nothing else. TTFB and TTFA
412+
carry the latency for those.
413+
"""
414+
return True
415+
403416
async def start_tts_usage_metrics(self, text: str):
404417
"""Record TTS usage metrics.
405418
@@ -1162,10 +1175,12 @@ async def _push_tts_frames(
11621175
if self._is_streaming_tokens:
11631176
self._streamed_text += text
11641177

1165-
# Skip per-token processing metrics when streaming. The per-token
1166-
# processing time is just websocket send overhead (~0.1ms) and not
1167-
# meaningful. TTFB captures the important timing for streaming TTS.
1168-
if not self._is_streaming_tokens:
1178+
# Two things disqualify the measurement. A service that hands text off
1179+
# for synthesis elsewhere would time only the handoff, and a token is
1180+
# the wrong unit to report a processing time against — one metric per
1181+
# token, for work that spans a sentence. TTFB and TTFA carry the timing
1182+
# that matters in both cases.
1183+
if self.supports_processing_metrics and not self._is_streaming_tokens:
11691184
await self.start_processing_metrics()
11701185

11711186
# Process all filters.
@@ -1272,7 +1287,7 @@ async def _push_tts_frames(
12721287

12731288
await self.tts_process_generator(context_id, self.run_tts(prepared_text, context_id))
12741289

1275-
if not self._is_streaming_tokens:
1290+
if self.supports_processing_metrics and not self._is_streaming_tokens:
12761291
await self.stop_processing_metrics()
12771292

12781293
if self._push_text_frames and not self._is_streaming_tokens:
@@ -1805,6 +1820,17 @@ def __init__(self, *, reconnect_on_error: bool = True, **kwargs):
18051820
TTSService.__init__(self, **kwargs)
18061821
WebsocketService.__init__(self, reconnect_on_error=reconnect_on_error, **kwargs)
18071822

1823+
@property
1824+
def supports_processing_metrics(self) -> bool:
1825+
"""Whether this service has a meaningful processing-time metric.
1826+
1827+
False: ``run_tts`` sends the text and returns, and audio arrives later
1828+
on the receive task, so there is no synthesis inside the measured
1829+
window. A subclass that instead waits for the server to signal the end
1830+
of synthesis before returning can override this back to True.
1831+
"""
1832+
return False
1833+
18081834
async def stop(self, frame: EndFrame):
18091835
"""Stop the websocket TTS service on a graceful end.
18101836
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
#
2+
# Copyright (c) 2024-2026, Daily
3+
#
4+
# SPDX-License-Identifier: BSD 2-Clause License
5+
#
6+
7+
"""Tests for which TTS services report a processing-time metric."""
8+
9+
import pytest
10+
11+
from pipecat.services.cartesia.tts import CartesiaHttpTTSService, CartesiaTTSService
12+
from pipecat.services.deepgram.tts import DeepgramHttpTTSService, DeepgramTTSService
13+
from pipecat.services.elevenlabs.tts import ElevenLabsHttpTTSService, ElevenLabsTTSService
14+
from pipecat.services.openai.tts import OpenAITTSService
15+
from pipecat.services.tts_service import TTSService, WebsocketTTSService
16+
17+
# Services whose run_tts completes synthesis before returning.
18+
SYNCHRONOUS = [
19+
CartesiaHttpTTSService,
20+
DeepgramHttpTTSService,
21+
ElevenLabsHttpTTSService,
22+
OpenAITTSService,
23+
]
24+
25+
# Services whose run_tts sends the text and returns, leaving audio to arrive
26+
# on the receive task.
27+
HANDS_OFF = [
28+
CartesiaTTSService,
29+
DeepgramTTSService,
30+
ElevenLabsTTSService,
31+
]
32+
33+
34+
@pytest.mark.parametrize("cls", SYNCHRONOUS)
35+
def test_synchronous_services_report_processing_metrics(cls):
36+
service = cls.__new__(cls)
37+
assert service.supports_processing_metrics
38+
39+
40+
@pytest.mark.parametrize("cls", HANDS_OFF)
41+
def test_handoff_services_do_not_report_processing_metrics(cls):
42+
service = cls.__new__(cls)
43+
assert not service.supports_processing_metrics
44+
45+
46+
def test_default_is_to_report():
47+
# A service that synthesizes inside run_tts is the common case, so the base
48+
# class opts in and the handoff services opt out.
49+
assert TTSService.supports_processing_metrics.fget(None)
50+
assert not WebsocketTTSService.supports_processing_metrics.fget(None)

0 commit comments

Comments
 (0)