diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26ee50b..adb95e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,7 @@ jobs: - ubuntu-22.04 - ubuntu-22.04-arm - windows-2022 + - windows-11-arm steps: - name: Checkout repository @@ -77,6 +78,13 @@ jobs: if: matrix.node == 24 run: pnpm prebuild + - name: Test selected N-API prebuild + if: matrix.node == 24 + env: + PREBUILDS_ONLY: "1" + LIBOPUS_TEST_NATIVE: "1" + run: pnpm test-native + - name: Run tests run: pnpm test @@ -131,7 +139,8 @@ jobs: if [ "${{ matrix.node }}" = "24" ]; then # Build N-API prebuilds for musl, tagging libc so node-gyp-build # can prefer them on Alpine. - pnpm prebuild + pnpm prebuild && + PREBUILDS_ONLY=1 LIBOPUS_TEST_NATIVE=1 pnpm test-native fi && pnpm test ' @@ -179,7 +188,7 @@ jobs: # package builds run in the platform-native jobs above; this job # verifies the OpenBSD prebuild itself. pnpm prebuild - LIBOPUS_TEST_NATIVE=1 node src/tests/test.js + PREBUILDS_ONLY=1 LIBOPUS_TEST_NATIVE=1 node src/tests/test.js - name: Archive OpenBSD prebuilds run: tar -czf prebuilds-openbsd-x64.tar.gz -C prebuilds . @@ -190,10 +199,10 @@ jobs: name: prebuild-openbsd-x64 path: prebuilds-openbsd-x64.tar.gz - verify_release_artifact: - name: Verify packed release on Alpine Linux arm64 + package_release_candidate: + name: Assemble packed release candidate needs: [quality, build, build_musl, build_openbsd] - runs-on: ubuntu-22.04-arm + runs-on: ubuntu-22.04 steps: - name: Checkout repository uses: actions/checkout@v5 @@ -255,47 +264,159 @@ jobs: exit 1 fi - - name: Verify the packed module in native Alpine arm64 + - name: Upload packed release candidate + uses: actions/upload-artifact@v6 + with: + name: release-candidate + path: artifacts/package/*.tgz + + verify_release_artifact: + name: Verify packed release on ${{ matrix.name }} + needs: package_release_candidate + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - name: Linux glibc x64 + runner: ubuntu-22.04 + platform: linux + architecture: x64 + libc: glibc + - name: Linux glibc arm64 + runner: ubuntu-22.04-arm + platform: linux + architecture: arm64 + libc: glibc + - name: macOS x64 + runner: macos-15-intel + platform: darwin + architecture: x64 + libc: glibc + - name: macOS arm64 + runner: macos-15 + platform: darwin + architecture: arm64 + libc: glibc + - name: Windows x64 + runner: windows-2022 + platform: win32 + architecture: x64 + libc: glibc + - name: Windows arm64 + runner: windows-11-arm + platform: win32 + architecture: arm64 + libc: glibc + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + + - name: Download packed release candidate + uses: actions/download-artifact@v7 + with: + name: release-candidate + path: artifacts/package + + - name: Install and test the packed module + env: + LIBOPUS_EXPECTED_PLATFORM: ${{ matrix.platform }} + LIBOPUS_EXPECTED_ARCH: ${{ matrix.architecture }} + LIBOPUS_EXPECTED_LIBC: ${{ matrix.libc }} + run: node scripts/verify-packed-package.mjs artifacts/package + + verify_release_artifact_musl: + name: Verify packed release on Alpine Linux ${{ matrix.architecture }} + needs: package_release_candidate + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - architecture: x64 + runner: ubuntu-22.04 + - architecture: arm64 + runner: ubuntu-22.04-arm + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Download packed release candidate + uses: actions/download-artifact@v7 + with: + name: release-candidate + path: artifacts/package + + - name: Install and test the packed module in Alpine run: | set -euo pipefail - tar -tzf artifacts/package/*.tgz | grep -E '^package/prebuilds/linux-arm64/.*\.musl\.node$' docker run --rm \ - -v "${{ github.workspace }}/artifacts/package:/package:ro" \ + -v "${{ github.workspace }}:/work:ro" \ + -w /work \ + -e LIBOPUS_EXPECTED_PLATFORM=linux \ + -e LIBOPUS_EXPECTED_ARCH=${{ matrix.architecture }} \ + -e LIBOPUS_EXPECTED_LIBC=musl \ node:24-alpine \ sh -lc ' set -euo pipefail - test "$(uname -m)" = aarch64 - mkdir /consumer - cd /consumer - npm init --yes >/dev/null - npm install --foreground-scripts /package/*.tgz - node - <<"NODE" - const assert = require("node:assert"); - const fs = require("node:fs"); - const { createRequire } = require("node:module"); - const path = require("node:path"); - - const packageRoot = path.resolve("node_modules/libopus-node"); - const packageRequire = createRequire(path.join(packageRoot, "package.json")); - const selected = packageRequire("node-gyp-build").resolve(packageRoot); - - assert.match(selected, /\/prebuilds\/linux-arm64\/.*\.musl\.node$/); - assert(!fs.existsSync(path.join(packageRoot, "build"))); - console.log(`Selected Alpine arm64 musl prebuild: ${selected}`); - NODE - node --input-type=module -e " - import { OpusEncoder } from \"libopus-node\"; - const encoder = new OpusEncoder(16_000, 1); - const pcm = Buffer.alloc(640); - for (let i = 0; i < 320; i++) { - pcm.writeInt16LE(Math.round(12_000 * Math.sin((2 * Math.PI * i) / 40)), i * 2); - } - if (encoder.encode(pcm).length === 0) throw new Error(\"Encoder produced no packet\"); - " + npm install --global corepack@0.35.0 + corepack enable + node scripts/verify-packed-package.mjs artifacts/package ' + verify_release_artifact_openbsd: + name: Verify packed release on OpenBSD x86-64 + needs: package_release_candidate + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Download packed release candidate + uses: actions/download-artifact@v7 + with: + name: release-candidate + path: artifacts/package + + - name: Start OpenBSD VM + uses: cross-platform-actions/action@v1.4.0 + with: + operating_system: openbsd + version: "7.8" + architecture: x86-64 + + - name: Install and test the packed module on OpenBSD + shell: cpa.sh {0} + run: | + set -euo pipefail + sudo pkg_add node npm + sudo npm install --global corepack@0.35.0 + sudo corepack enable + LIBOPUS_EXPECTED_PLATFORM=openbsd \ + LIBOPUS_EXPECTED_ARCH=x64 \ + LIBOPUS_EXPECTED_LIBC=glibc \ + node scripts/verify-packed-package.mjs artifacts/package + publish: - needs: [quality, build, build_musl, build_openbsd, verify_release_artifact] + needs: + - quality + - build + - build_musl + - build_openbsd + - package_release_candidate + - verify_release_artifact + - verify_release_artifact_musl + - verify_release_artifact_openbsd if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: ./.github/workflows/publish.yml permissions: diff --git a/README.md b/README.md index 0c3a36c..38827ca 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ Prebuilt binaries are provided for: | Linux (glibc) | x64, arm64 | | Linux (musl) | x64, arm64 | | macOS | x64, arm64 | -| Windows | x64 | +| Windows | x64, arm64 | | OpenBSD | x64 | If a prebuilt binary is not available for your platform, the package cannot be used on that platform. diff --git a/libopus/binding.gyp b/libopus/binding.gyp index ac124e4..7692f51 100644 --- a/libopus/binding.gyp +++ b/libopus/binding.gyp @@ -227,6 +227,24 @@ ], } ], + [ + "OS==\"win\" and target_arch==\"arm64\"", + { + # MSVC's C compiler cannot fold the floating-point + # constants in these upstream tables on ARM64 (C2099). + # Compile the two table translation units as C++ instead. + "sources!": [ + "opus/celt/quant_bands.c", + "opus/silk/tables_NLSF_CB_WB.c", + "opus/silk/tables_NLSF_CB_NB_MB.c", + ], + "sources": [ + "win/arm64/quant_bands.cc", + "win/arm64/tables_NLSF_CB_WB.cc", + "win/arm64/tables_NLSF_CB_NB_MB.cc", + ], + } + ], ], }, ], diff --git a/libopus/config/win/arm64/config.h b/libopus/config/win/arm64/config.h new file mode 100644 index 0000000..4db84bd --- /dev/null +++ b/libopus/config/win/arm64/config.h @@ -0,0 +1,45 @@ +/*********************************************************************** +Copyright (c) 2011, Skype Limited. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +- Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. +- Neither the name of Internet Society, IETF or IETF Trust, nor the +names of specific contributors, may be used to endorse or promote +products derived from this software without specific prior written +permission. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +***********************************************************************/ + +#ifndef CONFIG_H +#define CONFIG_H + +#define USE_ALLOCA 1 + +/* Comment out the next line for floating-point code */ +/* #define FIXED_POINT 1 */ + +/* AArch64 requires NEON and MSVC supplies the corresponding intrinsics. */ +#define OPUS_ARM_MAY_HAVE_NEON_INTR 1 +#define OPUS_ARM_PRESUME_AARCH64_NEON_INTR 1 +#define OPUS_ARM_PRESUME_NEON_INTR 1 + +#define OPUS_BUILD 1 + +#define PACKAGE_VERSION "1.3.1" + +#endif /* CONFIG_H */ diff --git a/libopus/win/arm64/quant_bands.cc b/libopus/win/arm64/quant_bands.cc new file mode 100644 index 0000000..a918fc0 --- /dev/null +++ b/libopus/win/arm64/quant_bands.cc @@ -0,0 +1,8 @@ +/* + * MSVC's ARM64 C frontend reports C2099 for floating-point static initializers + * in this upstream table. Compiling the original translation unit as C++ + * preserves the table contents while allowing those initializers. + */ +extern "C" { +#include "../../opus/celt/quant_bands.c" +} diff --git a/libopus/win/arm64/tables_NLSF_CB_NB_MB.cc b/libopus/win/arm64/tables_NLSF_CB_NB_MB.cc new file mode 100644 index 0000000..0879acf --- /dev/null +++ b/libopus/win/arm64/tables_NLSF_CB_NB_MB.cc @@ -0,0 +1,4 @@ +/* + * See tables_NLSF_CB_WB.cc for why this upstream table is compiled as C++. + */ +#include "../../opus/silk/tables_NLSF_CB_NB_MB.c" diff --git a/libopus/win/arm64/tables_NLSF_CB_WB.cc b/libopus/win/arm64/tables_NLSF_CB_WB.cc new file mode 100644 index 0000000..d54f6e1 --- /dev/null +++ b/libopus/win/arm64/tables_NLSF_CB_WB.cc @@ -0,0 +1,6 @@ +/* + * MSVC's ARM64 C frontend reports C2099 for floating-point static initializers + * in this upstream table. Compiling the original translation unit as C++ + * preserves the table contents while allowing those initializers. + */ +#include "../../opus/silk/tables_NLSF_CB_WB.c" diff --git a/package.json b/package.json index 61f4b17..db062dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "libopus-node", - "version": "1.3.0", + "version": "1.3.1", "description": "libopus bindings for Node", "keywords": [ "binary", diff --git a/scripts/verify-packed-package.mjs b/scripts/verify-packed-package.mjs new file mode 100644 index 0000000..03cc683 --- /dev/null +++ b/scripts/verify-packed-package.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; + +const artifactDirectory = process.argv[2]; +const expectedPlatform = process.env.LIBOPUS_EXPECTED_PLATFORM; +const expectedArchitecture = process.env.LIBOPUS_EXPECTED_ARCH; +const expectedLibc = process.env.LIBOPUS_EXPECTED_LIBC; + +assert(artifactDirectory, "Usage: node scripts/verify-packed-package.mjs "); +assert(expectedPlatform, "LIBOPUS_EXPECTED_PLATFORM must be set"); +assert(expectedArchitecture, "LIBOPUS_EXPECTED_ARCH must be set"); +assert(expectedLibc, "LIBOPUS_EXPECTED_LIBC must be set"); +assert.strictEqual( + process.platform, + expectedPlatform, + "Verification must run on the expected platform", +); +assert.strictEqual( + process.arch, + expectedArchitecture, + "Verification must run on the expected architecture", +); + +const tarballs = fs + .readdirSync(artifactDirectory) + .filter((file) => file.endsWith(".tgz")) + .map((file) => path.resolve(artifactDirectory, file)); +assert.strictEqual(tarballs.length, 1, "Expected exactly one packed release candidate"); + +const consumerDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "libopus-node-consumer-")); +const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + +run(npm, ["init", "--yes"], { cwd: consumerDirectory }); +run(npm, ["install", "--foreground-scripts", "--ignore-scripts", tarballs[0]], { + cwd: consumerDirectory, +}); + +const packageName = JSON.parse(fs.readFileSync("package.json", "utf8")).name; +const packageRoot = path.join(consumerDirectory, "node_modules", packageName); +const packageRequire = createRequire(path.join(packageRoot, "package.json")); +const selected = packageRequire("node-gyp-build").resolve(packageRoot); +const expectedPrebuildDirectory = path.join( + packageRoot, + "prebuilds", + `${expectedPlatform}-${expectedArchitecture}`, +); + +assert.strictEqual( + path.dirname(selected), + expectedPrebuildDirectory, + "Selected an unexpected prebuild directory", +); +assert.match(path.basename(selected), new RegExp(`\\.${expectedLibc}\\.node$`)); + +for (const excludedPath of ["binding.gyp", "build", "libopus", "src"]) { + assert( + !fs.existsSync(path.join(packageRoot, excludedPath)), + `Packed package contains ${excludedPath}`, + ); +} + +run( + process.execPath, + ["--input-type=module", "--eval", `await import(${JSON.stringify(packageName)})`], + { + cwd: consumerDirectory, + env: { + ...process.env, + PREBUILDS_ONLY: "1", + }, + }, +); + +run(process.execPath, [path.resolve("src/tests/test.js")], { + env: { + ...process.env, + LIBOPUS_TEST_PACKAGE_ROOT: packageRoot, + PREBUILDS_ONLY: "1", + }, +}); + +console.log( + `Verified ${expectedPlatform}-${expectedArchitecture} ${expectedLibc} prebuild: ${selected}`, +); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + stdio: "inherit", + // Windows command shims such as npm.cmd require a shell to launch. + shell: process.platform === "win32" && command.endsWith(".cmd"), + ...options, + }); + + if (result.error) throw result.error; + assert.strictEqual(result.status, 0, `${command} exited with ${result.status}`); +} diff --git a/src/tests/test.js b/src/tests/test.js index 0a9d0d5..ccb0d42 100644 --- a/src/tests/test.js +++ b/src/tests/test.js @@ -1,13 +1,19 @@ import assert from "node:assert"; import fs from "node:fs"; import path from "node:path"; -import nodeGypBuild from "node-gyp-build"; +import { pathToFileURL } from "node:url"; -const { OpusEncoder } = - process.env.LIBOPUS_TEST_NATIVE === "1" - ? nodeGypBuild(path.resolve(import.meta.dirname, "../..")) +const packageRoot = process.env.LIBOPUS_TEST_PACKAGE_ROOT; +const { OpusEncoder } = packageRoot + ? await loadPackage(packageRoot) + : process.env.LIBOPUS_TEST_NATIVE === "1" + ? (await import("node-gyp-build")).default(path.resolve(import.meta.dirname, "../..")) : await import("../../dist/index.js"); +async function loadPackage(root) { + return import(pathToFileURL(path.join(root, "dist/index.js")).href); +} + const sampleRate = 16_000; const channels = 1; const frameSize = 320;