diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b2b2155..31b076b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,7 +59,7 @@ 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 + 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 - name: Publish Mediabunny to npm run: npm publish --access public diff --git a/.gitignore b/.gitignore index a359208..289f126 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ node_modules /docs/api *.tsbuildinfo -packages/mp3-encoder/dist \ No newline at end of file +packages/mp3-encoder/dist +packages/ac3/dist \ No newline at end of file diff --git a/dev/convert.html b/dev/convert.html index 76494f1..661d12b 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -2,9 +2,11 @@ + + + +``` + +This will expose the global objects `Mediabunny` and `MediabunnyAc3`. Use `mediabunny-ac3.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 { registerAc3Decoder, registerAc3Encoder } from '@mediabunny/ac3'; + +registerAc3Decoder(); +registerAc3Encoder(); +``` +That's it - Mediabunny now uses the registered AC-3/E-AC-3 decoder and encoder automatically. diff --git a/docs/guide/supported-formats-and-codecs.md b/docs/guide/supported-formats-and-codecs.md index 1c83d05..dbaf533 100644 --- a/docs/guide/supported-formats-and-codecs.md +++ b/docs/guide/supported-formats-and-codecs.md @@ -59,7 +59,7 @@ 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 must provide a [custom coder](#custom-coders). +[^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 diff --git a/package-lock.json b/package-lock.json index 7338ee5..7618011 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1413,6 +1413,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mediabunny/ac3": { + "resolved": "packages/ac3", + "link": true + }, "node_modules/@mediabunny/mp3-encoder": { "resolved": "packages/mp3-encoder", "link": true @@ -12063,6 +12067,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/ac3": { + "name": "@mediabunny/ac3", + "version": "1.33.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/mp3-encoder": { "name": "@mediabunny/mp3-encoder", "version": "1.33.0", diff --git a/package.json b/package.json index 4ebc696..33ae921 100644 --- a/package.json +++ b/package.json @@ -29,18 +29,18 @@ }, "sideEffects": false, "scripts": { - "build": "./build.sh", + "build": "./scripts/build.sh", "watch": "tsx scripts/bundle.ts --watch", "lint": "eslint .", "test": "npx vitest --run", "test-node": "npm run test node/", "test-browser": "npm run test browser/", - "check": "rm -rf dist/modules && tsc -p src --stripInternal false && tsc -p packages/mp3-encoder/src --noEmit && tsc -p tsconfig.vitest.json --noEmit && tsc -p scripts --noEmit && tsc -p tsconfig.vite.json --noEmit", + "check": "./scripts/check.sh", "check-docblocks": "tsx scripts/check-docblocks.ts dist/mediabunny.d.ts", "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 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 docs/api-config.json", "dev": "vite", "examples:build": "vite build", "fix-build-import-paths": "tsx scripts/add-import-extensions.ts", diff --git a/packages/ac3/.gitattributes b/packages/ac3/.gitattributes new file mode 100644 index 0000000..72ef19a --- /dev/null +++ b/packages/ac3/.gitattributes @@ -0,0 +1 @@ +build/* linguist-generated diff --git a/packages/ac3/LICENSE b/packages/ac3/LICENSE new file mode 100644 index 0000000..d0a1fa1 --- /dev/null +++ b/packages/ac3/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/ac3/README.md b/packages/ac3/README.md new file mode 100644 index 0000000..8f1874b --- /dev/null +++ b/packages/ac3/README.md @@ -0,0 +1,103 @@ +# @mediabunny/ac3 + +[![](https://img.shields.io/npm/v/@mediabunny/ac3)](https://www.npmjs.com/package/@mediabunny/ac3) +[![](https://img.shields.io/bundlephobia/minzip/@mediabunny/ac3)](https://bundlephobia.com/package/@mediabunny/ac3) +[![](https://img.shields.io/npm/dm/@mediabunny/ac3)](https://www.npmjs.com/package/@mediabunny/ac3) + +Browsers have no support for AC-3 (Dolby Digital) or E-AC-3 (Dolby Digital Plus) in their WebCodecs implementations. This extension package provides both a decoder and encoder for use with [Mediabunny](https://github.com/Vanilagy/mediabunny), allowing you to decode and encode these codecs directly in the browser. 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 AC-3 and E-AC-3 coders 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/ac3 +``` + +Alternatively, directly include them using a script tag: +```html + + +``` + +This will expose the global objects `Mediabunny` and `MediabunnyAc3`. Use `mediabunny-ac3.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 { registerAc3Decoder, registerAc3Encoder } from '@mediabunny/ac3'; + +registerAc3Decoder(); +registerAc3Encoder(); +``` +That's it - Mediabunny now uses the registered AC-3/E-AC-3 decoder and encoder automatically. + +## 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-decoder=ac3 \ + --enable-decoder=eac3 \ + --enable-encoder=ac3 \ + --enable-encoder=eac3 \ + --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/ac3 +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/ac3.js +``` + +This generates `build/ac3.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/ac3/api-extractor.json b/packages/ac3/api-extractor.json new file mode 100644 index 0000000..461488c --- /dev/null +++ b/packages/ac3/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-ac3.d.ts" + }, + "tsdocMetadata": { + "enabled": false + }, + "messages": { + "compilerMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + "extractorMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + } + } + }, + "newlineKind": "lf" +} diff --git a/packages/ac3/build/ac3.js b/packages/ac3/build/ac3.js new file mode 100644 index 0000000..777bd8a Binary files /dev/null and b/packages/ac3/build/ac3.js differ diff --git a/packages/ac3/package.json b/packages/ac3/package.json new file mode 100644 index 0000000..5d95cbb --- /dev/null +++ b/packages/ac3/package.json @@ -0,0 +1,56 @@ +{ + "name": "@mediabunny/ac3", + "author": "Vanilagy", + "version": "1.33.0", + "description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on FFmpeg.", + "main": "./dist/bundles/mediabunny-ac3.mjs", + "module": "./dist/bundles/mediabunny-ac3.mjs", + "types": "./dist/modules/src/index.d.ts", + "exports": { + "types": "./dist/modules/src/index.d.ts", + "import": "./dist/bundles/mediabunny-ac3.mjs", + "require": "./dist/bundles/mediabunny-ac3.mjs" + }, + "files": [ + "README.md", + "package.json", + "LICENSE", + "dist", + "src" + ], + "sideEffects": false, + "license": "MPL-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/Vanilagy/mediabunny.git", + "directory": "packages/ac3" + }, + "bugs": { + "url": "https://github.com/Vanilagy/mediabunny/issues" + }, + "homepage": "https://mediabunny.dev/guide/extensions/ac3", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + }, + "devDependencies": { + "@types/emscripten": "^1.40.1" + }, + "keywords": [ + "ac3", + "eac3", + "dolby", + "dolby-digital", + "encoding", + "decoding", + "codec", + "mediabunny", + "ffmpeg", + "browser", + "wasm", + "polyfill" + ] +} diff --git a/packages/ac3/src/bridge.c b/packages/ac3/src/bridge.c new file mode 100644 index 0000000..21973f2 --- /dev/null +++ b/packages/ac3/src/bridge.c @@ -0,0 +1,289 @@ +/*! + * 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" + +typedef struct { + AVCodecContext *codec_ctx; + AVPacket *packet; + AVFrame *frame; +} DecoderContext; + +EMSCRIPTEN_KEEPALIVE +DecoderContext *init_decoder(int codec_id) { + enum AVCodecID av_codec_id = codec_id == 0 ? AV_CODEC_ID_AC3 : AV_CODEC_ID_EAC3; + + const AVCodec *codec = avcodec_find_decoder(av_codec_id); + if (!codec) return NULL; + + AVCodecContext *codec_ctx = avcodec_alloc_context3(codec); + if (!codec_ctx) return NULL; + + 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; + } + + DecoderContext *ctx = malloc(sizeof(DecoderContext)); + 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; + + return ctx; +} + +EMSCRIPTEN_KEEPALIVE +uint8_t *configure_decode_packet(DecoderContext *ctx, int size) { + if (av_new_packet(ctx->packet, size) < 0) { + return NULL; + } + + return ctx->packet->data; +} + +EMSCRIPTEN_KEEPALIVE +int decode_packet(DecoderContext *ctx, int pts) { + ctx->packet->pts = pts; + int ret = avcodec_send_packet(ctx->codec_ctx, ctx->packet); + av_packet_unref(ctx->packet); + if (ret < 0) return ret; + + ret = avcodec_receive_frame(ctx->codec_ctx, ctx->frame); + if (ret < 0) return ret; + + return 0; +} + +EMSCRIPTEN_KEEPALIVE +int get_decoded_format(DecoderContext *ctx) { + return ctx->frame->format; +} + +EMSCRIPTEN_KEEPALIVE +uint8_t *get_decoded_plane_ptr(DecoderContext *ctx, int plane) { + return ctx->frame->data[plane]; +} + +EMSCRIPTEN_KEEPALIVE +int get_decoded_channels(DecoderContext *ctx) { + return ctx->frame->ch_layout.nb_channels; +} + +EMSCRIPTEN_KEEPALIVE +int get_decoded_sample_rate(DecoderContext *ctx) { + return ctx->frame->sample_rate; +} + +EMSCRIPTEN_KEEPALIVE +int get_decoded_sample_count(DecoderContext *ctx) { + return ctx->frame->nb_samples; +} + +EMSCRIPTEN_KEEPALIVE +int get_decoded_pts(DecoderContext *ctx) { + return (int)ctx->frame->pts; +} + +EMSCRIPTEN_KEEPALIVE +void flush_decoder(DecoderContext *ctx) { + avcodec_send_packet(ctx->codec_ctx, NULL); + while (avcodec_receive_frame(ctx->codec_ctx, ctx->frame) == 0) {} + avcodec_flush_buffers(ctx->codec_ctx); +} + +EMSCRIPTEN_KEEPALIVE +void close_decoder(DecoderContext *ctx) { + av_frame_free(&ctx->frame); + av_packet_free(&ctx->packet); + avcodec_free_context(&ctx->codec_ctx); + free(ctx); +} + +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 codec_id, int channels, int sample_rate, int bitrate) { + enum AVCodecID av_codec_id = codec_id == 0 ? AV_CODEC_ID_AC3 : AV_CODEC_ID_EAC3; + + const AVCodec *codec = avcodec_find_encoder(av_codec_id); + 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; + } + + // The frame has a fixed format, so let's create it now: + 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 +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 encode_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]; + } + } + + int ret = avcodec_send_frame(ctx->codec_ctx, ctx->frame); + if (ret < 0) return ret; + + ret = avcodec_receive_packet(ctx->codec_ctx, ctx->packet); + 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(EncoderContext *ctx) { + avcodec_send_frame(ctx->codec_ctx, NULL); + while (avcodec_receive_packet(ctx->codec_ctx, ctx->packet) == 0) { + av_packet_unref(ctx->packet); + } +} + +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/ac3/src/codec.worker.ts b/packages/ac3/src/codec.worker.ts new file mode 100644 index 0000000..7224b15 --- /dev/null +++ b/packages/ac3/src/codec.worker.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 createModule from '../build/ac3'; +import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared'; + +type ExtendedEmscriptenModule = EmscriptenModule & { + cwrap: typeof cwrap; +}; + +let module: ExtendedEmscriptenModule; +let modulePromise: Promise | null = null; + +let initDecoderFn: (codecId: number) => number; +let configureDecodePacket: (ctx: number, size: number) => number; +let decodePacket: (ctx: number, pts: number) => number; +let getDecodedFormat: (ctx: number) => number; +let getDecodedPlanePtr: (ctx: number, plane: number) => number; +let getDecodedChannels: (ctx: number) => number; +let getDecodedSampleRate: (ctx: number) => number; +let getDecodedSampleCount: (ctx: number) => number; +let getDecodedPts: (ctx: number) => number; +let flushDecoderFn: (ctx: number) => void; +let closeDecoderFn: (ctx: number) => void; + +let initEncoderFn: (codecId: number, channels: number, sampleRate: number, bitrate: number) => number; +let getEncoderFrameSize: (ctx: number) => number; +let getEncodeInputPtr: (ctx: number, size: number) => number; +let encodeFrameFn: (ctx: number, pts: number) => number; +let flushEncoderFn: (ctx: number) => void; +let getEncodedData: (ctx: number) => number; +let getEncodedPts: (ctx: number) => number; +let getEncodedDuration: (ctx: number) => number; +let closeEncoderFn: (ctx: number) => void; + +const codecToId = (codec: string) => codec === 'ac3' ? 0 : 1; + +const ensureModule = async () => { + if (!module) { + if (modulePromise) { + // If we don't do this we can have a race condition + return modulePromise; + } + + modulePromise = createModule() as Promise; + module = await modulePromise; + modulePromise = null; + + initDecoderFn = module.cwrap('init_decoder', 'number', ['number']); + configureDecodePacket = module.cwrap('configure_decode_packet', 'number', ['number', 'number']); + decodePacket = module.cwrap('decode_packet', 'number', ['number', 'number']); + getDecodedFormat = module.cwrap('get_decoded_format', 'number', ['number']); + getDecodedPlanePtr = module.cwrap('get_decoded_plane_ptr', 'number', ['number', 'number']); + getDecodedChannels = module.cwrap('get_decoded_channels', 'number', ['number']); + getDecodedSampleRate = module.cwrap('get_decoded_sample_rate', 'number', ['number']); + getDecodedSampleCount = module.cwrap('get_decoded_sample_count', 'number', ['number']); + getDecodedPts = module.cwrap('get_decoded_pts', 'number', ['number']); + flushDecoderFn = module.cwrap('flush_decoder', null, ['number']); + closeDecoderFn = module.cwrap('close_decoder', null, ['number']); + + initEncoderFn = module.cwrap('init_encoder', 'number', ['number', 'number', 'number', 'number']); + getEncoderFrameSize = module.cwrap('get_encoder_frame_size', 'number', ['number']); + getEncodeInputPtr = module.cwrap('get_encode_input_ptr', 'number', ['number', 'number']); + encodeFrameFn = module.cwrap('encode_frame', 'number', ['number', 'number']); + flushEncoderFn = module.cwrap('flush_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 initDecoder = async (codec: string) => { + await ensureModule(); + + const ctx = initDecoderFn(codecToId(codec)); + if (ctx === 0) { + throw new Error('Failed to initialize AC3 decoder.'); + } + + return { ctx, frameSize: 0 }; +}; + +// Keys are AVSampleFormat enum values +const AV_FORMAT_MAP: Record = { + 0: { format: 'u8', bytesPerSample: 1, planar: false }, + 1: { format: 's16', bytesPerSample: 2, planar: false }, + 2: { format: 's32', bytesPerSample: 4, planar: false }, + 3: { format: 'f32', bytesPerSample: 4, planar: false }, + 5: { format: 'u8-planar', bytesPerSample: 1, planar: true }, + 6: { format: 's16-planar', bytesPerSample: 2, planar: true }, + 7: { format: 's32-planar', bytesPerSample: 4, planar: true }, + 8: { format: 'f32-planar', bytesPerSample: 4, planar: true }, +}; + +const decode = (ctx: number, encodedData: ArrayBuffer, timestamp: number) => { + const bytes = new Uint8Array(encodedData); + + const dataPtr = configureDecodePacket(ctx, bytes.length); + if (dataPtr === 0) { + throw new Error('Failed to configure decode packet.'); + } + + module.HEAPU8.set(bytes, dataPtr); + + const ret = decodePacket(ctx, timestamp); + if (ret < 0) { + throw new Error(`Decode failed with error code ${ret}.`); + } + + const avFormat = getDecodedFormat(ctx); + const info = AV_FORMAT_MAP[avFormat]; + if (!info) { + throw new Error(`Unsupported AVSampleFormat: ${avFormat}`); + } + + const channels = getDecodedChannels(ctx); + const sampleRate = getDecodedSampleRate(ctx); + const sampleCount = getDecodedSampleCount(ctx); + const pts = getDecodedPts(ctx); + + let pcmData: ArrayBuffer; + if (info.planar) { + const planeSize = sampleCount * info.bytesPerSample; + const buffer = new Uint8Array(planeSize * channels); + + for (let ch = 0; ch < channels; ch++) { + const ptr = getDecodedPlanePtr(ctx, ch); + buffer.set(module.HEAPU8.subarray(ptr, ptr + planeSize), ch * planeSize); + } + + pcmData = buffer.buffer; + } else { + const totalSize = sampleCount * channels * info.bytesPerSample; + const ptr = getDecodedPlanePtr(ctx, 0); + pcmData = module.HEAPU8.slice(ptr, ptr + totalSize).buffer; + } + + return { pcmData, format: info.format, channels, sampleRate, sampleCount, pts }; +}; + +const initEncoder = async ( + codec: string, + numberOfChannels: number, + sampleRate: number, + bitrate: number, +) => { + await ensureModule(); + + const ctx = initEncoderFn(codecToId(codec), numberOfChannels, sampleRate, bitrate); + if (ctx === 0) { + throw new Error('Failed to initialize AC3 encoder.'); + } + + return { ctx, frameSize: getEncoderFrameSize(ctx) }; +}; + +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 bytesWritten = encodeFrameFn(ctx, timestamp); + if (bytesWritten < 0) { + throw new Error(`Encode failed with error code ${bytesWritten}.`); + } + + const ptr = getEncodedData(ctx); + const encodedData = module.HEAPU8.slice(ptr, ptr + bytesWritten).buffer; + const pts = getEncodedPts(ctx); + const duration = getEncodedDuration(ctx); + + return { encodedData, pts, duration }; +}; + +const flushEncoder = (ctx: number) => { + flushEncoderFn(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-decoder': { + const { ctx, frameSize } = await initDecoder(command.data.codec); + result = { type: command.type, ctx, frameSize }; + }; break; + + case 'decode': { + const decoded = decode(command.data.ctx, command.data.encodedData, command.data.timestamp); + result = { + type: command.type, + pcmData: decoded.pcmData, + format: decoded.format, + channels: decoded.channels, + sampleRate: decoded.sampleRate, + sampleCount: decoded.sampleCount, + pts: decoded.pts, + }; + transferables.push(decoded.pcmData); + }; break; + + case 'flush-decoder': { + flushDecoderFn(command.data.ctx); + result = { type: command.type }; + }; break; + + case 'close-decoder': { + closeDecoderFn(command.data.ctx); + result = { type: command.type }; + }; break; + + case 'init-encoder': { + const { ctx, frameSize } = await initEncoder( + command.data.codec, + command.data.numberOfChannels, + command.data.sampleRate, + command.data.bitrate, + ); + result = { type: command.type, ctx, frameSize }; + }; break; + + case 'encode': { + const encoded = encode( + command.data.ctx, + command.data.audioData, + command.data.timestamp, + ); + result = { + type: command.type, + encodedData: encoded.encodedData, + pts: encoded.pts, + duration: encoded.duration, + }; + transferables.push(encoded.encodedData); + }; break; + + case 'flush-encoder': { + flushEncoder(command.data.ctx); + result = { type: command.type }; + }; break; + + case 'close-encoder': { + 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/ac3/src/decoder.ts b/packages/ac3/src/decoder.ts new file mode 100644 index 0000000..73cc9fe --- /dev/null +++ b/packages/ac3/src/decoder.ts @@ -0,0 +1,70 @@ +/*! + * 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 { + CustomAudioDecoder, + AudioCodec, + AudioSample, + EncodedPacket, + registerDecoder, +} from 'mediabunny'; +import { sendCommand } from './worker-client'; + +class Ac3Decoder extends CustomAudioDecoder { + private ctx = 0; + + static override supports(codec: AudioCodec): boolean { + return codec === 'ac3' || codec === 'eac3'; + } + + async init() { + const result = await sendCommand({ + type: 'init-decoder', + data: { codec: this.codec }, + }); + this.ctx = result.ctx; + } + + async decode(packet: EncodedPacket) { + const encodedData = packet.data.slice().buffer; + const timestamp = Math.round(packet.timestamp * this.config.sampleRate); + + const result = await sendCommand({ + type: 'decode', + data: { ctx: this.ctx, encodedData, timestamp }, + }, [encodedData]); + + const sample = new AudioSample({ + data: result.pcmData, + format: result.format, + numberOfChannels: result.channels, + sampleRate: result.sampleRate, + timestamp: result.pts / result.sampleRate, + }); + this.onSample(sample); + } + + async flush() { + await sendCommand({ type: 'flush-decoder', data: { ctx: this.ctx } }); + } + + close() { + void sendCommand({ type: 'close-decoder', data: { ctx: this.ctx } }); + } +} + +/** + * Registers AC-3 and E-AC-3 decoders, which Mediabunny will then use automatically when applicable. Make sure to call + * this function before starting any decoding task. + * + * @group \@mediabunny/ac3 + * @public + */ +export const registerAc3Decoder = () => { + registerDecoder(Ac3Decoder); +}; diff --git a/packages/ac3/src/encoder.ts b/packages/ac3/src/encoder.ts new file mode 100644 index 0000000..1f567a0 --- /dev/null +++ b/packages/ac3/src/encoder.ts @@ -0,0 +1,191 @@ +/*! + * 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 { + CustomAudioEncoder, + AudioCodec, + AudioSample, + EncodedPacket, + registerEncoder, +} from 'mediabunny'; +import { sendCommand } from './worker-client'; +import { assert } from './shared'; +import { AC3_SAMPLE_RATES, EAC3_REDUCED_SAMPLE_RATES } from '../../../shared/ac3-misc'; + +class Ac3Encoder extends CustomAudioEncoder { + private ctx = 0; + private encoderFrameSize = 0; + private sampleRate = 0; + private numberOfChannels = 0; + private chunkMetadata: EncodedAudioChunkMetadata = {}; + + // 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 { + const sampleRates = codec === 'eac3' + ? [...AC3_SAMPLE_RATES, ...EAC3_REDUCED_SAMPLE_RATES] + : AC3_SAMPLE_RATES; + + return (codec === 'ac3' || codec === 'eac3') + && config.numberOfChannels >= 1 + && config.numberOfChannels <= 8 + && sampleRates.includes(config.sampleRate); + } + + async init() { + assert(this.config.bitrate); + this.sampleRate = this.config.sampleRate; + this.numberOfChannels = this.config.numberOfChannels; + + const result = await sendCommand({ + type: 'init-encoder', + data: { + codec: this.codec, + numberOfChannels: this.config.numberOfChannels, + sampleRate: this.config.sampleRate, + bitrate: this.config.bitrate, + }, + }); + + this.ctx = result.ctx; + this.encoderFrameSize = result.frameSize; + + this.resetInternalState(); + } + + private resetInternalState() { + this.pendingFrames = 0; + this.nextSampleTimestampInSamples = null; + this.nextPacketTimestampInSamples = null; + + this.chunkMetadata = { + decoderConfig: { + codec: this.codec === 'ac3' ? 'ac-3' : 'ec-3', + numberOfChannels: this.config.numberOfChannels, + sampleRate: this.config.sampleRate, + }, + }; + } + + 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(); + } + + await sendCommand({ type: 'flush-encoder', data: { ctx: this.ctx } }); + + this.resetInternalState(); + } + + close() { + void sendCommand({ type: 'close-encoder', data: { ctx: this.ctx } }); + } + + 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 sendCommand({ + type: 'encode', + data: { + ctx: this.ctx, + audioData, + timestamp: this.nextSampleTimestampInSamples, + }, + }, [audioData]); + + this.nextSampleTimestampInSamples += frameSize; + + // We always get exactly one packet because we encode the correct frame size + const packet = new EncodedPacket( + new Uint8Array(result.encodedData), + 'key', + this.nextPacketTimestampInSamples / this.sampleRate, + result.duration / this.sampleRate, + ); + + this.nextPacketTimestampInSamples += result.duration; + + this.onPacket( + packet, + this.chunkMetadata, + ); + + this.chunkMetadata = {}; + } +} + +/** + * Registers AC-3 and E-AC-3 encoders, which Mediabunny will then use automatically when applicable. Make sure to call + * this function before starting any encoding task. + * + * @group \@mediabunny/ac3 + * @public + */ +export const registerAc3Encoder = () => { + registerEncoder(Ac3Encoder); +}; diff --git a/packages/ac3/src/index.ts b/packages/ac3/src/index.ts new file mode 100644 index 0000000..79ebf7f --- /dev/null +++ b/packages/ac3/src/index.ts @@ -0,0 +1,21 @@ +/*! + * 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 AC3_LOADED_SYMBOL = Symbol.for('@mediabunny/ac3 loaded'); +if ((globalThis as Record)[AC3_LOADED_SYMBOL]) { + console.error( + '[WARNING]\n@mediabunny/ac3 was loaded twice.' + + ' This will likely cause the encoder/decoder not to work correctly.' + + ' Check if multiple dependencies are importing different versions of @mediabunny/ac3,' + + ' or if something is being bundled incorrectly.', + ); +} +(globalThis as Record)[AC3_LOADED_SYMBOL] = true; + +export { registerAc3Decoder } from './decoder'; +export { registerAc3Encoder } from './encoder'; diff --git a/packages/ac3/src/shared.ts b/packages/ac3/src/shared.ts new file mode 100644 index 0000000..ba8d578 --- /dev/null +++ b/packages/ac3/src/shared.ts @@ -0,0 +1,103 @@ +/*! + * 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-decoder'; + data: { + codec: string; + }; +} | { + type: 'decode'; + data: { + ctx: number; + encodedData: ArrayBuffer; + timestamp: number; + }; +} | { + type: 'flush-decoder'; + data: { + ctx: number; + }; +} | { + type: 'close-decoder'; + data: { + ctx: number; + }; +} | { + type: 'init-encoder'; + data: { + codec: string; + numberOfChannels: number; + sampleRate: number; + bitrate: number; + }; +} | { + type: 'encode'; + data: { + ctx: number; + audioData: ArrayBuffer; + timestamp: number; + }; +} | { + type: 'flush-encoder'; + data: { + ctx: number; + }; +} | { + type: 'close-encoder'; + data: { + ctx: number; + }; +}; + +export type WorkerResponseData = { + type: 'init-decoder'; + ctx: number; + frameSize: number; +} | { + type: 'decode'; + pcmData: ArrayBuffer; + format: AudioSampleFormat; + channels: number; + sampleRate: number; + sampleCount: number; + pts: number; +} | { + type: 'flush-decoder'; +} | { + type: 'close-decoder'; +} | { + type: 'init-encoder'; + ctx: number; + frameSize: number; +} | { + type: 'encode'; + encodedData: ArrayBuffer; + pts: number; + duration: number; +} | { + type: 'flush-encoder'; +} | { + type: 'close-encoder'; +}; + +export type WorkerResponse = { + id: number; +} & ({ + success: true; + data: WorkerResponseData; +} | { + success: false; + error: unknown; +}); + +export function assert(x: unknown): asserts x { + if (!x) { + throw new Error('Assertion failed.'); + } +} diff --git a/packages/ac3/src/worker-client.ts b/packages/ac3/src/worker-client.ts new file mode 100644 index 0000000..d79e1ae --- /dev/null +++ b/packages/ac3/src/worker-client.ts @@ -0,0 +1,69 @@ +/*! + * 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 { assert, type WorkerCommand, type WorkerResponse, type WorkerResponseData } from './shared'; +// @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand +import createWorker from './codec.worker'; + +let workerPromise: Promise | null; +let nextMessageId = 0; +const pendingMessages = new Map void; + reject: (reason?: unknown) => void; +}>(); + +export const sendCommand = async ( + command: WorkerCommand & { type: T }, + transferables?: Transferable[], +) => { + const worker = await ensureWorker(); + + return new Promise((resolve, reject) => { + const id = nextMessageId++; + pendingMessages.set(id, { + resolve: resolve as (value: WorkerResponseData) => void, + reject, + }); + + if (transferables) { + worker.postMessage({ id, command }, transferables); + } else { + worker.postMessage({ id, command }); + } + }); +}; + +const ensureWorker = () => { + return workerPromise ??= (async () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const worker = (await createWorker()) as Worker; + + const onMessage = (data: WorkerResponse) => { + const pending = pendingMessages.get(data.id); + assert(pending !== undefined); + + pendingMessages.delete(data.id); + if (data.success) { + pending.resolve(data.data); + } else { + pending.reject(data.error); + } + }; + + if (worker.addEventListener) { + worker.addEventListener('message', event => onMessage(event.data as WorkerResponse)); + } else { + const nodeWorker = worker as unknown as { + on: (event: string, listener: (data: never) => void) => void; + }; + nodeWorker.on('message', onMessage); + } + + return worker; + })(); +}; diff --git a/packages/mp3-encoder/src/tsconfig.json b/packages/ac3/tsconfig.json similarity index 56% rename from packages/mp3-encoder/src/tsconfig.json rename to packages/ac3/tsconfig.json index 8e70282..27a7ef1 100644 --- a/packages/mp3-encoder/src/tsconfig.json +++ b/packages/ac3/tsconfig.json @@ -1,7 +1,8 @@ { - "extends": "../../../tsconfig.json", + "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "../dist/modules", + "composite": true, + "outDir": "./dist/modules", "declaration": true, "declarationMap": true, "stripInternal": true, @@ -10,14 +11,14 @@ "module": "nodenext", "allowJs": true, "paths": { - "mediabunny": ["../../../src/index.ts"], + "mediabunny": ["../../src/index.ts"], }, }, "include": [ - "**/*", - "../../../shared/**/*" + "./src/**/*", + "./build/**/*", ], "references": [ - { "path": "../../../src" } + { "path": "../../src" } ] -} \ No newline at end of file +} diff --git a/packages/ac3/tsdoc.json b/packages/ac3/tsdoc.json new file mode 100644 index 0000000..51d29d3 --- /dev/null +++ b/packages/ac3/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/README.md b/packages/mp3-encoder/README.md index 14178c5..c7e7dfb 100644 --- a/packages/mp3-encoder/README.md +++ b/packages/mp3-encoder/README.md @@ -102,8 +102,6 @@ The WASM build itself is a performance-optimized, SIMD-enabled build of LAME 3.1 ## Building and development -Building this library is done using the build commands in the [Mediabunny root](https://github.com/Vanilagy/mediabunny). - 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: ### Prerequisites @@ -148,7 +146,7 @@ emcc src/lame-bridge.c build/libmp3lame.a \ -o build/lame.js ``` -This generates `build/lame.js`, which contains both the JavaScript "glue code" as well as the compiled WASM encoded with Base64. +This generates `build/lame.js`, which contains both the JavaScript "glue code" as well as the compiled WASM inlined. ### Building the package diff --git a/packages/mp3-encoder/src/encode.worker.ts b/packages/mp3-encoder/src/encode.worker.ts index e5745b9..ee24db8 100644 --- a/packages/mp3-encoder/src/encode.worker.ts +++ b/packages/mp3-encoder/src/encode.worker.ts @@ -146,7 +146,10 @@ const onMessage = (data: { id: number; command: WorkerCommand }) => { command.data.sampleRate, command.data.bitrate, ); - result = { success: true }; + result = { + type: command.type, + success: true, + }; }; break; case 'encode': { @@ -154,13 +157,19 @@ const onMessage = (data: { id: number; command: WorkerCommand }) => { command.data.audioData, command.data.numberOfFrames, ); - result = { encodedData }; + result = { + type: command.type, + encodedData, + }; transferables.push(encodedData); }; break; case 'flush': { const flushedData = flush(); - result = { flushedData }; + result = { + type: command.type, + flushedData, + }; transferables.push(flushedData); }; break; } diff --git a/packages/mp3-encoder/src/index.ts b/packages/mp3-encoder/src/index.ts index 1519a19..ad32601 100644 --- a/packages/mp3-encoder/src/index.ts +++ b/packages/mp3-encoder/src/index.ts @@ -80,6 +80,12 @@ class Mp3Encoder extends CustomAudioEncoder { }, }); + this.resetInternalState(); + } + + private resetInternalState() { + this.currentBufferOffset = 0; + this.currentTimestamp = null; this.chunkMetadata = { decoderConfig: { codec: 'mp3', @@ -119,15 +125,15 @@ class Mp3Encoder extends CustomAudioEncoder { }, }, [audioData]); - assert('encodedData' in result); this.digestOutput(new Uint8Array(result.encodedData)); } async flush() { const result = await this.sendCommand({ type: 'flush' }); - assert('flushedData' in result); this.digestOutput(new Uint8Array(result.flushedData)); + + this.resetInternalState(); } close() { @@ -171,9 +177,7 @@ class Mp3Encoder extends CustomAudioEncoder { const duration = header.audioSamplesInFrame / header.sampleRate; this.onPacket(new EncodedPacket(data, 'key', this.currentTimestamp, duration), this.chunkMetadata); - if (this.currentTimestamp === 0) { - this.chunkMetadata = {}; // Mimic WebCodecs-like behavior - } + this.chunkMetadata = {}; // Mimic WebCodecs-like behavior this.currentTimestamp += duration; pos += header.totalSize; @@ -186,13 +190,16 @@ class Mp3Encoder extends CustomAudioEncoder { } } - private sendCommand( - command: WorkerCommand, + private sendCommand( + command: WorkerCommand & { type: T }, transferables?: Transferable[], ) { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const id = this.nextMessageId++; - this.pendingMessages.set(id, { resolve, reject }); + this.pendingMessages.set(id, { + resolve: resolve as (value: WorkerResponseData) => void, + reject, + }); assert(this.worker); diff --git a/packages/mp3-encoder/src/shared.ts b/packages/mp3-encoder/src/shared.ts index d407003..526dac1 100644 --- a/packages/mp3-encoder/src/shared.ts +++ b/packages/mp3-encoder/src/shared.ts @@ -24,10 +24,13 @@ export type WorkerCommand = { }; export type WorkerResponseData = { + type: 'init'; success: boolean; } | { + type: 'encode'; encodedData: ArrayBuffer; } | { + type: 'flush'; flushedData: ArrayBuffer; }; diff --git a/packages/mp3-encoder/tsconfig.json b/packages/mp3-encoder/tsconfig.json new file mode 100644 index 0000000..bf2e94f --- /dev/null +++ b/packages/mp3-encoder/tsconfig.json @@ -0,0 +1,25 @@ +{ + "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/**/*", + "../../shared/**/*", + ], + "references": [ + { "path": "../../src" } + ] +} diff --git a/packages/mp3-encoder/tsdoc.json b/packages/mp3-encoder/tsdoc.json new file mode 100644 index 0000000..51d29d3 --- /dev/null +++ b/packages/mp3-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/build.sh b/scripts/build.sh similarity index 78% rename from build.sh rename to scripts/build.sh index e1d5e58..c0595fe 100755 --- a/build.sh +++ b/scripts/build.sh @@ -6,13 +6,15 @@ set -e # Clear the stuff from last build rm -rf dist rm -rf packages/mp3-encoder/dist +rm -rf packages/ac3/dist # Ensure license headers on all source files tsx scripts/ensure-license-headers.ts # Type check & generate .js and .d.ts files tsc -p src -tsc -p packages/mp3-encoder/src +tsc -p packages/mp3-encoder +tsc -p packages/ac3 # 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) @@ -24,14 +26,17 @@ tsx scripts/bundle.ts # Declaration file rollup and checks api-extractor run api-extractor run -c packages/mp3-encoder/api-extractor.json +api-extractor run -c packages/ac3/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 # Checks that API docs are generatable 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 \ No newline at end of file +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 diff --git a/scripts/bundle.ts b/scripts/bundle.ts index 5ca0c3d..c1a23d2 100644 --- a/scripts/bundle.ts +++ b/scripts/bundle.ts @@ -113,9 +113,41 @@ const mp3EncoderVariants = await createVariants( }, ); +const ac3Variants = await createVariants( + 'packages/ac3/src/index.ts', + 'MediabunnyAc3', + 'packages/ac3/dist/bundles/mediabunny-ac3', + '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, ]; if (process.argv[2] === '--watch') { diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..41aaef3 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +rm -rf dist/modules +tsc -p src --stripInternal false + +tsc -p packages/mp3-encoder --noEmit + +rm -rf packages/ac3/dist/modules +tsc -p packages/ac3 + +tsc -p tsconfig.vitest.json --noEmit + +tsc -p scripts --noEmit + +tsc -p tsconfig.vite.json --noEmit diff --git a/scripts/ensure-license-headers.ts b/scripts/ensure-license-headers.ts index 231f427..37720ed 100644 --- a/scripts/ensure-license-headers.ts +++ b/scripts/ensure-license-headers.ts @@ -36,6 +36,7 @@ const checkDirectory = (dirPath: string) => { checkDirectory(path.join(__dirname, '..', 'src')); checkDirectory(path.join(__dirname, '..', 'packages', 'mp3-encoder', 'src')); +checkDirectory(path.join(__dirname, '..', 'packages', 'ac3', 'src')); checkDirectory(path.join(__dirname, '..', 'shared')); if (missingFiles.length > 0) { diff --git a/shared/ac3-misc.ts b/shared/ac3-misc.ts new file mode 100644 index 0000000..fa3145f --- /dev/null +++ b/shared/ac3-misc.ts @@ -0,0 +1,13 @@ +/*! + * 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/. + */ + +/** Sample rates indexed by fscod (Table 4.1) */ +export const AC3_SAMPLE_RATES = [48000, 44100, 32000]; + +/** E-AC-3 reduced sample rates for fscod2 per ATSC A/52:2018 */ +export const EAC3_REDUCED_SAMPLE_RATES = [24000, 22050, 16000]; diff --git a/src/codec-data.ts b/src/codec-data.ts index 685948b..5f2ffb5 100644 --- a/src/codec-data.ts +++ b/src/codec-data.ts @@ -28,6 +28,7 @@ import { } from './misc'; import { PacketType } from './packet'; import { MetadataTags } from './metadata'; +import { AC3_SAMPLE_RATES, EAC3_REDUCED_SAMPLE_RATES } from '../shared/ac3-misc'; // References for AVC/HEVC code: // ISO 14496-15 @@ -2649,14 +2650,11 @@ export const createVorbisComments = (headerBytes: Uint8Array, tags: MetadataTags // Reference: ETSI TS 102 366 V1.4.1 // ============================================================================ -/** Sample rates indexed by fscod (Table 4.1) */ -export const AC3_SAMPLE_RATES = [48000, 44100, 32000] as const; - /** * Channel counts indexed by acmod (Table 4.3). * Does NOT include LFE - add lfeon to get total channel count. */ -export const AC3_ACMOD_CHANNEL_COUNTS = [2, 1, 2, 3, 3, 4, 4, 5] as const; +export const AC3_ACMOD_CHANNEL_COUNTS = [2, 1, 2, 3, 3, 4, 4, 5]; export interface Ac3FrameInfo { /** Sample rate code */ @@ -2788,11 +2786,8 @@ export const AC3_REGISTRATION_DESCRIPTOR = new Uint8Array([0x05, 0x04, 0x41, 0x4 /** E-AC-3 registration_descriptor for MPEG-TS/ */ export const EAC3_REGISTRATION_DESCRIPTOR = new Uint8Array([0x05, 0x04, 0x45, 0x41, 0x43, 0x33]); -/** E-AC-3 reduced sample rates for fscod2 per ATSC A/52:2018 */ -const EAC3_REDUCED_SAMPLE_RATES = [24000, 22050, 16000] as const; - /** Number of audio blocks per syncframe, indexed by numblkscod */ -export const EAC3_NUMBLKS_TABLE = [1, 2, 3, 6] as const; +export const EAC3_NUMBLKS_TABLE = [1, 2, 3, 6]; /** * E-AC-3 independent substream info. diff --git a/src/conversion.ts b/src/conversion.ts index 221e13a..0a4ac13 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -773,6 +773,13 @@ export class Conversion { `\nThe @mediabunny/mp3-encoder extension package provides support for encoding MP3.`, ); } + + if (codecs.includes('ac3') || codecs.includes('eac3')) { + elements.push( + '\nThe @mediabunny/ac3 extension package provides support' + + ' for encoding and decoding AC-3/E-AC-3.', + ); + } } else { elements.push('\nCheck the discardedTracks field for more info.'); } diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index 37eee33..c70efcc 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -30,7 +30,6 @@ import { parseEac3Config, getEac3SampleRate, getEac3ChannelCount, - AC3_SAMPLE_RATES, AC3_ACMOD_CHANNEL_COUNTS, } from '../codec-data'; import { Demuxer } from '../demuxer'; @@ -92,6 +91,7 @@ import { readAscii, } from '../reader'; import { DEFAULT_TRACK_DISPOSITION, MetadataTags, RichImageData, TrackDisposition } from '../metadata'; +import { AC3_SAMPLE_RATES } from '../../shared/ac3-misc'; type InternalTrack = { id: number; diff --git a/src/media-source.ts b/src/media-source.ts index 362733c..a65a9da 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -227,7 +227,6 @@ class VideoEncoderWrapper { * So, we keep track of the encoder error and throw it as soon as we get the chance. */ private error: Error | null = null; - private errorNeedsNewStack = true; constructor(private source: VideoSource, private encodingConfig: VideoEncodingConfig) {} @@ -413,7 +412,6 @@ class VideoEncoderWrapper { } private ensureEncoder(videoSample: VideoSample) { - const encoderError = new Error(); this.ensureEncoderPromise = (async () => { const encoderConfig = buildVideoEncoderConfig({ width: videoSample.codedWidth, @@ -448,7 +446,6 @@ class VideoEncoderWrapper { void this.muxer!.addEncodedVideoPacket(this.source._connectedTrack!, packet, meta) .catch((error) => { this.error ??= error; - this.errorNeedsNewStack = false; }); }; @@ -520,10 +517,11 @@ class VideoEncoderWrapper { void this.muxer!.addEncodedVideoPacket(this.source._connectedTrack!, packet, meta) .catch((error) => { this.error ??= error; - this.errorNeedsNewStack = false; }); }; + const stack = new Error('Encoding error').stack; + this.encoder = new VideoEncoder({ output: (chunk, meta) => { if (!this.alphaEncoder) { @@ -560,13 +558,15 @@ class VideoEncoderWrapper { } }, error: (error) => { - error.stack = encoderError.stack; // Provide a more useful stack trace + error.stack = stack; // Provide a more useful stack trace, the default one sucks this.error ??= error; }, }); this.encoder.configure(encoderConfig); if (this.encodingConfig.alpha === 'keep') { + const stack = new Error('Encoding error').stack; + // We need to encode alpha as well, which we do with a separate encoder this.alphaEncoder = new VideoEncoder({ // We ignore the alpha chunk's metadata @@ -594,7 +594,7 @@ class VideoEncoderWrapper { } }, error: (error) => { - error.stack = encoderError.stack; // Provide a more useful stack trace + error.stack = stack; // Provide a more useful stack trace this.error ??= error; }, }); @@ -652,10 +652,6 @@ class VideoEncoderWrapper { checkForEncoderError() { if (this.error) { - if (this.errorNeedsNewStack) { - this.error.stack = new Error().stack; // Provide an even more useful stack trace - } - throw this.error; } } @@ -1351,7 +1347,6 @@ class AudioEncoderWrapper { * So, we keep track of the encoder error and throw it as soon as we get the chance. */ private error: Error | null = null; - private errorNeedsNewStack = true; constructor(private source: AudioSource, private encodingConfig: AudioEncodingConfig) {} @@ -1546,7 +1541,6 @@ class AudioEncoderWrapper { } private ensureEncoder(audioSample: AudioSample) { - const encoderError = new Error(); this.ensureEncoderPromise = (async () => { const { numberOfChannels, sampleRate } = audioSample; @@ -1582,7 +1576,6 @@ class AudioEncoderWrapper { void this.muxer!.addEncodedAudioPacket(this.source._connectedTrack!, packet, meta) .catch((error) => { this.error ??= error; - this.errorNeedsNewStack = false; }); }; @@ -1603,6 +1596,8 @@ class AudioEncoderWrapper { ); } + const stack = new Error('Encoding error').stack; + this.encoder = new AudioEncoder({ output: (chunk, meta) => { // WebKit emits an invalid description for AAC (https://bugs.webkit.org/show_bug.cgi?id=302253), @@ -1637,11 +1632,10 @@ class AudioEncoderWrapper { void this.muxer!.addEncodedAudioPacket(this.source._connectedTrack!, packet, meta) .catch((error) => { this.error ??= error; - this.errorNeedsNewStack = false; }); }, error: (error) => { - error.stack = encoderError.stack; // Provide a more useful stack trace + error.stack = stack; // Provide a more useful stack trace this.error ??= error; }, }); @@ -1782,10 +1776,6 @@ class AudioEncoderWrapper { checkForEncoderError() { if (this.error) { - if (this.errorNeedsNewStack) { - this.error.stack = new Error().stack; // Provide an even more useful stack trace - } - throw this.error; } } diff --git a/src/mpeg-ts/mpeg-ts-demuxer.ts b/src/mpeg-ts/mpeg-ts-demuxer.ts index b9810da..6223e7b 100644 --- a/src/mpeg-ts/mpeg-ts-demuxer.ts +++ b/src/mpeg-ts/mpeg-ts-demuxer.ts @@ -20,7 +20,6 @@ import { } from '../codec'; import { AC3_ACMOD_CHANNEL_COUNTS, - AC3_SAMPLE_RATES, AC3_SAMPLES_PER_FRAME, AvcDecoderConfigurationRecord, AvcNalUnitType, @@ -71,6 +70,7 @@ import { FRAME_HEADER_SIZE as MP3_FRAME_HEADER_SIZE, readMp3FrameHeader } from ' 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'; type ElementaryStream = { demuxer: MpegTsDemuxer; diff --git a/test/node/ac3.test.ts b/test/node/ac3.test.ts index 08e3fbb..0989b89 100644 --- a/test/node/ac3.test.ts +++ b/test/node/ac3.test.ts @@ -8,6 +8,12 @@ import { MpegTsOutputFormat } from '../../src/output-format.js'; import { BufferTarget } from '../../src/target.js'; import { Conversion } from '../../src/conversion.js'; import { AC3_REGISTRATION_DESCRIPTOR, EAC3_REGISTRATION_DESCRIPTOR } from '../../src/codec-data.js'; +import { canEncode } from '../../src/encode.js'; +import { AudioSampleSink, EncodedPacketSink } from '../../src/media-sink.js'; +import { AudioSampleSource } from '../../src/media-source.js'; +import { Mp4OutputFormat } from '../../src/output-format.js'; +import { AudioSample } from '../../src/sample.js'; +import { registerAc3Decoder, registerAc3Encoder } from '@mediabunny/ac3'; const __dirname = new URL('.', import.meta.url).pathname; @@ -122,3 +128,200 @@ test('reads and writes E-AC-3 in MPEG-TS', async () => { } expect(found).toBe(true); }); + +test('Custom coder registration', async () => { + using ac3Input = new Input({ + source: new FilePathSource(path.join(__dirname, '..', 'public/ac3.mp4')), + formats: ALL_FORMATS, + }); + using eac3Input = new Input({ + source: new FilePathSource(path.join(__dirname, '..', 'public/eac3.mp4')), + formats: ALL_FORMATS, + }); + + const ac3Track = (await ac3Input.getPrimaryAudioTrack())!; + const eac3Track = (await eac3Input.getPrimaryAudioTrack())!; + + // Before registration, nothing can decode or encode AC3/EAC3 + expect(await ac3Track.canDecode()).toBe(false); + expect(await eac3Track.canDecode()).toBe(false); + expect(await canEncode('ac3')).toBe(false); + expect(await canEncode('eac3')).toBe(false); + + registerAc3Decoder(); + registerAc3Encoder(); + + // After registration, both should work + expect(await ac3Track.canDecode()).toBe(true); + expect(await eac3Track.canDecode()).toBe(true); + expect(await canEncode('ac3')).toBe(true); + expect(await canEncode('eac3')).toBe(true); +}); + +test('AC-3 decoding', async () => { + registerAc3Decoder(); + + using input = new Input({ + source: new FilePathSource(path.join(__dirname, '..', 'public/ac3.mp4')), + formats: ALL_FORMATS, + }); + + const track = (await input.getPrimaryAudioTrack())!; + const { packetCount } = await track.computePacketStats(); + const sink = new AudioSampleSink(track); + + let sampleCount = 0; + let nextTimestamp = 0; + + for await (using sample of sink.samples()) { + expect(sample.timestamp).toBeCloseTo(nextTimestamp); + expect(sample.duration).toBe(0.032); + expect(sample.format).toBe('f32-planar'); + expect(sample.numberOfChannels).toBe(track.numberOfChannels); + expect(sample.sampleRate).toBe(track.sampleRate); + + nextTimestamp += sample.duration; + sampleCount++; + } + + expect(sampleCount).toBe(packetCount); +}); + +test('E-AC-3 decoding', async () => { + registerAc3Decoder(); + + using input = new Input({ + source: new FilePathSource(path.join(__dirname, '..', 'public/eac3.mp4')), + formats: ALL_FORMATS, + }); + + const track = (await input.getPrimaryAudioTrack())!; + const { packetCount } = await track.computePacketStats(); + const sink = new AudioSampleSink(track); + + let sampleCount = 0; + let nextTimestamp = 0; + + for await (using sample of sink.samples()) { + expect(sample.timestamp).toBeCloseTo(nextTimestamp); + expect(sample.duration).toBe(0.032); + expect(sample.format).toBe('f32-planar'); + expect(sample.numberOfChannels).toBe(track.numberOfChannels); + expect(sample.sampleRate).toBe(track.sampleRate); + + nextTimestamp += sample.duration; + sampleCount++; + } + + expect(sampleCount).toBe(packetCount); +}); + +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('AC-3 encoding', async () => { + registerAc3Encoder(); + + 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: 'ac3', bitrate: 192000 }); + 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('ac3'); + 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(0); + + expect(await track.computeDuration()).toBeCloseTo(2, 1); +}); + +test('E-AC-3 encoding', async () => { + registerAc3Encoder(); + + 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: 'eac3', bitrate: 192000 }); + 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('eac3'); + 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(0); + + expect(await track.computeDuration()).toBeCloseTo(2, 1); +}); diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..25dba5b --- /dev/null +++ b/todo.txt @@ -0,0 +1 @@ +- Investigate quicktime stuttering with mediabunny-generated .ts file \ No newline at end of file diff --git a/tsconfig.vite.json b/tsconfig.vite.json index e40e718..18c8e14 100644 --- a/tsconfig.vite.json +++ b/tsconfig.vite.json @@ -7,6 +7,7 @@ "noEmit": false, "paths": { "mediabunny": ["./src/index.ts"], + "@mediabunny/ac3": ["./packages/ac3/src/index.ts"], }, "types": ["vite/client"] }, diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json index 7a24606..7101888 100644 --- a/tsconfig.vitest.json +++ b/tsconfig.vitest.json @@ -4,9 +4,13 @@ "moduleResolution": "nodenext", "module": "NodeNext", "composite": true, - "noEmit": false + "noEmit": false, + "paths": { + "mediabunny": ["./src/index.ts"], + "@mediabunny/ac3": ["./packages/ac3/src/index.ts"], + }, }, "include": ["vitest.config.ts", "./test/**/*"], "exclude": ["./test/public/**/*"], - "references": [{ "path": "./src" }] + "references": [{ "path": "./src" }, { "path": "./packages/ac3" }] } diff --git a/vitest.config.ts b/vitest.config.ts index 33a53df..3623ed8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,11 +1,19 @@ /// import { defineConfig } from 'vitest/config'; +import path from 'path'; export default defineConfig({ + resolve: { + alias: { + 'mediabunny': path.resolve(__dirname, './src/index.ts'), + '@mediabunny/ac3': path.resolve(__dirname, './packages/ac3/dist/bundles/mediabunny-ac3.mjs'), + }, + }, test: { projects: [ { + extends: true, test: { name: 'node', root: 'test', @@ -14,6 +22,7 @@ export default defineConfig({ }, }, { + extends: true, test: { name: 'browser', root: 'test',