mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 12:50:42 -07:00
- Created a new .npmrc file to enforce bun usage. - Bumped version numbers for multiple packages to 0.1.9 in bun.lock. - Added react-sound-visualizer dependency to enhance audio visualization features. - Introduced convert:assets script in package.json for asset optimization. - Updated CONTRIBUTING.md with instructions for converting assets to web formats. - Added documentation files for API endpoints and developer guidelines in the docs directory.
311 lines
7.8 KiB
Plaintext
311 lines
7.8 KiB
Plaintext
---
|
|
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)
|