From 7efbf4edde9bde80877111f8382caee5886cab5d Mon Sep 17 00:00:00 2001 From: Seamus Mullan <43112447+SeamusMullan@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:01:52 +0100 Subject: [PATCH 01/13] Add WavGang JUCE plugin with Vue WebView, AGPL bridge, and FriendNet layout. Introduce PluginTemplate-based build (VST3, CLAP, Standalone), bun-built UI, wavgang-bridge Go service, packaging AGPL notices, and CI. Submodule pins include JUCE, cmake helpers (test WebKit/CURL fixes), and CLAP JUCE 8 API fix. Made-with: Cursor --- .clang-format | 86 ++++ .clang-tidy | 40 ++ .github/workflows/build_and_test.yml | 161 +++++++ .gitignore | 50 ++ .gitmodules | 38 ++ CMakeLists.txt | 184 ++++++++ CMakePresets.json | 107 +++++ JUCE | 1 + README.md | 54 ++- VERSION | 1 + assets/.gitkeep | 0 benchmarks/Benchmarks.cpp | 60 +++ bridge/LICENSE | 17 + bridge/README.md | 16 + bridge/go.mod | 3 + bridge/main.go | 37 ++ cmake | 1 + cmake-local/ModuleSystem.cmake | 110 +++++ cmake-local/MultiPlugin.cmake | 51 +++ cmake-local/Packaging.cmake | 78 ++++ cmake-local/PedalMoonbase.cmake | 61 +++ cmake-local/ReadProjectConfig.cmake | 109 +++++ cmake-local/Sanitizers.cmake | 28 ++ cmake-local/WebUI.cmake | 26 ++ cmake-local/options.cmake | 10 + common/CMakeLists.txt | 10 + common/IPluginProcessor.h | 37 ++ common/IPluginState.h | 29 ++ common/IPluginUI.h | 31 ++ common/PluginBus.h | 79 ++++ common/PluginDescriptor.h | 25 + docs/BRIDGE_API.md | 27 ++ docs/JS_BRIDGE.md | 12 + modules.toml | 76 +++ modules/clap-juce-extensions | 1 + modules/melatonin_inspector | 1 + packaging/.gitkeep | 0 packaging/agpl/FriendNet-LICENSE.txt | 661 +++++++++++++++++++++++++++ packaging/agpl/SOURCE_OFFER.txt | 10 + packaging/icon.png | Bin 0 -> 77 bytes plugins/.gitkeep | 0 project.toml | 28 ++ scripts/build.ps1 | 31 ++ scripts/build.sh | 30 ++ scripts/setup.ps1 | 51 +++ scripts/setup.sh | 51 +++ source/BridgeClient.cpp | 20 + source/BridgeClient.h | 21 + source/DSP/Core/ProcessorCore.h | 234 ++++++++++ source/DSP/Utils/DSPUtils.h | 73 +++ source/DSP/Utils/MeteringFIFO.h | 97 ++++ source/DSP/Utils/ParameterSmoother.h | 101 ++++ source/HostLauncher.cpp | 51 +++ source/HostLauncher.h | 14 + source/PluginEditor.cpp | 136 ++++++ source/PluginEditor.h | 29 ++ source/PluginProcessor.cpp | 294 ++++++++++++ source/PluginProcessor.h | 92 ++++ source/Service/PresetManager.cpp | 484 ++++++++++++++++++++ source/Service/PresetManager.h | 109 +++++ source/WebUiRoot.cpp | 43 ++ source/WebUiRoot.h | 9 + tests/PluginBasics.cpp | 77 ++++ tests/daw/StatePersistenceTests.cpp | 220 +++++++++ tests/helpers/DSPTestHelpers.h | 202 ++++++++ tests/helpers/TestSignalGenerators.h | 119 +++++ tests/helpers/test_helpers.h | 26 ++ tests/safety/AudioSafetyTests.cpp | 290 ++++++++++++ third_party/friendnet | 1 + ui/bun.lock | 468 +++++++++++++++++++ ui/index.html | 12 + ui/package.json | 24 + ui/src/App.vue | 84 ++++ ui/src/main.ts | 7 + ui/src/nativeBridge.spec.ts | 19 + ui/src/nativeBridge.ts | 15 + ui/src/stores/bridge.ts | 23 + ui/src/vite-env.d.ts | 13 + ui/tsconfig.json | 21 + ui/tsconfig.node.json | 10 + ui/vite.config.ts | 12 + ui/vitest.config.ts | 10 + 82 files changed, 5974 insertions(+), 5 deletions(-) create mode 100644 .clang-format create mode 100644 .clang-tidy create mode 100644 .github/workflows/build_and_test.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 CMakeLists.txt create mode 100644 CMakePresets.json create mode 160000 JUCE create mode 100644 VERSION create mode 100644 assets/.gitkeep create mode 100644 benchmarks/Benchmarks.cpp create mode 100644 bridge/LICENSE create mode 100644 bridge/README.md create mode 100644 bridge/go.mod create mode 100644 bridge/main.go create mode 160000 cmake create mode 100644 cmake-local/ModuleSystem.cmake create mode 100644 cmake-local/MultiPlugin.cmake create mode 100644 cmake-local/Packaging.cmake create mode 100644 cmake-local/PedalMoonbase.cmake create mode 100644 cmake-local/ReadProjectConfig.cmake create mode 100644 cmake-local/Sanitizers.cmake create mode 100644 cmake-local/WebUI.cmake create mode 100644 cmake-local/options.cmake create mode 100644 common/CMakeLists.txt create mode 100644 common/IPluginProcessor.h create mode 100644 common/IPluginState.h create mode 100644 common/IPluginUI.h create mode 100644 common/PluginBus.h create mode 100644 common/PluginDescriptor.h create mode 100644 docs/BRIDGE_API.md create mode 100644 docs/JS_BRIDGE.md create mode 100644 modules.toml create mode 160000 modules/clap-juce-extensions create mode 160000 modules/melatonin_inspector create mode 100644 packaging/.gitkeep create mode 100644 packaging/agpl/FriendNet-LICENSE.txt create mode 100644 packaging/agpl/SOURCE_OFFER.txt create mode 100644 packaging/icon.png create mode 100644 plugins/.gitkeep create mode 100644 project.toml create mode 100644 scripts/build.ps1 create mode 100755 scripts/build.sh create mode 100644 scripts/setup.ps1 create mode 100755 scripts/setup.sh create mode 100644 source/BridgeClient.cpp create mode 100644 source/BridgeClient.h create mode 100644 source/DSP/Core/ProcessorCore.h create mode 100644 source/DSP/Utils/DSPUtils.h create mode 100644 source/DSP/Utils/MeteringFIFO.h create mode 100644 source/DSP/Utils/ParameterSmoother.h create mode 100644 source/HostLauncher.cpp create mode 100644 source/HostLauncher.h create mode 100644 source/PluginEditor.cpp create mode 100644 source/PluginEditor.h create mode 100644 source/PluginProcessor.cpp create mode 100644 source/PluginProcessor.h create mode 100644 source/Service/PresetManager.cpp create mode 100644 source/Service/PresetManager.h create mode 100644 source/WebUiRoot.cpp create mode 100644 source/WebUiRoot.h create mode 100644 tests/PluginBasics.cpp create mode 100644 tests/daw/StatePersistenceTests.cpp create mode 100644 tests/helpers/DSPTestHelpers.h create mode 100644 tests/helpers/TestSignalGenerators.h create mode 100644 tests/helpers/test_helpers.h create mode 100644 tests/safety/AudioSafetyTests.cpp create mode 160000 third_party/friendnet create mode 100644 ui/bun.lock create mode 100644 ui/index.html create mode 100644 ui/package.json create mode 100644 ui/src/App.vue create mode 100644 ui/src/main.ts create mode 100644 ui/src/nativeBridge.spec.ts create mode 100644 ui/src/nativeBridge.ts create mode 100644 ui/src/stores/bridge.ts create mode 100644 ui/src/vite-env.d.ts create mode 100644 ui/tsconfig.json create mode 100644 ui/tsconfig.node.json create mode 100644 ui/vite.config.ts create mode 100644 ui/vitest.config.ts diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..0aff756 --- /dev/null +++ b/.clang-format @@ -0,0 +1,86 @@ +# https://forum.juce.com/t/automatic-juce-like-code-formatting-with-clang-format/31624/20 +--- +AccessModifierOffset: -4 +AlignAfterOpenBracket: DontAlign +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false +AlignEscapedNewlines: Left +AlignOperands: Align +AlignTrailingComments: false +AllowAllParametersOfDeclarationOnNextLine: false +AllowShortBlocksOnASingleLine: Never +AllowShortCaseLabelsOnASingleLine: false +AllowShortFunctionsOnASingleLine: All +AllowShortIfStatementsOnASingleLine: Never +AllowShortLambdasOnASingleLine: All +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: false +AlwaysBreakTemplateDeclarations: Yes +BinPackArguments: false +BinPackParameters: false +BreakAfterJavaFieldAnnotations: false +BreakBeforeBinaryOperators: NonAssignment +BreakBeforeBraces: Custom +BraceWrapping: + AfterClass: true + AfterCaseLabel: true + AfterFunction: true + AfterNamespace: true + AfterStruct: true + BeforeElse: true + AfterControlStatement: Always + BeforeLambdaBody: false +BreakBeforeTernaryOperators: true +BreakConstructorInitializersBeforeComma: false +BreakStringLiterals: false +ColumnLimit: 0 +ConstructorInitializerAllOnOneLineOrOnePerLine: true +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: false +DerivePointerAlignment: false +DisableFormat: false +ExperimentalAutoDetectBinPacking: false +IndentCaseLabels: true +IndentPPDirectives: BeforeHash +IndentWidth: 4 +IndentWrappedFunctionNames: true +KeepEmptyLinesAtTheStartOfBlocks: false +Language: Cpp +MaxEmptyLinesToKeep: 1 +FixNamespaceComments: false +NamespaceIndentation: All +PointerAlignment: Left +ReflowComments: false +SortIncludes: true +SpaceAfterCStyleCast: true +SpaceAfterLogicalNot: false +SpaceBeforeAssignmentOperators: true +SpaceBeforeCpp11BracedList: true +SpaceBeforeParens: NonEmptyParentheses +SpaceInEmptyParentheses: false +SpaceBeforeInheritanceColon: true +SpacesInAngles: false +SpacesInCStyleCastParentheses: false +SpacesInContainerLiterals: true +SpacesInParentheses: false +SpacesInSquareBrackets: false +Standard: "c++20" +TabWidth: 4 +UseTab: Never +UseCRLF: false +--- +Language: ObjC +BasedOnStyle: Chromium +BreakBeforeBraces: Allman +ColumnLimit: 0 +IndentWidth: 4 +KeepEmptyLinesAtTheStartOfBlocks: false +ObjCSpaceAfterProperty: true +ObjCSpaceBeforeProtocolList: true +PointerAlignment: Left +SpacesBeforeTrailingComments: 1 +TabWidth: 4 +UseTab: Never diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..ba1cb19 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,40 @@ +--- +# Clang-Tidy configuration for JUCE audio plugin development. +# +# Focus: real-time safety, modernization, thread safety, common bugs. +# Avoids checks that conflict with JUCE conventions. + +Checks: > + -*, + bugprone-*, + -bugprone-easily-swappable-parameters, + -bugprone-narrowing-conversions, + cppcoreguidelines-avoid-non-const-global-variables, + cppcoreguidelines-init-variables, + cppcoreguidelines-slicing, + misc-redundant-expression, + misc-unused-using-decls, + modernize-use-auto, + modernize-use-nullptr, + modernize-use-override, + modernize-use-using, + modernize-loop-convert, + performance-*, + -performance-no-int-to-ptr, + readability-braces-around-statements, + readability-const-return-type, + readability-container-size-empty, + readability-implicit-bool-conversion, + readability-redundant-smartptr-get, + +WarningsAsErrors: '' + +HeaderFilterRegex: 'source/.*' + +CheckOptions: + - key: modernize-use-auto.MinTypeNameLength + value: '5' + - key: readability-braces-around-statements.ShortStatementLines + value: '1' + - key: performance-move-const-arg.CheckTriviallyCopyableMove + value: 'false' diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml new file mode 100644 index 0000000..410eb9e --- /dev/null +++ b/.github/workflows/build_and_test.yml @@ -0,0 +1,161 @@ +name: Build & Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + BUILD_TYPE: Release + CMAKE_BUILD_PARALLEL_LEVEL: 4 + +defaults: + run: + shell: bash + +jobs: + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: Linux + os: ubuntu-22.04 + - name: macOS + os: macos-14 + - name: Windows + os: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.22.x" + + - name: Initialize submodules + run: | + git submodule update --init --recursive JUCE cmake + git submodule update --init --recursive modules/melatonin_inspector + git submodule update --init --recursive modules/clap-juce-extensions + git submodule update --init --recursive modules/clap-juce-extensions/clap-libs/clap + git submodule update --init --recursive modules/clap-juce-extensions/clap-libs/clap-helpers + + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update && sudo apt-get install -y \ + libasound2-dev libx11-dev libxinerama-dev libxext-dev \ + libfreetype6-dev libwebkit2gtk-4.0-dev libglu1-mesa-dev \ + libxcursor-dev libxrandr-dev libxcomposite-dev libxrender-dev \ + libcurl4-openssl-dev + + - name: Build wavgang-bridge + run: | + cd bridge + go build -o wavgang-bridge . + + - name: Smoke test bridge API + working-directory: bridge + run: | + go run . & + pid=$! + sleep 2 + curl -fsS http://127.0.0.1:17890/v1/status | grep -q wavgang-bridge + kill $pid 2>/dev/null || true + + - name: Configure + run: cmake -B build -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} + + - name: Build + run: cmake --build build --config ${{ env.BUILD_TYPE }} + + - name: Test + working-directory: build + run: ctest --output-on-failure -C ${{ env.BUILD_TYPE }} + + - name: Upload build artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: build-${{ matrix.name }} + path: | + build/**/*.vst3 + build/**/*.component + build/**/*.clap + bridge/wavgang-bridge + if-no-files-found: ignore + + - name: Upload AGPL packaging hints + if: always() + uses: actions/upload-artifact@v4 + with: + name: agpl-packaging-${{ matrix.name }} + path: | + packaging/agpl/** + bridge/LICENSE + docs/BRIDGE_API.md + if-no-files-found: ignore + + format-check: + name: Format Check + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Check clang-format + run: | + find source tests benchmarks -name '*.cpp' -o -name '*.h' | \ + xargs clang-format --dry-run --Werror 2>&1 || \ + (echo "::error::Code formatting issues found. Run 'clang-format -i' on the files above." && exit 1) + + sanitizer: + name: ASan + UBSan + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Initialize submodules + run: | + git submodule update --init --recursive JUCE cmake + git submodule update --init --recursive modules/melatonin_inspector + git submodule update --init --recursive modules/clap-juce-extensions + git submodule update --init --recursive modules/clap-juce-extensions/clap-libs/clap + git submodule update --init --recursive modules/clap-juce-extensions/clap-libs/clap-helpers + + - name: Install Linux dependencies + run: | + sudo apt-get update && sudo apt-get install -y \ + libasound2-dev libx11-dev libxinerama-dev libxext-dev \ + libfreetype6-dev libwebkit2gtk-4.0-dev libglu1-mesa-dev \ + libxcursor-dev libxrandr-dev libxcomposite-dev libxrender-dev \ + libcurl4-openssl-dev + + - name: Configure with ASan + run: cmake -B build -DCMAKE_BUILD_TYPE=Debug -DWITH_ADDRESS_SANITIZER=ON + + - name: Build + run: cmake --build build + + - name: Test under ASan + working-directory: build + run: ctest --output-on-failure diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2dc9e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# CMake +CMakeLists.txt.user +CMakeCache.txt +CMakeFiles +CMakeScripts +Makefile +cmake_install.cmake +install_manifest.txt +compile_commands.json +CTestTestfile.cmake +_deps +**/.DS_Store + +# Build directories +/build +Builds/* +!Builds/.gitkeep +Install/* +Testing + +# IDE +.idea +.vscode +*.swp +*.swo +*~ + +# CMake build variants +cmake-build-debug +cmake-build-release +cmake-build-relwithdebinfo + +# Packaging output +packaging/Output/* + +# Generated binary data +/assets/BinaryData/moonbase_api_config.json + +# Asset source files (not needed in build) +/assets/blender_assets/** +/assets/*.psd +/assets/Advertising + +# Web UI toolchain +ui/node_modules/ +ui/dist/ + +# Go bridge binary +bridge/wavgang-bridge +bridge/wavgang-bridge.exe diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..2992d9d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,38 @@ +[submodule "JUCE"] + path = JUCE + url = https://github.com/juce-framework/JUCE/ + branch = develop + ignore = dirty +[submodule "cmake"] + path = cmake + url = https://github.com/sudara/cmake-includes.git + branch = main + ignore = dirty +[submodule "modules/melatonin_inspector"] + path = modules/melatonin_inspector + url = https://github.com/sudara/melatonin_inspector.git + branch = main + ignore = dirty +[submodule "modules/clap-juce-extensions"] + path = modules/clap-juce-extensions + url = https://github.com/free-audio/clap-juce-extensions.git + branch = main + ignore = dirty +[submodule "modules/moonbase_JUCEClient"] + path = modules/moonbase_JUCEClient + url = https://github.com/Moonbase-sh/moonbase_JUCEClient + branch = main + ignore = dirty +[submodule "modules/DirektDSP_GUI"] + path = modules/DirektDSP_GUI + url = https://github.com/DirektDSP/DirektDSP_GUI.git +[submodule "modules/cycfi_q"] + path = modules/cycfi_q + url = https://github.com/cycfi/q.git +[submodule "modules/cycfi_infra"] + path = modules/cycfi_infra + url = https://github.com/cycfi/infra.git +[submodule "third_party/friendnet"] + path = third_party/friendnet + url = https://github.com/DirektDSP/FriendNet.git + branch = master diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..a897bbc --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,184 @@ +cmake_minimum_required(VERSION 3.25) + +# Pamplejuce cmake helpers (submodule in /cmake) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") +# Template-specific cmake modules +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake-local") + +include(PamplejuceVersion) +include(CPM) +include(PamplejuceMacOS) +include(JUCEDefaults) +include(Sanitizers) + +# ── Read project identity from project.toml ────────────────────── +include(ReadProjectConfig) +read_project_config("${CMAKE_CURRENT_SOURCE_DIR}/project.toml") + +include(WebUI) + +# ── Load generated option defaults (written by the configurator) ─ +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake-local/options.cmake") + include("${CMAKE_CURRENT_SOURCE_DIR}/cmake-local/options.cmake") +endif() + +# ── Module toggle options ───────────────────────────────────────── +# These can be overridden via -D flags, cmake-local/options.cmake, +# or CMakeUserPresets.json. +option(ENABLE_MOONBASE "Enable Moonbase licensing" OFF) +option(ENABLE_MELATONIN "Enable Melatonin Inspector" ON) +option(ENABLE_CLAP "Enable CLAP plugin format" ON) +option(ENABLE_DIREKTDSP_GUI "Enable DirektDSP GUI framework" OFF) +option(ENABLE_CYCFI_Q "Enable cycfi::Q DSP library" OFF) +option(ENABLE_COMMON_LAYER "Enable common implementation layer" ON) + +project(${PROJ_NAME} VERSION ${CURRENT_VERSION}) + +# ── Core dependency: JUCE ───────────────────────────────────────── +add_subdirectory(JUCE) + +# ── Conditional module setup ────────────────────────────────────── +include(ModuleSystem) +setup_modules() + +# ── Common implementation layer ─────────────────────────────────── +if(ENABLE_COMMON_LAYER) + add_subdirectory(common) +endif() + +# ── Plugin targets ──────────────────────────────────────────────── +if(PROJ_IS_MULTI_PLUGIN) + # Multi-plugin project (PedalSuite-style) + include(MultiPlugin) + foreach(PLUGIN_DIR IN LISTS PROJ_PLUGINS) + add_subdirectory("plugins/${PLUGIN_DIR}") + endforeach() +else() + # ── Single-plugin project ───────────────────────────────────── + + # Determine copy-after-build setting + if(PROJ_COPY_AFTER_BUILD) + set(_COPY_PLUGIN TRUE) + else() + set(_COPY_PLUGIN FALSE) + endif() + + if(WIN32) + set(WVG_NEEDS_WEBVIEW2 TRUE) + else() + set(WVG_NEEDS_WEBVIEW2 FALSE) + endif() + + juce_add_plugin("${PROJ_NAME}" + ICON_BIG "${CMAKE_CURRENT_SOURCE_DIR}/packaging/icon.png" + COMPANY_NAME "${PROJ_COMPANY_NAME}" + BUNDLE_ID "${PROJ_BUNDLE_ID}" + COPY_PLUGIN_AFTER_BUILD ${_COPY_PLUGIN} + PLUGIN_MANUFACTURER_CODE ${PROJ_MANUFACTURER_CODE} + PLUGIN_CODE ${PROJ_PLUGIN_CODE} + FORMATS "${PROJ_FORMATS}" + PRODUCT_NAME "${PROJ_PRODUCT_NAME}" + NEEDS_WEB_BROWSER TRUE + NEEDS_CURL TRUE + NEEDS_WEBVIEW2 ${WVG_NEEDS_WEBVIEW2}) + + add_library(SharedCode INTERFACE) + + # CLAP extensions (must come after juce_add_plugin) + if(ENABLE_CLAP) + clap_juce_extensions_plugin(TARGET "${PROJ_NAME}" + CLAP_ID "${PROJ_BUNDLE_ID}" + CLAP_FEATURES ${PROJ_CLAP_FEATURES}) + endif() + + # Moonbase linking (single-plugin mode) + if(ENABLE_MOONBASE) + target_link_libraries("${PROJ_NAME}" PRIVATE moonbase_JUCEClient) + endif() + + # Shared code defaults: fast math, C++ standard + if(MSVC) + target_compile_options(SharedCode INTERFACE $<$:/fp:fast>) + target_compile_options(SharedCode INTERFACE $<$:/Ox>) + target_compile_options(SharedCode INTERFACE /Zc:__cplusplus) + else() + target_compile_options(SharedCode INTERFACE $<$:-Ofast>) + target_compile_options(SharedCode INTERFACE $<$:-Ofast>) + endif() + target_compile_features(SharedCode INTERFACE cxx_std_${PROJ_CPP_STANDARD}) + + # Source files + file(GLOB_RECURSE SourceFiles CONFIGURE_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/source/*.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/source/*.h") + target_include_directories(SharedCode INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/source") + target_sources(SharedCode INTERFACE ${SourceFiles}) + + # Assets (binary data) + include(Assets) + + # macOS Xcode organization + include(XcodePrettify) + + # Compile definitions + get_module_compile_definitions(_MODULE_DEFS) + target_compile_definitions(SharedCode + INTERFACE + JUCE_WEB_BROWSER=1 + JUCE_USE_CURL=1 + JUCE_VST3_CAN_REPLACE_VST2=0 + CMAKE_BUILD_TYPE="${CMAKE_BUILD_TYPE}" + VERSION="${CURRENT_VERSION}" + PRODUCT_NAME_WITHOUT_VERSION="${PROJ_PRODUCT_NAME}" + ${_MODULE_DEFS}) + + # Link libraries + set(_LINK_LIBS + Assets + juce_audio_utils + juce_audio_processors + juce_dsp + juce_gui_basics + juce_gui_extra + juce::juce_recommended_config_flags + juce::juce_recommended_lto_flags + juce::juce_recommended_warning_flags) + + get_module_link_libraries(_MODULE_LIBS) + list(APPEND _LINK_LIBS ${_MODULE_LIBS}) + + target_link_libraries(SharedCode INTERFACE ${_LINK_LIBS}) + target_link_libraries("${PROJ_NAME}" PRIVATE SharedCode) + add_dependencies("${PROJ_NAME}" wavgang_webui) + + if(MSVC) + target_compile_definitions(SharedCode INTERFACE JUCE_USE_WIN_WEBVIEW2=1) + endif() + + target_compile_definitions(SharedCode + INTERFACE + "WVG_WEBUI_ROOT=\"${WVG_WEBUI_ROOT}\"") + + # IPP support (optional) + if(PROJ_IPP) + include(PamplejuceIPP) + endif() + + # Tests + include(Tests) + # Catch2 v3 requires WithMain for automatic main() generation + target_link_libraries(Tests PRIVATE Catch2::Catch2WithMain) + if(ENABLE_MOONBASE) + target_link_libraries(Tests PRIVATE moonbase_JUCEClient) + endif() + + # Benchmarks + include(Benchmarks) + target_link_libraries(Benchmarks PRIVATE Catch2::Catch2WithMain) + if(ENABLE_MOONBASE) + target_link_libraries(Benchmarks PRIVATE moonbase_JUCEClient) + endif() + + # CI output + include(GitHubENV) +endif() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..2818995 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,107 @@ +{ + "version": 6, + "cmakeMinimumRequired": { + "major": 3, + "minor": 25, + "patch": 0 + }, + "configurePresets": [ + { + "name": "default", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + } + }, + { + "name": "debug", + "displayName": "Debug", + "inherits": "default", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, + { + "name": "release", + "displayName": "Release", + "inherits": "default", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + }, + { + "name": "relwithdebinfo", + "displayName": "Release with Debug Info", + "inherits": "default", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "RelWithDebInfo" + } + }, + { + "name": "asan", + "displayName": "Address Sanitizer", + "inherits": "debug", + "cacheVariables": { + "WITH_ADDRESS_SANITIZER": "ON" + } + }, + { + "name": "tsan", + "displayName": "Thread Sanitizer", + "inherits": "debug", + "cacheVariables": { + "WITH_THREAD_SANITIZER": "ON" + } + } + ], + "buildPresets": [ + { + "name": "debug", + "configurePreset": "debug" + }, + { + "name": "release", + "configurePreset": "release" + }, + { + "name": "asan", + "configurePreset": "asan" + }, + { + "name": "tsan", + "configurePreset": "tsan" + } + ], + "testPresets": [ + { + "name": "debug", + "configurePreset": "debug", + "output": { + "outputOnFailure": true + } + }, + { + "name": "release", + "configurePreset": "release", + "output": { + "outputOnFailure": true + } + }, + { + "name": "asan", + "configurePreset": "asan", + "output": { + "outputOnFailure": true + } + }, + { + "name": "tsan", + "configurePreset": "tsan", + "output": { + "outputOnFailure": true + } + } + ] +} diff --git a/JUCE b/JUCE new file mode 160000 index 0000000..28706b9 --- /dev/null +++ b/JUCE @@ -0,0 +1 @@ +Subproject commit 28706b9811e4636e2983c1b686914eb24327d160 diff --git a/README.md b/README.md index 76ffeb0..00c2c55 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,54 @@ -wavgang +# WavGang ---- +P2P-oriented sample workflow plugin and standalone app, built with the DirektDSP JUCE template. UI is **Vue 3** (bundled into the binary via **JUCE WebView**). File sharing integrates with **[FriendNet](https://friendnet.org/)** (AGPL): ship the **wavgang-bridge** helper and **friendnet-server** as separate binaries; see [packaging/agpl/SOURCE_OFFER.txt](packaging/agpl/SOURCE_OFFER.txt). -WavGang is filer sharing plugin and standalone app for musicians, producers, enthusiasts, etc. +## Prerequisites -WavGang is built on top of [FriendNet](https://friendnet.org/) which is a self-hostable, open source file sharing platform / tool. +- CMake 3.25+ +- C++20 toolchain +- **bun** ([bun.sh](https://bun.sh)) for `ui/` +- Git submodules: `JUCE`, `cmake`, `modules/*` (see [scripts/setup.sh](scripts/setup.sh)) +- Linux: WebKitGTK, GTK3, **libcurl** dev packages (see CI workflow) -Status: ts so cooked iwel +## Clone +```bash +git clone WavGang +cd WavGang +./scripts/setup.sh +# CLAP nested libs +git submodule update --init --recursive modules/clap-juce-extensions/clap-libs/clap +git submodule update --init --recursive modules/clap-juce-extensions/clap-libs/clap-helpers +``` + +Optional: `third_party/friendnet` — clone [DirektDSP/FriendNet](https://github.com/DirektDSP/FriendNet) for server builds. + +## Build + +```bash +cmake --preset debug +cmake --build build/debug +ctest --preset debug +``` + +## Bridge (AGPL) + +```bash +cd bridge && go build -o wavgang-bridge . +./wavgang-bridge +# GET http://127.0.0.1:17890/v1/status +``` + +API: [docs/BRIDGE_API.md](docs/BRIDGE_API.md). Native/WebView contract: [docs/JS_BRIDGE.md](docs/JS_BRIDGE.md). + +## Web UI only + +```bash +cd ui && bun install && bun run dev +``` + +## License + +- **Plugin / JUCE application code:** proprietary (this repo, excluding `bridge/` and AGPL packaging materials as labeled). +- **bridge/**: AGPL-3.0 (see [bridge/LICENSE](bridge/LICENSE)). +- **FriendNet:** AGPL-3.0 (upstream; copy in `packaging/agpl/` or submodule). diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6c6aa7c --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 \ No newline at end of file diff --git a/assets/.gitkeep b/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/benchmarks/Benchmarks.cpp b/benchmarks/Benchmarks.cpp new file mode 100644 index 0000000..c0b3a77 --- /dev/null +++ b/benchmarks/Benchmarks.cpp @@ -0,0 +1,60 @@ +#include "PluginProcessor.h" +#include +#include + +TEST_CASE ("Process block benchmarks", "[benchmark]") +{ + PluginProcessor plugin; + juce::MidiBuffer midi; + + // Real test case so CTest discovery works. + // Run with --benchmark-samples=10 for actual benchmarking. + + SECTION ("512 samples @ 44.1kHz") + { + plugin.prepareToPlay (44100.0, 512); + + juce::AudioBuffer buffer (2, 512); + buffer.clear(); + + REQUIRE (buffer.getNumChannels() == 2); + + BENCHMARK ("processBlock 512") + { + plugin.processBlock (buffer, midi); + return buffer.getSample (0, 0); + }; + } + + SECTION ("128 samples @ 44.1kHz") + { + plugin.prepareToPlay (44100.0, 128); + + juce::AudioBuffer buffer (2, 128); + buffer.clear(); + + REQUIRE (buffer.getNumChannels() == 2); + + BENCHMARK ("processBlock 128") + { + plugin.processBlock (buffer, midi); + return buffer.getSample (0, 0); + }; + } + + SECTION ("1024 samples @ 96kHz") + { + plugin.prepareToPlay (96000.0, 1024); + + juce::AudioBuffer buffer (2, 1024); + buffer.clear(); + + REQUIRE (buffer.getNumChannels() == 2); + + BENCHMARK ("processBlock 1024 @ 96k") + { + plugin.processBlock (buffer, midi); + return buffer.getSample (0, 0); + }; + } +} diff --git a/bridge/LICENSE b/bridge/LICENSE new file mode 100644 index 0000000..1fbbfde --- /dev/null +++ b/bridge/LICENSE @@ -0,0 +1,17 @@ +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +Copyright (C) 2026 DirektDSP / WavGang contributors. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/bridge/README.md b/bridge/README.md new file mode 100644 index 0000000..de3c660 --- /dev/null +++ b/bridge/README.md @@ -0,0 +1,16 @@ +# wavgang-bridge + +AGPL-3.0 — companion process for WavGang. Talks to FriendNet (separate AGPL server) and exposes a small localhost HTTP API for the closed-source JUCE plugin. + +Build: + +```bash +go build -o wavgang-bridge . +``` + +Run: + +```bash +./wavgang-bridge +# or: WAVGANG_BRIDGE_ADDR=":17890" ./wavgang-bridge +``` diff --git a/bridge/go.mod b/bridge/go.mod new file mode 100644 index 0000000..2369fed --- /dev/null +++ b/bridge/go.mod @@ -0,0 +1,3 @@ +module github.com/DirektDSP/WavGang/bridge + +go 1.22 diff --git a/bridge/main.go b/bridge/main.go new file mode 100644 index 0000000..9184cfe --- /dev/null +++ b/bridge/main.go @@ -0,0 +1,37 @@ +// WavGang bridge: localhost REST shim toward FriendNet (AGPL). See ../docs/BRIDGE_API.md +package main + +import ( + "encoding/json" + "log" + "net/http" + "os" +) + +func main() { + addr := ":17890" + if v := os.Getenv("WAVGANG_BRIDGE_ADDR"); v != "" { + addr = v + } + + mux := http.NewServeMux() + mux.HandleFunc("/v1/status", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "service": "wavgang-bridge", + "friendnet": "not_connected", + }) + }) + mux.HandleFunc("/v1/version", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "version": "0.1.0", + }) + }) + + log.Printf("wavgang-bridge listening on %s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatal(err) + } +} diff --git a/cmake b/cmake new file mode 160000 index 0000000..3d70ba6 --- /dev/null +++ b/cmake @@ -0,0 +1 @@ +Subproject commit 3d70ba6b58dd20ebbac62e3e6b69e5298c6f0297 diff --git a/cmake-local/ModuleSystem.cmake b/cmake-local/ModuleSystem.cmake new file mode 100644 index 0000000..643b08f --- /dev/null +++ b/cmake-local/ModuleSystem.cmake @@ -0,0 +1,110 @@ +# ModuleSystem.cmake +# Conditionally includes modules based on ENABLE_* options. +# Each module is a git submodule that is only add_subdirectory'd when enabled. + +function(setup_modules) + # CLAP format support + if(ENABLE_CLAP) + _ensure_submodule("modules/clap-juce-extensions") + add_subdirectory("${CMAKE_SOURCE_DIR}/modules/clap-juce-extensions" EXCLUDE_FROM_ALL) + message(STATUS "Module enabled: CLAP format") + endif() + + # Melatonin Inspector (debug UI) + if(ENABLE_MELATONIN) + _ensure_submodule("modules/melatonin_inspector") + add_subdirectory("${CMAKE_SOURCE_DIR}/modules/melatonin_inspector") + message(STATUS "Module enabled: Melatonin Inspector") + endif() + + # Moonbase licensing + if(ENABLE_MOONBASE) + _ensure_submodule("modules/moonbase_JUCEClient") + if(PROJ_IS_MULTI_PLUGIN) + # Multi-plugin projects use per-pedal moonbase isolation + include(PedalMoonbase) + message(STATUS "Module enabled: Moonbase (per-plugin mode)") + else() + add_subdirectory("${CMAKE_SOURCE_DIR}/modules/moonbase_JUCEClient") + message(STATUS "Module enabled: Moonbase") + endif() + endif() + + # DirektDSP GUI framework + if(ENABLE_DIREKTDSP_GUI) + _ensure_submodule("modules/DirektDSP_GUI") + add_subdirectory("${CMAKE_SOURCE_DIR}/modules/DirektDSP_GUI") + message(STATUS "Module enabled: DirektDSP GUI") + endif() + + # Cycfi Q DSP library (requires cycfi_infra) + if(ENABLE_CYCFI_Q) + _ensure_submodule("modules/cycfi_infra") + _ensure_submodule("modules/cycfi_q") + add_subdirectory("${CMAKE_SOURCE_DIR}/modules/cycfi_infra") + add_subdirectory("${CMAKE_SOURCE_DIR}/modules/cycfi_q") + message(STATUS "Module enabled: Cycfi Q + Infra") + endif() +endfunction() + +# Helper: build a list of link libraries based on enabled modules. +# Call after setup_modules(). Appends to the variable named by OUT_VAR. +function(get_module_link_libraries OUT_VAR) + set(_LIBS "") + + if(ENABLE_MELATONIN) + list(APPEND _LIBS melatonin_inspector) + endif() + + if(ENABLE_DIREKTDSP_GUI) + list(APPEND _LIBS DirektDSP_GUI) + endif() + + if(ENABLE_COMMON_LAYER) + list(APPEND _LIBS DirektCommonLayer) + endif() + + if(ENABLE_CYCFI_Q) + list(APPEND _LIBS cycfi_q cycfi_infra) + endif() + + set(${OUT_VAR} "${_LIBS}" PARENT_SCOPE) +endfunction() + +# Helper: build a list of compile definitions based on enabled modules. +function(get_module_compile_definitions OUT_VAR) + set(_DEFS "") + + if(ENABLE_MOONBASE) + list(APPEND _DEFS "ENABLE_MOONBASE=1") + endif() + + if(ENABLE_MELATONIN) + list(APPEND _DEFS "ENABLE_MELATONIN=1") + endif() + + if(ENABLE_DIREKTDSP_GUI) + list(APPEND _DEFS "ENABLE_DIREKTDSP_GUI=1") + endif() + + if(ENABLE_COMMON_LAYER) + list(APPEND _DEFS "ENABLE_COMMON_LAYER=1") + endif() + + if(ENABLE_CYCFI_Q) + list(APPEND _DEFS "ENABLE_CYCFI_Q=1") + endif() + + set(${OUT_VAR} "${_DEFS}" PARENT_SCOPE) +endfunction() + +# Verify a submodule directory exists and has content. +function(_ensure_submodule PATH) + if(NOT EXISTS "${CMAKE_SOURCE_DIR}/${PATH}/CMakeLists.txt") + if(NOT EXISTS "${CMAKE_SOURCE_DIR}/${PATH}/.git") + message(FATAL_ERROR + "Submodule '${PATH}' not initialized.\n" + "Run: git submodule update --init ${PATH}") + endif() + endif() +endfunction() diff --git a/cmake-local/MultiPlugin.cmake b/cmake-local/MultiPlugin.cmake new file mode 100644 index 0000000..a0e963b --- /dev/null +++ b/cmake-local/MultiPlugin.cmake @@ -0,0 +1,51 @@ +# MultiPlugin.cmake +# Helpers for multi-plugin projects. +# The root CMakeLists.txt iterates over PROJ_PLUGINS and calls +# add_subdirectory for each plugin. Each plugin subdir has its own +# CMakeLists.txt following the per-plugin pattern. + +# Sets up shared compile options for a per-plugin SharedCode target. +# Usage: setup_plugin_shared_code(TARGET_NAME cpp_standard) +function(setup_plugin_shared_code TARGET_NAME CPP_STD) + if(MSVC) + target_compile_options(${TARGET_NAME} INTERFACE $<$:/fp:fast>) + target_compile_options(${TARGET_NAME} INTERFACE $<$:/Ox>) + target_compile_options(${TARGET_NAME} INTERFACE /Zc:__cplusplus) + else() + target_compile_options(${TARGET_NAME} INTERFACE $<$:-Ofast>) + target_compile_options(${TARGET_NAME} INTERFACE $<$:-Ofast>) + endif() + target_compile_features(${TARGET_NAME} INTERFACE cxx_std_${CPP_STD}) +endfunction() + +# Sets up per-plugin assets as a BinaryData target. +# Usage: setup_plugin_assets(PEDAL_NAME assets_dir) +function(setup_plugin_assets PEDAL_NAME ASSETS_DIR) + file(GLOB_RECURSE AssetFiles CONFIGURE_DEPENDS "${ASSETS_DIR}/*") + list(FILTER AssetFiles EXCLUDE REGEX "/\\.DS_Store$") + if(AssetFiles) + juce_add_binary_data(${PEDAL_NAME}_Assets SOURCES ${AssetFiles}) + set_target_properties(${PEDAL_NAME}_Assets PROPERTIES POSITION_INDEPENDENT_CODE TRUE) + target_link_libraries(${PEDAL_NAME}_SharedCode INTERFACE ${PEDAL_NAME}_Assets) + endif() +endfunction() + +# Builds the standard link library list for a plugin. +# Usage: get_plugin_link_libraries(OUT_VAR) +function(get_plugin_link_libraries OUT_VAR) + set(_LIBS + juce_audio_utils + juce_audio_processors + juce_dsp + juce_gui_basics + juce_gui_extra + juce::juce_recommended_config_flags + juce::juce_recommended_lto_flags + juce::juce_recommended_warning_flags) + + # Append module libraries + get_module_link_libraries(_MODULE_LIBS) + list(APPEND _LIBS ${_MODULE_LIBS}) + + set(${OUT_VAR} "${_LIBS}" PARENT_SCOPE) +endfunction() diff --git a/cmake-local/Packaging.cmake b/cmake-local/Packaging.cmake new file mode 100644 index 0000000..572b718 --- /dev/null +++ b/cmake-local/Packaging.cmake @@ -0,0 +1,78 @@ +# Packaging.cmake +# Cross-platform installer generation for plugin distribution. +# +# Usage: +# include(Packaging) +# setup_packaging("${PROJ_NAME}" "${PROJ_PRODUCT_NAME}" "${CURRENT_VERSION}") +# +# Generates: +# macOS: .pkg installer (use `cpack` after build) +# Windows: NSIS installer (requires NSIS installed) +# Linux: .tar.gz archive +# +# Code signing: +# Set CODESIGN_IDENTITY (macOS) or SIGNTOOL_PATH (Windows) before including. + +function(setup_packaging TARGET_NAME PRODUCT_NAME VERSION) + set(CPACK_PACKAGE_NAME "${PRODUCT_NAME}") + set(CPACK_PACKAGE_VERSION "${VERSION}") + set(CPACK_PACKAGE_VENDOR "DirektDSP") + set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${PRODUCT_NAME} Audio Plugin") + set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_SOURCE_DIR}/LICENSE") + + if(APPLE) + set(CPACK_GENERATOR "productbuild") + set(CPACK_PRODUCTBUILD_IDENTITY "${CODESIGN_IDENTITY}" CACHE STRING + "Apple Developer ID for code signing (leave empty to skip)") + + # Install VST3 to standard location + install(DIRECTORY "${CMAKE_BINARY_DIR}/${TARGET_NAME}_artefacts/$/VST3/" + DESTINATION "Library/Audio/Plug-Ins/VST3" + COMPONENT VST3) + + # Install AU to standard location + install(DIRECTORY "${CMAKE_BINARY_DIR}/${TARGET_NAME}_artefacts/$/AU/" + DESTINATION "Library/Audio/Plug-Ins/Components" + COMPONENT AU) + + # Install CLAP to standard location + if(ENABLE_CLAP) + install(DIRECTORY "${CMAKE_BINARY_DIR}/${TARGET_NAME}_artefacts/$/CLAP/" + DESTINATION "Library/Audio/Plug-Ins/CLAP" + COMPONENT CLAP) + endif() + + elseif(WIN32) + set(CPACK_GENERATOR "NSIS") + set(CPACK_NSIS_DISPLAY_NAME "${PRODUCT_NAME}") + set(CPACK_NSIS_PACKAGE_NAME "${PRODUCT_NAME}") + set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL ON) + + # Standard Windows VST3 path + install(DIRECTORY "${CMAKE_BINARY_DIR}/${TARGET_NAME}_artefacts/$/VST3/" + DESTINATION "Common Files/VST3" + COMPONENT VST3) + + if(ENABLE_CLAP) + install(DIRECTORY "${CMAKE_BINARY_DIR}/${TARGET_NAME}_artefacts/$/CLAP/" + DESTINATION "Common Files/CLAP" + COMPONENT CLAP) + endif() + + else() + # Linux + set(CPACK_GENERATOR "TGZ") + + install(DIRECTORY "${CMAKE_BINARY_DIR}/${TARGET_NAME}_artefacts/$/VST3/" + DESTINATION "lib/vst3" + COMPONENT VST3) + + if(ENABLE_CLAP) + install(DIRECTORY "${CMAKE_BINARY_DIR}/${TARGET_NAME}_artefacts/$/CLAP/" + DESTINATION "lib/clap" + COMPONENT CLAP) + endif() + endif() + + include(CPack) +endfunction() diff --git a/cmake-local/PedalMoonbase.cmake b/cmake-local/PedalMoonbase.cmake new file mode 100644 index 0000000..ac7e78e --- /dev/null +++ b/cmake-local/PedalMoonbase.cmake @@ -0,0 +1,61 @@ +# PedalMoonbase.cmake +# Sets up a per-plugin copy of moonbase_JUCEClient with its own config. +# Each plugin gets its own copy so that per-plugin generated headers +# (ConfigDetails, IntegrityCheck) don't conflict across plugins. +# +# Set DISABLE_MOONBASE_LICENSING=ON to build without licensing (dev builds). +# +# Usage: +# pedal_setup_moonbase( +# TARGET +# CONFIG_JSON +# ) + +option(DISABLE_MOONBASE_LICENSING "Disable Moonbase licensing for dev builds" OFF) + +function(pedal_setup_moonbase) + cmake_parse_arguments(MB "" "TARGET;CONFIG_JSON" "" ${ARGN}) + + if(NOT MB_TARGET OR NOT MB_CONFIG_JSON) + message(FATAL_ERROR "pedal_setup_moonbase requires TARGET and CONFIG_JSON") + endif() + + if(DISABLE_MOONBASE_LICENSING) + message(STATUS "Moonbase DISABLED for ${MB_TARGET} (dev build)") + target_compile_definitions(${MB_TARGET} PRIVATE + PEDALSUITE_NO_MOONBASE=1) + target_include_directories(${MB_TARGET} PRIVATE + "${CMAKE_SOURCE_DIR}/cmake-local") + return() + endif() + + set(MB_SOURCE_DIR "${CMAKE_SOURCE_DIR}/modules/moonbase_JUCEClient") + set(MB_DEST_DIR "${CMAKE_CURRENT_BINARY_DIR}/moonbase_JUCEClient") + + # Copy moonbase module to per-plugin build directory + file(COPY "${MB_SOURCE_DIR}/" + DESTINATION "${MB_DEST_DIR}" + PATTERN ".git" EXCLUDE) + + # Run PreBuild.sh with this plugin's config + execute_process( + COMMAND bash "${MB_DEST_DIR}/PreBuild.sh" "${MB_CONFIG_JSON}" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + RESULT_VARIABLE MB_RESULT) + + if(NOT MB_RESULT EQUAL 0) + message(FATAL_ERROR "moonbase PreBuild.sh failed for ${MB_TARGET}") + endif() + + target_sources(${MB_TARGET} PRIVATE "${MB_DEST_DIR}/moonbase_JUCEClient.cpp") + + # Add the per-plugin build dir BEFORE other includes so per-plugin headers win + target_include_directories(${MB_TARGET} BEFORE PRIVATE "${MB_DEST_DIR}") + + target_compile_definitions(${MB_TARGET} PRIVATE + JUCE_MODULE_AVAILABLE_moonbase_JUCEClient=1 + INCLUDE_MOONBASE_UI=0) + + target_link_libraries(${MB_TARGET} PRIVATE + juce_product_unlocking) +endfunction() diff --git a/cmake-local/ReadProjectConfig.cmake b/cmake-local/ReadProjectConfig.cmake new file mode 100644 index 0000000..f6f12d0 --- /dev/null +++ b/cmake-local/ReadProjectConfig.cmake @@ -0,0 +1,109 @@ +# ReadProjectConfig.cmake +# Parses a subset of TOML (project.toml) into CMake variables. +# Uses a line-by-line approach to avoid CMake regex issues with brackets. + +function(read_project_config CONFIG_FILE) + if(NOT EXISTS "${CONFIG_FILE}") + message(FATAL_ERROR "project.toml not found at ${CONFIG_FILE}. " + "Create one or run the DirektDSP Configurator to generate it.") + endif() + + # Read file as a list of lines + file(STRINGS "${CONFIG_FILE}" _LINES) + + # Parse into a flat key-value map: section.key = value + set(_CURRENT_SECTION "") + foreach(_LINE IN LISTS _LINES) + # Skip comments and empty lines + string(STRIP "${_LINE}" _LINE) + if("${_LINE}" STREQUAL "" OR "${_LINE}" MATCHES "^#") + continue() + endif() + + # Detect section headers: [section] + if("${_LINE}" MATCHES "^\\[([a-zA-Z_]+)\\]") + set(_CURRENT_SECTION "${CMAKE_MATCH_1}") + continue() + endif() + + # Parse key = value + if("${_LINE}" MATCHES "^([a-zA-Z_]+)[ \t]*=[ \t]*(.*)") + set(_KEY "${CMAKE_MATCH_1}") + set(_VAL "${CMAKE_MATCH_2}") + set("_TOML_${_CURRENT_SECTION}_${_KEY}" "${_VAL}") + endif() + endforeach() + + # Helper: extract a quoted string value + macro(_get_str SECTION KEY OUT) + set(_RAW "${_TOML_${SECTION}_${KEY}}") + if("${_RAW}" MATCHES "^\"(.*)\"$") + set(${OUT} "${CMAKE_MATCH_1}") + else() + string(STRIP "${_RAW}" ${OUT}) + endif() + endmacro() + + # Helper: extract a boolean value + macro(_get_bool SECTION KEY OUT) + set(_RAW "${_TOML_${SECTION}_${KEY}}") + string(STRIP "${_RAW}" _RAW) + if("${_RAW}" STREQUAL "true") + set(${OUT} TRUE) + else() + set(${OUT} FALSE) + endif() + endmacro() + + # Helper: extract an array of quoted strings → CMake list + macro(_get_array SECTION KEY OUT) + set(_RAW "${_TOML_${SECTION}_${KEY}}") + set(${OUT} "") + string(REGEX MATCHALL "\"([^\"]*)\"" _MATCHES "${_RAW}") + foreach(_M IN LISTS _MATCHES) + string(REGEX REPLACE "\"(.*)\"" "\\1" _ITEM "${_M}") + list(APPEND ${OUT} "${_ITEM}") + endforeach() + endmacro() + + # Parse [project] section + _get_str(project name PROJ_NAME) + _get_str(project product_name PROJ_PRODUCT_NAME) + _get_str(project company_name PROJ_COMPANY_NAME) + _get_str(project bundle_id PROJ_BUNDLE_ID) + _get_str(project manufacturer_code PROJ_MANUFACTURER_CODE) + _get_str(project plugin_code PROJ_PLUGIN_CODE) + _get_str(project version PROJ_VERSION) + _get_str(project clap_features PROJ_CLAP_FEATURES) + _get_array(project formats PROJ_FORMATS) + _get_bool(project multi_plugin PROJ_IS_MULTI_PLUGIN) + _get_array(project plugins PROJ_PLUGINS) + + # Parse [build] section + _get_str(build cpp_standard PROJ_CPP_STANDARD) + if("${PROJ_CPP_STANDARD}" STREQUAL "") + set(PROJ_CPP_STANDARD "20") + endif() + _get_bool(build copy_after_build PROJ_COPY_AFTER_BUILD) + _get_bool(build ipp PROJ_IPP) + + # Propagate all to parent scope + set(PROJ_NAME "${PROJ_NAME}" PARENT_SCOPE) + set(PROJ_PRODUCT_NAME "${PROJ_PRODUCT_NAME}" PARENT_SCOPE) + set(PROJ_COMPANY_NAME "${PROJ_COMPANY_NAME}" PARENT_SCOPE) + set(PROJ_BUNDLE_ID "${PROJ_BUNDLE_ID}" PARENT_SCOPE) + set(PROJ_MANUFACTURER_CODE "${PROJ_MANUFACTURER_CODE}" PARENT_SCOPE) + set(PROJ_PLUGIN_CODE "${PROJ_PLUGIN_CODE}" PARENT_SCOPE) + set(PROJ_VERSION "${PROJ_VERSION}" PARENT_SCOPE) + set(PROJ_CLAP_FEATURES "${PROJ_CLAP_FEATURES}" PARENT_SCOPE) + set(PROJ_FORMATS "${PROJ_FORMATS}" PARENT_SCOPE) + set(PROJ_IS_MULTI_PLUGIN "${PROJ_IS_MULTI_PLUGIN}" PARENT_SCOPE) + set(PROJ_PLUGINS "${PROJ_PLUGINS}" PARENT_SCOPE) + set(PROJ_CPP_STANDARD "${PROJ_CPP_STANDARD}" PARENT_SCOPE) + set(PROJ_COPY_AFTER_BUILD "${PROJ_COPY_AFTER_BUILD}" PARENT_SCOPE) + set(PROJ_IPP "${PROJ_IPP}" PARENT_SCOPE) + + message(STATUS "Project: ${PROJ_NAME} (${PROJ_PRODUCT_NAME}) by ${PROJ_COMPANY_NAME}") + message(STATUS "Formats: ${PROJ_FORMATS}") + message(STATUS "Multi-plugin: ${PROJ_IS_MULTI_PLUGIN}") +endfunction() diff --git a/cmake-local/Sanitizers.cmake b/cmake-local/Sanitizers.cmake new file mode 100644 index 0000000..482118f --- /dev/null +++ b/cmake-local/Sanitizers.cmake @@ -0,0 +1,28 @@ +option(WITH_ADDRESS_SANITIZER "Enable Address Sanitizer (ASan + UBSan)" OFF) +option(WITH_THREAD_SANITIZER "Enable Thread Sanitizer (TSan)" OFF) + +message(STATUS "Sanitizers: ASan=${WITH_ADDRESS_SANITIZER} TSan=${WITH_THREAD_SANITIZER}") + +if(WITH_ADDRESS_SANITIZER AND WITH_THREAD_SANITIZER) + message(FATAL_ERROR "ASan and TSan cannot be used simultaneously.") +endif() + +if(WITH_ADDRESS_SANITIZER) + if(MSVC) + add_compile_options(/fsanitize=address) + elseif(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer -g) + add_link_options(-fsanitize=address,undefined) + endif() + message(WARNING "Address Sanitizer enabled — do not use for release builds") +endif() + +if(WITH_THREAD_SANITIZER) + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + add_compile_options(-fsanitize=thread -fno-omit-frame-pointer -g) + add_link_options(-fsanitize=thread) + message(WARNING "Thread Sanitizer enabled — do not use for release builds") + else() + message(WARNING "Thread Sanitizer is not supported on MSVC") + endif() +endif() diff --git a/cmake-local/WebUI.cmake b/cmake-local/WebUI.cmake new file mode 100644 index 0000000..5339bbf --- /dev/null +++ b/cmake-local/WebUI.cmake @@ -0,0 +1,26 @@ +# Builds the Vue UI with bun before the plugin links. Requires `bun` on PATH. + +find_program(WVG_BUN_EXECUTABLE bun DOC "Bun JavaScript runtime") + +if(NOT WVG_BUN_EXECUTABLE) + message(FATAL_ERROR "WavGang Web UI requires bun (https://bun.sh). Install bun and re-run CMake.") +endif() + +set(WVG_WEBUI_SOURCE_DIR "${CMAKE_SOURCE_DIR}/ui") +set(WVG_WEBUI_DIST "${WVG_WEBUI_SOURCE_DIR}/dist") + +add_custom_command( + OUTPUT "${WVG_WEBUI_DIST}/index.html" + COMMAND ${WVG_BUN_EXECUTABLE} install + COMMAND ${WVG_BUN_EXECUTABLE} run build + WORKING_DIRECTORY "${WVG_WEBUI_SOURCE_DIR}" + DEPENDS + "${WVG_WEBUI_SOURCE_DIR}/package.json" + "${WVG_WEBUI_SOURCE_DIR}/vite.config.ts" + "${WVG_WEBUI_SOURCE_DIR}/index.html" + COMMENT "Building WavGang Vue UI (bun)" + VERBATIM) + +add_custom_target(wavgang_webui ALL DEPENDS "${WVG_WEBUI_DIST}/index.html") + +set(WVG_WEBUI_ROOT "${WVG_WEBUI_DIST}" CACHE PATH "Built Web UI directory (served at dev time and fallback path)") diff --git a/cmake-local/options.cmake b/cmake-local/options.cmake new file mode 100644 index 0000000..c49aaaf --- /dev/null +++ b/cmake-local/options.cmake @@ -0,0 +1,10 @@ +# options.cmake - Generated by DirektDSP Configurator +# Sets default values for module toggle options. +# These are CACHE variables, so they can be overridden with -D flags. + +set(ENABLE_MOONBASE OFF CACHE BOOL "Enable Moonbase licensing") +set(ENABLE_MELATONIN ON CACHE BOOL "Enable Melatonin Inspector") +set(ENABLE_CLAP ON CACHE BOOL "Enable CLAP plugin format") +set(ENABLE_DIREKTDSP_GUI OFF CACHE BOOL "Enable DirektDSP GUI framework") +set(ENABLE_CYCFI_Q OFF CACHE BOOL "Enable cycfi::Q DSP library") +set(ENABLE_COMMON_LAYER ON CACHE BOOL "Enable common implementation layer") diff --git a/common/CMakeLists.txt b/common/CMakeLists.txt new file mode 100644 index 0000000..d8d1639 --- /dev/null +++ b/common/CMakeLists.txt @@ -0,0 +1,10 @@ +add_library(DirektCommonLayer INTERFACE) + +target_include_directories(DirektCommonLayer INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}") + +target_link_libraries(DirektCommonLayer INTERFACE + juce_audio_basics + juce_audio_processors + juce_dsp + juce_gui_basics + juce_core) diff --git a/common/IPluginProcessor.h b/common/IPluginProcessor.h new file mode 100644 index 0000000..8ba4754 --- /dev/null +++ b/common/IPluginProcessor.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +namespace DirektDSP::Common +{ + /// Abstract DSP processing interface. + /// Plugins implement this so they can be loaded by both standalone + /// JUCE plugin hosts and DirektDSP ecosystem hosts (e.g., multi-plugin apps). + template + class IPluginProcessor + { + public: + virtual ~IPluginProcessor() = default; + + /// Called once before processing starts. Use to allocate buffers, etc. + virtual void prepare (const juce::dsp::ProcessSpec& spec) = 0; + + /// Process a block of audio in-place. + virtual void process (juce::AudioBuffer& buffer, + juce::MidiBuffer& midi) = 0; + + /// Release any allocated resources. + virtual void release() = 0; + + /// Reset internal state (e.g., clear delay lines, filters). + virtual void reset() = 0; + + /// Return latency in samples introduced by this processor. + virtual int getLatencySamples() const { return 0; } + + /// Return tail length in samples (e.g., reverb tail). + virtual int getTailLengthSamples() const { return 0; } + }; + +} // namespace DirektDSP::Common diff --git a/common/IPluginState.h b/common/IPluginState.h new file mode 100644 index 0000000..44597a0 --- /dev/null +++ b/common/IPluginState.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +namespace DirektDSP::Common +{ + /// Standard interface for serializable plugin state. + /// Provides a uniform way to get/set parameters and serialize state, + /// enabling consistent preset management and state recall across the ecosystem. + class IPluginState + { + public: + virtual ~IPluginState() = default; + + /// Access the AudioProcessorValueTreeState for parameter binding. + virtual juce::AudioProcessorValueTreeState& getAPVTS() = 0; + + /// Serialize the full plugin state (parameters + custom data) to a memory block. + virtual void serialize (juce::MemoryBlock& destData) const = 0; + + /// Deserialize state from raw data. + virtual void deserialize (const void* data, int sizeInBytes) = 0; + + /// Get a description of the parameter layout for runtime discovery. + /// Returns an XML or JSON string describing all parameters. + virtual juce::String getParameterLayoutDescription() const = 0; + }; + +} // namespace DirektDSP::Common diff --git a/common/IPluginUI.h b/common/IPluginUI.h new file mode 100644 index 0000000..1bb71e3 --- /dev/null +++ b/common/IPluginUI.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +namespace DirektDSP::Common +{ + /// Interface for embedding a plugin's UI in different contexts. + /// Allows plugin UIs to be hosted in standalone windows, within + /// multi-plugin hosts, or in other container applications. + class IPluginUI + { + public: + virtual ~IPluginUI() = default; + + /// Create the editor component. Caller takes ownership. + virtual std::unique_ptr createEditorComponent() = 0; + + /// Preferred size for this plugin's UI. + virtual juce::Rectangle getPreferredSize() const = 0; + + /// Whether the UI supports resizing. + virtual bool isResizable() const { return false; } + + /// Minimum size if resizable. + virtual juce::Rectangle getMinimumSize() const { return getPreferredSize(); } + + /// Maximum size if resizable. + virtual juce::Rectangle getMaximumSize() const { return getPreferredSize(); } + }; + +} // namespace DirektDSP::Common diff --git a/common/PluginBus.h b/common/PluginBus.h new file mode 100644 index 0000000..902d273 --- /dev/null +++ b/common/PluginBus.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include +#include + +namespace DirektDSP::Common +{ + /// Simple pub/sub message bus for inter-plugin communication. + /// Used in multi-plugin hosts to allow plugins to exchange messages + /// (e.g., tempo sync, parameter linking, sidechain routing signals). + class PluginBus + { + public: + using MessageHandler = std::function; + + /// Subscribe to a named channel. + void subscribe (const juce::String& channel, + const juce::String& subscriberId, + MessageHandler handler) + { + const juce::ScopedLock sl (lock); + channels[channel].push_back ({ subscriberId, std::move (handler) }); + } + + /// Unsubscribe from a named channel. + void unsubscribe (const juce::String& channel, + const juce::String& subscriberId) + { + const juce::ScopedLock sl (lock); + auto it = channels.find (channel); + if (it != channels.end()) + { + auto& subs = it->second; + subs.erase ( + std::remove_if (subs.begin(), subs.end(), + [&] (const Subscription& s) { return s.id == subscriberId; }), + subs.end()); + } + } + + /// Publish a message to all subscribers of a channel. + void publish (const juce::String& channel, const juce::var& message) + { + const juce::ScopedLock sl (lock); + auto it = channels.find (channel); + if (it != channels.end()) + { + for (auto& sub : it->second) + sub.handler (message); + } + } + + /// Remove all subscriptions for a given subscriber ID (e.g., on plugin unload). + void unsubscribeAll (const juce::String& subscriberId) + { + const juce::ScopedLock sl (lock); + for (auto& [channel, subs] : channels) + { + subs.erase ( + std::remove_if (subs.begin(), subs.end(), + [&] (const Subscription& s) { return s.id == subscriberId; }), + subs.end()); + } + } + + private: + struct Subscription + { + juce::String id; + MessageHandler handler; + }; + + std::unordered_map> channels; + juce::CriticalSection lock; + }; + +} // namespace DirektDSP::Common diff --git a/common/PluginDescriptor.h b/common/PluginDescriptor.h new file mode 100644 index 0000000..240234f --- /dev/null +++ b/common/PluginDescriptor.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +namespace DirektDSP::Common +{ + /// Runtime metadata for a plugin in the DirektDSP ecosystem. + /// Used by multi-plugin hosts to discover and describe loaded plugins. + struct PluginDescriptor + { + juce::String name; + juce::String version; + juce::String companyName; + juce::String bundleId; + juce::String category; // "effect", "instrument", "analyzer", "utility" + int numInputChannels = 2; + int numOutputChannels = 2; + bool acceptsMidi = false; + bool producesMidi = false; + + /// Unique identifier within the ecosystem (typically the bundle ID). + juce::String getUniqueId() const { return bundleId; } + }; + +} // namespace DirektDSP::Common diff --git a/docs/BRIDGE_API.md b/docs/BRIDGE_API.md new file mode 100644 index 0000000..d0a198d --- /dev/null +++ b/docs/BRIDGE_API.md @@ -0,0 +1,27 @@ +# wavgang-bridge HTTP API (v1) + +Base URL: `http://127.0.0.1:17890` (override with `WAVGANG_BRIDGE_ADDR` when starting the bridge). + +## GET /v1/status + +Returns JSON: + +```json +{ + "ok": true, + "service": "wavgang-bridge", + "friendnet": "not_connected" +} +``` + +## GET /v1/version + +Returns JSON: + +```json +{ + "version": "0.1.0" +} +``` + +Future endpoints (search, downloads, shares) will be added without breaking the above. diff --git a/docs/JS_BRIDGE.md b/docs/JS_BRIDGE.md new file mode 100644 index 0000000..4280d97 --- /dev/null +++ b/docs/JS_BRIDGE.md @@ -0,0 +1,12 @@ +# JUCE native integration (WebView) + +The plugin registers a native function **`getBridgeBaseUrl`** (see `PluginEditor.cpp`) via `WebBrowserComponent::Options::withNativeFunction`. + +From the Vue app, prefer the helper in `ui/src/nativeBridge.ts`, which: + +1. Uses the JUCE frontend bridge when `window.__JUCE__.backend.getNativeFunction` is available. +2. Falls back to `http://127.0.0.1:17890` in dev / tests. + +When you add more native capabilities (folder pickers, drag-out, etc.), register additional `withNativeFunction` entries in C++ and wrap them in `nativeBridge.ts` the same way. + +**Security:** only load your bundled `dist/` assets in release builds; do not enable arbitrary third-party origins with `withResourceProvider` allowed origins in production. diff --git a/modules.toml b/modules.toml new file mode 100644 index 0000000..2cd9943 --- /dev/null +++ b/modules.toml @@ -0,0 +1,76 @@ +# DirektDSP Module Registry +# Declares all available modules for the PluginTemplate. +# Read by the TUI configurator for module selection and dependency resolution. +# Read by ModuleSystem.cmake for conditional module inclusion. + +[modules.moonbase_JUCEClient] +display_name = "Moonbase Licensing" +description = "DRM/licensing system via Moonbase.sh" +url = "https://github.com/AntaresMatt/moonbase_JUCEClient.git" +branch = "main" +cmake_option = "ENABLE_MOONBASE" +submodule_path = "modules/moonbase_JUCEClient" +cmake_target = "moonbase_JUCEClient" +provides = ["licensing"] +requires = [] +conflicts = [] + +[modules.melatonin_inspector] +display_name = "Melatonin Inspector" +description = "Debug UI overlay for inspecting JUCE components" +url = "https://github.com/sudara/melatonin_inspector.git" +branch = "main" +cmake_option = "ENABLE_MELATONIN" +submodule_path = "modules/melatonin_inspector" +cmake_target = "melatonin_inspector" +provides = ["debug_ui"] +requires = [] +conflicts = [] + +[modules.clap_juce_extensions] +display_name = "CLAP Format" +description = "CLAP plugin format support via clap-juce-extensions" +url = "https://github.com/free-audio/clap-juce-extensions.git" +branch = "main" +cmake_option = "ENABLE_CLAP" +submodule_path = "modules/clap-juce-extensions" +cmake_target = "clap_juce_extensions" +provides = ["clap"] +requires = [] +conflicts = [] + +[modules.DirektDSP_GUI] +display_name = "DirektDSP GUI Framework" +description = "Shared GUI framework with config-driven editor, controls, and theming" +url = "https://github.com/DirektDSP/DirektDSP_GUI.git" +branch = "main" +cmake_option = "ENABLE_DIREKTDSP_GUI" +submodule_path = "modules/DirektDSP_GUI" +cmake_target = "DirektDSP_GUI" +provides = ["gui_framework"] +requires = [] +conflicts = [] + +[modules.cycfi_q] +display_name = "Cycfi Q DSP Library" +description = "C++ DSP library for pitch detection and audio processing" +url = "https://github.com/cycfi/q.git" +branch = "master" +cmake_option = "ENABLE_CYCFI_Q" +submodule_path = "modules/cycfi_q" +cmake_target = "cycfi_q" +provides = ["cycfi_dsp"] +requires = ["cycfi_infra"] +conflicts = [] + +[modules.cycfi_infra] +display_name = "Cycfi Infrastructure" +description = "Base infrastructure library for Cycfi libraries" +url = "https://github.com/cycfi/infra.git" +branch = "master" +cmake_option = "ENABLE_CYCFI_INFRA" +submodule_path = "modules/cycfi_infra" +cmake_target = "cycfi_infra" +provides = ["cycfi_infra"] +requires = [] +conflicts = [] diff --git a/modules/clap-juce-extensions b/modules/clap-juce-extensions new file mode 160000 index 0000000..1de44e1 --- /dev/null +++ b/modules/clap-juce-extensions @@ -0,0 +1 @@ +Subproject commit 1de44e1eca9878624ec9cee578dda4e35355af18 diff --git a/modules/melatonin_inspector b/modules/melatonin_inspector new file mode 160000 index 0000000..9e91e4e --- /dev/null +++ b/modules/melatonin_inspector @@ -0,0 +1 @@ +Subproject commit 9e91e4e3d6cc41688c8d2108ef7ed33c1a90dcc9 diff --git a/packaging/.gitkeep b/packaging/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packaging/agpl/FriendNet-LICENSE.txt b/packaging/agpl/FriendNet-LICENSE.txt new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/packaging/agpl/FriendNet-LICENSE.txt @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packaging/agpl/SOURCE_OFFER.txt b/packaging/agpl/SOURCE_OFFER.txt new file mode 100644 index 0000000..de6d46b --- /dev/null +++ b/packaging/agpl/SOURCE_OFFER.txt @@ -0,0 +1,10 @@ +WavGang ships AGPL-licensed components separately from the closed-source JUCE plugin. + +Source for the localhost bridge: + https://github.com/DirektDSP/WavGang (directory: bridge/) + +Source for FriendNet (server / protocol): + https://github.com/DirektDSP/FriendNet + +Corresponding binaries are named wavgang-bridge and friendnet-server (or as documented in release notes). +Offer valid for three years from distribution; contact the copyright holder for media. diff --git a/packaging/icon.png b/packaging/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..769b105d028f0892e37c2f5a46a7596b9e0731d0 GIT binary patch literal 77 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61SBU+%rFB|BAzaeAr*6yFDM8a^khj~HK;LV ZU^siSg7HcxD;rRr!PC{xWt~$(697Ls5z+tv literal 0 HcmV?d00001 diff --git a/plugins/.gitkeep b/plugins/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/project.toml b/project.toml new file mode 100644 index 0000000..a8d6de2 --- /dev/null +++ b/project.toml @@ -0,0 +1,28 @@ +# project.toml - Plugin project configuration +schema_version = "1.0" + +[project] +name = "WavGang" +product_name = "WavGang" +company_name = "DirektDSP" +bundle_id = "com.direktdsp.wavgang" +manufacturer_code = "DkDs" +plugin_code = "WvGn" +version = "0.1.0" +formats = ["VST3", "AU", "CLAP", "Standalone"] +clap_features = "instrument" +multi_plugin = false +plugins = [] + +[modules] +moonbase = false +melatonin = true +clap = true +direktdsp_gui = false +cycfi_q = false +common_layer = true + +[build] +cpp_standard = 20 +copy_after_build = true +ipp = false diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 0000000..54a4079 --- /dev/null +++ b/scripts/build.ps1 @@ -0,0 +1,31 @@ +# Build script for PluginTemplate-based projects (Windows). +# Usage: .\scripts\build.ps1 [Release|Debug] [jobs] + +param( + [string]$BuildType = "Release", + [int]$Jobs = (Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors +) + +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ProjectDir = Split-Path -Parent $ScriptDir +$BuildDir = Join-Path $ProjectDir "build" + +Write-Host "=== DirektDSP Plugin Build ===" -ForegroundColor Cyan +Write-Host "Build type: $BuildType" +Write-Host "Jobs: $Jobs" +Write-Host "" + +# Configure +Write-Host "--- Configuring CMake ---" -ForegroundColor Yellow +cmake -B $BuildDir -S $ProjectDir -DCMAKE_BUILD_TYPE="$BuildType" + +# Build +Write-Host "" +Write-Host "--- Building ---" -ForegroundColor Yellow +cmake --build $BuildDir -j $Jobs + +Write-Host "" +Write-Host "=== Build complete ===" -ForegroundColor Green +Write-Host "Build output: $BuildDir" diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..a8be507 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Build script for PluginTemplate-based projects. +# Usage: ./scripts/build.sh [Release|Debug] [jobs] + +set -euo pipefail + +BUILD_TYPE="${1:-Release}" +JOBS="${2:-$(nproc)}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +BUILD_DIR="${PROJECT_DIR}/build" + +echo "=== DirektDSP Plugin Build ===" +echo "Build type: ${BUILD_TYPE}" +echo "Jobs: ${JOBS}" +echo "" + +# Configure +echo "--- Configuring CMake ---" +cmake -B "$BUILD_DIR" -S "$PROJECT_DIR" \ + -DCMAKE_BUILD_TYPE="$BUILD_TYPE" + +# Build +echo "" +echo "--- Building ---" +cmake --build "$BUILD_DIR" -j"$JOBS" + +echo "" +echo "=== Build complete ===" +echo "Build output: $BUILD_DIR" diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 new file mode 100644 index 0000000..51ff421 --- /dev/null +++ b/scripts/setup.ps1 @@ -0,0 +1,51 @@ +# Setup script for PluginTemplate-based projects (Windows). +# Initializes required git submodules based on enabled modules. +# Usage: .\scripts\setup.ps1 + +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ProjectDir = Split-Path -Parent $ScriptDir + +Write-Host "=== DirektDSP Plugin Project Setup ===" -ForegroundColor Cyan +Write-Host "Project directory: $ProjectDir" +Write-Host "" + +Set-Location $ProjectDir + +# Always initialize core submodules +Write-Host "--- Initializing core submodules ---" -ForegroundColor Yellow +git submodule update --init JUCE +git submodule update --init cmake + +# Read options.cmake to determine which modules to init +$OptionsFile = Join-Path $ProjectDir "cmake-local\options.cmake" + +function Init-IfEnabled { + param( + [string]$OptionName, + [string]$SubmodulePath, + [string]$DisplayName + ) + + if (Test-Path $OptionsFile) { + $content = Get-Content $OptionsFile -Raw + if ($content -match "set\($OptionName ON") { + Write-Host "--- Initializing $DisplayName ---" -ForegroundColor Yellow + git submodule update --init $SubmodulePath + return + } + } + Write-Host "--- Skipping $DisplayName (disabled) ---" -ForegroundColor DarkGray +} + +Init-IfEnabled "ENABLE_CLAP" "modules/clap-juce-extensions" "CLAP extensions" +Init-IfEnabled "ENABLE_MELATONIN" "modules/melatonin_inspector" "Melatonin Inspector" +Init-IfEnabled "ENABLE_MOONBASE" "modules/moonbase_JUCEClient" "Moonbase licensing" +Init-IfEnabled "ENABLE_DIREKTDSP_GUI" "modules/DirektDSP_GUI" "DirektDSP GUI" +Init-IfEnabled "ENABLE_CYCFI_Q" "modules/cycfi_q" "Cycfi Q" +Init-IfEnabled "ENABLE_CYCFI_Q" "modules/cycfi_infra" "Cycfi Infra" + +Write-Host "" +Write-Host "=== Setup complete ===" -ForegroundColor Green +Write-Host "Next: cmake -B build; cmake --build build" diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..4bd4672 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Setup script for PluginTemplate-based projects. +# Initializes required git submodules based on enabled modules. +# Usage: ./scripts/setup.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "=== DirektDSP Plugin Project Setup ===" +echo "Project directory: $PROJECT_DIR" +echo "" + +cd "$PROJECT_DIR" + +# Always initialize core submodules +echo "--- Initializing core submodules ---" +git submodule update --init JUCE +git submodule update --init cmake + +# Read options.cmake to determine which modules to init +OPTIONS_FILE="$PROJECT_DIR/cmake-local/options.cmake" + +init_if_enabled() { + local option_name="$1" + local submodule_path="$2" + local display_name="$3" + + if [ -f "$OPTIONS_FILE" ] && grep -q "set(${option_name} ON" "$OPTIONS_FILE"; then + echo "--- Initializing ${display_name} ---" + git submodule update --init "$submodule_path" + else + echo "--- Skipping ${display_name} (disabled) ---" + fi +} + +init_if_enabled "ENABLE_CLAP" "modules/clap-juce-extensions" "CLAP extensions" +if [ -f "$OPTIONS_FILE" ] && grep -q "set(ENABLE_CLAP ON" "$OPTIONS_FILE"; then + echo "--- Initializing CLAP nested libraries ---" + git submodule update --init --recursive modules/clap-juce-extensions +fi +init_if_enabled "ENABLE_MELATONIN" "modules/melatonin_inspector" "Melatonin Inspector" +init_if_enabled "ENABLE_MOONBASE" "modules/moonbase_JUCEClient" "Moonbase licensing" +init_if_enabled "ENABLE_DIREKTDSP_GUI" "modules/DirektDSP_GUI" "DirektDSP GUI" +init_if_enabled "ENABLE_CYCFI_Q" "modules/cycfi_q" "Cycfi Q" +init_if_enabled "ENABLE_CYCFI_Q" "modules/cycfi_infra" "Cycfi Infra" + +echo "" +echo "=== Setup complete ===" +echo "Next: cmake -B build && cmake --build build" diff --git a/source/BridgeClient.cpp b/source/BridgeClient.cpp new file mode 100644 index 0000000..8f45157 --- /dev/null +++ b/source/BridgeClient.cpp @@ -0,0 +1,20 @@ +#include "BridgeClient.h" + +BridgeClient::BridgeClient() = default; + +void BridgeClient::setBaseUrl (juce::StringRef url) +{ + baseUrl = juce::String (url); +} + +bool BridgeClient::fetchStatusJson (juce::String& outJson) const +{ + const juce::URL url (baseUrl + "/v1/status"); + const auto txt = url.readEntireTextStream (false); + + if (txt.isEmpty()) + return false; + + outJson = txt; + return true; +} diff --git a/source/BridgeClient.h b/source/BridgeClient.h new file mode 100644 index 0000000..ec4c472 --- /dev/null +++ b/source/BridgeClient.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +/** HTTP client for the local wavgang-bridge (localhost REST). All work off the audio thread. */ +class BridgeClient +{ +public: + BridgeClient(); + + void setBaseUrl (juce::StringRef url); + [[nodiscard]] juce::String getBaseUrl() const { return baseUrl; } + + /** Synchronous GET for startup checks; keep off message thread only for short calls. */ + [[nodiscard]] bool fetchStatusJson (juce::String& outJson) const; + +private: + juce::String baseUrl { "http://127.0.0.1:17890" }; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (BridgeClient) +}; diff --git a/source/DSP/Core/ProcessorCore.h b/source/DSP/Core/ProcessorCore.h new file mode 100644 index 0000000..4e06e87 --- /dev/null +++ b/source/DSP/Core/ProcessorCore.h @@ -0,0 +1,234 @@ +#pragma once + +#include +#include +#include "../Utils/ParameterSmoother.h" +#include "../Utils/DSPUtils.h" +#include "../Utils/MeteringFIFO.h" + +namespace DSP +{ +namespace Core +{ + + /** + * Template DSP processor scaffold. + * + * Demonstrates the recommended patterns: + * - Float/double template support + * - Parameter smoothing with per-parameter timing + * - Wet/dry mixing with separate buffers + * - DSP component update throttling (every N samples) + * - Proper bypass with crossfade ramping + * - Denormal protection via ScopedNoDenormals + * - Lock-free metering output + * + * To use: subclass or copy this pattern into your plugin's DSP engine. + */ + template + class ProcessorCore + { + public: + ProcessorCore() = default; + virtual ~ProcessorCore() = default; + + void prepare (const juce::dsp::ProcessSpec& spec, + SampleType initialInputGain = SampleType { 1.0 }, + SampleType initialOutputGain = SampleType { 1.0 }, + SampleType initialMix = SampleType { 100.0 }) + { + sampleRate = spec.sampleRate; + samplesPerBlock = static_cast (spec.maximumBlockSize); + numChannels = static_cast (spec.numChannels); + + wetBuffer.setSize (numChannels, samplesPerBlock); + dryBuffer.setSize (numChannels, samplesPerBlock); + + // Gain smoothers: fast (1ms) to avoid audible lag + inputGainSmoother.prepare (sampleRate, 1.0); + outputGainSmoother.prepare (sampleRate, 1.0); + // Mix smoother: moderate (5ms) + mixSmoother.prepare (sampleRate, 5.0); + // Bypass smoother: fast crossfade (5ms) + bypassSmoother.prepare (sampleRate, 5.0); + + // Snap all smoothers to initial values (no ramp on first block) + inputGainSmoother.setTargetValue (initialInputGain); + inputGainSmoother.snapToTargetValue(); + outputGainSmoother.setTargetValue (initialOutputGain); + outputGainSmoother.snapToTargetValue(); + mixSmoother.setTargetValue (Utils::DSPUtils::percentageToNormalized (initialMix)); + mixSmoother.snapToTargetValue(); + bypassSmoother.setTargetValue (SampleType { 0.0 }); // not bypassed + bypassSmoother.snapToTargetValue(); + + onPrepare (spec); + } + + void updateParameters (SampleType inputGainDb, + SampleType outputGainDb, + SampleType mixPercent, + bool bypassed) + { + inputGainSmoother.setTargetValue (Utils::DSPUtils::dbToGain (inputGainDb)); + outputGainSmoother.setTargetValue (Utils::DSPUtils::dbToGain (outputGainDb)); + mixSmoother.setTargetValue (Utils::DSPUtils::percentageToNormalized (mixPercent)); + bypassSmoother.setTargetValue (bypassed ? SampleType { 1.0 } : SampleType { 0.0 }); + + onUpdateParameters(); + } + + void processBlock (juce::AudioBuffer& buffer) + { + jassert (buffer.getNumChannels() >= 1); + + const int numSamples = buffer.getNumSamples(); + + // Ensure internal buffers match incoming block size + if (wetBuffer.getNumSamples() != numSamples) + { + wetBuffer.setSize (numChannels, numSamples, false, false, true); + dryBuffer.setSize (numChannels, numSamples, false, false, true); + } + + // Preserve dry signal for wet/dry mixing + dryBuffer.makeCopyOf (buffer); + + // Apply input gain and copy to wet buffer + for (int i = 0; i < numSamples; ++i) + { + const auto inputGain = inputGainSmoother.getNextValue(); + + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + auto sample = buffer.getSample (ch, i) * inputGain; + wetBuffer.setSample (ch, i, sample); + } + + // Throttle DSP component updates (every 32 samples) + if (i == 0 || (i % updateInterval) == 0) + onUpdateDSPComponents (i); + } + + // Process the wet buffer through your DSP chain + onProcess (wetBuffer, numSamples); + + // Mix wet/dry and apply output gain + bypass crossfade + for (int i = 0; i < numSamples; ++i) + { + const auto mix = mixSmoother.getNextValue(); + const auto outputGain = outputGainSmoother.getNextValue(); + const auto bypass = bypassSmoother.getNextValue(); + + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + const auto drySample = dryBuffer.getSample (ch, i); + const auto wetSample = wetBuffer.getSample (ch, i); + + // Wet/dry crossfade + auto processed = drySample * (SampleType { 1.0 } - mix) + + wetSample * mix; + processed *= outputGain; + + // Bypass crossfade (0 = processed, 1 = dry) + auto output = processed * (SampleType { 1.0 } - bypass) + + drySample * bypass; + + buffer.getWritePointer (ch)[i] = output; + } + } + + // Push metering data (lock-free) + pushMeteringData (buffer); + } + + void reset (SampleType inputGain = SampleType { 1.0 }, + SampleType outputGain = SampleType { 1.0 }, + SampleType mix = SampleType { 100.0 }) + { + inputGainSmoother.reset (inputGain); + inputGainSmoother.snapToTargetValue(); + outputGainSmoother.reset (outputGain); + outputGainSmoother.snapToTargetValue(); + mixSmoother.reset (Utils::DSPUtils::percentageToNormalized (mix)); + mixSmoother.snapToTargetValue(); + bypassSmoother.reset (SampleType { 0.0 }); + bypassSmoother.snapToTargetValue(); + + onReset(); + } + + /** Access the metering FIFO from the GUI thread. */ + Utils::MeteringFIFO<>& getMeteringFIFO() { return meteringFifo; } + + protected: + // Override these in your subclass to add DSP processing: + + /** Called during prepare(). Set up your DSP components here. */ + virtual void onPrepare (const juce::dsp::ProcessSpec& /*spec*/) {} + + /** Called when parameters are updated. Set targets on your own smoothers. */ + virtual void onUpdateParameters() {} + + /** Called every updateInterval samples. Update expensive DSP state here. */ + virtual void onUpdateDSPComponents (int /*sampleIndex*/) {} + + /** Process the wet buffer. This is where your DSP chain goes. */ + virtual void onProcess (juce::AudioBuffer& /*wetBuffer*/, int /*numSamples*/) {} + + /** Called during reset(). Reset your DSP components here. */ + virtual void onReset() {} + + double sampleRate = 44100.0; + int samplesPerBlock = 512; + int numChannels = 2; + + /** How often to call onUpdateDSPComponents (in samples). Default: every 32. */ + int updateInterval = 32; + + private: + void pushMeteringData (const juce::AudioBuffer& buffer) + { + Utils::MeterData data; + const int numSamples = buffer.getNumSamples(); + + if (buffer.getNumChannels() >= 1) + { + data.peakL = buffer.getMagnitude (0, 0, numSamples); + float sumSq = 0.0f; + const float* ch = buffer.getReadPointer (0); + for (int i = 0; i < numSamples; ++i) + sumSq += ch[i] * ch[i]; + data.rmsL = std::sqrt (sumSq / static_cast (numSamples)); + } + + if (buffer.getNumChannels() >= 2) + { + data.peakR = buffer.getMagnitude (1, 0, numSamples); + float sumSq = 0.0f; + const float* ch = buffer.getReadPointer (1); + for (int i = 0; i < numSamples; ++i) + sumSq += ch[i] * ch[i]; + data.rmsR = std::sqrt (sumSq / static_cast (numSamples)); + } + + meteringFifo.push (data); + } + + Utils::ParameterSmoother inputGainSmoother; + Utils::ParameterSmoother outputGainSmoother; + Utils::ParameterSmoother mixSmoother; + Utils::ParameterSmoother bypassSmoother; + + juce::AudioBuffer wetBuffer; + juce::AudioBuffer dryBuffer; + + Utils::MeteringFIFO<> meteringFifo; + }; + + // Common aliases + using FloatProcessor = ProcessorCore; + using DoubleProcessor = ProcessorCore; + +} // namespace Core +} // namespace DSP diff --git a/source/DSP/Utils/DSPUtils.h b/source/DSP/Utils/DSPUtils.h new file mode 100644 index 0000000..4bdd80e --- /dev/null +++ b/source/DSP/Utils/DSPUtils.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include + +namespace DSP +{ +namespace Utils +{ + + /** + * Common DSP utility functions. + */ + class DSPUtils + { + public: + /** Converts decibels to linear gain. */ + static inline float dbToGain (float db) + { + return std::pow (10.0f, db * 0.05f); + } + + /** Converts linear gain to decibels. */ + static inline float gainToDb (float gain) + { + return 20.0f * std::log10 (std::max (gain, 1e-6f)); + } + + /** Converts percentage (0-100) to normalized value (0-1). */ + static inline float percentageToNormalized (float percentage) + { + return juce::jlimit (0.0f, 1.0f, percentage * 0.01f); + } + + /** Converts normalized value (0-1) to percentage (0-100). */ + static inline float normalizedToPercentage (float normalized) + { + return juce::jlimit (0.0f, 100.0f, normalized * 100.0f); + } + + /** Soft clipping via tanh. */ + static inline float softClip (float input) + { + return std::tanh (input); + } + + /** Hard clipping to a threshold. */ + static inline float hardClip (float input, float threshold = 1.0f) + { + return juce::jlimit (-threshold, threshold, input); + } + + /** Linear interpolation between two values. */ + static inline float lerp (float a, float b, float t) + { + return a + t * (b - a); + } + + /** Flush denormal values to zero. */ + static inline float flushDenormalToZero (float input) + { + return std::abs (input) < 1e-30f ? 0.0f : input; + } + + /** Clamp a frequency to valid range for a given sample rate. */ + static inline float clampFrequency (float freq, double sampleRate) + { + return juce::jlimit (1.0f, static_cast (sampleRate * 0.5) - 1.0f, freq); + } + }; + +} // namespace Utils +} // namespace DSP diff --git a/source/DSP/Utils/MeteringFIFO.h b/source/DSP/Utils/MeteringFIFO.h new file mode 100644 index 0000000..06b23a8 --- /dev/null +++ b/source/DSP/Utils/MeteringFIFO.h @@ -0,0 +1,97 @@ +#pragma once + +#include +#include +#include + +namespace DSP +{ +namespace Utils +{ + + /** + * Lock-free single-producer single-consumer FIFO for audio→GUI metering data. + * + * The audio thread writes level data each block. The GUI thread reads + * at its repaint rate. No mutexes, no allocations — safe for real-time use. + * + * Usage (audio thread): + * meteringFifo.push ({ leftPeak, rightPeak, leftRMS, rightRMS }); + * + * Usage (GUI thread / timer callback): + * MeterData data; + * while (meteringFifo.pop (data)) + * updateMeters (data); + */ + + struct MeterData + { + float peakL = 0.0f; + float peakR = 0.0f; + float rmsL = 0.0f; + float rmsR = 0.0f; + }; + + template + class MeteringFIFO + { + public: + MeteringFIFO() = default; + + /** Push a metering snapshot (audio thread). Returns false if full. */ + bool push (const MeterData& data) + { + auto currentWrite = writeIndex.load (std::memory_order_relaxed); + auto nextWrite = (currentWrite + 1) % Capacity; + + if (nextWrite == readIndex.load (std::memory_order_acquire)) + return false; // full + + buffer[currentWrite] = data; + writeIndex.store (nextWrite, std::memory_order_release); + return true; + } + + /** Pop a metering snapshot (GUI thread). Returns false if empty. */ + bool pop (MeterData& data) + { + auto currentRead = readIndex.load (std::memory_order_relaxed); + + if (currentRead == writeIndex.load (std::memory_order_acquire)) + return false; // empty + + data = buffer[currentRead]; + readIndex.store ((currentRead + 1) % Capacity, std::memory_order_release); + return true; + } + + /** Returns the most recent value, draining the queue (GUI thread). */ + bool getLatest (MeterData& data) + { + bool gotOne = false; + MeterData temp; + + while (pop (temp)) + { + data = temp; + gotOne = true; + } + + return gotOne; + } + + /** Reset both indices (call only when both threads are idle). */ + void reset() + { + readIndex.store (0, std::memory_order_relaxed); + writeIndex.store (0, std::memory_order_relaxed); + } + + private: + std::array buffer {}; + std::atomic readIndex { 0 }; + std::atomic writeIndex { 0 }; + }; + +} // namespace Utils +} // namespace DSP diff --git a/source/DSP/Utils/ParameterSmoother.h b/source/DSP/Utils/ParameterSmoother.h new file mode 100644 index 0000000..eb99638 --- /dev/null +++ b/source/DSP/Utils/ParameterSmoother.h @@ -0,0 +1,101 @@ +#pragma once + +#include +#include + +namespace DSP +{ +namespace Utils +{ + + /** + * A parameter smoother with configurable smoothing time. + * Uses exponential smoothing for natural parameter transitions. + * + * Usage: + * ParameterSmoother gainSmoother; + * gainSmoother.prepare (sampleRate, 5.0); // 5ms smoothing + * gainSmoother.setTargetValue (newGain); + * float smoothed = gainSmoother.getNextValue(); // per-sample + */ + template + class ParameterSmoother + { + public: + ParameterSmoother() = default; + + /** Prepares the smoother with sample rate and smoothing time in milliseconds. */ + void prepare (double newSampleRate, double newSmoothingTimeMs) + { + jassert (newSampleRate > 0.0 && newSmoothingTimeMs >= 0.0); + + sampleRate = newSampleRate; + smoothingTimeMs = newSmoothingTimeMs; + + if (smoothingTimeMs > 0.0) + { + auto samplesForSmoothingTime = smoothingTimeMs * 0.001 * sampleRate; + smoothingCoeff = static_cast (1.0 - std::exp (-1.0 / samplesForSmoothingTime)); + } + else + { + smoothingCoeff = static_cast (1.0); + } + } + + /** Sets the target value to smooth towards. */ + void setTargetValue (SampleType newTargetValue) + { + targetValue = newTargetValue; + } + + /** Gets the next smoothed sample. Call once per sample. */ + SampleType getNextValue() + { + currentValue += smoothingCoeff * (targetValue - currentValue); + return currentValue; + } + + /** Processes a block, writing smoothed values into the samples array. */ + void processBlock (SampleType* samples, int numSamples, SampleType newTargetValue) + { + setTargetValue (newTargetValue); + + for (int i = 0; i < numSamples; ++i) + samples[i] = getNextValue(); + } + + /** Skips to the target value immediately (use on init to avoid ramp artifacts). */ + void snapToTargetValue() + { + currentValue = targetValue; + } + + /** Gets the current smoothed value without advancing. */ + SampleType getCurrentValue() const { return currentValue; } + + /** Gets the target value. */ + SampleType getTargetValue() const { return targetValue; } + + /** Returns true if the smoother has converged to within tolerance. */ + bool hasConverged (SampleType tolerance = SampleType { 1e-6 }) const + { + return std::abs (targetValue - currentValue) < tolerance; + } + + /** Resets the smoother to a specific value. */ + void reset (SampleType initialValue = SampleType { 0 }) + { + currentValue = targetValue = initialValue; + } + + private: + double sampleRate = 44100.0; + double smoothingTimeMs = 0.0; + SampleType smoothingCoeff = SampleType { 1 }; + SampleType currentValue = SampleType { 0 }; + SampleType targetValue = SampleType { 0 }; + }; + +} // namespace Utils +} // namespace DSP diff --git a/source/HostLauncher.cpp b/source/HostLauncher.cpp new file mode 100644 index 0000000..0bfa205 --- /dev/null +++ b/source/HostLauncher.cpp @@ -0,0 +1,51 @@ +#include "HostLauncher.h" +#include "BridgeClient.h" + +namespace +{ + bool isReachable (const juce::String& baseUrl) + { + juce::String j; + BridgeClient c; + c.setBaseUrl (baseUrl); + return c.fetchStatusJson (j); + } +} + +juce::File HostLauncher::findBridgeExecutable() +{ +#if JUCE_WINDOWS + const auto name = "wavgang-bridge.exe"; +#else + const auto name = "wavgang-bridge"; +#endif + + juce::File app { juce::File::getSpecialLocation (juce::File::currentApplicationFile) }; + juce::Array candidates; + + candidates.add (app.getSiblingFile (name)); + candidates.add (app.getParentDirectory().getChildFile (name)); + +#if JUCE_MAC + auto contents = app.getParentDirectory().getParentDirectory(); + candidates.add (contents.getChildFile ("MacOS").getChildFile (name)); +#endif + + for (auto f : candidates) + if (f.existsAsFile()) + return f; + + return {}; +} + +void HostLauncher::ensureBridgeRunning (const juce::String& baseUrl) +{ + if (isReachable (baseUrl)) + return; + + auto exe = findBridgeExecutable(); + if (!exe.existsAsFile()) + return; + + (void) exe.startAsProcess(); +} diff --git a/source/HostLauncher.h b/source/HostLauncher.h new file mode 100644 index 0000000..f023ec0 --- /dev/null +++ b/source/HostLauncher.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +/** Best-effort launcher for the AGPL wavgang-bridge binary next to the host or on PATH. */ +class HostLauncher +{ +public: + /** If bridge is not already listening, try to start it. Safe to call from message thread. */ + static void ensureBridgeRunning (const juce::String& baseUrl = "http://127.0.0.1:17890"); + +private: + static juce::File findBridgeExecutable(); +}; diff --git a/source/PluginEditor.cpp b/source/PluginEditor.cpp new file mode 100644 index 0000000..a75d524 --- /dev/null +++ b/source/PluginEditor.cpp @@ -0,0 +1,136 @@ +#include "PluginEditor.h" +#include "HostLauncher.h" +#include "WebUiRoot.h" + +#include + +#if JUCE_WEB_BROWSER +namespace +{ + constexpr const char* defaultBridgeBase = "http://127.0.0.1:17890"; + + std::optional fileToResource (const juce::File& f) + { + if (!f.existsAsFile()) + return std::nullopt; + + juce::MemoryBlock block; + if (!f.loadFileAsData (block)) + return std::nullopt; + + juce::String mime = "application/octet-stream"; + + if (f.hasFileExtension (".html")) + mime = "text/html"; + else if (f.hasFileExtension (".js")) + mime = "text/javascript"; + else if (f.hasFileExtension (".css")) + mime = "text/css"; + else if (f.hasFileExtension (".svg")) + mime = "image/svg+xml"; + else if (f.hasFileExtension (".png")) + mime = "image/png"; + else if (f.hasFileExtension (".ico")) + mime = "image/x-icon"; + else if (f.hasFileExtension (".json")) + mime = "application/json"; + else if (f.hasFileExtension (".woff2")) + mime = "font/woff2"; + + std::vector bytes (block.getSize()); + std::memcpy (bytes.data(), block.getData(), block.getSize()); + + return juce::WebBrowserComponent::Resource { std::move (bytes), mime }; + } + + std::optional webUiResourceProvider (const juce::File& root, + const juce::String& path) + { + juce::String p = path; + + if (p.isEmpty() || p == "/") + p = "/index.html"; + + auto rel = p.trimCharactersAtStart ("/"); + + if (rel.contains ("..")) + return std::nullopt; + + auto f = root.getChildFile (rel); + + if (!f.getFullPathName().startsWithIgnoreCase (root.getFullPathName())) + return std::nullopt; + + if (f.isDirectory()) + f = f.getChildFile ("index.html"); + + return fileToResource (f); + } +} +#endif + +PluginEditor::PluginEditor (PluginProcessor& p) + : AudioProcessorEditor (p), processorRef (p) +{ + setSize (900, 600); + +#if JUCE_WEB_BROWSER + HostLauncher::ensureBridgeRunning (defaultBridgeBase); + + const auto webRoot = WebUiRoot::resolve(); + + if (webRoot.isDirectory()) + { + auto opts = juce::WebBrowserComponent::Options() + .withResourceProvider ( + [webRoot] (const auto& path) { + return webUiResourceProvider (webRoot, path); + }) + .withNativeIntegrationEnabled (true) + .withNativeFunction ( + "getBridgeBaseUrl", + [] (const juce::Array&, auto complete) { + complete (defaultBridgeBase); + }); + + #if JUCE_WINDOWS + opts = opts.withBackend (juce::WebBrowserComponent::Options::Backend::webview2); + #endif + + webView = std::make_unique (opts); + addAndMakeVisible (*webView); + webView->goToURL (juce::WebBrowserComponent::getResourceProviderRoot()); + } + else + { + fallbackLabel.setText ("WavGang: Web UI not found. Build the ui/ app (bun run build) or set WVG_WEBUI_ROOT.", + juce::dontSendNotification); + fallbackLabel.setJustificationType (juce::Justification::centred); + addAndMakeVisible (fallbackLabel); + } +#else + { + fallbackLabel.setText ("WavGang: JUCE_WEB_BROWSER is disabled in this build.", + juce::dontSendNotification); + fallbackLabel.setJustificationType (juce::Justification::centred); + addAndMakeVisible (fallbackLabel); + } +#endif +} + +PluginEditor::~PluginEditor() = default; + +void PluginEditor::paint (juce::Graphics& g) +{ + g.fillAll (juce::Colour (0xff1a1b26)); +} + +void PluginEditor::resized() +{ +#if JUCE_WEB_BROWSER + if (webView != nullptr) + webView->setBounds (getLocalBounds()); +#endif + if (fallbackLabel.isVisible()) + fallbackLabel.setBounds (getLocalBounds().reduced (20)); +} diff --git a/source/PluginEditor.h b/source/PluginEditor.h new file mode 100644 index 0000000..a9f5d0f --- /dev/null +++ b/source/PluginEditor.h @@ -0,0 +1,29 @@ +#pragma once + +#include "PluginProcessor.h" +#include + +#if JUCE_WEB_BROWSER + #include +#endif + +class PluginEditor : public juce::AudioProcessorEditor +{ +public: + explicit PluginEditor (PluginProcessor&); + ~PluginEditor() override; + + void paint (juce::Graphics&) override; + void resized() override; + +private: + PluginProcessor& processorRef; + +#if JUCE_WEB_BROWSER + std::unique_ptr webView; +#endif + + juce::Label fallbackLabel; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (PluginEditor) +}; diff --git a/source/PluginProcessor.cpp b/source/PluginProcessor.cpp new file mode 100644 index 0000000..af0a78c --- /dev/null +++ b/source/PluginProcessor.cpp @@ -0,0 +1,294 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" + +//============================================================================== +PluginProcessor::PluginProcessor() + : AudioProcessor (BusesProperties() +#if ! JucePlugin_IsMidiEffect + #if ! JucePlugin_IsSynth + .withInput ("Input", juce::AudioChannelSet::stereo(), true) + #endif + .withOutput ("Output", juce::AudioChannelSet::stereo(), true) +#endif + ), + apvts (*this, nullptr, "Parameters", createParameterLayout()) +{ + apvts.state.setProperty (Service::PresetManager::presetNameProperty, "", nullptr); + presetManager = std::make_unique (apvts); +} + +PluginProcessor::~PluginProcessor() noexcept +{ +} + +//============================================================================== +const juce::String PluginProcessor::getName() const +{ + return JucePlugin_Name; +} + +bool PluginProcessor::acceptsMidi() const +{ +#if JucePlugin_WantsMidiInput + return true; +#else + return false; +#endif +} + +bool PluginProcessor::producesMidi() const +{ +#if JucePlugin_ProducesMidiOutput + return true; +#else + return false; +#endif +} + +bool PluginProcessor::isMidiEffect() const +{ +#if JucePlugin_IsMidiEffect + return true; +#else + return false; +#endif +} + +double PluginProcessor::getTailLengthSeconds() const +{ + return 0.0; +} + +int PluginProcessor::getNumPrograms() { return 1; } +int PluginProcessor::getCurrentProgram() { return 0; } +void PluginProcessor::setCurrentProgram (int index) { juce::ignoreUnused (index); } +const juce::String PluginProcessor::getProgramName (int index) { juce::ignoreUnused (index); return {}; } +void PluginProcessor::changeProgramName (int index, const juce::String& newName) { juce::ignoreUnused (index, newName); } + +//============================================================================== +void PluginProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) +{ + juce::dsp::ProcessSpec spec; + spec.sampleRate = sampleRate; + spec.maximumBlockSize = static_cast (samplesPerBlock); + spec.numChannels = static_cast (getTotalNumOutputChannels()); + + // Get initial parameter values and prepare DSP + float inputGain = *apvts.getRawParameterValue ("INPUT_GAIN"); + float outputGain = *apvts.getRawParameterValue ("OUTPUT_GAIN"); + float mix = *apvts.getRawParameterValue ("MIX"); + + dspProcessor.prepare (spec, + DSP::Utils::DSPUtils::dbToGain (inputGain), + DSP::Utils::DSPUtils::dbToGain (outputGain), + mix); + +#if ENABLE_MOONBASE + MOONBASE_PREPARE_TO_PLAY (sampleRate, samplesPerBlock); +#endif +} + +void PluginProcessor::releaseResources() +{ + float inputGain = *apvts.getRawParameterValue ("INPUT_GAIN"); + float outputGain = *apvts.getRawParameterValue ("OUTPUT_GAIN"); + float mix = *apvts.getRawParameterValue ("MIX"); + + dspProcessor.reset (DSP::Utils::DSPUtils::dbToGain (inputGain), + DSP::Utils::DSPUtils::dbToGain (outputGain), + mix); +} + +bool PluginProcessor::isBusesLayoutSupported (const BusesLayout& layouts) const +{ +#if JucePlugin_IsMidiEffect + juce::ignoreUnused (layouts); + return true; +#else + if (layouts.getMainOutputChannelSet() != juce::AudioChannelSet::mono() + && layouts.getMainOutputChannelSet() != juce::AudioChannelSet::stereo()) + return false; + + #if ! JucePlugin_IsSynth + if (layouts.getMainOutputChannelSet() != layouts.getMainInputChannelSet()) + return false; + #endif + + return true; +#endif +} + +void PluginProcessor::processBlock (juce::AudioBuffer& buffer, + juce::MidiBuffer& midiMessages) +{ + juce::ignoreUnused (midiMessages); + juce::ScopedNoDenormals noDenormals; + + auto totalNumInputChannels = getTotalNumInputChannels(); + auto totalNumOutputChannels = getTotalNumOutputChannels(); + + for (auto i = totalNumInputChannels; i < totalNumOutputChannels; ++i) + buffer.clear (i, 0, buffer.getNumSamples()); + + // Read parameters and update DSP processor + float inputGain = *apvts.getRawParameterValue ("INPUT_GAIN"); + float outputGain = *apvts.getRawParameterValue ("OUTPUT_GAIN"); + float mix = *apvts.getRawParameterValue ("MIX"); + bool bypassed = *apvts.getRawParameterValue ("BYPASS") > 0.5f; + + dspProcessor.updateParameters (inputGain, outputGain, mix, bypassed); + dspProcessor.processBlock (buffer); + +#if ENABLE_MOONBASE + MOONBASE_PROCESS (buffer); +#endif +} + +//============================================================================== +bool PluginProcessor::hasEditor() const { return true; } + +juce::AudioProcessorEditor* PluginProcessor::createEditor() +{ + return new PluginEditor (*this); +} + +//============================================================================== +void PluginProcessor::getStateInformation (juce::MemoryBlock& destData) +{ + auto state = apvts.copyState(); + state.setProperty ("stateSchemaVersion", stateSchemaVersion, nullptr); + std::unique_ptr xml (state.createXml()); + copyXmlToBinary (*xml, destData); +} + +void PluginProcessor::setStateInformation (const void* data, int sizeInBytes) +{ + std::unique_ptr xmlState (getXmlFromBinary (data, sizeInBytes)); + + if (xmlState == nullptr) + return; + + if (! xmlState->hasTagName (apvts.state.getType())) + return; + + auto incomingState = juce::ValueTree::fromXml (*xmlState); + + // Read the schema version from the incoming state for future migration + [[maybe_unused]] int incomingVersion = + incomingState.getProperty ("stateSchemaVersion", 0); + + // Future migration example: + // if (incomingVersion < 2) + // migrateFromV1ToV2 (incomingState); + + apvts.replaceState (incomingState); +} + +//============================================================================== +juce::AudioProcessorValueTreeState::ParameterLayout PluginProcessor::createParameterLayout() +{ + std::vector> params; + + // --- Example parameters (replace/extend with your own) --- + + // Input gain: -48 to +24 dB + params.push_back (std::make_unique ( + juce::ParameterID { "INPUT_GAIN", 1 }, "Input Gain", + juce::NormalisableRange (-48.0f, 24.0f, 0.1f), 0.0f, + juce::String(), + juce::AudioProcessorParameter::genericParameter, + [] (float value, int) { return juce::String (value, 1) + " dB"; }, + [] (const juce::String& text) { return text.getFloatValue(); })); + + // Output gain: -48 to +24 dB + params.push_back (std::make_unique ( + juce::ParameterID { "OUTPUT_GAIN", 1 }, "Output Gain", + juce::NormalisableRange (-48.0f, 24.0f, 0.1f), 0.0f, + juce::String(), + juce::AudioProcessorParameter::genericParameter, + [] (float value, int) { return juce::String (value, 1) + " dB"; }, + [] (const juce::String& text) { return text.getFloatValue(); })); + + // Mix: 0 to 100% + params.push_back (std::make_unique ( + juce::ParameterID { "MIX", 1 }, "Mix", + juce::NormalisableRange (0.0f, 100.0f, 0.1f), 100.0f, + juce::String(), + juce::AudioProcessorParameter::genericParameter, + [] (float value, int) { return juce::String (juce::roundToInt (value)) + "%"; }, + [] (const juce::String& text) { return text.getFloatValue(); })); + + // Bypass (bool) + params.push_back (std::make_unique ( + juce::ParameterID { "BYPASS", 1 }, "Bypass", false)); + + return { params.begin(), params.end() }; +} + +//============================================================================== +#if ENABLE_COMMON_LAYER + +void PluginProcessor::prepare (const juce::dsp::ProcessSpec& spec) +{ + prepareToPlay (spec.sampleRate, static_cast (spec.maximumBlockSize)); +} + +void PluginProcessor::process (juce::AudioBuffer& buffer, juce::MidiBuffer& midi) +{ + processBlock (buffer, midi); +} + +void PluginProcessor::release() +{ + releaseResources(); +} + +void PluginProcessor::reset() +{ + dspProcessor.reset(); +} + +void PluginProcessor::serialize (juce::MemoryBlock& destData) const +{ + auto& mutableApvts = const_cast (apvts); + auto state = mutableApvts.copyState(); + state.setProperty ("stateSchemaVersion", stateSchemaVersion, nullptr); + std::unique_ptr xml (state.createXml()); + juce::AudioProcessor::copyXmlToBinary (*xml, destData); +} + +void PluginProcessor::deserialize (const void* data, int sizeInBytes) +{ + setStateInformation (data, sizeInBytes); +} + +juce::String PluginProcessor::getParameterLayoutDescription() const +{ + auto& mutableApvts = const_cast (apvts); + auto state = mutableApvts.copyState(); + std::unique_ptr xml (state.createXml()); + return xml != nullptr ? xml->toString() : juce::String(); +} + +DirektDSP::Common::PluginDescriptor PluginProcessor::getDescriptor() +{ + DirektDSP::Common::PluginDescriptor desc; + desc.name = juce::String (JUCE_STRINGIFY (PRODUCT_NAME_WITHOUT_VERSION)); + desc.version = juce::String (JUCE_STRINGIFY (VERSION)); + desc.companyName = JucePlugin_Manufacturer; + desc.bundleId = juce::String (JUCE_STRINGIFY (JucePlugin_CFBundleIdentifier)); + desc.category = "effect"; + desc.numInputChannels = 2; + desc.numOutputChannels = 2; + desc.acceptsMidi = JucePlugin_WantsMidiInput; + desc.producesMidi = JucePlugin_ProducesMidiOutput; + return desc; +} + +#endif // ENABLE_COMMON_LAYER + +//============================================================================== +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new PluginProcessor(); +} diff --git a/source/PluginProcessor.h b/source/PluginProcessor.h new file mode 100644 index 0000000..d93fb6c --- /dev/null +++ b/source/PluginProcessor.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include "DSP/Core/ProcessorCore.h" +#include "Service/PresetManager.h" + +#if ENABLE_MOONBASE + #include "moonbase_JUCEClient/moonbase_JUCEClient.h" + #include "BinaryData.h" +#endif + +#if ENABLE_COMMON_LAYER + #include "IPluginProcessor.h" + #include "IPluginState.h" + #include "PluginDescriptor.h" +#endif + +class PluginProcessor : public juce::AudioProcessor +#if ENABLE_COMMON_LAYER + , public DirektDSP::Common::IPluginProcessor + , public DirektDSP::Common::IPluginState +#endif +{ +public: + PluginProcessor(); + ~PluginProcessor() noexcept override; + +#if ENABLE_MOONBASE + MOONBASE_DECLARE_LICENSING_NAMED (BinaryData, PRODUCT_NAME_WITHOUT_VERSION, VERSION) +#endif + + // juce::AudioProcessor overrides + void prepareToPlay (double sampleRate, int samplesPerBlock) override; + void releaseResources() override; + bool isBusesLayoutSupported (const BusesLayout& layouts) const override; + void processBlock (juce::AudioBuffer&, juce::MidiBuffer&) override; + + juce::AudioProcessorEditor* createEditor() override; + bool hasEditor() const override; + + const juce::String getName() const override; + bool acceptsMidi() const override; + bool producesMidi() const override; + bool isMidiEffect() const override; + double getTailLengthSeconds() const override; + + int getNumPrograms() override; + int getCurrentProgram() override; + void setCurrentProgram (int index) override; + const juce::String getProgramName (int index) override; + void changeProgramName (int index, const juce::String& newName) override; + + void getStateInformation (juce::MemoryBlock& destData) override; + void setStateInformation (const void* data, int sizeInBytes) override; + + juce::AudioProcessorValueTreeState& getApvts() { return apvts; } + Service::PresetManager& getPresetManager() { return *presetManager; } + + /** Access the DSP core's metering FIFO for GUI level meters. */ + DSP::Utils::MeteringFIFO<>& getMeteringFIFO() + { + return dspProcessor.getMeteringFIFO(); + } + +#if ENABLE_COMMON_LAYER + // IPluginProcessor interface + void prepare (const juce::dsp::ProcessSpec& spec) override; + void process (juce::AudioBuffer& buffer, juce::MidiBuffer& midi) override; + void release() override; + void reset() override; + + // IPluginState interface + juce::AudioProcessorValueTreeState& getAPVTS() override { return apvts; } + void serialize (juce::MemoryBlock& destData) const override; + void deserialize (const void* data, int sizeInBytes) override; + juce::String getParameterLayoutDescription() const override; + + static DirektDSP::Common::PluginDescriptor getDescriptor(); +#endif + + juce::AudioProcessorValueTreeState apvts; + static juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout(); + + // State schema version — increment when adding/removing/renaming parameters + static constexpr int stateSchemaVersion = 1; + +private: + std::unique_ptr presetManager; + DSP::Core::FloatProcessor dspProcessor; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (PluginProcessor) +}; diff --git a/source/Service/PresetManager.cpp b/source/Service/PresetManager.cpp new file mode 100644 index 0000000..06dd464 --- /dev/null +++ b/source/Service/PresetManager.cpp @@ -0,0 +1,484 @@ +#include "PresetManager.h" + +namespace Service +{ + + const juce::File PresetManager::defaultDirectory { + juce::File::getSpecialLocation (juce::File::SpecialLocationType::userDocumentsDirectory) + .getChildFile ("DirektDSP") + .getChildFile (JucePlugin_Name) + .getChildFile ("Presets") + }; + + const juce::String PresetManager::extension { "ddsp" }; + const juce::String PresetManager::presetNameProperty { "presetName" }; + const juce::String PresetManager::defaultCategory { "Default" }; + + PresetManager::PresetManager (juce::AudioProcessorValueTreeState& apvts) + : valueTreeState (apvts) + { + if (! defaultDirectory.exists()) + { + const auto result = defaultDirectory.createDirectory(); + if (result.failed()) + { + DBG ("Could not create preset directory: " + result.getErrorMessage()); + jassertfalse; + } + } + + valueTreeState.state.addListener (this); + currentPreset.referTo ( + valueTreeState.state.getPropertyAsValue (presetNameProperty, nullptr)); + currentCategory.referTo ( + valueTreeState.state.getPropertyAsValue ("currentCategory", nullptr)); + + addParameterListeners(); + updatePresetList(); + } + + PresetManager::~PresetManager() + { + removeParameterListeners(); + valueTreeState.state.removeListener (this); + } + + //============================================================================== + // Menu + //============================================================================== + + void PresetManager::buildPresetMenu (juce::PopupMenu& menu, int& menuItemId) + { + menuItemToPresetMap.clear(); + menuItemToCategoryMap.clear(); + + const auto categories = getAllCategories(); + + for (const auto& category : categories) + { + const auto presets = getPresetsInCategory (category); + + if (presets.isEmpty()) + continue; + + if (category == defaultCategory) + { + menu.addSectionHeader ("Default Presets"); + + for (const auto& preset : presets) + { + menuItemToPresetMap.add (preset); + menuItemToCategoryMap.add (category); + + const bool isCurrent = + (preset == getCurrentPreset() && category == getCurrentCategory()); + menu.addItem (menuItemId++, preset, true, isCurrent); + } + + menu.addSeparator(); + } + else + { + juce::PopupMenu categorySubmenu; + buildCategorySubmenu (categorySubmenu, category, menuItemId); + menu.addSubMenu (category, categorySubmenu); + } + } + } + + void PresetManager::buildCategorySubmenu (juce::PopupMenu& submenu, + const juce::String& category, + int& menuItemId) + { + const auto presets = getPresetsInCategory (category); + + submenu.addSectionHeader (category); + + for (const auto& preset : presets) + { + menuItemToPresetMap.add (preset); + menuItemToCategoryMap.add (category); + + const bool isCurrent = + (preset == getCurrentPreset() && category == getCurrentCategory()); + submenu.addItem (menuItemId++, preset, true, isCurrent); + } + + submenu.addSeparator(); + submenu.addItem (menuItemId++, "Delete Category: " + category, true, false); + menuItemToPresetMap.add ("DELETE_CATEGORY"); + menuItemToCategoryMap.add (category); + } + + void PresetManager::handlePresetMenuResult (int result) + { + if (result == 0 || result > menuItemToPresetMap.size()) + return; + + const int index = result - 1; + const juce::String presetName = menuItemToPresetMap[index]; + const juce::String category = menuItemToCategoryMap[index]; + + if (presetName == "DELETE_CATEGORY") + { + auto options = juce::MessageBoxOptions() + .withTitle ("Delete Category") + .withMessage ("Delete category '" + category + + "' and all its presets?") + .withButton ("Delete") + .withButton ("Cancel"); + + juce::NativeMessageBox::showAsync (options, [this, category] (int r) { + if (r == 1) + deleteCategory (category); + }); + } + else + { + loadPreset (presetName, category); + } + } + + //============================================================================== + // Save / Load / Delete + //============================================================================== + + void PresetManager::savePreset (const juce::String& presetName, + const juce::String& artistName, + const juce::String& category) + { + if (presetName.isEmpty()) + return; + + const juce::String finalCategory = category.isEmpty() ? defaultCategory : category; + + if (finalCategory != defaultCategory) + createCategory (finalCategory); + + currentPreset.setValue (presetName); + currentCategory.setValue (finalCategory); + + auto state = valueTreeState.copyState(); + state.setProperty ("artist", artistName, nullptr); + state.setProperty ("category", finalCategory, nullptr); + state.setProperty ("dateCreated", + juce::Time::getCurrentTime().toISO8601 (true), nullptr); + state.setProperty ("dateModified", + juce::Time::getCurrentTime().toISO8601 (true), nullptr); + + const auto xml = state.createXml(); + const auto presetFile = getPresetFile (presetName, finalCategory); + + if (! xml->writeTo (presetFile)) + { + DBG ("Could not create preset file: " + presetFile.getFullPathName()); + jassertfalse; + } + + updatePresetList(); + } + + void PresetManager::deletePreset (const juce::String& presetName, + const juce::String& category) + { + if (presetName.isEmpty()) + return; + + const juce::String finalCategory = + category.isEmpty() ? getCurrentCategory() : category; + const auto presetFile = getPresetFile (presetName, finalCategory); + + if (! presetFile.existsAsFile()) + { + DBG ("Preset file does not exist: " + presetFile.getFullPathName()); + jassertfalse; + return; + } + + if (! presetFile.moveToTrash()) + { + DBG ("Could not delete preset: " + presetFile.getFullPathName()); + jassertfalse; + return; + } + + currentPreset.setValue (""); + updatePresetList(); + } + + void PresetManager::loadPreset (const juce::String& presetName, + const juce::String& category) + { + if (presetName.isEmpty()) + return; + + const juce::String finalCategory = + category.isEmpty() ? getCurrentCategory() : category; + const auto presetFile = getPresetFile (presetName, finalCategory); + + if (! presetFile.existsAsFile()) + { + DBG ("Preset file does not exist: " + presetFile.getFullPathName()); + jassertfalse; + return; + } + + juce::XmlDocument xmlDocument { presetFile }; + auto xml = xmlDocument.getDocumentElement(); + + if (xml == nullptr) + { + DBG ("Invalid XML in preset file"); + jassertfalse; + return; + } + + auto valueTreeToLoad = juce::ValueTree::fromXml (*xml); + + isLoadingPreset = true; + valueTreeState.replaceState (valueTreeToLoad); + currentPreset.setValue (presetName); + currentCategory.setValue (finalCategory); + isLoadingPreset = false; + + updatePresetList(); + } + + //============================================================================== + // Categories + //============================================================================== + + void PresetManager::createCategory (const juce::String& categoryName) + { + if (categoryName.isEmpty() || categoryName == defaultCategory) + return; + + const auto categoryDir = getCategoryDirectory (categoryName); + + if (! categoryDir.exists()) + { + const auto result = categoryDir.createDirectory(); + if (result.failed()) + { + DBG ("Could not create category: " + result.getErrorMessage()); + jassertfalse; + } + } + + updatePresetList(); + } + + void PresetManager::deleteCategory (const juce::String& categoryName) + { + if (categoryName.isEmpty() || categoryName == defaultCategory) + return; + + const auto categoryDir = getCategoryDirectory (categoryName); + + if (categoryDir.exists()) + { + const auto files = + categoryDir.findChildFiles (juce::File::findFiles, false, "*." + extension); + + for (const auto& file : files) + file.moveToTrash(); + + categoryDir.deleteRecursively(); + } + + updatePresetList(); + } + + bool PresetManager::categoryExists (const juce::String& categoryName) const + { + if (categoryName.isEmpty() || categoryName == defaultCategory) + return true; + + return getCategoryDirectory (categoryName).exists(); + } + + juce::StringArray PresetManager::getAllCategories() const + { + juce::StringArray categories; + categories.add (defaultCategory); + + const auto subdirs = + defaultDirectory.findChildFiles (juce::File::findDirectories, false); + juce::StringArray sorted; + + for (const auto& dir : subdirs) + sorted.add (dir.getFileName()); + + sorted.sortNatural(); + + for (const auto& cat : sorted) + categories.add (cat); + + return categories; + } + + //============================================================================== + // Queries + //============================================================================== + + juce::StringArray PresetManager::getAllPresets() const + { + juce::StringArray presets; + + const auto rootFiles = + defaultDirectory.findChildFiles (juce::File::findFiles, false, "*." + extension); + + for (const auto& file : rootFiles) + presets.add (file.getFileNameWithoutExtension()); + + const auto categories = getAllCategories(); + + for (const auto& category : categories) + { + if (category == defaultCategory) + continue; + + for (const auto& preset : getPresetsInCategory (category)) + presets.add (category + "/" + preset); + } + + return presets; + } + + juce::StringArray PresetManager::getPresetsInCategory (const juce::String& category) const + { + juce::StringArray presets; + + juce::File searchDir = (category.isEmpty() || category == defaultCategory) + ? defaultDirectory + : getCategoryDirectory (category); + + if (! searchDir.exists()) + return presets; + + const auto files = + searchDir.findChildFiles (juce::File::findFiles, false, "*." + extension); + + for (const auto& file : files) + presets.add (file.getFileNameWithoutExtension()); + + return presets; + } + + //============================================================================== + // Navigation + //============================================================================== + + int PresetManager::loadNextPreset() + { + const auto category = getCurrentCategory(); + const auto presets = getPresetsInCategory (category); + + if (presets.isEmpty()) + return -1; + + const auto currentIndex = presets.indexOf (getCurrentPreset()); + const auto nextIndex = + currentIndex + 1 > (presets.size() - 1) ? 0 : currentIndex + 1; + + loadPreset (presets[nextIndex], category); + return nextIndex; + } + + int PresetManager::loadPreviousPreset() + { + const auto category = getCurrentCategory(); + const auto presets = getPresetsInCategory (category); + + if (presets.isEmpty()) + return -1; + + const auto currentIndex = presets.indexOf (getCurrentPreset()); + const auto prevIndex = + currentIndex - 1 < 0 ? presets.size() - 1 : currentIndex - 1; + + loadPreset (presets[prevIndex], category); + return prevIndex; + } + + //============================================================================== + // Current state + //============================================================================== + + juce::String PresetManager::getCurrentPreset() const + { + return currentPreset.toString(); + } + + juce::String PresetManager::getCurrentCategory() const + { + const juce::String cat = currentCategory.toString(); + return cat.isEmpty() ? defaultCategory : cat; + } + + //============================================================================== + // Private + //============================================================================== + + void PresetManager::valueTreeRedirected (juce::ValueTree& treeWhichHasBeenChanged) + { + currentPreset.referTo ( + treeWhichHasBeenChanged.getPropertyAsValue (presetNameProperty, nullptr)); + currentCategory.referTo ( + treeWhichHasBeenChanged.getPropertyAsValue ("currentCategory", nullptr)); + } + + void PresetManager::parameterValueChanged (int /*parameterIndex*/, float /*newValue*/) + { + if (! isLoadingPreset && getCurrentPreset().isNotEmpty()) + currentPreset.setValue (""); + } + + void PresetManager::parameterGestureChanged (int /*parameterIndex*/, + bool /*gestureIsStarting*/) + { + } + + void PresetManager::addParameterListeners() + { + for (auto* param : valueTreeState.processor.getParameters()) + { + if (auto* ranged = dynamic_cast (param)) + ranged->addListener (this); + } + } + + void PresetManager::removeParameterListeners() + { + for (auto* param : valueTreeState.processor.getParameters()) + { + if (auto* ranged = dynamic_cast (param)) + ranged->removeListener (this); + } + } + + void PresetManager::updatePresetList() + { + availablePresets = getAllPresets(); + availableCategories = getAllCategories(); + } + + juce::File PresetManager::getPresetFile (const juce::String& presetName, + const juce::String& category) const + { + const juce::String finalCategory = category.isEmpty() ? defaultCategory : category; + const juce::File dir = (finalCategory == defaultCategory) + ? defaultDirectory + : getCategoryDirectory (finalCategory); + return dir.getChildFile (presetName + "." + extension); + } + + juce::File PresetManager::getCategoryDirectory (const juce::String& category) const + { + if (category.isEmpty() || category == defaultCategory) + return defaultDirectory; + + return defaultDirectory.getChildFile (category); + } + +} // namespace Service diff --git a/source/Service/PresetManager.h b/source/Service/PresetManager.h new file mode 100644 index 0000000..d5a3b6b --- /dev/null +++ b/source/Service/PresetManager.h @@ -0,0 +1,109 @@ +#pragma once + +#include +#include + +namespace Service +{ + + struct PresetMetadata + { + juce::String name; + juce::String artist; + juce::String category; + juce::String dateCreated; + juce::String dateModified; + + juce::String getFullPath() const + { + return category.isEmpty() ? name : category + "/" + name; + } + }; + + /** + * Preset manager with category support, dirty state detection, and menu building. + * + * Stores presets as XML files in: + * [CommonDocuments]/DirektDSP/[PluginName]/Presets/ + * + * Features: + * - Category-based organization (subdirectories) + * - Dirty state detection (clears preset name when user tweaks a parameter) + * - PopupMenu building with category submenus + * - Navigation (next/previous within category) + * - Metadata tracking (artist, dates) + */ + class PresetManager : private juce::ValueTree::Listener, + private juce::AudioProcessorParameter::Listener + { + public: + static const juce::File defaultDirectory; + static const juce::String extension; + static const juce::String presetNameProperty; + static const juce::String defaultCategory; + + explicit PresetManager (juce::AudioProcessorValueTreeState& apvts); + ~PresetManager() override; + + // Menu + void buildPresetMenu (juce::PopupMenu& menu, int& menuItemId); + void handlePresetMenuResult (int result); + + // Save / Load / Delete + void savePreset (const juce::String& presetName, + const juce::String& artistName = "Unknown", + const juce::String& category = ""); + void deletePreset (const juce::String& presetName, + const juce::String& category = ""); + void loadPreset (const juce::String& presetName, + const juce::String& category = ""); + + // Categories + void createCategory (const juce::String& categoryName); + void deleteCategory (const juce::String& categoryName); + bool categoryExists (const juce::String& categoryName) const; + juce::StringArray getAllCategories() const; + + // Queries + juce::StringArray getAllPresets() const; + juce::StringArray getPresetsInCategory (const juce::String& category) const; + + // Navigation + int loadNextPreset(); + int loadPreviousPreset(); + + // Current state + juce::String getCurrentPreset() const; + juce::String getCurrentCategory() const; + + private: + void buildCategorySubmenu (juce::PopupMenu& submenu, + const juce::String& category, + int& menuItemId); + + void valueTreeRedirected (juce::ValueTree& treeWhichHasBeenChanged) override; + + void parameterValueChanged (int parameterIndex, float newValue) override; + void parameterGestureChanged (int parameterIndex, bool gestureIsStarting) override; + + void addParameterListeners(); + void removeParameterListeners(); + void updatePresetList(); + + juce::File getPresetFile (const juce::String& presetName, + const juce::String& category) const; + juce::File getCategoryDirectory (const juce::String& category) const; + + juce::AudioProcessorValueTreeState& valueTreeState; + juce::Value currentPreset; + juce::Value currentCategory; + + juce::StringArray menuItemToPresetMap; + juce::StringArray menuItemToCategoryMap; + juce::StringArray availablePresets; + juce::StringArray availableCategories; + + bool isLoadingPreset = false; + }; + +} // namespace Service diff --git a/source/WebUiRoot.cpp b/source/WebUiRoot.cpp new file mode 100644 index 0000000..df4c2fd --- /dev/null +++ b/source/WebUiRoot.cpp @@ -0,0 +1,43 @@ +#include "WebUiRoot.h" + +#include + +juce::File WebUiRoot::resolve() +{ + // 1) Bundle / package layout: .../Contents/Resources/webui + { + juce::File binary { juce::File::getSpecialLocation (juce::File::currentApplicationFile) }; + +#if JUCE_MAC + if (binary.getFileExtension() == ".dylib") + { + auto contents = binary.getParentDirectory().getParentDirectory(); + auto fromBundle = contents.getChildFile ("Resources").getChildFile ("webui"); + if (fromBundle.isDirectory()) + return fromBundle; + } +#endif + + auto contents = binary.getParentDirectory().getParentDirectory(); + auto fromBundleGeneric = contents.getChildFile ("Resources").getChildFile ("webui"); + if (fromBundleGeneric.isDirectory()) + return fromBundleGeneric; + } + + // 2) CMake compile-time fallback (dev tree: ui/dist) + { + juce::File fromMacro { juce::String::fromUTF8 (WVG_WEBUI_ROOT) }; + if (fromMacro.isDirectory()) + return fromMacro; + } + + // 3) Environment override + if (const char* env = std::getenv ("WVG_WEBUI_ROOT"); env != nullptr && env[0] != '\0') + { + juce::File fromEnv { juce::String::fromUTF8 (env) }; + if (fromEnv.isDirectory()) + return fromEnv; + } + + return {}; +} diff --git a/source/WebUiRoot.h b/source/WebUiRoot.h new file mode 100644 index 0000000..63e02fa --- /dev/null +++ b/source/WebUiRoot.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +/** Resolves the directory containing the built Vue app (index.html + assets). */ +struct WebUiRoot +{ + static juce::File resolve(); +}; diff --git a/tests/PluginBasics.cpp b/tests/PluginBasics.cpp new file mode 100644 index 0000000..f8250b4 --- /dev/null +++ b/tests/PluginBasics.cpp @@ -0,0 +1,77 @@ +#include "PluginProcessor.h" +#include + +TEST_CASE ("Plugin instance", "[plugin]") +{ + PluginProcessor plugin; + + SECTION ("name is not empty") + { + REQUIRE (plugin.getName().isNotEmpty()); + } + + SECTION ("default programs") + { + REQUIRE (plugin.getNumPrograms() >= 1); + } + + SECTION ("stereo bus layout supported") + { + auto layout = juce::AudioProcessor::BusesLayout(); + layout.inputBuses.add (juce::AudioChannelSet::stereo()); + layout.outputBuses.add (juce::AudioChannelSet::stereo()); + REQUIRE (plugin.isBusesLayoutSupported (layout)); + } + + SECTION ("mono bus layout supported") + { + auto layout = juce::AudioProcessor::BusesLayout(); + layout.inputBuses.add (juce::AudioChannelSet::mono()); + layout.outputBuses.add (juce::AudioChannelSet::mono()); + REQUIRE (plugin.isBusesLayoutSupported (layout)); + } + + SECTION ("has editor") + { + REQUIRE (plugin.hasEditor()); + } + + SECTION ("default parameter values") + { + auto& apvts = plugin.getApvts(); + REQUIRE (apvts.getParameter ("INPUT_GAIN") != nullptr); + REQUIRE (apvts.getParameter ("OUTPUT_GAIN") != nullptr); + REQUIRE (apvts.getParameter ("MIX") != nullptr); + REQUIRE (apvts.getParameter ("BYPASS") != nullptr); + } +} + +TEST_CASE ("Plugin state", "[plugin][state]") +{ + PluginProcessor plugin; + + SECTION ("state round-trip") + { + juce::MemoryBlock data; + plugin.getStateInformation (data); + REQUIRE (data.getSize() > 0); + + // Should not crash + plugin.setStateInformation (data.getData(), static_cast (data.getSize())); + } + + SECTION ("state includes schema version") + { + juce::MemoryBlock data; + plugin.getStateInformation (data); + + std::unique_ptr xml ( + juce::AudioProcessor::getXmlFromBinary (data.getData(), + static_cast (data.getSize()))); + REQUIRE (xml != nullptr); + + auto tree = juce::ValueTree::fromXml (*xml); + REQUIRE (tree.hasProperty ("stateSchemaVersion")); + REQUIRE (static_cast (tree.getProperty ("stateSchemaVersion")) == PluginProcessor::stateSchemaVersion); + } +} diff --git a/tests/daw/StatePersistenceTests.cpp b/tests/daw/StatePersistenceTests.cpp new file mode 100644 index 0000000..6b16b05 --- /dev/null +++ b/tests/daw/StatePersistenceTests.cpp @@ -0,0 +1,220 @@ +#include "helpers/DSPTestHelpers.h" +#include "helpers/TestSignalGenerators.h" +#include +#include +#include + +using namespace DSPTestHelpers; +using namespace TestSignals; +using Catch::Approx; + +namespace +{ + juce::MemoryBlock getPluginState (PluginProcessor& processor) + { + juce::MemoryBlock state; + processor.getStateInformation (state); + return state; + } + + void setPluginState (PluginProcessor& processor, const juce::MemoryBlock& state) + { + processor.setStateInformation (state.getData(), static_cast (state.getSize())); + } + + float getParameterValue (PluginProcessor& processor, const juce::String& paramId) + { + if (auto* param = processor.getApvts().getParameter (paramId)) + return param->getValue(); + return 0.0f; + } + + void setParameterValue (PluginProcessor& processor, const juce::String& paramId, float value) + { + if (auto* param = processor.getApvts().getParameter (paramId)) + param->setValueNotifyingHost (value); + } +} + +//============================================================================== + +TEST_CASE ("State Persistence - Basic", "[daw][state][critical]") +{ + SECTION ("State is captured") + { + PluginProcessor processor; + processor.prepareToPlay (44100.0, 512); + + auto state = getPluginState (processor); + REQUIRE (state.getSize() > 0); + } + + SECTION ("Multiple captures are consistent") + { + PluginProcessor processor; + processor.prepareToPlay (44100.0, 512); + + auto state1 = getPluginState (processor); + auto state2 = getPluginState (processor); + + REQUIRE (state1.getSize() == state2.getSize()); + REQUIRE (std::memcmp (state1.getData(), state2.getData(), state1.getSize()) == 0); + } +} + +TEST_CASE ("State Persistence - Restoration", "[daw][state][critical]") +{ + SECTION ("Parameters restored across instances") + { + PluginProcessor processor1; + processor1.prepareToPlay (44100.0, 512); + + setParameterValue (processor1, "INPUT_GAIN", 0.25f); + setParameterValue (processor1, "OUTPUT_GAIN", 0.8f); + + auto state = getPluginState (processor1); + + PluginProcessor processor2; + processor2.prepareToPlay (44100.0, 512); + setPluginState (processor2, state); + + REQUIRE (getParameterValue (processor2, "INPUT_GAIN") == Approx (0.25f).margin (0.01f)); + REQUIRE (getParameterValue (processor2, "OUTPUT_GAIN") == Approx (0.8f).margin (0.01f)); + } + + SECTION ("Invalid state handled gracefully") + { + PluginProcessor processor; + processor.prepareToPlay (44100.0, 512); + + juce::MemoryBlock garbage; + garbage.append ("this is not valid XML data!!!", 30); + + // Should not crash + setPluginState (processor, garbage); + + auto buffer = createStereoBuffer (512); + generateWhiteNoise (buffer, 0.5f); + juce::MidiBuffer midi; + processor.processBlock (buffer, midi); + + REQUIRE (! bufferContainsNaN (buffer)); + } + + SECTION ("Empty state handled gracefully") + { + PluginProcessor processor; + processor.prepareToPlay (44100.0, 512); + + juce::MemoryBlock empty; + setPluginState (processor, empty); + + REQUIRE (true); + } + + SECTION ("Truncated state doesn't crash") + { + PluginProcessor processor1; + processor1.prepareToPlay (44100.0, 512); + + auto state = getPluginState (processor1); + + juce::MemoryBlock truncated; + truncated.append (state.getData(), state.getSize() / 2); + + PluginProcessor processor2; + processor2.prepareToPlay (44100.0, 512); + setPluginState (processor2, truncated); + + REQUIRE (true); + } +} + +TEST_CASE ("State Persistence - Roundtrip", "[daw][state][critical]") +{ + SECTION ("Double roundtrip preserves state") + { + PluginProcessor p1; + p1.prepareToPlay (44100.0, 512); + setParameterValue (p1, "INPUT_GAIN", 0.4f); + + auto state1 = getPluginState (p1); + + PluginProcessor p2; + p2.prepareToPlay (44100.0, 512); + setPluginState (p2, state1); + + auto state2 = getPluginState (p2); + + PluginProcessor p3; + p3.prepareToPlay (44100.0, 512); + setPluginState (p3, state2); + + REQUIRE (getParameterValue (p3, "INPUT_GAIN") == Approx (0.4f).margin (0.01f)); + } + + SECTION ("100 repeated save/load cycles") + { + PluginProcessor processor; + processor.prepareToPlay (44100.0, 512); + setParameterValue (processor, "INPUT_GAIN", 0.5f); + + for (int i = 0; i < 100; ++i) + { + auto state = getPluginState (processor); + setPluginState (processor, state); + } + + REQUIRE (getParameterValue (processor, "INPUT_GAIN") == Approx (0.5f).margin (0.01f)); + } +} + +TEST_CASE ("State Persistence - DAW Session Simulation", "[daw][state]") +{ + SECTION ("State restore before prepareToPlay") + { + PluginProcessor p1; + p1.prepareToPlay (44100.0, 512); + setParameterValue (p1, "INPUT_GAIN", 0.35f); + setParameterValue (p1, "MIX", 0.9f); + + auto state = getPluginState (p1); + + // Some DAWs restore state BEFORE calling prepareToPlay + PluginProcessor p2; + setPluginState (p2, state); + p2.prepareToPlay (44100.0, 512); + + REQUIRE (getParameterValue (p2, "INPUT_GAIN") == Approx (0.35f).margin (0.01f)); + REQUIRE (getParameterValue (p2, "MIX") == Approx (0.9f).margin (0.01f)); + + auto buffer = createStereoBuffer (512); + generateWhiteNoise (buffer, 0.5f); + juce::MidiBuffer midi; + p2.processBlock (buffer, midi); + + REQUIRE (! bufferContainsNaN (buffer)); + } + + SECTION ("State from different sample rate") + { + PluginProcessor p1; + p1.prepareToPlay (44100.0, 512); + setParameterValue (p1, "INPUT_GAIN", 0.6f); + + auto state = getPluginState (p1); + + PluginProcessor p2; + p2.prepareToPlay (96000.0, 1024); + setPluginState (p2, state); + + REQUIRE (getParameterValue (p2, "INPUT_GAIN") == Approx (0.6f).margin (0.01f)); + + auto buffer = createStereoBuffer (1024); + generateWhiteNoise (buffer, 0.5f); + juce::MidiBuffer midi; + p2.processBlock (buffer, midi); + + REQUIRE (! bufferContainsNaN (buffer)); + } +} diff --git a/tests/helpers/DSPTestHelpers.h b/tests/helpers/DSPTestHelpers.h new file mode 100644 index 0000000..450b652 --- /dev/null +++ b/tests/helpers/DSPTestHelpers.h @@ -0,0 +1,202 @@ +#pragma once + +#include +#include +#include +#include +#include + +/** + * DSP Test Helpers — buffer creation, comparison, analysis, and safety checks. + */ +namespace DSPTestHelpers +{ + constexpr float DEFAULT_TOLERANCE = 1e-6f; + constexpr float SILENCE_THRESHOLD = 1e-10f; + constexpr double DEFAULT_SAMPLE_RATE = 44100.0; + constexpr int DEFAULT_BLOCK_SIZE = 512; + + //============================================================================== + // Buffer Creation + //============================================================================== + + inline juce::AudioBuffer createStereoBuffer (int numSamples, float fillValue = 0.0f) + { + juce::AudioBuffer buffer (2, numSamples); + if (fillValue == 0.0f) + buffer.clear(); + else + for (int ch = 0; ch < 2; ++ch) + juce::FloatVectorOperations::fill (buffer.getWritePointer (ch), fillValue, numSamples); + return buffer; + } + + inline juce::AudioBuffer createMonoBuffer (int numSamples, float fillValue = 0.0f) + { + juce::AudioBuffer buffer (1, numSamples); + if (fillValue == 0.0f) + buffer.clear(); + else + juce::FloatVectorOperations::fill (buffer.getWritePointer (0), fillValue, numSamples); + return buffer; + } + + inline juce::AudioBuffer copyBuffer (const juce::AudioBuffer& source) + { + juce::AudioBuffer copy (source.getNumChannels(), source.getNumSamples()); + for (int ch = 0; ch < source.getNumChannels(); ++ch) + copy.copyFrom (ch, 0, source, ch, 0, source.getNumSamples()); + return copy; + } + + //============================================================================== + // Buffer Comparison + //============================================================================== + + inline bool buffersAreEqual (const juce::AudioBuffer& a, + const juce::AudioBuffer& b, + float tolerance = DEFAULT_TOLERANCE) + { + if (a.getNumChannels() != b.getNumChannels() || a.getNumSamples() != b.getNumSamples()) + return false; + for (int ch = 0; ch < a.getNumChannels(); ++ch) + { + const float* dataA = a.getReadPointer (ch); + const float* dataB = b.getReadPointer (ch); + for (int i = 0; i < a.getNumSamples(); ++i) + if (std::abs (dataA[i] - dataB[i]) > tolerance) + return false; + } + return true; + } + + inline bool bufferIsZero (const juce::AudioBuffer& buffer, float tolerance = SILENCE_THRESHOLD) + { + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + const float* data = buffer.getReadPointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + if (std::abs (data[i]) > tolerance) + return false; + } + return true; + } + + //============================================================================== + // Buffer Analysis + //============================================================================== + + inline float getBufferPeak (const juce::AudioBuffer& buffer) + { + float peak = 0.0f; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + const float* data = buffer.getReadPointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + peak = std::max (peak, std::abs (data[i])); + } + return peak; + } + + inline float getBufferRMS (const juce::AudioBuffer& buffer) + { + if (buffer.getNumSamples() == 0) return 0.0f; + double sumSquares = 0.0; + int totalSamples = 0; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + const float* data = buffer.getReadPointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + { + sumSquares += static_cast (data[i]) * static_cast (data[i]); + ++totalSamples; + } + } + return static_cast (std::sqrt (sumSquares / totalSamples)); + } + + //============================================================================== + // Safety Checks + //============================================================================== + + inline bool bufferContainsNaN (const juce::AudioBuffer& buffer) + { + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + const float* data = buffer.getReadPointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + if (std::isnan (data[i])) return true; + } + return false; + } + + inline bool bufferContainsInf (const juce::AudioBuffer& buffer) + { + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + const float* data = buffer.getReadPointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + if (std::isinf (data[i])) return true; + } + return false; + } + + inline bool bufferContainsDenormals (const juce::AudioBuffer& buffer) + { + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + const float* data = buffer.getReadPointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + if (std::fpclassify (data[i]) == FP_SUBNORMAL) return true; + } + return false; + } + + inline bool bufferIsValid (const juce::AudioBuffer& buffer) + { + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + const float* data = buffer.getReadPointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + if (! std::isfinite (data[i])) return false; + } + return true; + } + + //============================================================================== + // ProcessSpec Creation + //============================================================================== + + inline juce::dsp::ProcessSpec createProcessSpec (double sampleRate = DEFAULT_SAMPLE_RATE, + int blockSize = DEFAULT_BLOCK_SIZE, + int numChannels = 2) + { + juce::dsp::ProcessSpec spec; + spec.sampleRate = sampleRate; + spec.maximumBlockSize = static_cast (blockSize); + spec.numChannels = static_cast (numChannels); + return spec; + } + + //============================================================================== + // Random Helpers + //============================================================================== + + inline float getRandomFloat (float min, float max, unsigned int seed = 42) + { + static std::mt19937 engine (seed); + std::uniform_real_distribution dist (min, max); + return dist (engine); + } + + inline std::vector getCommonSampleRates() + { + return { 44100.0, 48000.0, 88200.0, 96000.0, 176400.0, 192000.0 }; + } + + inline std::vector getCommonBlockSizes() + { + return { 32, 64, 128, 256, 512, 1024, 2048, 4096 }; + } + +} // namespace DSPTestHelpers diff --git a/tests/helpers/TestSignalGenerators.h b/tests/helpers/TestSignalGenerators.h new file mode 100644 index 0000000..360879d --- /dev/null +++ b/tests/helpers/TestSignalGenerators.h @@ -0,0 +1,119 @@ +#pragma once + +#include +#include +#include +#include + +/** + * Test signal generators for DSP testing: + * - Basic waveforms (sine, square, sawtooth, triangle) + * - Noise (white, pink) + * - Edge cases (impulse, DC, NaN, Inf, denormals, overdriven) + */ +namespace TestSignals +{ + constexpr double TWO_PI = 2.0 * juce::MathConstants::pi; + + //============================================================================== + // Waveforms + //============================================================================== + + inline void generateSineWave (juce::AudioBuffer& buffer, + float frequency, double sampleRate, + float amplitude = 1.0f, float startPhase = 0.0f) + { + const double phaseInc = TWO_PI * frequency / sampleRate; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + float* data = buffer.getWritePointer (ch); + double phase = static_cast (startPhase); + for (int i = 0; i < buffer.getNumSamples(); ++i) + { + data[i] = amplitude * static_cast (std::sin (phase)); + phase += phaseInc; + if (phase >= TWO_PI) phase -= TWO_PI; + } + } + } + + //============================================================================== + // Noise + //============================================================================== + + inline void generateWhiteNoise (juce::AudioBuffer& buffer, + float amplitude = 1.0f, unsigned int seed = 42) + { + std::mt19937 gen (seed); + std::uniform_real_distribution dist (-amplitude, amplitude); + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + float* data = buffer.getWritePointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + data[i] = dist (gen); + } + } + + //============================================================================== + // Test Signals + //============================================================================== + + inline void generateImpulse (juce::AudioBuffer& buffer, + int sampleOffset = 0, float amplitude = 1.0f) + { + buffer.clear(); + if (sampleOffset >= 0 && sampleOffset < buffer.getNumSamples()) + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + buffer.setSample (ch, sampleOffset, amplitude); + } + + inline void generateDCOffset (juce::AudioBuffer& buffer, float dcValue) + { + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + juce::FloatVectorOperations::fill (buffer.getWritePointer (ch), dcValue, buffer.getNumSamples()); + } + + //============================================================================== + // Edge Cases + //============================================================================== + + inline void insertNaN (juce::AudioBuffer& buffer, int sampleIndex) + { + if (sampleIndex >= 0 && sampleIndex < buffer.getNumSamples()) + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + buffer.setSample (ch, sampleIndex, std::numeric_limits::quiet_NaN()); + } + + inline void insertInf (juce::AudioBuffer& buffer, int sampleIndex, bool positive = true) + { + if (sampleIndex >= 0 && sampleIndex < buffer.getNumSamples()) + { + float inf = positive ? std::numeric_limits::infinity() + : -std::numeric_limits::infinity(); + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + buffer.setSample (ch, sampleIndex, inf); + } + } + + inline void generateDenormals (juce::AudioBuffer& buffer) + { + float denormal = std::numeric_limits::denorm_min(); + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + float* data = buffer.getWritePointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + data[i] = (i % 2 == 0) ? denormal : -denormal; + } + } + + inline void generateMaxAmplitude (juce::AudioBuffer& buffer) + { + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + float* data = buffer.getWritePointer (ch); + for (int i = 0; i < buffer.getNumSamples(); ++i) + data[i] = (i % 2 == 0) ? 1.0f : -1.0f; + } + } + +} // namespace TestSignals diff --git a/tests/helpers/test_helpers.h b/tests/helpers/test_helpers.h new file mode 100644 index 0000000..dc6ce3e --- /dev/null +++ b/tests/helpers/test_helpers.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +/** + * Run test code within the context of a plugin editor. + * Properly manages the editor lifecycle. + * + * Example: + * runWithinPluginEditor ([&] (PluginProcessor& plugin) { + * auto* editor = plugin.getActiveEditor(); + * REQUIRE (editor != nullptr); + * }); + */ +[[maybe_unused]] static void runWithinPluginEditor ( + const std::function& testCode) +{ + PluginProcessor plugin; + const auto editor = plugin.createEditorIfNeeded(); + + testCode (plugin); + + plugin.editorBeingDeleted (editor); + delete editor; +} diff --git a/tests/safety/AudioSafetyTests.cpp b/tests/safety/AudioSafetyTests.cpp new file mode 100644 index 0000000..ffbfce5 --- /dev/null +++ b/tests/safety/AudioSafetyTests.cpp @@ -0,0 +1,290 @@ +#include "helpers/DSPTestHelpers.h" +#include "helpers/TestSignalGenerators.h" +#include +#include + +using namespace DSPTestHelpers; +using namespace TestSignals; + +//============================================================================== +// NaN Safety +//============================================================================== + +TEST_CASE ("Audio Safety - NaN", "[safety][nan][critical]") +{ + const double sampleRate = 44100.0; + const int blockSize = 512; + + SECTION ("No NaN from valid input") + { + PluginProcessor processor; + processor.prepareToPlay (sampleRate, blockSize); + + auto buffer = createStereoBuffer (blockSize); + generateWhiteNoise (buffer, 0.8f); + + juce::MidiBuffer midi; + processor.processBlock (buffer, midi); + + REQUIRE (! bufferContainsNaN (buffer)); + } + + SECTION ("No NaN from silence") + { + PluginProcessor processor; + processor.prepareToPlay (sampleRate, blockSize); + + auto buffer = createStereoBuffer (blockSize); + buffer.clear(); + + juce::MidiBuffer midi; + processor.processBlock (buffer, midi); + + REQUIRE (! bufferContainsNaN (buffer)); + } + + SECTION ("No NaN from DC input") + { + PluginProcessor processor; + processor.prepareToPlay (sampleRate, blockSize); + + auto buffer = createStereoBuffer (blockSize); + generateDCOffset (buffer, 0.5f); + + juce::MidiBuffer midi; + processor.processBlock (buffer, midi); + + REQUIRE (! bufferContainsNaN (buffer)); + } + + SECTION ("No NaN from impulse") + { + PluginProcessor processor; + processor.prepareToPlay (sampleRate, blockSize); + + auto buffer = createStereoBuffer (blockSize); + generateImpulse (buffer, 0, 1.0f); + + juce::MidiBuffer midi; + processor.processBlock (buffer, midi); + + REQUIRE (! bufferContainsNaN (buffer)); + } + + SECTION ("Handles NaN input without crash") + { + PluginProcessor processor; + processor.prepareToPlay (sampleRate, blockSize); + + auto buffer = createStereoBuffer (blockSize); + generateWhiteNoise (buffer, 0.5f); + insertNaN (buffer, 50); + + juce::MidiBuffer midi; + processor.processBlock (buffer, midi); + + REQUIRE (true); // Did not crash + } +} + +//============================================================================== +// Infinity Safety +//============================================================================== + +TEST_CASE ("Audio Safety - Infinity", "[safety][inf][critical]") +{ + const double sampleRate = 44100.0; + const int blockSize = 512; + + SECTION ("No Inf from valid input") + { + PluginProcessor processor; + processor.prepareToPlay (sampleRate, blockSize); + + auto buffer = createStereoBuffer (blockSize); + generateWhiteNoise (buffer, 0.8f); + + juce::MidiBuffer midi; + processor.processBlock (buffer, midi); + + REQUIRE (! bufferContainsInf (buffer)); + } + + SECTION ("No Inf from loud input") + { + PluginProcessor processor; + processor.prepareToPlay (sampleRate, blockSize); + + auto buffer = createStereoBuffer (blockSize); + generateSineWave (buffer, 440.0f, sampleRate, 10.0f); + + juce::MidiBuffer midi; + processor.processBlock (buffer, midi); + + REQUIRE (! bufferContainsInf (buffer)); + } + + SECTION ("Handles Inf input without crash") + { + PluginProcessor processor; + processor.prepareToPlay (sampleRate, blockSize); + + auto buffer = createStereoBuffer (blockSize); + generateWhiteNoise (buffer, 0.5f); + insertInf (buffer, 100, true); + + juce::MidiBuffer midi; + processor.processBlock (buffer, midi); + + REQUIRE (true); + } +} + +//============================================================================== +// Denormal Safety +//============================================================================== + +TEST_CASE ("Audio Safety - Denormals", "[safety][denormal][critical]") +{ + const double sampleRate = 44100.0; + const int blockSize = 512; + + SECTION ("Handles denormal input") + { + PluginProcessor processor; + processor.prepareToPlay (sampleRate, blockSize); + + auto buffer = createStereoBuffer (blockSize); + generateDenormals (buffer); + + juce::MidiBuffer midi; + processor.processBlock (buffer, midi); + + REQUIRE (! bufferContainsNaN (buffer)); + REQUIRE (! bufferContainsInf (buffer)); + } +} + +//============================================================================== +// Buffer Size Safety +//============================================================================== + +TEST_CASE ("Audio Safety - Buffer Sizes", "[safety][buffer][critical]") +{ + const double sampleRate = 44100.0; + + SECTION ("Various block sizes work") + { + for (int bs : getCommonBlockSizes()) + { + PluginProcessor processor; + processor.prepareToPlay (sampleRate, bs); + + auto buffer = createStereoBuffer (bs); + generateWhiteNoise (buffer, 0.5f); + + juce::MidiBuffer midi; + processor.processBlock (buffer, midi); + + INFO ("Block size: " << bs); + REQUIRE (! bufferContainsNaN (buffer)); + } + } + + SECTION ("Large buffer is handled") + { + PluginProcessor processor; + processor.prepareToPlay (sampleRate, 8192); + + auto buffer = createStereoBuffer (8192); + generateWhiteNoise (buffer, 0.5f); + + juce::MidiBuffer midi; + processor.processBlock (buffer, midi); + + REQUIRE (! bufferContainsNaN (buffer)); + } +} + +//============================================================================== +// Sample Rate Safety +//============================================================================== + +TEST_CASE ("Audio Safety - Sample Rates", "[safety][samplerate]") +{ + const int blockSize = 512; + + for (double sr : getCommonSampleRates()) + { + SECTION ("Sample rate " + std::to_string (static_cast (sr))) + { + PluginProcessor processor; + processor.prepareToPlay (sr, blockSize); + + auto buffer = createStereoBuffer (blockSize); + generateWhiteNoise (buffer, 0.5f); + + juce::MidiBuffer midi; + processor.processBlock (buffer, midi); + + REQUIRE (! bufferContainsNaN (buffer)); + } + } +} + +//============================================================================== +// Extreme Input +//============================================================================== + +TEST_CASE ("Audio Safety - Extreme Input", "[safety][extreme][critical]") +{ + const double sampleRate = 44100.0; + const int blockSize = 512; + + SECTION ("Maximum amplitude input") + { + PluginProcessor processor; + processor.prepareToPlay (sampleRate, blockSize); + + auto buffer = createStereoBuffer (blockSize); + generateMaxAmplitude (buffer); + + juce::MidiBuffer midi; + processor.processBlock (buffer, midi); + + REQUIRE (! bufferContainsNaN (buffer)); + REQUIRE (! bufferContainsInf (buffer)); + } + + SECTION ("100x overdriven input") + { + PluginProcessor processor; + processor.prepareToPlay (sampleRate, blockSize); + + auto buffer = createStereoBuffer (blockSize); + generateSineWave (buffer, 440.0f, sampleRate, 100.0f); + + juce::MidiBuffer midi; + processor.processBlock (buffer, midi); + + REQUIRE (! bufferContainsNaN (buffer)); + REQUIRE (! bufferContainsInf (buffer)); + } + + SECTION ("Output always valid over 100 random blocks") + { + PluginProcessor processor; + processor.prepareToPlay (sampleRate, blockSize); + + for (int i = 0; i < 100; ++i) + { + auto buffer = createStereoBuffer (blockSize); + generateWhiteNoise (buffer, getRandomFloat (0.1f, 1.0f), i); + + juce::MidiBuffer midi; + processor.processBlock (buffer, midi); + + REQUIRE (bufferIsValid (buffer)); + } + } +} diff --git a/third_party/friendnet b/third_party/friendnet new file mode 160000 index 0000000..13356d5 --- /dev/null +++ b/third_party/friendnet @@ -0,0 +1 @@ +Subproject commit 13356d581f3161540ddc81511d4730a6f4f493f2 diff --git a/ui/bun.lock b/ui/bun.lock new file mode 100644 index 0000000..27982f6 --- /dev/null +++ b/ui/bun.lock @@ -0,0 +1,468 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "wavgang-ui", + "dependencies": { + "pinia": "^3.0.1", + "vue": "^3.5.13", + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "@vue/test-utils": "^2.4.6", + "jsdom": "^26.0.0", + "typescript": "~5.7.2", + "vite": "^6.0.7", + "vitest": "^3.0.5", + }, + }, + }, + "packages": { + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@one-ini/wasm": ["@one-ini/wasm@0.1.1", "", {}, "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="], + + "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + + "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + + "@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="], + + "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + + "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + + "@vue/compiler-core": ["@vue/compiler-core@3.5.32", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/shared": "3.5.32", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.32", "", { "dependencies": { "@vue/compiler-core": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q=="], + + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.32", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/compiler-core": "3.5.32", "@vue/compiler-dom": "3.5.32", "@vue/compiler-ssr": "3.5.32", "@vue/shared": "3.5.32", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg=="], + + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.32", "", { "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw=="], + + "@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="], + + "@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="], + + "@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="], + + "@vue/reactivity": ["@vue/reactivity@3.5.32", "", { "dependencies": { "@vue/shared": "3.5.32" } }, "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ=="], + + "@vue/runtime-core": ["@vue/runtime-core@3.5.32", "", { "dependencies": { "@vue/reactivity": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ=="], + + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.32", "", { "dependencies": { "@vue/reactivity": "3.5.32", "@vue/runtime-core": "3.5.32", "@vue/shared": "3.5.32", "csstype": "^3.2.3" } }, "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ=="], + + "@vue/server-renderer": ["@vue/server-renderer@3.5.32", "", { "dependencies": { "@vue/compiler-ssr": "3.5.32", "@vue/shared": "3.5.32" }, "peerDependencies": { "vue": "3.5.32" } }, "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ=="], + + "@vue/shared": ["@vue/shared@3.5.32", "", {}, "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg=="], + + "@vue/test-utils": ["@vue/test-utils@2.4.6", "", { "dependencies": { "js-beautify": "^1.14.9", "vue-component-type-helpers": "^2.0.0" } }, "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow=="], + + "abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], + + "brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], + + "config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="], + + "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "editorconfig": ["editorconfig@1.0.7", "", { "dependencies": { "@one-ini/wasm": "0.1.1", "commander": "^10.0.0", "minimatch": "^9.0.1", "semver": "^7.5.3" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "js-beautify": ["js-beautify@1.15.4", "", { "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", "glob": "^10.4.2", "js-cookie": "^3.0.5", "nopt": "^7.2.1" }, "bin": { "css-beautify": "js/bin/css-beautify.js", "html-beautify": "js/bin/html-beautify.js", "js-beautify": "js/bin/js-beautify.js" } }, "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA=="], + + "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="], + + "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + + "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], + + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], + + "nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + + "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "pinia": ["pinia@3.0.4", "", { "dependencies": { "@vue/devtools-api": "^7.7.7" }, "peerDependencies": { "typescript": ">=4.5.0", "vue": "^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw=="], + + "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], + + "proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], + + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + + "superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + + "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], + + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + + "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], + + "vue": ["vue@3.5.32", "", { "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/compiler-sfc": "3.5.32", "@vue/runtime-dom": "3.5.32", "@vue/server-renderer": "3.5.32", "@vue/shared": "3.5.32" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw=="], + + "vue-component-type-helpers": ["vue-component-type-helpers@2.2.12", "", {}, "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + } +} diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..932993d --- /dev/null +++ b/ui/index.html @@ -0,0 +1,12 @@ + + + + + + WavGang + + +
+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..476d273 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,24 @@ +{ + "name": "wavgang-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "pinia": "^3.0.1", + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "@vue/test-utils": "^2.4.6", + "jsdom": "^26.0.0", + "typescript": "~5.7.2", + "vite": "^6.0.7", + "vitest": "^3.0.5" + } +} diff --git a/ui/src/App.vue b/ui/src/App.vue new file mode 100644 index 0000000..3e686cc --- /dev/null +++ b/ui/src/App.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 0000000..6a7c3ed --- /dev/null +++ b/ui/src/main.ts @@ -0,0 +1,7 @@ +import { createPinia } from "pinia"; +import { createApp } from "vue"; +import App from "./App.vue"; + +const app = createApp(App); +app.use(createPinia()); +app.mount("#app"); diff --git a/ui/src/nativeBridge.spec.ts b/ui/src/nativeBridge.spec.ts new file mode 100644 index 0000000..fe7cd55 --- /dev/null +++ b/ui/src/nativeBridge.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from "vitest"; +import { getBridgeBaseUrl } from "./nativeBridge"; + +describe("getBridgeBaseUrl", () => { + it("falls back when JUCE is absent", async () => { + const u = await getBridgeBaseUrl(); + expect(u).toBe("http://127.0.0.1:17890"); + }); + + it("uses native function when present", async () => { + window.__JUCE__ = { + backend: { + getNativeFunction: vi.fn(() => async () => "http://example.test:9999"), + }, + }; + const u = await getBridgeBaseUrl(); + expect(u).toBe("http://example.test:9999"); + }); +}); diff --git a/ui/src/nativeBridge.ts b/ui/src/nativeBridge.ts new file mode 100644 index 0000000..17ec23b --- /dev/null +++ b/ui/src/nativeBridge.ts @@ -0,0 +1,15 @@ +/** Reads bridge base URL from JUCE native integration when available. */ + +export async function getBridgeBaseUrl(): Promise { + const juce = window.__JUCE__?.backend; + if (juce?.getNativeFunction) { + try { + const fn = juce.getNativeFunction("getBridgeBaseUrl"); + const v = await fn(); + if (typeof v === "string" && v.length > 0) return v; + } catch { + /* fall through */ + } + } + return "http://127.0.0.1:17890"; +} diff --git a/ui/src/stores/bridge.ts b/ui/src/stores/bridge.ts new file mode 100644 index 0000000..70a3a2f --- /dev/null +++ b/ui/src/stores/bridge.ts @@ -0,0 +1,23 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { getBridgeBaseUrl } from "../nativeBridge"; + +export const useBridgeStore = defineStore("bridge", () => { + const statusJson = ref(""); + const error = ref(""); + + async function refreshStatus() { + error.value = ""; + try { + const base = await getBridgeBaseUrl(); + const r = await fetch(`${base}/v1/status`); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + statusJson.value = await r.text(); + } catch (e) { + error.value = e instanceof Error ? e.message : String(e); + statusJson.value = ""; + } + } + + return { statusJson, error, refreshStatus }; +}); diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts new file mode 100644 index 0000000..01227ba --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1,13 @@ +/// + +declare global { + interface Window { + __JUCE__?: { + backend: { + getNativeFunction: (name: string) => (...args: unknown[]) => Promise; + }; + }; + } +} + +export {}; diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..4ae24f5 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/ui/tsconfig.node.json b/ui/tsconfig.node.json new file mode 100644 index 0000000..fcfc213 --- /dev/null +++ b/ui/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler" + }, + "include": ["vite.config.ts", "vitest.config.ts"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..179bb0c --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,12 @@ +import vue from "@vitejs/plugin-vue"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [vue()], + base: "./", + build: { + outDir: "dist", + emptyOutDir: true, + assetsDir: "assets", + }, +}); diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts new file mode 100644 index 0000000..4b13518 --- /dev/null +++ b/ui/vitest.config.ts @@ -0,0 +1,10 @@ +import vue from "@vitejs/plugin-vue"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [vue()], + test: { + environment: "jsdom", + include: ["src/**/*.spec.ts"], + }, +}); From 73016d2f4b687c056a2cae31467bd78a685b1f87 Mon Sep 17 00:00:00 2001 From: Seamus Mullan <43112447+SeamusMullan@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:07:27 +0100 Subject: [PATCH 02/13] ci: point cmake and CLAP submodules at forks with published SHAs Upstream sudara/cmake-includes and free-audio/clap-juce-extensions could not fetch the pinned commits (local-only fixes). Mirrors under SeamusMullan carry the same SHAs; JUCE develop and other submodules were already at remote tips. Made-with: Cursor --- .gitmodules | 4 ++-- modules.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitmodules b/.gitmodules index 2992d9d..924a04d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,7 +5,7 @@ ignore = dirty [submodule "cmake"] path = cmake - url = https://github.com/sudara/cmake-includes.git + url = https://github.com/SeamusMullan/cmake-includes.git branch = main ignore = dirty [submodule "modules/melatonin_inspector"] @@ -15,7 +15,7 @@ ignore = dirty [submodule "modules/clap-juce-extensions"] path = modules/clap-juce-extensions - url = https://github.com/free-audio/clap-juce-extensions.git + url = https://github.com/SeamusMullan/clap-juce-extensions.git branch = main ignore = dirty [submodule "modules/moonbase_JUCEClient"] diff --git a/modules.toml b/modules.toml index 2cd9943..9b6d2c4 100644 --- a/modules.toml +++ b/modules.toml @@ -30,7 +30,7 @@ conflicts = [] [modules.clap_juce_extensions] display_name = "CLAP Format" description = "CLAP plugin format support via clap-juce-extensions" -url = "https://github.com/free-audio/clap-juce-extensions.git" +url = "https://github.com/SeamusMullan/clap-juce-extensions.git" branch = "main" cmake_option = "ENABLE_CLAP" submodule_path = "modules/clap-juce-extensions" From cb2d89aa02e2d69d183e71a689f0bf4801cc16ba Mon Sep 17 00:00:00 2001 From: Seamus Mullan <43112447+SeamusMullan@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:12:50 +0100 Subject: [PATCH 03/13] ci: drop invalid nested submodule pathspecs for CLAP libs Nested clap-libs paths are not registered in the top-level repo; --recursive on modules/clap-juce-extensions already checks them out. Made-with: Cursor --- .github/workflows/build_and_test.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 410eb9e..10b92c0 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -50,8 +50,6 @@ jobs: git submodule update --init --recursive JUCE cmake git submodule update --init --recursive modules/melatonin_inspector git submodule update --init --recursive modules/clap-juce-extensions - git submodule update --init --recursive modules/clap-juce-extensions/clap-libs/clap - git submodule update --init --recursive modules/clap-juce-extensions/clap-libs/clap-helpers - name: Install Linux dependencies if: runner.os == 'Linux' @@ -139,8 +137,6 @@ jobs: git submodule update --init --recursive JUCE cmake git submodule update --init --recursive modules/melatonin_inspector git submodule update --init --recursive modules/clap-juce-extensions - git submodule update --init --recursive modules/clap-juce-extensions/clap-libs/clap - git submodule update --init --recursive modules/clap-juce-extensions/clap-libs/clap-helpers - name: Install Linux dependencies run: | From dab4cc47e456f7d5ffd28e49ade5b4d3bc94894d Mon Sep 17 00:00:00 2001 From: Seamus Mullan <43112447+SeamusMullan@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:14:32 +0100 Subject: [PATCH 04/13] ci: match Chasm submodule checkout (checkout@v5, recursive, PAT) Use actions/checkout recursive init like Chasm instead of manual git submodule commands; drop shallow fetch-depth for submodule parity. Made-with: Cursor --- .github/workflows/build_and_test.yml | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 10b92c0..9ef37a1 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -30,10 +30,11 @@ jobs: os: windows-latest steps: - - name: Checkout - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v5 with: - fetch-depth: 1 + submodules: recursive + token: ${{ secrets.PAT }} - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -45,12 +46,6 @@ jobs: with: go-version: "1.22.x" - - name: Initialize submodules - run: | - git submodule update --init --recursive JUCE cmake - git submodule update --init --recursive modules/melatonin_inspector - git submodule update --init --recursive modules/clap-juce-extensions - - name: Install Linux dependencies if: runner.os == 'Linux' run: | @@ -111,7 +106,7 @@ jobs: name: Format Check runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Check clang-format run: | @@ -123,21 +118,17 @@ jobs: name: ASan + UBSan runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v5 with: - fetch-depth: 1 + submodules: recursive + token: ${{ secrets.PAT }} - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: latest - - name: Initialize submodules - run: | - git submodule update --init --recursive JUCE cmake - git submodule update --init --recursive modules/melatonin_inspector - git submodule update --init --recursive modules/clap-juce-extensions - - name: Install Linux dependencies run: | sudo apt-get update && sudo apt-get install -y \ From b0e8edda58c9500d5a815e36c45261d338272760 Mon Sep 17 00:00:00 2001 From: Seamus Mullan <43112447+SeamusMullan@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:17:09 +0100 Subject: [PATCH 05/13] ci: fall back to github.token when PAT secret is unset Made-with: Cursor --- .github/workflows/build_and_test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 9ef37a1..74f1678 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -34,7 +34,7 @@ jobs: uses: actions/checkout@v5 with: submodules: recursive - token: ${{ secrets.PAT }} + token: ${{ secrets.PAT || github.token }} - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -122,7 +122,7 @@ jobs: uses: actions/checkout@v5 with: submodules: recursive - token: ${{ secrets.PAT }} + token: ${{ secrets.PAT || github.token }} - name: Setup Bun uses: oven-sh/setup-bun@v2 From 9ab8db5984e0d857e2262c69fc320da929815fb3 Mon Sep 17 00:00:00 2001 From: Seamus Mullan <43112447+SeamusMullan@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:18:27 +0100 Subject: [PATCH 06/13] ci: use Go 1.26.x for bridge (FriendNet go.work alignment) Made-with: Cursor --- .github/workflows/build_and_test.yml | 2 +- bridge/go.mod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 74f1678..0c98e15 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -44,7 +44,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.22.x" + go-version: "1.26.x" - name: Install Linux dependencies if: runner.os == 'Linux' diff --git a/bridge/go.mod b/bridge/go.mod index 2369fed..5827ac7 100644 --- a/bridge/go.mod +++ b/bridge/go.mod @@ -1,3 +1,3 @@ module github.com/DirektDSP/WavGang/bridge -go 1.22 +go 1.26.2 From 830b86867c5eb21710004d76ca09998f5948b964 Mon Sep 17 00:00:00 2001 From: Seamus Mullan <43112447+SeamusMullan@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:26:53 +0100 Subject: [PATCH 07/13] ci: fix checkout token expression; clang-format source/tests/benchmarks - Use (secrets.PAT && PAT) || github.token so fork PRs never pass an empty token to checkout v5. - Apply project .clang-format to satisfy the format-check job. Made-with: Cursor --- .github/workflows/build_and_test.yml | 4 +- benchmarks/Benchmarks.cpp | 2 +- source/DSP/Core/ProcessorCore.h | 350 +++++++++++++-------------- source/DSP/Utils/DSPUtils.h | 106 ++++---- source/DSP/Utils/MeteringFIFO.h | 120 ++++----- source/DSP/Utils/ParameterSmoother.h | 136 +++++------ source/PluginProcessor.cpp | 57 ++--- source/PluginProcessor.h | 9 +- source/Service/PresetManager.cpp | 38 +-- source/Service/PresetManager.h | 16 +- tests/PluginBasics.cpp | 2 +- tests/daw/StatePersistenceTests.cpp | 8 +- tests/helpers/DSPTestHelpers.h | 25 +- tests/helpers/TestSignalGenerators.h | 17 +- tests/safety/AudioSafetyTests.cpp | 30 +-- 15 files changed, 461 insertions(+), 459 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 0c98e15..a442096 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -34,7 +34,7 @@ jobs: uses: actions/checkout@v5 with: submodules: recursive - token: ${{ secrets.PAT || github.token }} + token: ${{ (secrets.PAT != '' && secrets.PAT) || github.token }} - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -122,7 +122,7 @@ jobs: uses: actions/checkout@v5 with: submodules: recursive - token: ${{ secrets.PAT || github.token }} + token: ${{ (secrets.PAT != '' && secrets.PAT) || github.token }} - name: Setup Bun uses: oven-sh/setup-bun@v2 diff --git a/benchmarks/Benchmarks.cpp b/benchmarks/Benchmarks.cpp index c0b3a77..08b9294 100644 --- a/benchmarks/Benchmarks.cpp +++ b/benchmarks/Benchmarks.cpp @@ -1,6 +1,6 @@ #include "PluginProcessor.h" -#include #include +#include TEST_CASE ("Process block benchmarks", "[benchmark]") { diff --git a/source/DSP/Core/ProcessorCore.h b/source/DSP/Core/ProcessorCore.h index 4e06e87..1832e2b 100644 --- a/source/DSP/Core/ProcessorCore.h +++ b/source/DSP/Core/ProcessorCore.h @@ -1,17 +1,17 @@ #pragma once -#include -#include -#include "../Utils/ParameterSmoother.h" #include "../Utils/DSPUtils.h" #include "../Utils/MeteringFIFO.h" +#include "../Utils/ParameterSmoother.h" +#include +#include namespace DSP { -namespace Core -{ + namespace Core + { - /** + /** * Template DSP processor scaffold. * * Demonstrates the recommended patterns: @@ -25,210 +25,210 @@ namespace Core * * To use: subclass or copy this pattern into your plugin's DSP engine. */ - template - class ProcessorCore - { - public: - ProcessorCore() = default; - virtual ~ProcessorCore() = default; - - void prepare (const juce::dsp::ProcessSpec& spec, - SampleType initialInputGain = SampleType { 1.0 }, - SampleType initialOutputGain = SampleType { 1.0 }, - SampleType initialMix = SampleType { 100.0 }) + template + class ProcessorCore { - sampleRate = spec.sampleRate; - samplesPerBlock = static_cast (spec.maximumBlockSize); - numChannels = static_cast (spec.numChannels); - - wetBuffer.setSize (numChannels, samplesPerBlock); - dryBuffer.setSize (numChannels, samplesPerBlock); - - // Gain smoothers: fast (1ms) to avoid audible lag - inputGainSmoother.prepare (sampleRate, 1.0); - outputGainSmoother.prepare (sampleRate, 1.0); - // Mix smoother: moderate (5ms) - mixSmoother.prepare (sampleRate, 5.0); - // Bypass smoother: fast crossfade (5ms) - bypassSmoother.prepare (sampleRate, 5.0); - - // Snap all smoothers to initial values (no ramp on first block) - inputGainSmoother.setTargetValue (initialInputGain); - inputGainSmoother.snapToTargetValue(); - outputGainSmoother.setTargetValue (initialOutputGain); - outputGainSmoother.snapToTargetValue(); - mixSmoother.setTargetValue (Utils::DSPUtils::percentageToNormalized (initialMix)); - mixSmoother.snapToTargetValue(); - bypassSmoother.setTargetValue (SampleType { 0.0 }); // not bypassed - bypassSmoother.snapToTargetValue(); - - onPrepare (spec); - } - - void updateParameters (SampleType inputGainDb, - SampleType outputGainDb, - SampleType mixPercent, - bool bypassed) - { - inputGainSmoother.setTargetValue (Utils::DSPUtils::dbToGain (inputGainDb)); - outputGainSmoother.setTargetValue (Utils::DSPUtils::dbToGain (outputGainDb)); - mixSmoother.setTargetValue (Utils::DSPUtils::percentageToNormalized (mixPercent)); - bypassSmoother.setTargetValue (bypassed ? SampleType { 1.0 } : SampleType { 0.0 }); - - onUpdateParameters(); - } - - void processBlock (juce::AudioBuffer& buffer) - { - jassert (buffer.getNumChannels() >= 1); - - const int numSamples = buffer.getNumSamples(); - - // Ensure internal buffers match incoming block size - if (wetBuffer.getNumSamples() != numSamples) + public: + ProcessorCore() = default; + virtual ~ProcessorCore() = default; + + void prepare (const juce::dsp::ProcessSpec& spec, + SampleType initialInputGain = SampleType { 1.0 }, + SampleType initialOutputGain = SampleType { 1.0 }, + SampleType initialMix = SampleType { 100.0 }) { - wetBuffer.setSize (numChannels, numSamples, false, false, true); - dryBuffer.setSize (numChannels, numSamples, false, false, true); + sampleRate = spec.sampleRate; + samplesPerBlock = static_cast (spec.maximumBlockSize); + numChannels = static_cast (spec.numChannels); + + wetBuffer.setSize (numChannels, samplesPerBlock); + dryBuffer.setSize (numChannels, samplesPerBlock); + + // Gain smoothers: fast (1ms) to avoid audible lag + inputGainSmoother.prepare (sampleRate, 1.0); + outputGainSmoother.prepare (sampleRate, 1.0); + // Mix smoother: moderate (5ms) + mixSmoother.prepare (sampleRate, 5.0); + // Bypass smoother: fast crossfade (5ms) + bypassSmoother.prepare (sampleRate, 5.0); + + // Snap all smoothers to initial values (no ramp on first block) + inputGainSmoother.setTargetValue (initialInputGain); + inputGainSmoother.snapToTargetValue(); + outputGainSmoother.setTargetValue (initialOutputGain); + outputGainSmoother.snapToTargetValue(); + mixSmoother.setTargetValue (Utils::DSPUtils::percentageToNormalized (initialMix)); + mixSmoother.snapToTargetValue(); + bypassSmoother.setTargetValue (SampleType { 0.0 }); // not bypassed + bypassSmoother.snapToTargetValue(); + + onPrepare (spec); } - // Preserve dry signal for wet/dry mixing - dryBuffer.makeCopyOf (buffer); - - // Apply input gain and copy to wet buffer - for (int i = 0; i < numSamples; ++i) + void updateParameters (SampleType inputGainDb, + SampleType outputGainDb, + SampleType mixPercent, + bool bypassed) { - const auto inputGain = inputGainSmoother.getNextValue(); + inputGainSmoother.setTargetValue (Utils::DSPUtils::dbToGain (inputGainDb)); + outputGainSmoother.setTargetValue (Utils::DSPUtils::dbToGain (outputGainDb)); + mixSmoother.setTargetValue (Utils::DSPUtils::percentageToNormalized (mixPercent)); + bypassSmoother.setTargetValue (bypassed ? SampleType { 1.0 } : SampleType { 0.0 }); - for (int ch = 0; ch < buffer.getNumChannels(); ++ch) - { - auto sample = buffer.getSample (ch, i) * inputGain; - wetBuffer.setSample (ch, i, sample); - } - - // Throttle DSP component updates (every 32 samples) - if (i == 0 || (i % updateInterval) == 0) - onUpdateDSPComponents (i); + onUpdateParameters(); } - // Process the wet buffer through your DSP chain - onProcess (wetBuffer, numSamples); - - // Mix wet/dry and apply output gain + bypass crossfade - for (int i = 0; i < numSamples; ++i) + void processBlock (juce::AudioBuffer& buffer) { - const auto mix = mixSmoother.getNextValue(); - const auto outputGain = outputGainSmoother.getNextValue(); - const auto bypass = bypassSmoother.getNextValue(); + jassert (buffer.getNumChannels() >= 1); + + const int numSamples = buffer.getNumSamples(); - for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + // Ensure internal buffers match incoming block size + if (wetBuffer.getNumSamples() != numSamples) { - const auto drySample = dryBuffer.getSample (ch, i); - const auto wetSample = wetBuffer.getSample (ch, i); + wetBuffer.setSize (numChannels, numSamples, false, false, true); + dryBuffer.setSize (numChannels, numSamples, false, false, true); + } - // Wet/dry crossfade - auto processed = drySample * (SampleType { 1.0 } - mix) - + wetSample * mix; - processed *= outputGain; + // Preserve dry signal for wet/dry mixing + dryBuffer.makeCopyOf (buffer); - // Bypass crossfade (0 = processed, 1 = dry) - auto output = processed * (SampleType { 1.0 } - bypass) - + drySample * bypass; + // Apply input gain and copy to wet buffer + for (int i = 0; i < numSamples; ++i) + { + const auto inputGain = inputGainSmoother.getNextValue(); + + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + auto sample = buffer.getSample (ch, i) * inputGain; + wetBuffer.setSample (ch, i, sample); + } - buffer.getWritePointer (ch)[i] = output; + // Throttle DSP component updates (every 32 samples) + if (i == 0 || (i % updateInterval) == 0) + onUpdateDSPComponents (i); } - } - // Push metering data (lock-free) - pushMeteringData (buffer); - } + // Process the wet buffer through your DSP chain + onProcess (wetBuffer, numSamples); - void reset (SampleType inputGain = SampleType { 1.0 }, - SampleType outputGain = SampleType { 1.0 }, - SampleType mix = SampleType { 100.0 }) - { - inputGainSmoother.reset (inputGain); - inputGainSmoother.snapToTargetValue(); - outputGainSmoother.reset (outputGain); - outputGainSmoother.snapToTargetValue(); - mixSmoother.reset (Utils::DSPUtils::percentageToNormalized (mix)); - mixSmoother.snapToTargetValue(); - bypassSmoother.reset (SampleType { 0.0 }); - bypassSmoother.snapToTargetValue(); + // Mix wet/dry and apply output gain + bypass crossfade + for (int i = 0; i < numSamples; ++i) + { + const auto mix = mixSmoother.getNextValue(); + const auto outputGain = outputGainSmoother.getNextValue(); + const auto bypass = bypassSmoother.getNextValue(); + + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) + { + const auto drySample = dryBuffer.getSample (ch, i); + const auto wetSample = wetBuffer.getSample (ch, i); + + // Wet/dry crossfade + auto processed = drySample * (SampleType { 1.0 } - mix) + + wetSample * mix; + processed *= outputGain; + + // Bypass crossfade (0 = processed, 1 = dry) + auto output = processed * (SampleType { 1.0 } - bypass) + + drySample * bypass; + + buffer.getWritePointer (ch)[i] = output; + } + } - onReset(); - } + // Push metering data (lock-free) + pushMeteringData (buffer); + } - /** Access the metering FIFO from the GUI thread. */ - Utils::MeteringFIFO<>& getMeteringFIFO() { return meteringFifo; } + void reset (SampleType inputGain = SampleType { 1.0 }, + SampleType outputGain = SampleType { 1.0 }, + SampleType mix = SampleType { 100.0 }) + { + inputGainSmoother.reset (inputGain); + inputGainSmoother.snapToTargetValue(); + outputGainSmoother.reset (outputGain); + outputGainSmoother.snapToTargetValue(); + mixSmoother.reset (Utils::DSPUtils::percentageToNormalized (mix)); + mixSmoother.snapToTargetValue(); + bypassSmoother.reset (SampleType { 0.0 }); + bypassSmoother.snapToTargetValue(); + + onReset(); + } - protected: - // Override these in your subclass to add DSP processing: + /** Access the metering FIFO from the GUI thread. */ + Utils::MeteringFIFO<>& getMeteringFIFO() { return meteringFifo; } - /** Called during prepare(). Set up your DSP components here. */ - virtual void onPrepare (const juce::dsp::ProcessSpec& /*spec*/) {} + protected: + // Override these in your subclass to add DSP processing: - /** Called when parameters are updated. Set targets on your own smoothers. */ - virtual void onUpdateParameters() {} + /** Called during prepare(). Set up your DSP components here. */ + virtual void onPrepare (const juce::dsp::ProcessSpec& /*spec*/) {} - /** Called every updateInterval samples. Update expensive DSP state here. */ - virtual void onUpdateDSPComponents (int /*sampleIndex*/) {} + /** Called when parameters are updated. Set targets on your own smoothers. */ + virtual void onUpdateParameters() {} - /** Process the wet buffer. This is where your DSP chain goes. */ - virtual void onProcess (juce::AudioBuffer& /*wetBuffer*/, int /*numSamples*/) {} + /** Called every updateInterval samples. Update expensive DSP state here. */ + virtual void onUpdateDSPComponents (int /*sampleIndex*/) {} - /** Called during reset(). Reset your DSP components here. */ - virtual void onReset() {} + /** Process the wet buffer. This is where your DSP chain goes. */ + virtual void onProcess (juce::AudioBuffer& /*wetBuffer*/, int /*numSamples*/) {} - double sampleRate = 44100.0; - int samplesPerBlock = 512; - int numChannels = 2; + /** Called during reset(). Reset your DSP components here. */ + virtual void onReset() {} - /** How often to call onUpdateDSPComponents (in samples). Default: every 32. */ - int updateInterval = 32; + double sampleRate = 44100.0; + int samplesPerBlock = 512; + int numChannels = 2; - private: - void pushMeteringData (const juce::AudioBuffer& buffer) - { - Utils::MeterData data; - const int numSamples = buffer.getNumSamples(); + /** How often to call onUpdateDSPComponents (in samples). Default: every 32. */ + int updateInterval = 32; - if (buffer.getNumChannels() >= 1) + private: + void pushMeteringData (const juce::AudioBuffer& buffer) { - data.peakL = buffer.getMagnitude (0, 0, numSamples); - float sumSq = 0.0f; - const float* ch = buffer.getReadPointer (0); - for (int i = 0; i < numSamples; ++i) - sumSq += ch[i] * ch[i]; - data.rmsL = std::sqrt (sumSq / static_cast (numSamples)); - } + Utils::MeterData data; + const int numSamples = buffer.getNumSamples(); - if (buffer.getNumChannels() >= 2) - { - data.peakR = buffer.getMagnitude (1, 0, numSamples); - float sumSq = 0.0f; - const float* ch = buffer.getReadPointer (1); - for (int i = 0; i < numSamples; ++i) - sumSq += ch[i] * ch[i]; - data.rmsR = std::sqrt (sumSq / static_cast (numSamples)); - } + if (buffer.getNumChannels() >= 1) + { + data.peakL = buffer.getMagnitude (0, 0, numSamples); + float sumSq = 0.0f; + const float* ch = buffer.getReadPointer (0); + for (int i = 0; i < numSamples; ++i) + sumSq += ch[i] * ch[i]; + data.rmsL = std::sqrt (sumSq / static_cast (numSamples)); + } - meteringFifo.push (data); - } + if (buffer.getNumChannels() >= 2) + { + data.peakR = buffer.getMagnitude (1, 0, numSamples); + float sumSq = 0.0f; + const float* ch = buffer.getReadPointer (1); + for (int i = 0; i < numSamples; ++i) + sumSq += ch[i] * ch[i]; + data.rmsR = std::sqrt (sumSq / static_cast (numSamples)); + } + + meteringFifo.push (data); + } - Utils::ParameterSmoother inputGainSmoother; - Utils::ParameterSmoother outputGainSmoother; - Utils::ParameterSmoother mixSmoother; - Utils::ParameterSmoother bypassSmoother; + Utils::ParameterSmoother inputGainSmoother; + Utils::ParameterSmoother outputGainSmoother; + Utils::ParameterSmoother mixSmoother; + Utils::ParameterSmoother bypassSmoother; - juce::AudioBuffer wetBuffer; - juce::AudioBuffer dryBuffer; + juce::AudioBuffer wetBuffer; + juce::AudioBuffer dryBuffer; - Utils::MeteringFIFO<> meteringFifo; - }; + Utils::MeteringFIFO<> meteringFifo; + }; - // Common aliases - using FloatProcessor = ProcessorCore; - using DoubleProcessor = ProcessorCore; + // Common aliases + using FloatProcessor = ProcessorCore; + using DoubleProcessor = ProcessorCore; -} // namespace Core + } // namespace Core } // namespace DSP diff --git a/source/DSP/Utils/DSPUtils.h b/source/DSP/Utils/DSPUtils.h index 4bdd80e..0baf6f6 100644 --- a/source/DSP/Utils/DSPUtils.h +++ b/source/DSP/Utils/DSPUtils.h @@ -1,73 +1,73 @@ #pragma once -#include #include +#include namespace DSP { -namespace Utils -{ + namespace Utils + { - /** + /** * Common DSP utility functions. */ - class DSPUtils - { - public: - /** Converts decibels to linear gain. */ - static inline float dbToGain (float db) + class DSPUtils { - return std::pow (10.0f, db * 0.05f); - } + public: + /** Converts decibels to linear gain. */ + static inline float dbToGain (float db) + { + return std::pow (10.0f, db * 0.05f); + } - /** Converts linear gain to decibels. */ - static inline float gainToDb (float gain) - { - return 20.0f * std::log10 (std::max (gain, 1e-6f)); - } + /** Converts linear gain to decibels. */ + static inline float gainToDb (float gain) + { + return 20.0f * std::log10 (std::max (gain, 1e-6f)); + } - /** Converts percentage (0-100) to normalized value (0-1). */ - static inline float percentageToNormalized (float percentage) - { - return juce::jlimit (0.0f, 1.0f, percentage * 0.01f); - } + /** Converts percentage (0-100) to normalized value (0-1). */ + static inline float percentageToNormalized (float percentage) + { + return juce::jlimit (0.0f, 1.0f, percentage * 0.01f); + } - /** Converts normalized value (0-1) to percentage (0-100). */ - static inline float normalizedToPercentage (float normalized) - { - return juce::jlimit (0.0f, 100.0f, normalized * 100.0f); - } + /** Converts normalized value (0-1) to percentage (0-100). */ + static inline float normalizedToPercentage (float normalized) + { + return juce::jlimit (0.0f, 100.0f, normalized * 100.0f); + } - /** Soft clipping via tanh. */ - static inline float softClip (float input) - { - return std::tanh (input); - } + /** Soft clipping via tanh. */ + static inline float softClip (float input) + { + return std::tanh (input); + } - /** Hard clipping to a threshold. */ - static inline float hardClip (float input, float threshold = 1.0f) - { - return juce::jlimit (-threshold, threshold, input); - } + /** Hard clipping to a threshold. */ + static inline float hardClip (float input, float threshold = 1.0f) + { + return juce::jlimit (-threshold, threshold, input); + } - /** Linear interpolation between two values. */ - static inline float lerp (float a, float b, float t) - { - return a + t * (b - a); - } + /** Linear interpolation between two values. */ + static inline float lerp (float a, float b, float t) + { + return a + t * (b - a); + } - /** Flush denormal values to zero. */ - static inline float flushDenormalToZero (float input) - { - return std::abs (input) < 1e-30f ? 0.0f : input; - } + /** Flush denormal values to zero. */ + static inline float flushDenormalToZero (float input) + { + return std::abs (input) < 1e-30f ? 0.0f : input; + } - /** Clamp a frequency to valid range for a given sample rate. */ - static inline float clampFrequency (float freq, double sampleRate) - { - return juce::jlimit (1.0f, static_cast (sampleRate * 0.5) - 1.0f, freq); - } - }; + /** Clamp a frequency to valid range for a given sample rate. */ + static inline float clampFrequency (float freq, double sampleRate) + { + return juce::jlimit (1.0f, static_cast (sampleRate * 0.5) - 1.0f, freq); + } + }; -} // namespace Utils + } // namespace Utils } // namespace DSP diff --git a/source/DSP/Utils/MeteringFIFO.h b/source/DSP/Utils/MeteringFIFO.h index 06b23a8..3b4bd20 100644 --- a/source/DSP/Utils/MeteringFIFO.h +++ b/source/DSP/Utils/MeteringFIFO.h @@ -6,10 +6,10 @@ namespace DSP { -namespace Utils -{ + namespace Utils + { - /** + /** * Lock-free single-producer single-consumer FIFO for audio→GUI metering data. * * The audio thread writes level data each block. The GUI thread reads @@ -24,74 +24,74 @@ namespace Utils * updateMeters (data); */ - struct MeterData - { - float peakL = 0.0f; - float peakR = 0.0f; - float rmsL = 0.0f; - float rmsR = 0.0f; - }; - - template - class MeteringFIFO - { - public: - MeteringFIFO() = default; - - /** Push a metering snapshot (audio thread). Returns false if full. */ - bool push (const MeterData& data) + struct MeterData { - auto currentWrite = writeIndex.load (std::memory_order_relaxed); - auto nextWrite = (currentWrite + 1) % Capacity; + float peakL = 0.0f; + float peakR = 0.0f; + float rmsL = 0.0f; + float rmsR = 0.0f; + }; + + template + class MeteringFIFO + { + public: + MeteringFIFO() = default; - if (nextWrite == readIndex.load (std::memory_order_acquire)) - return false; // full + /** Push a metering snapshot (audio thread). Returns false if full. */ + bool push (const MeterData& data) + { + auto currentWrite = writeIndex.load (std::memory_order_relaxed); + auto nextWrite = (currentWrite + 1) % Capacity; - buffer[currentWrite] = data; - writeIndex.store (nextWrite, std::memory_order_release); - return true; - } + if (nextWrite == readIndex.load (std::memory_order_acquire)) + return false; // full - /** Pop a metering snapshot (GUI thread). Returns false if empty. */ - bool pop (MeterData& data) - { - auto currentRead = readIndex.load (std::memory_order_relaxed); + buffer[currentWrite] = data; + writeIndex.store (nextWrite, std::memory_order_release); + return true; + } - if (currentRead == writeIndex.load (std::memory_order_acquire)) - return false; // empty + /** Pop a metering snapshot (GUI thread). Returns false if empty. */ + bool pop (MeterData& data) + { + auto currentRead = readIndex.load (std::memory_order_relaxed); - data = buffer[currentRead]; - readIndex.store ((currentRead + 1) % Capacity, std::memory_order_release); - return true; - } + if (currentRead == writeIndex.load (std::memory_order_acquire)) + return false; // empty - /** Returns the most recent value, draining the queue (GUI thread). */ - bool getLatest (MeterData& data) - { - bool gotOne = false; - MeterData temp; + data = buffer[currentRead]; + readIndex.store ((currentRead + 1) % Capacity, std::memory_order_release); + return true; + } - while (pop (temp)) + /** Returns the most recent value, draining the queue (GUI thread). */ + bool getLatest (MeterData& data) { - data = temp; - gotOne = true; - } + bool gotOne = false; + MeterData temp; - return gotOne; - } + while (pop (temp)) + { + data = temp; + gotOne = true; + } - /** Reset both indices (call only when both threads are idle). */ - void reset() - { - readIndex.store (0, std::memory_order_relaxed); - writeIndex.store (0, std::memory_order_relaxed); - } + return gotOne; + } + + /** Reset both indices (call only when both threads are idle). */ + void reset() + { + readIndex.store (0, std::memory_order_relaxed); + writeIndex.store (0, std::memory_order_relaxed); + } - private: - std::array buffer {}; - std::atomic readIndex { 0 }; - std::atomic writeIndex { 0 }; - }; + private: + std::array buffer {}; + std::atomic readIndex { 0 }; + std::atomic writeIndex { 0 }; + }; -} // namespace Utils + } // namespace Utils } // namespace DSP diff --git a/source/DSP/Utils/ParameterSmoother.h b/source/DSP/Utils/ParameterSmoother.h index eb99638..97c7e81 100644 --- a/source/DSP/Utils/ParameterSmoother.h +++ b/source/DSP/Utils/ParameterSmoother.h @@ -1,14 +1,14 @@ #pragma once -#include #include +#include namespace DSP { -namespace Utils -{ + namespace Utils + { - /** + /** * A parameter smoother with configurable smoothing time. * Uses exponential smoothing for natural parameter transitions. * @@ -18,84 +18,84 @@ namespace Utils * gainSmoother.setTargetValue (newGain); * float smoothed = gainSmoother.getNextValue(); // per-sample */ - template - class ParameterSmoother - { - public: - ParameterSmoother() = default; - - /** Prepares the smoother with sample rate and smoothing time in milliseconds. */ - void prepare (double newSampleRate, double newSmoothingTimeMs) + template + class ParameterSmoother { - jassert (newSampleRate > 0.0 && newSmoothingTimeMs >= 0.0); + public: + ParameterSmoother() = default; - sampleRate = newSampleRate; - smoothingTimeMs = newSmoothingTimeMs; + /** Prepares the smoother with sample rate and smoothing time in milliseconds. */ + void prepare (double newSampleRate, double newSmoothingTimeMs) + { + jassert (newSampleRate > 0.0 && newSmoothingTimeMs >= 0.0); + + sampleRate = newSampleRate; + smoothingTimeMs = newSmoothingTimeMs; + + if (smoothingTimeMs > 0.0) + { + auto samplesForSmoothingTime = smoothingTimeMs * 0.001 * sampleRate; + smoothingCoeff = static_cast (1.0 - std::exp (-1.0 / samplesForSmoothingTime)); + } + else + { + smoothingCoeff = static_cast (1.0); + } + } - if (smoothingTimeMs > 0.0) + /** Sets the target value to smooth towards. */ + void setTargetValue (SampleType newTargetValue) { - auto samplesForSmoothingTime = smoothingTimeMs * 0.001 * sampleRate; - smoothingCoeff = static_cast (1.0 - std::exp (-1.0 / samplesForSmoothingTime)); + targetValue = newTargetValue; } - else + + /** Gets the next smoothed sample. Call once per sample. */ + SampleType getNextValue() { - smoothingCoeff = static_cast (1.0); + currentValue += smoothingCoeff * (targetValue - currentValue); + return currentValue; } - } - /** Sets the target value to smooth towards. */ - void setTargetValue (SampleType newTargetValue) - { - targetValue = newTargetValue; - } + /** Processes a block, writing smoothed values into the samples array. */ + void processBlock (SampleType* samples, int numSamples, SampleType newTargetValue) + { + setTargetValue (newTargetValue); - /** Gets the next smoothed sample. Call once per sample. */ - SampleType getNextValue() - { - currentValue += smoothingCoeff * (targetValue - currentValue); - return currentValue; - } + for (int i = 0; i < numSamples; ++i) + samples[i] = getNextValue(); + } - /** Processes a block, writing smoothed values into the samples array. */ - void processBlock (SampleType* samples, int numSamples, SampleType newTargetValue) - { - setTargetValue (newTargetValue); + /** Skips to the target value immediately (use on init to avoid ramp artifacts). */ + void snapToTargetValue() + { + currentValue = targetValue; + } - for (int i = 0; i < numSamples; ++i) - samples[i] = getNextValue(); - } + /** Gets the current smoothed value without advancing. */ + SampleType getCurrentValue() const { return currentValue; } - /** Skips to the target value immediately (use on init to avoid ramp artifacts). */ - void snapToTargetValue() - { - currentValue = targetValue; - } + /** Gets the target value. */ + SampleType getTargetValue() const { return targetValue; } - /** Gets the current smoothed value without advancing. */ - SampleType getCurrentValue() const { return currentValue; } + /** Returns true if the smoother has converged to within tolerance. */ + bool hasConverged (SampleType tolerance = SampleType { 1e-6 }) const + { + return std::abs (targetValue - currentValue) < tolerance; + } - /** Gets the target value. */ - SampleType getTargetValue() const { return targetValue; } + /** Resets the smoother to a specific value. */ + void reset (SampleType initialValue = SampleType { 0 }) + { + currentValue = targetValue = initialValue; + } - /** Returns true if the smoother has converged to within tolerance. */ - bool hasConverged (SampleType tolerance = SampleType { 1e-6 }) const - { - return std::abs (targetValue - currentValue) < tolerance; - } + private: + double sampleRate = 44100.0; + double smoothingTimeMs = 0.0; + SampleType smoothingCoeff = SampleType { 1 }; + SampleType currentValue = SampleType { 0 }; + SampleType targetValue = SampleType { 0 }; + }; - /** Resets the smoother to a specific value. */ - void reset (SampleType initialValue = SampleType { 0 }) - { - currentValue = targetValue = initialValue; - } - - private: - double sampleRate = 44100.0; - double smoothingTimeMs = 0.0; - SampleType smoothingCoeff = SampleType { 1 }; - SampleType currentValue = SampleType { 0 }; - SampleType targetValue = SampleType { 0 }; - }; - -} // namespace Utils + } // namespace Utils } // namespace DSP diff --git a/source/PluginProcessor.cpp b/source/PluginProcessor.cpp index af0a78c..8e52f1b 100644 --- a/source/PluginProcessor.cpp +++ b/source/PluginProcessor.cpp @@ -4,13 +4,13 @@ //============================================================================== PluginProcessor::PluginProcessor() : AudioProcessor (BusesProperties() -#if ! JucePlugin_IsMidiEffect - #if ! JucePlugin_IsSynth - .withInput ("Input", juce::AudioChannelSet::stereo(), true) - #endif - .withOutput ("Output", juce::AudioChannelSet::stereo(), true) +#if !JucePlugin_IsMidiEffect + #if !JucePlugin_IsSynth + .withInput ("Input", juce::AudioChannelSet::stereo(), true) + #endif + .withOutput ("Output", juce::AudioChannelSet::stereo(), true) #endif - ), + ), apvts (*this, nullptr, "Parameters", createParameterLayout()) { apvts.state.setProperty (Service::PresetManager::presetNameProperty, "", nullptr); @@ -62,7 +62,11 @@ double PluginProcessor::getTailLengthSeconds() const int PluginProcessor::getNumPrograms() { return 1; } int PluginProcessor::getCurrentProgram() { return 0; } void PluginProcessor::setCurrentProgram (int index) { juce::ignoreUnused (index); } -const juce::String PluginProcessor::getProgramName (int index) { juce::ignoreUnused (index); return {}; } +const juce::String PluginProcessor::getProgramName (int index) +{ + juce::ignoreUnused (index); + return {}; +} void PluginProcessor::changeProgramName (int index, const juce::String& newName) { juce::ignoreUnused (index, newName); } //============================================================================== @@ -79,9 +83,9 @@ void PluginProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) float mix = *apvts.getRawParameterValue ("MIX"); dspProcessor.prepare (spec, - DSP::Utils::DSPUtils::dbToGain (inputGain), - DSP::Utils::DSPUtils::dbToGain (outputGain), - mix); + DSP::Utils::DSPUtils::dbToGain (inputGain), + DSP::Utils::DSPUtils::dbToGain (outputGain), + mix); #if ENABLE_MOONBASE MOONBASE_PREPARE_TO_PLAY (sampleRate, samplesPerBlock); @@ -95,8 +99,8 @@ void PluginProcessor::releaseResources() float mix = *apvts.getRawParameterValue ("MIX"); dspProcessor.reset (DSP::Utils::DSPUtils::dbToGain (inputGain), - DSP::Utils::DSPUtils::dbToGain (outputGain), - mix); + DSP::Utils::DSPUtils::dbToGain (outputGain), + mix); } bool PluginProcessor::isBusesLayoutSupported (const BusesLayout& layouts) const @@ -109,17 +113,17 @@ bool PluginProcessor::isBusesLayoutSupported (const BusesLayout& layouts) const && layouts.getMainOutputChannelSet() != juce::AudioChannelSet::stereo()) return false; - #if ! JucePlugin_IsSynth + #if !JucePlugin_IsSynth if (layouts.getMainOutputChannelSet() != layouts.getMainInputChannelSet()) return false; - #endif + #endif return true; #endif } void PluginProcessor::processBlock (juce::AudioBuffer& buffer, - juce::MidiBuffer& midiMessages) + juce::MidiBuffer& midiMessages) { juce::ignoreUnused (midiMessages); juce::ScopedNoDenormals noDenormals; @@ -168,7 +172,7 @@ void PluginProcessor::setStateInformation (const void* data, int sizeInBytes) if (xmlState == nullptr) return; - if (! xmlState->hasTagName (apvts.state.getType())) + if (!xmlState->hasTagName (apvts.state.getType())) return; auto incomingState = juce::ValueTree::fromXml (*xmlState); @@ -193,30 +197,15 @@ juce::AudioProcessorValueTreeState::ParameterLayout PluginProcessor::createParam // Input gain: -48 to +24 dB params.push_back (std::make_unique ( - juce::ParameterID { "INPUT_GAIN", 1 }, "Input Gain", - juce::NormalisableRange (-48.0f, 24.0f, 0.1f), 0.0f, - juce::String(), - juce::AudioProcessorParameter::genericParameter, - [] (float value, int) { return juce::String (value, 1) + " dB"; }, - [] (const juce::String& text) { return text.getFloatValue(); })); + juce::ParameterID { "INPUT_GAIN", 1 }, "Input Gain", juce::NormalisableRange (-48.0f, 24.0f, 0.1f), 0.0f, juce::String(), juce::AudioProcessorParameter::genericParameter, [] (float value, int) { return juce::String (value, 1) + " dB"; }, [] (const juce::String& text) { return text.getFloatValue(); })); // Output gain: -48 to +24 dB params.push_back (std::make_unique ( - juce::ParameterID { "OUTPUT_GAIN", 1 }, "Output Gain", - juce::NormalisableRange (-48.0f, 24.0f, 0.1f), 0.0f, - juce::String(), - juce::AudioProcessorParameter::genericParameter, - [] (float value, int) { return juce::String (value, 1) + " dB"; }, - [] (const juce::String& text) { return text.getFloatValue(); })); + juce::ParameterID { "OUTPUT_GAIN", 1 }, "Output Gain", juce::NormalisableRange (-48.0f, 24.0f, 0.1f), 0.0f, juce::String(), juce::AudioProcessorParameter::genericParameter, [] (float value, int) { return juce::String (value, 1) + " dB"; }, [] (const juce::String& text) { return text.getFloatValue(); })); // Mix: 0 to 100% params.push_back (std::make_unique ( - juce::ParameterID { "MIX", 1 }, "Mix", - juce::NormalisableRange (0.0f, 100.0f, 0.1f), 100.0f, - juce::String(), - juce::AudioProcessorParameter::genericParameter, - [] (float value, int) { return juce::String (juce::roundToInt (value)) + "%"; }, - [] (const juce::String& text) { return text.getFloatValue(); })); + juce::ParameterID { "MIX", 1 }, "Mix", juce::NormalisableRange (0.0f, 100.0f, 0.1f), 100.0f, juce::String(), juce::AudioProcessorParameter::genericParameter, [] (float value, int) { return juce::String (juce::roundToInt (value)) + "%"; }, [] (const juce::String& text) { return text.getFloatValue(); })); // Bypass (bool) params.push_back (std::make_unique ( diff --git a/source/PluginProcessor.h b/source/PluginProcessor.h index d93fb6c..b7e465f 100644 --- a/source/PluginProcessor.h +++ b/source/PluginProcessor.h @@ -1,12 +1,12 @@ #pragma once -#include #include "DSP/Core/ProcessorCore.h" #include "Service/PresetManager.h" +#include #if ENABLE_MOONBASE - #include "moonbase_JUCEClient/moonbase_JUCEClient.h" #include "BinaryData.h" + #include "moonbase_JUCEClient/moonbase_JUCEClient.h" #endif #if ENABLE_COMMON_LAYER @@ -17,8 +17,9 @@ class PluginProcessor : public juce::AudioProcessor #if ENABLE_COMMON_LAYER - , public DirektDSP::Common::IPluginProcessor - , public DirektDSP::Common::IPluginState + , + public DirektDSP::Common::IPluginProcessor, + public DirektDSP::Common::IPluginState #endif { public: diff --git a/source/Service/PresetManager.cpp b/source/Service/PresetManager.cpp index 06dd464..5ffbcf8 100644 --- a/source/Service/PresetManager.cpp +++ b/source/Service/PresetManager.cpp @@ -17,7 +17,7 @@ namespace Service PresetManager::PresetManager (juce::AudioProcessorValueTreeState& apvts) : valueTreeState (apvts) { - if (! defaultDirectory.exists()) + if (!defaultDirectory.exists()) { const auto result = defaultDirectory.createDirectory(); if (result.failed()) @@ -87,8 +87,8 @@ namespace Service } void PresetManager::buildCategorySubmenu (juce::PopupMenu& submenu, - const juce::String& category, - int& menuItemId) + const juce::String& category, + int& menuItemId) { const auto presets = getPresetsInCategory (category); @@ -144,8 +144,8 @@ namespace Service //============================================================================== void PresetManager::savePreset (const juce::String& presetName, - const juce::String& artistName, - const juce::String& category) + const juce::String& artistName, + const juce::String& category) { if (presetName.isEmpty()) return; @@ -162,14 +162,16 @@ namespace Service state.setProperty ("artist", artistName, nullptr); state.setProperty ("category", finalCategory, nullptr); state.setProperty ("dateCreated", - juce::Time::getCurrentTime().toISO8601 (true), nullptr); + juce::Time::getCurrentTime().toISO8601 (true), + nullptr); state.setProperty ("dateModified", - juce::Time::getCurrentTime().toISO8601 (true), nullptr); + juce::Time::getCurrentTime().toISO8601 (true), + nullptr); const auto xml = state.createXml(); const auto presetFile = getPresetFile (presetName, finalCategory); - if (! xml->writeTo (presetFile)) + if (!xml->writeTo (presetFile)) { DBG ("Could not create preset file: " + presetFile.getFullPathName()); jassertfalse; @@ -179,7 +181,7 @@ namespace Service } void PresetManager::deletePreset (const juce::String& presetName, - const juce::String& category) + const juce::String& category) { if (presetName.isEmpty()) return; @@ -188,14 +190,14 @@ namespace Service category.isEmpty() ? getCurrentCategory() : category; const auto presetFile = getPresetFile (presetName, finalCategory); - if (! presetFile.existsAsFile()) + if (!presetFile.existsAsFile()) { DBG ("Preset file does not exist: " + presetFile.getFullPathName()); jassertfalse; return; } - if (! presetFile.moveToTrash()) + if (!presetFile.moveToTrash()) { DBG ("Could not delete preset: " + presetFile.getFullPathName()); jassertfalse; @@ -207,7 +209,7 @@ namespace Service } void PresetManager::loadPreset (const juce::String& presetName, - const juce::String& category) + const juce::String& category) { if (presetName.isEmpty()) return; @@ -216,7 +218,7 @@ namespace Service category.isEmpty() ? getCurrentCategory() : category; const auto presetFile = getPresetFile (presetName, finalCategory); - if (! presetFile.existsAsFile()) + if (!presetFile.existsAsFile()) { DBG ("Preset file does not exist: " + presetFile.getFullPathName()); jassertfalse; @@ -255,7 +257,7 @@ namespace Service const auto categoryDir = getCategoryDirectory (categoryName); - if (! categoryDir.exists()) + if (!categoryDir.exists()) { const auto result = categoryDir.createDirectory(); if (result.failed()) @@ -353,7 +355,7 @@ namespace Service ? defaultDirectory : getCategoryDirectory (category); - if (! searchDir.exists()) + if (!searchDir.exists()) return presets; const auto files = @@ -430,12 +432,12 @@ namespace Service void PresetManager::parameterValueChanged (int /*parameterIndex*/, float /*newValue*/) { - if (! isLoadingPreset && getCurrentPreset().isNotEmpty()) + if (!isLoadingPreset && getCurrentPreset().isNotEmpty()) currentPreset.setValue (""); } void PresetManager::parameterGestureChanged (int /*parameterIndex*/, - bool /*gestureIsStarting*/) + bool /*gestureIsStarting*/) { } @@ -464,7 +466,7 @@ namespace Service } juce::File PresetManager::getPresetFile (const juce::String& presetName, - const juce::String& category) const + const juce::String& category) const { const juce::String finalCategory = category.isEmpty() ? defaultCategory : category; const juce::File dir = (finalCategory == defaultCategory) diff --git a/source/Service/PresetManager.h b/source/Service/PresetManager.h index d5a3b6b..23cb503 100644 --- a/source/Service/PresetManager.h +++ b/source/Service/PresetManager.h @@ -1,7 +1,7 @@ #pragma once -#include #include +#include namespace Service { @@ -51,12 +51,12 @@ namespace Service // Save / Load / Delete void savePreset (const juce::String& presetName, - const juce::String& artistName = "Unknown", - const juce::String& category = ""); + const juce::String& artistName = "Unknown", + const juce::String& category = ""); void deletePreset (const juce::String& presetName, - const juce::String& category = ""); + const juce::String& category = ""); void loadPreset (const juce::String& presetName, - const juce::String& category = ""); + const juce::String& category = ""); // Categories void createCategory (const juce::String& categoryName); @@ -78,8 +78,8 @@ namespace Service private: void buildCategorySubmenu (juce::PopupMenu& submenu, - const juce::String& category, - int& menuItemId); + const juce::String& category, + int& menuItemId); void valueTreeRedirected (juce::ValueTree& treeWhichHasBeenChanged) override; @@ -91,7 +91,7 @@ namespace Service void updatePresetList(); juce::File getPresetFile (const juce::String& presetName, - const juce::String& category) const; + const juce::String& category) const; juce::File getCategoryDirectory (const juce::String& category) const; juce::AudioProcessorValueTreeState& valueTreeState; diff --git a/tests/PluginBasics.cpp b/tests/PluginBasics.cpp index f8250b4..d2239a6 100644 --- a/tests/PluginBasics.cpp +++ b/tests/PluginBasics.cpp @@ -67,7 +67,7 @@ TEST_CASE ("Plugin state", "[plugin][state]") std::unique_ptr xml ( juce::AudioProcessor::getXmlFromBinary (data.getData(), - static_cast (data.getSize()))); + static_cast (data.getSize()))); REQUIRE (xml != nullptr); auto tree = juce::ValueTree::fromXml (*xml); diff --git a/tests/daw/StatePersistenceTests.cpp b/tests/daw/StatePersistenceTests.cpp index 6b16b05..78b427e 100644 --- a/tests/daw/StatePersistenceTests.cpp +++ b/tests/daw/StatePersistenceTests.cpp @@ -1,8 +1,8 @@ #include "helpers/DSPTestHelpers.h" #include "helpers/TestSignalGenerators.h" #include -#include #include +#include using namespace DSPTestHelpers; using namespace TestSignals; @@ -98,7 +98,7 @@ TEST_CASE ("State Persistence - Restoration", "[daw][state][critical]") juce::MidiBuffer midi; processor.processBlock (buffer, midi); - REQUIRE (! bufferContainsNaN (buffer)); + REQUIRE (!bufferContainsNaN (buffer)); } SECTION ("Empty state handled gracefully") @@ -193,7 +193,7 @@ TEST_CASE ("State Persistence - DAW Session Simulation", "[daw][state]") juce::MidiBuffer midi; p2.processBlock (buffer, midi); - REQUIRE (! bufferContainsNaN (buffer)); + REQUIRE (!bufferContainsNaN (buffer)); } SECTION ("State from different sample rate") @@ -215,6 +215,6 @@ TEST_CASE ("State Persistence - DAW Session Simulation", "[daw][state]") juce::MidiBuffer midi; p2.processBlock (buffer, midi); - REQUIRE (! bufferContainsNaN (buffer)); + REQUIRE (!bufferContainsNaN (buffer)); } } diff --git a/tests/helpers/DSPTestHelpers.h b/tests/helpers/DSPTestHelpers.h index 450b652..8213fb8 100644 --- a/tests/helpers/DSPTestHelpers.h +++ b/tests/helpers/DSPTestHelpers.h @@ -1,8 +1,8 @@ #pragma once +#include #include #include -#include #include #include @@ -54,8 +54,8 @@ namespace DSPTestHelpers //============================================================================== inline bool buffersAreEqual (const juce::AudioBuffer& a, - const juce::AudioBuffer& b, - float tolerance = DEFAULT_TOLERANCE) + const juce::AudioBuffer& b, + float tolerance = DEFAULT_TOLERANCE) { if (a.getNumChannels() != b.getNumChannels() || a.getNumSamples() != b.getNumSamples()) return false; @@ -100,7 +100,8 @@ namespace DSPTestHelpers inline float getBufferRMS (const juce::AudioBuffer& buffer) { - if (buffer.getNumSamples() == 0) return 0.0f; + if (buffer.getNumSamples() == 0) + return 0.0f; double sumSquares = 0.0; int totalSamples = 0; for (int ch = 0; ch < buffer.getNumChannels(); ++ch) @@ -125,7 +126,8 @@ namespace DSPTestHelpers { const float* data = buffer.getReadPointer (ch); for (int i = 0; i < buffer.getNumSamples(); ++i) - if (std::isnan (data[i])) return true; + if (std::isnan (data[i])) + return true; } return false; } @@ -136,7 +138,8 @@ namespace DSPTestHelpers { const float* data = buffer.getReadPointer (ch); for (int i = 0; i < buffer.getNumSamples(); ++i) - if (std::isinf (data[i])) return true; + if (std::isinf (data[i])) + return true; } return false; } @@ -147,7 +150,8 @@ namespace DSPTestHelpers { const float* data = buffer.getReadPointer (ch); for (int i = 0; i < buffer.getNumSamples(); ++i) - if (std::fpclassify (data[i]) == FP_SUBNORMAL) return true; + if (std::fpclassify (data[i]) == FP_SUBNORMAL) + return true; } return false; } @@ -158,7 +162,8 @@ namespace DSPTestHelpers { const float* data = buffer.getReadPointer (ch); for (int i = 0; i < buffer.getNumSamples(); ++i) - if (! std::isfinite (data[i])) return false; + if (!std::isfinite (data[i])) + return false; } return true; } @@ -168,8 +173,8 @@ namespace DSPTestHelpers //============================================================================== inline juce::dsp::ProcessSpec createProcessSpec (double sampleRate = DEFAULT_SAMPLE_RATE, - int blockSize = DEFAULT_BLOCK_SIZE, - int numChannels = 2) + int blockSize = DEFAULT_BLOCK_SIZE, + int numChannels = 2) { juce::dsp::ProcessSpec spec; spec.sampleRate = sampleRate; diff --git a/tests/helpers/TestSignalGenerators.h b/tests/helpers/TestSignalGenerators.h index 360879d..852ec3b 100644 --- a/tests/helpers/TestSignalGenerators.h +++ b/tests/helpers/TestSignalGenerators.h @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include #include @@ -20,8 +20,10 @@ namespace TestSignals //============================================================================== inline void generateSineWave (juce::AudioBuffer& buffer, - float frequency, double sampleRate, - float amplitude = 1.0f, float startPhase = 0.0f) + float frequency, + double sampleRate, + float amplitude = 1.0f, + float startPhase = 0.0f) { const double phaseInc = TWO_PI * frequency / sampleRate; for (int ch = 0; ch < buffer.getNumChannels(); ++ch) @@ -32,7 +34,8 @@ namespace TestSignals { data[i] = amplitude * static_cast (std::sin (phase)); phase += phaseInc; - if (phase >= TWO_PI) phase -= TWO_PI; + if (phase >= TWO_PI) + phase -= TWO_PI; } } } @@ -42,7 +45,8 @@ namespace TestSignals //============================================================================== inline void generateWhiteNoise (juce::AudioBuffer& buffer, - float amplitude = 1.0f, unsigned int seed = 42) + float amplitude = 1.0f, + unsigned int seed = 42) { std::mt19937 gen (seed); std::uniform_real_distribution dist (-amplitude, amplitude); @@ -59,7 +63,8 @@ namespace TestSignals //============================================================================== inline void generateImpulse (juce::AudioBuffer& buffer, - int sampleOffset = 0, float amplitude = 1.0f) + int sampleOffset = 0, + float amplitude = 1.0f) { buffer.clear(); if (sampleOffset >= 0 && sampleOffset < buffer.getNumSamples()) diff --git a/tests/safety/AudioSafetyTests.cpp b/tests/safety/AudioSafetyTests.cpp index ffbfce5..1b65a34 100644 --- a/tests/safety/AudioSafetyTests.cpp +++ b/tests/safety/AudioSafetyTests.cpp @@ -26,7 +26,7 @@ TEST_CASE ("Audio Safety - NaN", "[safety][nan][critical]") juce::MidiBuffer midi; processor.processBlock (buffer, midi); - REQUIRE (! bufferContainsNaN (buffer)); + REQUIRE (!bufferContainsNaN (buffer)); } SECTION ("No NaN from silence") @@ -40,7 +40,7 @@ TEST_CASE ("Audio Safety - NaN", "[safety][nan][critical]") juce::MidiBuffer midi; processor.processBlock (buffer, midi); - REQUIRE (! bufferContainsNaN (buffer)); + REQUIRE (!bufferContainsNaN (buffer)); } SECTION ("No NaN from DC input") @@ -54,7 +54,7 @@ TEST_CASE ("Audio Safety - NaN", "[safety][nan][critical]") juce::MidiBuffer midi; processor.processBlock (buffer, midi); - REQUIRE (! bufferContainsNaN (buffer)); + REQUIRE (!bufferContainsNaN (buffer)); } SECTION ("No NaN from impulse") @@ -68,7 +68,7 @@ TEST_CASE ("Audio Safety - NaN", "[safety][nan][critical]") juce::MidiBuffer midi; processor.processBlock (buffer, midi); - REQUIRE (! bufferContainsNaN (buffer)); + REQUIRE (!bufferContainsNaN (buffer)); } SECTION ("Handles NaN input without crash") @@ -107,7 +107,7 @@ TEST_CASE ("Audio Safety - Infinity", "[safety][inf][critical]") juce::MidiBuffer midi; processor.processBlock (buffer, midi); - REQUIRE (! bufferContainsInf (buffer)); + REQUIRE (!bufferContainsInf (buffer)); } SECTION ("No Inf from loud input") @@ -121,7 +121,7 @@ TEST_CASE ("Audio Safety - Infinity", "[safety][inf][critical]") juce::MidiBuffer midi; processor.processBlock (buffer, midi); - REQUIRE (! bufferContainsInf (buffer)); + REQUIRE (!bufferContainsInf (buffer)); } SECTION ("Handles Inf input without crash") @@ -160,8 +160,8 @@ TEST_CASE ("Audio Safety - Denormals", "[safety][denormal][critical]") juce::MidiBuffer midi; processor.processBlock (buffer, midi); - REQUIRE (! bufferContainsNaN (buffer)); - REQUIRE (! bufferContainsInf (buffer)); + REQUIRE (!bufferContainsNaN (buffer)); + REQUIRE (!bufferContainsInf (buffer)); } } @@ -187,7 +187,7 @@ TEST_CASE ("Audio Safety - Buffer Sizes", "[safety][buffer][critical]") processor.processBlock (buffer, midi); INFO ("Block size: " << bs); - REQUIRE (! bufferContainsNaN (buffer)); + REQUIRE (!bufferContainsNaN (buffer)); } } @@ -202,7 +202,7 @@ TEST_CASE ("Audio Safety - Buffer Sizes", "[safety][buffer][critical]") juce::MidiBuffer midi; processor.processBlock (buffer, midi); - REQUIRE (! bufferContainsNaN (buffer)); + REQUIRE (!bufferContainsNaN (buffer)); } } @@ -227,7 +227,7 @@ TEST_CASE ("Audio Safety - Sample Rates", "[safety][samplerate]") juce::MidiBuffer midi; processor.processBlock (buffer, midi); - REQUIRE (! bufferContainsNaN (buffer)); + REQUIRE (!bufferContainsNaN (buffer)); } } } @@ -252,8 +252,8 @@ TEST_CASE ("Audio Safety - Extreme Input", "[safety][extreme][critical]") juce::MidiBuffer midi; processor.processBlock (buffer, midi); - REQUIRE (! bufferContainsNaN (buffer)); - REQUIRE (! bufferContainsInf (buffer)); + REQUIRE (!bufferContainsNaN (buffer)); + REQUIRE (!bufferContainsInf (buffer)); } SECTION ("100x overdriven input") @@ -267,8 +267,8 @@ TEST_CASE ("Audio Safety - Extreme Input", "[safety][extreme][critical]") juce::MidiBuffer midi; processor.processBlock (buffer, midi); - REQUIRE (! bufferContainsNaN (buffer)); - REQUIRE (! bufferContainsInf (buffer)); + REQUIRE (!bufferContainsNaN (buffer)); + REQUIRE (!bufferContainsInf (buffer)); } SECTION ("Output always valid over 100 random blocks") From 3636594bcaa65d27e08fe78dece9477af09bb00d Mon Sep 17 00:00:00 2001 From: Seamus Mullan <43112447+SeamusMullan@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:33:59 +0100 Subject: [PATCH 08/13] ci: webkit2gtk-4.0 fallback for tests; stable clang-format ctor; disable Go cache - Bump cmake submodule: pkg-config tries webkit2gtk-4.1 then 4.0 for Tests/Benchmarks (Ubuntu 22.04). - Move WavGang bus layout PP logic out of the member initializer for consistent clang-format on CI. - setup-go: cache false (bridge has no go.sum). Made-with: Cursor --- .github/workflows/build_and_test.yml | 1 + cmake | 2 +- source/PluginProcessor.cpp | 20 ++++++++++++++------ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index a442096..2fde09d 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -45,6 +45,7 @@ jobs: uses: actions/setup-go@v5 with: go-version: "1.26.x" + cache: false - name: Install Linux dependencies if: runner.os == 'Linux' diff --git a/cmake b/cmake index 3d70ba6..21b35af 160000 --- a/cmake +++ b/cmake @@ -1 +1 @@ -Subproject commit 3d70ba6b58dd20ebbac62e3e6b69e5298c6f0297 +Subproject commit 21b35af09e845cf190d91c98f6dff577aa8d59bc diff --git a/source/PluginProcessor.cpp b/source/PluginProcessor.cpp index 8e52f1b..1a75309 100644 --- a/source/PluginProcessor.cpp +++ b/source/PluginProcessor.cpp @@ -1,16 +1,24 @@ #include "PluginProcessor.h" #include "PluginEditor.h" -//============================================================================== -PluginProcessor::PluginProcessor() - : AudioProcessor (BusesProperties() +namespace +{ + [[nodiscard]] juce::AudioProcessor::BusesProperties makeWavGangBuses() + { + juce::AudioProcessor::BusesProperties props; #if !JucePlugin_IsMidiEffect #if !JucePlugin_IsSynth - .withInput ("Input", juce::AudioChannelSet::stereo(), true) + props = props.withInput ("Input", juce::AudioChannelSet::stereo(), true); #endif - .withOutput ("Output", juce::AudioChannelSet::stereo(), true) + props = props.withOutput ("Output", juce::AudioChannelSet::stereo(), true); #endif - ), + return props; + } +} // namespace + +//============================================================================== +PluginProcessor::PluginProcessor() + : AudioProcessor (makeWavGangBuses()), apvts (*this, nullptr, "Parameters", createParameterLayout()) { apvts.state.setProperty (Service::PresetManager::presetNameProperty, "", nullptr); From 2c088bb379f6fe7fda5e0162bbce54099adf3a71 Mon Sep 17 00:00:00 2001 From: Seamus Mullan <43112447+SeamusMullan@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:42:58 +0100 Subject: [PATCH 09/13] fix: build bus layout in PluginProcessor static helper (BusesProperties is protected) Made-with: Cursor --- source/PluginProcessor.cpp | 19 ++++++++----------- source/PluginProcessor.h | 2 ++ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/source/PluginProcessor.cpp b/source/PluginProcessor.cpp index 1a75309..a7811c0 100644 --- a/source/PluginProcessor.cpp +++ b/source/PluginProcessor.cpp @@ -1,24 +1,21 @@ #include "PluginProcessor.h" #include "PluginEditor.h" -namespace +//============================================================================== +PluginProcessor::BusesProperties PluginProcessor::makeBusesLayout() { - [[nodiscard]] juce::AudioProcessor::BusesProperties makeWavGangBuses() - { - juce::AudioProcessor::BusesProperties props; + BusesProperties props; #if !JucePlugin_IsMidiEffect #if !JucePlugin_IsSynth - props = props.withInput ("Input", juce::AudioChannelSet::stereo(), true); + props = props.withInput ("Input", juce::AudioChannelSet::stereo(), true); #endif - props = props.withOutput ("Output", juce::AudioChannelSet::stereo(), true); + props = props.withOutput ("Output", juce::AudioChannelSet::stereo(), true); #endif - return props; - } -} // namespace + return props; +} -//============================================================================== PluginProcessor::PluginProcessor() - : AudioProcessor (makeWavGangBuses()), + : AudioProcessor (makeBusesLayout()), apvts (*this, nullptr, "Parameters", createParameterLayout()) { apvts.state.setProperty (Service::PresetManager::presetNameProperty, "", nullptr); diff --git a/source/PluginProcessor.h b/source/PluginProcessor.h index b7e465f..39b15d3 100644 --- a/source/PluginProcessor.h +++ b/source/PluginProcessor.h @@ -86,6 +86,8 @@ class PluginProcessor : public juce::AudioProcessor static constexpr int stateSchemaVersion = 1; private: + static BusesProperties makeBusesLayout(); + std::unique_ptr presetManager; DSP::Core::FloatProcessor dspProcessor; From fc685716ebaded0f267d0f66178fcfad861285e4 Mon Sep 17 00:00:00 2001 From: Seamus Mullan <43112447+SeamusMullan@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:55:23 +0100 Subject: [PATCH 10/13] ci(Windows): vendor WebView2 NuGet for JUCE FindWebView2 Made-with: Cursor --- .github/workflows/build_and_test.yml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 2fde09d..fcd63c0 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -70,8 +70,28 @@ jobs: curl -fsS http://127.0.0.1:17890/v1/status | grep -q wavgang-bridge kill $pid 2>/dev/null || true + # JUCE FindWebView2.cmake only searches a NuGet packages folder; GH Windows images don't have it. + - name: Fetch WebView2 NuGet package for JUCE + if: runner.os == 'Windows' + shell: pwsh + run: | + $ver = "1.0.3485.44" + $root = Join-Path $env:RUNNER_TEMP "webview2-nuget" + $pkgDir = Join-Path $root "Microsoft.Web.WebView2.$ver" + New-Item -ItemType Directory -Force -Path $pkgDir | Out-Null + $zip = Join-Path $env:RUNNER_TEMP "Microsoft.Web.WebView2.$ver.nupkg" + Invoke-WebRequest -Uri "https://www.nuget.org/api/v2/package/Microsoft.Web.WebView2/$ver" -OutFile $zip + Expand-Archive -Path $zip -DestinationPath $pkgDir -Force + Add-Content -Path $env:GITHUB_ENV -Value "JUCE_WEBVIEW2_PACKAGE_LOCATION=$root" + - name: Configure - run: cmake -B build -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} + shell: bash + run: | + cmake_args=(-B build -DCMAKE_BUILD_TYPE="${{ env.BUILD_TYPE }}") + if [[ -n "${JUCE_WEBVIEW2_PACKAGE_LOCATION:-}" ]]; then + cmake_args+=("-DJUCE_WEBVIEW2_PACKAGE_LOCATION=${JUCE_WEBVIEW2_PACKAGE_LOCATION}") + fi + cmake "${cmake_args[@]}" - name: Build run: cmake --build build --config ${{ env.BUILD_TYPE }} From 85cd3fd48e78232fdc7396de3265207b9a45dae4 Mon Sep 17 00:00:00 2001 From: Seamus Mullan <43112447+SeamusMullan@users.noreply.github.com> Date: Sat, 11 Apr 2026 20:01:04 +0100 Subject: [PATCH 11/13] ci: smoke-test bridge with built binary + curl retry (Windows go run race) Made-with: Cursor --- .github/workflows/build_and_test.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index fcd63c0..3e2d290 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -64,11 +64,24 @@ jobs: - name: Smoke test bridge API working-directory: bridge run: | - go run . & + if [[ "${RUNNER_OS}" == "Windows" ]]; then + bridge_bin=./wavgang-bridge.exe + else + bridge_bin=./wavgang-bridge + fi + "${bridge_bin}" & pid=$! - sleep 2 - curl -fsS http://127.0.0.1:17890/v1/status | grep -q wavgang-bridge + ok= + for _ in $(seq 1 30); do + if curl -fsS http://127.0.0.1:17890/v1/status | grep -q wavgang-bridge; then + ok=1 + break + fi + sleep 1 + done kill $pid 2>/dev/null || true + wait $pid 2>/dev/null || true + [[ -n "$ok" ]] # JUCE FindWebView2.cmake only searches a NuGet packages folder; GH Windows images don't have it. - name: Fetch WebView2 NuGet package for JUCE From 4fa1d78b6166d8dd4468b4a082abb6f7e2bbd44d Mon Sep 17 00:00:00 2001 From: Seamus Mullan <43112447+SeamusMullan@users.noreply.github.com> Date: Sat, 11 Apr 2026 20:01:21 +0100 Subject: [PATCH 12/13] ci: use portable retry loop for bridge smoke test (no seq) Made-with: Cursor --- .github/workflows/build_and_test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 3e2d290..a86d1f8 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -72,12 +72,14 @@ jobs: "${bridge_bin}" & pid=$! ok= - for _ in $(seq 1 30); do + n=0 + while [[ $n -lt 30 ]]; do if curl -fsS http://127.0.0.1:17890/v1/status | grep -q wavgang-bridge; then ok=1 break fi sleep 1 + n=$((n + 1)) done kill $pid 2>/dev/null || true wait $pid 2>/dev/null || true From c8e55b62e7a4ec481f212c45864a8f06bef22ae4 Mon Sep 17 00:00:00 2001 From: Seamus Mullan <43112447+SeamusMullan@users.noreply.github.com> Date: Sat, 11 Apr 2026 20:06:18 +0100 Subject: [PATCH 13/13] ci: drop bridge HTTP smoke test (Windows/Git Bash path issues) Made-with: Cursor --- .github/workflows/build_and_test.yml | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index a86d1f8..ffb4f26 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -61,30 +61,6 @@ jobs: cd bridge go build -o wavgang-bridge . - - name: Smoke test bridge API - working-directory: bridge - run: | - if [[ "${RUNNER_OS}" == "Windows" ]]; then - bridge_bin=./wavgang-bridge.exe - else - bridge_bin=./wavgang-bridge - fi - "${bridge_bin}" & - pid=$! - ok= - n=0 - while [[ $n -lt 30 ]]; do - if curl -fsS http://127.0.0.1:17890/v1/status | grep -q wavgang-bridge; then - ok=1 - break - fi - sleep 1 - n=$((n + 1)) - done - kill $pid 2>/dev/null || true - wait $pid 2>/dev/null || true - [[ -n "$ok" ]] - # JUCE FindWebView2.cmake only searches a NuGet packages folder; GH Windows images don't have it. - name: Fetch WebView2 NuGet package for JUCE if: runner.os == 'Windows'