diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31b076b..441a113 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,7 +59,28 @@ jobs: - name: Upload build artifacts env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release upload ${{ github.event.release.tag_name }} dist/bundles/mediabunny.cjs dist/bundles/mediabunny.min.cjs dist/bundles/mediabunny.mjs dist/bundles/mediabunny.min.mjs dist/mediabunny.d.ts packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.mjs packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.mjs packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts packages/ac3/dist/bundles/mediabunny-ac3.js packages/ac3/dist/bundles/mediabunny-ac3.min.js packages/ac3/dist/bundles/mediabunny-ac3.mjs packages/ac3/dist/bundles/mediabunny-ac3.min.mjs packages/ac3/dist/mediabunny-ac3.d.ts + run: > + gh release upload ${{ github.event.release.tag_name }} + dist/bundles/mediabunny.cjs + dist/bundles/mediabunny.min.cjs + dist/bundles/mediabunny.mjs + dist/bundles/mediabunny.min.mjs + dist/mediabunny.d.ts + packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js + packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.js + packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.mjs + packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.mjs + packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts + packages/ac3/dist/bundles/mediabunny-ac3.js + packages/ac3/dist/bundles/mediabunny-ac3.min.js + packages/ac3/dist/bundles/mediabunny-ac3.mjs + packages/ac3/dist/bundles/mediabunny-ac3.min.mjs + packages/ac3/dist/mediabunny-ac3.d.ts + packages/aac-encoder/dist/bundles/mediabunny-aac-encoder.js + packages/aac-encoder/dist/bundles/mediabunny-aac-encoder.min.js + packages/aac-encoder/dist/bundles/mediabunny-aac-encoder.mjs + packages/aac-encoder/dist/bundles/mediabunny-aac-encoder.min.mjs + packages/aac-encoder/dist/mediabunny-aac-encoder.d.ts - name: Publish Mediabunny to npm run: npm publish --access public diff --git a/.gitignore b/.gitignore index 289f126..e4b72d3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ node_modules *.tsbuildinfo packages/mp3-encoder/dist -packages/ac3/dist \ No newline at end of file +packages/ac3/dist +packages/aac-encoder/dist \ No newline at end of file diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 7452915..a050b49 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -93,6 +93,7 @@ export default withMermaid({ text: 'Extensions', items: [ { text: 'mp3-encoder', link: '/guide/extensions/mp3-encoder' }, + { text: 'aac-encoder', link: '/guide/extensions/aac-encoder' }, { text: 'ac3', link: '/guide/extensions/ac3' }, ], }, diff --git a/docs/api-config.json b/docs/api-config.json index 202768f..53ba487 100644 --- a/docs/api-config.json +++ b/docs/api-config.json @@ -20,5 +20,6 @@ "Miscellaneous": "Whatever's left.", "@mediabunny/mp3-encoder": "Adds MP3 encoder support to Mediabunny.", - "@mediabunny/ac3": "Adds AC-3/E-AC-3 decoder and encoder support to Mediabunny." + "@mediabunny/ac3": "Adds AC-3/E-AC-3 decoder and encoder support to Mediabunny.", + "@mediabunny/aac-encoder": "Adds AAC encoder support to Mediabunny." } diff --git a/docs/guide/extensions/aac-encoder.md b/docs/guide/extensions/aac-encoder.md new file mode 100644 index 0000000..a8b3488 --- /dev/null +++ b/docs/guide/extensions/aac-encoder.md @@ -0,0 +1,82 @@ +# @mediabunny/aac-encoder + +Some browsers lack support for AAC encoding in their WebCodecs implementations. This extension package provides a reliable AAC-LC encoder for use with Mediabunny. It is implemented using Mediabunny's [custom coder API](../supported-formats-and-codecs#custom-coders) and uses a fast, size-optimized WASM build of [FFmpeg](https://ffmpeg.org/)'s AAC encoder under the hood. + + + GitHub page + + + +## Installation + +This library peer-depends on Mediabunny. Install both using npm: +```bash +npm install mediabunny @mediabunny/aac-encoder +``` + +Alternatively, directly include them using a script tag: +```html + + +``` + +This will expose the global objects `Mediabunny` and `MediabunnyAacEncoder`. Use `mediabunny-aac-encoder.d.ts` to provide types for these globals. You can download the built distribution files from the [releases page](https://github.com/Vanilagy/mediabunny/releases). + +## Usage + +```ts +import { registerAacEncoder } from '@mediabunny/aac-encoder'; + +registerAacEncoder(); +``` +That's it - Mediabunny now uses the registered AAC encoder automatically. + +If you want to be more correct, check for native browser support first: +```ts +import { canEncodeAudio } from 'mediabunny'; +import { registerAacEncoder } from '@mediabunny/aac-encoder'; + +if (!(await canEncodeAudio('aac'))) { + registerAacEncoder(); +} +``` + +## Example + +Here, we convert an input file to an MP4 with AAC audio: + +```ts +import { + Input, + ALL_FORMATS, + BlobSource, + Output, + BufferTarget, + Mp4OutputFormat, + canEncodeAudio, + Conversion, +} from 'mediabunny'; +import { registerAacEncoder } from '@mediabunny/aac-encoder'; + +if (!(await canEncodeAudio('aac'))) { + // Only register the custom encoder if there's no native support + registerAacEncoder(); +} + +const input = new Input({ + source: new BlobSource(file), // From a file picker, for example + formats: ALL_FORMATS, +}); +const output = new Output({ + format: new Mp4OutputFormat(), + target: new BufferTarget(), +}); + +const conversion = await Conversion.init({ + input, + output, +}); +await conversion.execute(); + +output.target.buffer; // => ArrayBuffer containing the MP4 file +``` diff --git a/docs/guide/supported-formats-and-codecs.md b/docs/guide/supported-formats-and-codecs.md index dbaf533..1e2f5c3 100644 --- a/docs/guide/supported-formats-and-codecs.md +++ b/docs/guide/supported-formats-and-codecs.md @@ -37,13 +37,13 @@ Mediabunny ships with built-in decoders and encoders for all audio PCM codecs, m ### Audio codecs -- `'aac'` - Advanced Audio Coding (AAC) +- `'aac'` - Advanced Audio Coding (AAC) [^1] - `'opus'` - Opus -- `'mp3'` - MP3 +- `'mp3'` - MP3 [^2] - `'vorbis'` - Vorbis - `'flac'` - Free Lossless Audio Codec (FLAC) -- `'ac3'` - Dolby Digital (AC-3) [^1] -- `'eac3'` - Dolby Digital Plus (E-AC-3) [^1] +- `'ac3'` - Dolby Digital (AC-3) [^3] +- `'eac3'` - Dolby Digital Plus (E-AC-3) [^3] - `'pcm-u8'` - 8-bit unsigned PCM - `'pcm-s8'` - 8-bit signed PCM - `'pcm-s16'` - 16-bit little-endian signed PCM @@ -59,8 +59,6 @@ Mediabunny ships with built-in decoders and encoders for all audio PCM codecs, m - `'ulaw'` - μ-law PCM - `'alaw'` - A-law PCM -[^1]: AC-3 and E-AC-3 are not natively supported by WebCodecs. To encode or decode these codecs, you can use the [`@mediabunny/ac3`](./extensions/ac3) extension package, or provide your own [custom coder](#custom-coders). - ### Subtitle codecs - `'webvtt'` - WebVTT @@ -69,7 +67,7 @@ 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[^2] | .ogg | .mp3 | .wav | .aac | .flac | .ts | +| | .mp4 | .mov | .mkv | .webm[^4] | .ogg | .mp3 | .wav | .aac | .flac | .ts | |:--------------:|:--------:|:-----:|:-----:|:---------:|:-----:|:-----:|:-----:|:-----:|:-----:|:-----:| | `'avc'` | ✓ | ✓ | ✓ | | | | | | | ✓ | | `'hevc'` | ✓ | ✓ | ✓ | | | | | | | ✓ | @@ -97,11 +95,13 @@ Not all codecs can be used with all containers. The following table specifies th | `'pcm-f64be'` | ✓ | ✓ | | | | | | | | | | `'ulaw'` | | ✓ | | | | | ✓ | | | | | `'alaw'` | | ✓ | | | | | ✓ | | | | -| `'webvtt'`[^3] | (✓) | | (✓) | (✓) | | | | | | | +| `'webvtt'`[^5] | (✓) | | (✓) | (✓) | | | | | | | - -[^2]: 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. -[^3]: WebVTT can only be written, not read. +[^1]: In some browsers, AAC encoding is not supported by WebCodecs. You can polyfill it with the [`@mediabunny/aac-encoder`](./extensions/aac-encoder) extension package, or provide your own [custom coder](#custom-coders). +[^2]: MP3 encoding is not supported by WebCodecs. You can polyfill it with the [`@mediabunny/mp3-encoder`](./extensions/mp3-encoder) extension package, or provide your own [custom coder](#custom-coders). +[^3]: AC-3 and E-AC-3 are not natively supported by WebCodecs. To encode or decode these codecs, you can use the [`@mediabunny/ac3`](./extensions/ac3) extension package, or provide your own [custom coder](#custom-coders). +[^4]: 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. +[^5]: WebVTT can only be written, not read. ## Querying codec encodability diff --git a/eslint.config.mjs b/eslint.config.mjs index fa924e2..95f80b1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -40,6 +40,8 @@ export default tseslint.config( 'packages/mp3-encoder/build', 'packages/ac3/dist', 'packages/ac3/build', + 'packages/aac-encoder/dist', + 'packages/aac-encoder/build', 'eslint.config.mjs', 'docs/.vitepress/cache', 'test/public', diff --git a/package-lock.json b/package-lock.json index 7fb6a76..4cdbc8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1413,6 +1413,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mediabunny/aac-encoder": { + "resolved": "packages/aac-encoder", + "link": true + }, "node_modules/@mediabunny/ac3": { "resolved": "packages/ac3", "link": true @@ -12067,6 +12071,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/aac-encoder": { + "name": "@mediabunny/aac-encoder", + "version": "1.35.0", + "license": "MPL-2.0", + "devDependencies": { + "@types/emscripten": "^1.40.1" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, "packages/ac3": { "name": "@mediabunny/ac3", "version": "1.35.1", diff --git a/package.json b/package.json index 15fe549..536af7e 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "docs:dev": "vitepress dev docs", "docs:build": "npm run build && npm run docs:generate && vitepress build docs && npm run examples:build && cp dist/mediabunny.d.ts dist-docs/", "docs:preview": "vitepress preview docs", - "docs:generate": "tsx scripts/generate-api-docs.ts src/index.ts packages/mp3-encoder/src/index.ts packages/ac3/src/index.ts docs/api-config.json", + "docs:generate": "tsx scripts/generate-api-docs.ts src/index.ts packages/mp3-encoder/src/index.ts packages/ac3/src/index.ts packages/aac-encoder/src/index.ts docs/api-config.json", "dev": "vite", "examples:build": "vite build", "fix-build-import-paths": "tsx scripts/add-import-extensions.ts", diff --git a/packages/aac-encoder/.gitattributes b/packages/aac-encoder/.gitattributes new file mode 100644 index 0000000..72ef19a --- /dev/null +++ b/packages/aac-encoder/.gitattributes @@ -0,0 +1 @@ +build/* linguist-generated diff --git a/packages/aac-encoder/LICENSE b/packages/aac-encoder/LICENSE new file mode 100644 index 0000000..d0a1fa1 --- /dev/null +++ b/packages/aac-encoder/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + 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/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/packages/aac-encoder/README.md b/packages/aac-encoder/README.md new file mode 100644 index 0000000..69062c2 --- /dev/null +++ b/packages/aac-encoder/README.md @@ -0,0 +1,156 @@ +# @mediabunny/aac-encoder + +[![](https://img.shields.io/npm/v/@mediabunny/aac-encoder)](https://www.npmjs.com/package/@mediabunny/aac-encoder) +[![](https://img.shields.io/bundlephobia/minzip/@mediabunny/aac-encoder)](https://bundlephobia.com/package/@mediabunny/aac-encoder) +[![](https://img.shields.io/npm/dm/@mediabunny/aac-encoder)](https://www.npmjs.com/package/@mediabunny/aac-encoder) +[![](https://img.shields.io/discord/1390044844285497344?logo=discord&label=Discord)](https://discord.gg/hmpkyYuS4U) + +
+ +
+ +Some browsers lack support for AAC encoding in their WebCodecs implementations. This extension package provides a reliable AAC-LC encoder for use with [Mediabunny](https://github.com/Vanilagy/mediabunny). It is implemented using Mediabunny's [custom coder API](https://mediabunny.dev/guide/supported-formats-and-codecs#custom-coders) and uses a fast, size-optimized WASM build of [FFmpeg](https://ffmpeg.org/)'s AAC encoder under the hood. + +> This package, like the rest of Mediabunny, is enabled by its [sponsors](https://mediabunny.dev/#sponsors) and their donations. If you've derived value from this package, please consider [leaving a donation](https://github.com/sponsors/Vanilagy)! 💘 + +## Installation + +This library peer-depends on Mediabunny. Install both using npm: +```bash +npm install mediabunny @mediabunny/aac-encoder +``` + +Alternatively, directly include them using a script tag: +```html + + +``` + +This will expose the global objects `Mediabunny` and `MediabunnyAacEncoder`. Use `mediabunny-aac-encoder.d.ts` to provide types for these globals. You can download the built distribution files from the [releases page](https://github.com/Vanilagy/mediabunny/releases). + +## Usage + +```ts +import { registerAacEncoder } from '@mediabunny/aac-encoder'; + +registerAacEncoder(); +``` +That's it - Mediabunny now uses the registered AAC encoder automatically. + +If you want to be more correct, check for native browser support first: +```ts +import { canEncodeAudio } from 'mediabunny'; +import { registerAacEncoder } from '@mediabunny/aac-encoder'; + +if (!(await canEncodeAudio('aac'))) { + registerAacEncoder(); +} +``` + +## Example + +Here, we convert an input file to an MP4 with AAC audio: + +```ts +import { + Input, + ALL_FORMATS, + BlobSource, + Output, + BufferTarget, + Mp4OutputFormat, + canEncodeAudio, + Conversion, +} from 'mediabunny'; +import { registerAacEncoder } from '@mediabunny/aac-encoder'; + +if (!(await canEncodeAudio('aac'))) { + // Only register the custom encoder if there's no native support + registerAacEncoder(); +} + +const input = new Input({ + source: new BlobSource(file), // From a file picker, for example + formats: ALL_FORMATS, +}); +const output = new Output({ + format: new Mp4OutputFormat(), + target: new BufferTarget(), +}); + +const conversion = await Conversion.init({ + input, + output, +}); +await conversion.execute(); + +output.target.buffer; // => ArrayBuffer containing the MP4 file +``` + +For more ways of using Mediabunny, refer to its [guide](https://mediabunny.dev/guide/introduction). + +## Building and development + +For simplicity, all built WASM artifacts are included in the repo, since these rarely change. However, here are the instructions for building them from scratch: + +[Install Emscripten](https://emscripten.org/docs/getting_started/downloads.html) and clone [FFmpeg](https://github.com/FFmpeg/FFmpeg). Then, from the Mediabunny root and with Emscripten sourced in: + +```bash +export FFMPEG_PATH=/path/to/ffmpeg +export MEDIABUNNY_ROOT=$PWD + +# Build FFmpeg +cd $FFMPEG_PATH +emmake make distclean +emconfigure ./configure \ + --target-os=none \ + --arch=x86_32 \ + --enable-cross-compile \ + --disable-asm \ + --disable-x86asm \ + --disable-inline-asm \ + --disable-programs \ + --disable-doc \ + --disable-debug \ + --disable-all \ + --disable-everything \ + --disable-autodetect \ + --disable-pthreads \ + --disable-runtime-cpudetect \ + --enable-avcodec \ + --enable-encoder=aac \ + --cc="emcc" \ + --cxx=em++ \ + --ar=emar \ + --ranlib=emranlib \ + --extra-cflags="-DNDEBUG -Oz -flto -msimd128" \ + --extra-ldflags="-Oz -flto" +emmake make + +# Compile the bridge between JavaScript and FFmpeg's API +cd $MEDIABUNNY_ROOT/packages/aac-encoder +emcc src/bridge.c \ + $FFMPEG_PATH/libavcodec/libavcodec.a \ + $FFMPEG_PATH/libavutil/libavutil.a \ + -I$FFMPEG_PATH \ + -s MODULARIZE=1 \ + -s EXPORT_ES6=1 \ + -s SINGLE_FILE=1 \ + -s ALLOW_MEMORY_GROWTH=1 \ + -s ENVIRONMENT=web,worker \ + -s FILESYSTEM=0 \ + -s MALLOC=emmalloc \ + -s SUPPORT_LONGJMP=0 \ + -s EXPORTED_RUNTIME_METHODS=cwrap,HEAPU8 \ + -s EXPORTED_FUNCTIONS=_malloc,_free \ + -msimd128 \ + -flto \ + -Oz \ + -o build/aac.js +``` + +This generates `build/aac.js`, which contains both the JavaScript "glue code" as well as the compiled WASM inlined. + +### Building the package + +Then, the complete JavaScript package can be built alongside the rest of Mediabunny by running `npm run build` in Mediabunny's root. diff --git a/packages/aac-encoder/api-extractor.json b/packages/aac-encoder/api-extractor.json new file mode 100644 index 0000000..6908bb7 --- /dev/null +++ b/packages/aac-encoder/api-extractor.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "dist/modules/src/index.d.ts", + "bundledPackages": [], + "compiler": {}, + "apiReport": { + "enabled": false + }, + "docModel": { + "enabled": false + }, + "dtsRollup": { + "enabled": true, + "untrimmedFilePath": "dist/mediabunny-aac-encoder.d.ts" + }, + "tsdocMetadata": { + "enabled": false + }, + "messages": { + "compilerMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + "extractorMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + } + } + }, + "newlineKind": "lf" +} diff --git a/packages/aac-encoder/build/aac.js b/packages/aac-encoder/build/aac.js new file mode 100644 index 0000000..9bd7d8d Binary files /dev/null and b/packages/aac-encoder/build/aac.js differ diff --git a/packages/aac-encoder/package.json b/packages/aac-encoder/package.json new file mode 100644 index 0000000..5f7560f --- /dev/null +++ b/packages/aac-encoder/package.json @@ -0,0 +1,55 @@ +{ + "name": "@mediabunny/aac-encoder", + "author": "Vanilagy", + "version": "1.35.1", + "description": "AAC encoder extension for Mediabunny, based on FFmpeg.", + "main": "./dist/bundles/mediabunny-aac-encoder.mjs", + "module": "./dist/bundles/mediabunny-aac-encoder.mjs", + "types": "./dist/modules/src/index.d.ts", + "exports": { + "types": "./dist/modules/src/index.d.ts", + "import": "./dist/bundles/mediabunny-aac-encoder.mjs", + "require": "./dist/bundles/mediabunny-aac-encoder.mjs" + }, + "files": [ + "README.md", + "package.json", + "LICENSE", + "dist", + "src" + ], + "browser": { + "worker_threads": false + }, + "sideEffects": false, + "license": "MPL-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/Vanilagy/mediabunny.git", + "directory": "packages/aac-encoder" + }, + "bugs": { + "url": "https://github.com/Vanilagy/mediabunny/issues" + }, + "homepage": "https://mediabunny.dev/guide/extensions/aac-encoder", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + }, + "devDependencies": { + "@types/emscripten": "^1.40.1" + }, + "keywords": [ + "aac", + "encoding", + "codec", + "mediabunny", + "ffmpeg", + "browser", + "wasm", + "polyfill" + ] +} diff --git a/packages/aac-encoder/src/bridge.c b/packages/aac-encoder/src/bridge.c new file mode 100644 index 0000000..0306e38 --- /dev/null +++ b/packages/aac-encoder/src/bridge.c @@ -0,0 +1,190 @@ +/*! + * Copyright (c) 2026-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/. + */ + +#include +#include +#include +#include "libavcodec/avcodec.h" +#include "libavutil/opt.h" +#include "libavutil/channel_layout.h" +#include "libavutil/log.h" + +typedef struct { + AVCodecContext *codec_ctx; + AVPacket *packet; + AVFrame *frame; + float *input_buffer; + int input_buffer_size; + int encoded_pts; + int encoded_duration; +} EncoderContext; + +EMSCRIPTEN_KEEPALIVE +EncoderContext *init_encoder(int channels, int sample_rate, int bitrate) { + av_log_set_level(AV_LOG_ERROR); + + const AVCodec *codec = avcodec_find_encoder(AV_CODEC_ID_AAC); + if (!codec) return NULL; + + AVCodecContext *codec_ctx = avcodec_alloc_context3(codec); + if (!codec_ctx) return NULL; + + codec_ctx->sample_fmt = AV_SAMPLE_FMT_FLTP; + codec_ctx->sample_rate = sample_rate; + codec_ctx->bit_rate = bitrate; + codec_ctx->time_base = (AVRational){1, sample_rate}; + + AVChannelLayout layout; + av_channel_layout_default(&layout, channels); + av_channel_layout_copy(&codec_ctx->ch_layout, &layout); + av_channel_layout_uninit(&layout); + + if (avcodec_open2(codec_ctx, codec, NULL) < 0) { + avcodec_free_context(&codec_ctx); + return NULL; + } + + AVPacket *packet = av_packet_alloc(); + if (!packet) { + avcodec_free_context(&codec_ctx); + return NULL; + } + + AVFrame *frame = av_frame_alloc(); + if (!frame) { + av_packet_free(&packet); + avcodec_free_context(&codec_ctx); + return NULL; + } + + frame->format = AV_SAMPLE_FMT_FLTP; + frame->sample_rate = sample_rate; + frame->nb_samples = codec_ctx->frame_size; + av_channel_layout_copy(&frame->ch_layout, &codec_ctx->ch_layout); + + if (av_frame_get_buffer(frame, 0) < 0) { + av_frame_free(&frame); + av_packet_free(&packet); + avcodec_free_context(&codec_ctx); + return NULL; + } + + EncoderContext *ctx = malloc(sizeof(EncoderContext)); + if (!ctx) { + av_frame_free(&frame); + av_packet_free(&packet); + avcodec_free_context(&codec_ctx); + return NULL; + } + + ctx->codec_ctx = codec_ctx; + ctx->packet = packet; + ctx->frame = frame; + ctx->input_buffer = NULL; + ctx->input_buffer_size = 0; + ctx->encoded_pts = 0; + ctx->encoded_duration = 0; + + return ctx; +} + +EMSCRIPTEN_KEEPALIVE +int get_encoder_frame_size(EncoderContext *ctx) { + return ctx->codec_ctx->frame_size; +} + +EMSCRIPTEN_KEEPALIVE +uint8_t *get_encoder_extradata(EncoderContext *ctx) { + return ctx->codec_ctx->extradata; +} + +EMSCRIPTEN_KEEPALIVE +int get_encoder_extradata_size(EncoderContext *ctx) { + return ctx->codec_ctx->extradata_size; +} + +EMSCRIPTEN_KEEPALIVE +float *get_encode_input_ptr(EncoderContext *ctx, int size) { + if (ctx->input_buffer_size < size) { + free(ctx->input_buffer); + ctx->input_buffer = malloc(size); + if (!ctx->input_buffer) { + ctx->input_buffer_size = 0; + return NULL; + } + ctx->input_buffer_size = size; + } + return ctx->input_buffer; +} + +EMSCRIPTEN_KEEPALIVE +int send_frame(EncoderContext *ctx, int pts) { + int channels = ctx->codec_ctx->ch_layout.nb_channels; + int frame_size = ctx->frame->nb_samples; + + ctx->frame->pts = pts; + + // Deinterleave f32 input into the frame's f32-planar planes + float *input = ctx->input_buffer; + for (int ch = 0; ch < channels; ch++) { + float *plane = (float *)ctx->frame->data[ch]; + for (int i = 0; i < frame_size; i++) { + plane[i] = input[i * channels + ch]; + } + } + + return avcodec_send_frame(ctx->codec_ctx, ctx->frame); +} + +EMSCRIPTEN_KEEPALIVE +int receive_packet(EncoderContext *ctx) { + int ret = avcodec_receive_packet(ctx->codec_ctx, ctx->packet); + if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) { + return 0; + } + if (ret < 0) return ret; + + ctx->encoded_pts = ctx->packet->pts; + ctx->encoded_duration = ctx->packet->duration; + + return ctx->packet->size; +} + +EMSCRIPTEN_KEEPALIVE +void flush_encoder_start(EncoderContext *ctx) { + avcodec_send_frame(ctx->codec_ctx, NULL); +} + +EMSCRIPTEN_KEEPALIVE +void reset_encoder(EncoderContext *ctx) { + avcodec_flush_buffers(ctx->codec_ctx); +} + +EMSCRIPTEN_KEEPALIVE +uint8_t *get_encoded_data(EncoderContext *ctx) { + return ctx->packet->data; +} + +EMSCRIPTEN_KEEPALIVE +int get_encoded_pts(EncoderContext *ctx) { + return ctx->encoded_pts; +} + +EMSCRIPTEN_KEEPALIVE +int get_encoded_duration(EncoderContext *ctx) { + return ctx->encoded_duration; +} + +EMSCRIPTEN_KEEPALIVE +void close_encoder(EncoderContext *ctx) { + free(ctx->input_buffer); + av_frame_free(&ctx->frame); + av_packet_free(&ctx->packet); + avcodec_free_context(&ctx->codec_ctx); + free(ctx); +} diff --git a/packages/aac-encoder/src/encode.worker.ts b/packages/aac-encoder/src/encode.worker.ts new file mode 100644 index 0000000..d109920 --- /dev/null +++ b/packages/aac-encoder/src/encode.worker.ts @@ -0,0 +1,204 @@ +/*! + * Copyright (c) 2026-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 createModule from '../build/aac'; +import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared'; + +type ExtendedEmscriptenModule = EmscriptenModule & { + cwrap: typeof cwrap; +}; + +let module: ExtendedEmscriptenModule; +let modulePromise: Promise | null = null; + +let initEncoderFn: (channels: number, sampleRate: number, bitrate: number) => number; +let getEncoderFrameSize: (ctx: number) => number; +let getEncoderExtradata: (ctx: number) => number; +let getEncoderExtradataSize: (ctx: number) => number; +let getEncodeInputPtr: (ctx: number, size: number) => number; +let sendFrameFn: (ctx: number, pts: number) => number; +let receivePacketFn: (ctx: number) => number; +let flushEncoderStartFn: (ctx: number) => void; +let resetEncoderFn: (ctx: number) => void; +let getEncodedData: (ctx: number) => number; +let getEncodedPts: (ctx: number) => number; +let getEncodedDuration: (ctx: number) => number; +let closeEncoderFn: (ctx: number) => void; + +const ensureModule = async () => { + if (!module) { + if (modulePromise) { + return modulePromise; + } + + modulePromise = createModule() as Promise; + module = await modulePromise; + modulePromise = null; + + initEncoderFn = module.cwrap('init_encoder', 'number', ['number', 'number', 'number']); + getEncoderFrameSize = module.cwrap('get_encoder_frame_size', 'number', ['number']); + getEncoderExtradata = module.cwrap('get_encoder_extradata', 'number', ['number']); + getEncoderExtradataSize = module.cwrap('get_encoder_extradata_size', 'number', ['number']); + getEncodeInputPtr = module.cwrap('get_encode_input_ptr', 'number', ['number', 'number']); + sendFrameFn = module.cwrap('send_frame', 'number', ['number', 'number']); + receivePacketFn = module.cwrap('receive_packet', 'number', ['number']); + flushEncoderStartFn = module.cwrap('flush_encoder_start', null, ['number']); + resetEncoderFn = module.cwrap('reset_encoder', null, ['number']); + getEncodedData = module.cwrap('get_encoded_data', 'number', ['number']); + getEncodedPts = module.cwrap('get_encoded_pts', 'number', ['number']); + getEncodedDuration = module.cwrap('get_encoded_duration', 'number', ['number']); + closeEncoderFn = module.cwrap('close_encoder', null, ['number']); + } +}; + +const initEncoder = async ( + numberOfChannels: number, + sampleRate: number, + bitrate: number, +) => { + await ensureModule(); + + const ctx = initEncoderFn(numberOfChannels, sampleRate, bitrate); + if (ctx === 0) { + throw new Error('Failed to initialize AAC encoder.'); + } + + const frameSize = getEncoderFrameSize(ctx); + + const extradataPtr = getEncoderExtradata(ctx); + const extradataSize = getEncoderExtradataSize(ctx); + const extradata = module.HEAPU8.slice(extradataPtr, extradataPtr + extradataSize).buffer; + + return { ctx, frameSize, extradata }; +}; + +type PacketInfo = { encodedData: ArrayBuffer; pts: number; duration: number }; + +const drainPackets = (ctx: number) => { + const packets: PacketInfo[] = []; + + let size: number; + while ((size = receivePacketFn(ctx)) > 0) { + const ptr = getEncodedData(ctx); + const encodedData = module.HEAPU8.slice(ptr, ptr + size).buffer; + const pts = getEncodedPts(ctx); + const duration = getEncodedDuration(ctx); + packets.push({ encodedData, pts, duration }); + } + + return packets; +}; + +const encode = (ctx: number, audioData: ArrayBuffer, timestamp: number) => { + const audioBytes = new Uint8Array(audioData); + + const inputPtr = getEncodeInputPtr(ctx, audioBytes.length); + if (inputPtr === 0) { + throw new Error('Failed to allocate encoder input buffer.'); + } + module.HEAPU8.set(audioBytes, inputPtr); + + const ret = sendFrameFn(ctx, timestamp); + if (ret < 0) { + throw new Error(`Encode failed with error code ${ret}.`); + } + + return drainPackets(ctx); +}; + +const onMessage = (data: { id: number; command: WorkerCommand }) => { + const { id, command } = data; + + const handleCommand = async (): Promise => { + try { + let result: WorkerResponseData; + const transferables: Transferable[] = []; + + switch (command.type) { + case 'init': { + const { ctx, frameSize, extradata } = await initEncoder( + command.data.numberOfChannels, + command.data.sampleRate, + command.data.bitrate, + ); + result = { type: command.type, ctx, frameSize, extradata }; + transferables.push(extradata); + }; break; + + case 'encode': { + const packets = encode( + command.data.ctx, + command.data.audioData, + command.data.timestamp, + ); + for (const p of packets) { + transferables.push(p.encodedData); + } + result = { type: command.type, packets }; + }; break; + + case 'flush': { + flushEncoderStartFn(command.data.ctx); + const packets = drainPackets(command.data.ctx); + for (const p of packets) { + transferables.push(p.encodedData); + } + resetEncoderFn(command.data.ctx); + result = { type: command.type, packets }; + }; break; + + case 'close': { + closeEncoderFn(command.data.ctx); + result = { type: command.type }; + }; break; + } + + const response: WorkerResponse = { + id, + success: true, + data: result, + }; + sendMessage(response, transferables); + } catch (error: unknown) { + const response: WorkerResponse = { + id, + success: false, + error, + }; + sendMessage(response); + } + }; + + void handleCommand(); +}; + +const sendMessage = (data: unknown, transferables?: Transferable[]) => { + if (parentPort) { + parentPort.postMessage(data, transferables ?? []); + } else { + self.postMessage(data, { transfer: transferables ?? [] }); + } +}; + +let parentPort: { + postMessage: (data: unknown, transferables?: Transferable[]) => void; + on: (event: string, listener: (data: never) => void) => void; +} | null = null; + +if (typeof self === 'undefined') { + const workerModule = 'worker_threads'; + // eslint-disable-next-line @stylistic/max-len + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-member-access + parentPort = require(workerModule).parentPort; +} + +if (parentPort) { + parentPort.on('message', onMessage); +} else { + self.addEventListener('message', event => onMessage(event.data as { id: number; command: WorkerCommand })); +} diff --git a/packages/aac-encoder/src/encoder.ts b/packages/aac-encoder/src/encoder.ts new file mode 100644 index 0000000..6f2d681 --- /dev/null +++ b/packages/aac-encoder/src/encoder.ts @@ -0,0 +1,306 @@ +/*! + * Copyright (c) 2026-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 { + type AdtsHeaderTemplate, + buildAdtsHeaderTemplate, + parseAacAudioSpecificConfig, + writeAdtsFrameLength, +} from '../../../shared/aac-misc'; +import { + CustomAudioEncoder, + AudioCodec, + AudioSample, + EncodedPacket, + registerEncoder, +} from 'mediabunny'; +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'; + +const AAC_SAMPLE_RATES = [ + 96000, 88200, 64000, 48000, 44100, 32000, + 24000, 22050, 16000, 12000, 11025, 8000, 7350, +]; + +class AacEncoder extends CustomAudioEncoder { + private worker: Worker | null = null; + private nextMessageId = 0; + private pendingMessages = new Map void; + reject: (reason?: unknown) => void; + }>(); + + private ctx = 0; + private encoderFrameSize = 0; + private sampleRate = 0; + private numberOfChannels = 0; + private chunkMetadata: EncodedAudioChunkMetadata = {}; + private useAdts = false; + private adtsHeaderTemplate: AdtsHeaderTemplate | null = null; + private description: Uint8Array | null = null; + + // Accumulate interleaved f32 samples until we have a full frame + private pendingBuffer = new Float32Array(2 ** 16); + private pendingFrames = 0; + private nextSampleTimestampInSamples: number | null = null; + private nextPacketTimestampInSamples: number | null = null; + + static override supports(codec: AudioCodec, config: AudioEncoderConfig): boolean { + return codec === 'aac' + && config.numberOfChannels >= 1 + && config.numberOfChannels <= 8 + && AAC_SAMPLE_RATES.includes(config.sampleRate) + && config.bitrate !== undefined; + } + + async init() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + this.worker = (await createWorker()) as Worker; + + const onMessage = (data: WorkerResponse) => { + const pending = this.pendingMessages.get(data.id); + assert(pending !== undefined); + + this.pendingMessages.delete(data.id); + if (data.success) { + pending.resolve(data.data); + } else { + pending.reject(data.error); + } + }; + + if (this.worker.addEventListener) { + this.worker.addEventListener('message', event => onMessage(event.data as WorkerResponse)); + } else { + const nodeWorker = this.worker as unknown as { + on: (event: string, listener: (data: never) => void) => void; + }; + nodeWorker.on('message', onMessage); + } + + assert(this.config.bitrate !== undefined); + this.sampleRate = this.config.sampleRate; + this.numberOfChannels = this.config.numberOfChannels; + + const result = await this.sendCommand({ + type: 'init', + data: { + numberOfChannels: this.config.numberOfChannels, + sampleRate: this.config.sampleRate, + bitrate: this.config.bitrate, + }, + }); + + this.ctx = result.ctx; + this.encoderFrameSize = result.frameSize; + + // The ffmpeg encoder provides an AudioSpecificConfig as extradata after init + const description = new Uint8Array(result.extradata); + + const aacConfig = (this.config as { aac?: { format?: 'aac' | 'adts' } }).aac; + this.useAdts = aacConfig?.format === 'adts'; + + if (this.useAdts) { + const audioSpecificConfig = parseAacAudioSpecificConfig(description); + this.adtsHeaderTemplate = buildAdtsHeaderTemplate(audioSpecificConfig); + } + + this.description = this.useAdts ? null : description; + this.resetInternalState(); + } + + private resetInternalState() { + this.pendingFrames = 0; + this.nextSampleTimestampInSamples = null; + this.nextPacketTimestampInSamples = null; + + this.chunkMetadata = { + decoderConfig: { + codec: 'mp4a.40.2', + numberOfChannels: this.config.numberOfChannels, + sampleRate: this.config.sampleRate, + ...(this.description ? { description: this.description } : {}), + }, + }; + } + + async encode(audioSample: AudioSample) { + if (this.nextSampleTimestampInSamples === null) { + this.nextSampleTimestampInSamples = Math.round(audioSample.timestamp * this.sampleRate); + this.nextPacketTimestampInSamples = this.nextSampleTimestampInSamples; + } + + const channels = this.numberOfChannels; + const incomingFrames = audioSample.numberOfFrames; + + // Extract interleaved f32 data + const totalBytes = audioSample.allocationSize({ format: 'f32', planeIndex: 0 }); + const audioBytes = new Uint8Array(totalBytes); + audioSample.copyTo(audioBytes, { format: 'f32', planeIndex: 0 }); + const incomingData = new Float32Array(audioBytes.buffer); + + const requiredSamples = (this.pendingFrames + incomingFrames) * channels; + if (requiredSamples > this.pendingBuffer.length) { + let newSize = this.pendingBuffer.length; + while (newSize < requiredSamples) { + newSize *= 2; + } + const newBuffer = new Float32Array(newSize); + newBuffer.set(this.pendingBuffer.subarray(0, this.pendingFrames * channels)); + this.pendingBuffer = newBuffer; + } + this.pendingBuffer.set(incomingData, this.pendingFrames * channels); + this.pendingFrames += incomingFrames; + + while (this.pendingFrames >= this.encoderFrameSize) { + await this.encodeOneFrame(); + } + } + + async flush() { + // Pad remaining samples with silence to fill a full frame + if (this.pendingFrames > 0) { + const channels = this.numberOfChannels; + const frameSize = this.encoderFrameSize; + const usedSamples = this.pendingFrames * channels; + const frameSamples = frameSize * channels; + + this.pendingBuffer.fill(0, usedSamples, frameSamples); + this.pendingFrames = frameSize; + + await this.encodeOneFrame(); + } + + const result = await this.sendCommand({ type: 'flush', data: { ctx: this.ctx } }); + this.emitPackets(result.packets); + + this.resetInternalState(); + } + + close() { + void this.sendCommand({ type: 'close', data: { ctx: this.ctx } }); + this.worker?.terminate(); + } + + private async encodeOneFrame() { + assert(this.nextSampleTimestampInSamples !== null); + assert(this.nextPacketTimestampInSamples !== null); + + const channels = this.numberOfChannels; + const frameSize = this.encoderFrameSize; + const frameSamples = frameSize * channels; + + const frameData = this.pendingBuffer.slice(0, frameSamples); + + // Shift remaining using copyWithin + this.pendingFrames -= frameSize; + if (this.pendingFrames > 0) { + this.pendingBuffer.copyWithin(0, frameSamples, frameSamples + this.pendingFrames * channels); + } + + const audioData = frameData.buffer; + const result = await this.sendCommand({ + type: 'encode', + data: { + ctx: this.ctx, + audioData, + timestamp: this.nextSampleTimestampInSamples, + }, + }, [audioData]); + + this.nextSampleTimestampInSamples += frameSize; + + this.emitPackets(result.packets); + } + + private emitPackets(packets: Array<{ encodedData: ArrayBuffer; pts: number; duration: number }>) { + assert(this.nextPacketTimestampInSamples !== null); + + for (const p of packets) { + let data = new Uint8Array(p.encodedData); + + if (this.useAdts) { + assert(this.adtsHeaderTemplate !== null); + const { header, bitstream } = this.adtsHeaderTemplate; + const frameLength = header.byteLength + data.byteLength; + writeAdtsFrameLength(bitstream, frameLength); + + const adtsFrame = new Uint8Array(frameLength); + adtsFrame.set(header, 0); + adtsFrame.set(data, header.byteLength); + data = adtsFrame; + } + + const packet = new EncodedPacket( + data, + 'key', + this.nextPacketTimestampInSamples / this.sampleRate, + p.duration / this.sampleRate, + ); + + this.nextPacketTimestampInSamples += p.duration; + + this.onPacket( + packet, + this.chunkMetadata, + ); + + this.chunkMetadata = {}; + } + } + + private sendCommand( + command: WorkerCommand & { type: T }, + transferables?: Transferable[], + ) { + return new Promise((resolve, reject) => { + const id = this.nextMessageId++; + this.pendingMessages.set(id, { + resolve: resolve as (value: WorkerResponseData) => void, + reject, + }); + + assert(this.worker); + + if (transferables) { + this.worker.postMessage({ id, command }, transferables); + } else { + this.worker.postMessage({ id, command }); + } + }); + } +} + +/** + * Registers the AAC encoder, which Mediabunny will then use automatically when applicable. Make sure to call this + * function before starting any encoding task. + * + * Preferably, wrap the call in a condition to avoid overriding any native AAC encoder: + * + * ```ts + * import { canEncodeAudio } from 'mediabunny'; + * import { registerAacEncoder } from '@mediabunny/aac-encoder'; + * + * if (!(await canEncodeAudio('aac'))) { + * registerAacEncoder(); + * } + * ``` + * + * @group \@mediabunny/aac-encoder + * @public + */ +export const registerAacEncoder = () => { + registerEncoder(AacEncoder); +}; + +function assert(x: unknown): asserts x { + if (!x) { + throw new Error('Assertion failed.'); + } +} diff --git a/packages/aac-encoder/src/index.ts b/packages/aac-encoder/src/index.ts new file mode 100644 index 0000000..d0bed47 --- /dev/null +++ b/packages/aac-encoder/src/index.ts @@ -0,0 +1,20 @@ +/*! + * Copyright (c) 2026-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/. + */ + +const AAC_ENCODER_LOADED_SYMBOL = Symbol.for('@mediabunny/aac-encoder loaded'); +if ((globalThis as Record)[AAC_ENCODER_LOADED_SYMBOL]) { + console.error( + '[WARNING]\n@mediabunny/aac-encoder was loaded twice.' + + ' This will likely cause the encoder not to work correctly.' + + ' Check if multiple dependencies are importing different versions of @mediabunny/aac-encoder,' + + ' or if something is being bundled incorrectly.', + ); +} +(globalThis as Record)[AAC_ENCODER_LOADED_SYMBOL] = true; + +export { registerAacEncoder } from './encoder'; diff --git a/packages/aac-encoder/src/shared.ts b/packages/aac-encoder/src/shared.ts new file mode 100644 index 0000000..ce5513d --- /dev/null +++ b/packages/aac-encoder/src/shared.ts @@ -0,0 +1,66 @@ +/*! + * Copyright (c) 2026-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/. + */ + +export type WorkerCommand = { + type: 'init'; + data: { + numberOfChannels: number; + sampleRate: number; + bitrate: number; + }; +} | { + type: 'encode'; + data: { + ctx: number; + audioData: ArrayBuffer; + timestamp: number; + }; +} | { + type: 'flush'; + data: { + ctx: number; + }; +} | { + type: 'close'; + data: { + ctx: number; + }; +}; + +export type WorkerResponseData = { + type: 'init'; + ctx: number; + frameSize: number; + extradata: ArrayBuffer; +} | { + type: 'encode'; + packets: Array<{ + encodedData: ArrayBuffer; + pts: number; + duration: number; + }>; +} | { + type: 'flush'; + packets: Array<{ + encodedData: ArrayBuffer; + pts: number; + duration: number; + }>; +} | { + type: 'close'; +}; + +export type WorkerResponse = { + id: number; +} & ({ + success: true; + data: WorkerResponseData; +} | { + success: false; + error: unknown; +}); diff --git a/packages/aac-encoder/tsconfig.json b/packages/aac-encoder/tsconfig.json new file mode 100644 index 0000000..27a7ef1 --- /dev/null +++ b/packages/aac-encoder/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist/modules", + "declaration": true, + "declarationMap": true, + "stripInternal": true, + "noEmit": false, + "moduleResolution": "nodenext", + "module": "nodenext", + "allowJs": true, + "paths": { + "mediabunny": ["../../src/index.ts"], + }, + }, + "include": [ + "./src/**/*", + "./build/**/*", + ], + "references": [ + { "path": "../../src" } + ] +} diff --git a/packages/aac-encoder/tsdoc.json b/packages/aac-encoder/tsdoc.json new file mode 100644 index 0000000..51d29d3 --- /dev/null +++ b/packages/aac-encoder/tsdoc.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + "extends": ["../../tsdoc.json"] +} diff --git a/packages/mp3-encoder/tsconfig.json b/packages/mp3-encoder/tsconfig.json index bf2e94f..27a7ef1 100644 --- a/packages/mp3-encoder/tsconfig.json +++ b/packages/mp3-encoder/tsconfig.json @@ -17,7 +17,6 @@ "include": [ "./src/**/*", "./build/**/*", - "../../shared/**/*", ], "references": [ { "path": "../../src" } diff --git a/scripts/build.sh b/scripts/build.sh index c0595fe..e448138 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -7,6 +7,7 @@ set -e rm -rf dist rm -rf packages/mp3-encoder/dist rm -rf packages/ac3/dist +rm -rf packages/aac-encoder/dist # Ensure license headers on all source files tsx scripts/ensure-license-headers.ts @@ -15,6 +16,7 @@ tsx scripts/ensure-license-headers.ts tsc -p src tsc -p packages/mp3-encoder tsc -p packages/ac3 +tsc -p packages/aac-encoder # So that the resulting files use valid ESM imports with file extension. This only runs for the core Mediabunny as only # it ships the individual files to npm (for tree shaking, because it's large) @@ -27,11 +29,13 @@ tsx scripts/bundle.ts api-extractor run api-extractor run -c packages/mp3-encoder/api-extractor.json api-extractor run -c packages/ac3/api-extractor.json +api-extractor run -c packages/aac-encoder/api-extractor.json # Checks that all symbols are documented tsx scripts/check-docblocks.ts dist/mediabunny.d.ts tsx scripts/check-docblocks.ts packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts tsx scripts/check-docblocks.ts packages/ac3/dist/mediabunny-ac3.d.ts +tsx scripts/check-docblocks.ts packages/aac-encoder/dist/mediabunny-aac-encoder.d.ts # Checks that API docs are generatable npm run docs:generate -- --dry @@ -39,4 +43,5 @@ npm run docs:generate -- --dry # Appends stuff to the declaration files to register the global variables these libraries expose echo 'export as namespace Mediabunny;' >> dist/mediabunny.d.ts echo 'export as namespace MediabunnyMp3Encoder;' >> packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts -echo 'export as namespace MediabunnyAc3;' >> packages/ac3/dist/mediabunny-ac3.d.ts \ No newline at end of file +echo 'export as namespace MediabunnyAc3;' >> packages/ac3/dist/mediabunny-ac3.d.ts +echo 'export as namespace MediabunnyAacEncoder;' >> packages/aac-encoder/dist/mediabunny-aac-encoder.d.ts \ No newline at end of file diff --git a/scripts/bundle.ts b/scripts/bundle.ts index c1a23d2..b94106f 100644 --- a/scripts/bundle.ts +++ b/scripts/bundle.ts @@ -144,10 +144,42 @@ const ac3Variants = await createVariants( }, ); +const aacEncoderVariants = await createVariants( + 'packages/aac-encoder/src/index.ts', + 'MediabunnyAacEncoder', + 'packages/aac-encoder/dist/bundles/mediabunny-aac-encoder', + 'js', // The bundles are purely for the browser, not for Node (due to the peer dependecy) + { + plugins: [ + PluginExternalGlobal.externalGlobalPlugin({ + mediabunny: 'Mediabunny', + }), + inlineWorkerPlugin({ + define: { + 'import.meta.url': '""', + }, + legalComments: 'none', + }), + ], + }, + { + external: ['mediabunny'], + plugins: [ + inlineWorkerPlugin({ + define: { + 'import.meta.url': '""', + }, + legalComments: 'none', + }), + ], + }, +); + const contexts = [ ...mediabunnyVariants, ...mp3EncoderVariants, ...ac3Variants, + ...aacEncoderVariants, ]; if (process.argv[2] === '--watch') { diff --git a/scripts/check.sh b/scripts/check.sh index 41aaef3..6b1c4db 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -4,11 +4,15 @@ set -e rm -rf dist/modules tsc -p src --stripInternal false -tsc -p packages/mp3-encoder --noEmit +rm -rf packages/mp3-encoder/dist/modules +tsc -p packages/mp3-encoder rm -rf packages/ac3/dist/modules tsc -p packages/ac3 +rm -rf packages/aac-encoder/dist/modules +tsc -p packages/aac-encoder + tsc -p tsconfig.vitest.json --noEmit tsc -p scripts --noEmit diff --git a/shared/aac-misc.ts b/shared/aac-misc.ts new file mode 100644 index 0000000..5c0fc71 --- /dev/null +++ b/shared/aac-misc.ts @@ -0,0 +1,146 @@ +/*! + * Copyright (c) 2026-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 './bitstream'; + +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.'); + } + + const bitstream = new Bitstream(bytes); + + let objectType = bitstream.readBits(5); + if (objectType === 31) { + objectType = 32 + bitstream.readBits(6); + } + + const frequencyIndex = bitstream.readBits(4); + let sampleRate: number | null = null; + if (frequencyIndex === 15) { + sampleRate = bitstream.readBits(24); + } else { + if (frequencyIndex < aacFrequencyTable.length) { + sampleRate = aacFrequencyTable[frequencyIndex]!; + } + } + + const channelConfiguration = bitstream.readBits(4); + let numberOfChannels: number | null = null; + if (channelConfiguration >= 1 && channelConfiguration <= 7) { + numberOfChannels = aacChannelMap[channelConfiguration]!; + } + + return { + objectType, + frequencyIndex, + sampleRate, + channelConfiguration, + numberOfChannels, + }; +}; + +export const buildAacAudioSpecificConfig = (config: { + objectType: number; + sampleRate: number; + numberOfChannels: number; +}) => { + let frequencyIndex = aacFrequencyTable.indexOf(config.sampleRate); + let customSampleRate: number | null = null; + + if (frequencyIndex === -1) { + frequencyIndex = 15; + customSampleRate = config.sampleRate; + } + + const channelConfiguration = aacChannelMap.indexOf(config.numberOfChannels); + if (channelConfiguration === -1) { + throw new TypeError(`Unsupported number of channels: ${config.numberOfChannels}`); + } + + let bitCount = 5 + 4 + 4; + if (config.objectType >= 32) { + bitCount += 6; + } + if (frequencyIndex === 15) { + bitCount += 24; + } + + const byteCount = Math.ceil(bitCount / 8); + const bytes = new Uint8Array(byteCount); + const bitstream = new Bitstream(bytes); + + if (config.objectType < 32) { + bitstream.writeBits(5, config.objectType); + } else { + bitstream.writeBits(5, 31); + bitstream.writeBits(6, config.objectType - 32); + } + + bitstream.writeBits(4, frequencyIndex); + + if (frequencyIndex === 15) { + bitstream.writeBits(24, customSampleRate!); + } + + bitstream.writeBits(4, channelConfiguration); + + return bytes; +}; + +export type AdtsHeaderTemplate = { + header: Uint8Array; + bitstream: Bitstream; +}; + +export const buildAdtsHeaderTemplate = (config: AacAudioSpecificConfig): AdtsHeaderTemplate => { + const header = new Uint8Array(7); + const bitstream = new Bitstream(header); + + const { objectType, frequencyIndex, channelConfiguration } = config; + const profile = objectType - 1; + + bitstream.writeBits(12, 0b1111_11111111); // Syncword + bitstream.writeBits(1, 0); // MPEG Version + bitstream.writeBits(2, 0); // Layer + bitstream.writeBits(1, 1); // Protection absence + bitstream.writeBits(2, profile); // Profile + bitstream.writeBits(4, frequencyIndex); // MPEG-4 Sampling Frequency Index + bitstream.writeBits(1, 0); // Private bit + bitstream.writeBits(3, channelConfiguration); // MPEG-4 Channel Configuration + bitstream.writeBits(1, 0); // Originality + bitstream.writeBits(1, 0); // Home + bitstream.writeBits(1, 0); // Copyright ID bit + bitstream.writeBits(1, 0); // Copyright ID start + bitstream.skipBits(13); // Frame length (to be filled per packet) + bitstream.writeBits(11, 0x7ff); // Buffer fullness + bitstream.writeBits(2, 0); // Number of AAC frames minus 1 + // Omit CRC check + + return { header, bitstream }; +}; + +export const writeAdtsFrameLength = (bitstream: Bitstream, frameLength: number) => { + bitstream.pos = 30; + bitstream.writeBits(13, frameLength); +}; diff --git a/shared/bitstream.ts b/shared/bitstream.ts new file mode 100644 index 0000000..f2d2590 --- /dev/null +++ b/shared/bitstream.ts @@ -0,0 +1,85 @@ +/*! + * Copyright (c) 2026-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/. + */ + +export class Bitstream { + /** Current offset in bits. */ + pos = 0; + + constructor(public bytes: Uint8Array) {} + + seekToByte(byteOffset: number) { + this.pos = 8 * byteOffset; + } + + private readBit() { + const byteIndex = Math.floor(this.pos / 8); + const byte = this.bytes[byteIndex] ?? 0; + const bitIndex = 0b111 - (this.pos & 0b111); + const bit = (byte & (1 << bitIndex)) >> bitIndex; + + this.pos++; + return bit; + } + + readBits(n: number) { + if (n === 1) { + return this.readBit(); + } + + let result = 0; + + for (let i = 0; i < n; i++) { + result <<= 1; + result |= this.readBit(); + } + + 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() { + if (this.pos % 8 !== 0) { + throw new Error('Bitstream is not byte-aligned.'); + } + + const byteIndex = this.pos / 8; + const byte = this.bytes[byteIndex] ?? 0; + + this.pos += 8; + return byte; + } + + skipBits(n: number) { + this.pos += n; + } + + getBitsLeft() { + return this.bytes.length * 8 - this.pos; + } + + clone() { + const clone = new Bitstream(this.bytes); + clone.pos = this.pos; + return clone; + } +} diff --git a/src/adts/adts-demuxer.ts b/src/adts/adts-demuxer.ts index 18af576..0539e86 100644 --- a/src/adts/adts-demuxer.ts +++ b/src/adts/adts-demuxer.ts @@ -6,7 +6,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { aacChannelMap, aacFrequencyTable, AudioCodec } from '../codec'; +import { aacChannelMap, aacFrequencyTable } from '../../shared/aac-misc'; +import { AudioCodec } from '../codec'; import { Demuxer } from '../demuxer'; import { ID3_V2_HEADER_SIZE, diff --git a/src/adts/adts-misc.ts b/src/adts/adts-misc.ts deleted file mode 100644 index c736703..0000000 --- a/src/adts/adts-misc.ts +++ /dev/null @@ -1,47 +0,0 @@ -/*! - * Copyright (c) 2026-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 } from '../codec'; -import { Bitstream } from '../misc'; - -export type AdtsHeaderTemplate = { - header: Uint8Array; - bitstream: Bitstream; -}; - -export const buildAdtsHeaderTemplate = (config: AacAudioSpecificConfig): AdtsHeaderTemplate => { - const header = new Uint8Array(7); - const bitstream = new Bitstream(header); - - const { objectType, frequencyIndex, channelConfiguration } = config; - const profile = objectType - 1; - - bitstream.writeBits(12, 0b1111_11111111); // Syncword - bitstream.writeBits(1, 0); // MPEG Version - bitstream.writeBits(2, 0); // Layer - bitstream.writeBits(1, 1); // Protection absence - bitstream.writeBits(2, profile); // Profile - bitstream.writeBits(4, frequencyIndex); // MPEG-4 Sampling Frequency Index - bitstream.writeBits(1, 0); // Private bit - bitstream.writeBits(3, channelConfiguration); // MPEG-4 Channel Configuration - bitstream.writeBits(1, 0); // Originality - bitstream.writeBits(1, 0); // Home - bitstream.writeBits(1, 0); // Copyright ID bit - bitstream.writeBits(1, 0); // Copyright ID start - bitstream.skipBits(13); // Frame length (to be filled per packet) - bitstream.writeBits(11, 0x7ff); // Buffer fullness - bitstream.writeBits(2, 0); // Number of AAC frames minus 1 - // Omit CRC check - - return { header, bitstream }; -}; - -export const writeAdtsFrameLength = (bitstream: Bitstream, frameLength: number) => { - bitstream.pos = 30; - bitstream.writeBits(13, frameLength); -}; diff --git a/src/adts/adts-muxer.ts b/src/adts/adts-muxer.ts index f004ae3..bc0c55b 100644 --- a/src/adts/adts-muxer.ts +++ b/src/adts/adts-muxer.ts @@ -6,16 +6,17 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { parseAacAudioSpecificConfig, validateAudioChunkMetadata } from '../codec'; +import { buildAdtsHeaderTemplate, parseAacAudioSpecificConfig, writeAdtsFrameLength } from '../../shared/aac-misc'; +import { Bitstream } from '../../shared/bitstream'; +import { validateAudioChunkMetadata } from '../codec'; import { Id3V2Writer } from '../id3'; import { metadataTagsAreEmpty } from '../metadata'; -import { assert, Bitstream, toUint8Array } from '../misc'; +import { assert, 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'; -import { buildAdtsHeaderTemplate, writeAdtsFrameLength } from './adts-misc'; export class AdtsMuxer extends Muxer { private format: AdtsOutputFormat; diff --git a/src/adts/adts-reader.ts b/src/adts/adts-reader.ts index c6803e9..672e727 100644 --- a/src/adts/adts-reader.ts +++ b/src/adts/adts-reader.ts @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { Bitstream } from '../misc'; +import { Bitstream } from '../../shared/bitstream'; import { FileSlice, readBytes } from '../reader'; export const MIN_ADTS_FRAME_HEADER_SIZE = 7; diff --git a/src/codec-data.ts b/src/codec-data.ts index 0cb5135..235c080 100644 --- a/src/codec-data.ts +++ b/src/codec-data.ts @@ -11,7 +11,6 @@ import { assert, assertNever, base64ToBytes, - Bitstream, bytesToBase64, keyValueIterator, getUint24, @@ -30,6 +29,7 @@ import { import { PacketType } from './packet'; import { MetadataTags } from './metadata'; import { AC3_SAMPLE_RATES, EAC3_REDUCED_SAMPLE_RATES } from '../shared/ac3-misc'; +import { Bitstream } from '../shared/bitstream'; // References for AVC/HEVC code: // ISO 14496-15 diff --git a/src/codec.ts b/src/codec.ts index b7086f6..4c7d1ff 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -6,6 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { parseAacAudioSpecificConfig } from '../shared/aac-misc'; import { Av1CodecInfo, AvcDecoderConfigurationRecord, @@ -13,7 +14,6 @@ import { Vp9CodecInfo, } from './codec-data'; import { - Bitstream, COLOR_PRIMARIES_MAP, MATRIX_COEFFICIENTS_MAP, TRANSFER_CHARACTERISTICS_MAP, @@ -590,106 +590,6 @@ export const extractAudioCodecString = (trackInfo: { throw new TypeError(`Unhandled codec '${codec}'.`); }; -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.'); - } - - const bitstream = new Bitstream(bytes); - - let objectType = bitstream.readBits(5); - if (objectType === 31) { - objectType = 32 + bitstream.readBits(6); - } - - const frequencyIndex = bitstream.readBits(4); - let sampleRate: number | null = null; - if (frequencyIndex === 15) { - sampleRate = bitstream.readBits(24); - } else { - if (frequencyIndex < aacFrequencyTable.length) { - sampleRate = aacFrequencyTable[frequencyIndex]!; - } - } - - const channelConfiguration = bitstream.readBits(4); - let numberOfChannels: number | null = null; - if (channelConfiguration >= 1 && channelConfiguration <= 7) { - numberOfChannels = aacChannelMap[channelConfiguration]!; - } - - return { - objectType, - frequencyIndex, - sampleRate, - channelConfiguration, - numberOfChannels, - }; -}; - -export const buildAacAudioSpecificConfig = (config: { - objectType: number; - sampleRate: number; - numberOfChannels: number; -}) => { - let frequencyIndex = aacFrequencyTable.indexOf(config.sampleRate); - let customSampleRate: number | null = null; - - if (frequencyIndex === -1) { - frequencyIndex = 15; - customSampleRate = config.sampleRate; - } - - const channelConfiguration = aacChannelMap.indexOf(config.numberOfChannels); - if (channelConfiguration === -1) { - throw new TypeError(`Unsupported number of channels: ${config.numberOfChannels}`); - } - - let bitCount = 5 + 4 + 4; - if (config.objectType >= 32) { - bitCount += 6; - } - if (frequencyIndex === 15) { - bitCount += 24; - } - - const byteCount = Math.ceil(bitCount / 8); - const bytes = new Uint8Array(byteCount); - const bitstream = new Bitstream(bytes); - - if (config.objectType < 32) { - bitstream.writeBits(5, config.objectType); - } else { - bitstream.writeBits(5, 31); - bitstream.writeBits(6, config.objectType - 32); - } - - bitstream.writeBits(4, frequencyIndex); - - if (frequencyIndex === 15) { - bitstream.writeBits(24, customSampleRate!); - } - - bitstream.writeBits(4, channelConfiguration); - - return bytes; -}; - export const OPUS_SAMPLE_RATE = 48_000; const PCM_CODEC_REGEX = /^pcm-([usf])(\d+)+(be)?$/; diff --git a/src/conversion.ts b/src/conversion.ts index 14bc516..cda03c8 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -774,6 +774,12 @@ export class Conversion { ); } + if (codecs.includes('aac')) { + elements.push( + '\nThe @mediabunny/aac-encoder extension package provides support for encoding AAC.', + ); + } + if (codecs.includes('ac3') || codecs.includes('eac3')) { elements.push( '\nThe @mediabunny/ac3 extension package provides support' diff --git a/src/flac/flac-demuxer.ts b/src/flac/flac-demuxer.ts index 4547c4b..adffadf 100644 --- a/src/flac/flac-demuxer.ts +++ b/src/flac/flac-demuxer.ts @@ -15,7 +15,6 @@ import { assert, AsyncMutex, binarySearchLessOrEqual, - Bitstream, textDecoder, UNDETERMINED_LANGUAGE, } from '../misc'; @@ -37,6 +36,7 @@ import { readSampleRate, getSampleRateOrUncommon, } from './flac-misc'; +import { Bitstream } from '../../shared/bitstream'; type FlacAudioInfo = { numberOfChannels: number; diff --git a/src/flac/flac-misc.ts b/src/flac/flac-misc.ts index 1cc78b2..f440d3d 100644 --- a/src/flac/flac-misc.ts +++ b/src/flac/flac-misc.ts @@ -6,7 +6,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { assert, assertNever, Bitstream } from '../misc'; +import { Bitstream } from '../../shared/bitstream'; +import { assert, assertNever } from '../misc'; import { FileSlice, readBytes, readU16Be, readU8 } from '../reader'; type BlockSizeOrUncommon = number | 'uncommon-u16' | 'uncommon-u8'; diff --git a/src/flac/flac-muxer.ts b/src/flac/flac-muxer.ts index 01bbd7c..dad7a61 100644 --- a/src/flac/flac-muxer.ts +++ b/src/flac/flac-muxer.ts @@ -10,7 +10,6 @@ import { validateAudioChunkMetadata } from '../codec'; import { createVorbisComments, FlacBlockType } from '../codec-data'; import { assert, - Bitstream, textEncoder, toDataView, toUint8Array, @@ -27,6 +26,7 @@ import { getBlockSizeOrUncommon, readCodedNumber, } from './flac-misc'; +import { Bitstream } from '../../shared/bitstream'; const FLAC_HEADER = /* #__PURE__ */ new Uint8Array([0x66, 0x4c, 0x61, 0x43]); // 'fLaC' const STREAMINFO_SIZE = 38; diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts index 040f00b..7115b1c 100644 --- a/src/isobmff/isobmff-boxes.ts +++ b/src/isobmff/isobmff-boxes.ts @@ -20,7 +20,6 @@ import { UNDETERMINED_LANGUAGE, assertNever, keyValueIterator, - Bitstream, } from '../misc'; import { AudioCodec, @@ -46,6 +45,7 @@ import { } from './isobmff-muxer'; import { parseAc3SyncFrame, parseEac3SyncFrame, parseOpusIdentificationHeader } from '../codec-data'; import { MetadataTags, RichImageData } from '../metadata'; +import { Bitstream } from '../../shared/bitstream'; export class IsobmffBoxWriter { private helper = new Uint8Array(8); diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index df68ff9..e247193 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -6,6 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { parseAacAudioSpecificConfig } from '../../shared/aac-misc'; import { AacCodecInfo, AudioCodec, @@ -13,7 +14,6 @@ import { extractVideoCodecString, MediaCodec, OPUS_SAMPLE_RATE, - parseAacAudioSpecificConfig, parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, @@ -47,7 +47,6 @@ import { assert, binarySearchExact, binarySearchLessOrEqual, - Bitstream, COLOR_PRIMARIES_MAP_INVERSE, findLastIndex, isIso639Dash2LanguageCode, @@ -92,6 +91,7 @@ import { } from '../reader'; import { DEFAULT_TRACK_DISPOSITION, MetadataTags, RichImageData, TrackDisposition } from '../metadata'; import { AC3_SAMPLE_RATES } from '../../shared/ac3-misc'; +import { Bitstream } from '../../shared/bitstream'; type InternalTrack = { id: number; diff --git a/src/isobmff/isobmff-muxer.ts b/src/isobmff/isobmff-muxer.ts index 7c56b7c..4256f53 100644 --- a/src/isobmff/isobmff-muxer.ts +++ b/src/isobmff/isobmff-muxer.ts @@ -13,10 +13,8 @@ import { BufferTargetWriter, Writer } from '../writer'; import { assert, computeRationalApproximation, last, promiseWithResolvers, Rational, simplifyRational } from '../misc'; import { IsobmffOutputFormatOptions, IsobmffOutputFormat, MovOutputFormat } from '../output-format'; import { inlineTimestampRegex, SubtitleConfig, SubtitleCue, SubtitleMetadata } from '../subtitles'; +import { aacChannelMap, aacFrequencyTable, buildAacAudioSpecificConfig } from '../../shared/aac-misc'; import { - aacChannelMap, - aacFrequencyTable, - buildAacAudioSpecificConfig, parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts index 6554f20..fb1e6b1 100644 --- a/src/matroska/matroska-muxer.ts +++ b/src/matroska/matroska-muxer.ts @@ -6,8 +6,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { Bitstream } from '../../shared/bitstream'; import { - Bitstream, COLOR_PRIMARIES_MAP, MATRIX_COEFFICIENTS_MAP, TRANSFER_CHARACTERISTICS_MAP, @@ -49,10 +49,8 @@ import { inlineTimestampRegex, parseSubtitleTimestamp, } from '../subtitles'; +import { aacChannelMap, aacFrequencyTable, buildAacAudioSpecificConfig } from '../../shared/aac-misc'; import { - aacChannelMap, - aacFrequencyTable, - buildAacAudioSpecificConfig, OPUS_SAMPLE_RATE, PCM_AUDIO_CODECS, PcmAudioCodec, diff --git a/src/media-source.ts b/src/media-source.ts index faf6243..3580a14 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -6,11 +6,10 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { buildAacAudioSpecificConfig, parseAacAudioSpecificConfig } from '../shared/aac-misc'; import { AUDIO_CODECS, AudioCodec, - buildAacAudioSpecificConfig, - parseAacAudioSpecificConfig, parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, diff --git a/src/misc.ts b/src/misc.ts index 72b33df..b963ae7 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -6,6 +6,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { Bitstream } from '../shared/bitstream'; + export function assert(x: unknown): asserts x { if (!x) { throw new Error('Assertion failed.'); @@ -39,85 +41,6 @@ export const isU32 = (value: number) => { return value >= 0 && value < 2 ** 32; }; -export class Bitstream { - /** Current offset in bits. */ - pos = 0; - - constructor(public bytes: Uint8Array) {} - - seekToByte(byteOffset: number) { - this.pos = 8 * byteOffset; - } - - private readBit() { - const byteIndex = Math.floor(this.pos / 8); - const byte = this.bytes[byteIndex] ?? 0; - const bitIndex = 0b111 - (this.pos & 0b111); - const bit = (byte & (1 << bitIndex)) >> bitIndex; - - this.pos++; - return bit; - } - - readBits(n: number) { - if (n === 1) { - return this.readBit(); - } - - let result = 0; - - for (let i = 0; i < n; i++) { - result <<= 1; - result |= this.readBit(); - } - - 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) { - throw new Error('Bitstream is not byte-aligned.'); - } - - const byteIndex = this.pos / 8; - const byte = this.bytes[byteIndex] ?? 0; - - this.pos += 8; - return byte; - } - - skipBits(n: number) { - this.pos += n; - } - - getBitsLeft() { - return this.bytes.length * 8 - this.pos; - } - - clone() { - const clone = new Bitstream(this.bytes); - clone.pos = this.pos; - return clone; - } -} - /** Reads an exponential-Golomb universal code from a Bitstream. */ export const readExpGolomb = (bitstream: Bitstream) => { let leadingZeroBits = 0; diff --git a/src/mpeg-ts/mpeg-ts-demuxer.ts b/src/mpeg-ts/mpeg-ts-demuxer.ts index 562ee45..253e8de 100644 --- a/src/mpeg-ts/mpeg-ts-demuxer.ts +++ b/src/mpeg-ts/mpeg-ts-demuxer.ts @@ -8,10 +8,9 @@ import { SAMPLES_PER_AAC_FRAME } from '../adts/adts-demuxer'; import { MAX_ADTS_FRAME_HEADER_SIZE, readAdtsFrameHeader } from '../adts/adts-reader'; +import { aacChannelMap, aacFrequencyTable } from '../../shared/aac-misc'; import { - aacChannelMap, AacCodecInfo, - aacFrequencyTable, AudioCodec, extractAudioCodecString, extractVideoCodecString, @@ -54,7 +53,6 @@ import { assert, binarySearchExact, binarySearchLessOrEqual, - Bitstream, COLOR_PRIMARIES_MAP_INVERSE, findLastIndex, floorToMultiple, @@ -71,6 +69,7 @@ import { EncodedPacket, PacketType, PLACEHOLDER_DATA } from '../packet'; import { FileSlice, readBytes, Reader, readU16Be, readU32Be, readU8 } from '../reader'; import { buildMpegTsMimeType, MpegTsStreamType, TIMESCALE, TS_PACKET_SIZE } from './mpeg-ts-misc'; import { AC3_SAMPLE_RATES } from '../../shared/ac3-misc'; +import { Bitstream } from '../../shared/bitstream'; // Resources: // ISO/IEC 13818-1 diff --git a/src/mpeg-ts/mpeg-ts-muxer.ts b/src/mpeg-ts/mpeg-ts-muxer.ts index e4977cc..102843a 100644 --- a/src/mpeg-ts/mpeg-ts-muxer.ts +++ b/src/mpeg-ts/mpeg-ts-muxer.ts @@ -6,8 +6,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { parseAacAudioSpecificConfig, validateAudioChunkMetadata, validateVideoChunkMetadata } from '../codec'; -import { buildAdtsHeaderTemplate, writeAdtsFrameLength } from '../adts/adts-misc'; +import { buildAdtsHeaderTemplate, parseAacAudioSpecificConfig, writeAdtsFrameLength } from '../../shared/aac-misc'; +import { validateAudioChunkMetadata, validateVideoChunkMetadata } from '../codec'; import { AC3_REGISTRATION_DESCRIPTOR, AvcDecoderConfigurationRecord, @@ -23,7 +23,8 @@ import { iterateNalUnitsInAnnexB, iterateNalUnitsInLengthPrefixed, } from '../codec-data'; -import { assert, Bitstream, promiseWithResolvers, setUint24, toDataView, toUint8Array } from '../misc'; +import { Bitstream } from '../../shared/bitstream'; +import { assert, promiseWithResolvers, setUint24, toDataView, toUint8Array } from '../misc'; import { Muxer } from '../muxer'; import { Output, OutputAudioTrack, OutputTrack, OutputVideoTrack } from '../output'; import { MpegTsOutputFormat } from '../output-format'; diff --git a/test/node/aac-encoder-extension.test.ts b/test/node/aac-encoder-extension.test.ts new file mode 100644 index 0000000..9a1bb0c --- /dev/null +++ b/test/node/aac-encoder-extension.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from 'vitest'; +import { Input } from '../../src/input.js'; +import { BufferSource } from '../../src/source.js'; +import { ALL_FORMATS } from '../../src/input-format.js'; +import { Output } from '../../src/output.js'; +import { BufferTarget } from '../../src/target.js'; +import { canEncode } from '../../src/encode.js'; +import { AudioSampleSource } from '../../src/media-source.js'; +import { EncodedPacketSink } from '../../src/media-sink.js'; +import { Mp4OutputFormat } from '../../src/output-format.js'; +import { AudioSample } from '../../src/sample.js'; +import { registerAacEncoder } from '@mediabunny/aac-encoder'; + +const createSineWave = (sampleRate: number, channels: number, durationSeconds: number) => { + const totalFrames = sampleRate * durationSeconds; + const data = new Float32Array(totalFrames * channels); + + for (let i = 0; i < totalFrames; i++) { + const value = Math.sin(2 * Math.PI * 440 * i / sampleRate); + for (let ch = 0; ch < channels; ch++) { + data[i * channels + ch] = value; + } + } + + return data; +}; + +test('Custom coder registration', async () => { + expect(await canEncode('aac')).toBe(false); + + registerAacEncoder(); + + expect(await canEncode('aac')).toBe(true); +}); + +test('AAC encoding', async () => { + registerAacEncoder(); + + const sampleRate = 48000; + const channels = 2; + const durationSeconds = 2; + const data = createSineWave(sampleRate, channels, durationSeconds); + + const output = new Output({ + format: new Mp4OutputFormat(), + target: new BufferTarget(), + }); + + const audioSource = new AudioSampleSource({ codec: 'aac', bitrate: 128000 }); + output.addAudioTrack(audioSource); + + await output.start(); + await audioSource.add(new AudioSample({ + data, + format: 'f32', + numberOfChannels: channels, + sampleRate, + timestamp: 0, + })); + audioSource.close(); + await output.finalize(); + + using input = new Input({ + source: new BufferSource(output.target.buffer!), + formats: ALL_FORMATS, + }); + + const track = (await input.getPrimaryAudioTrack())!; + expect(track.codec).toBe('aac'); + expect(track.sampleRate).toBe(sampleRate); + expect(track.numberOfChannels).toBe(channels); + + const sink = new EncodedPacketSink(track); + let packetCount = 0; + for await (const packet of sink.packets()) { + expect(packet.type).toBe('key'); + packetCount++; + } + expect(packetCount).toBeGreaterThan(durationSeconds * sampleRate / 1024); + + expect(await track.computeDuration()).toBeCloseTo(2, 1); +}); diff --git a/tsconfig.vite.json b/tsconfig.vite.json index 18c8e14..07743b0 100644 --- a/tsconfig.vite.json +++ b/tsconfig.vite.json @@ -8,6 +8,7 @@ "paths": { "mediabunny": ["./src/index.ts"], "@mediabunny/ac3": ["./packages/ac3/src/index.ts"], + "@mediabunny/aac-encoder": ["./packages/aac-encoder/src/index.ts"], }, "types": ["vite/client"] }, diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json index 7101888..e4ac060 100644 --- a/tsconfig.vitest.json +++ b/tsconfig.vitest.json @@ -8,9 +8,10 @@ "paths": { "mediabunny": ["./src/index.ts"], "@mediabunny/ac3": ["./packages/ac3/src/index.ts"], + "@mediabunny/aac-encoder": ["./packages/aac-encoder/src/index.ts"], }, }, "include": ["vitest.config.ts", "./test/**/*"], "exclude": ["./test/public/**/*"], - "references": [{ "path": "./src" }, { "path": "./packages/ac3" }] + "references": [{ "path": "./src" }, { "path": "./packages/ac3" }, { "path": "./packages/aac-encoder" }] } diff --git a/vite.config.ts b/vite.config.ts index 6b460b0..c376f06 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,7 +20,9 @@ const rollupInput = Object.fromEntries( export default defineConfig({ resolve: { alias: { - mediabunny: path.resolve(__dirname, './dist/bundles/mediabunny.mjs'), + 'mediabunny': path.resolve(__dirname, './dist/bundles/mediabunny.mjs'), + '@mediabunny/aac-encoder': + path.resolve(__dirname, './packages/aac-encoder/dist/bundles/mediabunny-aac-encoder.mjs'), }, }, plugins: [ diff --git a/vitest.config.ts b/vitest.config.ts index 3623ed8..d73adc9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,8 @@ export default defineConfig({ alias: { 'mediabunny': path.resolve(__dirname, './src/index.ts'), '@mediabunny/ac3': path.resolve(__dirname, './packages/ac3/dist/bundles/mediabunny-ac3.mjs'), + '@mediabunny/aac-encoder': + path.resolve(__dirname, './packages/aac-encoder/dist/bundles/mediabunny-aac-encoder.mjs'), }, }, test: {