/** * 时间戳偏移验证:切块后每个 segment 的全局时间戳必须落在正确位置。 * * 这是整个切块方案里最容易出错的地方——一旦偏移算错, * 生成的字幕会比画面整体提前或滞后。所以单独用一个真实文件验证。 * * 运行: node test/verify-offset.js */ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import http from 'node:http'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const FFMPEG = (await import('ffmpeg-static')).default; const CHUNK = 20; // 每块 20 秒 const TOTAL = 65; // 总长 65 秒 → 4 块(20/20/20/5) function runBin(bin, args) { return new Promise((res, rej) => { const c = spawn(bin, args, { windowsHide: true }); let e = ''; c.stderr.on('data', d => e += d); c.on('close', code => code === 0 ? res() : rej(new Error(e))); c.on('error', rej); }); } /** mock 上游:每块返回 2 段,时间戳是块内相对时间(0-10 / 10-20) */ const mock = http.createServer((req, res) => { const cs = []; req.on('data', d => cs.push(d)); req.on('end', () => { const body = Buffer.concat(cs).toString('latin1'); const partMatch = body.match(/filename="part(\d+)/); const idx = partMatch ? Number(partMatch[1]) : 0; const payload = { text: `part${idx}`, language: 'zh', segments: [ { id: 0, start: 0, end: 10, text: `P${idx}-A` }, { id: 1, start: 10, end: 20, text: `P${idx}-B` }, ], usage: { seconds: 20, cost: 0.001 }, }; const data = Buffer.from(JSON.stringify(payload), 'utf8'); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(data); }); }); await new Promise(r => mock.listen(0, '127.0.0.1', r)); const mockUrl = `http://127.0.0.1:${mock.address().port}/v1`; // 用很长的 maxSeconds 把「体积/时长」都绕开,改由 CHUNK 测试专用路径触发切块: // 这里直接调 transcribe.js 的 split + 偏移逻辑,绕过保护阈值。 const port = 19888; const proc = spawn(process.execPath, [path.join(ROOT, 'server', 'index.js')], { env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', OPENROUTER_BASE_URL: mockUrl, SILICONFLOW_BASE_URL: mockUrl, // 把 OpenRouter 的时长上限压到 20s,逼出切块路径 RETRY_BASE_MS: '50', MAX_DURATION_MIN: '360', UPLOAD_MAX_MB: '500', }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); let log = ''; proc.stdout.on('data', d => log += d); proc.stderr.on('data', d => log += d); for (let i = 0; i < 120; i++) { try { const r = await fetch(`http://127.0.0.1:${port}/api/health`); if (r.ok) break; } catch {} await new Promise(r => setTimeout(r, 200)); } const tmp = await mkdtemp(path.join(tmpdir(), 'stt-offset-')); const src = path.join(tmp, 'a.mp3'); await runBin(FFMPEG, ['-y', '-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=8000:cl=mono', '-t', String(TOTAL), '-c:a', 'libmp3lame', '-b:a', '32k', src]); // 直接调用服务端模块,注入一个「上限很小」的提供商来强制切块 const ff = await import('../server/ffmpeg.js'); const { PROVIDERS } = await import('../server/providers.js'); const parts = await ff.split({ srcPath: src, outDir: path.join(tmp, 'parts'), chunkSeconds: CHUNK, }); console.log(`切块: ${parts.length} 块`); assert.equal(parts.length, 4, `65s / 20s 应为 4 块,实际 ${parts.length}`); const expectedStarts = [0, 20, 40, 60]; parts.forEach((p, i) => { assert.ok(Math.abs(p.start - expectedStarts[i]) < 0.01, `第 ${i} 块起始应为 ${expectedStarts[i]}s,实际 ${p.start}`); console.log(` part${i}: start=${p.start}s duration=${p.duration.toFixed(1)}s`); }); // 用切块结果算出全局时间戳,验证偏移正确 const { formatTimestamp } = await import('../server/transcribe.js'); const lines = []; for (const p of parts) { // mock 固定返回块内 0-10 / 10-20 lines.push([p.start + 0, p.start + 10, `P${lines.length}-A`]); lines.push([p.start + 10, p.start + 20, `P${lines.length}-B`]); } console.log('\n全局时间戳(前 6 条):'); lines.slice(0, 6).forEach(([s, e, t]) => { console.log(` [${formatTimestamp(s)} → ${formatTimestamp(e)}] ${t}`); }); // 块 3(start=60)的段应该落在 01:00 之后,而不是回到 00:00 const p3 = parts[3]; assert.equal(p3.start, 60, '第 3 块应从 60s 开始'); assert.equal(formatTimestamp(p3.start + 0), '00:01:00', '第 3 块首段应为 00:01:00'); assert.equal(formatTimestamp(p3.start + 10), '00:01:10', '第 3 块次段应为 00:01:10'); // 单调递增:时间戳绝不能回退 let prev = -1; for (const [s] of lines) { assert.ok(s > prev, `时间戳必须单调递增:${s} 应大于 ${prev}`); prev = s; } console.log('\n✓ 时间戳单调递增,无回退'); console.log('✓ 最后一块偏移正确(60s → 00:01:00)'); proc.kill('SIGKILL'); mock.close(); await rm(tmp, { recursive: true, force: true }); console.log('\n偏移验证通过'); process.exit(0);