|
@@ -1,7 +1,6 @@
|
|
|
import path from 'path';
|
|
import path from 'path';
|
|
|
import fs from 'fs';
|
|
import fs from 'fs';
|
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
-import { execSync } from 'child_process';
|
|
|
|
|
import { config } from '../../config';
|
|
import { config } from '../../config';
|
|
|
import { prisma } from '../../models';
|
|
import { prisma } from '../../models';
|
|
|
import { VoiceParams, Voice } from '../../types';
|
|
import { VoiceParams, Voice } from '../../types';
|
|
@@ -78,7 +77,7 @@ async function getOrCreateDefaultBook(userId: string): Promise<number> {
|
|
|
title: bookTitle,
|
|
title: bookTitle,
|
|
|
description: '我的语音合成音频收藏',
|
|
description: '我的语音合成音频收藏',
|
|
|
userId: userIdNum,
|
|
userId: userIdNum,
|
|
|
- status: 'completed',
|
|
|
|
|
|
|
+ genStage: 'content_completed',
|
|
|
bookScale: 'short',
|
|
bookScale: 'short',
|
|
|
totalChapters: 0,
|
|
totalChapters: 0,
|
|
|
estimatedWords: 0,
|
|
estimatedWords: 0,
|
|
@@ -96,71 +95,132 @@ export function shouldUseLongText(text: string): boolean {
|
|
|
return false; // 强制返回 false,禁用 realtime 模式
|
|
return false; // 强制返回 false,禁用 realtime 模式
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 文本分段 - 阿里云 TTS 限制 600 字符,增加到 550 留安全余量
|
|
|
|
|
-export function splitText(text: string, maxLength: number = 550): string[] {
|
|
|
|
|
|
|
+// 文本分段 - 统一 1000 字符上限,优先在标点处断开
|
|
|
|
|
+// 策略:按自然句子累加,接近上限时在最后一个标点处截断,避免在字中间硬切
|
|
|
|
|
+// 好处:TTS 在句末韵律自然,拼接后听感流畅
|
|
|
|
|
+//
|
|
|
|
|
+// 1000 字的依据(主流 TTS 接口上限,大多以千为单位):
|
|
|
|
|
+// MiniMax 同步: 1万 | 阿里云 CosyVoice: 2万 | 讯飞 流式: ~4000字
|
|
|
|
|
+// 百度 短文本: 5120字 | OpenAI TTS: 4096字 | Google Cloud: 5000字节
|
|
|
|
|
+// ElevenLabs: 3000~5000字 | Azure: ~3000~5000字
|
|
|
|
|
+// 不支持1000的接口(应走异步长文本API,而非短文本接口):
|
|
|
|
|
+// 火山引擎 非流式: 300字 | 腾讯云 基础: 150字 | 百度 短文本: 60字
|
|
|
|
|
+const SEGMENT_MAX_LENGTH = 1000;
|
|
|
|
|
+// 句末标点(中文 + 英文),用于寻找断点
|
|
|
|
|
+const SENTENCE_END_RE = /[。!?;\n.!?;]/;
|
|
|
|
|
+
|
|
|
|
|
+export function splitText(text: string, maxLength: number = SEGMENT_MAX_LENGTH): string[] {
|
|
|
const segments: string[] = [];
|
|
const segments: string[] = [];
|
|
|
- let current = '';
|
|
|
|
|
|
|
|
|
|
- // 清理文本,移除可能导致问题的字符
|
|
|
|
|
|
|
+ // 清理文本
|
|
|
const cleanText = text.replace(/\r/g, '');
|
|
const cleanText = text.replace(/\r/g, '');
|
|
|
|
|
|
|
|
- // 按段落分割
|
|
|
|
|
|
|
+ // 第一步:按换行拆成段落,再按句末标点拆成句子
|
|
|
|
|
+ // 这样保留了段落边界,又不会在一个句子中间断开
|
|
|
|
|
+ const rawSentences: string[] = [];
|
|
|
const paragraphs = cleanText.split(/\n+/);
|
|
const paragraphs = cleanText.split(/\n+/);
|
|
|
|
|
|
|
|
for (const para of paragraphs) {
|
|
for (const para of paragraphs) {
|
|
|
- if ((current + para).length <= maxLength) {
|
|
|
|
|
- current += (current ? '\n' : '') + para;
|
|
|
|
|
- } else {
|
|
|
|
|
- if (current) segments.push(current);
|
|
|
|
|
|
|
+ if (para.length === 0) continue;
|
|
|
|
|
+
|
|
|
|
|
+ // 按句末标点拆分,保留标点
|
|
|
|
|
+ const sentences = para.match(/[^。!?;.!?;]+[。!?;.!?;]?/g) || [para];
|
|
|
|
|
+ for (const s of sentences) {
|
|
|
|
|
+ if (s.trim().length > 0) {
|
|
|
|
|
+ rawSentences.push(s);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // 如果段落本身超长,按句子分割
|
|
|
|
|
- if (para.length > maxLength) {
|
|
|
|
|
- // 使用更安全的分割方式
|
|
|
|
|
- const sentences = para.match(/[^。!?;]+[。!?;]?/g) || [para];
|
|
|
|
|
- current = '';
|
|
|
|
|
|
|
+ // 第二步:将句子累加成段,接近上限时在最后一个标点处截断
|
|
|
|
|
+ let current = '';
|
|
|
|
|
|
|
|
- for (const sentence of sentences) {
|
|
|
|
|
- if (sentence.length === 0) continue;
|
|
|
|
|
|
|
+ for (const sentence of rawSentences) {
|
|
|
|
|
+ // 当前段 + 这句还能放下
|
|
|
|
|
+ if ((current + sentence).length <= maxLength) {
|
|
|
|
|
+ current += sentence;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- if ((current + sentence).length <= maxLength) {
|
|
|
|
|
- current += sentence;
|
|
|
|
|
- } else {
|
|
|
|
|
- if (current) segments.push(current);
|
|
|
|
|
- // 如果句子本身超长,强制分割
|
|
|
|
|
- if (sentence.length > maxLength) {
|
|
|
|
|
- for (let i = 0; i < sentence.length; i += maxLength) {
|
|
|
|
|
- segments.push(sentence.slice(i, i + maxLength));
|
|
|
|
|
- }
|
|
|
|
|
- current = '';
|
|
|
|
|
- } else {
|
|
|
|
|
- current = sentence;
|
|
|
|
|
|
|
+ // 放不下了,先把当前段输出
|
|
|
|
|
+ if (current) {
|
|
|
|
|
+ segments.push(current);
|
|
|
|
|
+ current = '';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 如果这句话本身就超长,需要在句内找标点断点
|
|
|
|
|
+ if (sentence.length > maxLength) {
|
|
|
|
|
+ let remaining = sentence;
|
|
|
|
|
+ while (remaining.length > 0) {
|
|
|
|
|
+ if (remaining.length <= maxLength) {
|
|
|
|
|
+ current = remaining;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 在上限附近向前找最后一个标点作为断点
|
|
|
|
|
+ let breakPos = -1;
|
|
|
|
|
+ for (let i = maxLength; i > Math.max(0, maxLength - 100); i--) {
|
|
|
|
|
+ if (SENTENCE_END_RE.test(remaining[i])) {
|
|
|
|
|
+ breakPos = i + 1; // 标点后一位
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 找不到标点,尝试逗号/顿号等次级标点
|
|
|
|
|
+ if (breakPos === -1) {
|
|
|
|
|
+ for (let i = maxLength; i > Math.max(0, maxLength - 100); i--) {
|
|
|
|
|
+ if (/[,、,]/.test(remaining[i])) {
|
|
|
|
|
+ breakPos = i + 1;
|
|
|
|
|
+ break;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- } else {
|
|
|
|
|
- current = para;
|
|
|
|
|
|
|
+ // 实在找不到标点,只能在上限处硬切(最后手段)
|
|
|
|
|
+ if (breakPos === -1) {
|
|
|
|
|
+ breakPos = maxLength;
|
|
|
|
|
+ }
|
|
|
|
|
+ segments.push(remaining.slice(0, breakPos));
|
|
|
|
|
+ remaining = remaining.slice(breakPos);
|
|
|
}
|
|
}
|
|
|
|
|
+ } else {
|
|
|
|
|
+ current = sentence;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (current) segments.push(current);
|
|
if (current) segments.push(current);
|
|
|
|
|
|
|
|
- // 验证每段长度不超过限制(安全检查)
|
|
|
|
|
- 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;
|
|
|
|
|
|
|
+ return segments;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// TTS Provider 工厂
|
|
// TTS Provider 工厂
|
|
|
-// 支持: aliyun (阿里云百炼), minimax (MiniMax), mock (模拟)
|
|
|
|
|
-// 默认使用 MiniMax speech-2.8-hd
|
|
|
|
|
|
|
+// MiniMax: 异步模式(提交任务 → 轮询 → 下载)
|
|
|
|
|
+// 阿里云 Qwen-TTS: 同步模式(直接返回音频 URL → 下载),不支持 X-DashScope-Async
|
|
|
|
|
+//
|
|
|
|
|
+// 各家 TTS API 限制参考(2026年5月):
|
|
|
|
|
+// ┌──────────────┬────────────────────┬───────────┬──────────────────────┐
|
|
|
|
|
+// │ 服务商 │ 接口类型 │ 最大长度 │ 备注 │
|
|
|
|
|
+// ├──────────────┼────────────────────┼───────────┼──────────────────────┤
|
|
|
|
|
+// │ MiniMax │ 异步长文本 │ 1,000,000 │ 业界最长 │
|
|
|
|
|
+// │ MiniMax │ 同步 │ 10,000 │ >3000推荐异步 │
|
|
|
|
|
+// │ 阿里云 │ 非流式/单向流式 │ 20,000 │ CosyVoice │
|
|
|
|
|
+// │ 阿里云 │ WebSocket 流式 │ 20,000 │ 累计200,000 │
|
|
|
|
|
+// │ 阿里云 │ 传统长文本 │ 80,000 │ 建议40,000以内 │
|
|
|
|
|
+// │ 火山引擎/豆包 │ 异步长文本 │ 100,000 │ 音频保存7天 │
|
|
|
|
|
+// │ 火山引擎/豆包 │ 非流式 │ 1,024字节 │ 建议<300字符 │
|
|
|
|
|
+// │ 百度 │ 长文本异步 │ 100,000 │ 一次性合成 │
|
|
|
|
|
+// │ 百度 │ 短文本 │ 5,120 │ ~10240字节 │
|
|
|
|
|
+// │ 讯飞 │ 长文本TTS │ 100,000 │ 万字级别快速合成 │
|
|
|
|
|
+// │ 讯飞 │ 流式在线 │ 8,000字节 │ ~4000汉字 │
|
|
|
|
|
+// │ 腾讯云 │ 长文本语音合成 │ 10,000+ │ 每个speak标签≤150字 │
|
|
|
|
|
+// │ 腾讯云 │ 基础语音合成 │ 150 │ 中文限制严格 │
|
|
|
|
|
+// │ OpenAI │ TTS-1/HD │ 4,096 │ 隐藏限制 │
|
|
|
|
|
+// │ Google Cloud │ Text-to-Speech │ 5,000字节 │ SSML也计入 │
|
|
|
|
|
+// │ Azure │ 实时合成 │ ~10分钟 │ 按音频时长限制 │
|
|
|
|
|
+// │ ElevenLabs │ Flash/Turbo │ 5,000 │ 付费计划 │
|
|
|
|
|
+// │ ElevenLabs │ eleven_v3 │ 3,000 │ 表现力最强但限制低 │
|
|
|
|
|
+// │ Fish Audio │ Fish Speech │ ~8,192 │ tokens限制 │
|
|
|
|
|
+// └──────────────┴────────────────────┴───────────┴──────────────────────┘
|
|
|
|
|
+//
|
|
|
|
|
+// 当前分段上限 1000 字符,覆盖所有主流接口的短文本限制
|
|
|
|
|
+// 不满足1000字的接口(火山300/腾讯150/百度60)应走各自的异步长文本API
|
|
|
function getTtsProvider(text: string, voiceId: string, providerType?: string) {
|
|
function getTtsProvider(text: string, voiceId: string, providerType?: string) {
|
|
|
// 如果指定了 provider 类型,直接使用
|
|
// 如果指定了 provider 类型,直接使用
|
|
|
if (providerType === 'minimax') {
|
|
if (providerType === 'minimax') {
|
|
@@ -266,6 +326,15 @@ export async function generateAudio(
|
|
|
return result;
|
|
return result;
|
|
|
} catch (error: any) {
|
|
} catch (error: any) {
|
|
|
console.error('❌ 音频生成失败:', error.message);
|
|
console.error('❌ 音频生成失败:', error.message);
|
|
|
|
|
+ // 更新 AudioRecord 状态为失败
|
|
|
|
|
+ try {
|
|
|
|
|
+ await prisma.audioRecord.update({
|
|
|
|
|
+ where: { audioId },
|
|
|
|
|
+ data: { status: 'failed', errorMsg: error.message },
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (dbError) {
|
|
|
|
|
+ console.error('❌ 更新 AudioRecord 失败状态失败:', dbError);
|
|
|
|
|
+ }
|
|
|
// 创建失败标记文件
|
|
// 创建失败标记文件
|
|
|
const failedMarker = path.join(audioDir, 'failed');
|
|
const failedMarker = path.join(audioDir, 'failed');
|
|
|
fs.writeFileSync(failedMarker, error.message);
|
|
fs.writeFileSync(failedMarker, error.message);
|
|
@@ -328,16 +397,12 @@ async function processAudioGeneration(
|
|
|
const selectedModel = (type !== 'mock' && type !== 'minimax') ? getRandomModel() : undefined;
|
|
const selectedModel = (type !== 'mock' && type !== 'minimax') ? getRandomModel() : undefined;
|
|
|
console.log(`🎲 selectedModel: ${selectedModel}, voiceName: ${voiceName}`);
|
|
console.log(`🎲 selectedModel: ${selectedModel}, voiceName: ${voiceName}`);
|
|
|
|
|
|
|
|
- // 根据 Provider 类型决定分段策略
|
|
|
|
|
- let segments: string[];
|
|
|
|
|
- if (type === 'minimax') {
|
|
|
|
|
- // MiniMax 异步支持长文本,无需分段
|
|
|
|
|
- segments = [text];
|
|
|
|
|
- console.log(`📝 使用 MiniMax TTS,文本长度 ${text.length} 字符(不需分段)`);
|
|
|
|
|
- } else {
|
|
|
|
|
- segments = splitText(text);
|
|
|
|
|
- console.log(`📝 文本已分段: ${segments.length} 段`);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // 统一分段策略:所有 Provider 使用 550 字符/段
|
|
|
|
|
+ // 理由:1) 小段通用,兼容任何 TTS 模型(新加模型无需改代码)
|
|
|
|
|
+ // 2) 失败只需重试某段,不需要整篇重来
|
|
|
|
|
+ // 3) 所有 Provider 走同一套合并逻辑,行为一致
|
|
|
|
|
+ const segments = splitText(text);
|
|
|
|
|
+ console.log(`📝 文本已分段: ${segments.length} 段 (Provider: ${type})`);
|
|
|
|
|
|
|
|
segments.forEach((seg, i) => {
|
|
segments.forEach((seg, i) => {
|
|
|
console.log(` 段落 ${i + 1}: ${seg.length} 字符`);
|
|
console.log(` 段落 ${i + 1}: ${seg.length} 字符`);
|
|
@@ -389,9 +454,9 @@ async function processAudioGeneration(
|
|
|
let duration = 0;
|
|
let duration = 0;
|
|
|
let size = 0;
|
|
let size = 0;
|
|
|
|
|
|
|
|
- if (cloudUrls.length > 0) {
|
|
|
|
|
- // 云端 URL:下载后统一通过 storageService 上传到 OSS
|
|
|
|
|
- console.log('☁️ 下载云端音频并上传到 OSS...');
|
|
|
|
|
|
|
+ if (cloudUrls.length > 0 && audioFiles.length === 0 && cloudUrls.length === 1) {
|
|
|
|
|
+ // 只有1个云端URL且没有本地文件:直接下载并上传
|
|
|
|
|
+ console.log('☁️ 下载单个云端音频并上传到 OSS...');
|
|
|
try {
|
|
try {
|
|
|
const cloudUrl = cloudUrls[0];
|
|
const cloudUrl = cloudUrls[0];
|
|
|
const response = await axios.get(cloudUrl, { responseType: 'arraybuffer', timeout: 60000 });
|
|
const response = await axios.get(cloudUrl, { responseType: 'arraybuffer', timeout: 60000 });
|
|
@@ -413,28 +478,41 @@ async function processAudioGeneration(
|
|
|
const stats = fs.statSync(tempPath);
|
|
const stats = fs.statSync(tempPath);
|
|
|
size = stats.size;
|
|
size = stats.size;
|
|
|
} catch (uploadError: any) {
|
|
} catch (uploadError: any) {
|
|
|
- console.error('❌ 云端音频上传失败,使用本地文件:', uploadError.message);
|
|
|
|
|
- // 降级到本地文件处理
|
|
|
|
|
- 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 = await storageService.uploadAudio(mergedFile, audioId);
|
|
|
|
|
- } else {
|
|
|
|
|
- throw new Error('云端音频上传失败且无本地文件降级');
|
|
|
|
|
|
|
+ console.error('❌ 云端音频上传失败:', uploadError.message);
|
|
|
|
|
+ throw new Error('云端音频下载/上传失败');
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 有多个文件需要合并(混合云端+本地,或多个云端,或多个本地)
|
|
|
|
|
+ // 先下载所有云端URL到本地
|
|
|
|
|
+ const allLocalFiles = [...audioFiles];
|
|
|
|
|
+ if (cloudUrls.length > 0) {
|
|
|
|
|
+ console.log(`☁️ 下载 ${cloudUrls.length} 个云端音频文件...`);
|
|
|
|
|
+ for (let ci = 0; ci < cloudUrls.length; ci++) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await axios.get(cloudUrls[ci], { responseType: 'arraybuffer', timeout: 60000 });
|
|
|
|
|
+ const localPath = path.join(audioDir, `cloud_segment_${ci}.mp3`);
|
|
|
|
|
+ fs.writeFileSync(localPath, Buffer.from(response.data));
|
|
|
|
|
+ allLocalFiles.push(localPath);
|
|
|
|
|
+ console.log(`☁️ 云端音频 ${ci + 1}/${cloudUrls.length} 已下载`);
|
|
|
|
|
+ } catch (dlErr: any) {
|
|
|
|
|
+ console.error(`❌ 云端音频 ${ci + 1} 下载失败:`, dlErr.message);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- } else if (audioFiles.length > 0) {
|
|
|
|
|
|
|
+
|
|
|
|
|
+ if (allLocalFiles.length === 0) {
|
|
|
|
|
+ throw new Error('所有音频文件下载失败,无法生成音频');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 合并所有文件
|
|
|
const outputPath = path.join(audioDir, 'output.mp3');
|
|
const outputPath = path.join(audioDir, 'output.mp3');
|
|
|
- const mergedFile = await AudioMerger.merge(audioFiles, outputPath);
|
|
|
|
|
|
|
+ console.log(`📁 合并 ${allLocalFiles.length} 个音频文件...`);
|
|
|
|
|
+ const mergedFile = await AudioMerger.merge(allLocalFiles, outputPath);
|
|
|
const stats = fs.statSync(mergedFile);
|
|
const stats = fs.statSync(mergedFile);
|
|
|
size = stats.size;
|
|
size = stats.size;
|
|
|
duration = await AudioMerger.getDuration(mergedFile);
|
|
duration = await AudioMerger.getDuration(mergedFile);
|
|
|
- // 统一通过 storageService 上传
|
|
|
|
|
audioUrl = await storageService.uploadAudio(mergedFile, audioId);
|
|
audioUrl = await storageService.uploadAudio(mergedFile, audioId);
|
|
|
- console.log('📁 本地音频已上传:', audioUrl);
|
|
|
|
|
|
|
+ console.log(`📁 音频已合并并上传 (${duration}s):`, audioUrl);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 使用 AI 生成标题、摘要和标签
|
|
// 使用 AI 生成标题、摘要和标签
|
|
@@ -449,10 +527,24 @@ async function processAudioGeneration(
|
|
|
const finalAudioUrl = audioUrl;
|
|
const finalAudioUrl = audioUrl;
|
|
|
|
|
|
|
|
// 生成 LRC 格式歌词时间轴(优先使用 FFmpeg 精确停顿检测)
|
|
// 生成 LRC 格式歌词时间轴(优先使用 FFmpeg 精确停顿检测)
|
|
|
- const localAudioPath = path.join(audioDir, 'output.mp3');
|
|
|
|
|
- const lrcLyrics = generateLrc(text, duration, localAudioPath);
|
|
|
|
|
|
|
+ let lrcLyrics = '';
|
|
|
|
|
+ try {
|
|
|
|
|
+ const localAudioPath = path.join(audioDir, 'output.mp3');
|
|
|
|
|
+ const fileExists = fs.existsSync(localAudioPath);
|
|
|
|
|
+ logToFile(`🎵 LRC 开始: 本地文件=${localAudioPath}, 存在=${fileExists}, duration=${duration}, textLen=${text.length}`);
|
|
|
|
|
+ console.log(`🎵 开始生成 LRC, 本地文件: ${localAudioPath}, 存在: ${fileExists}`);
|
|
|
|
|
+ lrcLyrics = generateLrc(text, duration, localAudioPath);
|
|
|
|
|
+ logToFile(`🎵 LRC 结果: length=${lrcLyrics.length}, lines=${lrcLyrics ? lrcLyrics.split('\n').length : 0}`);
|
|
|
|
|
+ } catch (lrcErr: any) {
|
|
|
|
|
+ logToFile(`❌ LRC 异常: ${lrcErr.message}`);
|
|
|
|
|
+ console.error(`❌ LRC 生成异常:`, lrcErr.message);
|
|
|
|
|
+ }
|
|
|
if (lrcLyrics) {
|
|
if (lrcLyrics) {
|
|
|
console.log(`🎵 LRC 歌词已生成: ${lrcLyrics.split('\n').length} 句`);
|
|
console.log(`🎵 LRC 歌词已生成: ${lrcLyrics.split('\n').length} 句`);
|
|
|
|
|
+ logToFile(`🎵 LRC 保存: ${lrcLyrics.split('\n').length} 句, 首行=${lrcLyrics.split('\n')[0]}`);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.warn(`⚠️ LRC 生成结果为空`);
|
|
|
|
|
+ logToFile(`⚠️ LRC 为空`);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 保存到书籍章节(所有音频必须属于书籍)
|
|
// 保存到书籍章节(所有音频必须属于书籍)
|
|
@@ -461,21 +553,24 @@ async function processAudioGeneration(
|
|
|
|
|
|
|
|
if (targetBookId && targetChapterId) {
|
|
if (targetBookId && targetChapterId) {
|
|
|
try {
|
|
try {
|
|
|
|
|
+ logToFile(`💾 写入章 chapterId=${targetChapterId}, audioUrl=${finalAudioUrl?.substring(0,40)}..., dur=${duration}, lrcLen=${lrcLyrics?.length || 0}`);
|
|
|
await prisma.bookChapter.update({
|
|
await prisma.bookChapter.update({
|
|
|
where: { id: targetChapterId },
|
|
where: { id: targetChapterId },
|
|
|
data: {
|
|
data: {
|
|
|
audioUrl: finalAudioUrl,
|
|
audioUrl: finalAudioUrl,
|
|
|
audioDuration: duration,
|
|
audioDuration: duration,
|
|
|
lrcLyrics: lrcLyrics || null,
|
|
lrcLyrics: lrcLyrics || null,
|
|
|
- status: 'completed',
|
|
|
|
|
generatedAt: new Date(),
|
|
generatedAt: new Date(),
|
|
|
},
|
|
},
|
|
|
});
|
|
});
|
|
|
|
|
+ logToFile(`✅ 章更新成功 chapterId=${targetChapterId}`);
|
|
|
console.log(`✅ 已更新书籍章节音频 (chapterId: ${targetChapterId}), URL: ${finalAudioUrl}`);
|
|
console.log(`✅ 已更新书籍章节音频 (chapterId: ${targetChapterId}), URL: ${finalAudioUrl}`);
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
|
|
+ logToFile(`❌ 章更新失败 chapterId=${targetChapterId}: ${error}`);
|
|
|
console.error('❌ 保存到书籍章节失败:', error);
|
|
console.error('❌ 保存到书籍章节失败:', error);
|
|
|
}
|
|
}
|
|
|
} else {
|
|
} else {
|
|
|
|
|
+ logToFile(`⚠️ 跳过章保存: bookId=${targetBookId}, chapterId=${targetChapterId}`);
|
|
|
console.warn(`⚠️ 未指定 bookId 或 chapterId,不能保存音频到书籍章节`);
|
|
console.warn(`⚠️ 未指定 bookId 或 chapterId,不能保存音频到书籍章节`);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -603,142 +698,255 @@ export function getVoices(): Voice[] {
|
|
|
return VOICES;
|
|
return VOICES;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// ============ FFmpeg 静音检测 + 精确 LRC 生成 ============
|
|
|
|
|
|
|
+// ============ LRC 歌词生成 ============
|
|
|
|
|
+
|
|
|
|
|
+/** 格式化秒数为 [MM:SS.XX] */
|
|
|
|
|
+function formatLrcTimestamp(seconds: number): string {
|
|
|
|
|
+ const m = Math.floor(seconds / 60);
|
|
|
|
|
+ const s = seconds % 60;
|
|
|
|
|
+ const cs = Math.round((s - Math.floor(s)) * 100);
|
|
|
|
|
+ const sec = Math.floor(s);
|
|
|
|
|
+ return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}.${cs.toString().padStart(2, '0')}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 清洗文本中的换行符(LRC每行只能有一个时间戳,文本中不能含换行) */
|
|
|
|
|
+function sanitizeLrcText(s: string): string {
|
|
|
|
|
+ return s.replace(/\n+/g, ' ').trim();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 统计可见字符数(去除空白) */
|
|
|
|
|
+function countVisible(s: string): number {
|
|
|
|
|
+ return s.replace(/\s/g, '').length;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 最大每行字符数(超过则进一步拆分) */
|
|
|
|
|
+const MAX_CHARS_PER_LINE = 50;
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 用 FFmpeg 检测音频中的静音间隙,返回真实的停顿时间点(秒)
|
|
|
|
|
- * silencedetect 参数:-30dB 阈值,0.3s 最小静音时长
|
|
|
|
|
|
|
+ * 将文本拆分为适合 LRC 显示的小句子
|
|
|
|
|
+ * 策略:
|
|
|
|
|
+ * 1. 先按 Markdown 标题(#/##)切分,标题独立一行
|
|
|
|
|
+ * 2. 按句末标点(。!?)切分
|
|
|
|
|
+ * 3. 按分号/冒号(;:)切分
|
|
|
|
|
+ * 4. 按逗号(,,)切分
|
|
|
|
|
+ * 5. 按换行切分
|
|
|
|
|
+ * 6. 兜底:按固定字符数切分
|
|
|
|
|
+ * 每行不超过 MAX_CHARS_PER_LINE 字符
|
|
|
*/
|
|
*/
|
|
|
-export function detectAudioSilence(audioPath: string): number[] {
|
|
|
|
|
- try {
|
|
|
|
|
- const result = execSync(
|
|
|
|
|
- `ffmpeg -i "${audioPath}" -af silencedetect=n=-30dB:d=0.3 -f null - 2>&1`,
|
|
|
|
|
- { timeout: 30000, encoding: 'utf-8' }
|
|
|
|
|
- );
|
|
|
|
|
- const silenceEnds: number[] = [];
|
|
|
|
|
- const regex = /silence_end:\s*([\d.]+)/g;
|
|
|
|
|
- let match;
|
|
|
|
|
- while ((match = regex.exec(result)) !== null) {
|
|
|
|
|
- silenceEnds.push(parseFloat(match[1]));
|
|
|
|
|
|
|
+function splitIntoLrcSentences(text: string): string[] {
|
|
|
|
|
+ const result: string[] = [];
|
|
|
|
|
+ const maxChars = MAX_CHARS_PER_LINE;
|
|
|
|
|
+
|
|
|
|
|
+ // 第一步:按 Markdown 标题切分,标题独立成行
|
|
|
|
|
+ const titleParts = text.split(/(?=^#{1,3}\s)/m);
|
|
|
|
|
+
|
|
|
|
|
+ for (const part of titleParts) {
|
|
|
|
|
+ const trimmed = part.trim();
|
|
|
|
|
+ if (!trimmed) continue;
|
|
|
|
|
+
|
|
|
|
|
+ // 如果是标题行(单独一行)
|
|
|
|
|
+ const titleMatch = trimmed.match(/^(#{1,3}\s+.+?)(\n|$)/);
|
|
|
|
|
+ if (titleMatch) {
|
|
|
|
|
+ const titleLine = titleMatch[1].trim();
|
|
|
|
|
+ const rest = trimmed.substring(titleMatch[0].length).trim();
|
|
|
|
|
+
|
|
|
|
|
+ // 标题单独一行
|
|
|
|
|
+ result.push(titleLine);
|
|
|
|
|
+
|
|
|
|
|
+ // 处理标题后面的正文
|
|
|
|
|
+ if (rest) {
|
|
|
|
|
+ result.push(...splitBodyIntoSentences(rest, maxChars));
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 没有标题,直接处理正文
|
|
|
|
|
+ result.push(...splitBodyIntoSentences(trimmed, maxChars));
|
|
|
}
|
|
}
|
|
|
- console.log(`🔇 FFmpeg 检测到 ${silenceEnds.length} 个静音点`);
|
|
|
|
|
- return silenceEnds;
|
|
|
|
|
- } catch (err: any) {
|
|
|
|
|
- console.warn(`⚠️ FFmpeg 静音检测失败,降级到估算模式:`, err.message);
|
|
|
|
|
- return [];
|
|
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ return result.filter(s => countVisible(s) > 0);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 按 Markdown 结构分段(优先级:# 标题 > ## 小节 > 空行分段 > 。!?断句) */
|
|
|
|
|
-function splitByMarkdownSections(text: string): string[] {
|
|
|
|
|
- const sections: string[] = [];
|
|
|
|
|
-
|
|
|
|
|
- // 先按一级标题分割
|
|
|
|
|
- const h1Parts = text.split(/(?=^# )/m);
|
|
|
|
|
- for (const h1Part of h1Parts) {
|
|
|
|
|
- if (!h1Part.trim()) continue;
|
|
|
|
|
- // 再按二级标题分割
|
|
|
|
|
- const h2Parts = h1Part.split(/(?=^## )/m);
|
|
|
|
|
- for (const h2Part of h2Parts) {
|
|
|
|
|
- const trimmed = h2Part.trim();
|
|
|
|
|
- if (!trimmed) continue;
|
|
|
|
|
- // 标题行占比少,作为一个独立段落
|
|
|
|
|
- const isHeading = /^#{1,2}\s/.test(trimmed);
|
|
|
|
|
- if (isHeading) {
|
|
|
|
|
- sections.push(trimmed);
|
|
|
|
|
- } else {
|
|
|
|
|
- // 正文段落:按空行分割
|
|
|
|
|
- const paras = trimmed.split(/\n{2,}/).filter(p => p.trim());
|
|
|
|
|
- for (const para of paras) {
|
|
|
|
|
- sections.push(para.trim());
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * 将正文拆分为小句子
|
|
|
|
|
+ * 优先级:句号 > 分号/冒号 > 逗号 > 换行 > 固定长度
|
|
|
|
|
+ */
|
|
|
|
|
+function splitBodyIntoSentences(text: string, maxChars: number): string[] {
|
|
|
|
|
+ const result: string[] = [];
|
|
|
|
|
+
|
|
|
|
|
+ // 先按段落(空行/换行)粗分
|
|
|
|
|
+ const paragraphs = text.split(/\n+/).filter(p => p.trim());
|
|
|
|
|
+
|
|
|
|
|
+ for (const para of paragraphs) {
|
|
|
|
|
+ const trimmed = para.trim();
|
|
|
|
|
+ if (!trimmed) continue;
|
|
|
|
|
+
|
|
|
|
|
+ // 如果整段就小于 maxChars,直接加入
|
|
|
|
|
+ if (countVisible(trimmed) <= maxChars) {
|
|
|
|
|
+ result.push(trimmed);
|
|
|
|
|
+ continue;
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // 按句末标点切分(。!?)
|
|
|
|
|
+ let sentences = splitByPunctuation(trimmed, /[。!?]+/, maxChars);
|
|
|
|
|
+
|
|
|
|
|
+ // 如果切分后仍有超长句子,按分号/冒号再切
|
|
|
|
|
+ sentences = furtherSplit(sentences, /[;:]/, maxChars);
|
|
|
|
|
+
|
|
|
|
|
+ // 如果仍有超长句子,按逗号再切
|
|
|
|
|
+ sentences = furtherSplit(sentences, /[,,]/, maxChars);
|
|
|
|
|
+
|
|
|
|
|
+ // 兜底:强制按字符数切分
|
|
|
|
|
+ sentences = forceSplitByCharCount(sentences, maxChars);
|
|
|
|
|
+
|
|
|
|
|
+ result.push(...sentences);
|
|
|
}
|
|
}
|
|
|
- // 如果没有 markdown 结构,退回到按句号分段
|
|
|
|
|
- if (sections.length <= 1) {
|
|
|
|
|
- return text.split(/(?<=[。!?;])\s*/).filter(s => s.trim());
|
|
|
|
|
|
|
+
|
|
|
|
|
+ return result;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 按指定标点切分,标点附在前面那句末尾 */
|
|
|
|
|
+function splitByPunctuation(text: string, punctRegex: RegExp, maxChars: number): string[] {
|
|
|
|
|
+ const parts: string[] = [];
|
|
|
|
|
+ let remaining = text;
|
|
|
|
|
+
|
|
|
|
|
+ while (remaining.length > 0) {
|
|
|
|
|
+ // 查找下一个标点位置
|
|
|
|
|
+ const match = remaining.match(punctRegex);
|
|
|
|
|
+ if (!match || match.index === undefined) {
|
|
|
|
|
+ // 没有更多标点,剩余部分整体加入
|
|
|
|
|
+ parts.push(remaining);
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const cutPos = match.index + match[0].length;
|
|
|
|
|
+ const sentence = remaining.substring(0, cutPos).trim();
|
|
|
|
|
+
|
|
|
|
|
+ if (sentence) {
|
|
|
|
|
+ parts.push(sentence);
|
|
|
|
|
+ }
|
|
|
|
|
+ remaining = remaining.substring(cutPos).trim();
|
|
|
}
|
|
}
|
|
|
- return sections;
|
|
|
|
|
|
|
+
|
|
|
|
|
+ return parts;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 格式化秒数为 [MM:SS.XX] */
|
|
|
|
|
-function formatLrcTimestamp(seconds: number): string {
|
|
|
|
|
- const m = Math.floor(seconds / 60);
|
|
|
|
|
- const s = seconds % 60;
|
|
|
|
|
- const cs = Math.round((s - Math.floor(s)) * 100);
|
|
|
|
|
- const sec = Math.floor(s);
|
|
|
|
|
- return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}.${cs.toString().padStart(2, '0')}`;
|
|
|
|
|
|
|
+/** 对已切分的句子,如果某些句子仍然超长,用更细粒度的标点再切 */
|
|
|
|
|
+function furtherSplit(sentences: string[], punctRegex: RegExp, maxChars: number): string[] {
|
|
|
|
|
+ const result: string[] = [];
|
|
|
|
|
+ for (const s of sentences) {
|
|
|
|
|
+ if (countVisible(s) <= maxChars) {
|
|
|
|
|
+ result.push(s);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 用更细粒度标点再切
|
|
|
|
|
+ const subParts = splitByPunctuation(s, punctRegex, maxChars);
|
|
|
|
|
+ result.push(...subParts);
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 兜底:强制按字符数切分超长句子 */
|
|
|
|
|
+function forceSplitByCharCount(sentences: string[], maxChars: number): string[] {
|
|
|
|
|
+ const result: string[] = [];
|
|
|
|
|
+ for (const s of sentences) {
|
|
|
|
|
+ if (countVisible(s) <= maxChars) {
|
|
|
|
|
+ result.push(s);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 按可见字符数强制切分
|
|
|
|
|
+ let buf = '';
|
|
|
|
|
+ let visibleCount = 0;
|
|
|
|
|
+ for (const ch of s) {
|
|
|
|
|
+ buf += ch;
|
|
|
|
|
+ if (!/\s/.test(ch)) visibleCount++;
|
|
|
|
|
+ if (visibleCount >= maxChars) {
|
|
|
|
|
+ result.push(buf.trim());
|
|
|
|
|
+ buf = '';
|
|
|
|
|
+ visibleCount = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (buf.trim()) result.push(buf.trim());
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 基于真实停顿点 + Markdown 段落 生成精确 LRC 歌词
|
|
|
|
|
- * 优先使用 FFmpeg 静音检测,失败退回到智能分段估算
|
|
|
|
|
|
|
+ * 生成 LRC 歌词时间轴
|
|
|
|
|
+ *
|
|
|
|
|
+ * 核心算法:**按句子拆分 + 均匀语速分配**
|
|
|
|
|
+ *
|
|
|
|
|
+ * 原理:
|
|
|
|
|
+ * - TTS 生成的音频语速相对均匀
|
|
|
|
|
+ * - 用 总时长/总字数 得到真实平均语速(秒/字)
|
|
|
|
|
+ * - 每句时长 = 该句字数 × 平均语速
|
|
|
|
|
+ * - 这样保证所有句子时间之和 = 总时长,且短句少分时间、长句多分时间
|
|
|
*/
|
|
*/
|
|
|
export function generateLrc(text: string, duration: number, audioPath?: string): string {
|
|
export function generateLrc(text: string, duration: number, audioPath?: string): string {
|
|
|
if (!text || duration <= 0) return '';
|
|
if (!text || duration <= 0) return '';
|
|
|
|
|
|
|
|
- const sections = splitByMarkdownSections(text);
|
|
|
|
|
- if (sections.length === 0) return '';
|
|
|
|
|
|
|
+ try {
|
|
|
|
|
+ const sentences = splitIntoLrcSentences(text);
|
|
|
|
|
+ if (sentences.length === 0) return '';
|
|
|
|
|
|
|
|
- // 尝试 FFmpeg 静音检测获取真实停顿点
|
|
|
|
|
- let silencePoints: number[] = [];
|
|
|
|
|
- if (audioPath && fs.existsSync(audioPath)) {
|
|
|
|
|
- silencePoints = detectAudioSilence(audioPath);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ logToFile(`🎵 LRC 拆分: ${sentences.length} 句, 总时长=${duration}s`);
|
|
|
|
|
|
|
|
- // 如果有足够多的真实停顿点(至少比段落数少1),直接映射
|
|
|
|
|
- if (silencePoints.length >= sections.length - 1) {
|
|
|
|
|
- console.log(`✅ 使用 FFmpeg 精确停顿点生成 LRC (${sections.length} 个段落)`);
|
|
|
|
|
- return buildLrcFromSilencePoints(sections, silencePoints, duration);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // 计算总可见字符数
|
|
|
|
|
+ const totalChars = sentences.reduce((sum, s) => sum + countVisible(s), 0);
|
|
|
|
|
+ if (totalChars === 0) return '';
|
|
|
|
|
|
|
|
- // 降级:智能分段估算(标题停顿长、正文停顿短)
|
|
|
|
|
- console.log(`📝 FFmpeg 数据不足,使用智能估算生成 LRC (${sections.length} 个段落)`);
|
|
|
|
|
- return buildLrcByEstimation(sections, duration);
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ // 核心:均匀语速 = 总时长 / 总字数
|
|
|
|
|
+ const speechRate = duration / totalChars; // 秒/字
|
|
|
|
|
+ logToFile(`🎵 语速: ${speechRate.toFixed(3)}s/字, 总字数=${totalChars}`);
|
|
|
|
|
|
|
|
-/** 用真实停顿点构建 LRC */
|
|
|
|
|
-function buildLrcFromSilencePoints(sections: string[], silencePoints: number[], totalDuration: number): string {
|
|
|
|
|
- const lines: string[] = [];
|
|
|
|
|
- for (let i = 0; i < sections.length; i++) {
|
|
|
|
|
- const startTime = i === 0 ? 0 : silencePoints[i - 1];
|
|
|
|
|
- const endTime = i < silencePoints.length ? silencePoints[i] : totalDuration;
|
|
|
|
|
- const correctedStart = Math.min(startTime, totalDuration - 0.5);
|
|
|
|
|
- lines.push(`[${formatLrcTimestamp(correctedStart)}] ${sections[i]}`);
|
|
|
|
|
|
|
+ const lines: string[] = [];
|
|
|
|
|
+ let currentTime = 0;
|
|
|
|
|
+
|
|
|
|
|
+ for (let i = 0; i < sentences.length; i++) {
|
|
|
|
|
+ const charCount = countVisible(sentences[i]);
|
|
|
|
|
+ const lineDuration = charCount * speechRate;
|
|
|
|
|
+
|
|
|
|
|
+ lines.push(`[${formatLrcTimestamp(currentTime)}] ${sanitizeLrcText(sentences[i])}`);
|
|
|
|
|
+
|
|
|
|
|
+ logToFile(` [${formatLrcTimestamp(currentTime)}] ${charCount}字 ${sentences[i].substring(0, 30)}...`);
|
|
|
|
|
+
|
|
|
|
|
+ currentTime += lineDuration;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 确保最后一行不超过总时长
|
|
|
|
|
+ logToFile(`🎵 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);
|
|
|
|
|
+ return buildSimpleLrc(text, duration);
|
|
|
}
|
|
}
|
|
|
- return lines.join('\n');
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 智能估算构建 LRC(降级方案) */
|
|
|
|
|
-function buildLrcByEstimation(sections: string[], totalDuration: number): string {
|
|
|
|
|
- const lines: string[] = [];
|
|
|
|
|
|
|
+/** 最简降级:按句号或换行分割,再不行按固定长度切分 */
|
|
|
|
|
+function buildSimpleLrc(text: string, duration: number): string {
|
|
|
|
|
+ let sentences = text.split(/(?<=[。!?;])\s*/).filter(s => s.trim());
|
|
|
|
|
+ if (sentences.length <= 1) {
|
|
|
|
|
+ sentences = text.split(/\n+/).filter(s => s.trim());
|
|
|
|
|
+ }
|
|
|
|
|
+ if (sentences.length <= 1 && text.length > 20) {
|
|
|
|
|
+ const chunkSize = 40;
|
|
|
|
|
+ sentences = [];
|
|
|
|
|
+ for (let i = 0; i < text.length; i += chunkSize) {
|
|
|
|
|
+ sentences.push(text.substring(i, i + chunkSize));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (sentences.length === 0) return '';
|
|
|
|
|
|
|
|
- // 分配时长:标题权重 0.3,正文权重 1.0,标题间加 0.5s 停顿
|
|
|
|
|
- const weights = sections.map((s) => {
|
|
|
|
|
- const isHeading = /^#{1,2}\s/.test(s);
|
|
|
|
|
- return isHeading ? 0.3 : 1.0;
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const totalChars = sentences.reduce((sum, s) => sum + countVisible(s), 0);
|
|
|
|
|
+ if (totalChars === 0) return '';
|
|
|
|
|
|
|
|
- // 按字符加权分配
|
|
|
|
|
- const weightedChars = sections.map((s, i) => {
|
|
|
|
|
- const chars = (s.match(/[一-龥a-zA-Z0-9]/g) || []).length;
|
|
|
|
|
- return chars * weights[i];
|
|
|
|
|
- });
|
|
|
|
|
- const totalWeighted = weightedChars.reduce((a, b) => a + b, 0);
|
|
|
|
|
- if (totalWeighted === 0) return '';
|
|
|
|
|
-
|
|
|
|
|
- let currentTime = 0;
|
|
|
|
|
- for (let i = 0; i < sections.length; i++) {
|
|
|
|
|
- const ratio = weightedChars[i] / totalWeighted;
|
|
|
|
|
- const segmentDuration = ratio * totalDuration;
|
|
|
|
|
-
|
|
|
|
|
- lines.push(`[${formatLrcTimestamp(currentTime)}] ${sections[i]}`);
|
|
|
|
|
- currentTime += segmentDuration;
|
|
|
|
|
- // 标题后加小停顿
|
|
|
|
|
- if (i < sections.length - 1 && /^#{1,2}\s/.test(sections[i])) {
|
|
|
|
|
- currentTime = Math.min(currentTime + 0.5, totalDuration);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const speechRate = duration / totalChars;
|
|
|
|
|
+ const lines: string[] = [];
|
|
|
|
|
+ let t = 0;
|
|
|
|
|
+ for (const s of sentences) {
|
|
|
|
|
+ lines.push(`[${formatLrcTimestamp(t)}] ${sanitizeLrcText(s)}`);
|
|
|
|
|
+ t += countVisible(s) * speechRate;
|
|
|
}
|
|
}
|
|
|
return lines.join('\n');
|
|
return lines.join('\n');
|
|
|
}
|
|
}
|
|
@@ -813,10 +1021,8 @@ export async function generatePreview(
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- return {
|
|
|
|
|
- audioId,
|
|
|
|
|
- audioUrl: '',
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ // 所有分段都失败,抛出明确错误
|
|
|
|
|
+ throw new Error('预览生成失败:所有音频分段生成均失败');
|
|
|
} catch (error: any) {
|
|
} catch (error: any) {
|
|
|
console.error('❌ 预览生成失败:', error);
|
|
console.error('❌ 预览生成失败:', error);
|
|
|
throw error;
|
|
throw error;
|