---
title: "Audio Channels"
description: "How audio output routing works in Voicebox"
---

## Overview

Audio channels allow routing voice output to different audio devices. This is useful for multi-output setups where different voices should play through different speakers or applications.

## Architecture

**Channel:** A named audio bus that can be assigned to output devices.

**Device Mapping:** Links channels to OS audio device identifiers.

**Profile Mapping:** Links voice profiles to channels (many-to-many).

## Data Model

### AudioChannel Table

```python
class AudioChannel(Base):
    __tablename__ = "audio_channels"
    
    id = Column(String, primary_key=True)
    name = Column(String, nullable=False)
    is_default = Column(Boolean, default=False)
    created_at = Column(DateTime)
```

### ChannelDeviceMapping Table

```python
class ChannelDeviceMapping(Base):
    __tablename__ = "channel_device_mappings"
    
    id = Column(String, primary_key=True)
    channel_id = Column(String, ForeignKey("audio_channels.id"))
    device_id = Column(String)  # OS device identifier
```

### ProfileChannelMapping Table

```python
class ProfileChannelMapping(Base):
    __tablename__ = "profile_channel_mappings"
    
    profile_id = Column(String, ForeignKey("profiles.id"), primary_key=True)
    channel_id = Column(String, ForeignKey("audio_channels.id"), primary_key=True)
```

## Default Channel

A default channel is created on database initialization:

```python
def init_db():
    # Create default channel if it doesn't exist
    default_channel = db.query(AudioChannel).filter(
        AudioChannel.is_default == True
    ).first()
    
    if not default_channel:
        default_channel = AudioChannel(
            id=str(uuid.uuid4()),
            name="Default",
            is_default=True
        )
        db.add(default_channel)
        
        # Assign all existing profiles to default channel
        profiles = db.query(VoiceProfile).all()
        for profile in profiles:
            mapping = ProfileChannelMapping(
                profile_id=profile.id,
                channel_id=default_channel.id
            )
            db.add(mapping)
```

## Core Operations

### Creating a Channel

```python
async def create_channel(
    data: AudioChannelCreate,
    db: Session,
) -> AudioChannelResponse:
    # Check name uniqueness
    existing = db.query(DBAudioChannel).filter_by(name=data.name).first()
    if existing:
        raise ValueError(f"Channel with name '{data.name}' already exists")
    
    # Create channel
    channel = DBAudioChannel(
        id=str(uuid.uuid4()),
        name=data.name,
        is_default=False,
    )
    db.add(channel)
    
    # Add device mappings
    for device_id in data.device_ids:
        mapping = DBChannelDeviceMapping(
            id=str(uuid.uuid4()),
            channel_id=channel.id,
            device_id=device_id,
        )
        db.add(mapping)
    
    db.commit()
```

### Updating a Channel

```python
async def update_channel(
    channel_id: str,
    data: AudioChannelUpdate,
    db: Session,
) -> AudioChannelResponse:
    channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
    
    # Cannot modify default channel
    if channel.is_default:
        raise ValueError("Cannot modify the default channel")
    
    # Update name
    if data.name is not None:
        channel.name = data.name
    
    # Update device mappings
    if data.device_ids is not None:
        # Delete existing
        db.query(DBChannelDeviceMapping).filter_by(channel_id=channel_id).delete()
        
        # Add new
        for device_id in data.device_ids:
            mapping = DBChannelDeviceMapping(
                channel_id=channel.id,
                device_id=device_id,
            )
            db.add(mapping)
    
    db.commit()
```

### Deleting a Channel

```python
async def delete_channel(channel_id: str, db: Session) -> bool:
    channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
    
    # Cannot delete default channel
    if channel.is_default:
        raise ValueError("Cannot delete the default channel")
    
    # Delete device mappings
    db.query(DBChannelDeviceMapping).filter_by(channel_id=channel_id).delete()
    
    # Delete profile-channel mappings
    db.query(DBProfileChannelMapping).filter_by(channel_id=channel_id).delete()
    
    # Delete channel
    db.delete(channel)
    db.commit()
```

## Voice Assignment

### Assigning Voices to Channel

```python
async def set_channel_voices(
    channel_id: str,
    data: ChannelVoiceAssignment,
    db: Session,
) -> None:
    # Verify channel exists
    channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
    if not channel:
        raise ValueError(f"Channel {channel_id} not found")
    
    # Verify all profiles exist
    for profile_id in data.profile_ids:
        profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
        if not profile:
            raise ValueError(f"Profile {profile_id} not found")
    
    # Delete existing mappings
    db.query(DBProfileChannelMapping).filter_by(channel_id=channel_id).delete()
    
    # Add new mappings
    for profile_id in data.profile_ids:
        mapping = DBProfileChannelMapping(
            profile_id=profile_id,
            channel_id=channel_id,
        )
        db.add(mapping)
    
    db.commit()
```

### Assigning Channels to Voice

```python
async def set_profile_channels(
    profile_id: str,
    data: ProfileChannelAssignment,
    db: Session,
) -> None:
    # Verify profile exists
    profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
    if not profile:
        raise ValueError(f"Profile {profile_id} not found")
    
    # Delete existing mappings
    db.query(DBProfileChannelMapping).filter_by(profile_id=profile_id).delete()
    
    # Add new mappings
    for channel_id in data.channel_ids:
        mapping = DBProfileChannelMapping(
            profile_id=profile_id,
            channel_id=channel_id,
        )
        db.add(mapping)
    
    db.commit()
```

## API Endpoints

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/channels` | List all channels |
| POST | `/channels` | Create a channel |
| GET | `/channels/{id}` | Get channel by ID |
| PUT | `/channels/{id}` | Update channel |
| DELETE | `/channels/{id}` | Delete channel |
| GET | `/channels/{id}/voices` | Get assigned voices |
| PUT | `/channels/{id}/voices` | Set assigned voices |
| GET | `/profiles/{id}/channels` | Get profile's channels |
| PUT | `/profiles/{id}/channels` | Set profile's channels |

## Request/Response Schemas

### AudioChannelCreate

```json
{
  "name": "Speakers",
  "device_ids": ["device_uuid_1", "device_uuid_2"]
}
```

### AudioChannelResponse

```json
{
  "id": "channel_uuid",
  "name": "Speakers",
  "is_default": false,
  "device_ids": ["device_uuid_1", "device_uuid_2"],
  "created_at": "2024-01-15T10:30:00Z"
}
```

### ChannelVoiceAssignment

```json
{
  "profile_ids": ["profile_1", "profile_2"]
}
```

## Use Cases

### Multi-Output Setup

**Scenario:** Stream with different voice characters

1. Create "Stream" channel → OBS virtual audio
2. Create "Monitor" channel → Headphones
3. Assign "Narrator" profile → Both channels
4. Assign "Character 1" profile → Stream only

### Virtual Audio Cables

Common device IDs for virtual audio:
- VB-Audio Virtual Cable
- BlackHole (macOS)
- Soundflower (macOS)

## Frontend Integration

The frontend needs to:

1. **Enumerate devices** using Web Audio API or Tauri
2. **Display channel list** with device assignments
3. **Allow profile assignment** via drag/drop or dropdown
4. **Route playback** to correct device based on profile's channel

## Limitations

- Device IDs are OS-specific
- Hot-plugging may invalidate device IDs
- Default channel cannot be modified/deleted
- Frontend handles actual audio routing (backend just stores config)
