Merge pull request #1696 from pipecat-ai/mb/fix-gemini-live-context
Fix: GeminiMultimodalLiveLLMService was appending tokens to the context
This commit is contained in:
@@ -87,6 +87,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with `GeminiMultimodalLiveLLMService` where the context
|
||||||
|
contained tokens instead of words.
|
||||||
|
|
||||||
- Fixed an issue with HTTP Smart Turn handling, where the service returns a 500
|
- Fixed an issue with HTTP Smart Turn handling, where the service returns a 500
|
||||||
error. Previously, this would cause an unhandled exception. Now, a 500 error
|
error. Previously, this would cause an unhandled exception. Now, a 500 error
|
||||||
is treated as an incomplete response.
|
is treated as an incomplete response.
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ async def run_bot(webrtc_connection: SmallWebRTCConnection, _: argparse.Namespac
|
|||||||
llm = GeminiMultimodalLiveLLMService(
|
llm = GeminiMultimodalLiveLLMService(
|
||||||
api_key=os.getenv("GOOGLE_API_KEY"),
|
api_key=os.getenv("GOOGLE_API_KEY"),
|
||||||
system_instruction=system_instruction,
|
system_instruction=system_instruction,
|
||||||
|
transcribe_user_audio=True,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -93,49 +93,55 @@ class AssistantTranscriptProcessor(BaseTranscriptProcessor):
|
|||||||
"""Aggregates and emits text fragments as a transcript message.
|
"""Aggregates and emits text fragments as a transcript message.
|
||||||
|
|
||||||
This method uses a heuristic to automatically detect whether text fragments
|
This method uses a heuristic to automatically detect whether text fragments
|
||||||
use pre-spacing (spaces at the beginning of fragments) or not, and applies
|
contain embedded spacing (spaces at the beginning or end of fragments) or not,
|
||||||
the appropriate joining strategy. It handles fragments from different TTS
|
and applies the appropriate joining strategy. It handles fragments from different
|
||||||
services with different formatting patterns.
|
TTS services with different formatting patterns.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
Pre-spaced fragments (concatenated):
|
Fragments with embedded spacing (concatenated):
|
||||||
```
|
```
|
||||||
TTSTextFrame: ["Hello"]
|
TTSTextFrame: ["Hello"]
|
||||||
TTSTextFrame: [" there"]
|
TTSTextFrame: [" there"] # Leading space
|
||||||
TTSTextFrame: ["!"]
|
TTSTextFrame: ["!"]
|
||||||
TTSTextFrame: [" How"]
|
TTSTextFrame: [" How"] # Leading space
|
||||||
TTSTextFrame: ["'s"]
|
TTSTextFrame: ["'s"]
|
||||||
TTSTextFrame: [" it"]
|
TTSTextFrame: [" it"] # Leading space
|
||||||
TTSTextFrame: [" going"]
|
|
||||||
TTSTextFrame: ["?"]
|
|
||||||
```
|
```
|
||||||
Result: "Hello there! How's it going?"
|
Result: "Hello there! How's it"
|
||||||
|
|
||||||
Word-by-word fragments (joined with spaces):
|
Fragments with trailing spaces (concatenated):
|
||||||
|
```
|
||||||
|
TTSTextFrame: ["Hel"]
|
||||||
|
TTSTextFrame: ["lo "] # Trailing space
|
||||||
|
TTSTextFrame: ["to "] # Trailing space
|
||||||
|
TTSTextFrame: ["you"]
|
||||||
|
```
|
||||||
|
Result: "Hello to you"
|
||||||
|
|
||||||
|
Word-by-word fragments without spacing (joined with spaces):
|
||||||
```
|
```
|
||||||
TTSTextFrame: ["Hello"]
|
TTSTextFrame: ["Hello"]
|
||||||
TTSTextFrame: ["there!"]
|
TTSTextFrame: ["there"]
|
||||||
TTSTextFrame: ["How"]
|
TTSTextFrame: ["how"]
|
||||||
TTSTextFrame: ["is"]
|
TTSTextFrame: ["are"]
|
||||||
TTSTextFrame: ["it"]
|
TTSTextFrame: ["you"]
|
||||||
TTSTextFrame: ["going?"]
|
|
||||||
```
|
```
|
||||||
Result: "Hello there! How is it going?"
|
Result: "Hello there how are you"
|
||||||
"""
|
"""
|
||||||
if self._current_text_parts and self._aggregation_start_time:
|
if self._current_text_parts and self._aggregation_start_time:
|
||||||
# Heuristic to detect pre-spaced fragments
|
has_leading_spaces = any(
|
||||||
uses_prespacing = False
|
part and part[0].isspace() for part in self._current_text_parts[1:]
|
||||||
if len(self._current_text_parts) > 1:
|
)
|
||||||
# Check if any fragment after the first one starts with whitespace
|
has_trailing_spaces = any(
|
||||||
has_spaced_parts = any(
|
part and part[-1].isspace() for part in self._current_text_parts[:-1]
|
||||||
part and part[0].isspace() for part in self._current_text_parts[1:]
|
)
|
||||||
)
|
|
||||||
if has_spaced_parts:
|
|
||||||
uses_prespacing = True
|
|
||||||
|
|
||||||
# Apply appropriate joining method
|
# If there are embedded spaces in the fragments, use direct concatenation
|
||||||
if uses_prespacing:
|
contains_spacing_between_fragments = has_leading_spaces or has_trailing_spaces
|
||||||
# Pre-spaced fragments - just concatenate
|
|
||||||
|
# Apply corresponding joining method
|
||||||
|
if contains_spacing_between_fragments:
|
||||||
|
# Fragments already have spacing - just concatenate
|
||||||
content = "".join(self._current_text_parts)
|
content = "".join(self._current_text_parts)
|
||||||
else:
|
else:
|
||||||
# Word-by-word fragments - join with spaces
|
# Word-by-word fragments - join with spaces
|
||||||
|
|||||||
@@ -223,6 +223,16 @@ class GeminiMultimodalLiveUserContextAggregator(OpenAIUserContextAggregator):
|
|||||||
|
|
||||||
|
|
||||||
class GeminiMultimodalLiveAssistantContextAggregator(OpenAIAssistantContextAggregator):
|
class GeminiMultimodalLiveAssistantContextAggregator(OpenAIAssistantContextAggregator):
|
||||||
|
# The LLMAssistantContextAggregator uses TextFrames to aggregate the LLM output,
|
||||||
|
# but the GeminiMultimodalLiveAssistantContextAggregator pushes LLMTextFrames and TTSTextFrames. We
|
||||||
|
# need to override this proces_frame for LLMTextFrame, so that only the TTSTextFrames
|
||||||
|
# are process. This ensures that the context gets only one set of messages.
|
||||||
|
# GeminiMultimodalLiveLLMService also pushes TranscriptionFrames, so we need to
|
||||||
|
# ignore pushing those as well, as they're also TextFrames.
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
if not isinstance(frame, (LLMTextFrame, TranscriptionFrame)):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
|
||||||
async def handle_user_image_frame(self, frame: UserImageRawFrame):
|
async def handle_user_image_frame(self, frame: UserImageRawFrame):
|
||||||
# We don't want to store any images in the context. Revisit this later
|
# We don't want to store any images in the context. Revisit this later
|
||||||
# when the API evolves.
|
# when the API evolves.
|
||||||
@@ -344,7 +354,6 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
self._bot_is_speaking = False
|
self._bot_is_speaking = False
|
||||||
self._user_audio_buffer = bytearray()
|
self._user_audio_buffer = bytearray()
|
||||||
self._bot_audio_buffer = bytearray()
|
self._bot_audio_buffer = bytearray()
|
||||||
self._bot_text_buffer = ""
|
|
||||||
|
|
||||||
self._sample_rate = 24000
|
self._sample_rate = 24000
|
||||||
|
|
||||||
@@ -367,7 +376,7 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
"vad": params.vad,
|
"vad": params.vad,
|
||||||
"context_window_compression": params.context_window_compression.model_dump()
|
"context_window_compression": params.context_window_compression.model_dump()
|
||||||
if params.context_window_compression
|
if params.context_window_compression
|
||||||
else None,
|
else {},
|
||||||
"extra": params.extra if isinstance(params.extra, dict) else {},
|
"extra": params.extra if isinstance(params.extra, dict) else {},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +436,9 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
#
|
#
|
||||||
|
|
||||||
async def _handle_interruption(self):
|
async def _handle_interruption(self):
|
||||||
pass
|
self._bot_is_speaking = False
|
||||||
|
await self.push_frame(TTSStoppedFrame())
|
||||||
|
await self.push_frame(LLMFullResponseEndFrame())
|
||||||
|
|
||||||
async def _handle_user_started_speaking(self, frame):
|
async def _handle_user_started_speaking(self, frame):
|
||||||
self._user_is_speaking = True
|
self._user_is_speaking = True
|
||||||
@@ -450,10 +461,12 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
text = await self._transcribe_audio(audio, context)
|
text = await self._transcribe_audio(audio, context)
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
logger.debug(f"[Transcription:user] {text}")
|
# Sometimes the transcription contains newlines; we want to remove them.
|
||||||
context.add_message({"role": "user", "content": [{"type": "text", "text": text}]})
|
cleaned_text = text.rstrip("\n")
|
||||||
|
logger.debug(f"[Transcription:user] {cleaned_text}")
|
||||||
|
context.add_message({"role": "user", "content": [{"type": "text", "text": cleaned_text}]})
|
||||||
await self.push_frame(
|
await self.push_frame(
|
||||||
TranscriptionFrame(text=text, user_id="user", timestamp=time_now_iso8601())
|
TranscriptionFrame(text=cleaned_text, user_id="user", timestamp=time_now_iso8601())
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _transcribe_audio(self, audio, context):
|
async def _transcribe_audio(self, audio, context):
|
||||||
@@ -839,14 +852,6 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
if not part:
|
if not part:
|
||||||
return
|
return
|
||||||
|
|
||||||
text = part.text
|
|
||||||
if text:
|
|
||||||
if not self._bot_text_buffer:
|
|
||||||
await self.push_frame(LLMFullResponseStartFrame())
|
|
||||||
|
|
||||||
self._bot_text_buffer += text
|
|
||||||
await self.push_frame(LLMTextFrame(text=text))
|
|
||||||
|
|
||||||
inline_data = part.inlineData
|
inline_data = part.inlineData
|
||||||
if not inline_data:
|
if not inline_data:
|
||||||
return
|
return
|
||||||
@@ -861,6 +866,7 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
if not self._bot_is_speaking:
|
if not self._bot_is_speaking:
|
||||||
self._bot_is_speaking = True
|
self._bot_is_speaking = True
|
||||||
await self.push_frame(TTSStartedFrame())
|
await self.push_frame(TTSStartedFrame())
|
||||||
|
await self.push_frame(LLMFullResponseStartFrame())
|
||||||
|
|
||||||
self._bot_audio_buffer.extend(audio)
|
self._bot_audio_buffer.extend(audio)
|
||||||
frame = TTSAudioRawFrame(
|
frame = TTSAudioRawFrame(
|
||||||
@@ -886,24 +892,20 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
|
|
||||||
async def _handle_evt_turn_complete(self, evt):
|
async def _handle_evt_turn_complete(self, evt):
|
||||||
self._bot_is_speaking = False
|
self._bot_is_speaking = False
|
||||||
text = self._bot_text_buffer
|
|
||||||
self._bot_text_buffer = ""
|
|
||||||
|
|
||||||
if text:
|
|
||||||
await self.push_frame(LLMFullResponseEndFrame())
|
|
||||||
|
|
||||||
await self.push_frame(TTSStoppedFrame())
|
await self.push_frame(TTSStoppedFrame())
|
||||||
|
await self.push_frame(LLMFullResponseEndFrame())
|
||||||
|
|
||||||
async def _handle_evt_output_transcription(self, evt):
|
async def _handle_evt_output_transcription(self, evt):
|
||||||
if not evt.serverContent.outputTranscription:
|
if not evt.serverContent.outputTranscription:
|
||||||
return
|
return
|
||||||
|
|
||||||
text = evt.serverContent.outputTranscription.text
|
text = evt.serverContent.outputTranscription.text
|
||||||
if text:
|
|
||||||
await self.push_frame(LLMFullResponseStartFrame())
|
if not text:
|
||||||
await self.push_frame(LLMTextFrame(text=text))
|
return
|
||||||
await self.push_frame(TTSTextFrame(text=text))
|
|
||||||
await self.push_frame(LLMFullResponseEndFrame())
|
await self.push_frame(LLMTextFrame(text=text))
|
||||||
|
await self.push_frame(TTSTextFrame(text=text))
|
||||||
|
|
||||||
def create_context_aggregator(
|
def create_context_aggregator(
|
||||||
self,
|
self,
|
||||||
@@ -934,6 +936,6 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
GeminiMultimodalLiveContext.upgrade(context)
|
GeminiMultimodalLiveContext.upgrade(context)
|
||||||
user = GeminiMultimodalLiveUserContextAggregator(context, params=user_params)
|
user = GeminiMultimodalLiveUserContextAggregator(context, params=user_params)
|
||||||
|
|
||||||
assistant_params.expect_stripped_words = True
|
assistant_params.expect_stripped_words = False
|
||||||
assistant = GeminiMultimodalLiveAssistantContextAggregator(context, params=assistant_params)
|
assistant = GeminiMultimodalLiveAssistantContextAggregator(context, params=assistant_params)
|
||||||
return GeminiMultimodalLiveContextAggregatorPair(_user=user, _assistant=assistant)
|
return GeminiMultimodalLiveContextAggregatorPair(_user=user, _assistant=assistant)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from pipecat.frames.frames import (
|
|||||||
FunctionCallResultFrame,
|
FunctionCallResultFrame,
|
||||||
LLMMessagesUpdateFrame,
|
LLMMessagesUpdateFrame,
|
||||||
LLMSetToolsFrame,
|
LLMSetToolsFrame,
|
||||||
|
LLMTextFrame,
|
||||||
)
|
)
|
||||||
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
|
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
@@ -170,6 +171,14 @@ class OpenAIRealtimeUserContextAggregator(OpenAIUserContextAggregator):
|
|||||||
|
|
||||||
|
|
||||||
class OpenAIRealtimeAssistantContextAggregator(OpenAIAssistantContextAggregator):
|
class OpenAIRealtimeAssistantContextAggregator(OpenAIAssistantContextAggregator):
|
||||||
|
# The LLMAssistantContextAggregator uses TextFrames to aggregate the LLM output,
|
||||||
|
# but the OpenAIRealtimeLLMService pushes LLMTextFrames and TTSTextFrames. We
|
||||||
|
# need to override this proces_frame for LLMTextFrame, so that only the TTSTextFrames
|
||||||
|
# are process. This ensures that the context gets only one set of messages.
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
if not isinstance(frame, LLMTextFrame):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
|
||||||
async def handle_function_call_result(self, frame: FunctionCallResultFrame):
|
async def handle_function_call_result(self, frame: FunctionCallResultFrame):
|
||||||
await super().handle_function_call_result(frame)
|
await super().handle_function_call_result(frame)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user