mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-04 06:13:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4b97c1731 | ||
|
|
5782219941 | ||
|
|
b265826b7e | ||
|
|
c9633d26d7 | ||
|
|
6e802bdf0b | ||
|
|
9054b8e381 | ||
|
|
8b9f22ba46 | ||
|
|
8da619a624 | ||
|
|
1ce5e108a7 | ||
|
|
0cb466ebb1 | ||
|
|
3be94cc788 | ||
|
|
89df8fdae2 | ||
|
|
3acb9e8f99 | ||
|
|
716b864fbf | ||
|
|
bea53d9a2b | ||
|
|
4521a65587 | ||
|
|
5c598e35c2 | ||
|
|
c2292c8304 | ||
|
|
84219b3ed4 | ||
|
|
88b0b5f46f | ||
|
|
0348eda8a9 |
@@ -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
@@ -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
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+6
-6
@@ -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
@@ -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": [
|
||||
|
||||
@@ -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 \
|
||||
|
||||
Generated
+1
-1
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -35,6 +35,8 @@ export {
|
||||
WavOutputFormatOptions,
|
||||
OggOutputFormat,
|
||||
OggOutputFormatOptions,
|
||||
AdtsOutputFormat,
|
||||
AdtsOutputFormatOptions,
|
||||
TrackCountLimits,
|
||||
InclusiveIntegerRange,
|
||||
} from './output-format';
|
||||
|
||||
+56
-1
@@ -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];
|
||||
|
||||
@@ -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
@@ -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>> = {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user