> For the complete documentation index, see [llms.txt](https://docs.ojin.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ojin.ai/models/build-with-python-sdk.md).

# Python SDK

> Drive Ojin's face models — **Oris Presence** and **Oris Portrait** — straight from Python. Feed in your TTS audio; get back a synchronized, lip-synced talking-avatar stream.

The [`ojin-client`](https://pypi.org/project/ojin-client/) SDK is the fastest way to add a real-time talking avatar to a Python app or voice agent. You bring the words (any TTS); the SDK streams back a face that speaks them — perfectly in sync — handling buffering, the playback clock, resampling, and barge-in for you.

{% hint style="info" %}
**Just want a complete agent?** If you'd rather not assemble a pipeline, the [Human Agent](/apps/overview.md) gives you a full speech-in, speech-out avatar with a single line of embed code. Already on [Pipecat](https://github.com/pipecat-ai/pipecat)? Jump to the [Pipecat integration](/models/introduction/integrations.md).
{% endhint %}

## Requirements

* **Python 3.10+**
* An **Ojin API key** — [get yours here](/getting-started/authentication.md)
* A face-model **config ID** — a Presence or Portrait configuration from your [dashboard](https://ojin.ai/dashboard)

New accounts start with **$10 in free credits**.

## Install

With [uv](https://docs.astral.sh/uv/) (recommended):

```bash
uv add "ojin-client[stv]"
```

Or with pip:

```bash
pip install "ojin-client[stv]"
```

The `[stv]` extra pulls in the speech-to-video helpers (`numpy`, `opencv-python-headless`, `soxr`) that handle resampling, decoding, and the sync math for you.

## Authenticate

The client needs your API key and a configuration ID. Keep both out of source — read them from the environment:

```bash
export OJIN_API_KEY="…"      # from your Ojin account
export OJIN_CONFIG_ID="…"    # a Presence or Portrait configuration to drive
```

```python
import os
from ojin.stv import OjinSTVClient

client = OjinSTVClient(
    api_key=os.environ["OJIN_API_KEY"],
    config_id=os.environ["OJIN_CONFIG_ID"],
)
```

Or let the SDK load and validate them for you — `resolve_credentials()` reads an optional `.env` file and raises a clear error if either value is missing:

```python
from ojin import resolve_credentials
from ojin.stv import OjinSTVClient

creds = resolve_credentials()  # reads OJIN_API_KEY + OJIN_CONFIG_ID
client = OjinSTVClient(api_key=creds.api_key, config_id=creds.config_id)
```

By default the client connects to `wss://models.ojin.ai/realtime`.

{% hint style="warning" %}
Never hardcode your API key in source or commit it to version control.
{% endhint %}

## Pick your model

The SDK is **identical for both face models** — the `config_id` you pass selects which one runs.

| A `config_id` from…         | You get                                                                                    | Choose it when                                  |
| --------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------- |
| an **Oris Presence** config | the flagship, fully expressive generative presence — rich expressions and natural movement | quality and expressiveness matter most          |
| an **Oris Portrait** config | a cost-effective, real-time lip-synced talking face                                        | you want a great talking face at the best price |

Create configurations in the [dashboard](https://ojin.ai/dashboard). The code below is the same either way — only the `config_id` changes.

## Two clients, one package

|             | `OjinSTVClient` (high-level)                               | `OjinClient` (low-level)                  |
| ----------- | ---------------------------------------------------------- | ----------------------------------------- |
| Import      | `from ojin.stv import OjinSTVClient`                       | `from ojin.ojin_client import OjinClient` |
| You give it | TTS audio at **any** sample rate                           | 16 kHz mono `int16` PCM                   |
| You get     | synced `STVAudioFrame` + `STVVideoFrame` on a 25 fps clock | raw protocol messages (JPEG + PCM)        |
| It handles  | buffering, playback clock, resampling, barge-in re-sync    | just the WebSocket + wire protocol        |

Most applications want **`OjinSTVClient`**. Reach for `OjinClient` only when you're writing your own playback/sync layer.

## Quickstart

Feed the avatar one utterance of TTS audio and consume the synchronized stream it speaks back.

```python
import asyncio
import os
import wave

from ojin.stv import OjinSTVClient, STVEvent, STVAudioFrame, STVVideoFrame


async def main() -> None:
    ready = asyncio.Event()
    done = asyncio.Event()

    async with OjinSTVClient(
        api_key=os.environ["OJIN_API_KEY"],
        config_id=os.environ["OJIN_CONFIG_ID"],
    ) as client:
        # Events may be sync or async; register with a decorator or add_listener().
        client.add_listener(STVEvent.SESSION_READY, lambda **_: ready.set())
        client.add_listener(STVEvent.BOT_STOPPED_SPEAKING, lambda **_: done.set())

        @client.on(STVEvent.ERROR)
        def _on_error(message: str, **_):
            print("ojin error:", message)

        # Wait until the avatar session is live (race-free: skip if already up).
        if not client.is_connected:
            await ready.wait()

        # Send one utterance of mono int16 PCM TTS audio at ANY sample rate —
        # the SDK resamples the copy it sends to the server for lip-sync.
        with wave.open("hello.wav", "rb") as wav:  # a mono WAV
            await client.say(
                wav.readframes(wav.getnframes()),
                sample_rate=wav.getframerate(),
                num_channels=1,
            )

        # Consume the synchronized 25 fps audio + video stream.
        async def consume() -> None:
            async for frame in client.output_stream():
                if isinstance(frame, STVVideoFrame) and frame.rgb is not None:
                    ...  # frame.rgb = width*height*3 RGB bytes — render it
                elif isinstance(frame, STVAudioFrame):
                    ...  # frame.pcm = int16 PCM @ frame.sample_rate — play it

        consumer = asyncio.create_task(consume())
        await done.wait()           # the avatar finished the turn
        await asyncio.sleep(0.5)    # let the tail frames drain
        consumer.cancel()


asyncio.run(main())
```

`say()` is a one-shot helper for `start_turn()` + `send_tts_audio()`. For a **live agent**, call `start_turn()` once per utterance and stream chunks with `send_tts_audio()` as your TTS produces them — the user hears the exact audio you sent, lip-synced to the avatar.

{% hint style="success" %}
**The SDK shapes the input for you.** Feed audio at whatever cadence your TTS produces — even tiny 40 ms fragments. `OjinSTVClient` primes a \~1 s lead after each `start_turn()` and coalesces your audio into large chunks before forwarding it, so the inference head never starves and lip-sync stays stable. It also paces `output_stream()` to realtime 25 fps with audio and video already in sync. You don't manage input buffering, the playback clock, or frame-dropping yourself — see [Optimizing Performance](/guides/optimizing-performance.md) for how it works and how to tune it.
{% endhint %}

## Events you'll use

Register handlers with `@client.on(STVEvent.X)` or `client.add_listener(STVEvent.X, cb)`. Handlers may be sync or async; a failing handler never breaks the playback loop.

| Event                  | Fires when                                 |
| ---------------------- | ------------------------------------------ |
| `SESSION_READY`        | the session is live and ready for audio    |
| `BOT_STARTED_SPEAKING` | the first speech frame of a turn is played |
| `BOT_STOPPED_SPEAKING` | a turn has finished playing                |
| `INTERRUPTED`          | a barge-in was accepted                    |
| `ERROR`                | a transport or server error occurred       |
| `CLOSED`               | the session has been torn down             |

## Where to go next

### Runnable examples

Realtime streaming and render-to-MP4, ready to clone

[View examples →](https://github.com/ojinai/python-sdk/tree/main/examples)

### Build a voice agent

Drop the avatar into a Pipecat pipeline

[Pipecat integration →](/models/introduction/integrations.md)

* **Best practices** — [working-pipeline patterns](/models/build-with-python-sdk/python-sdk-best-practices.md) for browser output and WebRTC backends
* **Choose a face model** — [Oris Presence](/models/oris-presence.md) (flagship) or [Oris Portrait](/models/oris-portrait.md) (cost-effective)
* **Tune latency & stability** — [Optimizing Performance](/guides/optimizing-performance.md) and the [Troubleshooting guide](/guides/troubleshooting.md)
* **Full API surface** — the complete client reference lives in the [`ojin-client` README](https://github.com/ojinai/python-sdk) and on [PyPI](https://pypi.org/project/ojin-client/)
* **Starter examples** — `02-realtime-speech-to-video` (live streaming) and `01-speech-to-video-mp4-generator` (render to file)


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.ojin.ai/models/build-with-python-sdk.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
