mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 21:30:39 -07:00
* docs: audit mdx docs against multi-engine backend and refresh stale content Rewrote developer-facing docs that predated the TTSBackend Protocol / ModelConfig registry refactor (architecture, tts-generation, model-management, transcription). Updated user-facing docs to reflect all seven shipped engines (Qwen, Qwen CustomVoice, LuxTTS, Chatterbox, Chatterbox Turbo, TADA, Kokoro) instead of the outdated "5 engines" claim. Also fixes: - Stale app identifier (com.voicebox.app → sh.voicebox.app) - CUDA backend update flow (now two-archive split, not N-way chunks) - Whisper model list (removed tiny, added turbo) - Broken /development/ and /guides/ route links - Stale just commands and install steps (missing --no-deps chatterbox/tada) - Removed ASCII art diagrams from README and stories.mdx - History Generation schema sync with DB model Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * docs: add DeepWiki badge to README Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * docs: address PR review feedback - architecture.mdx: fix backends/ file list (remove nonexistent qwen_backend.py, rename tada_backend.py → hume_backend.py) - model-management.mdx: Kokoro language count 9 → 8 (matches ModelConfig) - model-management.mdx: ProgressManager path services/ → utils/ - tts-generation.mdx: ModelConfig example uses field(default_factory=...) — mutable default would raise at runtime - tts-generation.mdx: "1080p samples" → "on CUDA" (1080p is video, not audio) - PROJECT_STATUS.md: replace ASCII architecture diagram with prose (matches no-ASCII-art rule) Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(app): guard against undefined engine in FloatingGenerateBox preset check form.getValues('engine') returns string | undefined; Set<string>.has() rejects undefined under strict mode. Added a truthy guard before the preset lookup. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
302 lines
8.3 KiB
Plaintext
302 lines
8.3 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` is the absolute position on the timeline where an item begins playing. Items on the same track cannot overlap; items on different tracks can.
|
|
|
|
### Tracks
|
|
|
|
A `track` is an integer (0-indexed) that identifies the horizontal row an item sits on. Audio on separate tracks plays concurrently, so tracks are the primary way to layer multiple voices or sound effects.
|
|
|
|
### Trimming
|
|
|
|
`trim_start_ms` and `trim_end_ms` hide the leading/trailing portions of the source generation without modifying the underlying audio file. The effective playback length is `generation.duration * 1000 - trim_start_ms - trim_end_ms`. Trimming is non-destructive — the same generation can be trimmed differently in different stories.
|
|
|
|
## 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
|