|
@@ -29,14 +29,6 @@ const UNIFIED_VOICES: Voice[] = [
|
|
|
{ id: 'voice_10', name: '稚嫩童声', gender: 'female', description: '稚嫩天真,适合童话寓言' },
|
|
{ id: 'voice_10', name: '稚嫩童声', gender: 'female', description: '稚嫩天真,适合童话寓言' },
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
-// 统一音色 → MiniMax 真实音色映射
|
|
|
|
|
-const MINIMAX_VOICE_MAP: Record<string, string> = {
|
|
|
|
|
- voice_01: 'cherry', voice_02: 'ethan', voice_03: 'chelsie',
|
|
|
|
|
- voice_04: 'serena', voice_05: 'kai', voice_06: 'nofish',
|
|
|
|
|
- voice_07: 'momo', voice_08: 'moon', voice_09: 'maia',
|
|
|
|
|
- voice_10: 'cherry', // MiniMax无童声,用cherry代替
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
// 统一音色 → 阿里云 真实音色映射
|
|
// 统一音色 → 阿里云 真实音色映射
|
|
|
const ALIYUN_VOICE_MAP: Record<string, string> = {
|
|
const ALIYUN_VOICE_MAP: Record<string, string> = {
|
|
|
voice_01: 'longanyang', voice_02: 'longsanshu_v3', voice_03: 'longhuhu_v3',
|
|
voice_01: 'longanyang', voice_02: 'longsanshu_v3', voice_03: 'longhuhu_v3',
|
|
@@ -45,12 +37,82 @@ const ALIYUN_VOICE_MAP: Record<string, string> = {
|
|
|
voice_10: 'longhuhu_v3',
|
|
voice_10: 'longhuhu_v3',
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-function mapToProviderVoice(unifiedVoiceId: string, provider: string): string {
|
|
|
|
|
- if (provider.startsWith('minimax')) {
|
|
|
|
|
- return MINIMAX_VOICE_MAP[unifiedVoiceId] || MINIMAX_VOICE_MAP['voice_01'];
|
|
|
|
|
|
|
+function mapToProviderVoice(unifiedVoiceId: string, _provider: string): string {
|
|
|
|
|
+ const mapped = ALIYUN_VOICE_MAP[unifiedVoiceId];
|
|
|
|
|
+ if (mapped) return mapped;
|
|
|
|
|
+ // 不在映射表中(如遗留的 'cherry' 等旧 MiniMax 音色)→ 用 CosyVoice 默认音色
|
|
|
|
|
+ console.warn(`⚠️ [VoiceMap] 未识别的音色ID: "${unifiedVoiceId}",降级使用默认音色 longyingling_v3`);
|
|
|
|
|
+ return 'longyingling_v3';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ============ Aliyun Instruct 情感/场景控制 ============
|
|
|
|
|
+// 后期优化功能,暂不启用。启用时改为 true
|
|
|
|
|
+const INSTRUCT_ENABLED = false;
|
|
|
|
|
+
|
|
|
|
|
+interface EmotionScene {
|
|
|
|
|
+ emotion: string; // neutral | fearful | angry | sad | surprised | happy | disgusted
|
|
|
|
|
+ scene: string; // 闲聊互动 | 新闻播报 | 广告促销 | 比赛解说 | 一些儿童内容解说 | 语音导航 | 脱口秀表演
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 情感关键词库
|
|
|
|
|
+const EMOTION_KEYWORDS: { emotion: string; keywords: string[] }[] = [
|
|
|
|
|
+ { emotion: 'fearful', keywords: ['恐怖', '可怕', '惊悚', '恐惧', '阴森', '黑暗', '鬼', '死亡', '谋杀', '悬疑', '危险', '深渊', '噩梦'] },
|
|
|
|
|
+ { emotion: 'sad', keywords: ['悲伤', '难过', '哭泣', '眼泪', '心痛', '遗憾', '孤独', '寂寞', '失落', '离别', '思念', '哀伤', '去世', '失去'] },
|
|
|
|
|
+ { emotion: 'angry', keywords: ['愤怒', '生气', '怒火', '仇恨', '战斗', '厮杀', '复仇', '战争', '侵略', '暴怒'] },
|
|
|
|
|
+ { emotion: 'happy', keywords: ['快乐', '开心', '幸福', '欢笑', '庆祝', '美好', '甜蜜', '温暖', '阳光', '喜悦', '高兴', '浪漫', '恋爱', '美好'] },
|
|
|
|
|
+ { emotion: 'surprised', keywords: ['惊奇', '惊喜', '意外', '奇迹', '神奇', '魔法', '童话', '幻想', '奇妙'] },
|
|
|
|
|
+ { emotion: 'disgusted', keywords: ['恶心', '厌恶', '肮脏', '丑陋', '卑鄙'] },
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+// 场景关键词库
|
|
|
|
|
+const SCENE_KEYWORDS: { scene: string; keywords: string[] }[] = [
|
|
|
|
|
+ { scene: '新闻播报', keywords: ['新闻', '报道', '消息', '公告', '通知', '声明', '记者', '据悉', '据新华社', '人民日报'] },
|
|
|
|
|
+ { scene: '一些儿童内容解说', keywords: ['童话', '儿童', '小朋友', '宝宝', '故事', '小熊', '小兔', '公主', '王子', '森林', '魔法', '精灵'] },
|
|
|
|
|
+ { scene: '广告促销', keywords: ['促销', '优惠', '折扣', '限时', '秒杀', '购买', '抢购', '免费', '特价'] },
|
|
|
|
|
+ { scene: '脱口秀表演', keywords: ['搞笑', '幽默', '笑话', '段子', '吐槽', '趣事'] },
|
|
|
|
|
+ { scene: '语音导航', keywords: ['导航', '前方', '左转', '右转', '直行', '到达', '目的地', '路线'] },
|
|
|
|
|
+ { scene: '比赛解说', keywords: ['比赛', '进球', '得分', '冠军', '决赛', '球队', '选手', '比分'] },
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+/** 根据文本内容分析情感 */
|
|
|
|
|
+function detectEmotion(text: string): string {
|
|
|
|
|
+ const scores: Record<string, number> = {};
|
|
|
|
|
+ for (const { emotion, keywords } of EMOTION_KEYWORDS) {
|
|
|
|
|
+ scores[emotion] = 0;
|
|
|
|
|
+ for (const kw of keywords) {
|
|
|
|
|
+ if (text.includes(kw)) scores[emotion]++;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- // 阿里云: CosyVoice 音色ID直接作API参数名(如 longanyang, longyingling_v3)
|
|
|
|
|
- return ALIYUN_VOICE_MAP[unifiedVoiceId] || unifiedVoiceId;
|
|
|
|
|
|
|
+ let best = 'neutral';
|
|
|
|
|
+ let bestScore = 0;
|
|
|
|
|
+ for (const [emotion, score] of Object.entries(scores)) {
|
|
|
|
|
+ if (score > bestScore) { best = emotion; bestScore = score; }
|
|
|
|
|
+ }
|
|
|
|
|
+ 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]++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ let best = '闲聊互动';
|
|
|
|
|
+ let bestScore = 0;
|
|
|
|
|
+ for (const [scene, score] of Object.entries(scores)) {
|
|
|
|
|
+ if (score > bestScore) { best = scene; bestScore = score; }
|
|
|
|
|
+ }
|
|
|
|
|
+ return best;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 根据文本内容动态生成 Aliyun Instruct 文本 */
|
|
|
|
|
+export function getVoiceInstruct(text: string): string {
|
|
|
|
|
+ const emotion = detectEmotion(text);
|
|
|
|
|
+ const scene = detectScene(text);
|
|
|
|
|
+ return `你正在进行${scene},你说话的情感是${emotion}。`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 兼容旧代码的旧版音色列表(保留但不再推荐使用)
|
|
// 兼容旧代码的旧版音色列表(保留但不再推荐使用)
|
|
@@ -223,6 +285,80 @@ export function splitText(text: string, maxLength: number = SEGMENT_MAX_LENGTH):
|
|
|
// │ OpenAI │ TTS-1/HD │ 4,096 │ 隐藏限制 │
|
|
// │ OpenAI │ TTS-1/HD │ 4,096 │ 隐藏限制 │
|
|
|
// └──────────────┴────────────────────┴───────────┴──────────────────────┘
|
|
// └──────────────┴────────────────────┴───────────┴──────────────────────┘
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * TTS 直接生成请求(复用有声书生成立逻辑)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 与有声书的核心区别:内容由用户直接提供(跳过 AI 内容生成),只创建单章节。
|
|
|
|
|
+ * 创建章节后通过 TtsTask 队列异步生成音频,与有声书完全相同的后续流程。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @returns { chapterId, bookId } - 前端通过轮询 genStage 或 WebSocket 获取进度
|
|
|
|
|
+ */
|
|
|
|
|
+export async function requestTtsGeneration(
|
|
|
|
|
+ userId: string,
|
|
|
|
|
+ text: string,
|
|
|
|
|
+ voiceId: string,
|
|
|
|
|
+ voiceParams: VoiceParams,
|
|
|
|
|
+ options?: {
|
|
|
|
|
+ bookId?: string;
|
|
|
|
|
+ chapterTitle?: string;
|
|
|
|
|
+ }
|
|
|
|
|
+): Promise<{
|
|
|
|
|
+ chapterId: number;
|
|
|
|
|
+ bookId: number;
|
|
|
|
|
+}> {
|
|
|
|
|
+ const userIdNum = userId ? parseInt(userId) : null;
|
|
|
|
|
+
|
|
|
|
|
+ // 1. 获取或创建默认书籍
|
|
|
|
|
+ let targetBookId: number;
|
|
|
|
|
+ if (options?.bookId) {
|
|
|
|
|
+ targetBookId = parseInt(options.bookId);
|
|
|
|
|
+ // 验证书籍存在
|
|
|
|
|
+ const book = await prisma.book.findUnique({ where: { id: targetBookId } });
|
|
|
|
|
+ if (!book) {
|
|
|
|
|
+ throw new Error('指定的书籍不存在');
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ targetBookId = await getOrCreateDefaultBook(userId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 在默认书籍下创建章节(内容=用户输入,跳过AI生成)
|
|
|
|
|
+ const chapterTitle = options?.chapterTitle || text.replace(/[\n\r]/g, ' ').substring(0, 30) + (text.length > 30 ? '...' : '');
|
|
|
|
|
+ const wordCount = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
|
|
|
|
|
+
|
|
|
|
|
+ // 找到当前书籍的最大章节序号
|
|
|
|
|
+ const maxChapter = await prisma.bookChapter.findFirst({
|
|
|
|
|
+ where: { bookId: targetBookId, parentId: 0 },
|
|
|
|
|
+ orderBy: { number: 'desc' },
|
|
|
|
|
+ });
|
|
|
|
|
+ const chapterNumber = (maxChapter?.number || 0) + 1;
|
|
|
|
|
+
|
|
|
|
|
+ const chapter = await prisma.bookChapter.create({
|
|
|
|
|
+ data: {
|
|
|
|
|
+ bookId: targetBookId,
|
|
|
|
|
+ parentId: 0, // 顶层章节
|
|
|
|
|
+ level: 1, // 章级别
|
|
|
|
|
+ number: chapterNumber,
|
|
|
|
|
+ title: chapterTitle,
|
|
|
|
|
+ content: text,
|
|
|
|
|
+ wordCount,
|
|
|
|
|
+ genStage: 'content_completed', // 跳过AI内容生成,直接标记内容已完成
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ console.log(`📝 [TTS] 创建章节: chapterId=${chapter.id}, bookId=${targetBookId}, 标题="${chapterTitle}", 字数=${wordCount}`);
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 通过有声书队列生成音频(与有声书完全相同的后续流程)
|
|
|
|
|
+ const { bookStore } = await import('../book-generator/book-generator.store.js');
|
|
|
|
|
+ await bookStore.generateChapterAudioById(chapter.id, userIdNum || undefined, voiceId);
|
|
|
|
|
+
|
|
|
|
|
+ console.log(`✅ [TTS] 音频任务已入队: chapterId=${chapter.id}, voiceId=${voiceId}`);
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ chapterId: chapter.id,
|
|
|
|
|
+ bookId: targetBookId,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// 生成音频(异步模式,不再创建 Audio 记录)
|
|
// 生成音频(异步模式,不再创建 Audio 记录)
|
|
|
export async function generateAudio(
|
|
export async function generateAudio(
|
|
|
userId: string,
|
|
userId: string,
|
|
@@ -375,6 +511,14 @@ async function processAudioGeneration(
|
|
|
// 解析音色名称(使用统一音色映射到具体Provider的真实音色)
|
|
// 解析音色名称(使用统一音色映射到具体Provider的真实音色)
|
|
|
const voiceName = mapToProviderVoice(voiceId, tts.vendor);
|
|
const voiceName = mapToProviderVoice(voiceId, tts.vendor);
|
|
|
|
|
|
|
|
|
|
+ // 为 Aliyun 注入 Instruct 情感/场景控制(后期优化,暂不启用)
|
|
|
|
|
+ if (INSTRUCT_ENABLED && tts.vendor === 'bailian') {
|
|
|
|
|
+ const instructText = getVoiceInstruct(text);
|
|
|
|
|
+ if (instructText) {
|
|
|
|
|
+ voiceParams = { ...voiceParams, instructText };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// 按模型配置分段:有 maxTextLength 就用它的 80%,没配置就用默认 1000
|
|
// 按模型配置分段:有 maxTextLength 就用它的 80%,没配置就用默认 1000
|
|
|
const segmentMax = tts.maxTextLength ? Math.floor(tts.maxTextLength * 0.8) : 1000;
|
|
const segmentMax = tts.maxTextLength ? Math.floor(tts.maxTextLength * 0.8) : 1000;
|
|
|
const segments = splitText(text, segmentMax);
|
|
const segments = splitText(text, segmentMax);
|