|
@@ -14,19 +14,186 @@ import { ProviderNode } from '../../common/provider-registry';
|
|
|
import { ttsLogger } from './tts-logger';
|
|
import { ttsLogger } from './tts-logger';
|
|
|
import axios from 'axios';
|
|
import axios from 'axios';
|
|
|
|
|
|
|
|
-// ============ 统一音色定义(10个固定音色,前端使用)============
|
|
|
|
|
-// 前端使用统一 ID,后端根据 Provider 类型映射到真实音色
|
|
|
|
|
|
|
+// ============ CosyVoice 官方 Instruct 指令生成 ============
|
|
|
|
|
+// 严格按官方格式,使用中文,包括标点符号,不可遗漏结尾句号。
|
|
|
|
|
+// 支持的情感值:neutral、fearful、angry、sad、surprised、happy、disgusted
|
|
|
|
|
+// 优先级:身份 > 角色 > 场景 > 仅情感
|
|
|
|
|
+
|
|
|
|
|
+/** 统一的情感值校验:只返回官方支持的7种情感之一,非法值降级为 neutral */
|
|
|
|
|
+function normalizeEmotion(emotion?: string): string {
|
|
|
|
|
+ const valid = ['neutral', 'fearful', 'angry', 'sad', 'surprised', 'happy', 'disgusted'];
|
|
|
|
|
+ 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);
|
|
|
|
|
+
|
|
|
|
|
+ if (voice.identity) {
|
|
|
|
|
+ return `你正在以一个${voice.identity}的身份说话,你说话的情感是${emotion}。`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (voice.role) {
|
|
|
|
|
+ return `你现在说话的角色是${voice.role},你说话的情感是${emotion}。`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (voice.scene) {
|
|
|
|
|
+ return `你正在进行${voice.scene},你说话的情感是${emotion}。`;
|
|
|
|
|
+ }
|
|
|
|
|
+ return `你说话的情感是${emotion}。`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 仅情感指令(无场景/角色/身份时使用) */
|
|
|
|
|
+function emotionOnlyInstruct(emotion: string): string {
|
|
|
|
|
+ return `你说话的情感是${normalizeEmotion(emotion)}。`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ============ 统一音色定义(22个音色,10基础 + 12个情感变体)============
|
|
|
|
|
+// 6个支持 Instruct 的基础音色各扩展 2 个情感变体 → 10 + 12 = 22
|
|
|
|
|
+// 每条音色包含 7 个维度描述 + 官方 Instruct 场景,instructText 由 generateInstruct 自动生成
|
|
|
const UNIFIED_VOICES: Voice[] = [
|
|
const UNIFIED_VOICES: Voice[] = [
|
|
|
- { id: 'voice_01', name: '温柔女声', gender: 'female', description: '柔和温暖,适合情感故事' },
|
|
|
|
|
- { id: 'voice_02', name: '磁性男声', gender: 'male', description: '低沉有力,适合悬疑推理' },
|
|
|
|
|
- { id: 'voice_03', name: '活泼女声', gender: 'female', description: '清新明亮,适合儿童故事' },
|
|
|
|
|
- { id: 'voice_04', name: '知性女声', gender: 'female', description: '知性稳重,适合科普知识' },
|
|
|
|
|
- { id: 'voice_05', name: '阳光男声', gender: 'male', description: '阳光活力,适合校园青春' },
|
|
|
|
|
- { id: 'voice_06', name: '沧桑男声', gender: 'male', description: '成熟沧桑,适合历史军事' },
|
|
|
|
|
- { id: 'voice_07', name: '甜美女声', gender: 'female', description: '甜美可爱,适合爱情都市' },
|
|
|
|
|
- { id: 'voice_08', name: '清朗男声', gender: 'male', description: '清朗干练,适合职场商战' },
|
|
|
|
|
- { id: 'voice_09', name: '亲切女声', gender: 'female', description: '亲切自然,适合日常叙事' },
|
|
|
|
|
- { id: 'voice_10', name: '稚嫩童声', gender: 'female', description: '稚嫩天真,适合童话寓言' },
|
|
|
|
|
|
|
+ // ═══════════════════ 基础音色(默认自然风格)═══════════════════
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_01', name: '温柔女声·自然', gender: 'female',
|
|
|
|
|
+ description: '柔和温暖,适合情感故事',
|
|
|
|
|
+ age: '青年', pitch: '中音', speed: '中速', style: '温柔',
|
|
|
|
|
+ characteristics: '圆润甜美', usage: '有声书', scene: '闲聊互动',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_02', name: '磁性男声·自然', gender: 'male',
|
|
|
|
|
+ description: '低沉有力,适合悬疑推理',
|
|
|
|
|
+ age: '青年', pitch: '低音', speed: '中速', style: '沉稳',
|
|
|
|
|
+ characteristics: '有磁性浑厚', usage: '有声书', scene: '闲聊互动',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_03', name: '活泼女声·自然', gender: 'female',
|
|
|
|
|
+ description: '清新明亮,适合儿童故事',
|
|
|
|
|
+ age: '儿童', pitch: '高音', speed: '偏快', style: '活泼',
|
|
|
|
|
+ characteristics: '清脆甜美', usage: '动画角色', scene: '一些儿童内容解说',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_04', name: '知性女声', gender: 'female',
|
|
|
|
|
+ description: '知性稳重,适合科普知识(不支持情感变体)',
|
|
|
|
|
+ age: '中年', pitch: '中音', speed: '中速', style: '沉稳',
|
|
|
|
|
+ characteristics: '圆润', usage: '纪录片解说', scene: '闲聊互动',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_05', name: '阳光男声·自然', gender: 'male',
|
|
|
|
|
+ description: '阳光活力,适合校园青春',
|
|
|
|
|
+ age: '青年', pitch: '偏高', speed: '偏快', style: '开朗',
|
|
|
|
|
+ characteristics: '清脆有力', usage: '有声书', scene: '闲聊互动',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_06', name: '沧桑男声', gender: 'male',
|
|
|
|
|
+ description: '成熟沧桑,适合历史军事(不支持情感变体)',
|
|
|
|
|
+ age: '老年', pitch: '低音', speed: '偏慢', style: '严肃',
|
|
|
|
|
+ characteristics: '沙哑浑厚', usage: '纪录片解说', scene: '闲聊互动',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_07', name: '甜美女声', gender: 'female',
|
|
|
|
|
+ description: '甜美可爱,适合爱情都市(不支持情感变体)',
|
|
|
|
|
+ age: '青年', pitch: '偏高', speed: '中速', style: '开朗',
|
|
|
|
|
+ characteristics: '甜美圆润', usage: '有声书', scene: '闲聊互动',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_08', name: '清朗男声', gender: 'male',
|
|
|
|
|
+ description: '清朗干练,适合职场商战(不支持情感变体)',
|
|
|
|
|
+ age: '青年', pitch: '中音', speed: '中速', style: '沉稳',
|
|
|
|
|
+ characteristics: '清脆有力', usage: '新闻播报', scene: '新闻播报',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_09', name: '亲切女声·自然', gender: 'female',
|
|
|
|
|
+ description: '亲切自然,适合日常叙事',
|
|
|
|
|
+ age: '青年', pitch: '中音', speed: '中速', style: '治愈',
|
|
|
|
|
+ characteristics: '圆润甜美', usage: '有声书', scene: '闲聊互动',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_10', name: '稚嫩童声·自然', gender: 'female',
|
|
|
|
|
+ description: '稚嫩天真,适合童话寓言',
|
|
|
|
|
+ age: '儿童', pitch: '偏高', speed: '中速', style: '活泼',
|
|
|
|
|
+ characteristics: '清脆甜美', usage: '动画角色', scene: '一些儿童内容解说',
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ // ═══════════════════ 情感变体 ════════════════════
|
|
|
|
|
+ // voice_01 → longanhuan_v3 (元气女) → +happy, +surprised
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_01_happy', name: '温柔女声·欢快', gender: 'female',
|
|
|
|
|
+ description: '欢快活泼,适合轻松故事',
|
|
|
|
|
+ age: '青年', pitch: '中音', speed: '偏快', style: '开朗',
|
|
|
|
|
+ characteristics: '圆润甜美', usage: '有声书', scene: '闲聊互动', cosyEmotion: 'happy',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_01_surprised', name: '温柔女声·惊奇', gender: 'female',
|
|
|
|
|
+ description: '惊奇感叹,适合奇幻冒险',
|
|
|
|
|
+ age: '青年', pitch: '中音', speed: '中速', style: '活泼',
|
|
|
|
|
+ characteristics: '圆润甜美', usage: '有声书', scene: '闲聊互动', cosyEmotion: 'surprised',
|
|
|
|
|
+ },
|
|
|
|
|
+ // voice_02 → longanyang (磁性男) → +fearful, +angry
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_02_fearful', name: '磁性男声·悬疑', gender: 'male',
|
|
|
|
|
+ description: '低沉恐惧,适合悬疑惊悚',
|
|
|
|
|
+ age: '青年', pitch: '低音', speed: '偏慢', style: '严肃',
|
|
|
|
|
+ characteristics: '有磁性沙哑', usage: '有声书', scene: '闲聊互动', cosyEmotion: 'fearful',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_02_angry', name: '磁性男声·激昂', gender: 'male',
|
|
|
|
|
+ description: '愤怒有力,适合热血战斗',
|
|
|
|
|
+ age: '青年', pitch: '低音', speed: '偏快', style: '严肃',
|
|
|
|
|
+ characteristics: '有磁性有力', usage: '有声书', scene: '闲聊互动', cosyEmotion: 'angry',
|
|
|
|
|
+ },
|
|
|
|
|
+ // voice_03 → longhuhu_v3 (女童音) → +happy, +surprised
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_03_happy', name: '活泼女声·开心', gender: 'female',
|
|
|
|
|
+ description: '天真快乐,适合童话故事',
|
|
|
|
|
+ age: '儿童', pitch: '高音', speed: '偏快', style: '开朗',
|
|
|
|
|
+ characteristics: '清脆甜美', usage: '动画角色', scene: '一些儿童内容解说', cosyEmotion: 'happy',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_03_surprised', name: '活泼女声·惊奇', gender: 'female',
|
|
|
|
|
+ description: '惊奇可爱,适合魔法冒险',
|
|
|
|
|
+ age: '儿童', pitch: '高音', speed: '中速', style: '活泼',
|
|
|
|
|
+ characteristics: '清脆甜美', usage: '动画角色', scene: '一些儿童内容解说', cosyEmotion: 'surprised',
|
|
|
|
|
+ },
|
|
|
|
|
+ // voice_05 → longyichen_v3 (阳光男) → +happy, +angry
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_05_happy', name: '阳光男声·活力', gender: 'male',
|
|
|
|
|
+ description: '热情洋溢,适合青春校园',
|
|
|
|
|
+ age: '青年', pitch: '偏高', speed: '偏快', style: '开朗',
|
|
|
|
|
+ characteristics: '清脆有力', usage: '有声书', scene: '闲聊互动', cosyEmotion: 'happy',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_05_angry', name: '阳光男声·热血', gender: 'male',
|
|
|
|
|
+ description: '热血激昂,适合竞技体育',
|
|
|
|
|
+ age: '青年', pitch: '偏高', speed: '偏快', style: '严肃',
|
|
|
|
|
+ characteristics: '清脆有力', usage: '有声书', scene: '闲聊互动', cosyEmotion: 'angry',
|
|
|
|
|
+ },
|
|
|
|
|
+ // voice_09 → longanrou_v3 (温柔闺蜜) → +sad, +happy
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_09_sad', name: '亲切女声·深情', gender: 'female',
|
|
|
|
|
+ description: '深情悲伤,适合抒情文学',
|
|
|
|
|
+ age: '青年', pitch: '中音', speed: '偏慢', style: '温柔',
|
|
|
|
|
+ characteristics: '圆润甜美', usage: '有声书', scene: '闲聊互动', cosyEmotion: 'sad',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_09_happy', name: '亲切女声·温暖', gender: 'female',
|
|
|
|
|
+ description: '温暖治愈,适合生活记录',
|
|
|
|
|
+ age: '青年', pitch: '中音', speed: '中速', style: '治愈',
|
|
|
|
|
+ characteristics: '圆润甜美', usage: '有声书', scene: '闲聊互动', cosyEmotion: 'happy',
|
|
|
|
|
+ },
|
|
|
|
|
+ // voice_10 → longpaopao_v3 (童声泡泡音) → +happy, +surprised
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_10_happy', name: '稚嫩童声·开心', gender: 'female',
|
|
|
|
|
+ description: '开心童趣,适合儿童故事',
|
|
|
|
|
+ age: '儿童', pitch: '偏高', speed: '偏快', style: '开朗',
|
|
|
|
|
+ characteristics: '清脆甜美', usage: '动画角色', scene: '一些儿童内容解说', cosyEmotion: 'happy',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'voice_10_surprised', name: '稚嫩童声·好奇', gender: 'female',
|
|
|
|
|
+ description: '好奇惊讶,适合探索冒险',
|
|
|
|
|
+ age: '儿童', pitch: '偏高', speed: '中速', style: '活泼',
|
|
|
|
|
+ characteristics: '清脆甜美', usage: '动画角色', scene: '一些儿童内容解说', cosyEmotion: 'surprised',
|
|
|
|
|
+ },
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
// 统一音色 → 阿里云 CosyVoice 真实音色映射
|
|
// 统一音色 → 阿里云 CosyVoice 真实音色映射
|
|
@@ -58,16 +225,33 @@ const EDGE_VOICE_MAP: Record<string, string> = {
|
|
|
voice_10: 'zh-CN-XiaoshuangNeural', // 稚嫩童声 → 晓双(童声)
|
|
voice_10: 'zh-CN-XiaoshuangNeural', // 稚嫩童声 → 晓双(童声)
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+/** 从变体 ID 提取基础音色 ID(voice_01_happy → voice_01, voice_01 → voice_01) */
|
|
|
|
|
+function getBaseVoiceId(voiceId: string): string {
|
|
|
|
|
+ const match = voiceId.match(/^(voice_\d+)/);
|
|
|
|
|
+ return match ? match[1] : voiceId;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 根据音色 ID 获取官方的 instructText(优先显式值,否则从维度自动生成) */
|
|
|
|
|
+function getVoiceInstructById(voiceId: string): string {
|
|
|
|
|
+ const voice = UNIFIED_VOICES.find(v => v.id === voiceId);
|
|
|
|
|
+ if (!voice) return '';
|
|
|
|
|
+ // 显式 instructText 优先(向后兼容),否则从维度自动生成
|
|
|
|
|
+ if (voice.instructText) return voice.instructText;
|
|
|
|
|
+ return generateInstruct(voice) || '';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function mapToProviderVoice(unifiedVoiceId: string, providerVendor: string): string {
|
|
function mapToProviderVoice(unifiedVoiceId: string, providerVendor: string): string {
|
|
|
|
|
+ const baseId = getBaseVoiceId(unifiedVoiceId);
|
|
|
|
|
+
|
|
|
// Edge-TTS: 使用微软免费音色
|
|
// Edge-TTS: 使用微软免费音色
|
|
|
if (providerVendor === 'edge') {
|
|
if (providerVendor === 'edge') {
|
|
|
- const mapped = EDGE_VOICE_MAP[unifiedVoiceId];
|
|
|
|
|
|
|
+ const mapped = EDGE_VOICE_MAP[baseId];
|
|
|
if (mapped) return mapped;
|
|
if (mapped) return mapped;
|
|
|
console.warn(`⚠️ [VoiceMap] Edge 未识别的音色ID: "${unifiedVoiceId}",降级使用默认音色`);
|
|
console.warn(`⚠️ [VoiceMap] Edge 未识别的音色ID: "${unifiedVoiceId}",降级使用默认音色`);
|
|
|
return 'zh-CN-XiaoxiaoNeural';
|
|
return 'zh-CN-XiaoxiaoNeural';
|
|
|
}
|
|
}
|
|
|
// 默认: 阿里云百炼 CosyVoice
|
|
// 默认: 阿里云百炼 CosyVoice
|
|
|
- const mapped = ALIYUN_VOICE_MAP[unifiedVoiceId];
|
|
|
|
|
|
|
+ const mapped = ALIYUN_VOICE_MAP[baseId];
|
|
|
if (mapped) return mapped;
|
|
if (mapped) return mapped;
|
|
|
// 不在映射表中(如遗留的 'cherry' 等旧 MiniMax 音色)→ 用 CosyVoice 默认音色
|
|
// 不在映射表中(如遗留的 'cherry' 等旧 MiniMax 音色)→ 用 CosyVoice 默认音色
|
|
|
console.warn(`⚠️ [VoiceMap] 未识别的音色ID: "${unifiedVoiceId}",降级使用默认音色 longanhuan_v3`);
|
|
console.warn(`⚠️ [VoiceMap] 未识别的音色ID: "${unifiedVoiceId}",降级使用默认音色 longanhuan_v3`);
|
|
@@ -76,7 +260,11 @@ function mapToProviderVoice(unifiedVoiceId: string, providerVendor: string): str
|
|
|
|
|
|
|
|
// ============ CosyVoice Instruct 情感控制 ============
|
|
// ============ CosyVoice Instruct 情感控制 ============
|
|
|
// CosyVoice v3-flash 系统音色支持 7 种情感指令:neutral/happy/sad/angry/fearful/surprised/disgusted
|
|
// CosyVoice v3-flash 系统音色支持 7 种情感指令:neutral/happy/sad/angry/fearful/surprised/disgusted
|
|
|
-// 格式:设置情感:{emotion}。(固定格式,不可自定义)
|
|
|
|
|
|
|
+// 官方格式:
|
|
|
|
|
+// 仅情感:你说话的情感是<情感值>。
|
|
|
|
|
+// 场景+情感:你正在进行<场景>,你说话的情感是<情感值>。
|
|
|
|
|
+// 角色+情感:你现在说话的角色是<角色>,你说话的情感是<情感值>。
|
|
|
|
|
+// 身份+情感:你正在以一个<身份>的身份说话,你说话的情感是<情感值>。
|
|
|
const INSTRUCT_ENABLED = true;
|
|
const INSTRUCT_ENABLED = true;
|
|
|
|
|
|
|
|
interface EmotionScene {
|
|
interface EmotionScene {
|
|
@@ -154,7 +342,7 @@ const CONTENT_PROFILES: ContentProfile[] = [
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
/** 根据文本内容分析情感(兜底:纯情感关键词匹配) */
|
|
/** 根据文本内容分析情感(兜底:纯情感关键词匹配) */
|
|
|
-function detectEmotion(text: string): string {
|
|
|
|
|
|
|
+export function detectEmotion(text: string): string {
|
|
|
const scores: Record<string, number> = {};
|
|
const scores: Record<string, number> = {};
|
|
|
for (const { emotion, keywords } of EMOTION_KEYWORDS) {
|
|
for (const { emotion, keywords } of EMOTION_KEYWORDS) {
|
|
|
scores[emotion] = 0;
|
|
scores[emotion] = 0;
|
|
@@ -172,10 +360,10 @@ function detectEmotion(text: string): string {
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 根据文本内容自动检测最佳音色 + 情感
|
|
* 根据文本内容自动检测最佳音色 + 情感
|
|
|
- * 返回 { voiceId, instructText },voiceId 为统一音色 ID (voice_01~voice_10)
|
|
|
|
|
|
|
+ * 返回 { voiceId, cosyEmotion, instructText },voiceId 为统一音色 ID (voice_01~voice_10)
|
|
|
* 当没有任何特征匹配时返回 null(由调用方使用默认音色)
|
|
* 当没有任何特征匹配时返回 null(由调用方使用默认音色)
|
|
|
*/
|
|
*/
|
|
|
-export function detectBestVoiceAndEmotion(text: string): { voiceId: string; instructText: string } | null {
|
|
|
|
|
|
|
+export function detectBestVoiceAndEmotion(text: string): { voiceId: string; cosyEmotion: string; instructText: string } | null {
|
|
|
if (!text || text.length < 20) return null;
|
|
if (!text || text.length < 20) return null;
|
|
|
|
|
|
|
|
const scores = new Map<string, { voiceId: string; emotion: string; score: number }>();
|
|
const scores = new Map<string, { voiceId: string; emotion: string; score: number }>();
|
|
@@ -211,17 +399,35 @@ export function detectBestVoiceAndEmotion(text: string): { voiceId: string; inst
|
|
|
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 指令
|
|
|
|
|
+ const baseVoice = UNIFIED_VOICES.find(v => v.id === best!.voiceId);
|
|
|
|
|
+ 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);
|
|
|
return {
|
|
return {
|
|
|
voiceId: best.voiceId,
|
|
voiceId: best.voiceId,
|
|
|
- instructText: `设置情感:${best.emotion}。`,
|
|
|
|
|
|
|
+ cosyEmotion: cosyEmotion || 'neutral',
|
|
|
|
|
+ instructText: instructText || emotionOnlyInstruct(best.emotion),
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 根据文本内容动态生成 CosyVoice Instruct 文本(固定格式) */
|
|
|
|
|
-export function getVoiceInstruct(text: string): string {
|
|
|
|
|
|
|
+/** 根据文本内容 + 音色维度动态生成 CosyVoice Instruct 文本(多维度兜底) */
|
|
|
|
|
+export function getVoiceInstruct(text: string, voiceId?: string): string {
|
|
|
const emotion = detectEmotion(text);
|
|
const emotion = detectEmotion(text);
|
|
|
- // CosyVoice v3-flash 系统音色只支持固定格式:设置情感:{emotion}。
|
|
|
|
|
- return `设置情感:${emotion}。`;
|
|
|
|
|
|
|
+ // 如果有音色维度数据,基于场景+情感生成官方 Instruct 指令
|
|
|
|
|
+ if (voiceId) {
|
|
|
|
|
+ const baseVoice = UNIFIED_VOICES.find(v => v.id === voiceId) || UNIFIED_VOICES.find(v => v.id === getBaseVoiceId(voiceId));
|
|
|
|
|
+ if (baseVoice) {
|
|
|
|
|
+ const enriched: Voice = { ...baseVoice, cosyEmotion: emotion !== 'neutral' ? emotion : baseVoice.cosyEmotion };
|
|
|
|
|
+ const instruct = generateInstruct(enriched);
|
|
|
|
|
+ if (instruct) return instruct;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 纯兜底:仅情感指令
|
|
|
|
|
+ return emotionOnlyInstruct(emotion);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** 儿童内容语速(3-6岁儿童友好语速,0.78 为业内经验值) */
|
|
/** 儿童内容语速(3-6岁儿童友好语速,0.78 为业内经验值) */
|
|
@@ -707,13 +913,23 @@ async function processAudioGeneration(
|
|
|
console.log(`🔊 使用 TTS Provider: ${tts.name} (vendor=${tts.vendor}, mode=${tts.mode})`);
|
|
console.log(`🔊 使用 TTS Provider: ${tts.name} (vendor=${tts.vendor}, mode=${tts.mode})`);
|
|
|
ttsLogger.debug(`Provider: ${tts.name}, vendor=${tts.vendor}, mode=${tts.mode}`);
|
|
ttsLogger.debug(`Provider: ${tts.name}, vendor=${tts.vendor}, mode=${tts.mode}`);
|
|
|
|
|
|
|
|
- // 🔍 情感检测:优先级 = 用户手动指定 > 内容自动检测 > 兜底情感关键词
|
|
|
|
|
- // 用户手动指定了 emotion → 直接用,跳过所有检测
|
|
|
|
|
|
|
+ // 🔍 情感检测:优先级 = 用户手动指定 > 音色预设 > 内容自动检测 > 兜底情感关键词
|
|
|
|
|
+ // ① 用户手动指定了 emotion → 直接用,跳过所有检测
|
|
|
if ((voiceParams as any).emotion) {
|
|
if ((voiceParams as any).emotion) {
|
|
|
const userEmotion = (voiceParams as any).emotion;
|
|
const userEmotion = (voiceParams as any).emotion;
|
|
|
console.log(`🎭 [Emotion] 用户指定情感: ${userEmotion}`);
|
|
console.log(`🎭 [Emotion] 用户指定情感: ${userEmotion}`);
|
|
|
if (INSTRUCT_ENABLED && tts.vendor === 'bailian') {
|
|
if (INSTRUCT_ENABLED && tts.vendor === 'bailian') {
|
|
|
- voiceParams = { ...voiceParams, instructText: `设置情感:${userEmotion}。` };
|
|
|
|
|
|
|
+ voiceParams = { ...voiceParams, instructText: emotionOnlyInstruct(userEmotion) };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ② 音色自带的预设 instructText(如 voice_01_happy → 你正在进行闲聊互动,你说话的情感是happy。)
|
|
|
|
|
+ // 仅在用户未手动指定情感时生效,优先级高于文本自动检测
|
|
|
|
|
+ if (INSTRUCT_ENABLED && tts.vendor === 'bailian' && !(voiceParams as any).instructText) {
|
|
|
|
|
+ const presetInstruct = getVoiceInstructById(voiceId);
|
|
|
|
|
+ if (presetInstruct) {
|
|
|
|
|
+ console.log(`🎭 [Instruct] 音色预设指令: ${voiceId} → ${presetInstruct}`);
|
|
|
|
|
+ voiceParams = { ...voiceParams, instructText: presetInstruct };
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -721,7 +937,7 @@ async function processAudioGeneration(
|
|
|
// 触发条件:voiceId 为空 / 旧系统音色(如'cherry') / 不在统一音色表中
|
|
// 触发条件:voiceId 为空 / 旧系统音色(如'cherry') / 不在统一音色表中
|
|
|
const isDefaultVoice = !voiceId
|
|
const isDefaultVoice = !voiceId
|
|
|
|| voiceId === 'cherry' // 旧系统默认音色
|
|
|| voiceId === 'cherry' // 旧系统默认音色
|
|
|
- || !UNIFIED_VOICES.some(v => v.id === voiceId); // 不在10个统一音色中
|
|
|
|
|
|
|
+ || !UNIFIED_VOICES.some(v => v.id === voiceId); // 不在22个统一音色中
|
|
|
let effectiveVoiceId = voiceId;
|
|
let effectiveVoiceId = voiceId;
|
|
|
|
|
|
|
|
if (isDefaultVoice) {
|
|
if (isDefaultVoice) {
|
|
@@ -741,7 +957,7 @@ async function processAudioGeneration(
|
|
|
|
|
|
|
|
// 为 Aliyun 注入 Instruct 情感控制(兜底:自动检测未触发时用情感关键词兜底)
|
|
// 为 Aliyun 注入 Instruct 情感控制(兜底:自动检测未触发时用情感关键词兜底)
|
|
|
if (INSTRUCT_ENABLED && tts.vendor === 'bailian' && !(voiceParams as any).instructText) {
|
|
if (INSTRUCT_ENABLED && tts.vendor === 'bailian' && !(voiceParams as any).instructText) {
|
|
|
- const instructText = getVoiceInstruct(text);
|
|
|
|
|
|
|
+ const instructText = getVoiceInstruct(text, effectiveVoiceId || voiceId);
|
|
|
if (instructText) {
|
|
if (instructText) {
|
|
|
voiceParams = { ...voiceParams, instructText };
|
|
voiceParams = { ...voiceParams, instructText };
|
|
|
}
|
|
}
|
|
@@ -1025,7 +1241,7 @@ export async function getAudioStatus(audioId: string): Promise<{ status: string;
|
|
|
return { status: 'not_found' };
|
|
return { status: 'not_found' };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 获取可用音色(统一10个音色)
|
|
|
|
|
|
|
+// 获取可用音色(22个:10基础 + 12情感变体)
|
|
|
export function getVoices(): Voice[] {
|
|
export function getVoices(): Voice[] {
|
|
|
return UNIFIED_VOICES;
|
|
return UNIFIED_VOICES;
|
|
|
}
|
|
}
|
|
@@ -1389,13 +1605,22 @@ export async function generatePreview(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
|
|
+ // 注入多维度 Instruct 指令(CosyVoice / Qwen Instruct)
|
|
|
|
|
+ const instructToInject = getVoiceInstructById(voiceId);
|
|
|
|
|
+ const paramsToUse = instructToInject
|
|
|
|
|
+ ? { ...params, instructText: instructToInject }
|
|
|
|
|
+ : params;
|
|
|
|
|
+ if (instructToInject) {
|
|
|
|
|
+ console.log(`🎭 [Preview Instruct] ${voiceId} → ${instructToInject}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// 先创建空文件让 aliyun 下载能定位目录
|
|
// 先创建空文件让 aliyun 下载能定位目录
|
|
|
const ext = tts.vendor.startsWith('minimax') ? 'mp3' : 'wav';
|
|
const ext = tts.vendor.startsWith('minimax') ? 'mp3' : 'wav';
|
|
|
const outputPath = path.join(audioDir, `preview.${ext}`);
|
|
const outputPath = path.join(audioDir, `preview.${ext}`);
|
|
|
if (!fs.existsSync(outputPath)) {
|
|
if (!fs.existsSync(outputPath)) {
|
|
|
fs.writeFileSync(outputPath, Buffer.alloc(0));
|
|
fs.writeFileSync(outputPath, Buffer.alloc(0));
|
|
|
}
|
|
}
|
|
|
- const actualPath = await node.breaker.call(() => tts.synthesize(previewText, voiceName, params, outputPath));
|
|
|
|
|
|
|
+ const actualPath = await node.breaker.call(() => tts.synthesize(previewText, voiceName, paramsToUse, outputPath));
|
|
|
const audioUrl = await storageService.uploadAudio(actualPath, audioId);
|
|
const audioUrl = await storageService.uploadAudio(actualPath, audioId);
|
|
|
console.log(`✅ [TTS Preview] ${tts.name} 生成成功`);
|
|
console.log(`✅ [TTS Preview] ${tts.name} 生成成功`);
|
|
|
return { audioId, audioUrl };
|
|
return { audioId, audioUrl };
|