Files
prop-ai-hr/mobile-uni/tests/mpeg-audio-duration.test.mjs
T

111 lines
4.6 KiB
JavaScript

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);
});