فهرست منبع

docs+frontend: Instruct动态场景检测 - 文档+前端展示+进度日志

- 前端 create/index.vue: 展示场景/身份/角色信息
- docs/Instruct动态场景检测功能说明.md: 完整功能文档
- agent-progress.txt: 更新进度日志
MyFramework User 2 ماه پیش
والد
کامیت
ba4c8307dd
3فایلهای تغییر یافته به همراه156 افزوده شده و 0 حذف شده
  1. 38 0
      agent-progress.txt
  2. 90 0
      docs/Instruct动态场景检测功能说明.md
  3. 28 0
      my-uniapp-vue3/src/pages/create/index.vue

+ 38 - 0
agent-progress.txt

@@ -418,3 +418,41 @@ curl -X PUT http://localhost:3000/api/player/audio/1/public -H "Content-Type: ap
 
 【调价方式】
 只需修改 BILLING 中的 costPerMinute 或 coefficients 即可全局生效
+
+========================================
+=== 2026-06-14 Instruct 动态场景检测 ===
+========================================
+
+【背景】
+之前 CosyVoice Instruct 场景硬编码在音色上,全文同一 instruct,
+无法利用官方 7 个场景和身份/角色能力。
+
+【改动内容】
+1. detectScene() - 7 场景关键词匹配,按文本内容动态选最佳场景
+2. detectContentType() - 综合检测 场景 + 角色("一个旁白") + 身份("故事机")
+3. generateSegmentInstruct() - 逐段独立生成 Instruct,不再绑定 Voice 对象
+4. generateInstruct() 重构 - 接受独立 InstructParams,优先级: 身份 > 角色 > 场景 > 情感
+5. processAudioGeneration - 每段文本独立分析 → 逐段不同 Instruct
+6. 控制器透出 scene/role/identity 字段
+7. 前端展示场景/身份/角色信息
+
+【后端测试结果(全部通过)】
+✅ 故事类 → 故事机身份: "你正在以一个故事机的身份说话,你说话的情感是happy。"
+✅ 新闻类 → 新闻播报: scene=新闻播报
+✅ 比赛类 → 比赛解说: scene=比赛解说
+✅ 广告类 → 广告促销: scene=广告促销
+✅ 脱口秀类 → 脱口秀表演: scene=脱口秀表演
+✅ 导航类 → 语音导航: scene=语音导航
+✅ 闲聊类 → 闲聊互动(兜底): scene=闲聊互动
+✅ 逐段Instruct: 故事段→故事机身份,覆盖音色预设
+
+【前端 Playwright 测试结果(全部通过)】
+✅ 故事类: 显示 场景=一些儿童内容解说 + 身份=故事机
+✅ 新闻类: 显示 场景=新闻播报
+✅ 比赛类: 显示 场景=比赛解说
+
+【文档】
+docs/Instruct动态场景检测功能说明.md
+
+【Git】
+commit: df6b3381 feat: Instruct 动态场景检测 - 逐段内容匹配场景/角色/身份+情感

+ 90 - 0
docs/Instruct动态场景检测功能说明.md

@@ -0,0 +1,90 @@
+# Instruct 动态场景检测功能说明
+
+> 版本:2026-06-14 | 关联提交:`df6b3381`
+
+## 功能概述
+
+利用 CosyVoice 官方 Instruct 指令的 4 层能力(身份 / 角色 / 场景 / 情感),根据文本内容**动态匹配最佳指令**,替代之前"音色硬编码单一场景"的粗放模式。
+
+## 4 层 Instruct 能力(优先级从高到低)
+
+| 层级 | 格式 | 示例 | 适用内容 |
+|------|------|------|----------|
+| 身份 | `你正在以一个{身份}的身份说话,你说话的情感是{情感}。` | 故事机 | 童话、儿童故事 |
+| 角色 | `你现在说话的角色是{角色},你说话的情感是{情感}。` | 一个旁白 | 小说、叙事类 |
+| 场景 | `你正在进行{场景},你说话的情感是{情感}。` | 新闻播报 | 新闻、广告、解说等 |
+| 仅情感 | `你说话的情感是{情感}。` | happy | 任意无场景匹配的文本 |
+
+## 7 个官方场景 + 关键词特征
+
+| 场景 | 触发关键词示例 |
+|------|---------------|
+| **新闻播报** | 据报道、记者从、新华社、数据显示、今日头条 |
+| **广告促销** | 限时、特价、优惠、折扣、立即购买、扫码 |
+| **比赛解说** | 比赛、进球、得分、冠军、绝杀、裁判 |
+| **一些儿童内容解说** | 小朋友、小熊、童话、魔法、幼儿园 |
+| **语音导航** | 前方、左转、路况、出口、公里 |
+| **脱口秀表演** | 哈哈、段子、吐槽、笑死、你知道吗 |
+| **闲聊互动** | 你好、今天、觉得、怎么样(默认兜底) |
+
+## 角色 / 身份检测
+
+### "一个旁白"(角色)
+- 触发条件:文本含 "第一章"、"很久很久以前"、"从前"等叙事标记
+- 效果:旁白朗讀模式,提升叙事感的自然度
+
+### "故事机"(身份)
+- 触发条件:文本含 "童话"、"小熊"、"魔法"、"小朋友"等儿童内容关键词
+- **优先级高于角色**:检测到身份时不使用角色
+
+## 逐段独立 Instruct
+
+生成音频时,每段文本(≤1000字)**独立分析**,动态生成不同的 Instruct:
+- 第1段是新闻 → "你正在进行新闻播报"
+- 第2段是比赛 → "你正在进行比赛解说"
+- 第3段是故事 → "你正在以一个故事机的身份说话"
+
+## 前端展示
+
+生成页面(`/pages/create/index`)"内容分析"卡片显示:
+- 🎭 **情感**:检测到的情绪(😊快乐、😢悲伤…)
+- 🎤 **推荐音色**:匹配的音色名称
+- 🏷️ **场景**:匹配的场景类型(如"新闻播报")
+- 👤 **身份**:如检测到儿童内容则显示"故事机"
+- 🎭 **角色**:如检测到叙事文本则显示"一个旁白"
+
+## API 接口
+
+### `POST /api/tts/detect-emotion`
+
+响应新增字段:
+```json
+{
+  "data": {
+    "emotion": "happy",
+    "scene": "一些儿童内容解说",
+    "role": null,
+    "identity": "故事机"
+  }
+}
+```
+
+## 核心代码文件
+
+| 文件 | 改动 |
+|------|------|
+| `server/src/modules/tts/tts.service.ts` | `detectScene()`、`detectContentType()`、`generateSegmentInstruct()` |
+| `server/src/modules/tts/tts.controller.ts` | detect-emotion 返回 scene/role/identity |
+| `my-uniapp-vue3/src/pages/create/index.vue` | 前端展示场景/身份/角色信息 |
+
+## 测试结果(2026-06-14)
+
+| 测试内容 | 后端 detect-emotion | 逐段 Instruct | 前端显示 |
+|----------|---------------------|---------------|----------|
+| 故事类 | ✅ scene=儿童内容解说 identity=故事机 | ✅ 故事机身份 | ✅ 场景+身份正确 |
+| 新闻类 | ✅ scene=新闻播报 | N/A(单段) | ✅ 场景正确 |
+| 比赛类 | ✅ scene=比赛解说 | ✅ 比赛解说 | ✅ 场景正确 |
+| 广告类 | ✅ scene=广告促销 | N/A | N/A |
+| 脱口秀 | ✅ scene=脱口秀表演 | N/A | N/A |
+| 导航类 | ✅ scene=语音导航 | N/A | N/A |
+| 闲聊类 | ✅ scene=闲聊互动(兜底) | N/A | N/A |

+ 28 - 0
my-uniapp-vue3/src/pages/create/index.vue

@@ -74,6 +74,18 @@
           <text class="emotion-label">推荐音色</text>
           <text class="emotion-value voice-value">{{ detectedVoiceName }}</text>
         </view>
+        <view v-if="detectedScene" class="emotion-row">
+          <text class="emotion-label">场景</text>
+          <text class="emotion-value scene-value">{{ detectedScene }}</text>
+        </view>
+        <view v-if="detectedIdentity" class="emotion-row">
+          <text class="emotion-label">身份</text>
+          <text class="emotion-value identity-value">{{ detectedIdentity }}</text>
+        </view>
+        <view v-if="detectedRole" class="emotion-row">
+          <text class="emotion-label">角色</text>
+          <text class="emotion-value role-value">{{ detectedRole }}</text>
+        </view>
 
         <!-- 情感覆盖选择器 -->
         <view class="emotion-override">
@@ -100,6 +112,10 @@
           <text class="emotion-label">情感</text>
           <text class="emotion-value emotion-fallback">{{ detectedEmotionLabel || '中性' }}</text>
         </view>
+        <view v-if="detectedScene" class="emotion-row">
+          <text class="emotion-label">场景</text>
+          <text class="emotion-value scene-value">{{ detectedScene }}</text>
+        </view>
         <text class="emotion-hint">未匹配到特定内容类型,使用通用音色</text>
 
         <!-- 情感覆盖选择器 -->
@@ -333,6 +349,9 @@ const detecting = ref(false);
 const detectedEmotion = ref(false);
 const detectedEmotionLabel = ref('');
 const detectedVoiceName = ref('');
+const detectedScene = ref('');      // 检测到的场景
+const detectedRole = ref('');       // 检测到的角色
+const detectedIdentity = ref('');   // 检测到的身份
 const emotionDetectAttempted = ref(false);
 const selectedEmotion = ref(''); // 用户手动选择的情感,空=不覆盖
 
@@ -373,10 +392,16 @@ function debouncedDetect() {
         emotion?: string;
         emotionLabel?: string;
         hint?: string;
+        scene?: string;
+        role?: string | null;
+        identity?: string | null;
       }>('/tts/detect-emotion', { text: text.value });
       detectedEmotion.value = res.detected || false;
       detectedEmotionLabel.value = res.emotionLabel || '';
       detectedVoiceName.value = res.voiceName || '';
+      detectedScene.value = res.scene || '';
+      detectedRole.value = res.role || '';
+      detectedIdentity.value = res.identity || '';
       if (res.emotion && !selectedEmotion.value) {
         // 首次检测到情感时,自动选中(但不覆盖用户手动选择)
       }
@@ -626,6 +651,9 @@ function goToMember() { showQuotaModal.value = false; uni.navigateTo({ url: '/pa
 .emotion-value { font-size: 26rpx; font-weight: 600; color: #1f2937; }
 .emotion-value.voice-value { color: #4f46e5; }
 .emotion-value.emotion-fallback { color: #9ca3af; }
+.emotion-value.scene-value { color: #0891b2; }
+.emotion-value.identity-value { color: #7c3aed; }
+.emotion-value.role-value { color: #059669; }
 .emotion-hint { font-size: 20rpx; color: #9ca3af; margin-top: 4rpx; }
 
 /* 情感覆盖选择器 */