|
|
@@ -448,8 +448,9 @@ async function processAudioGeneration(
|
|
|
// 最终音频 URL:统一使用 storageService 处理后的 URL
|
|
|
const finalAudioUrl = audioUrl;
|
|
|
|
|
|
- // 生成 LRC 格式歌词时间轴
|
|
|
- const lrcLyrics = generateLrc(text, duration);
|
|
|
+ // 生成 LRC 格式歌词时间轴(优先使用 FFmpeg 精确停顿检测)
|
|
|
+ const localAudioPath = path.join(audioDir, 'output.mp3');
|
|
|
+ const lrcLyrics = generateLrc(text, duration, localAudioPath);
|
|
|
if (lrcLyrics) {
|
|
|
console.log(`🎵 LRC 歌词已生成: ${lrcLyrics.split('\n').length} 句`);
|
|
|
}
|
|
|
@@ -602,36 +603,143 @@ export function getVoices(): Voice[] {
|
|
|
return VOICES;
|
|
|
}
|
|
|
|
|
|
-// 生成 LRC 格式歌词时间轴
|
|
|
-export function generateLrc(text: string, duration: number): string {
|
|
|
- if (!text || duration <= 0) return '';
|
|
|
+// ============ FFmpeg 静音检测 + 精确 LRC 生成 ============
|
|
|
|
|
|
- // 按标点分割句子
|
|
|
- const sentences = text.split(/(?<=[。!?;\n])/);
|
|
|
- const lines: string[] = [];
|
|
|
- let currentTime = 0;
|
|
|
+/**
|
|
|
+ * 用 FFmpeg 检测音频中的静音间隙,返回真实的停顿时间点(秒)
|
|
|
+ * silencedetect 参数:-30dB 阈值,0.3s 最小静音时长
|
|
|
+ */
|
|
|
+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]));
|
|
|
+ }
|
|
|
+ console.log(`🔇 FFmpeg 检测到 ${silenceEnds.length} 个静音点`);
|
|
|
+ return silenceEnds;
|
|
|
+ } catch (err: any) {
|
|
|
+ console.warn(`⚠️ FFmpeg 静音检测失败,降级到估算模式:`, err.message);
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** 按 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());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 如果没有 markdown 结构,退回到按句号分段
|
|
|
+ if (sections.length <= 1) {
|
|
|
+ return text.split(/(?<=[。!?;])\s*/).filter(s => s.trim());
|
|
|
+ }
|
|
|
+ return sections;
|
|
|
+}
|
|
|
+
|
|
|
+/** 格式化秒数为 [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')}`;
|
|
|
+}
|
|
|
|
|
|
- // 估算总字数(中文+英文+数字)
|
|
|
- const totalChars = (text.match(/[一-龥a-zA-Z0-9]/g) || []).length;
|
|
|
- const charsPerSecond = totalChars / duration || 4.5;
|
|
|
+/**
|
|
|
+ * 基于真实停顿点 + Markdown 段落 生成精确 LRC 歌词
|
|
|
+ * 优先使用 FFmpeg 静音检测,失败退回到智能分段估算
|
|
|
+ */
|
|
|
+export function generateLrc(text: string, duration: number, audioPath?: string): string {
|
|
|
+ if (!text || duration <= 0) return '';
|
|
|
|
|
|
- for (const sentence of sentences) {
|
|
|
- const trimmed = sentence.trim();
|
|
|
- if (!trimmed) continue;
|
|
|
+ const sections = splitByMarkdownSections(text);
|
|
|
+ if (sections.length === 0) return '';
|
|
|
+
|
|
|
+ // 尝试 FFmpeg 静音检测获取真实停顿点
|
|
|
+ let silencePoints: number[] = [];
|
|
|
+ if (audioPath && fs.existsSync(audioPath)) {
|
|
|
+ silencePoints = detectAudioSilence(audioPath);
|
|
|
+ }
|
|
|
|
|
|
- // 估算句子时长
|
|
|
- const chars = (trimmed.match(/[一-龥a-zA-Z0-9]/g) || []).length;
|
|
|
- const sentenceDuration = chars / charsPerSecond;
|
|
|
+ // 如果有足够多的真实停顿点(至少比段落数少1),直接映射
|
|
|
+ if (silencePoints.length >= sections.length - 1) {
|
|
|
+ console.log(`✅ 使用 FFmpeg 精确停顿点生成 LRC (${sections.length} 个段落)`);
|
|
|
+ return buildLrcFromSilencePoints(sections, silencePoints, duration);
|
|
|
+ }
|
|
|
|
|
|
- // 格式化时间戳 [MM:SS.XX]
|
|
|
- const minutes = Math.floor(currentTime / 60);
|
|
|
- const seconds = currentTime % 60;
|
|
|
- const timestamp = `${minutes.toString().padStart(2, '0')}:${seconds.toFixed(2).padStart(5, '0')}`;
|
|
|
+ // 降级:智能分段估算(标题停顿长、正文停顿短)
|
|
|
+ console.log(`📝 FFmpeg 数据不足,使用智能估算生成 LRC (${sections.length} 个段落)`);
|
|
|
+ return buildLrcByEstimation(sections, duration);
|
|
|
+}
|
|
|
|
|
|
- lines.push(`[${timestamp}] ${trimmed}`);
|
|
|
- currentTime += sentenceDuration;
|
|
|
+/** 用真实停顿点构建 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]}`);
|
|
|
}
|
|
|
+ return lines.join('\n');
|
|
|
+}
|
|
|
|
|
|
+/** 智能估算构建 LRC(降级方案) */
|
|
|
+function buildLrcByEstimation(sections: string[], totalDuration: number): string {
|
|
|
+ const lines: string[] = [];
|
|
|
+
|
|
|
+ // 分配时长:标题权重 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 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);
|
|
|
+ }
|
|
|
+ }
|
|
|
return lines.join('\n');
|
|
|
}
|
|
|
|