Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
),
)
Expand Down Expand Up @@ -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()
Expand All @@ -310,6 +317,44 @@ 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).
The parameters `at_seconds` and `in_seconds` only support positive finite numbers, a finite guardrail is in place to prevent invalid JSON serialization at session start. All other invalid values will be returned with from the backend and be ignored.

#### 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
Expand Down Expand Up @@ -481,7 +526,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(
Expand All @@ -490,6 +535,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,
)
```
Expand Down
2 changes: 2 additions & 0 deletions src/anam/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ async def consume_audio():
AnamEvent,
ClientOptions,
ConnectionClosedCode,
DirectorNotes,
EgressDailyOptions,
EgressOptions,
Message,
Expand All @@ -82,6 +83,7 @@ async def consume_audio():
"AudioFrame",
"ClientOptions",
"ConnectionClosedCode",
"DirectorNotes",
"EgressDailyOptions",
"EgressOptions",
"Message",
Expand Down
59 changes: 46 additions & 13 deletions src/anam/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -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:
Expand All @@ -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 ``"playful"``).
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) -> StreamingClient:
"""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.
Expand Down
32 changes: 32 additions & 0 deletions src/anam/types.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Type definitions for the Anam SDK."""

import math
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Literal
Expand Down Expand Up @@ -47,6 +48,31 @@ 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
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.expressivity is not None:
# Non-finite floats are invalid JSON
if not math.isfinite(self.expressivity):
raise ValueError("expressivity must be a finite number")
result["expressivity"] = self.expressivity
return result


@dataclass
class PersonaConfig:
"""Configuration for an Anam persona.
Expand All @@ -62,6 +88,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.
"""
Expand All @@ -75,6 +102,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]:
Expand All @@ -98,6 +126,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
Expand Down
Loading
Loading