diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 744c4a1..550b77c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,6 +75,11 @@ jobs: packages/ac3/dist/bundles/mediabunny-ac3.mjs packages/ac3/dist/bundles/mediabunny-ac3.min.mjs packages/ac3/dist/mediabunny-ac3.d.ts + packages/dts/dist/bundles/mediabunny-dts.js + packages/dts/dist/bundles/mediabunny-dts.min.js + packages/dts/dist/bundles/mediabunny-dts.mjs + packages/dts/dist/bundles/mediabunny-dts.min.mjs + packages/dts/dist/mediabunny-dts.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 diff --git a/.gitignore b/.gitignore index 30c66a0..8546073 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ node_modules packages/mp3-encoder/dist packages/ac3/dist +packages/dts/dist packages/aac-encoder/dist packages/flac-encoder/dist packages/server/dist diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 9720265..24dbe67 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -125,6 +125,7 @@ export default withMermaid({ { text: 'mp3-encoder', link: '/guide/extensions/mp3-encoder' }, { text: 'aac-encoder', link: '/guide/extensions/aac-encoder' }, { text: 'ac3', link: '/guide/extensions/ac3' }, + { text: 'dts', link: '/guide/extensions/dts' }, { text: 'flac-encoder', link: '/guide/extensions/flac-encoder' }, { text: 'prores', link: '/guide/extensions/prores' }, ], diff --git a/docs/api-config.json b/docs/api-config.json index b56656e..39b9878 100644 --- a/docs/api-config.json +++ b/docs/api-config.json @@ -25,6 +25,7 @@ "@mediabunny/server": "Adds full video/audio decoder and encoder support to Mediabunny running in server-side environments such as Node, Bun, or Deno.", "@mediabunny/mp3-encoder": "Adds MP3 encoder support to Mediabunny.", "@mediabunny/ac3": "Adds AC-3/E-AC-3 decoder and encoder support to Mediabunny.", + "@mediabunny/dts": "Adds DTS decoder and encoder support to Mediabunny.", "@mediabunny/aac-encoder": "Polyfills AAC encoder support to Mediabunny.", "@mediabunny/flac-encoder": "Adds FLAC encoder support to Mediabunny.", "@mediabunny/prores": "Adds Apple ProRes decoder support to Mediabunny." diff --git a/docs/codec-registry/dts.md b/docs/codec-registry/dts.md index 3f0d5b6..88ef8b2 100644 --- a/docs/codec-registry/dts.md +++ b/docs/codec-registry/dts.md @@ -1,5 +1,5 @@ --- -description: DTS (DTS Coherent Acoustics) audio codec definition, defining legal codec strings, decoder configs, and packet data formats. +description: DTS audio codec definition, defining legal codec strings, decoder configs, and packet data formats. --- + +``` + +This will expose the global objects `Mediabunny` and `MediabunnyDts`. Use `mediabunny-dts.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 { registerDtsDecoder, registerDtsEncoder } from '@mediabunny/dts'; + +registerDtsDecoder(); +registerDtsEncoder(); +``` +That's it - Mediabunny now uses the registered DTS decoder and encoder automatically. diff --git a/docs/guide/supported-formats-and-codecs.md b/docs/guide/supported-formats-and-codecs.md index 6158de2..bc4c537 100644 --- a/docs/guide/supported-formats-and-codecs.md +++ b/docs/guide/supported-formats-and-codecs.md @@ -114,7 +114,7 @@ For HLS, the supported codecs depend on the segment format chosen. [^mp3]: MP3 encoding is not supported by WebCodecs. You can polyfill it with the [`@mediabunny/mp3-encoder`](./extensions/mp3-encoder) extension package. [^flac]: FLAC encoding is not supported by WebCodecs. You can polyfill it with the [`@mediabunny/flac-encoder`](./extensions/flac-encoder) extension package. [^ac3]: 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. -[^dts]: DTS is not supported by WebCodecs. The [`@mediabunny/server`](./extensions/server) extension package provides both decoding and encoding support for server-side environments. +[^dts]: DTS is not natively supported by WebCodecs. To encode or decode it, you can use the [`@mediabunny/dts`](./extensions/dts) extension package. [^webm]: 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. [^webvtt]: WebVTT can only be written, not read. diff --git a/examples/file-compression/file-compression.ts b/examples/file-compression/file-compression.ts index 5f9370b..cba16e8 100644 --- a/examples/file-compression/file-compression.ts +++ b/examples/file-compression/file-compression.ts @@ -10,12 +10,14 @@ import { UrlSource, } from 'mediabunny'; import { registerAc3Decoder } from '@mediabunny/ac3'; +import { registerDtsDecoder } from '@mediabunny/dts'; import { registerProresDecoder } from '@mediabunny/prores'; import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4'; // Enable codecs that aren't natively supported by WebCodecs. registerAc3Decoder(); +registerDtsDecoder(); registerProresDecoder(); (document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl; diff --git a/examples/hls-transcoding/hls-transcoding.ts b/examples/hls-transcoding/hls-transcoding.ts index fc13c1d..a85fdde 100644 --- a/examples/hls-transcoding/hls-transcoding.ts +++ b/examples/hls-transcoding/hls-transcoding.ts @@ -12,12 +12,14 @@ import { UrlSource, } from 'mediabunny'; import { registerAc3Decoder } from '@mediabunny/ac3'; +import { registerDtsDecoder } from '@mediabunny/dts'; import { registerProresDecoder } from '@mediabunny/prores'; import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4'; // Enable codecs that aren't natively supported by WebCodecs. registerAc3Decoder(); +registerDtsDecoder(); registerProresDecoder(); (document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl; diff --git a/examples/media-player/media-player.ts b/examples/media-player/media-player.ts index 6d851e2..1572163 100644 --- a/examples/media-player/media-player.ts +++ b/examples/media-player/media-player.ts @@ -9,12 +9,14 @@ import { WrappedCanvas, } from 'mediabunny'; import { registerAc3Decoder } from '@mediabunny/ac3'; +import { registerDtsDecoder } from '@mediabunny/dts'; import { registerProresDecoder } from '@mediabunny/prores'; import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4'; // Enable codecs that aren't natively supported by WebCodecs. registerAc3Decoder(); +registerDtsDecoder(); registerProresDecoder(); (document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl; diff --git a/examples/thumbnail-generation/thumbnail-generation.ts b/examples/thumbnail-generation/thumbnail-generation.ts index 69a995a..7add9bc 100644 --- a/examples/thumbnail-generation/thumbnail-generation.ts +++ b/examples/thumbnail-generation/thumbnail-generation.ts @@ -1,11 +1,13 @@ import { Input, ALL_FORMATS, BlobSource, UrlSource, CanvasSink } from 'mediabunny'; import { registerAc3Decoder } from '@mediabunny/ac3'; +import { registerDtsDecoder } from '@mediabunny/dts'; import { registerProresDecoder } from '@mediabunny/prores'; import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4'; // Enable codecs that aren't natively supported by WebCodecs. registerAc3Decoder(); +registerDtsDecoder(); registerProresDecoder(); (document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl; diff --git a/package-lock.json b/package-lock.json index e8a6e4e..63832d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1453,6 +1453,10 @@ "resolved": "packages/ac3", "link": true }, + "node_modules/@mediabunny/dts": { + "resolved": "packages/dts", + "link": true + }, "node_modules/@mediabunny/flac-encoder": { "resolved": "packages/flac-encoder", "link": true @@ -12982,6 +12986,21 @@ "mediabunny": "^1.0.0" } }, + "packages/dts": { + "name": "@mediabunny/dts", + "version": "1.54.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/flac-encoder": { "name": "@mediabunny/flac-encoder", "version": "1.54.0", diff --git a/package.json b/package.json index b3ea4ae..651137d 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,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 packages/aac-encoder/src/index.ts packages/flac-encoder/src/index.ts packages/prores/src/index.ts packages/server/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/dts/src/index.ts packages/aac-encoder/src/index.ts packages/flac-encoder/src/index.ts packages/prores/src/index.ts packages/server/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/package.json b/packages/aac-encoder/package.json index 0efade3..4cf829a 100644 --- a/packages/aac-encoder/package.json +++ b/packages/aac-encoder/package.json @@ -2,7 +2,7 @@ "name": "@mediabunny/aac-encoder", "author": "Vanilagy", "version": "1.54.0", - "description": "AAC encoder extension for Mediabunny, based on FFmpeg.", + "description": "AAC encoder extension for Mediabunny, based on libavcodec.", "main": "./dist/bundles/mediabunny-aac-encoder.mjs", "module": "./dist/bundles/mediabunny-aac-encoder.mjs", "types": "./dist/modules/src/index.d.ts", diff --git a/packages/ac3/package.json b/packages/ac3/package.json index eb7be18..2b2fbe6 100644 --- a/packages/ac3/package.json +++ b/packages/ac3/package.json @@ -2,7 +2,7 @@ "name": "@mediabunny/ac3", "author": "Vanilagy", "version": "1.54.0", - "description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on FFmpeg.", + "description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on libavcodec.", "main": "./dist/bundles/mediabunny-ac3.mjs", "module": "./dist/bundles/mediabunny-ac3.mjs", "types": "./dist/modules/src/index.d.ts", diff --git a/packages/dts/.gitattributes b/packages/dts/.gitattributes new file mode 100644 index 0000000..72ef19a --- /dev/null +++ b/packages/dts/.gitattributes @@ -0,0 +1 @@ +build/* linguist-generated diff --git a/packages/dts/LICENSE b/packages/dts/LICENSE new file mode 100644 index 0000000..d0a1fa1 --- /dev/null +++ b/packages/dts/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/dts/README.md b/packages/dts/README.md new file mode 100644 index 0000000..7f74a3e --- /dev/null +++ b/packages/dts/README.md @@ -0,0 +1,106 @@ +# @mediabunny/dts + +[![](https://img.shields.io/npm/v/@mediabunny/dts)](https://www.npmjs.com/package/@mediabunny/dts) +[![](https://img.shields.io/bundlephobia/minzip/@mediabunny/dts)](https://bundlephobia.com/package/@mediabunny/dts) +[![](https://img.shields.io/npm/dm/@mediabunny/dts)](https://www.npmjs.com/package/@mediabunny/dts) +[![](https://img.shields.io/discord/1390044844285497344?logo=discord&label=Discord)](https://discord.gg/hmpkyYuS4U) + +
+ +
+ +Browsers have no support for the DTS audio codec 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 this codec 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 DTS 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/dts +``` + +Alternatively, directly include them using a script tag: +```html + + +``` + +This will expose the global objects `Mediabunny` and `MediabunnyDts`. Use `mediabunny-dts.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 { registerDtsDecoder, registerDtsEncoder } from '@mediabunny/dts'; + +registerDtsDecoder(); +registerDtsEncoder(); +``` +That's it - Mediabunny now uses the registered DTS 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=dca \ + --enable-encoder=dca \ + --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/dts +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/dts.js +``` + +This generates `build/dts.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/dts/api-extractor.json b/packages/dts/api-extractor.json new file mode 100644 index 0000000..8cb42c0 --- /dev/null +++ b/packages/dts/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-dts.d.ts" + }, + "tsdocMetadata": { + "enabled": false + }, + "messages": { + "compilerMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + "extractorMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + } + } + }, + "newlineKind": "lf" +} diff --git a/packages/dts/build/dts.js b/packages/dts/build/dts.js new file mode 100644 index 0000000..ee43507 Binary files /dev/null and b/packages/dts/build/dts.js differ diff --git a/packages/dts/package.json b/packages/dts/package.json new file mode 100644 index 0000000..f59f771 --- /dev/null +++ b/packages/dts/package.json @@ -0,0 +1,59 @@ +{ + "name": "@mediabunny/dts", + "author": "Vanilagy", + "version": "1.54.0", + "description": "DTS decoder and encoder extension for Mediabunny, based on libavcodec.", + "main": "./dist/bundles/mediabunny-dts.mjs", + "module": "./dist/bundles/mediabunny-dts.mjs", + "types": "./dist/modules/src/index.d.ts", + "exports": { + "types": "./dist/modules/src/index.d.ts", + "import": "./dist/bundles/mediabunny-dts.mjs", + "require": "./dist/bundles/mediabunny-dts.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/dts" + }, + "bugs": { + "url": "https://github.com/Vanilagy/mediabunny/issues" + }, + "homepage": "https://mediabunny.dev/guide/extensions/dts", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + }, + "devDependencies": { + "@types/emscripten": "^1.40.1" + }, + "keywords": [ + "dts", + "dca", + "dts-hd", + "digital-theater-systems", + "encoding", + "decoding", + "codec", + "mediabunny", + "ffmpeg", + "browser", + "wasm", + "polyfill" + ] +} diff --git a/packages/dts/src/bridge.c b/packages/dts/src/bridge.c new file mode 100644 index 0000000..b7446c6 --- /dev/null +++ b/packages/dts/src/bridge.c @@ -0,0 +1,321 @@ +/*! + * 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 +#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() { + const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_DTS); + 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, int64_t 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 +int64_t get_decoded_pts(DecoderContext *ctx) { + return 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; + int64_t encoded_pts; + int encoded_duration; +} EncoderContext; + +/** + * DTS insists on the side-based surround layouts and rejects the back-based ones that av_channel_layout_default hands + * out for 4, 5 and 6 channels. + */ +static int set_dts_channel_layout(AVChannelLayout *layout, int channels) { + switch (channels) { + case 1: { + AVChannelLayout mono = AV_CHANNEL_LAYOUT_MONO; + return av_channel_layout_copy(layout, &mono); + } + case 2: { + AVChannelLayout stereo = AV_CHANNEL_LAYOUT_STEREO; + return av_channel_layout_copy(layout, &stereo); + } + case 4: { + AVChannelLayout quad_side = AV_CHANNEL_LAYOUT_2_2; + return av_channel_layout_copy(layout, &quad_side); + } + case 5: { + AVChannelLayout five_zero = AV_CHANNEL_LAYOUT_5POINT0; + return av_channel_layout_copy(layout, &five_zero); + } + case 6: { + AVChannelLayout five_one = AV_CHANNEL_LAYOUT_5POINT1; + return av_channel_layout_copy(layout, &five_one); + } + default: + return -1; + } +} + +EMSCRIPTEN_KEEPALIVE +EncoderContext *init_encoder(int channels, int sample_rate, int bitrate) { + const AVCodec *codec = avcodec_find_encoder(AV_CODEC_ID_DTS); + if (!codec) return NULL; + + AVCodecContext *codec_ctx = avcodec_alloc_context3(codec); + if (!codec_ctx) return NULL; + + codec_ctx->sample_fmt = AV_SAMPLE_FMT_S32; + codec_ctx->sample_rate = sample_rate; + codec_ctx->bit_rate = bitrate; + codec_ctx->time_base = (AVRational){1, sample_rate}; + + // FFmpeg marks its DTS encoder experimental, so it refuses to open at the default compliance level + codec_ctx->strict_std_compliance = FF_COMPLIANCE_EXPERIMENTAL; + + if (set_dts_channel_layout(&codec_ctx->ch_layout, channels) < 0) { + avcodec_free_context(&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; + } + + // The frame has a fixed format, so let's create it now: + frame->format = AV_SAMPLE_FMT_S32; + 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, int64_t pts) { + int channels = ctx->codec_ctx->ch_layout.nb_channels; + int frame_size = ctx->frame->nb_samples; + + ctx->frame->pts = pts; + + // DTS encodes from s32, which is a packed format, so the samples stay interleaved and all land in data[0] + float *input = ctx->input_buffer; + int32_t *output = (int32_t *)ctx->frame->data[0]; + for (int i = 0; i < frame_size * channels; i++) { + float sample = input[i]; + if (sample > 1.0f) sample = 1.0f; + if (sample < -1.0f) sample = -1.0f; + output[i] = (int32_t)(sample * 2147483647.0f); + } + + 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 +int64_t 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/dts/src/codec.worker.ts b/packages/dts/src/codec.worker.ts new file mode 100644 index 0000000..6cf9c80 --- /dev/null +++ b/packages/dts/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/dts'; +import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared'; + +type ExtendedEmscriptenModule = EmscriptenModule & { + cwrap: typeof cwrap; +}; + +let module: ExtendedEmscriptenModule; +let modulePromise: Promise | null = null; + +let initDecoderFn: () => number; +let configureDecodePacket: (ctx: number, size: number) => number; +let decodePacket: (ctx: number, pts: bigint) => 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) => bigint; +let flushDecoderFn: (ctx: number) => void; +let closeDecoderFn: (ctx: number) => void; + +let initEncoderFn: (channels: number, sampleRate: number, bitrate: number) => number; +let getEncoderFrameSize: (ctx: number) => number; +let getEncodeInputPtr: (ctx: number, size: number) => number; +let encodeFrameFn: (ctx: number, pts: bigint) => number; +let flushEncoderFn: (ctx: number) => void; +let getEncodedData: (ctx: number) => number; +let getEncodedPts: (ctx: number) => bigint; +let getEncodedDuration: (ctx: number) => number; +let closeEncoderFn: (ctx: number) => void; + +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', []); + configureDecodePacket = module.cwrap('configure_decode_packet', 'number', ['number', 'number']); + decodePacket = module.cwrap('decode_packet', 'number', ['number', 'number']) as unknown as typeof decodePacket; + 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']) as unknown as typeof getDecodedPts; + flushDecoderFn = module.cwrap('flush_decoder', null, ['number']); + closeDecoderFn = module.cwrap('close_decoder', null, ['number']); + + initEncoderFn = module.cwrap('init_encoder', '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']) as unknown as typeof encodeFrameFn; + flushEncoderFn = module.cwrap('flush_encoder', null, ['number']); + getEncodedData = module.cwrap('get_encoded_data', 'number', ['number']); + getEncodedPts = module.cwrap('get_encoded_pts', 'number', ['number']) as unknown as typeof getEncodedPts; + getEncodedDuration = module.cwrap('get_encoded_duration', 'number', ['number']); + closeEncoderFn = module.cwrap('close_encoder', null, ['number']); + } +}; + +const initDecoder = async () => { + await ensureModule(); + + const ctx = initDecoderFn(); + if (ctx === 0) { + throw new Error('Failed to initialize DTS 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, BigInt(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 = Number(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 ( + numberOfChannels: number, + sampleRate: number, + bitrate: number, +) => { + await ensureModule(); + + const ctx = initEncoderFn(numberOfChannels, sampleRate, bitrate); + if (ctx === 0) { + throw new Error('Failed to initialize DTS 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, BigInt(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 = Number(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(); + 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.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 })); +} + +// Prevents the worker for being randomly closed by Firefox +// https://github.com/Vanilagy/mediabunny/issues/435 +setInterval(() => {}, 1000); diff --git a/packages/dts/src/decoder.ts b/packages/dts/src/decoder.ts new file mode 100644 index 0000000..0b5dfdb --- /dev/null +++ b/packages/dts/src/decoder.ts @@ -0,0 +1,80 @@ +/*! + * 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, refWorker, unrefWorker } from './worker-client'; + +class DtsDecoder extends CustomAudioDecoder { + private ctx = 0; + + static override supports(codec: AudioCodec): boolean { + return codec === 'dts'; + } + + async init() { + await refWorker(); + + const result = await sendCommand({ + type: 'init-decoder', + data: {}, + }); + 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 } }); + } + + async close() { + void sendCommand({ type: 'close-decoder', data: { ctx: this.ctx } }); + await unrefWorker(); + } +} + +let registered = false; + +/** + * Registers a DTS audio decoder, which Mediabunny will then use automatically when applicable. Make sure to call this + * function before starting any decoding task. + * + * @group \@mediabunny/dts + * @public + */ +export const registerDtsDecoder = () => { + if (registered) { + return; + } + registered = true; + + registerDecoder(DtsDecoder); +}; diff --git a/packages/dts/src/encoder.ts b/packages/dts/src/encoder.ts new file mode 100644 index 0000000..7b8ecd2 --- /dev/null +++ b/packages/dts/src/encoder.ts @@ -0,0 +1,197 @@ +/*! + * 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, refWorker, unrefWorker } from './worker-client'; +import { assert } from './shared'; +import { DTS_CHANNEL_COUNTS, DTS_SAMPLE_RATES, dtsBitrateFits } from '../../../shared/dts-misc'; + +class DtsEncoder 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 { + return codec === 'dts' + && DTS_CHANNEL_COUNTS.includes(config.numberOfChannels) + && DTS_SAMPLE_RATES.includes(config.sampleRate) + && config.bitrate !== undefined + && dtsBitrateFits(config.bitrate, config.sampleRate, config.numberOfChannels); + } + + async init() { + await refWorker(); + + assert(this.config.bitrate !== undefined); + this.sampleRate = this.config.sampleRate; + this.numberOfChannels = this.config.numberOfChannels; + + const result = await sendCommand({ + type: 'init-encoder', + data: { + 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: 'dtsc', + 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 } }); + void unrefWorker(); + } + + 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 = {}; + } +} + +let registered = false; + +/** + * Registers a DTS audio encoder, which Mediabunny will then use automatically when applicable. Make sure to call this + * function before starting any encoding task. + * + * @group \@mediabunny/dts + * @public + */ +export const registerDtsEncoder = () => { + if (registered) { + return; + } + registered = true; + + registerEncoder(DtsEncoder); +}; diff --git a/packages/dts/src/index.ts b/packages/dts/src/index.ts new file mode 100644 index 0000000..3344b00 --- /dev/null +++ b/packages/dts/src/index.ts @@ -0,0 +1,23 @@ +/*! + * 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 { Logging } from 'mediabunny'; + +const DTS_LOADED_SYMBOL = Symbol.for('@mediabunny/dts loaded'); +if ((globalThis as Record)[DTS_LOADED_SYMBOL]) { + Logging._error( + '[WARNING]\n@mediabunny/dts was loaded twice.' + + ' This will likely cause the encoder/decoder not to work correctly.' + + ' Check if multiple dependencies are importing different versions of @mediabunny/dts,' + + ' or if something is being bundled incorrectly.', + ); +} +(globalThis as Record)[DTS_LOADED_SYMBOL] = true; + +export { registerDtsDecoder } from './decoder'; +export { registerDtsEncoder } from './encoder'; diff --git a/packages/dts/src/shared.ts b/packages/dts/src/shared.ts new file mode 100644 index 0000000..27757c1 --- /dev/null +++ b/packages/dts/src/shared.ts @@ -0,0 +1,100 @@ +/*! + * 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: Record; +} | { + 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: { + 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/dts/src/worker-client.ts b/packages/dts/src/worker-client.ts new file mode 100644 index 0000000..15c6b8f --- /dev/null +++ b/packages/dts/src/worker-client.ts @@ -0,0 +1,109 @@ +/*! + * 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'; + +type ExtendedWorker = Worker & { + ref?: () => void; + unref?: () => void; +}; + +let workerPromise: Promise | null; +let nextMessageId = 0; +const pendingMessages = new Map void; + reject: (reason?: unknown) => void; +}>(); + +let refCount = 0; +let keepAliveInterval: ReturnType | null = null; + +export const refWorker = async () => { + refCount++; + if (refCount === 1) { + keepAliveInterval = setInterval(() => {}, 2 ** 31 - 1); + const worker = await ensureWorker(); + worker.ref?.(); + } +}; + +export const unrefWorker = async () => { + refCount--; + if (refCount === 0) { + if (keepAliveInterval !== null) { + clearInterval(keepAliveInterval); + keepAliveInterval = null; + } + + const worker = await workerPromise; + if (worker) { + if (worker.unref) { + worker.unref(); // If we don't do this, then the Node process never terminates by itself + // Keep the worker around tho + } else if (typeof window === 'undefined') { + // Non-browser environment without unref - terminate instead + worker.terminate(); + workerPromise = null; + } + } + } +}; + +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 ExtendedWorker; + worker.unref?.(); // Start unreffed + + 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/dts/tsconfig.json b/packages/dts/tsconfig.json new file mode 100644 index 0000000..27a7ef1 --- /dev/null +++ b/packages/dts/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/dts/tsdoc.json b/packages/dts/tsdoc.json new file mode 100644 index 0000000..51d29d3 --- /dev/null +++ b/packages/dts/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/server/src/audio-encoder.ts b/packages/server/src/audio-encoder.ts index 172a0f5..ac3c99b 100644 --- a/packages/server/src/audio-encoder.ts +++ b/packages/server/src/audio-encoder.ts @@ -23,17 +23,13 @@ import { buildAdtsHeaderTemplate, parseAacAudioSpecificConfig, } from '../../../shared/aac-misc'; +import { DTS_CHANNEL_COUNTS, DTS_SAMPLE_RATES, dtsBitrateFits } from '../../../shared/dts-misc'; const AAC_SAMPLE_RATES = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350]; const OPUS_SAMPLE_RATES = [8000, 12000, 16000, 24000, 48000]; const MP3_SAMPLE_RATES = [8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000]; const AC3_SAMPLE_RATES = [32000, 44100, 48000]; -const DTS_SAMPLE_RATES = [8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000]; - -/** DTS only has layouts for these channel counts; 3 and 7 have no mapping. */ -const DTS_CHANNEL_COUNTS = [1, 2, 4, 5, 6]; -const DTS_MAX_FRAME_SIZE = 16384; const FRAME_SIZE_FALLBACK = 1024; // Just 'cause @@ -421,20 +417,3 @@ const resolveBitrate = (config: AudioEncoderConfig, codec: AudioCodec) => { return config.bitrate ?? new Quality('medium')._toAudioBitrate(codec) ?? 0; }; -/** - * The DTS encoder needs each frame to be big enough to hold the per-channel side info and rejects the whole - * configuration when it isn't, so this mirrors the check from FFmpeg's dcaenc.c. - */ -const dtsBitrateFits = (bitrate: number, sampleRate: number, numberOfChannels: number) => { - if (bitrate < 32000 || bitrate > 3840000) { - return false; - } - - const hasLfe = numberOfChannels === 6; - const fullbandChannels = numberOfChannels - (hasLfe ? 1 : 0); - - const frameBits = 32 * Math.ceil(Math.ceil(bitrate * 512 / sampleRate) / 32); - const minFrameBits = 132 + (493 + 28 * 32) * fullbandChannels + (hasLfe ? 72 : 0); - - return frameBits >= minFrameBits && frameBits <= 8 * DTS_MAX_FRAME_SIZE; -}; diff --git a/scripts/build.sh b/scripts/build.sh index 0ffad0a..dec1389 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/dts/dist rm -rf packages/aac-encoder/dist rm -rf packages/flac-encoder/dist rm -rf packages/prores/dist @@ -19,6 +20,7 @@ tsx scripts/ensure-license-headers.ts tsc -p src --stripInternal false # Don't strip internals since the packages may use them tsc -p packages/mp3-encoder tsc -p packages/ac3 +tsc -p packages/dts tsc -p packages/aac-encoder tsc -p packages/flac-encoder tsc -p packages/prores @@ -39,6 +41,7 @@ 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/dts/api-extractor.json api-extractor run -c packages/aac-encoder/api-extractor.json api-extractor run -c packages/flac-encoder/api-extractor.json api-extractor run -c packages/prores/api-extractor.json @@ -48,6 +51,7 @@ api-extractor run -c packages/server/api-extractor.json 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/dts/dist/mediabunny-dts.d.ts tsx scripts/check-docblocks.ts packages/aac-encoder/dist/mediabunny-aac-encoder.d.ts tsx scripts/check-docblocks.ts packages/flac-encoder/dist/mediabunny-flac-encoder.d.ts tsx scripts/check-docblocks.ts packages/prores/dist/mediabunny-prores.d.ts @@ -60,6 +64,7 @@ npm run docs:generate -- --dry 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 +echo 'export as namespace MediabunnyDts;' >> packages/dts/dist/mediabunny-dts.d.ts echo 'export as namespace MediabunnyAacEncoder;' >> packages/aac-encoder/dist/mediabunny-aac-encoder.d.ts echo 'export as namespace MediabunnyFlacEncoder;' >> packages/flac-encoder/dist/mediabunny-flac-encoder.d.ts echo 'export as namespace MediabunnyProres;' >> packages/prores/dist/mediabunny-prores.d.ts diff --git a/scripts/bundle.ts b/scripts/bundle.ts index 1b2c29e..43633fd 100644 --- a/scripts/bundle.ts +++ b/scripts/bundle.ts @@ -161,6 +161,37 @@ const ac3Variants = await createVariants( }, ); +const dtsVariants = await createVariants( + 'packages/dts/src/index.ts', + 'MediabunnyDts', + 'packages/dts/dist/bundles/mediabunny-dts', + 'js', // The bundles are purely for the browser, not for Node (due to the peer dependency) + { + plugins: [ + PluginExternalGlobal.externalGlobalPlugin({ + mediabunny: 'Mediabunny', + }), + inlineWorkerPlugin({ + define: { + 'import.meta.url': '""', + }, + legalComments: 'none', + }), + ], + }, + { + external: ['mediabunny'], + plugins: [ + inlineWorkerPlugin({ + define: { + 'import.meta.url': '""', + }, + legalComments: 'none', + }), + ], + }, +); + const aacEncoderVariants = await createVariants( 'packages/aac-encoder/src/index.ts', 'MediabunnyAacEncoder', @@ -262,6 +293,7 @@ const contexts = [ ...mediabunnyVariants, ...mp3EncoderVariants, ...ac3Variants, + ...dtsVariants, ...aacEncoderVariants, ...flacEncoderVariants, ...proresVariants, diff --git a/scripts/check.sh b/scripts/check.sh index ed2d185..40eef51 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -10,6 +10,9 @@ tsc -p packages/mp3-encoder rm -rf packages/ac3/dist/modules tsc -p packages/ac3 +rm -rf packages/dts/dist/modules +tsc -p packages/dts + rm -rf packages/aac-encoder/dist/modules tsc -p packages/aac-encoder diff --git a/scripts/ensure-license-headers.ts b/scripts/ensure-license-headers.ts index 44b2dbe..bcb93e2 100644 --- a/scripts/ensure-license-headers.ts +++ b/scripts/ensure-license-headers.ts @@ -38,6 +38,7 @@ checkDirectory(path.join(__dirname, '..', 'src')); checkDirectory(path.join(__dirname, '..', 'shared')); checkDirectory(path.join(__dirname, '..', 'packages', 'mp3-encoder', 'src')); checkDirectory(path.join(__dirname, '..', 'packages', 'ac3', 'src')); +checkDirectory(path.join(__dirname, '..', 'packages', 'dts', 'src')); checkDirectory(path.join(__dirname, '..', 'packages', 'flac-encoder', 'src')); checkDirectory(path.join(__dirname, '..', 'packages', 'aac-encoder', 'src')); checkDirectory(path.join(__dirname, '..', 'packages', 'prores', 'src')); diff --git a/shared/dts-misc.ts b/shared/dts-misc.ts new file mode 100644 index 0000000..d85cf8d --- /dev/null +++ b/shared/dts-misc.ts @@ -0,0 +1,33 @@ +/*! + * 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 the core can be encoded at (Table 5-5) */ +export const DTS_SAMPLE_RATES = [8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000]; + +/** Channel counts DTS has a speaker layout for; 3 and 7 have no mapping */ +export const DTS_CHANNEL_COUNTS = [1, 2, 4, 5, 6]; + +const DTS_MAX_FRAME_SIZE = 16384; + +/** + * The DTS encoder needs each frame to be big enough to hold the per-channel side info and rejects the whole + * configuration when it isn't, so this mirrors the check from FFmpeg's dcaenc.c. + */ +export const dtsBitrateFits = (bitrate: number, sampleRate: number, numberOfChannels: number) => { + if (bitrate < 32000 || bitrate > 3840000) { + return false; + } + + const hasLfe = numberOfChannels === 6; + const fullbandChannels = numberOfChannels - (hasLfe ? 1 : 0); + + const frameBits = 32 * Math.ceil(Math.ceil(bitrate * 512 / sampleRate) / 32); + const minFrameBits = 132 + (493 + 28 * 32) * fullbandChannels + (hasLfe ? 72 : 0); + + return frameBits >= minFrameBits && frameBits <= 8 * DTS_MAX_FRAME_SIZE; +}; diff --git a/src/media-sink.ts b/src/media-sink.ts index d67148d..01d82cf 100644 --- a/src/media-sink.ts +++ b/src/media-sink.ts @@ -1297,8 +1297,13 @@ class VideoDecoderWrapper extends DecoderWrapper { void this.customDecoderCallSerializer.call(() => this.customDecoder!.close()); } else { assert(this.decoder); - this.decoder.close(); - this.alphaDecoder?.close(); + + if (this.decoder.state !== 'closed') { + this.decoder.close(); + } + if (this.alphaDecoder && this.alphaDecoder.state !== 'closed') { + this.alphaDecoder.close(); + } this.colorQueue.forEach(x => x.close()); this.colorQueue.length = 0; @@ -2202,7 +2207,10 @@ class AudioDecoderWrapper extends DecoderWrapper { void this.customDecoderCallSerializer.call(() => this.customDecoder!.close()); } else { assert(this.decoder); - this.decoder.close(); + + if (this.decoder.state !== 'closed') { + this.decoder.close(); + } } } } diff --git a/test/browser/mpeg-ts-muxing.test.ts b/test/browser/mpeg-ts-muxing.test.ts index ac841f7..362453e 100644 --- a/test/browser/mpeg-ts-muxing.test.ts +++ b/test/browser/mpeg-ts-muxing.test.ts @@ -16,7 +16,7 @@ test('MPEG-TS output format', async () => { expect(format.mimeType).toBe('video/MP2T'); expect(format.fileExtension).toBe('.ts'); expect(format.supportsVideoRotationMetadata).toBe(false); - expect(format.getSupportedCodecs()).toEqual(['avc', 'hevc', 'aac', 'mp3', 'ac3', 'eac3']); + expect(format.getSupportedCodecs()).toEqual(['avc', 'hevc', 'aac', 'mp3', 'ac3', 'eac3', 'dts']); expect(format.getSupportedTrackCounts()).toEqual({ video: { min: 0, max: 16 }, audio: { min: 0, max: 32 }, diff --git a/test/node/dts.test.ts b/test/node/dts.test.ts index 6ddfa76..656e321 100644 --- a/test/node/dts.test.ts +++ b/test/node/dts.test.ts @@ -10,6 +10,11 @@ import { Conversion } from '../../src/conversion.js'; import { EncodedPacketSink } from '../../src/media-sink.js'; import { EncodedPacket } from '../../src/packet.js'; import { assert, uint8ArraysAreEqual } from '../../src/misc.js'; +import { AudioSampleSink } from '../../src/media-sink.js'; +import { AudioSampleSource } from '../../src/media-source.js'; +import { AudioSample } from '../../src/sample.js'; +import { canEncode, Quality } from '../../src/encode.js'; +import { registerDtsDecoder, registerDtsEncoder } from '@mediabunny/dts'; const __dirname = new URL('.', import.meta.url).pathname; @@ -89,6 +94,160 @@ test('Transmux esds MP4 into MPEG-TS', async () => { await expectTransmuxToPreservePackets(ESDS_FILE, new MpegTsOutputFormat(), MPEG_TS_TIMESTAMP_TOLERANCE); }); +test('Custom coder registration', async () => { + using input = new Input({ + source: new FilePathSource(path.join(__dirname, '..', 'public', DTSC_FILE)), + formats: ALL_FORMATS, + }); + + const track = await input.getPrimaryAudioTrack(); + assert(track); + + expect(await track.canDecode()).toBe(false); + expect(await canEncode('dts')).toBe(false); + + registerDtsDecoder(); + registerDtsEncoder(); + + expect(await track.canDecode()).toBe(true); + expect(await canEncode('dts')).toBe(true); +}); + +test('Decode with the extension', async () => { + registerDtsDecoder(); + + using input = new Input({ + source: new FilePathSource(path.join(__dirname, '..', 'public', DTSC_FILE)), + formats: ALL_FORMATS, + }); + + const track = await input.getPrimaryAudioTrack(); + assert(track); + + 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).toBeCloseTo(512 / 24000); + expect(sample.format).toBe('f32-planar'); + expect(sample.numberOfChannels).toBe(2); + expect(sample.sampleRate).toBe(24000); + + nextTimestamp += sample.duration; + sampleCount++; + } + + expect(sampleCount).toBe(packetCount); +}); + +test('Encode with the extension', async () => { + registerDtsEncoder(); + + const output = await encodeSineWaveToMp4(0, new Mp4OutputFormat()); + + using input = new Input({ + source: new BufferSource(output), + formats: ALL_FORMATS, + }); + + const track = await input.getPrimaryAudioTrack(); + assert(track); + + expect(await track.getCodec()).toBe('dts'); + expect(await track.getSampleRate()).toBe(ENCODE_SAMPLE_RATE); + expect(await track.getNumberOfChannels()).toBe(ENCODE_CHANNELS); + + const decoderConfig = await track.getDecoderConfig(); + assert(decoderConfig); + expect(decoderConfig.codec).toBe('dtsc'); + expect(decoderConfig.description).toBeUndefined(); + + 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(ENCODE_DURATION, 1); +}); + +test('Round-trip through the extension', async () => { + registerDtsDecoder(); + registerDtsEncoder(); + + const output = await encodeSineWaveToMp4(0, new Mp4OutputFormat()); + + using input = new Input({ + source: new BufferSource(output), + formats: ALL_FORMATS, + }); + + const track = await input.getPrimaryAudioTrack(); + assert(track); + + const sink = new AudioSampleSink(track); + const chunks: Float32Array[] = []; + let decodedFrames = 0; + + for await (using sample of sink.samples()) { + expect(sample.numberOfChannels).toBe(ENCODE_CHANNELS); + expect(sample.sampleRate).toBe(ENCODE_SAMPLE_RATE); + decodedFrames += sample.numberOfFrames; + + const chunk = new Float32Array( + new ArrayBuffer(sample.allocationSize({ format: 'f32-planar', planeIndex: 0 })), + ); + sample.copyTo(chunk, { format: 'f32-planar', planeIndex: 0 }); + chunks.push(chunk); + } + + expect(decodedFrames).toBeGreaterThanOrEqual(ENCODE_SAMPLE_RATE * ENCODE_DURATION); + + const signal = new Float32Array(chunks.reduce((sum, chunk) => sum + chunk.length, 0)); + let offset = 0; + for (const chunk of chunks) { + signal.set(chunk, offset); + offset += chunk.length; + } + + expect(sine440Score(signal, ENCODE_SAMPLE_RATE, 440)).toBeGreaterThan(0.98); +}); + +test('Encode with huge timestamps', async () => { + registerDtsDecoder(); + registerDtsEncoder(); + + const timestamp = 1e9; + const output = await encodeSineWaveToMp4(timestamp, new Mp4OutputFormat({ fastStart: 'fragmented' })); + + using input = new Input({ + source: new BufferSource(output), + formats: ALL_FORMATS, + }); + + const track = await input.getPrimaryAudioTrack(); + assert(track); + + const packetSink = new EncodedPacketSink(track); + const firstPacket = await packetSink.getFirstPacket(); + assert(firstPacket); + + expect(firstPacket.timestamp).toBe(timestamp); + + const sampleSink = new AudioSampleSink(track); + const firstSample = (await sampleSink.samples(timestamp).next()).value; + assert(firstSample); + + expect(firstSample.timestamp).toBe(timestamp); +}); + const expectStreamShape = async (input: Input) => { const packets = await readAllPackets(input); @@ -169,3 +328,80 @@ const readAllPackets = async (input: Input) => { return packets; }; + +const ENCODE_SAMPLE_RATE = 48000; +const ENCODE_CHANNELS = 2; +const ENCODE_DURATION = 2; +const ENCODE_BITRATE = 768000; + +const encodeSineWaveToMp4 = async (startTimestamp: number, format: Mp4OutputFormat) => { + const totalFrames = ENCODE_SAMPLE_RATE * ENCODE_DURATION; + const data = new Float32Array(totalFrames * ENCODE_CHANNELS); + + for (let i = 0; i < totalFrames; i++) { + const value = Math.sin(2 * Math.PI * 440 * i / ENCODE_SAMPLE_RATE); + for (let channel = 0; channel < ENCODE_CHANNELS; channel++) { + data[i * ENCODE_CHANNELS + channel] = value; + } + } + + const output = new Output({ format, target: new BufferTarget() }); + const source = new AudioSampleSource({ codec: 'dts', quality: new Quality({ bitrate: ENCODE_BITRATE }) }); + output.addAudioTrack(source); + + await output.start(); + + const sample = new AudioSample({ + data, + format: 'f32', + numberOfChannels: ENCODE_CHANNELS, + sampleRate: ENCODE_SAMPLE_RATE, + timestamp: startTimestamp, + }); + await source.add(sample); + sample.close(); + source.close(); + + await output.finalize(); + + const { buffer } = output.target; + assert(buffer); + + return buffer; +}; + +/** Returns the fraction of the signal's energy explained by a pure sine at `freq`. */ +const sine440Score = (x: Float32Array, sampleRate: number, freq: number) => { + const w = 2 * Math.PI * freq / sampleRate; + + let ss = 0, cc = 0, sc = 0; + let xs = 0, xc = 0; + let xx = 0; + + for (let n = 0; n < x.length; n++) { + const s = Math.sin(w * n); + const c = Math.cos(w * n); + + ss += s * s; + cc += c * c; + sc += s * c; + + xs += x[n]! * s; + xc += x[n]! * c; + xx += x[n]! * x[n]!; + } + + const det = ss * cc - sc * sc; + + const a = (xs * cc - xc * sc) / det; + const b = (xc * ss - xs * sc) / det; + + let fitEnergy = 0; + + for (let n = 0; n < x.length; n++) { + const y = a * Math.sin(w * n) + b * Math.cos(w * n); + fitEnergy += y * y; + } + + return fitEnergy / xx; +}; diff --git a/vite.config.ts b/vite.config.ts index 576ccc9..a9978e0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -23,6 +23,8 @@ export default defineConfig({ 'mediabunny': path.resolve(__dirname, './dist/bundles/mediabunny.mjs'), '@mediabunny/ac3': path.resolve(__dirname, './packages/ac3/dist/bundles/mediabunny-ac3.mjs'), + '@mediabunny/dts': + path.resolve(__dirname, './packages/dts/dist/bundles/mediabunny-dts.mjs'), '@mediabunny/aac-encoder': path.resolve(__dirname, './packages/aac-encoder/dist/bundles/mediabunny-aac-encoder.mjs'), '@mediabunny/flac-encoder':