mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-01 04:43:51 +02:00
Fix WAV files with embedded ID3 metadata returning empty tags (#110)
* Read ID3v2 tags from WAV file * remove js import Co-authored-by: David P. <[email protected]> * revert slicing in wave-demuxer.ts and add id3.ts for further sharing * Add `writeId3Tag` to the `WavOutputFormat` constructor options * add more context in private and public APIs that it's non-standard * compare actual values in the test with real file * remove unnecesarry double slice requesting * trim test audio file (i tried my best to preserve ID3, including artwork) * Couple of code cleanups * const -> using * Fix linting errors --------- Co-authored-by: David P. <[email protected]>
This commit is contained in:
co-authored by
David P.
parent
cab8319051
commit
6aee4e6a6a
+2
-626
@@ -6,64 +6,8 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { decodeSynchsafe, FRAME_HEADER_SIZE, FrameHeader, readFrameHeader } from '../../shared/mp3-misc';
|
||||
import { MetadataTags } from '../tags';
|
||||
import { coalesceIndex, textDecoder } from '../misc';
|
||||
import { FileSlice, readAscii, readBytes, Reader, readU32Be, readU8 } from '../reader';
|
||||
|
||||
export type Id3V2Header = {
|
||||
majorVersion: number;
|
||||
revision: number;
|
||||
flags: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export enum Id3V2HeaderFlags {
|
||||
Unsynchronisation = 1 << 7,
|
||||
ExtendedHeader = 1 << 6,
|
||||
ExperimentalIndicator = 1 << 5,
|
||||
Footer = 1 << 4,
|
||||
}
|
||||
|
||||
export enum Id3V2TextEncoding {
|
||||
ISO_8859_1,
|
||||
UTF_16_WITH_BOM,
|
||||
UTF_16_BE_NO_BOM,
|
||||
UTF_8,
|
||||
}
|
||||
|
||||
export const ID3_V1_TAG_SIZE = 128;
|
||||
export const ID3_V2_HEADER_SIZE = 10;
|
||||
|
||||
export const ID3_V1_GENRES = [
|
||||
'Blues', 'Classic rock', 'Country', 'Dance', 'Disco', 'Funk', 'Grunge', 'Hip-hop', 'Jazz',
|
||||
'Metal', 'New age', 'Oldies', 'Other', 'Pop', 'Rhythm and blues', 'Rap', 'Reggae', 'Rock',
|
||||
'Techno', 'Industrial', 'Alternative', 'Ska', 'Death metal', 'Pranks', 'Soundtrack',
|
||||
'Euro-techno', 'Ambient', 'Trip-hop', 'Vocal', 'Jazz & funk', 'Fusion', 'Trance', 'Classical',
|
||||
'Instrumental', 'Acid', 'House', 'Game', 'Sound clip', 'Gospel', 'Noise', 'Alternative rock',
|
||||
'Bass', 'Soul', 'Punk', 'Space', 'Meditative', 'Instrumental pop', 'Instrumental rock',
|
||||
'Ethnic', 'Gothic', 'Darkwave', 'Techno-industrial', 'Electronic', 'Pop-folk', 'Eurodance',
|
||||
'Dream', 'Southern rock', 'Comedy', 'Cult', 'Gangsta', 'Top 40', 'Christian rap', 'Pop/funk',
|
||||
'Jungle music', 'Native US', 'Cabaret', 'New wave', 'Psychedelic', 'Rave', 'Showtunes',
|
||||
'Trailer', 'Lo-fi', 'Tribal', 'Acid punk', 'Acid jazz', 'Polka', 'Retro', 'Musical',
|
||||
'Rock \'n\' roll', 'Hard rock', 'Folk', 'Folk rock', 'National folk', 'Swing', 'Fast fusion',
|
||||
'Bebop', 'Latin', 'Revival', 'Celtic', 'Bluegrass', 'Avantgarde', 'Gothic rock',
|
||||
'Progressive rock', 'Psychedelic rock', 'Symphonic rock', 'Slow rock', 'Big band', 'Chorus',
|
||||
'Easy listening', 'Acoustic', 'Humour', 'Speech', 'Chanson', 'Opera', 'Chamber music',
|
||||
'Sonata', 'Symphony', 'Booty bass', 'Primus', 'Porn groove', 'Satire', 'Slow jam', 'Club',
|
||||
'Tango', 'Samba', 'Folklore', 'Ballad', 'Power ballad', 'Rhythmic Soul', 'Freestyle', 'Duet',
|
||||
'Punk rock', 'Drum solo', 'A cappella', 'Euro-house', 'Dance hall', 'Goa music', 'Drum & bass',
|
||||
'Club-house', 'Hardcore techno', 'Terror', 'Indie', 'Britpop', 'Negerpunk', 'Polsk punk',
|
||||
'Beat', 'Christian gangsta rap', 'Heavy metal', 'Black metal', 'Crossover',
|
||||
'Contemporary Christian', 'Christian rock', 'Merengue', 'Salsa', 'Thrash metal', 'Anime',
|
||||
'Jpop', 'Synthpop', 'Christmas', 'Art rock', 'Baroque', 'Bhangra', 'Big beat', 'Breakbeat',
|
||||
'Chillout', 'Downtempo', 'Dub', 'EBM', 'Eclectic', 'Electro', 'Electroclash', 'Emo',
|
||||
'Experimental', 'Garage', 'Global', 'IDM', 'Illbient', 'Industro-Goth', 'Jam Band',
|
||||
'Krautrock', 'Leftfield', 'Lounge', 'Math rock', 'New romantic', 'Nu-breakz', 'Post-punk',
|
||||
'Post-rock', 'Psytrance', 'Shoegaze', 'Space rock', 'Trop rock', 'World music', 'Neoclassical',
|
||||
'Audiobook', 'Audio theatre', 'Neue Deutsche Welle', 'Podcast', 'Indie rock', 'G-Funk',
|
||||
'Dubstep', 'Garage rock', 'Psybient',
|
||||
];
|
||||
import { FRAME_HEADER_SIZE, FrameHeader, readFrameHeader } from '../../shared/mp3-misc';
|
||||
import { Reader, readU32Be } from '../reader';
|
||||
|
||||
export const readNextFrameHeader = async (reader: Reader, startPos: number, until: number | null): Promise<{
|
||||
header: FrameHeader;
|
||||
@@ -88,571 +32,3 @@ export const readNextFrameHeader = async (reader: Reader, startPos: number, unti
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const parseId3V1Tag = (slice: FileSlice, tags: MetadataTags) => {
|
||||
const startPos = slice.filePos;
|
||||
tags.raw ??= {};
|
||||
tags.raw['TAG'] ??= readBytes(slice, ID3_V1_TAG_SIZE - 3); // Dump the whole tag into the raw metadata
|
||||
slice.filePos = startPos;
|
||||
|
||||
const title = readId3V1String(slice, 30);
|
||||
if (title) tags.title ??= title;
|
||||
|
||||
const artist = readId3V1String(slice, 30);
|
||||
if (artist) tags.artist ??= artist;
|
||||
|
||||
const album = readId3V1String(slice, 30);
|
||||
if (album) tags.album ??= album;
|
||||
|
||||
const yearText = readId3V1String(slice, 4);
|
||||
const year = Number.parseInt(yearText, 10);
|
||||
if (Number.isInteger(year) && year > 0) {
|
||||
tags.date ??= new Date(year, 0, 1);
|
||||
}
|
||||
|
||||
const commentBytes = readBytes(slice, 30);
|
||||
let comment: string;
|
||||
|
||||
// Check for the ID3v1.1 track number format:
|
||||
// The 29th byte (index 28) is a null terminator, and the 30th byte is the track number.
|
||||
if (commentBytes[28] === 0 && commentBytes[29] !== 0) {
|
||||
const trackNum = commentBytes[29]!;
|
||||
if (trackNum > 0) {
|
||||
tags.trackNumber ??= trackNum;
|
||||
}
|
||||
|
||||
slice.skip(-30);
|
||||
comment = readId3V1String(slice, 28);
|
||||
slice.skip(2);
|
||||
} else {
|
||||
slice.skip(-30);
|
||||
comment = readId3V1String(slice, 30);
|
||||
}
|
||||
|
||||
if (comment) tags.comment ??= comment;
|
||||
|
||||
const genreIndex = readU8(slice);
|
||||
if (genreIndex < ID3_V1_GENRES.length) {
|
||||
tags.genre ??= ID3_V1_GENRES[genreIndex];
|
||||
}
|
||||
};
|
||||
|
||||
export const readId3V1String = (slice: FileSlice, length: number) => {
|
||||
const bytes = readBytes(slice, length);
|
||||
|
||||
const endIndex = coalesceIndex(bytes.indexOf(0), bytes.length);
|
||||
const relevantBytes = bytes.subarray(0, endIndex);
|
||||
|
||||
// Decode as ISO-8859-1
|
||||
let str = '';
|
||||
for (let i = 0; i < relevantBytes.length; i++) {
|
||||
str += String.fromCharCode(relevantBytes[i]!);
|
||||
}
|
||||
|
||||
return str.trimEnd(); // String also may be padded with spaces
|
||||
};
|
||||
|
||||
export const readId3V2Header = (slice: FileSlice): Id3V2Header | null => {
|
||||
const startPos = slice.filePos;
|
||||
|
||||
const tag = readAscii(slice, 3);
|
||||
const majorVersion = readU8(slice);
|
||||
const revision = readU8(slice);
|
||||
const flags = readU8(slice);
|
||||
const sizeRaw = readU32Be(slice);
|
||||
|
||||
if (tag !== 'ID3' || majorVersion === 0xff || revision === 0xff || (sizeRaw & 0x80808080) !== 0) {
|
||||
slice.filePos = startPos;
|
||||
return null;
|
||||
}
|
||||
|
||||
const size = decodeSynchsafe(sizeRaw);
|
||||
|
||||
return { majorVersion, revision, flags, size };
|
||||
};
|
||||
|
||||
export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, tags: MetadataTags) => {
|
||||
// https://id3.org/id3v2.3.0
|
||||
|
||||
if (![2, 3, 4].includes(header.majorVersion)) {
|
||||
console.warn(`Unsupported ID3v2 major version: ${header.majorVersion}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = readBytes(slice, header.size);
|
||||
const reader = new Id3V2Reader(header, bytes);
|
||||
|
||||
if (header.flags & Id3V2HeaderFlags.Footer) {
|
||||
reader.removeFooter();
|
||||
}
|
||||
|
||||
if ((header.flags & Id3V2HeaderFlags.Unsynchronisation) && header.majorVersion === 3) {
|
||||
reader.ununsynchronizeAll();
|
||||
}
|
||||
|
||||
if (header.flags & Id3V2HeaderFlags.ExtendedHeader) {
|
||||
const extendedHeaderSize = reader.readU32();
|
||||
|
||||
if (header.majorVersion === 3) {
|
||||
reader.pos += extendedHeaderSize; // The extended header size excludes itself
|
||||
} else {
|
||||
reader.pos += extendedHeaderSize - 4; // The extended header size includes itself
|
||||
}
|
||||
}
|
||||
|
||||
while (reader.pos <= reader.bytes.length - reader.frameHeaderSize()) {
|
||||
const frame = reader.readId3V2Frame();
|
||||
if (!frame) {
|
||||
break;
|
||||
}
|
||||
|
||||
const frameStartPos = reader.pos;
|
||||
const frameEndPos = reader.pos + frame.size;
|
||||
|
||||
let frameEncrypted = false;
|
||||
let frameCompressed = false;
|
||||
let frameUnsynchronized = false;
|
||||
|
||||
if (header.majorVersion === 3) {
|
||||
frameEncrypted = !!(frame.flags & (1 << 6));
|
||||
frameCompressed = !!(frame.flags & (1 << 7));
|
||||
} else if (header.majorVersion === 4) {
|
||||
frameEncrypted = !!(frame.flags & (1 << 2));
|
||||
frameCompressed = !!(frame.flags & (1 << 3));
|
||||
frameUnsynchronized = !!(frame.flags & (1 << 1))
|
||||
|| !!(header.flags & Id3V2HeaderFlags.Unsynchronisation);
|
||||
}
|
||||
|
||||
if (frameEncrypted) {
|
||||
console.warn(`Skipping encrypted ID3v2 frame ${frame.id}`);
|
||||
reader.pos = frameEndPos;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frameCompressed) {
|
||||
console.warn(`Skipping compressed ID3v2 frame ${frame.id}`); // Maybe someday? Idk
|
||||
reader.pos = frameEndPos;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frameUnsynchronized) {
|
||||
reader.ununsynchronizeRegion(reader.pos, frameEndPos);
|
||||
}
|
||||
|
||||
tags.raw ??= {};
|
||||
if (frame.id[0] === 'T') {
|
||||
// It's a text frame, let's decode as text
|
||||
tags.raw[frame.id] ??= reader.readId3V2EncodingAndText(frameEndPos);
|
||||
} else {
|
||||
// For the others, let's just get the bytes
|
||||
tags.raw[frame.id] ??= reader.readBytes(frame.size);
|
||||
}
|
||||
|
||||
reader.pos = frameStartPos;
|
||||
|
||||
switch (frame.id) {
|
||||
case 'TIT2':
|
||||
case 'TT2': {
|
||||
tags.title ??= reader.readId3V2EncodingAndText(frameEndPos);
|
||||
}; break;
|
||||
|
||||
case 'TIT3':
|
||||
case 'TT3': {
|
||||
tags.description ??= reader.readId3V2EncodingAndText(frameEndPos);
|
||||
}; break;
|
||||
|
||||
case 'TPE1':
|
||||
case 'TP1': {
|
||||
tags.artist ??= reader.readId3V2EncodingAndText(frameEndPos);
|
||||
}; break;
|
||||
|
||||
case 'TALB':
|
||||
case 'TAL': {
|
||||
tags.album ??= reader.readId3V2EncodingAndText(frameEndPos);
|
||||
}; break;
|
||||
|
||||
case 'TPE2':
|
||||
case 'TP2': {
|
||||
tags.albumArtist ??= reader.readId3V2EncodingAndText(frameEndPos);
|
||||
}; break;
|
||||
|
||||
case 'TRCK':
|
||||
case 'TRK': {
|
||||
const trackText = reader.readId3V2EncodingAndText(frameEndPos);
|
||||
const parts = trackText.split('/');
|
||||
const trackNum = Number.parseInt(parts[0]!, 10);
|
||||
const tracksTotal = parts[1] && Number.parseInt(parts[1], 10);
|
||||
|
||||
if (Number.isInteger(trackNum) && trackNum > 0) {
|
||||
tags.trackNumber ??= trackNum;
|
||||
}
|
||||
if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) {
|
||||
tags.tracksTotal ??= tracksTotal;
|
||||
}
|
||||
}; break;
|
||||
|
||||
case 'TPOS':
|
||||
case 'TPA': {
|
||||
const discText = reader.readId3V2EncodingAndText(frameEndPos);
|
||||
const parts = discText.split('/');
|
||||
const discNum = Number.parseInt(parts[0]!, 10);
|
||||
const discsTotal = parts[1] && Number.parseInt(parts[1], 10);
|
||||
|
||||
if (Number.isInteger(discNum) && discNum > 0) {
|
||||
tags.discNumber ??= discNum;
|
||||
}
|
||||
if (discsTotal && Number.isInteger(discsTotal) && discsTotal > 0) {
|
||||
tags.discsTotal ??= discsTotal;
|
||||
}
|
||||
}; break;
|
||||
|
||||
case 'TCON':
|
||||
case 'TCO': {
|
||||
const genreText = reader.readId3V2EncodingAndText(frameEndPos);
|
||||
let match = /^\((\d+)\)/.exec(genreText);
|
||||
if (match) {
|
||||
const genreNumber = Number.parseInt(match[1]!);
|
||||
if (ID3_V1_GENRES[genreNumber] !== undefined) {
|
||||
tags.genre ??= ID3_V1_GENRES[genreNumber];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
match = /^\d+$/.exec(genreText);
|
||||
if (match) {
|
||||
const genreNumber = Number.parseInt(match[0]);
|
||||
if (ID3_V1_GENRES[genreNumber] !== undefined) {
|
||||
tags.genre ??= ID3_V1_GENRES[genreNumber];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
tags.genre ??= genreText;
|
||||
}; break;
|
||||
|
||||
case 'TDRC':
|
||||
case 'TDAT': {
|
||||
const dateText = reader.readId3V2EncodingAndText(frameEndPos);
|
||||
const date = new Date(dateText);
|
||||
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
tags.date ??= date;
|
||||
}
|
||||
}; break;
|
||||
|
||||
case 'TYER':
|
||||
case 'TYE': {
|
||||
const yearText = reader.readId3V2EncodingAndText(frameEndPos);
|
||||
const year = Number.parseInt(yearText, 10);
|
||||
|
||||
if (Number.isInteger(year)) {
|
||||
tags.date ??= new Date(year, 0, 1);
|
||||
}
|
||||
}; break;
|
||||
|
||||
case 'USLT':
|
||||
case 'ULT': {
|
||||
const encoding = reader.readU8();
|
||||
reader.pos += 3; // Skip language
|
||||
reader.readId3V2Text(encoding, frameEndPos); // Short content description
|
||||
tags.lyrics ??= reader.readId3V2Text(encoding, frameEndPos);
|
||||
}; break;
|
||||
|
||||
case 'COMM':
|
||||
case 'COM': {
|
||||
const encoding = reader.readU8();
|
||||
reader.pos += 3; // Skip language
|
||||
reader.readId3V2Text(encoding, frameEndPos); // Short content description
|
||||
tags.comment ??= reader.readId3V2Text(encoding, frameEndPos);
|
||||
}; break;
|
||||
|
||||
case 'APIC':
|
||||
case 'PIC': {
|
||||
const encoding = reader.readId3V2TextEncoding();
|
||||
|
||||
let mimeType: string;
|
||||
if (header.majorVersion === 2) {
|
||||
const imageFormat = reader.readAscii(3);
|
||||
mimeType = imageFormat === 'PNG'
|
||||
? 'image/png'
|
||||
: imageFormat === 'JPG'
|
||||
? 'image/jpeg'
|
||||
: 'image/*';
|
||||
} else {
|
||||
mimeType = reader.readId3V2Text(encoding, frameEndPos);
|
||||
}
|
||||
|
||||
const pictureType = reader.readU8();
|
||||
const description = reader.readId3V2Text(encoding, frameEndPos).trimEnd(); // Trim ending spaces
|
||||
|
||||
const imageDataSize = frameEndPos - reader.pos;
|
||||
if (imageDataSize >= 0) {
|
||||
const imageData = reader.readBytes(imageDataSize);
|
||||
|
||||
if (!tags.images) tags.images = [];
|
||||
tags.images.push({
|
||||
data: imageData,
|
||||
mimeType,
|
||||
kind: pictureType === 3
|
||||
? 'coverFront'
|
||||
: pictureType === 4
|
||||
? 'coverBack'
|
||||
: 'unknown',
|
||||
description,
|
||||
});
|
||||
}
|
||||
}; break;
|
||||
|
||||
default: {
|
||||
reader.pos += frame.size;
|
||||
}; break;
|
||||
}
|
||||
|
||||
reader.pos = frameEndPos;
|
||||
}
|
||||
};
|
||||
|
||||
// https://id3.org/id3v2.3.0
|
||||
export class Id3V2Reader {
|
||||
pos = 0;
|
||||
view: DataView;
|
||||
|
||||
constructor(public header: Id3V2Header, public bytes: Uint8Array) {
|
||||
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
}
|
||||
|
||||
frameHeaderSize() {
|
||||
return this.header.majorVersion === 2 ? 6 : 10;
|
||||
}
|
||||
|
||||
ununsynchronizeAll() {
|
||||
const newBytes: number[] = [];
|
||||
|
||||
for (let i = 0; i < this.bytes.length; i++) {
|
||||
const value1 = this.bytes[i]!;
|
||||
newBytes.push(value1);
|
||||
|
||||
if (value1 === 0xff && i !== this.bytes.length - 1) {
|
||||
const value2 = this.bytes[i]!;
|
||||
if (value2 === 0x00) {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.bytes = new Uint8Array(newBytes);
|
||||
this.view = new DataView(this.bytes.buffer);
|
||||
}
|
||||
|
||||
ununsynchronizeRegion(start: number, end: number) {
|
||||
const newBytes: number[] = [];
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
const value1 = this.bytes[i]!;
|
||||
newBytes.push(value1);
|
||||
|
||||
if (value1 === 0xff && i !== end - 1) {
|
||||
const value2 = this.bytes[i + 1]!;
|
||||
if (value2 === 0x00) {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const before = this.bytes.subarray(0, start);
|
||||
const after = this.bytes.subarray(end);
|
||||
|
||||
this.bytes = new Uint8Array(before.length + newBytes.length + after.length);
|
||||
this.bytes.set(before, 0);
|
||||
this.bytes.set(newBytes, before.length);
|
||||
this.bytes.set(after, before.length + newBytes.length);
|
||||
|
||||
this.view = new DataView(this.bytes.buffer);
|
||||
}
|
||||
|
||||
removeFooter() {
|
||||
this.bytes = this.bytes.subarray(0, this.bytes.length - ID3_V2_HEADER_SIZE);
|
||||
this.view = new DataView(this.bytes.buffer);
|
||||
}
|
||||
|
||||
readBytes(length: number) {
|
||||
const slice = this.bytes.subarray(this.pos, this.pos + length);
|
||||
this.pos += length;
|
||||
return slice;
|
||||
}
|
||||
|
||||
readU8() {
|
||||
const value = this.view.getUint8(this.pos);
|
||||
this.pos += 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
readU16() {
|
||||
const value = this.view.getUint16(this.pos, false);
|
||||
this.pos += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
readU24() {
|
||||
const high = this.view.getUint16(this.pos, false);
|
||||
const low = this.view.getUint8(this.pos + 1);
|
||||
this.pos += 3;
|
||||
return high * 0x100 + low;
|
||||
}
|
||||
|
||||
readU32() {
|
||||
const value = this.view.getUint32(this.pos, false);
|
||||
this.pos += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
readAscii(length: number) {
|
||||
let str = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
str += String.fromCharCode(this.view.getUint8(this.pos + i));
|
||||
}
|
||||
this.pos += length;
|
||||
return str;
|
||||
}
|
||||
|
||||
readId3V2Frame() {
|
||||
if (this.header.majorVersion === 2) {
|
||||
const id = this.readAscii(3);
|
||||
if (id === '\x00\x00\x00') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const size = this.readU24();
|
||||
|
||||
return { id, size, flags: 0 };
|
||||
} else {
|
||||
const id = this.readAscii(4);
|
||||
if (id === '\x00\x00\x00\x00') {
|
||||
// We've landed in the padding section
|
||||
return null;
|
||||
}
|
||||
|
||||
const sizeRaw = this.readU32();
|
||||
let size = this.header.majorVersion === 4
|
||||
? decodeSynchsafe(sizeRaw)
|
||||
: sizeRaw;
|
||||
const flags = this.readU16();
|
||||
const headerEndPos = this.pos;
|
||||
|
||||
// Some files may have incorrectly synchsafed/unsynchsafed sizes. To validate which interpretation is valid,
|
||||
// we validate a size by skipping ahead and seeing if we land at a valid frame header (or at the end of the
|
||||
// tag.
|
||||
|
||||
const isSizeValid = (size: number) => {
|
||||
const nextPos = this.pos + size;
|
||||
if (nextPos > this.bytes.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nextPos <= this.bytes.length - this.frameHeaderSize()) {
|
||||
this.pos += size;
|
||||
const nextId = this.readAscii(4);
|
||||
if (nextId !== '\x00\x00\x00\x00' && !/[0-9A-Z]{4}/.test(nextId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!isSizeValid(size)) {
|
||||
// Flip the synchsafing, and try if this one makes more sense
|
||||
const otherSize = this.header.majorVersion === 4
|
||||
? sizeRaw
|
||||
: decodeSynchsafe(sizeRaw);
|
||||
|
||||
if (isSizeValid(otherSize)) {
|
||||
size = otherSize;
|
||||
}
|
||||
}
|
||||
|
||||
this.pos = headerEndPos;
|
||||
return { id, size, flags };
|
||||
}
|
||||
}
|
||||
|
||||
readId3V2TextEncoding(): Id3V2TextEncoding {
|
||||
const number = this.readU8();
|
||||
if (number > 3) {
|
||||
throw new Error(`Unsupported text encoding: ${number}`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
readId3V2Text(encoding: Id3V2TextEncoding, until: number): string {
|
||||
const startPos = this.pos;
|
||||
const data = this.readBytes(until);
|
||||
|
||||
switch (encoding) {
|
||||
case Id3V2TextEncoding.ISO_8859_1: {
|
||||
let str = '';
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const value = data[i]!;
|
||||
if (value === 0) {
|
||||
this.pos = startPos + i + 1;
|
||||
break;
|
||||
}
|
||||
str += String.fromCharCode(value);
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
case Id3V2TextEncoding.UTF_16_WITH_BOM: {
|
||||
if (data[0] === 0xff && data[1] === 0xfe) {
|
||||
const decoder = new TextDecoder('utf-16le');
|
||||
const endIndex = coalesceIndex(
|
||||
data.findIndex((x, i) => x === 0 && data[i + 1] === 0 && i % 2 === 0),
|
||||
data.length,
|
||||
);
|
||||
this.pos = startPos + Math.min(endIndex + 2, data.length);
|
||||
return decoder.decode(data.subarray(2, endIndex));
|
||||
} else if (data[0] === 0xfe && data[1] === 0xff) {
|
||||
const decoder = new TextDecoder('utf-16be');
|
||||
const endIndex = coalesceIndex(
|
||||
data.findIndex((x, i) => x === 0 && data[i + 1] === 0 && i % 2 === 0),
|
||||
data.length,
|
||||
);
|
||||
this.pos = startPos + Math.min(endIndex + 2, data.length);
|
||||
return decoder.decode(data.subarray(2, endIndex));
|
||||
} else {
|
||||
// Treat it like UTF-8, some files do this
|
||||
const endIndex = coalesceIndex(data.findIndex(x => x === 0), data.length);
|
||||
this.pos = startPos + Math.min(endIndex + 1, data.length);
|
||||
return textDecoder.decode(data.subarray(0, endIndex));
|
||||
}
|
||||
}
|
||||
|
||||
case Id3V2TextEncoding.UTF_16_BE_NO_BOM: {
|
||||
const decoder = new TextDecoder('utf-16be');
|
||||
const endIndex = coalesceIndex(
|
||||
data.findIndex((x, i) => x === 0 && data[i + 1] === 0 && i % 2 === 0),
|
||||
data.length,
|
||||
);
|
||||
this.pos = startPos + Math.min(endIndex + 2, data.length);
|
||||
return decoder.decode(data.subarray(0, endIndex));
|
||||
}
|
||||
|
||||
case Id3V2TextEncoding.UTF_8: {
|
||||
const endIndex = coalesceIndex(data.findIndex(x => x === 0), data.length);
|
||||
this.pos = startPos + Math.min(endIndex + 1, data.length);
|
||||
return textDecoder.decode(data.subarray(0, endIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readId3V2EncodingAndText(until: number) {
|
||||
if (this.pos >= until) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const encoding = this.readId3V2TextEncoding();
|
||||
return this.readId3V2Text(encoding, until);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user