Files
mediabunny/test/node/flac.test.ts
T
d426d36386 FLAC container support (#95)
* add a test

* recognize as input format

* scaffold flac demuxer

* implement getting metadata

* Implement mime type

* read all metadata + deduplicate stubs

* Read first packet

* copyright headers

* read the first packet

* Get entire first packet, work on advancing

* iterate over all samples

* testable with bun

* stub out metadata support

* parse descriptive metadata

* All in 1 file

seems more appropriate to the philosophy

* some parameters are not needed anymore all within 1 class

* skip over bytes we are sure are not the syncword

* run prettier

* timestamp is determined based on passed blocks, not maximumBlockSize

* no binary search needed!

* simplifications

* Finish demuxer reading sequentially

* more tests + add a file with a seektable

* don't throw if (this.audioInfo.minimumBlockSize !== this.audioInfo.maximumBlockSize

* Add docs

* Update format compatibility table

* Simplification

* confirm conversion is working

* Finish

* Resolve TODO comment

* Support images (read-only)

* Returning description as Uint8Array

* Cleanup of demuxer

* Misc renames

* Object on same line

* Resolve first batch of comments

* Throw errors on corrupt blocks, correctly use requestSlice()

* Fix description field

* Explain why last frame is a bit shorter

* Add FLAC to README

* Put track backings below demuxer

* convert to methods

* Reorder container checking

* getBlockSize() -> readBlockSize()

* bitStream -> bitstream

* better naming for bytes

* use .skip()

* Don't return blockSize twice in readFlacFrameHeader

* Use enum + switch to distinguish Flac block types

* Use else-if

* Use else-if

* Compressed switch statement

* readCodedNumber

* Update flac-misc.ts

* We don't need the bits variable at all

* reorder functions in flac-demuxer

* Handle gracefully not being able to load another sample

* Update flac-demuxer.ts

* blockingbit null

* use async instead of promise.resolve

* use binary search

* Replace recursion with while loop

* Add mutex to getPacket()

* Load more data not in getPacketAtIndex, but outside

* Apply suggestion from @Vanilagy

Co-authored-by: David P. <[email protected]>

* computeDuration() reads last packet

* Update flac-demuxer.ts

* share vorbis comment reading logic

* reuse vorbis comment writing logic, set vendor always to "Mediabunny"

* flush after writign

* Use FileSlice.tempFromBytes

* assert !== null

* fixing nitpicks

* apply suggestions

* fix ogg

* We are now muxing images

* apply suggestion

* Update src/flac/flac-muxer.ts

Co-authored-by: David P. <[email protected]>

* apply suggestion

* readSampleRate()

* seek outside writeHeader()

* mention vorbis metadata +  add to metadatatags comment

* should be able to -> can

* Add test in metadata tags

* Add test for packets being byte identical after remuxing

* compare to null

* no casting to uint8array

* make test pass

* Update flac-muxer.ts

* Call validateAudioChunkMetadata() and validateAndNormalizeTimestamp()

* `onFrame` option

* emit frames using onFrame

* Run prettier over files

* Fix FLAC PICTURE block logic, small other changes

* Update docs

* Remove .only modifier

* fix remuxing and add test

* Don't throw error if parsing fails in header, since hitting a syncword might just be coincidential

* Fix remaining type errors

---------

Co-authored-by: David P. <[email protected]>
2025-09-18 21:05:09 +02:00

257 lines
7.9 KiB
TypeScript

import { expect, test } from 'vitest';
import path from 'node:path';
import { assert, toUint8Array } from '../../src/misc.js';
import { Input } from '../../src/input.js';
import { BufferSource, FilePathSource } from '../../src/source.js';
import { ALL_FORMATS, FLAC } from '../../src/input-format.js';
import { EncodedPacketSink } from '../../src/media-sink.js';
import { Output } from '../../src/output.js';
import { BufferTarget } from '../../src/target.js';
import { FlacOutputFormat } from '../../src/output-format.js';
import { Conversion } from '../../src/conversion.js';
const __dirname = new URL('.', import.meta.url).pathname;
test('can loop over all samples', async () => {
const filePath = path.join(__dirname, '..', 'public/sample.flac');
const input = new Input({
source: new FilePathSource(filePath),
formats: ALL_FORMATS,
});
const track = await input.getPrimaryAudioTrack();
assert(track);
expect(await track.computeDuration()).toEqual(19.71428571428571);
expect(await track.getDecoderConfig()).toEqual({
codec: 'flac',
numberOfChannels: 2,
sampleRate: 44100,
description: new Uint8Array([
102, 76, 97, 67, 128, 0, 0, 34, 16, 0, 16, 0, 0, 6, 45, 0, 37, 173, 10,
196, 66, 240, 0, 13, 68, 24, 85, 22, 231, 0, 113, 139, 185, 1, 33, 54,
155, 80, 241, 191, 203, 112,
]),
});
expect(await track.getCodecParameterString()).toEqual('flac');
expect(track.timeResolution).toEqual(44100);
expect(await input.getMimeType()).toEqual('audio/flac');
const sink = new EncodedPacketSink(track);
let samples = 0;
let lastSampleTimestamp = 0;
for await (const sample of sink.packets()) {
samples++;
lastSampleTimestamp = sample.timestamp;
if (sample.sequenceNumber === 212) {
// Last frame is a bit shorter
// due it having a custom block size and the duration not being a multiple of the frame size
expect(sample.duration).toEqual(0.023764172335600908);
} else {
expect(sample.duration).toEqual(0.09287981859410431);
}
}
expect(samples).toBe(213);
expect(lastSampleTimestamp).toBe(19.690521541950112);
});
test('can do random access', async () => {
const filePath = path.join(__dirname, '..', 'public/sample.flac');
const input = new Input({
source: new FilePathSource(filePath),
formats: ALL_FORMATS,
});
const track = await input.getPrimaryAudioTrack();
assert(track);
const packetSink = new EncodedPacketSink(track);
const packet = await packetSink.getPacket(10);
assert(packet);
expect(packet.timestamp).toBe(9.93814058956916);
expect(packet.data.byteLength).toBe(8345);
expect(packet.sequenceNumber).toBe(107);
expect(packet.duration).toBe(0.09287981859410431);
const nextPacket = await packetSink.getNextPacket(packet);
assert(nextPacket);
expect(nextPacket.timestamp).toBe(10.031020408163265);
expect(nextPacket.data.byteLength).toBe(8988);
expect(nextPacket.sequenceNumber).toBe(108);
expect(nextPacket.duration).toBe(0.09287981859410431);
const priorPacket = await packetSink.getPacket(3);
assert(priorPacket);
expect(priorPacket.timestamp).toBe(2.972154195011338);
expect(priorPacket.data.byteLength).toBe(6877);
expect(priorPacket.sequenceNumber).toBe(32);
expect(priorPacket.duration).toBe(0.09287981859410431);
});
test('can get metadata-only packets', async () => {
const filePath = path.join(__dirname, '..', 'public/sample.flac');
const input = new Input({
source: new FilePathSource(filePath),
formats: ALL_FORMATS,
});
const track = await input.getPrimaryAudioTrack();
assert(track);
const packetSink = new EncodedPacketSink(track);
const packet = await packetSink.getPacket(10, { metadataOnly: true });
assert(packet);
expect(packet.timestamp).toBe(9.93814058956916);
expect(packet.isMetadataOnly).toBe(true);
expect(packet.sequenceNumber).toBe(107);
expect(packet.duration).toBe(0.09287981859410431);
});
test('can get metadata', async () => {
const filePath = path.join(__dirname, '..', 'public/sample.flac');
const input = new Input({
source: new FilePathSource(filePath),
formats: ALL_FORMATS,
});
const { images: inputImages, ...descriptiveMetadata } = await input.getMetadataTags();
expect(inputImages![0]!.data).toHaveLength(4852);
expect(inputImages![0]!.data.slice(0, 8)).toEqual(new Uint8Array([
137, 80, 78, 71, 13, 10, 26, 10,
]));
expect(inputImages![0]!.mimeType).toBe('image/png');
expect(inputImages![0]!.description).toBe('Album cover');
expect(inputImages![0]!.kind).toBe('coverFront');
expect(descriptiveMetadata).toEqual({
title: 'The Happy Meeting',
date: new Date('2020'),
album: 'Samples files',
artist: 'Samples Files',
trackNumber: 4,
genre: 'Ambient',
raw: {
ALBUM: 'Samples files',
ARTIST: 'Samples Files',
DATE: '2020',
ENCODER: 'Lavf58.76.100',
GENRE: 'Ambient',
TITLE: 'The Happy Meeting',
TRACKNUMBER: '4',
vendor: 'Lavf58.76.100',
},
});
});
test('can re-mux a .flac', async () => {
const filePath = path.join(__dirname, '..', 'public/sample.flac');
const input = new Input({
source: new FilePathSource(filePath),
formats: ALL_FORMATS,
});
let framesWritten = 0;
const output = new Output({
format: new FlacOutputFormat({
onFrame() {
framesWritten++;
},
}),
target: new BufferTarget(),
});
const conversion = await Conversion.init({ input, output });
await conversion.execute();
expect(framesWritten).toBe(213);
const buffer = output.target.buffer;
assert(buffer);
const outputAsInput = new Input({
source: new BufferSource(buffer),
formats: [FLAC],
});
const outputTrack = await outputAsInput.getPrimaryAudioTrack();
assert(outputTrack);
const inputTrack = await input.getPrimaryAudioTrack();
assert(inputTrack);
expect(inputTrack.sampleRate).toBe(outputTrack.sampleRate);
expect(inputTrack.numberOfChannels).toBe(outputTrack.numberOfChannels);
expect(inputTrack.timeResolution).toBe(outputTrack.timeResolution);
const outputMetadataTags = await outputAsInput.getMetadataTags();
const inputMetadataTags = await input.getMetadataTags();
expect(inputMetadataTags.images).toHaveLength(1);
expect(Object.keys(outputMetadataTags)).toEqual([
'images',
'raw',
'title',
'date',
'album',
'artist',
'trackNumber',
'genre',
]);
const { images: inputImages, ...otherInputMetadataTags } = inputMetadataTags;
expect(outputMetadataTags).toEqual({
...otherInputMetadataTags,
raw: {
...otherInputMetadataTags.raw,
vendor: 'Mediabunny',
},
images: inputImages,
});
const inputPacketSink = new EncodedPacketSink(inputTrack);
const outputPacketSink = new EncodedPacketSink(outputTrack);
let packets = 0;
let timestamp = 0;
for await (const packet of outputPacketSink.packets()) {
packets++;
timestamp = packet.timestamp;
}
expect(packets).toBe(213);
expect(timestamp).toBe(19.690521541950112);
// Test that packets are byte-identical
const inputPacket = await inputPacketSink.getPacket(10);
const outputPacket = await outputPacketSink.getPacket(10);
assert(inputPacket);
assert(outputPacket);
const { data: inputPacketData, ...otherInputPacket } = inputPacket;
const { data: outputPacketData, ...otherOutputPacket } = outputPacket;
expect(otherInputPacket).toEqual(otherOutputPacket);
expect(inputPacketData).toEqual(outputPacketData);
const inputDecoderConfig = await inputTrack.getDecoderConfig();
const outputDecoderConfig = await outputTrack.getDecoderConfig();
assert(inputDecoderConfig);
assert(outputDecoderConfig);
const { description: inputDescription, ...otherInputDecoderConfig } = inputDecoderConfig;
const { description: outputDescription, ...otherOutputDecoderConfig } = outputDecoderConfig;
assert(inputDescription);
assert(outputDescription);
const inputArray = toUint8Array(inputDescription);
const outputArray = toUint8Array(outputDescription);
const inputWithoutCrc = inputArray.slice(0, -16);
const outputWithoutCrc = outputArray.slice(0, -16);
expect(inputWithoutCrc).toEqual(outputWithoutCrc);
expect(otherInputDecoderConfig).toEqual(otherOutputDecoderConfig);
});