fix: surface TTSSpeakFrame greetings in on_assistant_turn_stopped
Two issues were causing TTSSpeakFrame(append_to_context=True) greetings to silently lose their trailing words and never fire on_assistant_turn_stopped: - LLMAssistantPushAggregationFrame was emitted without a PTS, so the transport routed it through the audio (sync) queue while word-level TTSTextFrames travel through the clock queue. The aggregation could reach the assistant aggregator before the final words, leaving them orphaned in the buffer. Stamp the frame with `_word_last_pts + 1` when there are word timestamps so it can't overtake them. - The aggregator's LLMAssistantPushAggregationFrame handler called push_aggregation() directly, bypassing _trigger_assistant_turn_stopped. For TTS-only flows there is no LLMFullResponseStartFrame, so the turn start timestamp was never set and on_assistant_turn_stopped never fired. Open a turn (if needed) and trigger stopped from the handler. Fixes #4264.
This commit is contained in:
@@ -927,7 +927,7 @@ class LLMAssistantAggregator(LLMContextAggregator):
|
||||
await self._handle_end_or_cancel(frame)
|
||||
await self.push_frame(frame, direction)
|
||||
elif isinstance(frame, LLMAssistantPushAggregationFrame):
|
||||
await self.push_aggregation()
|
||||
await self._handle_push_aggregation()
|
||||
elif isinstance(frame, LLMFullResponseStartFrame):
|
||||
await self._handle_llm_start(frame)
|
||||
elif isinstance(frame, LLMFullResponseEndFrame):
|
||||
@@ -1309,6 +1309,17 @@ class LLMAssistantAggregator(LLMContextAggregator):
|
||||
async def _handle_llm_end(self, _: LLMFullResponseEndFrame):
|
||||
await self._trigger_assistant_turn_stopped()
|
||||
|
||||
async def _handle_push_aggregation(self):
|
||||
# LLMAssistantPushAggregationFrame is emitted by TTSService at the end
|
||||
# of a TTSSpeakFrame-driven utterance (no surrounding LLM response
|
||||
# cycle), so no LLMFullResponseStartFrame ever set the turn-start
|
||||
# timestamp. Open a turn now so on_assistant_turn_stopped fires for the
|
||||
# greeting text the same way it did before LLMAssistantPushAggregationFrame
|
||||
# was introduced.
|
||||
if not self._assistant_turn_start_timestamp:
|
||||
await self._trigger_assistant_turn_started()
|
||||
await self._trigger_assistant_turn_stopped()
|
||||
|
||||
async def _handle_text(self, frame: TextFrame):
|
||||
# Skip TextFrame types not intended to build the assistant context
|
||||
if isinstance(frame, (TranscriptionFrame, TranslationFrame, InterimTranscriptionFrame)):
|
||||
|
||||
@@ -771,7 +771,17 @@ class TTSService(AIService):
|
||||
if isinstance(frame, TTSStoppedFrame) and frame.context_id:
|
||||
if frame.context_id in self._tts_contexts:
|
||||
if self._tts_contexts[frame.context_id].push_assistant_aggregation:
|
||||
await self.push_frame(LLMAssistantPushAggregationFrame())
|
||||
aggregation_frame = LLMAssistantPushAggregationFrame()
|
||||
# When word-level TTSTextFrames are routed through the
|
||||
# transport's clock queue (PTS-based), the aggregation frame
|
||||
# would otherwise take the audio (sync) queue path and
|
||||
# could overtake the final word frames. Stamping it with a
|
||||
# PTS just past the last word forces it through the clock
|
||||
# queue too, so the assistant aggregator sees every word
|
||||
# before flushing.
|
||||
if self._word_last_pts:
|
||||
aggregation_frame.pts = self._word_last_pts + 1
|
||||
await self.push_frame(aggregation_frame)
|
||||
logger.debug(f"{self} cleaning up TTS context {frame.context_id}")
|
||||
del self._tts_contexts[frame.context_id]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user