// ♊ 字幕 ↔ 旁白波形对齐(全新编写)。node gemini/tools/align.mjs (环境变量 FF = ffmpeg) // 起点:吸附到真实发声点前 LEAD 秒;终点:到下一句开口前、本句最后一个有声帧之后 TAIL 秒 import fs from 'fs'; import path from 'path'; import { execFileSync } from 'child_process'; import { fileURLToPath } from 'url'; const DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'), FILE = path.join(DIR, 'engine/timeline.js'); const src = fs.readFileSync(FILE, 'utf8'), TL = JSON.parse(src.slice(src.indexOf('{'), src.lastIndexOf('}') + 1)); const SR = 8000, FR = 100, LEAD = .06, TAIL = .15, stats = []; for (const A of TL.acts) { const b = execFileSync(process.env.FF || 'ffmpeg', ['-v', 'error', '-i', path.join(DIR, A.voice.file), '-ac', '1', '-ar', '' + SR, '-f', 'f32le', '-'], { maxBuffer: 1 << 26 }); const x = new Float32Array(b.buffer, b.byteOffset, b.length / 4), hop = SR / FR, e = []; for (let i = 0; i < x.length; i += hop) { let s = 0; for (let j = i; j < i + hop && j < x.length; j++) s += x[j] * x[j]; e.push(Math.sqrt(s / hop)); } const th = Math.max(.01, Math.max(...e) * .055), loud = f => e[f] > th; const onset = f0 => { let best = null; for (let d = -40; d <= 40; d++) { const f = f0 + d; if (f >= 0 && loud(f) && [1, 2, 3, 4, 5].every(k => f - k < 0 || !loud(f - k)) && (best === null || Math.abs(d) < Math.abs(best))) best = d; } return best === null ? f0 : f0 + best; }; const on = A.subs.map((s, i) => i === 0 ? e.findIndex(v => v > th) : onset(Math.round((s.t0 - A.voice.at) * FR))); // 每幕首句 = 音频第一个有声帧 A.subs.forEach((s, i) => { const t0 = A.voice.at + on[i] / FR - LEAD; stats.push(Math.round((t0 + LEAD - s.t0) * 1000)); s.t0 = +t0.toFixed(3); const lim = i < on.length - 1 ? on[i + 1] : e.length; let last = on[i]; for (let f = on[i]; f < lim; f++) if (loud(f)) last = f; s.t1 = +(A.voice.at + last / FR + TAIL).toFixed(3); }); A.subs.forEach((s, i) => { const n = A.subs[i + 1]; if (n && (s.t1 > n.t0 - .02 || n.t0 - s.t1 < .3)) s.t1 = +(n.t0 - .02).toFixed(3); if (s.t1 < s.t0 + .5) s.t1 = +(s.t0 + .5).toFixed(3); }); // 校验:每个有声帧都应被某条字幕覆盖 let miss = 0; e.forEach((v, f) => { if (v > th) { const t = A.voice.at + f / FR; if (!A.subs.some(s => t >= s.t0 && t <= s.t1)) miss++; } }); A._miss = miss; } const miss = TL.acts.reduce((a, A) => a + A._miss, 0); TL.acts.forEach(A => delete A._miss); fs.writeFileSync(FILE, src.slice(0, src.indexOf('{')) + JSON.stringify(TL, null, 1) + src.slice(src.lastIndexOf('}') + 1)); console.log(`对齐 ${stats.length} 条;原始偏差 ${Math.min(...stats)}~${Math.max(...stats)} ms;有声却无字幕的帧 ${miss}`);