Metadata-Version: 2.4
Name: odin-voice
Version: 2.0.1
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Rust
Classifier: Topic :: Multimedia :: Sound/Audio
Summary: Python bindings for the 4Players ODIN Voice SDK
Keywords: voice,chat,audio,voip,odin
Author-email: 4Players GmbH <odin@4players.io>
License: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Documentation, https://docs.4players.io
Project-URL: Homepage, https://odin.4players.io
Project-URL: Repository, https://github.com/4Players/odin-sdk

# odin-voice

Python bindings for the [4Players ODIN](https://www.4players.io/odin/) Voice SDK - a cross-platform solution for integrating real-time voice chat into your applications.

## What is ODIN?

ODIN is a cross-platform SDK that integrates real-time voice chat into multiplayer games, applications and websites. It supports both managed cloud hosting through 4Players and self-hosted deployments, giving developers flexibility in their deployment strategy.

### Key Features

- **Real-Time Voice Chat** - Low-latency audio transmission with Opus codec compression and automatic sample rate conversion
- **Audio Processing** - Voice Activity Detection (VAD), echo cancellation, noise suppression, automatic gain control and AI-powered voice isolation
- **Channels & Proximity Chat** - Up to 64 logical channels per room with per-channel 3D positions; the server only forwards what a peer wants to hear and what is in range
- **End-to-End Encryption** - Secure voice communication with customizable room passwords
- **Sockets** - Reliable and unreliable data channels between peers, alongside the voice stream
- **Connection Pooling** - Efficient multi-room management for complex applications
- **Flexible Pipeline** - Extensible audio pipeline architecture supporting custom effects

## Installation

```bash
pip install odin-voice
```

## Quick Start

```python
import asyncio
import json
import time

from odin_voice import (
    OdinConnectionPool,
    OdinTokenGenerator,
    OdinEncoder,
    OdinDecoder,
    OdinAudioEncoderConfig,
    OdinAudioDecoderConfig,
)

async def main():
    # Generate a token (in production, do this server-side)
    generator = OdinTokenGenerator.from_access_key("your-access-key")
    nbf = int(time.time())
    token = generator.sign(json.dumps({
        "rid": "my-room",
        "uid": "user-123",
        "aud": "gateway",
        "nbf": nbf,
        "exp": nbf + 300,
    }))

    async with OdinConnectionPool() as pool:
        room = pool.join_room(
            gateway="gateway.odin.4players.io",
            token=token,
            user_data={"name": "user-123"},
        )

        # Wait for the join to complete
        own_peer_id, room_id, customer = await room.joined()
        print(f"Joined {room_id} as peer {own_peer_id}")

        # Encoder for outgoing audio; the room stamps our peer id into each datagram
        encoder = OdinEncoder(OdinAudioEncoderConfig(sample_rate=48000))

        # One decoder per remote peer
        decoders: dict[int, OdinDecoder] = {}

        async def handle_events():
            async for rpc in room.rpcs():
                match rpc:
                    case {"PeerJoined": {"peer_id": peer_id}}:
                        decoders[peer_id] = OdinDecoder(OdinAudioDecoderConfig(sample_rate=48000))
                    case {"PeerLeft": {"peer_id": peer_id}}:
                        decoders.pop(peer_id, None)
                    case _:
                        print(f"Event: {rpc}")

        async def handle_audio():
            async for datagram in room.datagrams():
                if decoder := decoders.get(datagram.peer_id):
                    decoder.push(datagram.data)
                    samples, is_silent = decoder.pop(960)  # 20ms at 48kHz mono
                    # Process samples...

        await asyncio.gather(handle_events(), handle_audio())

asyncio.run(main())
```

Events arrive as dicts with the event name as their only key: `Joined`, `Left`, `PeerJoined`, `PeerLeft`, `PeerChanged`, `MessageReceived`, `NewReconnectToken`, `Error`, plus a locally generated `RoomStatusChanged` whenever the connection state moves between `joining`, `joined` and `closed`. Commands go out the same way via `room.send_rpc({...})`.

## Channels and Positions

Every voice datagram travels on one or more of 64 logical channels, each with a 3D position. Which channels you *send* on is decided by the positions set on your encoder; which channels of which peers you *receive* is decided by channel masks on the room.

```python
# Transmit on channel 3 from position (10, 0, 5); a fresh encoder sends on channel 0 at the origin
encoder.set_position(10.0, 0.0, 5.0, channel=3)
encoder.clear_position(0)

# Only hear peer 7 on channels 1, 3 and 5; peer id 0 sets the default for everybody
room.set_channel_masks({7: 0b10101, 0: 0xFFFF_FFFF_FFFF_FFFF})

# Decoders report what they last received
print(hex(decoder.active_channels), decoder.positions)
```

The server culls audio whose sender is further than one unit away on that channel, so positions double as a proximity filter. Coordinates are fixed point with 1/16 resolution.

## Sockets

Besides voice, two peers can exchange arbitrary data over the room's connection. A socket is opened towards one peer and shows up on the other side with its first message.

```python
from odin_voice import OdinSocketType

# Open a socket to peer 7; reliable is ordered and retransmitted, unreliable is fire-and-forget
sock = room.create_socket(7, kind=OdinSocketType.Reliable, label=1)
sock.send(b"hello")

# Messages from sockets opened on either side arrive on one stream
async for message in room.sockets():
    print(message.socket.remote_peer_id, message.socket.info.label, message.data)
    if message.socket.info.is_inbound:
        message.socket.send(b"got it")   # answer on the socket the peer opened
```

Sockets are bound to the connection they were opened on and stop working after a reconnect. An unreliable message has to fit into a single datagram together with its header, so its size is bounded by the connection's path MTU, typically a little under 1.5 KB; sending more raises `OdinError` naming the exact limit. Reliable sockets have no such bound.

## Token Generation

Tokens authenticate users and grant access to rooms. **Generate tokens server-side only** - never embed access keys in client code.

```python
import json
import time

from odin_voice import OdinTokenGenerator

# Create from your access key
generator = OdinTokenGenerator.from_access_key("your-access-key")

# Or generate a new key pair (share public_key with 4Players)
generator = OdinTokenGenerator.generate()
print(f"Access Key: {generator.access_key}")  # Keep secret!
print(f"Public Key: {generator.public_key}")  # Share with 4Players

# Create a token
nbf = int(time.time())
token = generator.sign(json.dumps({
    "rid": "my-room",     # Room ID
    "uid": "user-id",     # User ID
    "aud": "gateway",     # Audience: "gateway" or "sfu"
    "nbf": nbf,           # Not before (Unix timestamp)
    "exp": nbf + 300,     # Expiration (Unix timestamp)
}))
```

## End-to-End Encryption

Enable E2EE by creating a cipher with a shared password. All peers must use the same password. A cipher serves exactly one room.

```python
from odin_voice import OdinCipher, OdinCryptoPeerStatus

# Create cipher with password
cipher = OdinCipher("shared-secret")

# Join room with encryption
room = pool.join_room(gateway=gateway, token=token, cipher=cipher)

# Check peer encryption status
status = cipher.peer_status(peer_id)
match status:
    case OdinCryptoPeerStatus.Encrypted:
        print("Peer is using the same password - secure!")
    case OdinCryptoPeerStatus.PasswordMismatch:
        print("Peer is using a different password")
    case OdinCryptoPeerStatus.Unencrypted:
        print("Peer is not using encryption")

# Change password at runtime
cipher.set_password("new-password")
```

## Audio Processing Pipeline

Add effects like VAD, noise suppression and voice isolation to encoders and decoders:

```python
from odin_voice import (
    OdinEncoder,
    OdinAudioEncoderConfig,
    OdinVadConfig,
    OdinApmConfig,
    OdinViConfig,
    OdinNoiseSuppressionLevel,
    OdinGainControllerVersion
)

encoder = OdinEncoder(OdinAudioEncoderConfig())
pipeline = encoder.pipeline

# Add Voice Activity Detection with default settings
vad_id = pipeline.insert_vad(0, OdinVadConfig())

# Add automatic gain control, noise suppression and echo cancellation
apm_config = OdinApmConfig(
    gain_controller_version=OdinGainControllerVersion.V2,
    noise_suppression_level=OdinNoiseSuppressionLevel.High,
    echo_canceller=True,
)
apm_id = pipeline.insert_apm(1, 48000, False, apm_config)

# Echo cancellation needs to know what is being played back
pipeline.update_apm_playback(apm_id, playback_samples, delay_ms=10)

# Add AI-powered voice isolation
vi_id = pipeline.insert_vi(2, OdinViConfig(enabled=True, attenuation_limit_db=100.0))

# Reconfigure or remove effects at any time
pipeline.set_vad_config(vad_id, OdinVadConfig.disabled())
pipeline.remove_effect(vi_id)
```

## Connection Statistics

Monitor connection quality:

```python
stats = room.stats
if stats:
    print(f"RTT: {stats.rtt:.1f}ms")
    print(f"Packet Loss: {stats.udp_tx_loss:.1f}%")
    print(f"RX Datagrams: {stats.udp_rx_datagrams}")
    print(f"TX Datagrams: {stats.udp_tx_datagrams}")

# Encoders, decoders and rooms expose their internals for debugging
print(decoder.dump()["decoder"]["jitter_buffer"])
print(room.dump())  # join state, gateway, own peer id, join parameters with the token redacted
```

## Documentation

For full documentation, visit [docs.4players.io](https://docs.4players.io).

