Sonna
Developer

Audio Channels

How audio output routing works in Sonna

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

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

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

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:

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

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

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

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

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

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

MethodEndpointDescription
GET/channelsList all channels
POST/channelsCreate a channel
GET/channels/{id}Get channel by ID
PUT/channels/{id}Update channel
DELETE/channels/{id}Delete channel
GET/channels/{id}/voicesGet assigned voices
PUT/channels/{id}/voicesSet assigned voices
GET/profiles/{id}/channelsGet profile's channels
PUT/profiles/{id}/channelsSet profile's channels

Request/Response Schemas

AudioChannelCreate

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

AudioChannelResponse

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

ChannelVoiceAssignment

{
  "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)

On this page