chore: establish learning module baseline
This commit is contained in:
commit
2d04197f3f
2409 files changed
+533050
No files matched your search
@@ -0,0 +1,219 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user