Add text_aggregation_mode parameter to TTSService

Move the sentence vs token aggregation concern into text aggregators
so all text flows through them regardless of mode. This enables
pattern detection and tag handling to work in TOKEN mode.

- Add TextAggregationMode enum (SENTENCE, TOKEN) as the user-facing
  TTS setting, separate from the internal AggregationType
- Add TOKEN mode support to Simple, SkipTags, and PatternPair aggregators
- Add text_aggregation_mode parameter to TTSService and all TTS subclasses
- Deprecate aggregate_sentences in favor of text_aggregation_mode
- Merge TTSService._process_text_frame() into a single codepath
This commit is contained in:
Mark Backman
2026-02-10 13:55:19 -05:00
parent f7434cdde1
commit d69a337def
20 changed files with 480 additions and 95 deletions

View File

@@ -194,5 +194,66 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.assertEqual(self.aggregator.text.text, "")
class TestPatternPairAggregatorTokenMode(unittest.IsolatedAsyncioTestCase):
def setUp(self):
from pipecat.utils.text.base_text_aggregator import AggregationType
self.aggregator = PatternPairAggregator(aggregation_type=AggregationType.TOKEN)
self.handler = AsyncMock()
self.aggregator.add_pattern(
type="think",
start_pattern="<think>",
end_pattern="</think>",
action=MatchAction.REMOVE,
)
self.aggregator.on_pattern_match("think", self.handler)
async def test_token_no_patterns(self):
"""Non-pattern text passes through as TOKEN, one per aggregate call."""
results = []
for token in ["Hello", " world", "."]:
async for r in self.aggregator.aggregate(token):
results.append(r)
self.assertEqual(len(results), 3)
self.assertEqual(results[0].text, "Hello")
self.assertEqual(results[1].text, " world")
self.assertEqual(results[2].text, ".")
for r in results:
self.assertEqual(r.type, "token")
async def test_token_pattern_detection(self):
"""Pattern detection still works with word-by-word token delivery."""
results = []
for token in ["Hi ", "<think>", "secret", "</think>", " bye"]:
async for r in self.aggregator.aggregate(token):
results.append(r)
# Handler called once when the pattern completes
self.handler.assert_called_once()
call_args = self.handler.call_args[0][0]
self.assertEqual(call_args.text, "secret")
# "Hi " yields before pattern starts, pattern is removed, " bye" yields after
self.assertEqual(len(results), 2)
self.assertEqual(results[0].text, "Hi ")
self.assertEqual(results[0].type, "token")
self.assertEqual(results[1].text, " bye")
self.assertEqual(results[1].type, "token")
async def test_token_incomplete_pattern_buffers(self):
"""Incomplete pattern is buffered across calls, not leaked to output."""
results = []
for token in ["Hi ", "<think>", "partial"]:
async for r in self.aggregator.aggregate(token):
results.append(r)
# Only "Hi " should be yielded; "<think>partial" stays buffered
self.assertEqual(len(results), 1)
self.assertEqual(results[0].text, "Hi ")
self.assertEqual(results[0].type, "token")
self.handler.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -181,5 +181,39 @@ class TestSimpleTextAggregator(unittest.IsolatedAsyncioTestCase):
assert result.text == "こんにちは。"
class TestSimpleTextAggregatorTokenMode(unittest.IsolatedAsyncioTestCase):
def setUp(self):
from pipecat.utils.text.base_text_aggregator import AggregationType
self.aggregator = SimpleTextAggregator(aggregation_type=AggregationType.TOKEN)
async def test_token_passthrough(self):
"""TOKEN mode yields text immediately without buffering."""
results = [agg async for agg in self.aggregator.aggregate("Hello")]
assert len(results) == 1
assert results[0].text == "Hello"
assert results[0].type == "token"
async def test_token_multiple_calls(self):
"""Each aggregate call yields its text independently."""
r1 = [agg async for agg in self.aggregator.aggregate("Hello ")]
r2 = [agg async for agg in self.aggregator.aggregate("world.")]
assert len(r1) == 1
assert r1[0].text == "Hello "
assert len(r2) == 1
assert r2[0].text == "world."
async def test_token_empty_text(self):
"""Empty text yields nothing."""
results = [agg async for agg in self.aggregator.aggregate("")]
assert len(results) == 0
async def test_token_flush_returns_none(self):
"""Flush returns None in TOKEN mode since nothing is buffered."""
await self.aggregator.aggregate("Hello").__anext__()
result = await self.aggregator.flush()
assert result is None
if __name__ == "__main__":
unittest.main()

View File

@@ -64,5 +64,60 @@ class TestSkipTagsAggregator(unittest.IsolatedAsyncioTestCase):
self.assertEqual(self.aggregator.text.type, "sentence")
class TestSkipTagsAggregatorTokenMode(unittest.IsolatedAsyncioTestCase):
def setUp(self):
from pipecat.utils.text.base_text_aggregator import AggregationType
self.aggregator = SkipTagsAggregator(
[("<spell>", "</spell>")], aggregation_type=AggregationType.TOKEN
)
async def test_token_no_tags(self):
"""No tags: text passes through immediately as TOKEN."""
results = [agg async for agg in self.aggregator.aggregate("Hello!")]
self.assertEqual(len(results), 1)
self.assertEqual(results[0].text, "Hello!")
self.assertEqual(results[0].type, "token")
async def test_token_inside_tag_buffers(self):
"""Inside a tag, text is buffered until the closing tag is found."""
results = [agg async for agg in self.aggregator.aggregate("<spell>foo@bar")]
# Still inside tag, nothing yielded
self.assertEqual(len(results), 0)
# Close the tag
results = [agg async for agg in self.aggregator.aggregate("</spell>")]
self.assertEqual(len(results), 1)
self.assertEqual(results[0].text, "<spell>foo@bar</spell>")
self.assertEqual(results[0].type, "token")
async def test_token_flush_unclosed_tag(self):
"""Flush with unclosed tag returns remaining text."""
async for _ in self.aggregator.aggregate("<spell>unclosed"):
pass
result = await self.aggregator.flush()
# TOKEN mode flush returns None (parent behavior)
self.assertIsNone(result)
async def test_token_text_around_tags(self):
"""Simulate word-by-word token delivery with tags."""
results = []
# Simulate LLM streaming tokens one at a time
for token in ["Hi ", "<spell>", "X", "</spell>", " bye"]:
async for agg in self.aggregator.aggregate(token):
results.append(agg)
self.assertEqual(len(results), 3)
# Text before tag passes through immediately
self.assertEqual(results[0].text, "Hi ")
self.assertEqual(results[0].type, "token")
# Tagged content is buffered until the closing tag, then yielded whole
self.assertEqual(results[1].text, "<spell>X</spell>")
self.assertEqual(results[1].type, "token")
# Text after tag passes through immediately
self.assertEqual(results[2].text, " bye")
self.assertEqual(results[2].type, "token")
if __name__ == "__main__":
unittest.main()