Files
ViGent2/Docs/DevLogs/Day23.md
Kevin Wong a6cc919e5c 更新
2026-02-11 13:57:41 +08:00

32 KiB
Raw Blame History

🎙️ 配音前置重构 — 第一阶段 (Day 23)

概述

将配音从视频生成流程中独立出来,实现"先生成配音 → 选中配音 → 再选素材 → 生成视频"的新工作流。用户可以独立管理配音(生成/试听/改名/删除/选择),并在选中配音后看到时长信息,为第二阶段的素材时间轴编排奠定数据基础。

旧流程: 文案 + 选素材 → 一键生成(内联 TTS → Whisper → 均分 → LipSync → 合成) 新流程: 文案 → 配音方式 → 生成配音 → 选中配音 → 选素材 → 背景音乐 → 生成视频


一、后端:新增 generated_audios 模块

模块结构

backend/app/modules/generated_audios/
├── __init__.py
├── router.py      # 5 个 API 端点
├── schemas.py     # 请求/响应模型
└── service.py     # 生成/列表/删除/改名

API 端点

方法 路径 说明
POST /api/generated-audios/generate 异步生成配音(返回 task_id
GET /api/generated-audios/tasks/{task_id} 轮询生成进度
GET /api/generated-audios 列出用户所有配音
DELETE /api/generated-audios/{audio_id} 删除配音
PUT /api/generated-audios/{audio_id} 改名

存储方案

  • Supabase 存储桶:generated-audios(启动时自动创建)
  • 音频文件:{user_id}/{timestamp}_audio.wav
  • 元数据文件:{user_id}/{timestamp}_audio.json(含 display_name、text、tts_mode、duration_sec 等)

生成流程

复用现有 TTSService / voice_clone_service / task_store

POST /generate → 创建 task → BackgroundTask:
  1. edgetts → TTSService.generate_audio()
     voiceclone → 下载 ref_audio → voice_clone_service.generate_audio()
  2. ffprobe 获取时长
  3. 上传 .wav + .json 到 generated-audios 桶
  4. 更新 task(status=completed, output={audio_id, duration_sec, ...})

二、后端:修改视频生成 workflow

GenerateRequest 新增字段

generated_audio_id: Optional[str] = None  # 预生成配音 ID存在时跳过内联 TTS

workflow.py TTS 阶段新增分支

if req.generated_audio_id:
    # 下载预生成配音 + 从元数据读取 language
elif req.tts_mode == "voiceclone":
    # 原有声音克隆逻辑
else:
    # 原有 EdgeTTS 逻辑

向后兼容:不传 generated_audio_id 时,原有内联 TTS 流程不受影响。


三、前端:新增配音列表 hook + 面板

useGeneratedAudios.ts

  • 状态:generatedAudios[]selectedAudioisGeneratingAudioaudioTask
  • 方法:fetchGeneratedAudios()generateAudio()deleteAudio()renameAudio()selectAudio()
  • 轮询:生成后 1s 轮询 task 状态,完成后自动刷新列表并选中最新配音
  • 独立于视频生成的 TaskContext不互相干扰

GeneratedAudiosPanel.tsx

  • 每条配音:播放/暂停、名称、时长、重命名、删除
  • 选中态:border-purple-500 bg-purple-500/20
  • 内嵌进度条(生成中显示)
  • 底部显示选中配音的原始文案(截断)
  • 播放逻辑自包含于面板内(new Audio() + play/pause toggle

四、前端UI 面板重排序

旧顺序: MaterialSelector → ScriptEditor → TitleSubtitle → VoiceSelector → BgmPanel → GenerateActionBar

新顺序:

  1. ScriptEditor文案编辑
  2. TitleSubtitlePanel标题与字幕样式
  3. VoiceSelector配音方式
  4. GeneratedAudiosPanel(配音列表)← 新增
  5. MaterialSelector视频素材← 后移,需选中配音才解锁
  6. BgmPanel背景音乐
  7. GenerateActionBar生成视频

素材区门控

未选中配音时,素材区显示半透明遮罩 + "请先生成并选中配音"提示。素材上传/预览/改名/删除始终可用,仅选择勾选被遮罩。

时长信息

选中配音后MaterialSelector 顶部显示:

当前配音: 45.2 秒 | 已选 3 个素材(自动均分每段 ~15.1 秒)

生成按钮条件更新

// 旧条件
disabled={isGenerating || selectedMaterials.length === 0 || (ttsMode === "voiceclone" && !selectedRefAudio)}
// 新条件
disabled={isGenerating || selectedMaterials.length === 0 || !selectedAudio}

五、持久化

useHomePersistence 新增 selectedAudioId 的 localStorage 读写,刷新页面后恢复选中的配音。


涉及文件汇总

后端新增

文件 说明
backend/app/modules/generated_audios/__init__.py 模块标记
backend/app/modules/generated_audios/router.py 5 个 API 端点
backend/app/modules/generated_audios/service.py 生成/列表/删除/改名
backend/app/modules/generated_audios/schemas.py 请求/响应模型

后端修改

文件 变更
backend/app/main.py 注册 generated_audios 路由
backend/app/services/storage.py 新增 BUCKET_GENERATED_AUDIOS,启动时自动创建桶
backend/app/modules/videos/schemas.py GenerateRequest 新增 generated_audio_id 字段
backend/app/modules/videos/workflow.py TTS 阶段新增预生成音频分支

前端新增

文件 说明
frontend/src/features/home/model/useGeneratedAudios.ts 配音列表 hook
frontend/src/features/home/ui/GeneratedAudiosPanel.tsx 配音列表面板

前端修改

文件 变更
frontend/src/features/home/ui/HomePage.tsx 面板重排序 + 素材区门控 + 插入 GeneratedAudiosPanel
frontend/src/features/home/ui/MaterialSelector.tsx 新增 selectedAudioDuration prop + 时长信息显示
frontend/src/features/home/ui/GenerateActionBar.tsx 禁用条件改为 !selectedAudio
frontend/src/features/home/model/useHomeController.ts 集成 useGeneratedAudios、新增 handleGenerateAudio、修改 handleGenerate 使用 generated_audio_id
frontend/src/features/home/model/useHomePersistence.ts 新增 selectedAudioId 持久化

🎞️ 素材时间轴编排 — 第二阶段 (Day 23)

概述

在第一阶段"配音前置"基础上,新增时间轴编辑器,用户可以:

  1. 在音频波形上查看各素材块的时长分配
  2. 拖拽分割线调整每段素材的时长(无缝铺满,调整一段自动压缩/扩展相邻段)
  3. 为每段素材设置源视频截取起点(从视频任意位置开始,而非始终从头)

旧行为: 多素材时自动均分(_split_equal),无法控制每段时长和源视频起始点 新行为: 时间轴编辑器可视化分配 + 拖拽调整 + ClipTrimmer 截取设置


一、后端改动

1.1 新增 CustomAssignment 模型

# backend/app/modules/videos/schemas.py
class CustomAssignment(BaseModel):
    material_path: str
    start: float           # 音频时间轴起点
    end: float             # 音频时间轴终点
    source_start: float = 0.0  # 源视频截取起点

GenerateRequest 新增 custom_assignments: Optional[List[CustomAssignment]] = None。存在时跳过 Whisper 均分,直接使用用户定义的分配。

1.2 prepare_segment 支持 source_start

def prepare_segment(self, video_path, target_duration, output_path,
                    target_resolution=None, source_start: float = 0.0):

关键逻辑:

  • source_start > 0 时使用 -ss 快速 seek并强制重编码避免 stream copy 关键帧不精确)
  • 当需要循环且有 source_start 时,先裁剪出 source_start 到视频结尾的片段,再循环裁剪后的文件(避免 stream_loop 从视频 0s 开始循环)
  • 裁剪临时文件在 finally 中自动清理

1.3 workflow.py 支持 custom_assignments

  • 多素材模式: custom_assignments 存在时,直接使用用户分配(仍运行 Whisper 生成字幕),每个 prepare_segment 调用传入 source_start
  • 单素材模式: custom_assignments 有 1 条且 source_start > 0 时,先截取片段再传入 LatentSync
  • 向后兼容: custom_assignmentsNone 时完全走旧路径

二、前端新增组件

2.1 useTimelineEditor.ts — 时间轴段管理 hook

interface TimelineSegment {
  id: string;              // React key
  materialId: string;      // 素材 ID
  materialName: string;    // 显示名
  start: number;           // 音频时间轴开始秒数
  end: number;             // 音频时间轴结束秒数
  sourceStart: number;     // 源视频截取起点(默认 0
  sourceEnd: number;       // 源视频截取终点0 = 到结尾)
  color: string;           // 色块颜色
}

核心方法:

  • initSegments(): selectedMaterials 变化时按数量均分 audioDuration
  • resizeSegment(id, newEnd): 拖拽右边界,约束每段最小 1s
  • setSourceRange(id, sourceStart, sourceEnd): 设置截取范围
  • toCustomAssignments(): 转为后端 CustomAssignment[] 格式

2.2 TimelineEditor.tsx — 波形 + 色块时间轴

  • wavesurfer.js 渲染音频波形(仅展示,不播放)
  • 色块层按比例排列,显示素材名 + 时长 + 截取标记
  • 色块间分割线可拖拽(onPointerDown/Move/Up 实现连续像素拖拽)
  • 点击色块打开 ClipTrimmer

2.3 ClipTrimmer.tsx — 素材截取模态框

  • HTML5 <video> 实时预览,拖拽滑块时 video.currentTime 跟随
  • 双端 Range Slider起点/终点),互锁约束 ≥ 0.5s
  • 显示截取时长 vs 分配时长对比(循环补足/截断提示)
  • loadedmetadata 获取源视频时长

三、前端整合改动

3.1 useHomeController.ts

  • 集成 useTimelineEditor hook
  • 新增 clipTrimmerOpen / clipTrimmerSegmentId 状态
  • handleGenerate 多素材时始终发送 custom_assignments;单素材 + sourceStart > 0 时也发送
  • 移除不再使用的 reorderMaterials 导出

3.2 HomePage.tsx

  • 在 MaterialSelector 和 BgmPanel 之间插入 TimelineEditor仅当有配音且已选素材时显示
  • 底部新增 ClipTrimmer 模态框
  • 移除 reorderMaterialsselectedAudioDuration prop 传递

3.3 MaterialSelector.tsx

  • 移除配音时长信息栏(功能迁至 TimelineEditor
  • 移除拖拽排序区SortableChip + @dnd-kit 相关代码)
  • 移除 onReorderMaterials / selectedAudioDuration prop

四、审查修复的 Bug

# 严重程度 问题 修复
1 prepare_segment 使用 source_start > 0 + stream copy 时 seek 不精确 添加 source_start > 0 到重编码条件
2 stream_loop + source_start 循环时从视频 0s 开始而非从 source_start 循环 改为两步:先裁剪片段再循环裁剪后的文件
3 useHomeController 导出已废弃的 reorderMaterials 移除

涉及文件汇总

后端修改

文件 变更
backend/app/modules/videos/schemas.py 新增 CustomAssignment modelGenerateRequest 新增 custom_assignments 字段
backend/app/services/video_service.py prepare_segment 新增 source_start 参数,循环+截取两步处理
backend/app/modules/videos/workflow.py 多素材/单素材流水线支持 custom_assignments,传递 source_start

前端新增

文件 说明
frontend/src/features/home/model/useTimelineEditor.ts 时间轴段管理 hook
frontend/src/features/home/ui/TimelineEditor.tsx 波形 + 色块时间轴组件
frontend/src/features/home/ui/ClipTrimmer.tsx 素材截取模态框

前端修改

文件 变更
frontend/src/features/home/ui/HomePage.tsx 插入 TimelineEditor + ClipTrimmer
frontend/src/features/home/ui/MaterialSelector.tsx 移除时长信息 + 拖拽排序区 + 相关 prop
frontend/src/features/home/model/useHomeController.ts 集成 useTimelineEditorhandleGenerate 发送 custom_assignments
frontend/package.json 新增 wavesurfer.js 依赖

🎨 UI 体验优化 + TTS 稳定性修复 — 第三阶段 (Day 23)

概述

根据用户反馈,修复 6 项 UI 体验问题,同时修复声音克隆服务的 SoX 路径问题和显存缓存管理。

: Qwen3-TTS 已在后续被 CosyVoice 3.0 (端口 8010) 替换,以下记录为当时的修复过程。


一、Qwen3-TTS 稳定性修复 (已被 CosyVoice 3.0 替换)

1.1 SoX PATH 修复

问题: PM2 启动 qwen-tts 时,sox 工具安装在 conda env 的 bin 目录中,系统 PATH 找不到,导致音频编解码走 fallback 路径CPU 密集型),日志中出现 SoX could not be found! 警告。

修复: run_qwen_tts.sh 中 export conda env bin 到 PATH

export PATH="/home/rongye/ProgramFiles/miniconda3/envs/qwen-tts/bin:$PATH"

1.2 CUDA 缓存清理

修复: qwen_tts_server.py 每次生成完成后(无论成功或失败)调用 torch.cuda.empty_cache(),防止显存碎片累积。使用 asyncio.to_thread() 在线程池中运行推理,避免阻塞事件循环导致健康检查超时。

后续: Qwen3-TTS 已停用CosyVoice 3.0 沿用了相同的保护机制GPU 推理锁、超时保护、显存清理、启动自检)。


二、配音列表按钮布局统一 (反馈 #1 + #6)

问题: GeneratedAudiosPanel 的试听按钮位于左侧(独立于 Edit/DeleteRefAudioPanel 的布局不一致。底部文案摘要区域不需要展示。

修复:

  • Play/Edit/Delete 按钮统一放在右侧同组hover 显示,顺序为 试听→重命名→删除
  • 移除选中配音的文案摘要区域
  • 布局与 RefAudioPanel 一致:左侧名称+时长,右侧操作按钮组

三、视频素材区域移除配音依赖遮罩 (反馈 #2)

问题: MaterialSelector 被 !selectedAudio 遮罩覆盖,必须先选配音才能操作素材。

修复: 移除 HomePage.tsx 中 MaterialSelector 外层的 disabled overlay <div>。素材随时可上传/预览/管理,仅 TimelineEditor 需要选中配音才显示(已有独立条件 selectedAudio && selectedMaterials.length > 0)。


四、时间轴拖拽排序 (反馈 #3)

问题: TimelineEditor 不支持调换素材顺序。

修复:

  • useTimelineEditor 已有 reorderSegments() 方法(交换两个段的素材信息但保留时间范围)
  • 通过 useHomeController 暴露 reorderSegments,传入 TimelineEditor
  • 色块支持 HTML5 Drag & Dropdraggable + onDragStart/Over/Drop/End
  • 拖拽时:源色块半透明(opacity-50),目标色块高亮 ringring-2 ring-purple-400 scale-[1.02]
  • 光标样式:cursor-grab / active:cursor-grabbing

五、截取设置双手柄 Range Slider (反馈 #4)

问题: ClipTrimmer 使用两个独立的 <input type="range"> 滑块,起点和终点分开操作,体验不直观。

修复: 改为自定义双手柄 range slider

  • 单条轨道,紫色圆形手柄(起点)+ 粉色圆形手柄(终点)
  • 轨道底色 bg-white/10,选中范围用素材对应颜色高亮
  • Pointer Events 实现拖拽:onPointerDown 捕获手柄 → onPointerMove 更新位置 → onPointerUp 释放
  • 手柄互锁约束:起点不超过终点 - 0.5s,终点不低于起点 + 0.5s
  • 底部显示起点(紫色)和终点(粉色)时间标签

六、截取设置视频预览 (反馈 #5)

问题: ClipTrimmer 的视频只能静态查看,无法播放预览截取范围。

修复:

  • 视频区域点击可播放/暂停Play/Pause 图标覆盖层)
  • 播放范围:从 sourceStart 播放到 sourceEnd 自动停止
  • 播放结束后回到起点
  • 拖拽手柄时 video.currentTime 实时跟随seek 到当前位置查看画面)
  • 播放进度条(白色竖线)叠加在 range slider 轨道上
  • preload="auto" 预加载视频,确保拖拽时快速 seek

涉及文件汇总

后端修改

文件 变更
run_qwen_tts.sh export conda env bin 到 PATH修复 SoX 找不到问题 (已停用)
models/Qwen3-TTS/qwen_tts_server.py 每次生成后 torch.cuda.empty_cache()asyncio.to_thread 避免阻塞 (已停用)

前端修改

文件 变更
frontend/src/features/home/ui/GeneratedAudiosPanel.tsx 按钮布局统一Play/Edit/Delete 右侧同组),移除文案摘要
frontend/src/features/home/ui/HomePage.tsx 移除 MaterialSelector 配音遮罩,传入 onReorderSegment
frontend/src/features/home/ui/TimelineEditor.tsx 新增 HTML5 Drag & Drop 排序,新增 onReorderSegment prop
frontend/src/features/home/ui/ClipTrimmer.tsx 双手柄 range slider + 视频播放预览 + 播放进度指示
frontend/src/features/home/model/useHomeController.ts 暴露 reorderSegments 方法

📝 历史文案保存 + 时间轴拖拽修复 — 第四阶段 (Day 23)

概述

新增文案手动保存与加载功能,修复时间轴拖拽排序后素材时长不跟随的 Bug统一按钮视觉规范。


一、历史文案保存与加载

功能

用户可手动保存当前文案到历史列表,随时从历史中加载恢复。只有手动保存的文案才出现在历史列表中,与自动保存(useHomePersistence)完全独立。

UI 布局

按钮栏: [历史文案▼] [文案提取助手] [AI多语言▼] [AI生成标题标签]
底部栏: 128 字                                    [保存文案]
  • 历史文案下拉: 展示已保存列表(名称 + 日期 + 删除按钮),点击条目加载文案,空列表显示"暂无保存的文案"
  • 保存文案按钮: 文案为空时 disabled点击后 toast.success("文案已保存")
  • 预计时长已移除: 底部栏只保留字数 + 保存按钮

实现

useSavedScripts.ts(新建)
interface SavedScript { id: string; name: string; content: string; savedAt: number }
  • localStorage key: vigent_{storageKey}_savedScripts
  • saveScript(content): 取前 15 字符自动命名,新条目插入列表头部,直接写入 localStorage
  • deleteScript(id): 删除指定条目,直接写入 localStorage
  • useEffect([lsKey]): lsKey 变化时guest → userId重新从 localStorage 读取
  • 不使用自动持久化 effect,避免 storageKey 切换时空数组覆盖已有数据
数据流
ScriptEditor (UI)
  ↑ savedScripts / onSaveScript / onLoadScript / onDeleteScript (纯 props + callbacks)
  │
useHomeController
  ├── useSavedScripts(storageKey) → { savedScripts, saveScript, deleteScript }
  └── handleSaveScript() → saveScript(text) + toast
  │
HomePage
  └── 传递 props 到 ScriptEditor

二、时间轴拖拽排序 Bug 修复

问题

拖拽调换素材顺序后各素材的时长没有跟随素材移动而是留在原槽位。例如素材1(3s) + 素材2(8s+4s循环)拖拽后变成素材2(3s) + 素材1(8s+4s循环),时长分配没变。

根因

reorderSegments 使用属性交换方式:逐个拷贝 materialIdsourceStartsourceEnd 等属性在两个槽位间交换,然后调用 recalcPositions 重算位置。

修复

改为数组移动splice将整个 segment 对象从旧位置取出插入到新位置。segment 对象携带全部属性materialId、sourceStart、sourceEnd、color 等)作为一个整体移动,再由 recalcPositions 重算位置。

// 修复前:属性交换
const fromMat = { materialId: next[fromIdx].materialId, ... };
const toMat = { materialId: next[toIdx].materialId, ... };
next[fromIdx] = { ...next[fromIdx], ...toMat };
next[toIdx] = { ...next[toIdx], ...fromMat };

// 修复后:数组移动
const [moved] = next.splice(fromIdx, 1);
next.splice(toIdx, 0, moved);

附带优势3+ 素材拖拽行为从"交换"变为"插入",更符合用户直觉。


三、按钮视觉统一

问题

历史文案、文案提取助手、AI多语言、AI生成标题标签 4 个按钮高度不一致AI 按钮的文本被 <span> 嵌套包裹导致内部布局差异。

修复

  • 4 个按钮统一为 h-7 px-2.5 text-xs rounded inline-flex items-center gap-1(固定高度 28px
  • 移除 AI多语言 / AI生成标题标签 按钮内多余的 <span> 嵌套,改为 <>...</> fragment

涉及文件汇总

前端新增

文件 说明
frontend/src/features/home/model/useSavedScripts.ts 历史文案 hooklocalStorage 持久化)

前端修改

文件 变更
frontend/src/features/home/ui/ScriptEditor.tsx 历史文案下拉 + 保存按钮 + 移除预计时长 + 按钮高度统一
frontend/src/features/home/model/useHomeController.ts 集成 useSavedScripts新增 handleSaveScript
frontend/src/features/home/ui/HomePage.tsx 传递 savedScripts / handleSaveScript / deleteSavedScript 到 ScriptEditor
frontend/src/features/home/model/useTimelineEditor.ts reorderSegments 从属性交换改为数组移动splice

🔤 字幕语言不匹配 + 视频比例错位修复 — 第五阶段 (Day 23)

概述

修复两个视频生成 Bug

  1. 字幕语言不匹配: 中文配音 + 英文翻译文案 → 字幕错误显示英文Whisper 独立转录,忽略原文)
  2. 标题字幕比例错位: 9:16 竖屏素材生成视频后,标题/字幕按 16:9 横屏布局渲染

附带修复代码审查中发现的 split_word_to_chars 英文空格丢失问题。


一、字幕用原文替换 Whisper 转录文字

根因

Whisper 对音频独立转录,完全忽略传入的 text 参数。当配音语言与编辑器文案语言不一致时(例如:用户先写中文文案 → 翻译成英文 → 生成英文配音 → 再改回中文文案Whisper "听到"英文语音就输出英文字幕。

修复思路

Whisper 仅负责检测语音总时间范围first_startlast_end),字幕文字永远用配音保存的原始文案。

whisper_service.pyalign() 新增 original_text 参数

async def align(self, audio_path, text, output_path=None,
                language="zh", original_text=None):

original_text 非空时:

  1. 正常运行 Whisper 转录,记录 whisper_first_startwhisper_last_end
  2. original_text 传入 split_word_to_chars() 在总时间范围上线性分布
  3. split_segment_to_lines() 按标点和字数断行
  4. 替换 Whisper 的转录结果

workflow.py — 配音元数据无条件覆盖 + 传入原文

# 改前(只在文案为空时覆盖)
if not req.text.strip():
    req.text = meta.get("text", req.text)

# 改后(无条件用配音元数据覆盖)
meta_text = meta.get("text", "")
if meta_text:
    req.text = meta_text

所有 4 处 whisper_service.align() 调用添加 original_text=req.text


二、Remotion 动态传入视频尺寸

根因

remotion/src/Root.tsx 硬编码 width={1280} height={720}。虽然 render.ts 用 ffprobe 检测真实尺寸后覆盖 composition.width/height,但 selectComposition 阶段组件已按 1280×720 初始化,标题和字幕定位基于错误的画布尺寸。

修复

Root.tsxcalculateMetadata 从 props 读取尺寸
<Composition
  id="ViGentVideo"
  component={Video}
  durationInFrames={300}
  fps={25}
  width={1080}
  height={1920}
  calculateMetadata={async ({ props }) => ({
    width: props.width || 1080,
    height: props.height || 1920,
  })}
  defaultProps={{
    videoSrc: '',
    width: 1080,
    height: 1920,
    // ...
  }}
/>

默认从 1280×720 改为 1080×1920竖屏优先calculateMetadata 确保 selectComposition 阶段使用 ffprobe 检测的真实尺寸。

Video.tsx — VideoProps 新增可选 width/height

仅供 calculateMetadata 访问,组件渲染不引用。

render.ts — inputProps 统一传入视频尺寸
const inputProps = {
  videoSrc: videoFileName,
  captions,
  title: options.title,
  // ...
  width: videoWidth,     // ffprobe 检测值
  height: videoHeight,   // ffprobe 检测值
};

selectCompositionrenderMedia 使用同一个 inputProps。保留显式 composition.width/height 覆盖作为保险。


三、代码审查修复:英文空格丢失

问题

split_word_to_chars 原设计处理 Whisper 单个词(如 " Hello"),但 original_text 传入整段文本时,中间空格被 continue 跳过且不 flush ascii_buffer,导致 "Hello World" 变成 "HelloWorld"

执行路径追踪

输入: "Hello World"
  H,e,l,l,o → ascii_buffer = "Hello"
  ' '       → continue跳过不 flush
  W,o,r,l,d → ascii_buffer = "HelloWorld"
结果: tokens = ["HelloWorld"]  ← 空格丢失

修复

遇到空格时 flush ascii_buffer,并用 pending_space 标记给下一个 token 前置空格:

if not char.strip():
    if ascii_buffer:
        tokens.append(ascii_buffer)
        ascii_buffer = ""
    if tokens:
        pending_space = True
    continue

修复后:"Hello World" → tokens = ["Hello", " World"] → 字幕正确显示。中文不受影响。


涉及文件汇总

后端修改

文件 变更
backend/app/services/whisper_service.py align() 新增 original_text 参数;split_word_to_chars 修复英文空格丢失
backend/app/modules/videos/workflow.py 配音元数据无条件覆盖 text/language4 处 align() 调用传入 original_text

前端修改Remotion

文件 变更
remotion/src/Root.tsx 默认尺寸改为 1080×1920新增 calculateMetadata + width/height defaultProps
remotion/src/Video.tsx VideoProps 新增可选 width/height
remotion/render.ts inputProps 统一传入 videoWidth/videoHeightselectComposition 和 renderMedia 共用

🎤 参考音频自动转写 + 语速控制 — 第六阶段 (Day 23)

概述

解决声音克隆 ref_text 不匹配问题:旧方案使用前端固定文字作为 ref_textCosyVoice zero-shot 克隆要求 ref_text 必须与参考音频实际内容匹配,不匹配时模型会在生成音频开头"幻觉"出多余片段。

改进:上传参考音频时自动调用 Whisper 转写内容作为 ref_text同时新增语速控制功能。


一、Whisper 自动转写参考音频

1.1 whisper_service.py — 语言自动检测

transcribe() 方法原先硬编码 language="zh",改为接受可选 language 参数(默认 None = 自动检测),支持多语言参考音频。

1.2 ref_audios/service.py — 上传时自动转写

上传流程变更:转码 WAV → 检查时长(≥1s) → 超 10s 在静音点截取 → Whisper 自动转写 → 验证非空 → 上传。

try:
    transcribed = await whisper_service.transcribe(tmp_wav_path)
    if transcribed.strip():
        ref_text = transcribed.strip()
except Exception as e:
    logger.warning(f"Auto-transcribe failed: {e}")

if not ref_text or not ref_text.strip():
    raise ValueError("无法识别音频内容,请确保音频包含清晰的语音")

1.3 ref_audios/router.py — ref_text 改为可选

ref_text: str = Form("")(不再必填),前端不再发送固定文字。


二、参考音频智能截取10 秒上限)

CosyVoice 对 3-10 秒参考音频效果最好。

2.1 静音点检测

使用 ffmpeg silencedetect 找 10 秒内最后一个静音结束点(阈值 -30dB最短 0.3s),避免在字词中间硬切:

def _find_silence_cut_point(file_path, max_duration):
    # silencedetect → 解析 silence_end → 找 3s~max_duration 内最后的静音点
    # 找不到则回退到 max_duration

2.2 淡出处理

截取时末尾 0.1 秒淡出(afade=t=out),避免截断爆音。


三、重新识别功能(旧数据迁移)

3.1 新增 API

POST /api/ref-audios/{audio_id}/retranscribe — 下载音频 → 超 10s 截取 → Whisper 转写 → 重新上传音频和元数据。

3.2 前端 UI

  • RefAudioPanel 新增 RotateCw 按钮("重新识别文字"),转写中显示 animate-spin
  • 旧音频 ref_text 以固定文字开头时显示 ⚠ 黄色警告

四、语速控制CosyVoice speed 参数)

4.1 全链路传递

前端 GeneratedAudiosPanel (速度选择器)
  → useHomeController (speed state + persistence)
  → useGeneratedAudios.generateAudio(params)
  → POST /api/generated-audios/generate { speed: 1.0 }
  → GenerateAudioRequest.speed (Pydantic)
  → generate_audio_task → voice_clone_service.generate_audio(speed=)
  → _generate_once → POST /generate { speed: "1.0" }
  → cosyvoice_server → _model.inference_zero_shot(speed=speed)

4.2 前端 UI

声音克隆模式下,配音列表面板标题栏"生成配音"按钮左侧显示语速下拉菜单(语速: 正常 ▼

标签 speed 值
较慢 0.8
稍慢 0.9
正常 1.0 (默认)
稍快 1.1
较快 1.2

语速选择持久化到 localStoragevigent_{storageKey}_speed)。


五、缺少参考音频门控

声音克隆模式下未选参考音频时:

  • "生成配音"按钮禁用 + title 提示"请先选择参考音频"
  • 面板内显示黄色警告条"声音克隆模式需要先选择参考音频"

六、前端清理

  • 移除 FIXED_REF_TEXT 常量和 fixedRefText prop
  • 移除"请朗读以下内容"引导区块
  • 上传提示简化为"上传任意语音样本3-10秒系统将自动识别内容并克隆声音"
  • 录音区备注"建议 3-10 秒,超出将自动截取"

涉及文件汇总

后端修改

文件 变更
backend/app/services/whisper_service.py transcribe() 增加可选 language 参数,默认 None (自动检测)
backend/app/modules/ref_audios/service.py 上传自动转写 + 静音点截取 + 淡出 + retranscribe 函数
backend/app/modules/ref_audios/router.py ref_text 改为 Form(""),新增 retranscribe 端点
backend/app/modules/generated_audios/schemas.py GenerateAudioRequest 新增 speed: float = 1.0
backend/app/modules/generated_audios/service.py 传递 req.speed 到 voice_clone_service
backend/app/services/voice_clone_service.py generate_audio() / _generate_once() 接受并传递 speed
models/CosyVoice/cosyvoice_server.py /generate 端点接受 speed 参数,传递到 inference_zero_shot(speed=)

前端修改

文件 变更
frontend/src/features/home/model/useHomeController.ts 新增 speed state移除 FIXED_REF_TEXThandleGenerateAudio 传 speed
frontend/src/features/home/model/useHomePersistence.ts 新增 speed 持久化
frontend/src/features/home/model/useRefAudios.ts 移除 fixedRefText新增 retranscribe
frontend/src/features/home/model/useGeneratedAudios.ts generateAudio params 新增 speed
frontend/src/features/home/ui/GeneratedAudiosPanel.tsx 新增语速选择器 + 缺少参考音频门控
frontend/src/features/home/ui/RefAudioPanel.tsx 移除朗读引导,新增重新识别按钮 + ⚠ 警告
frontend/src/features/home/ui/HomePage.tsx 传递 speed/setSpeed/ttsMode 到 GeneratedAudiosPanel