diff --git a/7.x/cmake/WasmPlugins.cmake b/7.x/cmake/WasmPlugins.cmake index 280f685..541f6c4 100644 --- a/7.x/cmake/WasmPlugins.cmake +++ b/7.x/cmake/WasmPlugins.cmake @@ -15,8 +15,13 @@ function(configure_wasm_plugin target) target_compile_options(${target} PRIVATE -fPIC "SHELL:-mllvm -wasm-enable-sjlj" "$<$:-std=gnu11>" - "$<$:-fno-exceptions;-nostdinc++>" + "$<$:-nostdinc++>" "$<$:-isystem${WASM_CXX_INCLUDE_DIR}>") + if(WASM_CXX_EXCEPTIONS) + target_compile_options(${target} PRIVATE "$<$:-fwasm-exceptions>") + else() + target_compile_options(${target} PRIVATE "$<$:-fno-exceptions>") + endif() target_link_options(${target} PRIVATE -shared -nostdlib -nostartfiles "LINKER:-Bsymbolic" diff --git a/7.x/src/stk/CMakeLists.txt b/7.x/src/stk/CMakeLists.txt index caa1d4c..bd809aa 100644 --- a/7.x/src/stk/CMakeLists.txt +++ b/7.x/src/stk/CMakeLists.txt @@ -1,4 +1,5 @@ option(BUILD_STK_OPCODES "Build the stk opcodes" ON) +option(STK_EMBED_RAWWAVES "Embed STK's raw-wave files in the plugin" ${WASM}) # The STK sources are bundled under this directory (include/, src/ and # rawwaves/) so that a patched copy is always built, independent of any system @@ -8,20 +9,48 @@ if(BUILD_STK_OPCODES) file(GLOB stk_srcs "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp") list(APPEND stk_srcs "${CMAKE_CURRENT_SOURCE_DIR}/stkOpcodes.cpp") + if(WASM) + if(NOT WASM_LIBCXX_EXCEPTIONS OR NOT WASM_CXX_EXCEPTIONS_INCLUDE_DIR OR NOT WASM_LIBUNWIND) + message(FATAL_ERROR "STK needs the WASM C++ exception runtime and unwinder") + endif() + set(WASM_CXX_EXCEPTIONS ON) + set(WASM_LIBCXX "${WASM_LIBCXX_EXCEPTIONS}") + set(WASM_CXX_INCLUDE_DIR "${WASM_CXX_EXCEPTIONS_INCLUDE_DIR}") + endif() make_plugin(stkops "${stk_srcs}") + if(WASM) + target_link_libraries(stkops PRIVATE "${WASM_LIBUNWIND}") + endif() target_include_directories(stkops PRIVATE ${CSOUND_INCLUDE_DIRS} "${CMAKE_CURRENT_SOURCE_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}/src") target_compile_definitions(stkops PRIVATE BUILD_PLUGINS) + if(STK_EMBED_RAWWAVES) + file(GLOB rawwaves CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/rawwaves/*.raw") + set(embedded_waves "${CMAKE_CURRENT_BINARY_DIR}/stk_rawwaves.inc") + add_custom_command(OUTPUT "${embedded_waves}" + COMMAND ${CMAKE_COMMAND} + "-DRAWWAVE_DIR=${CMAKE_CURRENT_SOURCE_DIR}/rawwaves" + "-DOUTPUT_FILE=${embedded_waves}" + -P "${CMAKE_CURRENT_SOURCE_DIR}/EmbedRawwaves.cmake" + DEPENDS ${rawwaves} "${CMAKE_CURRENT_SOURCE_DIR}/EmbedRawwaves.cmake" + VERBATIM) + target_sources(stkops PRIVATE "${embedded_waves}") + target_include_directories(stkops PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") + target_compile_definitions(stkops PRIVATE STK_EMBED_RAWWAVES) + endif() + # Install the rawwaves used by the STK instruments and make them the # default raw wave path; the RAWWAVE_PATH environment variable overrides it. - install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/rawwaves" - DESTINATION "${PLUGIN_INSTALL_DIR}/stk") - if(PLUGIN_INSTALL_DIR) - target_compile_definitions(stkops PRIVATE - DEFAULT_RAWWAVE_PATH="${PLUGIN_INSTALL_DIR}/stk/rawwaves") + if(NOT STK_EMBED_RAWWAVES) + install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/rawwaves" + DESTINATION "${PLUGIN_INSTALL_DIR}/stk") + if(PLUGIN_INSTALL_DIR) + target_compile_definitions(stkops PRIVATE + DEFAULT_RAWWAVE_PATH="${PLUGIN_INSTALL_DIR}/stk/rawwaves") + endif() endif() message(STATUS "Building STK opcodes") diff --git a/7.x/src/stk/EmbedRawwaves.cmake b/7.x/src/stk/EmbedRawwaves.cmake new file mode 100644 index 0000000..78204d2 --- /dev/null +++ b/7.x/src/stk/EmbedRawwaves.cmake @@ -0,0 +1,21 @@ +# Generate byte arrays from the bundled files; no decoder is needed at runtime. +file(GLOB waves "${RAWWAVE_DIR}/*.raw") +list(SORT waves) +file(WRITE "${OUTPUT_FILE}" "// Generated by EmbedRawwaves.cmake.\n") +set(entries "") +foreach(wave IN LISTS waves) + get_filename_component(name "${wave}" NAME) + string(MAKE_C_IDENTIFIER "${name}" symbol) + file(READ "${wave}" bytes HEX) + string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," bytes "${bytes}") + file(APPEND "${OUTPUT_FILE}" "static const unsigned char ${symbol}[] = {${bytes}};\n") + string(APPEND entries " {\"${name}\", ${symbol}, sizeof(${symbol})},\n") +endforeach() +file(APPEND "${OUTPUT_FILE}" " +static const struct { + const char *name; + const unsigned char *bytes; + size_t size; +} stkRawwaves[] = { +${entries}}; +") diff --git a/7.x/src/stk/stkOpcodes.cpp b/7.x/src/stk/stkOpcodes.cpp index 0f721fd..d672079 100644 --- a/7.x/src/stk/stkOpcodes.cpp +++ b/7.x/src/stk/stkOpcodes.cpp @@ -25,9 +25,10 @@ * * The STK sources are bundled in this directory (include/, src/ and * rawwaves/) so that a patched copy is always used, independent of any system - * or package-manager STK installation. The rawwaves path defaults to the - * installed location (DEFAULT_RAWWAVE_PATH) and can be overridden with the - * RAWWAVE_PATH environment variable. + * or package-manager STK installation. STK_EMBED_RAWWAVES stages the bundled + * samples at module initialization; otherwise the rawwaves path defaults to + * the installed location (DEFAULT_RAWWAVE_PATH). The RAWWAVE_PATH environment + * variable overrides either default. * * All these opcodes are named "STK" + the STK classname, * e.g. "STKBowed" for the Bowed instrument. @@ -53,6 +54,16 @@ #include #include +#ifdef STK_EMBED_RAWWAVES +#include +#ifdef _WIN32 +#include +#else +#include +#endif +#include "stk_rawwaves.inc" +#endif + #include "BandedWG.h" #include "BeeThree.h" #include "BlowBotl.h" @@ -584,8 +595,45 @@ OENTRY oentries[] = { PUBLIC int32_t csoundModuleCreate(CSOUND *csound) { return 0; } +#ifdef STK_EMBED_RAWWAVES +// Keep STK's file reader unchanged by staging the embedded bytes at module init. +static bool writeEmbeddedRawwaves(CSOUND *csound, const char *directory) { +#ifdef _WIN32 + const int result = _mkdir(directory); +#else + const int result = mkdir(directory, 0700); +#endif + if (result != 0 && errno != EEXIST) { + csound->Message(csound, "STK: cannot create raw-wave directory %s\n", directory); + return false; + } + for (const auto &wave : stkRawwaves) { + const std::string filename = std::string(directory) + "/" + wave.name; + FILE *file = fopen(filename.c_str(), "wb"); + if (!file) { + csound->Message(csound, "STK: cannot open embedded raw wave %s\n", filename.c_str()); + return false; + } + const bool written = fwrite(wave.bytes, 1, wave.size, file) == wave.size; + const int closed = fclose(file); + if (!written || closed != 0) { + csound->Message(csound, "STK: cannot write embedded raw wave %s\n", filename.c_str()); + return false; + } + } + return true; +} +#endif + PUBLIC int32_t csoundModuleInit(CSOUND *csound) { const char *path = csound->GetEnv(csound, "RAWWAVE_PATH"); +#ifdef STK_EMBED_RAWWAVES + if (path == NULL || strlen(path) == 0) { + path = ".csound-stk-rawwaves"; + if (!writeEmbeddedRawwaves(csound, path)) + return NOTOK; + } +#endif #ifdef DEFAULT_RAWWAVE_PATH if (path == NULL || strlen(path) == 0) path = DEFAULT_RAWWAVE_PATH; diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index 9b8d546..6e94339 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -10,11 +10,13 @@ The package also contains code from these libraries: | 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, compiler-rt | C++ runtime and compiler support | `dist/licenses/libcxx.txt`, `libcxxabi.txt`, `compiler-rt.txt` | +| LLVM libc++, libc++abi, libunwind, compiler-rt | C++ runtime and compiler support | `dist/licenses/libcxx.txt`, `libcxxabi.txt`, `libunwind.txt`, `compiler-rt.txt` | The Nix build copies these notices from the source used to build each library. [`wasm/nixpkgs.nix`](https://github.com/csound/plugins/blob/develop/wasm/nixpkgs.nix) -pins their source versions. The GMM++ headers live in this repository. The build -uses upstream CMake and make options for static PIC libraries; it adds no source -patches. `wasm/default.nix` records those options. +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. diff --git a/index.d.ts b/index.d.ts index 92dc222..d1c4647 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,4 +3,5 @@ export declare const plugins: Readonly<{ chua: string; image: string; linearAlgebra: string; + stk: string; }>; diff --git a/index.js b/index.js index 312756a..d8963c5 100644 --- a/index.js +++ b/index.js @@ -3,4 +3,5 @@ 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/wasm/README.md b/wasm/README.md index 72ce0e8..5a361cf 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -2,8 +2,8 @@ WASM opcode plugins for the double-precision Csound 7 browser engine. -This first package includes Chua (`chuap`), image, and linear algebra. This build -disables STK, FluidSynth, HDF5, Python, WebSockets, device, and GPU plugins. +This package includes Chua (`chuap`), image, linear algebra, and STK. This build +disables FluidSynth, HDF5, Python, WebSockets, device, and GPU plugins. Csound 6 is not supported. ## Runtime dependency @@ -24,6 +24,12 @@ 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 +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 +embedded samples; that directory must already contain the files STK needs. + The exports are asset URL strings. Bundlers must copy the WASM files referenced by `new URL(..., import.meta.url)`. You can also serve files from `@csound/plugins/dist/` yourself and pass their URLs to `withPlugins`. @@ -57,13 +63,18 @@ npm pack Nix pins the toolchain and builds the existing `7.x` CMake targets with Ninja. Dependencies use static PIC builds. The C runtime imports the engine's allocator; -it does not link its own heap. C++ plugins use libc++ without C++ exceptions. +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. Set `NIX_SYSTEM=x86_64-linux` when using a Linux remote builder from macOS. -Tests load all plugins together, render Chua, compute a vector norm, and save and -reload a PNG through the WASI filesystem. Packing runs the tests and includes -the WASM files and their license notices. For work on the browser loader, +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. diff --git a/wasm/default.nix b/wasm/default.nix index 229265c..9b7353a 100644 --- a/wasm/default.nix +++ b/wasm/default.nix @@ -4,6 +4,13 @@ }: let wasi = pkgs.pkgsCross.wasi32.clangStdenv; + # Nix's WASI wrapper otherwise forces -fno-exceptions after CMake's flags. + # Let each target select its exception mode instead. + wasiCxx = pkgs.overrideCC wasi (wasi.cc.override (old: { + nixSupport = old.nixSupport // { + cc-cflags = builtins.filter (flag: flag != "-fno-exceptions") old.nixSupport.cc-cflags; + }; + })); libc = pkgs.pkgsCross.wasi32.wasilibc.overrideAttrs (old: { NIX_CFLAGS_COMPILE = (old.NIX_CFLAGS_COMPILE or []) ++ [ "-fPIC" ]; # Use the engine's allocator. wasi-libc also skips its fixed symbol list @@ -19,6 +26,26 @@ let ]; dontStrip = true; }); + libunwindSource = pkgs.pkgsCross.wasi32.llvmPackages.libunwind.src; + libunwind = wasi.mkDerivation { + pname = "libunwind-wasm-pic"; + inherit (libcxx) version; + src = ./libunwind; + nativeBuildInputs = [ pkgs.cmake pkgs.ninja ]; + cmakeFlags = [ "-DLIBUNWIND_SOURCE_DIR=${libunwindSource}/libunwind" ]; + dontStrip = true; + }; + libcxxExceptions = (libcxx.override { stdenv = wasiCxx; }).overrideAttrs (old: { + pname = "libcxx-wasm-exceptions"; + cmakeFlags = old.cmakeFlags ++ [ + "-DLIBCXX_ENABLE_EXCEPTIONS=ON" + "-DLIBCXX_ADDITIONAL_LIBRARIES=" + "-DLIBCXXABI_ENABLE_EXCEPTIONS=ON" + ]; + preConfigure = (old.preConfigure or "") + '' + cmakeFlagsArray+=("-DCMAKE_CXX_FLAGS=-fexceptions -fwasm-exceptions -I${libunwindSource}/libunwind/include") + ''; + }); lock = builtins.fromJSON (builtins.readFile ../package-lock.json); wasmBin = lock.packages."node_modules/@csound/wasm-bin"; sdkArchive = if csoundSdkArchive == null then null else builtins.path { @@ -56,7 +83,7 @@ let NIX_CFLAGS_COMPILE = [ "-mllvm" "-wasm-enable-sjlj" ]; dontStrip = true; }; -in wasi.mkDerivation { +in wasiCxx.mkDerivation { pname = "csound-plugins-wasm"; version = (builtins.fromJSON (builtins.readFile ../package.json)).version; src = pkgs.lib.cleanSource ../7.x; @@ -71,13 +98,16 @@ in wasi.mkDerivation { "-DWASM_SETJMP=${libc}/lib/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" "-DPNG_LIBRARY=${libpng}/lib/libpng.a" "-DBUILD_CHUA_OPCODES=ON" "-DBUILD_IMAGE_OPCODES=ON" "-DBUILD_LINEAR_ALGEBRA_OPCODES=ON" - "-DBUILD_STK_OPCODES=OFF" + "-DBUILD_STK_OPCODES=ON" "-DBUILD_FLUID_OPCODES=OFF" "-DBUILD_HDF5_OPCODES=OFF" "-DBUILD_WEBSOCKET_OPCODE=OFF" @@ -95,12 +125,14 @@ in wasi.mkDerivation { cp ${libc.src}/fts/musl-fts/COPYING $out/share/licenses/wasi-libc/musl-fts.txt cp ${libcxx.src}/libcxx/LICENSE.TXT $out/share/licenses/libcxx.txt cp ${libcxx.src}/libcxxabi/LICENSE.TXT $out/share/licenses/libcxxabi.txt + cp ${libunwindSource}/libunwind/LICENSE.TXT $out/share/licenses/libunwind.txt + cp ${../7.x/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 ${pkgs.eigen.src}/COPYING* $out/share/licenses/eigen/ sed -n '1,/^=*[*][/]$/p' ${../7.x/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; do + for plugin in chua image linear_algebra stkops; do test -s "$out/lib/$plugin.wasm" done ''; diff --git a/wasm/libunwind/CMakeLists.txt b/wasm/libunwind/CMakeLists.txt new file mode 100644 index 0000000..5e79ec6 --- /dev/null +++ b/wasm/libunwind/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.16) +project(CsoundWasmUnwind C) + +# LLVM 21's main build also selects assembly files that do not support WASM. +# The VM unwinds the stack; this source supplies the C++ exception interface. +add_library(unwind STATIC "${LIBUNWIND_SOURCE_DIR}/src/Unwind-wasm.c") +set_target_properties(unwind PROPERTIES POSITION_INDEPENDENT_CODE ON) +target_compile_features(unwind PRIVATE c_std_11) +target_compile_options(unwind PRIVATE -fwasm-exceptions) +target_compile_definitions(unwind PRIVATE _LIBUNWIND_HIDE_SYMBOLS) +target_include_directories(unwind PRIVATE "${LIBUNWIND_SOURCE_DIR}/include") +install(TARGETS unwind ARCHIVE DESTINATION lib) diff --git a/wasm/scripts/build.sh b/wasm/scripts/build.sh index 22c5070..87afa3c 100644 --- a/wasm/scripts/build.sh +++ b/wasm/scripts/build.sh @@ -12,7 +12,7 @@ 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; do +for plugin in chua image linear_algebra stkops; do install -m 644 "result-wasm/lib/$plugin.wasm" "$staging/$plugin.wasm" done cp -R result-wasm/share/licenses "$staging/licenses" diff --git a/wasm/tests/runtime.test.js b/wasm/tests/runtime.test.js index 783c3c9..b7ca0f6 100644 --- a/wasm/tests/runtime.test.js +++ b/wasm/tests/runtime.test.js @@ -31,10 +31,11 @@ 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) { +async function run(body, check, withPlugins = binaries, prepare = () => {}) { const errors = []; const originalError = console.error; console.error = (...args) => errors.push(args.join(" ")); @@ -49,6 +50,7 @@ async function run(body, check, withPlugins = binaries) { 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; @@ -89,6 +91,53 @@ test("chua produces finite, nonzero audio alongside the other plugins", async () `, (_, __, 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(` + a1 ${instrument} 220, 0.5 + out a1 + `, (_, __, 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]); +}); + +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); + }); +}); + +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; + } +}); + test("linear algebra computes a vector norm", async () => { await run(` iv la_i_vr_create 2