111 lines
4.6 KiB
JavaScript
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);
|
|
});
|