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
+14 -8
View File
@@ -3,10 +3,12 @@
<script src="../dist/bundles/mediabunny.cjs"></script>
<script src="../packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js"></script>
<script src="../packages/ac3/dist/bundles/mediabunny-ac3.js"></script>
<script src="../packages/flac-encoder/dist/bundles/mediabunny-flac-encoder.js"></script>
<script type="module">
//MediabunnyMp3Encoder.registerMp3Encoder();
MediabunnyAc3.registerAc3Decoder();
MediabunnyFlacEncoder.registerFlacEncoder();
const fileInput = document.createElement('input');
fileInput.type = 'file';
@@ -23,7 +25,7 @@
chunked: true,
chunkSize: 2**20
});
const outputFormat = new Mediabunny.WavOutputFormat();
const outputFormat = new Mediabunny.FlacOutputFormat();
const p = document.createElement('p');
p.textContent = 'Capturing...';
@@ -57,7 +59,7 @@
const tracks = [];
let start = 0;
if (true) {
if (false) {
input = new Mediabunny.Input({
source: new Mediabunny.UrlSource('http://localhost:8000/index.m3u8'),
formats: Mediabunny.ALL_FORMATS,
@@ -85,15 +87,18 @@
});
}
const primaryTrack = await input.getPrimaryAudioTrack();
const startTime = await primaryTrack.getFirstTimestamp();
console.log(startTime)
//const primaryTrack = await input.getPrimaryAudioTrack();
//const startTime = await primaryTrack.getFirstTimestamp();
//console.log(startTime)
let ctx = null;
let conversion = await Mediabunny.Conversion.init({
input,
output,
audio: (track) => ({ discard: track.number !== primaryTrack.number }),
audio: {
//forceTranscode: true,
//sampleFormat: 's16',
},
/*
video: {
discard: true,
@@ -139,11 +144,12 @@
}
},
trim: {
start: startTime,
end: startTime + 2,
//start: startTime,
//end: startTime + 2,
},
});
//console.log(conversion);
console.log(conversion.discardedTracks);
let progress = 0;
conversion.onProgress = newProgress => progress = newProgress;
+1
View File
@@ -242,6 +242,7 @@ type ConversionAudioOptions = {
bitrate?: number | Quality;
numberOfChannels?: number;
sampleRate?: number;
sampleFormat?: 'u8' | 's16' | 's32' | 'f32';
forceTranscode?: boolean;
process?: (sample: AudioSample) => MaybePromise<
AudioSample | AudioSample[] | null
+1
View File
@@ -32,6 +32,7 @@ export default tseslint.config(
'@typescript-eslint/no-unsafe-enum-comparison': 'off',
'@typescript-eslint/no-unsafe-unary-minus': 'off',
'@typescript-eslint/no-deprecated': 'error',
'@typescript-eslint/consistent-type-exports': 'error',
},
},
{
Binary file not shown.
+9 -16
View File
@@ -12,7 +12,6 @@
#include <stdlib.h>
#include <string.h>
#define BITS_PER_SAMPLE 16
#define COMPRESSION_LEVEL 5
typedef struct {
@@ -23,14 +22,10 @@ typedef struct {
typedef struct {
FLAC__StreamEncoder *encoder;
// Input buffer for interleaved int16 samples from JS
int16_t *input_buffer;
// Input buffer for interleaved int32 samples from JS
FLAC__int32 *input_buffer;
int input_buffer_size;
// Widened to int32 for libFLAC
FLAC__int32 *int32_buffer;
int int32_buffer_size;
// Contiguous output buffer for encoded frame data
uint8_t *output_buffer;
int output_size;
@@ -48,6 +43,7 @@ typedef struct {
bool header_done;
int channels;
int bits_per_sample;
} EncoderContext;
static void ensure_output_capacity(EncoderContext *ctx, int needed) {
@@ -120,13 +116,14 @@ static void reset_output(EncoderContext *ctx) {
}
EMSCRIPTEN_KEEPALIVE
int init_encoder(int channels, int sample_rate) {
int init_encoder(int channels, int sample_rate, int bits_per_sample) {
EncoderContext *ctx = calloc(1, sizeof(EncoderContext));
if (!ctx) {
return 0;
}
ctx->channels = channels;
ctx->bits_per_sample = bits_per_sample;
ctx->encoder = FLAC__stream_encoder_new();
if (!ctx->encoder) {
@@ -136,7 +133,7 @@ int init_encoder(int channels, int sample_rate) {
FLAC__stream_encoder_set_channels(ctx->encoder, channels);
FLAC__stream_encoder_set_sample_rate(ctx->encoder, sample_rate);
FLAC__stream_encoder_set_bits_per_sample(ctx->encoder, BITS_PER_SAMPLE);
FLAC__stream_encoder_set_bits_per_sample(ctx->encoder, bits_per_sample);
FLAC__stream_encoder_set_compression_level(ctx->encoder, COMPRESSION_LEVEL);
FLAC__stream_encoder_set_verify(ctx->encoder, false);
@@ -174,19 +171,15 @@ EMSCRIPTEN_KEEPALIVE
int send_samples(int ctx_ptr, int num_samples) {
EncoderContext *ctx = (EncoderContext *)ctx_ptr;
// Widen int16 to int32 for libFLAC
int total = num_samples * ctx->channels;
if (total > ctx->int32_buffer_size) {
ctx->int32_buffer = realloc(ctx->int32_buffer, total * sizeof(FLAC__int32));
ctx->int32_buffer_size = total;
}
int shift = 32 - ctx->bits_per_sample;
for (int i = 0; i < total; i++) {
ctx->int32_buffer[i] = ctx->input_buffer[i];
ctx->input_buffer[i] >>= shift;
}
reset_output(ctx);
FLAC__bool ok = FLAC__stream_encoder_process_interleaved(ctx->encoder, ctx->int32_buffer, num_samples);
FLAC__bool ok = FLAC__stream_encoder_process_interleaved(ctx->encoder, ctx->input_buffer, num_samples);
return ok ? 0 : -1;
}
+5 -4
View File
@@ -16,7 +16,7 @@ type ExtendedEmscriptenModule = EmscriptenModule & {
let module: ExtendedEmscriptenModule;
let modulePromise: Promise<ExtendedEmscriptenModule> | null = null;
let initEncoderFn: (channels: number, sampleRate: number) => number;
let initEncoderFn: (channels: number, sampleRate: number, bitsPerSample: number) => number;
let getEncodeInputPtr: (ctx: number, size: number) => number;
let sendSamplesFn: (ctx: number, numSamples: number) => number;
let getOutputData: (ctx: number) => number;
@@ -37,7 +37,7 @@ const ensureModule = async () => {
module = await modulePromise;
modulePromise = null;
initEncoderFn = module.cwrap('init_encoder', 'number', ['number', 'number']);
initEncoderFn = module.cwrap('init_encoder', 'number', ['number', 'number', 'number']);
getEncodeInputPtr = module.cwrap('get_encode_input_ptr', 'number', ['number', 'number']);
sendSamplesFn = module.cwrap('send_samples', 'number', ['number', 'number']);
getOutputData = module.cwrap('get_output_data', 'number', ['number']);
@@ -50,10 +50,10 @@ const ensureModule = async () => {
}
};
const initEncoder = async (numberOfChannels: number, sampleRate: number) => {
const initEncoder = async (numberOfChannels: number, sampleRate: number, bitsPerSample: 16 | 24) => {
await ensureModule();
const ctx = initEncoderFn(numberOfChannels, sampleRate);
const ctx = initEncoderFn(numberOfChannels, sampleRate, bitsPerSample);
if (ctx === 0) {
throw new Error('Failed to initialize FLAC encoder.');
}
@@ -121,6 +121,7 @@ const onMessage = (data: { id: number; command: WorkerCommand }) => {
const { ctx, header } = await initEncoder(
command.data.numberOfChannels,
command.data.sampleRate,
command.data.bitsPerSample,
);
result = { type: command.type, ctx, header };
transferables.push(header);
+49 -18
View File
@@ -29,7 +29,7 @@ class FlacEncoder extends CustomAudioEncoder {
reject: (reason?: unknown) => void;
}>();
private ctx = 0;
private ctx: number | null = null;
private chunkMetadata: EncodedAudioChunkMetadata = {};
private description: Uint8Array | null = null;
private nextTimestampInSamples: number | null = null;
@@ -65,19 +65,6 @@ class FlacEncoder extends CustomAudioEncoder {
};
nodeWorker.on('message', onMessage);
}
const result = await this.sendCommand({
type: 'init',
data: {
numberOfChannels: this.config.numberOfChannels,
sampleRate: this.config.sampleRate,
},
});
this.ctx = result.ctx;
this.description = new Uint8Array(result.header);
this.resetInternalState();
}
private resetInternalState() {
@@ -94,15 +81,50 @@ class FlacEncoder extends CustomAudioEncoder {
}
async encode(audioSample: AudioSample) {
if (this.ctx === null) {
// This is the first sample, let's do some init
let bitsPerSample: 16 | 24;
switch (audioSample.format) {
case 'u8':
case 'u8-planar':
case 's16':
case 's16-planar':
bitsPerSample = 16;
break;
case 's32':
case 's32-planar':
case 'f32':
case 'f32-planar':
bitsPerSample = 24;
break;
default:
assertNever(audioSample.format);
assert(false);
}
const result = await this.sendCommand({
type: 'init',
data: {
numberOfChannels: this.config.numberOfChannels,
sampleRate: this.config.sampleRate,
bitsPerSample,
},
});
this.ctx = result.ctx;
this.description = new Uint8Array(result.header);
this.resetInternalState();
}
if (this.nextTimestampInSamples === null) {
this.nextTimestampInSamples = Math.round(audioSample.timestamp * this.config.sampleRate);
}
const totalBytes = audioSample.allocationSize({ format: 's16', planeIndex: 0 });
const audioBytes = new Uint8Array(totalBytes);
audioSample.copyTo(audioBytes, { format: 's16', planeIndex: 0 });
const totalBytes = audioSample.allocationSize({ format: 's32', planeIndex: 0 });
const audioData = new ArrayBuffer(totalBytes);
audioSample.copyTo(audioData, { format: 's32', planeIndex: 0 });
const audioData = audioBytes.buffer;
const result = await this.sendCommand({
type: 'encode',
data: {
@@ -116,6 +138,10 @@ class FlacEncoder extends CustomAudioEncoder {
}
async flush() {
if (this.ctx === null) {
return;
}
const result = await this.sendCommand({ type: 'flush', data: { ctx: this.ctx } });
this.emitPackets(result.packets);
@@ -198,3 +224,8 @@ function assert(x: unknown): asserts x {
throw new Error('Assertion failed.');
}
}
export const assertNever = (x: never) => {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unexpected value: ${x}`);
};
+1
View File
@@ -16,6 +16,7 @@ export type WorkerCommand = {
data: {
numberOfChannels: number;
sampleRate: number;
bitsPerSample: 16 | 24;
};
} | {
type: 'encode';
+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,
});
};
+107
View File
@@ -0,0 +1,107 @@
import { expect, test } from 'vitest';
import { Input } from '../../src/input.js';
import { ALL_FORMATS } from '../../src/input-format.js';
import { AudioSampleSource } from '../../src/media-source.js';
import { AudioSampleSink } from '../../src/media-sink.js';
import { assert } from '../../src/misc.js';
import { Output } from '../../src/output.js';
import { FlacOutputFormat } from '../../src/output-format.js';
import { AudioSample } from '../../src/sample.js';
import { BufferSource } from '../../src/source.js';
import { BufferTarget } from '../../src/target.js';
import { registerFlacEncoder } from '@mediabunny/flac-encoder';
test('FLAC encoder, 24-bit', async () => {
registerFlacEncoder();
const sampleRate = 48000;
const channels = 2;
const durationSeconds = 2;
const data = createF32SineWave(sampleRate, channels, durationSeconds);
using sample = await encodeAndDecodeFirstSample(new AudioSample({
data,
format: 'f32',
numberOfChannels: channels,
sampleRate,
timestamp: 0,
}));
expect(sample.format).toBe('s32');
});
test('FLAC encoder, 16-bit', async () => {
registerFlacEncoder();
const sampleRate = 48000;
const channels = 2;
const durationSeconds = 2;
const data = createS16SineWave(sampleRate, channels, durationSeconds);
using sample = await encodeAndDecodeFirstSample(new AudioSample({
data,
format: 's16',
numberOfChannels: channels,
sampleRate,
timestamp: 0,
}));
expect(sample.format).toBe('s16');
});
const createF32SineWave = (sampleRate: number, channels: number, durationSeconds: number) => {
const totalFrames = sampleRate * durationSeconds;
const data = new Float32Array(totalFrames * channels);
for (let i = 0; i < totalFrames; i++) {
const value = Math.sin(2 * Math.PI * 440 * i / sampleRate);
for (let ch = 0; ch < channels; ch++) {
data[i * channels + ch] = value;
}
}
return data;
};
const createS16SineWave = (sampleRate: number, channels: number, durationSeconds: number) => {
const totalFrames = sampleRate * durationSeconds;
const data = new Int16Array(totalFrames * channels);
for (let i = 0; i < totalFrames; i++) {
const value = Math.round(Math.sin(2 * Math.PI * 440 * i / sampleRate) * 32767);
for (let ch = 0; ch < channels; ch++) {
data[i * channels + ch] = value;
}
}
return data;
};
const encodeAndDecodeFirstSample = async (audioSample: AudioSample) => {
const output = new Output({
format: new FlacOutputFormat(),
target: new BufferTarget(),
});
const audioSource = new AudioSampleSource({ codec: 'flac' });
output.addAudioTrack(audioSource);
await output.start();
await audioSource.add(audioSample);
audioSource.close();
await output.finalize();
using input = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
const track = await input.getPrimaryAudioTrack();
assert(track);
const sink = new AudioSampleSink(track);
const sample = await sink.getSample(0);
assert(sample);
return sample;
};
+2 -2
View File
@@ -47,7 +47,7 @@ test('FLAC encoding', async () => {
target: new BufferTarget(),
});
const audioSource = new AudioSampleSource({ codec: 'flac', bitrate: 1 });
const audioSource = new AudioSampleSource({ codec: 'flac' });
output.addAudioTrack(audioSource);
await output.start();
@@ -97,7 +97,7 @@ test('FLAC with huge timestamps', async () => {
target: new BufferTarget(),
});
const audioSource = new AudioSampleSource({ codec: 'flac', bitrate: 1 });
const audioSource = new AudioSampleSource({ codec: 'flac' });
output.addAudioTrack(audioSource);
await output.start();