Просмотр исходного кода

feat: Instruct 动态场景检测 - 逐段内容匹配场景/角色/身份+情感

核心改进:
1. detectScene() - 7场景关键词匹配,按内容动态选择最佳场景
2. detectContentType() - 综合检测场景+角色(一个旁白)+身份(故事机)
3. generateSegmentInstruct() - 逐段独立生成Instruct
4. processAudioGeneration - 每段文本独立分析内容→动态Instruct

验证结果:
- 故事类→故事机身份「你正在以一个故事机的身份说话,你说话的情感是happy。」
- 新闻类→新闻播报「你正在进行新闻播报,你说话的情感是neutral。」
- 比赛类→比赛解说「你正在进行比赛解说,你说话的情感是neutral。」
- 广告类→广告促销「你正在进行广告促销,你说话的情感是neutral。」
- 脱口秀→脱口秀表演「你正在进行脱口秀表演,你说话的情感是surprised。」
MyFramework User 2 месяцев назад
Родитель
Сommit
df6b33813a
2 измененных файлов с 213 добавлено и 24 удалено
  1. 9 0
      server/src/modules/tts/tts.controller.ts
  2. 204 24
      server/src/modules/tts/tts.service.ts

+ 9 - 0
server/src/modules/tts/tts.controller.ts

@@ -46,6 +46,9 @@ router.post('/detect-emotion', async (ctx: Context) => {
   }
   }
 
 
   const detected = TtsService.detectBestVoiceAndEmotion(text);
   const detected = TtsService.detectBestVoiceAndEmotion(text);
+  // 内容类型检测(场景/角色/身份)
+  const contentType = TtsService.detectContentType(text);
+
   if (!detected) {
   if (!detected) {
     // 兜底:至少返回情感检测结果
     // 兜底:至少返回情感检测结果
     const emotion = TtsService.detectEmotion(text);
     const emotion = TtsService.detectEmotion(text);
@@ -56,6 +59,9 @@ router.post('/detect-emotion', async (ctx: Context) => {
         detected: false,
         detected: false,
         emotion,
         emotion,
         emotionLabel: TtsService.EMOTION_LABELS[emotion] || '中性',
         emotionLabel: TtsService.EMOTION_LABELS[emotion] || '中性',
+        scene: contentType.scene,
+        role: contentType.role || null,
+        identity: contentType.identity || null,
         hint: '未匹配到特定内容类型,使用通用情感',
         hint: '未匹配到特定内容类型,使用通用情感',
       },
       },
     };
     };
@@ -74,6 +80,9 @@ router.post('/detect-emotion', async (ctx: Context) => {
       emotion: detected.cosyEmotion,
       emotion: detected.cosyEmotion,
       emotionLabel: TtsService.EMOTION_LABELS[detected.cosyEmotion] || detected.cosyEmotion,
       emotionLabel: TtsService.EMOTION_LABELS[detected.cosyEmotion] || detected.cosyEmotion,
       instructText: detected.instructText,
       instructText: detected.instructText,
+      scene: contentType.scene,
+      role: contentType.role || null,
+      identity: contentType.identity || null,
     },
     },
   };
   };
 });
 });

+ 204 - 24
server/src/modules/tts/tts.service.ts

@@ -25,23 +25,35 @@ function normalizeEmotion(emotion?: string): string {
   return emotion && valid.includes(emotion) ? emotion : 'neutral';
   return emotion && valid.includes(emotion) ? emotion : 'neutral';
 }
 }
 
 
-/** 生成 CosyVoice 格式的最终 Instruct 指令文本 */
-function generateInstruct(voice: Voice): string | undefined {
-  if (!voice.cosyEmotion && !voice.scene && !voice.role && !voice.identity) {
-    return undefined;
-  }
-  const emotion = normalizeEmotion(voice.cosyEmotion);
+/** Instruct 参数(独立于 Voice 对象,支持逐段动态组装) */
+interface InstructParams {
+  identity?: string;   // 故事机
+  role?: string;       // 一个旁白
+  scene?: string;      // 闲聊互动 / 新闻播报 / ... 
+  emotion?: string;    // happy / sad / ...
+}
 
 
-  if (voice.identity) {
-    return `你正在以一个${voice.identity}的身份说话,你说话的情感是${emotion}。`;
+/**
+ * 生成 CosyVoice 格式的 Instruct 指令文本
+ * 优先级:身份 > 角色 > 场景 > 仅情感
+ * 接受独立参数,不再依赖 Voice 对象
+ */
+function generateInstruct(params: InstructParams): string | undefined {
+  const emotion = normalizeEmotion(params.emotion);
+
+  if (params.identity) {
+    return `你正在以一个${params.identity}的身份说话,你说话的情感是${emotion}。`;
+  }
+  if (params.role) {
+    return `你现在说话的角色是${params.role},你说话的情感是${emotion}。`;
   }
   }
-  if (voice.role) {
-    return `你现在说话的角色是${voice.role},你说话的情感是${emotion}。`;
+  if (params.scene) {
+    return `你正在进行${params.scene},你说话的情感是${emotion}。`;
   }
   }
-  if (voice.scene) {
-    return `你正在进行${voice.scene},你说话的情感是${emotion}。`;
+  if (params.emotion) {
+    return `你说话的情感是${emotion}。`;
   }
   }
-  return `你说话的情感是${emotion}。`;
+  return undefined;
 }
 }
 
 
 /** 仅情感指令(无场景/角色/身份时使用) */
 /** 仅情感指令(无场景/角色/身份时使用) */
@@ -49,6 +61,35 @@ function emotionOnlyInstruct(emotion: string): string {
   return `你说话的情感是${normalizeEmotion(emotion)}。`;
   return `你说话的情感是${normalizeEmotion(emotion)}。`;
 }
 }
 
 
+/**
+ * 从 Voice 对象提取 Instruct 参数(兼容旧调用,如音色预设场景+情感)
+ */
+function voiceToInstructParams(voice: Voice, overrideEmotion?: string): InstructParams {
+  return {
+    identity: voice.identity,
+    role: voice.role,
+    scene: voice.scene,
+    emotion: overrideEmotion || voice.cosyEmotion,
+  };
+}
+
+/**
+ * 为单个文本段生成最佳 Instruct 指令
+ * 核心"妙用":根据段落内容 → 动态匹配场景/角色/身份 + 情感
+ */
+function generateSegmentInstruct(segmentText: string, voice: Voice): string | undefined {
+  const contentType = detectContentType(segmentText);
+  const emotion = detectEmotion(segmentText);
+
+  // 组装 Instruct 参数:内容检测结果 优先于 音色预设
+  return generateInstruct({
+    identity: contentType.identity || voice.identity,
+    role: contentType.role || voice.role,
+    scene: contentType.scene || voice.scene,
+    emotion: emotion !== 'neutral' ? emotion : (voice.cosyEmotion || 'neutral'),
+  });
+}
+
 // ============ 统一音色定义(22个音色,10基础 + 12个情感变体)============
 // ============ 统一音色定义(22个音色,10基础 + 12个情感变体)============
 // 6个支持 Instruct 的基础音色各扩展 2 个情感变体 → 10 + 12 = 22
 // 6个支持 Instruct 的基础音色各扩展 2 个情感变体 → 10 + 12 = 22
 // 每条音色包含 7 个维度描述 + 官方 Instruct 场景,instructText 由 generateInstruct 自动生成
 // 每条音色包含 7 个维度描述 + 官方 Instruct 场景,instructText 由 generateInstruct 自动生成
@@ -237,7 +278,7 @@ function getVoiceInstructById(voiceId: string): string {
   if (!voice) return '';
   if (!voice) return '';
   // 显式 instructText 优先(向后兼容),否则从维度自动生成
   // 显式 instructText 优先(向后兼容),否则从维度自动生成
   if (voice.instructText) return voice.instructText;
   if (voice.instructText) return voice.instructText;
-  return generateInstruct(voice) || '';
+  return generateInstruct(voiceToInstructParams(voice)) || '';
 }
 }
 
 
 function mapToProviderVoice(unifiedVoiceId: string, providerVendor: string): string {
 function mapToProviderVoice(unifiedVoiceId: string, providerVendor: string): string {
@@ -399,14 +440,17 @@ export function detectBestVoiceAndEmotion(text: string): { voiceId: string; cosy
   if (!best || best.score < 2) return null; // 置信度太低,不使用自动选择
   if (!best || best.score < 2) return null; // 置信度太低,不使用自动选择
 
 
   console.log(`🔍 [AutoDetect] 内容分析: 最佳音色=${best.voiceId}, 情感=${best.emotion}, 匹配度=${best.score}`);
   console.log(`🔍 [AutoDetect] 内容分析: 最佳音色=${best.voiceId}, 情感=${best.emotion}, 匹配度=${best.score}`);
-  // 基于音色维度 + 检测的情感生成官方 Instruct 指令
+  // 基于音色维度 + 检测的情感 + 内容检测的场景/角色/身份 → 生成官方 Instruct
   const baseVoice = UNIFIED_VOICES.find(v => v.id === best!.voiceId);
   const baseVoice = UNIFIED_VOICES.find(v => v.id === best!.voiceId);
   const cosyEmotion = best!.emotion !== 'neutral' ? best!.emotion : undefined;
   const cosyEmotion = best!.emotion !== 'neutral' ? best!.emotion : undefined;
-  const enrichedVoice: Voice = {
-    ...(baseVoice || { id: best!.voiceId, gender: 'female' as const, name: '', description: '' }),
-    cosyEmotion,
-  };
-  const instructText = generateInstruct(enrichedVoice);
+  // 内容检测:场景/角色/身份 优先于音色预设
+  const contentType = detectContentType(text);
+  const instructText = generateInstruct({
+    identity: contentType.identity || baseVoice?.identity,
+    role: contentType.role || baseVoice?.role,
+    scene: contentType.scene || baseVoice?.scene,
+    emotion: cosyEmotion,
+  });
   return {
   return {
     voiceId: best.voiceId,
     voiceId: best.voiceId,
     cosyEmotion: cosyEmotion || 'neutral',
     cosyEmotion: cosyEmotion || 'neutral',
@@ -417,19 +461,139 @@ export function detectBestVoiceAndEmotion(text: string): { voiceId: string; cosy
 /** 根据文本内容 + 音色维度动态生成 CosyVoice Instruct 文本(多维度兜底) */
 /** 根据文本内容 + 音色维度动态生成 CosyVoice Instruct 文本(多维度兜底) */
 export function getVoiceInstruct(text: string, voiceId?: string): string {
 export function getVoiceInstruct(text: string, voiceId?: string): string {
   const emotion = detectEmotion(text);
   const emotion = detectEmotion(text);
-  // 如果有音色维度数据,基于场景+情感生成官方 Instruct 指令
+  const contentType = detectContentType(text);
+  // 如果有音色维度数据,基于场景+角色/身份+情感+内容检测生成官方 Instruct
   if (voiceId) {
   if (voiceId) {
     const baseVoice = UNIFIED_VOICES.find(v => v.id === voiceId) || UNIFIED_VOICES.find(v => v.id === getBaseVoiceId(voiceId));
     const baseVoice = UNIFIED_VOICES.find(v => v.id === voiceId) || UNIFIED_VOICES.find(v => v.id === getBaseVoiceId(voiceId));
     if (baseVoice) {
     if (baseVoice) {
-      const enriched: Voice = { ...baseVoice, cosyEmotion: emotion !== 'neutral' ? emotion : baseVoice.cosyEmotion };
-      const instruct = generateInstruct(enriched);
+      const instruct = generateInstruct({
+        identity: contentType.identity || baseVoice.identity,
+        role: contentType.role || baseVoice.role,
+        scene: contentType.scene || baseVoice.scene,
+        emotion: emotion !== 'neutral' ? emotion : baseVoice.cosyEmotion,
+      });
       if (instruct) return instruct;
       if (instruct) return instruct;
     }
     }
   }
   }
+  // 即使无音色,也能用内容检测的结果
+  const instruct = generateInstruct({
+    identity: contentType.identity,
+    role: contentType.role,
+    scene: contentType.scene,
+    emotion: emotion,
+  });
+  if (instruct) return instruct;
   // 纯兜底:仅情感指令
   // 纯兜底:仅情感指令
   return emotionOnlyInstruct(emotion);
   return emotionOnlyInstruct(emotion);
 }
 }
 
 
+// ============ 官方 Instruct 支持的 7 个场景 ============
+const OFFICIAL_SCENES = ['闲聊互动', '新闻播报', '广告促销', '比赛解说', '一些儿童内容解说', '语音导航', '脱口秀表演'] as const;
+type OfficialScene = typeof OFFICIAL_SCENES[number];
+
+/** 每个场景的关键词特征(用于文本内容匹配) */
+const SCENE_KEYWORDS: Record<OfficialScene, string[]> = {
+  '新闻播报': ['报道', '据新华社', '记者从', '新闻', '发布会', '宣布', '声明', '公告', '据悉', '数据显示',
+    '同比增长', '环比', '股市', '大盘', '指数', '涨停', '跌停', '基金', '央行', '证监会',
+    'GDP', 'CPI', 'PMI', '最新消息', '今日头条', '记者'],
+  '广告促销': ['限时', '特价', '优惠', '折扣', '促销', '抢购', '秒杀', '满减', '包邮', '免费',
+    '送', '赠品', '立即购买', '点击', '扫码', '关注', '领券', '立减', '立省', '错过'],
+  '比赛解说': ['比赛', '决赛', '半决赛', '进球', '得分', '比分', '裁判', '运动员', '选手', '冠军',
+    '亚军', '季军', '破纪录', '赛点', '开局', '下半场', '绝杀', '扣篮', '冲刺', '夺冠'],
+  '一些儿童内容解说': ['小朋友', '宝宝', '小熊', '小兔', '公主', '童话', '魔法', '森林', '幼儿园',
+    '儿歌', '故事', '小动物', '小猪', '小猫咪', '小鸭子', '小王子', '小白兔', '小鹿', '小象',
+    '小老鼠', '玩耍', '玩具', '糖果', '彩虹', '星星', '月亮'],
+  '语音导航': ['前方', '左转', '右转', '直行', '掉头', '路口', '红绿灯', '高速', '出口', '入口',
+    '公里', '米后', '到达', '目的地', '导航', '路线', '限速', '拥堵', '收费站', '服务区'],
+  '脱口秀表演': ['哈哈', '搞笑', '段子', '吐槽', '梗', '笑死', '绝了', '离谱', '居然', '没想到',
+    '你知道吗', '听说过吗', '猜猜', '告诉你个秘密', '绝绝子', '家人们', '老铁', '笑点'],
+  '闲聊互动': ['你好', '嗨', '嘿', '今天', '天气', '吃饭', '睡觉', '觉得', '感觉', '想',
+    '喜欢', '讨厌', '开心', '难过', '怎么样', '能不能', '有没有', '对吧', '是吧', '嗯'],
+};
+
+/** 故事类内容标识(适合使用"一个旁白"角色模式) */
+const NARRATION_MARKERS = [
+  '从前', '很久很久以前', '话说', '据说', '传说', '在遥远的', '古代', '很久以前',
+  '第一章', '第1章', '引子', '序章', '第一章', '第二', '第三', '第四', '第五',
+  '忽然', '突然', '这时候', '与此同时', '就在此时', '话音未落',
+];
+
+/** 儿童内容标识(适合使用"故事机"身份模式) */
+const STORYTELLER_MARKERS = [
+  '童话', '儿童', '小朋友', '宝宝', '睡前故事', '寓言', '儿歌', '童谣', '绘本',
+  '魔法', '小动物', '公主', '王子', '小熊', '小兔', '幼儿园', '讲故事',
+];
+
+/**
+ * 根据文本内容动态检测最佳 Instruct 场景
+ * 策略:对7个场景各计算关键词命中率,取最高分场景
+ * 无匹配时降级为"闲聊互动"
+ */
+export function detectScene(text: string): OfficialScene {
+  if (!text || text.length < 5) return '闲聊互动';
+
+  const normalized = text.toLowerCase();
+  let bestScene: OfficialScene = '闲聊互动';
+  let bestScore = 0;
+
+  for (const scene of OFFICIAL_SCENES) {
+    const keywords = SCENE_KEYWORDS[scene];
+    let score = 0;
+    for (const kw of keywords) {
+      // 简单包含匹配,计算命中次数
+      let idx = -1;
+      while ((idx = normalized.indexOf(kw.toLowerCase(), idx + 1)) !== -1) {
+        score++;
+      }
+    }
+    // 归一化:命中词数 / 关键词总数
+    const normalizedScore = score / keywords.length;
+    if (normalizedScore > bestScore) {
+      bestScore = normalizedScore;
+      bestScene = scene;
+    }
+  }
+
+  return bestScene;
+}
+
+/**
+ * 综合检测文本内容类型(场景 + 角色 + 身份)
+ * 返回最适合该段文本的 Instruct 组合
+ */
+export function detectContentType(text: string): {
+  scene: OfficialScene;
+  role?: string;      // "一个旁白" 或 undefined
+  identity?: string;  // "故事机" 或 undefined
+} {
+  const scene = detectScene(text);
+  const result: { scene: OfficialScene; role?: string; identity?: string } = { scene };
+
+  // 角色检测:故事/叙事类 → "一个旁白"
+  let narrationScore = 0;
+  for (const marker of NARRATION_MARKERS) {
+    if (text.includes(marker)) narrationScore++;
+  }
+  // 文本较长且有故事标识 → 旁白模式
+  if (narrationScore >= 1 || (text.length > 200 && narrationScore > 0)) {
+    result.role = '一个旁白';
+  }
+
+  // 身份检测:儿童内容 → "故事机"
+  let storytellerScore = 0;
+  for (const marker of STORYTELLER_MARKERS) {
+    if (text.includes(marker)) storytellerScore++;
+  }
+  // 检测到儿童内容关键词 或 场景已经是儿童内容解说
+  if (storytellerScore >= 1 || scene === '一些儿童内容解说') {
+    result.identity = '故事机';
+    // 身份 > 角色,有身份时不需要角色
+    delete result.role;
+  }
+
+  return result;
+}
+
 /** 儿童内容语速(3-6岁儿童友好语速,0.78 为业内经验值) */
 /** 儿童内容语速(3-6岁儿童友好语速,0.78 为业内经验值) */
 export const CHILDREN_VOICE_SPEED = 0.78;
 export const CHILDREN_VOICE_SPEED = 0.78;
 
 
@@ -972,6 +1136,12 @@ async function processAudioGeneration(
     const segments = splitText(cleanText, segmentMax);
     const segments = splitText(cleanText, segmentMax);
     console.log(`📝 文本已分段: ${segments.length} 段, 阈值=${segmentMax}字 (Provider: ${tts.name})`);
     console.log(`📝 文本已分段: ${segments.length} 段, 阈值=${segmentMax}字 (Provider: ${tts.name})`);
 
 
+    // 🎭 逐段 Instruct:为每段文本独立分析内容,动态匹配场景/角色/身份+情感
+    const baseVoiceForInstruct = UNIFIED_VOICES.find(v => v.id === effectiveVoiceId)
+      || UNIFIED_VOICES.find(v => v.id === voiceId);
+    // 全局 instruct 作为兜底(用户手动指定 或 音色预设 优先)
+    const globalInstruct = (voiceParams as any).instructText;
+
     // 并行生成各段音频(使用 Provider 自身的并发设置,每段带重试)
     // 并行生成各段音频(使用 Provider 自身的并发设置,每段带重试)
     const audioFiles: string[] = [];
     const audioFiles: string[] = [];
     const cloudUrls: string[] = [];
     const cloudUrls: string[] = [];
@@ -984,7 +1154,17 @@ async function processAudioGeneration(
       const results = await Promise.all(
       const results = await Promise.all(
         batch.map(async (segment, idx) => {
         batch.map(async (segment, idx) => {
           const segPath = path.join(audioDir, `segment_${i + idx}.mp3`);
           const segPath = path.join(audioDir, `segment_${i + idx}.mp3`);
-          return synthesizeSegmentWithRetry(tts, node, segment, voiceName, voiceParams, segPath);
+          // 🎭 逐段 Instruct:每段文本独立分析内容 → 动态生成场景+情感指令
+          let segVoiceParams = voiceParams;
+          if (INSTRUCT_ENABLED && tts.vendor === 'bailian' && baseVoiceForInstruct && !(voiceParams as any).emotion) {
+            // 用户手动指定情感时 跳过逐段检测,使用用户选择
+            const segInstruct = generateSegmentInstruct(segment, baseVoiceForInstruct);
+            if (segInstruct && segInstruct !== globalInstruct) {
+              segVoiceParams = { ...voiceParams, instructText: segInstruct };
+              console.log(`🎭 [SegInstruct] 段${i+idx} 「${segment.substring(0, 25)}...」→ ${segInstruct.substring(0, 50)}...`);
+            }
+          }
+          return synthesizeSegmentWithRetry(tts, node, segment, voiceName, segVoiceParams, segPath);
         })
         })
       );
       );