Compare commits

...
9 Commits
16 changed files with 256 additions and 75 deletions
+3 -1
View File
@@ -1,4 +1,6 @@
- Prefer functions declared using const, not using the function keyword
- Code style is tab indent with semicolons
- Mediabunny core code is contained in src/, extensions are in packages/*/, website is in docs/
- Tests: Prefer fewer, longer test files over many small ones. Test files should be named after the general catergory of thing that is being tested, not after any individual single test.
- Tests: Prefer fewer, longer test files over many small ones. Test files should be named after the general catergory of thing that is being tested, not after any individual single test.
- Avoid ifs without a {} block. So no if (cond) return;, always do if (cond) { return; }
- `type` instead of `interface` for object types
+4
View File
@@ -51,6 +51,10 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
### Bronze sponsors
<div align="center">
<a href="https://elevenlabs.io/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/elevenlabs.png" width="40" height="40" alt="ElevenLabs">
</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.reactvideoeditor.com/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/rve.png" width="40" height="40" alt="React Video Editor">
</a>
+3
View File
@@ -506,6 +506,9 @@ type UrlSourceOptions = {
// in memory. Defaults to 8 MiB.
maxCacheSize?: number;
// The maximum number of parallel requests to use for fetching. Defaults to 2.
parallelism?: number;
// Used to provide a custom fetch function
fetchFn?: typeof fetch;
};
+3
View File
@@ -101,6 +101,7 @@ const sponsors = {
{ image: '/sponsors/ponder.png', name: 'Ponder', url: 'https://ponder.ai/' },
],
bronze: [
{ image: '/sponsors/elevenlabs.png', name: 'ElevenLabs', url: 'https://elevenlabs.io/' },
{ image: '/sponsors/rve.png', name: 'React Video Editor', url: 'https://www.reactvideoeditor.com/' },
{ image: '/sponsors/mux.jpg', name: 'Mux', url: 'https://www.mux.com/' },
{ image: '/sponsors/jellypod.png', name: 'Jellypod', url: 'https://jellypod.ai/' },
@@ -112,6 +113,7 @@ const sponsors = {
{ image: 'https://avatars.githubusercontent.com/u/84167135', name: 'Memenome', url: 'https://github.com/memenome' },
{ image: 'https://avatars.githubusercontent.com/u/5913254', name: 'Brandon McConnell', url: 'https://github.com/brandonmcconnell' },
{ image: 'https://avatars.githubusercontent.com/u/9549394', name: 'studnitz', url: 'https://github.com/studnitz' },
{ image: 'https://avatars.githubusercontent.com/u/4714175', name: 'Phoomparin Mano', url: 'https://github.com/heypoom' },
{ image: 'https://avatars.githubusercontent.com/u/504909', name: 'Hirbod', url: 'https://github.com/hirbod' },
{ image: 'https://avatars.githubusercontent.com/u/2698271', name: 'Matthew Gardner', url: 'https://github.com/spheric' },
{ image: 'https://avatars.githubusercontent.com/u/5475819', name: 'AJ Funk', url: 'https://github.com/AJFunk' },
@@ -121,6 +123,7 @@ const sponsors = {
{ image: 'https://avatars.githubusercontent.com/u/38181164', name: 'wcw', url: 'https://github.com/asd55667' },
{ image: 'https://avatars.githubusercontent.com/u/1836701', name: 'Bean Deng', url: 'https://github.com/HADB' },
{ image: 'https://avatars.githubusercontent.com/u/255616819', name: 'cronischarles-del', url: 'https://github.com/cronischarles-del' },
{ image: 'https://avatars.githubusercontent.com/u/37973863', name: 'Gomi', url: 'https://github.com/gxy5202' },
{ image: 'https://avatars.githubusercontent.com/u/63088713', name: 'taf2000', url: 'https://github.com/taf2000' },
{ image: 'https://avatars.githubusercontent.com/u/58149663', name: 'H7GhosT', url: 'https://github.com/H7GhosT' },
{ image: 'https://avatars.githubusercontent.com/u/91711202', name: 'ihasq', url: 'https://github.com/ihasq' },
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.30.1",
"version": "1.32.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.30.1",
"version": "1.32.0",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -7739,9 +7739,9 @@
}
},
"node_modules/mediabunny": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.30.0.tgz",
"integrity": "sha512-clbGEyZKozmAK0mDsHvF4luzOz1WI9bhm1NJFRP7a87v2LdN4b3DZZJYFSwhLcqAP7BCehqGmHoGPqfYrqvbTA==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.31.1.tgz",
"integrity": "sha512-7ijrySW675UTxXoAji8Xj/H8H1uyZhPDARlNO1FF1zGnzJV3snayU32c1bpumslx5vbUa/M7fm6kcG/3y7UxHQ==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
@@ -12065,7 +12065,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
"version": "1.30.1",
"version": "1.32.0",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.30.1",
"version": "1.32.0",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.30.1",
"version": "1.32.0",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+62 -2
View File
@@ -8,9 +8,15 @@
import { aacChannelMap, aacFrequencyTable, AudioCodec } from '../codec';
import { Demuxer } from '../demuxer';
import {
ID3_V2_HEADER_SIZE,
parseId3V2Tag,
readId3V2Header,
} from '../id3';
import { Input } from '../input';
import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
import { PacketRetrievalOptions } from '../media-sink';
import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata';
import {
assert,
AsyncMutex,
@@ -20,7 +26,6 @@ import {
} from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { readBytes, Reader } from '../reader';
import { DEFAULT_TRACK_DISPOSITION } from '../metadata';
import {
AdtsFrameHeader,
MIN_ADTS_FRAME_HEADER_SIZE,
@@ -43,6 +48,7 @@ export class AdtsDemuxer extends Demuxer {
metadataPromise: Promise<void> | null = null;
firstFrameHeader: AdtsFrameHeader | null = null;
loadedSamples: Sample[] = [];
metadataTags: MetadataTags | null = null;
tracks: InputAudioTrack[] = [];
@@ -73,6 +79,26 @@ export class AdtsDemuxer extends Demuxer {
}
async advanceReader() {
if (this.lastLoadedPos === 0) {
// Skip all ID3v2 tags at the start of the file
while (true) {
let slice = this.reader.requestSlice(this.lastLoadedPos, ID3_V2_HEADER_SIZE);
if (slice instanceof Promise) slice = await slice;
if (!slice) {
this.lastSampleLoaded = true;
return;
}
const id3V2Header = readId3V2Header(slice);
if (!id3V2Header) {
break;
}
this.lastLoadedPos = slice.filePos + id3V2Header.size;
}
}
let slice = this.reader.requestSliceRange(
this.lastLoadedPos,
MIN_ADTS_FRAME_HEADER_SIZE,
@@ -135,7 +161,41 @@ export class AdtsDemuxer extends Demuxer {
}
async getMetadataTags() {
return {}; // No tags in this one
const release = await this.readingMutex.acquire();
try {
await this.readMetadata();
if (this.metadataTags) {
return this.metadataTags;
}
this.metadataTags = {};
let currentPos = 0;
while (true) {
let headerSlice = this.reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);
if (headerSlice instanceof Promise) headerSlice = await headerSlice;
if (!headerSlice) break;
const id3V2Header = readId3V2Header(headerSlice);
if (!id3V2Header) {
break;
}
let contentSlice = this.reader.requestSlice(headerSlice.filePos, id3V2Header.size);
if (contentSlice instanceof Promise) contentSlice = await contentSlice;
if (!contentSlice) break;
parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);
currentPos = headerSlice.filePos + id3V2Header.size;
}
return this.metadataTags;
} finally {
release();
}
}
}
+6 -1
View File
@@ -7,6 +7,8 @@
*/
import { parseAacAudioSpecificConfig, validateAudioChunkMetadata } from '../codec';
import { Id3V2Writer } from '../id3';
import { metadataTagsAreEmpty } from '../metadata';
import { assert, Bitstream, toUint8Array } from '../misc';
import { Muxer } from '../muxer';
import { Output, OutputAudioTrack } from '../output';
@@ -30,7 +32,10 @@ export class AdtsMuxer extends Muxer {
}
async start() {
// Nothing needed here
if (!metadataTagsAreEmpty(this.output._metadataTags)) {
const id3Writer = new Id3V2Writer(this.writer);
id3Writer.writeId3V2Tag(this.output._metadataTags);
}
}
async getMimeType() {
+5 -1
View File
@@ -801,9 +801,13 @@ export class Conversion {
this._executed = true;
if (this.onProgress) {
// Compute duration using only the utilized tracks
const durationPromises = this.utilizedTracks.map(x => x.computeDuration());
const duration = Math.max(0, ...await Promise.all(durationPromises));
this._computeProgress = true;
this._totalDuration = Math.min(
(await this.input.computeDuration()) - this._startTimestamp,
duration - this._startTimestamp,
this._endTimestamp - this._startTimestamp,
);
+33 -13
View File
@@ -21,14 +21,14 @@ import {
} from './matroska/ebml';
import { MatroskaDemuxer } from './matroska/matroska-demuxer';
import { Mp3Demuxer } from './mp3/mp3-demuxer';
import { FRAME_HEADER_SIZE } from '../shared/mp3-misc';
import { FRAME_HEADER_SIZE, getXingOffset, INFO, XING } from '../shared/mp3-misc';
import { ID3_V2_HEADER_SIZE, readId3V2Header } from './id3';
import { readNextMp3FrameHeader } from './mp3/mp3-reader';
import { OggDemuxer } from './ogg/ogg-demuxer';
import { WaveDemuxer } from './wave/wave-demuxer';
import { MAX_ADTS_FRAME_HEADER_SIZE, MIN_ADTS_FRAME_HEADER_SIZE, readAdtsFrameHeader } from './adts/adts-reader';
import { AdtsDemuxer } from './adts/adts-demuxer';
import { readAscii, readBytes } from './reader';
import { readAscii, readBytes, readU32Be } from './reader';
import { FlacDemuxer } from './flac/flac-demuxer';
import { MpegTsDemuxer } from './mpeg-ts/mpeg-ts-demuxer';
import { TS_PACKET_SIZE } from './mpeg-ts/mpeg-ts-misc';
@@ -261,12 +261,7 @@ export class WebMInputFormat extends MatroskaInputFormat {
export class Mp3InputFormat extends InputFormat {
/** @internal */
async _canReadInput(input: Input) {
let slice = input._reader.requestSlice(0, 10);
if (slice instanceof Promise) slice = await slice;
if (!slice) return false;
let currentPos = 0;
let id3V2HeaderFound = false;
while (true) {
let slice = input._reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);
@@ -278,7 +273,6 @@ export class Mp3InputFormat extends InputFormat {
break;
}
id3V2HeaderFound = true;
currentPos = slice.filePos + id3V2Header.size;
}
@@ -287,8 +281,18 @@ export class Mp3InputFormat extends InputFormat {
return false;
}
if (id3V2HeaderFound) {
// If there was an ID3v2 tag at the start, we can be pretty sure this is MP3 by now
const firstHeader = firstResult.header;
const xingOffset = getXingOffset(firstHeader.mpegVersionId, firstHeader.channel);
let slice = input._reader.requestSlice(firstResult.startPos + xingOffset, 4);
if (slice instanceof Promise) slice = await slice;
if (!slice) return false;
const word = readU32Be(slice);
const isXing = word === XING || word === INFO;
if (isXing) {
// Gotta be MP3
return true;
}
@@ -301,7 +305,6 @@ export class Mp3InputFormat extends InputFormat {
return false;
}
const firstHeader = firstResult.header;
const secondHeader = secondResult.header;
// In a well-formed MP3 file, we'd expect these two frames to share some similarities:
@@ -441,8 +444,23 @@ export class FlacInputFormat extends InputFormat {
export class AdtsInputFormat extends InputFormat {
/** @internal */
async _canReadInput(input: Input) {
let currentPos = 0;
while (true) {
let slice = input._reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);
if (slice instanceof Promise) slice = await slice;
if (!slice) break;
const id3V2Header = readId3V2Header(slice);
if (!id3V2Header) {
break;
}
currentPos = slice.filePos + id3V2Header.size;
}
let slice = input._reader.requestSliceRange(
0,
currentPos,
MIN_ADTS_FRAME_HEADER_SIZE,
MAX_ADTS_FRAME_HEADER_SIZE,
);
@@ -454,8 +472,10 @@ export class AdtsInputFormat extends InputFormat {
return false;
}
currentPos += firstHeader.frameLength;
slice = input._reader.requestSliceRange(
firstHeader.frameLength,
currentPos,
MIN_ADTS_FRAME_HEADER_SIZE,
MAX_ADTS_FRAME_HEADER_SIZE,
);
+2 -1
View File
@@ -17,7 +17,7 @@
* - For Ogg files, there is no global metadata so instead, the metadata refers to the combined metadata of all tracks,
* in Vorbis-style comment headers.
* - For WAVE files, the metadata refers to the chunks within the RIFF INFO chunk.
* - For ADTS files, there is no metadata.
* - For ADTS files, the metadata refers to the ID3v2 tags.
* - For FLAC files, the metadata lives in Vorbis style in the Vorbis comment block.
* - For MPEG-TS files, metadata tags are currently not supported.
*
@@ -70,6 +70,7 @@ export type MetadataTags = {
* values. Additionally, all attached files (such as font files) are included here, where the key corresponds to
* the FileUID and the value is an {@link AttachedFile}.
* - MP3: The ID3v2 tags, or a single `'TAG'` key with the contents of the ID3v1 tag.
* - ADTS: The ID3v2 tags.
* - Ogg: The key-value string pairs from the Vorbis-style comment header (see RFC 7845, Section 5.2).
* Additionally, the `'vendor'` key refers to the vendor string within this header.
* - WAVE: The individual metadata chunks within the RIFF INFO chunk. Values are always ISO 8859-1 strings.
+60 -45
View File
@@ -63,8 +63,6 @@ import { EncodedPacket, PacketType, PLACEHOLDER_DATA } from '../packet';
import { FileSlice, readBytes, Reader, readU16Be, readU32Be, readU8 } from '../reader';
import { buildMpegTsMimeType, MpegTsStreamType, TIMESCALE, TS_PACKET_SIZE } from './mpeg-ts-misc';
const MISSING_PES_PACKET_ERROR = 'No PES packet found where one was expected.';
type ElementaryStream = {
demuxer: MpegTsDemuxer;
pid: number;
@@ -191,6 +189,26 @@ export class MpegTsDemuxer extends Demuxer {
const BYTES_BEFORE_SECTION_LENGTH = 3;
const BITS_IN_CRC_32 = 32; // Duh
// Some streams don't contain a PAT for some reason, so we must do some guesswork to figure out where
// the PMT is.
let isProbablyProgramMap = false;
if (!hasProgramMap && section.pid !== 0) {
const isPesPacket
= section.payload[0] === 0x00 && section.payload[1] === 0x00 && section.payload[2] === 0x01;
if (!isPesPacket) {
// Assume it's a PSI
const bitstream = new Bitstream(section.payload);
const pointerField = bitstream.readAlignedByte();
bitstream.skipBits(8 * pointerField);
const tableId = bitstream.readBits(8);
isProbablyProgramMap = tableId === 0x02; // 0x02 == TS_program_map_section
}
}
if (section.pid === 0 && !hasProgramAssociationTable) {
const bitstream = new Bitstream(section.payload);
const pointerField = bitstream.readAlignedByte();
@@ -220,7 +238,7 @@ export class MpegTsDemuxer extends Demuxer {
}
hasProgramAssociationTable = true;
} else if (section.pid === programMapPid && !hasProgramMap) {
} else if ((section.pid === programMapPid || isProbablyProgramMap) && !hasProgramMap) {
const bitstream = new Bitstream(section.payload);
const pointerField = bitstream.readAlignedByte();
@@ -291,6 +309,7 @@ export class MpegTsDemuxer extends Demuxer {
default: {
// If we don't recognize the codec, we don't surface the track at all. This is because
// we can't determine its metadata and also have no idea how to packetize its data.
console.warn(`Unsupported stream_type 0x${streamType.toString(16)}; ignoring stream.`);
}
}
@@ -433,10 +452,11 @@ export class MpegTsDemuxer extends Demuxer {
currentPos += this.packetStride;
}
if (!hasProgramAssociationTable) {
throw new Error('No Program Association Table found in the file.');
}
if (!hasProgramMap) {
if (!hasProgramAssociationTable) {
throw new Error('No Program Association Table found in the file.');
}
throw new Error('No Program Map Table found in the file.');
}
@@ -674,6 +694,10 @@ type PesPacket = PesPacketHeader & {
};
const readPesPacketHeader = (section: Section): PesPacketHeader | null => {
if (section.payload.byteLength < 3) {
return null;
}
const bitstream = new Bitstream(section.payload);
const startCodePrefix = bitstream.readBits(24);
@@ -994,7 +1018,9 @@ export abstract class MpegTsTrackBacking implements InputTrackBacking {
}
const pesPacketHeader = readPesPacketHeader(section);
return pesPacketHeader;
if (pesPacketHeader) {
return pesPacketHeader;
}
}
currentPos += demuxer.packetStride;
@@ -1152,17 +1178,16 @@ export abstract class MpegTsTrackBacking implements InputTrackBacking {
const section = await demuxer.readSection(currentPos, false);
if (section) {
const nextPesHeader = readPesPacketHeader(section);
if (!nextPesHeader) {
throw new Error(MISSING_PES_PACKET_ERROR);
}
if (nextPesHeader.pts > searchPts) {
break outer;
}
if (nextPesHeader) {
if (nextPesHeader.pts > searchPts) {
break outer;
}
currentPesHeader = nextPesHeader;
maybeInsertReferencePacket(this.elementaryStream, nextPesHeader);
currentPesHeader = nextPesHeader;
maybeInsertReferencePacket(this.elementaryStream, nextPesHeader);
break;
break;
}
}
}
@@ -1185,12 +1210,10 @@ export abstract class MpegTsTrackBacking implements InputTrackBacking {
const section = await demuxer.readSection(pos, false);
if (section) {
const header = readPesPacketHeader(section);
if (!header) {
throw new Error(MISSING_PES_PACKET_ERROR);
if (header) {
currentPesHeader = header;
break;
}
currentPesHeader = header;
break;
}
}
@@ -1265,13 +1288,10 @@ export abstract class MpegTsTrackBacking implements InputTrackBacking {
const section = await demuxer.readSection(currentPos, false);
if (section) {
pesHeader = readPesPacketHeader(section);
if (!pesHeader) {
throw new Error(MISSING_PES_PACKET_ERROR);
if (pesHeader) {
maybeInsertReferencePacket(this.elementaryStream, pesHeader);
break;
}
maybeInsertReferencePacket(this.elementaryStream, pesHeader);
break;
}
}
@@ -1298,12 +1318,10 @@ export abstract class MpegTsTrackBacking implements InputTrackBacking {
const section = await demuxer.readSection(pos, false);
if (section) {
const header = readPesPacketHeader(section);
if (!header) {
throw new Error(MISSING_PES_PACKET_ERROR);
if (header) {
startPesHeader = header;
break;
}
startPesHeader = header;
break;
}
}
@@ -1759,23 +1777,20 @@ class PacketReadingContext {
}
if (packetHeader.pid === this.pid) {
break;
const nextSection = await this.demuxer.readSection(currentPos, true);
if (!nextSection) {
return;
}
const nextPesPacket = readPesPacket(nextSection);
if (nextPesPacket) {
pesPacket = nextPesPacket;
break;
}
}
currentPos += this.demuxer.packetStride;
}
const nextSection = await this.demuxer.readSection(currentPos, true);
if (!nextSection) {
return;
}
const nextPesPacket = readPesPacket(nextSection);
if (!nextPesPacket) {
throw new Error(MISSING_PES_PACKET_ERROR);
}
pesPacket = nextPesPacket;
}
this.pesPackets.push(pesPacket);
+11 -3
View File
@@ -343,6 +343,9 @@ export type UrlSourceOptions = {
/** The maximum number of bytes the cache is allowed to hold in memory. Defaults to 64 MiB. */
maxCacheSize?: number;
/** The maximum number of parallel requests to use for fetching. Defaults to 2. */
parallelism?: number;
/**
* A WHATWG-compatible fetch function. You can use this field to polyfill the `fetch` function, add missing
* features, or use a custom implementation.
@@ -403,6 +406,9 @@ export class UrlSource extends Source {
) {
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative number.');
}
if (options.parallelism !== undefined && (!Number.isInteger(options.parallelism) || options.parallelism < 1)) {
throw new TypeError('options.parallelism, when provided, must be a positive number.');
}
if (options.fetchFn !== undefined && typeof options.fetchFn !== 'function') {
throw new TypeError('options.fetchFn, when provided, must be a function.');
// Won't bother validating this function beyond this
@@ -414,11 +420,13 @@ export class UrlSource extends Source {
this._options = options;
this._getRetryDelay = options.getRetryDelay ?? DEFAULT_RETRY_DELAY;
// Most files in the real-world have a single sequential access pattern, but having two in parallel can
// also happen
const DEFAULT_PARALLELISM = 2;
this._orchestrator = new ReadOrchestrator({
maxCacheSize: options.maxCacheSize ?? (64 * 2 ** 20 /* 64 MiB */),
// Most files in the real-world have a single sequential access pattern, but having two in parallel can
// also happen
maxWorkerCount: 2,
maxWorkerCount: options.parallelism ?? DEFAULT_PARALLELISM,
runWorker: this._runWorker.bind(this),
prefetchProfile: PREFETCH_PROFILES.network,
});
+56
View File
@@ -1,6 +1,7 @@
import { expect, test } from 'vitest';
import { Output } from '../../src/output.js';
import {
AdtsOutputFormat,
FlacOutputFormat,
MkvOutputFormat,
MovOutputFormat,
@@ -478,6 +479,61 @@ test('Read and write metadata, FLAC', async () => {
expect(readTags.raw!['COMPOSER']).toBe('Hans Zimmer');
});
test('Read and write metadata, ADTS', async () => {
const originalInput = new Input({
source: new FilePathSource(path.join(__dirname, '../public/sample3.aac')),
formats: ALL_FORMATS,
});
const output = new Output({
format: new AdtsOutputFormat(),
target: new BufferTarget(),
});
const conversion = await Conversion.init({
input: originalInput,
output,
tags: {
...songMetadata,
raw: {
TXXY: 'ID3v2 goated',
},
},
});
await conversion.execute();
using input = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
const readTags = await input.getMetadataTags();
// ID3v2 is goated, so pretty much everything was copied:
expect(readTags.title).toBe(songMetadata.title);
expect(readTags.description).toBe(songMetadata.description);
expect(readTags.artist).toBe(songMetadata.artist);
expect(readTags.album).toBe(songMetadata.album);
expect(readTags.albumArtist).toBe(songMetadata.albumArtist);
expect(readTags.comment).toBe(songMetadata.comment);
expect(readTags.lyrics).toBe(songMetadata.lyrics);
expect(readTags.trackNumber).toBe(songMetadata.trackNumber);
expect(readTags.tracksTotal).toBe(songMetadata.tracksTotal);
expect(readTags.discNumber).toBe(songMetadata.discNumber);
expect(readTags.discsTotal).toBe(songMetadata.discsTotal);
expect(readTags.date).toEqual(readTags.date);
expect(readTags.images).toHaveLength(1);
expect(readTags.images![0]!.data).toEqual(coverArt);
expect(readTags.images![0]!.mimeType).toEqual('image/jpeg');
expect(readTags.images![0]!.kind).toEqual('coverFront');
expect(readTags.images![0]!.description).toEqual(songMetadata.images![0]!.description);
expect(readTags.images![0]!.name).toBeUndefined(); // Can't be contained in ID3v2
expect(readTags.raw!['TIT2']).toBe(songMetadata.title);
expect(readTags.raw!['APIC']).instanceOf(Uint8Array);
expect(readTags.raw!['TXXY']).toBe('ID3v2 goated');
});
test('Read and write metadata, WAVE', async () => {
const output = new Output({
format: new WavOutputFormat(),