|
@@ -5,10 +5,19 @@ import { config } from '../../config';
|
|
|
import { prisma } from '../../models';
|
|
import { prisma } from '../../models';
|
|
|
import { VoiceParams, Voice } from '../../types';
|
|
import { VoiceParams, Voice } from '../../types';
|
|
|
import { AliyunTtsProvider } from './aliyun.provider';
|
|
import { AliyunTtsProvider } from './aliyun.provider';
|
|
|
|
|
+import { AliyunRealtimeTtsProvider } from './aliyun-realtime.provider';
|
|
|
import { MockTtsProvider } from './mock.provider';
|
|
import { MockTtsProvider } from './mock.provider';
|
|
|
import { AudioMerger } from './audio-merger';
|
|
import { AudioMerger } from './audio-merger';
|
|
|
import { aiSummaryService } from './ai-summary.service';
|
|
import { aiSummaryService } from './ai-summary.service';
|
|
|
|
|
|
|
|
|
|
+// 日志文件路径
|
|
|
|
|
+const LOG_FILE = path.join(process.cwd(), 'tts-debug.log');
|
|
|
|
|
+
|
|
|
|
|
+function logToFile(msg: string) {
|
|
|
|
|
+ const timestamp = new Date().toISOString();
|
|
|
|
|
+ fs.appendFileSync(LOG_FILE, `[${timestamp}] ${msg}\n`);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// 可用音色列表(使用阿里云官方音色)
|
|
// 可用音色列表(使用阿里云官方音色)
|
|
|
export const VOICES: Voice[] = [
|
|
export const VOICES: Voice[] = [
|
|
|
{ id: 'cherry', name: '芊悦', gender: 'female', description: '阳光积极、亲切自然' },
|
|
{ id: 'cherry', name: '芊悦', gender: 'female', description: '阳光积极、亲切自然' },
|
|
@@ -42,13 +51,23 @@ export function getAliyunVoice(voiceId: string): string {
|
|
|
return VOICE_MAPPING[voiceId] || 'Cherry';
|
|
return VOICE_MAPPING[voiceId] || 'Cherry';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 文本分段
|
|
|
|
|
-export function splitText(text: string, maxLength: number = 500): string[] {
|
|
|
|
|
|
|
+// 判断是否使用长文本模式(>5000字符且启用realtime)
|
|
|
|
|
+// 注意:WebSocket realtime 模式需要特殊的API权限和配置,如果连接失败会导致生成失败。
|
|
|
|
|
+// 暂时强制禁用,使用 HTTP 分段模式
|
|
|
|
|
+export function shouldUseLongText(text: string): boolean {
|
|
|
|
|
+ return false; // 强制返回 false,禁用 realtime 模式
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 文本分段 - 阿里云 TTS 限制 600 字符,增加到 550 留安全余量
|
|
|
|
|
+export function splitText(text: string, maxLength: number = 550): string[] {
|
|
|
const segments: string[] = [];
|
|
const segments: string[] = [];
|
|
|
let current = '';
|
|
let current = '';
|
|
|
|
|
|
|
|
|
|
+ // 清理文本,移除可能导致问题的字符
|
|
|
|
|
+ const cleanText = text.replace(/\r/g, '');
|
|
|
|
|
+
|
|
|
// 按段落分割
|
|
// 按段落分割
|
|
|
- const paragraphs = text.split(/\n+/);
|
|
|
|
|
|
|
+ const paragraphs = cleanText.split(/\n+/);
|
|
|
|
|
|
|
|
for (const para of paragraphs) {
|
|
for (const para of paragraphs) {
|
|
|
if ((current + para).length <= maxLength) {
|
|
if ((current + para).length <= maxLength) {
|
|
@@ -58,7 +77,8 @@ export function splitText(text: string, maxLength: number = 500): string[] {
|
|
|
|
|
|
|
|
// 如果段落本身超长,按句子分割
|
|
// 如果段落本身超长,按句子分割
|
|
|
if (para.length > maxLength) {
|
|
if (para.length > maxLength) {
|
|
|
- const sentences = para.split(/[。!?;]/g);
|
|
|
|
|
|
|
+ // 使用更安全的分割方式
|
|
|
|
|
+ const sentences = para.match(/[^。!?;]+[。!?;]?/g) || [para];
|
|
|
current = '';
|
|
current = '';
|
|
|
|
|
|
|
|
for (const sentence of sentences) {
|
|
for (const sentence of sentences) {
|
|
@@ -86,22 +106,50 @@ export function splitText(text: string, maxLength: number = 500): string[] {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (current) segments.push(current);
|
|
if (current) segments.push(current);
|
|
|
- return segments;
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // 验证每段长度不超过限制(安全检查)
|
|
|
|
|
+ const safeLimit = 550;
|
|
|
|
|
+ const validatedSegments = segments.map((seg, idx) => {
|
|
|
|
|
+ if (seg.length > safeLimit) {
|
|
|
|
|
+ console.warn(`⚠️ 段落 ${idx + 1} 长度 ${seg.length} 超过限制,强制截断`);
|
|
|
|
|
+ return seg.substring(0, safeLimit);
|
|
|
|
|
+ }
|
|
|
|
|
+ return seg;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return validatedSegments;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// TTS Provider 工厂
|
|
// TTS Provider 工厂
|
|
|
-function getTtsProvider() {
|
|
|
|
|
|
|
+function getTtsProvider(text: string, voiceId: string) {
|
|
|
|
|
+ const useLongText = shouldUseLongText(text);
|
|
|
|
|
+
|
|
|
|
|
+ // 文本超长时使用 WebSocket 实时合成
|
|
|
|
|
+ if (useLongText && config.dashscope.apiKey) {
|
|
|
|
|
+ console.log('🔊 文本超过5000字符,使用 Qwen Realtime TTS 流式合成');
|
|
|
|
|
+ return { provider: new AliyunRealtimeTtsProvider(), type: 'realtime' as const };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// 优先使用阿里云百炼 Qwen TTS
|
|
// 优先使用阿里云百炼 Qwen TTS
|
|
|
if (config.dashscope.apiKey) {
|
|
if (config.dashscope.apiKey) {
|
|
|
console.log('🔊 使用阿里云百炼 Qwen TTS 服务');
|
|
console.log('🔊 使用阿里云百炼 Qwen TTS 服务');
|
|
|
- return new AliyunTtsProvider();
|
|
|
|
|
|
|
+ return { provider: new AliyunTtsProvider(), type: 'http' as const };
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
// 降级到模拟服务
|
|
// 降级到模拟服务
|
|
|
- console.log('🔊 使用模拟 TTS 服务');
|
|
|
|
|
- return new MockTtsProvider();
|
|
|
|
|
|
|
+ console.log('🔊 使用模拟 TTS 服务(无 API Key)');
|
|
|
|
|
+ return { provider: new MockTtsProvider(), type: 'mock' as const };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 随机选择 TTS 模型
|
|
|
|
|
+function getRandomModel(): string {
|
|
|
|
|
+ const models = config.dashscope.ttsModels;
|
|
|
|
|
+ const model = models[Math.floor(Math.random() * models.length)];
|
|
|
|
|
+ console.log(`🎲 随机选择 TTS 模型: ${model}`);
|
|
|
|
|
+ return model;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 生成音频
|
|
|
|
|
|
|
+// 生成音频(异步模式)
|
|
|
export async function generateAudio(
|
|
export async function generateAudio(
|
|
|
userId: string,
|
|
userId: string,
|
|
|
text: string,
|
|
text: string,
|
|
@@ -109,17 +157,9 @@ export async function generateAudio(
|
|
|
voiceParams: VoiceParams
|
|
voiceParams: VoiceParams
|
|
|
): Promise<{
|
|
): Promise<{
|
|
|
audioId: string;
|
|
audioId: string;
|
|
|
- audioUrl: string;
|
|
|
|
|
- duration: number;
|
|
|
|
|
- size: number;
|
|
|
|
|
|
|
+ status: string;
|
|
|
}> {
|
|
}> {
|
|
|
- const provider = getTtsProvider();
|
|
|
|
|
-
|
|
|
|
|
- // 分段
|
|
|
|
|
- const segments = splitText(text);
|
|
|
|
|
- console.log(`📝 文本已分段: ${segments.length} 段`);
|
|
|
|
|
-
|
|
|
|
|
- // 创建音频记录
|
|
|
|
|
|
|
+ // 创建音频记录(待处理状态)
|
|
|
const audioId = uuidv4();
|
|
const audioId = uuidv4();
|
|
|
const audioDir = path.join(config.upload.dir, audioId);
|
|
const audioDir = path.join(config.upload.dir, audioId);
|
|
|
|
|
|
|
@@ -127,104 +167,181 @@ export async function generateAudio(
|
|
|
fs.mkdirSync(audioDir, { recursive: true });
|
|
fs.mkdirSync(audioDir, { recursive: true });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 并行生成各段音频
|
|
|
|
|
- const audioFiles: string[] = [];
|
|
|
|
|
- const cloudUrls: string[] = [];
|
|
|
|
|
- const concurrency = 5; // 并发数
|
|
|
|
|
-
|
|
|
|
|
- for (let i = 0; i < segments.length; i += concurrency) {
|
|
|
|
|
- const batch = segments.slice(i, i + concurrency);
|
|
|
|
|
- const results = await Promise.all(
|
|
|
|
|
- batch.map((segment, idx) =>
|
|
|
|
|
- provider.synthesize(segment, getAliyunVoice(voiceId), voiceParams, path.join(audioDir, `segment_${i + idx}.mp3`))
|
|
|
|
|
- )
|
|
|
|
|
- );
|
|
|
|
|
- // 检查是否有云端 URL
|
|
|
|
|
- results.forEach(r => {
|
|
|
|
|
- if (r.startsWith('cloud:')) {
|
|
|
|
|
- cloudUrls.push(r.substring(6));
|
|
|
|
|
- } else {
|
|
|
|
|
- audioFiles.push(r);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ let audio;
|
|
|
|
|
+ try {
|
|
|
|
|
+ audio = await prisma.audio.create({
|
|
|
|
|
+ data: {
|
|
|
|
|
+ userId: userId ? parseInt(userId) : undefined,
|
|
|
|
|
+ title: '处理中...',
|
|
|
|
|
+ text,
|
|
|
|
|
+ summary: '',
|
|
|
|
|
+ tags: '[]',
|
|
|
|
|
+ audioUrl: '',
|
|
|
|
|
+ audioDuration: 0,
|
|
|
|
|
+ audioSize: 0,
|
|
|
|
|
+ wordCount: text.length,
|
|
|
|
|
+ voiceId,
|
|
|
|
|
+ voiceParams: JSON.stringify(voiceParams),
|
|
|
|
|
+ status: 'pending',
|
|
|
|
|
+ },
|
|
|
});
|
|
});
|
|
|
|
|
+ console.log('📝 创建音频记录:', audio.id, '状态: pending');
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('❌ 音频记录创建失败:', error);
|
|
|
|
|
+ throw error;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 如果有云端 URL,直接返回(单段文本情况)
|
|
|
|
|
- let audioUrl = '';
|
|
|
|
|
- let duration = 0;
|
|
|
|
|
- let size = 0;
|
|
|
|
|
-
|
|
|
|
|
- if (cloudUrls.length > 0) {
|
|
|
|
|
- // 使用第一个云端 URL(简化处理)
|
|
|
|
|
- audioUrl = cloudUrls[0];
|
|
|
|
|
- console.log('☁️ 使用云端音频 URL:', audioUrl);
|
|
|
|
|
- } else if (audioFiles.length > 0) {
|
|
|
|
|
- // 合并音频
|
|
|
|
|
- const outputPath = path.join(audioDir, 'output.mp3');
|
|
|
|
|
- const mergedFile = await AudioMerger.merge(audioFiles, outputPath);
|
|
|
|
|
-
|
|
|
|
|
- // 获取文件信息
|
|
|
|
|
- const stats = fs.statSync(mergedFile);
|
|
|
|
|
- size = stats.size;
|
|
|
|
|
- duration = await AudioMerger.getDuration(mergedFile);
|
|
|
|
|
- audioUrl = `/uploads/${audioId}/output.mp3`;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 使用 AI 生成标题、摘要和标签
|
|
|
|
|
- console.log('🤖 使用 AI 生成标题、摘要和标签...');
|
|
|
|
|
- const [title, summary, tags] = await Promise.all([
|
|
|
|
|
- aiSummaryService.generateTitle(text),
|
|
|
|
|
- aiSummaryService.generateSummary(text, 200),
|
|
|
|
|
- aiSummaryService.extractTags(text),
|
|
|
|
|
- ]);
|
|
|
|
|
-
|
|
|
|
|
- // 创建数据库记录
|
|
|
|
|
- const finalAudioUrl = cloudUrls.length > 0 ? cloudUrls[0] : `/uploads/${audioId}/output.mp3`;
|
|
|
|
|
- console.log('📝 创建音频记录:', {
|
|
|
|
|
- userId,
|
|
|
|
|
- title,
|
|
|
|
|
- text: text.substring(0, 50),
|
|
|
|
|
- summary,
|
|
|
|
|
- tags,
|
|
|
|
|
- audioUrl: finalAudioUrl,
|
|
|
|
|
- audioDuration: duration,
|
|
|
|
|
- audioSize: size,
|
|
|
|
|
- wordCount: text.length,
|
|
|
|
|
- voiceId,
|
|
|
|
|
- voiceParams,
|
|
|
|
|
- status: 'completed',
|
|
|
|
|
|
|
+ // 异步处理音频生成
|
|
|
|
|
+ processAudioGeneration(audio.id, text, voiceId, voiceParams, audioDir).catch(error => {
|
|
|
|
|
+ const errMsg = `❌ 异步音频生成失败: ${error.message}`;
|
|
|
|
|
+ console.error(errMsg);
|
|
|
|
|
+ console.error('❌ 错误堆栈:', error.stack);
|
|
|
|
|
+ logToFile(errMsg + '\n' + error.stack);
|
|
|
|
|
+ prisma.audio.update({
|
|
|
|
|
+ where: { id: audio.id },
|
|
|
|
|
+ data: { status: 'failed' },
|
|
|
|
|
+ }).catch(err => console.error('❌ 更新数据库状态失败:', err));
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- let audio;
|
|
|
|
|
|
|
+ // 立即返回音频ID和状态
|
|
|
|
|
+ return {
|
|
|
|
|
+ audioId: audio.id.toString(),
|
|
|
|
|
+ status: 'pending',
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 异步处理音频生成
|
|
|
|
|
+ */
|
|
|
|
|
+async function processAudioGeneration(
|
|
|
|
|
+ audioRecordId: number,
|
|
|
|
|
+ text: string,
|
|
|
|
|
+ voiceId: string,
|
|
|
|
|
+ voiceParams: VoiceParams,
|
|
|
|
|
+ audioDir: string
|
|
|
|
|
+) {
|
|
|
|
|
+ const logMsg = `🔄 开始处理音频 ID: ${audioRecordId}, 文本长度: ${text.length}, voiceId: ${voiceId}`;
|
|
|
|
|
+ console.log(logMsg);
|
|
|
|
|
+ logToFile(logMsg);
|
|
|
|
|
+
|
|
|
try {
|
|
try {
|
|
|
- audio = await prisma.audio.create({
|
|
|
|
|
|
|
+ const { provider, type } = getTtsProvider(text, voiceId);
|
|
|
|
|
+ console.log(`🔧 Provider type: ${type}, voiceName: ${voiceId}`);
|
|
|
|
|
+ logToFile(`Provider type: ${type}, voiceId: ${voiceId}`);
|
|
|
|
|
+ const voiceName = getAliyunVoice(voiceId);
|
|
|
|
|
+ const selectedModel = (type !== 'mock') ? getRandomModel() : undefined;
|
|
|
|
|
+ console.log(`🎲 selectedModel: ${selectedModel}`);
|
|
|
|
|
+
|
|
|
|
|
+ // 根据 Provider 类型决定分段策略
|
|
|
|
|
+ let segments: string[];
|
|
|
|
|
+ if (type === 'realtime') {
|
|
|
|
|
+ segments = [text];
|
|
|
|
|
+ console.log(`📝 使用 Qwen Realtime TTS,文本长度 ${text.length} 字符(不需分段)`);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ segments = splitText(text);
|
|
|
|
|
+ console.log(`📝 文本已分段: ${segments.length} 段`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ segments.forEach((seg, i) => {
|
|
|
|
|
+ console.log(` 段落 ${i + 1}: ${seg.length} 字符`);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 并行生成各段音频
|
|
|
|
|
+ const audioFiles: string[] = [];
|
|
|
|
|
+ const cloudUrls: string[] = [];
|
|
|
|
|
+ const concurrency = type === 'realtime' ? 1 : 2;
|
|
|
|
|
+
|
|
|
|
|
+ for (let i = 0; i < segments.length; i += concurrency) {
|
|
|
|
|
+ const batch = segments.slice(i, i + concurrency);
|
|
|
|
|
+
|
|
|
|
|
+ // 根据类型选择调用方式
|
|
|
|
|
+ let results: string[];
|
|
|
|
|
+ if (type === 'mock') {
|
|
|
|
|
+ results = await Promise.all(
|
|
|
|
|
+ batch.map((segment, idx) =>
|
|
|
|
|
+ (provider as any).synthesize(segment, voiceName, voiceParams, path.join(audioDir, `segment_${i + idx}.mp3`))
|
|
|
|
|
+ )
|
|
|
|
|
+ );
|
|
|
|
|
+ } else {
|
|
|
|
|
+ results = await Promise.all(
|
|
|
|
|
+ batch.map((segment, idx) =>
|
|
|
|
|
+ (provider as any).synthesize(segment, voiceName, voiceParams, path.join(audioDir, `segment_${i + idx}.mp3`), 3, selectedModel)
|
|
|
|
|
+ )
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ results.forEach(r => {
|
|
|
|
|
+ if (r.startsWith('cloud:')) {
|
|
|
|
|
+ cloudUrls.push(r.substring(6));
|
|
|
|
|
+ } else {
|
|
|
|
|
+ audioFiles.push(r);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ console.log(`📁 生成了 ${audioFiles.length} 个音频文件, ${cloudUrls.length} 个云端URL`);
|
|
|
|
|
+
|
|
|
|
|
+ let audioUrl = '';
|
|
|
|
|
+ let duration = 0;
|
|
|
|
|
+ let size = 0;
|
|
|
|
|
+
|
|
|
|
|
+ if (cloudUrls.length > 0) {
|
|
|
|
|
+ audioUrl = cloudUrls[0];
|
|
|
|
|
+ console.log('☁️ 使用云端音频 URL:', audioUrl);
|
|
|
|
|
+ } else if (audioFiles.length > 0) {
|
|
|
|
|
+ const outputPath = path.join(audioDir, 'output.mp3');
|
|
|
|
|
+ const mergedFile = await AudioMerger.merge(audioFiles, outputPath);
|
|
|
|
|
+ const stats = fs.statSync(mergedFile);
|
|
|
|
|
+ size = stats.size;
|
|
|
|
|
+ duration = await AudioMerger.getDuration(mergedFile);
|
|
|
|
|
+ audioUrl = `/uploads/${audioDir.split(/[/\\]/).pop()}/output.mp3`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 使用 AI 生成标题、摘要和标签
|
|
|
|
|
+ console.log('🤖 使用 AI 生成标题、摘要和标签...');
|
|
|
|
|
+ const [title, summary, tags] = await Promise.all([
|
|
|
|
|
+ aiSummaryService.generateTitle(text),
|
|
|
|
|
+ aiSummaryService.generateSummary(text, 200),
|
|
|
|
|
+ aiSummaryService.extractTags(text),
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ const finalAudioUrl = cloudUrls.length > 0 ? cloudUrls[0] : audioUrl;
|
|
|
|
|
+
|
|
|
|
|
+ // 更新数据库记录
|
|
|
|
|
+ await prisma.audio.update({
|
|
|
|
|
+ where: { id: audioRecordId },
|
|
|
data: {
|
|
data: {
|
|
|
- userId: userId ? parseInt(userId) : undefined,
|
|
|
|
|
title,
|
|
title,
|
|
|
- text,
|
|
|
|
|
summary,
|
|
summary,
|
|
|
tags: JSON.stringify(tags),
|
|
tags: JSON.stringify(tags),
|
|
|
audioUrl: finalAudioUrl,
|
|
audioUrl: finalAudioUrl,
|
|
|
audioDuration: duration,
|
|
audioDuration: duration,
|
|
|
audioSize: size,
|
|
audioSize: size,
|
|
|
- wordCount: text.length,
|
|
|
|
|
- voiceId,
|
|
|
|
|
- voiceParams: JSON.stringify(voiceParams),
|
|
|
|
|
status: 'completed',
|
|
status: 'completed',
|
|
|
},
|
|
},
|
|
|
});
|
|
});
|
|
|
- console.log('✅ 音频记录创建成功:', audio.id);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ console.log('✅ 音频生成完成:', audioRecordId);
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- console.error('❌ 音频记录创建失败:', error);
|
|
|
|
|
|
|
+ console.error('❌ processAudioGeneration 错误:', error);
|
|
|
throw error;
|
|
throw error;
|
|
|
}
|
|
}
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- return {
|
|
|
|
|
- audioId: audio.id.toString(),
|
|
|
|
|
- audioUrl: audio.audioUrl,
|
|
|
|
|
- duration,
|
|
|
|
|
- size,
|
|
|
|
|
- };
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * 获取音频状态
|
|
|
|
|
+ */
|
|
|
|
|
+export async function getAudioStatus(audioId: string): Promise<{ status: string; audio?: any }> {
|
|
|
|
|
+ const id = parseInt(audioId);
|
|
|
|
|
+ if (isNaN(id)) {
|
|
|
|
|
+ return { status: 'not_found' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const audio = await prisma.audio.findUnique({ where: { id } });
|
|
|
|
|
+ if (!audio) {
|
|
|
|
|
+ return { status: 'not_found' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return { status: audio.status, audio };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 获取可用音色
|
|
// 获取可用音色
|