139 lines
5.2 KiB
JavaScript
139 lines
5.2 KiB
JavaScript
/**
|
||
* 时间戳偏移验证:切块后每个 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);
|