进阶 docs.voicebox.sh 2026-10-11 09:58:06 · 21 阅读

第29章 Voicebox 多声部时间线编辑器解析

第29章:故事与时间线

故事与时间线:Voicebox 中多声部时间线编辑器的工作原理

概览

故事功能允许用户在时间线上编排多个语音生成片段,以创建多声部的叙事内容。该系统支持音轨、修剪、拆分以及音频混音。

架构

Story(故事):一个包含故事条目及其元数据的容器。 Story Item(故事条目):将某个生成片段与故事关联,并记录其时间线位置、所属音轨及修剪数据。 Export(导出):将所有条目混合为单个音频文件。

数据模型

Story 表

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 表

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) # 时间线位置 track = Column(Integer, default=0) # 音轨编号 trim_start_ms = Column(Integer, default=0) # 起始修剪量 trim_end_ms = Column(Integer, default=0) # 结尾修剪量 created_at = Column(DateTime)

时间线概念

起始时间

start_time_ms 表示条目在时间线上开始播放的绝对位置。同一音轨上的条目不能重叠,但不同音轨上的条目可以重叠。

音轨

音轨是一个整数(从 0 开始索引),用于标识条目所在的水平行。不同音轨上的音频会同时播放,因此音轨是实现多声部或多音效叠加的主要方式。

修剪

trim_start_ms 和 trim_end_ms 用于隐藏源生成片段的头部或尾部部分,且不修改底层的音频文件。有效的播放时长计算公式为:$generation.duration \times 1000 - trim\_start\_ms - trim\_end\_ms$。修剪是非破坏性的,同一个生成片段可以在不同的故事中应用不同的修剪设置。

核心操作

添加条目

将生成片段添加到故事时:

async def add_item_to_story( story_id: str, data: StoryItemCreate, db: Session, ) -> StoryItemDetail: # 如果未提供,则计算起始时间 if data.start_time_ms is None: # 查找所有现有条目的结束位置 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 间隔 # 创建条目 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()

移动条目

更新位置及/或音轨:

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()

修剪条目

非破坏性修剪:

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) # 验证修剪量不超过时长 max_duration_ms = int(generation.duration * 1000) if data.trim_start_ms + data.trim_end_ms >= max_duration_ms: return None # 无效修剪 item.trim_start_ms = data.trim_start_ms item.trim_end_ms = data.trim_end_ms db.commit()

拆分条目

在特定时间点将一个条目拆分为两个:

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) # 计算拆分点 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 # 更新原条目:从结尾修剪 item.trim_end_ms = original_duration_ms - absolute_split_ms # 创建新条目:从开头修剪 new_item = DBStoryItem( generation_id=item.generation_id, # 相同的生成片段 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]

复制条目

创建包含所有属性的副本:

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) # 计算有效时长以确定位置 effective_duration_ms = ( int(generation.duration * 1000) - original.trim_start_ms - original.trim_end_ms ) # 将副本放在原条目之后,间隔 200ms 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()

音频导出

混音算法

导出功能将所有条目混合为单个音频文件:

async def export_story_audio(story_id: str, db: Session) -> bytes: items = get_all_items_with_generations(story_id, db) # 计算总时长 max_end_time_ms = max( data['start_time_ms'] + data['duration_ms'] for data in audio_data ) # 创建输出缓冲区 total_samples = int((max_end_time_ms / 1000.0) * sample_rate) final_audio = np.zeros(total_samples, dtype=np.float32) # 在每个条目所在位置进行混音 for data in audio_data: audio = data['audio'] start_sample = int((data['start_time_ms'] / 1000.0) * sample_rate) # 应用修剪 trimmed_audio = audio[trim_start_sample:len(audio) - trim_end_sample] # 添加到缓冲区(重叠部分的条目进行求和) final_audio[start_sample:start_sample + len(trimmed_audio)] += trimmed_audio # 归一化以防止削波 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 端点

方法 端点 描述 GET /stories 列出所有故事 POST /stories 创建故事 GET /stories/{id} 获取包含条目的故事 PUT /stories/{id} 更新故事元数据 DELETE /stories/{id} 删除故事 POST /stories/{id}/items 向故事添加条目 DELETE /stories/{id}/items/{item_id} 移除条目 PUT /stories/{id}/items/{item_id}/move 移动条目 PUT /stories/{id}/items/{item_id}/trim 修剪条目 POST /stories/{id}/items/{item_id}/split 拆分条目 POST /stories/{id}/items/{item_id}/duplicate 复制条目 PUT /stories/{id}/items/times 批量更新时间 PUT /stories/{id}/items/reorder 重新排列条目 GET /stories/{id}/export-audio 导出混合音频 (?format=wav|m4b|mp3&chapters=none|auto; m4b/mp3 需要 ffmpeg,否则返回 503)

响应模式

StoryItemDetail

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

前端集成

时间线 UI 需要执行以下操作:

获取包含所有条目的故事 为每个条目渲染波形 处理拖放以移动条目 处理边缘拖拽以进行修剪 在所有音轨间同步播放头 在用户点击下载时执行导出

在 GitHub 上编辑 生成历史:Voicebox 中的生成历史记录机制 转写:Voicebox 中基于 Whisper 的音频转写机制

评论 (0)