diff --git a/README.md b/README.md index f31d7c1..f6ae26d 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ async with client.connect(session_options=session_options) as session: The main client class for connecting to Anam AI. ```python -from anam import AnamClient, PersonaConfig, ClientOptions +from anam import AnamClient, ClientOptions, DirectorNotes, PersonaConfig # Simple initialization for pre-defined personas - all other parameters are ignored except enable_audio_passthrough client = AnamClient( @@ -172,6 +172,10 @@ client = AnamClient( system_prompt="You are a helpful assistant...", avatar_model="cara-4", language_code="en", + director_notes=DirectorNotes( + preset_style="warm", + expressivity=0.7, + ), enable_audio_passthrough=False, ), ) @@ -300,6 +304,9 @@ async with client.connect() as session: # Interrupt the avatar if speaking await session.interrupt() + + # Runtime director-note cue (turn-scoped) + await session.send_director_note_cue("curious", at_seconds=0.5) # Get message history history = client.get_message_history() @@ -310,6 +317,43 @@ async with client.connect() as session: await session.wait_until_closed() ``` +### Director Notes + +Director notes control how the avatar performs a conversation (how something is said, not just what is said). Set session defaults on `PersonaConfig(director_notes=...)`, then send runtime `director_note_cue` messages with `session.send_director_note_cue(...)`. +For supported styles/cues and timing semantics (`at_seconds` vs `in_seconds`), see the official docs: [Director Notes](https://anam.ai/docs/personas/director-notes). + +#### Initial Director Notes + +Set director notes directly on `PersonaConfig` to apply them from session start: + +```python +from anam import DirectorNotes, PersonaConfig + +persona = PersonaConfig( + avatar_id="your-avatar-id", + director_notes=DirectorNotes( + preset_style="warm", + expressivity=0.7, + ), +) +``` +The initial director notes are the avatar's default presence for the session. + +##### Runtime Director Note Cues + +Use `session.send_director_note_cue(...)` to adjust delivery style during the active turn. + +```python +import asyncio + +async with client.connect() as session: + cues = ["warm", "playful", "concerned", "laughter"] + + for cue in cues: + await session.send_director_note_cue(cue, at_seconds=3.0) + await asyncio.sleep(8) +``` + ## Examples ### Save Video and Audio @@ -481,7 +525,7 @@ Ephemeral personas give you full control over components at startup. Configure a They are ideal for production environments where you need to control the components at startup. ```python -from anam import PersonaConfig +from anam import DirectorNotes, PersonaConfig # Ephemeral: specify avatar_id, voice_id, and optionally llm_id, avatar_model persona = PersonaConfig( @@ -490,6 +534,10 @@ persona = PersonaConfig( llm_id="your-llm-id", # From https://lab.anam.ai/llms (optional) avatar_model="cara-4", # Video frame model (optional) system_prompt="You are...", # See https://docs.anam.ai/concepts/prompting-guide + director_notes=DirectorNotes( # Optional: Initial director notes + preset_style="warm", + expressivity=0.7, + ), enable_audio_passthrough=False, ) ``` diff --git a/src/anam/__init__.py b/src/anam/__init__.py index f6f4da2..7384389 100644 --- a/src/anam/__init__.py +++ b/src/anam/__init__.py @@ -59,6 +59,7 @@ async def consume_audio(): AnamEvent, ClientOptions, ConnectionClosedCode, + DirectorNotes, EgressDailyOptions, EgressOptions, Message, @@ -82,6 +83,7 @@ async def consume_audio(): "AudioFrame", "ClientOptions", "ConnectionClosedCode", + "DirectorNotes", "EgressDailyOptions", "EgressOptions", "Message", diff --git a/src/anam/client.py b/src/anam/client.py index 377ebf4..d118bb3 100644 --- a/src/anam/client.py +++ b/src/anam/client.py @@ -3,7 +3,9 @@ from __future__ import annotations import asyncio +import json import logging +import math import uuid from collections.abc import AsyncIterator from typing import Any, Awaitable, Callable, TypeVar @@ -531,9 +533,6 @@ async def send_message(self, content: str) -> None: Raises: SessionError: If not connected or if LLM is not available. """ - if not self._client._streaming_client: - raise SessionError("Not connected") - # Validate that LLM is available for processing messages persona_config = self._get_persona_config() @@ -545,13 +544,7 @@ async def send_message(self, content: str) -> None: "Persona ID and LLM ID are not set, messages will not be processed by the backend." ) - # Wait for data channel to be ready - streaming = self._client._streaming_client - if not getattr(streaming, "_data_channel_open", False): - logger.debug("Waiting for data channel to open...") - if not await streaming.wait_for_data_channel(timeout=10.0): - raise SessionError("Data channel did not open in time") - + streaming = await self._ensure_data_channel_open() streaming.send_user_message(content) async def interrupt(self) -> None: @@ -560,17 +553,57 @@ async def interrupt(self) -> None: Raises: SessionError: If not connected. """ + streaming = await self._ensure_data_channel_open() + streaming.send_interrupt() + + async def send_director_note_cue( + self, + tag: str, + *, + at_seconds: float | None = None, + in_seconds: float | None = None, + ) -> None: + """Send a director-note cue over the active session data channel. + + Args: + tag: Director-note cue tag (for example ``"curious"``). + at_seconds: Optional turn-relative cue time in seconds. + in_seconds: Optional delay relative to receipt time in seconds. + + Raises: + SessionError: If not connected, data channel is unavailable, or send fails. + ValueError: If a provided timing value is not finite. + """ + for field_name, value in (("at_seconds", at_seconds), ("in_seconds", in_seconds)): + if value is not None and not math.isfinite(value): + raise ValueError(f"{field_name} must be a finite number") + + payload: dict[str, Any] = { + "message_type": "director_note_cue", + "cue": {"tag": tag}, + } + + if at_seconds is not None: + payload["at_seconds"] = at_seconds + + if in_seconds is not None: + payload["in_seconds"] = in_seconds + + streaming = await self._ensure_data_channel_open() + if not streaming.send_data_message(json.dumps(payload)): + raise SessionError("Failed to send director note cue over data channel") + + async def _ensure_data_channel_open(self): + """Return the streaming client once the data channel is ready.""" if not self._client._streaming_client: raise SessionError("Not connected") - # Wait for data channel to be ready (same as send_message) streaming = self._client._streaming_client if not getattr(streaming, "_data_channel_open", False): logger.debug("Waiting for data channel to open...") if not await streaming.wait_for_data_channel(timeout=10.0): raise SessionError("Data channel did not open in time") - - streaming.send_interrupt() + return streaming def create_talk_stream(self, correlation_id: str | None = None) -> TalkMessageStream: """Create a talk message stream for sending text chunks to TTS. diff --git a/src/anam/types.py b/src/anam/types.py index fdcb9a2..4d4e55c 100644 --- a/src/anam/types.py +++ b/src/anam/types.py @@ -47,6 +47,34 @@ class MessageRole(str, Enum): SYSTEM = "system" +@dataclass +class DirectorNotes: + """Director-notes settings applied at session start via ``PersonaConfig``. + + These fields are optional and serialized only when set. + """ + + custom_style_prompt: str | None = None + preset_style: str | None = None + idling_prompt: str | None = None + overlay_model_name_on_output: bool | None = None + expressivity: float | None = None + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = {} + if self.custom_style_prompt is not None: + result["customStylePrompt"] = self.custom_style_prompt + if self.preset_style is not None: + result["presetStyle"] = self.preset_style + if self.idling_prompt is not None: + result["idlingPrompt"] = self.idling_prompt + if self.overlay_model_name_on_output is not None: + result["overlayModelNameOnOutput"] = self.overlay_model_name_on_output + if self.expressivity is not None: + result["expressivity"] = self.expressivity + return result + + @dataclass class PersonaConfig: """Configuration for an Anam persona. @@ -62,6 +90,7 @@ class PersonaConfig: llm_id: LLM model to use (optional). Set to 'CUSTOMER_CLIENT_V1' to disable Anam's default brain for custom LLM integration. max_session_length_seconds: Maximum session duration (optional). + director_notes: Optional director-notes defaults applied at session start. enable_audio_passthrough: If True, bypasses Anam's orchestration layer and allows to ingest TTS audio directly through the socket. """ @@ -75,6 +104,7 @@ class PersonaConfig: language_code: str | None = None llm_id: str | None = None max_session_length_seconds: int | None = None + director_notes: DirectorNotes | None = None enable_audio_passthrough: bool | None = False def to_dict(self) -> dict[str, Any]: @@ -98,6 +128,10 @@ def to_dict(self) -> dict[str, Any]: result["llmId"] = self.llm_id if self.max_session_length_seconds is not None: result["maxSessionLengthSeconds"] = self.max_session_length_seconds + if self.director_notes is not None: + director_notes = self.director_notes.to_dict() + if director_notes: + result["directorNotes"] = director_notes if self.enable_audio_passthrough is not None: result["enableAudioPassthrough"] = self.enable_audio_passthrough return result diff --git a/tests/test_client.py b/tests/test_client.py index 4c98340..4c973bc 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,8 @@ """Tests for AnamClient.""" -from unittest.mock import AsyncMock +import json +import math +from unittest.mock import AsyncMock, MagicMock import pytest @@ -8,12 +10,14 @@ AnamClient, AnamEvent, ClientOptions, + DirectorNotes, MessageRole, MessageStreamEvent, PersonaConfig, + Session, SessionOptions, ) -from anam.errors import ConfigurationError +from anam.errors import ConfigurationError, SessionError class TestAnamClientInit: @@ -208,6 +212,11 @@ def test_to_dict_full(self) -> None: language_code="en", llm_id="gpt-4", max_session_length_seconds=300, + director_notes=DirectorNotes( + preset_style="warm", + expressivity=0.8, + custom_style_prompt="speak softly", + ), ) result = config.to_dict() assert result["personaId"] == "test-id" @@ -218,6 +227,11 @@ def test_to_dict_full(self) -> None: assert result["languageCode"] == "en" assert result["llmId"] == "gpt-4" assert result["maxSessionLengthSeconds"] == 300 + assert result["directorNotes"] == { + "presetStyle": "warm", + "expressivity": 0.8, + "customStylePrompt": "speak softly", + } class TestSessionOptions: @@ -253,3 +267,91 @@ def test_to_dict_with_video_quality_auto(self) -> None: def test_invalid_video_quality_raises_value_error(self) -> None: with pytest.raises(ValueError, match='video_quality must be either "high" or "auto"'): SessionOptions(video_quality="medium") # type: ignore[arg-type] + + +class TestDirectorNoteCue: + """Tests for Session.send_director_note_cue.""" + + @pytest.mark.asyncio + async def test_sends_at_seconds_cue_payload(self) -> None: + client = AnamClient( + api_key="test-key", + persona_config=PersonaConfig(avatar_id="avatar-only"), + ) + client._streaming_client = MagicMock() + client._streaming_client._data_channel_open = True + client._streaming_client.send_data_message = MagicMock(return_value=True) + + session_obj = Session(client) + await session_obj.send_director_note_cue("curious", at_seconds=0.5) + + client._streaming_client.send_data_message.assert_called_once() + payload = json.loads(client._streaming_client.send_data_message.call_args.args[0]) + assert payload == { + "message_type": "director_note_cue", + "cue": {"tag": "curious"}, + "at_seconds": 0.5, + } + + @pytest.mark.asyncio + async def test_sends_in_seconds_cue_payload(self) -> None: + client = AnamClient(api_key="test-key", persona_id="stateful-persona") + client._streaming_client = MagicMock() + client._streaming_client._data_channel_open = True + client._streaming_client.send_data_message = MagicMock(return_value=True) + + session_obj = Session(client) + await session_obj.send_director_note_cue("concerned", in_seconds=1.25) + + payload = json.loads(client._streaming_client.send_data_message.call_args.args[0]) + assert payload == { + "message_type": "director_note_cue", + "cue": {"tag": "concerned"}, + "in_seconds": 1.25, + } + + @pytest.mark.asyncio + async def test_forwards_payload_verbatim(self) -> None: + """The SDK is pass-through: the backend is the single source of truth.""" + client = AnamClient(api_key="test-key", persona_id="stateful-persona") + client._streaming_client = MagicMock() + client._streaming_client._data_channel_open = True + client._streaming_client.send_data_message = MagicMock(return_value=True) + + session_obj = Session(client) + await session_obj.send_director_note_cue("warm", at_seconds=-0.1, in_seconds=2.0) + + payload = json.loads(client._streaming_client.send_data_message.call_args.args[0]) + assert payload == { + "message_type": "director_note_cue", + "cue": {"tag": "warm"}, + "at_seconds": -0.1, + "in_seconds": 2.0, + } + + @pytest.mark.asyncio + @pytest.mark.parametrize("field", ["at_seconds", "in_seconds"]) + @pytest.mark.parametrize("value", [math.inf, -math.inf, math.nan]) + async def test_rejects_non_finite_timing(self, field: str, value: float) -> None: + """Non-finite floats would serialize to invalid JSON (Infinity/NaN), which + breaks the engine's parser, so the SDK rejects them before sending.""" + client = AnamClient(api_key="test-key", persona_id="stateful-persona") + client._streaming_client = MagicMock() + client._streaming_client._data_channel_open = True + client._streaming_client.send_data_message = MagicMock(return_value=True) + + session_obj = Session(client) + with pytest.raises(ValueError, match=f"{field} must be a finite number"): + await session_obj.send_director_note_cue("warm", **{field: value}) + client._streaming_client.send_data_message.assert_not_called() + + @pytest.mark.asyncio + async def test_raises_when_data_channel_send_fails(self) -> None: + client = AnamClient(api_key="test-key", persona_id="stateful-persona") + client._streaming_client = MagicMock() + client._streaming_client._data_channel_open = True + client._streaming_client.send_data_message = MagicMock(return_value=False) + + session_obj = Session(client) + with pytest.raises(SessionError, match="Failed to send director note cue"): + await session_obj.send_director_note_cue("surprised", at_seconds=0.0) diff --git a/uv.lock b/uv.lock index d16d761..66bc0cd 100644 --- a/uv.lock +++ b/uv.lock @@ -188,7 +188,7 @@ wheels = [ [[package]] name = "anam" -version = "0.4.0a2" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "aiohttp" },