import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; import vm from 'node:vm'; import ts from 'typescript'; const loadModule = async () => { const source = await readFile(new URL('../src/services/mpeg-audio-duration.ts', import.meta.url), 'utf8'); const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }); const runtimeModule = { exports: {} }; vm.runInNewContext(compiled.outputText, { exports: runtimeModule.exports, module: runtimeModule }); return runtimeModule.exports; }; /** * 造一个 Layer III 帧头。默认参数对应后端 TTS 实际产物: * MPEG2 / 24 kHz / 160 kbps / 单声道,帧长 480 字节、每帧 576 采样 = 24ms。 */ const frame = ({ mpeg1 = false, bitrateIndex = 14, sampleRateIndex = 1, padding = 0 } = {}) => { const versionBits = mpeg1 ? 3 : 2; const header = Uint8Array.from([ 0xff, 0xe0 | (versionBits << 3) | (1 << 1), // layer III, 无 CRC (bitrateIndex << 4) | (sampleRateIndex << 2) | (padding << 1), 0xc0 // 单声道 ]); const samples = mpeg1 ? 1152 : 576; const bitrate = mpeg1 ? [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0][bitrateIndex] : [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0][bitrateIndex]; const sampleRate = (mpeg1 ? [44100, 48000, 32000] : [22050, 24000, 16000])[sampleRateIndex]; const length = Math.floor((samples / 8) * bitrate * 1000 / sampleRate) + padding; // free-format(bitrate 0)算出的帧长为 0,仍要放得下 4 字节帧头供解析器判定。 const body = new Uint8Array(Math.max(4, length)); body.set(header); return body; }; const concat = (...chunks) => { const total = chunks.reduce((sum, c) => sum + c.length, 0); const out = new Uint8Array(total); let at = 0; for (const c of chunks) { out.set(c, at); at += c.length; } return out; }; /** ID3v2 头:尺寸为 4 个 synchsafe 字节,必须被跳过才能对上首帧。 */ const id3v2 = (payloadSize) => { const head = new Uint8Array(10 + payloadSize); head.set([0x49, 0x44, 0x33, 0x03, 0x00, 0x00]); head[6] = (payloadSize >> 21) & 0x7f; head[7] = (payloadSize >> 14) & 0x7f; head[8] = (payloadSize >> 7) & 0x7f; head[9] = payloadSize & 0x7f; return head; }; test('累加帧数得出时长', async () => { const { estimateMpegAudioDuration } = await loadModule(); const bytes = concat(...Array.from({ length: 10 }, () => frame())); assert.equal(Number(estimateMpegAudioDuration(bytes).toFixed(3)), 0.24); }); test('跳过 ID3v2 标签,不把标签字节当帧', async () => { const { estimateMpegAudioDuration } = await loadModule(); // 标签内塞入 0xff,若未按尺寸跳过就会被误判成帧头。 const tag = id3v2(81); tag.fill(0xff, 10); const bytes = concat(tag, ...Array.from({ length: 171 }, () => frame())); assert.equal(Number(estimateMpegAudioDuration(bytes).toFixed(3)), 4.104); }); test('VBR:逐帧累加而非按首帧比特率外推', async () => { const { estimateMpegAudioDuration } = await loadModule(); // 帧长不同但每帧采样数相同,时长只取决于帧数。 const bytes = concat(frame({ bitrateIndex: 14 }), frame({ bitrateIndex: 6 }), frame({ bitrateIndex: 1 })); assert.equal(Number(estimateMpegAudioDuration(bytes).toFixed(3)), 0.072); }); test('MPEG1 每帧 1152 采样', async () => { const { estimateMpegAudioDuration } = await loadModule(); const bytes = concat(...Array.from({ length: 5 }, () => frame({ mpeg1: true, bitrateIndex: 10, sampleRateIndex: 0 }))); assert.equal(Number(estimateMpegAudioDuration(bytes).toFixed(4)), 0.1306); }); test('padding 位计入帧长,否则后续帧全部错位', async () => { const { estimateMpegAudioDuration } = await loadModule(); const bytes = concat(frame({ padding: 1 }), frame(), frame()); assert.equal(Number(estimateMpegAudioDuration(bytes).toFixed(3)), 0.072); }); test('尾部非帧数据(如 ID3v1)不虚增时长', async () => { const { estimateMpegAudioDuration } = await loadModule(); const tail = new Uint8Array(128).fill(0xff); const bytes = concat(frame(), frame(), tail); assert.equal(Number(estimateMpegAudioDuration(bytes).toFixed(3)), 0.048); }); test('没有任何合法帧时返回 null', async () => { const { estimateMpegAudioDuration } = await loadModule(); assert.equal(estimateMpegAudioDuration(new Uint8Array(0)), null); assert.equal(estimateMpegAudioDuration(Uint8Array.from([0x00, 0x01, 0x02])), null); // free-format(bitrateIndex 0)无法据此算帧长,应判为不可解析。 assert.equal(estimateMpegAudioDuration(frame({ bitrateIndex: 0 })), null); });