diff --git a/changelog/3995.added.md b/changelog/3995.added.md new file mode 100644 index 0000000000..f0fa6f2404 --- /dev/null +++ b/changelog/3995.added.md @@ -0,0 +1,4 @@ +- Updated LemonSlice transport: + - Added `on_avatar_connected` and `on_avatar_disconnected` events triggered when the avatar joins and leaves the room + - Added `api_url` parameter to `LemonSliceNewSessionRequest` to allow overriding the LemonSlice API endpoint + - Added support for passing arbitrary named parameters to the LemonSlice API endpoint diff --git a/changelog/3995.fixed.md b/changelog/3995.fixed.md new file mode 100644 index 0000000000..7100b8f831 --- /dev/null +++ b/changelog/3995.fixed.md @@ -0,0 +1 @@ +- Fixed LemonSlice transport example to use a publicly accessible ElevenLabs voice ID diff --git a/examples/foundational/56-lemonslice-transport.py b/examples/foundational/56-lemonslice-transport.py index 67f4e8ad17..8801dcb0eb 100644 --- a/examples/foundational/56-lemonslice-transport.py +++ b/examples/foundational/56-lemonslice-transport.py @@ -65,7 +65,7 @@ async def main(): tts = ElevenLabsTTSService( api_key=os.getenv("ELEVENLABS_API_KEY", ""), settings=ElevenLabsTTSService.Settings( - voice="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + voice="21m00Tcm4TlvDq8ikWAM", # Public voice ID ), ) @@ -114,6 +114,14 @@ async def on_client_disconnected(transport, participant): logger.info("Client disconnected") await task.cancel() + @transport.event_handler("on_avatar_connected") + async def on_avatar_connected(transport, participant): + logger.info(f"Avatar connected") + + @transport.event_handler("on_avatar_disconnected") + async def on_avatar_disconnected(transport, participant): + logger.info(f"Avatar disconnected") + runner = PipelineRunner() await runner.run(task) diff --git a/src/pipecat/transports/lemonslice/api.py b/src/pipecat/transports/lemonslice/api.py index cac341d7da..093256891a 100644 --- a/src/pipecat/transports/lemonslice/api.py +++ b/src/pipecat/transports/lemonslice/api.py @@ -45,6 +45,7 @@ async def create_session( daily_room_url: Optional[str] = None, daily_token: Optional[str] = None, properties: Optional[dict[str, Any]] = None, + api_url: Optional[str] = None, ) -> dict: """Create a new session with the specified agent_id or agent_image_url. @@ -56,6 +57,7 @@ async def create_session( daily_room_url: Daily room URL to use for the session. daily_token: Daily token for authenticating with the room. properties: Additional properties to pass to the session. + api_url: LemonSlice API URL override. Returns: Dictionary containing session_id, room_url, and control_url. @@ -64,16 +66,14 @@ async def create_session( ValueError: If neither agent_id nor agent_image_url is provided. """ if not agent_id and not agent_image_url: - # Fallback to a default agent if none is provided - logger.debug("No agent_id or agent_image_url provided, using default agent") - agent_id = "agent_080308d8b6e99f47" + raise ValueError("Provide an agent_id or agent_image_url") if agent_id and agent_image_url: raise ValueError("Provide exactly one of agent_id or agent_image_url, not both") logger.debug( f"Creating LemonSlice session: agent_id={agent_id}, agent_image_url={agent_image_url}" ) - payload: dict[str, object] = {"transport_type": "daily"} + payload: dict[str, Any] = {"transport_type": "daily"} if agent_id is not None: payload["agent_id"] = agent_id if agent_image_url is not None: @@ -82,16 +82,17 @@ async def create_session( payload["agent_prompt"] = agent_prompt if idle_timeout is not None: payload["idle_timeout"] = idle_timeout - properties_dict: dict[str, Any] = dict(properties) if properties else {} + properties_dict: dict[str, Any] = {} if daily_room_url is not None: properties_dict["daily_url"] = daily_room_url if daily_token is not None: properties_dict["daily_token"] = daily_token if properties_dict: payload["properties"] = properties_dict - async with self._session.post( - self.LEMONSLICE_URL, headers=self._headers, json=payload - ) as r: + if properties: + payload.update(properties) + url = api_url if api_url is not None else self.LEMONSLICE_URL + async with self._session.post(url, headers=self._headers, json=payload) as r: r.raise_for_status() response = await r.json() logger.debug(f"Created LemonSlice session: {response}") diff --git a/src/pipecat/transports/lemonslice/transport.py b/src/pipecat/transports/lemonslice/transport.py index 6a6894167d..023160b222 100644 --- a/src/pipecat/transports/lemonslice/transport.py +++ b/src/pipecat/transports/lemonslice/transport.py @@ -16,7 +16,7 @@ import aiohttp from daily.daily import AudioData from loguru import logger -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from pipecat.frames.frames import ( BotStartedSpeakingFrame, @@ -55,8 +55,11 @@ class LemonSliceNewSessionRequest(BaseModel): daily_room_url: Daily room URL to use for the session. daily_token: Daily token for authenticating with the room. lemonslice_properties: Additional properties to pass to the session. + api_url: Override the LemonSlice API URL. """ + model_config = ConfigDict(extra="allow") + agent_image_url: Optional[str] = None agent_id: Optional[str] = None agent_prompt: Optional[str] = None @@ -64,6 +67,7 @@ class LemonSliceNewSessionRequest(BaseModel): daily_room_url: Optional[str] = None daily_token: Optional[str] = None lemonslice_properties: Optional[dict] = None + api_url: Optional[str] = None class LemonSliceCallbacks(BaseModel): @@ -135,6 +139,9 @@ def __init__( async def _initialize(self) -> str: """Initialize the conversation and return the room URL.""" + properties = dict(self._session_request.lemonslice_properties or {}) + if self._session_request.model_extra: + properties.update(self._session_request.model_extra) response = await self._api.create_session( agent_image_url=self._session_request.agent_image_url, agent_id=self._session_request.agent_id, @@ -142,7 +149,8 @@ async def _initialize(self) -> str: idle_timeout=self._session_request.idle_timeout, daily_room_url=self._session_request.daily_room_url, daily_token=self._session_request.daily_token, - properties=self._session_request.lemonslice_properties, + properties=properties if properties else None, + api_url=self._session_request.api_url, ) self._session_id = response["session_id"] self._control_url = response["control_url"] @@ -668,6 +676,8 @@ class LemonSliceTransport(BaseTransport): - on_client_connected(transport, participant): Participant connected to the session - on_client_disconnected(transport, participant): Participant disconnected from the session + - on_avatar_connected(transport, participant): LemonSlice avatar connected to the session + - on_avatar_disconnected(transport, participant, reason): LemonSlice avatar disconnected from the session Example:: @@ -721,11 +731,15 @@ def __init__( # these handlers. self._register_event_handler("on_client_connected") self._register_event_handler("on_client_disconnected") + self._register_event_handler("on_avatar_connected") + self._register_event_handler("on_avatar_disconnected") async def _on_participant_left(self, participant, reason): """Handle participant left events.""" ls_bot_name = await self._client.get_bot_name() - if participant.get("info", {}).get("userName", "") != ls_bot_name: + if participant.get("info", {}).get("userName", "") == ls_bot_name: + await self._on_avatar_disconnected(participant) + else: await self._on_client_disconnected(participant) async def _on_participant_joined(self, participant): @@ -735,6 +749,7 @@ async def _on_participant_joined(self, participant): # Ignore the LemonSlice bot's microphone if participant.get("info", {}).get("userName", "") == ls_bot_name: self._lemonslice_participant_id = participant["id"] + await self._on_avatar_connected(participant) else: await self._on_client_connected(participant) if self._lemonslice_participant_id: @@ -781,6 +796,14 @@ def output(self) -> FrameProcessor: self._output = LemonSliceOutputTransport(client=self._client, params=self._params) return self._output + async def _on_avatar_connected(self, participant: Any): + """Handle avatar connected events.""" + await self._call_event_handler("on_avatar_connected", participant) + + async def _on_avatar_disconnected(self, participant: Any): + """Handle avatar disconnected events.""" + await self._call_event_handler("on_avatar_disconnected", participant) + async def _on_client_connected(self, participant: Any): """Handle client connected events.""" await self._call_event_handler("on_client_connected", participant)