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.
321 lines
8.2 KiB
Plaintext
321 lines
8.2 KiB
Plaintext
---
|
|
title: "Stories & Timeline"
|
|
description: "How the multi-voice timeline editor works in Voicebox"
|
|
---
|
|
|
|
## Overview
|
|
|
|
Stories allow users to arrange multiple voice generations on a timeline to create multi-voice narratives. The system supports tracks, trimming, splitting, and audio mixing.
|
|
|
|
## Architecture
|
|
|
|
**Story:** A container that holds story items with metadata.
|
|
|
|
**Story Item:** Links a generation to a story with timeline position, track, and trim data.
|
|
|
|
**Export:** Combines all items into a single mixed audio file.
|
|
|
|
## Data Model
|
|
|
|
### Story Table
|
|
|
|
```python
|
|
class Story(Base):
|
|
__tablename__ = "stories"
|
|
|
|
id = Column(String, primary_key=True)
|
|
name = Column(String, nullable=False)
|
|
description = Column(Text)
|
|
created_at = Column(DateTime)
|
|
updated_at = Column(DateTime)
|
|
```
|
|
|
|
### StoryItem Table
|
|
|
|
```python
|
|
class StoryItem(Base):
|
|
__tablename__ = "story_items"
|
|
|
|
id = Column(String, primary_key=True)
|
|
story_id = Column(String, ForeignKey("stories.id"))
|
|
generation_id = Column(String, ForeignKey("generations.id"))
|
|
start_time_ms = Column(Integer, default=0) # Timeline position
|
|
track = Column(Integer, default=0) # Track number
|
|
trim_start_ms = Column(Integer, default=0) # Trim from start
|
|
trim_end_ms = Column(Integer, default=0) # Trim from end
|
|
created_at = Column(DateTime)
|
|
```
|
|
|
|
## Timeline Concepts
|
|
|
|
### Start Time
|
|
|
|
`start_time_ms` defines when an item begins on the timeline:
|
|
|
|
```
|
|
Timeline (ms): 0----1000----2000----3000----4000
|
|
Item 1: [======]
|
|
Item 2: [==========]
|
|
Item 3: [====]
|
|
```
|
|
|
|
### Tracks
|
|
|
|
Multiple tracks allow overlapping audio:
|
|
|
|
```
|
|
Track 0: [Item 1] [Item 3]
|
|
Track 1: [Item 2]
|
|
```
|
|
|
|
### Trimming
|
|
|
|
Trim values cut audio from the start or end without destroying the original:
|
|
|
|
```
|
|
Original: [=========AUDIO=========]
|
|
trim_start: ^^
|
|
trim_end: ^^
|
|
Result: [=====AUDIO=====]
|
|
```
|
|
|
|
## Core Operations
|
|
|
|
### Adding Items
|
|
|
|
When adding a generation to a story:
|
|
|
|
```python
|
|
async def add_item_to_story(
|
|
story_id: str,
|
|
data: StoryItemCreate,
|
|
db: Session,
|
|
) -> StoryItemDetail:
|
|
# Calculate start time if not provided
|
|
if data.start_time_ms is None:
|
|
# Find the end of all existing items
|
|
existing_items = get_items_with_durations(story_id, db)
|
|
max_end_time_ms = max(
|
|
item.start_time_ms + int(gen.duration * 1000)
|
|
for item, gen in existing_items
|
|
)
|
|
start_time_ms = max_end_time_ms + 200 # 200ms gap
|
|
|
|
# Create the item
|
|
item = DBStoryItem(
|
|
id=str(uuid.uuid4()),
|
|
story_id=story_id,
|
|
generation_id=data.generation_id,
|
|
start_time_ms=start_time_ms,
|
|
track=data.track or 0,
|
|
)
|
|
db.add(item)
|
|
db.commit()
|
|
```
|
|
|
|
### Moving Items
|
|
|
|
Update position and/or track:
|
|
|
|
```python
|
|
async def move_story_item(
|
|
story_id: str,
|
|
item_id: str,
|
|
data: StoryItemMove,
|
|
db: Session,
|
|
) -> StoryItemDetail:
|
|
item = get_item(story_id, item_id, db)
|
|
|
|
item.start_time_ms = data.start_time_ms
|
|
item.track = data.track
|
|
|
|
db.commit()
|
|
```
|
|
|
|
### Trimming Items
|
|
|
|
Non-destructive trimming:
|
|
|
|
```python
|
|
async def trim_story_item(
|
|
story_id: str,
|
|
item_id: str,
|
|
data: StoryItemTrim,
|
|
db: Session,
|
|
) -> StoryItemDetail:
|
|
item = get_item(story_id, item_id, db)
|
|
generation = get_generation(item.generation_id, db)
|
|
|
|
# Validate trim doesn't exceed duration
|
|
max_duration_ms = int(generation.duration * 1000)
|
|
if data.trim_start_ms + data.trim_end_ms >= max_duration_ms:
|
|
return None # Invalid trim
|
|
|
|
item.trim_start_ms = data.trim_start_ms
|
|
item.trim_end_ms = data.trim_end_ms
|
|
|
|
db.commit()
|
|
```
|
|
|
|
### Splitting Items
|
|
|
|
Split one item into two at a specific time:
|
|
|
|
```python
|
|
async def split_story_item(
|
|
story_id: str,
|
|
item_id: str,
|
|
data: StoryItemSplit,
|
|
db: Session,
|
|
) -> List[StoryItemDetail]:
|
|
item = get_item(story_id, item_id, db)
|
|
generation = get_generation(item.generation_id, db)
|
|
|
|
# Calculate split point
|
|
current_trim_start = item.trim_start_ms
|
|
current_trim_end = item.trim_end_ms
|
|
original_duration_ms = int(generation.duration * 1000)
|
|
absolute_split_ms = current_trim_start + data.split_time_ms
|
|
|
|
# Update original: trim from end
|
|
item.trim_end_ms = original_duration_ms - absolute_split_ms
|
|
|
|
# Create new item: trim from start
|
|
new_item = DBStoryItem(
|
|
generation_id=item.generation_id, # Same generation
|
|
start_time_ms=item.start_time_ms + data.split_time_ms,
|
|
track=item.track,
|
|
trim_start_ms=absolute_split_ms,
|
|
trim_end_ms=current_trim_end,
|
|
)
|
|
|
|
db.add(new_item)
|
|
db.commit()
|
|
|
|
return [item, new_item]
|
|
```
|
|
|
|
### Duplicating Items
|
|
|
|
Create a copy with all properties:
|
|
|
|
```python
|
|
async def duplicate_story_item(
|
|
story_id: str,
|
|
item_id: str,
|
|
db: Session,
|
|
) -> StoryItemDetail:
|
|
original = get_item(story_id, item_id, db)
|
|
generation = get_generation(original.generation_id, db)
|
|
|
|
# Calculate effective duration for positioning
|
|
effective_duration_ms = (
|
|
int(generation.duration * 1000)
|
|
- original.trim_start_ms
|
|
- original.trim_end_ms
|
|
)
|
|
|
|
# Place copy after original with 200ms gap
|
|
new_item = DBStoryItem(
|
|
generation_id=original.generation_id,
|
|
start_time_ms=original.start_time_ms + effective_duration_ms + 200,
|
|
track=original.track,
|
|
trim_start_ms=original.trim_start_ms,
|
|
trim_end_ms=original.trim_end_ms,
|
|
)
|
|
|
|
db.add(new_item)
|
|
db.commit()
|
|
```
|
|
|
|
## Audio Export
|
|
|
|
### Mixing Algorithm
|
|
|
|
The export function mixes all items into a single audio file:
|
|
|
|
```python
|
|
async def export_story_audio(story_id: str, db: Session) -> bytes:
|
|
items = get_all_items_with_generations(story_id, db)
|
|
|
|
# Calculate total duration
|
|
max_end_time_ms = max(
|
|
data['start_time_ms'] + data['duration_ms']
|
|
for data in audio_data
|
|
)
|
|
|
|
# Create output buffer
|
|
total_samples = int((max_end_time_ms / 1000.0) * sample_rate)
|
|
final_audio = np.zeros(total_samples, dtype=np.float32)
|
|
|
|
# Mix each item at its position
|
|
for data in audio_data:
|
|
audio = data['audio']
|
|
start_sample = int((data['start_time_ms'] / 1000.0) * sample_rate)
|
|
|
|
# Apply trim
|
|
trimmed_audio = audio[trim_start_sample:len(audio) - trim_end_sample]
|
|
|
|
# Add to buffer (overlapping items sum together)
|
|
final_audio[start_sample:start_sample + len(trimmed_audio)] += trimmed_audio
|
|
|
|
# Normalize to prevent clipping
|
|
max_val = np.abs(final_audio).max()
|
|
if max_val > 1.0:
|
|
final_audio = final_audio / max_val
|
|
|
|
return audio_to_bytes(final_audio, sample_rate)
|
|
```
|
|
|
|
## API Endpoints
|
|
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| GET | `/stories` | List all stories |
|
|
| POST | `/stories` | Create a story |
|
|
| GET | `/stories/{id}` | Get story with items |
|
|
| PUT | `/stories/{id}` | Update story metadata |
|
|
| DELETE | `/stories/{id}` | Delete story |
|
|
| POST | `/stories/{id}/items` | Add item to story |
|
|
| DELETE | `/stories/{id}/items/{item_id}` | Remove item |
|
|
| PUT | `/stories/{id}/items/{item_id}/move` | Move item |
|
|
| PUT | `/stories/{id}/items/{item_id}/trim` | Trim item |
|
|
| POST | `/stories/{id}/items/{item_id}/split` | Split item |
|
|
| POST | `/stories/{id}/items/{item_id}/duplicate` | Duplicate item |
|
|
| PUT | `/stories/{id}/items/times` | Batch update times |
|
|
| PUT | `/stories/{id}/items/reorder` | Reorder items |
|
|
| GET | `/stories/{id}/export-audio` | Export mixed audio |
|
|
|
|
## Response Schemas
|
|
|
|
### StoryItemDetail
|
|
|
|
```json
|
|
{
|
|
"id": "item_uuid",
|
|
"story_id": "story_uuid",
|
|
"generation_id": "generation_uuid",
|
|
"start_time_ms": 1500,
|
|
"track": 0,
|
|
"trim_start_ms": 200,
|
|
"trim_end_ms": 100,
|
|
"profile_id": "profile_uuid",
|
|
"profile_name": "Narrator",
|
|
"text": "Hello world",
|
|
"audio_path": "/path/to/audio.wav",
|
|
"duration": 2.5,
|
|
"created_at": "2024-01-15T10:30:00Z"
|
|
}
|
|
```
|
|
|
|
## Frontend Integration
|
|
|
|
The timeline UI needs to:
|
|
|
|
1. **Fetch story** with all items
|
|
2. **Render waveforms** for each item
|
|
3. **Handle drag/drop** to move items
|
|
4. **Handle edge drag** for trimming
|
|
5. **Sync playhead** across all tracks
|
|
6. **Export** when user clicks download
|