| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765 |
- /**
- * 音频问题扫描与修复脚本
- *
- * 功能:
- * 1. 扫描所有章节,检测语速异常(音频时长与字数不匹配)
- * 2. 检测 WAV 文件被错误存为 MP3 的问题
- * 3. 检测 FFmpeg concat 合并丢失段的问题
- * 4. 检测 level=1 章节合并音频与子节时长总和不匹配
- * 5. 检测 audioDuration 为 0 但有 audioUrl 的章节
- * 6. 提供自动修复(重新合并分段、上传、更新数据库)
- *
- * 用法:
- * npx tsx scripts/fix-audio-issues.ts # 扫描所有问题
- * npx tsx scripts/fix-audio-issues.ts --fix # 扫描并自动修复
- * npx tsx scripts/fix-audio-issues.ts --book 5 # 只扫描指定书籍
- * npx tsx scripts/fix-audio-issues.ts --chapter 11 # 只扫描指定章节
- * npx tsx scripts/fix-audio-issues.ts --fix --chapter 11 # 修复指定章节
- * npx tsx scripts/fix-audio-issues.ts --merge-all # 重新合并所有 level=1 章的音频
- *
- * 异常判定规则:
- * - 语速 > 10字/秒 → 严重异常(可能丢失了大部分音频)
- * - 语速 6-10字/秒 → 轻微异常(可能丢失了部分音频)
- * - 语速 3-6字/秒 → 正常
- * - 音频时长为0但有 audioUrl → 需要补充时长
- * - level=1 章合并音频时长 < 子节时长总和 * 0.8 → 合并不完整
- */
- import { PrismaClient } from '@prisma/client';
- import { exec } from 'child_process';
- import { promisify } from 'util';
- import fs from 'fs';
- import path from 'path';
- import axios from 'axios';
- const execAsync = promisify(exec);
- const prisma = new PrismaClient();
- // ============ 配置 ============
- const SEVERE_RATE = 10; // 严重异常阈值(字/秒)
- const MILD_RATE = 6; // 轻微异常阈值(字/秒)
- const CHARS_PER_SECOND = 4; // 估算用的标准语速
- const MERGE_TOLERANCE = 0.8; // 合并音频时长容差(低于子节总时长的80%视为异常)
- // ============ 解析命令行参数 ============
- const args = process.argv.slice(2);
- const shouldFix = args.includes('--fix');
- const mergeAll = args.includes('--merge-all');
- const bookArg = args.find(a => a.startsWith('--book'));
- const chapterArg = args.find(a => a.startsWith('--chapter'));
- const bookId = bookArg ? parseInt(bookArg.split('=')[1] || args[args.indexOf(bookArg) + 1]) : null;
- const chapterId = chapterArg ? parseInt(chapterArg.split('=')[1] || args[args.indexOf(chapterArg) + 1]) : null;
- // ============ 类型定义 ============
- type IssueSeverity = 'critical' | 'mild' | 'missing_duration' | 'format_mismatch' | 'merge_incomplete';
- interface AudioIssue {
- chapterId: number;
- bookId: number;
- bookTitle: string;
- chapterTitle: string;
- level: number;
- wordCount: number;
- audioDuration: number;
- audioUrl: string;
- rate: number;
- severity: IssueSeverity;
- description: string;
- localSegments?: string[];
- segmentTotalDuration?: number;
- expectedDuration?: number; // 预估应有的时长
- fuzzyMatchedDir?: string; // 模糊匹配的可能目录(需人工确认)
- }
- // ============ 工具函数 ============
- async function getLocalAudioDuration(filePath: string): Promise<number> {
- try {
- const { stdout } = await execAsync(
- `ffprobe -i "${filePath}" -show_entries format=duration -v quiet -of csv="p=0"`,
- { timeout: 10000 }
- );
- return Math.round((parseFloat(stdout.trim()) || 0) * 10) / 10;
- } catch {
- return 0;
- }
- }
- async function getRemoteAudioDuration(url: string): Promise<number> {
- try {
- const { stdout } = await execAsync(
- `ffprobe -i "${url}" -show_entries format=duration -v quiet -of csv="p=0"`,
- { timeout: 15000 }
- );
- const duration = parseFloat(stdout.trim());
- if (duration > 0) return Math.round(duration * 10) / 10;
- } catch {}
- try {
- const tempDir = path.join(process.cwd(), 'temp');
- if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
- const tempFile = path.join(tempDir, `check_${Date.now()}.tmp`);
- const response = await axios.get(url, { responseType: 'arraybuffer', timeout: 30000 });
- fs.writeFileSync(tempFile, response.data);
- const duration = await getLocalAudioDuration(tempFile);
- try { fs.unlinkSync(tempFile); } catch {}
- return duration;
- } catch {
- return 0;
- }
- }
- function isWavFile(filePath: string): boolean {
- try {
- if (!fs.existsSync(filePath)) return false;
- const buffer = Buffer.alloc(12);
- const fd = fs.openSync(filePath, 'r');
- fs.readSync(fd, buffer, 0, 12, 0);
- fs.closeSync(fd);
- return buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WAVE';
- } catch {
- return false;
- }
- }
- function resolveAudioUrlToLocal(audioUrl: string): string | null {
- if (!audioUrl) return null;
- if (audioUrl.startsWith('http')) return null;
- const relativePath = audioUrl.replace(/^\//, '');
- const absolutePath = path.join(process.cwd(), relativePath);
- return fs.existsSync(absolutePath) ? absolutePath : null;
- }
- function findLocalSegments(audioDir: string): string[] {
- if (!fs.existsSync(audioDir)) return [];
- const segments: string[] = [];
- const files = fs.readdirSync(audioDir).sort();
- for (const file of files) {
- if (file.match(/^segment_\d+\.(wav|mp3)$/)) {
- segments.push(path.join(audioDir, file));
- }
- }
- return segments;
- }
- function inferAudioDir(audioUrl: string): string | null {
- if (!audioUrl) return null;
- const match = audioUrl.match(/\/uploads\/([^/]+)\//);
- if (match) {
- return path.join(process.cwd(), 'uploads', match[1]);
- }
- return null;
- }
- /**
- * 扫描 uploads 目录,查找所有包含 segment_*.wav 的目录
- * 返回 Map<目录路径, 分段文件列表>
- */
- function scanAllSegmentDirs(): Map<string, string[]> {
- const uploadsDir = path.join(process.cwd(), 'uploads');
- if (!fs.existsSync(uploadsDir)) return new Map();
- const result = new Map<string, string[]>();
- try {
- const dirs = fs.readdirSync(uploadsDir);
- for (const dir of dirs) {
- const dirPath = path.join(uploadsDir, dir);
- try {
- if (!fs.statSync(dirPath).isDirectory()) continue;
- } catch { continue; }
- const segments = findLocalSegments(dirPath);
- if (segments.length > 0) {
- result.set(dirPath, segments);
- }
- }
- } catch {}
- return result;
- }
- /**
- * 通过分段文件总时长与数据库章节字数对比,找到最匹配的目录
- * ⚠️ 此方法仅作参考,不自动使用——需要人工确认后再修复
- */
- function findBestMatchingSegmentDir(
- segmentDirs: Map<string, string[]>,
- wordCount: number,
- currentDuration: number
- ): { dir: string; segments: string[]; totalDuration: number } | null {
- const expectedDuration = wordCount / CHARS_PER_SECOND;
- let bestMatch: { dir: string; segments: string[]; totalDuration: number; diff: number } | null = null;
- for (const [dir, segments] of segmentDirs) {
- // 计算分段总时长(同步快速估算)
- let totalSize = 0;
- for (const seg of segments) {
- try { totalSize += fs.statSync(seg).size; } catch {}
- }
- // WAV 文件大约 176KB/s(16bit 22kHz 立体声)或 88KB/s(单声道)
- const estimatedDuration = totalSize / 176000; // 粗估
- // 寻找估算时长接近预期时长的
- const diff = Math.abs(estimatedDuration - expectedDuration);
- if (!bestMatch || diff < bestMatch.diff) {
- bestMatch = { dir, segments, totalDuration: estimatedDuration, diff };
- }
- }
- return bestMatch ? { dir: bestMatch.dir, segments: bestMatch.segments, totalDuration: bestMatch.totalDuration } : null;
- }
- async function mergeAndUpload(
- segmentFiles: string[],
- outputDir: string,
- chapterId: number
- ): Promise<{ url: string; duration: number } | null> {
- if (segmentFiles.length === 0) return null;
- try {
- const listFile = path.join(outputDir, 'fix_concat_list.txt');
- const listContent = segmentFiles.map(f => {
- const relativePath = path.relative(outputDir, f).replace(/\\/g, '/');
- return `file '${relativePath}'`;
- }).join('\n');
- fs.writeFileSync(listFile, listContent);
- const outputFile = path.join(outputDir, `output_fixed_${Date.now()}.mp3`);
- const cmd = `ffmpeg -f concat -safe 0 -i "${listFile}" -c:a libmp3lame -b:a 192k -y "${outputFile}"`;
- console.log(` 🔧 执行: ${cmd}`);
- await execAsync(cmd, { timeout: 300000, cwd: outputDir });
- const duration = await getLocalAudioDuration(outputFile);
- const { storageService } = await import('../src/services/storage.service');
- const audioId = `fix_${chapterId}_${Date.now()}`;
- const finalUrl = await storageService.uploadAudio(outputFile, audioId);
- try { fs.unlinkSync(listFile); } catch {}
- try { fs.unlinkSync(outputFile); } catch {}
- console.log(` ✅ 合并上传成功: ${finalUrl}, 时长: ${duration}s`);
- return { url: finalUrl, duration };
- } catch (error: any) {
- console.error(` ❌ 合并失败: ${error.message}`);
- return null;
- }
- }
- async function transcodeAndUpload(
- localFile: string,
- chapterId: number
- ): Promise<{ url: string; duration: number } | null> {
- try {
- const outputDir = path.dirname(localFile);
- const outputFile = path.join(outputDir, `transcoded_${Date.now()}.mp3`);
- const cmd = `ffmpeg -i "${localFile}" -c:a libmp3lame -b:a 192k -y "${outputFile}"`;
- console.log(` 🔧 转码: ${cmd}`);
- await execAsync(cmd, { timeout: 120000 });
- const duration = await getLocalAudioDuration(outputFile);
- const { storageService } = await import('../src/services/storage.service');
- const audioId = `fix_${chapterId}_${Date.now()}`;
- const finalUrl = await storageService.uploadAudio(outputFile, audioId);
- try { fs.unlinkSync(outputFile); } catch {}
- console.log(` ✅ 转码上传成功: ${finalUrl}, 时长: ${duration}s`);
- return { url: finalUrl, duration };
- } catch (error: any) {
- console.error(` ❌ 转码失败: ${error.message}`);
- return null;
- }
- }
- /**
- * 合并子节音频为 level=1 章节音频(通过 AudioMerger)
- */
- async function mergeChapterAudio(chapterId: number): Promise<{ url: string; duration: number } | null> {
- try {
- // 使用 player.service 的 mergeChapterAudios 函数
- const { mergeChapterAudios } = await import('../src/modules/player/player.service');
- const mergedUrl = await mergeChapterAudios(chapterId);
-
- if (!mergedUrl) {
- console.log(` ⚠️ mergeChapterAudios 返回 null,可能没有子节音频`);
- return null;
- }
- // 获取合并后时长
- let duration = 0;
- const localPath = resolveAudioUrlToLocal(mergedUrl);
- if (localPath) {
- duration = await getLocalAudioDuration(localPath);
- } else if (mergedUrl.startsWith('http')) {
- duration = await getRemoteAudioDuration(mergedUrl);
- }
- // 更新数据库时长
- if (duration > 0) {
- await prisma.bookChapter.update({
- where: { id: chapterId },
- data: { audioDuration: duration },
- });
- }
- console.log(` ✅ 章节合并完成: ${mergedUrl.substring(0, 60)}..., 时长: ${duration}s`);
- return { url: mergedUrl, duration };
- } catch (error: any) {
- console.error(` ❌ 章节合并失败: ${error.message}`);
- return null;
- }
- }
- // ============ 扫描函数 ============
- async function scanLeafChapterIssues(): Promise<AudioIssue[]> {
- const issues: AudioIssue[] = [];
- const whereClause: any = {
- audioUrl: { not: '' },
- };
- if (chapterId) {
- whereClause.id = chapterId;
- } else if (bookId) {
- whereClause.bookId = bookId;
- }
- const chapters = await prisma.bookChapter.findMany({
- where: whereClause,
- include: { book: { select: { id: true, title: true } } },
- orderBy: [{ bookId: 'asc' }, { number: 'asc' }],
- });
- console.log(`📚 找到 ${chapters.length} 个有音频的章节(叶节点检查)\n`);
- // 预扫描 uploads 目录中的分段文件
- let allSegmentDirs: Map<string, string[]> | null = null;
- let currentBookId = -1;
- for (const chapter of chapters) {
- if (chapter.bookId !== currentBookId) {
- currentBookId = chapter.bookId;
- console.log(`📖 书籍 ${currentBookId}: ${chapter.book?.title || '未知'}`);
- }
- const wordCount = chapter.wordCount || chapter.content?.length || 0;
- const duration = chapter.audioDuration || 0;
- const rate = duration > 0 ? wordCount / duration : 0;
- let severity: IssueSeverity | null = null;
- let description = '';
- // 检查1: 语速异常
- if (duration > 0 && rate > SEVERE_RATE) {
- severity = 'critical';
- description = `严重异常:语速 ${rate.toFixed(1)}字/秒,音频时长仅${duration}s,应有约${Math.round(wordCount / CHARS_PER_SECOND)}s`;
- } else if (duration > 0 && rate > MILD_RATE) {
- severity = 'mild';
- description = `轻微异常:语速 ${rate.toFixed(1)}字/秒,偏快,可能丢失部分音频`;
- }
- // 检查2: 时长缺失
- if (duration === 0 && chapter.audioUrl) {
- severity = 'missing_duration';
- description = `音频时长为0,但audioUrl存在`;
- }
- // 检查3: 格式不匹配
- const localPath = resolveAudioUrlToLocal(chapter.audioUrl);
- if (localPath && chapter.audioUrl.endsWith('.mp3') && isWavFile(localPath)) {
- if (!severity) {
- severity = 'format_mismatch';
- description = `文件格式不匹配:扩展名.mp3但内容为WAV格式`;
- } else {
- description += ';且文件格式不匹配(.mp3扩展名但WAV内容)';
- }
- }
- if (severity) {
- const issue: AudioIssue = {
- chapterId: chapter.id,
- bookId: chapter.bookId,
- bookTitle: chapter.book?.title || '',
- chapterTitle: chapter.title,
- level: chapter.level,
- wordCount,
- audioDuration: duration,
- audioUrl: chapter.audioUrl,
- rate,
- severity,
- description,
- expectedDuration: Math.round(wordCount / CHARS_PER_SECOND),
- };
- // 查找本地分段文件
- let audioDir = inferAudioDir(chapter.audioUrl);
-
- // 如果从 URL 无法推断目录(远程 OSS URL),尝试扫描 uploads 目录
- // ⚠️ 注意:模糊匹配可能不准确,仅用于提示,不自动修复
- let fuzzyMatchedDir: string | null = null;
- if (!audioDir) {
- if (!allSegmentDirs) {
- console.log(' 🔍 扫描本地 uploads 目录查找分段文件...');
- allSegmentDirs = scanAllSegmentDirs();
- console.log(` 📂 找到 ${allSegmentDirs.size} 个含分段文件的目录`);
- }
-
- // 通过字数匹配找到可能的目录(仅提示,不自动使用)
- if (allSegmentDirs.size > 0) {
- const match = findBestMatchingSegmentDir(allSegmentDirs, wordCount, duration);
- if (match && match.totalDuration > duration * 1.5) {
- fuzzyMatchedDir = match.dir;
- console.log(` ⚠️ 章节 ${chapter.id} 可能有本地分段目录: ${fuzzyMatchedDir} (估算时长${Math.round(match.totalDuration)}s) - 需人工确认`);
- }
- }
- }
-
- if (audioDir) {
- const segments = findLocalSegments(audioDir);
- if (segments.length > 0) {
- issue.localSegments = segments;
- let totalSegDuration = 0;
- for (const seg of segments) {
- totalSegDuration += await getLocalAudioDuration(seg);
- }
- issue.segmentTotalDuration = totalSegDuration;
- }
- }
- // 保存模糊匹配结果
- if (fuzzyMatchedDir) {
- issue.fuzzyMatchedDir = fuzzyMatchedDir;
- }
- issues.push(issue);
- const icon = severity === 'critical' ? '🔴' : severity === 'mild' ? '🟡' : severity === 'format_mismatch' ? '🟠' : '🔵';
- console.log(` ${icon} 章节 ${chapter.id} "${chapter.title}" (level=${chapter.level}): ${description}`);
- if (issue.localSegments?.length) {
- console.log(` 📎 找到 ${issue.localSegments.length} 个分段文件,总时长 ${issue.segmentTotalDuration}s`);
- }
- if (issue.fuzzyMatchedDir) {
- console.log(` ⚠️ 疑似分段目录: ${issue.fuzzyMatchedDir}(需人工确认)`);
- }
- }
- }
- return issues;
- }
- async function scanMergedChapterIssues(): Promise<AudioIssue[]> {
- const issues: AudioIssue[] = [];
- // 查找所有 level=1 的章节(有子节)
- const whereClause: any = { level: 1 };
- if (chapterId) {
- whereClause.id = chapterId;
- } else if (bookId) {
- whereClause.bookId = bookId;
- }
- const level1Chapters = await prisma.bookChapter.findMany({
- where: whereClause,
- include: { book: { select: { id: true, title: true } } },
- orderBy: [{ bookId: 'asc' }, { number: 'asc' }],
- });
- console.log(`\n📚 找到 ${level1Chapters.length} 个一级章节(合并音频检查)\n`);
- let currentBookId = -1;
- for (const chapter of level1Chapters) {
- if (chapter.bookId !== currentBookId) {
- currentBookId = chapter.bookId;
- console.log(`📖 书籍 ${currentBookId}: ${chapter.book?.title || '未知'}`);
- }
- // 获取子节音频
- const children = await prisma.bookChapter.findMany({
- where: { parentId: chapter.id, audioUrl: { not: '' } },
- orderBy: { number: 'asc' },
- });
- if (children.length === 0) continue;
- const childrenTotalDuration = children.reduce((sum, c) => sum + (c.audioDuration || 0), 0);
- const chapterDuration = chapter.audioDuration || 0;
- // 检查: 合并音频时长应接近子节时长总和
- if (chapter.audioUrl && childrenTotalDuration > 0) {
- const ratio = chapterDuration / childrenTotalDuration;
- if (ratio < MERGE_TOLERANCE) {
- const issue: AudioIssue = {
- chapterId: chapter.id,
- bookId: chapter.bookId,
- bookTitle: chapter.book?.title || '',
- chapterTitle: chapter.title,
- level: chapter.level,
- wordCount: chapter.wordCount || 0,
- audioDuration: chapterDuration,
- audioUrl: chapter.audioUrl,
- rate: 0,
- severity: 'merge_incomplete',
- description: `合并不完整:章音频${chapterDuration}s < 子节总时长${childrenTotalDuration}s(比例${(ratio * 100).toFixed(0)}%)`,
- expectedDuration: childrenTotalDuration,
- };
- issues.push(issue);
- console.log(` 🟣 章节 ${chapter.id} "${chapter.title}": ${issue.description}`);
- } else {
- // 正常
- process.stdout.write(` ✅ 章节 ${chapter.id} "${chapter.title}": 合并正常 (${chapterDuration}s ≈ ${childrenTotalDuration}s)\n`);
- }
- } else if (!chapter.audioUrl && children.length > 0) {
- // 章没有合并音频但有子节
- console.log(` ⚪ 章节 ${chapter.id} "${chapter.title}": 无合并音频(${children.length}个子节总时长${childrenTotalDuration}s)`);
- }
- }
- return issues;
- }
- // ============ 修复函数 ============
- async function fixIssue(issue: AudioIssue): Promise<boolean> {
- console.log(`\n--- 修复章节 ${issue.chapterId} "${issue.chapterTitle}" ---`);
- console.log(` 问题: ${issue.description}`);
- let result: { url: string; duration: number } | null = null;
- switch (issue.severity) {
- case 'critical':
- case 'mild':
- // 策略1: 本地分段文件重新合并
- if (issue.localSegments && issue.localSegments.length > 1) {
- console.log(` 策略: 重新合并 ${issue.localSegments.length} 个分段文件...`);
- const audioDir = path.dirname(issue.localSegments[0]);
- result = await mergeAndUpload(issue.localSegments, audioDir, issue.chapterId);
- }
- // 策略2: 单个分段文件(可能格式不匹配)
- else if (issue.localSegments && issue.localSegments.length === 1) {
- const localFile = issue.localSegments[0];
- if (isWavFile(localFile)) {
- console.log(` 策略: WAV 分段转码为 MP3...`);
- result = await transcodeAndUpload(localFile, issue.chapterId);
- } else {
- // 尝试重新检测时长
- const actualDuration = await getLocalAudioDuration(localFile);
- if (actualDuration > issue.audioDuration * 1.5) {
- console.log(` 策略: 本地文件实际时长(${actualDuration}s) > 数据库时长(${issue.audioDuration}s),更新`);
- result = { url: issue.audioUrl, duration: actualDuration };
- }
- }
- }
- // 策略3: 远程文件
- else if (issue.audioUrl.startsWith('http')) {
- const remoteDuration = await getRemoteAudioDuration(issue.audioUrl);
- if (remoteDuration > issue.audioDuration * 1.5) {
- console.log(` 策略: 远程时长(${remoteDuration}s) > 数据库时长(${issue.audioDuration}s),更新数据库`);
- result = { url: issue.audioUrl, duration: remoteDuration };
- } else {
- console.log(` ⚠️ 远程音频时长也异常,无法自动修复(需重新生成音频)`);
- }
- } else {
- console.log(` ⚠️ 没有本地分段文件,无法自动修复(需重新生成音频)`);
- }
- break;
- case 'format_mismatch':
- const localFile = resolveAudioUrlToLocal(issue.audioUrl);
- if (localFile) {
- console.log(` 策略: 转码 WAV→MP3...`);
- result = await transcodeAndUpload(localFile, issue.chapterId);
- }
- break;
- case 'missing_duration':
- const localPath = resolveAudioUrlToLocal(issue.audioUrl);
- if (localPath) {
- const duration = await getLocalAudioDuration(localPath);
- if (duration > 0) {
- console.log(` 策略: 补充本地音频时长 ${duration}s`);
- result = { url: issue.audioUrl, duration };
- }
- } else if (issue.audioUrl.startsWith('http')) {
- const duration = await getRemoteAudioDuration(issue.audioUrl);
- if (duration > 0) {
- console.log(` 策略: 补充远程音频时长 ${duration}s`);
- result = { url: issue.audioUrl, duration };
- }
- }
- break;
- case 'merge_incomplete':
- console.log(` 策略: 重新合并子节音频...`);
- result = await mergeChapterAudio(issue.chapterId);
- break;
- }
- // 更新数据库
- if (result) {
- try {
- await prisma.bookChapter.update({
- where: { id: issue.chapterId },
- data: {
- audioUrl: result.url,
- audioDuration: result.duration,
- },
- });
- const newRate = result.duration > 0 ? (issue.wordCount / result.duration).toFixed(1) : 'N/A';
- console.log(` ✅ 数据库已更新: duration=${result.duration}s, 语速=${newRate}字/秒`);
- return true;
- } catch (error: any) {
- console.error(` ❌ 数据库更新失败: ${error.message}`);
- return false;
- }
- }
- console.log(` ⏭️ 无法自动修复,跳过`);
- return false;
- }
- // ============ 主流程 ============
- async function main() {
- console.log('='.repeat(70));
- console.log('🔍 音频问题扫描与修复工具');
- console.log('='.repeat(70));
- console.log(`模式: ${mergeAll ? '🔄 重新合并所有' : shouldFix ? '🔧 扫描+修复' : '🔍 仅扫描'}`);
- if (bookId) console.log(`指定书籍 ID: ${bookId}`);
- if (chapterId) console.log(`指定章节 ID: ${chapterId}`);
- console.log('');
- // --merge-all 模式:重新合并所有 level=1 章节
- if (mergeAll) {
- console.log('🔄 重新合并所有 level=1 章节音频...\n');
- const whereClause: any = { level: 1 };
- if (bookId) whereClause.bookId = bookId;
- const chapters = await prisma.bookChapter.findMany({
- where: whereClause,
- include: { book: { select: { id: true, title: true } } },
- orderBy: [{ bookId: 'asc' }, { number: 'asc' }],
- });
- let successCount = 0;
- let failCount = 0;
- for (const chapter of chapters) {
- console.log(`\n📖 合并章节 ${chapter.id} "${chapter.title}"...`);
- try {
- const result = await mergeChapterAudio(chapter.id);
- if (result) {
- successCount++;
- } else {
- console.log(` ⚠️ 无子节音频可合并,跳过`);
- failCount++;
- }
- } catch (error: any) {
- console.error(` ❌ 合并失败: ${error.message}`);
- failCount++;
- }
- }
- console.log(`\n${'='.repeat(70)}`);
- console.log(`📊 合并结果: ✅ ${successCount} 成功, ❌ ${failCount} 失败`);
- await prisma.$disconnect();
- return;
- }
- // 普通扫描模式
- const leafIssues = await scanLeafChapterIssues();
- const mergeIssues = await scanMergedChapterIssues();
- const allIssues = [...leafIssues, ...mergeIssues];
- // 汇总
- console.log('\n' + '='.repeat(70));
- console.log('📊 扫描结果汇总');
- console.log('='.repeat(70));
- const critical = allIssues.filter(i => i.severity === 'critical');
- const mild = allIssues.filter(i => i.severity === 'mild');
- const formatMismatch = allIssues.filter(i => i.severity === 'format_mismatch');
- const missingDur = allIssues.filter(i => i.severity === 'missing_duration');
- const mergeIncomplete = allIssues.filter(i => i.severity === 'merge_incomplete');
- console.log(`🔴 严重异常(语速>${SEVERE_RATE}字/秒): ${critical.length} 个`);
- console.log(`🟡 轻微异常(语速${MILD_RATE}-${SEVERE_RATE}字/秒): ${mild.length} 个`);
- console.log(`🟠 格式不匹配(WAV存为.mp3): ${formatMismatch.length} 个`);
- console.log(`🔵 时长缺失: ${missingDur.length} 个`);
- console.log(`🟣 合并不完整(章音频<子节总时长80%): ${mergeIncomplete.length} 个`);
- console.log(`总计: ${allIssues.length} 个问题\n`);
- if (allIssues.length === 0) {
- console.log('✅ 没有发现音频问题!');
- await prisma.$disconnect();
- return;
- }
- // 输出详细信息
- if (allIssues.length > 0) {
- console.log('📋 问题详情:');
- for (const issue of allIssues) {
- const icon = issue.severity === 'critical' ? '🔴' :
- issue.severity === 'mild' ? '🟡' :
- issue.severity === 'format_mismatch' ? '🟠' :
- issue.severity === 'merge_incomplete' ? '🟣' : '🔵';
- console.log(` ${icon} [${issue.severity}] 章节${issue.chapterId} "${issue.chapterTitle}" (book=${issue.bookId}, level=${issue.level})`);
- console.log(` ${issue.description}`);
- if (issue.localSegments?.length) {
- console.log(` 分段文件: ${issue.localSegments.length}个, 分段总时长: ${issue.segmentTotalDuration}s`);
- }
- if (issue.expectedDuration) {
- console.log(` 预期时长: ~${issue.expectedDuration}s, 实际: ${issue.audioDuration}s`);
- }
- if (issue.fuzzyMatchedDir) {
- console.log(` ⚠️ 疑似分段目录: ${issue.fuzzyMatchedDir}(需人工确认后再修复)`);
- }
- }
- }
- if (!shouldFix) {
- console.log('\n💡 使用 --fix 参数自动修复问题:');
- console.log(' npx tsx scripts/fix-audio-issues.ts --fix');
- console.log(' npx tsx scripts/fix-audio-issues.ts --fix --chapter 11');
- console.log('\n💡 使用 --merge-all 重新合并所有 level=1 章节音频:');
- console.log(' npx tsx scripts/fix-audio-issues.ts --merge-all');
- console.log(' npx tsx scripts/fix-audio-issues.ts --merge-all --book 5');
- await prisma.$disconnect();
- return;
- }
- // 自动修复
- console.log('\n' + '='.repeat(70));
- console.log('🔧 开始自动修复');
- console.log('='.repeat(70) + '\n');
- let fixedCount = 0;
- let failedCount = 0;
- for (const issue of allIssues) {
- const success = await fixIssue(issue);
- if (success) fixedCount++;
- else failedCount++;
- }
- console.log('\n' + '='.repeat(70));
- console.log('📊 修复结果');
- console.log('='.repeat(70));
- console.log(`✅ 修复成功: ${fixedCount} 个`);
- console.log(`❌ 修复失败/跳过: ${failedCount} 个`);
- await prisma.$disconnect();
- }
- main().catch(err => {
- console.error('脚本执行失败:', err);
- process.exit(1);
- });
|