Make FLAC encoder bit depth depend on input sample format, add sampleFormat audio transformation option, fix incorrect bitrate validation for FLAC (closes #357)

This commit is contained in:
Vanilagy
2026-04-29 16:27:20 +02:00
parent 2d49122277
commit f3dec587fd
15 changed files with 384 additions and 143 deletions
+45 -11
View File
@@ -50,7 +50,15 @@ import {
} from './misc';
import { Output, OutputTrackGroup, TrackType } from './output';
import { Mp4OutputFormat } from './output-format';
import { AudioSample, clampCropRectangle, CropRectangle, validateCropRectangle, VideoSample } from './sample';
import {
AudioSample,
audioSampleToInterleavedFormat,
clampCropRectangle,
CropRectangle,
toInterleavedAudioFormat,
validateCropRectangle,
VideoSample,
} from './sample';
import { MetadataTags, validateMetadataTags } from './metadata';
import { NullTarget } from './target';
import { AudioResampler } from './resample';
@@ -261,6 +269,13 @@ export type ConversionAudioOptions = {
numberOfChannels?: number;
/** The desired sample rate of the output audio, in hertz. */
sampleRate?: number;
/**
* The desired sample format (and therefore bit depth) of the audio samples before they are passed to the encoder.
* Can be used to control bit depth with certain output codecs such as FLAC.
*
* Setting this field forces audio transcoding.
*/
sampleFormat?: 'u8' | 's16' | 's32' | 'f32';
/** The desired output audio codec. */
codec?: AudioCodec;
/** The desired bitrate of the output audio. */
@@ -442,6 +457,12 @@ const validateAudioOptions = (audioOptions: ConversionAudioOptions) => {
) {
throw new TypeError('options.audio.sampleRate, when provided, must be a positive integer.');
}
if (
audioOptions?.sampleFormat !== undefined
&& !['u8', 's16', 's32', 'f32'].includes(audioOptions.sampleFormat)
) {
throw new TypeError('options.audio.sampleFormat, when provided, must be one of: u8, s16, s32, f32.');
}
if (audioOptions?.process !== undefined && typeof audioOptions.process !== 'function') {
throw new TypeError('options.audio.process, when provided, must be a function.');
}
@@ -1347,7 +1368,7 @@ export class Conversion {
timestamp: lastCanvasTimestamp! + i / frameRate,
duration: 1 / frameRate,
});
await this._registerVideoSample(track, trackOptions, outputTrackId, source, sample);
await this._registerVideoSample(trackOptions, outputTrackId, source, sample);
sample.close();
}
};
@@ -1384,7 +1405,7 @@ export class Conversion {
timestamp: adjustedSampleTimestamp,
duration: frameRate !== undefined ? 1 / frameRate : duration,
});
await this._registerVideoSample(track, trackOptions, outputTrackId, source, sample);
await this._registerVideoSample(trackOptions, outputTrackId, source, sample);
sample.close();
if (frameRate !== undefined) {
@@ -1425,7 +1446,7 @@ export class Conversion {
for (let i = 1; i < frameDifference; i++) {
lastSample.setTimestamp(lastSampleTimestamp! + i / frameRate);
lastSample.setDuration(1 / frameRate);
await this._registerVideoSample(track, trackOptions, outputTrackId, source, lastSample);
await this._registerVideoSample(trackOptions, outputTrackId, source, lastSample);
}
lastSample.close();
@@ -1464,7 +1485,7 @@ export class Conversion {
}
sample.setTimestamp(adjustedSampleTimestamp);
await this._registerVideoSample(track, trackOptions, outputTrackId, source, sample);
await this._registerVideoSample(trackOptions, outputTrackId, source, sample);
if (frameRate !== undefined) {
lastSample = sample;
@@ -1513,7 +1534,6 @@ export class Conversion {
/** @internal */
async _registerVideoSample(
track: InputVideoTrack,
trackOptions: ConversionVideoOptions,
outputTrackId: number,
source: VideoSampleSource,
@@ -1609,6 +1629,7 @@ export class Conversion {
&& audioCodecs.includes(sourceCodec)
&& (!trackOptions.codec || trackOptions.codec === sourceCodec)
&& !trackOptions.process
&& trackOptions.sampleFormat === undefined
) {
// Fast path, we can simply copy over the encoded packets
@@ -1745,7 +1766,7 @@ export class Conversion {
// Offset the timestamp as needed
sample.setTimestamp(sample.timestamp - this._startTimestamp);
await this._registerAudioSample(track, trackOptions, outputTrackId, source, sample);
await this._registerAudioSample(trackOptions, outputTrackId, source, sample);
sample.close();
}
@@ -1778,16 +1799,25 @@ export class Conversion {
/** @internal */
async _registerAudioSample(
track: InputAudioTrack,
trackOptions: ConversionAudioOptions,
outputTrackId: number,
source: AudioSampleSource,
sample: AudioSample,
inputSample: AudioSample,
) {
if (this._canceled) {
return;
}
let sample = inputSample;
if (
trackOptions.sampleFormat !== undefined
&& toInterleavedAudioFormat(sample.format) !== trackOptions.sampleFormat
) {
// Do a sample format conversion
sample = audioSampleToInterleavedFormat(sample, trackOptions.sampleFormat);
}
this._reportProgress(outputTrackId, sample.timestamp + sample.duration);
let finalSamples: AudioSample[];
@@ -1823,8 +1853,12 @@ export class Conversion {
}
}
} finally {
if (sample !== inputSample) {
sample.close();
}
for (const finalSample of finalSamples) {
if (finalSample !== sample) {
if (finalSample !== inputSample) {
finalSample.close();
}
}
@@ -1857,7 +1891,7 @@ export class Conversion {
onSample: async (sample) => {
sample.setTimestamp(sample.timestamp - this._startTimestamp);
await this._registerAudioSample(track, trackOptions, outputTrackId, source, sample);
await this._registerAudioSample(trackOptions, outputTrackId, source, sample);
sample.close();
},
});
+12 -1
View File
@@ -386,6 +386,11 @@ export type AudioTransformOptions = {
numberOfChannels?: number;
/** The desired output sample rate in hertz to resample to. */
sampleRate?: number;
/**
* The desired sample format (and therefore bit depth) of the audio samples before they are passed to the encoder.
* Can be used to control bit depth with certain output codecs such as FLAC.
*/
sampleFormat?: 'u8' | 's16' | 's32' | 'f32';
/**
* Allows for custom user-defined processing of audio samples, e.g. for applying audio effects or timestamp
* modifications. Called for each audio sample after resampling and remixing.
@@ -406,7 +411,7 @@ export const validateAudioEncodingConfig = (config: AudioEncodingConfig) => {
}
if (
config.bitrate === undefined
&& (!(PCM_AUDIO_CODECS as readonly string[]).includes(config.codec) || config.codec === 'flac')
&& !((PCM_AUDIO_CODECS as readonly string[]).includes(config.codec) || config.codec === 'flac')
) {
throw new TypeError('config.bitrate must be provided for compressed audio codecs.');
}
@@ -433,6 +438,12 @@ export const validateAudioEncodingConfig = (config: AudioEncodingConfig) => {
) {
throw new TypeError('config.transform.sampleRate, when provided, must be a positive integer.');
}
if (
config.transform.sampleFormat !== undefined
&& !['u8', 's16', 's32', 'f32'].includes(config.transform.sampleFormat)
) {
throw new TypeError('config.transform.sampleFormat, when provided, must be one of: u8, s16, s32, f32.');
}
if (config.transform.process !== undefined && typeof config.transform.process !== 'function') {
throw new TypeError('config.transform.process, when provided, must be a function.');
}
+82 -82
View File
@@ -22,48 +22,48 @@ if ((globalThis as Record<symbol, unknown>)[MEDIABUNNY_LOADED_SYMBOL]) {
export {
Output,
OutputOptions,
type OutputOptions,
OutputTrack,
OutputVideoTrack,
OutputAudioTrack,
OutputSubtitleTrack,
OutputTrackGroup,
BaseTrackMetadata,
VideoTrackMetadata,
AudioTrackMetadata,
SubtitleTrackMetadata,
OutputEvents,
type BaseTrackMetadata,
type VideoTrackMetadata,
type AudioTrackMetadata,
type SubtitleTrackMetadata,
type OutputEvents,
} from './output';
export {
OutputFormat,
AdtsOutputFormat,
AdtsOutputFormatOptions,
type AdtsOutputFormatOptions,
CmafOutputFormat,
CmafOutputFormatOptions,
type CmafOutputFormatOptions,
FlacOutputFormat,
FlacOutputFormatOptions,
type FlacOutputFormatOptions,
HlsOutputFormat,
HlsOutputFormatOptions,
HlsOutputPlaylistInfo,
HlsOutputSegmentInfo,
type HlsOutputFormatOptions,
type HlsOutputPlaylistInfo,
type HlsOutputSegmentInfo,
IsobmffOutputFormat,
IsobmffOutputFormatOptions,
type IsobmffOutputFormatOptions,
MkvOutputFormat,
MkvOutputFormatOptions,
type MkvOutputFormatOptions,
MovOutputFormat,
Mp3OutputFormat,
Mp3OutputFormatOptions,
type Mp3OutputFormatOptions,
Mp4OutputFormat,
MpegTsOutputFormat,
MpegTsOutputFormatOptions,
type MpegTsOutputFormatOptions,
OggOutputFormat,
OggOutputFormatOptions,
type OggOutputFormatOptions,
WavOutputFormat,
WavOutputFormatOptions,
type WavOutputFormatOptions,
WebMOutputFormat,
WebMOutputFormatOptions,
InclusiveIntegerRange,
TrackCountLimits,
type WebMOutputFormatOptions,
type InclusiveIntegerRange,
type TrackCountLimits,
} from './output-format';
export {
MediaSource,
@@ -76,17 +76,17 @@ export {
EncodedAudioPacketSource,
EncodedVideoPacketSource,
MediaStreamAudioTrackSource,
MediaStreamAudioTrackSourceOptions,
type MediaStreamAudioTrackSourceOptions,
MediaStreamVideoTrackSource,
MediaStreamVideoTrackSourceOptions,
type MediaStreamVideoTrackSourceOptions,
TextSubtitleSource,
VideoSampleSource,
} from './media-source';
export {
MediaCodec,
VideoCodec,
AudioCodec,
SubtitleCodec,
type MediaCodec,
type VideoCodec,
type AudioCodec,
type SubtitleCodec,
VIDEO_CODECS,
AUDIO_CODECS,
PCM_AUDIO_CODECS,
@@ -102,12 +102,12 @@ export {
getDecodableAudioCodecs,
} from './decode';
export {
VideoEncodingConfig,
VideoEncodingAdditionalOptions,
VideoTransformOptions,
AudioEncodingConfig,
AudioEncodingAdditionalOptions,
AudioTransformOptions,
type VideoEncodingConfig,
type VideoEncodingAdditionalOptions,
type VideoTransformOptions,
type AudioEncodingConfig,
type AudioEncodingAdditionalOptions,
type AudioTransformOptions,
canEncode,
canEncodeVideo,
canEncodeAudio,
@@ -128,69 +128,69 @@ export {
} from './encode';
export {
Target,
TargetEvents,
TargetRequest,
type TargetEvents,
type TargetRequest,
AppendOnlyStreamTarget,
BufferTarget,
BufferTargetOptions,
type BufferTargetOptions,
FilePathTarget,
FilePathTargetOptions,
type FilePathTargetOptions,
NullTarget,
PathedTarget,
RangedTarget,
StreamTarget,
StreamTargetOptions,
StreamTargetChunk,
type StreamTargetOptions,
type StreamTargetChunk,
} from './target';
export {
AnyIterable,
type AnyIterable,
ConcurrentRunner,
EventEmitter,
EventListenerOptions,
FilePath,
MaybePromise,
type EventListenerOptions,
type FilePath,
type MaybePromise,
} from './misc';
export {
PsshBox,
type PsshBox,
} from './isobmff/isobmff-misc';
export {
Rational,
Rectangle,
Rotation,
SetOptional,
SetRequired,
type Rational,
type Rectangle,
type Rotation,
type SetOptional,
type SetRequired,
} from './misc';
export {
TrackType,
type TrackType,
ALL_TRACK_TYPES,
} from './output';
export {
Source,
SourceEvents,
type SourceEvents,
SourceRef,
SourceRequest,
type SourceRequest,
BlobSource,
BlobSourceOptions,
type BlobSourceOptions,
BufferSource,
CustomPathedSource,
FilePathSource,
FilePathSourceOptions,
type FilePathSourceOptions,
PathedSource,
StreamSource,
StreamSourceOptions,
type StreamSourceOptions,
RangedSource,
ReadableStreamSource,
ReadableStreamSourceOptions,
type ReadableStreamSourceOptions,
UrlSource,
UrlSourceOptions,
type UrlSourceOptions,
} from './source';
export {
InputFormat,
InputFormatOptions,
type InputFormatOptions,
AdtsInputFormat,
FlacInputFormat,
IsobmffInputFormat,
IsobmffInputFormatOptions,
type IsobmffInputFormatOptions,
HlsInputFormat,
MatroskaInputFormat,
Mp3InputFormat,
@@ -216,38 +216,38 @@ export {
} from './input-format';
export {
Input,
InputOptions,
InputEvents,
type InputOptions,
type InputEvents,
InputDisposedError,
UnsupportedInputFormatError,
} from './input';
export {
DurationMetadataRequestOptions,
type DurationMetadataRequestOptions,
} from './demuxer';
export {
InputTrack,
InputVideoTrack,
InputAudioTrack,
InputTrackQuery,
PacketStats,
type InputTrackQuery,
type PacketStats,
asc,
desc,
prefer,
} from './input-track';
export {
EncodedPacket,
EncodedPacketSideData,
PacketType,
type EncodedPacketSideData,
type PacketType,
} from './packet';
export {
AudioSample,
AudioSampleInit,
AudioSampleCopyToOptions,
type AudioSampleInit,
type AudioSampleCopyToOptions,
VideoSample,
VideoSampleInit,
VideoSamplePixelFormat,
type VideoSampleInit,
type VideoSamplePixelFormat,
VideoSampleColorSpace,
CropRectangle,
type CropRectangle,
VIDEO_SAMPLE_PIXEL_FORMATS,
} from './sample';
export {
@@ -255,20 +255,20 @@ export {
AudioSampleSink,
BaseMediaSampleSink,
CanvasSink,
CanvasSinkOptions,
type CanvasSinkOptions,
EncodedPacketSink,
PacketRetrievalOptions,
type PacketRetrievalOptions,
VideoSampleSink,
WrappedAudioBuffer,
WrappedCanvas,
type WrappedAudioBuffer,
type WrappedCanvas,
} from './media-sink';
export {
Conversion,
ConversionOptions,
ConversionVideoOptions,
ConversionAudioOptions,
type ConversionOptions,
type ConversionVideoOptions,
type ConversionAudioOptions,
ConversionCanceledError,
DiscardedTrack,
type DiscardedTrack,
} from './conversion';
export {
CustomVideoDecoder,
@@ -279,11 +279,11 @@ export {
registerEncoder,
} from './custom-coder';
export {
MetadataTags,
AttachedImage,
type MetadataTags,
type AttachedImage,
RichImageData,
AttachedFile,
TrackDisposition,
type TrackDisposition,
} from './metadata';
// 🐡🦔
+26 -1
View File
@@ -50,7 +50,13 @@ import {
customAudioEncoders,
} from './custom-coder';
import { EncodedPacket, EncodedPacketSideData } from './packet';
import { AudioSample, clampCropRectangle, VideoSample } from './sample';
import {
AudioSample,
audioSampleToInterleavedFormat,
clampCropRectangle,
toInterleavedAudioFormat,
VideoSample,
} from './sample';
import {
AudioEncodingConfig,
buildAudioEncoderConfig,
@@ -1888,6 +1894,21 @@ class AudioEncoderWrapper {
private async processAndEncode(audioSample: AudioSample, shouldClose: boolean) {
const config = this.encodingConfig;
if (
config.transform?.sampleFormat !== undefined
&& toInterleavedAudioFormat(audioSample.format) !== config.transform.sampleFormat
) {
// Do a sample format conversion
const newSample = audioSampleToInterleavedFormat(audioSample, config.transform.sampleFormat);
if (shouldClose) {
audioSample.close();
}
audioSample = newSample;
shouldClose = true;
}
if (config.transform?.process) {
let processed = config.transform.process(audioSample);
if (processed instanceof Promise) {
@@ -1910,6 +1931,10 @@ class AudioEncoderWrapper {
}
await this.encodeSample(sample, true);
}
if (shouldClose) {
audioSample.close();
}
} else {
await this.encodeSample(audioSample, shouldClose);
}
+30
View File
@@ -1953,6 +1953,21 @@ const isAudioData = (x: unknown): x is AudioData => {
return typeof AudioData !== 'undefined' && x instanceof AudioData;
};
export const toInterleavedAudioFormat = (format: AudioSampleFormat): 'u8' | 's16' | 's32' | 'f32' => {
switch (format) {
case 'u8-planar':
return 'u8';
case 's16-planar':
return 's16';
case 's32-planar':
return 's32';
case 'f32-planar':
return 'f32';
default:
return format;
}
};
/**
* WebKit has a bug where calling AudioData.copyTo with a format different from the source format
* crashes the tab when there are more than 2 channels. This function works around that by always
@@ -2061,3 +2076,18 @@ const doAudioDataCopyToWebKitWorkaround = (
}
}
};
export const audioSampleToInterleavedFormat = (sample: AudioSample, format: 'u8' | 's16' | 's32' | 'f32') => {
const size = sample.allocationSize({ format, planeIndex: 0 });
const buffer = new ArrayBuffer(size);
sample.copyTo(buffer, { format, planeIndex: 0 });
return new AudioSample({
data: buffer,
format,
numberOfChannels: sample.numberOfChannels,
sampleRate: sample.sampleRate,
timestamp: sample.timestamp,
duration: sample.duration,
});
};