kv-music/js/metadata.flac.js

588 lines
19 KiB
JavaScript

import { getCoverBlob, getTrackTitle } from './utils.js';
import { getFullArtistString } from './utils.js';
import { METADATA_STRINGS } from './metadata.js';
export const FLAC_MIME_TYPE = 'audio/flac';
export async function readFlacMetadata(file, metadata) {
const arrayBuffer = await file.arrayBuffer();
const dataView = new DataView(arrayBuffer);
if (!isFlacFile(dataView)) return;
const blocks = parseFlacBlocks(dataView);
const vorbisBlock = blocks.find((b) => b.type === 4);
const pictureBlock = blocks.find((b) => b.type === 6);
const streamInfo = blocks.find((b) => b.type === 0);
const artists = [];
if (vorbisBlock) {
const offset = vorbisBlock.offset;
const vendorLen = dataView.getUint32(offset, true);
let pos = offset + 4 + vendorLen;
const commentListLen = dataView.getUint32(pos, true);
pos += 4;
for (let i = 0; i < commentListLen; i++) {
const len = dataView.getUint32(pos, true);
pos += 4;
const comment = new TextDecoder().decode(new Uint8Array(arrayBuffer, pos, len));
pos += len;
const eqIdx = comment.indexOf('=');
if (eqIdx > -1) {
const key = comment.substring(0, eqIdx);
const value = comment.substring(eqIdx + 1);
const upperKey = key.toUpperCase();
if (upperKey === 'TITLE') metadata.title = value;
if (upperKey === 'ARTIST' || upperKey === 'ALBUMARTIST') {
artists.push(value);
}
if (upperKey === 'ALBUM') metadata.album.title = value;
if (upperKey === 'ISRC') metadata.isrc = value;
if (upperKey === 'COPYRIGHT') metadata.copyright = value;
if (upperKey === 'ITUNESADVISORY') metadata.explicit = value === '1';
}
}
}
if (streamInfo) {
const offset = streamInfo.offset;
// Sample Rate is 20 bits spanning bytes 10, 11, and the first 4 bits of 12
const byte10 = dataView.getUint8(offset + 10);
const byte11 = dataView.getUint8(offset + 11);
const byte12 = dataView.getUint8(offset + 12);
// since data for some reason spans across multiple bytes, we need to combine them into one int
const sampleRate = (byte10 << 12) | (byte11 << 4) | (byte12 >> 4);
const byte13 = dataView.getUint8(offset + 13);
const tsHigh = byte13 & 0x0f;
const tsLow = dataView.getUint32(offset + 14, false);
// same thing for total samples
const totalSamples = tsHigh * 0x100000000 + tsLow;
if (sampleRate > 0) {
// beatiful
metadata.duration = totalSamples / sampleRate;
}
}
if (artists.length > 0) {
metadata.artists = artists.flatMap((a) => a.split(/; |\/|\\/)).map((name) => ({ name: name.trim() }));
}
if (pictureBlock) {
try {
let pos = pictureBlock.offset;
pos += 4;
const mimeLen = dataView.getUint32(pos, false);
pos += 4;
const mime = new TextDecoder().decode(new Uint8Array(arrayBuffer, pos, mimeLen));
pos += mimeLen;
const descLen = dataView.getUint32(pos, false);
pos += 4;
pos += descLen;
pos += 16;
const dataLen = dataView.getUint32(pos, false);
pos += 4;
const pictureData = new Uint8Array(arrayBuffer, pos, dataLen);
const blob = new Blob([pictureData], { type: mime });
metadata.album.cover = URL.createObjectURL(blob);
} catch (e) {
console.warn('Error parsing FLAC picture:', e);
}
}
}
/**
* Adds Vorbis comment metadata to FLAC files
*/
export async function addFlacMetadata(flacBlob, track, api) {
try {
const arrayBuffer = await flacBlob.arrayBuffer();
const dataView = new DataView(arrayBuffer);
// Verify FLAC signature
if (!isFlacFile(dataView)) {
console.warn('Not a valid FLAC file, returning original');
return flacBlob;
}
// Parse FLAC structure
const blocks = parseFlacBlocks(dataView);
// If parsing failed or no audio data found, return original
if (!blocks || blocks.length === 0 || blocks.audioDataOffset === undefined) {
console.warn('Failed to parse FLAC blocks, returning original');
return flacBlob;
}
// Check for STREAMINFO block (must be first, type 0)
if (blocks[0].type !== 0) {
console.warn('FLAC file missing STREAMINFO block, returning original');
return flacBlob;
}
// Create or update Vorbis comment block
const vorbisCommentBlock = createVorbisCommentBlock(track);
// Fetch album artwork if available
let pictureBlock = null;
if (track.album?.cover) {
try {
pictureBlock = await createFlacPictureBlock(track.album.cover, api);
} catch (error) {
console.warn('Failed to embed album art:', error);
}
}
// Rebuild FLAC file with new metadata
let newFlacData;
try {
newFlacData = rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBlock);
} catch (rebuildError) {
console.error('Failed to rebuild FLAC structure:', rebuildError);
return flacBlob;
}
// Validate the rebuilt file
const validationView = new DataView(newFlacData.buffer);
if (!isFlacFile(validationView)) {
console.error('Rebuilt FLAC has invalid signature, returning original');
return flacBlob;
}
// Validate new file has proper block structure
const newBlocks = parseFlacBlocks(validationView);
if (!newBlocks || newBlocks.length === 0 || newBlocks.audioDataOffset === undefined) {
console.error('Rebuilt FLAC has invalid block structure, returning original');
return flacBlob;
}
return new Blob([newFlacData], { type: 'audio/flac' });
} catch (error) {
console.error('Failed to add FLAC metadata:', error);
return flacBlob;
}
}
export function isFlacFile(dataView) {
// Check for "fLaC" signature at the beginning
return (
dataView.byteLength >= 4 &&
dataView.getUint8(0) === 0x66 && // 'f'
dataView.getUint8(1) === 0x4c && // 'L'
dataView.getUint8(2) === 0x61 && // 'a'
dataView.getUint8(3) === 0x43
); // 'C'
}
export function parseFlacBlocks(dataView) {
const blocks = [];
let offset = 4; // Skip "fLaC" signature
while (offset + 4 <= dataView.byteLength) {
const header = dataView.getUint8(offset);
const isLast = (header & 0x80) !== 0;
const blockType = header & 0x7f;
// Block type 127 is invalid, types > 6 are reserved (except 127)
// Valid types: 0=STREAMINFO, 1=PADDING, 2=APPLICATION, 3=SEEKTABLE, 4=VORBIS_COMMENT, 5=CUESHEET, 6=PICTURE
if (blockType === 127) {
console.warn('Encountered invalid block type 127, stopping parse');
break;
}
const blockSize =
(dataView.getUint8(offset + 1) << 16) |
(dataView.getUint8(offset + 2) << 8) |
dataView.getUint8(offset + 3);
// Validate block size
if (blockSize < 0 || offset + 4 + blockSize > dataView.byteLength) {
console.warn(`Invalid block size ${blockSize} at offset ${offset}, stopping parse`);
break;
}
blocks.push({
type: blockType,
isLast: isLast,
size: blockSize,
offset: offset + 4,
headerOffset: offset,
});
offset += 4 + blockSize;
if (isLast) {
// Save the audio data offset
blocks.audioDataOffset = offset;
break;
}
}
// If we didn't find the last block marker, estimate audio offset
if (blocks.audioDataOffset === undefined && blocks.length > 0) {
const lastBlock = blocks[blocks.length - 1];
blocks.audioDataOffset = lastBlock.headerOffset + 4 + lastBlock.size;
console.warn('No last-block marker found, estimated audio offset:', blocks.audioDataOffset);
}
return blocks;
}
export function createVorbisComments(track) {
// Vorbis comment structure
const comments = [];
const discNumber = track.volumeNumber ?? track.discNumber;
// Add standard tags
if (track.title) {
comments.push(['TITLE', getTrackTitle(track)]);
}
const artistStr = getFullArtistString(track);
if (artistStr) {
comments.push(['ARTIST', artistStr]);
}
if (track.album?.title) {
comments.push(['ALBUM', track.album.title]);
}
const albumArtist = track.album?.artist?.name || track.artist?.name;
if (albumArtist) {
comments.push(['ALBUMARTIST', albumArtist]);
}
if (track.trackNumber) {
comments.push(['TRACKNUMBER', String(track.trackNumber)]);
}
if (discNumber) {
comments.push(['DISCNUMBER', String(discNumber)]);
}
if (track.album?.numberOfTracks) {
comments.push(['TRACKTOTAL', String(track.album.numberOfTracks)]);
}
if (track.bpm != null) {
const bpm = Number(track.bpm);
if (Number.isFinite(bpm)) {
comments.push(['TEMPO', String(Math.round(bpm))]);
}
}
if (track.replayGain) {
const { albumReplayGain, albumPeakAmplitude, trackReplayGain, trackPeakAmplitude } = track.replayGain;
if (albumReplayGain) comments.push(['REPLAYGAIN_ALBUM_GAIN', String(albumReplayGain)]);
if (albumPeakAmplitude) comments.push(['REPLAYGAIN_ALBUM_PEAK', String(albumPeakAmplitude)]);
if (trackReplayGain) comments.push(['REPLAYGAIN_TRACK_GAIN', String(trackReplayGain)]);
if (trackPeakAmplitude) comments.push(['REPLAYGAIN_TRACK_PEAK', String(trackPeakAmplitude)]);
}
const releaseDateStr =
track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : '');
if (releaseDateStr) {
try {
const year = new Date(releaseDateStr).getFullYear();
if (!isNaN(year)) {
comments.push(['DATE', String(year)]);
}
} catch {
// Invalid date, skip
}
}
if (track.copyright) {
comments.push(['COPYRIGHT', track.copyright]);
}
if (track.isrc) {
comments.push(['ISRC', track.isrc]);
}
if (track.explicit) {
comments.push(['ITUNESADVISORY', '1']);
}
return comments;
}
export function createVorbisCommentBlock(comments = []) {
// Calculate total size
const vendor = METADATA_STRINGS.VENDOR_STRING;
const vendorBytes = new TextEncoder().encode(vendor);
let totalSize = 4 + vendorBytes.length + 4; // vendor length + vendor + comment count
const encodedComments = comments.map(([key, value]) => {
const text = `${key}=${value}`;
const bytes = new TextEncoder().encode(text);
totalSize += 4 + bytes.length;
return bytes;
});
// Create buffer
const buffer = new ArrayBuffer(totalSize);
const view = new DataView(buffer);
const uint8Array = new Uint8Array(buffer);
let offset = 0;
// Vendor length (little-endian)
view.setUint32(offset, vendorBytes.length, true);
offset += 4;
// Vendor string
uint8Array.set(vendorBytes, offset);
offset += vendorBytes.length;
// Comment count (little-endian)
view.setUint32(offset, comments.length, true);
offset += 4;
// Comments
for (const commentBytes of encodedComments) {
view.setUint32(offset, commentBytes.length, true);
offset += 4;
uint8Array.set(commentBytes, offset);
offset += commentBytes.length;
}
return uint8Array;
}
export async function createFlacPictureBlock(coverId, api) {
try {
// Fetch album art
const imageBlob = await getCoverBlob(api, coverId);
if (!imageBlob) {
throw new Error('Failed to fetch album art');
}
const imageBytes = new Uint8Array(await imageBlob.arrayBuffer());
// Detect MIME type from blob or use default
const mimeType = imageBlob.type || 'image/jpeg';
const mimeBytes = new TextEncoder().encode(mimeType);
const description = '';
const descBytes = new TextEncoder().encode(description);
// Calculate total size
const totalSize =
4 + // picture type
4 +
mimeBytes.length + // mime length + mime
4 +
descBytes.length + // desc length + desc
4 + // width
4 + // height
4 + // color depth
4 + // indexed colors
4 +
imageBytes.length; // image length + image
const buffer = new ArrayBuffer(totalSize);
const view = new DataView(buffer);
const uint8Array = new Uint8Array(buffer);
let offset = 0;
// Picture type (3 = front cover)
view.setUint32(offset, 3, false);
offset += 4;
// MIME type length
view.setUint32(offset, mimeBytes.length, false);
offset += 4;
// MIME type
uint8Array.set(mimeBytes, offset);
offset += mimeBytes.length;
// Description length
view.setUint32(offset, descBytes.length, false);
offset += 4;
// Description (empty)
if (descBytes.length > 0) {
uint8Array.set(descBytes, offset);
offset += descBytes.length;
}
// Width (0 = unknown)
view.setUint32(offset, 0, false);
offset += 4;
// Height (0 = unknown)
view.setUint32(offset, 0, false);
offset += 4;
// Color depth (0 = unknown)
view.setUint32(offset, 0, false);
offset += 4;
// Indexed colors (0 = not indexed)
view.setUint32(offset, 0, false);
offset += 4;
// Image data length
view.setUint32(offset, imageBytes.length, false);
offset += 4;
// Image data
uint8Array.set(imageBytes, offset);
return uint8Array;
} catch (error) {
console.error('Failed to create FLAC picture block:', error);
return null;
}
}
export function rebuildFlacWithMetadata(
dataView,
blocks,
vorbisCommentBlock = createVorbisCommentBlock(),
pictureBlock
) {
const originalArray = new Uint8Array(dataView.buffer);
// Remove old Vorbis comment and picture blocks
const filteredBlocks = blocks.filter((b) => b.type !== 4 && b.type !== 6); // 4 = Vorbis, 6 = Picture
// Calculate new file size
let newSize = 4; // "fLaC" signature
// Add STREAMINFO and other essential blocks
for (const block of filteredBlocks) {
newSize += 4 + block.size; // header + data
}
if (vorbisCommentBlock) {
// Add new Vorbis comment block
newSize += 4 + vorbisCommentBlock.length;
}
// Add picture block if available
if (pictureBlock) {
newSize += 4 + pictureBlock.length;
}
// Add audio data
const audioDataOffset = blocks.audioDataOffset;
if (audioDataOffset === undefined) {
throw new Error('Invalid FLAC file structure: unable to locate audio data stream');
}
const audioDataSize = dataView.byteLength - audioDataOffset;
newSize += audioDataSize;
// Build new file
const newFile = new Uint8Array(newSize);
let offset = 0;
// Write "fLaC" signature
newFile[offset++] = 0x66; // 'f'
newFile[offset++] = 0x4c; // 'L'
newFile[offset++] = 0x61; // 'a'
newFile[offset++] = 0x43; // 'C'
// Write existing blocks (except Vorbis and Picture)
for (let i = 0; i < filteredBlocks.length; i++) {
const block = filteredBlocks[i];
const isLast = false; // We'll add more blocks
// Write block header
const header = (isLast ? 0x80 : 0x00) | block.type;
newFile[offset++] = header;
newFile[offset++] = (block.size >> 16) & 0xff;
newFile[offset++] = (block.size >> 8) & 0xff;
newFile[offset++] = block.size & 0xff;
// Write block data
newFile.set(originalArray.subarray(block.offset, block.offset + block.size), offset);
offset += block.size;
}
let lastBlockHeaderOffset = offset;
if (vorbisCommentBlock) {
// Write new Vorbis comment block
const vorbisHeaderOffset = offset;
const vorbisHeader = 0x04; // Vorbis comment type
newFile[offset++] = vorbisHeader;
newFile[offset++] = (vorbisCommentBlock.length >> 16) & 0xff;
newFile[offset++] = (vorbisCommentBlock.length >> 8) & 0xff;
newFile[offset++] = vorbisCommentBlock.length & 0xff;
newFile.set(vorbisCommentBlock, offset);
offset += vorbisCommentBlock.length;
lastBlockHeaderOffset = vorbisHeaderOffset;
}
// Write picture block if available
if (pictureBlock) {
const pictureHeaderOffset = offset;
const pictureHeader = 0x06; // Picture type
newFile[offset++] = pictureHeader;
newFile[offset++] = (pictureBlock.length >> 16) & 0xff;
newFile[offset++] = (pictureBlock.length >> 8) & 0xff;
newFile[offset++] = pictureBlock.length & 0xff;
newFile.set(pictureBlock, offset);
offset += pictureBlock.length;
lastBlockHeaderOffset = pictureHeaderOffset;
}
// Mark the last metadata block with the 0x80 flag
newFile[lastBlockHeaderOffset] |= 0x80;
// Write audio data
if (audioDataSize > 0) {
newFile.set(originalArray.subarray(audioDataOffset, audioDataOffset + audioDataSize), offset);
}
return newFile;
}
export function getFlacBlocks(dataView) {
// Verify FLAC signature
if (!isFlacFile(dataView)) {
throw new Error('Not a valid FLAC file');
}
// Parse FLAC structure
const blocks = parseFlacBlocks(dataView);
// If parsing failed or no audio data found, return original
if (!blocks || blocks.length === 0 || blocks.audioDataOffset === undefined) {
throw new Error('Failed to parse FLAC blocks');
}
// Check for STREAMINFO block (must be first, type 0)
if (blocks[0].type !== 0) {
throw new Error('FLAC file missing STREAMINFO block');
}
return blocks;
}
/**
* Removes all metadata from a FLAC file blob and returns the rebuilt FLAC data.
*
* @async
* @param {Blob} flacBlob - The FLAC audio file as a Blob object
* @returns {Promise<Blob>} A Promise that resolves to a new Blob containing the FLAC file without metadata,
* or the original flacBlob if an error occurs during processing
* @throws {Error} Logs errors to console but catches and returns original blob instead of throwing
*
* @example
* const flacFile = new Blob([arrayBuffer], { type: 'audio/flac' });
* const cleanFlac = await rebuildFlacWithoutMetadata(flacFile);
*/
export async function rebuildFlacWithoutMetadata(flacBlob) {
try {
const arrayBuffer = await flacBlob.arrayBuffer();
const dataView = new DataView(arrayBuffer);
const blocks = getFlacBlocks(dataView);
return new Blob([rebuildFlacWithMetadata(dataView, blocks, createVorbisCommentBlock(), null)], {
type: FLAC_MIME_TYPE,
});
} catch (err) {
console.error('Error rebuilding FLAC file:', err);
return flacBlob;
}
}