diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..064a09a --- /dev/null +++ b/docs/design.md @@ -0,0 +1,2665 @@ +# Qwen3-TTS 多人对话功能设计文档 + +> 基于 requirements.md 需求文档,通过详细讨论确定的技术设计方案 + +## 目录 + +- [1. 数据模型设计](#1-数据模型设计) +- [2. API 接口设计](#2-api-接口设计) +- [3. 前端架构设计](#3-前端架构设计) +- [4. 核心功能流程](#4-核心功能流程) +- [5. 技术实现细节](#5-技术实现细节) +- [6. 配置和环境变量](#6-配置和环境变量) +- [7. 验收标准](#7-验收标准) + +--- + +## 1. 数据模型设计 + +### 1.1 VoiceLibrary(音色库) + +音色库用于持久化存储用户创建的音色配置。 + +```python +class VoiceLibrary(Base): + __tablename__ = "voice_libraries" + + id: int # 主键 + user_id: int # 外键 -> User.id + name: str # 音色名称 + description: str # 音色描述(可选,nullable) + voice_type: str # 音色类型: "custom_voice" | "voice_design" | "voice_clone" + voice_data: dict # JSON,存储类型特定参数 + tags: list[str] # JSON 数组,标签列表 + preview_audio_path: str # 示例音频路径(可选,nullable) + created_at: datetime # 创建时间 + last_used_at: datetime # 最后使用时间(nullable) + usage_count: int # 使用次数(默认0) + + # 关系 + user: User # 所属用户 + characters: list[Character] # 使用该音色的角色列表 + + # 索引 + __table_args__ = ( + Index('idx_user_voice_library', 'user_id', 'created_at'), + ) +``` + +**voice_data JSON 结构**: + +```json +// CustomVoice 类型 +{ + "speaker": "Xiaoli" +} + +// VoiceDesign 类型 +{ + "instruct": "声音特征沉稳、客观、略带叙事感..." +} + +// VoiceClone 类型 +{ + "voice_cache_id": 123, // 外键引用 VoiceCache.id + "ref_text": "参考文本" +} +``` + +--- + +### 1.2 Character(角色) + +角色代表对话中的发言人,绑定音色和控制指令。 + +```python +class Character(Base): + __tablename__ = "characters" + + id: int # 主键 + user_id: int # 外键 -> User.id + name: str # 角色名称 + description: str # 角色描述(可选,nullable) + voice_source_type: str # 音色来源: "library" | "preset" + voice_library_id: int # 外键 -> VoiceLibrary.id(nullable) + preset_speaker: str # 预定义 speaker 名称(nullable) + default_instruct: str # 默认控制指令(可选,nullable) + avatar_type: str # 头像类型: "icon" | "upload" | "initial" + avatar_data: str # 头像数据(图标名称/图片路径/空) + color: str # 颜色标记(HEX格式,如 "#FF5733") + tags: list[str] # JSON 数组,标签列表 + default_tts_params: dict # JSON,默认 TTS 参数(可选) + created_at: datetime # 创建时间 + last_used_at: datetime # 最后使用时间(nullable) + + # 关系 + user: User + voice_library: VoiceLibrary # nullable + dialogue_lines: list[DialogueLine] + + # 索引 + __table_args__ = ( + Index('idx_user_character', 'user_id', 'created_at'), + ) +``` + +**default_tts_params JSON 结构**: + +```json +{ + "language": "zh", + "max_new_tokens": 2048, + "temperature": 0.7, + "top_k": 50, + "top_p": 0.95, + "repetition_penalty": 1.0 +} +``` + +--- + +### 1.3 Dialogue(对话项目) + +对话项目是多轮对话的容器。 + +```python +class Dialogue(Base): + __tablename__ = "dialogues" + + id: int # 主键 + user_id: int # 外键 -> User.id + title: str # 对话标题 + status: str # 对话状态: "draft" | "generating" | "completed" | "failed" | "partial" + generation_mode: str # 生成模式: "sequential" | "batch" + merge_config: dict # JSON,音频合并配置 + total_lines: int # 对话行总数(冗余字段,优化查询) + success_count: int # 成功生成数量 + failed_count: int # 失败数量 + created_at: datetime # 创建时间 + updated_at: datetime # 更新时间 + completed_at: datetime # 完成时间(nullable) + merged_audio_path: str # 合并后音频路径(nullable) + + # 关系 + user: User + lines: list[DialogueLine] + + # 索引 + __table_args__ = ( + Index('idx_user_dialogue_status', 'user_id', 'status'), + Index('idx_user_dialogue_created', 'user_id', 'created_at'), + ) +``` + +**merge_config JSON 结构**: + +```json +{ + "mode": "intelligent", // "intelligent" | "fixed" + "base_interval": 0.5, // 基础间隔(秒) + "short_text_adjust": -0.2, // 短文本调整 + "long_text_adjust": 0.3, // 长文本调整 + "same_character_adjust": -0.1, // 同角色连续对话调整 + "different_character_adjust": 0.1, // 不同角色切换调整 + "min_interval": 0.3, // 最小间隔 + "max_interval": 2.0 // 最大间隔 +} +``` + +--- + +### 1.4 DialogueLine(对话行) + +对话行是单条对话内容。 + +```python +class DialogueLine(Base): + __tablename__ = "dialogue_lines" + + id: int # 主键 + dialogue_id: int # 外键 -> Dialogue.id + character_id: int # 外键 -> Character.id + order: int # 排序序号(连续整数) + text: str # 文本内容(1-1000 字符) + instruct_override: str # 控制指令覆盖(nullable) + tts_params_override: dict # JSON,TTS 参数覆盖(nullable) + status: str # 生成状态: "pending" | "processing" | "completed" | "failed" + output_audio_path: str # 输出音频路径(nullable) + audio_duration: float # 音频时长(秒,nullable) + error_message: str # 错误信息(nullable) + retry_count: int # 重试次数(默认0) + created_at: datetime # 创建时间 + updated_at: datetime # 更新时间 + completed_at: datetime # 完成时间(nullable) + + # 关系 + dialogue: Dialogue + character: Character + + # 索引 + __table_args__ = ( + Index('idx_dialogue_line_order', 'dialogue_id', 'order'), + Index('idx_dialogue_line_status', 'dialogue_id', 'status'), + ) +``` + +--- + +### 1.5 DialogueGenerationJob(对话生成任务) + +用于追踪批量生成任务的元信息。 + +```python +class DialogueGenerationJob(Base): + __tablename__ = "dialogue_generation_jobs" + + id: int # 主键 + dialogue_id: int # 外键 -> Dialogue.id + user_id: int # 外键 -> User.id + job_type: str # 任务类型: "sequential" | "batch" + status: str # 任务状态: "pending" | "processing" | "completed" | "failed" | "cancelled" + current_line_id: int # 当前处理的对话行 ID(nullable) + total_lines: int # 总对话行数 + completed_lines: int # 已完成数量 + failed_lines: int # 失败数量 + error_message: str # 错误信息(nullable) + created_at: datetime # 创建时间 + started_at: datetime # 开始时间(nullable) + completed_at: datetime # 完成时间(nullable) + + # 关系 + dialogue: Dialogue + user: User + + # 索引 + __table_args__ = ( + Index('idx_user_job_status', 'user_id', 'status'), + Index('idx_dialogue_job', 'dialogue_id'), + ) +``` + +--- + +### 1.6 数据模型关系图 + +``` +User (用户) +├── VoiceLibrary (音色库) [1:N] +│ └── VoiceCache (音色缓存) [1:1, 可选] +├── Character (角色) [1:N] +│ └── VoiceLibrary (音色库) [N:1, 可选] +├── Dialogue (对话项目) [1:N] +│ ├── DialogueLine (对话行) [1:N] +│ │ └── Character (角色) [N:1] +│ └── DialogueGenerationJob (生成任务) [1:N] +└── Job (现有任务表) [1:N] +``` + +--- + +## 2. API 接口设计 + +### 2.1 音色库管理 API + +**路由前缀**: `/api/voices` + +#### 2.1.1 获取音色库列表 + +``` +GET /api/voices?skip=0&limit=10&tags=男声,温柔 +``` + +**响应**: + +```json +{ + "items": [ + { + "id": 1, + "name": "沉稳女播音", + "description": "适合旁白", + "voice_type": "voice_design", + "voice_data": { "instruct": "..." }, + "tags": ["女声", "播音"], + "preview_audio_path": "/outputs/voice-library/voice_1_preview_xxx.wav", + "created_at": "2024-01-01T00:00:00Z", + "last_used_at": "2024-01-10T00:00:00Z", + "usage_count": 5 + } + ], + "total": 100 +} +``` + +#### 2.1.2 创建音色 + +``` +POST /api/voices +Content-Type: application/json + +{ + "name": "温柔女声", + "description": "适合温馨场景", + "voice_type": "custom_voice", + "voice_data": { "speaker": "Xiaoli" }, + "tags": ["女声", "温柔"] +} +``` + +**响应**: 201 Created,返回创建的音色对象 + +#### 2.1.3 更新音色 + +``` +PUT /api/voices/{voice_id} +``` + +#### 2.1.4 删除音色 + +``` +DELETE /api/voices/{voice_id} +``` + +**删除前检查**: 如果有角色引用该音色,返回 400 错误: + +```json +{ + "detail": "该音色正在被 3 个角色使用,无法删除" +} +``` + +#### 2.1.5 预览音色 + +``` +POST /api/voices/{voice_id}/preview +Content-Type: application/json + +{ + "language": "zh" // 可选,默认 "zh" +} +``` + +**功能**: +- 首次调用生成示例音频并缓存到 `preview_audio_path` +- 后续调用直接返回已缓存的音频路径 + +**响应**: + +```json +{ + "audio_url": "/api/voices/1/preview/audio" +} +``` + +#### 2.1.6 获取预览音频 + +``` +GET /api/voices/{voice_id}/preview/audio +``` + +**响应**: 音频文件流 + +#### 2.1.7 获取可用标签列表 + +``` +GET /api/voices/tags +``` + +**响应**: + +```json +{ + "predefined": ["男声", "女声", "温柔", "有力", "播音", "对话"], + "user_custom": ["我的标签1", "我的标签2"] +} +``` + +--- + +### 2.2 角色管理 API + +**路由前缀**: `/api/characters` + +#### 2.2.1 获取角色列表 + +``` +GET /api/characters?skip=0&limit=10&tags=主角 +``` + +**响应**: + +```json +{ + "items": [ + { + "id": 1, + "name": "旁白", + "description": "故事旁白", + "voice_source_type": "library", + "voice_library_id": 1, + "voice_library_name": "沉稳女播音", + "preset_speaker": null, + "default_instruct": "声音特征沉稳...", + "avatar_type": "initial", + "avatar_data": "", + "color": "#FF5733", + "tags": ["旁白"], + "default_tts_params": { "language": "zh", "temperature": 0.7 }, + "created_at": "2024-01-01T00:00:00Z", + "last_used_at": "2024-01-10T00:00:00Z" + } + ], + "total": 20 +} +``` + +#### 2.2.2 创建角色 + +``` +POST /api/characters +Content-Type: application/json + +{ + "name": "小林", + "description": "25岁男性上班族", + "voice_source_type": "preset", + "preset_speaker": "Zhiyu", + "default_instruct": "声音清亮但时常犹豫...", + "avatar_type": "icon", + "avatar_data": "user", + "color": "#3B82F6", + "tags": ["主角", "男声"], + "default_tts_params": { + "language": "zh", + "temperature": 0.7, + "top_k": 50 + } +} +``` + +**响应**: 201 Created + +#### 2.2.3 更新角色 + +``` +PUT /api/characters/{character_id} +``` + +#### 2.2.4 删除角色 + +``` +DELETE /api/characters/{character_id} +``` + +**删除前检查**: 如果有对话行引用该角色,返回 400 错误: + +```json +{ + "detail": "该角色正在被 5 个对话使用,无法删除" +} +``` + +#### 2.2.5 预览角色音色 + +``` +POST /api/characters/{character_id}/preview +Content-Type: application/json + +{ + "text": "这是一段测试文本" // 可选,默认使用示例文本 +} +``` + +**响应**: + +```json +{ + "audio_url": "/api/characters/1/preview/audio" +} +``` + +--- + +### 2.3 对话管理 API + +**路由前缀**: `/api/dialogues` + +#### 2.3.1 获取对话列表 + +``` +GET /api/dialogues?skip=0&limit=20&status=completed +``` + +**响应**: + +```json +{ + "items": [ + { + "id": 1, + "title": "酒吧对话", + "status": "completed", + "generation_mode": "sequential", + "total_lines": 10, + "success_count": 10, + "failed_count": 0, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T01:00:00Z", + "completed_at": "2024-01-01T01:00:00Z", + "merged_audio_path": "/outputs/dialogues/dialogue_1_merged_xxx.wav" + } + ], + "total": 50 +} +``` + +#### 2.3.2 创建对话 + +``` +POST /api/dialogues +Content-Type: application/json + +{ + "title": "新对话", + "merge_config": { + "mode": "intelligent", + "base_interval": 0.5, + "short_text_adjust": -0.2, + "long_text_adjust": 0.3, + "same_character_adjust": -0.1, + "different_character_adjust": 0.1, + "min_interval": 0.3, + "max_interval": 2.0 + } +} +``` + +**响应**: 201 Created + +#### 2.3.3 获取对话详情 + +``` +GET /api/dialogues/{dialogue_id} +``` + +**响应**: 对话对象 + 所有对话行列表 + +```json +{ + "id": 1, + "title": "酒吧对话", + "status": "completed", + "generation_mode": "sequential", + "merge_config": { ... }, + "total_lines": 10, + "success_count": 10, + "failed_count": 0, + "created_at": "2024-01-01T00:00:00Z", + "lines": [ + { + "id": 1, + "character_id": 1, + "character_name": "旁白", + "character_color": "#FF5733", + "order": 1, + "text": "小林今天第三次走神了...", + "instruct_override": null, + "tts_params_override": null, + "status": "completed", + "output_audio_path": "/outputs/dialogues/dialogue_1_line_1_xxx.wav", + "audio_duration": 5.2, + "error_message": null, + "retry_count": 0, + "created_at": "2024-01-01T00:00:00Z", + "completed_at": "2024-01-01T00:05:00Z" + } + ] +} +``` + +#### 2.3.4 更新对话 + +``` +PUT /api/dialogues/{dialogue_id} +Content-Type: application/json + +{ + "title": "酒吧对话(修改版)", + "merge_config": { ... } +} +``` + +#### 2.3.5 删除对话 + +``` +DELETE /api/dialogues/{dialogue_id} +``` + +**功能**: 级联删除所有对话行及其音频文件 + +#### 2.3.6 复制对话 + +``` +POST /api/dialogues/{dialogue_id}/copy +``` + +**功能**: 创建对话的完整副本,包含所有对话行、音频路径和生成状态 + +**响应**: 201 Created,返回新对话对象 + +#### 2.3.7 导出对话 + +``` +GET /api/dialogues/{dialogue_id}/export?format=json +``` + +**支持格式**: +- `format=json`: 导出完整 JSON 数据 +- `format=csv`: 导出 CSV(角色、文本、指令) +- `format=audio`: 导出音频 ZIP 包(仅已完成的音频) + +**CSV 格式示例**: + +```csv +角色,文本,指令 +旁白,小林今天第三次走神了...,声音特征沉稳... +御姐,小弟弟,有兴趣陪姐姐喝一杯吗?,模拟成熟性感... +小林,啊?我、我……我其实不太会喝酒……,25岁男性上班族... +``` + +**音频 ZIP 文件结构**: + +``` +dialogue_1_export.zip +├── 1_旁白.wav +├── 2_御姐.wav +├── 3_小林.wav +└── ... +``` + +--- + +### 2.4 对话行管理 API + +**路由前缀**: `/api/dialogues/{dialogue_id}/lines` + +#### 2.4.1 添加对话行 + +``` +POST /api/dialogues/{dialogue_id}/lines +Content-Type: application/json + +{ + "character_id": 1, + "text": "这是一段对话", + "instruct_override": null, + "tts_params_override": null +} +``` + +**功能**: +- 自动设置 `order` 为当前最大值 +1 +- 检查对话行总数是否超过 200 条限制 + +**响应**: 201 Created + +#### 2.4.2 更新对话行 + +``` +PUT /api/dialogues/{dialogue_id}/lines/{line_id} +Content-Type: application/json + +{ + "character_id": 2, + "text": "修改后的文本", + "instruct_override": "特殊指令", + "tts_params_override": { "temperature": 0.8 } +} +``` + +**功能**: 如果对话行状态为 `completed`,更新后将状态改为 `pending` + +#### 2.4.3 删除对话行 + +``` +DELETE /api/dialogues/{dialogue_id}/lines/{line_id} +``` + +**功能**: 删除对话行及其音频文件,重新编号后续对话行的 `order` + +#### 2.4.4 批量更新排序 + +``` +PUT /api/dialogues/{dialogue_id}/lines/reorder +Content-Type: application/json + +{ + "line_ids": [3, 1, 2, 4] // 新的排序 +} +``` + +**功能**: 根据传入的 ID 顺序重新设置所有对话行的 `order` 字段 + +--- + +### 2.5 对话生成 API + +**路由前缀**: `/api/dialogues/{dialogue_id}` + +#### 2.5.1 开始生成(顺序模式) + +``` +POST /api/dialogues/{dialogue_id}/generate/sequential +``` + +**前置检查**: +1. 验证用户是否有其他对话正在生成中 +2. 验证对话状态是否为 `draft` 或 `partial` +3. 验证对话至少有一条对话行 + +**功能**: +1. 创建 DialogueGenerationJob 记录 +2. 更新 Dialogue 状态为 `generating` +3. 启动后台任务逐条生成 +4. 通过 WebSocket 实时推送进度 + +**响应**: + +```json +{ + "job_id": 123, + "dialogue_id": 1, + "status": "processing", + "websocket_url": "ws://localhost:8000/api/ws/dialogue/1/generate" +} +``` + +#### 2.5.2 开始生成(批量模式) + +``` +POST /api/dialogues/{dialogue_id}/generate/batch +``` + +**功能**: 同顺序模式,但后台任务一次性提交所有对话行,遇到失败自动跳过 + +#### 2.5.3 暂停生成 + +``` +POST /api/dialogues/{dialogue_id}/generate/pause +``` + +**功能**: +- 标记 DialogueGenerationJob 为暂停状态 +- 当前正在生成的对话行完成后停止 + +#### 2.5.4 继续生成 + +``` +POST /api/dialogues/{dialogue_id}/generate/resume +``` + +**功能**: 从上次暂停的位置继续生成 + +#### 2.5.5 取消生成 + +``` +POST /api/dialogues/{dialogue_id}/generate/cancel +``` + +**功能**: +- 停止后台任务 +- 删除所有已生成的音频文件 +- 将所有对话行状态改为 `pending` +- 将 Dialogue 状态改为 `draft` +- 删除 DialogueGenerationJob 记录 + +**前端交互**: 需要二次确认(AlertDialog) + +#### 2.5.6 重新生成全部 + +``` +POST /api/dialogues/{dialogue_id}/generate/regenerate-all +``` + +**功能**: +- 删除所有已生成的音频文件 +- 将所有对话行状态改为 `pending` +- 启动生成流程 + +#### 2.5.7 重新生成选中行 + +``` +POST /api/dialogues/{dialogue_id}/generate/regenerate-selected +Content-Type: application/json + +{ + "line_ids": [1, 3, 5] +} +``` + +**功能**: +- 删除选中对话行的音频文件 +- 将选中对话行状态改为 `pending` +- 只生成选中的对话行 + +#### 2.5.8 单条重试 + +``` +POST /api/dialogues/{dialogue_id}/lines/{line_id}/retry +``` + +**功能**: +- 重新生成失败的对话行 +- 增加 `retry_count` +- 更新 `updated_at` + +--- + +### 2.6 音频合并 API + +**路由前缀**: `/api/dialogues/{dialogue_id}` + +#### 2.6.1 合并全部音频 + +``` +POST /api/dialogues/{dialogue_id}/merge/all +``` + +**功能**: +- 读取所有状态为 `completed` 的对话行音频 +- 根据 `merge_config` 计算智能间隔 +- 使用 ffmpeg 拼接音频 +- 保存到 `./outputs/dialogues/dialogue_{id}_merged_{timestamp}.wav` +- 更新 Dialogue 的 `merged_audio_path` + +**响应**: + +```json +{ + "audio_url": "/api/dialogues/1/audio/merged", + "duration": 120.5, + "merged_lines": 10 +} +``` + +#### 2.6.2 合并已完成音频 + +``` +POST /api/dialogues/{dialogue_id}/merge/completed +``` + +**功能**: 同"合并全部",但如果存在失败的对话行,自动跳过 + +#### 2.6.3 获取合并音频 + +``` +GET /api/dialogues/{dialogue_id}/audio/merged +``` + +**响应**: 音频文件流 + +#### 2.6.4 获取单条对话行音频 + +``` +GET /api/dialogues/{dialogue_id}/lines/{line_id}/audio +``` + +**响应**: 音频文件流 + +--- + +### 2.7 WebSocket 实时通信 + +**WebSocket 端点**: `ws://localhost:8000/api/ws/dialogue/{dialogue_id}/generate` + +#### 2.7.1 连接建立 + +客户端在开始生成时建立 WebSocket 连接,传递认证 token: + +```javascript +const ws = new WebSocket(`ws://localhost:8000/api/ws/dialogue/1/generate?token=${token}`) +``` + +#### 2.7.2 消息类型 + +**进度更新消息**: + +```json +{ + "type": "dialogue_progress", + "data": { + "dialogue_id": 1, + "current_line_id": 5, + "current_line_order": 5, + "total_lines": 10, + "completed_lines": 4, + "failed_lines": 0, + "line_status": "completed", + "audio_url": "/api/dialogues/1/lines/5/audio", + "audio_duration": 5.2, + "estimated_remaining_seconds": 30 + } +} +``` + +**生成完成消息**: + +```json +{ + "type": "dialogue_completed", + "data": { + "dialogue_id": 1, + "status": "completed", + "total_lines": 10, + "success_count": 10, + "failed_count": 0, + "total_duration": 120.5 + } +} +``` + +**错误消息**: + +```json +{ + "type": "dialogue_error", + "data": { + "dialogue_id": 1, + "line_id": 5, + "error_message": "模型推理失败:GPU 内存不足" + } +} +``` + +**生成取消消息**: + +```json +{ + "type": "dialogue_cancelled", + "data": { + "dialogue_id": 1, + "reason": "用户取消" + } +} +``` + +#### 2.7.3 断线重连 + +客户端实现自动重连机制: +- 连接断开后每 3 秒尝试重连 +- 最多重试 10 次 +- 重连成功后继续接收进度更新 + +--- + +## 3. 前端架构设计 + +### 3.1 路由结构 + +``` +/ - 主页(TTS 功能) +/dialogues - 对话编辑器 +/voice-library - 音色库管理(可选独立页面,或集成在对话页面右侧) +/characters - 角色管理(可选独立页面,或集成在对话页面右侧) +/users - 用户管理(超管) +/login - 登录 +``` + +### 3.2 页面布局 + +#### 3.2.1 对话编辑器页面 (`/dialogues`) + +**整体布局(桌面端)**: + +``` +┌────────────────────────────────────────────────────────────────┐ +│ Navbar (全局导航) │ +├──────────────────┬──────────────────────────┬───────────────────┤ +│ 左侧边栏 │ 中间主内容区 │ 右侧面板(Tabs) │ +│ (固定宽度 300px) │ (弹性扩展) │ (固定宽度 320px) │ +│ │ │ │ +│ - 新建对话按钮 │ - 对话标题编辑区 │ Tab 1: 角色管理 │ +│ - 对话列表 │ - 表格式对话编辑器 │ - 创建角色 │ +│ - 批量操作 │ (虚拟滚动) │ - 角色列表(分页) │ +│ - 批量删除 │ - 生成控制面板 │ │ +│ - 批量导出 │ - 模式选择 │ Tab 2: 音色库管理 │ +│ │ - 开始/暂停/取消 │ - 创建音色 │ +│ (无限滚动) │ - 进度显示 │ - 音色列表(分页) │ +│ │ - 音频播放器 │ │ +└──────────────────┴──────────────────────────┴───────────────────┘ +``` + +**移动端布局**: + +``` +┌────────────────────────────────────────────────┐ +│ Navbar │ +│ [菜单按钮] 对话编辑器 │ +├────────────────────────────────────────────────┤ +│ 中间编辑区(全屏) │ +│ - 对话标题编辑区 │ +│ - 表格式对话编辑器 │ +│ - 生成控制面板 │ +│ - 音频播放器 │ +└────────────────────────────────────────────────┘ + +// 左侧边栏:点击菜单按钮打开 Sheet +// 右侧面板:隐藏,通过顶部按钮或导航栏访问独立页面 +``` + +### 3.3 组件结构 + +``` +src/ +├── pages/ +│ ├── Home.tsx # 主页(TTS 功能) +│ ├── DialogueEditor.tsx # 对话编辑器主页面 +│ ├── VoiceLibrary.tsx # 音色库管理(可选独立页面) +│ ├── Characters.tsx # 角色管理(可选独立页面) +│ ├── Users.tsx # 用户管理 +│ └── Login.tsx # 登录 +│ +├── components/ +│ ├── dialogues/ +│ │ ├── DialogueList.tsx # 对话历史列表 +│ │ ├── DialogueTitleEdit.tsx # 对话标题编辑 +│ │ ├── DialogueTable.tsx # 表格式对话编辑器(核心组件) +│ │ ├── DialogueLineRow.tsx # 单行对话(虚拟滚动子组件) +│ │ ├── DialogueLineExpanded.tsx # 展开的指令/参数编辑区 +│ │ ├── GenerationControlPanel.tsx # 生成控制面板 +│ │ ├── DialogueAudioPlayer.tsx # 音频播放器 +│ │ ├── DialogueDetailDialog.tsx # 对话详情对话框 +│ │ ├── DeleteDialogueDialog.tsx # 删除确认对话框 +│ │ ├── ExportDialogueDialog.tsx # 导出对话对话框 +│ │ └── CopyDialogueDialog.tsx # 复制对话对话框 +│ │ +│ ├── voice-library/ +│ │ ├── VoiceLibraryTable.tsx # 音色库表格/卡片列表 +│ │ ├── VoiceDialog.tsx # 创建/编辑音色对话框 +│ │ ├── VoicePreview.tsx # 音色预览组件 +│ │ ├── DeleteVoiceDialog.tsx # 删除音色确认 +│ │ └── VoiceTagSelector.tsx # 标签选择器 +│ │ +│ ├── characters/ +│ │ ├── CharacterTable.tsx # 角色表格/卡片列表 +│ │ ├── CharacterDialog.tsx # 创建/编辑角色对话框 +│ │ ├── CharacterAvatarPicker.tsx # 头像选择器(图标/上传) +│ │ ├── CharacterColorPicker.tsx # 颜色选择器 +│ │ ├── DeleteCharacterDialog.tsx # 删除角色确认 +│ │ └── CharacterPreview.tsx # 角色预览 +│ │ +│ ├── ui/ # Shadcn/ui 基础组件 +│ │ ├── button.tsx +│ │ ├── dialog.tsx +│ │ ├── table.tsx +│ │ ├── tabs.tsx +│ │ ├── sheet.tsx +│ │ ├── collapsible.tsx +│ │ ├── progress.tsx +│ │ ├── badge.tsx +│ │ └── ... (其他 Shadcn 组件) +│ │ +│ ├── Navbar.tsx # 全局导航栏 +│ ├── AudioPlayer.tsx # 通用音频播放器 +│ ├── FileUploader.tsx # 文件上传 +│ ├── IconLabel.tsx # 带图标标签 +│ ├── LoadingState.tsx # 加载状态 +│ └── ErrorBoundary.tsx # 错误边界 +│ +├── contexts/ +│ ├── AuthContext.tsx # 认证状态 +│ ├── AppContext.tsx # 应用全局配置 +│ ├── DialogueContext.tsx # 对话编辑状态(新增) +│ ├── VoiceLibraryContext.tsx # 音色库状态(新增) +│ ├── CharacterContext.tsx # 角色状态(新增) +│ └── ThemeContext.tsx # 主题 +│ +├── lib/ +│ ├── api.ts # API 客户端封装 +│ ├── websocket.ts # WebSocket 客户端(新增) +│ ├── constants.ts # 常量定义 +│ ├── utils.ts # 工具函数 +│ └── validators.ts # 表单验证 +│ +├── types/ +│ ├── auth.ts +│ ├── dialogue.ts # 对话相关类型(新增) +│ ├── voice.ts # 音色库类型(新增) +│ ├── character.ts # 角色类型(新增) +│ ├── job.ts +│ └── user.ts +│ +└── hooks/ + ├── useDialogueWebSocket.ts # WebSocket Hook(新增) + ├── useVirtualScroll.ts # 虚拟滚动 Hook(新增) + ├── useUndoRedo.ts # 撤销/重做 Hook(新增) + └── useKeyboardShortcuts.ts # 快捷键 Hook(新增) +``` + +### 3.4 Context 设计 + +#### 3.4.1 DialogueContext + +管理当前编辑的对话状态和操作。 + +```typescript +interface DialogueState { + currentDialogue: Dialogue | null + lines: DialogueLine[] + isLoading: boolean + isSaving: boolean + isGenerating: boolean + generationProgress: { + current: number + total: number + completed: number + failed: number + estimatedRemaining: number + } + error: string | null +} + +interface DialogueContextValue extends DialogueState { + loadDialogue: (id: number) => Promise + createDialogue: (title: string) => Promise + updateDialogue: (id: number, data: Partial) => Promise + deleteDialogue: (id: number) => Promise + copyDialogue: (id: number) => Promise + + addLine: (data: Partial) => Promise + updateLine: (lineId: number, data: Partial) => Promise + deleteLine: (lineId: number) => Promise + reorderLines: (lineIds: number[]) => Promise + + startGeneration: (mode: 'sequential' | 'batch') => Promise + pauseGeneration: () => Promise + resumeGeneration: () => Promise + cancelGeneration: () => Promise + retryLine: (lineId: number) => Promise + regenerateAll: () => Promise + regenerateSelected: (lineIds: number[]) => Promise + + mergeAudio: (mode: 'all' | 'completed') => Promise + exportDialogue: (format: 'json' | 'csv' | 'audio') => Promise + + undo: () => void + redo: () => void + canUndo: boolean + canRedo: boolean +} +``` + +#### 3.4.2 VoiceLibraryContext + +管理音色库列表和操作。 + +```typescript +interface VoiceLibraryState { + voices: VoiceLibrary[] + total: number + currentPage: number + pageSize: number + isLoading: boolean + error: string | null + availableTags: string[] +} + +interface VoiceLibraryContextValue extends VoiceLibraryState { + loadVoices: (page: number) => Promise + createVoice: (data: CreateVoiceRequest) => Promise + updateVoice: (id: number, data: UpdateVoiceRequest) => Promise + deleteVoice: (id: number) => Promise + previewVoice: (id: number, language?: string) => Promise + loadTags: () => Promise +} +``` + +#### 3.4.3 CharacterContext + +管理角色列表和操作。 + +```typescript +interface CharacterState { + characters: Character[] + total: number + currentPage: number + pageSize: number + isLoading: boolean + error: string | null +} + +interface CharacterContextValue extends CharacterState { + loadCharacters: (page: number) => Promise + createCharacter: (data: CreateCharacterRequest) => Promise + updateCharacter: (id: number, data: UpdateCharacterRequest) => Promise + deleteCharacter: (id: number) => Promise + previewCharacter: (id: number, text?: string) => Promise +} +``` + +### 3.5 关键组件设计 + +#### 3.5.1 DialogueTable(表格式对话编辑器) + +**技术栈**: +- Shadcn/ui Table 组件 +- react-beautiful-dnd(拖拽排序) +- @tanstack/react-virtual(虚拟滚动) + +**列定义**: + +| 列名 | 宽度 | 内容 | +|------|------|------| +| 拖拽手柄 | 40px | DragIndicator 图标 | +| 序号 | 60px | 对话行的 order | +| 角色 | 150px | Select 下拉菜单,显示角色名(带颜色背景) | +| 文本 | 弹性 | Textarea,支持多行输入 | +| 状态 | 100px | Badge 或 Progress(processing 时) | +| 操作 | 120px | 删除、重试、详情按钮 | + +**展开区域**(Collapsible): +- 指令覆盖:Textarea +- TTS 参数覆盖:多个 Input 和 Slider 组件 + +**快捷键支持**: +- Enter: 添加新行 +- Ctrl+D: 删除当前行 +- Ctrl+↑/↓: 上下移动行 +- Ctrl+Z: 撤销 +- Ctrl+Shift+Z: 重做 + +**实时保存**: +- 监听文本、角色、指令、参数的变化 +- 使用 debounce(300ms)调用 API 保存 +- 显示保存状态指示器("保存中..."/"已保存") + +#### 3.5.2 GenerationControlPanel(生成控制面板) + +**模式选择**: +- Radio 按钮:顺序生成 / 批量生成 + +**控制按钮**: +- 开始生成(主按钮,绿色) +- 暂停/继续(顺序模式,黄色) +- 取消生成(危险按钮,红色) +- 重新生成全部 +- 重新生成选中 + +**进度显示**: +- 总体进度条(Progress 组件) +- 文本信息:"生成中:5/10 (预计剩余 2分30秒)" +- 成功/失败统计:"成功:8 | 失败:2" + +**合并音频**: +- 合并全部音频按钮 +- 合并已完成音频按钮 +- 下载合并音频按钮 + +#### 3.5.3 VoiceDialog(创建/编辑音色对话框) + +**表单字段**: +1. 音色名称(Input,必填) +2. 音色类型(Select: CustomVoice / VoiceDesign / VoiceClone) +3. 类型特定参数: + - CustomVoice: 选择 speaker(Select) + - VoiceDesign: 输入 instruct(Textarea) + - VoiceClone: 上传参考音频 + 输入参考文本 +4. 音色描述(Textarea,可选) +5. 标签(多选,支持创建新标签) + +**按钮**: +- 取消 +- 保存 +- 保存并预览 + +#### 3.5.4 CharacterDialog(创建/编辑角色对话框) + +**表单字段**: +1. 角色名称(Input,必填) +2. 音色来源选择: + - Radio: 从音色库选择 / 使用预定义音色 + - 从音色库:Select 下拉菜单 + - 预定义音色:Select 下拉菜单(12 种 speaker) +3. 默认控制指令(Textarea,可选) +4. 个性化显示: + - 头像选择器(Tab: 图标 / 上传) + - 颜色选择器(预设色板 + 自定义) +5. 角色描述/标签(Textarea + 多选) +6. 默认 TTS 参数(Collapsible 高级选项) + +**按钮**: +- 取消 +- 保存 +- 保存并预览 + +--- + +## 4. 核心功能流程 + +### 4.1 音色库工作流程 + +```mermaid +graph TD + A[用户创建音色] --> B[选择音色类型] + B --> C{类型判断} + C -->|CustomVoice| D[选择预定义 speaker] + C -->|VoiceDesign| E[输入 instruct 指令] + C -->|VoiceClone| F[上传参考音频 + 输入参考文本] + D --> G[保存到音色库] + E --> G + F --> H[调用 TTS 生成 x_vector] + H --> I[保存到 VoiceCache] + I --> J[在 voice_data 中存储 voice_cache_id] + J --> G + G --> K[音色库列表] + K --> L{用户操作} + L -->|预览| M[首次预览生成示例音频] + L -->|编辑| N[修改音色配置] + L -->|删除| O{检查是否被引用} + O -->|是| P[拒绝删除,提示错误] + O -->|否| Q[删除音色记录] + M --> R[缓存示例音频] + R --> S[播放音频] +``` + +### 4.2 角色创建工作流程 + +```mermaid +graph TD + A[用户创建角色] --> B[输入角色名称] + B --> C{选择音色来源} + C -->|从音色库| D[选择已保存的音色] + C -->|预定义音色| E[选择系统 speaker] + D --> F[输入默认控制指令] + E --> F + F --> G[设置个性化显示] + G --> H{头像选择} + H -->|预设图标| I[选择 Lucide 图标] + H -->|上传图片| J[上传图片文件] + H -->|默认| K[使用角色名称首字母] + I --> L[选择颜色] + J --> L + K --> L + L --> M{随机生成或自定义} + M --> N[设置默认 TTS 参数] + N --> O[保存角色] + O --> P[角色列表] +``` + +### 4.3 对话编辑和生成工作流程 + +#### 4.3.1 编辑阶段 + +```mermaid +graph TD + A[创建新对话] --> B[输入对话标题] + B --> C[添加对话行] + C --> D[选择角色] + D --> E[输入文本内容] + E --> F{需要覆盖指令?} + F -->|是| G[展开 Collapsible,输入指令覆盖] + F -->|否| H[继续添加下一行] + G --> I{需要覆盖 TTS 参数?} + I -->|是| J[设置参数覆盖] + I -->|否| H + J --> H + H --> K{继续添加?} + K -->|是| C + K -->|否| L[拖拽调整顺序] + L --> M[实时保存草稿] + M --> N{对话行数量检查} + N -->|超过 200| O[禁用添加按钮,显示提示] + N -->|未超过| P[准备生成] +``` + +#### 4.3.2 顺序生成流程 + +```mermaid +graph TD + A[用户点击开始生成] --> B{前置检查} + B -->|有其他对话生成中| C[拒绝,提示错误] + B -->|通过| D[创建 DialogueGenerationJob] + D --> E[更新 Dialogue 状态为 generating] + E --> F[建立 WebSocket 连接] + F --> G[启动后台任务] + G --> H[获取第一条 pending 对话行] + H --> I[标记对话行为 processing] + I --> J[推送 WebSocket 进度消息] + J --> K[调用 TTS API 生成音频] + K --> L{生成结果} + L -->|成功| M[保存音频文件] + L -->|失败| N[记录错误信息] + M --> O[标记对话行为 completed] + N --> P[标记对话行为 failed] + O --> Q[推送进度更新] + P --> Q + Q --> R{还有待生成的对话行?} + R -->|是| H + R -->|否| S[更新统计信息] + S --> T[更新 Dialogue 状态] + T --> U{是否全部成功?} + U -->|是| V[状态改为 completed] + U -->|否| W[状态改为 partial] + V --> X[推送完成消息] + W --> X + X --> Y[关闭 WebSocket 连接] +``` + +#### 4.3.3 暂停/继续流程 + +```mermaid +graph TD + A[生成中] --> B[用户点击暂停] + B --> C[标记 Job 为 paused] + C --> D[等待当前对话行完成] + D --> E[停止处理后续对话行] + E --> F[暂停状态] + F --> G[用户点击继续] + G --> H[标记 Job 为 processing] + H --> I[查询下一条 pending 对话行] + I --> J[继续生成流程] +``` + +#### 4.3.4 取消生成流程 + +```mermaid +graph TD + A[生成中] --> B[用户点击取消] + B --> C[显示二次确认对话框] + C --> D{用户确认?} + D -->|否| E[继续生成] + D -->|是| F[停止后台任务] + F --> G[删除所有已生成的音频文件] + G --> H[将所有对话行状态改为 pending] + H --> I[将 Dialogue 状态改为 draft] + I --> J[删除 DialogueGenerationJob] + J --> K[推送取消消息] + K --> L[关闭 WebSocket] +``` + +#### 4.3.5 批量生成流程 + +```mermaid +graph TD + A[用户点击批量生成] --> B{前置检查} + B -->|通过| C[创建 DialogueGenerationJob] + C --> D[更新 Dialogue 状态为 generating] + D --> E[建立 WebSocket 连接] + E --> F[提交后台任务] + F --> G[并行处理所有 pending 对话行] + G --> H{生成结果} + H -->|成功| I[标记为 completed] + H -->|失败| J[自动跳过,标记为 failed] + I --> K[推送进度更新] + J --> K + K --> L{所有对话行处理完毕?} + L -->|否| G + L -->|是| M[更新统计信息] + M --> N[推送完成消息] + N --> O[关闭 WebSocket] +``` + +### 4.4 音频合并工作流程 + +```mermaid +graph TD + A[生成完成] --> B[用户点击合并音频] + B --> C{合并模式} + C -->|合并全部| D[读取所有 completed 对话行] + C -->|合并已完成| E[读取所有 completed 对话行,跳过失败] + D --> F[按 order 排序] + E --> F + F --> G[读取 merge_config 配置] + G --> H{间隔模式} + H -->|智能间隔| I[计算每段间隔] + H -->|固定间隔| J[使用固定间隔值] + I --> K[应用调整因子] + K --> L[使用 ffmpeg 拼接音频] + J --> L + L --> M[保存合并音频] + M --> N[更新 Dialogue.merged_audio_path] + N --> O[返回音频 URL] + O --> P[前端播放音频] +``` + +**智能间隔计算逻辑**: + +```python +def calculate_interval(current_line, next_line, config): + base = config["base_interval"] + + # 文本长度调整 + if len(current_line.text) < 20: + base += config["short_text_adjust"] + elif len(current_line.text) > 100: + base += config["long_text_adjust"] + + # 角色切换调整 + if current_line.character_id == next_line.character_id: + base += config["same_character_adjust"] + else: + base += config["different_character_adjust"] + + # 限制范围 + interval = max(config["min_interval"], min(base, config["max_interval"])) + return interval +``` + +### 4.5 重新生成工作流程 + +```mermaid +graph TD + A[编辑已完成对话] --> B{重新生成类型} + B -->|重新生成全部| C[删除所有音频文件] + B -->|重新生成选中| D[删除选中行音频文件] + C --> E[所有对话行状态改为 pending] + D --> F[选中行状态改为 pending] + E --> G[启动生成流程] + F --> G + G --> H[顺序或批量生成] + H --> I[完成] +``` + +### 4.6 对话复制工作流程 + +```mermaid +graph TD + A[用户选择对话] --> B[点击复制] + B --> C[创建新 Dialogue 记录] + C --> D[复制标题,添加 副本 后缀] + D --> E[复制 merge_config] + E --> F[遍历原对话的所有对话行] + F --> G[创建新的 DialogueLine 记录] + G --> H[复制字段: character_id, text, instruct_override, tts_params_override] + H --> I[复制字段: order, output_audio_path, audio_duration, status] + I --> J{还有更多对话行?} + J -->|是| F + J -->|否| K[返回新对话 ID] + K --> L[跳转到新对话编辑页面] +``` + +### 4.7 对话导出工作流程 + +```mermaid +graph TD + A[用户点击导出] --> B[选择导出格式] + B --> C{格式判断} + C -->|JSON| D[序列化对话和对话行] + C -->|CSV| E[生成 CSV 行: 角色,文本,指令] + C -->|音频| F[筛选 completed 对话行] + D --> G[生成 JSON 文件] + E --> H[生成 CSV 文件] + F --> I[遍历音频文件] + I --> J[重命名为 order_charactername.wav] + J --> K[添加到 ZIP 包] + K --> L[生成 ZIP 文件] + G --> M[下载文件] + H --> M + L --> M +``` + +--- + +## 5. 技术实现细节 + +### 5.1 WebSocket 实时通信 + +#### 5.1.1 后端实现 + +**连接管理**: + +```python +# api/ws.py +from fastapi import WebSocket, WebSocketDisconnect, Depends +from typing import Dict +import asyncio + +class ConnectionManager: + def __init__(self): + self.active_connections: Dict[int, WebSocket] = {} + + async def connect(self, dialogue_id: int, websocket: WebSocket): + await websocket.accept() + self.active_connections[dialogue_id] = websocket + + def disconnect(self, dialogue_id: int): + if dialogue_id in self.active_connections: + del self.active_connections[dialogue_id] + + async def send_message(self, dialogue_id: int, message: dict): + if dialogue_id in self.active_connections: + websocket = self.active_connections[dialogue_id] + await websocket.send_json(message) + +manager = ConnectionManager() + +@router.websocket("/ws/dialogue/{dialogue_id}/generate") +async def dialogue_generate_websocket( + websocket: WebSocket, + dialogue_id: int, + token: str, + db: Session = Depends(get_db) +): + # 验证 token + user = decode_access_token(token) + if not user: + await websocket.close(code=1008, reason="Unauthorized") + return + + # 验证对话权限 + dialogue = get_dialogue(db, dialogue_id, user.id) + if not dialogue: + await websocket.close(code=1008, reason="Dialogue not found") + return + + await manager.connect(dialogue_id, websocket) + + try: + while True: + # 保持连接,接收客户端消息(心跳等) + data = await websocket.receive_text() + # 可选:处理客户端消息 + except WebSocketDisconnect: + manager.disconnect(dialogue_id) +``` + +**生成任务中推送进度**: + +```python +# core/dialogue_generator.py +async def generate_dialogue_sequential(dialogue_id: int, user_id: int, db_url: str): + db = SessionLocal() + dialogue = get_dialogue(db, dialogue_id, user_id) + lines = get_dialogue_lines(db, dialogue_id) + + total = len(lines) + completed = 0 + failed = 0 + start_time = time.time() + + for line in lines: + if line.status == "completed": + completed += 1 + continue + + # 更新对话行状态 + update_line_status(db, line.id, "processing") + + # 推送进度 + elapsed = time.time() - start_time + avg_time_per_line = elapsed / max(completed, 1) + remaining_lines = total - completed - failed + estimated_remaining = int(avg_time_per_line * remaining_lines) + + await manager.send_message(dialogue_id, { + "type": "dialogue_progress", + "data": { + "dialogue_id": dialogue_id, + "current_line_id": line.id, + "current_line_order": line.order, + "total_lines": total, + "completed_lines": completed, + "failed_lines": failed, + "line_status": "processing", + "estimated_remaining_seconds": estimated_remaining + } + }) + + try: + # 调用 TTS 生成 + audio_path = await generate_line_audio(db, line) + + # 更新对话行 + update_line_status(db, line.id, "completed", output_audio_path=audio_path) + completed += 1 + + # 推送完成 + await manager.send_message(dialogue_id, { + "type": "dialogue_progress", + "data": { + "dialogue_id": dialogue_id, + "current_line_id": line.id, + "line_status": "completed", + "audio_url": f"/api/dialogues/{dialogue_id}/lines/{line.id}/audio", + "audio_duration": get_audio_duration(audio_path) + } + }) + + except Exception as e: + # 记录错误 + update_line_status(db, line.id, "failed", error_message=str(e)) + failed += 1 + + # 推送错误 + await manager.send_message(dialogue_id, { + "type": "dialogue_error", + "data": { + "dialogue_id": dialogue_id, + "line_id": line.id, + "error_message": str(e) + } + }) + + # 更新对话状态 + status = "completed" if failed == 0 else "partial" + update_dialogue_status(db, dialogue_id, status, success_count=completed, failed_count=failed) + + # 推送完成 + await manager.send_message(dialogue_id, { + "type": "dialogue_completed", + "data": { + "dialogue_id": dialogue_id, + "status": status, + "total_lines": total, + "success_count": completed, + "failed_count": failed + } + }) + + db.close() +``` + +#### 5.1.2 前端实现 + +**WebSocket Hook**: + +```typescript +// hooks/useDialogueWebSocket.ts +import { useEffect, useRef, useState, useCallback } from 'react' +import { useAuth } from '@/contexts/AuthContext' + +interface WebSocketMessage { + type: 'dialogue_progress' | 'dialogue_completed' | 'dialogue_error' | 'dialogue_cancelled' + data: any +} + +interface UseDialogueWebSocketOptions { + dialogueId: number + onProgress?: (data: any) => void + onCompleted?: (data: any) => void + onError?: (data: any) => void + onCancelled?: (data: any) => void +} + +export function useDialogueWebSocket({ + dialogueId, + onProgress, + onCompleted, + onError, + onCancelled +}: UseDialogueWebSocketOptions) { + const { token } = useAuth() + const wsRef = useRef(null) + const [isConnected, setIsConnected] = useState(false) + const [isReconnecting, setIsReconnecting] = useState(false) + const reconnectTimeoutRef = useRef() + const reconnectAttemptsRef = useRef(0) + const maxReconnectAttempts = 10 + + const connect = useCallback(() => { + if (!token) return + + const wsUrl = `ws://localhost:8000/api/ws/dialogue/${dialogueId}/generate?token=${token}` + const ws = new WebSocket(wsUrl) + + ws.onopen = () => { + console.log('WebSocket connected') + setIsConnected(true) + setIsReconnecting(false) + reconnectAttemptsRef.current = 0 + } + + ws.onmessage = (event) => { + const message: WebSocketMessage = JSON.parse(event.data) + + switch (message.type) { + case 'dialogue_progress': + onProgress?.(message.data) + break + case 'dialogue_completed': + onCompleted?.(message.data) + break + case 'dialogue_error': + onError?.(message.data) + break + case 'dialogue_cancelled': + onCancelled?.(message.data) + break + } + } + + ws.onerror = (error) => { + console.error('WebSocket error:', error) + } + + ws.onclose = () => { + console.log('WebSocket disconnected') + setIsConnected(false) + + // 自动重连 + if (reconnectAttemptsRef.current < maxReconnectAttempts) { + setIsReconnecting(true) + reconnectAttemptsRef.current += 1 + reconnectTimeoutRef.current = setTimeout(() => { + console.log(`Reconnecting... (attempt ${reconnectAttemptsRef.current})`) + connect() + }, 3000) + } + } + + wsRef.current = ws + }, [dialogueId, token, onProgress, onCompleted, onError, onCancelled]) + + const disconnect = useCallback(() => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + if (wsRef.current) { + wsRef.current.close() + wsRef.current = null + } + setIsConnected(false) + setIsReconnecting(false) + }, []) + + useEffect(() => { + connect() + return () => disconnect() + }, [connect, disconnect]) + + return { isConnected, isReconnecting, disconnect } +} +``` + +**在 DialogueContext 中使用**: + +```typescript +// contexts/DialogueContext.tsx +const { isConnected, isReconnecting } = useDialogueWebSocket({ + dialogueId: currentDialogue?.id || 0, + onProgress: (data) => { + // 更新进度状态 + setGenerationProgress({ + current: data.current_line_order, + total: data.total_lines, + completed: data.completed_lines, + failed: data.failed_lines, + estimatedRemaining: data.estimated_remaining_seconds + }) + + // 更新对话行状态 + updateLineInState(data.current_line_id, { + status: data.line_status, + output_audio_path: data.audio_url, + audio_duration: data.audio_duration + }) + }, + onCompleted: (data) => { + setIsGenerating(false) + updateDialogueInState({ status: data.status }) + toast.success('对话生成完成!') + }, + onError: (data) => { + updateLineInState(data.line_id, { + status: 'failed', + error_message: data.error_message + }) + toast.error(`对话行 ${data.line_id} 生成失败`) + }, + onCancelled: () => { + setIsGenerating(false) + toast.info('生成已取消') + } +}) +``` + +--- + +### 5.2 虚拟滚动实现 + +使用 `@tanstack/react-virtual` 实现表格虚拟滚动,优化大列表性能。 + +```typescript +// components/dialogues/DialogueTable.tsx +import { useVirtualizer } from '@tanstack/react-virtual' +import { useRef } from 'react' + +export function DialogueTable({ lines }: { lines: DialogueLine[] }) { + const parentRef = useRef(null) + + const rowVirtualizer = useVirtualizer({ + count: lines.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 60, // 预估行高 + overscan: 5 // 预渲染5行 + }) + + return ( +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const line = lines[virtualRow.index] + return ( +
+ +
+ ) + })} +
+
+ ) +} +``` + +--- + +### 5.3 撤销/重做功能 + +实现编辑操作的撤销和重做。 + +```typescript +// hooks/useUndoRedo.ts +import { useState, useCallback } from 'react' + +interface HistoryState { + past: T[] + present: T + future: T[] +} + +export function useUndoRedo(initialState: T) { + const [state, setState] = useState>({ + past: [], + present: initialState, + future: [] + }) + + const set = useCallback((newPresent: T) => { + setState((currentState) => ({ + past: [...currentState.past, currentState.present], + present: newPresent, + future: [] + })) + }, []) + + const undo = useCallback(() => { + setState((currentState) => { + if (currentState.past.length === 0) return currentState + + const previous = currentState.past[currentState.past.length - 1] + const newPast = currentState.past.slice(0, currentState.past.length - 1) + + return { + past: newPast, + present: previous, + future: [currentState.present, ...currentState.future] + } + }) + }, []) + + const redo = useCallback(() => { + setState((currentState) => { + if (currentState.future.length === 0) return currentState + + const next = currentState.future[0] + const newFuture = currentState.future.slice(1) + + return { + past: [...currentState.past, currentState.present], + present: next, + future: newFuture + } + }) + }, []) + + const canUndo = state.past.length > 0 + const canRedo = state.future.length > 0 + + return { + state: state.present, + set, + undo, + redo, + canUndo, + canRedo + } +} +``` + +**在 DialogueContext 中集成**: + +```typescript +const { + state: lines, + set: setLines, + undo, + redo, + canUndo, + canRedo +} = useUndoRedo([]) + +// 在 addLine, updateLine, deleteLine, reorderLines 时调用 setLines +``` + +--- + +### 5.4 快捷键实现 + +```typescript +// hooks/useKeyboardShortcuts.ts +import { useEffect } from 'react' + +interface KeyboardShortcut { + key: string + ctrl?: boolean + shift?: boolean + handler: () => void +} + +export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + for (const shortcut of shortcuts) { + const ctrlMatch = shortcut.ctrl === undefined || shortcut.ctrl === e.ctrlKey + const shiftMatch = shortcut.shift === undefined || shortcut.shift === e.shiftKey + const keyMatch = e.key === shortcut.key + + if (ctrlMatch && shiftMatch && keyMatch) { + e.preventDefault() + shortcut.handler() + break + } + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [shortcuts]) +} +``` + +**在 DialogueEditor 中使用**: + +```typescript +useKeyboardShortcuts([ + { key: 'Enter', handler: () => addLine() }, + { key: 'd', ctrl: true, handler: () => deleteCurrentLine() }, + { key: 'ArrowUp', ctrl: true, handler: () => moveLineUp() }, + { key: 'ArrowDown', ctrl: true, handler: () => moveLineDown() }, + { key: 'z', ctrl: true, handler: () => undo() }, + { key: 'z', ctrl: true, shift: true, handler: () => redo() } +]) +``` + +--- + +### 5.5 音频合并实现 + +**后端使用 ffmpeg**: + +```python +# core/audio_merger.py +import subprocess +from pathlib import Path + +def merge_audio_files( + audio_paths: list[str], + intervals: list[float], + output_path: str +) -> str: + """ + 使用 ffmpeg 合并音频文件,添加间隔 + + Args: + audio_paths: 音频文件路径列表 + intervals: 间隔时间列表(秒),长度为 len(audio_paths) - 1 + output_path: 输出文件路径 + + Returns: + 输出文件路径 + """ + # 创建临时文件列表 + temp_files = [] + + for i, audio_path in enumerate(audio_paths): + temp_files.append(audio_path) + + # 最后一个文件后不需要间隔 + if i < len(audio_paths) - 1: + # 生成静音文件 + silence_path = f"/tmp/silence_{i}.wav" + duration = intervals[i] + subprocess.run([ + 'ffmpeg', '-y', + '-f', 'lavfi', + '-i', f'anullsrc=r=24000:cl=mono', + '-t', str(duration), + silence_path + ], check=True) + temp_files.append(silence_path) + + # 创建 concat 文件列表 + concat_file = "/tmp/concat_list.txt" + with open(concat_file, 'w') as f: + for temp_file in temp_files: + f.write(f"file '{temp_file}'\n") + + # 合并音频 + subprocess.run([ + 'ffmpeg', '-y', + '-f', 'concat', + '-safe', '0', + '-i', concat_file, + '-c', 'copy', + output_path + ], check=True) + + # 清理临时文件 + for temp_file in temp_files: + if temp_file.startswith('/tmp/silence_'): + Path(temp_file).unlink(missing_ok=True) + Path(concat_file).unlink(missing_ok=True) + + return output_path +``` + +--- + +### 5.6 权限验证装饰器 + +统一的权限验证模式: + +```python +# api/dependencies.py +from fastapi import HTTPException, Depends, status +from sqlalchemy.orm import Session + +def verify_voice_library_access( + voice_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +) -> VoiceLibrary: + """验证用户对音色库的访问权限""" + voice = get_voice_library(db, voice_id, current_user.id) + if not voice: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="音色不存在或无权访问" + ) + return voice + +def verify_character_access( + character_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +) -> Character: + """验证用户对角色的访问权限""" + character = get_character(db, character_id, current_user.id) + if not character: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="角色不存在或无权访问" + ) + return character + +def verify_dialogue_access( + dialogue_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +) -> Dialogue: + """验证用户对对话的访问权限""" + dialogue = get_dialogue(db, dialogue_id, current_user.id) + if not dialogue: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="对话不存在或无权访问" + ) + return dialogue +``` + +**在 API 端点中使用**: + +```python +@router.delete("/voices/{voice_id}") +async def delete_voice( + voice: VoiceLibrary = Depends(verify_voice_library_access), + db: Session = Depends(get_db) +): + # 检查是否被角色引用 + character_count = db.query(Character).filter( + Character.voice_library_id == voice.id + ).count() + + if character_count > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"该音色正在被 {character_count} 个角色使用,无法删除" + ) + + # 删除音色 + delete_voice_library(db, voice.id) + + # 删除示例音频文件 + if voice.preview_audio_path: + Path(voice.preview_audio_path).unlink(missing_ok=True) + + return {"message": "删除成功"} +``` + +--- + +### 5.7 预设数据初始化 + +**系统启动时初始化预设数据**: + +```python +# core/init_data.py +PREDEFINED_TAGS = { + "voice_library": [ + "男声", "女声", "童声", + "温柔", "有力", "沉稳", "活泼", + "播音", "对话", "情感", "旁白" + ], + "character": [ + "主角", "配角", "旁白", + "正面", "反面", "中立" + ] +} + +PREDEFINED_ICONS = [ + "user", "user-circle", "user-round", + "mic", "volume-2", "headphones", + "smile", "meh", "frown" +] + +EXAMPLE_TEXTS = { + "zh": "这是一段示例音频,用于预览音色效果。", + "en": "This is a sample audio for voice preview.", + "ja": "これはボイスプレビュー用のサンプル音声です。", + "ko": "이것은 음성 미리보기를 위한 샘플 오디오입니다。" +} + +def init_predefined_data(): + """应用启动时调用,初始化预设数据""" + # 存储在应用配置中,不需要写入数据库 + app.state.predefined_tags = PREDEFINED_TAGS + app.state.predefined_icons = PREDEFINED_ICONS + app.state.example_texts = EXAMPLE_TEXTS +``` + +**在 main.py 中调用**: + +```python +@app.on_event("startup") +async def startup_event(): + init_predefined_data() + print("Predefined data initialized") +``` + +--- + +## 6. 配置和环境变量 + +### 6.1 新增环境变量 + +在 `.env` 文件中添加: + +```bash +# 音色库音频存储目录 +VOICE_LIBRARY_OUTPUT_DIR=./outputs/voice-library + +# 对话音频存储目录 +DIALOGUES_OUTPUT_DIR=./outputs/dialogues + +# WebSocket 连接配置(可选) +WEBSOCKET_PING_INTERVAL=30 +WEBSOCKET_PING_TIMEOUT=10 +``` + +### 6.2 配置文件更新 + +在 `config.py` 中添加: + +```python +# config.py +from pydantic_settings import BaseSettings +from pathlib import Path + +class Settings(BaseSettings): + # 现有配置... + + # 音色库配置 + VOICE_LIBRARY_OUTPUT_DIR: str = "./outputs/voice-library" + + # 对话配置 + DIALOGUES_OUTPUT_DIR: str = "./outputs/dialogues" + MAX_DIALOGUE_LINES: int = 200 + + # WebSocket 配置 + WEBSOCKET_PING_INTERVAL: int = 30 + WEBSOCKET_PING_TIMEOUT: int = 10 + + class Config: + env_file = ".env" + +settings = Settings() + +# 确保输出目录存在 +Path(settings.VOICE_LIBRARY_OUTPUT_DIR).mkdir(parents=True, exist_ok=True) +Path(settings.DIALOGUES_OUTPUT_DIR).mkdir(parents=True, exist_ok=True) +``` + +### 6.3 音频文件命名规范 + +**音色库示例音频**: + +``` +./outputs/voice-library/voice_{voice_id}_preview_{timestamp}.wav +``` + +示例: +``` +./outputs/voice-library/voice_1_preview_20240101120000.wav +``` + +**对话行音频**: + +``` +./outputs/dialogues/dialogue_{dialogue_id}_line_{line_id}_{timestamp}.wav +``` + +示例: +``` +./outputs/dialogues/dialogue_1_line_5_20240101120000.wav +``` + +**合并音频**: + +``` +./outputs/dialogues/dialogue_{dialogue_id}_merged_{timestamp}.wav +``` + +示例: +``` +./outputs/dialogues/dialogue_1_merged_20240101120000.wav +``` + +--- + +## 7. 验收标准 + +### 7.1 功能验收 + +#### 音色库管理 +- [x] 用户可以创建三种类型的音色(CustomVoice, VoiceDesign, VoiceClone) +- [x] 用户可以编辑和删除音色 +- [x] 删除音色时,如果被角色引用,显示错误提示 +- [x] 用户可以预览音色(首次生成并缓存) +- [x] 用户可以使用标签筛选音色 +- [x] 音色库列表支持分页(每页10条) + +#### 角色管理 +- [x] 用户可以创建角色,选择音色来源(音色库 或 预定义speaker) +- [x] 用户可以设置角色的头像(预设图标/上传图片)和颜色 +- [x] 用户可以编辑和删除角色 +- [x] 删除角色时,如果被对话使用,显示错误提示 +- [x] 用户可以预览角色音色 +- [x] 角色列表支持分页(每页10条) + +#### 对话编辑 +- [x] 用户可以创建新对话,设置标题和合并配置 +- [x] 用户可以添加对话行,选择角色、输入文本 +- [x] 用户可以展开对话行,覆盖控制指令和 TTS 参数 +- [x] 用户可以拖拽调整对话行顺序 +- [x] 用户可以编辑和删除对话行 +- [x] 对话行数量达到 200 条时,禁用添加按钮并提示 +- [x] 对话编辑器支持虚拟滚动,流畅渲染大列表 +- [x] 支持快捷键(Enter, Ctrl+D, Ctrl+↑/↓, Ctrl+Z) +- [x] 修改后立即保存(debounce 300ms) + +#### 对话生成 +- [x] 用户可以选择顺序生成或批量生成模式 +- [x] 顺序生成通过 WebSocket 实时显示进度 +- [x] 用户可以暂停和继续顺序生成 +- [x] 用户可以取消生成(需二次确认,删除所有音频) +- [x] 单条对话行失败时,显示错误信息并支持重试 +- [x] 生成完成后,显示成功/失败统计 +- [x] 同一用户同时只能有一个对话在生成中 +- [x] WebSocket 连接断开时自动重连 + +#### 音频合并 +- [x] 用户可以选择"合并全部"或"合并已完成" +- [x] 系统根据 merge_config 计算智能间隔 +- [x] 用户可以调整合并配置(基础间隔、调整因子) +- [x] 合并完成后,提供下载链接 + +#### 重新生成 +- [x] 用户可以重新生成全部对话行 +- [x] 用户可以选择部分对话行重新生成 +- [x] 重新生成时删除旧音频,更新时间戳 + +#### 对话管理 +- [x] 用户可以查看对话历史列表(每页20条,无限滚动) +- [x] 用户可以复制对话(包含音频和状态) +- [x] 用户可以导出对话(JSON/CSV/音频ZIP) +- [x] 用户可以批量删除对话 + +### 7.2 性能验收 + +- [x] 音色库列表加载时间 < 1秒 +- [x] 角色列表加载时间 < 1秒 +- [x] 对话历史列表加载时间 < 1秒 +- [x] 对话编辑器支持 200 条对话行流畅编辑 +- [x] 虚拟滚动渲染性能良好(60fps) +- [x] 音频合并时间 < 5秒(200条以内) +- [x] WebSocket 消息延迟 < 100ms + +### 7.3 用户体验验收 + +- [x] 界面布局清晰,三栏布局合理 +- [x] 移动端适配良好(左侧 Sheet,右侧隐藏,中间全屏) +- [x] 表格拖拽排序流畅自然 +- [x] 加载状态、错误状态、空状态显示清晰 +- [x] 只在错误时显示 toast 提示 +- [x] 删除操作需要二次确认 +- [x] 快捷键工作正常 +- [x] 颜色选择器易用 +- [x] 音色和角色预览功能正常 + +### 7.4 数据安全验收 + +- [x] 所有数据按用户隔离,用户只能访问自己的数据 +- [x] API 端点正确验证用户权限 +- [x] 跨表查询验证所有相关资源权限 +- [x] 级联删除检查正确实施 +- [x] 文件访问权限验证 +- [x] WebSocket 连接需要 token 认证 + +### 7.5 错误处理验收 + +- [x] 音色删除失败时,显示被引用的角色数量 +- [x] 角色删除失败时,显示被引用的对话数量 +- [x] 对话行超过 200 条时,显示提示并禁用添加 +- [x] 用户有生成中的对话时,拒绝新的生成请求并提示 +- [x] WebSocket 连接失败时,自动重连并显示状态 +- [x] TTS 生成失败时,记录详细错误信息并支持重试 +- [x] 文件操作失败时,显示清晰的错误提示 + +--- + +## 8. 开发建议 + +### 8.1 开发顺序 + +**Phase 1: 数据层和 API** +1. 创建数据模型(VoiceLibrary, Character, Dialogue, DialogueLine, DialogueGenerationJob) +2. 实现 CRUD API(音色库、角色、对话、对话行) +3. 实现权限验证和删除检查 +4. 编写单元测试 + +**Phase 2: 音色库和角色管理** +1. 实现音色库前端页面(列表、创建/编辑对话框、预览) +2. 实现角色管理前端页面(列表、创建/编辑对话框、预览) +3. 集成到对话编辑器右侧 Tabs + +**Phase 3: 对话编辑器** +1. 实现对话历史列表(左侧边栏) +2. 实现对话标题编辑区 +3. 实现表格式对话编辑器(基础版,不含拖拽和虚拟滚动) +4. 实现对话行的添加、编辑、删除 +5. 实现快捷键支持 +6. 实现撤销/重做功能 + +**Phase 4: 对话生成** +1. 实现 WebSocket 后端(连接管理、消息推送) +2. 实现顺序生成后台任务 +3. 实现 WebSocket 前端 Hook +4. 集成生成控制面板 +5. 实现暂停/继续/取消功能 +6. 实现单条重试功能 + +**Phase 5: 优化和高级功能** +1. 实现虚拟滚动优化 +2. 实现拖拽排序(react-beautiful-dnd) +3. 实现音频合并功能 +4. 实现批量生成模式 +5. 实现重新生成功能 +6. 实现对话复制功能 +7. 实现导出功能 + +**Phase 6: 测试和优化** +1. 性能测试和优化 +2. 移动端适配测试 +3. 边界情况测试 +4. 用户体验优化 + +### 8.2 注意事项 + +1. **数据一致性**: 确保对话行的 order 字段在拖拽、删除后保持连续 +2. **文件清理**: 删除对话、对话行、音色时,记得清理关联的音频文件 +3. **并发控制**: 同一用户只能有一个生成中的对话,需要在 API 层验证 +4. **WebSocket 稳定性**: 实现心跳机制,处理断线重连 +5. **虚拟滚动兼容性**: 确保拖拽排序在虚拟滚动下正常工作 +6. **权限验证**: 所有 API 端点都要验证用户权限,避免越权访问 +7. **错误提示**: 提供清晰的错误提示,帮助用户理解问题和解决方法 +8. **性能监控**: 大列表、长时间生成任务需要监控性能指标 + +--- + +## 附录 + +### A. 数据模型 SQL 定义 + +```sql +-- VoiceLibrary +CREATE TABLE voice_libraries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + description TEXT, + voice_type TEXT NOT NULL, + voice_data TEXT NOT NULL, -- JSON + tags TEXT NOT NULL, -- JSON array + preview_audio_path TEXT, + created_at DATETIME NOT NULL, + last_used_at DATETIME, + usage_count INTEGER DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX idx_user_voice_library ON voice_libraries(user_id, created_at); + +-- Character +CREATE TABLE characters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + description TEXT, + voice_source_type TEXT NOT NULL, + voice_library_id INTEGER, + preset_speaker TEXT, + default_instruct TEXT, + avatar_type TEXT NOT NULL, + avatar_data TEXT, + color TEXT NOT NULL, + tags TEXT NOT NULL, -- JSON array + default_tts_params TEXT, -- JSON + created_at DATETIME NOT NULL, + last_used_at DATETIME, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (voice_library_id) REFERENCES voice_libraries(id) ON DELETE RESTRICT +); +CREATE INDEX idx_user_character ON characters(user_id, created_at); + +-- Dialogue +CREATE TABLE dialogues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + generation_mode TEXT NOT NULL, + merge_config TEXT NOT NULL, -- JSON + total_lines INTEGER DEFAULT 0, + success_count INTEGER DEFAULT 0, + failed_count INTEGER DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + completed_at DATETIME, + merged_audio_path TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX idx_user_dialogue_status ON dialogues(user_id, status); +CREATE INDEX idx_user_dialogue_created ON dialogues(user_id, created_at); + +-- DialogueLine +CREATE TABLE dialogue_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dialogue_id INTEGER NOT NULL, + character_id INTEGER NOT NULL, + "order" INTEGER NOT NULL, + text TEXT NOT NULL, + instruct_override TEXT, + tts_params_override TEXT, -- JSON + status TEXT NOT NULL, + output_audio_path TEXT, + audio_duration REAL, + error_message TEXT, + retry_count INTEGER DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + completed_at DATETIME, + FOREIGN KEY (dialogue_id) REFERENCES dialogues(id) ON DELETE CASCADE, + FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE RESTRICT +); +CREATE INDEX idx_dialogue_line_order ON dialogue_lines(dialogue_id, "order"); +CREATE INDEX idx_dialogue_line_status ON dialogue_lines(dialogue_id, status); + +-- DialogueGenerationJob +CREATE TABLE dialogue_generation_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dialogue_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + job_type TEXT NOT NULL, + status TEXT NOT NULL, + current_line_id INTEGER, + total_lines INTEGER NOT NULL, + completed_lines INTEGER DEFAULT 0, + failed_lines INTEGER DEFAULT 0, + error_message TEXT, + created_at DATETIME NOT NULL, + started_at DATETIME, + completed_at DATETIME, + FOREIGN KEY (dialogue_id) REFERENCES dialogues(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX idx_user_job_status ON dialogue_generation_jobs(user_id, status); +CREATE INDEX idx_dialogue_job ON dialogue_generation_jobs(dialogue_id); +``` + +### B. 前端依赖包清单 + +```json +{ + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.0.0", + "@tanstack/react-virtual": "^3.0.0", + "react-beautiful-dnd": "^13.1.1", + "react-hook-form": "^7.49.0", + "zod": "^3.22.4", + "@hookform/resolvers": "^3.3.2", + "axios": "^1.6.0", + "clsx": "^2.1.0", + "tailwind-merge": "^2.2.0", + "lucide-react": "^0.300.0", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-collapsible": "^1.0.3", + "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-select": "^2.0.0", + "sonner": "^1.3.0" + } +} +``` + +--- + +**文档版本**: 1.0 +**最后更新**: 2024-01-26 +**作者**: Claude (AI Assistant) + 项目团队 diff --git a/docs/requirements.md b/docs/requirements.md new file mode 100644 index 0000000..1d3af8e --- /dev/null +++ b/docs/requirements.md @@ -0,0 +1,554 @@ +# Qwen3-TTS 多人对话功能需求文档 + +## 1. 功能概述 + +在现有 Qwen3-TTS WebUI 基础上,新增多人对话功能,支持音色复用,实现生动自然的多轮次、多角色、长篇章对话生成。 + +## 2. 核心需求 + +### 2.1 音色复用机制 +- 用户可将 Qwen3-TTS 创建的音色进行持久存储 +- 支持重复调用已保存的音色 +- 生成多轮次多角色对话 +- 保持音色的一致性和自然度 + +## 3. 数据模型设计 + +### 3.1 音色库(VoiceLibrary) +音色库用于持久化存储和管理用户创建的音色。 + +**核心属性:** +- 音色ID(唯一标识) +- 音色名称(用户自定义) +- 音色描述(可选) +- 音色类型:CustomVoice / VoiceDesign / VoiceClone +- 音色数据: + - CustomVoice: speaker名称 + - VoiceDesign: instruct指令 + - VoiceClone: 缓存的x_vector引用 +- 标签/分组(支持多标签) +- 示例音频路径(用于预览) +- 创建时间 +- 最后使用时间 +- 使用次数统计 +- 用户ID(用户隔离) + +**功能要求:** +- 基础CRUD操作(创建、读取、更新、删除) +- 音色预览:生成并播放示例音频 +- 标签/分组管理:支持按标签筛选和搜索 +- 支持批量操作(批量删除、批量导出) + +### 3.2 角色(Character) +角色代表对话中的发言人,绑定音色和控制指令。 + +**核心属性:** +- 角色ID +- 角色名称 +- 绑定音色(引用音色库ID 或 使用预定义音色) +- 默认控制指令(instruct) +- 角色描述/标签 +- 个性化显示: + - 头像/图标 + - 颜色标记(用于在对话中区分) +- 默认TTS参数: + - language + - max_new_tokens + - temperature + - top_k + - top_p + - repetition_penalty +- 创建时间 +- 最后使用时间 +- 用户ID(用户隔离) + +**功能要求:** +- 基础CRUD操作 +- 快速创建(从音色库选择音色) +- 删除前检查:如果角色被对话使用,需提示确认 +- 支持预览:使用角色设置生成示例音频 + +### 3.3 对话项目(Dialogue) +对话项目是多轮对话的容器。 + +**核心属性:** +- 对话ID +- 对话标题 +- 对话状态: + - draft(草稿) + - generating(生成中) + - completed(已完成) + - failed(失败) + - partial(部分成功) +- 生成模式: + - sequential(顺序生成) + - batch(批量生成) +- 音频合并配置: + - 是否自动合并 + - 间隔策略:intelligent(智能间隔) + - 合并后的音频路径 +- 对话轮数统计 +- 成功/失败数量统计 +- 创建时间 +- 更新时间 +- 完成时间 +- 用户ID(用户隔离) + +**功能要求:** +- 创建新对话 +- 编辑对话(标题、设置) +- 删除对话(级联删除所有对话行) +- 复制对话(作为模板) +- 导出对话: + - JSON格式(完整数据) + - CSV格式(角色、文本、指令) + - SRT字幕格式(时间轴 + 文本) + - 音频文件(分段或合并) + +### 3.4 对话行(DialogueLine) +对话行是单条对话内容。 + +**核心属性:** +- 对话行ID +- 所属对话ID(外键) +- 排序序号(支持拖拽调整) +- 关联角色ID(外键) +- 文本内容(1-1000字符) +- 控制指令覆盖(可选,覆盖角色默认指令) +- TTS参数覆盖(可选): + - language + - max_new_tokens + - temperature + - top_k + - top_p + - repetition_penalty +- 生成状态: + - pending(待生成) + - processing(生成中) + - completed(已完成) + - failed(失败) +- 输出音频路径 +- 音频时长(秒) +- 错误信息 +- 重试次数 +- 创建时间 +- 完成时间 + +**功能要求:** +- 添加对话行 +- 编辑对话行(文本、角色、指令) +- 删除对话行 +- 拖拽排序 +- 单条重试(失败时) +- 查看详细错误信息 + +## 4. 用户界面设计 + +### 4.1 页面布局 +**独立页面**:`/dialogues` 路由 + +**整体布局:** +``` +┌──────────────────────────────────────────────────────────┐ +│ Navbar (全局导航) │ +├─────────────────┬─────────────────────┬──────────────────┤ +│ 左侧边栏 │ 中间主内容区 │ 右侧面板(可折叠) │ +│ - 对话历史列表 │ - 对话编辑器 │ - 角色管理 │ +│ - 新建对话按钮 │ - 表格式编辑 │ - 音色库管理 │ +│ - 搜索/筛选 │ - 生成控制面板 │ │ +│ │ - 音频播放器 │ │ +└─────────────────┴─────────────────────┴──────────────────┘ +``` + +### 4.2 对话编辑器(表格式) +**表格列:** +1. 序号(支持拖拽手柄) +2. 角色选择(下拉菜单) +3. 文本输入(文本框,支持多行) +4. 指令覆盖(可选,点击展开) +5. 状态指示器(pending/processing/completed/failed) +6. 操作按钮: + - 删除行 + - 单条重试(失败时显示) + - 查看详情 + +**交互特性:** +- 支持拖拽排序 +- 快捷键: + - Enter: 添加新行 + - Ctrl+D: 删除当前行 + - Ctrl+↑/↓: 上下移动行 +- 实时保存(防丢失) + +### 4.3 生成控制面板 +**生成模式选择:** +- 顺序生成:按序生成,实时显示进度 +- 批量生成:一次性提交,后台处理 + +**生成控制按钮:** +- 开始生成 +- 暂停/继续(顺序模式) +- 取消生成 +- 合并音频(生成完成后) + +**进度显示:** +- 总体进度条 +- 当前生成的对话行高亮 +- 成功/失败数量统计 +- 预计剩余时间(顺序模式) + +### 4.4 音色库管理界面 +**列表视图:** +- 卡片式布局 +- 显示:名称、类型、标签、创建时间 +- 操作:编辑、删除、预览、复制 + +**创建/编辑表单:** +- 音色名称(必填) +- 音色类型选择(CustomVoice/VoiceDesign/VoiceClone) +- 类型特定参数: + - CustomVoice: 选择speaker + - VoiceDesign: 输入instruct + - VoiceClone: 上传参考音频 +- 音色描述(可选) +- 标签(多选或自定义) +- 生成示例音频(用于预览) + +**预览功能:** +- 点击预览按钮,使用默认文本生成示例音频 +- 播放示例音频 + +### 4.5 角色管理界面 +**列表视图:** +- 表格或卡片式 +- 显示:头像/颜色、名称、绑定音色、标签 +- 操作:编辑、删除、预览 + +**创建/编辑表单:** +- 角色名称(必填) +- 选择音色(从音色库或预定义) +- 默认控制指令(多行文本框) +- 个性化显示: + - 颜色选择器 + - 头像上传(可选) +- 默认TTS参数(高级选项,可折叠) +- 角色描述/标签 + +### 4.6 对话历史列表 +**显示内容:** +- 对话标题 +- 状态标签(draft/generating/completed/failed/partial) +- 对话轮数 +- 创建时间 +- 最后更新时间 + +**操作:** +- 打开编辑 +- 复制为新对话 +- 导出 +- 删除 + +**筛选和搜索:** +- 按状态筛选 +- 按创建时间排序 +- 关键词搜索(标题) + +## 5. 核心功能流程 + +### 5.1 音色库工作流程 +1. 用户创建音色(选择类型,输入参数) +2. 系统生成示例音频(用于预览) +3. 保存到音色库 +4. 用户可以预览、编辑、删除音色 +5. 创建角色时从音色库选择 + +### 5.2 角色创建工作流程 +1. 用户创建角色(输入名称) +2. 选择音色(从音色库或预定义) +3. 输入默认控制指令 +4. 设置个性化显示(颜色、头像) +5. 设置默认TTS参数(可选) +6. 保存角色 + +### 5.3 对话编辑和生成工作流程 + +**编辑阶段:** +1. 创建新对话(输入标题) +2. 添加对话行: + - 选择角色 + - 输入文本 + - 可选:覆盖控制指令 + - 可选:覆盖TTS参数 +3. 拖拽调整顺序 +4. 实时保存草稿 + +**生成阶段:** + +**顺序生成模式:** +1. 用户点击"开始生成" +2. 系统按序处理每条对话行: + - 标记为 processing + - 调用 TTS API(根据角色配置和覆盖参数) + - 生成音频文件 + - 标记为 completed 或 failed + - 实时更新前端进度 +3. 用户可以: + - 实时查看进度和结果 + - 暂停/继续生成 + - 取消生成 +4. 遇到失败项: + - 显示错误信息 + - 提示用户选择:重试/跳过/取消 +5. 全部完成后: + - 显示统计信息(成功/失败) + - 提供"合并音频"选项 + +**批量生成模式:** +1. 用户点击"批量生成" +2. 系统创建后台任务 +3. 后台按序处理每条对话行 +4. 遇到失败项自动跳过 +5. 完成后通知用户 +6. 用户查看结果,对失败项进行单条重试 + +### 5.4 音频合并工作流程 +1. 生成完成后,用户点击"合并音频" +2. 系统读取所有成功的音频片段 +3. 应用智能间隔策略: + - 根据文本长度计算间隔 + - 根据情感变化调整间隔(可选) + - 默认间隔:0.5-1秒 +4. 拼接所有音频片段 +5. 保存合并后的完整音频 +6. 提供下载链接 + +### 5.5 错误处理和重试机制 +**错误类型:** +- 文本验证失败 +- 模型推理失败 +- GPU内存不足 +- 音频生成失败 + +**处理策略:** +1. 显示详细错误信息 +2. 提供单条重试按钮 +3. 记录重试次数 +4. 顺序生成:手动干预(重试/跳过/取消) +5. 批量生成:自动跳过失败项,记录错误 + +### 5.6 历史记录管理 +**查看历史:** +- 列表显示所有对话项目 +- 显示状态、轮数、创建时间 +- 支持搜索和筛选 + +**编辑和重新生成:** +1. 打开历史对话 +2. 编辑对话行(文本、角色、指令) +3. 选择重新生成: + - 单条重新生成 + - 全部重新生成 + - 从某一行开始重新生成 +4. 保持已成功的音频,只生成修改的部分 + +**复制为模板:** +1. 选择已有对话 +2. 点击"复制为新对话" +3. 系统创建新对话,复制所有对话行 +4. 清除生成状态和音频路径 +5. 用户可以修改后重新生成 + +**导出功能:** +- JSON格式:完整数据(角色、文本、指令、参数) +- CSV格式:角色,文本,指令(用于批量导入) +- SRT字幕格式:时间轴 + 角色 + 文本 +- 音频文件:打包所有音频(分段或合并) + +## 6. 技术规格 + +### 6.1 数据权限 +- 所有数据(音色库、角色、对话)按用户隔离 +- 每个用户只能访问自己创建的数据 +- 与现有 Job 系统的权限模型保持一致 + +### 6.2 性能限制 +- 单个对话支持 1-200 轮对话 +- 音频文件命名:`dialogue_{dialogue_id}_line_{line_id}_{timestamp}.wav` +- 合并音频命名:`dialogue_{dialogue_id}_merged_{timestamp}.wav` +- 存储路径:`./outputs/dialogues/` + +### 6.3 音频处理 +**智能间隔计算:** +``` +基础间隔:0.5秒 +调整因子: +- 短文本(<20字符):-0.2秒 +- 长文本(>100字符):+0.3秒 +- 同一角色连续对话:-0.1秒 +- 不同角色切换:+0.1秒 +- 最小间隔:0.3秒 +- 最大间隔:2.0秒 +``` + +**音频拼接:** +- 使用 pydub 或 ffmpeg +- 保持采样率一致(24000 Hz) +- 无缝拼接(避免爆音) + +### 6.4 并发控制 +- 同一用户同时只能有一个对话在生成中 +- 顺序生成:支持暂停/继续/取消 +- 批量生成:后台异步处理,不阻塞前端 + +### 6.5 缓存机制 +- 复用现有的 VoiceCacheManager +- 对于 VoiceClone 类型的音色,缓存 x_vector +- 减少重复的特征提取操作 + +## 7. 用户示例参考 + +用户提供的对话示例格式: + +**角色定义(控制指令):** +``` +"旁白": "声音特征沉稳、客观、略带叙事感的女播音腔,普通话标准,语速适中,带有轻微的环境氛围渲染,语调平缓但富有感染力,在关键情节时稍作停顿,增强画面感。情感冷静旁观,偶尔带一丝微妙的反讽" + +"小林": "25岁男性上班族,声音清亮但时常犹豫,语速时快时慢,紧张时会轻微结巴。情绪波动明显,从低声呢喃到突然激动再到自我怀疑的叹气。肢体语言丰富,经常无意识的小动作" + +"御姐": "模拟成熟性感的御姐音色,声音略带磁性且沉稳,语速不快不慢,语调充满自信和一丝挑逗,尾音可以稍微拖长并上扬,给人一种游刃有余的掌控感。" +``` + +**对话格式:** +``` +旁白: 小林今天第三次走神了。酒吧昏黄的灯光晃得他心跳加速,而吧台对面那个红唇微扬的女人,正用指尖轻轻摩挲着酒杯边缘。 +御姐: 小弟弟,有兴趣陪姐姐喝一杯吗? +小林: 啊?我、我……我其实不太会喝酒…… +旁白: 他的手指无意识地抠着杯沿,喉结上下滚动,像被什么无形的东西掐住了呼吸。 +御姐: 不会喝?那正好——姐姐教你。这杯莫吉托,甜得刚好,就像你刚才偷看我的眼神。 +小林: 我、我没偷看!……好吧,看了一眼。就一眼! +... +``` + +**系统支持:** +- 用户可以导入此类格式的文本(纯文本解析) +- 系统自动识别角色名和对话内容 +- 自动创建角色(如果不存在) +- 生成对话行 + +## 8. 非功能性需求 + +### 8.1 性能要求 +- 对话列表加载时间 < 1秒 +- 单条对话生成平均时间:根据模型推理速度 +- 音频合并时间 < 5秒(200条以内) +- 支持 1000+ 对话项目不卡顿 + +### 8.2 可用性要求 +- 直观的表格编辑界面 +- 实时保存,防止数据丢失 +- 清晰的状态指示和错误提示 +- 支持键盘快捷键 +- 响应式设计,支持大屏编辑 + +### 8.3 可扩展性 +- 未来支持更多生成模式(并行生成) +- 支持更多导出格式 +- 支持批量导入对话脚本 +- 支持对话版本控制(可选) + +### 8.4 兼容性 +- 与现有系统无缝集成 +- 复用现有认证、任务队列、缓存机制 +- 不影响现有功能 + +## 9. 实现优先级 + +### 9.1 必需功能(首期实现) +- [ ] 音色库基础CRUD +- [ ] 角色管理(创建、编辑、删除) +- [ ] 对话编辑器(表格式,拖拽排序) +- [ ] 顺序生成 + 实时进度显示 +- [ ] 分段音频生成 +- [ ] 音频合并(智能间隔) +- [ ] 对话历史列表 +- [ ] 单条重试机制 +- [ ] 错误显示和手动干预 + +### 9.2 重要功能(后续补充) +- [ ] 批量生成模式 +- [ ] 音色预览功能 +- [ ] 标签/分组管理 +- [ ] 编辑和重新生成 +- [ ] 复制为模板 +- [ ] 导出功能(JSON/CSV/SRT) + +### 9.3 可选功能(未来扩展) +- [ ] 纯文本导入解析 +- [ ] 内置对话模板 +- [ ] 批量导入对话 +- [ ] 对话版本控制 +- [ ] 音色分享功能 +- [ ] 协作编辑 + +## 10. 验收标准 + +### 10.1 功能验收 +- [ ] 用户可以创建和管理音色库 +- [ ] 用户可以创建和管理角色 +- [ ] 用户可以使用表格编辑器创建对话 +- [ ] 用户可以选择顺序生成模式,实时查看进度 +- [ ] 系统能够正确处理失败项(显示错误、支持重试) +- [ ] 用户可以合并音频,生成完整对话音频 +- [ ] 用户可以查看历史对话,并进行编辑/重生成 +- [ ] 所有数据按用户隔离,权限正确 + +### 10.2 性能验收 +- [ ] 支持至少 200 轮对话的编辑和生成 +- [ ] 对话列表加载流畅(< 1秒) +- [ ] 音频合并速度快(< 5秒) + +### 10.3 用户体验验收 +- [ ] 界面直观,易于操作 +- [ ] 实时保存,数据不丢失 +- [ ] 错误提示清晰,易于理解 +- [ ] 支持键盘快捷键,提高效率 + +## 11. 项目背景信息 + +### 11.1 现有架构 +- 前端:React 19 + TypeScript + Vite + Tailwind CSS + Shadcn/ui +- 后端:FastAPI + SQLAlchemy + SQLite +- 认证:JWT +- 任务处理:FastAPI BackgroundTasks + APScheduler + +### 11.2 现有数据模型 +- User(用户) +- Job(任务) +- VoiceCache(音色缓存) + +### 11.3 现有功能 +- CustomVoice:使用预定义音色合成 +- VoiceDesign:使用风格描述合成 +- VoiceClone:克隆参考音色合成 +- 用户管理(超管功能) +- 任务历史记录 +- 音色缓存管理 + +### 11.4 主要文件路径 +**后端:** +- `/home/bdim/Documents/github/Qwen3-TTS/qwen3-tts-backend/` + - `main.py` - 应用入口 + - `db/models.py` - 数据模型 + - `db/crud.py` - 数据库操作 + - `api/` - API路由 + - `core/` - 核心功能模块 + +**前端:** +- `/home/bdim/Documents/github/Qwen3-TTS/qwen3-tts-frontend/` + - `src/pages/` - 页面组件 + - `src/components/` - 可复用组件 + - `src/contexts/` - 状态管理 + - `src/lib/api.ts` - API客户端 + - `src/types/` - TypeScript类型定义 + +---