diff --git a/.gitignore b/.gitignore index e4b72d3..3c8896f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ node_modules packages/mp3-encoder/dist packages/ac3/dist -packages/aac-encoder/dist \ No newline at end of file +packages/aac-encoder/dist +packages/flac-encoder/dist \ No newline at end of file diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index a050b49..68e6167 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -95,6 +95,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: 'flac-encoder', link: '/guide/extensions/flac-encoder' }, ], }, ], diff --git a/docs/api-config.json b/docs/api-config.json index 53ba487..a17dc62 100644 --- a/docs/api-config.json +++ b/docs/api-config.json @@ -21,5 +21,6 @@ "@mediabunny/mp3-encoder": "Adds MP3 encoder support to Mediabunny.", "@mediabunny/ac3": "Adds AC-3/E-AC-3 decoder and encoder support to Mediabunny.", - "@mediabunny/aac-encoder": "Adds AAC encoder support to Mediabunny." + "@mediabunny/aac-encoder": "Polyfills AAC encoder support to Mediabunny.", + "@mediabunny/flac-encoder": "Adds FLAC encoder support to Mediabunny." } diff --git a/docs/guide/extensions/flac-encoder.md b/docs/guide/extensions/flac-encoder.md new file mode 100644 index 0000000..c2f2ee9 --- /dev/null +++ b/docs/guide/extensions/flac-encoder.md @@ -0,0 +1,81 @@ +# @mediabunny/flac-encoder + +No browser currently supports FLAC encoding in their WebCodecs implementations. This extension package provides a reliable FLAC encoder for use with Mediabunny. It is implemented using Mediabunny's [custom coder API](../supported-formats-and-codecs#custom-coders) and uses a fast, size-optimized WASM build of [libFLAC](https://github.com/xiph/flac) (the reference FLAC encoder) under the hood. + + + GitHub page + + + +## Installation + +This library peer-depends on Mediabunny. Install both using npm: +```bash +npm install mediabunny @mediabunny/flac-encoder +``` + +Alternatively, directly include them using a script tag: +```html + + +``` + +This will expose the global objects `Mediabunny` and `MediabunnyFlacEncoder`. Use `mediabunny-flac-encoder.d.ts` to provide types for these globals. You can download the built distribution files from the [releases page](https://github.com/Vanilagy/mediabunny/releases). + +## Usage + +```ts +import { registerFlacEncoder } from '@mediabunny/flac-encoder'; + +registerFlacEncoder(); +``` +That's it - Mediabunny now uses the registered FLAC encoder automatically. + +If you want to be more correct, check for native browser support first: +```ts +import { canEncodeAudio } from 'mediabunny'; +import { registerFlacEncoder } from '@mediabunny/flac-encoder'; + +if (!(await canEncodeAudio('flac'))) { + registerFlacEncoder(); +} +``` + +## Example + +Here, we convert an input file to a FLAC file: + +```ts +import { + Input, + ALL_FORMATS, + BlobSource, + Output, + BufferTarget, + FlacOutputFormat, + canEncodeAudio, + Conversion, +} from 'mediabunny'; +import { registerFlacEncoder } from '@mediabunny/flac-encoder'; + +if (!(await canEncodeAudio('flac'))) { + registerFlacEncoder(); +} + +const input = new Input({ + source: new BlobSource(file), // From a file picker, for example + formats: ALL_FORMATS, +}); +const output = new Output({ + format: new FlacOutputFormat(), + target: new BufferTarget(), +}); + +const conversion = await Conversion.init({ + input, + output, +}); +await conversion.execute(); + +output.target.buffer; // => ArrayBuffer containing the FLAC file +``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 95f80b1..e80f29c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -42,6 +42,8 @@ export default tseslint.config( 'packages/ac3/build', 'packages/aac-encoder/dist', 'packages/aac-encoder/build', + 'packages/flac-encoder/dist', + 'packages/flac-encoder/build', 'eslint.config.mjs', 'docs/.vitepress/cache', 'test/public', diff --git a/package-lock.json b/package-lock.json index d7365a1..62b1cd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1421,6 +1421,10 @@ "resolved": "packages/ac3", "link": true }, + "node_modules/@mediabunny/flac-encoder": { + "resolved": "packages/flac-encoder", + "link": true + }, "node_modules/@mediabunny/mp3-encoder": { "resolved": "packages/mp3-encoder", "link": true @@ -12101,6 +12105,21 @@ "mediabunny": "^1.0.0" } }, + "packages/flac-encoder": { + "name": "@mediabunny/flac-encoder", + "version": "1.36.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.36.0", diff --git a/package.json b/package.json index 503fdb6..0a533fb 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "docs:dev": "vitepress dev docs", "docs:build": "npm run build && npm run docs:generate && vitepress build docs && npm run examples:build && cp dist/mediabunny.d.ts dist-docs/", "docs:preview": "vitepress preview docs", - "docs:generate": "tsx scripts/generate-api-docs.ts src/index.ts packages/mp3-encoder/src/index.ts packages/ac3/src/index.ts packages/aac-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 packages/aac-encoder/src/index.ts packages/flac-encoder/src/index.ts docs/api-config.json", "dev": "vite", "examples:build": "vite build", "fix-build-import-paths": "tsx scripts/add-import-extensions.ts", diff --git a/packages/aac-encoder/src/encode.worker.ts b/packages/aac-encoder/src/encode.worker.ts index d109920..0023d49 100644 --- a/packages/aac-encoder/src/encode.worker.ts +++ b/packages/aac-encoder/src/encode.worker.ts @@ -7,7 +7,7 @@ */ import createModule from '../build/aac'; -import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared'; +import type { PacketInfo, WorkerCommand, WorkerResponse, WorkerResponseData } from './shared'; type ExtendedEmscriptenModule = EmscriptenModule & { cwrap: typeof cwrap; @@ -28,8 +28,6 @@ let resetEncoderFn: (ctx: number) => void; let getEncodedData: (ctx: number) => number; let getEncodedPts: (ctx: number) => number; let getEncodedDuration: (ctx: number) => number; -let closeEncoderFn: (ctx: number) => void; - const ensureModule = async () => { if (!module) { if (modulePromise) { @@ -52,7 +50,6 @@ const ensureModule = async () => { 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']); } }; @@ -77,8 +74,6 @@ const initEncoder = async ( return { ctx, frameSize, extradata }; }; -type PacketInfo = { encodedData: ArrayBuffer; pts: number; duration: number }; - const drainPackets = (ctx: number) => { const packets: PacketInfo[] = []; @@ -151,11 +146,6 @@ const onMessage = (data: { id: number; command: WorkerCommand }) => { resetEncoderFn(command.data.ctx); result = { type: command.type, packets }; }; break; - - case 'close': { - closeEncoderFn(command.data.ctx); - result = { type: command.type }; - }; break; } const response: WorkerResponse = { diff --git a/packages/aac-encoder/src/encoder.ts b/packages/aac-encoder/src/encoder.ts index 6f2d681..61421fd 100644 --- a/packages/aac-encoder/src/encoder.ts +++ b/packages/aac-encoder/src/encoder.ts @@ -19,7 +19,7 @@ import { EncodedPacket, registerEncoder, } from 'mediabunny'; -import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared'; +import type { PacketInfo, WorkerCommand, WorkerResponse, WorkerResponseData } from './shared'; // @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand import createWorker from './encode.worker'; @@ -184,7 +184,6 @@ class AacEncoder extends CustomAudioEncoder { } close() { - void this.sendCommand({ type: 'close', data: { ctx: this.ctx } }); this.worker?.terminate(); } @@ -219,7 +218,7 @@ class AacEncoder extends CustomAudioEncoder { this.emitPackets(result.packets); } - private emitPackets(packets: Array<{ encodedData: ArrayBuffer; pts: number; duration: number }>) { + private emitPackets(packets: PacketInfo[]) { assert(this.nextPacketTimestampInSamples !== null); for (const p of packets) { diff --git a/packages/aac-encoder/src/shared.ts b/packages/aac-encoder/src/shared.ts index ce5513d..9f08589 100644 --- a/packages/aac-encoder/src/shared.ts +++ b/packages/aac-encoder/src/shared.ts @@ -25,11 +25,12 @@ export type WorkerCommand = { data: { ctx: number; }; -} | { - type: 'close'; - data: { - ctx: number; - }; +}; + +export type PacketInfo = { + encodedData: ArrayBuffer; + pts: number; + duration: number; }; export type WorkerResponseData = { @@ -39,20 +40,10 @@ export type WorkerResponseData = { extradata: ArrayBuffer; } | { type: 'encode'; - packets: Array<{ - encodedData: ArrayBuffer; - pts: number; - duration: number; - }>; + packets: PacketInfo[]; } | { type: 'flush'; - packets: Array<{ - encodedData: ArrayBuffer; - pts: number; - duration: number; - }>; -} | { - type: 'close'; + packets: PacketInfo[]; }; export type WorkerResponse = { diff --git a/packages/flac-encoder/.gitattributes b/packages/flac-encoder/.gitattributes new file mode 100644 index 0000000..72ef19a --- /dev/null +++ b/packages/flac-encoder/.gitattributes @@ -0,0 +1 @@ +build/* linguist-generated diff --git a/packages/flac-encoder/LICENSE b/packages/flac-encoder/LICENSE new file mode 100644 index 0000000..d0a1fa1 --- /dev/null +++ b/packages/flac-encoder/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/packages/flac-encoder/README.md b/packages/flac-encoder/README.md new file mode 100644 index 0000000..86cc470 --- /dev/null +++ b/packages/flac-encoder/README.md @@ -0,0 +1,140 @@ +# @mediabunny/flac-encoder + +[![](https://img.shields.io/npm/v/@mediabunny/flac-encoder)](https://www.npmjs.com/package/@mediabunny/flac-encoder) +[![](https://img.shields.io/bundlephobia/minzip/@mediabunny/flac-encoder)](https://bundlephobia.com/package/@mediabunny/flac-encoder) +[![](https://img.shields.io/npm/dm/@mediabunny/flac-encoder)](https://www.npmjs.com/package/@mediabunny/flac-encoder) +[![](https://img.shields.io/discord/1390044844285497344?logo=discord&label=Discord)](https://discord.gg/hmpkyYuS4U) + +
+ +
+ +No browser currently supports FLAC encoding in their WebCodecs implementations. This extension package provides a reliable FLAC encoder for use with [Mediabunny](https://github.com/Vanilagy/mediabunny). It is implemented using Mediabunny's [custom coder API](https://mediabunny.dev/guide/supported-formats-and-codecs#custom-coders) and uses a fast, size-optimized WASM build of [libFLAC](https://github.com/xiph/flac) 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/flac-encoder +``` + +Alternatively, directly include them using a script tag: +```html + + +``` + +This will expose the global objects `Mediabunny` and `MediabunnyFlacEncoder`. Use `mediabunny-flac-encoder.d.ts` to provide types for these globals. You can download the built distribution files from the [releases page](https://github.com/Vanilagy/mediabunny/releases). + +## Usage + +```ts +import { registerFlacEncoder } from '@mediabunny/flac-encoder'; + +registerFlacEncoder(); +``` +That's it - Mediabunny now uses the registered FLAC encoder automatically. + +If you want to be more correct, check for native browser support first: +```ts +import { canEncodeAudio } from 'mediabunny'; +import { registerFlacEncoder } from '@mediabunny/flac-encoder'; + +if (!(await canEncodeAudio('flac'))) { + registerFlacEncoder(); +} +``` + +## Example + +Here, we convert an input file to a FLAC file: + +```ts +import { + Input, + ALL_FORMATS, + BlobSource, + Output, + BufferTarget, + FlacOutputFormat, + canEncodeAudio, + Conversion, +} from 'mediabunny'; +import { registerFlacEncoder } from '@mediabunny/flac-encoder'; + +if (!(await canEncodeAudio('flac'))) { + registerFlacEncoder(); +} + +const input = new Input({ + source: new BlobSource(file), // From a file picker, for example + formats: ALL_FORMATS, +}); +const output = new Output({ + format: new FlacOutputFormat(), + target: new BufferTarget(), +}); + +const conversion = await Conversion.init({ + input, + output, +}); +await conversion.execute(); + +output.target.buffer; // => ArrayBuffer containing the FLAC file +``` + +For more ways of using Mediabunny, refer to its [guide](https://mediabunny.dev/guide/introduction). + +## Building and development + +For simplicity, all built WASM artifacts are included in the repo, since these rarely change. However, here are the instructions for building them from scratch: + +[Install Emscripten](https://emscripten.org/docs/getting_started/downloads.html), install CMake, and clone [libFLAC](https://github.com/xiph/flac). Then, from the Mediabunny root and with Emscripten sourced in: + +```bash +export FLAC_PATH=/path/to/flac +export MEDIABUNNY_ROOT=$PWD + +cd $FLAC_PATH +mkdir -p build && cd build +emcmake cmake .. \ + -DBUILD_PROGRAMS=OFF \ + -DBUILD_CXXLIBS=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_TESTING=OFF \ + -DWITH_OGG=OFF \ + -DBUILD_SHARED_LIBS=OFF \ + -DENABLE_MULTITHREADING=OFF \ + -DINSTALL_MANPAGES=OFF \ + -DCMAKE_C_FLAGS="-DNDEBUG -Oz -flto -msimd128" +emmake make + +# Compile the bridge between JavaScript and libFLAC's API +cd $MEDIABUNNY_ROOT/packages/flac-encoder +emcc src/bridge.c \ + $FLAC_PATH/build/src/libFLAC/libFLAC.a \ + -I$FLAC_PATH/include \ + -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/flac.js +``` + +This generates `build/flac.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/flac-encoder/api-extractor.json b/packages/flac-encoder/api-extractor.json new file mode 100644 index 0000000..fafe3a5 --- /dev/null +++ b/packages/flac-encoder/api-extractor.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "dist/modules/src/index.d.ts", + "bundledPackages": [], + "compiler": {}, + "apiReport": { + "enabled": false + }, + "docModel": { + "enabled": false + }, + "dtsRollup": { + "enabled": true, + "untrimmedFilePath": "dist/mediabunny-flac-encoder.d.ts" + }, + "tsdocMetadata": { + "enabled": false + }, + "messages": { + "compilerMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + "extractorMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + } + } + }, + "newlineKind": "lf" +} diff --git a/packages/flac-encoder/build/flac.js b/packages/flac-encoder/build/flac.js new file mode 100644 index 0000000..da70144 Binary files /dev/null and b/packages/flac-encoder/build/flac.js differ diff --git a/packages/flac-encoder/package.json b/packages/flac-encoder/package.json new file mode 100644 index 0000000..9a2906d --- /dev/null +++ b/packages/flac-encoder/package.json @@ -0,0 +1,54 @@ +{ + "name": "@mediabunny/flac-encoder", + "author": "Vanilagy", + "version": "1.36.0", + "description": "FLAC encoder extension for Mediabunny, based on libFLAC.", + "main": "./dist/bundles/mediabunny-flac-encoder.mjs", + "module": "./dist/bundles/mediabunny-flac-encoder.mjs", + "types": "./dist/modules/src/index.d.ts", + "exports": { + "types": "./dist/modules/src/index.d.ts", + "import": "./dist/bundles/mediabunny-flac-encoder.mjs", + "require": "./dist/bundles/mediabunny-flac-encoder.mjs" + }, + "files": [ + "README.md", + "package.json", + "LICENSE", + "dist", + "src" + ], + "browser": { + "worker_threads": false + }, + "sideEffects": false, + "license": "MPL-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/Vanilagy/mediabunny.git", + "directory": "packages/flac-encoder" + }, + "bugs": { + "url": "https://github.com/Vanilagy/mediabunny/issues" + }, + "homepage": "https://mediabunny.dev/guide/extensions/flac-encoder", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + }, + "devDependencies": { + "@types/emscripten": "^1.40.1" + }, + "keywords": [ + "flac", + "encoding", + "codec", + "mediabunny", + "lossless", + "browser", + "wasm" + ] +} diff --git a/packages/flac-encoder/src/bridge.c b/packages/flac-encoder/src/bridge.c new file mode 100644 index 0000000..e25a981 --- /dev/null +++ b/packages/flac-encoder/src/bridge.c @@ -0,0 +1,255 @@ +/*! + * 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 + +#define BITS_PER_SAMPLE 16 +#define COMPRESSION_LEVEL 5 + +typedef struct { + int size; + int samples; +} FrameInfo; + +typedef struct { + FLAC__StreamEncoder *encoder; + + // Input buffer for interleaved int16 samples from JS + int16_t *input_buffer; + int input_buffer_size; + + // Widened to int32 for libFLAC + FLAC__int32 *int32_buffer; + int int32_buffer_size; + + // Contiguous output buffer for encoded frame data + uint8_t *output_buffer; + int output_size; + int output_capacity; + + // Per-frame metadata so JS can split the output buffer into individual packets + FrameInfo *frames; + int frame_count; + int frames_capacity; + + // Stream header captured during init (fLaC + metadata blocks) + uint8_t *header_buffer; + int header_size; + int header_capacity; + bool header_done; + + int channels; +} EncoderContext; + +static void ensure_output_capacity(EncoderContext *ctx, int needed) { + if (needed <= ctx->output_capacity) { + return; + } + + int new_capacity = ctx->output_capacity; + if (new_capacity < 4096) { + new_capacity = 4096; + } + while (new_capacity < needed) { + new_capacity *= 2; + } + + ctx->output_buffer = realloc(ctx->output_buffer, new_capacity); + ctx->output_capacity = new_capacity; +} + +static FLAC__StreamEncoderWriteStatus write_callback( + const FLAC__StreamEncoder *encoder, + const FLAC__byte buffer[], + size_t bytes, + uint32_t samples, + uint32_t current_frame, + void *client_data +) { + EncoderContext *ctx = (EncoderContext *)client_data; + + // samples == 0 means this is metadata (stream header) + if (samples == 0) { + if (!ctx->header_done) { + int needed = ctx->header_size + bytes; + if (needed > ctx->header_capacity) { + int new_cap = ctx->header_capacity < 256 ? 256 : ctx->header_capacity; + while (new_cap < needed) { new_cap *= 2; } + ctx->header_buffer = realloc(ctx->header_buffer, new_cap); + ctx->header_capacity = new_cap; + } + memcpy(ctx->header_buffer + ctx->header_size, buffer, bytes); + ctx->header_size += bytes; + } + + return FLAC__STREAM_ENCODER_WRITE_STATUS_OK; + } + + ctx->header_done = true; + + // Append encoded data + ensure_output_capacity(ctx, ctx->output_size + bytes); + memcpy(ctx->output_buffer + ctx->output_size, buffer, bytes); + ctx->output_size += bytes; + + // Record frame metadata + if (ctx->frame_count >= ctx->frames_capacity) { + int new_cap = ctx->frames_capacity < 16 ? 16 : ctx->frames_capacity * 2; + ctx->frames = realloc(ctx->frames, new_cap * sizeof(FrameInfo)); + ctx->frames_capacity = new_cap; + } + ctx->frames[ctx->frame_count].size = bytes; + ctx->frames[ctx->frame_count].samples = samples; + ctx->frame_count++; + + return FLAC__STREAM_ENCODER_WRITE_STATUS_OK; +} + +static void reset_output(EncoderContext *ctx) { + ctx->output_size = 0; + ctx->frame_count = 0; +} + +EMSCRIPTEN_KEEPALIVE +int init_encoder(int channels, int sample_rate) { + EncoderContext *ctx = calloc(1, sizeof(EncoderContext)); + if (!ctx) { + return 0; + } + + ctx->channels = channels; + + ctx->encoder = FLAC__stream_encoder_new(); + if (!ctx->encoder) { + free(ctx); + return 0; + } + + FLAC__stream_encoder_set_channels(ctx->encoder, channels); + FLAC__stream_encoder_set_sample_rate(ctx->encoder, sample_rate); + FLAC__stream_encoder_set_bits_per_sample(ctx->encoder, BITS_PER_SAMPLE); + FLAC__stream_encoder_set_compression_level(ctx->encoder, COMPRESSION_LEVEL); + FLAC__stream_encoder_set_verify(ctx->encoder, false); + + FLAC__StreamEncoderInitStatus status = FLAC__stream_encoder_init_stream( + ctx->encoder, + write_callback, + NULL, // seek callback + NULL, // tell callback + NULL, // metadata callback + ctx + ); + + if (status != FLAC__STREAM_ENCODER_INIT_STATUS_OK) { + FLAC__stream_encoder_delete(ctx->encoder); + free(ctx); + return 0; + } + + return (int)ctx; +} + +EMSCRIPTEN_KEEPALIVE +uint8_t *get_encode_input_ptr(int ctx_ptr, int size) { + EncoderContext *ctx = (EncoderContext *)ctx_ptr; + + if (size > ctx->input_buffer_size) { + ctx->input_buffer = realloc(ctx->input_buffer, size); + ctx->input_buffer_size = size; + } + + return (uint8_t *)ctx->input_buffer; +} + +EMSCRIPTEN_KEEPALIVE +int send_samples(int ctx_ptr, int num_samples) { + EncoderContext *ctx = (EncoderContext *)ctx_ptr; + + // Widen int16 to int32 for libFLAC + int total = num_samples * ctx->channels; + if (total > ctx->int32_buffer_size) { + ctx->int32_buffer = realloc(ctx->int32_buffer, total * sizeof(FLAC__int32)); + ctx->int32_buffer_size = total; + } + for (int i = 0; i < total; i++) { + ctx->int32_buffer[i] = ctx->input_buffer[i]; + } + + reset_output(ctx); + + FLAC__bool ok = FLAC__stream_encoder_process_interleaved(ctx->encoder, ctx->int32_buffer, num_samples); + return ok ? 0 : -1; +} + +EMSCRIPTEN_KEEPALIVE +uint8_t *get_output_data(int ctx_ptr) { + EncoderContext *ctx = (EncoderContext *)ctx_ptr; + return ctx->output_buffer; +} + +EMSCRIPTEN_KEEPALIVE +int get_frame_count(int ctx_ptr) { + EncoderContext *ctx = (EncoderContext *)ctx_ptr; + return ctx->frame_count; +} + +EMSCRIPTEN_KEEPALIVE +int get_frame_size(int ctx_ptr, int index) { + EncoderContext *ctx = (EncoderContext *)ctx_ptr; + return ctx->frames[index].size; +} + +EMSCRIPTEN_KEEPALIVE +int get_frame_samples(int ctx_ptr, int index) { + EncoderContext *ctx = (EncoderContext *)ctx_ptr; + return ctx->frames[index].samples; +} + +EMSCRIPTEN_KEEPALIVE +uint8_t *get_header_data(int ctx_ptr) { + EncoderContext *ctx = (EncoderContext *)ctx_ptr; + return ctx->header_buffer; +} + +EMSCRIPTEN_KEEPALIVE +int get_header_size(int ctx_ptr) { + EncoderContext *ctx = (EncoderContext *)ctx_ptr; + return ctx->header_size; +} + +EMSCRIPTEN_KEEPALIVE +int finish_encoder(int ctx_ptr) { + EncoderContext *ctx = (EncoderContext *)ctx_ptr; + + reset_output(ctx); + + FLAC__bool ok = FLAC__stream_encoder_finish(ctx->encoder); + if (!ok) { + return -1; + } + + // finish() leaves the encoder uninitialized but retains configuration (channels, sample rate, + // etc.), so we just re-init the stream to be ready for the next batch of samples. + ctx->header_size = 0; + ctx->header_done = false; + + FLAC__StreamEncoderInitStatus status = FLAC__stream_encoder_init_stream( + ctx->encoder, + write_callback, + NULL, + NULL, + NULL, + ctx + ); + + return status == FLAC__STREAM_ENCODER_INIT_STATUS_OK ? 0 : -1; +} diff --git a/packages/flac-encoder/src/encode.worker.ts b/packages/flac-encoder/src/encode.worker.ts new file mode 100644 index 0000000..1e178f8 --- /dev/null +++ b/packages/flac-encoder/src/encode.worker.ts @@ -0,0 +1,193 @@ +/*! + * 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/flac'; +import type { PacketInfo, WorkerCommand, WorkerResponse, WorkerResponseData } from './shared'; + +type ExtendedEmscriptenModule = EmscriptenModule & { + cwrap: typeof cwrap; +}; + +let module: ExtendedEmscriptenModule; +let modulePromise: Promise | null = null; + +let initEncoderFn: (channels: number, sampleRate: number) => number; +let getEncodeInputPtr: (ctx: number, size: number) => number; +let sendSamplesFn: (ctx: number, numSamples: number) => number; +let getOutputData: (ctx: number) => number; +let getFrameCount: (ctx: number) => number; +let getFrameSize: (ctx: number, index: number) => number; +let getFrameSamples: (ctx: number, index: number) => number; +let getHeaderData: (ctx: number) => number; +let getHeaderSize: (ctx: number) => number; +let finishEncoderFn: (ctx: number) => number; + +const ensureModule = async () => { + if (!module) { + if (modulePromise) { + return modulePromise; + } + + modulePromise = createModule() as Promise; + module = await modulePromise; + modulePromise = null; + + initEncoderFn = module.cwrap('init_encoder', 'number', ['number', 'number']); + getEncodeInputPtr = module.cwrap('get_encode_input_ptr', 'number', ['number', 'number']); + sendSamplesFn = module.cwrap('send_samples', 'number', ['number', 'number']); + getOutputData = module.cwrap('get_output_data', 'number', ['number']); + getFrameCount = module.cwrap('get_frame_count', 'number', ['number']); + getFrameSize = module.cwrap('get_frame_size', 'number', ['number', 'number']); + getFrameSamples = module.cwrap('get_frame_samples', 'number', ['number', 'number']); + getHeaderData = module.cwrap('get_header_data', 'number', ['number']); + getHeaderSize = module.cwrap('get_header_size', 'number', ['number']); + finishEncoderFn = module.cwrap('finish_encoder', 'number', ['number']); + } +}; + +const initEncoder = async (numberOfChannels: number, sampleRate: number) => { + await ensureModule(); + + const ctx = initEncoderFn(numberOfChannels, sampleRate); + if (ctx === 0) { + throw new Error('Failed to initialize FLAC encoder.'); + } + + const headerPtr = getHeaderData(ctx); + const headerSize = getHeaderSize(ctx); + const header = module.HEAPU8.slice(headerPtr, headerPtr + headerSize).buffer; + + return { ctx, header }; +}; + +const readPackets = (ctx: number) => { + const packets: PacketInfo[] = []; + const frameCount = getFrameCount(ctx); + const outputPtr = getOutputData(ctx); + + let offset = 0; + for (let i = 0; i < frameCount; i++) { + const size = getFrameSize(ctx, i); + const samples = getFrameSamples(ctx, i); + const encodedData = module.HEAPU8.slice(outputPtr + offset, outputPtr + offset + size).buffer; + packets.push({ encodedData, samples }); + offset += size; + } + + return packets; +}; + +const encode = (ctx: number, audioData: ArrayBuffer, numSamples: number) => { + const audioBytes = new Uint8Array(audioData); + + const inputPtr = getEncodeInputPtr(ctx, audioBytes.length); + if (inputPtr === 0) { + throw new Error('Failed to allocate encoder input buffer.'); + } + module.HEAPU8.set(audioBytes, inputPtr); + + const ret = sendSamplesFn(ctx, numSamples); + if (ret < 0) { + throw new Error(`Encode failed with error code ${ret}.`); + } + + return readPackets(ctx); +}; + +const flush = (ctx: number) => { + const ret = finishEncoderFn(ctx); + if (ret < 0) { + throw new Error('Flush failed.'); + } + + return readPackets(ctx); +}; + +const onMessage = (data: { id: number; command: WorkerCommand }) => { + const { id, command } = data; + + const handleCommand = async (): Promise => { + try { + let result: WorkerResponseData; + const transferables: Transferable[] = []; + + switch (command.type) { + case 'init': { + const { ctx, header } = await initEncoder( + command.data.numberOfChannels, + command.data.sampleRate, + ); + result = { type: command.type, ctx, header }; + transferables.push(header); + }; break; + + case 'encode': { + const packets = encode( + command.data.ctx, + command.data.audioData, + command.data.numSamples, + ); + for (const p of packets) { + transferables.push(p.encodedData); + } + result = { type: command.type, packets }; + }; break; + + case 'flush': { + const packets = flush(command.data.ctx); + for (const p of packets) { + transferables.push(p.encodedData); + } + result = { type: command.type, packets }; + }; 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/flac-encoder/src/encoder.ts b/packages/flac-encoder/src/encoder.ts new file mode 100644 index 0000000..7d0c89d --- /dev/null +++ b/packages/flac-encoder/src/encoder.ts @@ -0,0 +1,200 @@ +/*! + * 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 type { PacketInfo, WorkerCommand, WorkerResponse, WorkerResponseData } from './shared'; +// @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand +import createWorker from './encode.worker'; + +const FLAC_SAMPLE_RATES = [ + 8000, 16000, 22050, 24000, 32000, 44100, 48000, 88200, 96000, 176400, 192000, +]; + +class FlacEncoder extends CustomAudioEncoder { + private worker: Worker | null = null; + private nextMessageId = 0; + private pendingMessages = new Map void; + reject: (reason?: unknown) => void; + }>(); + + private ctx = 0; + private chunkMetadata: EncodedAudioChunkMetadata = {}; + private description: Uint8Array | null = null; + private nextTimestampInSamples: number | null = null; + + static override supports(codec: AudioCodec, config: AudioEncoderConfig): boolean { + return codec === 'flac' + && config.numberOfChannels >= 1 + && config.numberOfChannels <= 8 + && FLAC_SAMPLE_RATES.includes(config.sampleRate); + } + + async init() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + this.worker = (await createWorker()) as Worker; + + const onMessage = (data: WorkerResponse) => { + const pending = this.pendingMessages.get(data.id); + assert(pending !== undefined); + + this.pendingMessages.delete(data.id); + if (data.success) { + pending.resolve(data.data); + } else { + pending.reject(data.error); + } + }; + + if (this.worker.addEventListener) { + this.worker.addEventListener('message', event => onMessage(event.data as WorkerResponse)); + } else { + const nodeWorker = this.worker as unknown as { + on: (event: string, listener: (data: never) => void) => void; + }; + nodeWorker.on('message', onMessage); + } + + const result = await this.sendCommand({ + type: 'init', + data: { + numberOfChannels: this.config.numberOfChannels, + sampleRate: this.config.sampleRate, + }, + }); + + this.ctx = result.ctx; + + this.description = new Uint8Array(result.header); + this.resetInternalState(); + } + + private resetInternalState() { + this.nextTimestampInSamples = null; + + this.chunkMetadata = { + decoderConfig: { + codec: 'flac', + numberOfChannels: this.config.numberOfChannels, + sampleRate: this.config.sampleRate, + description: this.description!, + }, + }; + } + + async encode(audioSample: AudioSample) { + if (this.nextTimestampInSamples === null) { + this.nextTimestampInSamples = Math.round(audioSample.timestamp * this.config.sampleRate); + } + + const totalBytes = audioSample.allocationSize({ format: 's16', planeIndex: 0 }); + const audioBytes = new Uint8Array(totalBytes); + audioSample.copyTo(audioBytes, { format: 's16', planeIndex: 0 }); + + const audioData = audioBytes.buffer; + const result = await this.sendCommand({ + type: 'encode', + data: { + ctx: this.ctx, + audioData, + numSamples: audioSample.numberOfFrames, + }, + }, [audioData]); + + this.emitPackets(result.packets); + } + + async flush() { + const result = await this.sendCommand({ type: 'flush', data: { ctx: this.ctx } }); + this.emitPackets(result.packets); + + this.resetInternalState(); + } + + close() { + this.worker?.terminate(); + } + + private emitPackets(packets: PacketInfo[]) { + assert(this.nextTimestampInSamples !== null); + + for (const p of packets) { + const data = new Uint8Array(p.encodedData); + + const packet = new EncodedPacket( + data, + 'key', + this.nextTimestampInSamples / this.config.sampleRate, + p.samples / this.config.sampleRate, + ); + this.nextTimestampInSamples += p.samples; + + this.onPacket( + packet, + this.chunkMetadata, + ); + + this.chunkMetadata = {}; + } + } + + private sendCommand( + command: WorkerCommand & { type: T }, + transferables?: Transferable[], + ) { + return new Promise((resolve, reject) => { + const id = this.nextMessageId++; + this.pendingMessages.set(id, { + resolve: resolve as (value: WorkerResponseData) => void, + reject, + }); + + assert(this.worker); + + if (transferables) { + this.worker.postMessage({ id, command }, transferables); + } else { + this.worker.postMessage({ id, command }); + } + }); + } +} + +/** + * Registers the FLAC encoder, which Mediabunny will then use automatically when applicable. Make sure to call this + * function before starting any encoding task. + * + * Preferably, wrap the call in a condition to avoid overriding any native FLAC encoder: + * + * ```ts + * import { canEncodeAudio } from 'mediabunny'; + * import { registerFlacEncoder } from '@mediabunny/flac-encoder'; + * + * if (!(await canEncodeAudio('flac'))) { + * registerFlacEncoder(); + * } + * ``` + * + * @group \@mediabunny/flac-encoder + * @public + */ +export const registerFlacEncoder = () => { + registerEncoder(FlacEncoder); +}; + +function assert(x: unknown): asserts x { + if (!x) { + throw new Error('Assertion failed.'); + } +} diff --git a/packages/flac-encoder/src/index.ts b/packages/flac-encoder/src/index.ts new file mode 100644 index 0000000..3a86463 --- /dev/null +++ b/packages/flac-encoder/src/index.ts @@ -0,0 +1,20 @@ +/*! + * Copyright (c) 2026-present, Vanilagy and contributors + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +const FLAC_ENCODER_LOADED_SYMBOL = Symbol.for('@mediabunny/flac-encoder loaded'); +if ((globalThis as Record)[FLAC_ENCODER_LOADED_SYMBOL]) { + console.error( + '[WARNING]\n@mediabunny/flac-encoder was loaded twice.' + + ' This will likely cause the encoder not to work correctly.' + + ' Check if multiple dependencies are importing different versions of @mediabunny/flac-encoder,' + + ' or if something is being bundled incorrectly.', + ); +} +(globalThis as Record)[FLAC_ENCODER_LOADED_SYMBOL] = true; + +export { registerFlacEncoder } from './encoder'; diff --git a/packages/flac-encoder/src/shared.ts b/packages/flac-encoder/src/shared.ts new file mode 100644 index 0000000..0d7c596 --- /dev/null +++ b/packages/flac-encoder/src/shared.ts @@ -0,0 +1,54 @@ +/*! + * 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 PacketInfo = { + encodedData: ArrayBuffer; + samples: number; +}; + +export type WorkerCommand = { + type: 'init'; + data: { + numberOfChannels: number; + sampleRate: number; + }; +} | { + type: 'encode'; + data: { + ctx: number; + audioData: ArrayBuffer; + numSamples: number; + }; +} | { + type: 'flush'; + data: { + ctx: number; + }; +}; + +export type WorkerResponseData = { + type: 'init'; + ctx: number; + header: ArrayBuffer; +} | { + type: 'encode'; + packets: PacketInfo[]; +} | { + type: 'flush'; + packets: PacketInfo[]; +}; + +export type WorkerResponse = { + id: number; +} & ({ + success: true; + data: WorkerResponseData; +} | { + success: false; + error: unknown; +}); diff --git a/packages/flac-encoder/tsconfig.json b/packages/flac-encoder/tsconfig.json new file mode 100644 index 0000000..27a7ef1 --- /dev/null +++ b/packages/flac-encoder/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist/modules", + "declaration": true, + "declarationMap": true, + "stripInternal": true, + "noEmit": false, + "moduleResolution": "nodenext", + "module": "nodenext", + "allowJs": true, + "paths": { + "mediabunny": ["../../src/index.ts"], + }, + }, + "include": [ + "./src/**/*", + "./build/**/*", + ], + "references": [ + { "path": "../../src" } + ] +} diff --git a/packages/flac-encoder/tsdoc.json b/packages/flac-encoder/tsdoc.json new file mode 100644 index 0000000..776fc76 --- /dev/null +++ b/packages/flac-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/scripts/build.sh b/scripts/build.sh index e448138..3ffb1cb 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -8,6 +8,7 @@ rm -rf dist rm -rf packages/mp3-encoder/dist rm -rf packages/ac3/dist rm -rf packages/aac-encoder/dist +rm -rf packages/flac-encoder/dist # Ensure license headers on all source files tsx scripts/ensure-license-headers.ts @@ -17,6 +18,7 @@ tsc -p src tsc -p packages/mp3-encoder tsc -p packages/ac3 tsc -p packages/aac-encoder +tsc -p packages/flac-encoder # So that the resulting files use valid ESM imports with file extension. This only runs for the core Mediabunny as only # it ships the individual files to npm (for tree shaking, because it's large) @@ -30,12 +32,14 @@ api-extractor run api-extractor run -c packages/mp3-encoder/api-extractor.json api-extractor run -c packages/ac3/api-extractor.json api-extractor run -c packages/aac-encoder/api-extractor.json +api-extractor run -c packages/flac-encoder/api-extractor.json # Checks that all symbols are documented tsx scripts/check-docblocks.ts dist/mediabunny.d.ts tsx scripts/check-docblocks.ts packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts tsx scripts/check-docblocks.ts packages/ac3/dist/mediabunny-ac3.d.ts tsx scripts/check-docblocks.ts packages/aac-encoder/dist/mediabunny-aac-encoder.d.ts +tsx scripts/check-docblocks.ts packages/flac-encoder/dist/mediabunny-flac-encoder.d.ts # Checks that API docs are generatable npm run docs:generate -- --dry @@ -44,4 +48,6 @@ 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 MediabunnyAacEncoder;' >> packages/aac-encoder/dist/mediabunny-aac-encoder.d.ts \ No newline at end of file +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 + diff --git a/scripts/bundle.ts b/scripts/bundle.ts index b94106f..b748d3c 100644 --- a/scripts/bundle.ts +++ b/scripts/bundle.ts @@ -175,11 +175,43 @@ const aacEncoderVariants = await createVariants( }, ); +const flacEncoderVariants = await createVariants( + 'packages/flac-encoder/src/index.ts', + 'MediabunnyFlacEncoder', + 'packages/flac-encoder/dist/bundles/mediabunny-flac-encoder', + 'js', // The bundles are purely for the browser, not for Node (due to the peer dependecy) + { + plugins: [ + PluginExternalGlobal.externalGlobalPlugin({ + mediabunny: 'Mediabunny', + }), + inlineWorkerPlugin({ + define: { + 'import.meta.url': '""', + }, + legalComments: 'none', + }), + ], + }, + { + external: ['mediabunny'], + plugins: [ + inlineWorkerPlugin({ + define: { + 'import.meta.url': '""', + }, + legalComments: 'none', + }), + ], + }, +); + const contexts = [ ...mediabunnyVariants, ...mp3EncoderVariants, ...ac3Variants, ...aacEncoderVariants, + ...flacEncoderVariants, ]; if (process.argv[2] === '--watch') { diff --git a/scripts/check.sh b/scripts/check.sh index 6b1c4db..d47231e 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -13,6 +13,9 @@ tsc -p packages/ac3 rm -rf packages/aac-encoder/dist/modules tsc -p packages/aac-encoder +rm -rf packages/flac-encoder/dist/modules +tsc -p packages/flac-encoder + tsc -p tsconfig.vitest.json --noEmit tsc -p scripts --noEmit diff --git a/src/conversion.ts b/src/conversion.ts index cda03c8..01fc278 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -786,6 +786,12 @@ export class Conversion { + ' for encoding and decoding AC-3/E-AC-3.', ); } + + if (codecs.includes('flac')) { + elements.push( + '\nThe @mediabunny/flac-encoder extension package provides support for encoding FLAC.', + ); + } } else { elements.push('\nCheck the discardedTracks field for more info.'); } diff --git a/src/flac/flac-muxer.ts b/src/flac/flac-muxer.ts index dad7a61..83cdfe9 100644 --- a/src/flac/flac-muxer.ts +++ b/src/flac/flac-muxer.ts @@ -207,12 +207,6 @@ export class FlacMuxer extends Muxer { ): Promise { const release = await this.mutex.acquire(); - validateAudioChunkMetadata(meta); - - assert(meta); - assert(meta.decoderConfig); - assert(meta.decoderConfig.description); - try { this.validateAndNormalizeTimestamp( track, @@ -221,14 +215,16 @@ export class FlacMuxer extends Muxer { ); if (this.sampleRate === null) { + // It's the first packet + validateAudioChunkMetadata(meta); + + assert(meta); + assert(meta.decoderConfig); + assert(meta.decoderConfig.description); + this.sampleRate = meta.decoderConfig.sampleRate; - } - - if (this.channels === null) { this.channels = meta.decoderConfig.numberOfChannels; - } - if (this.bitsPerSample === null) { const descriptionBitstream = new Bitstream( toUint8Array(meta.decoderConfig.description), ); @@ -244,7 +240,7 @@ export class FlacMuxer extends Muxer { } const slice = FileSlice.tempFromBytes(packet.data); - readBytes(slice, 2); + slice.skip(2); const bytes = readBytes(slice, 2); const bitstream = new Bitstream(bytes); const blockSizeOrUncommon = getBlockSizeOrUncommon(bitstream.readBits(4)); diff --git a/test/node/flac-encoder-extension.test.ts b/test/node/flac-encoder-extension.test.ts new file mode 100644 index 0000000..6d7dc85 --- /dev/null +++ b/test/node/flac-encoder-extension.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from 'vitest'; +import { Input } from '../../src/input.js'; +import { BufferSource } from '../../src/source.js'; +import { ALL_FORMATS } from '../../src/input-format.js'; +import { Output } from '../../src/output.js'; +import { BufferTarget } from '../../src/target.js'; +import { canEncode } from '../../src/encode.js'; +import { AudioSampleSource } from '../../src/media-source.js'; +import { EncodedPacketSink } from '../../src/media-sink.js'; +import { FlacOutputFormat } from '../../src/output-format.js'; +import { AudioSample } from '../../src/sample.js'; +import { registerFlacEncoder } from '@mediabunny/flac-encoder'; + +const createSineWave = (sampleRate: number, channels: number, durationSeconds: number) => { + const totalFrames = sampleRate * durationSeconds; + const data = new Float32Array(totalFrames * channels); + + for (let i = 0; i < totalFrames; i++) { + const value = Math.sin(2 * Math.PI * 440 * i / sampleRate); + for (let ch = 0; ch < channels; ch++) { + data[i * channels + ch] = value; + } + } + + return data; +}; + +test('Custom coder registration', async () => { + expect(await canEncode('flac')).toBe(false); + + registerFlacEncoder(); + + expect(await canEncode('flac')).toBe(true); +}); + +test('FLAC encoding', async () => { + registerFlacEncoder(); + + const sampleRate = 48000; + const channels = 2; + const durationSeconds = 2; + const data = createSineWave(sampleRate, channels, durationSeconds); + + const output = new Output({ + format: new FlacOutputFormat(), + target: new BufferTarget(), + }); + + const audioSource = new AudioSampleSource({ codec: 'flac', bitrate: 1 }); + 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('flac'); + 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++; + } + // FLAC default block size is 4096, so ~24 packets for 2 seconds at 48kHz + expect(packetCount).toBeGreaterThan(durationSeconds * sampleRate / 4096); + + expect(await track.computeDuration()).toBeCloseTo(2, 1); +}); diff --git a/test/node/mp3-encoder-extension.test.ts b/test/node/mp3-encoder-extension.test.ts new file mode 100644 index 0000000..6a28f69 --- /dev/null +++ b/test/node/mp3-encoder-extension.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from 'vitest'; +import { Input } from '../../src/input.js'; +import { BufferSource } from '../../src/source.js'; +import { ALL_FORMATS } from '../../src/input-format.js'; +import { Output } from '../../src/output.js'; +import { BufferTarget } from '../../src/target.js'; +import { canEncode } from '../../src/encode.js'; +import { AudioSampleSource } from '../../src/media-source.js'; +import { EncodedPacketSink } from '../../src/media-sink.js'; +import { Mp3OutputFormat } from '../../src/output-format.js'; +import { AudioSample } from '../../src/sample.js'; +import { registerMp3Encoder } from '@mediabunny/mp3-encoder'; + +const createSineWave = (sampleRate: number, channels: number, durationSeconds: number) => { + const totalFrames = sampleRate * durationSeconds; + const data = new Float32Array(totalFrames * channels); + + for (let i = 0; i < totalFrames; i++) { + const value = Math.sin(2 * Math.PI * 440 * i / sampleRate); + for (let ch = 0; ch < channels; ch++) { + data[i * channels + ch] = value; + } + } + + return data; +}; + +test('Custom coder registration', async () => { + expect(await canEncode('mp3')).toBe(false); + + registerMp3Encoder(); + + expect(await canEncode('mp3')).toBe(true); +}); + +test('MP3 encoding', async () => { + registerMp3Encoder(); + + const sampleRate = 44100; + const channels = 2; + const durationSeconds = 2; + const data = createSineWave(sampleRate, channels, durationSeconds); + + const output = new Output({ + format: new Mp3OutputFormat(), + target: new BufferTarget(), + }); + + const audioSource = new AudioSampleSource({ codec: 'mp3', bitrate: 128_000 }); + 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('mp3'); + 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++; + } + // MP3 frames are 1152 samples each, so ~77 packets for 2 seconds at 44.1kHz + expect(packetCount).toBeGreaterThan(durationSeconds * sampleRate / 1152 - 2); + + expect(await track.computeDuration()).toBeCloseTo(2, 0); +}); diff --git a/tsconfig.json b/tsconfig.json index 2542ebc..128e38f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "allowJs": true, "noEmit": true, }, + "include": [], "references": [ { "path": "./tsconfig.vite.json" }, { "path": "./tsconfig.vitest.json" } diff --git a/tsconfig.vite.json b/tsconfig.vite.json index 07743b0..ad7a720 100644 --- a/tsconfig.vite.json +++ b/tsconfig.vite.json @@ -9,6 +9,7 @@ "mediabunny": ["./src/index.ts"], "@mediabunny/ac3": ["./packages/ac3/src/index.ts"], "@mediabunny/aac-encoder": ["./packages/aac-encoder/src/index.ts"], + "@mediabunny/flac-encoder": ["./packages/flac-encoder/src/index.ts"], }, "types": ["vite/client"] }, diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json index e4ac060..b2de3f3 100644 --- a/tsconfig.vitest.json +++ b/tsconfig.vitest.json @@ -9,9 +9,17 @@ "mediabunny": ["./src/index.ts"], "@mediabunny/ac3": ["./packages/ac3/src/index.ts"], "@mediabunny/aac-encoder": ["./packages/aac-encoder/src/index.ts"], + "@mediabunny/flac-encoder": ["./packages/flac-encoder/src/index.ts"], + "@mediabunny/mp3-encoder": ["./packages/mp3-encoder/src/index.ts"], }, }, "include": ["vitest.config.ts", "./test/**/*"], "exclude": ["./test/public/**/*"], - "references": [{ "path": "./src" }, { "path": "./packages/ac3" }, { "path": "./packages/aac-encoder" }] + "references": [ + { "path": "./src" }, + { "path": "./packages/ac3" }, + { "path": "./packages/aac-encoder" }, + { "path": "./packages/flac-encoder" }, + { "path": "./packages/mp3-encoder" }, + ] } diff --git a/vite.config.ts b/vite.config.ts index c376f06..f1c5335 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/aac-encoder': path.resolve(__dirname, './packages/aac-encoder/dist/bundles/mediabunny-aac-encoder.mjs'), + '@mediabunny/flac-encoder': + path.resolve(__dirname, './packages/flac-encoder/dist/bundles/mediabunny-flac-encoder.mjs'), }, }, plugins: [ diff --git a/vitest.config.ts b/vitest.config.ts index d73adc9..8578819 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,10 @@ export default defineConfig({ '@mediabunny/ac3': path.resolve(__dirname, './packages/ac3/dist/bundles/mediabunny-ac3.mjs'), '@mediabunny/aac-encoder': path.resolve(__dirname, './packages/aac-encoder/dist/bundles/mediabunny-aac-encoder.mjs'), + '@mediabunny/flac-encoder': + path.resolve(__dirname, './packages/flac-encoder/dist/bundles/mediabunny-flac-encoder.mjs'), + '@mediabunny/mp3-encoder': + path.resolve(__dirname, './packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.mjs'), }, }, test: {