Add metadata read/write support for Ogg

This commit is contained in:
Vanilagy
2025-09-05 15:39:29 +02:00
parent 6d32de0fb1
commit d3a030c307
11 changed files with 431 additions and 49 deletions
+169 -2
View File
@@ -12,13 +12,16 @@ import { Demuxer } from '../demuxer';
import { Input } from '../input';
import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
import { PacketRetrievalOptions } from '../media-sink';
import { MediaMetadata } from '../metadata';
import {
assert,
AsyncMutex,
base64ToBytes,
binarySearchLessOrEqual,
findLast,
last,
roundToPrecision,
textDecoder,
toDataView,
UNDETERMINED_LANGUAGE,
} from '../misc';
@@ -57,6 +60,7 @@ export class OggDemuxer extends Demuxer {
metadataPromise: Promise<void> | null = null;
bitstreams: LogicalBitstream[] = [];
tracks: InputAudioTrack[] = [];
metadata: MediaMetadata = {};
constructor(input: Input) {
super(input);
@@ -218,6 +222,8 @@ export class OggDemuxer extends Demuxer {
],
modeBlockflags: parseModesFromVorbisSetupPacket(thirdPacket.data).modeBlockflags,
};
this.readVorbisComments(secondPacket.data.subarray(7)); // Skip header type and 'vorbis'
}
async readOpusMetadata(firstPacket: Packet, bitstream: LogicalBitstream) {
@@ -237,8 +243,6 @@ export class OggDemuxer extends Demuxer {
return;
}
// We don't make use of the comment header's data
bitstream.codecInfo.codec = 'opus';
bitstream.description = firstPacket.data;
bitstream.lastMetadataPacket = secondPacket;
@@ -250,6 +254,164 @@ export class OggDemuxer extends Demuxer {
bitstream.codecInfo.opusInfo = {
preSkip: header.preSkip,
};
this.readVorbisComments(secondPacket.data.subarray(8)); // Skip 'OpusTags'
}
readVorbisComments(bytes: Uint8Array) {
// https://datatracker.ietf.org/doc/html/rfc7845#section-5.2
const commentView = toDataView(bytes);
let commentPos = 0;
const vendorStringLength = commentView.getUint32(commentPos, true);
commentPos += 4;
const vendorString = textDecoder.decode(
bytes.subarray(commentPos, commentPos + vendorStringLength),
);
commentPos += vendorStringLength;
if (vendorStringLength > 0) {
// Expose the vendor string in the raw metadata
this.metadata.raw ??= {};
this.metadata.raw['vendor'] ??= vendorString;
}
const listLength = commentView.getUint32(commentPos, true);
commentPos += 4;
// Loop over all metadata tags
for (let i = 0; i < listLength; i++) {
const stringLength = commentView.getUint32(commentPos, true);
commentPos += 4;
const string = textDecoder.decode(
bytes.subarray(commentPos, commentPos + stringLength),
);
commentPos += stringLength;
const separatorIndex = string.indexOf('=');
if (separatorIndex === -1) {
continue;
}
const key = string.slice(0, separatorIndex).toUpperCase();
const value = string.slice(separatorIndex + 1);
this.metadata.raw ??= {};
this.metadata.raw[key] ??= value;
switch (key) {
case 'TITLE': {
this.metadata.title ??= value;
}; break;
case 'DESCRIPTION': {
this.metadata.description ??= value;
}; break;
case 'ARTIST': {
this.metadata.artist ??= value;
}; break;
case 'ALBUM': {
this.metadata.album ??= value;
}; break;
case 'ALBUMARTIST': {
this.metadata.albumArtist ??= value;
}; break;
case 'COMMENT': {
this.metadata.comment ??= value;
}; break;
case 'LYRICS': {
this.metadata.lyrics ??= value;
}; break;
case 'TRACKNUMBER': {
const parts = value.split('/');
const trackNum = Number.parseInt(parts[0]!, 10);
const trackNumMax = parts[1] && Number.parseInt(parts[1], 10);
if (Number.isInteger(trackNum) && trackNum > 0) {
this.metadata.trackNumber ??= trackNum;
}
if (trackNumMax && Number.isInteger(trackNumMax) && trackNumMax > 0) {
this.metadata.trackNumberMax ??= trackNumMax;
}
}; break;
case 'TRACKTOTAL': {
const trackNumMax = Number.parseInt(value, 10);
if (Number.isInteger(trackNumMax) && trackNumMax > 0) {
this.metadata.trackNumberMax ??= trackNumMax;
}
}; break;
case 'DISCNUMBER': {
const parts = value.split('/');
const discNum = Number.parseInt(parts[0]!, 10);
const discNumMax = parts[1] && Number.parseInt(parts[1], 10);
if (Number.isInteger(discNum) && discNum > 0) {
this.metadata.discNumber ??= discNum;
}
if (discNumMax && Number.isInteger(discNumMax) && discNumMax > 0) {
this.metadata.discNumberMax ??= discNumMax;
}
}; break;
case 'DISCTOTAL': {
const trackNumMax = Number.parseInt(value, 10);
if (Number.isInteger(trackNumMax) && trackNumMax > 0) {
this.metadata.trackNumberMax ??= trackNumMax;
}
}; break;
case 'DATE': {
const date = new Date(value);
if (!Number.isNaN(date.getTime())) {
this.metadata.date ??= date;
}
}; break;
case 'GENRE': {
this.metadata.genre ??= value;
}; break;
case 'METADATA_BLOCK_PICTURE': {
// https://datatracker.ietf.org/doc/rfc9639/ Section 8.8
const decoded = base64ToBytes(value);
const view = toDataView(decoded);
const pictureType = view.getUint32(0, false);
const mediaTypeLength = view.getUint32(4, false);
const mediaType = String.fromCharCode(...decoded.subarray(8, 8 + mediaTypeLength)); // ASCII
const descriptionLength = view.getUint32(8 + mediaTypeLength, false);
const description = textDecoder.decode(decoded.subarray(
12 + mediaTypeLength,
12 + mediaTypeLength + descriptionLength,
));
const dataLength = view.getUint32(mediaTypeLength + descriptionLength + 28);
const data = decoded.subarray(
mediaTypeLength + descriptionLength + 32,
mediaTypeLength + descriptionLength + 32 + dataLength,
);
this.metadata.images ??= [];
this.metadata.images.push({
data,
mimeType: mediaType,
kind: pictureType === 3 ? 'coverFront' : pictureType === 4 ? 'coverBack' : 'unknown',
name: undefined,
description: description || undefined,
});
}; break;
}
}
}
async readPacket(startPage: Page, startSegmentIndex: number): Promise<Packet | null> {
@@ -388,6 +550,11 @@ export class OggDemuxer extends Demuxer {
const trackDurations = await Promise.all(tracks.map(x => x.computeDuration()));
return Math.max(0, ...trackDurations);
}
async getMetadata() {
await this.readMetadata();
return this.metadata;
}
}
type EncodedPacketMetadata = {
+201 -9
View File
@@ -8,9 +8,20 @@
import { OPUS_INTERNAL_SAMPLE_RATE, validateAudioChunkMetadata } from '../codec';
import { parseModesFromVorbisSetupPacket, parseOpusIdentificationHeader } from '../codec-data';
import { assert, promiseWithResolvers, setInt64, toDataView, toUint8Array } from '../misc';
import {
assert,
assertNever,
bytesToBase64,
keyValueIterator,
promiseWithResolvers,
setInt64,
textEncoder,
toDataView,
toUint8Array,
} from '../misc';
import { Muxer } from '../muxer';
import { Output, OutputAudioTrack } from '../output';
import { Output,
OutputAudioTrack } from '../output';
import { OggOutputFormat } from '../output-format';
import { EncodedPacket } from '../packet';
import { Writer } from '../writer';
@@ -176,9 +187,20 @@ export class OggMuxer extends Muxer {
}
const identificationHeader = bytes.subarray(pos, pos += identificationHeaderLength);
const commentHeader = bytes.subarray(pos, pos += commentHeaderLength);
pos += commentHeaderLength; // Skip the comment header, we'll build our own
const setupHeader = bytes.subarray(pos);
const commentHeaderHeader = new Uint8Array(7);
commentHeaderHeader[0] = 3; // Packet type
commentHeaderHeader[1] = 0x76; // 'v'
commentHeaderHeader[2] = 0x6f; // 'o'
commentHeaderHeader[3] = 0x72; // 'r'
commentHeaderHeader[4] = 0x62; // 'b'
commentHeaderHeader[5] = 0x69; // 'i'
commentHeaderHeader[6] = 0x73; // 's'
const commentHeader = this.createVorbisComments(commentHeaderHeader);
trackData.packetQueue.push({
data: identificationHeader,
endGranulePosition: 0,
@@ -213,12 +235,11 @@ export class OggMuxer extends Muxer {
const identificationHeader = toUint8Array(meta.decoderConfig.description);
const commentHeader = new Uint8Array(8 + 4 + 4);
const view = new DataView(commentHeader.buffer);
view.setUint32(0, 0x4f707573, false); // 'Opus'
view.setUint32(4, 0x54616773, false); // 'Tags'
view.setUint32(8, 0, true); // Vendor String Length
view.setUint32(12, 0, true); // User Comment List Length
const commentHeaderHeader = new Uint8Array(8);
const commentHeaderHeaderView = toDataView(commentHeaderHeader);
commentHeaderHeaderView.setUint32(0, 0x4f707573, false); // 'Opus'
commentHeaderHeaderView.setUint32(4, 0x54616773, false); // 'Tags'
const commentHeader = this.createVorbisComments(commentHeaderHeader);
trackData.packetQueue.push({
data: identificationHeader,
@@ -238,6 +259,177 @@ export class OggMuxer extends Muxer {
}
}
createVorbisComments(headerBytes: Uint8Array) {
// https://datatracker.ietf.org/doc/html/rfc7845#section-5.2
const metadata = this.output._metadata;
const commentHeaderParts = [
headerBytes,
];
let vendorString = '';
if (typeof metadata.raw?.['vendor'] === 'string') {
vendorString = metadata.raw?.['vendor'];
}
const encodedVendorString = textEncoder.encode(vendorString);
let currentBuffer = new Uint8Array(4 + encodedVendorString.length);
let currentView = new DataView(currentBuffer.buffer);
currentView.setUint32(0, encodedVendorString.length, true);
currentBuffer.set(encodedVendorString, 4);
commentHeaderParts.push(currentBuffer);
const writtenTags = new Set<string>();
const addCommentTag = (key: string, value: string) => {
const joined = `${key}=${value}`;
const encoded = textEncoder.encode(joined);
currentBuffer = new Uint8Array(4 + encoded.length);
currentView = new DataView(currentBuffer.buffer);
currentView.setUint32(0, encoded.length, true);
currentBuffer.set(encoded, 4);
commentHeaderParts.push(currentBuffer);
writtenTags.add(key);
};
for (const { key, value } of keyValueIterator(metadata)) {
switch (key) {
case 'title': {
addCommentTag('TITLE', value);
}; break;
case 'description': {
addCommentTag('DESCRIPTION', value);
}; break;
case 'artist': {
addCommentTag('ARTIST', value);
}; break;
case 'album': {
addCommentTag('ALBUM', value);
}; break;
case 'albumArtist': {
addCommentTag('ALBUMARTIST', value);
}; break;
case 'genre': {
addCommentTag('GENRE', value);
}; break;
case 'date': {
addCommentTag('DATE', value.toISOString().slice(0, 10));
}; break;
case 'comment': {
addCommentTag('COMMENT', value);
}; break;
case 'lyrics': {
addCommentTag('LYRICS', value);
}; break;
case 'discNumber': {
addCommentTag('TRACKNUMBER', value.toString());
}; break;
case 'discNumberMax': {
addCommentTag('TRACKTOTAL', value.toString());
}; break;
case 'trackNumber': {
addCommentTag('DISCNUMBER', value.toString());
}; break;
case 'trackNumberMax': {
addCommentTag('DISCTOTAL', value.toString());
}; break;
case 'images': {
for (const image of value) {
// https://datatracker.ietf.org/doc/rfc9639/ Section 8.8
const pictureType = image.kind === 'coverFront' ? 3 : image.kind === 'coverBack' ? 4 : 0;
const encodedMediaType = new Uint8Array(image.mimeType.length);
for (let i = 0; i < image.mimeType.length; i++) {
encodedMediaType[i] = image.mimeType.charCodeAt(i);
}
const encodedDescription = textEncoder.encode(image.description ?? '');
const buffer = new Uint8Array(
4 // Picture type
+ 4 // MIME type length
+ encodedMediaType.length // MIME type
+ 4 // Description length
+ encodedDescription.length // Description
+ 16 // Width, height, color depth, number of colors
+ 4 // Picture data length
+ image.data.length, // Picture data
);
const view = toDataView(buffer);
view.setUint32(0, pictureType, false);
view.setUint32(4, encodedMediaType.length, false);
buffer.set(encodedMediaType, 8);
view.setUint32(8 + encodedMediaType.length, encodedDescription.length, false);
buffer.set(encodedDescription, 12 + encodedMediaType.length);
// Skip a bunch of fields (width, height, color depth, number of colors)
view.setUint32(
28 + encodedMediaType.length + encodedDescription.length, image.data.length, false,
);
buffer.set(
image.data,
32 + encodedMediaType.length + encodedDescription.length,
);
const encoded = bytesToBase64(buffer);
addCommentTag('METADATA_BLOCK_PICTURE', encoded);
}
}; break;
case 'raw': {
// Handled later
}; break;
default: assertNever(key);
}
}
if (metadata.raw) {
for (const key in metadata.raw) {
const value = metadata.raw[key];
if (key === 'vendor' || value == null || writtenTags.has(key)) {
continue;
}
if (typeof value === 'string') {
addCommentTag(key, value);
}
}
}
const listLengthBuffer = new Uint8Array(4);
toDataView(listLengthBuffer).setUint32(0, writtenTags.size, true);
commentHeaderParts.splice(2, 0, listLengthBuffer); // Insert after the header and vendor section
// Merge all comment header parts into a single buffer
const commentHeaderLength = commentHeaderParts.reduce((a, b) => a + b.length, 0);
const commentHeader = new Uint8Array(commentHeaderLength);
let pos = 0;
for (const part of commentHeaderParts) {
commentHeader.set(part, pos);
pos += part.length;
}
return commentHeader;
}
async addEncodedAudioPacket(track: OutputAudioTrack, packet: EncodedPacket, meta?: EncodedAudioChunkMetadata) {
const release = await this.mutex.acquire();