|
|
@@ -29,12 +29,19 @@ const UNIFIED_VOICES: Voice[] = [
|
|
|
{ id: 'voice_10', name: '稚嫩童声', gender: 'female', description: '稚嫩天真,适合童话寓言' },
|
|
|
];
|
|
|
|
|
|
-// 统一音色 → 阿里云 真实音色映射
|
|
|
+// 统一音色 → 阿里云 CosyVoice 真实音色映射
|
|
|
+// 选型标准:优先选用支持 Instruct 指令的标杆音色(龙安欢/龙安洋/龙呼呼/龙逸尘/龙安柔/龙泡泡)
|
|
|
const ALIYUN_VOICE_MAP: Record<string, string> = {
|
|
|
- voice_01: 'longanyang', voice_02: 'longsanshu_v3', voice_03: 'longhuhu_v3',
|
|
|
- voice_04: 'longyue_v3', voice_05: 'longyichen_v3', voice_06: 'longlaobo_v3',
|
|
|
- voice_07: 'longmiao_v3', voice_08: 'longshuo_v3', voice_09: 'longwan_v3',
|
|
|
- voice_10: 'longhuhu_v3',
|
|
|
+ voice_01: 'longanhuan_v3', // 温柔女声 → 龙安欢V3(元气女,支持Instruct)
|
|
|
+ voice_02: 'longanyang', // 磁性男声 → 龙安洋(阳光男,支持Instruct)
|
|
|
+ voice_03: 'longhuhu_v3', // 活泼女声 → 龙呼呼(女童音,支持Instruct)
|
|
|
+ voice_04: 'longyuan_v3', // 知性女声 → 龙媛(温暖治愈)
|
|
|
+ voice_05: 'longyichen_v3', // 阳光男声 → 龙逸尘(支持Instruct)
|
|
|
+ voice_06: 'longlaobo_v3', // 沧桑男声 → 龙老伯
|
|
|
+ voice_07: 'longhua_v3', // 甜美女声 → 龙华(元气甜美)
|
|
|
+ voice_08: 'longshuo_v3', // 清朗男声 → 龙硕
|
|
|
+ voice_09: 'longanrou_v3', // 亲切女声 → 龙安柔(温柔闺蜜,支持Instruct)
|
|
|
+ voice_10: 'longpaopao_v3', // 稚嫩童声 → 龙泡泡(飞天泡泡音,支持Instruct)
|
|
|
};
|
|
|
|
|
|
// 统一音色 → Edge-TTS 真实音色映射
|
|
|
@@ -63,13 +70,14 @@ function mapToProviderVoice(unifiedVoiceId: string, providerVendor: string): str
|
|
|
const mapped = ALIYUN_VOICE_MAP[unifiedVoiceId];
|
|
|
if (mapped) return mapped;
|
|
|
// 不在映射表中(如遗留的 'cherry' 等旧 MiniMax 音色)→ 用 CosyVoice 默认音色
|
|
|
- console.warn(`⚠️ [VoiceMap] 未识别的音色ID: "${unifiedVoiceId}",降级使用默认音色 longyingling_v3`);
|
|
|
- return 'longyingling_v3';
|
|
|
+ console.warn(`⚠️ [VoiceMap] 未识别的音色ID: "${unifiedVoiceId}",降级使用默认音色 longanhuan_v3`);
|
|
|
+ return 'longanhuan_v3';
|
|
|
}
|
|
|
|
|
|
-// ============ Aliyun Instruct 情感/场景控制 ============
|
|
|
-// 后期优化功能,暂不启用。启用时改为 true
|
|
|
-const INSTRUCT_ENABLED = false;
|
|
|
+// ============ CosyVoice Instruct 情感控制 ============
|
|
|
+// CosyVoice v3-flash 系统音色支持 7 种情感指令:neutral/happy/sad/angry/fearful/surprised/disgusted
|
|
|
+// 格式:设置情感:{emotion}。(固定格式,不可自定义)
|
|
|
+const INSTRUCT_ENABLED = true;
|
|
|
|
|
|
interface EmotionScene {
|
|
|
emotion: string; // neutral | fearful | angry | sad | surprised | happy | disgusted
|
|
|
@@ -86,17 +94,66 @@ const EMOTION_KEYWORDS: { emotion: string; keywords: string[] }[] = [
|
|
|
{ emotion: 'disgusted', keywords: ['恶心', '厌恶', '肮脏', '丑陋', '卑鄙'] },
|
|
|
];
|
|
|
|
|
|
-// 场景关键词库
|
|
|
-const SCENE_KEYWORDS: { scene: string; keywords: string[] }[] = [
|
|
|
- { scene: '新闻播报', keywords: ['新闻', '报道', '消息', '公告', '通知', '声明', '记者', '据悉', '据新华社', '人民日报'] },
|
|
|
- { scene: '一些儿童内容解说', keywords: ['童话', '儿童', '小朋友', '宝宝', '故事', '小熊', '小兔', '公主', '王子', '森林', '魔法', '精灵'] },
|
|
|
- { scene: '广告促销', keywords: ['促销', '优惠', '折扣', '限时', '秒杀', '购买', '抢购', '免费', '特价'] },
|
|
|
- { scene: '脱口秀表演', keywords: ['搞笑', '幽默', '笑话', '段子', '吐槽', '趣事'] },
|
|
|
- { scene: '语音导航', keywords: ['导航', '前方', '左转', '右转', '直行', '到达', '目的地', '路线'] },
|
|
|
- { scene: '比赛解说', keywords: ['比赛', '进球', '得分', '冠军', '决赛', '球队', '选手', '比分'] },
|
|
|
+// ============ 内容特征 → 最佳音色 + 情感 自动检测 ============
|
|
|
+// 每条配置 = { voiceId (统一ID), emotion, weight, keywords }
|
|
|
+// weight 越高优先级越高,用于区分强匹配和弱匹配
|
|
|
+interface ContentProfile {
|
|
|
+ voiceId: string; // 统一音色 ID (voice_01~voice_10)
|
|
|
+ emotion: string; // CosyVoice 7种情感之一
|
|
|
+ weight: number; // 匹配权重
|
|
|
+ keywords: string[]; // 触发关键词
|
|
|
+}
|
|
|
+
|
|
|
+const CONTENT_PROFILES: ContentProfile[] = [
|
|
|
+ // 儿童/童话:童声 + 快乐
|
|
|
+ { voiceId: 'voice_03', emotion: 'happy', weight: 4, keywords: [
|
|
|
+ '童话', '儿童', '小朋友', '宝宝', '小熊', '小兔', '公主', '王子', '小动物',
|
|
|
+ '幼儿园', '睡前故事', '寓言', '儿歌', '童谣', '小鸭子', '小猫咪', '小狗狗',
|
|
|
+ ]},
|
|
|
+ // 恐怖/悬疑:磁性男声 + 恐惧
|
|
|
+ { voiceId: 'voice_02', emotion: 'fearful', weight: 3, keywords: [
|
|
|
+ '恐怖', '惊悚', '悬疑', '阴森', '黑暗', '鬼', '谋杀', '深渊', '噩梦', '死亡',
|
|
|
+ '罪案', '推理', '凶手', '密室', '诡异', '毛骨悚然',
|
|
|
+ ]},
|
|
|
+ // 奇幻/魔法:活泼女声 + 惊喜
|
|
|
+ { voiceId: 'voice_03', emotion: 'surprised', weight: 3, keywords: [
|
|
|
+ '魔法', '奇幻', '幻想', '精灵', '巫师', '龙', '异世界', '穿越', '奇迹', '仙境',
|
|
|
+ ]},
|
|
|
+ // 战斗/热血/武侠:清朗男声 + 愤怒
|
|
|
+ { voiceId: 'voice_08', emotion: 'angry', weight: 3, keywords: [
|
|
|
+ '战斗', '厮杀', '复仇', '战争', '侵略', '热血', '武侠', '剑客', '决斗',
|
|
|
+ '擂台', '武道', '拳法', '江湖',
|
|
|
+ ]},
|
|
|
+ // 历史/军事:沧桑男声 + 中性
|
|
|
+ { voiceId: 'voice_06', emotion: 'neutral', weight: 3, keywords: [
|
|
|
+ '历史', '古代', '王朝', '皇帝', '将军', '军事', '战场', '三国', '战国',
|
|
|
+ '秦始皇', '朱元璋', '李世民', '成吉思汗', '长征', '抗日',
|
|
|
+ ]},
|
|
|
+ // 悲伤/抒情:亲切女声 + 悲伤
|
|
|
+ { voiceId: 'voice_09', emotion: 'sad', weight: 3, keywords: [
|
|
|
+ '悲伤', '难过', '哭泣', '眼泪', '心痛', '遗憾', '孤独', '寂寞', '失落',
|
|
|
+ '离别', '思念', '哀伤', '去世', '失去', '回忆', '怀念',
|
|
|
+ ]},
|
|
|
+ // 搞笑/幽默:元气女声 + 惊喜
|
|
|
+ { voiceId: 'voice_01', emotion: 'surprised', weight: 2, keywords: [
|
|
|
+ '搞笑', '幽默', '笑话', '段子', '吐槽', '趣事', '逗比', '整蛊', '糗事',
|
|
|
+ ]},
|
|
|
+ // 浪漫/爱情:元气女声 + 快乐
|
|
|
+ { voiceId: 'voice_01', emotion: 'happy', weight: 2, keywords: [
|
|
|
+ '浪漫', '恋爱', '爱情', '甜蜜', '告白', '婚礼', '情侣', '约会', '暧昧',
|
|
|
+ ]},
|
|
|
+ // 科普/知识:知性女声 + 中性
|
|
|
+ { voiceId: 'voice_04', emotion: 'neutral', weight: 2, keywords: [
|
|
|
+ '科学', '科普', '知识', '原理', '发现', '实验', '研究', '探索', '宇宙',
|
|
|
+ '生物', '化学', '物理', '数学', '地理', '天文', '技术', '人工智能',
|
|
|
+ ]},
|
|
|
+ // 职场/商战:知性女声 + 中性
|
|
|
+ { voiceId: 'voice_04', emotion: 'neutral', weight: 2, keywords: [
|
|
|
+ '职场', '商战', '公司', '老板', '投资', '股票', '创业', '上市', '董事会',
|
|
|
+ ]},
|
|
|
];
|
|
|
|
|
|
-/** 根据文本内容分析情感 */
|
|
|
+/** 根据文本内容分析情感(兜底:纯情感关键词匹配) */
|
|
|
function detectEmotion(text: string): string {
|
|
|
const scores: Record<string, number> = {};
|
|
|
for (const { emotion, keywords } of EMOTION_KEYWORDS) {
|
|
|
@@ -113,28 +170,109 @@ function detectEmotion(text: string): string {
|
|
|
return best;
|
|
|
}
|
|
|
|
|
|
-/** 根据文本内容分析场景 */
|
|
|
-function detectScene(text: string): string {
|
|
|
- const scores: Record<string, number> = {};
|
|
|
- for (const { scene, keywords } of SCENE_KEYWORDS) {
|
|
|
- scores[scene] = 0;
|
|
|
- for (const kw of keywords) {
|
|
|
- if (text.includes(kw)) scores[scene]++;
|
|
|
+/**
|
|
|
+ * 根据文本内容自动检测最佳音色 + 情感
|
|
|
+ * 返回 { voiceId, instructText },voiceId 为统一音色 ID (voice_01~voice_10)
|
|
|
+ * 当没有任何特征匹配时返回 null(由调用方使用默认音色)
|
|
|
+ */
|
|
|
+export function detectBestVoiceAndEmotion(text: string): { voiceId: string; instructText: string } | null {
|
|
|
+ if (!text || text.length < 20) return null;
|
|
|
+
|
|
|
+ const scores = new Map<string, { voiceId: string; emotion: string; score: number }>();
|
|
|
+
|
|
|
+ for (const profile of CONTENT_PROFILES) {
|
|
|
+ let score = 0;
|
|
|
+ for (const kw of profile.keywords) {
|
|
|
+ // 中文关键词在文本中出现次数 × 权重
|
|
|
+ let idx = -1;
|
|
|
+ while ((idx = text.indexOf(kw, idx + 1)) !== -1) {
|
|
|
+ score += profile.weight;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (score > 0) {
|
|
|
+ const key = profile.voiceId;
|
|
|
+ const existing = scores.get(key);
|
|
|
+ if (!existing || score > existing.score) {
|
|
|
+ scores.set(key, { voiceId: profile.voiceId, emotion: profile.emotion, score });
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
- let best = '闲聊互动';
|
|
|
- let bestScore = 0;
|
|
|
- for (const [scene, score] of Object.entries(scores)) {
|
|
|
- if (score > bestScore) { best = scene; bestScore = score; }
|
|
|
+
|
|
|
+ if (scores.size === 0) return null;
|
|
|
+
|
|
|
+ // 取最高分的音色
|
|
|
+ let best: { voiceId: string; emotion: string; score: number } | null = null;
|
|
|
+ for (const entry of scores.values()) {
|
|
|
+ if (!best || entry.score > best.score) {
|
|
|
+ best = entry;
|
|
|
+ }
|
|
|
}
|
|
|
- return best;
|
|
|
+
|
|
|
+ if (!best || best.score < 2) return null; // 置信度太低,不使用自动选择
|
|
|
+
|
|
|
+ console.log(`🔍 [AutoDetect] 内容分析: 最佳音色=${best.voiceId}, 情感=${best.emotion}, 匹配度=${best.score}`);
|
|
|
+ return {
|
|
|
+ voiceId: best.voiceId,
|
|
|
+ instructText: `设置情感:${best.emotion}。`,
|
|
|
+ };
|
|
|
}
|
|
|
|
|
|
-/** 根据文本内容动态生成 Aliyun Instruct 文本 */
|
|
|
+/** 根据文本内容动态生成 CosyVoice Instruct 文本(固定格式) */
|
|
|
export function getVoiceInstruct(text: string): string {
|
|
|
const emotion = detectEmotion(text);
|
|
|
- const scene = detectScene(text);
|
|
|
- return `你正在进行${scene},你说话的情感是${emotion}。`;
|
|
|
+ // CosyVoice v3-flash 系统音色只支持固定格式:设置情感:{emotion}。
|
|
|
+ return `设置情感:${emotion}。`;
|
|
|
+}
|
|
|
+
|
|
|
+/** 儿童内容语速(3-6岁儿童友好语速,0.78 为业内经验值) */
|
|
|
+export const CHILDREN_VOICE_SPEED = 0.78;
|
|
|
+
|
|
|
+/** 默认语速 */
|
|
|
+export const DEFAULT_VOICE_SPEED = 1.0;
|
|
|
+
|
|
|
+/** 儿童/童话内容关键词(独立于 detectBestVoiceAndEmotion,对短文本也生效) */
|
|
|
+const CHILDREN_KEYWORDS = [
|
|
|
+ '童话', '儿童', '小朋友', '宝宝', '小熊', '小兔', '公主', '王子', '小动物',
|
|
|
+ '幼儿园', '睡前故事', '寓言', '儿歌', '童谣', '小鸭子', '小猫咪', '小狗狗',
|
|
|
+ '奇幻', '魔法', '小猪', '小羊', '小白兔', '小鹿', '小象', '小老鼠',
|
|
|
+];
|
|
|
+
|
|
|
+/**
|
|
|
+ * 根据文本内容检测是否为儿童内容(仅用于创建书籍时确定语速)
|
|
|
+ * 返回 { isChildren, suggestedSpeed }
|
|
|
+ */
|
|
|
+export function detectChildrenContent(text: string): { isChildren: boolean; suggestedSpeed: number } {
|
|
|
+ if (!text || text.length < 2) {
|
|
|
+ return { isChildren: false, suggestedSpeed: DEFAULT_VOICE_SPEED };
|
|
|
+ }
|
|
|
+ // 直接用儿童关键词匹配,对短文本(title)也有效
|
|
|
+ let score = 0;
|
|
|
+ for (const kw of CHILDREN_KEYWORDS) {
|
|
|
+ let idx = -1;
|
|
|
+ while ((idx = text.indexOf(kw, idx + 1)) !== -1) {
|
|
|
+ score++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (score >= 1) {
|
|
|
+ return { isChildren: true, suggestedSpeed: CHILDREN_VOICE_SPEED };
|
|
|
+ }
|
|
|
+ return { isChildren: false, suggestedSpeed: DEFAULT_VOICE_SPEED };
|
|
|
+}
|
|
|
+
|
|
|
+/** 7种情感的中文标签映射 */
|
|
|
+export const EMOTION_LABELS: Record<string, string> = {
|
|
|
+ neutral: '中性',
|
|
|
+ happy: '😊 快乐',
|
|
|
+ sad: '😢 悲伤',
|
|
|
+ angry: '😠 愤怒',
|
|
|
+ fearful: '😨 恐惧',
|
|
|
+ surprised: '😲 惊喜',
|
|
|
+ disgusted: '🤢 厌恶',
|
|
|
+};
|
|
|
+
|
|
|
+/** 根据统一音色ID查找音色信息 */
|
|
|
+export function getVoiceById(voiceId: string) {
|
|
|
+ return UNIFIED_VOICES.find(v => v.id === voiceId) || null;
|
|
|
}
|
|
|
|
|
|
// 兼容旧代码的旧版音色列表(保留但不再推荐使用)
|
|
|
@@ -569,11 +707,40 @@ async function processAudioGeneration(
|
|
|
console.log(`🔊 使用 TTS Provider: ${tts.name} (vendor=${tts.vendor}, mode=${tts.mode})`);
|
|
|
ttsLogger.debug(`Provider: ${tts.name}, vendor=${tts.vendor}, mode=${tts.mode}`);
|
|
|
|
|
|
+ // 🔍 情感检测:优先级 = 用户手动指定 > 内容自动检测 > 兜底情感关键词
|
|
|
+ // 用户手动指定了 emotion → 直接用,跳过所有检测
|
|
|
+ if ((voiceParams as any).emotion) {
|
|
|
+ const userEmotion = (voiceParams as any).emotion;
|
|
|
+ console.log(`🎭 [Emotion] 用户指定情感: ${userEmotion}`);
|
|
|
+ if (INSTRUCT_ENABLED && tts.vendor === 'bailian') {
|
|
|
+ voiceParams = { ...voiceParams, instructText: `设置情感:${userEmotion}。` };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 🔍 自动检测:当用户未显式选择音色时,根据内容智能选择最佳音色+情感
|
|
|
+ // 触发条件:voiceId 为空 / 旧系统音色(如'cherry') / 不在统一音色表中
|
|
|
+ const isDefaultVoice = !voiceId
|
|
|
+ || voiceId === 'cherry' // 旧系统默认音色
|
|
|
+ || !UNIFIED_VOICES.some(v => v.id === voiceId); // 不在10个统一音色中
|
|
|
+ let effectiveVoiceId = voiceId;
|
|
|
+
|
|
|
+ if (isDefaultVoice) {
|
|
|
+ const detected = detectBestVoiceAndEmotion(text);
|
|
|
+ if (detected) {
|
|
|
+ effectiveVoiceId = detected.voiceId;
|
|
|
+ console.log(`🔍 [AutoDetect] 自动选择音色: ${voiceId || '空'} → ${effectiveVoiceId}`);
|
|
|
+ // 自动检测的 instruct(仅在用户未手动指定情感时注入)
|
|
|
+ if (INSTRUCT_ENABLED && tts.vendor === 'bailian' && !(voiceParams as any).instructText) {
|
|
|
+ voiceParams = { ...voiceParams, instructText: detected.instructText };
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
// 解析音色名称(使用统一音色映射到具体Provider的真实音色)
|
|
|
- const voiceName = mapToProviderVoice(voiceId, tts.vendor);
|
|
|
+ const voiceName = mapToProviderVoice(effectiveVoiceId || voiceId, tts.vendor);
|
|
|
|
|
|
- // 为 Aliyun 注入 Instruct 情感/场景控制(后期优化,暂不启用)
|
|
|
- if (INSTRUCT_ENABLED && tts.vendor === 'bailian') {
|
|
|
+ // 为 Aliyun 注入 Instruct 情感控制(兜底:自动检测未触发时用情感关键词兜底)
|
|
|
+ if (INSTRUCT_ENABLED && tts.vendor === 'bailian' && !(voiceParams as any).instructText) {
|
|
|
const instructText = getVoiceInstruct(text);
|
|
|
if (instructText) {
|
|
|
voiceParams = { ...voiceParams, instructText };
|