455 lines
17 KiB
TypeScript
455 lines
17 KiB
TypeScript
import { execFile } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import * as path from 'path';
|
|
import logger from '../log/main';
|
|
import { getFFmpegPath, getFFprobePath } from '../shell';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
/**
|
|
* 音频片段信息
|
|
*/
|
|
export interface AudioSegment {
|
|
startTime: number; // 开始时间(秒)
|
|
endTime: number; // 结束时间(秒)
|
|
duration: number; // 持续时间(秒)
|
|
}
|
|
|
|
/**
|
|
* 音量检测结果
|
|
*/
|
|
export interface VolumeDetectResult {
|
|
success: boolean;
|
|
segments: AudioSegment[]; // 有声音的时间段
|
|
totalDuration: number; // 总时长
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* 音量检测配置
|
|
*/
|
|
export interface VolumeDetectConfig {
|
|
silenceThreshold: number; // 静音阈值(dB),默认 -40dB
|
|
minSilenceDuration: number; // 最小静音时长(秒),默认 0.5s
|
|
minSoundDuration: number; // 最小有声时长(秒),默认 0.3s
|
|
}
|
|
|
|
/**
|
|
* 使用 FFmpeg silencedetect 滤镜检测音频中的声音片段
|
|
*/
|
|
export async function detectAudioSegments(
|
|
audioOrVideoPath: string,
|
|
config: Partial<VolumeDetectConfig> = {}
|
|
): Promise<VolumeDetectResult> {
|
|
const defaultConfig: VolumeDetectConfig = {
|
|
silenceThreshold: -40,
|
|
minSilenceDuration: 0.5,
|
|
minSoundDuration: 0.3
|
|
};
|
|
|
|
const finalConfig = { ...defaultConfig, ...config };
|
|
|
|
logger.info('[VolumeDetect] 开始音量检测', {
|
|
file: path.basename(audioOrVideoPath),
|
|
config: finalConfig
|
|
});
|
|
|
|
try {
|
|
// 先获取总时长
|
|
const duration = await getMediaDuration(audioOrVideoPath);
|
|
|
|
if (!duration || isNaN(duration) || duration <= 0) {
|
|
throw new Error(`无效的媒体时长: ${duration}`);
|
|
}
|
|
|
|
logger.info(`[VolumeDetect] 媒体总时长: ${duration.toFixed(2)}秒`);
|
|
|
|
// 使用 silencedetect 滤镜检测静音段
|
|
// 输出格式: silence_start: 12.345 silence_end: 15.678 | silence_duration: 3.333
|
|
const ffmpegPath = await getFFmpegPath();
|
|
|
|
const args = [
|
|
'-i', audioOrVideoPath,
|
|
'-af', `silencedetect=noise=${finalConfig.silenceThreshold}dB:d=${finalConfig.minSilenceDuration}`,
|
|
'-f', 'null',
|
|
'-'
|
|
];
|
|
|
|
logger.info(`[VolumeDetect] FFmpeg 命令: ${ffmpegPath} ${args.join(' ')}`);
|
|
|
|
const { stdout, stderr } = await execFileAsync(ffmpegPath, args, {
|
|
maxBuffer: 10 * 1024 * 1024 // 10MB
|
|
});
|
|
|
|
// silencedetect 的输出在 stderr 中
|
|
const output = stderr;
|
|
|
|
// 解析静音段
|
|
const silenceSegments = parseSilenceDetectOutput(output);
|
|
logger.info(`[VolumeDetect] 检测到 ${silenceSegments.length} 个静音段`);
|
|
|
|
// 从静音段反推有声段
|
|
const soundSegments = invertSilenceToSound(silenceSegments, duration, finalConfig.minSoundDuration);
|
|
logger.info(`[VolumeDetect] 识别到 ${soundSegments.length} 个有声段`);
|
|
|
|
// 输出详细信息
|
|
console.log('\n========== 🔊 音量检测结果 ==========');
|
|
console.log(`总时长: ${duration.toFixed(2)}秒`);
|
|
console.log(`检测到的静音段: ${silenceSegments.length}个`);
|
|
silenceSegments.forEach((seg, idx) => {
|
|
console.log(` 静音${idx + 1}: ${seg.start.toFixed(2)}s - ${seg.end.toFixed(2)}s`);
|
|
});
|
|
console.log(`检测到的有声段: ${soundSegments.length}个`);
|
|
soundSegments.forEach((seg, idx) => {
|
|
logger.info(`[VolumeDetect] 片段${idx + 1}: ${seg.startTime.toFixed(2)}s - ${seg.endTime.toFixed(2)}s (${seg.duration.toFixed(2)}s)`);
|
|
console.log(` 有声${idx + 1}: ${seg.startTime.toFixed(2)}s - ${seg.endTime.toFixed(2)}s (${seg.duration.toFixed(2)}s)`);
|
|
});
|
|
console.log('====================================\n');
|
|
|
|
return {
|
|
success: true,
|
|
segments: soundSegments,
|
|
totalDuration: duration
|
|
};
|
|
|
|
} catch (error: any) {
|
|
logger.error('[VolumeDetect] 音量检测失败', error);
|
|
console.error('[VolumeDetect] ❌ 音量检测失败:', error);
|
|
console.error('[VolumeDetect] 错误堆栈:', error.stack);
|
|
console.error('[VolumeDetect] 文件路径:', audioOrVideoPath);
|
|
|
|
return {
|
|
success: false,
|
|
segments: [],
|
|
totalDuration: 0,
|
|
error: error.message || error.toString()
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取媒体文件时长
|
|
*/
|
|
async function getMediaDuration(filePath: string): Promise<number> {
|
|
const ffprobePath = await getFFprobePath();
|
|
|
|
const args = [
|
|
'-v', 'error',
|
|
'-show_entries', 'format=duration',
|
|
'-of', 'default=noprint_wrappers=1:nokey=1',
|
|
filePath
|
|
];
|
|
|
|
logger.info(`[VolumeDetect] 获取媒体时长: ${filePath}`);
|
|
logger.info(`[VolumeDetect] ffprobe路径: ${ffprobePath}`);
|
|
|
|
const { stdout, stderr } = await execFileAsync(ffprobePath, args);
|
|
|
|
logger.info(`[VolumeDetect] ffprobe输出: ${stdout.trim()}`);
|
|
if (stderr) {
|
|
logger.warn(`[VolumeDetect] ffprobe错误输出: ${stderr}`);
|
|
}
|
|
|
|
const duration = parseFloat(stdout.trim());
|
|
|
|
if (isNaN(duration) || !isFinite(duration) || duration <= 0) {
|
|
const errorMsg = `获取媒体时长失败: ffprobe返回了无效值 "${stdout.trim()}"`;
|
|
logger.error(`[VolumeDetect] ${errorMsg}`);
|
|
throw new Error(errorMsg);
|
|
}
|
|
|
|
return duration;
|
|
}
|
|
|
|
/**
|
|
* 解析 silencedetect 输出
|
|
*/
|
|
function parseSilenceDetectOutput(output: string): Array<{ start: number; end: number }> {
|
|
const silenceSegments: Array<{ start: number; end: number }> = [];
|
|
const lines = output.split('\n');
|
|
|
|
let currentStart: number | null = null;
|
|
|
|
for (const line of lines) {
|
|
// silence_start: 12.345
|
|
const startMatch = line.match(/silence_start:\s+([\d.]+)/);
|
|
if (startMatch) {
|
|
currentStart = parseFloat(startMatch[1]);
|
|
}
|
|
|
|
// silence_end: 15.678 | silence_duration: 3.333
|
|
const endMatch = line.match(/silence_end:\s+([\d.]+)/);
|
|
if (endMatch && currentStart !== null) {
|
|
const end = parseFloat(endMatch[1]);
|
|
silenceSegments.push({ start: currentStart, end });
|
|
currentStart = null;
|
|
}
|
|
}
|
|
|
|
return silenceSegments;
|
|
}
|
|
|
|
/**
|
|
* 从静音段反推有声段
|
|
*/
|
|
function invertSilenceToSound(
|
|
silenceSegments: Array<{ start: number; end: number }>,
|
|
totalDuration: number,
|
|
minSoundDuration: number
|
|
): AudioSegment[] {
|
|
const soundSegments: AudioSegment[] = [];
|
|
|
|
// 如果没有静音段,说明整个音频都有声音
|
|
if (silenceSegments.length === 0) {
|
|
return [{
|
|
startTime: 0,
|
|
endTime: totalDuration,
|
|
duration: totalDuration
|
|
}];
|
|
}
|
|
|
|
// 第一个有声段:从 0 到第一个静音段开始
|
|
console.log(`[invertSilenceToSound] 检查第一个有声段: silenceSegments[0].start=${silenceSegments[0].start}, minSoundDuration=${minSoundDuration}`);
|
|
if (silenceSegments[0].start > minSoundDuration) {
|
|
console.log(`[invertSilenceToSound] ✅ 添加第一个有声段: 0.00s - ${silenceSegments[0].start.toFixed(2)}s`);
|
|
soundSegments.push({
|
|
startTime: 0,
|
|
endTime: silenceSegments[0].start,
|
|
duration: silenceSegments[0].start
|
|
});
|
|
} else {
|
|
console.log(`[invertSilenceToSound] ⚠️ 跳过第一个有声段(太短)`);
|
|
}
|
|
|
|
// 中间的有声段:从一个静音段结束到下一个静音段开始
|
|
for (let i = 0; i < silenceSegments.length - 1; i++) {
|
|
const start = silenceSegments[i].end;
|
|
const end = silenceSegments[i + 1].start;
|
|
const duration = end - start;
|
|
|
|
if (duration >= minSoundDuration) {
|
|
soundSegments.push({
|
|
startTime: start,
|
|
endTime: end,
|
|
duration
|
|
});
|
|
}
|
|
}
|
|
|
|
// 最后一个有声段:从最后一个静音段结束到总时长
|
|
const lastSilence = silenceSegments[silenceSegments.length - 1];
|
|
if (totalDuration - lastSilence.end >= minSoundDuration) {
|
|
soundSegments.push({
|
|
startTime: lastSilence.end,
|
|
endTime: totalDuration,
|
|
duration: totalDuration - lastSilence.end
|
|
});
|
|
}
|
|
|
|
return soundSegments;
|
|
}
|
|
|
|
/**
|
|
* 将文本按照音频片段自动分配时间
|
|
* @param text 文案内容(支持换行分割)
|
|
* @param segments 音频片段列表
|
|
* @returns 带有时间的字幕数据
|
|
*/
|
|
export function allocateTextToSegments(
|
|
text: string,
|
|
segments: AudioSegment[]
|
|
): Array<{
|
|
index: number;
|
|
text: string;
|
|
startTime: number;
|
|
endTime: number;
|
|
}> {
|
|
// 按换行符或句号分割文本
|
|
// 🔧 修复:不使用 \s(会匹配空格),改用 \n(只匹配换行)
|
|
// 这样俄语、英语等语言不会被分割成单个单词
|
|
const lines = text
|
|
.split(/[\n。!?.!?]+/) // 只按换行符和句号等标点分割
|
|
.map(line => line.trim())
|
|
.filter(line => line.length > 0);
|
|
|
|
try {
|
|
|
|
logger.info(`[VolumeDetect] 文案内容分割成 ${lines.length} 条,音频片段 ${segments.length} 个`);
|
|
|
|
console.log('\n========== 📄 文本分割结果 ==========');
|
|
console.log(`原始文本长度: ${text.length}字符`);
|
|
console.log(`分割后行数: ${lines.length}行`);
|
|
console.log(`音频片段数: ${segments.length}个`);
|
|
console.log('前5行预览:');
|
|
try {
|
|
lines.slice(0, 5).forEach((line, idx) => {
|
|
console.log(` 行${idx + 1}: "${line.substring(0, 50)}${line.length > 50 ? '...' : ''}"`);
|
|
});
|
|
} catch (e: any) {
|
|
console.error('前5行预览失败:', e.message);
|
|
}
|
|
console.log('=====================================\n');
|
|
|
|
// 🔧 验证segments数据完整性
|
|
console.log('\n========== 🔍 Segments数据验证 ==========');
|
|
const invalidSegments = segments.filter((seg, idx) => {
|
|
const hasStartTime = typeof seg.startTime === 'number' && !isNaN(seg.startTime);
|
|
const hasEndTime = typeof seg.endTime === 'number' && !isNaN(seg.endTime);
|
|
const hasDuration = typeof seg.duration === 'number' && !isNaN(seg.duration);
|
|
if (!hasStartTime || !hasEndTime || !hasDuration) {
|
|
console.error(`⚠️ 片段${idx + 1}数据异常:`, {
|
|
startTime: seg.startTime,
|
|
endTime: seg.endTime,
|
|
duration: seg.duration,
|
|
hasStartTime,
|
|
hasEndTime,
|
|
hasDuration
|
|
});
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
if (invalidSegments.length > 0) {
|
|
throw new Error(`发现${invalidSegments.length}个无效的音频片段`);
|
|
}
|
|
console.log('✅ 所有segments数据验证通过');
|
|
console.log('========================================\n');
|
|
|
|
// 如果没有检测到音频片段,平均分配时间
|
|
if (segments.length === 0) {
|
|
logger.warn('[VolumeDetect] 没有检测到音频片段,使用平均时间分配');
|
|
const defaultDuration = 3; // 每行默认 3 秒
|
|
return lines.map((line, index) => ({
|
|
index: index + 1,
|
|
text: line.trim(),
|
|
startTime: index * defaultDuration,
|
|
endTime: (index + 1) * defaultDuration
|
|
}));
|
|
}
|
|
|
|
const result: Array<{
|
|
index: number;
|
|
text: string;
|
|
startTime: number;
|
|
endTime: number;
|
|
}> = [];
|
|
|
|
// 策略 1: 文本行数和音频片段数接近,一一对应
|
|
if (Math.abs(lines.length - segments.length) <= 2) {
|
|
logger.info('[VolumeDetect] 使用一一对应策略分配字幕');
|
|
console.log(`\n🎯 使用一一对应策略: ${lines.length}行文本 ≈ ${segments.length}个音频片段`);
|
|
lines.forEach((line, index) => {
|
|
const segment = segments[Math.min(index, segments.length - 1)];
|
|
result.push({
|
|
index: index + 1,
|
|
text: line.trim(),
|
|
startTime: segment.startTime,
|
|
endTime: segment.endTime
|
|
});
|
|
console.log(` 行${index + 1} -> 片段${Math.min(index + 1, segments.length)}: ${segment.startTime.toFixed(2)}s - ${segment.endTime.toFixed(2)}s`);
|
|
});
|
|
}
|
|
// 策略 2: 文本行数较少,多个片段合并
|
|
else if (lines.length < segments.length) {
|
|
logger.info('[VolumeDetect] 文本行少,合并音频片段');
|
|
const segmentsPerLine = Math.ceil(segments.length / lines.length);
|
|
console.log(`\n🎯 使用合并策略: ${lines.length}行文本 < ${segments.length}个片段,每行合并${segmentsPerLine}个片段`);
|
|
|
|
lines.forEach((line, index) => {
|
|
const startSegmentIdx = index * segmentsPerLine;
|
|
const endSegmentIdx = Math.min(startSegmentIdx + segmentsPerLine, segments.length);
|
|
|
|
const firstSegment = segments[startSegmentIdx];
|
|
const lastSegment = segments[endSegmentIdx - 1];
|
|
|
|
result.push({
|
|
index: index + 1,
|
|
text: line.trim(),
|
|
startTime: firstSegment.startTime,
|
|
endTime: lastSegment.endTime
|
|
});
|
|
console.log(` 行${index + 1} -> 合并片段${startSegmentIdx + 1}-${endSegmentIdx}: ${firstSegment.startTime.toFixed(2)}s - ${lastSegment.endTime.toFixed(2)}s | "${line.trim().substring(0, 30)}..."`);
|
|
});
|
|
}
|
|
// 策略 3: 文本行数较多,拆分音频片段
|
|
else {
|
|
logger.info('[VolumeDetect] 文本行多,拆分音频片段');
|
|
console.log(`\n🎯 使用拆分策略: ${lines.length}行文本 > ${segments.length}个片段`);
|
|
let currentLineIdx = 0;
|
|
|
|
segments.forEach((segment, segIdx) => {
|
|
if (currentLineIdx >= lines.length) return;
|
|
|
|
const remainingLines = lines.length - currentLineIdx;
|
|
const remainingSegments = segments.length - segIdx;
|
|
const linesPerSegment = Math.ceil(remainingLines / remainingSegments);
|
|
const duration = segment.duration;
|
|
const durationPerLine = duration / linesPerSegment;
|
|
|
|
console.log(` 片段${segIdx + 1}(${segment.startTime.toFixed(2)}s-${segment.endTime.toFixed(2)}s) 拆分成${linesPerSegment}行:`);
|
|
|
|
for (let i = 0; i < linesPerSegment && currentLineIdx < lines.length; i++) {
|
|
const startTime = segment.startTime + i * durationPerLine;
|
|
const endTime = segment.startTime + (i + 1) * durationPerLine;
|
|
result.push({
|
|
index: currentLineIdx + 1,
|
|
text: lines[currentLineIdx].trim(),
|
|
startTime: startTime,
|
|
endTime: endTime
|
|
});
|
|
console.log(` 行${currentLineIdx + 1}: ${startTime.toFixed(2)}s - ${endTime.toFixed(2)}s | "${lines[currentLineIdx].trim().substring(0, 30)}..."`);
|
|
currentLineIdx++;
|
|
}
|
|
});
|
|
}
|
|
|
|
logger.info(`[VolumeDetect] 字幕分配完成,共 ${result.length} 条`);
|
|
|
|
// 🔧 修复:如果第一条字幕开始时间太晚(超过1秒),强制提前到接近0秒
|
|
// 这样可以避免视频开头长时间没有字幕的问题
|
|
if (result.length > 0 && result[0].startTime > 1.0) {
|
|
const firstDelay = result[0].startTime;
|
|
logger.warn(`[VolumeDetect] ⚠️ 第一条字幕开始时间较晚: ${firstDelay.toFixed(2)}s,调整为0.3s`);
|
|
console.log(`\n⚠️ 第一条字幕开始时间较晚: ${firstDelay.toFixed(2)}s,调整为0.3s`);
|
|
|
|
// 将第一条字幕提前到0.3秒开始,保持持续时长不变
|
|
const firstDuration = result[0].endTime - result[0].startTime;
|
|
result[0].startTime = 0.3;
|
|
result[0].endTime = 0.3 + firstDuration;
|
|
|
|
logger.info(`[VolumeDetect] ✅ 已调整第一条字幕时间: 0.3s - ${result[0].endTime.toFixed(2)}s`);
|
|
console.log(`✅ 已调整第一条字幕时间: 0.3s - ${result[0].endTime.toFixed(2)}s\n`);
|
|
}
|
|
|
|
// 输出字幕时间详情
|
|
console.log('\n========== 📝 字幕时间分配详情 ==========');
|
|
console.log(`总字幕数: ${result.length}条`);
|
|
result.forEach((subtitle, idx) => {
|
|
const preview = subtitle.text.substring(0, 30) + (subtitle.text.length > 30 ? '...' : '');
|
|
console.log(` 字幕${idx + 1}: ${subtitle.startTime.toFixed(2)}s - ${subtitle.endTime.toFixed(2)}s | "${preview}"`);
|
|
});
|
|
console.log('========================================\n');
|
|
|
|
return result;
|
|
} catch (error: any) {
|
|
console.error('[VolumeDetect] ❌ 字幕时间分配失败:', error.message);
|
|
console.error('[VolumeDetect] 错误堆栈:', error.stack);
|
|
console.error('[VolumeDetect] 文本长度:', text.length);
|
|
console.error('[VolumeDetect] 文本行数:', lines.length);
|
|
console.error('[VolumeDetect] 音频片段数:', segments.length);
|
|
if (segments.length > 0) {
|
|
console.error('[VolumeDetect] 第一个片段:', segments[0]);
|
|
console.error('[VolumeDetect] 最后一个片段:', segments[segments.length - 1]);
|
|
}
|
|
|
|
// 返回降级结果:按行数平均分配时间
|
|
logger.warn('[VolumeDetect] 使用降级方案:平均分配时间');
|
|
return lines.map((line, index) => ({
|
|
index: index + 1,
|
|
text: line,
|
|
startTime: index * 3,
|
|
endTime: (index + 1) * 3
|
|
}));
|
|
}
|
|
}
|