220 lines
7.8 KiB
TypeScript
220 lines
7.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
measureAudioDuration,
|
||
measureMp3Duration,
|
||
measureWavDuration,
|
||
} from '@/lib/audio/audio-duration';
|
||
|
||
/** Build a minimal 44-byte-header PCM WAV for a given duration. */
|
||
function buildWav(
|
||
seconds: number,
|
||
sampleRate = 8000,
|
||
channels = 1,
|
||
bitsPerSample = 16,
|
||
): Uint8Array {
|
||
const bytesPerSample = bitsPerSample / 8;
|
||
const byteRate = sampleRate * channels * bytesPerSample;
|
||
const dataSize = Math.round(byteRate * seconds);
|
||
const buffer = new ArrayBuffer(44 + dataSize);
|
||
const view = new DataView(buffer);
|
||
const ascii = (offset: number, s: string) => {
|
||
for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i));
|
||
};
|
||
ascii(0, 'RIFF');
|
||
view.setUint32(4, 36 + dataSize, true);
|
||
ascii(8, 'WAVE');
|
||
ascii(12, 'fmt ');
|
||
view.setUint32(16, 16, true);
|
||
view.setUint16(20, 1, true);
|
||
view.setUint16(22, channels, true);
|
||
view.setUint32(24, sampleRate, true);
|
||
view.setUint32(28, byteRate, true);
|
||
view.setUint16(32, channels * bytesPerSample, true);
|
||
view.setUint16(34, bitsPerSample, true);
|
||
ascii(36, 'data');
|
||
view.setUint32(40, dataSize, true);
|
||
return new Uint8Array(buffer);
|
||
}
|
||
|
||
/**
|
||
* Build a CBR MP3 (MPEG-1 Layer III, 128 kbps, 44100 Hz, mono) of roughly the
|
||
* requested duration by repeating a valid frame header + padding. Duration is
|
||
* estimated from total audio bytes, so exact frame content doesn't matter.
|
||
*/
|
||
function buildCbrMp3(seconds: number): Uint8Array {
|
||
const bitrateKbps = 128;
|
||
const sampleRate = 44100;
|
||
const samplesPerFrame = 1152;
|
||
const frameLength = Math.floor((samplesPerFrame / 8) * ((bitrateKbps * 1000) / sampleRate));
|
||
const frameCount = Math.round((seconds * sampleRate) / samplesPerFrame);
|
||
const bytes = new Uint8Array(frameLength * frameCount);
|
||
for (let f = 0; f < frameCount; f++) {
|
||
const off = f * frameLength;
|
||
bytes[off] = 0xff; // sync
|
||
bytes[off + 1] = 0xfb; // MPEG1, Layer III, no CRC
|
||
bytes[off + 2] = 0x90; // 128 kbps (0x9), 44100 Hz (0x0), no padding
|
||
bytes[off + 3] = 0xc0; // mono
|
||
}
|
||
return bytes;
|
||
}
|
||
|
||
/**
|
||
* Build a single MPEG-1 Layer III mono frame carrying a Xing/Info header with a
|
||
* known frame count. The header sits at frame + 4 + 17 for MPEG-1 mono.
|
||
*/
|
||
function buildXingMp3(frameCount: number, tag: 'Xing' | 'Info' = 'Xing'): Uint8Array {
|
||
const bytes = new Uint8Array(512);
|
||
bytes[0] = 0xff; // sync
|
||
bytes[1] = 0xfb; // MPEG1, Layer III, no CRC
|
||
bytes[2] = 0x90; // 128 kbps, 44100 Hz, no padding
|
||
bytes[3] = 0xc0; // mono
|
||
const off = 4 + 17;
|
||
tag.split('').forEach((c, i) => (bytes[off + i] = c.charCodeAt(0)));
|
||
const dv = new DataView(bytes.buffer);
|
||
dv.setUint32(off + 4, 0x01, false); // flags: frame-count present
|
||
dv.setUint32(off + 8, frameCount, false);
|
||
return bytes;
|
||
}
|
||
|
||
/**
|
||
* Build a single MPEG-1 mono frame carrying a VBRI header with a known frame
|
||
* count. VBRI sits at a fixed frame + 4 + 32 offset, frame count at byte 14.
|
||
*/
|
||
function buildVbriMp3(frameCount: number): Uint8Array {
|
||
const bytes = new Uint8Array(512);
|
||
bytes[0] = 0xff;
|
||
bytes[1] = 0xfb;
|
||
bytes[2] = 0x90;
|
||
bytes[3] = 0xc0;
|
||
const off = 4 + 32;
|
||
'VBRI'.split('').forEach((c, i) => (bytes[off + i] = c.charCodeAt(0)));
|
||
new DataView(bytes.buffer).setUint32(off + 14, frameCount, false);
|
||
return bytes;
|
||
}
|
||
|
||
describe('measureWavDuration', () => {
|
||
it('measures duration from a PCM WAV header', () => {
|
||
const wav = buildWav(2.5);
|
||
expect(measureWavDuration(wav)).toBeCloseTo(2.5, 3);
|
||
});
|
||
|
||
it('tolerates an odd-length preceding chunk (word alignment)', () => {
|
||
// Prepend a LIST chunk of odd size before data; parser must skip its pad byte.
|
||
const base = buildWav(1);
|
||
const list = new Uint8Array(8 + 3 + 1); // header + 3-byte body + 1 pad
|
||
const dv = new DataView(list.buffer);
|
||
'LIST'.split('').forEach((c, i) => dv.setUint8(i, c.charCodeAt(0)));
|
||
dv.setUint32(4, 3, true);
|
||
// Splice the LIST chunk in right after the WAVE tag (offset 12).
|
||
const out = new Uint8Array(base.length + list.length);
|
||
out.set(base.subarray(0, 12), 0);
|
||
out.set(list, 12);
|
||
out.set(base.subarray(12), 12 + list.length);
|
||
// Fix RIFF size.
|
||
new DataView(out.buffer).setUint32(4, out.length - 8, true);
|
||
expect(measureWavDuration(out)).toBeCloseTo(1, 3);
|
||
});
|
||
|
||
it('returns null for non-RIFF bytes', () => {
|
||
expect(measureWavDuration(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('measureMp3Duration', () => {
|
||
it('estimates duration of a CBR MP3', () => {
|
||
const mp3 = buildCbrMp3(3);
|
||
const d = measureMp3Duration(mp3);
|
||
expect(d).not.toBeNull();
|
||
expect(d!).toBeGreaterThan(2.5);
|
||
expect(d!).toBeLessThan(3.5);
|
||
});
|
||
|
||
it('skips a leading ID3v2 tag', () => {
|
||
const mp3 = buildCbrMp3(2);
|
||
const id3 = new Uint8Array(10 + 20);
|
||
'ID3'.split('').forEach((c, i) => (id3[i] = c.charCodeAt(0)));
|
||
id3[6] = 0;
|
||
id3[7] = 0;
|
||
id3[8] = 0;
|
||
id3[9] = 20; // syncsafe size = 20
|
||
const out = new Uint8Array(id3.length + mp3.length);
|
||
out.set(id3, 0);
|
||
out.set(mp3, id3.length);
|
||
const d = measureMp3Duration(out);
|
||
expect(d).not.toBeNull();
|
||
expect(d!).toBeGreaterThan(1.5);
|
||
expect(d!).toBeLessThan(2.5);
|
||
});
|
||
|
||
it('returns null when no frame sync is found', () => {
|
||
expect(measureMp3Duration(new Uint8Array(64))).toBeNull();
|
||
});
|
||
|
||
it('uses the Xing frame count for VBR-accurate duration', () => {
|
||
// 153 frames × 1152 samples / 44100 Hz ≈ 3.997 s.
|
||
const d = measureMp3Duration(buildXingMp3(153));
|
||
expect(d).not.toBeNull();
|
||
expect(d!).toBeCloseTo((153 * 1152) / 44100, 3);
|
||
});
|
||
|
||
it('uses an Info (CBR) header frame count too', () => {
|
||
const d = measureMp3Duration(buildXingMp3(100, 'Info'));
|
||
expect(d!).toBeCloseTo((100 * 1152) / 44100, 3);
|
||
});
|
||
|
||
it('uses the VBRI frame count when no Xing/Info header is present', () => {
|
||
const d = measureMp3Duration(buildVbriMp3(200));
|
||
expect(d).not.toBeNull();
|
||
expect(d!).toBeCloseTo((200 * 1152) / 44100, 3);
|
||
});
|
||
|
||
it('excludes a trailing ID3v1 tag from the CBR estimate', () => {
|
||
const mp3 = buildCbrMp3(2);
|
||
const withTag = new Uint8Array(mp3.length + 128);
|
||
withTag.set(mp3, 0);
|
||
'TAG'.split('').forEach((c, i) => (withTag[mp3.length + i] = c.charCodeAt(0)));
|
||
// The 128-byte tag must not inflate the estimate: same duration as without.
|
||
expect(measureMp3Duration(withTag)).toBeCloseTo(measureMp3Duration(mp3)!, 3);
|
||
});
|
||
});
|
||
|
||
describe('measureAudioDuration', () => {
|
||
it('dispatches on the wav format hint', () => {
|
||
expect(measureAudioDuration(buildWav(1.5), 'wav')).toBeCloseTo(1.5, 3);
|
||
});
|
||
|
||
it('dispatches on the mp3 format hint', () => {
|
||
const d = measureAudioDuration(buildCbrMp3(1), 'mp3');
|
||
expect(d).not.toBeNull();
|
||
});
|
||
|
||
it('sniffs format when no hint is given', () => {
|
||
expect(measureAudioDuration(buildWav(1))).toBeCloseTo(1, 3);
|
||
expect(measureAudioDuration(buildCbrMp3(1))).not.toBeNull();
|
||
});
|
||
|
||
it('trusts content over a wrong format hint (mp3 hint, WAV bytes)', () => {
|
||
// getAudioResponseFormat falls back to 'mp3' when Content-Type is missing,
|
||
// but the provider may actually return WAV. Content must win.
|
||
expect(measureAudioDuration(buildWav(2.5), 'mp3')).toBeCloseTo(2.5, 3);
|
||
});
|
||
|
||
it('trusts content over a wrong format hint (wav hint, MP3 bytes)', () => {
|
||
const d = measureAudioDuration(buildCbrMp3(2), 'wav');
|
||
expect(d).not.toBeNull();
|
||
expect(d!).toBeGreaterThan(1.5);
|
||
expect(d!).toBeLessThan(2.5);
|
||
});
|
||
|
||
it('degrades to null on empty or unsupported input (caller still persists)', () => {
|
||
expect(measureAudioDuration(new Uint8Array(0))).toBeNull();
|
||
expect(measureAudioDuration(new Uint8Array([0x00, 0x01, 0x02, 0x03]), 'flac')).toBeNull();
|
||
});
|
||
|
||
it('accepts an ArrayBuffer as well as a Uint8Array', () => {
|
||
const wav = buildWav(2);
|
||
const copy = wav.slice().buffer as ArrayBuffer;
|
||
expect(measureAudioDuration(copy, 'wav')).toBeCloseTo(2, 3);
|
||
});
|
||
});
|