diff --git a/.github/workflows/plugins_wasm.yml b/.github/workflows/plugins_wasm.yml index c783d6d..3a62958 100644 --- a/.github/workflows/plugins_wasm.yml +++ b/.github/workflows/plugins_wasm.yml @@ -17,14 +17,16 @@ concurrency: jobs: package: runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 90 + env: + CSOUND_WASM_PACKAGE: ${{ github.workspace }}/.csound/platform/wasm-wasi steps: - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: actions/setup-node@v7.1.0 with: - node-version: 22 + node-version: 24 cache: npm - uses: cachix/install-nix-action@v31.11.1 - uses: cachix/cachix-action@v17 @@ -32,6 +34,16 @@ jobs: name: csound authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} skipPush: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} + # Depends on csound/csound#3556. Replace this checkout with published + # package pins once the matching browser and wasm-bin releases exist. + - uses: actions/checkout@v7.0.1 + with: + repository: hlolli/csound + ref: 623c6e8fa1f76ed02fc74dfe178061e7b6ef7aec + path: .csound + persist-credentials: false + - name: Build the upstream Preview 2 engines and SDKs + run: bash wasm/scripts/prepare-csound.sh - name: Install JS dependencies run: npm ci - name: Build WASM plugins with CMake and Nix diff --git a/README.md b/README.md index 56559b5..be34be0 100644 --- a/README.md +++ b/README.md @@ -78,14 +78,16 @@ platform facilities the browser does not provide: fluidOpcodes (FluidSynth), hdf5, faust (libfaust), websockets, py (Python), wiimote, p5glove, CUDA and OpenCL. -The `@csound/plugins` package exposes one asset URL per WASM plugin: +The default entry and `@csound/plugins/wasm32` expose wasm32 float plugins. +Use `@csound/plugins/wasm64` with the opt-in `@csound/browser/wasm64.js` engine. +Each entry exports one `plugins` object with these URLs: -| Export | Plugin | File | +| Export | wasm32 file | wasm64 file | | --- | --- | --- | -| `plugins.chua` | chua | `chua.wasm` | -| `plugins.image` | image | `image.wasm` | -| `plugins.linearAlgebra` | linear_algebra | `linear_algebra.wasm` | -| `plugins.stk` | STK | `stkops.wasm` | +| `plugins.chua` | `chua.wasm` | `chua-wasm64.wasm` | +| `plugins.image` | `image.wasm` | `image-wasm64.wasm` | +| `plugins.linearAlgebra` | `linear_algebra.wasm` | `linear_algebra-wasm64.wasm` | +| `plugins.stk` | `stkops.wasm` | `stkops-wasm64.wasm` | The URLs are passed to the runtime's `withPlugins` option, e.g.: @@ -97,7 +99,7 @@ const csound = await Csound({ withPlugins: [plugins.chua, plugins.image] }); ``` Unlike the native build, the WASM STK plugin embeds its raw-wave samples in -`stkops.wasm` (`STK_EMBED_RAWWAVES` defaults to ON for WASM): at initialization +each target's `stkops` binary (`STK_EMBED_RAWWAVES` defaults to ON for WASM): at initialization it writes them to Csound's virtual filesystem and sets the wave path, so no separate download or `RAWWAVE_PATH` setup is needed. An explicit `RAWWAVE_PATH` still overrides the embedded samples. diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index 7c3f670..905f3c2 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -6,17 +6,20 @@ The package also contains code from these libraries: | Library | Used by | License notices | | --- | --- | --- | -| Eigen | Chua | `dist/licenses/eigen/` | -| GMM++ (GetFEM) | Linear algebra | `dist/licenses/gmm.txt`; LGPL-3.0-or-later with the exception in the headers | -| libpng | Image | `dist/licenses/libpng.txt` | -| zlib | Image | `dist/licenses/zlib.txt` | -| STK and its raw-wave samples | STK instruments | `dist/licenses/stk.txt` | -| wasi-libc, musl, cloudlibc, musl-fts | C runtime | `dist/licenses/wasi-libc/` | -| LLVM libc++, libc++abi, libunwind, compiler-rt | C++ runtime and compiler support | `dist/licenses/libcxx.txt`, `libcxxabi.txt`, `libunwind.txt`, `compiler-rt.txt` | +| Eigen | Chua | `dist/licenses/TARGET/eigen/` | +| GMM++ (GetFEM) | Linear algebra | `dist/licenses/TARGET/gmm.txt`; LGPL-3.0-or-later with the exception in the headers | +| libpng | Image | `dist/licenses/TARGET/libpng.txt` | +| zlib | Image | `dist/licenses/TARGET/zlib.txt` | +| STK and its raw-wave samples | STK instruments | `dist/licenses/TARGET/stk.txt` | +| wasi-libc, musl, cloudlibc, musl-fts | C runtime | `dist/licenses/TARGET/wasi-libc/` | +| LLVM libc++, libc++abi, libunwind, compiler-rt | C++ runtime and compiler support | `dist/licenses/TARGET/libcxx.txt`, `libcxxabi.txt`, `libunwind.txt`, `compiler-rt.txt` | The Nix build copies these notices from the source used to build each library. +`TARGET` is `wasm32` or `wasm64`. [`wasm/nixpkgs.nix`](https://github.com/csound/plugins/blob/develop/wasm/nixpkgs.nix) pins their source versions. The GMM++ headers and STK sources and samples live -in this repository. The build uses upstream CMake and make options for static -PIC libraries, plus a small CMake target for LLVM's WASM unwinder; it adds no -source patches. `wasm/default.nix` records those options. +in this repository. The build uses static PIC libraries and a small CMake target +for LLVM's WASM unwinder. It patches wasi-libc's CMake file to omit allocator +objects and map paths into each plugin's own descriptor table. It also uses the +upstream Csound toolchain's wasm64 libc and compiler-rt +patches. `wasm/default.nix` and the selected Csound toolchain record those changes. diff --git a/cmake/WasmPlugins.cmake b/cmake/WasmPlugins.cmake index 1d183cf..543ff66 100644 --- a/cmake/WasmPlugins.cmake +++ b/cmake/WasmPlugins.cmake @@ -2,10 +2,6 @@ set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) set(USE_COMPILER_OPTIMIZATIONS OFF CACHE BOOL "" FORCE) set(REQUIRE_PTHREADS OFF CACHE BOOL "" FORCE) -set(USE_DOUBLE ON CACHE BOOL "" FORCE) -if(NOT WASM_MAX_MEMORY) - message(FATAL_ERROR "WASM_MAX_MEMORY must match the shared-memory Csound engine") -endif() execute_process(COMMAND ${CMAKE_C_COMPILER} --print-libgcc-file-name OUTPUT_VARIABLE WASM_BUILTINS OUTPUT_STRIP_TRAILING_WHITESPACE COMMAND_ERROR_IS_FATAL ANY) @@ -14,9 +10,10 @@ function(configure_wasm_plugin target) set_target_properties(${target} PROPERTIES SUFFIX ".wasm") target_compile_features(${target} PRIVATE c_std_11) target_compile_definitions(${target} PRIVATE - USE_DOUBLE=1 _WASI_EMULATED_SIGNAL _WASI_EMULATED_MMAN) + _WASI_EMULATED_SIGNAL _WASI_EMULATED_MMAN) + # The matching SDK float-version.h defines sample precision. target_compile_options(${target} PRIVATE - -fPIC -pthread "SHELL:-mllvm -wasm-enable-sjlj" + -fPIC "SHELL:-mllvm -wasm-enable-sjlj" "SHELL:-mllvm -wasm-use-legacy-eh=false" "$<$:-std=gnu11>" "$<$:-nostdinc++>" @@ -34,12 +31,7 @@ function(configure_wasm_plugin target) "LINKER:--no-entry" "LINKER:--import-table" "LINKER:--import-memory" - "LINKER:--shared-memory" - "LINKER:--max-memory=${WASM_MAX_MEMORY}" "LINKER:--allow-undefined" - "LINKER:--export=__wasm_init_tls" - "LINKER:--export=__tls_size" - "LINKER:--export=__tls_align" "LINKER:--export=__wasm_call_ctors" "LINKER:--export-if-defined=__wasm_apply_data_relocs" "LINKER:--export-if-defined=csound_opcode_init" diff --git a/index.js b/index.js index d8963c5..50f234f 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,2 @@ -/** Asset URLs for Csound 7's `withPlugins` option. */ -export const plugins = Object.freeze({ - chua: new URL("./dist/chua.wasm", import.meta.url).href, - image: new URL("./dist/image.wasm", import.meta.url).href, - linearAlgebra: new URL("./dist/linear_algebra.wasm", import.meta.url).href, - stk: new URL("./dist/stkops.wasm", import.meta.url).href, -}); +/** The default browser engine uses wasm32 with float samples. */ +export { plugins } from "./wasm32.js"; diff --git a/package-lock.json b/package-lock.json index 84d0b48..147fa26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@csound/wasm-bin": "7.0.0-beta31" }, "engines": { - "node": ">=22.13.0" + "node": ">=24" } }, "node_modules/@babel/runtime": { diff --git a/package.json b/package.json index 168a67c..1ba24ff 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "access": "public" }, "engines": { - "node": ">=22.13.0" + "node": ">=24" }, "files": [ "dist", @@ -24,7 +24,9 @@ "index.d.ts", "LICENSE", "THIRD_PARTY.md", - "wasm/README.md" + "wasm/README.md", + "wasm32.js", + "wasm64.js" ], "exports": { ".": { @@ -32,11 +34,19 @@ "import": "./index.js" }, "./dist/*": "./dist/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./wasm32": { + "types": "./index.d.ts", + "import": "./wasm32.js" + }, + "./wasm64": { + "types": "./index.d.ts", + "import": "./wasm64.js" + } }, "scripts": { "build": "bash wasm/scripts/build.sh", - "test": "node --test wasm/tests/*.test.js", + "test": "node --experimental-wasm-exnref --test wasm/tests/*.test.js", "prepack": "npm test" }, "devDependencies": { diff --git a/src/linear_algebra/linear_algebra.cpp b/src/linear_algebra/linear_algebra.cpp index ac6eaea..f0a6f52 100644 --- a/src/linear_algebra/linear_algebra.cpp +++ b/src/linear_algebra/linear_algebra.cpp @@ -373,13 +373,13 @@ extern "C" { using namespace csound; -static std::ostream &operator<<(std::ostream &o, const std::vector &m) { +static std::ostream &operator<<(std::ostream &o, const std::vector &m) { gmm::write(o, m); return o; } static std::ostream &operator<<(std::ostream &o, - const std::vector> &m) { + const std::vector> &m) { gmm::write(o, m); return o; } @@ -3783,7 +3783,7 @@ class la_i_qr_eigen_mr_t : public OpcodeBase { toa(lhs_eigenvalues_, lhs_eigenvalues); toa(rhs_A_, rhs_A); gmm::implicit_qr_algorithm(rhs_A->mr, lhs_eigenvalues->vr, - double(*itolerance)); + *itolerance); return OK; } }; @@ -3802,7 +3802,7 @@ class la_k_qr_eigen_mr_t : public OpcodeBase { } int32_t kontrol(CSOUND *) { gmm::implicit_qr_algorithm(rhs_A->mr, lhs_eigenvalues->vr, - double(*ktolerance)); + *ktolerance); return OK; } }; @@ -3818,7 +3818,7 @@ class la_i_qr_eigen_mc_t : public OpcodeBase { toa(lhs_eigenvalues_, lhs_eigenvalues); toa(rhs_A_, rhs_A); gmm::implicit_qr_algorithm(rhs_A->mc, lhs_eigenvalues->vc, - double(*itolerance)); + *itolerance); return OK; } }; @@ -3837,7 +3837,7 @@ class la_k_qr_eigen_mc_t : public OpcodeBase { } int32_t kontrol(CSOUND *) { gmm::implicit_qr_algorithm(rhs_A->mc, lhs_eigenvalues->vc, - double(*ktolerance)); + *ktolerance); return OK; } }; @@ -3856,7 +3856,7 @@ class la_i_qr_sym_eigen_mr_t : public OpcodeBase { toa(lhs_eigenvectors_, lhs_eigenvectors); toa(rhs_A_, rhs_A); gmm::implicit_qr_algorithm(rhs_A->mr, lhs_eigenvalues->vr, - lhs_eigenvectors->mr, double(*itolerance)); + lhs_eigenvectors->mr, *itolerance); return OK; } }; @@ -3878,7 +3878,7 @@ class la_k_qr_sym_eigen_mr_t : public OpcodeBase { } int32_t kontrol(CSOUND *) { gmm::implicit_qr_algorithm(rhs_A->mr, lhs_eigenvalues->vr, - lhs_eigenvectors->mr, double(*ktolerance)); + lhs_eigenvectors->mr, *ktolerance); return OK; } }; @@ -3897,7 +3897,7 @@ class la_i_qr_sym_eigen_mc_t : public OpcodeBase { toa(lhs_eigenvectors_, lhs_eigenvectors); toa(rhs_A_, rhs_A); gmm::implicit_qr_algorithm(rhs_A->mc, lhs_eigenvalues->vc, - lhs_eigenvectors->mc, double(*itolerance)); + lhs_eigenvectors->mc, *itolerance); return OK; } }; @@ -3919,7 +3919,7 @@ class la_k_qr_sym_eigen_mc_t : public OpcodeBase { } int32_t kontrol(CSOUND *) { gmm::implicit_qr_algorithm(rhs_A->mc, lhs_eigenvalues->vc, - lhs_eigenvectors->mc, double(*ktolerance)); + lhs_eigenvectors->mc, *ktolerance); return OK; } }; diff --git a/wasm/README.md b/wasm/README.md index 3ad2d0b..ea324c0 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -1,6 +1,14 @@ # @csound/plugins -WASM opcode plugins for the double-precision Csound 7 browser engine. +WASI Preview 2 opcode plugins for Csound 7. + +| Entry point | Target | Samples | Assets | +| --- | --- | --- | --- | +| `@csound/plugins/wasm32` | wasm32-wasip2 | float | `chua.wasm`, etc. | +| `@csound/plugins/wasm64` | wasm64-wasip2 | double | `chua-wasm64.wasm`, etc. | + +`@csound/plugins` selects wasm32 float, matching the default browser engine. +Each entry exports one `plugins` object. Both builds use the same plugin sources. This package includes Chua (`chuap`), image, linear algebra, and STK. This build disables FluidSynth, HDF5, Python, WebSockets, device, and GPU plugins. @@ -8,16 +16,19 @@ Csound 6 is not supported. ## Runtime dependency -Builds use the shared-memory plugin SDK and toolchain from `@csound/wasm-bin`. -Tests use the published `@csound/browser` runtime. [package.json](../package.json) and -[package-lock.json](../package-lock.json) record the versions used together. -Rebuild after updating these dependencies. Plugins built for the old, -non-shared engine cannot load in the shared-memory runtime. +This change depends on [csound/csound#3556](https://github.com/csound/csound/pull/3556). +The npm versions in `package.json` predate that change. Until matching releases +exist, use its built source directory as described below. CI pins the upstream +commit and builds its engines and SDKs from source. + +A plugin's pointer width, sample types, and memory mode must match the engine. +The old wasm32 double/shared-memory plugins cannot load in either new engine. +The SDK's generated `float-version.h` sets precision; do not pass +`-DUSE_DOUBLE=0` to the compiler, since the headers check whether it is defined. ## Use -Use a compatible `@csound/browser` release, such as the version pinned in -[package.json](../package.json): +Use the browser built from the upstream PR, or a later matching release: ```js import { Csound } from "@csound/browser"; @@ -26,7 +37,14 @@ import { plugins } from "@csound/plugins"; const csound = await Csound({ withPlugins: [plugins.chua, plugins.image] }); ``` -Use `plugins.stk` to load the STK instruments. Its single `stkops.wasm` file +To opt into wasm64 with double samples, change both imports: + +```js +import { Csound } from "@csound/browser/wasm64.js"; +import { plugins } from "@csound/plugins/wasm64"; +``` + +Use `plugins.stk` to load the STK instruments. Each target's `stkops` WASM file contains all bundled raw-wave samples. At initialization, the plugin writes them to Csound's virtual filesystem and sets its wave path. No extra downloads or `RAWWAVE_PATH` setup are needed. An explicit `RAWWAVE_PATH` overrides the @@ -63,24 +81,51 @@ npm test npm pack ``` -Nix pins the toolchain and builds the CMake targets in the repository root with Ninja. -Dependencies use threaded static PIC builds. Each plugin exports its TLS size, -alignment, and initializer so the engine can set up thread-local storage. +Nix pins the toolchain and builds both CMake targets with Ninja. +Dependencies use static PIC builds for the matching pointer width. The engines +use unshared WASM memory; Preview 2 has no pthread spawning ABI. Web Audio's +shared transport buffers do not change the plugin memory type. The C runtime imports the engine's allocator; it does not link its own heap. STK uses a separate libc++ build with C++ exceptions and a static WASM unwinder. Other C++ plugins keep exceptions disabled. CMake's `STK_EMBED_RAWWAVES` option embeds the samples and defaults to on for WASM. Native builds keep using the installed raw-wave directory by default. -To use another Csound 7 SDK, set `CSOUND_PLUGIN_SDK` to an absolute archive path. +The build uses `csound-plugin-sdk.tar.gz` for wasm32 and +`csound-wasm64-plugin-sdk.tar.gz` for wasm64. To override them, set +`CSOUND_PLUGIN_SDK` and `CSOUND_WASM64_PLUGIN_SDK` to absolute archive paths. +Set `CSOUND_WASM_PACKAGE` to the matching `@csound/wasm-bin` directory to also +override its toolchain and ABI metadata tools. Set `NIX_SYSTEM=x86_64-linux` when using a Linux remote builder from macOS. -Tests load all plugins together, render Chua and STK, compute a vector norm, and +For each target, tests load all plugins together, render Chua and STK, compute a vector norm, and save and reload a PNG through the WASI filesystem. STK tests also cover loading it alone, overriding its wave path, and catching a missing-sample error. Packing runs the tests and includes the WASM files and their license notices. -For work on the browser loader, -`CSOUND_BROWSER_URL` can point to an absolute file URL of a local browser bundle; -leave it unset to test the published package as CI does. +Each wasm64 plugin embeds its own Preview 2 adapter, so its WASI imports need +not be present in the engine. The private libc maps engine paths to its own +file descriptors while following the engine's working directory. +Tests use the upstream browser loader with each engine and check that it rejects +the other pointer width. `CSOUND_BROWSER_SOURCE` can select another browser +source directory; it defaults to `$CSOUND_WASM_PACKAGE/browser` for a source +checkout, or the installed `@csound/browser` package. + +### Test against the upstream PR + +Check out the commit pinned in the WASM workflow, then run from this repository: + +```sh +export CSOUND_WASM_PACKAGE=/absolute/path/to/csound/platform/wasm-wasi +bash wasm/scripts/prepare-csound.sh +npm ci +npm run build +npm test +npm pack +``` + +The prepare step builds the two engines and SDK archives with Nix. It does not +download a WASI SDK bundle. Skip it when those artifacts already exist. +Before releasing, update the npm pins to versions containing #3556, remove the +source checkout from CI, and verify with the source overrides unset. ## Updating dependencies @@ -101,7 +146,7 @@ GitHub Actions caches. Native and npm packages share the version in `package.json`; see the [release process](../README.md#releasing) for version changes and Git tags. -Run the build, test, and pack commands against the published browser package -with `CSOUND_BROWSER_URL` unset. Publish a prerelease with +Run the build, test, and pack commands against matching published packages +with the source overrides unset. Publish a prerelease with `npm publish --tag beta --access public`, or a stable release with `npm publish --access public`, from that build. No workflow publishes to npm. diff --git a/wasm/default.nix b/wasm/default.nix index 7d1b3ec..0f80742 100644 --- a/wasm/default.nix +++ b/wasm/default.nix @@ -1,17 +1,36 @@ { system ? builtins.currentSystem , pkgs ? import (import ./nixpkgs.nix) { inherit system; } +, wasm64 ? false +, csoundWasmPackage ? null , csoundSdkArchive ? null }: let lock = builtins.fromJSON (builtins.readFile ../package-lock.json); wasmBin = lock.packages."node_modules/@csound/wasm-bin"; - wasmPackage = pkgs.runCommand "csound-wasm-bin-${wasmBin.version}" {} '' + publishedPackage = pkgs.runCommand "csound-wasm-bin-${wasmBin.version}" {} '' mkdir -p $out tar -xzf ${pkgs.fetchurl { url = wasmBin.resolved; hash = wasmBin.integrity; }} --strip-components=1 -C $out ''; - # Use the same threaded WASI toolchain and memory limit as the engine. - toolchain = import "${wasmPackage}/src/wasi-realtime-toolchain.nix" { inherit pkgs; }; - maxMemory = builtins.fromJSON (builtins.readFile "${wasmPackage}/src/shared-memory-max.txt"); + wasmPackage = if csoundWasmPackage == null then publishedPackage else + pkgs.lib.cleanSourceWith { + src = builtins.toPath csoundWasmPackage; + filter = path: type: + let + root = toString (builtins.toPath csoundWasmPackage); + relative = pkgs.lib.removePrefix (root + "/") path; + in path == root || builtins.elem relative [ "src" "scripts" "scripts/wasm64" "lib" ] + || pkgs.lib.hasPrefix "src/" relative + || (pkgs.lib.hasPrefix "scripts/wasm64/" relative && pkgs.lib.hasSuffix ".py" relative) + || builtins.elem relative [ + "lib/csound-plugin-sdk.tar.gz" "lib/csound-wasm64-plugin-sdk.tar.gz" + ]; + }; + toolchainFile = if wasm64 then "wasi64-toolchain.nix" else "wasi-preview2-toolchain.nix"; + toolchain = if builtins.pathExists "${wasmPackage}/src/${toolchainFile}" + then import "${wasmPackage}/src/${toolchainFile}" { inherit system pkgs; } + else throw "Preview 2 plugins require csound/csound#3556; set CSOUND_WASM_PACKAGE to its built platform/wasm-wasi directory until a matching @csound/wasm-bin is published."; + inherit (toolchain) pkgsWasm; + suffix = pkgs.lib.optionalString wasm64 "-wasm64"; wasi = toolchain.stdenv; # Nix's WASI wrapper otherwise forces -fno-exceptions after CMake's flags. # Let each target select its exception mode instead. @@ -21,23 +40,31 @@ let }; })); libc = toolchain.libc.overrideAttrs (old: { + nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.python3 ]; NIX_CFLAGS_COMPILE = (old.NIX_CFLAGS_COMPILE or []) ++ [ "-fPIC" "-mllvm" "-wasm-enable-sjlj" "-mllvm" "-wasm-use-legacy-eh=false" ]; - # Use the engine's allocator. wasi-libc also skips its fixed symbol list - # for this mode; PIC adds imports absent from that non-PIC list. - makeFlags = (old.makeFlags or []) ++ [ "MALLOC_IMPL=none" ]; + # Use the engine's allocator. wasi-libc 32's CMake build still references + # empty malloc targets in MALLOC=none mode, so omit those object lists. + postPatch = (old.postPatch or "") + '' + python3 ${./scripts/patch-libc.py} + substituteInPlace CMakeLists.txt \ + --replace-fail '$' "" \ + --replace-fail '$' "" + ''; + cmakeFlags = old.cmakeFlags ++ [ "-DMALLOC=none" "-DCMAKE_POSITION_INDEPENDENT_CODE=ON" ]; preFixup = ""; dontStrip = true; }); - libcxx = toolchain.libcxx.overrideAttrs (old: { + libcxx = (pkgsWasm.llvmPackages.libcxx.override { stdenv = wasiCxx; }).overrideAttrs (old: { cmakeFlags = old.cmakeFlags ++ [ "-DCMAKE_POSITION_INDEPENDENT_CODE=ON" "-DLIBCXX_ENABLE_FILESYSTEM=ON" + "-DLIBCXX_ADDITIONAL_LIBRARIES=" ]; dontStrip = true; }); - libunwindSource = pkgs.pkgsCross.wasi32.llvmPackages.libunwind.src; + libunwindSource = pkgsWasm.llvmPackages.libunwind.src; libunwind = wasi.mkDerivation { pname = "libunwind-wasm-pic"; inherit (libcxx) version; @@ -64,7 +91,7 @@ let sdk = pkgs.runCommand "csound-wasm-plugin-sdk-${wasmBin.version}" {} '' mkdir -p $out ${if csoundSdkArchive == null then '' - tar -xzf ${wasmPackage}/lib/csound-plugin-sdk.tar.gz --strip-components=1 -C $out + tar -xzf ${wasmPackage}/lib/csound${suffix}-plugin-sdk.tar.gz --strip-components=1 -C $out '' else '' tar -xzf ${sdkArchive} --strip-components=1 -C $out ''} @@ -74,12 +101,12 @@ let inherit (pkgs.zlib) version src; nativeBuildInputs = [ pkgs.cmake pkgs.ninja ]; cmakeFlags = [ "-DCMAKE_POSITION_INDEPENDENT_CODE=ON" "-DCMAKE_INSTALL_LIBDIR=lib" "-DZLIB_BUILD_EXAMPLES=OFF" ]; - # zlib 1.3.1 has no CMake switch to omit its shared library. Build only + # Build only # the static target so it does not link the toolchain's non-PIC libc. ninjaFlags = [ "zlibstatic" ]; installPhase = '' runHook preInstall - install -Dm644 libzlibstatic.a $out/lib/libzlibstatic.a + install -Dm644 libz.a $out/lib/libz.a install -Dm644 ../zlib.h $out/include/zlib.h install -Dm644 zconf.h $out/include/zconf.h runHook postInstall @@ -96,13 +123,13 @@ let "-DCMAKE_POSITION_INDEPENDENT_CODE=ON" "-DCMAKE_INSTALL_LIBDIR=lib" "-DPNG_SHARED=OFF" "-DPNG_STATIC=ON" "-DPNG_TESTS=OFF" "-DPNG_TOOLS=OFF" - "-DZLIB_LIBRARY=${zlib}/lib/libzlibstatic.a" + "-DZLIB_LIBRARY=${zlib}/lib/libz.a" ]; NIX_CFLAGS_COMPILE = [ "-mllvm" "-wasm-enable-sjlj" "-mllvm" "-wasm-use-legacy-eh=false" ]; dontStrip = true; }; in wasiCxx.mkDerivation { - pname = "csound-plugins-wasm"; + pname = "csound-plugins-${if wasm64 then "wasm64" else "wasm32"}-preview2"; version = (builtins.fromJSON (builtins.readFile ../package.json)).version; src = pkgs.lib.sourceByRegex ../. [ "CMakeLists\\.txt" @@ -113,23 +140,23 @@ in wasiCxx.mkDerivation { "tests" "tests/.*" ]; - nativeBuildInputs = [ pkgs.cmake pkgs.ninja ]; + nativeBuildInputs = [ pkgs.cmake pkgs.ninja pkgs.python3 pkgs.wasm-tools ]; buildInputs = [ libpng zlib ]; dontStrip = true; cmakeFlags = [ "-DCMAKE_POLICY_VERSION_MINIMUM=3.5" "-DCMAKE_BUILD_TYPE=Release" "-DCSOUND_INCLUDE_DIR=${sdk}/include/csound" - "-DWASM_MAX_MEMORY=${toString maxMemory}" - "-DWASM_LIBC=${libc}/lib/libc.a" - "-DWASM_SETJMP=${libc}/lib/libsetjmp.a" + "-DUSE_DOUBLE=${if wasm64 then "ON" else "OFF"}" + "-DWASM_LIBC=${libc}/lib/${toolchain.target}/libc.a" + "-DWASM_SETJMP=${libc}/lib/${toolchain.target}/libsetjmp.a" "-DWASM_LIBCXX=${libcxx}/lib/libc++.a" "-DWASM_CXX_INCLUDE_DIR=${libcxx.dev}/include/c++/v1" "-DWASM_LIBCXX_EXCEPTIONS=${libcxxExceptions}/lib/libc++.a" "-DWASM_CXX_EXCEPTIONS_INCLUDE_DIR=${libcxxExceptions.dev}/include/c++/v1" "-DWASM_LIBUNWIND=${libunwind}/lib/libunwind.a" "-DEIGEN3_INCLUDE_DIR=${pkgs.eigen}/include/eigen3" - "-DZLIB_LIBRARY=${zlib}/lib/libzlibstatic.a" + "-DZLIB_LIBRARY=${zlib}/lib/libz.a" "-DPNG_LIBRARY=${libpng}/lib/libpng.a" "-DBUILD_CHUA_OPCODES=ON" "-DBUILD_IMAGE_OPCODES=ON" @@ -154,13 +181,17 @@ in wasiCxx.mkDerivation { cp ${libcxx.src}/libcxxabi/LICENSE.TXT $out/share/licenses/libcxxabi.txt cp ${libunwindSource}/libunwind/LICENSE.TXT $out/share/licenses/libunwind.txt cp ${../src/stk/LICENSE} $out/share/licenses/stk.txt - cp ${pkgs.pkgsCross.wasi32.llvmPackages.compiler-rt.src}/compiler-rt/LICENSE.TXT $out/share/licenses/compiler-rt.txt + cp ${pkgsWasm.llvmPackages.compiler-rt.src}/compiler-rt/LICENSE.TXT $out/share/licenses/compiler-rt.txt cp ${pkgs.eigen.src}/COPYING* $out/share/licenses/eigen/ sed -n '1,/^=*[*][/]$/p' ${../src/linear_algebra/gmm/gmm.h} > $out/share/licenses/gmm.txt tar -xOf ${pkgs.libpng.src} libpng-${pkgs.libpng.version}/LICENSE > $out/share/licenses/libpng.txt tar -xOf ${pkgs.zlib.src} zlib-${pkgs.zlib.version}/LICENSE > $out/share/licenses/zlib.txt for plugin in chua image linear_algebra stkops; do test -s "$out/lib/$plugin.wasm" + ${pkgs.lib.optionalString wasm64 '' + python3 ${./scripts/write-abi.py} ${wasmPackage}/scripts/wasm64 "$out/lib/$plugin.wasm" "$out/lib/$plugin-wasm64.wasm" + rm "$out/lib/$plugin.wasm" + ''} done ''; } diff --git a/wasm/nixpkgs.nix b/wasm/nixpkgs.nix index 89b6f90..03b6b70 100644 --- a/wasm/nixpkgs.nix +++ b/wasm/nixpkgs.nix @@ -1,5 +1,5 @@ -# Keep in sync with csound/csound's platform/wasm-wasi/scripts/nixpkgs-pin.sh. +# Match csound/csound#3556 (LLVM 21 and wasi-libc 32). builtins.fetchTarball { - url = "https://github.com/NixOS/nixpkgs/archive/4db2c220f32fd162658ed1b7bb2f46a82996ddbe.tar.gz"; - sha256 = "08b1k0zrbal2if94bph2ap9f73zlplsh05a65jbdkp5ipycmg92s"; + url = "https://github.com/NixOS/nixpkgs/archive/d3b13ad75cc492690728f9307e443dec9ef9a20e.tar.gz"; + sha256 = "05r2yhqg472368kf2i0mqrvbfww94ykssphkxdxxgjxwd1jffqx4"; } diff --git a/wasm/scripts/build.sh b/wasm/scripts/build.sh index 87afa3c..c0ad04e 100644 --- a/wasm/scripts/build.sh +++ b/wasm/scripts/build.sh @@ -2,20 +2,35 @@ set -euo pipefail cd "$(dirname "$0")/../.." -args=(--show-trace -o result-wasm) +args=(--show-trace) if [[ -n "${NIX_SYSTEM:-}" ]]; then args+=(--argstr system "$NIX_SYSTEM") fi -if [[ -n "${CSOUND_PLUGIN_SDK:-}" ]]; then - args+=(--argstr csoundSdkArchive "$CSOUND_PLUGIN_SDK") +if [[ -n "${CSOUND_WASM_PACKAGE:-}" ]]; then + args+=(--argstr csoundWasmPackage "$CSOUND_WASM_PACKAGE") fi -nix-build wasm/default.nix "${args[@]}" staging=$(mktemp -d .wasm-dist.XXXXXX) trap 'rm -rf "$staging"' EXIT -for plugin in chua image linear_algebra stkops; do - install -m 644 "result-wasm/lib/$plugin.wasm" "$staging/$plugin.wasm" +for target in wasm32 wasm64; do + wide=false + suffix="" + sdk="${CSOUND_PLUGIN_SDK:-}" + if [[ "$target" == wasm64 ]]; then + wide=true + suffix="-wasm64" + sdk="${CSOUND_WASM64_PLUGIN_SDK:-}" + fi + target_args=("${args[@]}" --arg wasm64 "$wide" -o "result-$target") + if [[ -n "$sdk" ]]; then + target_args+=(--argstr csoundSdkArchive "$sdk") + fi + nix-build wasm/default.nix "${target_args[@]}" + for plugin in chua image linear_algebra stkops; do + install -m 644 "result-$target/lib/$plugin$suffix.wasm" "$staging/$plugin$suffix.wasm" + done + mkdir -p "$staging/licenses/$target" + cp -R "result-$target/share/licenses/." "$staging/licenses/$target/" done -cp -R result-wasm/share/licenses "$staging/licenses" chmod -R u+w "$staging/licenses" if [[ -d dist ]]; then chmod -R u+w dist diff --git a/wasm/scripts/patch-libc.py b/wasm/scripts/patch-libc.py new file mode 100644 index 0000000..e8215fd --- /dev/null +++ b/wasm/scripts/patch-libc.py @@ -0,0 +1,30 @@ +"""Keep Preview 2 file descriptors inside the plugin's private libc.""" +from pathlib import Path + +header = Path("libc-bottom-half/headers/public/wasi/libc-find-relpath.h") +text = header.read_text() +marker = "#ifdef __cplusplus\n}\n#endif" +assert text.count(marker) == 1 +text = text.replace(marker, """ +/* Resolve the engine's cwd, then map its preopen prefix to our descriptor + * table. A Preview 2 libc fd is an index into that libc's private table; + * copying the engine's fd skips initialization and can select another file. */ +static inline int csound_plugin_find_relpath_alloc( + const char *path, const char **abs, char **relative, + size_t *relative_len, int can_realloc) { + if (__wasilibc_find_relpath_alloc(path, abs, relative, relative_len, + can_realloc) < 0) + return -1; + const char *local_prefix; + const char *local_relative; + return __wasilibc_find_abspath(*abs, &local_prefix, &local_relative); +} + +""" + marker) +header.write_text(text) +for name in ("posix.c", "preopens.c"): + path = Path("libc-bottom-half/sources") / name + text = path.read_text() + old = "return __wasilibc_find_relpath_alloc(" + assert text.count(old) == 1, name + path.write_text(text.replace(old, "return csound_plugin_find_relpath_alloc(")) diff --git a/wasm/scripts/prepare-csound.sh b/wasm/scripts/prepare-csound.sh new file mode 100644 index 0000000..9da97d9 --- /dev/null +++ b/wasm/scripts/prepare-csound.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Build only the two reactors and SDKs needed by this package. +set -euo pipefail +: "${CSOUND_WASM_PACKAGE:?Set CSOUND_WASM_PACKAGE to the platform/wasm-wasi directory in a Csound checkout}" +cd "$CSOUND_WASM_PACKAGE" +source scripts/nixpkgs-pin.sh +mkdir -p lib +for target in wasm32 wasm64; do + source=src/csound.nix + name=csound + if [[ "$target" == wasm64 ]]; then + source=src/csound-wasm64.nix + name=csound-wasm64 + fi + nix-build "$source" --argstr moduleKind browser -o "result-plugin-$target" + for file in "$name.wasm" "$name.wasm.z" "$name-plugin-sdk.tar.gz"; do + cp "result-plugin-$target/lib/$file" "lib/$file" + done +done diff --git a/wasm/scripts/write-abi.py b/wasm/scripts/write-abi.py new file mode 100644 index 0000000..81d3b56 --- /dev/null +++ b/wasm/scripts/write-abi.py @@ -0,0 +1,11 @@ +"""Give wasm64 plugins their ABI metadata and Preview 2 adapter.""" +import sys + +sys.path.insert(0, sys.argv[1]) +from wasm import VALUES +from bridge import build + +# LLVM's standardized exception runtime exposes exnref in its signatures. +# It stays an opaque reference; only i64 values need JS/BigInt conversion. +VALUES[0x69] = "exnref" +build(sys.argv[2], sys.argv[3], browser=True) diff --git a/wasm/tests/runtime.js b/wasm/tests/runtime.js new file mode 100644 index 0000000..d042702 --- /dev/null +++ b/wasm/tests/runtime.js @@ -0,0 +1,46 @@ +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +// Use the upstream loader with an explicit engine for each test target. +// Published browser entry points still select one engine per module. +globalThis.goog = { define: (_name, value) => value, global: globalThis }; +const wasmRoot = + process.env.CSOUND_WASM_PACKAGE || + dirname(fileURLToPath(import.meta.resolve("@csound/wasm-bin/package.json"))); +const browserRoot = + process.env.CSOUND_BROWSER_SOURCE || + (process.env.CSOUND_WASM_PACKAGE + ? resolve(wasmRoot, "browser") + : dirname(fileURLToPath(import.meta.resolve("@csound/browser/package.json")))); +const source = (name) => import(pathToFileURL(resolve(browserRoot, "src", name))); +const { default: loadWasm } = await source("module.js"); +const bindings = Object.assign( + {}, + ...(await Promise.all( + ["instantiation", "performance", "attributes", "control-events", "rtaudio"].map((name) => + source(`modules/${name}.js`), + ), + )), +); +const { default: filesystem } = await source("filesystem/worker-fs.js"); + +export async function runtime(target, withPlugins = []) { + const name = target === "wasm64" ? "csound-wasm64" : "csound"; + const [wasm, wasi] = await loadWasm({ + wasmDataURI: await readFile(resolve(wasmRoot, `lib/${name}.wasm.z`)), + withPlugins, + messagePort: { + post: (message) => { + if (message.log) console.log(message.log); + }, + }, + }); + wasm.wasi = wasi; + return { + ...Object.fromEntries( + Object.entries({ ...bindings, ...filesystem }).map(([name, bind]) => [name, bind(wasm)]), + ), + getMemory: () => wasm.exports.memory, + }; +} diff --git a/wasm/tests/runtime.test.js b/wasm/tests/runtime.test.js index 3b69d97..e521a98 100644 --- a/wasm/tests/runtime.test.js +++ b/wasm/tests/runtime.test.js @@ -1,16 +1,22 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { plugins } from "../../index.js"; +import { plugins as wasm32 } from "@csound/plugins/wasm32"; +import { plugins as wasm64 } from "@csound/plugins/wasm64"; +import { plugins as defaults } from "@csound/plugins"; +import { runtime } from "./runtime.js"; -// The in-process browser API needs these names, but no audio device or DOM. -globalThis.self = globalThis; -globalThis.window = globalThis; -const { libcsound } = await import(process.env.CSOUND_BROWSER_URL || "@csound/browser/dist/csound.js"); +test("the package default selects wasm32", () => assert.equal(defaults, wasm32)); -const binaries = await Promise.all(Object.values(plugins).map((url) => readFile(new URL(url)))); +for (const [target, plugins, Samples] of [ + ["wasm32", wasm32, Float32Array], + ["wasm64", wasm64, Float64Array], +]) { + const libcsound = ({ withPlugins = [] } = {}) => runtime(target, withPlugins); + const binaries = await Promise.all(Object.values(plugins).map((url) => readFile(new URL(url)))); + const targetTest = (name, run) => test(`${target}: ${name}`, run); -const document = (body) => ` + const document = (body) => ` -n -d -m0 @@ -29,137 +35,179 @@ e `; -/** - * Load plugin bytes into Csound 7 and render the given instrument body. - * Run prepare before compilation to set options or supply files. - * Call check with the API, Csound instance, and peak sample magnitude before - * destroying the instance. - */ -async function run(body, check, withPlugins = binaries, prepare = () => {}) { - const errors = []; - const originalError = console.error; - console.error = (...args) => errors.push(args.join(" ")); - let api; - try { - api = await libcsound({ withPlugins }); - } finally { - console.error = originalError; - } - assert.deepEqual(errors, [], "every plugin must load without missing imports"); - assert.ok(api.csoundGetVersion() >= 7000 && api.csoundGetVersion() < 8000); - assert.equal(api.csoundGetSizeOfMYFLT(), 8); - const csound = api.csoundCreate(); - try { - await prepare(api, csound); - assert.equal(api.csoundCompileCSD(csound, document(body)), 0); - assert.equal(api.csoundStart(csound), 0); - let peak = 0; - let blocks = 0; - while (api.csoundPerformKsmps(csound) === 0) { - const samples = new Float64Array(api.getMemory().buffer, api.csoundGetSpout(csound), 32); - for (const sample of samples) { - assert.ok(Number.isFinite(sample), "audio samples must be finite"); - peak = Math.max(peak, Math.abs(sample)); + /** + * Load plugin bytes into Csound 7 and render the given instrument body. + * Run prepare before compilation to set options or supply files. + * Call check with the API, Csound instance, and peak sample magnitude before + * destroying the instance. + */ + async function run(body, check, withPlugins = binaries, prepare = () => {}) { + const errors = []; + const originalError = console.error; + console.error = (...args) => errors.push(args.join(" ")); + let api; + try { + api = await libcsound({ withPlugins }); + } finally { + console.error = originalError; + } + assert.deepEqual(errors, [], "every plugin must load without missing imports"); + assert.ok(api.csoundGetVersion() >= 7000 && api.csoundGetVersion() < 8000); + assert.equal(api.csoundGetSizeOfMYFLT(), Samples.BYTES_PER_ELEMENT); + assert.equal(api.csoundGetSizeOfCsDouble(), Samples.BYTES_PER_ELEMENT); + const csound = api.csoundCreate(); + try { + await prepare(api, csound); + assert.equal(api.csoundCompileCSD(csound, document(body)), 0); + assert.equal(api.csoundStart(csound), 0); + let peak = 0; + let blocks = 0; + while (api.csoundPerformKsmps(csound) === 0) { + const samples = new Samples(api.getMemory().buffer, api.csoundGetSpout(csound), 32); + for (const sample of samples) { + assert.ok(Number.isFinite(sample), "audio samples must be finite"); + peak = Math.max(peak, Math.abs(sample)); + } + assert.ok(++blocks < 100, "score must finish"); } - assert.ok(++blocks < 100, "score must finish"); + assert.ok(blocks > 0); + await check(api, csound, peak); + } finally { + api.csoundDestroy(csound); } - assert.ok(blocks > 0); - await check(api, csound, peak); - } finally { - api.csoundDestroy(csound); } -} -test("all plugins are WASI side modules with the shared-memory TLS exports", async () => { - for (const bytes of binaries) { - const module = await WebAssembly.compile(bytes); - const imports = WebAssembly.Module.imports(module); - const exports = WebAssembly.Module.exports(module); - assert.ok(WebAssembly.Module.customSections(module, "dylink.0").length > 0); - assert.ok(imports.some(({ kind, name }) => kind === "memory" && name === "memory")); - assert.ok(imports.some(({ kind }) => kind === "table")); - assert.ok(exports.some(({ name }) => name === "__wasm_call_ctors")); - for (const [name, kind] of [ - ["__wasm_init_tls", "function"], - ["__tls_size", "global"], - ["__tls_align", "global"], - ]) { + targetTest("all plugins have side-module exports and the target ABI", async () => { + for (const bytes of binaries) { + const module = await WebAssembly.compile(bytes); + const imports = WebAssembly.Module.imports(module); + const exports = WebAssembly.Module.exports(module); + assert.ok(WebAssembly.Module.customSections(module, "dylink.0").length > 0); + assert.ok(imports.some(({ kind, name }) => kind === "memory" && name === "memory")); + assert.ok(imports.some(({ kind }) => kind === "table")); + assert.ok(exports.some(({ name }) => name === "__wasm_call_ctors")); + if (target === "wasm64") { + const [section] = WebAssembly.Module.customSections(module, "csound.abi"); + assert.ok(section, "wasm64 needs native signature and pointer metadata"); + assert.equal(JSON.parse(new TextDecoder().decode(section)).pointerSize, 8); + } assert.ok( - exports.some((entry) => entry.name === name && entry.kind === kind), - `${name} must be exported as a ${kind}`, + exports.some(({ name }) => ["csoundModuleInit", "csound_opcode_init"].includes(name)), ); + assert.ok(!exports.some(({ name }) => name === "_start")); } - assert.ok(exports.some(({ name }) => ["csoundModuleInit", "csound_opcode_init"].includes(name))); - assert.ok(!exports.some(({ name }) => name === "_start")); - } -}); + }); -test("chua produces finite, nonzero audio alongside the other plugins", async () => { - await run(` + targetTest("chua produces finite, nonzero audio alongside the other plugins", async () => { + await run( + ` a1, a2, a3 chuap 0.018, 0, 1e-7, 1/1500, -0.0008, -0.0005, 1, 1e-8, 0, 0, 0.1, 1e-5 out a3 * 0.1 - `, (_, __, peak) => assert.ok(peak > 0)); -}); + `, + (_, __, peak) => assert.ok(peak > 0), + ); + }); -for (const instrument of ["STKMandolin", "STKModalBar", "STKBeeThree", "STKDrummer"]) { - test(`${instrument} renders with embedded waves alongside the other plugins`, async () => { - await run(` + for (const instrument of ["STKMandolin", "STKModalBar", "STKBeeThree", "STKDrummer"]) { + targetTest( + `${instrument} renders with embedded waves alongside the other plugins`, + async () => { + await run( + ` a1 ${instrument} 220, 0.5 out a1 - `, (_, __, peak) => assert.ok(peak > 0)); - }); -} + `, + (_, __, peak) => assert.ok(peak > 0), + ); + }, + ); + } -test("STK loads on its own without a raw-wave path or extra files", async () => { - const stk = await readFile(new URL(plugins.stk)); - await run("a1 STKMandolin 220, 0.5\nout a1", (_, __, peak) => assert.ok(peak > 0), [stk]); -}); + targetTest("STK loads on its own without a raw-wave path or extra files", async () => { + const stk = await readFile(new URL(plugins.stk)); + await run("a1 STKMandolin 220, 0.5\nout a1", (_, __, peak) => assert.ok(peak > 0), [stk]); + }); -test("RAWWAVE_PATH uses caller-supplied samples without overwriting them", async () => { - const silentWave = new Uint8Array(512); - await run("a1 STKModalBar 220, 0.5\nout a1", async (api, csound, peak) => { - assert.equal(peak, 0); - assert.deepEqual(await api.readFile(csound, "/custom-waves/marmstk1.raw"), silentWave); - }, binaries, async (api, csound) => { - await api.writeFile(csound, "/custom-waves/marmstk1.raw", silentWave); - assert.equal(api.csoundSetOption(csound, "--env:RAWWAVE_PATH=/custom-waves"), 0); + targetTest("RAWWAVE_PATH uses caller-supplied samples without overwriting them", async () => { + const silentWave = new Uint8Array(512); + await run( + "a1 STKModalBar 220, 0.5\nout a1", + async (api, csound, peak) => { + assert.equal(peak, 0); + assert.deepEqual(await api.readFile(csound, "/custom-waves/marmstk1.raw"), silentWave); + }, + binaries, + async (api, csound) => { + await api.writeFile(csound, "/custom-waves/marmstk1.raw", silentWave); + assert.equal(api.csoundSetOption(csound, "--env:RAWWAVE_PATH=/custom-waves"), 0); + }, + ); }); -}); -test("STK reports a missing sample through Csound instead of throwing out of WASM", async () => { - const api = await libcsound({ withPlugins: binaries }); - const csound = api.csoundCreate(); - const messages = []; - const originalLog = console.log; - console.log = (...args) => messages.push(args.join(" ")); - try { - assert.equal(api.csoundSetOption(csound, "--env:RAWWAVE_PATH=/missing-stk-waves"), 0); - assert.equal(api.csoundCompileCSD(csound, document("a1 STKMandolin 220, 0.5\nout a1")), 0); - assert.equal(api.csoundStart(csound), 0); - let result = 0; - for (let block = 0; block < 100 && result === 0; block++) { - result = api.csoundPerformKsmps(csound); - } - assert.notEqual(result, 0, "the failed score must finish"); - assert.ok(messages.some((message) => message.includes("STK: FileRead::open")), messages.join("\n")); - } finally { - api.csoundDestroy(csound); - console.log = originalLog; - } -}); + targetTest( + "STK reports a missing sample through Csound instead of throwing out of WASM", + async () => { + const api = await libcsound({ withPlugins: binaries }); + const csound = api.csoundCreate(); + const messages = []; + const originalLog = console.log; + console.log = (...args) => messages.push(args.join(" ")); + try { + assert.equal(api.csoundSetOption(csound, "--env:RAWWAVE_PATH=/missing-stk-waves"), 0); + assert.equal(api.csoundCompileCSD(csound, document("a1 STKMandolin 220, 0.5\nout a1")), 0); + assert.equal(api.csoundStart(csound), 0); + let result = 0; + for (let block = 0; block < 100 && result === 0; block++) { + result = api.csoundPerformKsmps(csound); + } + assert.notEqual(result, 0, "the failed score must finish"); + assert.ok( + messages.some((message) => message.includes("STK: FileRead::open")), + messages.join("\n"), + ); + } finally { + api.csoundDestroy(csound); + console.log = originalLog; + } + }, + ); -test("linear algebra computes a vector norm", async () => { - await run(` + targetTest("linear algebra computes a vector norm", async () => { + await run( + ` iv la_i_vr_create 2 iv la_i_vr_set 0, 3 iv la_i_vr_set 1, 4 inorm la_i_norm_euclid_vr iv chnset inorm, "norm" - `, (api, csound) => assert.equal(api.csoundGetControlChannel(csound, "norm"), 5)); -}); + `, + (api, csound) => assert.equal(api.csoundGetControlChannel(csound, "norm"), 5), + ); + }); + + targetTest( + "linear algebra prints vectors and computes eigenvalues at the engine precision", + async () => { + await run( + ` + im la_i_mr_create 2, 2, 4 + iv la_i_vr_create 2 + iv la_i_qr_eigen_mr im, 0.00001 + la_i_print_vr iv + ic la_i_vc_create 2 + la_i_print_vc ic + inorm la_i_norm_euclid_vr iv + chnset inorm, "norm" + `, + (api, csound) => + assert.ok(Math.abs(api.csoundGetControlChannel(csound, "norm") - Math.sqrt(32)) < 0.0001), + ); + }, + ); -test("image writes and reads a PNG through the WASI filesystem", async () => { - await run(` + targetTest("image writes and reads a PNG through the WASI filesystem", async () => { + await run( + ` ih imagecreate 2, 3 imagesetpixel ih, 0, 0, 1, 0, 0 imagesave ih, "roundtrip.png" @@ -170,12 +218,40 @@ test("image writes and reads a PNG through the WASI filesystem", async () => { chnset ihgt, "height" chnset kred, "red" chnset kgreen, "green" - `, async (api, csound) => { - assert.equal(api.csoundGetControlChannel(csound, "width"), 2); - assert.equal(api.csoundGetControlChannel(csound, "height"), 3); - assert.equal(api.csoundGetControlChannel(csound, "red"), 1); - assert.equal(api.csoundGetControlChannel(csound, "green"), 0); - const bytes = await api.readFile(csound, "roundtrip.png"); - assert.deepEqual([...bytes.slice(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]); + `, + async (api, csound) => { + assert.equal(api.csoundGetControlChannel(csound, "width"), 2); + assert.equal(api.csoundGetControlChannel(csound, "height"), 3); + assert.equal(api.csoundGetControlChannel(csound, "red"), 1); + assert.equal(api.csoundGetControlChannel(csound, "green"), 0); + const bytes = await api.readFile(csound, "/project/roundtrip.png"); + assert.deepEqual([...bytes.slice(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]); + }, + binaries, + async (api, csound) => { + await api.mkdir(csound, "/project"); + assert.equal(api.chdir(csound, "/project"), 0); + }, + ); + }); + + targetTest("rejects a plugin built for the other pointer width", async () => { + const other = target === "wasm64" ? wasm32 : wasm64; + const errors = []; + const originalError = console.error; + console.error = (...args) => errors.push(args.join(" ")); + try { + const api = await libcsound({ + withPlugins: [await readFile(new URL(other.chua))], + }); + const csound = api.csoundCreate(); + api.csoundDestroy(csound); + } finally { + console.error = originalError; + } + assert.ok( + errors.some((error) => error.includes("Plugin pointer width does not match")), + errors.join("\n"), + ); }); -}); +} diff --git a/wasm32.js b/wasm32.js new file mode 100644 index 0000000..d8963c5 --- /dev/null +++ b/wasm32.js @@ -0,0 +1,7 @@ +/** Asset URLs for Csound 7's `withPlugins` option. */ +export const plugins = Object.freeze({ + chua: new URL("./dist/chua.wasm", import.meta.url).href, + image: new URL("./dist/image.wasm", import.meta.url).href, + linearAlgebra: new URL("./dist/linear_algebra.wasm", import.meta.url).href, + stk: new URL("./dist/stkops.wasm", import.meta.url).href, +}); diff --git a/wasm64.js b/wasm64.js new file mode 100644 index 0000000..20fca0a --- /dev/null +++ b/wasm64.js @@ -0,0 +1,7 @@ +/** Asset URLs for Csound 7's `withPlugins` option. */ +export const plugins = Object.freeze({ + chua: new URL("./dist/chua-wasm64.wasm", import.meta.url).href, + image: new URL("./dist/image-wasm64.wasm", import.meta.url).href, + linearAlgebra: new URL("./dist/linear_algebra-wasm64.wasm", import.meta.url).href, + stk: new URL("./dist/stkops-wasm64.wasm", import.meta.url).href, +});