Compare commits

..
21 Commits
Author SHA1 Message Date
Vanilagy f4b97c1731 Make mfra box lookup more resilient 2025-08-20 14:33:26 +02:00
Vanilagy 5782219941 Add Matroska resync logic for malformed files, add RVE sponsor 2025-08-20 13:38:54 +02:00
Vanilagy b265826b7e Update codec soup 2025-08-19 10:26:17 +02:00
Vanilagy c9633d26d7 Bump minor version 2025-08-18 22:57:17 +02:00
David P.andGitHub 6e802bdf0b Merge pull request #65 from Allwhy/feature/aac-support
Implement ADTS :3
2025-08-18 22:56:00 +02:00
Ally 9054b8e381 Rewrite ADTS muxer to use Bitstream 2025-08-18 22:47:43 +02:00
Vanilagy 8b9f22ba46 eqeqeq 2025-08-18 17:17:18 +02:00
Ally 8da619a624 Implement ADTS demuxer, Fix MP3 demuxer race conditions 2025-08-18 01:29:52 +02:00
Ally 1ce5e108a7 Implement ADTS muxer :3 2025-08-17 16:14:15 +02:00
Vanilagy 0cb466ebb1 Add taf2000 2025-08-15 21:17:21 +02:00
Vanilagy 3be94cc788 Decrease AudioBuffer chunking size, support more MP3 sample rates, lift MP3 encoder memory limit 2025-08-15 14:19:06 +02:00
Vanilagy 89df8fdae2 text-center 2025-08-15 12:00:17 +02:00
Vanilagy 3acb9e8f99 Add sponsor 2025-08-15 11:36:58 +02:00
Vanilagy 716b864fbf Bump patch 2025-08-14 10:56:23 +02:00
David P.andGitHub bea53d9a2b Merge pull request #60 from devPablo/main
Fix UrlSource single-byte buffer on Range Request HTTP 200
2025-08-14 03:12:21 +02:00
Pablo Bonilla 4521a65587 Add descriptive comment on why range request response must be skipped 2025-08-13 19:05:03 -06:00
Pablo Bonilla 5c598e35c2 Skip single-byte buffer when reading through UrlSource for range request 2025-08-13 18:22:59 -06:00
Vanilagy c2292c8304 Bump patch 2025-08-13 17:14:19 +02:00
David P.andGitHub 84219b3ed4 Merge pull request #59 from Yukiniro/e_q6qy_i
fix(conversion): fixed using wrong timestamp
2025-08-13 17:13:38 +02:00
Yukiniro 88b0b5f46f fix(conversion): fix sample end timestamp calculation error 2025-08-13 23:06:11 +08:00
Yukiniro 0348eda8a9 fix(conversion): fixed using wrong timestamp when calculating lastCanvasEndTimestamp 2025-08-13 22:48:08 +08:00
36 changed files with 1001 additions and 164 deletions
+8
View File
@@ -28,6 +28,14 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
</a>
</div>
### Bronze sponsors
<div align="center">
<a href="https://www.reactvideoeditor.com/" target="_blank">
<img src="./docs/public/sponsors/rve.svg" width="40" height="40" alt="React Video Editor">
</a>
</div>
[Sponsor Mediabunny's development](https://github.com/sponsors/Vanilagy)
## Features
+1 -1
View File
@@ -24,7 +24,7 @@
chunked: true,
chunkSize: 2**20
});
const outputFormat = new Mediabunny.Mp3OutputFormat({});
const outputFormat = new Mediabunny.Mp4OutputFormat({});
const button = document.createElement('button');
button.textContent = 'Cancel';
+3 -2
View File
@@ -19,9 +19,10 @@
const videoTrack = await input.getPrimaryVideoTrack();
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
for await (const packet of sink.packets(undefined, undefined, { verifyKeyPackets: false })) {
for await (const packet of sink.packets()) {
console.log(packet);
if (packet.timestamp >= 2.4) break;
if (packet.timestamp > 135) break;
}
/*
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 121 KiB

+1 -1
View File
@@ -12,7 +12,7 @@ Here's a long list of stuff this library does:
- Converting media files
- Hardware-accelerated decoding & encoding (via the WebCodecs API)
- Support for multiple video, audio and subtitle tracks
- Read & write support for many container formats (.mp4, .mov, .webm, .mkv, .mp3, .wav, .ogg), including variations such as MP4 with Fast Start, fragmented MP4, or streamable Matroska
- Read & write support for many container formats (.mp4, .mov, .webm, .mkv, .mp3, .wav, .ogg, .aac), including variations such as MP4 with Fast Start, fragmented MP4, or streamable Matroska
- Support for 25 different codecs
- Lazy, optimized, on-demand file reading
- Input and output streaming, arbitrary file size support
+22 -1
View File
@@ -244,4 +244,25 @@ type WavOutputFormatOptions = {
- `large`\
When enabled, an RF64 file be written, allowing for file sizes to exceed 4 GiB, which is otherwise not possible for regular WAVE files.
- `onHeader`\
Will be called once the file header is written. The header consists of the RIFF header, the format chunk, and the start of the data chunk (with a placeholder size of 0).
Will be called once the file header is written. The header consists of the RIFF header, the format chunk, and the start of the data chunk (with a placeholder size of 0).
## ADTS
This output format creates ADTS (.aac) files.
```ts
import { Output, AdtsOutputFormat } from 'mediabunny';
const output = new Output({
format: new AdtsOutputFormat(options),
// ...
});
```
The following options are available:
```ts
type AdtsOutputFormatOptions = {
onFrame?: (data: Uint8Array, position: number) => unknown;
};
```
- `onFrame`\
Will be called for each ADTS frame that is written.
+28 -27
View File
@@ -11,6 +11,7 @@ Mediabunny supports many commonly used media container formats, all of which are
- Ogg (.ogg)
- MP3 (.mp3)
- WAVE (.wav)
- ADTS (.aac)
## Codecs
@@ -60,33 +61,33 @@ Mediabunny ships with built-in decoders and encoders for all audio PCM codecs, m
Not all codecs can be used with all containers. The following table specifies the supported codec-container combinations:
| | .mp4 | .mov | .mkv | .webm[^1] | .ogg | .mp3 | .wav |
|:--------------:|:--------:|:-----:|:-----:|:---------:|:-----:|:-----:|:-----:|
| `'avc'` | ✓ | ✓ | ✓ | | | | |
| `'hevc'` | ✓ | ✓ | ✓ | | | | |
| `'vp8'` | ✓ | ✓ | ✓ | ✓ | | | |
| `'vp9'` | ✓ | ✓ | ✓ | ✓ | | | |
| `'av1'` | ✓ | ✓ | ✓ | ✓ | | | |
| `'aac'` | ✓ | ✓ | ✓ | | | | |
| `'opus'` | ✓ | ✓ | ✓ | ✓ | ✓ | | |
| `'mp3'` | ✓ | ✓ | ✓ | | | ✓ | |
| `'vorbis'` | ✓ | ✓ | ✓ | ✓ | ✓ | | |
| `'flac'` | ✓ | ✓ | ✓ | | | | |
| `'pcm-u8'` | | ✓ | ✓ | | | | ✓ |
| `'pcm-s8'` | | ✓ | | | | | |
| `'pcm-s16'` | ✓ | ✓ | ✓ | | | | ✓ |
| `'pcm-s16be'` | ✓ | ✓ | ✓ | | | | |
| `'pcm-s24'` | ✓ | ✓ | ✓ | | | | ✓ |
| `'pcm-s24be'` | ✓ | ✓ | ✓ | | | | |
| `'pcm-s32'` | ✓ | ✓ | ✓ | | | | ✓ |
| `'pcm-s32be'` | ✓ | ✓ | ✓ | | | | |
| `'pcm-f32'` | ✓ | ✓ | ✓ | | | | ✓ |
| `'pcm-f32be'` | ✓ | ✓ | | | | | |
| `'pcm-f64'` | ✓ | ✓ | ✓ | | | | |
| `'pcm-f64be'` | ✓ | ✓ | | | | | |
| `'ulaw'` | | ✓ | | | | | ✓ |
| `'alaw'` | | ✓ | | | | | ✓ |
| `'webvtt'`[^2] | (✓) | | (✓) | (✓) | | | |
| | .mp4 | .mov | .mkv | .webm[^1] | .ogg | .mp3 | .wav | .aac |
|:--------------:|:--------:|:-----:|:-----:|:---------:|:-----:|:-----:|:-----:|:-----:|
| `'avc'` | ✓ | ✓ | ✓ | | | | | |
| `'hevc'` | ✓ | ✓ | ✓ | | | | | |
| `'vp8'` | ✓ | ✓ | ✓ | ✓ | | | | |
| `'vp9'` | ✓ | ✓ | ✓ | ✓ | | | | |
| `'av1'` | ✓ | ✓ | ✓ | ✓ | | | | |
| `'aac'` | ✓ | ✓ | ✓ | | | | | ✓ |
| `'opus'` | ✓ | ✓ | ✓ | ✓ | ✓ | | | |
| `'mp3'` | ✓ | ✓ | ✓ | | | ✓ | | |
| `'vorbis'` | ✓ | ✓ | ✓ | ✓ | ✓ | | | |
| `'flac'` | ✓ | ✓ | ✓ | | | | | |
| `'pcm-u8'` | | ✓ | ✓ | | | | ✓ | |
| `'pcm-s8'` | | ✓ | | | | | | |
| `'pcm-s16'` | ✓ | ✓ | ✓ | | | | ✓ | |
| `'pcm-s16be'` | ✓ | ✓ | ✓ | | | | | |
| `'pcm-s24'` | ✓ | ✓ | ✓ | | | | ✓ | |
| `'pcm-s24be'` | ✓ | ✓ | ✓ | | | | | |
| `'pcm-s32'` | ✓ | ✓ | ✓ | | | | ✓ | |
| `'pcm-s32be'` | ✓ | ✓ | ✓ | | | | | |
| `'pcm-f32'` | ✓ | ✓ | ✓ | | | | ✓ | |
| `'pcm-f32be'` | ✓ | ✓ | | | | | | |
| `'pcm-f64'` | ✓ | ✓ | ✓ | | | | | |
| `'pcm-f64be'` | ✓ | ✓ | | | | | | |
| `'ulaw'` | | ✓ | | | | | ✓ | |
| `'alaw'` | | ✓ | | | | | ✓ | |
| `'webvtt'`[^2] | (✓) | | (✓) | (✓) | | | | |
[^1]: WebM only supports a small subset of the codecs supported by Matroska. However, this library can technically read all codecs from a WebM that are supported by Matroska.
+16 -2
View File
@@ -92,10 +92,15 @@ const sponsors = {
{ image: '/sponsors/diffusionstudio.png', name: 'Diffusion Studio', url: 'https://diffusion.studio/' },
{ image: '/sponsors/kino.jpg', name: 'Kino', url: 'https://kino.ai/' },
],
bronze: [
{ image: '/sponsors/rve.svg', name: 'React Video Editor', url: 'https://www.reactvideoeditor.com/' },
],
individual: [
{ image: 'https://avatars.githubusercontent.com/u/84167135', name: 'Memenome', url: 'https://github.com/memenome' },
{ image: 'https://avatars.githubusercontent.com/u/5913254', name: 'Brandon McConnell', url: 'https://github.com/brandonmcconnell' },
{ image: 'https://avatars.githubusercontent.com/u/9549394', name: 'studnitz', url: 'https://github.com/studnitz' },
{ image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' },
{ image: 'https://avatars.githubusercontent.com/u/63088713', name: 'taf2000', url: 'https://github.com/taf2000' },
{ image: 'https://avatars.githubusercontent.com/u/58149663', name: 'H7GhosT', url: 'https://github.com/H7GhosT' },
{ image: 'https://avatars.githubusercontent.com/u/91711202', name: 'ihasq', url: 'https://github.com/ihasq' },
{ image: 'https://avatars.githubusercontent.com/u/61233224', name: 'Allwhy', url: 'https://github.com/Allwhy' },
@@ -334,12 +339,21 @@ await conversion.execute();
</a>
</div>
</template>
<template v-if="sponsors.bronze.length > 0">
<h3 class="!text-xl">Bronze sponsors</h3>
<div class="flex flex-wrap mt-1 justify-center gap-1">
<a v-for="sponsor in sponsors.bronze" :href="sponsor.url" target="_blank" class="flex items-center p-2 rounded-full hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
<img :src="sponsor.image" class="size-10 rounded-full">
<p class="!my-0 !font-medium px-3 text-sm">{{ sponsor.name }}</p>
</a>
</div>
</template>
<template v-if="sponsors.individual.length > 0">
<h4>Individual sponsors</h4>
<h4 class="!text-base">Individual sponsors</h4>
<div class="flex flex-wrap mt-1 justify-center">
<a v-for="sponsor in sponsors.individual" :href="sponsor.url" target="_blank" class="flex gap-1 w-24 flex-col items-center p-2 rounded-xl hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
<img :src="sponsor.image" class="size-8 rounded-full">
<p class="!my-0 !font-medium text-xs !leading-4">{{ sponsor.name }}</p>
<p class="!my-0 !font-medium text-xs !leading-4 text-center">{{ sponsor.name }}</p>
</a>
</div>
</template>
+4
View File
@@ -0,0 +1,4 @@
<svg width="52" height="51" viewBox="0 0 52 51" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.1186 1.50109C23.3805 0.147773 21.1954 -0.253724 19.1203 0.397437L5.08373 4.80289C2.19204 5.71105 0.248047 8.44236 0.248047 11.6022V42.9894C0.248047 45.2558 1.24465 47.3313 2.98266 48.6825C4.72099 50.036 6.9084 50.4373 8.98111 49.7863L19.4595 46.4967C19.1318 45.366 18.9658 44.1862 18.9658 42.9894V28.735C18.9658 25.7417 20.0093 22.8829 21.9088 20.6899C23.303 19.0744 25.1116 17.8826 27.1329 17.2463L27.8534 17.0309V7.19654C27.8534 4.93006 26.8568 2.85458 25.1185 1.50109H25.1186Z" fill="#1C2AC5"/>
<path d="M51.2479 24.3293V38.5836C51.2479 41.7411 49.3061 44.4749 46.4122 45.3806L32.3756 49.7862C31.7253 49.9917 31.0632 50.0922 30.4081 50.0922C28.9739 50.0922 27.5703 49.6125 26.3771 48.6824C25.832 48.2565 25.3595 47.7645 24.9711 47.2139C24.4986 46.553 24.1453 45.8164 23.923 45.0257C23.9206 45.0257 23.9184 45.0257 23.916 45.0281L23.9206 45.0232C23.7406 44.3746 23.6423 43.6917 23.6423 42.9893V28.7349C23.6423 27.2712 24.0588 25.8983 24.7935 24.7576C25.528 23.6169 26.5807 22.7066 27.8534 22.1656C28.057 22.08 28.2652 22.0015 28.4782 21.9356L30.1953 21.3971L42.515 17.53C44.5899 16.8788 46.7751 17.2803 48.5132 18.6338C50.2516 19.9873 51.248 22.0628 51.248 24.3293H51.2479Z" fill="#3E8AF5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+2 -1
View File
@@ -25,10 +25,11 @@ export default tseslint.config(
code: 120,
}],
'curly': ['error', 'multi-line'],
'eqeqeq': ['error', 'always', { null: 'ignore' }],
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/require-await': 'off',
'@stylistic/yield-star-spacing': ['error', { before: false, after: true }],
'@typescript-eslint/no-unsafe-enum-comparison': 'off',
'@typescript-eslint/no-unsafe-enum-comparison': 'off',
},
},
{
@@ -117,7 +117,7 @@ const compressFile = async (file: File) => {
selectMediaButton.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'video/*,video/x-matroska,audio/*';
fileInput.accept = 'video/*,video/x-matroska,audio/*,audio/aac';
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) {
+1 -1
View File
@@ -581,7 +581,7 @@ const formatSeconds = (seconds: number) => {
selectMediaButton.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'video/*,video/x-matroska,audio/*';
fileInput.accept = 'video/*,video/x-matroska,audio/*,audio/aac';
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) {
@@ -157,7 +157,7 @@ const shortDelay = () => {
selectMediaButton.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'video/*,video/x-matroska,audio/*';
fileInput.accept = 'video/*,video/x-matroska,audio/*,audio/aac';
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) {
@@ -109,7 +109,7 @@ const generateThumbnails = async (file: File) => {
selectMediaButton.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'video/*,video/x-matroska,audio/*';
fileInput.accept = 'video/*,video/x-matroska,audio/*,audio/aac';
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) {
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.7.4",
"version": "1.9.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.7.4",
"version": "1.9.2",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -5900,9 +5900,9 @@
}
},
"node_modules/mediabunny": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.7.3.tgz",
"integrity": "sha512-wh0bTZ0bLKIdkn0gMMF/uxEVh85Ft/qH1OtUKJO8k5tdqP0RG7rMfoXLZI6r+wZ9VadfQ4xxYZTEallAWZ74jw==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.9.1.tgz",
"integrity": "sha512-DghqcYW2s8LsZ9kErFeZdm6308BEABYs8SkY1z3Y7v3LWgPNvBmkXk7Yt+EKcpEGEFjGgDGoTapOjrrrNh7dQA==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
@@ -9017,7 +9017,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
"version": "1.7.4",
"version": "1.9.2",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.7.4",
"version": "1.9.2",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
+2 -1
View File
@@ -108,7 +108,7 @@ For simplicity, all built WASM artifacts are included in the repo, since these r
### Prerequisites
[Install Emscripten](https://emscripten.org/docs/getting_started/downloads.html). The recommended way is using the emsdk, which involves cloning a repo and running a few commands.
[Install Emscripten](https://emscripten.org/docs/getting_started/downloads.html). The recommended way is using the emsdk, which involves cloning a repo and running a few commands. The following commands assume Emscripten is sourced in.
### Compiling LAME:
@@ -139,6 +139,7 @@ emcc src/lame-bridge.c build/libmp3lame.a \
-s MODULARIZE=1 \
-s EXPORT_ES6=1 \
-s SINGLE_FILE=1 \
-s ALLOW_MEMORY_GROWTH=1 \
-s ENVIRONMENT=web,worker \
-s EXPORTED_RUNTIME_METHODS=cwrap,HEAPU8 \
-s EXPORTED_FUNCTIONS=_malloc,_free \
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.7.4",
"version": "1.9.2",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+2 -2
View File
@@ -7,7 +7,7 @@
*/
import { CustomAudioEncoder, AudioCodec, AudioSample, EncodedPacket, registerEncoder } from 'mediabunny';
import { FRAME_HEADER_SIZE, readFrameHeader } from '../../../shared/mp3-misc';
import { FRAME_HEADER_SIZE, readFrameHeader, SAMPLING_RATES } from '../../../shared/mp3-misc';
import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared';
// @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand
import createWorker from './encode.worker';
@@ -28,7 +28,7 @@ class Mp3Encoder extends CustomAudioEncoder {
static override supports(codec: AudioCodec, config: AudioDecoderConfig): boolean {
return codec === 'mp3'
&& (config.numberOfChannels === 1 || config.numberOfChannels === 2)
&& (config.sampleRate === 32000 || config.sampleRate === 44100 || config.sampleRate === 48000);
&& Object.values(SAMPLING_RATES).some(x => x.includes(config.sampleRate));
}
async init() {
+312
View File
@@ -0,0 +1,312 @@
/*!
* Copyright (c) 2025-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { aacChannelMap, aacFrequencyTable, AudioCodec } from '../codec';
import { Demuxer } from '../demuxer';
import { Input } from '../input';
import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
import { PacketRetrievalOptions } from '../media-sink';
import {
assert,
AsyncMutex,
binarySearchExact,
binarySearchLessOrEqual,
Bitstream,
UNDETERMINED_LANGUAGE,
} from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { AdtsReader, FrameHeader, MAX_FRAME_HEADER_SIZE } from './adts-reader';
const SAMPLES_PER_AAC_FRAME = 1024;
type Sample = {
timestamp: number;
duration: number;
dataStart: number;
dataSize: number;
};
export class AdtsDemuxer extends Demuxer {
reader: AdtsReader;
metadataPromise: Promise<void> | null = null;
firstFrameHeader: FrameHeader | null = null;
loadedSamples: Sample[] = []; // All samples from the start of the file to lastLoadedPos
tracks: InputAudioTrack[] = [];
readingMutex = new AsyncMutex();
lastLoadedPos = 0;
fileSize = 0;
nextTimestampInSamples = 0;
constructor(input: Input) {
super(input);
this.reader = new AdtsReader(input._mainReader);
}
async readMetadata() {
return this.metadataPromise ??= (async () => {
this.fileSize = await this.input.source.getSize();
await this.loadNextChunk();
// There has to be a frame if this demuxer got selected
assert(this.firstFrameHeader);
// Create the single audio track
this.tracks = [new InputAudioTrack(new AdtsAudioTrackBacking(this))];
})();
}
async loadNextChunk() {
assert(this.lastLoadedPos < this.fileSize);
const chunkSize = 0.5 * 1024 * 1024; // 0.5 MiB
const endPos = Math.min(this.lastLoadedPos + chunkSize, this.fileSize);
await this.reader.reader.loadRange(this.lastLoadedPos, endPos);
this.lastLoadedPos = endPos;
assert(this.lastLoadedPos <= this.fileSize);
this.parseFramesFromLoadedData();
}
private parseFramesFromLoadedData() {
while (this.reader.pos <= this.fileSize - MAX_FRAME_HEADER_SIZE) {
const startPos = this.reader.pos;
const header = this.reader.readFrameHeader();
if (!header) {
break;
}
// Check if the entire frame fits in the loaded data
if (startPos + header.frameLength > this.lastLoadedPos) {
// Frame doesn't fit, reset positions and stop
this.reader.pos = startPos;
this.lastLoadedPos = startPos;
break;
}
if (!this.firstFrameHeader) {
this.firstFrameHeader = header;
}
const sampleRate = aacFrequencyTable[header.samplingFrequencyIndex];
assert(sampleRate !== undefined);
const sampleDuration = SAMPLES_PER_AAC_FRAME / sampleRate;
const headerSize = header.crcCheck ? MAX_FRAME_HEADER_SIZE : MAX_FRAME_HEADER_SIZE - 2;
const sample: Sample = {
timestamp: this.nextTimestampInSamples / sampleRate,
duration: sampleDuration,
dataStart: startPos + headerSize,
dataSize: header.frameLength - headerSize,
};
this.loadedSamples.push(sample);
this.nextTimestampInSamples += SAMPLES_PER_AAC_FRAME;
this.reader.pos = startPos + header.frameLength;
}
}
async getMimeType() {
return 'audio/aac';
}
async getTracks() {
await this.readMetadata();
return this.tracks;
}
async computeDuration() {
await this.readMetadata();
const track = this.tracks[0];
assert(track);
return track.computeDuration();
}
}
class AdtsAudioTrackBacking implements InputAudioTrackBacking {
constructor(public demuxer: AdtsDemuxer) {}
getId() {
return 1;
}
async getFirstTimestamp() {
return 0;
}
getTimeResolution() {
const sampleRate = this.getSampleRate();
return sampleRate / SAMPLES_PER_AAC_FRAME;
}
async computeDuration() {
const lastPacket = await this.getPacket(Infinity, { metadataOnly: true });
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
}
getLanguageCode() {
return UNDETERMINED_LANGUAGE;
}
getCodec(): AudioCodec {
return 'aac';
}
getNumberOfChannels() {
assert(this.demuxer.firstFrameHeader);
const numberOfChannels = aacChannelMap[this.demuxer.firstFrameHeader.channelConfiguration];
assert(numberOfChannels !== undefined);
return numberOfChannels;
}
getSampleRate() {
assert(this.demuxer.firstFrameHeader);
const sampleRate = aacFrequencyTable[this.demuxer.firstFrameHeader.samplingFrequencyIndex];
assert(sampleRate !== undefined);
return sampleRate;
}
async getDecoderConfig(): Promise<AudioDecoderConfig> {
assert(this.demuxer.firstFrameHeader);
const bytes = new Uint8Array(3); // 19 bits max
const bitstream = new Bitstream(bytes);
const { objectType, samplingFrequencyIndex, channelConfiguration } = this.demuxer.firstFrameHeader;
if (objectType > 31) {
bitstream.writeBits(5, 31);
bitstream.writeBits(6, objectType - 32);
} else {
bitstream.writeBits(5, objectType);
}
bitstream.writeBits(4, samplingFrequencyIndex); // samplingFrequencyIndex === 15 is forbidden
bitstream.writeBits(4, channelConfiguration);
return {
codec: `mp4a.40.${this.demuxer.firstFrameHeader.objectType}`,
numberOfChannels: this.getNumberOfChannels(),
sampleRate: this.getSampleRate(),
description: bytes.subarray(0, Math.ceil((bitstream.pos - 1) / 8)),
};
}
getPacketAtIndex(sampleIndex: number, options: PacketRetrievalOptions) {
if (sampleIndex === -1) {
return null;
}
const rawSample = this.demuxer.loadedSamples[sampleIndex];
if (!rawSample) {
return null;
}
let data: Uint8Array;
if (options.metadataOnly) {
data = PLACEHOLDER_DATA;
} else {
this.demuxer.reader.pos = rawSample.dataStart;
data = this.demuxer.reader.readBytes(rawSample.dataSize);
}
return new EncodedPacket(
data,
'key',
rawSample.timestamp,
rawSample.duration,
sampleIndex,
rawSample.dataSize,
);
}
async getFirstPacket(options: PacketRetrievalOptions) {
return this.getPacketAtIndex(0, options);
}
async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
const release = await this.demuxer.readingMutex.acquire();
try {
const sampleIndex = binarySearchExact(
this.demuxer.loadedSamples,
packet.timestamp,
x => x.timestamp,
);
if (sampleIndex === -1) {
throw new Error('Packet was not created from this track.');
}
const nextIndex = sampleIndex + 1;
// Ensure the next sample exists
while (
nextIndex >= this.demuxer.loadedSamples.length
&& this.demuxer.lastLoadedPos < this.demuxer.fileSize
) {
await this.demuxer.loadNextChunk();
}
return this.getPacketAtIndex(nextIndex, options);
} finally {
release();
}
}
async getPacket(timestamp: number, options: PacketRetrievalOptions) {
const release = await this.demuxer.readingMutex.acquire();
try {
while (true) {
const index = binarySearchLessOrEqual(
this.demuxer.loadedSamples,
timestamp,
x => x.timestamp,
);
if (index === -1 && this.demuxer.loadedSamples.length > 0) {
// We're before the first sample
return null;
}
if (this.demuxer.lastLoadedPos === this.demuxer.fileSize) {
// All data is loaded, return what we found
return this.getPacketAtIndex(index, options);
}
if (index >= 0 && index + 1 < this.demuxer.loadedSamples.length) {
// The next packet also exists, we're done
return this.getPacketAtIndex(index, options);
}
// Otherwise, keep loading data
await this.demuxer.loadNextChunk();
}
} finally {
release();
}
}
getKeyPacket(timestamp: number, options: PacketRetrievalOptions) {
return this.getPacket(timestamp, options);
}
getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
return this.getNextPacket(packet, options);
}
}
+111
View File
@@ -0,0 +1,111 @@
/*!
* Copyright (c) 2025-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { AacAudioSpecificConfig, parseAacAudioSpecificConfig, validateAudioChunkMetadata } from '../codec';
import { assert, Bitstream, toUint8Array } from '../misc';
import { Muxer } from '../muxer';
import { Output, OutputAudioTrack } from '../output';
import { AdtsOutputFormat } from '../output-format';
import { EncodedPacket } from '../packet';
import { Writer } from '../writer';
export class AdtsMuxer extends Muxer {
private format: AdtsOutputFormat;
private writer: Writer;
private header = new Uint8Array(7);
private headerBitstream = new Bitstream(this.header);
private audioSpecificConfig: AacAudioSpecificConfig | null = null;
constructor(output: Output, format: AdtsOutputFormat) {
super(output);
this.format = format;
this.writer = output._writer;
}
async start() {
// Nothing needed here
}
async getMimeType() {
return 'audio/aac';
}
async addEncodedVideoPacket() {
throw new Error('ADTS does not support video.');
}
async addEncodedAudioPacket(
track: OutputAudioTrack,
packet: EncodedPacket,
meta?: EncodedAudioChunkMetadata,
) {
// https://wiki.multimedia.cx/index.php/ADTS (last visited: 2025/08/17)
const release = await this.mutex.acquire();
try {
this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === 'key');
if (!this.audioSpecificConfig) {
validateAudioChunkMetadata(meta);
const description = meta?.decoderConfig?.description;
assert(description);
this.audioSpecificConfig = parseAacAudioSpecificConfig(toUint8Array(description));
const { objectType, frequencyIndex, channelConfiguration } = this.audioSpecificConfig;
const profile = objectType - 1;
this.headerBitstream.writeBits(12, 0b1111_11111111); // Syncword
this.headerBitstream.writeBits(1, 0); // MPEG Version
this.headerBitstream.writeBits(2, 0); // Layer
this.headerBitstream.writeBits(1, 1); // Protection absence
this.headerBitstream.writeBits(2, profile); // Profile
this.headerBitstream.writeBits(4, frequencyIndex); // MPEG-4 Sampling Frequency Index
this.headerBitstream.writeBits(1, 0); // Private bit
this.headerBitstream.writeBits(3, channelConfiguration); // MPEG-4 Channel Configuration
this.headerBitstream.writeBits(1, 0); // Originality
this.headerBitstream.writeBits(1, 0); // Home
this.headerBitstream.writeBits(1, 0); // Copyright ID bit
this.headerBitstream.writeBits(1, 0); // Copyright ID start
this.headerBitstream.skipBits(13); // Frame length
this.headerBitstream.writeBits(11, 0x7ff); // Buffer fullness
this.headerBitstream.writeBits(2, 0); // Number of AAC frames minus 1
// Omit CRC check
}
const frameLength = packet.data.byteLength + this.header.byteLength;
this.headerBitstream.pos = 30;
this.headerBitstream.writeBits(13, frameLength);
const startPos = this.writer.getPos();
this.writer.write(this.header);
this.writer.write(packet.data);
if (this.format._options.onFrame) {
const frameBytes = new Uint8Array(frameLength);
frameBytes.set(this.header, 0);
frameBytes.set(packet.data, this.header.byteLength);
this.format._options.onFrame(frameBytes, startPos);
}
await this.writer.flush();
} finally {
release();
}
}
async addSubtitleCue() {
throw new Error('ADTS does not support subtitles.');
}
async finalize() {}
}
+96
View File
@@ -0,0 +1,96 @@
/*!
* Copyright (c) 2025-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { Bitstream } from '../misc';
import { Reader } from '../reader';
export const MAX_FRAME_HEADER_SIZE = 9;
export type FrameHeader = {
objectType: number;
samplingFrequencyIndex: number;
channelConfiguration: number;
frameLength: number;
numberOfAacFrames: number;
crcCheck: number | null;
startPos: number;
};
export class AdtsReader {
pos = 0;
constructor(public reader: Reader) {}
readBytes(length: number) {
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
this.pos += length;
return new Uint8Array(view.buffer, offset, length);
}
readFrameHeader(): FrameHeader | null {
// https://wiki.multimedia.cx/index.php/ADTS (last visited: 2025/08/17)
const startPos = this.pos;
const bytes = this.readBytes(9); // 9 with CRC, 7 without CRC
const bitstream = new Bitstream(bytes);
const syncword = bitstream.readBits(12);
if (syncword !== 0b1111_11111111) {
return null;
}
bitstream.skipBits(1); // MPEG version
const layer = bitstream.readBits(2);
if (layer !== 0) {
return null;
}
const protectionAbsence = bitstream.readBits(1);
const objectType = bitstream.readBits(2) + 1;
const samplingFrequencyIndex = bitstream.readBits(4);
if (samplingFrequencyIndex === 15) {
return null;
}
bitstream.skipBits(1); // Private bit
const channelConfiguration = bitstream.readBits(3);
if (channelConfiguration === 0) {
throw new Error('ADTS frames with channel configuration 0 are not supported.');
}
bitstream.skipBits(1); // Originality
bitstream.skipBits(1); // Home
bitstream.skipBits(1); // Copyright ID bit
bitstream.skipBits(1); // Copyright ID start
const frameLength = bitstream.readBits(13);
bitstream.skipBits(11); // Buffer fullness
const numberOfAacFrames = bitstream.readBits(2) + 1;
if (numberOfAacFrames !== 1) {
throw new Error('ADTS frames with more than one AAC frame are not supported.');
}
let crcCheck: number | null = null;
if (protectionAbsence === 1) { // No CRC
this.pos -= 2;
} else { // CRC
crcCheck = bitstream.readBits(16);
}
return {
objectType,
samplingFrequencyIndex,
channelConfiguration,
frameLength,
numberOfAacFrames,
crcCheck,
startPos,
};
}
}
+19 -17
View File
@@ -561,7 +561,22 @@ export const extractAudioCodecString = (trackInfo: {
throw new TypeError(`Unhandled codec '${codec}'.`);
};
export const parseAacAudioSpecificConfig = (bytes: Uint8Array | null) => {
export type AacAudioSpecificConfig = {
objectType: number;
frequencyIndex: number;
sampleRate: number | null;
channelConfiguration: number;
numberOfChannels: number | null;
};
export const aacFrequencyTable = [
96000, 88200, 64000, 48000, 44100, 32000,
24000, 22050, 16000, 12000, 11025, 8000, 7350,
];
export const aacChannelMap = [-1, 1, 2, 3, 4, 5, 6, 8];
export const parseAacAudioSpecificConfig = (bytes: Uint8Array | null): AacAudioSpecificConfig => {
if (!bytes || bytes.byteLength < 2) {
throw new TypeError('AAC description must be at least 2 bytes long.');
}
@@ -578,28 +593,15 @@ export const parseAacAudioSpecificConfig = (bytes: Uint8Array | null) => {
if (frequencyIndex === 15) {
sampleRate = bitstream.readBits(24);
} else {
const freqTable = [
96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050,
16000, 12000, 11025, 8000, 7350,
];
if (frequencyIndex < freqTable.length) {
sampleRate = freqTable[frequencyIndex]!;
if (frequencyIndex < aacFrequencyTable.length) {
sampleRate = aacFrequencyTable[frequencyIndex]!;
}
}
const channelConfiguration = bitstream.readBits(4);
let numberOfChannels: number | null = null;
if (channelConfiguration >= 1 && channelConfiguration <= 7) {
const channelMap = {
1: 1,
2: 2,
3: 3,
4: 4,
5: 5,
6: 6,
7: 8,
};
numberOfChannels = channelMap[channelConfiguration as keyof typeof channelMap];
numberOfChannels = aacChannelMap[channelConfiguration]!;
}
return {
+2 -2
View File
@@ -671,7 +671,7 @@ export class Conversion {
}
let adjustedSampleTimestamp = Math.max(timestamp - this._startTimestamp, 0);
lastCanvasEndTimestamp = timestamp + duration;
lastCanvasEndTimestamp = adjustedSampleTimestamp + duration;
if (frameRate !== undefined) {
// Logic for skipping/repeating frames when a frame rate is set
@@ -757,7 +757,7 @@ export class Conversion {
}
let adjustedSampleTimestamp = Math.max(sample.timestamp - this._startTimestamp, 0);
lastSampleEndTimestamp = sample.timestamp + sample.duration;
lastSampleEndTimestamp = adjustedSampleTimestamp + sample.duration;
if (frameRate !== undefined) {
// Logic for skipping/repeating frames when a frame rate is set
+2
View File
@@ -35,6 +35,8 @@ export {
WavOutputFormatOptions,
OggOutputFormat,
OggOutputFormatOptions,
AdtsOutputFormat,
AdtsOutputFormatOptions,
TrackCountLimits,
InclusiveIntegerRange,
} from './output-format';
+56 -1
View File
@@ -19,6 +19,8 @@ import { OggDemuxer } from './ogg/ogg-demuxer';
import { OggReader } from './ogg/ogg-reader';
import { RiffReader } from './wave/riff-reader';
import { WaveDemuxer } from './wave/wave-demuxer';
import { AdtsReader, MAX_FRAME_HEADER_SIZE } from './adts/adts-reader';
import { AdtsDemuxer } from './adts/adts-demuxer';
/**
* Base class representing an input media file format.
@@ -357,6 +359,54 @@ export class OggInputFormat extends InputFormat {
}
}
/**
* ADTS file format.
* @public
*/
export class AdtsInputFormat extends InputFormat {
/** @internal */
async _canReadInput(input: Input) {
const sourceSize = await input._mainReader.source.getSize();
if (sourceSize < MAX_FRAME_HEADER_SIZE) {
return false;
}
const adtsReader = new AdtsReader(input._mainReader);
const firstHeader = adtsReader.readFrameHeader();
if (!firstHeader) {
return false;
}
if (sourceSize < firstHeader.frameLength + MAX_FRAME_HEADER_SIZE) {
return false;
}
adtsReader.pos = firstHeader.frameLength;
await adtsReader.reader.loadRange(adtsReader.pos, adtsReader.pos + MAX_FRAME_HEADER_SIZE);
const secondHeader = adtsReader.readFrameHeader();
if (!secondHeader) {
return false;
}
return firstHeader.objectType === secondHeader.objectType
&& firstHeader.samplingFrequencyIndex === secondHeader.samplingFrequencyIndex
&& firstHeader.channelConfiguration === secondHeader.channelConfiguration;
}
/** @internal */
_createDemuxer(input: Input) {
return new AdtsDemuxer(input);
}
get name() {
return 'ADTS';
}
get mimeType() {
return 'audio/aac';
}
}
/**
* MP4 input format singleton.
* @public
@@ -392,10 +442,15 @@ export const WAVE = new WaveInputFormat();
* @public
*/
export const OGG = new OggInputFormat();
/**
* ADTS input format singleton.
* @public
*/
export const ADTS = new AdtsInputFormat();
/**
* List of all input format singletons. If you don't need to support all input formats, you should specify the
* formats individually for better tree shaking.
* @public
*/
export const ALL_FORMATS: InputFormat[] = [MP4, QTFF, MATROSKA, WEBM, WAVE, OGG, MP3];
export const ALL_FORMATS: InputFormat[] = [MP4, QTFF, MATROSKA, WEBM, WAVE, OGG, MP3, ADTS];
+10 -4
View File
@@ -291,14 +291,20 @@ export class IsobmffDemuxer extends Demuxer {
const lastWord = this.metadataReader.readU32();
const potentialMfraPos = sourceSize - lastWord;
if (potentialMfraPos >= 0 && potentialMfraPos < sourceSize) {
await this.metadataReader.reader.loadRange(potentialMfraPos, sourceSize);
if (potentialMfraPos >= 0 && potentialMfraPos <= sourceSize - MAX_BOX_HEADER_SIZE) {
// Load the header and a bit more, likely covering the entire box
await this.metadataReader.reader.loadRange(potentialMfraPos, potentialMfraPos + 2 ** 16);
this.metadataReader.pos = potentialMfraPos;
const boxInfo = this.metadataReader.readBoxHeader();
if (boxInfo.name === 'mfra') {
// We found the mfra box, allowing for much better random access. Let's parse it:
// We found the mfra box, allowing for much better random access. Let's parse it.
await this.metadataReader.reader.loadRange(
potentialMfraPos,
potentialMfraPos + boxInfo.totalSize,
);
this.readContiguousBoxes(boxInfo.contentSize);
}
}
@@ -1046,7 +1052,7 @@ export class IsobmffDemuxer extends Demuxer {
const chromaSamplePosition = thirdByte & 0b11;
// Logic from https://aomediacodec.github.io/av1-spec/av1-spec.pdf
const bitDepth = profile == 2 && highBitDepth ? (twelveBit ? 12 : 10) : (highBitDepth ? 10 : 8);
const bitDepth = profile === 2 && highBitDepth ? (twelveBit ? 12 : 10) : (highBitDepth ? 10 : 8);
track.info.av1CodecInfo = {
profile,
+23 -2
View File
@@ -122,9 +122,8 @@ export const LEVEL_0_EBML_IDS: EBMLId[] = [
EBMLId.Segment,
];
// All the stuff that can appear in a segment, basically
export const LEVEL_1_EBML_IDS: EBMLId[] = [
EBMLId.EBMLMaxIDLength,
EBMLId.EBMLMaxSizeLength,
EBMLId.SeekHead,
EBMLId.Info,
EBMLId.Cluster,
@@ -585,6 +584,28 @@ export class EBMLReader {
return null;
}
/** Searches for the next occurrence of an element ID using a naive byte-wise search. */
async resync(ids: EBMLId[], until: number) {
const loadChunkSize = 2 ** 20; // 1 MiB
const idsSet = new Set(ids);
while (this.pos <= until - MIN_HEADER_SIZE) {
if (!this.reader.rangeIsLoaded(this.pos, Math.min(this.pos + MAX_HEADER_SIZE, until))) {
await this.reader.loadRange(this.pos, Math.min(this.pos + loadChunkSize, until));
}
const elementStartPos = this.pos;
const elementId = this.readElementId();
if (elementId !== null && idsSet.has(elementId)) {
return elementStartPos;
}
this.pos = elementStartPos + 1;
}
return null;
}
}
export const CODEC_STRING_MAP: Partial<Record<MediaCodec, string>> = {
+47 -6
View File
@@ -56,6 +56,7 @@ import {
EBMLId,
EBMLReader,
LEVEL_0_AND_1_EBML_IDS,
LEVEL_1_EBML_IDS,
MAX_HEADER_SIZE,
MIN_HEADER_SIZE,
readVarInt,
@@ -175,6 +176,7 @@ const METADATA_ELEMENTS = [
{ id: EBMLId.Tracks, flag: 'tracksSeen' },
{ id: EBMLId.Cues, flag: 'cuesSeen' },
] as const;
const MAX_RESYNC_LENGTH = 10 * 2 ** 20; // 10 MiB
export class MatroskaDemuxer extends Demuxer {
metadataReader: EBMLReader;
@@ -330,8 +332,22 @@ export class MatroskaDemuxer extends Demuxer {
const elementStartPos = this.metadataReader.pos;
const header = this.metadataReader.readElementHeader();
if (!header) {
break;
if (!header || !LEVEL_1_EBML_IDS.includes(header.id)) {
// Potential junk. Let's try to resync
this.metadataReader.pos = elementStartPos;
const nextPos = await this.metadataReader.resync(
LEVEL_1_EBML_IDS,
Math.min(this.currentSegment.elementEndPos, this.metadataReader.pos + MAX_RESYNC_LENGTH),
);
if (nextPos) {
this.metadataReader.pos = nextPos;
continue;
} else {
break; // Resync failed
}
}
const { id, size } = header;
@@ -352,6 +368,10 @@ export class MatroskaDemuxer extends Demuxer {
}
}
if (size !== null) {
this.metadataReader.pos = dataStartPos + size;
}
if (this.currentSegment.infoSeen && this.currentSegment.tracksSeen && this.currentSegment.cuesSeen) {
// No need to search anymore, we have everything
break;
@@ -381,10 +401,15 @@ export class MatroskaDemuxer extends Demuxer {
if (size === null) {
break;
}
}
this.metadataReader.pos = dataStartPos + size;
if (!clusterEncountered) {
const seekEntry = this.currentSegment.seekEntries.find(entry => entry.id === EBMLId.Cluster);
if (!clusterEncountered) {
if (seekEntry) {
// The seek head points us to the first cluster, nice
this.currentSegment.clusterSeekStartPos = segmentDataStart + seekEntry.segmentPosition;
} else {
this.currentSegment.clusterSeekStartPos = this.metadataReader.pos;
}
}
@@ -1609,8 +1634,24 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
await metadataReader.reader.loadRange(metadataReader.pos, metadataReader.pos + MAX_HEADER_SIZE);
const elementStartPos = metadataReader.pos;
const elementHeader = metadataReader.readElementHeader();
if (!elementHeader) {
break;
if (!elementHeader || !LEVEL_1_EBML_IDS.includes(elementHeader.id)) {
// There's an element here that shouldn't be here (or Void). Might be garbage. In this case, let's
// try and resync to the next valid element.
metadataReader.pos = elementStartPos;
const nextPos = await metadataReader.resync(
LEVEL_1_EBML_IDS,
Math.min(segment.elementEndPos, metadataReader.pos + MAX_RESYNC_LENGTH),
);
if (nextPos) {
metadataReader.pos = nextPos;
continue;
} else {
break; // Resync failed
}
}
const id = elementHeader.id;
+8 -7
View File
@@ -1334,16 +1334,17 @@ export class AudioBufferSource extends AudioSource {
* @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise
* to respect writer and encoder backpressure.
*/
add(audioBuffer: AudioBuffer) {
async add(audioBuffer: AudioBuffer) {
if (!(audioBuffer instanceof AudioBuffer)) {
throw new TypeError('audioBuffer must be an AudioBuffer.');
}
const audioSamples = AudioSample.fromAudioBuffer(audioBuffer, this._accumulatedTime);
const promises = audioSamples.map(sample => this._encoder.add(sample, true));
const iterator = AudioSample._fromAudioBuffer(audioBuffer, this._accumulatedTime);
this._accumulatedTime += audioBuffer.duration;
return Promise.all(promises);
for (const audioSample of iterator) {
await this._encoder.add(audioSample, true);
}
}
/** @internal */
@@ -1466,10 +1467,10 @@ export class MediaStreamAudioTrackSource extends AudioSource {
let totalDuration = 0;
this._scriptProcessorNode.onaudioprocess = (event) => {
const audioSamples = AudioSample.fromAudioBuffer(event.inputBuffer, totalDuration);
const iterator = AudioSample._fromAudioBuffer(event.inputBuffer, totalDuration);
totalDuration += event.inputBuffer.duration;
for (const audioSample of audioSamples) {
for (const audioSample of iterator) {
if (!audioReceived) {
audioReceived = true;
+16
View File
@@ -73,6 +73,22 @@ export class Bitstream {
return result;
}
writeBits(n: number, value: number) {
const end = this.pos + n;
for (let i = this.pos; i < end; i++) {
const byteIndex = Math.floor(i / 8);
let byte = this.bytes[byteIndex]!;
const bitIndex = 0b111 - (i & 0b111);
byte &= ~(1 << bitIndex);
byte |= ((value & (1 << (end - i - 1))) >> (end - i - 1)) << bitIndex;
this.bytes[byteIndex] = byte;
}
this.pos = end;
};
readAlignedByte() {
// Ensure we're byte-aligned
if (this.pos % 8 !== 0) {
+66 -64
View File
@@ -32,7 +32,7 @@ export class Mp3Demuxer extends Demuxer {
tracks: InputAudioTrack[] = [];
loadingMutex = new AsyncMutex();
readingMutex = new AsyncMutex();
lastLoadedPos = 0;
fileSize = 0;
nextTimestampInSamples = 0;
@@ -53,9 +53,8 @@ export class Mp3Demuxer extends Demuxer {
await this.loadNextChunk();
}
if (!this.firstFrameHeader) {
throw new Error('No MP3 frames found.');
}
// There has to be a frame if this demuxer got selected
assert(this.firstFrameHeader);
this.tracks = [new InputAudioTrack(new Mp3AudioTrackBacking(this))];
})();
@@ -63,30 +62,24 @@ export class Mp3Demuxer extends Demuxer {
/** Loads the next 0.5 MiB of frames. */
async loadNextChunk() {
const release = await this.loadingMutex.acquire();
assert(this.lastLoadedPos < this.fileSize);
try {
assert(this.lastLoadedPos < this.fileSize);
const chunkSize = 0.5 * 1024 * 1024; // 0.5 MiB
const endPos = Math.min(this.lastLoadedPos + chunkSize, this.fileSize);
await this.reader.reader.loadRange(this.lastLoadedPos, endPos);
const chunkSize = 0.5 * 1024 * 1024; // 0.5 MiB
const endPos = Math.min(this.lastLoadedPos + chunkSize, this.fileSize);
await this.reader.reader.loadRange(this.lastLoadedPos, endPos);
this.lastLoadedPos = endPos;
assert(this.lastLoadedPos <= this.fileSize);
this.lastLoadedPos = endPos;
assert(this.lastLoadedPos <= this.fileSize);
if (this.reader.pos === 0) {
// First time, let's see if there's an ID3 tag
const id3Tag = this.reader.readId3();
if (id3Tag) {
this.reader.pos += id3Tag.size;
}
if (this.reader.pos === 0) {
// First time, let's see if there's an ID3 tag
const id3Tag = this.reader.readId3();
if (id3Tag) {
this.reader.pos += id3Tag.size;
}
this.parseFramesFromLoadedData();
} finally {
release();
}
this.parseFramesFromLoadedData();
}
private parseFramesFromLoadedData() {
@@ -232,58 +225,67 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
}
async getFirstPacket(options: PacketRetrievalOptions) {
// Ensure we have at least one frame loaded
while (this.demuxer.loadedSamples.length === 0 && this.demuxer.lastLoadedPos < this.demuxer.fileSize) {
await this.demuxer.loadNextChunk();
}
return this.getPacketAtIndex(0, options);
}
async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
const sampleIndex = binarySearchExact(
this.demuxer.loadedSamples,
packet.timestamp,
x => x.timestamp,
);
if (sampleIndex === -1) {
throw new Error('Packet was not created from this track.');
}
const release = await this.demuxer.readingMutex.acquire();
const nextIndex = sampleIndex + 1;
// Ensure the next sample exists
while (nextIndex >= this.demuxer.loadedSamples.length && this.demuxer.lastLoadedPos < this.demuxer.fileSize) {
await this.demuxer.loadNextChunk();
}
try {
const sampleIndex = binarySearchExact(
this.demuxer.loadedSamples,
packet.timestamp,
x => x.timestamp,
);
if (sampleIndex === -1) {
throw new Error('Packet was not created from this track.');
}
return this.getPacketAtIndex(nextIndex, options);
const nextIndex = sampleIndex + 1;
// Ensure the next sample exists
while (
nextIndex >= this.demuxer.loadedSamples.length
&& this.demuxer.lastLoadedPos < this.demuxer.fileSize
) {
await this.demuxer.loadNextChunk();
}
return this.getPacketAtIndex(nextIndex, options);
} finally {
release();
}
}
async getPacket(timestamp: number, options: PacketRetrievalOptions) {
while (true) {
const index = binarySearchLessOrEqual(
this.demuxer.loadedSamples,
timestamp,
x => x.timestamp,
);
const release = await this.demuxer.readingMutex.acquire();
try {
while (true) {
const index = binarySearchLessOrEqual(
this.demuxer.loadedSamples,
timestamp,
x => x.timestamp,
);
if (index === -1 && this.demuxer.loadedSamples.length > 0) {
// We're before the first sample
return null;
if (index === -1 && this.demuxer.loadedSamples.length > 0) {
// We're before the first sample
return null;
}
if (this.demuxer.lastLoadedPos === this.demuxer.fileSize) {
// All data is loaded, return what we found
return this.getPacketAtIndex(index, options);
}
if (index >= 0 && index + 1 < this.demuxer.loadedSamples.length) {
// The next packet also exists, we're done
return this.getPacketAtIndex(index, options);
}
// Otherwise, keep loading data
await this.demuxer.loadNextChunk();
}
if (this.demuxer.lastLoadedPos === this.demuxer.fileSize) {
// All data is loaded, return what we found
return this.getPacketAtIndex(index, options);
}
if (index >= 0 && index + 1 < this.demuxer.loadedSamples.length) {
// The next packet also exists, we're done
return this.getPacketAtIndex(index, options);
}
// Otherwise, keep loading data
await this.demuxer.loadNextChunk();
} finally {
release();
}
}
+72
View File
@@ -6,6 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { AdtsMuxer } from './adts/adts-muxer';
import {
AUDIO_CODECS,
AudioCodec,
@@ -705,3 +706,74 @@ export class OggOutputFormat extends OutputFormat {
return false;
}
}
/**
* ADTS-specific output options.
* @public
*/
export type AdtsOutputFormatOptions = {
/**
* Will be called for each ADTS frame that is written.
*
* @param data - The raw bytes.
* @param position - The byte offset of the data in the file.
*/
onFrame?: (data: Uint8Array, position: number) => unknown;
};
/**
* ADTS file format.
* @public
*/
export class AdtsOutputFormat extends OutputFormat {
/** @internal */
_options: AdtsOutputFormatOptions;
constructor(options: AdtsOutputFormatOptions = {}) {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
}
if (options.onFrame !== undefined && typeof options.onFrame !== 'function') {
throw new TypeError('options.onFrame, when provided, must be a function.');
}
super();
this._options = options;
}
/** @internal */
_createMuxer(output: Output) {
return new AdtsMuxer(output, this);
}
/** @internal */
get _name() {
return 'ADTS';
}
getSupportedTrackCounts(): TrackCountLimits {
return {
video: { min: 0, max: 0 },
audio: { min: 1, max: 1 },
subtitle: { min: 0, max: 0 },
total: { min: 1, max: 1 },
};
}
get fileExtension() {
return '.aac';
}
get mimeType() {
return 'audio/aac';
}
getSupportedCodecs(): MediaCodec[] {
return ['aac'];
}
get supportsVideoRotationMetadata() {
return false;
}
}
+46 -3
View File
@@ -1067,6 +1067,49 @@ export class AudioSample {
(this.timestamp as number) = newTimestamp;
}
/** @internal */
static* _fromAudioBuffer(audioBuffer: AudioBuffer, timestamp: number) {
if (!(audioBuffer instanceof AudioBuffer)) {
throw new TypeError('audioBuffer must be an AudioBuffer.');
}
const MAX_FLOAT_COUNT = 48000 * 5; // 5 seconds of mono 48 kHz audio per sample
const numberOfChannels = audioBuffer.numberOfChannels;
const sampleRate = audioBuffer.sampleRate;
const totalFrames = audioBuffer.length;
const maxFramesPerChunk = Math.floor(MAX_FLOAT_COUNT / numberOfChannels);
let currentRelativeFrame = 0;
let remainingFrames = totalFrames;
// Create AudioSamples in a chunked fashion so we don't create huge Float32Arrays
while (remainingFrames > 0) {
const framesToCopy = Math.min(maxFramesPerChunk, remainingFrames);
const chunkData = new Float32Array(numberOfChannels * framesToCopy);
for (let channel = 0; channel < numberOfChannels; channel++) {
audioBuffer.copyFromChannel(
chunkData.subarray(channel * framesToCopy, (channel + 1) * framesToCopy),
channel,
currentRelativeFrame,
);
}
yield new AudioSample({
format: 'f32-planar',
sampleRate,
numberOfFrames: framesToCopy,
numberOfChannels,
timestamp: timestamp + currentRelativeFrame / sampleRate,
data: chunkData,
});
currentRelativeFrame += framesToCopy;
remainingFrames -= framesToCopy;
}
}
/**
* Creates AudioSamples from an AudioBuffer, starting at the given timestamp in seconds. Typically creates exactly
* one sample, but may create multiple if the AudioBuffer is exceedingly large.
@@ -1076,7 +1119,7 @@ export class AudioSample {
throw new TypeError('audioBuffer must be an AudioBuffer.');
}
const MAX_FLOAT_COUNT = 64 * 1024 * 1024;
const MAX_FLOAT_COUNT = 48000 * 5; // 5 seconds of mono 48 kHz audio per sample
const numberOfChannels = audioBuffer.numberOfChannels;
const sampleRate = audioBuffer.sampleRate;
@@ -1088,14 +1131,14 @@ export class AudioSample {
const result: AudioSample[] = [];
// Create AudioData in a chunked fashion so we don't create huge Float32Arrays
// Create AudioSamples in a chunked fashion so we don't create huge Float32Arrays
while (remainingFrames > 0) {
const framesToCopy = Math.min(maxFramesPerChunk, remainingFrames);
const chunkData = new Float32Array(numberOfChannels * framesToCopy);
for (let channel = 0; channel < numberOfChannels; channel++) {
audioBuffer.copyFromChannel(
chunkData.subarray(channel * framesToCopy, channel * framesToCopy + framesToCopy),
chunkData.subarray(channel * framesToCopy, (channel + 1) * framesToCopy),
channel,
currentRelativeFrame,
);
+5 -1
View File
@@ -314,7 +314,11 @@ export class UrlSource extends Source {
} else if (rangeResponse.status === 200) {
// The server just returned the whole thing
this._fullData = await rangeResponse.arrayBuffer();
return this._fullData.byteLength;
if (this._fullData.byteLength !== 1) {
return this._fullData.byteLength;
} else {
// The server responded with 200, but returned only the requested range, so skip the response
}
}
// If the range request didn't provide the size, make a full GET request