|
|
@@ -4,7 +4,6 @@ import { v4 as uuidv4 } from 'uuid';
|
|
|
import { config } from '../../config';
|
|
|
import { prisma } from '../../models';
|
|
|
import { VoiceParams, Voice } from '../../types';
|
|
|
-import { getMiniMaxVoice } from './minimax.provider';
|
|
|
import { AudioMerger } from './audio-merger';
|
|
|
import { aiSummaryService } from './ai-summary.service';
|
|
|
import { storageService } from '../../services/storage.service';
|
|
|
@@ -12,102 +11,56 @@ import { getTtsRegistry, getAvailableTtsProvider, startTtsHealthCheck } from './
|
|
|
import { ITtsProvider } from './provider.interface';
|
|
|
import { CircuitBreakerOpenError } from '../../common/circuit-breaker';
|
|
|
import { ProviderNode } from '../../common/provider-registry';
|
|
|
+import { ttsLogger } from './tts-logger';
|
|
|
import axios from 'axios';
|
|
|
|
|
|
-// 日志文件路径
|
|
|
-const LOG_FILE = path.join(process.cwd(), 'tts-debug.log');
|
|
|
+// ============ 统一音色定义(10个固定音色,前端使用)============
|
|
|
+// 前端使用统一 ID,后端根据 Provider 类型映射到真实音色
|
|
|
+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: '稚嫩天真,适合童话寓言' },
|
|
|
+];
|
|
|
|
|
|
-function logToFile(msg: string) {
|
|
|
- const timestamp = new Date().toISOString();
|
|
|
- fs.appendFileSync(LOG_FILE, `[${timestamp}] ${msg}\n`);
|
|
|
+// 统一音色 → 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> = {
|
|
|
+ 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',
|
|
|
+};
|
|
|
+
|
|
|
+function mapToProviderVoice(unifiedVoiceId: string, provider: string): string {
|
|
|
+ if (provider.startsWith('minimax')) {
|
|
|
+ return MINIMAX_VOICE_MAP[unifiedVoiceId] || MINIMAX_VOICE_MAP['voice_01'];
|
|
|
+ }
|
|
|
+ // 阿里云: CosyVoice 音色ID直接作API参数名(如 longanyang, longyingling_v3)
|
|
|
+ return ALIYUN_VOICE_MAP[unifiedVoiceId] || unifiedVoiceId;
|
|
|
}
|
|
|
|
|
|
-// 可用音色列表(CosyVoice v3-flash - 67个音色)
|
|
|
-// 数据来源:阿里云百炼官方文档 2026-05
|
|
|
-export const VOICES: Voice[] = [
|
|
|
- // 标杆音色
|
|
|
+// 兼容旧代码的旧版音色列表(保留但不再推荐使用)
|
|
|
+const LEGACY_VOICES: Voice[] = [
|
|
|
{ id: 'longanyang', name: '龙安洋', gender: 'male', description: '阳光大男孩 20~30岁' },
|
|
|
{ id: 'longanhuan', name: '龙安欢', gender: 'female', description: '欢脱元气女 20~30岁' },
|
|
|
- { id: 'longhuhu_v3', name: '龙呼呼', gender: 'female', description: '天真烂漫女童 6~10岁' },
|
|
|
- // 有声书 - 男声
|
|
|
- { id: 'longsanshu_v3', name: '龙三叔', gender: 'male', description: '沉稳质感男 25~45岁' },
|
|
|
- { id: 'longxiu_v3', name: '龙修', gender: 'male', description: '博才说书男 25~35岁' },
|
|
|
- { id: 'longnan_v3', name: '龙楠', gender: 'male', description: '睿智青年男 25~30岁' },
|
|
|
- { id: 'longyichen_v3', name: '龙逸尘', gender: 'male', description: '洒脱活力男 20~30岁' },
|
|
|
- { id: 'longlaobo_v3', name: '龙老伯', gender: 'male', description: '沧桑岁月爷 60岁以上' },
|
|
|
- // 有声书 - 女声
|
|
|
- { id: 'longmiao_v3', name: '龙妙', gender: 'female', description: '抑扬顿挫女 25~30岁' },
|
|
|
- { id: 'longyuan_v3', name: '龙媛', gender: 'female', description: '温暖治愈女 35~40岁' },
|
|
|
- { id: 'longyue_v3', name: '龙悦', gender: 'female', description: '温暖磁性女 30~35岁' },
|
|
|
- { id: 'longwanjun_v3', name: '龙婉君', gender: 'female', description: '细腻柔声女 20~30岁' },
|
|
|
- { id: 'longlaoyi_v3', name: '龙老姨', gender: 'female', description: '烟火从容阿姨 60岁以上' },
|
|
|
- // 客服/电话
|
|
|
- { id: 'longyingxun_v3', name: '龙应询', gender: 'male', description: '年轻青涩男 20~25岁' },
|
|
|
- { id: 'longyingjing_v3', name: '龙应静', gender: 'female', description: '低调冷静女 25~35岁' },
|
|
|
- { id: 'longyingling_v3', name: '龙应聆', gender: 'female', description: '温和共情女 25~30岁' },
|
|
|
- { id: 'longyingtao_v3', name: '龙应桃', gender: 'female', description: '温柔淡定女 25~30岁' },
|
|
|
- { id: 'longyingmu_v3', name: '龙应沐', gender: 'female', description: '优雅知性女 25~30岁' },
|
|
|
- { id: 'longyingxiao_v3', name: '龙应笑', gender: 'female', description: '清甜推销女 20~25岁' },
|
|
|
- // 语音助手
|
|
|
- { id: 'longxiaochun_v3', name: '龙小淳', gender: 'female', description: '知性积极女 25~30岁' },
|
|
|
- { id: 'longxiaoxia_v3', name: '龙小夏', gender: 'female', description: '沉稳权威女 25~30岁' },
|
|
|
- { id: 'longanyun_v3', name: '龙安昀', gender: 'male', description: '居家暖男 30~35岁' },
|
|
|
- { id: 'longanwen_v3', name: '龙安温', gender: 'female', description: '优雅知性女 25~35岁' },
|
|
|
- { id: 'longanli_v3', name: '龙安莉', gender: 'female', description: '利落从容女 25~35岁' },
|
|
|
- { id: 'longanlang_v3', name: '龙安朗', gender: 'male', description: '清爽利落男 20~25岁' },
|
|
|
- { id: 'longyumi_v3', name: 'YUMI', gender: 'female', description: '正经青年女 20~25岁' },
|
|
|
- // 社交陪伴
|
|
|
- { id: 'longanqin_v3', name: '龙安亲', gender: 'female', description: '亲和活泼女 20~25岁' },
|
|
|
- { id: 'longanya_v3', name: '龙安雅', gender: 'female', description: '高雅气质女 25~35岁' },
|
|
|
- { id: 'longanzhi_v3', name: '龙安智', gender: 'male', description: '睿智轻熟男 25~35岁' },
|
|
|
- { id: 'longanling_v3', name: '龙安灵', gender: 'female', description: '思维灵动女 20~30岁' },
|
|
|
- { id: 'longantai_v3', name: '龙安台', gender: 'female', description: '嗲甜台湾女 20~25岁' },
|
|
|
- { id: 'longhua_v3', name: '龙华', gender: 'female', description: '元气甜美女 20~25岁' },
|
|
|
- { id: 'longcheng_v3', name: '龙橙', gender: 'male', description: '智慧青年男 20~25岁' },
|
|
|
- { id: 'longze_v3', name: '龙泽', gender: 'male', description: '温暖元气男 25~30岁' },
|
|
|
- { id: 'longzhe_v3', name: '龙哲', gender: 'male', description: '呆板大暖男 25~30岁' },
|
|
|
- { id: 'longyan_v3', name: '龙颜', gender: 'female', description: '温暖春风女 30~35岁' },
|
|
|
- { id: 'longxing_v3', name: '龙星', gender: 'female', description: '温婉邻家女 20~25岁' },
|
|
|
- { id: 'longtian_v3', name: '龙天', gender: 'male', description: '磁性理智男 30~35岁' },
|
|
|
- { id: 'longwan_v3', name: '龙婉', gender: 'female', description: '细腻柔声女 20~30岁' },
|
|
|
- { id: 'longqiang_v3', name: '龙嫱', gender: 'female', description: '浪漫风情女 30~35岁' },
|
|
|
- { id: 'longfeifei_v3', name: '龙菲菲', gender: 'female', description: '甜美娇气女 20~25岁' },
|
|
|
- { id: 'longhao_v3', name: '龙浩', gender: 'male', description: '多情忧郁男 30~35岁' },
|
|
|
- { id: 'longanrou_v3', name: '龙安柔', gender: 'female', description: '温柔闺蜜女 20~35岁' },
|
|
|
- { id: 'longhan_v3', name: '龙寒', gender: 'male', description: '温暖痴情男 30~35岁' },
|
|
|
- // 童声
|
|
|
- { id: 'longpaopao_v3', name: '龙泡泡', gender: 'female', description: '飞天泡泡音 6~15岁' },
|
|
|
- { id: 'longjielidou_v3', name: '龙杰力豆', gender: 'male', description: '阳光顽皮男 10岁' },
|
|
|
- { id: 'longxian_v3', name: '龙仙', gender: 'female', description: '豪放可爱女 12岁' },
|
|
|
- { id: 'longling_v3', name: '龙铃', gender: 'female', description: '稚气呆板女 10岁' },
|
|
|
- { id: 'longshanshan_v3', name: '龙闪闪', gender: 'female', description: '戏剧化童声 6~15岁' },
|
|
|
- { id: 'longniuniu_v3', name: '龙牛牛', gender: 'male', description: '阳光男童声 6~15岁' },
|
|
|
- // 方言
|
|
|
- { id: 'longjiaxin_v3', name: '龙嘉欣', gender: 'female', description: '优雅粤语女 30~35岁' },
|
|
|
- { id: 'longjiayi_v3', name: '龙嘉怡', gender: 'female', description: '知性粤语女 25~30岁' },
|
|
|
- { id: 'longanyue_v3', name: '龙安粤', gender: 'male', description: '欢脱粤语男 25~35岁' },
|
|
|
- { id: 'longlaotie_v3', name: '龙老铁', gender: 'male', description: '东北直率男 25~30岁' },
|
|
|
- { id: 'longshange_v3', name: '龙陕哥', gender: 'male', description: '陕北原味男 25~35岁' },
|
|
|
- // 新闻/直播
|
|
|
- { id: 'longshuo_v3', name: '龙硕', gender: 'male', description: '博才干练男 25~30岁' },
|
|
|
- { id: 'longshu_v3', name: '龙书', gender: 'male', description: '沉稳青年男 20~25岁' },
|
|
|
- { id: 'loongbella_v3', name: 'Bella3.0',gender: 'female', description: '精准干练女 25~30岁' },
|
|
|
- { id: 'longanran_v3', name: '龙安燃', gender: 'female', description: '活泼质感女 30~40岁' },
|
|
|
- { id: 'longanxuan_v3', name: '龙安宣', gender: 'female', description: '经典直播女 30~40岁' },
|
|
|
- // 特色
|
|
|
- { id: 'longjiqi_v3', name: '龙机器', gender: 'male', description: '呆萌机器人 20~30岁' },
|
|
|
- { id: 'longhouge_v3', name: '龙猴哥', gender: 'male', description: '经典猴哥 20~25岁' },
|
|
|
- { id: 'longdaiyu_v3', name: '龙黛玉', gender: 'female', description: '娇率才女音 15~25岁' },
|
|
|
- { id: 'longfei_v3', name: '龙飞', gender: 'male', description: '热血磁性男 30~35岁' },
|
|
|
- // 外语
|
|
|
- { id: 'loongkyong_v3', name: '韩语女', gender: 'female', description: '韩语女声' },
|
|
|
- { id: 'loongriko_v3', name: 'Riko霓虹', gender: 'female', description: '二次元日语女' },
|
|
|
- { id: 'loongabby_v3', name: '美语女', gender: 'female', description: '美式英文女' },
|
|
|
- { id: 'loongandy_v3', name: '美语男', gender: 'male', description: '美式英文男' },
|
|
|
];
|
|
|
|
|
|
-// CosyVoice v3-flash 音色ID直接用作API参数名,无需映射
|
|
|
export function getAliyunVoice(voiceId: string): string {
|
|
|
+ // 旧音色ID直接返回(新音色已统一映射)
|
|
|
return voiceId;
|
|
|
}
|
|
|
|
|
|
@@ -135,7 +88,7 @@ async function getOrCreateDefaultBook(userId: string): Promise<number> {
|
|
|
description: '我的语音合成音频收藏',
|
|
|
userId: userIdNum,
|
|
|
genStage: 'content_completed',
|
|
|
- bookScale: 'short',
|
|
|
+ bookScale: '2000',
|
|
|
totalChapters: 0,
|
|
|
estimatedWords: 0,
|
|
|
},
|
|
|
@@ -379,9 +332,8 @@ async function processAudioGeneration(
|
|
|
ttsProvider?: 'aliyun' | 'minimax';
|
|
|
}
|
|
|
) {
|
|
|
- const logMsg = `🔄 开始处理音频 ID: ${audioId}, 文本长度: ${text.length}, voiceId: ${voiceId}`;
|
|
|
- console.log(logMsg);
|
|
|
- logToFile(logMsg);
|
|
|
+ const logMsg = `开始处理音频 ID: ${audioId}, 文本长度: ${text.length}, voiceId: ${voiceId}`;
|
|
|
+ ttsLogger.info(logMsg);
|
|
|
|
|
|
const registry = getTtsRegistry();
|
|
|
|
|
|
@@ -418,10 +370,10 @@ async function processAudioGeneration(
|
|
|
|
|
|
try {
|
|
|
console.log(`🔊 使用 TTS Provider: ${tts.name} (vendor=${tts.vendor}, mode=${tts.mode})`);
|
|
|
- logToFile(`Provider: ${tts.name}, vendor=${tts.vendor}, mode=${tts.mode}`);
|
|
|
+ ttsLogger.debug(`Provider: ${tts.name}, vendor=${tts.vendor}, mode=${tts.mode}`);
|
|
|
|
|
|
- // 解析音色名称(MiniMax 和阿里云用不同的音色映射表)
|
|
|
- const voiceName = tts.vendor.startsWith('minimax') ? getMiniMaxVoice(voiceId) : getAliyunVoice(voiceId);
|
|
|
+ // 解析音色名称(使用统一音色映射到具体Provider的真实音色)
|
|
|
+ const voiceName = mapToProviderVoice(voiceId, tts.vendor);
|
|
|
|
|
|
// 按模型配置分段:有 maxTextLength 就用它的 80%,没配置就用默认 1000
|
|
|
const segmentMax = tts.maxTextLength ? Math.floor(tts.maxTextLength * 0.8) : 1000;
|
|
|
@@ -506,16 +458,7 @@ async function processAudioGeneration(
|
|
|
]);
|
|
|
const finalAudioUrl = audioUrl;
|
|
|
|
|
|
- // LRC 歌词时间轴
|
|
|
- let lrcLyrics = '';
|
|
|
- try {
|
|
|
- const localAudioPath = path.join(audioDir, 'output.mp3');
|
|
|
- lrcLyrics = generateLrc(text, duration, localAudioPath);
|
|
|
- } catch (lrcErr: any) {
|
|
|
- console.error(`❌ LRC 生成异常:`, lrcErr.message);
|
|
|
- }
|
|
|
-
|
|
|
- // 保存到书籍章节
|
|
|
+ // 保存到书籍章节(先不带LRC,因为异步生成可能还没完成)
|
|
|
const targetBookId = options?.bookId ? parseInt(options.bookId) : null;
|
|
|
const targetChapterId = options?.chapterId ?? null;
|
|
|
if (targetBookId && targetChapterId) {
|
|
|
@@ -525,7 +468,6 @@ async function processAudioGeneration(
|
|
|
data: {
|
|
|
audioUrl: finalAudioUrl,
|
|
|
audioDuration: duration,
|
|
|
- lrcLyrics: lrcLyrics || null,
|
|
|
generatedAt: new Date(),
|
|
|
},
|
|
|
});
|
|
|
@@ -535,6 +477,22 @@ async function processAudioGeneration(
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // LRC 歌词时间轴(异步,不阻塞主流程,失败可接受)
|
|
|
+ const localAudioPath = path.join(audioDir, 'output.mp3');
|
|
|
+ Promise.resolve().then(async () => {
|
|
|
+ try {
|
|
|
+ const lrc = await generateLrc(text, duration, localAudioPath);
|
|
|
+ if (lrc && targetChapterId) {
|
|
|
+ prisma.bookChapter.update({
|
|
|
+ where: { id: targetChapterId },
|
|
|
+ data: { lrcLyrics: lrc },
|
|
|
+ }).catch(() => {});
|
|
|
+ }
|
|
|
+ } catch (lrcErr: any) {
|
|
|
+ console.warn(`⚠️ LRC 生成失败: ${lrcErr.message}`);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
// 更新 AudioRecord 为完成
|
|
|
try {
|
|
|
await prisma.audioRecord.update({
|
|
|
@@ -568,29 +526,32 @@ async function processAudioGeneration(
|
|
|
return { audioId, audioUrl: finalAudioUrl, bookId: targetBookId };
|
|
|
|
|
|
} catch (error: any) {
|
|
|
- // 自动降级:与 LLM 共用 shouldSwitchModel 判断可恢复错误
|
|
|
const errorMsg = error?.message || '';
|
|
|
- const isRecoverable = error instanceof CircuitBreakerOpenError
|
|
|
- || config.models.shouldSwitchModel(error);
|
|
|
-
|
|
|
- // 额度耗尽立即标记(4小时自动恢复)
|
|
|
- if (['quota', 'balance', 'insufficient', 'usage limit'].some(k => errorMsg.toLowerCase().includes(k))) {
|
|
|
- registry.markExhausted(tts.name, errorMsg, 4 * 60 * 60 * 1000);
|
|
|
- }
|
|
|
+
|
|
|
+ // 如果还有下一个 Provider 可降级,就尝试
|
|
|
+ const nextNode = registry.getNextAvailable(tts.name);
|
|
|
+ if (nextNode && !visited.has(nextNode.provider.name)) {
|
|
|
+ ttsLogger.fallback(tts.name, nextNode.provider.name, errorMsg.substring(0, 80));
|
|
|
+
|
|
|
+ // 标记当前 Provider 的健康状态(用于后续请求)
|
|
|
+ if (['quota', 'balance', 'insufficient', 'usage limit'].some(k => errorMsg.toLowerCase().includes(k))) {
|
|
|
+ registry.markExhausted(tts.name, errorMsg, 4 * 60 * 60 * 1000);
|
|
|
+ ttsLogger.quota(tts.name, 'exhausted', errorMsg);
|
|
|
+ }
|
|
|
|
|
|
- if (isRecoverable) {
|
|
|
- console.error(`❌ Provider ${tts.name} 失败(可恢复),通过 getNextAvailable 降级...`);
|
|
|
- currentNode = registry.getNextAvailable(tts.name);
|
|
|
+ currentNode = nextNode;
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
- // 非可恢复错误,直接抛出
|
|
|
- console.error(`❌ processAudioGeneration 失败(非可恢复错误):`, errorMsg);
|
|
|
- throw error;
|
|
|
+ // 没有更多 Provider 可降级
|
|
|
+ const triedList = [...visited, tts.name].join(' → ');
|
|
|
+ const detailMsg = `所有 Provider 尝试失败 [${triedList}],最后错误: ${errorMsg.substring(0, 100)}`;
|
|
|
+ ttsLogger.synthesisFailed(audioId, tts.name, errorMsg, false);
|
|
|
+ throw new Error(detailMsg);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 所有 Provider 都试过了,仍然失败
|
|
|
+ // 所有 Provider 都试过了
|
|
|
throw new Error('所有 TTS Provider 都已尝试,均无法生成音频');
|
|
|
}
|
|
|
|
|
|
@@ -619,8 +580,7 @@ async function synthesizeSegmentWithRetry(
|
|
|
if (!config.models.shouldSwitchModel(err)) throw err;
|
|
|
|
|
|
// 可恢复错误:延迟后重试1次(与 LLM 相同的 2s 延迟)
|
|
|
- console.log(`[TTS] 片段合成失败(${errorMsg.substring(0, 80)}),2s后重试...`);
|
|
|
- logToFile(`[Retry] 片段合成失败,2s后重试: ${errorMsg.substring(0, 100)}`);
|
|
|
+ ttsLogger.warn(`片段合成失败,2s后重试: ${errorMsg.substring(0, 80)}`);
|
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
|
|
|
|
return await node.breaker.call(() =>
|
|
|
@@ -684,9 +644,9 @@ export async function getAudioStatus(audioId: string): Promise<{ status: string;
|
|
|
return { status: 'not_found' };
|
|
|
}
|
|
|
|
|
|
-// 获取可用音色
|
|
|
+// 获取可用音色(统一10个音色)
|
|
|
export function getVoices(): Voice[] {
|
|
|
- return VOICES;
|
|
|
+ return UNIFIED_VOICES;
|
|
|
}
|
|
|
|
|
|
// ============ LRC 歌词生成 ============
|
|
|
@@ -877,7 +837,7 @@ export function generateLrc(text: string, duration: number, audioPath?: string):
|
|
|
const sentences = splitIntoLrcSentences(text);
|
|
|
if (sentences.length === 0) return '';
|
|
|
|
|
|
- logToFile(`🎵 LRC 拆分: ${sentences.length} 句, 总时长=${duration}s`);
|
|
|
+ ttsLogger.lrc(`拆分: ${sentences.length} 句, 总时长=${duration}s`);
|
|
|
|
|
|
// 计算总可见字符数
|
|
|
const totalChars = sentences.reduce((sum, s) => sum + countVisible(s), 0);
|
|
|
@@ -885,7 +845,7 @@ export function generateLrc(text: string, duration: number, audioPath?: string):
|
|
|
|
|
|
// 核心:均匀语速 = 总时长 / 总字数
|
|
|
const speechRate = duration / totalChars; // 秒/字
|
|
|
- logToFile(`🎵 语速: ${speechRate.toFixed(3)}s/字, 总字数=${totalChars}`);
|
|
|
+ ttsLogger.lrc(`语速: ${speechRate.toFixed(3)}s/字, 总字数=${totalChars}`);
|
|
|
|
|
|
const lines: string[] = [];
|
|
|
let currentTime = 0;
|
|
|
@@ -899,7 +859,7 @@ export function generateLrc(text: string, duration: number, audioPath?: string):
|
|
|
|
|
|
lines.push(`[${formatLrcTimestamp(currentTime)}] ${sanitizeLrcText(sentences[i])}`);
|
|
|
|
|
|
- logToFile(` [${formatLrcTimestamp(currentTime)}] ${charCount}字 ${sentences[i].substring(0, 30)}...`);
|
|
|
+ ttsLogger.lrc(`[${formatLrcTimestamp(currentTime)}] ${charCount}字 ${sentences[i].substring(0, 30)}...`);
|
|
|
|
|
|
currentTime += lineDuration;
|
|
|
}
|
|
|
@@ -919,17 +879,17 @@ export function generateLrc(text: string, duration: number, audioPath?: string):
|
|
|
rescaledLines.push(`[${formatLrcTimestamp(rescaledTime)}] ${sanitizeLrcText(sentences[i])}`);
|
|
|
rescaledTime += scaledLineDuration;
|
|
|
}
|
|
|
- logToFile(`🎵 LRC 标题补偿缩放: 原始总时长=${currentTime.toFixed(2)}s, 缩放因子=${scaleFactor.toFixed(3)}`);
|
|
|
+ ttsLogger.lrc(`标题补偿缩放: 原始总时长=${currentTime.toFixed(2)}s, 缩放因子=${scaleFactor.toFixed(3)}`);
|
|
|
return rescaledLines.join('\n');
|
|
|
}
|
|
|
|
|
|
// 确保最后一行不超过总时长
|
|
|
- logToFile(`🎵 LRC 完成: ${lines.length} 行, 末尾时间=${currentTime.toFixed(2)}s, 音频时长=${duration}s`);
|
|
|
+ ttsLogger.lrc(`完成: ${lines.length} 行, 末尾时间=${currentTime.toFixed(2)}s, 音频时长=${duration}s`);
|
|
|
|
|
|
return lines.join('\n');
|
|
|
} catch (err: any) {
|
|
|
- logToFile(`❌ generateLrc 异常: ${err.message}`);
|
|
|
- console.error(`❌ generateLrc 异常:`, err.message);
|
|
|
+ ttsLogger.error('generateLrc 异常', err as Error);
|
|
|
+ console.error(`❌ generateLrc 异常:`, (err as Error).message);
|
|
|
return buildSimpleLrc(text, duration);
|
|
|
}
|
|
|
}
|
|
|
@@ -1018,7 +978,7 @@ export async function generatePreview(
|
|
|
if (node.exhausted || node.breaker.isOpen()) continue;
|
|
|
|
|
|
const tts = node.provider;
|
|
|
- const voiceName = tts.vendor.startsWith('minimax') ? getMiniMaxVoice(voiceId) : getAliyunVoice(voiceId);
|
|
|
+ const voiceName = mapToProviderVoice(voiceId, tts.vendor);
|
|
|
|
|
|
if (tts.mode === 'mock') {
|
|
|
const mockPath = path.join(audioDir, 'preview.mp3');
|