fix-audio-issues.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765
  1. /**
  2. * 音频问题扫描与修复脚本
  3. *
  4. * 功能:
  5. * 1. 扫描所有章节,检测语速异常(音频时长与字数不匹配)
  6. * 2. 检测 WAV 文件被错误存为 MP3 的问题
  7. * 3. 检测 FFmpeg concat 合并丢失段的问题
  8. * 4. 检测 level=1 章节合并音频与子节时长总和不匹配
  9. * 5. 检测 audioDuration 为 0 但有 audioUrl 的章节
  10. * 6. 提供自动修复(重新合并分段、上传、更新数据库)
  11. *
  12. * 用法:
  13. * npx tsx scripts/fix-audio-issues.ts # 扫描所有问题
  14. * npx tsx scripts/fix-audio-issues.ts --fix # 扫描并自动修复
  15. * npx tsx scripts/fix-audio-issues.ts --book 5 # 只扫描指定书籍
  16. * npx tsx scripts/fix-audio-issues.ts --chapter 11 # 只扫描指定章节
  17. * npx tsx scripts/fix-audio-issues.ts --fix --chapter 11 # 修复指定章节
  18. * npx tsx scripts/fix-audio-issues.ts --merge-all # 重新合并所有 level=1 章的音频
  19. *
  20. * 异常判定规则:
  21. * - 语速 > 10字/秒 → 严重异常(可能丢失了大部分音频)
  22. * - 语速 6-10字/秒 → 轻微异常(可能丢失了部分音频)
  23. * - 语速 3-6字/秒 → 正常
  24. * - 音频时长为0但有 audioUrl → 需要补充时长
  25. * - level=1 章合并音频时长 < 子节时长总和 * 0.8 → 合并不完整
  26. */
  27. import { PrismaClient } from '@prisma/client';
  28. import { exec } from 'child_process';
  29. import { promisify } from 'util';
  30. import fs from 'fs';
  31. import path from 'path';
  32. import axios from 'axios';
  33. const execAsync = promisify(exec);
  34. const prisma = new PrismaClient();
  35. // ============ 配置 ============
  36. const SEVERE_RATE = 10; // 严重异常阈值(字/秒)
  37. const MILD_RATE = 6; // 轻微异常阈值(字/秒)
  38. const CHARS_PER_SECOND = 4; // 估算用的标准语速
  39. const MERGE_TOLERANCE = 0.8; // 合并音频时长容差(低于子节总时长的80%视为异常)
  40. // ============ 解析命令行参数 ============
  41. const args = process.argv.slice(2);
  42. const shouldFix = args.includes('--fix');
  43. const mergeAll = args.includes('--merge-all');
  44. const bookArg = args.find(a => a.startsWith('--book'));
  45. const chapterArg = args.find(a => a.startsWith('--chapter'));
  46. const bookId = bookArg ? parseInt(bookArg.split('=')[1] || args[args.indexOf(bookArg) + 1]) : null;
  47. const chapterId = chapterArg ? parseInt(chapterArg.split('=')[1] || args[args.indexOf(chapterArg) + 1]) : null;
  48. // ============ 类型定义 ============
  49. type IssueSeverity = 'critical' | 'mild' | 'missing_duration' | 'format_mismatch' | 'merge_incomplete';
  50. interface AudioIssue {
  51. chapterId: number;
  52. bookId: number;
  53. bookTitle: string;
  54. chapterTitle: string;
  55. level: number;
  56. wordCount: number;
  57. audioDuration: number;
  58. audioUrl: string;
  59. rate: number;
  60. severity: IssueSeverity;
  61. description: string;
  62. localSegments?: string[];
  63. segmentTotalDuration?: number;
  64. expectedDuration?: number; // 预估应有的时长
  65. fuzzyMatchedDir?: string; // 模糊匹配的可能目录(需人工确认)
  66. }
  67. // ============ 工具函数 ============
  68. async function getLocalAudioDuration(filePath: string): Promise<number> {
  69. try {
  70. const { stdout } = await execAsync(
  71. `ffprobe -i "${filePath}" -show_entries format=duration -v quiet -of csv="p=0"`,
  72. { timeout: 10000 }
  73. );
  74. return Math.round((parseFloat(stdout.trim()) || 0) * 10) / 10;
  75. } catch {
  76. return 0;
  77. }
  78. }
  79. async function getRemoteAudioDuration(url: string): Promise<number> {
  80. try {
  81. const { stdout } = await execAsync(
  82. `ffprobe -i "${url}" -show_entries format=duration -v quiet -of csv="p=0"`,
  83. { timeout: 15000 }
  84. );
  85. const duration = parseFloat(stdout.trim());
  86. if (duration > 0) return Math.round(duration * 10) / 10;
  87. } catch {}
  88. try {
  89. const tempDir = path.join(process.cwd(), 'temp');
  90. if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
  91. const tempFile = path.join(tempDir, `check_${Date.now()}.tmp`);
  92. const response = await axios.get(url, { responseType: 'arraybuffer', timeout: 30000 });
  93. fs.writeFileSync(tempFile, response.data);
  94. const duration = await getLocalAudioDuration(tempFile);
  95. try { fs.unlinkSync(tempFile); } catch {}
  96. return duration;
  97. } catch {
  98. return 0;
  99. }
  100. }
  101. function isWavFile(filePath: string): boolean {
  102. try {
  103. if (!fs.existsSync(filePath)) return false;
  104. const buffer = Buffer.alloc(12);
  105. const fd = fs.openSync(filePath, 'r');
  106. fs.readSync(fd, buffer, 0, 12, 0);
  107. fs.closeSync(fd);
  108. return buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WAVE';
  109. } catch {
  110. return false;
  111. }
  112. }
  113. function resolveAudioUrlToLocal(audioUrl: string): string | null {
  114. if (!audioUrl) return null;
  115. if (audioUrl.startsWith('http')) return null;
  116. const relativePath = audioUrl.replace(/^\//, '');
  117. const absolutePath = path.join(process.cwd(), relativePath);
  118. return fs.existsSync(absolutePath) ? absolutePath : null;
  119. }
  120. function findLocalSegments(audioDir: string): string[] {
  121. if (!fs.existsSync(audioDir)) return [];
  122. const segments: string[] = [];
  123. const files = fs.readdirSync(audioDir).sort();
  124. for (const file of files) {
  125. if (file.match(/^segment_\d+\.(wav|mp3)$/)) {
  126. segments.push(path.join(audioDir, file));
  127. }
  128. }
  129. return segments;
  130. }
  131. function inferAudioDir(audioUrl: string): string | null {
  132. if (!audioUrl) return null;
  133. const match = audioUrl.match(/\/uploads\/([^/]+)\//);
  134. if (match) {
  135. return path.join(process.cwd(), 'uploads', match[1]);
  136. }
  137. return null;
  138. }
  139. /**
  140. * 扫描 uploads 目录,查找所有包含 segment_*.wav 的目录
  141. * 返回 Map<目录路径, 分段文件列表>
  142. */
  143. function scanAllSegmentDirs(): Map<string, string[]> {
  144. const uploadsDir = path.join(process.cwd(), 'uploads');
  145. if (!fs.existsSync(uploadsDir)) return new Map();
  146. const result = new Map<string, string[]>();
  147. try {
  148. const dirs = fs.readdirSync(uploadsDir);
  149. for (const dir of dirs) {
  150. const dirPath = path.join(uploadsDir, dir);
  151. try {
  152. if (!fs.statSync(dirPath).isDirectory()) continue;
  153. } catch { continue; }
  154. const segments = findLocalSegments(dirPath);
  155. if (segments.length > 0) {
  156. result.set(dirPath, segments);
  157. }
  158. }
  159. } catch {}
  160. return result;
  161. }
  162. /**
  163. * 通过分段文件总时长与数据库章节字数对比,找到最匹配的目录
  164. * ⚠️ 此方法仅作参考,不自动使用——需要人工确认后再修复
  165. */
  166. function findBestMatchingSegmentDir(
  167. segmentDirs: Map<string, string[]>,
  168. wordCount: number,
  169. currentDuration: number
  170. ): { dir: string; segments: string[]; totalDuration: number } | null {
  171. const expectedDuration = wordCount / CHARS_PER_SECOND;
  172. let bestMatch: { dir: string; segments: string[]; totalDuration: number; diff: number } | null = null;
  173. for (const [dir, segments] of segmentDirs) {
  174. // 计算分段总时长(同步快速估算)
  175. let totalSize = 0;
  176. for (const seg of segments) {
  177. try { totalSize += fs.statSync(seg).size; } catch {}
  178. }
  179. // WAV 文件大约 176KB/s(16bit 22kHz 立体声)或 88KB/s(单声道)
  180. const estimatedDuration = totalSize / 176000; // 粗估
  181. // 寻找估算时长接近预期时长的
  182. const diff = Math.abs(estimatedDuration - expectedDuration);
  183. if (!bestMatch || diff < bestMatch.diff) {
  184. bestMatch = { dir, segments, totalDuration: estimatedDuration, diff };
  185. }
  186. }
  187. return bestMatch ? { dir: bestMatch.dir, segments: bestMatch.segments, totalDuration: bestMatch.totalDuration } : null;
  188. }
  189. async function mergeAndUpload(
  190. segmentFiles: string[],
  191. outputDir: string,
  192. chapterId: number
  193. ): Promise<{ url: string; duration: number } | null> {
  194. if (segmentFiles.length === 0) return null;
  195. try {
  196. const listFile = path.join(outputDir, 'fix_concat_list.txt');
  197. const listContent = segmentFiles.map(f => {
  198. const relativePath = path.relative(outputDir, f).replace(/\\/g, '/');
  199. return `file '${relativePath}'`;
  200. }).join('\n');
  201. fs.writeFileSync(listFile, listContent);
  202. const outputFile = path.join(outputDir, `output_fixed_${Date.now()}.mp3`);
  203. const cmd = `ffmpeg -f concat -safe 0 -i "${listFile}" -c:a libmp3lame -b:a 192k -y "${outputFile}"`;
  204. console.log(` 🔧 执行: ${cmd}`);
  205. await execAsync(cmd, { timeout: 300000, cwd: outputDir });
  206. const duration = await getLocalAudioDuration(outputFile);
  207. const { storageService } = await import('../src/services/storage.service');
  208. const audioId = `fix_${chapterId}_${Date.now()}`;
  209. const finalUrl = await storageService.uploadAudio(outputFile, audioId);
  210. try { fs.unlinkSync(listFile); } catch {}
  211. try { fs.unlinkSync(outputFile); } catch {}
  212. console.log(` ✅ 合并上传成功: ${finalUrl}, 时长: ${duration}s`);
  213. return { url: finalUrl, duration };
  214. } catch (error: any) {
  215. console.error(` ❌ 合并失败: ${error.message}`);
  216. return null;
  217. }
  218. }
  219. async function transcodeAndUpload(
  220. localFile: string,
  221. chapterId: number
  222. ): Promise<{ url: string; duration: number } | null> {
  223. try {
  224. const outputDir = path.dirname(localFile);
  225. const outputFile = path.join(outputDir, `transcoded_${Date.now()}.mp3`);
  226. const cmd = `ffmpeg -i "${localFile}" -c:a libmp3lame -b:a 192k -y "${outputFile}"`;
  227. console.log(` 🔧 转码: ${cmd}`);
  228. await execAsync(cmd, { timeout: 120000 });
  229. const duration = await getLocalAudioDuration(outputFile);
  230. const { storageService } = await import('../src/services/storage.service');
  231. const audioId = `fix_${chapterId}_${Date.now()}`;
  232. const finalUrl = await storageService.uploadAudio(outputFile, audioId);
  233. try { fs.unlinkSync(outputFile); } catch {}
  234. console.log(` ✅ 转码上传成功: ${finalUrl}, 时长: ${duration}s`);
  235. return { url: finalUrl, duration };
  236. } catch (error: any) {
  237. console.error(` ❌ 转码失败: ${error.message}`);
  238. return null;
  239. }
  240. }
  241. /**
  242. * 合并子节音频为 level=1 章节音频(通过 AudioMerger)
  243. */
  244. async function mergeChapterAudio(chapterId: number): Promise<{ url: string; duration: number } | null> {
  245. try {
  246. // 使用 player.service 的 mergeChapterAudios 函数
  247. const { mergeChapterAudios } = await import('../src/modules/player/player.service');
  248. const mergedUrl = await mergeChapterAudios(chapterId);
  249. if (!mergedUrl) {
  250. console.log(` ⚠️ mergeChapterAudios 返回 null,可能没有子节音频`);
  251. return null;
  252. }
  253. // 获取合并后时长
  254. let duration = 0;
  255. const localPath = resolveAudioUrlToLocal(mergedUrl);
  256. if (localPath) {
  257. duration = await getLocalAudioDuration(localPath);
  258. } else if (mergedUrl.startsWith('http')) {
  259. duration = await getRemoteAudioDuration(mergedUrl);
  260. }
  261. // 更新数据库时长
  262. if (duration > 0) {
  263. await prisma.bookChapter.update({
  264. where: { id: chapterId },
  265. data: { audioDuration: duration },
  266. });
  267. }
  268. console.log(` ✅ 章节合并完成: ${mergedUrl.substring(0, 60)}..., 时长: ${duration}s`);
  269. return { url: mergedUrl, duration };
  270. } catch (error: any) {
  271. console.error(` ❌ 章节合并失败: ${error.message}`);
  272. return null;
  273. }
  274. }
  275. // ============ 扫描函数 ============
  276. async function scanLeafChapterIssues(): Promise<AudioIssue[]> {
  277. const issues: AudioIssue[] = [];
  278. const whereClause: any = {
  279. audioUrl: { not: '' },
  280. };
  281. if (chapterId) {
  282. whereClause.id = chapterId;
  283. } else if (bookId) {
  284. whereClause.bookId = bookId;
  285. }
  286. const chapters = await prisma.bookChapter.findMany({
  287. where: whereClause,
  288. include: { book: { select: { id: true, title: true } } },
  289. orderBy: [{ bookId: 'asc' }, { number: 'asc' }],
  290. });
  291. console.log(`📚 找到 ${chapters.length} 个有音频的章节(叶节点检查)\n`);
  292. // 预扫描 uploads 目录中的分段文件
  293. let allSegmentDirs: Map<string, string[]> | null = null;
  294. let currentBookId = -1;
  295. for (const chapter of chapters) {
  296. if (chapter.bookId !== currentBookId) {
  297. currentBookId = chapter.bookId;
  298. console.log(`📖 书籍 ${currentBookId}: ${chapter.book?.title || '未知'}`);
  299. }
  300. const wordCount = chapter.wordCount || chapter.content?.length || 0;
  301. const duration = chapter.audioDuration || 0;
  302. const rate = duration > 0 ? wordCount / duration : 0;
  303. let severity: IssueSeverity | null = null;
  304. let description = '';
  305. // 检查1: 语速异常
  306. if (duration > 0 && rate > SEVERE_RATE) {
  307. severity = 'critical';
  308. description = `严重异常:语速 ${rate.toFixed(1)}字/秒,音频时长仅${duration}s,应有约${Math.round(wordCount / CHARS_PER_SECOND)}s`;
  309. } else if (duration > 0 && rate > MILD_RATE) {
  310. severity = 'mild';
  311. description = `轻微异常:语速 ${rate.toFixed(1)}字/秒,偏快,可能丢失部分音频`;
  312. }
  313. // 检查2: 时长缺失
  314. if (duration === 0 && chapter.audioUrl) {
  315. severity = 'missing_duration';
  316. description = `音频时长为0,但audioUrl存在`;
  317. }
  318. // 检查3: 格式不匹配
  319. const localPath = resolveAudioUrlToLocal(chapter.audioUrl);
  320. if (localPath && chapter.audioUrl.endsWith('.mp3') && isWavFile(localPath)) {
  321. if (!severity) {
  322. severity = 'format_mismatch';
  323. description = `文件格式不匹配:扩展名.mp3但内容为WAV格式`;
  324. } else {
  325. description += ';且文件格式不匹配(.mp3扩展名但WAV内容)';
  326. }
  327. }
  328. if (severity) {
  329. const issue: AudioIssue = {
  330. chapterId: chapter.id,
  331. bookId: chapter.bookId,
  332. bookTitle: chapter.book?.title || '',
  333. chapterTitle: chapter.title,
  334. level: chapter.level,
  335. wordCount,
  336. audioDuration: duration,
  337. audioUrl: chapter.audioUrl,
  338. rate,
  339. severity,
  340. description,
  341. expectedDuration: Math.round(wordCount / CHARS_PER_SECOND),
  342. };
  343. // 查找本地分段文件
  344. let audioDir = inferAudioDir(chapter.audioUrl);
  345. // 如果从 URL 无法推断目录(远程 OSS URL),尝试扫描 uploads 目录
  346. // ⚠️ 注意:模糊匹配可能不准确,仅用于提示,不自动修复
  347. let fuzzyMatchedDir: string | null = null;
  348. if (!audioDir) {
  349. if (!allSegmentDirs) {
  350. console.log(' 🔍 扫描本地 uploads 目录查找分段文件...');
  351. allSegmentDirs = scanAllSegmentDirs();
  352. console.log(` 📂 找到 ${allSegmentDirs.size} 个含分段文件的目录`);
  353. }
  354. // 通过字数匹配找到可能的目录(仅提示,不自动使用)
  355. if (allSegmentDirs.size > 0) {
  356. const match = findBestMatchingSegmentDir(allSegmentDirs, wordCount, duration);
  357. if (match && match.totalDuration > duration * 1.5) {
  358. fuzzyMatchedDir = match.dir;
  359. console.log(` ⚠️ 章节 ${chapter.id} 可能有本地分段目录: ${fuzzyMatchedDir} (估算时长${Math.round(match.totalDuration)}s) - 需人工确认`);
  360. }
  361. }
  362. }
  363. if (audioDir) {
  364. const segments = findLocalSegments(audioDir);
  365. if (segments.length > 0) {
  366. issue.localSegments = segments;
  367. let totalSegDuration = 0;
  368. for (const seg of segments) {
  369. totalSegDuration += await getLocalAudioDuration(seg);
  370. }
  371. issue.segmentTotalDuration = totalSegDuration;
  372. }
  373. }
  374. // 保存模糊匹配结果
  375. if (fuzzyMatchedDir) {
  376. issue.fuzzyMatchedDir = fuzzyMatchedDir;
  377. }
  378. issues.push(issue);
  379. const icon = severity === 'critical' ? '🔴' : severity === 'mild' ? '🟡' : severity === 'format_mismatch' ? '🟠' : '🔵';
  380. console.log(` ${icon} 章节 ${chapter.id} "${chapter.title}" (level=${chapter.level}): ${description}`);
  381. if (issue.localSegments?.length) {
  382. console.log(` 📎 找到 ${issue.localSegments.length} 个分段文件,总时长 ${issue.segmentTotalDuration}s`);
  383. }
  384. if (issue.fuzzyMatchedDir) {
  385. console.log(` ⚠️ 疑似分段目录: ${issue.fuzzyMatchedDir}(需人工确认)`);
  386. }
  387. }
  388. }
  389. return issues;
  390. }
  391. async function scanMergedChapterIssues(): Promise<AudioIssue[]> {
  392. const issues: AudioIssue[] = [];
  393. // 查找所有 level=1 的章节(有子节)
  394. const whereClause: any = { level: 1 };
  395. if (chapterId) {
  396. whereClause.id = chapterId;
  397. } else if (bookId) {
  398. whereClause.bookId = bookId;
  399. }
  400. const level1Chapters = await prisma.bookChapter.findMany({
  401. where: whereClause,
  402. include: { book: { select: { id: true, title: true } } },
  403. orderBy: [{ bookId: 'asc' }, { number: 'asc' }],
  404. });
  405. console.log(`\n📚 找到 ${level1Chapters.length} 个一级章节(合并音频检查)\n`);
  406. let currentBookId = -1;
  407. for (const chapter of level1Chapters) {
  408. if (chapter.bookId !== currentBookId) {
  409. currentBookId = chapter.bookId;
  410. console.log(`📖 书籍 ${currentBookId}: ${chapter.book?.title || '未知'}`);
  411. }
  412. // 获取子节音频
  413. const children = await prisma.bookChapter.findMany({
  414. where: { parentId: chapter.id, audioUrl: { not: '' } },
  415. orderBy: { number: 'asc' },
  416. });
  417. if (children.length === 0) continue;
  418. const childrenTotalDuration = children.reduce((sum, c) => sum + (c.audioDuration || 0), 0);
  419. const chapterDuration = chapter.audioDuration || 0;
  420. // 检查: 合并音频时长应接近子节时长总和
  421. if (chapter.audioUrl && childrenTotalDuration > 0) {
  422. const ratio = chapterDuration / childrenTotalDuration;
  423. if (ratio < MERGE_TOLERANCE) {
  424. const issue: AudioIssue = {
  425. chapterId: chapter.id,
  426. bookId: chapter.bookId,
  427. bookTitle: chapter.book?.title || '',
  428. chapterTitle: chapter.title,
  429. level: chapter.level,
  430. wordCount: chapter.wordCount || 0,
  431. audioDuration: chapterDuration,
  432. audioUrl: chapter.audioUrl,
  433. rate: 0,
  434. severity: 'merge_incomplete',
  435. description: `合并不完整:章音频${chapterDuration}s < 子节总时长${childrenTotalDuration}s(比例${(ratio * 100).toFixed(0)}%)`,
  436. expectedDuration: childrenTotalDuration,
  437. };
  438. issues.push(issue);
  439. console.log(` 🟣 章节 ${chapter.id} "${chapter.title}": ${issue.description}`);
  440. } else {
  441. // 正常
  442. process.stdout.write(` ✅ 章节 ${chapter.id} "${chapter.title}": 合并正常 (${chapterDuration}s ≈ ${childrenTotalDuration}s)\n`);
  443. }
  444. } else if (!chapter.audioUrl && children.length > 0) {
  445. // 章没有合并音频但有子节
  446. console.log(` ⚪ 章节 ${chapter.id} "${chapter.title}": 无合并音频(${children.length}个子节总时长${childrenTotalDuration}s)`);
  447. }
  448. }
  449. return issues;
  450. }
  451. // ============ 修复函数 ============
  452. async function fixIssue(issue: AudioIssue): Promise<boolean> {
  453. console.log(`\n--- 修复章节 ${issue.chapterId} "${issue.chapterTitle}" ---`);
  454. console.log(` 问题: ${issue.description}`);
  455. let result: { url: string; duration: number } | null = null;
  456. switch (issue.severity) {
  457. case 'critical':
  458. case 'mild':
  459. // 策略1: 本地分段文件重新合并
  460. if (issue.localSegments && issue.localSegments.length > 1) {
  461. console.log(` 策略: 重新合并 ${issue.localSegments.length} 个分段文件...`);
  462. const audioDir = path.dirname(issue.localSegments[0]);
  463. result = await mergeAndUpload(issue.localSegments, audioDir, issue.chapterId);
  464. }
  465. // 策略2: 单个分段文件(可能格式不匹配)
  466. else if (issue.localSegments && issue.localSegments.length === 1) {
  467. const localFile = issue.localSegments[0];
  468. if (isWavFile(localFile)) {
  469. console.log(` 策略: WAV 分段转码为 MP3...`);
  470. result = await transcodeAndUpload(localFile, issue.chapterId);
  471. } else {
  472. // 尝试重新检测时长
  473. const actualDuration = await getLocalAudioDuration(localFile);
  474. if (actualDuration > issue.audioDuration * 1.5) {
  475. console.log(` 策略: 本地文件实际时长(${actualDuration}s) > 数据库时长(${issue.audioDuration}s),更新`);
  476. result = { url: issue.audioUrl, duration: actualDuration };
  477. }
  478. }
  479. }
  480. // 策略3: 远程文件
  481. else if (issue.audioUrl.startsWith('http')) {
  482. const remoteDuration = await getRemoteAudioDuration(issue.audioUrl);
  483. if (remoteDuration > issue.audioDuration * 1.5) {
  484. console.log(` 策略: 远程时长(${remoteDuration}s) > 数据库时长(${issue.audioDuration}s),更新数据库`);
  485. result = { url: issue.audioUrl, duration: remoteDuration };
  486. } else {
  487. console.log(` ⚠️ 远程音频时长也异常,无法自动修复(需重新生成音频)`);
  488. }
  489. } else {
  490. console.log(` ⚠️ 没有本地分段文件,无法自动修复(需重新生成音频)`);
  491. }
  492. break;
  493. case 'format_mismatch':
  494. const localFile = resolveAudioUrlToLocal(issue.audioUrl);
  495. if (localFile) {
  496. console.log(` 策略: 转码 WAV→MP3...`);
  497. result = await transcodeAndUpload(localFile, issue.chapterId);
  498. }
  499. break;
  500. case 'missing_duration':
  501. const localPath = resolveAudioUrlToLocal(issue.audioUrl);
  502. if (localPath) {
  503. const duration = await getLocalAudioDuration(localPath);
  504. if (duration > 0) {
  505. console.log(` 策略: 补充本地音频时长 ${duration}s`);
  506. result = { url: issue.audioUrl, duration };
  507. }
  508. } else if (issue.audioUrl.startsWith('http')) {
  509. const duration = await getRemoteAudioDuration(issue.audioUrl);
  510. if (duration > 0) {
  511. console.log(` 策略: 补充远程音频时长 ${duration}s`);
  512. result = { url: issue.audioUrl, duration };
  513. }
  514. }
  515. break;
  516. case 'merge_incomplete':
  517. console.log(` 策略: 重新合并子节音频...`);
  518. result = await mergeChapterAudio(issue.chapterId);
  519. break;
  520. }
  521. // 更新数据库
  522. if (result) {
  523. try {
  524. await prisma.bookChapter.update({
  525. where: { id: issue.chapterId },
  526. data: {
  527. audioUrl: result.url,
  528. audioDuration: result.duration,
  529. },
  530. });
  531. const newRate = result.duration > 0 ? (issue.wordCount / result.duration).toFixed(1) : 'N/A';
  532. console.log(` ✅ 数据库已更新: duration=${result.duration}s, 语速=${newRate}字/秒`);
  533. return true;
  534. } catch (error: any) {
  535. console.error(` ❌ 数据库更新失败: ${error.message}`);
  536. return false;
  537. }
  538. }
  539. console.log(` ⏭️ 无法自动修复,跳过`);
  540. return false;
  541. }
  542. // ============ 主流程 ============
  543. async function main() {
  544. console.log('='.repeat(70));
  545. console.log('🔍 音频问题扫描与修复工具');
  546. console.log('='.repeat(70));
  547. console.log(`模式: ${mergeAll ? '🔄 重新合并所有' : shouldFix ? '🔧 扫描+修复' : '🔍 仅扫描'}`);
  548. if (bookId) console.log(`指定书籍 ID: ${bookId}`);
  549. if (chapterId) console.log(`指定章节 ID: ${chapterId}`);
  550. console.log('');
  551. // --merge-all 模式:重新合并所有 level=1 章节
  552. if (mergeAll) {
  553. console.log('🔄 重新合并所有 level=1 章节音频...\n');
  554. const whereClause: any = { level: 1 };
  555. if (bookId) whereClause.bookId = bookId;
  556. const chapters = await prisma.bookChapter.findMany({
  557. where: whereClause,
  558. include: { book: { select: { id: true, title: true } } },
  559. orderBy: [{ bookId: 'asc' }, { number: 'asc' }],
  560. });
  561. let successCount = 0;
  562. let failCount = 0;
  563. for (const chapter of chapters) {
  564. console.log(`\n📖 合并章节 ${chapter.id} "${chapter.title}"...`);
  565. try {
  566. const result = await mergeChapterAudio(chapter.id);
  567. if (result) {
  568. successCount++;
  569. } else {
  570. console.log(` ⚠️ 无子节音频可合并,跳过`);
  571. failCount++;
  572. }
  573. } catch (error: any) {
  574. console.error(` ❌ 合并失败: ${error.message}`);
  575. failCount++;
  576. }
  577. }
  578. console.log(`\n${'='.repeat(70)}`);
  579. console.log(`📊 合并结果: ✅ ${successCount} 成功, ❌ ${failCount} 失败`);
  580. await prisma.$disconnect();
  581. return;
  582. }
  583. // 普通扫描模式
  584. const leafIssues = await scanLeafChapterIssues();
  585. const mergeIssues = await scanMergedChapterIssues();
  586. const allIssues = [...leafIssues, ...mergeIssues];
  587. // 汇总
  588. console.log('\n' + '='.repeat(70));
  589. console.log('📊 扫描结果汇总');
  590. console.log('='.repeat(70));
  591. const critical = allIssues.filter(i => i.severity === 'critical');
  592. const mild = allIssues.filter(i => i.severity === 'mild');
  593. const formatMismatch = allIssues.filter(i => i.severity === 'format_mismatch');
  594. const missingDur = allIssues.filter(i => i.severity === 'missing_duration');
  595. const mergeIncomplete = allIssues.filter(i => i.severity === 'merge_incomplete');
  596. console.log(`🔴 严重异常(语速>${SEVERE_RATE}字/秒): ${critical.length} 个`);
  597. console.log(`🟡 轻微异常(语速${MILD_RATE}-${SEVERE_RATE}字/秒): ${mild.length} 个`);
  598. console.log(`🟠 格式不匹配(WAV存为.mp3): ${formatMismatch.length} 个`);
  599. console.log(`🔵 时长缺失: ${missingDur.length} 个`);
  600. console.log(`🟣 合并不完整(章音频<子节总时长80%): ${mergeIncomplete.length} 个`);
  601. console.log(`总计: ${allIssues.length} 个问题\n`);
  602. if (allIssues.length === 0) {
  603. console.log('✅ 没有发现音频问题!');
  604. await prisma.$disconnect();
  605. return;
  606. }
  607. // 输出详细信息
  608. if (allIssues.length > 0) {
  609. console.log('📋 问题详情:');
  610. for (const issue of allIssues) {
  611. const icon = issue.severity === 'critical' ? '🔴' :
  612. issue.severity === 'mild' ? '🟡' :
  613. issue.severity === 'format_mismatch' ? '🟠' :
  614. issue.severity === 'merge_incomplete' ? '🟣' : '🔵';
  615. console.log(` ${icon} [${issue.severity}] 章节${issue.chapterId} "${issue.chapterTitle}" (book=${issue.bookId}, level=${issue.level})`);
  616. console.log(` ${issue.description}`);
  617. if (issue.localSegments?.length) {
  618. console.log(` 分段文件: ${issue.localSegments.length}个, 分段总时长: ${issue.segmentTotalDuration}s`);
  619. }
  620. if (issue.expectedDuration) {
  621. console.log(` 预期时长: ~${issue.expectedDuration}s, 实际: ${issue.audioDuration}s`);
  622. }
  623. if (issue.fuzzyMatchedDir) {
  624. console.log(` ⚠️ 疑似分段目录: ${issue.fuzzyMatchedDir}(需人工确认后再修复)`);
  625. }
  626. }
  627. }
  628. if (!shouldFix) {
  629. console.log('\n💡 使用 --fix 参数自动修复问题:');
  630. console.log(' npx tsx scripts/fix-audio-issues.ts --fix');
  631. console.log(' npx tsx scripts/fix-audio-issues.ts --fix --chapter 11');
  632. console.log('\n💡 使用 --merge-all 重新合并所有 level=1 章节音频:');
  633. console.log(' npx tsx scripts/fix-audio-issues.ts --merge-all');
  634. console.log(' npx tsx scripts/fix-audio-issues.ts --merge-all --book 5');
  635. await prisma.$disconnect();
  636. return;
  637. }
  638. // 自动修复
  639. console.log('\n' + '='.repeat(70));
  640. console.log('🔧 开始自动修复');
  641. console.log('='.repeat(70) + '\n');
  642. let fixedCount = 0;
  643. let failedCount = 0;
  644. for (const issue of allIssues) {
  645. const success = await fixIssue(issue);
  646. if (success) fixedCount++;
  647. else failedCount++;
  648. }
  649. console.log('\n' + '='.repeat(70));
  650. console.log('📊 修复结果');
  651. console.log('='.repeat(70));
  652. console.log(`✅ 修复成功: ${fixedCount} 个`);
  653. console.log(`❌ 修复失败/跳过: ${failedCount} 个`);
  654. await prisma.$disconnect();
  655. }
  656. main().catch(err => {
  657. console.error('脚本执行失败:', err);
  658. process.exit(1);
  659. });