Compare commits

...
11 Commits
Author SHA1 Message Date
Vanilagy 794b84884f Make sure that samples are always closed in the Conversion API, even on error cases 2026-07-18 22:54:24 +02:00
Vanilagy 45c34f9dcd Bump patch 2026-07-18 16:15:53 +02:00
Vanilagy 3ec4061e03 Clean up 2026-07-18 15:49:57 +02:00
bf592a87ca Give Matroska subtitle cues a BlockDuration (#442)
Subtitle cues were written as SimpleBlocks, which carry no duration. A
SimpleBlock tells the player when a cue starts but not how long to show it,
so players such as VLC and libass-based renderers display nothing for
S_TEXT/WEBVTT tracks muxed into Matroska/WebM.

Route a subtitle chunk that has a positive duration through a BlockGroup so
its BlockDuration is written, matching how additions are already handled.
Non-subtitle tracks and zero-duration cues keep using SimpleBlocks.

Co-authored-by: hikari <[email protected]>
2026-07-18 15:48:55 +02:00
454476ab26 Fix WebVTT-in-MP4 output by starting the aux writer (#441)
* Fix WebVTT-in-MP4 output by starting the aux writer

The ISOBMFF muxer advertises WebVTT as a supported subtitle codec and maps
it to the wvtt sample entry, but IsobmffMuxer.start() never calls
auxWriter.start(). The aux writer builds subtitle sample boxes in memory, so
the first box write for any subtitle track hits its started === false assert
and muxing fails.

Start the aux writer alongside the main writer so WebVTT subtitle tracks can
be written to MP4/MOV.

* Move code around, add simple WebVTT muxing test

---------

Co-authored-by: hikari <[email protected]>
Co-authored-by: Vanilagy <[email protected]>
2026-07-18 13:45:23 +00:00
Vanilagy d2aea552d9 Add spacing 2026-07-18 15:27:06 +02:00
Igor SamokhovetsandGitHub 94d42667f1 Add extension decoders to media player example (#433)
* Add extension decoders to media player example

* Enable extension decoders in input examples

* Simplify media player file filter

* Align input file filters across examples
2026-07-18 15:26:31 +02:00
Vanilagy 0554696505 Fix nested worker randomly shutting off in Firefox (closes #435) 2026-07-18 14:44:57 +02:00
bfbd90e1d8 Account for multi-frame Opus packets when computing packet duration (#439)
* Account for multi-frame Opus packets when computing packet duration

parseOpusTocByte only read the config field of the TOC byte and always
assumed a single frame per packet. Per RFC 6716 section 3.2, a packet may
carry 1, 2 or an arbitrary number of frames, encoded in the two low bits of
the TOC byte (plus the frame count byte for code 3), and its duration is the
frame duration times the frame count.

As a result, the Ogg muxer wrote granule positions that advanced slower than
the actual audio. Chromium's MediaRecorder packs three 20 ms frames per Opus
packet, so remuxing WebM/Opus to Ogg/Opus produced files declaring a third of
their real duration: a 5.7 s recording ended with a final granule position of
99840 (2.08 s). Decoders that trust the container then truncate the audio.

* Clean up

---------

Co-authored-by: Vanilagy <[email protected]>
2026-07-18 12:05:31 +00:00
Vanilagy 1183b542ee Make WAVE demuxer throw for format tags that it doesn't support (fixes #440), add support for reading & writing float64 PCM in WAVE 2026-07-14 17:30:50 +02:00
Vanilagy db75747533 Bump patch 2026-07-10 00:15:25 +02:00
27 changed files with 349 additions and 53 deletions
@@ -9,8 +9,15 @@ import {
Conversion,
QUALITY_VERY_LOW,
} from 'mediabunny';
import { registerAc3Decoder } from '@mediabunny/ac3';
import { registerProresDecoder } from '@mediabunny/prores';
import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4';
// Enable codecs that aren't natively supported by WebCodecs.
registerAc3Decoder();
registerProresDecoder();
(document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl;
const selectMediaButton = document.querySelector('#select-file') as HTMLButtonElement;
@@ -137,7 +144,7 @@ const compressFile = async (resource: File | string) => {
selectMediaButton.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts,audio/*,audio/aac';
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.mkv,.ts,audio/*,audio/aac,.aac';
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) {
+8 -1
View File
@@ -15,8 +15,15 @@ import {
QUALITY_LOW,
QUALITY_VERY_LOW,
} from 'mediabunny';
import { registerAc3Decoder } from '@mediabunny/ac3';
import { registerProresDecoder } from '@mediabunny/prores';
import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4';
// Enable codecs that aren't natively supported by WebCodecs.
registerAc3Decoder();
registerProresDecoder();
(document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl;
declare global {
@@ -233,7 +240,7 @@ selectDirectoryButton.addEventListener('click', async () => {
selectMediaButton.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts';
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.mkv,.ts';
fileInput.addEventListener('change', () => {
const file = fileInput.files![0];
if (file) {
+8 -1
View File
@@ -8,8 +8,15 @@ import {
WrappedAudioBuffer,
WrappedCanvas,
} from 'mediabunny';
import { registerAc3Decoder } from '@mediabunny/ac3';
import { registerProresDecoder } from '@mediabunny/prores';
import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4';
// Enable codecs that aren't natively supported by WebCodecs.
registerAc3Decoder();
registerProresDecoder();
(document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl;
const selectMediaButton = document.querySelector('#select-file') as HTMLButtonElement;
@@ -732,7 +739,7 @@ window.addEventListener('resize', () => {
selectMediaButton.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts,audio/*,audio/aac';
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.mkv,.ts,audio/*,audio/aac,.aac';
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) {
@@ -1,6 +1,13 @@
import { Input, ALL_FORMATS, BlobSource, UrlSource, CanvasSink } from 'mediabunny';
import { registerAc3Decoder } from '@mediabunny/ac3';
import { registerProresDecoder } from '@mediabunny/prores';
import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4';
// Enable codecs that aren't natively supported by WebCodecs.
registerAc3Decoder();
registerProresDecoder();
(document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl;
const selectMediaButton = document.querySelector('#select-file') as HTMLButtonElement;
@@ -122,7 +129,7 @@ const generateThumbnails = async (resource: File | string) => {
selectMediaButton.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts,audio/*,audio/aac';
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.mkv,.ts,audio/*,audio/aac,.aac';
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) {
+73 -13
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.50.7",
"version": "1.50.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.50.7",
"version": "1.50.9",
"license": "MPL-2.0",
"workspaces": [
".",
@@ -2715,6 +2715,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.4.3",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.4.3",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.0.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.9",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.0",
"@emnapi/runtime": "^1.4.0",
"@tybys/wasm-util": "^0.9.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.0",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.7.tgz",
@@ -10963,9 +11023,9 @@
"optional": true
},
"node_modules/turbores": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/turbores/-/turbores-1.2.1.tgz",
"integrity": "sha512-HcnLHiUggT2YXmnTpUdT3JHnkGWvdJlW9XBPLKZBBB3X5oOEuBaHF99cW+H+CSxnhM+rth2qDTCAteIKhKwiGQ==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/turbores/-/turbores-1.2.2.tgz",
"integrity": "sha512-dTSKeZ6/BgBKz9T5goMS5ZMcqOiNYO1MbvresYGfWXEF5ZWK+yIZDxNjh86bRj1mnEzhtdsMOJ5GBi8b3Azcpg==",
"license": "MPL-2.0",
"funding": {
"type": "individual",
@@ -12894,7 +12954,7 @@
},
"packages/aac-encoder": {
"name": "@mediabunny/aac-encoder",
"version": "1.50.7",
"version": "1.50.9",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -12909,7 +12969,7 @@
},
"packages/ac3": {
"name": "@mediabunny/ac3",
"version": "1.50.7",
"version": "1.50.9",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -12924,7 +12984,7 @@
},
"packages/flac-encoder": {
"name": "@mediabunny/flac-encoder",
"version": "1.50.7",
"version": "1.50.9",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -12939,7 +12999,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
"version": "1.50.7",
"version": "1.50.9",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -12954,10 +13014,10 @@
},
"packages/prores": {
"name": "@mediabunny/prores",
"version": "1.50.7",
"version": "1.50.9",
"license": "MPL-2.0",
"dependencies": {
"turbores": "^1.2.1"
"turbores": "^1.2.2"
},
"funding": {
"type": "individual",
@@ -12969,10 +13029,10 @@
},
"packages/server": {
"name": "@mediabunny/server",
"version": "1.50.7",
"version": "1.50.9",
"license": "MPL-2.0",
"dependencies": {
"@mediabunny/prores": "^1.50.7",
"@mediabunny/prores": "^1.50.9",
"node-av": "^6.0.0"
},
"funding": {
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.50.7",
"version": "1.50.9",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
@@ -56,10 +56,10 @@
"examples:build": "vite build",
"fix-build-import-paths": "tsx scripts/add-import-extensions.ts",
"append-namespace": "echo 'export as namespace Mediabunny;' >> dist/mediabunny.d.ts",
"bump-patch": "npm version patch --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i",
"bump-minor": "npm version minor --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i",
"bump-major": "npm version major --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i",
"set-version": "npm version --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i"
"bump-patch": "npm version patch --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i --package-lock-only",
"bump-minor": "npm version minor --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i --package-lock-only",
"bump-major": "npm version major --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i --package-lock-only",
"set-version": "npm version --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts && npm i --package-lock-only"
},
"license": "MPL-2.0",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/aac-encoder",
"author": "Vanilagy",
"version": "1.50.7",
"version": "1.50.9",
"description": "AAC encoder extension for Mediabunny, based on FFmpeg.",
"main": "./dist/bundles/mediabunny-aac-encoder.mjs",
"module": "./dist/bundles/mediabunny-aac-encoder.mjs",
@@ -192,3 +192,7 @@ if (parentPort) {
} else {
self.addEventListener('message', event => onMessage(event.data as { id: number; command: WorkerCommand }));
}
// Prevents the worker for being randomly closed by Firefox
// https://github.com/Vanilagy/mediabunny/issues/435
setInterval(() => {}, 1000);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/ac3",
"author": "Vanilagy",
"version": "1.50.7",
"version": "1.50.9",
"description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on FFmpeg.",
"main": "./dist/bundles/mediabunny-ac3.mjs",
"module": "./dist/bundles/mediabunny-ac3.mjs",
+4
View File
@@ -304,3 +304,7 @@ if (parentPort) {
} else {
self.addEventListener('message', event => onMessage(event.data as { id: number; command: WorkerCommand }));
}
// Prevents the worker for being randomly closed by Firefox
// https://github.com/Vanilagy/mediabunny/issues/435
setInterval(() => {}, 1000);
+1
View File
@@ -46,6 +46,7 @@ export const unrefWorker = async () => {
if (worker) {
if (worker.unref) {
worker.unref(); // If we don't do this, then the Node process never terminates by itself
// Keep the worker around tho
} else if (typeof window === 'undefined') {
// Non-browser environment without unref - terminate instead
worker.terminate();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/flac-encoder",
"author": "Vanilagy",
"version": "1.50.7",
"version": "1.50.9",
"description": "FLAC encoder extension for Mediabunny, based on libFLAC.",
"main": "./dist/bundles/mediabunny-flac-encoder.mjs",
"module": "./dist/bundles/mediabunny-flac-encoder.mjs",
@@ -192,3 +192,7 @@ if (parentPort) {
} else {
self.addEventListener('message', event => onMessage(event.data as { id: number; command: WorkerCommand }));
}
// Prevents the worker for being randomly closed by Firefox
// https://github.com/Vanilagy/mediabunny/issues/435
setInterval(() => {}, 1000);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.50.7",
"version": "1.50.9",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
@@ -219,3 +219,7 @@ if (parentPort) {
} else {
self.addEventListener('message', event => onMessage(event.data as { id: number; command: WorkerCommand }));
}
// Prevents the worker for being randomly closed by Firefox
// https://github.com/Vanilagy/mediabunny/issues/435
setInterval(() => {}, 1000);
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/prores",
"author": "Vanilagy",
"version": "1.50.7",
"version": "1.50.9",
"description": "Apple ProRes decoder extension for Mediabunny, based on TurboRes.",
"main": "./dist/bundles/mediabunny-prores.mjs",
"module": "./dist/bundles/mediabunny-prores.mjs",
@@ -34,7 +34,7 @@
"url": "https://github.com/sponsors/Vanilagy"
},
"dependencies": {
"turbores": "^1.2.1"
"turbores": "^1.2.2"
},
"peerDependencies": {
"mediabunny": "^1.49.0"
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/server",
"author": "Vanilagy",
"version": "1.50.7",
"version": "1.50.9",
"description": "Adds full video and audio decoder and encoder support to Mediabunny for use in server-side environments (Node, Bun, Deno). Based on NodeAV.",
"main": "./dist/bundles/mediabunny-server.cjs",
"module": "./dist/bundles/mediabunny-server.mjs",
@@ -35,7 +35,7 @@
},
"dependencies": {
"node-av": "^6.0.0",
"@mediabunny/prores": "^1.50.7"
"@mediabunny/prores": "^1.50.9"
},
"peerDependencies": {
"mediabunny": "^1.45.0"
+14 -1
View File
@@ -2207,9 +2207,22 @@ const OPUS_FRAME_DURATION_TABLE = [
export const parseOpusTocByte = (packet: Uint8Array) => {
const config = packet[0]! >> 3;
const code = packet[0]! & 0b11;
// A packet may pack more than one frame, in which case its duration is the frame duration times the number of
// frames it carries. See https://datatracker.ietf.org/doc/html/rfc6716, section 3.2.
let frameCount: number;
if (code === 0) {
frameCount = 1;
} else if (code === 1 || code === 2) {
frameCount = 2;
} else {
// Code 3: the frame count sits in the six low bits of the frame count byte
frameCount = packet[1]! & 0b111111;
}
return {
durationInSamples: OPUS_FRAME_DURATION_TABLE[config]!,
durationInSamples: OPUS_FRAME_DURATION_TABLE[config]! * frameCount,
};
};
+17 -14
View File
@@ -1321,7 +1321,7 @@ export class Conversion {
await tempOutput.start();
const sink = new VideoSampleSink(track);
const firstSample = await sink.getSample(firstTimestamp); // Let's just use the first sample
using firstSample = await sink.getSample(firstTimestamp); // Let's just use the first sample
if (firstSample) {
try {
@@ -1375,9 +1375,8 @@ export class Conversion {
const sink = new VideoSampleSink(track);
for await (const sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
for await (using sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
if (this._canceled) {
sample.close();
return;
}
@@ -1386,14 +1385,13 @@ export class Conversion {
this._reportProgress(outputTrackId, sample.timestamp + sample.duration);
await source.add(sample);
sample.close();
if (lastSampleTimestamp !== null) {
if (this._synchronizer.shouldWait(outputTrackId, lastSampleTimestamp)) {
await this._synchronizer.wait(lastSampleTimestamp);
}
}
sample.close();
}
source.close();
@@ -1595,9 +1593,8 @@ export class Conversion {
await this._started;
const sink = new AudioSampleSink(track);
for await (let sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
for await (using sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
if (this._canceled) {
sample.close();
return;
}
@@ -1612,7 +1609,7 @@ export class Conversion {
data.fill(2 ** 7); // Fill it with the silent value
}
const silentSample = new AudioSample({
using silentSample = new AudioSample({
data,
// Use the same format the decoder is spitting out. This avoids feeding changing sample
// formats to the audio encoder.
@@ -1636,22 +1633,28 @@ export class Conversion {
endFrame = Math.round((this._endTimestamp - sample.timestamp) * sample.sampleRate);
}
// Can't assign to "using" identifiers so we gotta do this
let finalSampleLet: AudioSample;
if (startFrame > 0 || endFrame < sample.numberOfFrames) {
// Trim the sample if it sticks out of the trim region on either end
const trimmedSample = sample.trim(startFrame, endFrame);
sample.close();
sample = trimmedSample;
finalSampleLet = trimmedSample;
if (sample.numberOfFrames === 0) {
sample.close();
if (trimmedSample.numberOfFrames === 0) {
trimmedSample.close();
continue;
}
} else {
finalSampleLet = sample;
}
// Offset the timestamp as needed
sample.setTimestamp(sample.timestamp - this._startTimestamp);
using finalSample = finalSampleLet;
await this._registerAudioSample(sample, source, outputTrackId, () => lastSampleTimestamp);
// Offset the timestamp as needed
finalSample.setTimestamp(finalSample.timestamp - this._startTimestamp);
await this._registerAudioSample(finalSample, source, outputTrackId, () => lastSampleTimestamp);
}
source.close();
+2
View File
@@ -206,6 +206,8 @@ export class IsobmffMuxer extends Muxer {
this.isCmaf = format instanceof CmafOutputFormat;
this.minimumFragmentDuration = format._options.minimumFragmentDuration
?? (format instanceof CmafOutputFormat ? Infinity : 1);
this.auxWriter.start();
}
async start() {
+4 -1
View File
@@ -1168,7 +1168,10 @@ export class MatroskaMuxer extends Muxer {
const msDuration = Math.round(1000 * chunk.duration);
if (!chunk.additions) {
// Subtitle cues need an explicit BlockDuration (a SimpleBlock has none)
const needsBlockGroup = !!chunk.additions || trackData.type === 'subtitle';
if (!needsBlockGroup) {
// No additions, we can write out a SimpleBlock
view.setUint8(3, Number(chunk.type === 'key') << 7); // Flags (keyframe flag only present for SimpleBlock)
+1 -1
View File
@@ -799,7 +799,7 @@ export class WavOutputFormat extends OutputFormat {
getSupportedCodecs(): MediaCodec[] {
return [
...PCM_AUDIO_CODECS.filter(codec =>
['pcm-s16', 'pcm-s24', 'pcm-s32', 'pcm-f32', 'pcm-u8', 'ulaw', 'alaw'].includes(codec),
['pcm-s16', 'pcm-s24', 'pcm-s32', 'pcm-f32', 'pcm-f64', 'pcm-u8', 'ulaw', 'alaw'].includes(codec),
),
];
}
+25 -1
View File
@@ -173,6 +173,28 @@ export class WaveDemuxer extends Demuxer {
bitsPerSample = 8;
}
if (
formatTag !== WaveFormat.PCM
&& formatTag !== WaveFormat.IEEE_FLOAT
&& formatTag !== WaveFormat.ALAW
&& formatTag !== WaveFormat.MULAW
) {
throw new Error(
`Unsupported WAVE codec (format tag ${formatTag}). Only integer/float PCM, A-law, and μ-law are`
+ ` supported.`,
);
}
if (formatTag === WaveFormat.PCM && ![8, 16, 24, 32].includes(bitsPerSample)) {
throw new Error(
`Unsupported WAVE PCM bit depth (${bitsPerSample}). Only 8, 16, 24, and 32 bits are supported.`,
);
}
if (formatTag === WaveFormat.IEEE_FLOAT && ![32, 64].includes(bitsPerSample)) {
throw new Error(
`Unsupported WAVE float bit depth (${bitsPerSample}). Only 32 and 64 bits are supported.`,
);
}
this.audioInfo = {
format: formatTag,
numberOfChannels: numChannels,
@@ -322,10 +344,12 @@ export class WaveDemuxer extends Demuxer {
if (this.audioInfo.format === WaveFormat.IEEE_FLOAT) {
if (this.audioInfo.sampleSizeInBytes === 4) {
return 'pcm-f32';
} else if (this.audioInfo.sampleSizeInBytes === 8) {
return 'pcm-f64';
}
}
return null;
assert(false);
}
async getMimeType() {
+87 -2
View File
@@ -1,8 +1,14 @@
import { expect, test } from 'vitest';
import { Output } from '../../src/output.js';
import { OggOutputFormat } from '../../src/output-format.js';
import { NullTarget } from '../../src/target.js';
import { AudioBufferSource } from '../../src/media-source.js';
import { BufferTarget, NullTarget } from '../../src/target.js';
import { AudioBufferSource, EncodedAudioPacketSource } from '../../src/media-source.js';
import { EncodedPacket } from '../../src/packet.js';
import { assert } from '../../src/misc.js';
import { Input } from '../../src/input.js';
import { BufferSource } from '../../src/source.js';
import { ALL_FORMATS, OggInputFormat } from '../../src/input-format.js';
import { EncodedPacketSink } from '../../src/media-sink.js';
test('maximumPageDuration option', async () => {
const sampleRate = 48000;
@@ -55,3 +61,82 @@ test('maximumPageDuration option', async () => {
expect(pageCountWithoutOption).toBe(3);
expect(pageCountWithOption).toBe(23); // It created more pages
});
test('Multi-frame Opus packets', async () => {
const SAMPLE_RATE = 48000;
const SAMPLES_PER_FRAME = 960; // 20 ms at 48 kHz
const createOpusHead = () => {
const bytes = new Uint8Array(19);
const view = new DataView(bytes.buffer);
bytes.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0); // 'OpusHead'
bytes[8] = 1; // Version
bytes[9] = 1; // Channel count
view.setUint16(10, 312, true); // Pre-skip
view.setUint32(12, SAMPLE_RATE, true); // Input sample rate
view.setInt16(16, 0, true); // Output gain
bytes[18] = 0; // Channel mapping family
return bytes;
};
const createOpusPacket = (frameCount: number) => {
const data = new Uint8Array(2 + 3 * frameCount);
data[0] = (31 << 3) | 0b11; // TOC byte: config 31 (CELT fullband, 20 ms), code 3
data[1] = frameCount; // CBR, no padding, `frameCount` frames
return data;
};
const framesPerPacket = 3;
const packetCount = 10;
const packetDuration = (framesPerPacket * SAMPLES_PER_FRAME) / SAMPLE_RATE;
const output = new Output({
format: new OggOutputFormat(),
target: new BufferTarget(),
});
const audioSource = new EncodedAudioPacketSource('opus');
output.addAudioTrack(audioSource);
await output.start();
for (let i = 0; i < packetCount; i++) {
await audioSource.add(
new EncodedPacket(
createOpusPacket(framesPerPacket),
'key',
i * packetDuration,
packetDuration,
),
{
decoderConfig: {
codec: 'opus',
numberOfChannels: 1,
sampleRate: SAMPLE_RATE,
description: createOpusHead(),
},
},
);
}
audioSource.close();
await output.finalize();
assert(output.target.buffer);
const input = new Input({
source: new BufferSource(output.target.buffer),
formats: ALL_FORMATS,
});
expect(await input.getFormat()).toBeInstanceOf(OggInputFormat);
const sink = new EncodedPacketSink((await input.getPrimaryAudioTrack())!);
const packet = await sink.getFirstPacket();
expect(packet?.duration).toBe(packetDuration);
});
+57
View File
@@ -0,0 +1,57 @@
import { test } from 'vitest';
import { Output } from '../../src/output.js';
import { MkvOutputFormat, Mp4OutputFormat } from '../../src/output-format.js';
import { BufferTarget } from '../../src/target.js';
import { TextSubtitleSource } from '../../src/media-source.js';
test('ISOBMFF muxing', async () => {
const output = new Output({
format: new Mp4OutputFormat(),
target: new BufferTarget(),
});
const source = new TextSubtitleSource('webvtt');
output.addSubtitleTrack(source);
await output.start();
await source.add(`WEBVTT
00:00.000 --> 00:00.900
Hildy!
00:01.000 --> 00:01.400
How are you?
00:01.500 --> 00:02.900
Tell me, is the lord of the universe in?
`);
await output.finalize();
});
test('Matroska muxing', async () => {
const output = new Output({
format: new MkvOutputFormat(),
target: new BufferTarget(),
});
const source = new TextSubtitleSource('webvtt');
output.addSubtitleTrack(source);
await output.start();
await source.add(`WEBVTT
00:00.000 --> 00:00.900
Hildy!
00:01.000 --> 00:01.400
How are you?
00:01.500 --> 00:02.900
Tell me, is the lord of the universe in?
`);
await output.finalize();
});
+5 -3
View File
@@ -19,6 +19,8 @@
"./examples/**/*.ts"
],
"references": [
{ "path": "./src" }
]
}
{ "path": "./src" },
{ "path": "./packages/ac3" },
{ "path": "./packages/prores" }
]
}
+2
View File
@@ -21,6 +21,8 @@ export default defineConfig({
resolve: {
alias: {
'mediabunny': path.resolve(__dirname, './dist/bundles/mediabunny.mjs'),
'@mediabunny/ac3':
path.resolve(__dirname, './packages/ac3/dist/bundles/mediabunny-ac3.mjs'),
'@mediabunny/aac-encoder':
path.resolve(__dirname, './packages/aac-encoder/dist/bundles/mediabunny-aac-encoder.mjs'),
'@mediabunny/flac-encoder':