From a15283eaa33db90a7895e4a9e8a7f4bed6cbdd8d Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:21:52 +0100 Subject: [PATCH] Add @mediabunny/flac-encoder extension, clean up some other code, fix incorrect top-level tsconfig --- .gitignore | 3 +- docs/.vitepress/config.mts | 1 + docs/api-config.json | 3 +- docs/guide/extensions/flac-encoder.md | 81 +++++ eslint.config.mjs | 2 + package-lock.json | 19 ++ package.json | 2 +- packages/aac-encoder/src/encode.worker.ts | 12 +- packages/aac-encoder/src/encoder.ts | 5 +- packages/aac-encoder/src/shared.ts | 25 +- packages/flac-encoder/.gitattributes | 1 + packages/flac-encoder/LICENSE | 373 +++++++++++++++++++++ packages/flac-encoder/README.md | 140 ++++++++ packages/flac-encoder/api-extractor.json | 37 ++ packages/flac-encoder/build/flac.js | Bin 0 -> 202256 bytes packages/flac-encoder/package.json | 54 +++ packages/flac-encoder/src/bridge.c | 255 ++++++++++++++ packages/flac-encoder/src/encode.worker.ts | 193 +++++++++++ packages/flac-encoder/src/encoder.ts | 200 +++++++++++ packages/flac-encoder/src/index.ts | 20 ++ packages/flac-encoder/src/shared.ts | 54 +++ packages/flac-encoder/tsconfig.json | 24 ++ packages/flac-encoder/tsdoc.json | 4 + scripts/build.sh | 8 +- scripts/bundle.ts | 32 ++ scripts/check.sh | 3 + src/conversion.ts | 6 + src/flac/flac-muxer.ts | 20 +- test/node/flac-encoder-extension.test.ts | 83 +++++ test/node/mp3-encoder-extension.test.ts | 83 +++++ tsconfig.json | 1 + tsconfig.vite.json | 1 + tsconfig.vitest.json | 10 +- vite.config.ts | 2 + vitest.config.ts | 4 + 35 files changed, 1713 insertions(+), 48 deletions(-) create mode 100644 docs/guide/extensions/flac-encoder.md create mode 100644 packages/flac-encoder/.gitattributes create mode 100644 packages/flac-encoder/LICENSE create mode 100644 packages/flac-encoder/README.md create mode 100644 packages/flac-encoder/api-extractor.json create mode 100644 packages/flac-encoder/build/flac.js create mode 100644 packages/flac-encoder/package.json create mode 100644 packages/flac-encoder/src/bridge.c create mode 100644 packages/flac-encoder/src/encode.worker.ts create mode 100644 packages/flac-encoder/src/encoder.ts create mode 100644 packages/flac-encoder/src/index.ts create mode 100644 packages/flac-encoder/src/shared.ts create mode 100644 packages/flac-encoder/tsconfig.json create mode 100644 packages/flac-encoder/tsdoc.json create mode 100644 test/node/flac-encoder-extension.test.ts create mode 100644 test/node/mp3-encoder-extension.test.ts 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 0000000000000000000000000000000000000000..da7014481e996fa95e46f93bf3fb4765bcafea98 GIT binary patch literal 202256 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 OJnZ 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 z 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 UBrk&0V0jtCbsRfA3IFB)0GtiXzW@LL literal 0 HcmV?d00001 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: {