diff --git a/.github/workflows/build_android.yml b/.github/workflows/build_android.yml index 0888b1aca..1c7130303 100644 --- a/.github/workflows/build_android.yml +++ b/.github/workflows/build_android.yml @@ -27,6 +27,7 @@ concurrency: cancel-in-progress: true env: + ACTION_RUNNER: github-actions JAVA_VERSION: 17 JAVA_DISTRIBUTION: 'temurin' NDK_VERSION: r27c diff --git a/.github/workflows/build_ios.yml b/.github/workflows/build_ios.yml index b4f9df735..9ab86b76c 100644 --- a/.github/workflows/build_ios.yml +++ b/.github/workflows/build_ios.yml @@ -25,6 +25,7 @@ concurrency: cancel-in-progress: true env: + ACTION_RUNNER: github-actions IOS_PLATFORM: OS64 jobs: diff --git a/.github/workflows/build_linux.yml b/.github/workflows/build_linux.yml index efe72c2d2..d4f268040 100644 --- a/.github/workflows/build_linux.yml +++ b/.github/workflows/build_linux.yml @@ -28,11 +28,12 @@ concurrency: cancel-in-progress: true env: + ACTION_RUNNER: github-actions INSTALL_DEPS: >- libasound2-dev libjack-jackd2-dev ladspa-sdk libcurl4-openssl-dev libfreetype6-dev libx11-dev libxcomposite-dev libxcursor-dev libxcursor-dev libxext-dev libxi-dev libxinerama-dev libxrandr-dev libxrender-dev libxfixes-dev libxss-dev libxtst-dev libxkbcommon-dev - libglu1-mesa-dev libegl1-mesa-dev mesa-common-dev libgl1-mesa-dri mesa-utils xvfb + libglu1-mesa-dev libegl1-mesa-dev mesa-common-dev libgl1-mesa-dri mesa-utils xvfb glibc-tools LIBGL_ALWAYS_SOFTWARE: "1" GALLIUM_DRIVER: "llvmpipe" @@ -79,7 +80,7 @@ jobs: run: cmake ${{ github.workspace }} -G "Ninja Multi-Config" -B ${{ runner.workspace }}/build -DYUP_ENABLE_TESTS=ON - run: cmake --build ${{ runner.workspace }}/build --config Debug --target yup_tests - working-directory: ${{ runner.workspace }}/build/tests/Debug - run: xvfb-run -a ./yup_tests + run: xvfb-run -a catchsegv ./yup_tests build_tests_release: runs-on: ubuntu-latest @@ -100,7 +101,7 @@ jobs: run: cmake ${{ github.workspace }} -G "Ninja Multi-Config" -B ${{ runner.workspace }}/build -DYUP_ENABLE_TESTS=ON - run: cmake --build ${{ runner.workspace }}/build --config Release --target yup_tests - working-directory: ${{ runner.workspace }}/build/tests/Release - run: xvfb-run -a ./yup_tests + run: xvfb-run -a catchsegv ./yup_tests build_console: runs-on: ubuntu-latest diff --git a/.github/workflows/build_macos.yml b/.github/workflows/build_macos.yml index 639009dbb..61ea932d4 100644 --- a/.github/workflows/build_macos.yml +++ b/.github/workflows/build_macos.yml @@ -26,6 +26,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + ACTION_RUNNER: github-actions + jobs: configure: runs-on: macos-latest diff --git a/.github/workflows/build_wasm.yml b/.github/workflows/build_wasm.yml index 3646a8ad6..11ee269c0 100644 --- a/.github/workflows/build_wasm.yml +++ b/.github/workflows/build_wasm.yml @@ -26,11 +26,13 @@ concurrency: cancel-in-progress: true env: + ACTION_RUNNER: github-actions INSTALL_DEPS: >- libasound2-dev libjack-jackd2-dev ladspa-sdk libcurl4-openssl-dev libfreetype6-dev libx11-dev libxcomposite-dev libxcursor-dev libxcursor-dev libxext-dev libxi-dev libxinerama-dev libxrandr-dev libxrender-dev libglu1-mesa-dev mesa-common-dev EM_VERSION: 4.0.21 + EMCC_SKIP_SANITY_CHECK: 1 jobs: build_tests_debug: diff --git a/.github/workflows/build_windows.yml b/.github/workflows/build_windows.yml index b72150c99..95ade8e9d 100644 --- a/.github/workflows/build_windows.yml +++ b/.github/workflows/build_windows.yml @@ -28,6 +28,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +env: + ACTION_RUNNER: github-actions + jobs: build_tests_debug: runs-on: windows-latest diff --git a/.github/workflows/cache_cleanup.yml b/.github/workflows/cache_cleanup.yml index 15deaa285..4a76b2eec 100644 --- a/.github/workflows/cache_cleanup.yml +++ b/.github/workflows/cache_cleanup.yml @@ -5,6 +5,9 @@ on: types: - closed +env: + ACTION_RUNNER: github-actions + jobs: cleanup: runs-on: ubuntu-latest diff --git a/.github/workflows/clang_format.yml_ b/.github/workflows/clang_format.yml_ index a24cdf498..ff3dcb33c 100644 --- a/.github/workflows/clang_format.yml_ +++ b/.github/workflows/clang_format.yml_ @@ -11,6 +11,9 @@ on: - "**" - "!main" +env: + ACTION_RUNNER: github-actions + jobs: format: runs-on: ubuntu-latest diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 85dd1512e..f50cdb3a4 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -60,11 +60,12 @@ concurrency: cancel-in-progress: true env: + ACTION_RUNNER: github-actions INSTALL_DEPS: >- libasound2-dev libjack-jackd2-dev ladspa-sdk libcurl4-openssl-dev libfreetype6-dev libx11-dev libxcomposite-dev libxcursor-dev libxcursor-dev libxext-dev libxi-dev libxinerama-dev libxrandr-dev libxrender-dev libxfixes-dev libxss-dev libxtst-dev libxkbcommon-dev - libglu1-mesa-dev libegl1-mesa-dev mesa-common-dev libgl1-mesa-dri xvfb lcov + libglu1-mesa-dev libegl1-mesa-dev mesa-common-dev libgl1-mesa-dri xvfb lcov glibc-tools LIBGL_ALWAYS_SOFTWARE: "1" GALLIUM_DRIVER: "llvmpipe" IGNORE_ERRORS: "mismatch,gcov,source,negative,unused,empty,format,corrupt" @@ -96,7 +97,7 @@ jobs: run: cmake --build . --target coverage_clean - name: Run C++ Tests working-directory: ${{ runner.workspace }}/build/tests/Debug - run: xvfb-run -a ./yup_tests + run: xvfb-run -a catchsegv ./yup_tests - name: Generate C++ Coverage Report working-directory: ${{ runner.workspace }}/build run: | diff --git a/.github/workflows/python_linux.yml b/.github/workflows/python_linux.yml index fae4e8f08..b1110880c 100644 --- a/.github/workflows/python_linux.yml +++ b/.github/workflows/python_linux.yml @@ -25,6 +25,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +env: + ACTION_RUNNER: github-actions + jobs: build_python: name: build_cp${{matrix.python}}-${{ matrix.platform_id }} diff --git a/.github/workflows/python_macos.yml b/.github/workflows/python_macos.yml index 292853949..f9ab6514a 100644 --- a/.github/workflows/python_macos.yml +++ b/.github/workflows/python_macos.yml @@ -24,6 +24,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +env: + ACTION_RUNNER: github-actions + jobs: build_python: name: build_cp${{matrix.python}}-${{ matrix.platform_id }} diff --git a/.github/workflows/python_windows.yml b/.github/workflows/python_windows.yml index 1a4147037..b020f6bf1 100644 --- a/.github/workflows/python_windows.yml +++ b/.github/workflows/python_windows.yml @@ -26,6 +26,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +env: + ACTION_RUNNER: github-actions + jobs: build_python: name: build_cp${{matrix.python}}-${{ matrix.platform_id }} diff --git a/.gitignore b/.gitignore index 8e7b07be9..e2e66fb04 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ __pycache__/ .pytest_cache/ .cache/ docs/superpowers/ +.worktrees/ diff --git a/AGENTS.md b/AGENTS.md index cf9808150..851db0580 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,192 +1,54 @@ -# AI Assistant Guidelines for YUP Project +# AI Assistant Guidelines for YUP -This document provides directive guidelines for AI assistants working on the YUP project. Use these rules when generating, reviewing, or suggesting code changes. +Directive rules for generating, reviewing and suggesting code in YUP. Apply them on every task. -## Project Context -- **Project Type:** C++ graphics/audio library -- **License:** ISC License -- **Copyright:** `Copyright (c) 2026 - kunitoki@gmail.com` -- **Based On:** Fork of JUCE7 ISC Modules -- **Build System:** CMake -- **Testing Framework:** Google Test -- **Primary Dependencies:** Rive, OpenGL/Metal/D3D -- **C++ Standard**: >= C++20 +## Project -## Code Generation Rules +C++20 graphics/audio library, ISC licensed, forked from the JUCE7 ISC modules. CMake build, Google Test, Rive + OpenGL/Metal/D3D. +Copyright line for new files: `Copyright (c) 2026 - kunitoki@gmail.com`. -**NEVER EVER run bash commands to configure, compile or test the implementation, acknowledge that we should test and we'll run and report any issue.** -**If you see changes in code you didn't notice before, it's not the linter, it's me changing it for a reason and you should acknowledge the changes, not revert them.** -**Avoid polluting implementation files with obvious comments, make the code more expressive instead** +## Hard Rules -## AI Decision Making Rules +- **Never run bash commands to configure, compile or test.** Say what should be tested; the user runs it and reports back. +- **Code that changed without you noticing is the user's doing, not a linter's.** Acknowledge it, never revert it. +- Headers and implementation files are compiled through the main module header/cpp - linter errors when parsing them in isolation are expected. -### Always: -1. **Rely on the C++20 language and standard library** so use it (unless the feature is not supported in all YUP's platforms) -2. **Check existing patterns** in similar modules first -3. **Use YUP conventions** for similar functionality -4. **Use YUP infrastructure** instead of reinventing the wheel -5. **If the same functionality can be provided with less code and complexity** prefer less code -6. **Always prefer reusing code than creating duplicated code** -7. **Prefer composition over inheritance** -8. **Make classes small and focused** (single responsibility) -9. **Use const-correctness** throughout -10. **Do not leak internal details** -11. **Follow the open-closed principle** -12. **Never assume we use plain JUCE7 functionality, always check APIs** as they might have evolved +## Design Rules -### When implementing new features: -1. **Always provide extensive and useful doxygen documentation** for public APIs -2. **Make sure new code is always tested** +1. Use C++20 and the standard library, unless the feature is unsupported on a YUP platform. +2. Check existing patterns in similar modules first; reuse YUP infrastructure instead of reinventing or duplicating it. +3. Never assume plain JUCE7 APIs - verify them, they may have evolved (see *Differences with JUCE*). +4. Prefer less code. If 200 lines could be 50, rewrite it. Nothing speculative, no abstraction for single-use code. +5. Composition over inheritance. Small single-responsibility classes, open-closed, no leaked internals. +6. Const-correct throughout. Flat code with early exits over deep nesting. +7. RAII and smart pointers, no raw ownership. Consider thread safety where it applies. +8. Adapt or replace an existing implementation - never copy-and-modify and leave both behind. +9. Don't pollute implementation files with obvious comments. +10. Extensive Doxygen docs on public APIs and public build-system methods. +11. Test-first where practical - a bug fix starts with a failing test. New code is always tested; refactors keep tests passing, API contracts intact, platform-specific layout preserved and performance in mind. +12. Update `docs/` with effective and user targeted documentation (no extensive internal details if not needed) and add a **brief** `CHANGELOG.md` entry when the change warrants it. +13. Surgical edits: every changed line traces to the request. Don't reformat or refactor adjacent code; do clean up orphans your own change created. +14. Avoid the use of em-dashes, just use `-`. -### When writing tests: -1. **Test primarily public interfaces only** -2. **Cover normal, edge, and error cases** -3. **Use descriptive test names** (e.g., `ReturnsNullForInvalidInput`) -4. **Group related tests** in test fixtures -5. **Keep tests independent** and deterministic -6. **Never Use C or C++ macros (like M_PI)** use yup alternatives +## Style -### When suggesting refactoring: -1. **Maintain existing API contracts** -2. **Follow established module patterns** -3. **Preserve platform-specific code organization** -4. **Update tests accordingly** -5. **Consider performance implications** -6. **Keep API usage simple and effective** +Formatting is enforced by `.clang-format` (Allman braces, 4-space indent, no column limit, `Type* ptr` alignment, space before non-empty parens: `foo (x)`, `TEST_F (Fixture, name)`). -### 1. File Headers -**ALWAYS** start new files with this exact header: +- `PascalCase` types; `camelCase` for functions, variables, members and constants. +- One main class per file, named `yup_ClassName.h` / `yup_ClassName.cpp`. +- `using namespace` only in test files; elsewhere scope it to the smallest block. +- Include order: own module header → other YUP modules → same-module headers → external libraries (Rive) → standard library. -```cpp -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2026 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ -``` - -### 2. Module Headers -For main module headers (e.g., `yup_graphics.h`), include this declaration block after the file header: - -```cpp -/* - ============================================================================== - - BEGIN_YUP_MODULE_DECLARATION - - ID: module_name - vendor: yup - version: 1.2.3 - name: Module Display Name - description: Brief module description - website: https://github.com/kunitoki/yup - license: ISC - - dependencies: yup_graphics [other_dependencies] - searchpaths: native - - END_YUP_MODULE_DECLARATION - - ============================================================================== -*/ -``` - -Refer to `./docs/YUP Module Format.md` for more info if needed. For yup_* modules, the version number should be the same as all the others. - -### 3. Formatting Rules (Allman Style) - -### 4. Naming Conventions -- **Classes:** `PascalCase` (e.g., `GraphicsContext`) -- **Functions:** `camelCase` (e.g., `createRenderer`) -- **Variables:** `camelCase` (e.g., `currentState`) -- **Constants:** `camelCase` (e.g., `defaultSize`) -- **Member variables:** `camelCase` (e.g., `bufferSize`) -- **Files:** `yup_ClassName.h/cpp` for classes, one file per main class - -### 5. Include Order -```cpp -#pragma once - -// 1. Own module header (if in .cpp file) -#include - -// 2. Other project modules -#include "yup_core/yup_core.h" - -// 3. Same module headers -#include "graphics/yup_Color.h" -#include "primitives/yup_Point.h" - -// 5. External libraries (Rive, etc.) -#include - -// 4. Standard library -#include -#include -``` - -### 6. Namespace Usage -```cpp -// NEVER use "using namespace" except in test files -using namespace yup; - -// Prefer limited scope usage -TEST (MyClassTests, someFunction) -{ - using namespace std::chrono; -} -``` - -## File Organization Patterns - -### Module Structure -``` -modules/yup_module_name/ -├── yup_module_name.h // Main module header -├── yup_module_name.cpp // Main module implementation -├── yup_module_name.mm // Objective-C++ (Apple platforms) -├── subdirectory/ // Logical groupings -│ ├── yup_ClassName.h -│ └── yup_ClassName.cpp -└── native/ // Platform-specific code - ├── yup_ClassName_android.cpp - ├── yup_ClassName_windows.cpp - ├── yup_ClassName_linux.cpp - ├── yup_ClassName_wasm.cpp - ├── yup_ClassName_emscripten.cpp - ├── yup_ClassName_mac.mm - ├── yup_ClassName_ios.mm - └── yup_ClassName_apple.mm -``` -Avoid going deeply nested into modules. Prefer a single subdirectory whenever possible for YUP modules (might be ok for thirdparties as we don't control the upstream structure). +## Files and Layout -**Headers and Implementation files are designed to be included through the main module header/implementation, so linter errors are expected when parsing the files in isolation.** +- New files start with the ISC header - copy it from `modules/yup_dsp_jit/yup_dsp_jit.h`, which already carries the correct `2026` year (many older files still say `2024`, and ported JUCE files carry an extra JUCE attribution block that must not be reused). Headers then open with `#pragma once`. +- Module headers add the `BEGIN_YUP_MODULE_DECLARATION` block right after it (ID, vendor `yup`, version, name, description, website, license `ISC`, dependencies, `searchpaths: native`). Same file is the exemplar; all `yup_*` modules share one version number. Details in `docs/build-system/module-format.md`. +- Module layout: `modules/yup_module_name/` holding `yup_module_name.h` / `.cpp` / `.mm`, one level of subdirectory for logical groups, and `native/` for platform code named `yup_ClassName_.cpp` - `android`, `windows`, `linux`, `wasm`, `emscripten`, plus `mac` / `ios` / `apple` as `.mm`. Avoid deep nesting (third-party trees excepted, we don't control them). +- Modules are unity builds assembled by files in the root which should also resolve global includes, subfolder files are just included there and should not include anything on their own. +- Tests live in `tests/module_name/yup_ClassName.cpp`, one per class, plus `yup_ModuleIntegration.cpp` for integration tests. -### Test Structure -``` -tests/module_name/ -├── ModuleClassName.cpp // Test file per class -└── ModuleIntegration.cpp // Integration tests -``` - -## Class Design Templates +## Class Template -### YUP-Style Class (with leak detector) ```cpp class YupStyleClass { @@ -197,134 +59,35 @@ public: void publicMethod(); private: - void privateMethod(); - int memberVar; YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (YupStyleClass) }; ``` -## Testing Patterns - -### Test File Template -```cpp -#include - -#include - -using namespace yup; - -namespace -{ - -// Test helpers and constants, prefer move them into fixtures so they don't clash in unity builds -constexpr int kTestValue = 42; - -class TestHelper -{ -public: - static void setupTestData() { /* ... */ } -}; - -} // namespace - -class ClassNameTests : public ::testing::Test -{ -protected: - void SetUp() override - { - // Setup before each test - } - - void TearDown() override - { - // Cleanup after each test - } - - // Test fixtures - ClassName instance; -}; - -TEST_F (ClassNameTests, ConstructorInitializesCorrectly) -{ - EXPECT_TRUE (instance.isValid()); - EXPECT_EQ (0, instance.getValue()); -} - -TEST (ClassNameTests, StaticMethodBehavesCorrectly) -{ - auto result = ClassName::staticMethod(); - EXPECT_NE (nullptr, result.get()); -} -``` - -### Platform-specific code: -```cpp -#if YUP_WINDOWS // Windows -#elif YUP_MAC // macOS -#elif YUP_IOS // iOS -#elif YUP_LINUX // Linux -#elif YUP_ANDROID // Android -#elif YUP_WASM // WebAssembly (including emscripten) -#elif YUP_EMSCRIPTEN // WebAssembly (only emscripten) -#elif YUP_DESKTOP // Windows/macOS/Linux -#elif YUP_MOBILE // Android/iOS -#endif -``` +## Testing -### Error handling patterns: -```cpp -// Use YUP Result or ResultValue for operations that can fail -yup::Result performOperation() -{ - if (preconditionFailed) - return yup::Result::fail ("Precondition not met"); +Mirror the structure of an existing test, e.g. `tests/yup_dsp/yup_KMeterState.cpp`. - return yup::Result::ok(); -} +- Include the module header and ``, then `using namespace yup;`. +- Test the public interface only, covering normal, edge and error cases. +- Descriptive names: `TEST_F (ClassNameTests, ReturnsNullForInvalidInput)`. +- Group related tests in a fixture; keep them independent and deterministic. +- Keep helpers and constants inside the fixture rather than at file scope - unity builds make file-scope names clash. +- Never use C/C++ macros such as `M_PI`; use the YUP alternatives. -yup::ResultValue maybeGetInteger() -{ - if (preconditionFailed) - return yup::makeResultValueFail ("Precondition not met"); +## Platform Guards - return 1; // or yup::makeResultValueOk (1) -} +`YUP_WINDOWS`, `YUP_MAC`, `YUP_IOS`, `YUP_LINUX`, `YUP_ANDROID`, `YUP_WASM` (any WebAssembly), `YUP_EMSCRIPTEN` (Emscripten only), `YUP_DESKTOP`, `YUP_MOBILE`. -// Use assertions for programming errors -void publicMethod (int value) -{ - jassert (value >= 0); // Debug builds only - if (value < 0) - return; // Graceful handling in release -} -``` +## Error Handling -## Code Review Checklist for AI - -Before suggesting code, verify: -- [ ] Proper file header with correct copyright -- [ ] Allman-style braces throughout -- [ ] Consistent naming conventions -- [ ] Proper include order and guards -- [ ] Const-correctness whenever applicable -- [ ] Prefer flatter code and early exits over overly indented code -- [ ] Aim at simplifying and removing duplicated code, prefer removing rather than adding -- [ ] When changing implementation, don't copy it and change it, adapt the existing or remove the old one once the new is in place and working -- [ ] Platform-specific code properly guarded -- [ ] Proper TDD and ensure tests cover new functionality -- [ ] No memory leaks (prefer RAII/smart pointers) -- [ ] Thread safety considerations if applicable -- [ ] Consistent documentation for public APIs or public build system methods -- [ ] Documentation in @docs/ is reviewed, added, updated or removed depending on the changes being done -- [ ] Changes are updated in @CHANGELOG.md, they should be brief +- Fallible operations return `yup::Result` (`Result::ok()` / `Result::fail ("...")`) or `yup::ResultValue` (`makeResultValueOk (v)` / `makeResultValueFail ("...")`; a plain `T` converts implicitly). +- `jassert` for programming errors, paired with a graceful early return for release builds. ## Differences with JUCE -- We use American english in YUP, so it's `center` and not `centred`, or `Color` and not `Colour` -- Always check the available API in the Graphics class, don't assume we use JUCE Graphics classes -- Graphics primitives have a template `.to` method not `toFloat` -- Fonts are obtained via ApplicationTheme, don't try to instantiate fonts inline - -This document should be referenced for every code generation, review, and suggestion task in the YUP project. +- American English: `center` not `centred`, `Color` not `Colour`. +- Check the YUP `Graphics` API - do not assume JUCE's. +- Graphics primitives convert with the template `.to()`, not `toFloat()`. +- Fonts come from `ApplicationTheme`, never instantiated inline. diff --git a/CHANGELOG.md b/CHANGELOG.md index c51fd7a0c..35bda74dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,393 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [2.0.0] - Unreleased +- YDSP backend (emscripten): mint kernel handles from a module-wide counter instead of a JS-realm-local one, so a realm that runs a graph can no longer find another realm's kernel under the same key and silently invoke the wrong module. + +- YDSP VS Code extension: audition the active patch through `yup_dsp_compiler run` from a Patch Player sidebar view (transport, workspace patch list, audio/MIDI device selects, sample rate, block size, test note), with a single pinned player per window, a status bar, a dedicated playback output channel and an opt-in follow-active-patch mode. + +- YDSP player: `--hotreload` now works for a standalone `.ydsp` as well as a `.ydsp-project`, driven by `YdspDiagnostics::getSourceIds()`, which reports the source closure of the last compile (root source, project sources and transitively imported files). + +- YDSP diagnostics: check unused library and processor function bodies during editor validation, including return expressions. + +- YDSP tooling: validate standalone processor/function libraries without a graph; add player error details, verbose device/activity reporting, and a test-note option for diagnosing silent playback. + +- YDSP tooling: compile project bundles, validate unsaved project imports in VS Code, and add audio/MIDI device selection and project hot reload to the command-line player. + +- YDSP: add YAML .ydsp-project manifests with patch metadata, explicit-import source inventories, and overridable processor or graph entry points. + +- YDSP: keep the source value of a float literal that adapts to a `float64` context, instead of rounding the constant through `float32` first; regenerate bundles for codegen revision 14. + +- YDSP: preserve qualified function calls when nesting library imports, including calls from processors and other library functions. + +- YDSP: preserve source ranges through diagnostics and imports, print path:line:column with five-line context and caret underlines, and improve malformed-number and parser errors. + +- YDSP: add integer/boolean `match` statements with scoped arms, single selector evaluation, optional `_` fallback, constant-arm elimination, and invalid-pattern diagnostics; regenerate bundles for language version 4/codegen revision 13. + +- YDSP: add opt-in `trace("value={x}")` with bounded allocation-free recording, off-thread formatting/console printing, and native/WebAssembly bundle support; regenerate bundles for ABI 2/codegen revision 12. + +- YDSP examples: restore wrapping noise sequences in DigitalDrums, WaveLab and ControlRateWah after the integer saturation change. + +- YDSP: saturate integer kernel add/subtract/multiply across native, WebAssembly and constant folding while retaining direct arithmetic for proven-safe operations; regenerate bundles for codegen revision 11. + +- YDSP: eliminate array bounds checks for proven nonoverflowing integer products and strided indices; regenerate bundles for codegen revision 10. + +- YDSP: preserve integer storage width during constant folding so arithmetic and subsequent comparisons agree with runtime execution; regenerate bundles for codegen revision 9. + +- YDSP: saturate float-to-integer conversions and map NaN to zero across native/WebAssembly and constant folding, preserving direct instructions for proven-safe casts; regenerate bundles for codegen revision 8. + +- YDSP: respect source and destination widths in constant-folded numeric conversions and leave exceptional float-to-int casts unfolded; regenerate bundles for codegen revision 7. + +- YDSP: make constant-folded integer shifts match native/WebAssembly operand widths and masked counts without adding runtime checks; regenerate bundles for codegen revision 6. + +- YDSP: remove proven ring-counter and wrapped delay-tap bounds checks after auditing initialization and event writes; regenerate bundles for codegen revision 5. + +- YDSP: remove redundant bounds checks proven by scalar integer masks, clamps, min/max, selects and nonoverflowing arithmetic; retain checks for mutable or unknown ranges. + +- YDSP: fuse shared comparisons into native selects when every consumer preserves the operands, avoiding temporary bounds booleans. + +- YDSP: use safe-index selection for branchless checked reads from nonempty fixed state arrays. + +- YDSP: compact array bounds checks to unsigned comparisons and hoist delay clamps preceding guarded accesses. + +- YDSP: allow unconditional invariant hoisting and unrelated branch optimization in guarded kernels, preserving short-circuit and bounds checks; enable benchmark ASM dumps with `YUP_DUMP_KERNEL=1`. + +- YDSP: fold constant int32-to-int64 sign extension so widened constants participate in integer arithmetic folding. + +- YDSP: saturate integer kernel negation, absolute value and overflowing division at both widths in native/WebAssembly code and constant folding; regenerate bundles for codegen revision 4. + +- YDSP: preserve exact signed 64-bit literals, reject overflowing structural sizes and explicit nonfinite constants; regenerate bundles for codegen revision 3. + +- YDSP: preserve vectorization for proven in-range `blockSize - N` stream loops; retain guards when fixed loop and buffer lengths can differ. + +- YDSP: guard dynamic state-array, struct-field and indexed-stream accesses, diagnose constant invalid indices, and reject overflowing declared state layouts before flattening. + +- YDSP: lower logical operators and ternaries with short-circuit branches, preserving eager `select()` and reporting conservative optimization restrictions. + +- YDSP: replace `input value` / `output value` with `input parameter` / `output parameter` in processors and graphs, freeing `value` as an identifier, with migration diagnostics and updated examples/editor support; regenerate language-version-2 bundles. + +- YDSP: fix kernel listing histograms misreading hex-like mnemonics as machine-code bytes; exclude labels and assembler directives. + +- YDSP: retain cached state-array loads across provably disjoint stores using the same index, with type, overlap, and index-redefinition coverage. + +- YDSP: strengthen scalar tanh accuracy and feedback regression coverage; retain libm after rejecting a bounded ARM64 approximation that slowed the tanh shaper. + +- YDSP: make array store-to-load forwarding type- and lane-aware, recognize disjoint constant-index ranges, and run this cleanup after vectorization/unrolling with overlap regression coverage. + +- YDSP: allow bounded two-iteration unrolling around four-lane native math calls with a conservative call-liveness budget; add output/state parity and rejection coverage. + +- YDSP: expand default-policy timing to all 19 patches and four block sizes, separating prepared processing from reset/setup; add median/spread reporting, run metadata, and previous-JIT log comparison. + +- YDSP: replace the incomplete Zita example with the full steady-state stereo network and EQs; add a local JIT benchmark with reference parameter settings, native listings, impulse dumps, and impulse-tail regression coverage. + +- YDSP: hoist and share vector math call targets across native kernels while preserving strict/fastMath accuracy selection. + +- YDSP: omit unused ARM64 array-base setup and reuse dead local vector FMA addends on ARM64/x64. + +- YDSP: preserve unique temporaries and specialize indices in unrolled loops, enabling subsequent FMA contraction and constant vector array offsets on ARM64/x64. + +- YDSP: reuse state-array reads between writes, encode constant scalar array offsets on ARM64/x64, combine constant multiplication chains under fastMath, and rematerialize dry/wet coefficients after math calls. + +- YDSP: vectorize if-converted stream loops and positive constant starts, preserve fixed-index array dependencies, and hoist iteration-local invariant clamp/conversion chains. + +- YDSP: extend fastMath reciprocal multiplication to shared constant divisors and float64, retaining divisions when the reciprocal is non-normal. + +- YDSP: specialize finite positive constant-base powers as scaled exponentials under fastMath, preserving general power calls in strict mode. + +- YDSP: rematerialize constants used between math calls, reducing call-crossing register pressure in kernels such as the compressor. + +- YDSP: share identical immutable entry constants and coefficients across blocks after loop hoisting, reducing duplicate live values on both native targets. + +- YDSP: use encodable integer immediates for fused comparisons on ARM64 and x64, reducing constant-register pressure in delay-bank kernels. + +- YDSP: fold fused-subtraction write-backs in the shared optimizer and preserve overlapping multiply operands in x64 lowering. + +- YDSP: preserve local initializer snapshots when their source state or local is subsequently assigned. + +- YDSP: fold temporary state-copy chains in the shared optimizer, with coverage for saved values and sample history across blocks. + +- YDSP: share operand lowering between ARM64 and x64, including x64 fused product subtraction and width-correct integer immediates; add emission coverage for both architectures. + +- YDSP: contract product-minus-addend expressions and fold them to ARM64 `fnmsub`; encode small integer offsets and low-bit masks as ARM64 immediates. + +- YDSP: eliminate unconditional loop backedges through comparison-only headers in native codegen; add empty-block coverage and small-kernel block-size benchmarks. + +- YDSP: share compatible input delay taps in one masked history ring, prefer FMA contraction on longer feedback paths, and contract the right-hand product of product differences. + +- YDSP: use indexed optimizer lookups, worklist dead-code elimination and early cleanup convergence; reuse eligible stream scratch by lifetime, simplify delay wrapping, and preserve strict subtraction by negative zero. Add scratch-size reporting, opt-in allocation assertions and compile/dense-event benchmarks. + +- YDSP: retain only `process(const YdspProcessRequest&)`; migrate examples, tests and benchmarks from positional overloads. + +- YDSP: validate processing requests before mutation, add span-based `YdspProcessRequest`, return preparation validation failures via `Result`, and explicitly reject sample-accurate automation for rate-converted nodes. Slot APIs use `BySlot` names to avoid ambiguity with YUP strings. + +- YDSP: parameter/meter getters now read atomic block snapshots, with slot-based access and bounded single-consumer parameter draining. Delayed events survive multiple variable-sized blocks; exact-boundary events belong to the next block. Event dispatch uses ordered cursors while preserving equal-offset precedence. + +- YDSP: emit ARM64 and x64 kernels independently of the compiler host, including WebAssembly; version 2 bundles store native/WebAssembly artifacts and their source closure, resolve helpers at load time, and instantiate without disk imports or machine-code regeneration. Version 1 bundles must be regenerated. + +### Added + +- YDSP: `fmsubF` lowers to a single fused `fmsub` on AArch64 instead of an `fmul`/`fsub` pair. `FMSUB d, n, m, a` computes `a - n * m` with one rounding, so this removes an instruction, a virtual register and a link from the loop-carried dependency chain of every contracted `c - a * b` (the ladder filter's `in - fb * z4`, the wave shaper's `1.0 - env * 0.5`). It also settles an inconsistency: `lowerFusedMultiplyAdd` already gives a target *without* the instruction one rounding through its float64 expansion, so the FMA-capable target was the less accurate one. Pinned by a numeric test rather than left implicit. + +- Tests: `yup_YdspBenchmarkTests` gains nine real-life effect benchmarks - a fractionally addressed feedback echo, an LFO chorus, a Freeverb eight-comb/four-allpass reverb, a dB-domain bus compressor, a tanh drive distortion with tone control, a TPT state-variable low-pass, a six-stage phaser, a Karplus-Strong pluck and a 12:1 sample-hold/bitcrush lo-fi processor. Each compares the JIT kernel against a hand-written C++ routine over the standard benchmark length, across the four optimisation policies, and under the same loose parity guard the other shapes use (checksum where the loop stays libm-free, relative magnitude where it does not). The five effect shapes mirror the shipped `fx/` example processors with their UI annotations stripped. + +- YDSP: loop-invariant code motion now places a hoisted instruction as early in the sample loop's preheader (the kernel entry block) as its own operands allow - right after the last preheader instruction that defines one of them, never before. An invariant that reads no scalar state (a hoisted `tan`/`exp` coefficient) therefore lands ahead of the entry block's scalar-state loads and is no longer crossed by the loop's register-promoted state, which the register allocator previously parked on the stack for the whole block loop; the TPT state-variable filter benchmark dropped from ~1.9x to ~1.25x of its hand-written C++ reference as a result. An invariant that consumes a state register (a voice body's `env * gain`) still lands after the load that defines it. + +- YDSP: the native backends fuse a comparison into the branch that consumes it. A `branchIf` whose condition is a single-use integer or float comparison now re-emits that comparison into the condition flags at the terminator and branches on them (`jcc` on x86-64, `b.cond` on AArch64), instead of materialising a 0/1 register with `setcc`/`cset` and testing it - every sample-loop header and data branch drops the register round trip. + +- YDSP: values live across a call now survive it in callee-saved registers instead of round-tripping through the stack at every call. The bundled AsmJit (thirdparty/asmjit_library) previously spilled unconditionally any value sitting in a register a call clobbers whenever that value's live range crossed a basic-block boundary - which meant a per-sample libm call (sin/tanh/log/pow in an effect chain) stored and reloaded the loop's state, stream pointers and coefficients on every sample. `bin_pack` now detects call-crossing values by live-span containment over the call sites and gives them preserved homes up front, packing them ahead of any call-free value so none can claim the last preserved register as a fallback first (`x19-x28` / `d8-d15` on AArch64), and the local allocator only parks a value in a preserved register at a call when one is free, spilling only on overflow. Against hand-written C++ references the chorus benchmark dropped from ~2.4x to ~1.1x, distortion from ~1.6x to ~1.12x, tanh shaper to ~0.97x, and the phaser now runs at ~0.68x. The changes are small local patches over upstream AsmJit, marked at the sites in `core/ra_pass.cpp` and `core/ra_local.cpp`. + +- YDSP: scalar libm function addresses are materialized once per kernel instead of at every call site. A per-sample `sin`/`tanh`/`pow` used to rebuild its 64-bit target register (`movz` + two `movk`s) before each register-indirect branch; the codegen now pre-scans the IR, emits one address load per distinct function at kernel entry (`materializeScalarLibmTargets`) and lets every call site branch through that shared register, which the allocator keeps in a callee-saved slot. + +- YDSP: `foldStateWriteBacks` now folds integer state write-backs too, not just float chains. A per-sample `wp = wp + 1` ring-pointer update used to be a fresh `addI` plus a `movI` write-back into the loop-carried register every sample - exactly the float `fmov` round-trip the pass already removed - and so did the sample loop's own induction move. Integer arithmetic, bitwise and `select` producers now write the carried register in place (`wp = wp + 1` is one `add`), dropping one instruction per sample off the state chain in the delay-line, chorus, karplus and lo-fi shapes. The canonical write-back move of a constant-bound loop's induction is preserved (fusion, unrolling and the vectoriser pattern-match the loop body on it before the fold runs); the fold is what removes it after unrolling. + +- YDSP: the vectoriser now accepts `blockSize - k` (and `blockSize + k`) loop bounds instead of rejecting them as `unsupportedLoopBound`. They are runtime bounds like plain `blockSize`, so the same epilogue machinery widens them to `bound & ~(lanes - 1)` whole vectors plus a scalar remainder loop, with the stream-access requirement enforced as before; a `for i in 0..blockSize - 1` stream loop widens the same way `0..blockSize` does. + +- YDSP: the vectoriser now widens loops containing the rounding intrinsics `floor`, `ceil` and `rint`. Each lowers to one native packed instruction (`frintm`/`frintp`/`frintn` on AArch64, `roundps` on x86), so a block-mode quantiser or bitcrush loop over streams vectorises like any other element-wise loop and stays bit-exact per element. Vector `round` and `copysign` remain scalar: round-half-away has no one-instruction packed form on x86 and copysign needs float bitwise sign-mask work both backends do not expose yet. + +- YDSP: if-conversion now speculates an input-stream load when it reads the induction of the stream-length loop that directly encloses it (the sample loop, or a block-mode `for i in 0..blockSize` loop). A periodic sample-and-hold or downsample branch (`if (counter == 0) { held = in; }`) becomes straight-line code that loads unconditionally and selects, instead of a branch that mispredicts every Nth sample - the residual gap in the lo-fi 12:1 sample-hold benchmark. State-array and parameter loads stay inside their branch, as do loads under a constant-bound inner loop. + +- YDSP: scalar *leaf* values that would otherwise be parked around a per-sample libm call are now rematerialized instead. A parameter load or compile-time constant defined before the loop but used only after the loop body's last libm call gets re-defined right after that call (`rematerializePostCallLeaves`): it reloads once per sample - the same value - and its live range never spans the call, so the register allocator stops emitting the per-sample `str`/`ldr [sp]` parking pair and one less value competes for a preserved register. Leaf defs only; expF is not treated as a boundary because fastMath inlines it on AArch64. + +- YDSP: voice banks now work on subgraphs - `node v = VoiceChain[8]` inlines the whole chain as one runtime voice group, so an effect (filter, delay, reverb, shaper) runs once per voice with its own state instead of once after the summed bank. A banked subgraph must declare an `input event` and exactly one float32 output stream and contain at least one event-handler member; members must be ordinary single-voice float32 processors (nested banks, per-member rate changes and midi-only members are rejected). Group members run voice-major with per-voice intra-group delay rings, each note event is allocated once per group and fanned to every subscribing member on the same voice, all-sound-off clears the group's slots once and silences every member, and `getActiveVoiceCount` accepts the bank name. `examples/graphics/data/synths/PerVoiceEcho.ydsp` is the worked example; the crosstalk test proves two notes through the chain are numerically different from the same effect placed after the mix. + +- YDSP: a `state` array may now omit its size and let a `{ ... }` initialiser list determine it - `state float wavetable[] = { ... }` - and the compile-time pseudo function `size (...)` returns the element count of any array expression: a `state` array (explicit or inferred; an array of struct instances yields its instance count), a struct array field (`size (comb.buf)` / `size (combs[i].buf)`), and, in a block-mode processor, a stream (whose length is the runtime `blockSize`). A `[]` state without a non-empty `{ ... }` list, and struct-array states written `[]`, are compile errors with a message naming the fix. +- YDSP: one `state` statement may now declare several states of the same type, separated by commas - `state float x, y, z;`. Each declarator keeps its own array size, initialiser and trailing annotation (`state int active [[ role: voiceActivity ]], released;` marks only `active`), and the list is sugar for the equivalent run of single-`state` statements. + +- Examples: `fx.Delay` is now a fractionally addressable delay. The read tap is a linear interpolation between the two ring samples either side of the requested delay, and its `time` parameter is smoothed, so changing the delay time while audio is running glides the tap instead of clicking on whole-sample steps. `PerVoiceEcho.ydsp` runs one such delay per voice. +- YDSP: adjacent members of one banked voice chain that are pure per-sample processors now fuse into a single kernel, exactly as plain chains always did. The fused member still runs once per voice under the group's slot table (per-voice state and per-voice parameters are preserved), but the intra-group junction stops being a per-voice scratch round-trip; fusion never crosses a group boundary. +- YDSP: a banked voice chain now skips a whole voice when every member reports its `[[ role: voiceActivity ]]` flag asleep - the flag is no longer restricted to processors that declare event handlers, so pure effects (filters, delays, reverbs) can opt in by keeping their flag set until their own tail has died. Skipping freezes the whole voice (per-voice delay rings included); a held voice, a pending event, or block-wide automation/all-sound-off always keep the voice running. Fusion and skipping are complementary: fused effect members cannot carry a flag, so their voices always run. + +- YDSP: the optimizer now performs block-local common-subexpression elimination for pure IR expressions, reducing repeated arithmetic without changing non-SSA state or memory semantics. + +- YDSP: endpoint annotations gain `[[ mid: ]]` (the value that should sit at the middle of a host slider's travel, exposed as `YdspParameterInfo::midValue` so a UI can derive a logarithmic skew) and `[[ bipolar: true ]]` (a range-centered-on-zero flag, exposed as `YdspParameterInfo::bipolar`, default `false`). `examples/graphics`'s YDSP Synth Lab applies `mid` through `Slider::setSkewFactorFromMidpoint()`; the Analog Saw patch's Cutoff knob demonstrates it. + +- YDSP: introduced the `.ydsb` bundle API, `yup_dsp_compiler` host tool, CMake embedding helper, and bundle format documentation. + +- YDSP: fast-math contraction now covers multiply-add and subtract-multiply patterns; the explicit tradeoff is changed rounding. Fixed inline `@` delays use a compact increment-and-wrap IR operation for faster native code. + +- YDSP: `YdspCompiler` now accepts per-compile `YdspCompileOptions`, providing baseline/automatic/aggressive policies, strict-by-default `fastMath`, host or portable-target selection and an optional optimisation report. Native bank-loop SIMD now uses the selected target width (SSE2/ASIMD x4 or AVX2 x8), AVX2 emits packed FMA when fast math is enabled, and AVX-width kernels emit `vzeroupper` on return. AVX-512 is detected but remains disabled until a measured microarchitecture cost model is available. `YdspBenchmarkTests` now compare automatic code against the scalar baseline on the modal-bank shape, reporting lane width, generated code size and timing while requiring identical strict output. +- YDSP: the vectoriser now handles the scalar remainder of a trip count, and the per-sample stream loop. A constant-bound loop whose span is not a whole multiple of the lane count peels its leading remainder as straight-line scalar copies and starts the vector loop at a whole number of vectors (a 6-mode bank is two scalar iterations plus one four-lane trip, not six scalar ones); a non-zero constant start is handled the same way over the span, which also fixes an overrun the old divisible-bound-only rule had for such loops. A `blockSize`-bound loop whose body reads or writes streams at the loop variable (`in[i]` / `out[i]`, the sample-mode gain/mix shape or a block-mode stream loop) is widened too, with a rolled scalar tail loop after the vector loop, packed stream loads/stores on the native backends and `v128.load`/`v128.store` on wasm SIMD, and the reduction fold placed once after the tail. Loops that were already scalar stay scalar: a constant span shorter than one vector, a runtime start, a `blockSize`-bound `state`-array bank, or a stream store at a fixed index. +- YDSP: missed-vectorization diagnostics. `YdspVectorizer::run` now records one outcome per original loop - `widened` at the lane count, or the exact reason the loop stayed scalar (`shortTripCount`, `unsupportedWidenedOp`, `indirectAccess`, `loopCarriedValue`, `nonConstantStart`, ...) - exposed as `YdspKernelReport::loopVectorization` (with `YdspVectorizationReport::rejectionReasons()` for the deduplicated text and `YdspVectorizationResult::describe()` for one line), and emitted as info diagnostics when `YdspCompileOptions::emitOptimizationReport` is set, the LLVM `-Rpass` equivalent for "why is this loop scalar?". +- YDSP: the WebAssembly backend now lowers the vectorised IR to f32x4 SIMD when the module is compiled with `-msimd128` (the emscripten default, which defines `__wasm_simd128__`). Widened state-bank loops emit `v128.load`/`v128.store`, packed `f32x4` arithmetic, splats and a shuffle-based horizontal reduction; element-wise work stays bit-exact against the scalar form and the reassociated accumulation keeps the existing tolerance. The automatic tier applies the full native transform set on wasm: vectorisation at four f32x4 lanes, unrolling of the widened loops, and halving of the widened reduction chains (all pure IR passes), while a build without `-msimd128` keeps every loop transform off and the scalar-only rejection, now naming the flag. The wasm vector width is fixed at four lanes (128-bit SIMD), so kernels vectorized for AVX2/AVX-512 widths are rejected with a diagnostic rather than miscompiled. +- MIDI: the WASM (`Emscripten`) backend of `yup_audio_devices` is now backed by the Web MIDI API (`yup_Midi_wasm.cpp`). `MidiInput` and `MidiOutput` enumerate, open, start/stop and send to browser MIDI ports; SysEx is requested (`sysex: true`); hot-plug `statechange` events update `MidiDeviceListConnection` listeners; incoming streams are converted through the existing bytestream handlers (so `ump::Receiver` with `MIDI_2_0` works), outgoing `ump::View`/`ump::Packets` are converted to MIDI 1.0 bytes, and sends from other threads are proxied to the main thread. `createNewDevice()` stays unsupported since the Web MIDI API cannot create virtual ports. Note the browser permission is asynchronous: call `getAvailableDevices()` early and lists populate once the user grants access. +- Tools: a new `modules/yup_dsp_jit/tools/vscode-ydsp` extension brings YDSP syntax highlighting, snippets and editing configuration to VSCode, installed with `just vscode`. +- Examples: `examples/graphics` can now be configured to build a single demo instead of the full browser, via `-DYUP_EXAMPLE_GRAPHICS_DEMO=` (e.g. `SpinningCube`) in place of the default `ALL`. A single-demo build compiles out every other demo's code, skips the picker list UI, and only embeds/preloads the rive, lottie, shader and `data/synths` resources (and the `glslang`/`spirv_cross`/`spirv_tools` shader transpiler) that the selected demo actually needs - useful for small, single-page embeds such as documentation website demos. +- GUI: `yup_audio_gui` gains `PitchWheelComponent` and `ModWheelComponent`, vertical-drag controls for building a pitch-bend + mod wheel strip next to a `MidiKeyboardComponent`. The pitch wheel reports a bipolar `-1.0..1.0` value and springs back to its default on mouse release by default (settable to hold instead); the mod wheel reports a unipolar `0.0..1.0` value and never springs back. Both are themed as a cylindrical wheel body with a single sliding grip line - not a slider track and thumb. `examples/graphics`'s YDSP Synth Lab now drives pitch bend and CC1 from the two wheels, placed to the left of its keyboard, instead of the two sliders that previously stood in for them in the expression row. +- YDSP: `noteOn` handlers can now read `e.bendSemitones`, the pitch-bend in effect at the moment the note is triggered. Previously a freshly triggered voice only saw the bend as a later `pitchBend` event, so patches either reset their bend factor to 1.0 on note-on (a key pressed while the wheel was held up started un-bent) or leaked a stale value from a recycled voice. The runtime already computed the value (`payload.bend` from the MPE note, which in legacy mode is the last wheel position on the channel); it is now exposed on the `noteOn` shape like `pitch`/`velocity`, and mono note-ons carry it too (the mono held-note record now stores the bend). The bundled synth patches replace their `bendFactor = 1.0` note-on reset with `bendFactor = pow (2.0, e.bendSemitones / 12.0)`. +- YDSP: processors can now generate events. `output event ;` declares an emitting channel and `emit (field: expr, ...) -> ;` sends one, legal in the per-sample `process` body and in event handlers. A channel's declared name is an identifier, not a shape - it need not equal any of the seven shape names, and one channel may carry several different shapes over its lifetime. In a `connection { }` block, a node's `output event` wires to another node's `input event` (including the polymorphic `midi` input) or to the graph's own `output event` boundary, which the host reads back as MIDI; every declared `output event` must be connected at least once, and an emitted event straddling a block boundary or carrying `[[ latency ]]` compensation arrives already aligned with its source node's audio. This is what lets a MIDI-only processor - one with no stream endpoints at all, such as the new `midi.Arp` and `midi.Transpose` in `examples/graphics/data/synths/midi/` - drive an existing, unmodified voice bank by composition alone; `ArpPolySine.ydsp` and `ArpTranspose.ydsp` are worked examples, the latter a fully MIDI-only graph with no audio stream anywhere in the patch. A graph is now classified purely by which endpoint kinds it declares (audio-only, MIDI-only, or hybrid), not by a keyword. A MIDI-only node's own note bookkeeping (e.g. `midi.Arp`'s held-note table) is no longer subject to the runtime's ordinary per-voice allocation and stealing: with no stream endpoints, and therefore no per-note "sound" to steal, every event now reaches the node's sole instance directly, so a node like `midi.Arp` correctly tracks a whole chord rather than just its most recent note. `midi.Arp` also gained a `mode` parameter (`Up`, `Down`, `Up-Down`) selecting which direction it steps through the held notes, and restarts its clock on the first note of a phrase so a chord sounds immediately rather than after up to one full `1/rate`. +- YDSP: fixed a bug in the compiler's import-cloning step (`cloneStmt()`) that dropped an `emit` statement's shape, target and field list whenever the emitting processor was only ever reached through an `import` - every existing test that used `emit` declared its processor inline, so the gap went unnoticed until `midi.Arp`/`midi.Transpose` shipped as importable library processors. +- YDSP: a graph's `input event ;` is no longer a broadcast subscription. Previously any node whose own processor declared `input event ;` with the same name received every event on that port automatically, with no wire in the `connection { }` block to show it - and which port a node actually heard depended on its declaration order among the graph's inputs, invisible at the call site. A graph input event is now wired exactly like a stream: ` -> node.event;` in a `connection { }` block, fanning out to as many destinations as are wired and reaching none that aren't; an unconnected graph input event or an unconnected node input event is now a compile error, the same rule already applied to `output event`. All shipped synth patches (and `ArpPolySine.ydsp`/`ArpTranspose.ydsp`) gained the explicit `midi -> voices.midi;` (or `midiIn -> arp.midiIn;`) line this requires; `PolySine.ydsp` moved from the algebra body form to a `connection { }` block, since the algebra form has no event syntax. + +- YDSP: native transcendentals now lower through the bundled `sleef_library` + (SLEEF, Boost-licensed): widened transcendental loops vectorize to 4-lane + calls (`u35` under the now-default `fastMath`, `u10` when strict), scalar + float32 values stay on libm, `fastMath` is enabled by default on native + targets (wasm stays strict regardless), and an 8-lane AVX2 value splits into + two 4-lane calls. Optimizer borrows from the SNEX reference JIT: constant + division becomes reciprocal multiplication under fastMath, pow2 modulo of a + provably non-negative value becomes a mask, and adjacent same-bound + memory-disjoint loops fuse before vectorization; constant math calls fold + for the full intrinsic family. Register allocation weights hot values + (inductions, widened lanes, stream bases) so they never spill. Benchmarks report a + per-policy matrix (baseline strict / + fastMath / host strict / host + + fastMath default) with a transcendental-call count per kernel. +- YDSP native codegen: under the native `fastMath` default, scalar float32 + `exp` on AArch64 is lowered to a straight-line degree-8 Estrin polynomial + (~1 ulp across [-1, 1]) instead of a per-sample libm call plus its + register-allocator spill round-trip; arguments beyond |x| = 1 take a rare, + predictable fallback branch to libm, and x86-64 keeps the libm call. The + exp-envelope benchmark drops from ~4.6 to ~1.8 ns/sample (~1.05x of its + C++ reference, down from 2.6x) and the modal bank from ~7.3 to ~5.0 + ns/sample (0.74x of C++). The optimizer's block-local CSE now also covers + repeated reads of the same input stream slot - input buffers are immutable + for the lifetime of a kernel, so the reads are pure - collapsing e.g. the + four per-sample `in[i]` loads of the delay-taps shape to one. +- YDSP optimizer: scalar state write-backs fold into the value they move. A + sample-mode state update lowers to `v = op (...)` followed by `movF y = v` + (and the builder keeps binding the variable to `v`); the new + `foldStateWriteBacks` pass rewrites the producer to write the loop-carried + register `y` directly, redirects every in-block use of `v` to it and drops + the move, so the per-sample chain carries no extra register hop. The ladder + filter drops from ~10.7 to ~9.7 ns/sample (host + fastMath, now ~1.05x of + its C++ reference), the wave folder from ~1.8 to ~1.1 ns/sample (~1.03x of + C++), and the exp envelope from ~1.8 to ~1.5 (0.86x). + +### Changed + +- YDSP: the lexer scans with a UTF-8 character pointer instead of a character index into the source `String`. `String::operator[]`, `length()` and `substring()` each walk the buffer from the head, and the old cursor called them once or twice per source character, which made `YdspLexer::tokenize()` quadratic in source length; it is now linear. Tokens, line numbers and column numbers are unchanged - columns still count characters, not bytes. + +- Tools: the python stdlib archive generator (used by the tests target) now skips the copy and zip steps when the source bundle and tool configuration are unchanged since the last run, so reconfigures no longer pay for a full stdlib rebuild. + +- Build: `yup_add_embedded_binary_resources` now regenerates a resource's byte array only when the input file's content actually changed (tracked via an MD5 sidecar next to the generated `.inc`), so reconfigures no longer re-read and re-serialize large embedded files such as the python stdlib zip. + +- Build: Xcode builds no longer auto-regenerate the project during a build (`CMAKE_SUPPRESS_REGENERATION` is set for the Xcode generator), so building no longer cancels with "project is being modified while building" when CMake input files change; re-run cmake (e.g. `just mac`) after editing `CMakeLists.txt` or `.cmake` files. + +- YDSP native codegen: kernel prologues now load context pointers only when the generated IR uses the corresponding resource, reducing register pressure and avoidable spills in small sample kernels. +- Emscripten: the standalone shell now shows a non-blocking hint over the canvas when audio needs a user gesture, reports audio/MIDI availability in the top rail, and keeps activation in the shared AudioWorklet backend for all examples using the shell. + +- GUI: `PitchWheelComponent` and `ModWheelComponent` now take a `MidiKeyboardState` in their constructor, like `MidiKeyboardComponent`. Both register as state listeners and follow the pitch-wheel / modulation-wheel (CC 1) position of their midi channel (see `setMidiChannel()`), applied asynchronously on the message thread - updates arriving between message-thread passes are coalesced so only the latest position is applied, and no update is applied while the user is dragging the wheel. The YDSP Synth Lab demo drops its manual atomic bridge (incoming pitch bend / CC1 stored on the MIDI input thread and applied in `refreshDisplay()`) and lets the wheels follow `keyboardState` directly. +- YDSP optimizer: the compiler pass implementations are split out of the monolithic `optimiser/passes/yup_YdspPasses.cpp` into one file per pass (`yup_YdspPassesConstantFolding.cpp`, `yup_YdspPassesAlgebraicSimplification.cpp`, `yup_YdspPassesCopyPropagation.cpp`, `yup_YdspPassesIfConversion.cpp`, `yup_YdspPassesFullyUnrollBoundedLoops.cpp`, `yup_YdspPassesSplitWidenedReductionChains.cpp`, `yup_YdspPassesStoreToLoadForwarding.cpp`, `yup_YdspPassesDeadCodeElimination.cpp`, `yup_YdspPassesLoopInvariantCodeMotion.cpp`, `yup_YdspPassesContractMultiplyAdd.cpp`, `yup_YdspPassesLowerFusedMultiplyAdd.cpp`), with the helpers shared across passes moved to `yup_YdspPassesShared.cpp`. Pure cut/paste - no behavior or IR change. +- YDSP: the public API headers are split out of the monolithic `compiler/yup_YdspCompiler.h` into per-concern files: the runtime graph API (`YdspAudioGraph`, `YdspParameterInfo`, `YdspExecutionReport`, the stream buffers and `YdspProcessResult`) now lives in `runtime/` (`yup_YdspAudioGraph.h`, `yup_YdspTypes.h`, `yup_YdspExecutionReport.h`), and the compiler API (`YdspCompiler`, `YdspDiagnostics`, `YdspCompileOptions`/`YdspOptimizationReport`, `YdspRecursionGuard`) in `compiler/` (`yup_YdspCompiler.h`, `yup_YdspDiagnostics.h`/`.cpp`, `yup_YdspCompileOptions.h`, `yup_YdspRecursionGuard.h`). Pure cut/paste - no behavior or API change. +- Examples: the YDSP Synth Lab demo (`examples/graphics/source/examples/YdspSynths.h`) is restyled to match `cmake/platforms/emscripten/shell.html`'s dark theme - the same void/surface/edge/ink/muted/glow palette, and a surface-coloured rail with a hairline edge behind the toolbar/title bars and behind the keyboard row. The toolbar is now one aligned strip: the YUP mark and a bold "YUP!" wordmark, a shortened patch selector, then Performance/Editor tabs, master volume, the oscilloscope and All Notes Off as equal-width slots, so the row reads as a single set of controls rather than mismatched widths; the separate "YDSP Synth Lab" title label was dropped as a redundant second wordmark. The Dump Asm/Dump Wasm button moved out of the toolbar into the editor tab, next to Compile, since it only makes sense there. The row below the toolbar now holds the MIDI input selector alongside the expression sliders, reclaiming the space the old dedicated title/patch/volume row used to waste; and the parameter knob grid expanded into that freed height plus the space the oscilloscope vacated. Parameter cards, meters and the oscilloscope now share the shell's blue accent instead of each having their own. `data/logo.png` is preloaded unconditionally in the Emscripten build, since `main.cpp`'s own title chrome loads it regardless of which demo is selected and it previously was not. +- Emscripten: the standalone shell page (`cmake/platforms/emscripten/shell.html`) has been redesigned around a dark, low-chrome theme keyed to the YUP mark, with the loading indicator doubling as the download progress arc. The canvas display is now a three-way choice - embedded, full window (canvas fills the tab, no scrollbars) and fullscreen - replacing the old resize-canvas and lock-pointer checkboxes, which only fed `Module.requestFullscreen` and had no observable effect. Fullscreen now reuses the full window layout rather than emscripten's own fullscreen sizing, so the two behave identically. The top rail reports whether the page is cross-origin isolated and how many threads the browser offers, which is what a failing pthreads or audio worklet build needs first. Note that full window scales the existing framebuffer: the app is only redrawn at the new size once `SDL_EVENT_WINDOW_RESIZED` is forwarded to `handleResized` in `yup_Windowing_sdl.cpp`. + +- YDSP: new `fma(a,b,c)` intrinsic - `a * b + c` with a single rounding. The compiler has never fused a multiply and an add on its own, deliberately: contraction is a precision liberty, and a patch has to produce identical samples on every backend it can be compiled for. That refusal turned out to be most of what separates the JIT from compiled C++ on per-sample recurrences, where the multiply and the add are consecutive links of the loop-carried chain. A new benchmark variant sizes it by giving the C++ reference a `#pragma clang fp contract(off)`: the ladder filter reads **1.56x** against a reference that fuses and **1.05x** against one that cannot, so on that shape contraction was essentially the entire gap; the wave folder reads 2.15x and 1.24x. The wave shaper is the control - its recurrence is a multiply and a `select`, with no add to fuse into - and its two references land together, confirming its ~1.39x is not an FMA story. `fma` closes the gap without giving up the guarantee: the operation has **one defined value**, and a target with no fused instruction (wasm, or x86-64 without FMA3) reaches that same value by computing in float64 and rounding once - exact for normal results, since a float32 product is exact in float64 and 2p+2 = 50 bits fit in its 53. So native and wasm still agree; what differs is a patch written with `fma` against the same patch written with `*` and `+`. It is float32 only, because the fallback needs a format one step wider than the operands and none exists above float64 - `fma` on float64 operands is a compile error rather than a silent per-target difference. The expansion is an IR pass, so neither the wasm backend nor a pre-FMA3 x86 one needs to know the opcode exists; AArch64 lowers it to `fmadd`, x86-64 with FMA3 to `vfmadd213ss`. A `contractMultiplyAdd` pass applies the same rewrite automatically to every `a * b + c` whose multiply feeds nothing else, so patches get this without being rewritten - and measurably do: with it on, the ladder went **15.2 to 10.3 ns/sample (1.56x to 1.135x)** and the wave folder **2.71 to 1.87 (2.15x to 1.645x)**, both now *faster* than the reference that cannot fuse (0.77x and 0.94x). The same patches written with `fma()` by hand measure 0.98x and 0.99x against the automatic form - inside noise, which is the check that the pass finds what a person would and picks the same operand when both are eligible. The wave shaper is the control: its recurrence is a multiply and a `select` with no add to fuse into, and it did not move (1.39x to 1.41x). The pass runs after the vectoriser and skips anything widened, since there is no portable packed fused form; when both operands of an add are fusable multiplies it picks the one on the recurrence, because fusing the other leaves the loop-carried chain a link longer than it started. Two costs come with it being automatic: on a target with no fused instruction each contracted site is six operations instead of two, which WebAssembly pays throughout and which is not yet measured; and an algorithm that depends on the intermediate product being rounded (`a*b - c*d` determinants, Kahan summation, Dekker's `twoProduct`) is changed by it, with no per-expression way to opt out yet. + +- YDSP: a graph is now an arbitrary DAG. Connectivity was "exactly once" on every graph input, graph output, node input and node output stream, which made a YDSP graph a *forest of chains*: a signal could not be split and rejoined, so dry/wet, parallel multiband, mid/side, a metering tap and summing two sources into one input were all inexpressible. The rule is now **at least once** on both sides, and beyond that: a source may **fan out** to any number of destinations, and a destination fed by more than one source **sums** them, with no mixer node needed. Fan-out costs nothing - generated code never writes through `ctx.inputs`, so N consumers share one buffer pointer and no new memory is allocated. A new `YdspBenchmarkTests` shape measures the cost, and the answer is that summing is effectively free. A graph-level dry/wet built from fan-out plus fan-in runs at **6.32 ns/sample** against **5.96** for the same patch with dry/wet hand-rolled inside one processor - but most of that 0.43 gap is the second kernel call, not the mix, because the fanned form cannot fuse. A third variant in the same test separates them by fanning out to two *separate* graph outputs instead of summing into one: same two kernels, same fan-out, but both outputs take the direct-write path so no mix buffer exists. Against that, the mix path costs **0.109 ns/sample** (6.32 vs 6.21, a 1.018x ratio) - a `memcpy` plus one add per sample, which is what it should be. Graph-level dry/wet is therefore the idiomatic form now rather than a luxury, and the remaining ~0.32 ns/sample is simply what a second kernel call costs. Implicit summing requires a `float32` or `float64` stream, named in the diagnostic when it is not; fan-out has no type restriction, being the same buffer read twice. The four zero-use rejections survive and two of them are load-bearing rather than stylistic: a graph output with no source would have *nothing* written to it (and the runtime never zeroes an output buffer, so the host would hear its own uninitialised memory, not silence), and a node input with no connection leaves `runtimeInputs[s]` null for the kernel to dereference. Mixing happens on the **input** side and the destination owns the mix buffer, which leaves a node's exclusive ownership of its `runtimeOutputs` - and therefore the polyphonic pre-voice-loop zeroing, `clearVoiceSpan` and the voice accumulate - untouched; connection 0 `memcpy`s into the buffer and 1..N-1 accumulate, so "written exactly once" is structural rather than an ordering rule to get right. Summation order is the order the edges appear after analysis: deterministic per patch, and nothing more (subgraph inlining and kernel fusion both rebuild the edge list), so it is preserved with a counting sort rather than a `std::sort`, and documented as something not to depend on. Existing patches are bit-identical: a graph output keeps writing straight into the host buffer when it has exactly one source, that source is a node, the edge carries no delay, and that node output has exactly one destination - which every previously-legal shape satisfies. `MasterBus.ydsp` is the first patch in the tree to use the feature, gaining a real parallel dry path around its reverb so `reverbMix` is a graph-level balance instead of a value forwarded into `Reverb`'s own internal `mix`; three new demo patches showcase it: `ParallelRack.ydsp` (one voice fanned out to three character paths, summed back), `HaasWidener.ydsp` (one mono chain fanned out to both graph outputs with an inline delay on the right), and `ParallelDrive.ydsp` (a clean path summed against an oversampled hard clipper feeding a `[[ latency: 32 ]]` lookahead limiter, so the 48-sample skew is compensated rather than comb-filtering the blend). The four bundled effects are deliberately left alone as regression baselines. +- YDSP: automatic plugin delay compensation, and a latency figure to report to the host. Before paths could reconverge, latency misalignment could not be heard, so YDSP had no latency concept at all - zero occurrences of the word in the module. The moment fan-in exists, an oversampled branch summed against a dry one is comb-filtered rather than merely late. The new `computeLatencyAndCompensate` pass equalises it, and `YdspAudioGraph::getLatencySamples()` reports what is left (feed it to `AudioProcessorBase::setLatencySamples()`, which already drives every VST3/CLAP/AU/AUv3/AAX/LV2 wrapper). What makes it correct rather than merely present is *which* latency it compensates: an **artifact**, where the sample count is a leaked consequence of an implementation choice the author did not make (an oversampler's group delay; a new `processor P [[ latency: N ]]` declaration, which only the processor's author can know), as opposed to **intentional**, where the count *is* the semantics because the author typed the number (`-> [400] ->`, `x @ 400`). `edge.delaySamples` appears nowhere in the model. The decisive case is a dry/wet delay effect: the wet path's `@` is intentional, so both branches have artifact latency 0, nothing is inserted, the dry stays dry, and the patch reports 0 - where a naive "total group delay" model would delay the dry branch, destroying the effect, and tell the host a 500 ms echo was 500 ms of plugin latency. Compensation always lands on the reconvergence edge, never hoisted upstream, so a branch with one incoming edge always gets 0 and a plain chain is untouched. Separate graph outputs are equalised against each other as well, which is forced rather than chosen: every format YUP targets reports one scalar, so a per-output latency vector is unrepresentable and a patch whose L is 16 samples later than its R would be permanently skewed in every host with no diagnostic - while a *wanted* skew, written `-> [16] ->`, is never touched. The oversampler's contribution is derived from the same `ydspOversamplerSincRadius` constant the runtime instantiates its `yup::Oversampler` with (16 input-rate samples, linear phase, exactly integral for every factor - which is why an integer delay compensates it perfectly) rather than restated in a comment. `[[ latency: N ]]` is declared in the processor's own sample domain, so a `* 4` instance divides by 4 and a non-dividing factor is a hard error naming both numbers; rounding would ship a sub-sample residual inside the one feature whose job is phase alignment. `YdspAnalyzedEdge::compensationSamples` is kept apart from `delaySamples` so the latter keeps meaning "what the author wrote", the fusion predicate keeps its meaning and the pass stays idempotent - the compiler sums them at one line. `YdspAnalyzedNode::latencySamples` is the sole source of truth afterwards, because `fuseNodeChains` synthesises a processor declaration that would report 0; a fused node's latency is set to the sum of its members'. The pass runs after fusion, so it cannot cost a fusion opportunity. The reported value is a compile-time constant: a YDSP graph is a fixed DAG, so nothing at runtime can reroute it or change an oversampling factor, and a plugin offering an oversampling selector recompiles on the control thread instead - which is also all the formats support, since changing latency needs a restart request rather than a realtime notification. +- YDSP: the `<:` (split) and `:>` (merge) algebra operators are real. Both were tokenised, given their own `YdspOperator`, parsed at one precedence level with `:` and then quietly handed to `composeSequential` - so they behaved as a plain `:` and the documented arity rules were never applied (and were stated backwards: `<:` *widens*, `:>` narrows). `AlgebraValue`'s port lists became **bundles** - one set of terminals per channel rather than one terminal - because "channel 0 goes to two places" and "channel 0 is the sum of two places" cannot be said otherwise, and every wire-emitting site became a cross product over them. All three operators now share one `composeFanned`: `a <: b` requires `a.outArity` to divide `b.inArity` and assigns `b`'s input *j* to `a`'s output *j % a.outArity*; `a :> b` requires `b.inArity` to divide `a.outArity`, sends `a`'s output *i* to `b`'s input *i % b.inArity*, and **sums** the collisions; `:` is the case where the two are equal. The result arity is `(a.inArity, b.outArity)` throughout. Neither operator needs a relay node, and neither does `_`: it carries no ports, so `_ <: (a , b)` and `(a , b) :> _` emit *zero* wires and are handled by regrouping the other operand's bundles, with the fan materialising when the value is later sequenced against a real leaf. An unconstrained `_` defaults to arity 1 on the side the operator governs (left of `<:`, right of `:>` - the maximum fan) and to the known side's arity otherwise. `process = dry <: (Distort , Chorus) :> wet;` is now a parallel dry/wet, and compiles to a graph bit-identical to writing its four edges out by hand. This only became implementable once fan-out and summing fan-in existed: a split *is* a fanned-out edge and a merge *is* a summed one. +- YDSP: a subgraph boundary port can now carry a fan on either side. `inlineSubgraphs` held one `int` per boundary port, written unconditionally, so a second edge onto `sub.in` (or out of `sub.out`) silently replaced the first and one source or destination vanished - with fan-in now analyzable, that would have been silent wrong audio rather than a compile error. Each side is a list, and the splice is the cross product of the sources reaching a producer's boundary and the destinations leaving a consumer's boundary; the nested loop is *required* for a pass-through edge inside a subgraph, which touches both boundaries at once and which the two previous independent `if`s only handled for the 1x1 case. Each resulting edge accumulates the internal edge's own delay plus the parent edge's delay at each end, added per resulting edge rather than hoisted out of the loop. `paramAlias`/`meterAlias` stay single ints: a node parameter still takes one writer. +- YDSP: undersampling (`node x = P / 4`) is a real decimator. It previously only shortened `ctx.numSamples`, so the kernel filled `blockSize / N` samples and the rest of the block kept whatever the previous one left there, while an odd block size dropped the remainder outright - it was briefly rejected outright during this work rather than shipped broken. It now band-limits and decimates the node's inputs, runs the kernel at 1/N, and interpolates its outputs back, reusing the same `yup::Oversampler` windowed-sinc machinery `*N` does (factors 2, 4 and 8; `float32` streams only, like `*N`). Two `Oversampler` instances are needed rather than one, driven from opposite ends: `upsample()` resizes the shared oversampled buffer, so a single instance's direct writes for decimation would fight with it. That in turn needed `downsample()` to stop requiring a preceding `upsample()` - it now derives the oversampled length from its own `numSamples * OversampleFactor` (which the assert it replaced already proved equal) and its capacity checks are against what `prepare()` allocated, so the class is usable in either direction with no new API. `getOversampledChannelData()` is bounded by the allocation for the same reason: a decimate-first caller has to fill that buffer, and would otherwise have nowhere to write. "Is a block pending" remains `getOversampledNumSamples()`'s job, which is the accessor designed for it. Four new `OversamplerTest` cases cover the direction (accessor availability after `prepare()`, standalone anti-aliased decimation, a decimate-then-interpolate round trip in the two-instance shape, and continuity across block boundaries). The block-size problem `*N` does not have - `/N` can only consume whole groups of N samples, so any block size that is not a multiple of N leaves a remainder - is solved with two carry FIFOs whose counts are invariant at `N - 1` between them, which is exactly the surplus needed to fill every block; priming the output side with `N - 1` zeros is what establishes it, and costs `N - 1` samples of latency. Total `/N` latency is `16 * N + (N - 1)` graph-rate samples (the resampler's `2 * SincRadius` is in the *node's* domain, hence the multiplication), all of it artifact latency that the new delay compensation removes automatically - which is exactly what the artifact/intentional rule predicted for a decimator before one existed. Relatedly, `sampleRate` inside a rate-changed kernel now reports the rate that kernel is actually running at rather than the graph's; it was previously always the graph's, so an oversampled processor deriving any coefficient from it (which is the only reason to ask) ran those coefficients N times too slow, and an undersampled one would have run them N times too fast. The new `ControlRateWah.ydsp` demo is built on both: an envelope follower - control-rate work by nature - runs at `/ 8` while the audio path stays full-rate, and the 135-sample skew where the two reconverge is compensated away, which is what stops the filter opening ~2.8 ms after the transient that opened it. +- YDSP: the feedback-cycle diagnostic no longer implies an inline delay would fix it. `rebuildTopoOrder` rejects *every* cycle but said "feedback cycle without a delay", which read as a promise that `-> [1] ->` would break one. It now says plainly that cycles are unsupported in this version and that a delay does not break them. +- YDSP (breaking): four syntax changes. Function parameters are now `name: type` - `func noteToFreq (pitch: float) : float { ... }` - and the old `(float pitch)` form no longer parses. Imports use dotted module paths: `import fx.Delay` maps to the file `fx/Delay.ydsp` and forces access as `Delay.*` (`import X.Y.Z as W` forces `W.*`); importing two different files that would share a namespace is a compile error suggesting an `as` alias, and a circular import is now a hard error (it was a warning). A file that declares only top-level `func`s is a library, imported like anything else and called as `ns.funcName (...)`. Events are now named channels: `input event ;` at processor and graph scope, any name and any number (like streams), each carrying all seven shapes; a handler selects the shape with `event midi (e: noteOn) { ... }` (the old per-shape `input event noteOn;` / `event noteOn (e)` pair is gone). Each graph event input is a separate event stream - a node subscribes to the graph input matching its processor's `input event` name, and `process()` gained a span-based overload (`yup::Span`) plus `getEventInputCount()`/`getEventInputName()` to feed them individually; the single-buffer overload feeds the first input. +- YDSP: `YdspCompiler::compile()` takes an optional `ThreadPool*` (third parameter). When one is passed, imported files are read, lexed and parsed in parallel on that pool; the merge stays single-threaded, the results are identical to the sequential path, and the pool remains caller-owned - the compiler never removes jobs it did not add. + +- YDSP: a graph parameter can now drive more than one node parameter. It was capped at one, which made a wrapper graph unable to do the one thing it exists for - `MasterBus` could not forward a single `mix` to both its compressor and its reverb, and the diagnostic read as an arbitrary limit. The cap is gone from `validateConnectivity` (a *node* parameter still takes at most one writer, which is what keeps the aliasing unambiguous), and `Pimpl::paramSlotToNode` became a list per slot so sample-accurate automation is queued on every node the slot drives instead of only the last one wired - previously a second edge silently overwrote the first's routing, so automating that parameter moved one stage and left the other at its old value. The per-block copy path needed nothing: `node.paramCopies` was already per-node. +- YDSP: graphs can now be composed. A program may declare several `graph` blocks - the entry point is the one annotated `graph Name [[ main ]] { ... }`, or, when the program declares just one graph of its own, that one - and a `node` may instantiate a graph instead of a processor, so a reusable chunk of signal flow (an effects chain, a voice architecture) gets a name and a parameter surface of its own. `import` now merges the imported file's graphs alongside its processors under the same namespace prefix, so `node master = fx.MasterBus;` works across files; an imported graph is always a subgraph, whatever `[[ main ]]` it carries for its own file. A subgraph is *compiled away*: the semantic analyzer splices its nodes and edges into the graph that uses it, so the optimiser, all three backends and the whole runtime keep seeing one flat graph and nothing past analysis changed. Inner nodes take the instance path as a name prefix (`master.compressor.threshold`), a subgraph's `input value` forwards to whatever node parameter it drives (the node's override list sets it; a parameter edge onto the subgraph node aliases straight through), an `output value` forwards outwards the same way, and inline delays on both sides of the boundary add up into one edge. Graphs are inlined innermost-first in dependency order, so nesting is unbounded and a graph that reaches itself is a compile error naming the loop. The three things that are runtime mechanisms rather than wiring are rejected on a subgraph node with an explicit reason: a voice bank (`Sub[16]`) needs one processor with one float32 output stream for the per-voice summing path, over/undersampling (`Sub * 4`) is a per-processor rate change, and graph-scope `input event midi` belongs to the entry point. A subgraph parameter that drives no node inside it warns rather than silently doing nothing. The demo's "Analog Saw" patch uses the new `examples/graphics/data/synths/fx/MasterBus.ydsp`, a graph composing the existing `Compressor` and `Reverb` processors. +- YDSP: a voice bank can now skip idle voices. A processor nominates one `state int` scalar as its activity flag with the new state annotation `state int active [[ role: voiceActivity ]];`, and the runtime skips a voice whose flag reads 0 *and* whose key is not held - no kernel call, no output accumulation - so a `Voice[16]` bank playing a three-note chord pays for three voices instead of sixteen, and an idle one pays for none. The patch declares completion itself (it knows its own delay lines, high-Q filters and release curves) by testing the amplitude it actually emits; the runtime supplies the `held` check, so a patch that clears its flag too early wastes CPU on a held voice rather than going silently mute. The flag is read once per voice per block and re-checked after an event handler runs, so a mid-block note-on wakes the voice for the rest of the block, and a voice is never put back to sleep mid-block. Entirely opt-in: a processor that declares no flag keeps byte-identical behaviour. An unknown `role:` value, a non-`int` or array-typed flag, a second flag, an initialiser, or the annotation on a processor with no event handlers are all compile errors. The shipped `PolySine`, `AnalogSaw`, `FMBell`, `WobbleLead`, `PulseBass` and `ElectricPiano` patches opt in. New `YdspAudioGraph::getActiveVoiceCount ("nodeName")` reports how many voices will run on the next block, sharing the scheduler's predicate exactly. +- YDSP: new kernel-fusion pass. A chain of nodes that only feed each other - `x : Osc : Filter : Gain : y`, or the same wiring as a `connection` block - is compiled into one kernel instead of three, so the two `blockSize` scratch buffers the intermediates round-tripped through become registers. Measured on a new `YdspBenchmarkTests` shape that compares a three-stage chain against a single processor computing the same thing: the chain went from **5.68 to 2.68 ns/sample, a 2.1x speedup**, landing on the hand-fused processor's 2.70 ns. The shape had measured a 2.05x gap before the pass existed - the win had been asserted from first principles and never measured, which is why the item stayed open - so the ratio now sitting at ~1.0 is what the shape guards. The pass works off the analyzed graph, so the `:` algebra and an explicit `connection` block fuse alike, and it runs after subgraph inlining, so a chain assembled out of a subgraph's nodes fuses exactly like a hand-written one. A link is fused only when the producer feeds nothing else and the consumer is fed by nothing else - an intermediate tapped to a second destination is observable and stays a real buffer - the connection carries no inline delay (`[N]`, which only the runtime's delay buffer can provide), and both ends are per-sample single-in/single-out kernels that are not voice banks, rate-changed or event-driven. The fused processor is synthesized as an ordinary `processor` and analyzed through the normal path, so the IR builder, all three codegens and the runtime scheduler see nothing new; every name a member declares is rewritten with a per-member prefix, since a `block` statement does not open a scope in this language and two members' locals would otherwise collide. Host-visible parameter and meter names are preserved - `first.gain` and `first.level` still resolve after the node they belonged to has been fused away - via a new per-node public-name override, a graph parameter aliased onto a member's parameter still drives it, and a member's meter wired to a graph `output value` is rerouted onto the fused node so it keeps reporting. Once absorbed, a member's own kernel is dropped rather than JIT-compiled as unreachable machine code that `getExecutionReport()` would list as something the patch runs; only what fusion orphaned is dropped, so a processor still instantiated on another branch survives. `YdspDiagnostics` gained `mark()`/`rollbackTo()` so that a synthesized processor which fails to analyze is silently declined rather than failing the user's compile against source they never wrote. +- YDSP optimizer: new bounded-loop vectoriser. On the native backends a constant-bound loop over parallel `state float` arrays - an oscillator, partial or modal bank - is widened to four float32 lanes, so a 16-mode loop runs four packed iterations instead of sixteen scalar ones. It is an IR → IR pass rather than codegen widening, so both native targets share it and it is testable without a JIT; a widened value simply carries `lanes > 1` and the existing arithmetic opcodes are reused (`addF` at four lanes *is* `addps` / `fadd v.4s`), with only `vsplat` and `vreduceAddF` added for the two places where the operand and result lane counts differ. Only trip counts that are a whole multiple of four are widened, so there is no scalar epilogue and no block is created, emptied or reordered - the CFG-linear layout both backends recover regions from is preserved by construction. The pass runs last, after loop-invariant code motion has hoisted the loop's shared work (a bank's `exp`-derived drive term) into the preheader, where it stays scalar and is broadcast once. An accumulation (`sum = sum + z[i]`) becomes a vector accumulator folded once on the way out, which is what breaks the serial dependency between iterations - and it reassociates, so a widened *reduction* is not bit-exact while element-wise widening is. A widened body also sinks its loop-invariant scalar prelude into the preheader: `in` read inside a loop lands there as a `loadInput` and stays, because loop-invariant code motion never hoists a load, but in a widened body it provably cannot be invalidated - `storeOutput` disqualifies the loop outright, so nothing in it can write stream memory - and the index is already known to be loop-invariant. The load, everything pure that depends only on it, and the broadcast they feed therefore run once per loop entry instead of once per element. State-array loads are excluded: `z[j]` at a loop-invariant `j` is the same element as the widened `z[i]` store on whichever iteration `i == j`. Measured against the hand-written C++ equivalents in `YdspBenchmarkTests` on AArch64: the 32-partial harmonic bank went from 3.20x slower to **0.82x - faster than the compiled reference** - and the 16-mode modal bank from 7.51x to 2.35x, of which the last 0.65x came from that hoist. Shapes with no qualifying loop - a delay line, a ladder filter, a wave shaper - are unchanged. A loop is left scalar unless every array element is reached through the loop variable itself, the body is a single block, the loop variable is used for nothing but indexing, and nothing in it touches a stream, a scalar `state` slot, a `'` / `@` / `smooth` slot, a transcendental, a comparison or a `select` on a widened value. `YdspKernelReport` gained `vectorized` and `vectorWidth`. The wasm backend has no `0xFD`-prefix opcode family, so that path stays scalar and rejects a widened kernel outright rather than emitting scalar code for packed values. +- YDSP optimizer: new `ifConversion` pass. A short, else-less `if` whose body is a single side-effect-free block becomes straight-line code plus one `select` per assignment, turning a data-dependent branch in a sample loop into a conditional move - a wavefolder taking two such branches per sample was paying an unpredictable branch for each while compiled code emitted `fcsel`. The body is only speculated when every instruction can be executed unconditionally: no memory access (a guarded index may be out of range when the guard is false), no call, and at most eight instructions, so both paths together stay cheaper than a mispredict. The assignments keep their original order, so an instruction that reads a register the body already assigned still sees the conditionally-updated value. No block is added or removed - the condition block absorbs the body and both fall through - so every block index, loop bound and wasm structured region is unchanged. +- YDSP optimizer: `loopInvariantCodeMotion` now hoists into each loop's own preheader instead of only the function entry block, using real dominator sets rather than an "is it in block 0" test. Work that is invariant across an inner loop but varies per sample - a filter coefficient derived from an envelope, say - previously could not be hoisted anywhere: the entry block would freeze it at its pre-first-sample value, and there was nowhere else to put it. A benchmark bank of 16 modes sharing one `exp`-derived drive term was evaluating that `exp` once per mode instead of once per sample. No block is inserted: construction is CFG-linear, so every loop is already preceded by its preheader (the function prologue for the sample loop, the induction initialiser's block for a `for`), which leaves the block layout the wasm backend recovers regions from untouched. The single-definition guard on the hoisted instruction's own result is kept - that is what pins a promoted state register, deliberately written by both the prologue load and the per-sample move, inside the sample loop. +- YDSP optimizer: new `storeToLoadForwarding` pass. A state-array read that follows a write to the same element becomes a move from the value just stored, so `a[i] = x; ... = a[i];` no longer round-trips through memory - in a loop over parallel arrays (the ElectricPiano partial bank reads `oscI[i]` back immediately after writing it) that also removes a store-to-load-forwarding stall from the accumulation chain. The rewrite replaces one definition with an identical value, so it needs no SSA property. It forwards only within a block, and only when the region, the index value and the stored value are all provably unchanged in between; an intervening array store blocks it unless that store writes a *different* region through the *same* index and element width, where the two addresses differ by their region bases alone and so cannot alias for any index. The `@` delay ring is unaffected: it writes and reads through different indices. +- YDSP codegen: a comparison whose only consumer is a `select` now feeds it through the condition flags instead of a 0/1 general-purpose register. `select (a > b, x, y)` was emitting `fcmp`, `cset`, `cmp`, `fcsel` on AArch64 (and `comiss`, `setcc`, `movzx`, `test`, `cmov` on x86-64) where two instructions suffice - and the `cset`/`cmp` round-trip added two links to a dependency chain that, in a per-sample recurrence such as an envelope follower, is the critical path. The comparison is re-emitted next to the select rather than moved, so nothing can clobber the flags in between; the fusion is skipped unless the comparison is used only by that select, both are in the same block, and neither of the comparison's operands is rewritten between them. Candidates are identified by instruction position rather than by result value id, so a select written by if-conversion - whose result is a mutable local, and therefore defined more than once - fuses like any other. +- YDSP codegen (AArch64): state-array accesses no longer rebuild their region's base address on every access. AArch64 has no base + index + offset addressing mode, so reaching `array[i]` means materialising `stateArrays + regionBase` in a register first - which was emitted inline at *each* access as a `mov` plus an `add` (two extra instructions and two extra virtual registers), even though the region base is a compile-time constant. One register per distinct region is now computed once in the prologue. In a loop over parallel state arrays this was the bulk of the emitted code: the benchmark's 32-partial harmonic bank performs eight array accesses per partial, so it was emitting roughly twenty-four instructions per partial where eight are needed. x86-64 folds the same address into one addressing mode and is unaffected. +- YDSP codegen: the generated sample loop no longer pays a function call, a redundant memory round-trip or an unpredictable branch per sample. The `@` delay wrap lowers to a new `wrapI` opcode (compare + `cmov`/`csel`/`select`) instead of `modI`, which on x86-64 was a call into a helper for *every* delay tap on *every* sample; scalar `state` and the hidden `'` / `@` / `smooth` slots are loaded once in the kernel prologue and written back once in the epilogue instead of round-tripping through memory every sample; stream channel pointers are hoisted out of the loop rather than re-derived at every access; float constants are read from AsmJit's constant pool (and `negF` / `absF` from a sign mask there) instead of being materialised through a general-purpose register, which also frees the registers LICM used to pin across the whole kernel; `select` is branchless on both targets (`fcsel`/`csel` on AArch64, `cmov` plus an and/andn/or blend on x86-64); AArch64 comparisons use `fcmp`/`cmp` + `cset` instead of a five-instruction branch diamond; and a terminator whose target is the next block falls through instead of emitting a jump. `-0.0` is the only observable behaviour change: `negF` now flips the sign bit, so `-(+0.0)` is `-0.0` on x86-64 as it already was on AArch64. +- YDSP tests: new `YdspBenchmarkTests` time six discriminating patch shapes (`@` delay taps, a four-pole ladder, a 32-partial harmonic bank, compare + `select`, a modal bank with work invariant across its inner loop, and a wavefolder with data-dependent `if`/`else`) against hand-written C++ equivalents and print a ns/sample ratio for each, with a loose regression guard rather than a threshold assert. Two further shapes compare the JIT against *itself* to size a specific opportunity rather than to defend a number: the modal bank with and without its accumulation, whose delta is what a widened reduction costs (four serially dependent vector adds plus the horizontal fold), and three chained sample-mode nodes against one processor computing the same thing, whose gap is the headroom a kernel-fusion pass could recover - the win fusion was assumed to have, never measured. The reduction shape has since produced a more useful result than the one it was built for: removing the accumulation makes the kernel reproducibly *slower* (14.4 vs 20.8 ns/sample, eight fewer instructions, both loops confirmed widened), and a kernel whose runtime moves inversely with its instruction count is not issue-bound - which rules out loop overhead, and with it a full-unroll pass, as the modal bank's remaining gap. It is also the only shape in the suite with a ~30% run-to-run spread rather than under 3%, and the only one calling a transcendental inside the sample loop, so the shape now prints stack-traffic and call counts from the generated listing alongside the instruction count. Separately, the wave folder shape now also times a reference compiled with floating-point contraction disabled: `last = last * 0.5f + y * 0.5f` is one `fmadd` on AArch64 by default, which is one rounding where the source asks for two - a precision liberty YDSP does not take, because a patch must produce identical samples on every backend - so the ordinary `c++` row is not running the arithmetic the JIT runs, and on a shape whose critical path is exactly one multiply-add per sample that is most of the ratio: 2.26x against the ordinary reference, 1.29x against one that also cannot fuse. The two references produce bit-identical output, because `* 0.5f` is exact and fusing its rounding changes nothing numerically - which is the check that the pragma took effect rather than being ignored. +- YDSP optimizer: new full-unroll pass for constant-trip-count loops. A `for` loop whose trip count is known at compile time is written out in full into its preheader, which deletes the bound compare and the back edge from every iteration. It runs after the vectoriser and so unrolls at the *widened* trip count - a 16-mode bank at four lanes becomes four copies of a four-lane body, not sixteen of a scalar one - and running it before would have left nothing loop-shaped to widen. No block is added, removed or reordered: the header and body are emptied and left falling through, the same trick `ifConversion` uses, so every block index, every loop bound and the region layout the wasm backend recovers are untouched; the loop's entry in `fn.loops` survives so the report still answers "how many iterations could this run" with the worst case rather than 1, marked `unrolled` so no backend wraps a region around blocks that no longer branch. The body is copied verbatim, increment and all, rather than substituting a constant index per copy: that makes the unrolled code the identical instruction sequence the loop executed, so it is bit-exact by construction and needs no reasoning about a non-SSA IR. It declines a one-shot init kernel, a nested or runtime-bound loop, and any loop whose copies would exceed 256 instructions or 32 trips (256 admits a 32-partial bank at ~190 instructions, but that shape then moved by ~2%, inside its own spread, so the step up from 128 is unproven), and like the vectoriser it is off for wasm - a module is downloaded and parsed before it runs, and the browser's own engine re-optimises the loop regardless, so trading module size for branches is the wrong way round there. This also surfaced a latent trap in the AArch64 backend: `vectorIndexRegs` memoizes `index << 2` keyed by value id and cleared per *block*, which is only sound while a value id is written at most once per block - an unrolled loop writes its induction variable once per copy, and the stale scaled register would have addressed the wrong element with no diagnostic. A new `onValueRedefined` hook invalidates the memo at every write. Measured on the modal bank, whose 16-mode loop widens to four lanes and unrolls to four copies: **2.17x slower than the C++ reference down to 1.21x** (14.3 to 8.3 ns/sample). The larger result is what it did to the *variance* - that shape had been the only one in the suite swinging ~30% run to run, wide enough that it had reported a kernel with eight fewer instructions as the slower one and sent two investigations (spill traffic around the per-sample `exp` call, then the kernel not being issue-bound) after explanations that were not there. Unrolled, the spread is under 3%, and the shape finally produces the number it was written for: the widened reduction costs ~1.9 ns/sample, about a fifth of the kernel. `YdspKernelReport` gained `unrolled`, since `boundedIterationCount` deliberately reports the same worst case either way and is therefore no help in telling the two forms apart. +- YDSP optimizer: an unrolled bank's widened accumulator is now turned into a reduction tree. Unrolling leaves `acc = acc + x` once per copy and each add waits on the one before it; sending the odd copies to a second accumulator and adding the two at the end halves that serial depth for the cost of one instruction (the first odd link becomes a move rather than an add, since the second accumulator has no zero to start from, so the add count is unchanged and the combine puts one back). Halving can repeat: the scan resumes just inside the chain it rewrote and picks up the suffix still on the accumulator, so an eight-link chain halves twice and ends at depth four. That is short of what restarting the scan would reach, which is untried. It applies only to a **widened** accumulator: the vectoriser has already re-associated that sum - lane j sums elements j, j+4, j+8 … and the lanes are folded pairwise - and that is documented as not bit-exact, so this stays inside a licence the language already takes, while a scalar accumulator carries none and is left exactly as written. Measured on `ModalBankReductionCost`, which times the same bank with and without its accumulation: the reduction went from **1.93 ns/sample unsplit to 0.98 and 1.53 across two split runs**, and the modal bank overall from ~1.24x to **1.11-1.20x** of its C++ reference. Ranges rather than figures: the direction is unambiguous and the size is not, which is also why the switch exists. That was not the expected result - the reduction looked closer to throughput-bound than latency-bound (six extra instructions, ~6.4 cycles), which is why `setReductionSplittingEnabled` is a switch separate from the unroller it depends on. The repeated halving was not designed either: it was found by reading an instruction count that had grown by two combines rather than one, and kept because the largest bank in the benchmark posted its best figure with it. `YdspKernelReport` gained `reductionSplit`, so a patch can be asked whether its reduction was shortened without inferring it from a timing. + +### Fixed + +- GUI: `Component::hitTest()` is consulted again when routing mouse events. `Component::findComponentAtForMouseEvent()`, which replaced the windowing layer's own traversal, tested only visibility and bounds, so a component that carved out part of its rectangle - a round knob keeping its corners transparent, a control with a padded margin - received moves, enters, exits, clicks and wheel events over the area it rejects, and whatever sits behind it received none. + +- Thirdparty (`sleef_library`): the SIMD translation units reference `Sleef_x86CpuID` from their exported dispatch queries (`Sleef_getIntd2`/`Sleef_getIntf4`, the `DORENAME`-renamed `xgetInt`/`xgetIntf`), but the definition in SLEEF's `src/common/common.c` was never compiled into the module, so an x86-64 build failed to link (`LNK2019` for `Sleef_x86CpuID` in `sleef_library_simddp.obj`/`sleef_library_simdsp.obj` on the Windows CI; ARM targets were unaffected because their helper header has no CPUID probe). The module now compiles `upstream/src/common/common.c` as its own translation unit (`sleef_library_common.c`), as SLEEF's own build does. + +- YDSP: the four-lane x86 float comparison was miscompiled. `vectorFloatCompare` emitted legacy `cmpps` with three operands and five-bit AVX predicates, but `CMPPS` is two-operand (`dst = cmp (dst, src)`) and decodes only three bits - so the second source was dropped and the destination was read without being seeded. The path now seeds its destination and uses the legacy predicates, emitting `b < a` for `a > b` since the three-bit set has no greater-than form. Reachable on a pre-AVX2 host or under `targetPolicy = baseline` with `baselineTarget = sse2`; no asmjit validation is enabled in this module, so nothing rejected it at `finalize()`. + +- YDSP: `INT_MIN / -1` now yields 0 on AArch64, as it already did on x86-64 and wasm. The pair has no representable quotient; the x86 helpers had always guarded it, the wasm lowering guards it explicitly (`div_s` would otherwise trap), and the AArch64 lowering - whose comment claimed parity with x86 - guarded only the zero divisor and let `SDIV` wrap the quotient to `INT_MIN`. The same patch therefore produced a different value on Apple silicon than on an x86 host. `CMN b, #1` gates the `INT_MIN` compare so the common path pays one extra compare, on an operation the `@` delay wrap no longer uses at all. + +- YDSP: integer division and modulo on x86-64 no longer make an out-of-line helper call per occurrence. `emitIntDivision` now inlines `cdq`/`cqo` plus `idiv` behind the same zero-divisor guard the AArch64 lowering uses, so both targets keep returning 0 for a zero divisor, plus a guard for `INT_MIN / -1` - the one pair with no representable quotient, which raises `#DE` on x86 rather than wrapping. The orphaned `divInvoke` member and the four `yupDspIdiv`/`yupDspImod` helpers go with it. + + +- YDSP optimizer: `splitWidenedReductionChains` now snapshots each link's addend to a fresh value at the link itself before building the balanced tree. Loop unrolling duplicates a body verbatim, so one value id is redefined once per copy; the tree is emitted after the whole chain, so every leaf previously resolved to the *last* copy's definition and was summed repeatedly - the ElectricPiano harmonic bank summed only its final 4-lane product, making the patch sound as a thin ~0.1 s high-frequency click under the automatic tier (scalar and baseline tiers were correct, which is why only the widened demo patch was affected). The new `YdspElectricPianoTests::SustainsPastTheAttackClick` and the `YdspExamplePatchTests` ElectricPiano sustain tests are the regression guard. +- YDSP analysis: each top-level body (`process`, `init`) is now its own local scope, so a same-named local in both no longer reports a spurious duplicate-symbol error; a node written `P * 0` / `P / 0` is rejected with a diagnostic instead of reaching the latency math with a zero rate factor. +- YDSP optimizer: `if (c) { y = a; } else { y = b; }` where each arm moves into the same value is now fused into one `select` (the wavefolder / clipper shape), removing the branch. `pow (x, 2.0)` becomes `x * x` under fast math (strict keeps the intrinsic, since libm pow is not bit-equal to a multiply). Graph meter reads use compile-time byte offsets instead of re-scanning the meter type list per getter. A spelled `fma()`/`fmsub()` no longer disqualifies an otherwise widenable loop on targets with a packed fused multiply-add (x86 AVX2+FMA and AArch64), where the widened op lowers to `vfmadd213ps`/`fmla`. + +- YDSP runtime: output events carried across a block boundary are stored relative to the *next* block's start and delivered directly, so an event that straddles into a differently-sized block (64 then 32 samples) still lands at its exact sample instead of being unwound against the wrong block size. The host-side automation and MIDI sample offsets are also clamped to `[0, blockSize - 1]` rather than the inclusive `blockSize`. +- YDSP runtime: a polyphonic node's shared per-voice scratch is cleared for each awake voice before it renders (previously once per block), so a voice whose kernel reads its own output (`out = out + 1.0`) or writes it conditionally no longer sees the previous voice's samples - two held feedback notes now sum to exactly 2.0 per sample instead of leaking 3.0. +- YDSP runtime: `process()` no longer silently truncates a request larger than the prepared block size; it returns the new `YdspProcessResult::blockTooLarge` and leaves the graph untouched. `reset()` and `prepare()` now clear the dropped-event counters (the host-visible `droppedEventCount` and every node's output-event queue count), which previously grew monotonically for the life of the graph. +- YDSP runtime: `accumulateStream` refuses (rather than reinterpreting as float32) when a non-float stream ever reaches the summing fan-in path; the analyzer already rejects such graphs. +- YDSP wasm backend: integer `div`/`mod` now also guards `INT_MIN / -1` (wasm `div_s`/`rem_s` traps on it, like a zero divisor), mirroring the x86/AArch64 helpers. The emscripten kernel registrar checks its registry *before* compiling, so `prewarmKernels()` no longer recompiles an already-registered module. +- YDSP runtime: `setParameter`/`setDoubleParameter`/`setIntParameter` no longer memcpy straight into the params block the audio thread reads; they push (slot, raw bits) into a fixed control-to-audio ring that `process()` drains once at the top of the block, so host parameter writes are race-free against the audio thread and land at the start of the next block (an overfull ring counts as a dropped event). The typed getters drain the ring first, so a set is immediately visible to a control-thread get. +- YDSP codegen: the operand-register diagnostic skips scalar-load slot operands (operand 0 of `loadParam`/`loadParamOut`/`loadStateF`/`loadStateI` holds a state/param slot, not a value id), so state-heavy kernels no longer false-positive as having an undefined value. + +- YDSP optimizer: `foldStateWriteBacks` no longer folds a scalar state write-back whose produced value is read outside the move's own block - a function parameter reassigned inside an `if` keeps being read by the join block after the call, and folding the parameter copy renamed its producer and left that read with no definition at all, which asmjit rendered as `?255` on AArch64 (the ten `YdspExamplePatchTests` that failed that way are the end-to-end guard). The fold also stops treating a scalar load's slot index as a value id, requires matching value shapes, and applies overlapping write-backs into the same state register one at a time instead of as a batch against a stale snapshot. Dead-code elimination and the contraction pass now share one filtered value-use scan (`forEachValueUse`), so slot indices can no longer be mistaken for value uses anywhere. +- YDSP optimizer: confirmed miscompiles fixed. Loop fusion retargets the fused loop onto the exit block its own loop record names (it used to aim the header one block *before* the loop, at the preheader), only drops a second loop's induction init when it really starts at the literal 0 (a `3..8` second loop no longer silently restarts at 0), and treats event emission and param/paramOut/event-field accesses as memory - two event-emitting loops no longer fuse. Constant folding no longer materialises a fold its own guard declined (`x / 0`, `x % 0`, `x << 64` stay unfolded instead of becoming a compile-time 0), refuses to fold a non-finite or out-of-range `ftoi`, and rounds folded float32 values to float32 so folded and unfolded kernels agree. Strict math no longer applies the `x * 0.0 -> 0.0` / `x + 0.0 -> x` identities (both are wrong for signed zero, NaN and infinity). Copy propagation no longer retargets a block's terminator condition past a redefinition of the moved register. A widened `fmaF` is refused by the scalar lowering instead of being scalarised per lane, a widened-reduction link must be single-use to be deleted, and dead event-field loads are now removed by dead-code elimination. +- YDSP native codegen: the temporary `YUP_YDSP_RA_DEBUG` environment switch was removed (RA annotation is compiled out), a scalar-transcendental table miss now asserts instead of silently leaving the result register undefined, and the post-register-allocation redundant-move cleanup also removes identical `fmov`/ASIMD `mov` copies. + +- YDSP codegen (x86-64): integer variable-count shifts wrote the fixed physical `cl` register directly (`mov cl, srcB.r8()`). AsmJit's register allocator is blind to physical operands - it only models virtual ones - so it could keep another live value in RCX and the `mov cl` silently destroyed its low byte (most often a LICM-hoisted shift-count constant), corrupting the LFSR generator's `w >> k` / `<< 31` chain. The count is now passed as a virtual register and the allocator pins it into CL with full liveness knowledge. +- YDSP codegen (x86-64): the shared `clampI` lowering selected the max into the result register and then ran the min with that same register as its own `whenTrue` operand. The x86-64 select lowering writes `dst` before reading `whenTrue` (mov + cmov), so the second stage degenerated to a no-op and `clamp (n, -10, 10)` always returned the upper bound - `min/max/clamp/abs/sign` on an int returned `6` where `-14` was expected for negative `n`. The max stage now selects into a temporary, keeping the two select operands distinct on both backends. +- Emscripten: `PopupMenu`'s click-outside-to-dismiss and hover-forwarding (`Desktop::handleGlobalMouseDown/Up/Move`, fed from an SDL event watch in `yup_Initialisation_sdl.cpp`) built their `MouseEvent` from `SDL_GetGlobalMouseState()`, which reports real page-relative pixels on this platform. `Component::localToScreen()` instead anchors on `SDLComponentNative::getPosition()`, which returns `(0,0)` on Emscripten by design (there's no real window position to query, see the `isMouseOutsideWindow` fix below). The two frames only agreed when the canvas happened to sit near the page origin, so selecting a `ComboBox`/`PopupMenu` item would misfire "click is outside the popup" and dismiss it before the selection could register - reproducible on both HiDPI and standard displays, and sensitive to anything that shifts the canvas's on-page position (such as opening devtools). The global dispatcher now sources its position the same way regular per-window dispatch does on Emscripten, keeping desktop behavior (which needs true desktop-global coordinates) unchanged; mobile is left untouched pending separate verification. +- Emscripten: `isMouseOutsideWindow()` compared `SDL_GetGlobalMouseState()` (real page-relative coordinates) against `SDL_GetWindowPosition()`, which is meaningless on this platform since `SetWindowPosition` is unimplemented in the SDL3 Emscripten backend and returns a value centered against a synthetic virtual display. This misclassified ordinary clicks inside the canvas as "outside the window" on every mouse-up, which called `handleFocusChanged (false)` and stopped SDL text input - silently dropping every typed character while raw key events (Tab, Backspace, Enter, and anything reading `KeyPress` directly, like `MidiKeyboardComponent`) kept working, the exact symptom reported against the new `CodeEditor`. The check is now skipped on Emscripten, where "mouse outside the window" isn't a coherent concept for a single always-present canvas. +- YDSP codegen (AArch64): the register allocator was free to park a long-lived value in **x30 (LR)**, which any call then destroyed. asmjit's AArch64 tables disagree with AAPCS64 about that register: `a64func.cpp` lists x30 among the *callee-preserved* GP registers, so the allocator believes a value there survives a call, while `a64rapass.cpp` only makes SP and FP unavailable - leaving x30 allocatable. `blr` overwrites LR with the return address, so any kernel that both calls out (a transcendental, or the `exp` behind `smooth`) *and* has enough simultaneously-live values to reach x30 had that value silently destroyed mid-body. The symptom depended entirely on what the lost value was doing: a state-array region base meant every later access through it addressed wild memory, which is how this was found - a fused kernel with four reverb-sized `@` rings and a smoothed parameter faulting on `str s26, [x30, x15, lsl #2]` - but a promoted `state` scalar in x30 faults on nothing and simply makes the patch compute the wrong numbers, so this was a latent wrong-audio bug of unknown age rather than only a crash. x30 is now added to the function frame's unavailable set, which costs one of ~29 allocatable GP registers and is what a JIT should do with LR regardless; the fix sits on our side of the boundary so it survives an asmjit update. It needed three ingredients at once (a call, high register pressure, and a state array large enough for the corruption to land outside the allocation), which is why every existing patch and test missed it: four new `YdspFusionTests` shapes bisect exactly those ingredients, and the crashing combination is now a regression test. +- YDSP runtime: three routing defects that only the old exactly-once rule kept unreachable, all fixed by the same new representation (a node's input wiring and the graph's output wiring are both CSR-flattened lists of connections, each owning its own delay ring). **(a)** `outputSlotBuffer[s]` was last-edge-wins, so a node output feeding both a graph output and a node input clobbered one marker against the other - and the two edge orderings failed *differently*. **(b)** `outputSlotGraphOut[s]` was a single `int`, so one output could not drive two graph outputs at all: the second silently replaced the first. **(c)** `edge.delaySamples` was read only on the node-input branch, so `a.out -> [3] -> y` was parsed, accepted and then silently dropped. Owning the delay ring per connection rather than per input slot is what makes `x -> a.in; x -> [4] -> a.in;` a comb filter instead of representable-only-once. +- YDSP runtime: a `connection { x -> y; }` passthrough edge (graph input straight to graph output, whether hand-written or produced by inlining a pass-through subgraph) hit neither branch of the wiring producer, so it compiled and then output silence. It is now an ordinary graph-output source with no producing node. +- YDSP: oversampling (`node x = P * 4`) on a non-`float32` stream was silent memory corruption. `rateMultiplier` was validated only for the event-driven and subgraph cases - there was no type guard anywhere - and the runtime handed the node's buffers to `Oversampler`, so a `float64` stream was read and written at half stride, past the end of the buffer. It is now a compile error naming the offending stream and its type. Inline delays already carried the equivalent guard. +- YDSP: two out-of-bounds reads in the graph algebra's sequential composition. A graph input or output identifier used as a leaf gets arity `(1, 1)` but carries a port on one side only, so `dry : wet` produces a value claiming arity `(1, 1)` with no ports at all - the arity check then passes for `dry : wet : Gain` and the wire loop indexed `outPorts[0]` on an empty vector. Its mirror image, `Gain : dry`, indexed `inPorts[0]`. Separately, `_` inside `,` contributes no ports while still adding to the arity, so `(_ , Gain)` - and `(Gain , _)` - claimed arity 2 with one port, and whatever was sequenced against it read `inPorts[1]`. The composition now verifies that a declared arity is backed by actual ports and reports which side has nothing to connect, and mixing `_` with a port-carrying operand inside `,` is rejected with a message pointing at the `connection` form. `(_ , _)` still composes as it always did: it stays an identity. +- YDSP runtime: `prepare()` appended to each node's inline-delay buffer list instead of rebuilding it, while the per-block path indexes that list 1:1 with the input slots. A second `prepare()` therefore left every slot pointing at the *first* call's ring, whose one-block scratch is sized for the old block size - so preparing at a larger block size wrote past the end of it. The list is now assigned to the input-slot count and filled by index, which removes the mismatch by construction. `reset()` also left the rings holding audio (the oversamplers beside them were already reset), so the pre-reset tail kept playing out for `delaySamples` more samples; they are cleared now. Both were uncovered because every existing test prepares exactly once. Relatedly, `prepare()` ran the one-shot init kernels before rebuilding the scratch arena and the node pointer tables; the init kernels are handed null stream pointers so nothing observable changed, but they now run last. +- YDSP optimizer: `algebraicSimplification` treated any constant writing a register as that register's value, ignoring that the IR is not SSA and the register may be written again before the use being simplified. `process { float t = 0.0; t = in * 2.0; out = t + 1.0; }` folded `t + 1.0` against `t`'s *declaration* literal and emitted `out = 1.0`, dropping the input entirely - silent wrong audio, no diagnostic. Only a register defined exactly once is eligible now, which is the guard `constantFolding` already documents and depends on. The bug needed the reassignment and the later use to share a basic block, which is why the common `float sum = 0.0;` followed by an accumulating loop was never affected (a loop body is a separate block) - the kernel-fusion pass, whose junction locals are declared, assigned and read in one block, is what exposed it. +- YDSP runtime: a connection with an inline delay (`x -> [3] -> node.in`) wrote the delayed samples back over its *source* buffer. For an edge fed by a graph input that is the caller's own array, which `process()` declares as `Span` and the runtime was `const_cast`-ing away: a host that reuses or shares an input buffer found it silently rewritten after every block, and a genuinely read-only buffer was undefined behaviour. The delayed samples now go into a per-edge scratch buffer allocated in `prepare()` and the node's input pointer is repointed at it, so the source is untouched; the sub-block dispatch paths re-derive from that pointer and needed no change. The feature had no test anywhere in the suite, which is why this survived - it now has three, including one asserting the input buffer is byte-identical after `process()`. The delay ring is float32-only (as oversampling already was), so an inline delay on a non-float32 stream is now a compile error instead of reading the buffer at the wrong element width. +- YDSP codegen (x86-64): the backend did not compile at all. It told a float32 register apart from a float64 one with `reg.typeId()`, which the vendored AsmJit has no such member for - the API is snake_case throughout (`is_vec128`, `new_gp32`, `virt_reg_by_reg`) - so all thirteen call sites were hard errors, hidden on AArch64 hosts by the `#if ASMJIT_ARCH_X86` guard around the file. The width was never available on the operand in the first place: unlike AArch64, x86 has no distinct register class per float width, so a f32 scalar, a f64 scalar and a packed f32 vector are all a 128-bit Xmm. It is read from the register's `VirtReg` instead, as the `TypeId` it was created with. +- YDSP runtime: mid-block parameter automation on a **polyphonic** node reached voices 1..N-1 an entire sub-block early. A node's param block is shared by all its voices, but `applyAutomation` runs inside the per-voice loop and nothing rewound the block between voices, so voice 0 saw the correct pre/post values while every later voice entered the block with the param already holding voice 0's post-automation value - and therefore read the *new* value throughout its *pre*-automation sub-blocks. A `Voice[4]` bank with two notes held and a gain automated from 0.5 to 1.0 at sample 100 produced 1.5 before the offset instead of 1.0. The block-start value of every automated param is now snapshotted before the voice loop and restored at the top of each subsequent voice, so all voices replay the same timeline. Monophonic and single-voice nodes run the loop once and are untouched. +- YDSP optimizer: a loop nested inside another loop overwrote the outer loop's recorded `YdspLoopBound`. The bound is resolved before the body is lowered but only stored on the `YdspIrLoop` afterwards, and it was held in a builder member in between, so both loops ended up reporting the inner bound and `YdspKernelReport::boundedIterationCount` under-reported the worst-case iteration count. The bound is now captured into a local at the point it is resolved. +- YDSP runtime: a node's all-sound-off handling (MIDI CC120) kept a single sample offset per node per block, so two CC120s on different event inputs clobbered each other. The earlier (more urgent) silence point was lost, and a note re-triggered between two CC120s escaped the later one. Every CC120's offset is now recorded (fixed per-block capacity, reserved in `prepare()`) and applied as its own sub-block split point. Voice lookup is also scoped by event input now: each event input has its own MPEInstrument, so the same MIDI channel+note collides across inputs, and a note-off on one input could previously release a voice (poly) or remove a held note (mono) belonging to the other input. +- YDSP compiler: syntax errors in an imported file were swallowed. The parser recovers from a syntax error via `synchronize()`, so the merged program was non-null and the import merged anyway, dropping the diagnostic; the merge now treats any recorded error like the top-level path does. The import rename pass now also covers processor-local function bodies, graph node parameter overrides and graph-leaf override expressions (plain-name library calls in those positions previously dangled after merging). The static recursion check now walks if/loop bodies and calls nested anywhere in an expression, so a recursive library function hidden in a conditional is rejected before inlining. +- YDSP compiler: a diamond import silently dropped the second alias's namespace. The merge deduplicated by file path globally, so when two imported files both imported the same third file, only the first importer's namespace was populated - the second importer's references to its own alias of that file failed to resolve (and a same-file-different-alias import in one file lost the second alias too). The merge now deduplicates per (importing file, file, namespace) and runs each merge on a private copy of the parsed file, so every importer gets the shared declarations under its own namespace. +- YDSP compiler: the intrinsic name list was hardcoded twice - a mirror of the semantic analyzer's table lived in the compiler's import rename pass, so the two could drift (and already had, once). The rename pass now queries the analyzer's table through a shared `isIntrinsicName()`. The function shadowing rule (a processor-local function beats a program-level one of the same name) was likewise duplicated between the semantic analyzer and the IR builder; both now resolve through one shared `findFunctionInScope()`. The module's runtime-initialised static lookup tables (intrinsics, builtin constants, IR op dispatch, annotation keys) are now `constexpr` arrays with no dynamic initialisation. +- yup_core: new `CountDownLatch` synchronization primitive (addCount/countDown/wait), used by the parallel import parser to replace its hand-rolled mutex/condition-variable counting; the threaded import path now also has test coverage for diamond imports and failure cases. + +- YDSP runtime: sample-accurate sub-block dispatch advanced each sub-block's stream pointers from the *previous* sub-block's pointers while passing an absolute offset, so the offsets accumulated: any node split into three or more sub-blocks in one block (two or more distinct event/automation offsets) wrote its output - and read its input - at `sum(offsets)` instead of `offset`, corrupting samples past the end of the buffer in the worst case. Polyphonic *outputs* were immune (they re-derive from the per-voice scratch each call), which is why the existing voice-bank tests never caught it. Each sub-block now re-derives its pointers from the block's base. +- `yup_gui`: mouse-event hit-testing now respects `setWantsMouseEvents()` across siblings, not just up the parent chain. New `Component::findComponentAtForMouseEvent()` walks the hierarchy the same way `findComponentAt()` does, but a component (or its whole subtree) that opted out of mouse events is skipped in favor of the next sibling underneath it, instead of only bubbling up to a parent; `findComponentAt()` itself is unchanged (still a pure, mouse-event-agnostic geometric hit-test). Previously an overlapping sibling that didn't want mouse events (e.g. a decorative `Label` drawn on top of a `Slider`) would still win hit-testing and swallow clicks meant for the sibling beneath it. `SDLComponentNative::findComponentForMouseEvent()` now delegates to `findComponentAtForMouseEvent()` instead of duplicating the ancestor-walk itself. +- `yup_core`: `Thread::getCurrentThreadId()` is now TLS-based on wasm instead of returning the raw `pthread_self()` value, which is unreliable on the emscripten audio-worklet thread (a Wasm Worker, not a pthread) - thread-identity-based locks and checks (`RecursiveSpinLock` owners, `ReadWriteLock` writers, `MessageManager` thread checks) misbehaved there. Every thread (main thread, audio worklet, yup::Threads) gets a stable TLS id seeded by an atomic counter; yup::Threads sync their `threadId` to it at creation so `getThreadId()` keeps matching, while `threadHandle` stays the native pthread handle. The implementation must not consult `getCurrentThread()` - `ThreadLocalValue` uses `getCurrentThreadId()` to find its per-thread slot, which would recurse. +- `yup_audio_basics`: `AudioLockType` (used by the `MidiKeyboardState`, `Synthesiser` and `BufferingAudioSource` locks) is a `RecursiveSpinLock` on wasm (never blocks - no `futex_wait` - and re-entrant, which `Synthesiser` requires when processing MIDI) and the re-entrant `CriticalSection` on desktop. `MidiKeyboardState::allNotesOff` additionally no longer re-enters the lock (`allNotesOff`/`noteOff` delegate to non-recursive `*Locked` helpers), so the state is safe even with a non-recursive lock. +- `yup_audio_devices`: the locks taken on the audio thread - `AudioDeviceManager::audioCallbackLock`/`midiCallbackLock` (taken in every `audioDeviceIOCallbackInt` block), `MidiMessageCollector::midiCallbackLock`, `AudioSourcePlayer::readLock` and `AudioTransportSource::callbackLock` - now use `AudioLockType` instead of a raw `CriticalSection`, so on wasm a contended lock spins on the audio-worklet thread instead of blocking in a fatal `futex_wait`. `getAudioCallbackLock()`/`getMidiCallbackLock()` now return `AudioLockType&`. +- YDSP wasm codegen: stream loads/stores (`loadInput`/`loadOutput`/`storeOutput`) treated the channel-pointer array (`inputs`/`outputs`) as the sample buffer - the channel pointer `inputs[ch]` was never dereferenced, so a kernel indexed `&inputs[0] + i*4` and wrote sample data over the pointer array and into the surrounding heap. Every generated kernel that touched a stream corrupted the heap (crashes surfaced in the browser's audio worklet and in node tests). The channel pointer is now loaded first, then indexed by the sample. +- `examples/graphics`: the "Pulse Bass" patch is now the demo's monophonic example - `node voices = PulseVoice [[ mode: mono, priority: last ]]` - and is the first patch to read `e.isLegato`: a fresh key press re-attacks at the new pitch immediately, while a legato continuation leaves the envelope running and glides the pitch over a new `Glide` parameter. The re-attack ramps from wherever the envelope currently is rather than resetting it to zero - with a single voice, resetting would chop a still-sounding release tail off mid-level and click, and ramping is how a mono synth's single-trigger envelope behaves anyway. Previously it was a 4-voice bank whose `.isLegato` flag nothing consumed, so the mono/legato path shipped untested by any example. +- `examples/graphics`: the "YDSP Synths" patches clicked on every note. `Poly Sine`, `Analog Saw`, `FM Bell` and `Wobble Lead` all stepped their envelope straight to the note velocity in `noteOn` while the oscillator kept its previous phase, so the output jumped discontinuously at each attack (and at each voice steal). They now run a ~3 ms one-pole on the *gain* - the coefficient is a `state float ... = 1.0 - exp (-samplePeriod / 0.003)` initialiser, so it is computed once per voice in the init kernel rather than per sample. `Pulse Bass` already had a one-pole attack and is unchanged. +- `examples/graphics`: the "Electric Piano" patch was ~24 dB quieter than the other synths (a `* 0.125` output trim on top of partial weights that already sum to ~1); it now matches their level. +- `examples/graphics`: the "YDSP Synths" demo only played patches with exactly one output stream - any other count fell into the silence branch, so a stereo patch compiled and ran but was never invoked at all (`process()` also rejects a buffer span whose size does not match the declared stream count). It now accepts mono and stereo patches: a mono graph is fanned out to every device channel as before, a stereo graph maps its two output streams to alternating channels, and the oscilloscope shows the mean of the two. +- YDSP optimizer: a `for` loop nested inside an `if` hung the generated kernel. The if-lowering allocated its join block (and, with an `else`, the else block) *before* lowering the then region, so any block the region allocated landed past the join: the loop's preheader fell through into the join instead of into the loop header, and the join then fell through into that header - whose induction variable had never been initialised - so the loop ran forever. Both the join and the else block are now allocated after the regions they follow, keeping each region's blocks contiguous and the join last, which is the layout both the fallthrough-based asmjit codegen and the wasm backend's structured-region recovery require. The `else if` fix below only covered the else-carrying case. +- YDSP optimizer: `if / else if` chains produced a non-linear block order - the outer join block was allocated before the inner if's blocks, so the outer join fell through into the inner then-block and the else-tail branch looped back into a cycle (infinite loop on the asmjit backend; "unexpected conditional branch" compile error on the wasm backend, which surfaced it first via the AnalogSaw patch's `polyBlep`). The join is now allocated after the else region, so nested `else if` blocks stay between the else block and the join, restoring the CFG-linear order both codegens rely on. +- YDSP optimizer: `loopInvariantCodeMotion` treated loop-carried registers as invariant - the induction register is written both before the loop (sample mode: prologue `constI 0`; block mode: preheader `movI`) and inside the loop body, so its `addI`/`movI` update was hoisted out of the loop, freezing the counter and hanging the generated kernel (the sample-mode case slipped through even after the preheader exclusion because the prologue `constI 0` is a constant op that re-seeded it). Only single-assignment values defined in the entry block (or constants) can now be invariant, registers redefined inside the loop (induction variables, path-dependent if/else values) are never hoisted, and loads from input/output streams, params and meters are not hoisted either (the loop body can overwrite that memory). +- YDSP optimizer: `constantFolding` treated the non-SSA IR's value ids as single-assignment, so the sample-loop induction register - defined once by the prologue `constI 0` and once per iteration by the `movI` update - folded its per-iteration update to a literal and the generated kernel looped forever (`CompilesPassThroughAndProducesCorrectOutput` hung). Constants are now only propagated for value ids defined exactly once. +- YDSP optimizer: inlined `func` calls bound parameters directly to the caller's argument register (locals are mutable single-register IR slots), so a function that reassigns a parameter - e.g. `t = t / dt` in the AnalogSaw `polyBlep` - clobbered the caller's local and the block-end state store persisted the mutated value instead of the original (`state[0] = t` instead of `phase`), making the oscillator diverge to ±inf after the first cycle. Parameters are now pass-by-value: each argument is copied into a fresh value at the call site. Copy propagation also stops at redefinitions of the destination, so it can't propagate through the new parameter copies. +- `MidiKeyboardComponent`: computer-keyboard input now actually matches keys (the previous full-`KeyPress` comparison never matched SDL events) and sends matching note-offs on `keyUp`, releasing all keyboard-triggered notes when focus is lost instead of leaving them stuck; the mouse wheel now scrolls left/right by single white keys (vertical wheel works on horizontal keyboards too) and Ctrl/Cmd + wheel zooms in/out, clamped between a single octave and the full 0-127 range. +- YDSP optimizer: binary ops mixing a non-literal constant with a literal (e.g. `int64(1) << 32`) previously narrowed the constant to the literal's width, silently changing the result type (`int64(1) << 32` computed as 32-bit and wrapping to `1`); `unifyOperands` now mirrors the analyzer's contextual literal adaptation and only widens the source literal. +- YDSP codegen: parameter and state loads now consistently read their slot index from the `a` IR field (was `memIndex`), fixing invalid memory offsets for params, `prev`/`mem` slots, `@` delay write pointers and param-out meters. +- YDSP optimizer: implicit `int -> float` coercion for literals and values used in float arithmetic, comparisons, intrinsics, and ternary/select branches; fixes invalid mixed integer/float instructions on AArch64 (`fmul s0, s0, w0` and similar). `sign()` now converts its integer result to a float. +- YDSP optimizer: the `@` delay primitive delayed by `n + 1` samples; the ring read now targets the slot one past the write pointer so `x @ n` delays by exactly `n` samples. +- YDSP codegen: `fmod(a, b)` had no codegen case and silently produced garbage output; now lowered to a `fmodf` libm call. +- YDSP codegen: integer `/` and `%` by a zero divisor now consistently return `0` on both x86-64 and AArch64 (previously trapped with `SIGFPE` on x86-64, and `%` returned the dividend unchanged on AArch64). +- YDSP codegen: libm calls (`sin`, `cos`, `tanh`, `pow`, `fmod`, etc.) emitted an invalid `blr` with an immediate operand on AArch64; the call target is now materialized into a register first. +- YDSP optimizer: mixing an integer literal with a float expression (e.g. `1 - a` or `1 + 0.5 * side`) lowered to integer arithmetic, coercing the float operands through `fcvtzs` and truncating fractional values; integer literals now adapt to the other operand's float width as the language's contextual literal adaptation requires. +- YDSP semantic analyzer: writing 64-bit (`float64`/`int64`) input-value parameters in sample mode is now allowed (they double as per-sample accumulator registers); 32-bit parameters remain block-rate only. +- YDSP semantic analyzer: a processor with two or more `func` definitions crashed during analysis - `functionTable` stored pointers into the growing `functions` vector, so registering the second function reallocated it and dangled the first function's entry (`resolveFunctionCall` then read freed memory). The vector is now pre-sized before registration. +- YDSP optimizer: `copyPropagation` rewrote the `a`/`b`/`c` operand fields of every instruction, conflating value ids with the state/param slot indices carried by `loadStateF`/`loadStateI`/`loadParam`/`loadParamOut` (both live in the same small-int space). A sample-mode state reassignment redefines a low value id through a mov, so in a Reverb-style kernel (twelve `@` delays interleaved with comb-state reassignments) the hidden ring write-pointer loads had their slot rewritten to a value id and read the pointer from inside the array segment instead of the scalar slot (load `state+264` vs store `state+108`) - the ring store then indexed with garbage and crashed the generated kernel on the audio thread. Copy propagation now only rewrites operand fields that hold value ids for the target instruction. +- `yup_core` (wasm): `Time::getMillisecondCounterHiRes()` - read every audio block by `MidiMessageCollector::removeNextBlockOfMessages` - called `std::chrono::steady_clock::now()`, which `std::terminate()`s (surfacing as an opaque `abort()`) when called from the browser's AudioWorkletGlobalScope: libc++'s `steady_clock::now()` goes through a pthread-aware `clock_gettime` path, and the audio-worklet thread is a lightweight Wasm Worker rather than a real pthread - the same "not actually a pthread" gap already worked around elsewhere for thread identity (`Thread::getCurrentThreadId()`, TLS-based on wasm) and for locking (`AudioLockType`, a non-blocking `RecursiveSpinLock` on wasm). `getMillisecondCounterHiRes()`/`getHighResolutionTicks()`/`yup_millisecondsSinceStartup()` now go through `emscripten_get_now()` on Emscripten instead - a thin `performance.now()` wrapper with no pthread machinery behind it, safe on every thread including the audio worklet. (An earlier attempt at this fix, changing the startup-time baseline from a function-local to a namespace-scope static, addressed a real but unrelated latent issue and did not fix this crash - the terminate was inside `steady_clock::now()` itself, not in its own fallback baseline.) +- YDSP semantic analyzer: `procStates` (parallel to each state's per-processor index) was never cleared between processors, so a program with two or more processors declaring state could resolve a later processor's state against an earlier processor's `YdspStateDecl` - wrong type, wrong struct, silently wrong analysis. It is now reset alongside every other per-processor field. +- YDSP optimizer: `constantFolding` computed `divI`/`modI`/`shlI`/`shrI`/`addI`/`subI`/`mulI`/`negI`/`absI` through plain `int64_t` arithmetic, which is undefined behaviour at the extremes (`INT64_MIN / -1`, a shift amount outside `[0, 63]`, signed overflow near `INT64_MIN`/`INT64_MAX`) - triggered by the compiler's own fold, not generated code. `divI`/`modI`/`shlI`/`shrI` now leave the instruction unfolded (like the existing div-by-zero guard) when the operands would invoke UB; the others compute through `uint64_t` so the result matches the wraparound generated code would produce at runtime. `YdspSemanticAnalyzer::tryConstantFold`'s `shl`/`shr` (used when folding a program-level `let` constant) had the identical unguarded shift and is fixed the same way. +- YDSP runtime: `getActiveVoiceCount()` read a voice's `[[ role: voiceActivity ]]` flag through `state.data() + offset` unconditionally - `state` is only sized by `prepare()`, so calling it right after `compile()` (`isValid()` is already true then) read past the end of an empty buffer. It now falls back to the same conservative "treat as active" default used for a held voice. +- YDSP runtime: an all-sound-off event (MIDI CC120) dropped for capacity (more than 16 in one block on the same node) still wiped the node's voice-slot bookkeeping unconditionally, freeing those voices for reallocation even though `silenceVoice()` never ran for the dropped offset - a note reusing the slot inherited the previous voice's filter/envelope state instead of a clean one. The bookkeeping is now only cleared when the event itself was actually recorded. +- YDSP language: the recursive-descent parser, the semantic analyzer's AST walk, the IR builder's function-inlining chain and the wasm codegen's block/loop/if emission had no recursion-depth guard, so a pathologically nested expression, statement or call chain (not necessarily adversarial - a generated `.ydsp` source nests easily) could overflow the native stack instead of failing with a diagnostic. All four now share one `YdspRecursionGuard` depth limit. +- YDSP language: the lexer narrowed each source character to `unsigned char` in `peek()`/`current()`, so a non-ASCII character whose low byte matched a whitespace/newline byte could end a `//` comment early (leaking the rest of the comment as real tokens) or desync line/column tracking; string-literal content was separately narrowed to `char`, corrupting non-ASCII text. Both now carry the full code point. +- YDSP language: `parseProcess()`/`parseEventHandler()` pushed `parseStatement()`'s result into the process/event-handler body unguarded, unlike the otherwise-identical pattern in `parseBlockStatement()`/`parseFunction()` - a malformed statement (e.g. a bare `;`) landed as a null entry in that vector. `YdspParser::parseProgram()`'s doc comment also claimed it returns nullptr on any syntax error, which it never does (parsing recovers and returns a non-null, structurally complete program - callers must check `diagnostics.hasErrors()`); the comment now matches the actual, intentional contract. +- YDSP runtime: `droppedEventCount` was a plain `uint64_t` incremented on the audio thread and read from a UI/control thread with no synchronization - now a relaxed `std::atomic`. Several byte-buffer reads/writes (constant folding into state, parameter automation snapshot/restore, `get/setParameter`, meter reads) aliased a `uint8_t*` through a differently-typed pointer, which is undefined behaviour when the buffer offset isn't aligned for the target type; all now go through `memcpy`, matching a precedent already used elsewhere in the same file. +- YDSP backend: `YdspCompiledKernel`'s wasm module lookup (`wasmModules`/`wasmIndex`) had no bounds check, so a kernel wrapper that outlived a graph rebuild could index past the end of the module vector; it's now bounds-checked before every access. Function-pointer conversions were scattered across several ad-hoc `reinterpret_cast`s (contradicting a comment claiming they were "confined" to one class) and are now centralized behind two small helpers (`ydspFnPtrCast`, `ydspFnPtrToInt64`) in `yup_YdspAbi.h`. The wasm text decoder's LEB128 reader could shift a 32/64-bit accumulator by more than its width on a malformed byte sequence (UB); it now caps the shift. +- YDSP backend (emscripten): a pointer was narrowed to the wasm-call context id via a direct `reinterpret_cast`, which isn't a well-defined conversion in general (it happens to work only because this file is wasm32-only, where pointers and `int` are both 32 bits) - now goes through `uintptr_t` to make that dependency explicit. `yupDspWasmFreeKernel` also skipped the same registry-exists guard every other function in the file uses, so freeing a kernel in a realm that had never registered/called one threw instead of no-oping. +- YDSP codegen (AArch64): scalar `float32` values were allocated through the packed `kFloat32x1` TypeId, which AsmJit maps to a 64-bit (D) register on AArch64 but a 128-bit XMM on x86-64. Every float32 value was then computed at 64-bit width, loads over-read the next slot, and scaled array/stream stores (`str d0, [base, index, lsl 2]`) failed to assemble with `InvalidAddressScale`, breaking `AnalogSaw` on arm64. The scalar TypeIds are used again on AArch64; x86-64 keeps the packed forms. +- `yup_audio_gui`: `MidiKeyboardComponent` repainted synchronously from its `MidiKeyboardState::Listener` callbacks, but those callbacks are documented to be able to fire from an audio or MIDI input thread - feeding the state via `processNextMidiEvent()` from a CoreMIDI callback repainted off the UI thread, racing the paint loop (stuck key highlights) and crashing in `Component::repaint`. The listener callbacks now only trigger an internal `AsyncUpdater`; the repaint is coalesced and applied on the message thread, so the key state may be updated from any thread. Regression-tested in `yup_MidiKeyboardComponent.cpp` by feeding notes from a worker thread and asserting the deferred repaint lands on the message thread. +- Examples: the YDSP Synth Lab demo (`examples/graphics/source/examples/YdspSynths.h`) registered its `MidiMessageCollector` directly as the `MidiInputCallback`, so hardware MIDI went straight into the audio queue and never reached `MidiKeyboardState` - incoming notes never highlighted the on-screen keyboard, and incoming pitch bend / CC1 never moved the wheels. The demo is now the callback itself: it feeds `keyboardState` (which forwards note on/off to the collector through its existing keyboard-state listener), maps incoming pitch bend and CC1 onto `PitchWheelComponent`/`ModWheelComponent` with `dontSendNotification` (applied in `refreshDisplay()` on the message thread), and forwards every other message to the collector. + +- YDSP wasm backend: three defects fixed. The vectoriser widens float compare/select chains by reusing the scalar `eqF..geF`/`selectB` opcodes on `lanes > 1` values, and the wasm codegen lowered every compare to a scalar `f32.gt`/`f32.eq` over `v128` operands (the only branch was 32/64-bit) and a widened select to the untyped `select` - so any vectorized kernel containing a comparison produced a module the browser refused to compile (`expected type f32, found local.get of type v128`, the `YdspGraphTests`/`YdspVectorizerTests` compare shapes). The codegen now emits the `f32x4.eq..ge` per-lane mask opcodes and lowers a widened select through `v128.bitselect`, matching the AsmJit `cmpps`/`fcmeq` plus `andps`/`bsl` semantics. Second, wasm ignored `fastMath` entirely (it was clamped false), so `a * b + c` stayed two separate float32 roundings - `TheContractedSubtractRoundsOnce` produced `0.789999962` instead of the fused `0.790000021`. wasm now honors `fastMath` like native: scalar float32 contraction is fused there too, expanded through the exact float64 sequence (the target has no fused multiply-add instruction), so it rounds once and stays bit-stable with the native default. Because a widened chain cannot fuse without a packed fused instruction, the vectoriser keeps the implicit per-sample stream loop (runtime blockSize bound) scalar when it holds a fusable mul->add/sub chain on such a target (new rejection reason `keptScalarForContraction`) so the chain still rounds once; constant-bound bank `for i in 0..N` loops keep widening unfused by design. Third, every successful wasm kernel compile rendered its whole module to text as an info diagnostic, accumulated through `yup::String +=` on a String with no capacity reserve - quadratic in module size, so the largest example patch (TX81Z) effectively never finished compiling on wasm; the renderer now accumulates into a growth-friendly `std::string`. + +### DSP + +- `yup_dsp_jit`: YDSP gains parameter smoothing. The new `smooth (x, tau)` intrinsic is a one-pole ramp towards `x` with a `tau`-second time constant, plus a snap on the first sample where the ramp cannot advance, so the target arrives exactly and leaves no denormal tail (the compare tests the step rather than the remaining distance: a float32 lerp stalls while still short of the target by roughly `ulp / coeff`, so any fixed epsilon would either be unreachable or truncate a fast ramp); it lives under the same restrictions as the delay primitives (per-sample body, outside loops, not in event handlers) and costs one hidden float slot plus one hidden int slot per call site (the coefficient's `exp` is loop-invariant and hoisted out of the sample loop). `[[ smoothing: ]]` on an `input value float` is sugar for it: one synthetic local is prepended to the per-sample body and that body's references to the endpoint are rewritten, so event handlers, `func` bodies and `getParameter()` keep seeing the raw target. Nothing changed in the IR ops, the codegen backends or the runtime - automation is still a step, the ramp happens in generated code. Every "YDSP Synths" example patch now smooths the parameters that actually step the signal - `Delay` feedback/damping/mix, `Chorus` depth/mix, `Compressor` threshold/ratio/makeup, `Distortion` drive/tone/mix, `Reverb` mix/damping/room size, `Analog Saw` resonance, `FM Bell` mod index, `Wobble Lead` both cutoff extremes, `Pulse Bass` width and `Electric Piano`'s tremolo depth - so dragging a knob no longer zippers. Parameters that only set a rate (LFO rates, the compressor's attack/release) or that are read once in an event handler (envelope times, FM ratio, all the Electric Piano voice controls) are deliberately left stepped. `Delay`'s time stays raw on a slew-rate argument: ramping an ~88000-sample read index over 20 ms scrubs the buffer at ~100 samples per sample, worse than the single jump it replaces - whereas `Chorus`'s depth spans only ~880 samples and so slews at roughly the rate its own LFO already does. `Analog Saw` and `Pulse Bass` smooth their cutoff at the filter *coefficient* rather than at the parameter - `float k = smooth (1 - exp (...), 0.02)` - because a parameter is sampled once per kernel invocation, so an expression built only from parameters is already hoisted out of the sample loop; smoothing the parameter itself would pull that `exp` back in, while smoothing the coefficient keeps it hoisted and costs one `lerp`. The four voices' hand-rolled `envSmooth`/`smoothCoeff` anti-click one-pole collapses to a single `smooth (env, 0.003)`; not quite a pure refactor, as a voice's very first note now attacks slightly faster (the primed flag snaps the gain on the voice's first sample instead of ramping it up from zero). That is inaudible - the oscillator and filter state are zero at that point too, so the voice still starts from silence - and a stolen voice is unaffected, since state is not zeroed on steal and the smoother ramps exactly as before. +- `yup_dsp_jit`: YDSP gains four small language additions, all const-folded away before name resolution so nothing downstream of the semantic analyzer changes. Program-scope `let name = ;` declares a compile-time constant usable as a `state` array size, a `for` bound or in any expression (imported constants are namespaced like imported processors, and redeclaring a constant's name is an error); `state` declarations take initialisers (`state float feedback = 0.5;`, `state float table[32] = { ... };` with trailing elements left zero) which are lowered into the processor's `init` kernel, synthesising one if the processor has no explicit `init { }` block; the new `samplePeriod` builtin is `1 / sampleRate` (loop-invariant, so the division is hoisted out of the sample loop); and `[[ init: ]]` is accepted as an alias for a parameter's default value, so annotation blocks paste in unchanged (an explicit `= ` still wins). Constant folding now also handles binary arithmetic, so an endpoint default like `= 1.0 / 3.0` evaluates instead of silently becoming zero. +- `yup_dsp_jit`: YDSP `for` loop variables are now scoped to the loop body as documented - sibling loops may reuse the conventional `for i` without colliding, and the variable is no longer visible after the loop (previously the second `for i` in one body was rejected as a duplicate symbol). +- `examples/graphics`: new "Electric Piano" YDSP synth patch (`data/synths/ElectricPiano.ydsp`), a 16-voice additive electric piano - a 32-partial complex-rotation oscillator bank per voice with velocity-blended spectra, per-partial decay interpolated in 64-sample chunks, and a stereo triangle tremolo. The bank is stored as parallel `state float[32]` arrays rather than a struct of complex values so the harmonic loop walks unit-stride. The two partial-weight spectra are hand-authored, not measured from a real instrument. +- `yup_dsp_jit`: YDSP is now a full MIDI/MPE instrument host. Processor-scope events grew from `noteOn`/`noteOff` to seven shapes named `noteOn` (`.pitch`, `.velocity`, `.isLegato`), `noteOff`, `pitchBend` (`.bendSemitones`), `pressure`, `slide`, `control` (`.control`, `.value`) and `programChange` (`.program`) - driven by a single shape/field table that the analyzer, the IR builder and the runtime all consult, and lowered through two offset-carrying IR ops (`loadEventFieldF`/`loadEventFieldI`) that replace the previous per-field opcodes. Ingestion now goes through `yup::MPEInstrument`, so plain MIDI and MPE share one path and note expression is keyed by note identity rather than by pitch: per-note bend/pressure/slide reach only the owning voice, sustain, sostenuto, reset-all-controllers and all-notes-off are honoured as ordinary note lifecycle, and all-sound-off (CC120) silences and re-runs `init` at its exact sample offset. Voice behaviour is declared on the node - `node v = Voice[8] [[ mode: poly, stealing: oldest ]]`, `node b = Bass [[ mode: mono, priority: last ]]` - with mono mode keeping an allocation-free held-note stack and flagging legato continuations through `.isLegato`. New `YdspAudioGraph::setMpeZoneLayout()` / `setLegacyMidiMode()` (legacy is the default, so existing hosts are unaffected), and `prepare()`'s per-voice event capacity default rose from 32 to 64 for MPE traffic. Two behaviour changes: a second note-on at the same pitch and channel now retriggers (releases) the first instead of stacking a second voice, and expression for a note with no allocated voice is discarded and counted in `getDroppedEventCount()`. +- `yup_dsp_jit`: the native asmjit backend was split per architecture - `YdspCodegen` is now `YdspAsmJitCodegen` (architecture-independent facade) with the shared lowering in `YdspAsmJitCodegenImpl` and the per-target encodings in `YdspAsmJitCodegenX64` (x86-64 SSE) and `YdspAsmJitCodegenARM64` (AArch64 ASIMD) (`backend/yup_YdspAsmJitCodegen{,.X64,.ARM64}.*`); purely organizational, no behavior change beyond the class rename. +- `yup_dsp_jit`: new WebAssembly backend for emscripten/browser targets (previously the module hard-errored on `YUP_WASM`). Each kernel is lowered to a self-contained wasm module (`backend/yup_YdspWasmEmitter.*` binary writer + `yup_YdspWasmCodegen`), run by the browser's native `WebAssembly` engine: the module imports the host's shared linear memory as `env.memory` (host buffers addressed directly as i32 offsets, no marshalling), libm intrinsics come from `env` backed by `Math.*` with C-exact `round`/`copysign`/`fmod`, and structured control flow lowers to wasm block/loop/if (zero-division-guarded `divI`/`modI`, `trunc`-based `modF`). Kernels are instantiated per JS realm (main thread and audio-worklet thread each get their own copy, lazily on first use) and are keyed by unique per-kernel ids, so a worklet realm can never invoke an older graph's kernel with a newer graph's context when patches are swapped; `YdspAudioGraph::prewarmKernels()` pre-registers them in the calling realm, and the graphics example prewarms its graph from the audio callback. The asmjit dependency is now desktop-only, the wasm tests run under the emscripten node target, and the `examples/graphics` YDSP Synths demo works in the browser - its "Dump Asm" action prints the generated wasm as WebAssembly text (`YdspWasmCodegen::toText`, recorded as an info diagnostic at compile time, mirroring the asmjit assembly log on desktop). Defined-function indices are assigned in the wasm function index space (imported functions only; the memory import no longer shifts them). +- `yup_dsp_jit`: the whole module now uses `yup::String` / `yup::StringRef` instead of `std::string` / `std::string_view` - AST fields, tokens, parser/analyzer/optimizer/codegen signatures and diagnostics messages - and reuses `yup::String` facilities (`getLargeIntValue()`, `getDoubleValue()`, `substring()`, `lastIndexOfChar()`, concatenation) in place of the stdlib string helpers. +- `yup_dsp_jit`: node-level oversampling (`node = P * N`) now resamples through `yup::Oversampler` (windowed-sinc, 2x/4x/8x) instead of the hand-rolled linear-interpolation upsample and boxcar-average downsample; the resampler introduces `2 * SincRadius` (16) samples of latency per oversampled node, oversampling requires equal input/output stream counts (or no inputs), and unsupported factors run the node at 1x. +- `yup_dsp_jit`: `YdspAudioGraph` now exposes host-UI parameter metadata - `getParameterCount()`/`getParameterInfo(slot)` (qualified name, `[[ name ]]` display name, type, declared default, `[[ min ]]/[[ max ]]` bounds) plus `getInputStreamCount()`/`getOutputStreamCount()` - so hosts can build sliders directly from a patch. New `examples/graphics` "YDSP Synths" demo (desktop-only, since `yup_dsp_jit` requires asmjit) loads `.ydsp` synth patches from `data/synths/`, compiles them lazily with `YdspCompiler`, builds parameter sliders from the annotations, and drives them from a MIDI keyboard through `YdspAudioGraph::process(..., MidiBuffer*, ...)`. +- `yup_dsp_jit`: YDSP gains MIDI-driven events - `input event noteOn/noteOff` endpoints, `event () { ... }` handler blocks, and `node = Processor[N]` voice banks with fixed-size, allocation-free voice stealing. MIDI byte-decoding and voice allocation live in the runtime (`YdspAudioGraph`); events and parameter automation are dispatched sample-accurately via runtime sub-block splitting (the same compiled kernel is re-invoked per sub-block - no codegen changes), which also powers a new stepped parameter-automation API (`YdspAutomationEvent`, `YdspAudioGraph::getParameterSlot()`); `YdspAudioGraph::process` gains a `MidiBuffer`/automation overload and `getDroppedEventCount()`. A graph-level `input event midi;` documents MIDI consumption; routing is structural (broadcast to every event-driven node). Fixed a pre-existing out-of-bounds param/meter indexing bug in `validateConnectivity` for stream-free, parameter-carrying processors. +- `yup_dsp_jit`: YDSP processors can now declare `struct` types with primitive and fixed-array fields, and `state` variables can be struct instances (`Voice state;`) or arrays of structs (`Voice voices[32];`). Fields are accessed with `.` (`state.phase`, `voices[v].gate`, `state.buf[i]`, `voices[v].buf[j]`) and flatten into the existing scalar/array state slots - no IR or codegen changes required. Struct states are state-layout only (no struct values/locals/params yet). +- `yup_dsp_jit`: YDSP processors can now declare an `init { ... }` block that runs once before audio starts (state writes, param reads, bounded loops; streams and delay primitives are rejected). Each init block compiles to a one-shot kernel sharing the processor's state layout; `YdspAudioGraph::prepare()` runs them in topological order, and the new `YdspAudioGraph::reset()` re-zeroes state and re-runs them. +- `yup_dsp_jit`: YDSP now supports the C-style bitwise operators `&`, `|`, `^`, `<<`, `>>`, `~` (and their compound assignment forms `&=`, `|=`, `^=`, `<<=`, `>>=`) on `int32`/`int64` operands, with C-compatible precedence (`|` < `^` < `&` < equality < relational < shift < additive), arithmetic (sign-propagating) right shift, and full support through the optimizer (constant folding + identity peepholes) and the asmjit backend on both x86-64 and AArch64. +- `yup_dsp_jit`: YDSP now supports `int32`/`int64` and `float32`/`float64` primitive types end-to-end (`float`/`int` are aliases for the 32-bit types). Typing is strict: no implicit conversions, contextual literal adaptation only, with explicit `int32(x)`/`int64(x)`/`float32(x)`/`float64(x)` casts; `if`/`&&`/`||`/`!` require `bool`, indices and loop bounds require `int32`, and the delay primitives stay `float32`. +- `yup_dsp_jit`: float64/int64 streams, parameters and meters are supported through the whole pipeline: the asmjit backend emits SSE2 double instructions (`movsd`/`addsd`/`comisd`/`cvtsd2ss`/…) and 64-bit GPR arithmetic on x86-64, register-sized `fadd d`/`fcvt`/`sxtw`/`scvtf` on AArch64, and width-matched libm variants (`sin` vs `sinf`). `YdspAudioGraph::process` gained per-stream/param element-type introspection and typed parameter accessors (`getDoubleParameter`/`setIntParameter`/…). +- `yup_dsp_jit`: internal module sources were reorganized for maintainability - the semantic analyzer, the IR optimizer and the graph runtime monoliths were split into single-responsibility files (`analysis/semantic/`, `optimiser/builder/` + `optimiser/passes/`, `runtime/graph/`); purely organizational, no API or behavior change. +- `examples/graphics`: the "Analog Saw" YDSP synth patch now uses a polyBLEP band-limited sawtooth (naive ramp minus the polyBLEP residual at the phase wrap) instead of the naive aliasing ramp, demonstrated through the `polyBlep` function. +- `examples/graphics`: every oscillator left in the YDSP synth patches under `data/synths/` is now band-limited too - the remaining naive saws (Haas Widener, Parallel Drive, Parallel Rack, Wobble Lead) and the Pulse Bass pulse use the same polyBLEP treatment (two edges for the pulse), and the FM Bell is rebuilt as an additive (Le Brun) expansion whose Bessel-weighted sidebands are silenced above Nyquist, so none of the demo voices alias. +- `examples/graphics`: new "Wave Lab" YDSP synth patch (`data/synths/WaveLab.ydsp`) demonstrates four polyBLEP band-limited oscillators behind one `wave` selector knob - saw, square, triangle (whose corners are rounded by the exact antiderivative of the BLEP residual) and a width-morphable pulse. +- `examples/graphics`: the "YDSP Synths" demo gains a "Dump Asm" button that prints the asmjit assembly listing of the current patch's compiled kernels to the console (replayed from the graph's compile-time diagnostics). +- `examples/graphics`: the "YDSP Synths" demo gains Performance/Editor tabs - Performance keeps the existing knob grid, keyboard and oscilloscope; Editor is a live `CodeEditor` (YDSP syntax highlighting) bound to the selected patch's source with a Compile button that recompiles it through `YdspCompiler` and swaps in the resulting `YdspAudioGraph` on success (refreshing the Performance tab's knobs), or shows the compiler's diagnostics (source line + caret) in a red panel on failure. + +### Audio + +- `yup_audio_basics`: new `AudioLockType` alias - `yup::RecursiveSpinLock` on wasm and `yup::CriticalSection` elsewhere - used by the module's audio types (`MidiKeyboardState`, `UMPKeyboardState`, `Synthesiser`, `MPESynthesiser*`, `MPEInstrument`, and the audio sources). On wasm a `CriticalSection` can block in a `futex_wait`, which is fatal on the browser audio-worklet thread; `RecursiveSpinLock` busy-waits on an atomic and never blocks, while keeping the re-entrancy of `CriticalSection`. +- `yup_audio_basics`: `MPEInstrument` can now be driven from an audio callback without allocating - new `reserveNotes (int)` preallocates the note-tracking storage, and note removal uses the `Array::remove()` (in `yup_core`) which is now not allocating on removal. + +### Thirdparty + +- New `asmjit_library` module (`thirdparty/asmjit_library`): AsmJit machine-code generation library (core, x86, AArch64 and ujit backends), statically linked via the YUP module system. + ### Breaking changes +- `yup_dsp_jit`: the public API now uses `yup::String` / `yup::StringRef` instead of `std::string` / `std::string_view` - `YdspCompiler::compile`, every `YdspAudioGraph` param/meter accessor, `YdspDiagnostics::addError/addWarning/addInfo/setSource/toString`, `YdspDiagnostic::message`, `YdspKernelReport::name`/`loopBounds` (now `yup::StringArray`) and `YdspParameterInfo::name`/`displayName`. Callers passing `const char*` or `std::string` keep working unchanged (implicit `StringRef` conversion); explicit `std::string_view` arguments must switch to `yup::StringRef`. +- `yup_dsp_jit`: `YdspAudioGraph::process()` no longer takes raw `void*` stream pointer tables. Stream buffers are now passed as typed spans - `process (yup::Span, yup::Span, int)` - where `YdspInputBuffer`/`YdspOutputBuffer` are `std::variant`s of `yup::Span`. The active variant alternative carries the buffer's element type, so a mismatched buffer is ignored and reported through the returned `YdspProcessResult` value instead of being reinterpreted; the generated-kernel ABI (`YdspKernelContext`) is unchanged. The `process32()` convenience overload was removed - callers pass typed spans to `process()` directly. - macOS: OpenGL rendering backend removed in favor of Metal only - `LottieReader::parseFile()`, `parseData()`, `parseStream()`, and `parseFromZip()` now return `ResultValue` and no longer take a trailing `String* outError` out-parameter; check `wasOk()`/`failed()` and read the message via `getErrorMessage()`. - `AnimationFrameExporter` is now an instance-based class bound to a `GraphicsContext` (construct `AnimationFrameExporter exporter (ctx);` then call `exporter.renderFrame(anim, …)` / `exporter.renderAllFrames(…)` / `exporter.exportToGif(anim, …)`), so it can own and reuse the GPU matte-composite pipeline across frames instead of recompiling it per frame. The `exportToGif(frames, frameRate, …)` frame-sequence encoder remains a static helper. @@ -93,8 +478,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Fixed `GpuDevice::updateBuffer()` failing for every vertex, index and uniform buffer on the WebGPU, Dawn and D3D11 backends: those overrides handled native storage buffers only and returned false instead of delegating ore-backed buffers to the base class, the way the Metal and OpenGL overrides do - Implemented `GpuDevice::readBuffer()` for D3D11, which previously reported `isComputeAvailable()` but had no override, so every storage buffer readback silently failed through the base class. It copies into a cached `D3D11_USAGE_STAGING` buffer on the immediate context (ordered after the dispatch) and maps it for reading - `GpuComputePass` on D3D11 now unbinds the UAV slots it bound when the pass finishes, so a storage buffer is no longer left bound for writing while a later readback or draw reads it -- Fixed `GpuDevice::readBuffer()` never succeeding on the Emscripten WebGPU backend: it mapped its staging buffer with `WGPUCallbackMode_AllowProcessEvents` and then tested the result in the same call, but WebGPU buffer mapping only resolves through the JavaScript event loop, so the callback could not have run. The WGPU backend now pipelines the readback over a ring of three staging buffers using `WGPUCallbackMode_AllowSpontaneous`, which completes on its own between main-loop ticks — no ASYNCIFY needed -- `GpuDevice::readBuffer()` is no longer documented as unconditionally blocking. Whether it blocks is a property of the backend: Metal, D3D11 and OpenGL read back in lockstep and fill the destination every call, while WebGPU cannot map synchronously and so trails the GPU by a frame or two. Callers must now own the destination across calls and treat a false return as "no new data yet" rather than an error — the previous contents stay valid +- Fixed `GpuDevice::readBuffer()` never succeeding on the Emscripten WebGPU backend: it mapped its staging buffer with `WGPUCallbackMode_AllowProcessEvents` and then tested the result in the same call, but WebGPU buffer mapping only resolves through the JavaScript event loop, so the callback could not have run. The WGPU backend now pipelines the readback over a ring of three staging buffers using `WGPUCallbackMode_AllowSpontaneous`, which completes on its own between main-loop ticks - no ASYNCIFY needed +- `GpuDevice::readBuffer()` is no longer documented as unconditionally blocking. Whether it blocks is a property of the backend: Metal, D3D11 and OpenGL read back in lockstep and fill the destination every call, while WebGPU cannot map synchronously and so trails the GPU by a frame or two. Callers must now own the destination across calls and treat a false return as "no new data yet" rather than an error - the previous contents stay valid - `ComputeParticlesDemo`: keeps drawing the last particle snapshot on frames where no new one has landed, so it renders on the Emscripten WebGPU backend instead of showing nothing. The status label reports the landed-snapshot count alongside the frame count - `Component`'s effect path now reuses its offscreen `GpuCanvas` across frames while the component size is unchanged, instead of allocating (and freeing) a full-size render target every frame. On a size change the outgoing canvas is released before the replacement is created, so its `RenderContext` lease returns to the pool rather than forcing a second context to be reserved permanently - `ComponentEffectsDemo`: shader effects now share a common base that compiles the pipeline at most once instead of retrying a failed compile on every frame, reports the compile error in the status label and on the console, and shows the CPU time spent applying the effect next to the paint time @@ -130,7 +515,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - GraphicsContext GPU context integration. New `GraphicsContext::isGpuAvailable()` capability probe; `gpuContext()` is retained but documented `@internal` as the single backend bridge. - New `GpuTexture` class (`rhi/yup_GpuTexture.h`): opaque reference-counted GPU texture wrapping `rive::gpu::Texture` or `rive::gpu::RenderCanvas`. Obtained from `GpuCanvas::asTexture()` or constructed internally by `Image::fromTexture()`. -- New `GpuTarget` class (`rhi/yup_GpuTarget.h`): low-level render-pass-only offscreen GPU surface (`create`, `beginRenderPass`, `asTexture`, `asImage`, `readPixels`). Its backing texture is allocated from the context's main render context, so it does not reserve a dedicated `rive::gpu::RenderContext` — use it for custom `GpuPipeline` work (e.g. post-process passes) that needs no 2D drawing. +- New `GpuTarget` class (`rhi/yup_GpuTarget.h`): low-level render-pass-only offscreen GPU surface (`create`, `beginRenderPass`, `asTexture`, `asImage`, `readPixels`). Its backing texture is allocated from the context's main render context, so it does not reserve a dedicated `rive::gpu::RenderContext` - use it for custom `GpuPipeline` work (e.g. post-process passes) that needs no 2D drawing. - New `GpuCanvas` class (`rhi/yup_GpuCanvas.h`): consolidated backend-agnostic offscreen GPU surface that now composes a `GpuTarget` (over a `RenderableTarget`) and creates a non-owning `Graphics` lazily only when 2D drawing is requested. - Python bindings now expose `GpuColor` as `yup.GpuColor` (backing `GpuRenderOptions.clearColor`), comparable with `yup.Color`. - Python bindings now expose `GpuLoadOp` / `GpuStoreOp` and `GpuRenderOptions.loadOp` / `.storeOp`. `GpuRenderOptions.clear` is kept as a bool view of `loadOp`, so `GpuRenderOptions(True, color)` and `opts.clear` still read the same as before. @@ -138,8 +523,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). #### RHI module extraction & GpuDevice - **New `yup_rhi` module**: the GPU abstraction layer extracted from `yup_graphics` into its own module (depends on `yup_core`, `yup_shading`, `rive_renderer`). All RHI classes (`GpuFrame`, `GpuPipeline`, `GpuBuffer`, `GpuTexture`, `GpuTarget`, `GpuRenderPass`, `GpuPipelineCache`) now live in `yup_rhi`. `yup_graphics` depends on `yup_rhi` for GPU access. -- **`GpuDevice`**: new reference-counted GPU device abstraction (was `GpuContext`). Owns the native GPU device and command queue without requiring a window — can be used for headless GPU compute (e.g. audio DSP on the GPU). Created via `GpuDevice::create(GpuPlatform, Options)`. All RHI factory methods (`GpuFrame::begin`, `GpuPipeline::compile*`, `GpuBuffer::create`, `GpuTarget::create`) now take `GpuDevice::Ptr` for safe shared ownership. -- **`GpuPlatform`** enum: standalone platform enum (`Headless`, `Metal`, `Direct3D`, `OpenGL`, `OpenGLES`, `WebGPU`) replacing the nested `GpuDevice::Api`. `GraphicsContext::getPlatform()` returns it directly — no typedef alias. +- **`GpuDevice`**: new reference-counted GPU device abstraction (was `GpuContext`). Owns the native GPU device and command queue without requiring a window - can be used for headless GPU compute (e.g. audio DSP on the GPU). Created via `GpuDevice::create(GpuPlatform, Options)`. All RHI factory methods (`GpuFrame::begin`, `GpuPipeline::compile*`, `GpuBuffer::create`, `GpuTarget::create`) now take `GpuDevice::Ptr` for safe shared ownership. +- **`GpuPlatform`** enum: standalone platform enum (`Headless`, `Metal`, `Direct3D`, `OpenGL`, `OpenGLES`, `WebGPU`) replacing the nested `GpuDevice::Api`. `GraphicsContext::getPlatform()` returns it directly - no typedef alias. - **`GpuColor`** struct (`rhi/yup_GpuTypes.h`): lightweight 4-component GPU color for render options. Implicitly constructable from any type with `getRedFloat()`/`getGreenFloat()`/`getBlueFloat()`/`getAlphaFloat()` (e.g. `yup::Color`), so `GpuRenderOptions { true, Colors::transparentBlack }` works without code changes. - **`GraphicsContext` simplified**: wraps a `GpuDevice::Ptr` (obtained via `getGpuDevice()` returning `GpuDevice::Ptr`). Offscreen target management (`createOffscreenTarget`, `beginOffscreen`, `endOffscreen`, `readOffscreenPixels`) delegated to `GpuDevice`. Factory accepts optional `GpuDevice::Ptr` to share an existing GPU device. - **Backends**: `GpuDevice` has native implementations for all platforms (Metal, OpenGL, Direct3D 11, Dawn, WebGPU/Emscripten, Headless). OpenGL backend probes `GL_VERSION` at runtime to detect compute shader support (GL ≥4.3 / GLES ≥3.1). @@ -151,9 +536,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - New `GpuComputePass` class (`rhi/yup_GpuComputePass.h`): move-only RAII compute dispatch encoder (`GpuComputePass::begin(device)`). Binds a `GpuComputePipeline`, storage buffers (`setStorageBuffer`), uniform buffers (`setUniformBuffer`), and textures (`setTexture`), then dispatches workgroups via `dispatch(gx, gy, gz)`. - `GpuBuffer` extended with `GpuBufferType::storage`: native storage buffer creation for each backend (Metal `MTLBuffer`, D3D11 structured buffer + UAV, WebGPU `Storage` buffer, OpenGL `GL_SHADER_STORAGE_BUFFER`). Storage buffers are bound to `GpuComputePass::setStorageBuffer()`. - `GpuDevice` backends expose native compute handles: `getDevice()`/`getCommandQueue()` (Metal), `getD3DDevice()`/`getD3DDeviceContext()` (D3D11), `getWgpuDevice()`/`getWgpuQueue()` (WebGPU/Emscripten), `getBackendDevice()`/`getDevice()`/`getQueue()` (Dawn). -- `GpuAudioProcessingDemo` example: real-time GPU-accelerated audio effect (gain + soft clipper) using compute shaders. Captures live audio via `AudioIODeviceCallback`, uploads to GPU storage buffers, dispatches a compute shader, and reads back processed audio — all on the audio I/O thread. -- New `GpuDevice::updateBuffer()`: writes new data into an existing storage buffer without reallocating it (Metal `contents` memcpy, D3D11 `UpdateSubresource`, WebGPU/Dawn `WriteBuffer`, GL `glBufferSubData`). Fixes `GpuAudioProcessingDemo` reallocating its input storage buffer every audio callback, which caused audible stutter. The gain/mix parameters remain a uniform buffer (as before) — that path is unaffected and its small per-dispatch allocation is negligible next to the audio-block-sized buffer this fix removes. +- `GpuAudioProcessingDemo` example: real-time GPU-accelerated audio effect (gain + soft clipper) using compute shaders. Captures live audio via `AudioIODeviceCallback`, uploads to GPU storage buffers, dispatches a compute shader, and reads back processed audio - all on the audio I/O thread. +- New `GpuDevice::updateBuffer()`: writes new data into an existing storage buffer without reallocating it (Metal `contents` memcpy, D3D11 `UpdateSubresource`, WebGPU/Dawn `WriteBuffer`, GL `glBufferSubData`). Fixes `GpuAudioProcessingDemo` reallocating its input storage buffer every audio callback, which caused audible stutter. The gain/mix parameters remain a uniform buffer (as before) - that path is unaffected and its small per-dispatch allocation is negligible next to the audio-block-sized buffer this fix removes. - Fixed `ShaderTranspiler`'s MSL backend assigning storage/uniform buffer indices via spirv-cross's own auto-incrementing scheme instead of the shader's declared `layout(binding=N)`: added `CompilerMSL::Options::enable_decoration_binding = true` so the compiled `[[buffer(N)]]` index always matches the declared binding, matching what `GpuComputePass`'s native dispatch (which binds slots as `group*16+binding` with no reflection indirection) requires. This was silently producing zero output from any Metal compute shader with more than one storage/uniform buffer, including `GpuAudioProcessingDemo`. +- Metal `GpuDevice`/`GpuComputePass` calls now wrap their Objective-C work in `@autoreleasepool` blocks - without one, real-time callers (e.g. an audio thread with no ambient pool) accumulated command buffers/encoders indefinitely. - Metal `GpuDevice`/`GpuComputePass` calls now wrap their Objective-C work in `@autoreleasepool` blocks — without one, real-time callers (e.g. an audio thread with no ambient pool) accumulated command buffers/encoders indefinitely. - Fixed `ShaderTranspiler` emitting ESSL 3.00 for every stage, which made spirv-cross reject compute shaders on OpenGL ES ("At least ESSL 3.10 required for compute shaders") — compute stages now target ESSL 3.10 (`#version 310 es`) while vertex/fragment stages keep ESSL 3.00. @@ -163,10 +549,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Added TGA read/write support (`TgaImageFormat`): uncompressed and RLE-compressed truecolor and grayscale variants; RGB and RGBA output with alpha channel preservation. - Added animated WebP encoding and decoding support to `WebPImageFormatWriter` and `WebPImageFormatReader`: per-frame metadata (canvas dimensions, frame count, loop count, per-frame delays, dispose/blend modes) and frame decompression with manual compositing. - Added animated PNG (APNG) encoding and decoding support to `PngImageFormatWriter` and `PngImageFormatReader`: manual chunk-level parsing of `acTL`/`fcTL`/`fdAT` chunks for animation metadata, per-frame libpng decoding via synthetic minimal PNG construction, and canvas compositing supporting all three APNG disposal operations (none, background, previous) and both blend operations (source, over). -- `ImageFormat::Options` struct controls metadata extraction: `.withMetadata(true)` enables text metadata and DPI; `.withRawChunks(true)` enables raw binary chunks (EXIF, ICC, XMP). When both are false (the default), `ImageMetadata` is not allocated — true zero overhead. +- `ImageFormat::Options` struct controls metadata extraction: `.withMetadata(true)` enables text metadata and DPI; `.withRawChunks(true)` enables raw binary chunks (EXIF, ICC, XMP). When both are false (the default), `ImageMetadata` is not allocated - true zero overhead. - Introduced a ref-counted `ImageMetadata` object (`ImageMetadata::Ptr`) attached to `Image` and `ImageFormatReader::metadata`. DPI, text entries, and raw binary chunks are all accessed through the metadata object only when requested via `Options`. - Lossless roundtrip tests for all formats (BMP, PNG, WebP, TGA, TIFF, PPM, GIF) now verify pixel-perfect fidelity after write→read; animated roundtrip tests for GIF, WebP, and PNG verify per-frame pixel integrity. -- `StyledText::TextModifier::appendText()` gained a `Color` overload that creates (and caches per color) a solid fill paint, and `Graphics::fillFittedText()` now honors per-run style paints when every run carries one — enabling syntax-colored text. Single-color `StyledText` usage is unchanged. `Font` gained `isEmpty()`. +- `StyledText::TextModifier::appendText()` gained a `Color` overload that creates (and caches per color) a solid fill paint, and `Graphics::fillFittedText()` now honors per-run style paints when every run carries one - enabling syntax-colored text. Single-color `StyledText` usage is unchanged. `Font` gained `isEmpty()`. - New `Font` static loaders: `Font::loadFontFromData()`, `Font::loadFontFromFile()`, `Font::loadFontFromFirstAvailableFile()`, `Font::loadSerifSystemTextFont()` and `Font::loadMonospaceSystemTextFont()`, all returning `ResultValue` (`wasOk()` / `failed()` / `getValue()`). The former theme-local system font lookup helpers moved into `Font`; macOS/iOS use the CoreText system UI fonts, other platforms try well-known system font files. - `ImageFormatReader` and `ImageFormatWriter` gained a `deleteSourceWhenDestroyed` constructor parameter, defaulting to `true` so existing callers are unaffected. It lets a format be handed a stream it did not open and either take ownership of it - deleting it on destruction, as the C++ path has always done - or decline and leave the caller to own it, in which case the reader's / writer's owning pointer is released rather than deleted. The reader overload that accepts the caller's `InputStream` alongside the flag is what lets `ImageFormatManager::createReaderFor` hand a Python format the stream it opened without leaking it @@ -200,9 +586,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - The default theme now embeds JetBrains Mono Variable (SIL OFL) as its monospace font when `YUP_EMBED_DEFAULT_THEME_TEXT_MONOSPACE_FONT = 1` (forced on Emscripten), falling back to the system monospace font otherwise. The `tools/embed_font.py` regenerates the `.inc` byte arrays from any font file. - New `CodeDocument` (line-based text model with `UndoManager`-backed edits, positions, and incremental change notifications), `SyntaxDefinition` (JSON-driven language descriptions loaded from data/files or the built-in C++ / GLSL / Python definitions), `CodeTokeniser` (incremental per-line tokenizer with a line-state machine for multi-line constructs and lazy re-tokenization), and the `CodeEditor` component: syntax-highlighted editing, caret/selection with anchor semantics, clipboard, undo/redo, read-only, smart auto-indent, an optional line-number gutter with breakpoint markers, find/replace (find-all, next/previous with wrap, replace-one, replace-all in one undo step, match highlighting), bracket matching, and an optional minimap overview. Defaults to the theme's monospace font. See `docs/ui/code-editor.md`. - Added a built-in XML `SyntaxDefinition` (available as `SyntaxDefinition::getBuiltIn ("xml")` and matched for `.xml`, `.svg`, `.html`, `.xaml` and other markup extensions), with `` block comments, tag/attribute punctuation and `` / `` operator highlighting. The `CodeEditor` demo now has a language dropdown to switch between the built-in C++ / GLSL / Python / XML definitions. +- Added a built-in YDSP `SyntaxDefinition` (`SyntaxDefinition::getBuiltIn ("ydsp")`, matched for `.ydsp`), covering the language's keywords, primitive types and Faust-style composition operators (`<:`, `:>`, `->`, `~`, …). Used by the `yup_dsp_jit` "YDSP Synths" example's new Editor tab. - Fixed `CodeDocument`: `newLineChars` was default-constructed to an empty string instead of `"\n"`, breaking `getText()`, `getTextInRange()`, and character-offset calculations for all multi-line documents; `applyEdit()` returned a wrong caret column for single-line insertions (omitted `startIndex`), making every subsequent undo call operate on an inverted range and silently no-op; removed the `endsWithNewline` special case that returned a pre-newline position and similarly broke undo for Enter at the beginning of a line or in the middle of a line. - Fixed `CodeEditor`: `undo()` and `redo()` now clamp `caretPosition` to the new document length and clear the selection after each operation, preventing an out-of-bounds caret after undo shrinks the document; `replaceNext()` now uses the position returned by `replaceRange` instead of `selectionStart + replacement.length()`. -- Fixed `CodeTokeniser`: cutting or deleting text that removes one or more lines left the token cache larger than the document and the forward-propagation stability check could declare a line whose content had shifted "unchanged", returning stale tokens (wrong syntax colors) for every line below the cut point. `codeDocumentChanged` now shrinks the cache to the new document line count and proactively marks all shifted lines dirty before the stability pass runs. The same problem existed in the other direction and was more visible in practice: inserting a line (pressing Enter, or a multi-line paste) grew the document but `codeDocumentChanged` had no branch for it at all, so every cached entry at or after the edit point kept referring to whatever used to be at that index — one or more lines off from where it actually was — and the stability check could decide a shifted-in line's state was "unchanged" and never mark it dirty, leaving it with stale, wrongly-sized tokens that fail to tile the line and fall back to unhighlighted plain text. Both directions are now handled the same way: resize to the new line count and mark everything from the edit point to the new end dirty, so misaligned cache entries are always discarded and recomputed from the live document text rather than reused. +- Fixed `CodeTokeniser`: cutting or deleting text that removes one or more lines left the token cache larger than the document and the forward-propagation stability check could declare a line whose content had shifted "unchanged", returning stale tokens (wrong syntax colors) for every line below the cut point. `codeDocumentChanged` now shrinks the cache to the new document line count and proactively marks all shifted lines dirty before the stability pass runs. The same problem existed in the other direction and was more visible in practice: inserting a line (pressing Enter, or a multi-line paste) grew the document but `codeDocumentChanged` had no branch for it at all, so every cached entry at or after the edit point kept referring to whatever used to be at that index - one or more lines off from where it actually was - and the stability check could decide a shifted-in line's state was "unchanged" and never mark it dirty, leaving it with stale, wrongly-sized tokens that fail to tile the line and fall back to unhighlighted plain text. Both directions are now handled the same way: resize to the new line count and mark everything from the edit point to the new end dirty, so misaligned cache entries are always discarded and recomputed from the live document text rather than reused. - Fixed `CodeDocument::setText()` freezing for several seconds on a large paste: its line-splitting helper indexed the (UTF-8-backed) input `String` by character position inside the split loop, and both `operator[]` and `length()` are O(n) for UTF-8, turning the split into O(n²). It now walks the text once with a `CharPointer`. - Fixed `CodeEditor` drawing selected/highlighted/caret text past the gutter and minimap when scrolled horizontally, since nothing clipped that content to the text area; the gutter's painted background also stopped 4px short of where the text area actually starts (disagreeing with the hit-test boundary used by `mouseDown()`), reading as misalignment on the left. The minimap overview now merges lines that map to less than one device pixel row into a single bar instead of issuing one `fillRect()` per source line on every paint regardless of visibility. - Fixed `StyledText::update()` calling `Font::getPath()` (a CoreText round-trip on Apple platforms) once per glyph *occurrence* instead of once per unique glyph; a glyph's outline is the same every time for a given font, so it's now cached and reused, cutting a measured 240ms of the 453ms spent reshaping text on a single keystroke. @@ -297,7 +684,7 @@ The `FlexBox` and `Grid` containers landed in this cycle (they were previously l - New `GpuBuffer` class (`rhi/yup_GpuBuffer.h`): reference-counted GPU buffer handle wrapping a backend-native GPU buffer. `GpuBuffer::create(ctx, GpuBufferType, data, byteSize)` uploads immutable vertex/index/uniform data for use with `GpuRenderPass`. - `Image::fromTexture(GpuTexture::Ptr)`: creates an `Image` wrapping an existing GPU texture (no CPU round-trip). Suitable for `Graphics::drawImage()`. - `Graphics::drawTexture(GpuTexture::Ptr, Rectangle)`: draws a GPU texture directly without materialising an `Image`, avoiding CPU-side ImagePixelData allocation. -- `GpuRenderPass` no longer creates a sampler and a uniform buffer per draw. The linear/clamp-to-edge samplers that fill a layout's sampler bindings are created once when the `GpuPipeline` is compiled, and uniform buffers come from a size-bucketed pool on the `GpuDevice` that recycles them when a frame reports GPU completion — so a steady-state workload stops allocating GPU objects after its first frames. `GpuFrame` stays stack RAII; nothing changes for callers. +- `GpuRenderPass` no longer creates a sampler and a uniform buffer per draw. The linear/clamp-to-edge samplers that fill a layout's sampler bindings are created once when the `GpuPipeline` is compiled, and uniform buffers come from a size-bucketed pool on the `GpuDevice` that recycles them when a frame reports GPU completion - so a steady-state workload stops allocating GPU objects after its first frames. `GpuFrame` stays stack RAII; nothing changes for callers. - Fixed the GLSL→WGSL transpiler rejecting comma-separated members in a struct or interface block (`uniform Params { float s, r, rx, ry; }`), which failed with `Expected ';'`. Each declarator now becomes its own member and binds its own array specifiers. #### Shader Compiler (#126 and #130) @@ -310,6 +697,13 @@ The `FlexBox` and `Grid` containers landed in this cycle (they were previously l - New standalone `yup_shader_bundler` console tool (`cmake/tools/shader_bundler`): takes a `.vert` and `.frag` GLSL (v450 Vulkan dialect) pair on disk and produces a single `.ysl` bundle containing transpiled variants for all target languages (GLSL/ESSL/HLSL/MSL). - New `yup_add_shader_bundle()` CMake helper (`cmake/yup_shader_bundler.cmake`): builds the `yup_shader_bundler` tool for the host once (cached in the global property `YUP_SHADER_BUNDLER_EXECUTABLE`), runs it at configure time to generate the `.ysl`, and embeds it into a linkable object library via `yup_add_embedded_binary_resources`. Works even when the outer build is cross-compiling, since the tool is built in its own host binary tree without forwarding the cross toolchain. Accepts an `OPTIONS` argument that forwards arbitrary extra flags verbatim to `yup_shader_bundler` (e.g. `--spirv-opt`, `--target-langs`, `-DNAME=VALUE`, `-I`). +### DSP (`yup_dsp_jit`) + +- New `yup_dsp_jit` module (`modules/yup_dsp_jit`): YDSP, a realtime JIT-compiled audio DSP language compiled to native machine code via `asmjit_library` (x86-64 and AArch64). Full compiler pipeline (lexer, parser, type system with realtime-safety enforcement, optimiser, AsmJit backend) plus a zero-allocation realtime runtime (`YdspCompiler`, `YdspAudioGraph`). Supports Faust-style composition algebra and Cmajor-style `processor`/`graph` definitions, sample and block processing, history state, parameters and meters, and sidechain/scratch buffers. +- `YdspCompiler::compile()` gains an optional `importBasePath` argument: `import` directives in a patch now resolve relative to that directory (the patch's folder) instead of the process working directory, and nested imports inside an imported file resolve against that file's own folder. This makes multi-file patches loadable from disk; the "YDSP Synths" demo passes each patch's path and ships five importable effect processors in `data/synths/fx/` (`Delay`, `Compressor`, `Reverb`, `Distortion`, `Chorus`), one wired into the graph of each demo synth. +- Closed a set of silent-failure gaps found by an audit against the language spec: `min`/`max`/`clamp`/`abs`/`sign` gain dedicated integer opcodes (`minI`/`maxI`/`clampI`/`absI`/`signI`, branchless on both asmjit backends, compare+select on wasm) instead of only accepting float operands; endpoint annotations (`[[ key: value ]]`) now go through a whitelist that warns on an unrecognized key, matching every other annotation scope, and `unit`/`step`/`style` are plumbed all the way to `YdspParameterInfo` and the YDSP Synths demo's slider setup; `stream[N]` with `N != 1` is now a compile error instead of silently yielding mono; `buf[i] += x` and `s.field += x` (and every other compound-assignment operator, including the previously-missing `/=`/`%=`) now desugar correctly via a deep-cloned target instead of failing with "Unknown symbol ''"; the lexer accepts `.5`, `1.`, `0x1F`, `0b1010` and `1_000` literal forms and `\n`/`\t`/`\"`/`\\` string escapes; automating a non-`float32` parameter is now counted in `getDroppedEventCount()` instead of silently discarded; and the function inliner has a re-entrancy guard, so recursion the analyzer's own check misses now fails with a diagnostic instead of overflowing the host process stack. +- The one-per-test `YdspCompiler`/`YdspAudioGraph` recompiles in `yup_YdspGraphTests.cpp`'s `YdspElectricPianoTests` fixture and `yup_YdspExamplePatchTests.cpp`'s two shipped-patch sweeps are replaced with a compile-once cache (`yup_YdspTestPatches.h`'s `cachedPatch`/`restoreFreshState`) and a single merged sweep, cutting redundant JIT compiles in the test suite. + ### AI (`yup_ai`) - New `yup_ai` module (`modules/yup_ai`): LLM client and AI integration classes depending on `yup_core` and `yup_events`. @@ -327,7 +721,7 @@ The `FlexBox` and `Grid` containers landed in this cycle (they were previously l #### LLM Providers -- `LLMOpenAIChatClient` (`yup_LLMOpenAIChatClient.h`): OpenAI Chat Completions API — also compatible with Ollama, DeepSeek, OpenRouter, and llama-server. +- `LLMOpenAIChatClient` (`yup_LLMOpenAIChatClient.h`): OpenAI Chat Completions API - also compatible with Ollama, DeepSeek, OpenRouter, and llama-server. - `LLMOpenAIResponsesClient` (`yup_LLMOpenAIResponsesClient.h`): OpenAI Responses API (GPT-5+, reasoning models). - `LLMAnthropicClient` (`yup_LLMAnthropicClient.h`): Anthropic Messages API (Claude models). - `LLMGeminiClient` (`yup_LLMGeminiClient.h`): Google Gemini generateContent API. @@ -408,6 +802,7 @@ The `FlexBox` and `Grid` containers landed in this cycle (they were previously l ### Examples +- `SpinningCubeDemo` example (`examples/graphics`): rewritten to the new RHI shape - `GpuFrame` + `GpuCanvas::beginDraw` + `GpuRenderPass` for both the indexed cube draw and the separable two-pass blur (H+V sharing one `GpuFrame`), `isGpuAvailable()` capability probe, and live GLSL editing via `GpuPipeline::compileFromGlsl`. The default Lottie animation is now played back per-frame into an offscreen `GpuCanvas` (2D path) and sampled by the cube's fragment shader so the animation is texture-mapped onto every cube face. - `WidgetsDemo` example (`examples/graphics/source/examples/Widgets.h`): the placeholder image button is now a working `ImageHitTestButton` demonstrating `Component::hitTest` - it draws `data/logo.png` and samples the image's alpha at the hit point, so only the logo's opaque pixels are clickable and the transparent ones fall through to what is behind. The hover highlight goes through the same test, so moving the pointer over a transparent region inside the button's bounds drops it. - `SpinningCubeDemo` example (`examples/graphics`): rewritten to the new RHI shape — `GpuFrame` + `GpuCanvas::beginDraw` + `GpuRenderPass` for both the indexed cube draw and the separable two-pass blur (H+V sharing one `GpuFrame`), `isGpuAvailable()` capability probe, and live GLSL editing via `GpuPipeline::compileFromGlsl`. The default Lottie animation is now played back per-frame into an offscreen `GpuCanvas` (2D path) and sampled by the cube's fragment shader so the animation is texture-mapped onto every cube face. - `AIDemo` example (`examples/graphics/source/examples/AI.h`): interactive demo for all four LLM providers (OpenAI Chat, OpenAI Responses, Anthropic, Gemini) with model and API key configuration, system prompt editing, streaming and non-streaming completion, tool calling, MCP server integration, and embedded text generation. @@ -432,6 +827,7 @@ The `FlexBox` and `Grid` containers landed in this cycle (they were previously l ### Testing +- The `YdspOptimizerTests` suite (`tests/yup_dsp_jit/yup_YdspOptimizerTests.cpp`) is enabled and now exercises each optimizer pass - constant folding (including loop-carried induction registers, which must not fold), algebraic simplification, copy propagation, dead-code elimination and loop-invariant code motion - in addition to the existing IR-lowering and execution-report checks. The individual passes are exposed on `YdspOptimizer` so tests can drive them directly; the four-pass loop (constant folding, algebraic simplification, copy propagation, DCE) is wired into `YdspOptimizer::runPasses`, while LICM is exercised by its tests directly. - The AU and AUv3 wrapper tests no longer describe a stereo buffer list with a stack-allocated `AudioBufferList`. Only the first `AudioBuffer` is reserved inside the struct, so `AUStateTests.RenderProducesOutput` and the two `AUv3BypassRenderTests` render tests wrote past the object while filling `mBuffers[1].mDataByteSize`, aborting the suite under AddressSanitizer with a stack-buffer-overflow. All three now build their lists with a new `tests/yup_audio_plugin_client/yup_TestAudioBufferList.h` helper, which owns an allocation sized for the number of buffers asked for - The `yup_events` Python tests no longer depend on a single fixed-duration pump of the message loop. `next(juce_app)` runs the dispatch loop for 20ms and returns, but a dispatched callback still has to re-acquire the GIL before the Python side runs, so on a loaded machine it can land after the pump has already returned - `test_MessageListener::test_construct_and_post` failed this way on CI. The 22 call sites with a positive expectation now use a new `pump_until(app, predicate)` helper in `python/tests/utilities.py`, which pumps in short slices until the condition holds or a 5s timeout expires. The four sites that assert a *negative* after pumping ("still zero because it was cancelled") deliberately keep the fixed pump, since polling a negative predicate returns immediately and proves nothing - `Component` now befriends a single `ComponentTestHelper` class template instead of accumulating one friend class per test suite; unit tests specialize it (e.g. `ComponentTestHelper`, `ComponentTestHelper`) to reach private state. @@ -441,6 +837,7 @@ The `FlexBox` and `Grid` containers landed in this cycle (they were previously l ### Bug Fixes +- YDSP state is now segmented as `[scalars][arrays]` and the kernel ABI carries both base pointers (`state` + new `stateArrays` in `YdspKernelContext`/`YdspEventContext`, with `YdspCodegen::stateScalarSize` reporting the split). Every scalar slot (including the ring write-pointers of the `@` delay primitives) lives in the scalar segment head, and all arrays follow in their own segment. Previously, scalars were addressed at byte offsets that grew with array state (e.g. 50476 for a reverb kernel), which AArch64 could not encode as an LDR/STR immediate and failed to assemble with `InvalidDisplacement` (`ldur w2, [x3, 50476]`). Array state can now grow arbitrarily (delay lines, reverb rings) without pushing scalar slots out of range; an out-of-range materialization fallback remains as defense in depth. The optimiser's per-type array-base shift pass is gone (array bases are per-type element indices into the array segment). - SDL windowing: partial repaints now grow the dirty area by half a pixel before rounding it out to whole pixels. Rive applies rectangular clips as anti-aliased coverage rather than a pixel-exact scissor, so geometry touching a component's clip edge could bleed a tiny coverage into the adjacent pixel row; with a preserved render target that row was never redrawn and the bleed accumulated into a persistent line just outside components that repaint continuously (visible around the `SpectrogramComponent` at 1x scale). The extra border lets the parent repaint those pixels every frame. - AUv2 wrapper: an input bus that is not fed during a render cycle is now presented to the processor as a null-channel view. `buildInputBusViews` filled the per-bus channel pointers only for the channels it actually received, leaving the rest at whatever the previous render had stored there, so a sidechain input the host stopped feeding (inactive element or a failed `PullInput`) kept pointing at that element's stale audio instead of reading as silent - contradicting the comment on `pullAuxiliaryInputElements` and the `AudioBusBufferView` "null for an inactive or silent bus" contract. The input path now clears each bus's slots first, exactly as `buildOutputBusViews` already did for outputs; the AUv3 wrapper never had the problem because it maps input views onto its own scratch buffers - `MessageManager` on Apple platforms: `runDispatchLoop()` and `runDispatchLoopUntil()` now wrap their loop body in `YUP_TRY`/`YUP_CATCH_EXCEPTION`, matching the generic implementations in `yup_MessageManager.cpp` that `#if ! (YUP_APPLE || YUP_WASM)` compiles out on these platforms. The `.mm` replacements previously caught only `NSException`, a disjoint set from `std::exception`, so a C++ exception thrown by a message callback escaped the dispatch loop instead of reaching `YUPApplicationBase::sendUnhandledException()` — which made `unhandledException()` unreachable on macOS and iOS, and killed the application on the first failure. @@ -452,7 +849,7 @@ The `FlexBox` and `Grid` containers landed in this cycle (they were previously l - `StyledText` caret bounds, hit-testing and selection rectangles now use line-relative glyph x positions computed with the same accumulation as drawing, instead of rive's paragraph-relative `GlyphRun::xpos`. Character positions were wrong on soft-wrapped lines (off by the width of all preceding text in the paragraph) and selection was drawn shifted on wrapped text; the caret at the first character of a wrapped line now lands on that line's left edge. - iOS applications now use the `UIScene` lifecycle, removing UIKit's legacy lifecycle warning and ensuring SDL windows are created for the connected scene. - Offscreen GPU rendering now supports recursive targets on Metal, OpenGL/GLES, and D3D11, so Lottie alpha/luma mattes, isolated-opacity layers, and cached precomps retain GPU compositing when rendered into an `Image` or `GpuCanvas`. Each `RenderableTarget` leases a Rive render context exclusively for its lifetime and returns it to the pool when destroyed. Repeated Lottie matte and precomp renders now reuse their canvases rather than allocating GPU textures each frame. Metal child targets allocate only their Rive render-canvas output texture; the CPU readback staging texture is created only when pixels are requested. -- Fixed undefined offscreen contents when nesting pooled render targets on all GPU backends. Render context slots were recycled whenever no frame was currently active, so two long-lived targets could share one slot; once their frames nested — which happens as Lottie matte and precomp layers cross their in/out points and the nesting order changes between frames — the inner target skipped `beginFrame` and was then flushed against the outer target's frame descriptor. +- Fixed undefined offscreen contents when nesting pooled render targets on all GPU backends. Render context slots were recycled whenever no frame was currently active, so two long-lived targets could share one slot; once their frames nested - which happens as Lottie matte and precomp layers cross their in/out points and the nesting order changes between frames - the inner target skipped `beginFrame` and was then flushed against the outer target's frame descriptor. - Lottie: a matte layer no longer paints another matte layer's content. Drawing a matte result only queues a reference to its canvas texture, which the enclosing frame resolves at flush time, but the canvas lease was released as soon as the layer finished. Since every matte in a composition is sized to the same fitted rectangle, the pool handed the same canvas triple to the next matte layer, which overwrote the pixels already queued and left only the last matte visible (e.g. `world_locations.json`'s four matted dots collapsed to one and its continent outlines disappeared; `insta_camera.json` lost its animated circles). Leases are now held until the composition render completes. - `GpuFrame` now waits for the GPU before releasing the texture views, uniform buffers and samplers it keeps alive for its encoded render passes. Those passes reference them by raw pointer, and `submit()` does not block, so letting a frame go out of scope freed them while the GPU was still reading — corrupting the pass output progressively, as the freed memory only starts being handed back out after the allocator has churned for a while (the growing magenta flashes in `bell.json`). `waitForGPU()` is now only needed explicitly when results are required before the end of the frame's scope, and is idempotent so waiting explicitly costs no more than one stall. Move-assignment drains the frame it replaces for the same reason. - `AffineTransform::getScaleFactor()` is now independent of rotation. It averaged the absolute values of the matrix diagonal and ignored the shear terms, so a rotated transform reported `scale * cos(angle)` — falling to zero at 90 degrees. It now measures the lengths of the transformed basis vectors. Lottie precomposition and matte canvases are sized from this value, so a layer under an animated rotation (e.g. `bell.json`, whose precomposition is parented to a rotating null) requested a different pixel size on every frame, reallocating its canvases mid-frame and flashing while a queued draw still referenced the previous ones. @@ -468,9 +865,9 @@ The `FlexBox` and `Grid` containers landed in this cycle (they were previously l - `ArtboardFile::AssetInfo::uniquePath` was a `File` holding `rive::FileAsset::uniqueFilename()`, which is a bare file name (`"logo-1234.png"`) and not an absolute path — so every asset-resolving load hit `jassertfalse` in `File::parseAbsolutePath()` and then silently resolved the name against the current working directory. Renamed to `uniqueFilename` and retyped as a `String`; resolve it against your own asset directory with `File::getChildFile()`. - `ArtboardFile::load()` ignored the result of `readIntoMemoryBlock()`, so a stream that yielded nothing was reported as `"Malformed artboard file"` rather than as a read failure. - `GpuCanvas::beginDraw()` now drops the target's cached `GpuTexture` wrap, as its documentation already claimed. The wrap memoizes the Rive texture handle it resolved, so a pooled canvas reused across frames kept handing out the handle resolved on the frame it was first sampled. -- Lottie: a failed matte composite no longer blits undefined GPU memory over the matted layer. The result canvas is written only by the composite render pass — nothing else clears it, and its backing texture is allocated uninitialized — but the pass result was ignored and the texture composited regardless, flashing an arbitrary color. The renderer now falls back to the geometric-clip matte path when the composite fails. +- Lottie: a failed matte composite no longer blits undefined GPU memory over the matted layer. The result canvas is written only by the composite render pass - nothing else clears it, and its backing texture is allocated uninitialized - but the pass result was ignored and the texture composited regardless, flashing an arbitrary color. The renderer now falls back to the geometric-clip matte path when the composite fails. - Lottie: a paint-less nested group now contributes its geometry to the enclosing group's paints with its own modifiers applied. The geometry was rebuilt from raw shapes, dropping the nested group's trim, repeater, merge-paths and rounded-corner modifiers, which is what defines the outline: RubberHose rigs draw a limb as a 4-point star trimmed to a quarter, so the parent stroke painted the whole star instead of an arc (the stray stars in `mughead.json` and `pumped_up.json`). -- Lottie: track mattes (alpha, alpha-inverted, luma, luma-inverted) now composite the matte source's *rendered alpha* — including its fill opacity, gradients, and anti-aliased edges — instead of hard-clipping the target to the source silhouette. The matte source and target are rendered into offscreen GPU buffers (sized to the fitted on-screen resolution) and multiplied by a fullscreen matte-composite shader. A partially transparent matte source now shows through correctly (e.g. `matte_two_item_with_lowerlayer.json`, whose 65%-opacity source blends the white matted ellipse to pink over the red layer beneath). Falls back to the previous geometric-clip behaviour when no GPU is available (e.g. headless rendering). +- Lottie: track mattes (alpha, alpha-inverted, luma, luma-inverted) now composite the matte source's *rendered alpha* - including its fill opacity, gradients, and anti-aliased edges - instead of hard-clipping the target to the source silhouette. The matte source and target are rendered into offscreen GPU buffers (sized to the fitted on-screen resolution) and multiplied by a fullscreen matte-composite shader. A partially transparent matte source now shows through correctly (e.g. `matte_two_item_with_lowerlayer.json`, whose 65%-opacity source blends the white matted ellipse to pink over the red layer beneath). Falls back to the previous geometric-clip behaviour when no GPU is available (e.g. headless rendering). - Lottie: `EllipseShape` paths now start at the top (12 o'clock) and follow the shape direction (clockwise for `d == 1`, counter-clockwise for `d == 3`), matching Lottie's convention. Previously they started at the right (3 o'clock) going counter-clockwise, which placed trimmed arcs at the wrong position (e.g. the expanding rings in `world_locations.json` were cut short on the right). - `Path::withRoundedCorners()` left one corner sharp on closed subpaths whose geometry ended with an explicit segment back to the start vertex (as produced by Lottie bezier `toPath()`). The duplicated start/end point formed a zero-length edge that made that corner degenerate. The trailing duplicate is now dropped, and corners are rounded with a cubic arc (circle kappa) instead of a single quadratic through the vertex, so a square with a full Round Corners modifier becomes a proper circle (e.g. the morphing loader shape in `loader.json`). - Lottie: trailing top-level modifiers (trim, repeater, rounded-corner) in a shape layer now apply to every preceding top-level group in the run, not just the last one, so a single trim animates all shapes it should (e.g. the knife in `it's_lunch_time!.json`, and the segmented strokes in `imprint.json` / `fingerprint_success.json`). Trailing paints similarly reach all preceding paint-less groups. @@ -481,9 +878,9 @@ The `FlexBox` and `Grid` containers landed in this cycle (they were previously l - Lottie: the AfterEffects inertial-bounce ("overshoot") position expression (`amp`/`freq`/`decay`) is now approximated via `AnimationTransform` `InertialBounceParams`, producing the decaying oscillation past the last position keyframe. Fixes elements that dropped in without the expected bounce (e.g. `windmill.json`). - Lottie / `AnimationRenderer::renderComposition`: content that extends beyond the composition viewport (e.g. shapes with coordinates outside the `w`/`h` bounds, as in `jolly_walker.json`) now clips to the fitted composition rectangle instead of the full target bounds, so it no longer spills into the letterbox / pillarbox area when the target rectangle is not the composition's aspect ratio. - `AnimationTransform::positionAt()` spatial bezier motion paths were nearly straight instead of curved: the second control point used the *next* keyframe's incoming tangent (`k1.tangentIn`) rather than the current segment's own tangent (`k0.tangentIn`). In Lottie both `to` and `ti` belong to the keyframe starting the segment, so a circular motion path (e.g. a shape orbiting on a bezier arc) collapsed toward linear interpolation. -- OpenGL / WebGL: the main frame's rive flush went silently blank (draws degenerate, screen frozen on the last good frame) whenever a `GpuCanvas` committed mid-frame. `endOffscreen()`'s `unbindGLInternalResources()` wipes the shared GL texture units, but the main render context's internal textures (tessellation/gradient/feather/atlas) were only rebound at `begin()` — before `paint()` — so any offscreen 2D flush during paint left the main flush sampling incomplete textures (no GL error; GLES returns zeros). The GL backend now calls `invalidateGLState()` on the flushing context immediately before every `flush()` (main frame and offscreen), making each flush self-contained regardless of how many rive/ore contexts interleave on the one real GL context. Fixes `SpinningCubeDemo` on WASM/WebGL2 appearing frozen (with sporadic 5-15 s updates) and the page turning sluggish while the app still reported ~57 FPS. -- `Graphics::drawTexture` / `drawImage` / transparency layers rendered nothing (transparent) whenever the rive frame ran in atomic interlock mode — always the case on the iOS simulator, and on any platform when raster ordering is disabled. The composite was implemented as a path draw with an image paint, which atomic-mode shaders cannot sample; `Graphics::renderTexture` now routes through `rive::Renderer::drawImage`, which falls back to a dedicated image-rect draw in atomic mode. Fixes invisible Lottie precomps/mattes, `GpuCanvas` composites, and the SpinningCube demo output on the iOS simulator. -- OpenGL / WebGL: `GpuCanvas` textures drawn with `Graphics::drawTexture` (Lottie precomp caches and matte composites) rendered vertically flipped, because the GL canvas source texture is stored bottom-up. `GpuTexture::getOrAdoptGpuTexture()` now prefers the Y-flipped sampled mirror — kept fresh at each canvas flush — matching what `GpuRenderPass` already did for sampled inputs. No change on Metal/D3D, where the mirror is null. +- OpenGL / WebGL: the main frame's rive flush went silently blank (draws degenerate, screen frozen on the last good frame) whenever a `GpuCanvas` committed mid-frame. `endOffscreen()`'s `unbindGLInternalResources()` wipes the shared GL texture units, but the main render context's internal textures (tessellation/gradient/feather/atlas) were only rebound at `begin()` - before `paint()` - so any offscreen 2D flush during paint left the main flush sampling incomplete textures (no GL error; GLES returns zeros). The GL backend now calls `invalidateGLState()` on the flushing context immediately before every `flush()` (main frame and offscreen), making each flush self-contained regardless of how many rive/ore contexts interleave on the one real GL context. Fixes `SpinningCubeDemo` on WASM/WebGL2 appearing frozen (with sporadic 5-15 s updates) and the page turning sluggish while the app still reported ~57 FPS. +- `Graphics::drawTexture` / `drawImage` / transparency layers rendered nothing (transparent) whenever the rive frame ran in atomic interlock mode - always the case on the iOS simulator, and on any platform when raster ordering is disabled. The composite was implemented as a path draw with an image paint, which atomic-mode shaders cannot sample; `Graphics::renderTexture` now routes through `rive::Renderer::drawImage`, which falls back to a dedicated image-rect draw in atomic mode. Fixes invisible Lottie precomps/mattes, `GpuCanvas` composites, and the SpinningCube demo output on the iOS simulator. +- OpenGL / WebGL: `GpuCanvas` textures drawn with `Graphics::drawTexture` (Lottie precomp caches and matte composites) rendered vertically flipped, because the GL canvas source texture is stored bottom-up. `GpuTexture::getOrAdoptGpuTexture()` now prefers the Y-flipped sampled mirror - kept fresh at each canvas flush - matching what `GpuRenderPass` already did for sampled inputs. No change on Metal/D3D, where the mirror is null. - SDL3 windowing: mouse move/drag was broken on touch platforms (iOS, Android). Motion was synthesized only by polling `SDL_GetGlobalMouseState`, which has no backend implementation there and falls back to window-relative coordinates, so subtracting the window position shifted every move. Touch platforms now consume the touch-synthesized `SDL_EVENT_MOUSE_MOTION` events directly; desktop keeps the global-cursor poll (needed for embedded plugin editors). - SDL3 windowing: mouse drag events were lost inside embedded plugin editors (notably on macOS, where the host owns the native application so SDL never receives Cocoa mouse focus and suppresses drag motion). Dragging is now synthesized by polling the global cursor while a button is held, on the message thread, for all platforms. - `Slider` could get stuck showing its hover color after a touch drag: `mouseEnter`/`mouseExit` never fire for touch (no hover phase, and drag capture bypasses them for the mouse too), so releasing outside the slider's bounds left the hover state on. `mouseUp` now clears it directly when the pointer isn't over the slider anymore. @@ -498,6 +895,7 @@ The `FlexBox` and `Grid` containers landed in this cycle (they were previously l ### Documentation - Added a dedicated DSP documentation area (`docs/dsp/`) covering `yup_dsp` end to end: math/windowing/noise, FFTs and spectral analysis, filter design and filter implementations, dynamics and metering, onset detection, convolution and delay, resampling, and time-stretching/pitch-shifting +- `docs/dsp/yup-dsp-language.md` (the YDSP language reference) is now linked from `docs/dsp/index.md`'s toctree - it previously built but was unreachable from the docs site. Its §2.7 EBNF now covers the bitwise operators (`& | ^ ~ << >>`, at their actual precedence, which is tighter than comparisons unlike C) that were already implemented but undocumented; §2.8 lists the previously-undocumented `asinh`/`acosh`/`atanh`/`round`/`copysign` intrinsics and the new integer overload of `min`/`max`/`clamp`/`abs`/`sign` (including the `abs(INT_MIN)` edge case); and §2.7/§3.2 each gain a sentence clarifying that unary `~` (bitwise not) and the graph algebra's binary `~` (recursion) are unrelated operators in separate grammars, not an overload of one operator. --- diff --git a/CLAUDE.md b/CLAUDE.md index cf9808150..851db0580 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,192 +1,54 @@ -# AI Assistant Guidelines for YUP Project +# AI Assistant Guidelines for YUP -This document provides directive guidelines for AI assistants working on the YUP project. Use these rules when generating, reviewing, or suggesting code changes. +Directive rules for generating, reviewing and suggesting code in YUP. Apply them on every task. -## Project Context -- **Project Type:** C++ graphics/audio library -- **License:** ISC License -- **Copyright:** `Copyright (c) 2026 - kunitoki@gmail.com` -- **Based On:** Fork of JUCE7 ISC Modules -- **Build System:** CMake -- **Testing Framework:** Google Test -- **Primary Dependencies:** Rive, OpenGL/Metal/D3D -- **C++ Standard**: >= C++20 +## Project -## Code Generation Rules +C++20 graphics/audio library, ISC licensed, forked from the JUCE7 ISC modules. CMake build, Google Test, Rive + OpenGL/Metal/D3D. +Copyright line for new files: `Copyright (c) 2026 - kunitoki@gmail.com`. -**NEVER EVER run bash commands to configure, compile or test the implementation, acknowledge that we should test and we'll run and report any issue.** -**If you see changes in code you didn't notice before, it's not the linter, it's me changing it for a reason and you should acknowledge the changes, not revert them.** -**Avoid polluting implementation files with obvious comments, make the code more expressive instead** +## Hard Rules -## AI Decision Making Rules +- **Never run bash commands to configure, compile or test.** Say what should be tested; the user runs it and reports back. +- **Code that changed without you noticing is the user's doing, not a linter's.** Acknowledge it, never revert it. +- Headers and implementation files are compiled through the main module header/cpp - linter errors when parsing them in isolation are expected. -### Always: -1. **Rely on the C++20 language and standard library** so use it (unless the feature is not supported in all YUP's platforms) -2. **Check existing patterns** in similar modules first -3. **Use YUP conventions** for similar functionality -4. **Use YUP infrastructure** instead of reinventing the wheel -5. **If the same functionality can be provided with less code and complexity** prefer less code -6. **Always prefer reusing code than creating duplicated code** -7. **Prefer composition over inheritance** -8. **Make classes small and focused** (single responsibility) -9. **Use const-correctness** throughout -10. **Do not leak internal details** -11. **Follow the open-closed principle** -12. **Never assume we use plain JUCE7 functionality, always check APIs** as they might have evolved +## Design Rules -### When implementing new features: -1. **Always provide extensive and useful doxygen documentation** for public APIs -2. **Make sure new code is always tested** +1. Use C++20 and the standard library, unless the feature is unsupported on a YUP platform. +2. Check existing patterns in similar modules first; reuse YUP infrastructure instead of reinventing or duplicating it. +3. Never assume plain JUCE7 APIs - verify them, they may have evolved (see *Differences with JUCE*). +4. Prefer less code. If 200 lines could be 50, rewrite it. Nothing speculative, no abstraction for single-use code. +5. Composition over inheritance. Small single-responsibility classes, open-closed, no leaked internals. +6. Const-correct throughout. Flat code with early exits over deep nesting. +7. RAII and smart pointers, no raw ownership. Consider thread safety where it applies. +8. Adapt or replace an existing implementation - never copy-and-modify and leave both behind. +9. Don't pollute implementation files with obvious comments. +10. Extensive Doxygen docs on public APIs and public build-system methods. +11. Test-first where practical - a bug fix starts with a failing test. New code is always tested; refactors keep tests passing, API contracts intact, platform-specific layout preserved and performance in mind. +12. Update `docs/` with effective and user targeted documentation (no extensive internal details if not needed) and add a **brief** `CHANGELOG.md` entry when the change warrants it. +13. Surgical edits: every changed line traces to the request. Don't reformat or refactor adjacent code; do clean up orphans your own change created. +14. Avoid the use of em-dashes, just use `-`. -### When writing tests: -1. **Test primarily public interfaces only** -2. **Cover normal, edge, and error cases** -3. **Use descriptive test names** (e.g., `ReturnsNullForInvalidInput`) -4. **Group related tests** in test fixtures -5. **Keep tests independent** and deterministic -6. **Never Use C or C++ macros (like M_PI)** use yup alternatives +## Style -### When suggesting refactoring: -1. **Maintain existing API contracts** -2. **Follow established module patterns** -3. **Preserve platform-specific code organization** -4. **Update tests accordingly** -5. **Consider performance implications** -6. **Keep API usage simple and effective** +Formatting is enforced by `.clang-format` (Allman braces, 4-space indent, no column limit, `Type* ptr` alignment, space before non-empty parens: `foo (x)`, `TEST_F (Fixture, name)`). -### 1. File Headers -**ALWAYS** start new files with this exact header: +- `PascalCase` types; `camelCase` for functions, variables, members and constants. +- One main class per file, named `yup_ClassName.h` / `yup_ClassName.cpp`. +- `using namespace` only in test files; elsewhere scope it to the smallest block. +- Include order: own module header → other YUP modules → same-module headers → external libraries (Rive) → standard library. -```cpp -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2026 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ -``` - -### 2. Module Headers -For main module headers (e.g., `yup_graphics.h`), include this declaration block after the file header: - -```cpp -/* - ============================================================================== - - BEGIN_YUP_MODULE_DECLARATION - - ID: module_name - vendor: yup - version: 1.2.3 - name: Module Display Name - description: Brief module description - website: https://github.com/kunitoki/yup - license: ISC - - dependencies: yup_graphics [other_dependencies] - searchpaths: native - - END_YUP_MODULE_DECLARATION - - ============================================================================== -*/ -``` - -Refer to `./docs/YUP Module Format.md` for more info if needed. For yup_* modules, the version number should be the same as all the others. - -### 3. Formatting Rules (Allman Style) - -### 4. Naming Conventions -- **Classes:** `PascalCase` (e.g., `GraphicsContext`) -- **Functions:** `camelCase` (e.g., `createRenderer`) -- **Variables:** `camelCase` (e.g., `currentState`) -- **Constants:** `camelCase` (e.g., `defaultSize`) -- **Member variables:** `camelCase` (e.g., `bufferSize`) -- **Files:** `yup_ClassName.h/cpp` for classes, one file per main class - -### 5. Include Order -```cpp -#pragma once - -// 1. Own module header (if in .cpp file) -#include - -// 2. Other project modules -#include "yup_core/yup_core.h" - -// 3. Same module headers -#include "graphics/yup_Color.h" -#include "primitives/yup_Point.h" - -// 5. External libraries (Rive, etc.) -#include - -// 4. Standard library -#include -#include -``` - -### 6. Namespace Usage -```cpp -// NEVER use "using namespace" except in test files -using namespace yup; - -// Prefer limited scope usage -TEST (MyClassTests, someFunction) -{ - using namespace std::chrono; -} -``` - -## File Organization Patterns - -### Module Structure -``` -modules/yup_module_name/ -├── yup_module_name.h // Main module header -├── yup_module_name.cpp // Main module implementation -├── yup_module_name.mm // Objective-C++ (Apple platforms) -├── subdirectory/ // Logical groupings -│ ├── yup_ClassName.h -│ └── yup_ClassName.cpp -└── native/ // Platform-specific code - ├── yup_ClassName_android.cpp - ├── yup_ClassName_windows.cpp - ├── yup_ClassName_linux.cpp - ├── yup_ClassName_wasm.cpp - ├── yup_ClassName_emscripten.cpp - ├── yup_ClassName_mac.mm - ├── yup_ClassName_ios.mm - └── yup_ClassName_apple.mm -``` -Avoid going deeply nested into modules. Prefer a single subdirectory whenever possible for YUP modules (might be ok for thirdparties as we don't control the upstream structure). +## Files and Layout -**Headers and Implementation files are designed to be included through the main module header/implementation, so linter errors are expected when parsing the files in isolation.** +- New files start with the ISC header - copy it from `modules/yup_dsp_jit/yup_dsp_jit.h`, which already carries the correct `2026` year (many older files still say `2024`, and ported JUCE files carry an extra JUCE attribution block that must not be reused). Headers then open with `#pragma once`. +- Module headers add the `BEGIN_YUP_MODULE_DECLARATION` block right after it (ID, vendor `yup`, version, name, description, website, license `ISC`, dependencies, `searchpaths: native`). Same file is the exemplar; all `yup_*` modules share one version number. Details in `docs/build-system/module-format.md`. +- Module layout: `modules/yup_module_name/` holding `yup_module_name.h` / `.cpp` / `.mm`, one level of subdirectory for logical groups, and `native/` for platform code named `yup_ClassName_.cpp` - `android`, `windows`, `linux`, `wasm`, `emscripten`, plus `mac` / `ios` / `apple` as `.mm`. Avoid deep nesting (third-party trees excepted, we don't control them). +- Modules are unity builds assembled by files in the root which should also resolve global includes, subfolder files are just included there and should not include anything on their own. +- Tests live in `tests/module_name/yup_ClassName.cpp`, one per class, plus `yup_ModuleIntegration.cpp` for integration tests. -### Test Structure -``` -tests/module_name/ -├── ModuleClassName.cpp // Test file per class -└── ModuleIntegration.cpp // Integration tests -``` - -## Class Design Templates +## Class Template -### YUP-Style Class (with leak detector) ```cpp class YupStyleClass { @@ -197,134 +59,35 @@ public: void publicMethod(); private: - void privateMethod(); - int memberVar; YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (YupStyleClass) }; ``` -## Testing Patterns - -### Test File Template -```cpp -#include - -#include - -using namespace yup; - -namespace -{ - -// Test helpers and constants, prefer move them into fixtures so they don't clash in unity builds -constexpr int kTestValue = 42; - -class TestHelper -{ -public: - static void setupTestData() { /* ... */ } -}; - -} // namespace - -class ClassNameTests : public ::testing::Test -{ -protected: - void SetUp() override - { - // Setup before each test - } - - void TearDown() override - { - // Cleanup after each test - } - - // Test fixtures - ClassName instance; -}; - -TEST_F (ClassNameTests, ConstructorInitializesCorrectly) -{ - EXPECT_TRUE (instance.isValid()); - EXPECT_EQ (0, instance.getValue()); -} - -TEST (ClassNameTests, StaticMethodBehavesCorrectly) -{ - auto result = ClassName::staticMethod(); - EXPECT_NE (nullptr, result.get()); -} -``` - -### Platform-specific code: -```cpp -#if YUP_WINDOWS // Windows -#elif YUP_MAC // macOS -#elif YUP_IOS // iOS -#elif YUP_LINUX // Linux -#elif YUP_ANDROID // Android -#elif YUP_WASM // WebAssembly (including emscripten) -#elif YUP_EMSCRIPTEN // WebAssembly (only emscripten) -#elif YUP_DESKTOP // Windows/macOS/Linux -#elif YUP_MOBILE // Android/iOS -#endif -``` +## Testing -### Error handling patterns: -```cpp -// Use YUP Result or ResultValue for operations that can fail -yup::Result performOperation() -{ - if (preconditionFailed) - return yup::Result::fail ("Precondition not met"); +Mirror the structure of an existing test, e.g. `tests/yup_dsp/yup_KMeterState.cpp`. - return yup::Result::ok(); -} +- Include the module header and ``, then `using namespace yup;`. +- Test the public interface only, covering normal, edge and error cases. +- Descriptive names: `TEST_F (ClassNameTests, ReturnsNullForInvalidInput)`. +- Group related tests in a fixture; keep them independent and deterministic. +- Keep helpers and constants inside the fixture rather than at file scope - unity builds make file-scope names clash. +- Never use C/C++ macros such as `M_PI`; use the YUP alternatives. -yup::ResultValue maybeGetInteger() -{ - if (preconditionFailed) - return yup::makeResultValueFail ("Precondition not met"); +## Platform Guards - return 1; // or yup::makeResultValueOk (1) -} +`YUP_WINDOWS`, `YUP_MAC`, `YUP_IOS`, `YUP_LINUX`, `YUP_ANDROID`, `YUP_WASM` (any WebAssembly), `YUP_EMSCRIPTEN` (Emscripten only), `YUP_DESKTOP`, `YUP_MOBILE`. -// Use assertions for programming errors -void publicMethod (int value) -{ - jassert (value >= 0); // Debug builds only - if (value < 0) - return; // Graceful handling in release -} -``` +## Error Handling -## Code Review Checklist for AI - -Before suggesting code, verify: -- [ ] Proper file header with correct copyright -- [ ] Allman-style braces throughout -- [ ] Consistent naming conventions -- [ ] Proper include order and guards -- [ ] Const-correctness whenever applicable -- [ ] Prefer flatter code and early exits over overly indented code -- [ ] Aim at simplifying and removing duplicated code, prefer removing rather than adding -- [ ] When changing implementation, don't copy it and change it, adapt the existing or remove the old one once the new is in place and working -- [ ] Platform-specific code properly guarded -- [ ] Proper TDD and ensure tests cover new functionality -- [ ] No memory leaks (prefer RAII/smart pointers) -- [ ] Thread safety considerations if applicable -- [ ] Consistent documentation for public APIs or public build system methods -- [ ] Documentation in @docs/ is reviewed, added, updated or removed depending on the changes being done -- [ ] Changes are updated in @CHANGELOG.md, they should be brief +- Fallible operations return `yup::Result` (`Result::ok()` / `Result::fail ("...")`) or `yup::ResultValue` (`makeResultValueOk (v)` / `makeResultValueFail ("...")`; a plain `T` converts implicitly). +- `jassert` for programming errors, paired with a graceful early return for release builds. ## Differences with JUCE -- We use American english in YUP, so it's `center` and not `centred`, or `Color` and not `Colour` -- Always check the available API in the Graphics class, don't assume we use JUCE Graphics classes -- Graphics primitives have a template `.to` method not `toFloat` -- Fonts are obtained via ApplicationTheme, don't try to instantiate fonts inline - -This document should be referenced for every code generation, review, and suggestion task in the YUP project. +- American English: `center` not `centred`, `Color` not `Colour`. +- Check the YUP `Graphics` API - do not assume JUCE's. +- Graphics primitives convert with the template `.to()`, not `toFloat()`. +- Fonts come from `ApplicationTheme`, never instantiated inline. diff --git a/cmake/platforms/emscripten/shell.html b/cmake/platforms/emscripten/shell.html index 1e5c5704b..47a8db1fb 100644 --- a/cmake/platforms/emscripten/shell.html +++ b/cmake/platforms/emscripten/shell.html @@ -4,119 +4,588 @@ + + YUP! On Emscripten + - - + +
+
+ + YUP! +
+
+ checking + threads + audio off + midi off +
+
-
+
+ -
-
Downloading...
-
- -
+
Click the canvas to enable audio
-
+
+ - - Resize canvas - Lock/hide mouse pointer - - - +

+ Downloading module + +

+
+
-
+
+
+
+ + + +
+
+
-
- -
+ + + +`; +} diff --git a/modules/yup_dsp_jit/tools/vscode-ydsp/src/player.ts b/modules/yup_dsp_jit/tools/vscode-ydsp/src/player.ts new file mode 100644 index 000000000..b392d348f --- /dev/null +++ b/modules/yup_dsp_jit/tools/vscode-ydsp/src/player.ts @@ -0,0 +1,228 @@ +import * as path from "path"; +import { ChildProcess, spawn } from "child_process"; +import * as vscode from "vscode"; +import { errorMessage } from "./compiler"; + +/** Selected audio/MIDI devices, matching the `yup_dsp_compiler run` options. */ +export interface YdspDeviceSelection { + audioType?: string; + audioInput?: string; + audioOutput?: string; + midiInput?: string; + midiOutput?: string; +} + +export interface YdspPlayerRequest { + file: string; + verbose: boolean; + extraArguments: string[]; + devices: YdspDeviceSelection; +} + +export type YdspPlayerStatus = "idle" | "playing" | "stopping"; + +export interface YdspPlayerState { + status: YdspPlayerStatus; + running: boolean; + file?: string; +} + +const STOP_GRACE_PERIOD_MS = 2000; +const TAIL_LENGTH = 4096; + +/** Builds the `run` arguments for a request. */ +export function buildPlayerArguments (request: YdspPlayerRequest): string[] { + // Hot reload watches the entry file and everything it imports, so it works + // for standalone sources and projects alike. + const result = ["run", request.file, "--hotreload"]; + + const { audioType, audioInput, audioOutput, midiInput, midiOutput } = request.devices; + + if (audioType !== undefined && audioType.length > 0) + result.push ("--audio-type", audioType); + + if (audioOutput !== undefined && audioOutput.length > 0) + result.push ("--audio-output", audioOutput); + + if (audioInput !== undefined && audioInput.length > 0) + result.push ("--audio-input", audioInput); + + if (midiInput !== undefined && midiInput.length > 0) + result.push ("--midi-input", midiInput); + + if (midiOutput !== undefined && midiOutput.length > 0) + result.push ("--midi-output", midiOutput); + + if (request.verbose) + result.push ("--verbose"); + + result.push (...request.extraArguments); + + return result; +} + +/** True when both requests would launch the same player command line. */ +export function samePlayerRequest (a: YdspPlayerRequest, b: YdspPlayerRequest): boolean { + return buildPlayerArguments (a).join ("\u0000") === buildPlayerArguments (b).join ("\u0000"); +} + +/** Owns the single `yup_dsp_compiler run` subprocess used for patch playback. */ +export class YdspPlayer implements vscode.Disposable { + constructor (private readonly executable: string, + private readonly output: vscode.OutputChannel) { + } + + get status (): YdspPlayerStatus { + if (this.child === undefined) + return "idle"; + + return this.stopping ? "stopping" : "playing"; + } + + get isRunning (): boolean { + return this.child !== undefined; + } + + get currentFile (): string | undefined { + return this.request?.file; + } + + get onDidChangeState (): vscode.Event { + return this.stateEmitter.event; + } + + /** True when the given request is already the one being played. */ + isPlayingRequest (request: YdspPlayerRequest): boolean { + return this.isRunning && this.request !== undefined && samePlayerRequest (this.request, request); + } + + /** + Starts playback, replacing any running player once it has shut down. An + identical request is ignored unless `force` is set, so repeated Run + actions never spawn a second player. + */ + start (request: YdspPlayerRequest, force = false): void { + if (! force && this.isPlayingRequest (request)) + return; + + this.request = request; + + if (this.child !== undefined) { + this.pending = request; + this.stop (); + return; + } + + this.launch (request); + } + + /** Asks the player to stop, forcing termination if it does not exit promptly. */ + stop (): void { + const child = this.child; + + if (child === undefined) + return; + + this.stopping = true; + child.kill ("SIGTERM"); + this.emitState (); + + this.killTimer = setTimeout (() => { + if (this.child === child) + child.kill ("SIGKILL"); + }, STOP_GRACE_PERIOD_MS); + } + + dispose (): void { + this.pending = undefined; + this.stopping = true; + this.child?.kill ("SIGTERM"); + } + + private launch (request: YdspPlayerRequest): void { + const args = buildPlayerArguments (request); + this.output.appendLine (`> ${this.executable} ${args.join (" ")}`); + + let child: ChildProcess; + + try { + child = spawn (this.executable, args, { cwd: path.dirname (request.file), windowsHide: true }); + } catch (error) { + this.output.appendLine (`Cannot start the YDSP player: ${errorMessage (error)}`); + void vscode.window.showErrorMessage (`YDSP: cannot start the player (${errorMessage (error)}).`); + this.request = undefined; + this.emitState (); + return; + } + + this.child = child; + this.stopping = false; + this.tail = ""; + + child.stdout?.setEncoding ("utf8"); + child.stdout?.on ("data", (chunk: string) => this.output.append (chunk)); + + child.stderr?.setEncoding ("utf8"); + child.stderr?.on ("data", (chunk: string) => { + this.output.append (chunk); + this.tail = (this.tail + chunk).slice (-TAIL_LENGTH); + }); + + child.on ("error", (error) => { + // A failed spawn emits 'error' and 'close', never 'exit'. + this.output.appendLine (`Player error: ${errorMessage (error)}`); + this.tail = errorMessage (error); + this.handleTermination (child, null, null); + }); + + child.on ("exit", (code, signal) => this.handleTermination (child, code, signal)); + + this.emitState (); + } + + private handleTermination (child: ChildProcess, code: number | null, signal: NodeJS.Signals | null): void { + if (this.child !== child) + return; + + this.child = undefined; + + if (this.killTimer !== undefined) { + clearTimeout (this.killTimer); + this.killTimer = undefined; + } + + const stopped = this.stopping; + this.stopping = false; + + if (! stopped && code !== 0) { + const detail = this.tail.trim ().split ("\n").pop () ?? ""; + const ending = code !== null ? `code ${code}` : signal !== null ? `signal ${signal}` : "before opening a device"; + this.output.appendLine (`Player exited (${ending}).`); + this.output.show (true); + void vscode.window.showErrorMessage (`YDSP: playback stopped${detail.length > 0 ? ` - ${detail}` : ""}.`); + } + + const pending = this.pending; + this.pending = undefined; + + if (pending !== undefined) { + this.launch (pending); + return; + } + + this.request = undefined; + this.emitState (); + } + + private emitState (): void { + this.stateEmitter.fire ({ status: this.status, running: this.child !== undefined, file: this.request?.file }); + } + + private readonly stateEmitter = new vscode.EventEmitter (); + private child: ChildProcess | undefined; + private request: YdspPlayerRequest | undefined; + private pending: YdspPlayerRequest | undefined; + private killTimer: NodeJS.Timeout | undefined; + private stopping = false; + private tail = ""; +} diff --git a/modules/yup_dsp_jit/tools/vscode-ydsp/syntaxes/ydsp-project.tmLanguage.json b/modules/yup_dsp_jit/tools/vscode-ydsp/syntaxes/ydsp-project.tmLanguage.json new file mode 100644 index 000000000..39e09d57d --- /dev/null +++ b/modules/yup_dsp_jit/tools/vscode-ydsp/syntaxes/ydsp-project.tmLanguage.json @@ -0,0 +1,7 @@ +{ + "name": "YDSP Project", + "scopeName": "source.ydsp-project", + "patterns": [ + { "include": "source.yaml" } + ] +} diff --git a/modules/yup_dsp_jit/tools/vscode-ydsp/syntaxes/ydsp.tmLanguage.json b/modules/yup_dsp_jit/tools/vscode-ydsp/syntaxes/ydsp.tmLanguage.json new file mode 100644 index 000000000..e508b33db --- /dev/null +++ b/modules/yup_dsp_jit/tools/vscode-ydsp/syntaxes/ydsp.tmLanguage.json @@ -0,0 +1,168 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "YDSP", + "scopeName": "source.ydsp", + "patterns": [ + { "include": "#comments" }, + { "include": "#annotations" }, + { "include": "#strings" }, + { "include": "#numbers" }, + { "include": "#declarations" }, + { "include": "#endpoints" }, + { "include": "#keywords" }, + { "include": "#event-shapes" }, + { "include": "#intrinsics" }, + { "include": "#operators" }, + { "include": "#punctuation" } + ], + "repository": { + "comments": { + "patterns": [ + { "name": "comment.line.double-slash.ydsp", "match": "//.*$" }, + { "name": "comment.block.ydsp", "begin": "/\\*", "end": "\\*/" } + ] + }, + "annotations": { + "patterns": [ + { + "name": "meta.annotation.ydsp", + "begin": "\\[\\[", + "end": "\\]\\]", + "beginCaptures": { "0": { "name": "punctuation.definition.annotation.begin.ydsp" } }, + "endCaptures": { "0": { "name": "punctuation.definition.annotation.end.ydsp" } }, + "patterns": [ + { + "name": "support.type.annotation-key.ydsp", + "match": "\\b(?:name|author|version|license|description|min|max|step|unit|style|values|init|smoothing|latency|mode|stealing|priority|role|main)\\b" + }, + { + "name": "constant.language.annotation-value.ydsp", + "match": "\\b(?:true|false|poly|mono|oldest|newest|none|last|low|high|voiceActivity)\\b" + }, + { "include": "#strings" }, + { "include": "#numbers" } + ] + } + ] + }, + "strings": { + "patterns": [ + { + "name": "string.quoted.double.ydsp", + "begin": "\"", + "end": "\"", + "beginCaptures": { "0": { "name": "punctuation.definition.string.begin.ydsp" } }, + "endCaptures": { "0": { "name": "punctuation.definition.string.end.ydsp" } }, + "patterns": [ + { "name": "constant.character.escape.ydsp", "match": "\\\\." } + ] + } + ] + }, + "numbers": { + "patterns": [ + { + "name": "constant.numeric.ydsp", + "match": "\\b\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?\\b" + } + ] + }, + "declarations": { + "patterns": [ + { + "name": "meta.definition.type.ydsp", + "match": "\\b(processor|graph|struct)\\s+([A-Za-z_][A-Za-z0-9_]*)", + "captures": { + "1": { "name": "keyword.declaration.ydsp" }, + "2": { "name": "entity.name.type.ydsp" } + } + }, + { + "name": "meta.definition.function.ydsp", + "match": "\\b(func)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(?=\\()", + "captures": { + "1": { "name": "keyword.declaration.ydsp" }, + "2": { "name": "entity.name.function.ydsp" } + } + }, + { + "name": "meta.definition.node.ydsp", + "match": "\\b(node)\\s+([A-Za-z_][A-Za-z0-9_]*)", + "captures": { + "1": { "name": "keyword.declaration.ydsp" }, + "2": { "name": "variable.other.node.ydsp" } + } + } + ] + }, + "endpoints": { + "patterns": [ + { + "name": "meta.endpoint.ydsp", + "match": "\\b(input|output)\\s+(stream|parameter|event)\\b", + "captures": { + "1": { "name": "keyword.declaration.ydsp" }, + "2": { "name": "variable.other.endpoint.ydsp" } + } + } + ] + }, + "keywords": { + "patterns": [ + { "name": "keyword.control.ydsp", "match": "\\b(?:if|else|for|return)\\b" }, + { + "name": "keyword.declaration.ydsp", + "match": "\\b(?:processor|graph|node|input|output|connection|process|event|func|init|struct|state|import|declare|as|let)\\b" + }, + { + "name": "storage.type.ydsp", + "match": "\\b(?:bool|float|float32|float64|int|int32|int64)\\b" + }, + { "name": "constant.language.ydsp", "match": "\\b(?:true|false)\\b" }, + { + "name": "constant.language.ydsp", + "match": "\\b(?:blockSize|e|pi|samplePeriod|sampleRate)\\b" + } + ] + }, + "event-shapes": { + "patterns": [ + { + "name": "support.type.event.ydsp", + "match": "(?|~" }, + { "name": "keyword.operator.stream.ydsp", "match": "->" }, + { "name": "keyword.operator.range.ydsp", "match": "\\.\\." }, + { + "name": "keyword.operator.arithmetic.ydsp", + "match": "\\+|-|\\*|/|%|&&|\\|\\||==|!=|<=|>=|!|<|>" + }, + { "name": "keyword.operator.delay.ydsp", "match": "@|'" }, + { "name": "keyword.operator.passthrough.ydsp", "match": "_" }, + { "name": "keyword.operator.composition.ydsp", "match": ":" }, + { "name": "keyword.operator.assignment.ydsp", "match": "=" } + ] + }, + "punctuation": { + "patterns": [ + { "name": "punctuation.section.block.ydsp", "match": "[{}]" }, + { "name": "punctuation.section.parens.ydsp", "match": "[()]" }, + { "name": "punctuation.section.brackets.ydsp", "match": "\\[|\\]" }, + { "name": "punctuation.separator.ydsp", "match": ";|,|\\." } + ] + } + } +} diff --git a/modules/yup_dsp_jit/tools/vscode-ydsp/test/sample.ydsp b/modules/yup_dsp_jit/tools/vscode-ydsp/test/sample.ydsp new file mode 100644 index 000000000..a7588cb1d --- /dev/null +++ b/modules/yup_dsp_jit/tools/vscode-ydsp/test/sample.ydsp @@ -0,0 +1,85 @@ +/* + Block comment: a YDSP demo exercising the grammar. +*/ + +// line comment +import fx.Delay as fx; + +declare name "Demo"; +declare description "Grammar smoke test patch."; + +let harmonics = 32; // program constant + +processor Voice { + input stream in; + input stream side; + output stream out; + + input parameter float drive = 0.5 [[ name: "Drive", min: 0, max: 2, smoothing: 0.02 ]]; + input parameter float wave = 0.0 [[ name: "Waveform", values: { "Saw", "Square" } ]]; + output parameter float level; + + input event midi; + + state float z; + state float buf[256] = { 1.0, 0.5 }; + state int wp; + state int active [[ role: voiceActivity ]]; + + struct Comb { + float mem[1024]; + int writePos; + } + state Comb combs[4]; + + func noteToFreq (pitch: float) : float { + return 440.0 * pow (2.0, (pitch - 69.0) / 12.0); + } + + event midi (e: noteOn) { + freq = noteToFreq (e.pitch); + level = e.velocity; + if (! e.isLegato) { env = e.velocity; } + active = 1; + } + + event midi (e: pitchBend) { bend = e.bendSemitones; } + event midi (e: controlChange) { if (e.control == 1) { modWheel = e.value; } } + + process { + float c = smooth (drive, 0.02); + out = tanh (in * c) * (1 + 0.5 * side); + z = 0.999 * z + in; + level = abs (z); + if (z' > 1e-3) { out = out @ 4; } + active = select (level < 0.000001, 0, 1); + } +} + +graph Patch [[ main ]] { + input event midi; + input stream in; + output stream out; + input parameter float master = 0.8; + + node voices = Voice[8] [[ mode: poly, stealing: oldest ]]; + node dly = fx.Delay (time = 0.25); + node dist = Dist * 4; + + connection { + in -> voices.in; + voices.out -> dly.in; + dly.out -> [32] -> dist.in; + dist.out -> out; + master -> dly.time; + } +} + +graph Chain { + input stream dry; output stream wet; + node sat = Saturator (drive = 1.5); + process = dry <: (sat , Chorus) :> wet; + process = dry : sat : wet; + process = _ : Gain; + for i in 0..blockSize { out[i] = in[i] * drive; } +} diff --git a/modules/yup_dsp_jit/tools/vscode-ydsp/tools/package-server.sh b/modules/yup_dsp_jit/tools/vscode-ydsp/tools/package-server.sh new file mode 100644 index 000000000..5cdf75461 --- /dev/null +++ b/modules/yup_dsp_jit/tools/vscode-ydsp/tools/package-server.sh @@ -0,0 +1,36 @@ +#!/bin/sh +set -eu + +extension_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +repo_dir=$(CDPATH= cd -- "$extension_dir/../../../.." && pwd) +compiler_path=${YDSP_COMPILER:-$repo_dir/cmake/tools/ydsp_compiler/build/yup_dsp_compiler} + +if [ "$(uname -s)" = "Darwin" ]; then + platform=darwin +elif [ "$(uname -s)" = "Linux" ]; then + platform=linux +else + platform=win32 +fi + +case "$(uname -m)" in + arm64|aarch64) architecture=arm64 ;; + x86_64|amd64) architecture=x64 ;; + *) echo "Unsupported compiler architecture: $(uname -m)" >&2; exit 2 ;; +esac + +if [ ! -x "$compiler_path" ]; then + echo "YDSP compiler not found: $compiler_path" >&2 + echo "Build it with: just dsp_compiler" >&2 + exit 2 +fi + +destination="$extension_dir/server/$platform-$architecture" +if [ "$platform" = "win32" ]; then + compiler_name=yup_dsp_compiler.exe +else + compiler_name=yup_dsp_compiler +fi +mkdir -p "$destination" +cp "$compiler_path" "$destination/$compiler_name" +chmod 755 "$destination/$compiler_name" diff --git a/modules/yup_dsp_jit/tools/vscode-ydsp/tsconfig.json b/modules/yup_dsp_jit/tools/vscode-ydsp/tsconfig.json new file mode 100644 index 000000000..714c7b367 --- /dev/null +++ b/modules/yup_dsp_jit/tools/vscode-ydsp/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "outDir": "out", + "lib": ["ES2020"], + "sourceMap": true, + "strict": true, + "esModuleInterop": true + }, + "exclude": ["node_modules", ".vscode-test"] +} diff --git a/modules/yup_dsp_jit/yup_dsp_jit.cpp b/modules/yup_dsp_jit/yup_dsp_jit.cpp new file mode 100644 index 000000000..29346be88 --- /dev/null +++ b/modules/yup_dsp_jit/yup_dsp_jit.cpp @@ -0,0 +1,134 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#ifdef YUP_DSP_JIT_H_INCLUDED +/* When you add this cpp file to your project, you mustn't include it in a file where you've + already included any other headers - just put it inside a file on its own, possibly with your config + flags preceding it, but don't include anything else. That also includes avoiding any automatic prefix + header files that the compiler may be using. +*/ +#error "Incorrect use of YUP cpp file" +#endif + +#include "yup_dsp_jit.h" + +//============================================================================== + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +//============================================================================== + +#if YUP_EMSCRIPTEN +#include +#endif + +//============================================================================== + +// Language front-end: lexer, AST and parser +#include "language/yup_YdspLexer.cpp" +#include "language/yup_YdspParser.cpp" +#include "compiler/yup_YdspBundle.cpp" + +// Semantic analysis: type system and realtime-safety enforcement +#include "analysis/semantic/yup_YdspSemanticTypeSystem.cpp" +#include "analysis/semantic/yup_YdspSemanticProgram.cpp" +#include "analysis/semantic/yup_YdspSemanticProcessor.cpp" +#include "analysis/semantic/yup_YdspSemanticStatements.cpp" +#include "analysis/semantic/yup_YdspSemanticGraph.cpp" +#include "analysis/semantic/yup_YdspSemanticGraphForms.cpp" +#include "analysis/semantic/yup_YdspSemanticGraphInline.cpp" +#include "analysis/semantic/yup_YdspSemanticGraphFuse.cpp" +#include "analysis/semantic/yup_YdspSemanticGraphLatency.cpp" +#include "analysis/semantic/yup_YdspSemanticFunctions.cpp" + +// Optimisation: typed IL +#include "optimiser/builder/yup_YdspBuilder.h" +#include "optimiser/builder/yup_YdspRingBoundsProof.cpp" +#include "optimiser/builder/yup_YdspBuilderCore.cpp" +#include "optimiser/builder/yup_YdspBuilderExpressions.cpp" +#include "optimiser/builder/yup_YdspBuilderStatements.cpp" +#include "optimiser/builder/yup_YdspBuilderFunctions.cpp" + +// Optimisation: compiler passes +#include "optimiser/passes/yup_YdspPassesShared.cpp" +#include "optimiser/passes/yup_YdspPassesConstantFolding.cpp" +#include "optimiser/passes/yup_YdspPassesBoundsChecks.cpp" +#include "optimiser/passes/yup_YdspPassesAlgebraicSimplification.cpp" +#include "optimiser/passes/yup_YdspPassesCopyPropagation.cpp" +#include "optimiser/passes/yup_YdspPassesIfConversion.cpp" +#include "optimiser/passes/yup_YdspPassesFullyUnrollBoundedLoops.cpp" +#include "optimiser/passes/yup_YdspPassesSplitWidenedReductionChains.cpp" +#include "optimiser/passes/yup_YdspPassesStoreToLoadForwarding.cpp" +#include "optimiser/passes/yup_YdspPassesDeadCodeElimination.cpp" +#include "optimiser/passes/yup_YdspPassesLoopInvariantCodeMotion.cpp" +#include "optimiser/passes/yup_YdspPassesLoopFusion.cpp" +#include "optimiser/passes/yup_YdspPassesContractMultiplyAdd.cpp" +#include "optimiser/passes/yup_YdspPassesLowerFusedMultiplyAdd.cpp" + +// Optimisation: vectorisation +#include "optimiser/yup_YdspVectorizer.cpp" +#include "optimiser/yup_YdspOptimizer.cpp" + +// Backend: native emission on every compiler host; installation is native-only. +#include "backend/yup_YdspAsmJitCodegen.cpp" +#include "backend/yup_YdspAsmJitCodegenX64.cpp" +#include "backend/yup_YdspAsmJitCodegenARM64.cpp" + +// WebAssembly backend: wasm binary emission and IR lowering (pure C++) +#include "backend/yup_YdspWasmEmitter.cpp" +#include "backend/yup_YdspWasmCodegen.cpp" +#include "backend/yup_YdspWasmRuntime.h" + +// WebAssembly backend: JS glue for the browser's native WebAssembly API +#if YUP_EMSCRIPTEN +#include "native/yup_YdspWasmRuntime_emscripten.cpp" +#endif + +// Runtime: the realtime graph +#include "runtime/yup_YdspGraphInternal.h" +#include "runtime/yup_YdspAudioGraph.cpp" +#include "runtime/yup_YdspGraphPimpl.cpp" +#include "runtime/yup_YdspGraphQuery.cpp" + +// Compiler: the control-thread compiler +#include "compiler/yup_YdspDiagnostics.cpp" +#include "compiler/yup_YdspProject.cpp" +#include "compiler/yup_YdspCompiler.cpp" diff --git a/modules/yup_dsp_jit/yup_dsp_jit.h b/modules/yup_dsp_jit/yup_dsp_jit.h new file mode 100644 index 000000000..fafc1a9ae --- /dev/null +++ b/modules/yup_dsp_jit/yup_dsp_jit.h @@ -0,0 +1,114 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +/* + ============================================================================== + + BEGIN_YUP_MODULE_DECLARATION + + ID: yup_dsp_jit + vendor: yup + version: 2.0.0 + name: YUP DSP JIT + description: YDSP, a realtime JIT-compiled audio DSP language. + website: https://github.com/kunitoki/yup + license: ISC + + dependencies: yup_core yup_dsp yup_audio_basics + macDeps: asmjit_library sleef_library + linuxDeps: asmjit_library sleef_library + windowsDeps: asmjit_library sleef_library + androidDeps: asmjit_library sleef_library + wasmDeps: asmjit_library + + END_YUP_MODULE_DECLARATION + + ============================================================================== +*/ + +#pragma once +#define YUP_DSP_JIT_H_INCLUDED + +#include +#include +#include + +//============================================================================== + +#if YUP_IOS +#error "yup_dsp_jit is not supported on iOS targets" +#endif + +#include +#if ! YUP_WASM +#include +#endif + +//============================================================================== + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +//============================================================================== + +#include "backend/yup_YdspAbi.h" + +// Runtime: the realtime graph API +#include "runtime/yup_YdspTypes.h" +#include "runtime/yup_YdspExecutionReport.h" +#include "runtime/yup_YdspAudioGraph.h" + +// Compiler: the control-thread compile API +#include "compiler/yup_YdspRecursionGuard.h" +#include "compiler/yup_YdspDiagnostics.h" +#include "compiler/yup_YdspCompileOptions.h" +#include "compiler/yup_YdspBundle.h" +#include "compiler/yup_YdspProject.h" +#include "compiler/yup_YdspCompiler.h" + +#include "language/yup_YdspAst.h" +#include "language/yup_YdspAstVisitor.h" +#include "language/yup_YdspLexer.h" +#include "language/yup_YdspParser.h" +#include "analysis/yup_YdspTypes.h" +#include "analysis/yup_YdspSemanticAnalyzer.h" +#include "optimiser/yup_YdspIr.h" +#include "optimiser/yup_YdspUtils.h" +#include "optimiser/yup_YdspVectorizer.h" +#include "optimiser/yup_YdspOptimizer.h" +#include "backend/yup_YdspWasmEmitter.h" + +#include "backend/yup_YdspAsmJitCodegen.h" +#include "backend/yup_YdspAsmJitCodegenX64.h" +#include "backend/yup_YdspAsmJitCodegenARM64.h" + +#include "backend/yup_YdspWasmCodegen.h" diff --git a/modules/yup_events/messages/yup_MessageManager.cpp b/modules/yup_events/messages/yup_MessageManager.cpp index 4d778f4ae..104371d9e 100644 --- a/modules/yup_events/messages/yup_MessageManager.cpp +++ b/modules/yup_events/messages/yup_MessageManager.cpp @@ -141,6 +141,9 @@ void MessageManager::runDispatchLoop() { loopCallback(); + if (quitMessageReceived.get() != 0) + break; + if (! yup_dispatchNextMessageOnSystemQueue (false)) Thread::sleep (1); } @@ -153,10 +156,9 @@ void MessageManager::runDispatchLoop() void MessageManager::stopDispatchLoop() { - quitMessageReceived = true; - quitMessagePosted = true; - (new QuitMessage())->post(); + + quitMessagePosted = true; } #if YUP_MODAL_LOOPS_PERMITTED diff --git a/modules/yup_gui/buttons/yup_Button.cpp b/modules/yup_gui/buttons/yup_Button.cpp index cc10aa58f..d2dc57a9a 100644 --- a/modules/yup_gui/buttons/yup_Button.cpp +++ b/modules/yup_gui/buttons/yup_Button.cpp @@ -58,7 +58,8 @@ void Button::mouseDown (const MouseEvent& event) { isButtonCurrentlyDown = true; - takeKeyboardFocus(); + if (getWantsKeyboardFocus()) + takeKeyboardFocus(); repaint(); } diff --git a/modules/yup_gui/code/yup_CodeEditor.cpp b/modules/yup_gui/code/yup_CodeEditor.cpp index e7bcd453f..98801a765 100644 --- a/modules/yup_gui/code/yup_CodeEditor.cpp +++ b/modules/yup_gui/code/yup_CodeEditor.cpp @@ -106,7 +106,8 @@ class CodeEditor::Minimap : public Component void mouseDown (const MouseEvent& event) override { - editor.takeKeyboardFocus(); + if (editor.getWantsKeyboardFocus()) + editor.takeKeyboardFocus(); scrollToPosition (event.getPosition().to()); } @@ -1012,7 +1013,8 @@ void CodeEditor::enablementChanged() void CodeEditor::mouseDown (const MouseEvent& event) { - takeKeyboardFocus(); + if (getWantsKeyboardFocus()) + takeKeyboardFocus(); if (document == nullptr) return; @@ -1055,7 +1057,8 @@ void CodeEditor::mouseUp (const MouseEvent&) void CodeEditor::mouseDoubleClick (const MouseEvent& event) { - takeKeyboardFocus(); + if (getWantsKeyboardFocus()) + takeKeyboardFocus(); if (document == nullptr) return; diff --git a/modules/yup_gui/code/yup_CodeEditor.h b/modules/yup_gui/code/yup_CodeEditor.h index 1c9fe1cc9..14ef67719 100644 --- a/modules/yup_gui/code/yup_CodeEditor.h +++ b/modules/yup_gui/code/yup_CodeEditor.h @@ -85,7 +85,7 @@ class YUP_API CodeEditor : public Component */ void setSyntaxDefinition (const SyntaxDefinition& definition); - /** Sets a built-in syntax definition by language name ("cpp", "glsl", "python", "xml"). + /** Sets a built-in syntax definition by language name ("cpp", "glsl", "python", "xml", "ydsp"). @param languageName The language name. */ diff --git a/modules/yup_gui/code/yup_SyntaxDefinition.cpp b/modules/yup_gui/code/yup_SyntaxDefinition.cpp index eee3b223f..5398b8aac 100644 --- a/modules/yup_gui/code/yup_SyntaxDefinition.cpp +++ b/modules/yup_gui/code/yup_SyntaxDefinition.cpp @@ -112,6 +112,18 @@ constexpr auto pythonDefinitionJson = R"json({ "operators": ["**", "//", "<<", ">>", "<=", ">=", "==", "!=", "&&", "||", "+=", "-=", "*=", "/=", "//=", "%=", "**=", "@=", "&=", "|=", "^=", "<<=", ">>=", ":=", "->", "?", ":", ";", ",", ".", "(", ")", "[", "]", "{", "}", "+", "-", "*", "/", "%", "@", "=", "<", ">", "!", "&", "|", "^", "~"] })json"; +constexpr auto ydspDefinitionJson = R"json({ + "name": "YDSP", + "extensions": ["ydsp"], + "lineComment": "//", + "blockComment": { "start": "/*", "end": "*/" }, + "strings": { "delimiters": ["\""], "escape": "\\", "multiLine": false }, + "numbers": { "hex": false, "binary": false, "float": true, "exponent": true, "suffix": false }, + "keywords": ["processor", "graph", "node", "connection", "input", "output", "value", "stream", "state", "process", "block", "for", "if", "else", "let", "true", "false", "declare", "func", "return", "import", "as", "struct", "init", "event"], + "types": ["float", "float32", "float64", "int", "int32", "int64", "bool"], + "operators": ["<<=", ">>=", "<<", ">>", "<=", ">=", "==", "!=", "&&", "||", "+=", "-=", "*=", "&=", "|=", "^=", "->", "<:", ":>", "..", "[[", "]]", "?", ":", ";", ",", ".", "(", ")", "[", "]", "{", "}", "+", "-", "*", "/", "%", "=", "<", ">", "!", "&", "|", "^", "~", "@", "'"] +})json"; + constexpr auto xmlDefinitionJson = R"json({ "name": "XML", "extensions": ["xml", "xaml", "svg", "html", "htm", "xhtml", "xsd", "xsl", "xslt", "plist", "resx", "csproj", "vcxproj"], @@ -343,6 +355,7 @@ const SyntaxDefinition& SyntaxDefinition::getBuiltIn (StringRef languageName) static const SyntaxDefinition glsl = parseBuiltIn (glslDefinitionJson); static const SyntaxDefinition python = parseBuiltIn (pythonDefinitionJson); static const SyntaxDefinition xml = parseBuiltIn (xmlDefinitionJson); + static const SyntaxDefinition ydsp = parseBuiltIn (ydspDefinitionJson); static const SyntaxDefinition inert; const String language (languageName); @@ -359,6 +372,9 @@ const SyntaxDefinition& SyntaxDefinition::getBuiltIn (StringRef languageName) if (language == "xml") return xml; + if (language == "ydsp") + return ydsp; + return inert; } @@ -372,8 +388,9 @@ const SyntaxDefinition* SyntaxDefinition::getBuiltInForExtension (StringRef file const auto& glsl = getBuiltIn ("glsl"); const auto& python = getBuiltIn ("python"); const auto& xml = getBuiltIn ("xml"); + const auto& ydsp = getBuiltIn ("ydsp"); - for (const auto& candidate : { &cpp, &glsl, &python, &xml }) + for (const auto& candidate : { &cpp, &glsl, &python, &xml, &ydsp }) { for (const auto& candidateExtension : candidate->getFileExtensions()) { diff --git a/modules/yup_gui/code/yup_SyntaxDefinition.h b/modules/yup_gui/code/yup_SyntaxDefinition.h index cba1f8301..f2cbc6a29 100644 --- a/modules/yup_gui/code/yup_SyntaxDefinition.h +++ b/modules/yup_gui/code/yup_SyntaxDefinition.h @@ -31,7 +31,7 @@ namespace yup comment delimiters, string rules, number syntax, keyword/type/operator sets, preprocessor prefix, and per-token colors. It is loaded from a JSON file (see `loadFromData` / `loadFromFile` for the format) or obtained from the - built-in C++ / GLSL / Python / XML definitions via `getBuiltIn`. + built-in C++ / GLSL / Python / XML / YDSP definitions via `getBuiltIn`. The JSON format is: @code @@ -177,7 +177,7 @@ class YUP_API SyntaxDefinition bool isIdentifierPart (yup_wchar character) const; //============================================================================== - /** Returns a built-in definition by language name ("cpp", "glsl", "python" or "xml"). + /** Returns a built-in definition by language name ("cpp", "glsl", "python", "xml" or "ydsp"). @param languageName The language name. @returns The definition (a default inert one if the name is unknown). diff --git a/modules/yup_gui/component/yup_Component.cpp b/modules/yup_gui/component/yup_Component.cpp index cb45725bf..f35d5dffb 100644 --- a/modules/yup_gui/component/yup_Component.cpp +++ b/modules/yup_gui/component/yup_Component.cpp @@ -1089,6 +1089,27 @@ Component* Component::findComponentAt (const Point& p) return this; } +Component* Component::findComponentAtForMouseEvent (const Point& p) +{ + if (! options.isVisible || ! boundsInParent.withZeroPosition().contains (p) || ! hitTest (p.getX(), p.getY())) + return nullptr; + + if (doesWantChildrenMouseEvents()) + { + for (int index = children.size(); --index >= 0;) + { + auto child = children.getUnchecked (index); + if (! child->isVisible() || ! child->boundsInParent.contains (p)) + continue; + + if (auto* hit = child->findComponentAtForMouseEvent (p - child->boundsInParent.getPosition())) + return hit; + } + } + + return doesWantSelfMouseEvents() ? this : nullptr; +} + Component* Component::getTopLevelComponent() { auto currentComponent = this; diff --git a/modules/yup_gui/component/yup_Component.h b/modules/yup_gui/component/yup_Component.h index 40bb1a9f0..f3fe09d18 100644 --- a/modules/yup_gui/component/yup_Component.h +++ b/modules/yup_gui/component/yup_Component.h @@ -1082,6 +1082,22 @@ class YUP_API Component : public MouseListener */ Component* findComponentAt (const Point& p); + /** + Find the topmost component at a given point that is willing to receive mouse events. + + Like findComponentAt(), this walks the hierarchy depth-first preferring the + topmost (last added) child whose bounds contain the point and whose hitTest() + accepts it, but a component that has opted out of mouse events via + setWantsMouseEvents() is skipped in favor of the next sibling underneath it, + and the search bubbles up to this component itself (or returns nullptr) when + nothing in a given subtree wants the event. + + @param p The point to find the component at, in this component's local coordinates. + + @returns The component that should receive the mouse event, or nullptr if none does. + */ + Component* findComponentAtForMouseEvent (const Point& p); + /** Returns the top level component. */ diff --git a/modules/yup_gui/native/yup_Initialisation_sdl.cpp b/modules/yup_gui/native/yup_Initialisation_sdl.cpp index ab2cb8d65..2e8a7882b 100644 --- a/modules/yup_gui/native/yup_Initialisation_sdl.cpp +++ b/modules/yup_gui/native/yup_Initialisation_sdl.cpp @@ -38,6 +38,15 @@ String getSDLVersionString (int version) //============================================================================== +void getGlobalMouseState (float& x, float& y) +{ +#if YUP_EMSCRIPTEN + SDL_GetMouseState (&x, &y); +#else + SDL_GetGlobalMouseState (&x, &y); +#endif +} + bool displayEventDispatcher (void* userdata, SDL_Event* event) { if (auto* messageManager = MessageManager::getInstanceWithoutCreating(); @@ -88,7 +97,7 @@ bool displayEventDispatcher (void* userdata, SDL_Event* event) case SDL_EVENT_MOUSE_MOTION: { float x = 0.0f, y = 0.0f; - SDL_GetGlobalMouseState (&x, &y); + getGlobalMouseState (x, y); const SDL_Point pt { static_cast (x), static_cast (y) }; const auto displayScale = getDisplayUnitsPerPoint (SDL_GetDisplayForPoint (&pt)); @@ -113,7 +122,7 @@ bool displayEventDispatcher (void* userdata, SDL_Event* event) case SDL_EVENT_MOUSE_BUTTON_DOWN: { float x = 0.0f, y = 0.0f; - SDL_GetGlobalMouseState (&x, &y); + getGlobalMouseState (x, y); const SDL_Point pt { static_cast (x), static_cast (y) }; const auto displayScale = getDisplayUnitsPerPoint (SDL_GetDisplayForPoint (&pt)); @@ -134,7 +143,7 @@ bool displayEventDispatcher (void* userdata, SDL_Event* event) case SDL_EVENT_MOUSE_BUTTON_UP: { float x = 0.0f, y = 0.0f; - SDL_GetGlobalMouseState (&x, &y); + getGlobalMouseState (x, y); const SDL_Point pt { static_cast (x), static_cast (y) }; const auto displayScale = getDisplayUnitsPerPoint (SDL_GetDisplayForPoint (&pt)); @@ -155,7 +164,7 @@ bool displayEventDispatcher (void* userdata, SDL_Event* event) case SDL_EVENT_MOUSE_WHEEL: { float x = 0.0f, y = 0.0f; - SDL_GetGlobalMouseState (&x, &y); + getGlobalMouseState (x, y); const SDL_Point pt { static_cast (x), static_cast (y) }; const auto displayScale = getDisplayUnitsPerPoint (SDL_GetDisplayForPoint (&pt)); diff --git a/modules/yup_gui/native/yup_WindowingUtilities_sdl.cpp b/modules/yup_gui/native/yup_WindowingUtilities_sdl.cpp index a71f68c94..73e0b3663 100644 --- a/modules/yup_gui/native/yup_WindowingUtilities_sdl.cpp +++ b/modules/yup_gui/native/yup_WindowingUtilities_sdl.cpp @@ -260,6 +260,10 @@ float getWindowUnitsPerPoint (SDL_Window* window) noexcept bool isMouseOutsideWindow (SDL_Window* window) { +#if YUP_EMSCRIPTEN + ignoreUnused (window); + return false; +#else int windowX, windowY, windowW, windowH; SDL_GetWindowPosition (window, &windowX, &windowY); SDL_GetWindowSize (window, &windowW, &windowH); @@ -268,6 +272,7 @@ bool isMouseOutsideWindow (SDL_Window* window) [[maybe_unused]] auto mouseState = SDL_GetGlobalMouseState (&mouseX, &mouseY); return (mouseX < (float) windowX || mouseX > (float) (windowX + windowW) || mouseY < (float) windowY || mouseY > (float) (windowY + windowH)); +#endif } //============================================================================== diff --git a/modules/yup_gui/native/yup_Windowing_sdl.cpp b/modules/yup_gui/native/yup_Windowing_sdl.cpp index 6b3e42923..87f567c4c 100644 --- a/modules/yup_gui/native/yup_Windowing_sdl.cpp +++ b/modules/yup_gui/native/yup_Windowing_sdl.cpp @@ -1545,7 +1545,8 @@ void SDLComponentNative::handleMouseDown (const Point& position, MouseEve touchFinger->buttons = static_cast (touchFinger->buttons | button); if (touchFinger->buttons == button) - touchFinger->clickedComponent = findComponentForMouseEvent (position); + touchFinger->clickedComponent = component.findComponentAtForMouseEvent (position); + auto event = MouseEvent() .withButtons (touchFinger->buttons) .withModifiers (currentKeyModifiers) @@ -1577,13 +1578,16 @@ void SDLComponentNative::handleMouseDown (const Point& position, MouseEve currentMouseButtons = static_cast (toMouseButtons (SDL_GetMouseState (nullptr, nullptr)) | button); if (currentMouseButtons == button) - lastComponentClicked = findComponentForMouseEvent (position); + lastComponentClicked = component.findComponentAtForMouseEvent (position); auto event = MouseEvent() .withButtons (currentMouseButtons) .withModifiers (currentKeyModifiers) .withPosition (position); + if (currentMouseButtons == button) + lastComponentClicked = component.findComponentAtForMouseEvent (position); + if (auto* clickedComponent = lastComponentClicked.get()) { const auto currentMouseDownTime = yup::Time::getCurrentTime(); @@ -2252,35 +2256,6 @@ void SDLComponentNative::handleUserTriedToCloseWindow() //============================================================================== -Component* SDLComponentNative::findComponentForMouseEvent (const Point& position) -{ - Component* child = component.findComponentAt (position); - if (child == nullptr) - return nullptr; - - Component* current = child; - while (current != nullptr) - { - if (current->doesWantSelfMouseEvents()) - { - Component* parent = current->getParentComponent(); - while (parent != nullptr) - { - if (! parent->doesWantChildrenMouseEvents()) - return parent; - - parent = parent->getParentComponent(); - } - - return current; - } - - current = current->getParentComponent(); - } - - return nullptr; -} - void SDLComponentNative::updateComponentUnderMouse (const MouseEvent& event) { lastComponentUnderMouse = updateComponentUnderMouse (event, lastComponentUnderMouse); @@ -2288,7 +2263,7 @@ void SDLComponentNative::updateComponentUnderMouse (const MouseEvent& event) WeakReference SDLComponentNative::updateComponentUnderMouse (const MouseEvent& event, const WeakReference& previousComponent) { - Component* child = findComponentForMouseEvent (event.getPosition()); + Component* child = component.findComponentAtForMouseEvent (event.getPosition()); if (child != nullptr) { @@ -2340,9 +2315,13 @@ void SDLComponentNative::handleWindowEvent (const SDL_WindowEvent& windowEvent) break; case SDL_EVENT_WINDOW_RESIZED: + { YUP_MODULE_DBG (GUI_WINDOWING, "SDL_EVENT_WINDOW_RESIZED " << windowEvent.data1 << " " << windowEvent.data2); - // processEvent ([this] { handleResized (windowEvent.data1, windowEvent.data2); }); + const auto x = static_cast (windowEvent.data1); + const auto y = static_cast (windowEvent.data2); + processEvent ([this, x, y] { handleResized (x, y); }); break; + } case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: YUP_MODULE_DBG (GUI_WINDOWING, "SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED " << windowEvent.data1 << " " << windowEvent.data2); diff --git a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp index 298608c1d..c5f554f05 100644 --- a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp +++ b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp @@ -1517,6 +1517,75 @@ void paintMidiKeyboard (Graphics& g, const ApplicationTheme& theme, const MidiKe } } +void paintVectorWheel (Graphics& g, Rectangle bounds, float normalizedValue, Color topColor, Color bottomColor, Color outlineColor, Color gripColor) +{ + if (bounds.isEmpty()) + return; + + const auto contentBounds = bounds.reduced (0.5f); + + ColorGradient bodyGradient; + bodyGradient.addColorStop (topColor, Point (contentBounds.getX(), contentBounds.getY()), 0.0f); + bodyGradient.addColorStop (bottomColor, Point (contentBounds.getX(), contentBounds.getBottom()), 1.0f); + g.setFillColorGradient (bodyGradient); + g.fillRect (contentBounds); + + const float realValue = 1.0f - normalizedValue; + const float sinDelta = std::sin (realValue * MathConstants::pi); + + const float travelTop = contentBounds.getY() + contentBounds.getHeight() * 0.04f; + const float travel = contentBounds.getHeight() * 0.9f; + const float gripY = travelTop + realValue * travel; + + const float bandHalfHeight = (contentBounds.getHeight() * 0.075f + sinDelta * contentBounds.getHeight() * 0.175f) * 0.5f; + const float bandTop = jlimit (contentBounds.getY(), contentBounds.getBottom(), gripY - bandHalfHeight); + const float bandBottom = jlimit (contentBounds.getY(), contentBounds.getBottom(), gripY + bandHalfHeight); + + ColorGradient bandGradient; + bandGradient.addColorStop (bottomColor.withAlpha (0.5f), Point (contentBounds.getX(), bandTop), 0.0f); + bandGradient.addColorStop (topColor.withAlpha (0.5f), Point (contentBounds.getX(), bandBottom), 1.0f); + g.setFillColorGradient (bandGradient); + g.fillRect (Rectangle (contentBounds.getX(), bandTop, contentBounds.getWidth(), bandBottom - bandTop)); + + const float gripThickness = jmax (1.0f, contentBounds.getHeight() * 0.015f + contentBounds.getHeight() * 0.015f * sinDelta); + g.setFillColor (gripColor); + g.fillRect (Rectangle (contentBounds.getX(), gripY - gripThickness * 0.5f, contentBounds.getWidth(), gripThickness)); + + g.setStrokeColor (outlineColor); + g.setStrokeWidth (1.0f); + g.strokeRect (contentBounds); +} + +void paintPitchWheel (Graphics& g, const ApplicationTheme& theme, const PitchWheelComponent& wheel) +{ + const auto topColor = theme.findColor (wheel, PitchWheelComponent::Style::bodyTopColorId).value_or (Color (0xff5a5a5a)); + const auto bottomColor = theme.findColor (wheel, PitchWheelComponent::Style::bodyBottomColorId).value_or (Color (0xff1a1a1a)); + const auto outlineColor = theme.findColor (wheel, PitchWheelComponent::Style::outlineColorId).value_or (Color (0xff000000)); + const auto gripColor = theme.findColor (wheel, + wheel.isCurrentlyBeingDragged() ? PitchWheelComponent::Style::gripDownColorId + : wheel.isMouseOver() ? PitchWheelComponent::Style::gripOverColorId + : PitchWheelComponent::Style::gripColorId) + .value_or (Color (0xff4ebfff)); + + const auto normalized = static_cast ((wheel.getValue() + 1.0) * 0.5); + paintVectorWheel (g, wheel.getLocalBounds(), normalized, topColor, bottomColor, outlineColor, gripColor); +} + +void paintModWheel (Graphics& g, const ApplicationTheme& theme, const ModWheelComponent& wheel) +{ + const auto topColor = theme.findColor (wheel, ModWheelComponent::Style::bodyTopColorId).value_or (Color (0xff5a5a5a)); + const auto bottomColor = theme.findColor (wheel, ModWheelComponent::Style::bodyBottomColorId).value_or (Color (0xff1a1a1a)); + const auto outlineColor = theme.findColor (wheel, ModWheelComponent::Style::outlineColorId).value_or (Color (0xff000000)); + const auto gripColor = theme.findColor (wheel, + wheel.isCurrentlyBeingDragged() ? ModWheelComponent::Style::gripDownColorId + : wheel.isMouseOver() ? ModWheelComponent::Style::gripOverColorId + : ModWheelComponent::Style::gripColorId) + .value_or (Color (0xff4ebfff)); + + const auto normalized = static_cast (wheel.getValue()); + paintVectorWheel (g, wheel.getLocalBounds(), normalized, topColor, bottomColor, outlineColor, gripColor); +} + void paintKMeter (Graphics& g, const ApplicationTheme& theme, const KMeterComponent& meter) { const auto bounds = meter.getLocalBounds(); @@ -1930,6 +1999,22 @@ ApplicationTheme::Ptr createThemeVersion1() theme->setColor (MidiKeyboardComponent::Style::blackKeyShadowColorId, Color (0x80000000)); theme->setColor (MidiKeyboardComponent::Style::keyOutlineColorId, Color (0xff888888)); + theme->setComponentStyle (ComponentStyle::createStyle (paintPitchWheel)); + theme->setColor (PitchWheelComponent::Style::bodyTopColorId, Color (0xff5a5a5a)); + theme->setColor (PitchWheelComponent::Style::bodyBottomColorId, Color (0xff1a1a1a)); + theme->setColor (PitchWheelComponent::Style::outlineColorId, Colors::black); + theme->setColor (PitchWheelComponent::Style::gripColorId, Color (0xff4ebfff)); + theme->setColor (PitchWheelComponent::Style::gripOverColorId, Color (0xff7bd0ff)); + theme->setColor (PitchWheelComponent::Style::gripDownColorId, Color (0xff9de3ff)); + + theme->setComponentStyle (ComponentStyle::createStyle (paintModWheel)); + theme->setColor (ModWheelComponent::Style::bodyTopColorId, Color (0xff5a5a5a)); + theme->setColor (ModWheelComponent::Style::bodyBottomColorId, Color (0xff1a1a1a)); + theme->setColor (ModWheelComponent::Style::outlineColorId, Colors::black); + theme->setColor (ModWheelComponent::Style::gripColorId, Color (0xff4ebfff)); + theme->setColor (ModWheelComponent::Style::gripOverColorId, Color (0xff7bd0ff)); + theme->setColor (ModWheelComponent::Style::gripDownColorId, Color (0xff9de3ff)); + theme->setComponentStyle (ComponentStyle::createStyle (paintKMeter)); theme->setColor (KMeterComponent::Style::backgroundColorId, Color (0xff1a1a1a)); theme->setColor (KMeterComponent::Style::greenZoneColorId, Color (0xff00cc00)); diff --git a/modules/yup_gui/widgets/yup_ComboBox.cpp b/modules/yup_gui/widgets/yup_ComboBox.cpp index 32c2aaac0..287b4850f 100644 --- a/modules/yup_gui/widgets/yup_ComboBox.cpp +++ b/modules/yup_gui/widgets/yup_ComboBox.cpp @@ -205,7 +205,8 @@ void ComboBox::resized() void ComboBox::mouseDown (const MouseEvent& event) { - takeKeyboardFocus(); + if (getWantsKeyboardFocus()) + takeKeyboardFocus(); if (ignoreMouseDownAfterPopupDismissal) { @@ -279,7 +280,8 @@ void ComboBox::showPopup() }); } - takeKeyboardFocus(); + if (getWantsKeyboardFocus()) + takeKeyboardFocus(); }); } diff --git a/modules/yup_gui/widgets/yup_ListBox.cpp b/modules/yup_gui/widgets/yup_ListBox.cpp index d7c0f5010..c919c04f5 100644 --- a/modules/yup_gui/widgets/yup_ListBox.cpp +++ b/modules/yup_gui/widgets/yup_ListBox.cpp @@ -660,7 +660,8 @@ void ListBox::resized() //============================================================================== void ListBox::mouseDown (const MouseEvent& event) { - takeKeyboardFocus(); + if (getWantsKeyboardFocus()) + takeKeyboardFocus(); rowSelectedOnMouseUp = -1; diff --git a/modules/yup_gui/widgets/yup_Slider.cpp b/modules/yup_gui/widgets/yup_Slider.cpp index 7a395bf81..6a8cb019a 100644 --- a/modules/yup_gui/widgets/yup_Slider.cpp +++ b/modules/yup_gui/widgets/yup_Slider.cpp @@ -433,7 +433,9 @@ void Slider::mouseDown (const MouseEvent& event) maxValueOnMouseDown = maxValue; } - takeKeyboardFocus(); + if (getClickingGrabFocus()) + takeKeyboardFocus(); + repaint(); } diff --git a/modules/yup_gui/widgets/yup_TextEditor.cpp b/modules/yup_gui/widgets/yup_TextEditor.cpp index 65c864703..f5f6902d0 100644 --- a/modules/yup_gui/widgets/yup_TextEditor.cpp +++ b/modules/yup_gui/widgets/yup_TextEditor.cpp @@ -353,7 +353,8 @@ void TextEditor::focusLost() void TextEditor::mouseDown (const MouseEvent& event) { - takeKeyboardFocus(); + if (getWantsKeyboardFocus()) + takeKeyboardFocus(); auto position = event.getPosition().to(); int newCaretPos = getGlyphIndexAtPosition (position); diff --git a/python/tools/ArchivePythonStdlib.py b/python/tools/ArchivePythonStdlib.py index d959353e6..fc6cdcfdb 100644 --- a/python/tools/ArchivePythonStdlib.py +++ b/python/tools/ArchivePythonStdlib.py @@ -1,4 +1,5 @@ import os +import sys import stat import shutil import hashlib @@ -37,6 +38,81 @@ def should_exclude(path, name, exclude_patterns): return False +def collect_source_fingerprint(base_python, version_major, version_minor, exclude_patterns): + """Collect (path, size, mtime) of every source file that ends up in the archive.""" + version = f"{version_major}.{version_minor}" + python_folder_name = f"python{version}" + entries = [] + + def add_file(path, relative_to): + st = os.stat(path) + entries.append((os.path.relpath(path, relative_to), st.st_size, st.st_mtime_ns)) + + def add_tree(root, relative_to, filtered=False): + for dirname, dirnames, filenames in os.walk(root, followlinks=True): + if filtered: + dirnames[:] = [d for d in dirnames if not should_exclude(os.path.join(dirname, d), d, exclude_patterns)] + + for filename in filenames: + path = os.path.join(dirname, filename) + if filtered and should_exclude(path, filename, exclude_patterns): + continue + + add_file(path, relative_to) + + lib_src = base_python / "lib" + if lib_src.exists(): + python_lib_src = lib_src / python_folder_name + if python_lib_src.exists(): + add_tree(python_lib_src, python_lib_src, filtered=True) + + for item in lib_src.iterdir(): + if item.is_file() and any(ext in item.name for ext in ['.dylib', '.dll', '.so', '.a', '.lib']): + if not should_exclude(str(item), item.name, exclude_patterns): + add_file(item, lib_src) + + bin_src = base_python / "bin" + if bin_src.exists(): + executables = sorted(set([ + "python3", + "python", + f"python{version}", + f"python{version_major}", + f"python{version_major}.{version_minor}", + ])) + + for executable in executables: + exe_path = bin_src / executable + if exe_path.exists(): + add_file(exe_path, bin_src) + + include_src = base_python / "include" + python_include_src = include_src / python_folder_name + if python_include_src.exists(): + add_tree(python_include_src, python_include_src) + elif include_src.exists(): + for item in include_src.iterdir(): + if item.is_dir(): + add_tree(item, include_src) + elif item.is_file(): + add_file(item, include_src) + + entries.sort() + return entries + + +def compute_bundle_fingerprint(base_python, version_major, version_minor, exclude_patterns): + """Fingerprint of the tool, its patterns and every source file feeding the archive.""" + h = hashlib.md5() + h.update(Path(__file__).resolve().read_bytes()) + h.update("\n".join(exclude_patterns).encode()) + + for rel_path, size, mtime_ns in collect_source_fingerprint(base_python, version_major, version_minor, exclude_patterns): + h.update(f"{rel_path}|{size}|{mtime_ns}\n".encode()) + + return h.hexdigest() + + def copy_filtered_tree(src, dst, exclude_patterns): if not os.path.exists(dst): os.makedirs(dst) @@ -146,6 +222,13 @@ def make_archive(file, directory): custom_patterns = [x.strip() for x in args.exclude_patterns.replace('"', '').split(";")] base_patterns += custom_patterns + fingerprint_file = args.output_folder / f"python{version_nodot}.zip.fingerprint" + bundle_fingerprint = compute_bundle_fingerprint(base_python, args.version_major, args.version_minor, base_patterns) + + if final_archive.exists() and fingerprint_file.exists() and fingerprint_file.read_text().strip() == bundle_fingerprint: + print(f"-- YUP -- Bundle unchanged, skipping archive regeneration") + sys.exit(0) + print(f"-- YUP -- Cleaning up {final_location}...") if final_location.exists(): shutil.rmtree(final_location, ignore_errors=True) @@ -258,6 +341,8 @@ def make_archive(file, directory): make_archive(final_archive, final_location) print("-- YUP -- Archive created") + fingerprint_file.write_text(bundle_fingerprint) + # Clean up temporary directory print(f"-- YUP -- Cleaning up {final_location}...") shutil.rmtree(final_location, ignore_errors=True) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c17b4eeb5..e0987843e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -21,6 +21,8 @@ cmake_minimum_required(VERSION 3.31) enable_testing() +option (YUP_TEST_ALLOCATION_HOOKS "Enable allocation-count assertions in realtime tests" OFF) + # ==== Setup googletests if (YUP_PLATFORM_EMSCRIPTEN) set (gtest_disable_pthreads ON CACHE BOOL "" FORCE) @@ -89,10 +91,15 @@ set (target_gtest_modules GTest::gtest GTest::gmock) -if (YUP_PLATFORM_DESKTOP) +if (YUP_PLATFORM_DESKTOP OR YUP_PLATFORM_EMSCRIPTEN) list (APPEND target_modules - yup_audio_plugin_host - yup_audio_plugin_client) + yup_dsp_jit) + + if (YUP_PLATFORM_DESKTOP) + list (APPEND target_modules + yup_audio_plugin_host + yup_audio_plugin_client) + endif() endif() if (NOT YUP_PLATFORM_EMSCRIPTEN) @@ -142,6 +149,7 @@ yup_standalone_app ( $<$:-sWASM_BIGINT> $<$:-O1> DEFINITIONS + YUP_ENABLE_ALLOCATION_HOOKS=$ YUP_USE_CURL=0 YUP_MODAL_LOOPS_PERMITTED=1 PRELOAD_FILES @@ -157,18 +165,22 @@ yup_standalone_app ( set (sources "") set (imported_sources "") foreach (module ${target_modules}) - file (GLOB module_sources "${CMAKE_CURRENT_LIST_DIR}/${module}.cpp") + set (module_main "${CMAKE_CURRENT_LIST_DIR}/${module}/module.cpp") + file (GLOB module_sources "${module_main}") list (APPEND sources ${module_sources}) file (GLOB module_imported_sources "${CMAKE_CURRENT_LIST_DIR}/${module}/*.cpp") list (APPEND imported_sources ${module_imported_sources}) + list (REMOVE_ITEM imported_sources "${module_main}") if (YUP_PLATFORM_APPLE) - file (GLOB_RECURSE apple_sources "${CMAKE_CURRENT_LIST_DIR}/${module}.mm") + file (GLOB_RECURSE apple_sources "${CMAKE_CURRENT_LIST_DIR}/${module}/module.mm") list (APPEND sources ${apple_sources}) - file (GLOB_RECURSE apple_imported_sources "${CMAKE_CURRENT_LIST_DIR}/${module}/*.mm") + set (apple_main "${CMAKE_CURRENT_LIST_DIR}/${module}/module.mm") + file (GLOB_RECURSE apple_imported_sources "${apple_main}") list (APPEND imported_sources ${apple_imported_sources}) + list (REMOVE_ITEM imported_sources "${apple_main}") endif() endforeach() @@ -184,13 +196,13 @@ if (YUP_PLATFORM_DESKTOP) if (TARGET clap) target_link_libraries (${target_name} PRIVATE clap) target_sources (${target_name} PRIVATE - "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client_clap.cpp") + "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client/module_clap.cpp") endif() if (TARGET sdk) target_link_libraries (${target_name} PRIVATE sdk) target_sources (${target_name} PRIVATE - "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client_vst3.cpp") + "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client/module_vst3.cpp") if (TARGET sdk_hosting) target_link_libraries (${target_name} PRIVATE sdk_hosting) endif() @@ -199,17 +211,17 @@ if (YUP_PLATFORM_DESKTOP) if (YUP_PLATFORM_MAC AND TARGET base-sdk-auv2) target_link_libraries (${target_name} PRIVATE base-sdk-auv2) target_sources (${target_name} PRIVATE - "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client_au.mm") + "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client/module_au.mm") set_source_files_properties ( - "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client_au.mm" + "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client/module_au.mm" PROPERTIES COMPILE_FLAGS "-fobjc-arc") endif() if (YUP_PLATFORM_MAC) target_sources (${target_name} PRIVATE - "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client_auv3.mm") + "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client/module_auv3.mm") set_source_files_properties ( - "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client_auv3.mm" + "${CMAKE_CURRENT_LIST_DIR}/yup_audio_plugin_client/module_auv3.mm" PROPERTIES COMPILE_FLAGS "-fobjc-arc") target_link_libraries (${target_name} PRIVATE "-framework CoreAudioKit") endif() diff --git a/tests/yup_audio_graph.cpp b/tests/yup_ai/module.cpp similarity index 88% rename from tests/yup_audio_graph.cpp rename to tests/yup_ai/module.cpp index 205296769..72cd91145 100644 --- a/tests/yup_audio_graph.cpp +++ b/tests/yup_ai/module.cpp @@ -19,5 +19,6 @@ ============================================================================== */ -//#include "yup_audio_graph/yup_AudioGraphModel.cpp" -#include "yup_audio_graph/yup_AudioGraphProcessor.cpp" +#include "yup_LLMProviders.cpp" +#include "yup_LLMTypes.cpp" +#include "yup_MCPTypes.cpp" diff --git a/tests/yup_animation.cpp b/tests/yup_animation.cpp deleted file mode 100644 index ab9dbd26c..000000000 --- a/tests/yup_animation.cpp +++ /dev/null @@ -1,40 +0,0 @@ -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2026 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ - -#include "yup_animation/yup_AnimationEasing.cpp" -#include "yup_animation/yup_AnimationProperty.cpp" -#include "yup_animation/yup_AnimationRenderResources.cpp" -#include "yup_animation/yup_AnimationTransform.cpp" -#include "yup_animation/yup_LottieRoundtrip.cpp" -#include "yup_animation/yup_LottieExpressionEvaluator.cpp" -#include "yup_animation/yup_AnimationPlayer.cpp" -#include "yup_animation/yup_AnimationRenderer.cpp" -#include "yup_animation/yup_LottieWriter.cpp" -#include "yup_animation/yup_AnimationComposition.cpp" -#include "yup_animation/yup_AnimationGroup.cpp" -#include "yup_animation/yup_AnimationKeyPath.cpp" -#include "yup_animation/yup_AnimationModifier.cpp" -#include "yup_animation/yup_AnimationPaint.cpp" -#include "yup_animation/yup_AnimationPathData.cpp" -#include "yup_animation/yup_AnimationShape.cpp" -#include "yup_animation/yup_AnimationFrameExporter.cpp" -#include "yup_animation/yup_Animation.cpp" -#include "yup_animation/yup_LottieReader.cpp" diff --git a/tests/yup_animation/module.cpp b/tests/yup_animation/module.cpp new file mode 100644 index 000000000..bd4b93f11 --- /dev/null +++ b/tests/yup_animation/module.cpp @@ -0,0 +1,40 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_AnimationEasing.cpp" +#include "yup_AnimationProperty.cpp" +#include "yup_AnimationRenderResources.cpp" +#include "yup_AnimationTransform.cpp" +#include "yup_LottieRoundtrip.cpp" +#include "yup_LottieExpressionEvaluator.cpp" +#include "yup_AnimationPlayer.cpp" +#include "yup_AnimationRenderer.cpp" +#include "yup_LottieWriter.cpp" +#include "yup_AnimationComposition.cpp" +#include "yup_AnimationGroup.cpp" +#include "yup_AnimationKeyPath.cpp" +#include "yup_AnimationModifier.cpp" +#include "yup_AnimationPaint.cpp" +#include "yup_AnimationPathData.cpp" +#include "yup_AnimationShape.cpp" +#include "yup_AnimationFrameExporter.cpp" +#include "yup_Animation.cpp" +#include "yup_LottieReader.cpp" diff --git a/tests/yup_audio_basics.cpp b/tests/yup_audio_basics.cpp deleted file mode 100644 index 65c6fc03c..000000000 --- a/tests/yup_audio_basics.cpp +++ /dev/null @@ -1,76 +0,0 @@ -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2025 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ - -#include "mocks/yup_audio_basics.h" - -#include "yup_audio_basics/yup_ADSR.cpp" -#include "yup_audio_basics/yup_AudioChannelSet.cpp" -#include "yup_audio_basics/yup_AudioDataConverters.cpp" -#include "yup_audio_basics/yup_AudioPlayHead.cpp" -#include "yup_audio_basics/yup_AudioProcessLoadMeasurer.cpp" -#include "yup_audio_basics/yup_AudioSampleBuffer.cpp" -#include "yup_audio_basics/yup_AudioSpectralBuffer.cpp" -#include "yup_audio_basics/yup_BufferingAudioSource.cpp" -#include "yup_audio_basics/yup_ChannelRemappingAudioSource.cpp" -#include "yup_audio_basics/yup_Decibels.cpp" -#include "yup_audio_basics/yup_IIRFilter.cpp" -#include "yup_audio_basics/yup_Interpolators.cpp" -#include "yup_audio_basics/yup_MemoryAudioSource.cpp" -#include "yup_audio_basics/yup_MidiBuffer.cpp" -#include "yup_audio_basics/yup_MidiDataConcatenator.cpp" -#include "yup_audio_basics/yup_MidiFile.cpp" -#include "yup_audio_basics/yup_MidiKeyboardState.cpp" -#include "yup_audio_basics/yup_MidiMessage.cpp" -#include "yup_audio_basics/yup_MidiMessageSequence.cpp" -#include "yup_audio_basics/yup_MidiRPN.cpp" -#include "yup_audio_basics/yup_MixerAudioSource.cpp" -#include "yup_audio_basics/yup_MPEInstrument.cpp" -#include "yup_audio_basics/yup_MPEMessages.cpp" -#include "yup_audio_basics/yup_MPENote.cpp" -#include "yup_audio_basics/yup_MPESynthesiserBase.cpp" -#include "yup_audio_basics/yup_MPEUtils.cpp" -#include "yup_audio_basics/yup_MPEValue.cpp" -#include "yup_audio_basics/yup_MPEZoneLayout.cpp" -#include "yup_audio_basics/yup_ResamplingAudioSource.cpp" -#include "yup_audio_basics/yup_Reverb.cpp" -#include "yup_audio_basics/yup_ReverbAudioSource.cpp" -#include "yup_audio_basics/yup_SmoothedValue.cpp" -#include "yup_audio_basics/yup_Synthesiser.cpp" -#include "yup_audio_basics/yup_ToneGeneratorAudioSource.cpp" -#include "yup_audio_basics/yup_TuningMap.cpp" -#include "yup_audio_basics/yup_UMP.cpp" -#include "yup_audio_basics/yup_UMPCapabilityInquiry.cpp" -#include "yup_audio_basics/yup_UMPChannelVoice.cpp" -#include "yup_audio_basics/yup_UMPDataMessages.cpp" -#include "yup_audio_basics/yup_UMPExtendedDataMessages.cpp" -#include "yup_audio_basics/yup_UMPFlexDataMessages.cpp" -#include "yup_audio_basics/yup_UMPJitterReductionTimestamps.cpp" -#include "yup_audio_basics/yup_UMPKeyboardState.cpp" -#include "yup_audio_basics/yup_UMPMessages.cpp" -#include "yup_audio_basics/yup_UMPMidi1ByteStream.cpp" -#include "yup_audio_basics/yup_UMPMidi1ChannelVoiceMessage.cpp" -#include "yup_audio_basics/yup_UMPMidi2ChannelVoiceMessage.cpp" -#include "yup_audio_basics/yup_UMPPacketBuffer.cpp" -#include "yup_audio_basics/yup_UMPStreamMessages.cpp" -#include "yup_audio_basics/yup_UMPSysExCollectors.cpp" -#include "yup_audio_basics/yup_UMPTypes.cpp" -#include "yup_audio_basics/yup_UMPUniversalPacket.cpp" -#include "yup_audio_basics/yup_UMPUniversalSysEx.cpp" diff --git a/tests/yup_audio_basics/module.cpp b/tests/yup_audio_basics/module.cpp new file mode 100644 index 000000000..c691c1e40 --- /dev/null +++ b/tests/yup_audio_basics/module.cpp @@ -0,0 +1,77 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "../mocks/yup_audio_basics.h" + +#include "yup_ADSR.cpp" +#include "yup_AudioChannelSet.cpp" +#include "yup_AudioDataConverters.cpp" +#include "yup_AudioLockType.cpp" +#include "yup_AudioPlayHead.cpp" +#include "yup_AudioProcessLoadMeasurer.cpp" +#include "yup_AudioSampleBuffer.cpp" +#include "yup_AudioSpectralBuffer.cpp" +#include "yup_BufferingAudioSource.cpp" +#include "yup_ChannelRemappingAudioSource.cpp" +#include "yup_Decibels.cpp" +#include "yup_IIRFilter.cpp" +#include "yup_Interpolators.cpp" +#include "yup_MemoryAudioSource.cpp" +#include "yup_MidiBuffer.cpp" +#include "yup_MidiDataConcatenator.cpp" +#include "yup_MidiFile.cpp" +#include "yup_MidiKeyboardState.cpp" +#include "yup_MidiMessage.cpp" +#include "yup_MidiMessageSequence.cpp" +#include "yup_MidiRPN.cpp" +#include "yup_MixerAudioSource.cpp" +#include "yup_MPEInstrument.cpp" +#include "yup_MPEMessages.cpp" +#include "yup_MPENote.cpp" +#include "yup_MPESynthesiserBase.cpp" +#include "yup_MPEUtils.cpp" +#include "yup_MPEValue.cpp" +#include "yup_MPEZoneLayout.cpp" +#include "yup_ResamplingAudioSource.cpp" +#include "yup_Reverb.cpp" +#include "yup_ReverbAudioSource.cpp" +#include "yup_SmoothedValue.cpp" +#include "yup_Synthesiser.cpp" +#include "yup_ToneGeneratorAudioSource.cpp" +#include "yup_TuningMap.cpp" +#include "yup_UMP.cpp" +#include "yup_UMPCapabilityInquiry.cpp" +#include "yup_UMPChannelVoice.cpp" +#include "yup_UMPDataMessages.cpp" +#include "yup_UMPExtendedDataMessages.cpp" +#include "yup_UMPFlexDataMessages.cpp" +#include "yup_UMPJitterReductionTimestamps.cpp" +#include "yup_UMPKeyboardState.cpp" +#include "yup_UMPMessages.cpp" +#include "yup_UMPMidi1ByteStream.cpp" +#include "yup_UMPMidi1ChannelVoiceMessage.cpp" +#include "yup_UMPMidi2ChannelVoiceMessage.cpp" +#include "yup_UMPPacketBuffer.cpp" +#include "yup_UMPStreamMessages.cpp" +#include "yup_UMPSysExCollectors.cpp" +#include "yup_UMPTypes.cpp" +#include "yup_UMPUniversalPacket.cpp" +#include "yup_UMPUniversalSysEx.cpp" diff --git a/tests/yup_audio_basics/yup_AudioLockType.cpp b/tests/yup_audio_basics/yup_AudioLockType.cpp new file mode 100644 index 000000000..5e0c0ab37 --- /dev/null +++ b/tests/yup_audio_basics/yup_AudioLockType.cpp @@ -0,0 +1,70 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +using namespace yup; + +//============================================================================== +namespace +{ +#if YUP_WASM +using ExpectedAudioLockType = RecursiveSpinLock; +#else +using ExpectedAudioLockType = CriticalSection; +#endif +} // namespace + +//============================================================================== +TEST (AudioLockType, ResolvesToThePlatformAppropriateLock) +{ + static_assert (std::is_same_v); + EXPECT_TRUE ((std::is_same_v) ); +} + +TEST (AudioLockType, ScopedLockAcquiresAndReleases) +{ + AudioLockType lock; + + { + const AudioLockType::ScopedLockType sl (lock); + EXPECT_TRUE (lock.tryEnter()); // re-entrant on the same thread, so it succeeds + lock.exit(); + } + + EXPECT_TRUE (lock.tryEnter()); // released again after the scoped lock went out of scope + lock.exit(); +} + +TEST (AudioLockType, ReentrantAcquisitionOnTheSameThread) +{ + AudioLockType lock; + + lock.enter(); + lock.enter(); // must succeed without deadlocking on both CriticalSection and RecursiveSpinLock + lock.exit(); + lock.exit(); + + EXPECT_TRUE (lock.tryEnter()); + lock.exit(); +} diff --git a/tests/yup_audio_basics/yup_MidiKeyboardState.cpp b/tests/yup_audio_basics/yup_MidiKeyboardState.cpp index 0e45092b1..0e3393ea5 100644 --- a/tests/yup_audio_basics/yup_MidiKeyboardState.cpp +++ b/tests/yup_audio_basics/yup_MidiKeyboardState.cpp @@ -41,10 +41,22 @@ class TestListener : public MidiKeyboardState::Listener noteOffCalls.push_back ({ midiChannel, midiNoteNumber, velocity }); } + void handlePitchWheelMoved (MidiKeyboardState*, int midiChannel, int wheelPosition) override + { + pitchWheelCalls.push_back ({ midiChannel, wheelPosition }); + } + + void handleControllerMoved (MidiKeyboardState*, int midiChannel, int controllerNumber, int controllerValue) override + { + controllerCalls.push_back ({ midiChannel, controllerNumber, controllerValue }); + } + void reset() { noteOnCalls.clear(); noteOffCalls.clear(); + pitchWheelCalls.clear(); + controllerCalls.clear(); } struct NoteEvent @@ -54,8 +66,23 @@ class TestListener : public MidiKeyboardState::Listener float velocity; }; + struct PitchWheelEvent + { + int channel; + int position; + }; + + struct ControllerEvent + { + int channel; + int number; + int value; + }; + std::vector noteOnCalls; std::vector noteOffCalls; + std::vector pitchWheelCalls; + std::vector controllerCalls; }; } // namespace @@ -374,6 +401,151 @@ TEST_F (MidiKeyboardStateTests, AllNotesOffEmptyState) EXPECT_EQ (listener->noteOffCalls.size(), 0); } +//============================================================================== +// Pitch Wheel Tests +//============================================================================== +TEST_F (MidiKeyboardStateTests, PitchWheelPositionInitiallyZero) +{ + for (int ch = 1; ch <= 16; ++ch) + EXPECT_EQ (0, state->getPitchWheelPosition (ch)); +} + +TEST_F (MidiKeyboardStateTests, HandlePitchWheelUpdatesPosition) +{ + state->pitchWheel (1, 1000); + + EXPECT_EQ (1000, state->getPitchWheelPosition (1)); +} + +TEST_F (MidiKeyboardStateTests, HandlePitchWheelNotifiesListener) +{ + state->addListener (listener.get()); + + state->pitchWheel (3, 8192); + + ASSERT_EQ (1, (int) listener->pitchWheelCalls.size()); + EXPECT_EQ (3, listener->pitchWheelCalls[0].channel); + EXPECT_EQ (8192, listener->pitchWheelCalls[0].position); +} + +TEST_F (MidiKeyboardStateTests, HandlePitchWheelIgnoresUnchangedPosition) +{ + state->addListener (listener.get()); + + state->pitchWheel (1, 5000); + listener->reset(); + + state->pitchWheel (1, 5000); + + EXPECT_EQ (0, (int) listener->pitchWheelCalls.size()); +} + +TEST_F (MidiKeyboardStateTests, HandlePitchWheelIsPerChannel) +{ + state->addListener (listener.get()); + + state->pitchWheel (1, 2000); + + EXPECT_EQ (2000, state->getPitchWheelPosition (1)); + EXPECT_EQ (0, state->getPitchWheelPosition (2)); + + ASSERT_EQ (1, (int) listener->pitchWheelCalls.size()); + EXPECT_EQ (1, listener->pitchWheelCalls[0].channel); +} + +TEST_F (MidiKeyboardStateTests, ProcessNextMidiEventPitchWheel) +{ + state->addListener (listener.get()); + + state->processNextMidiEvent (MidiMessage::pitchWheel (1, 12000)); + + EXPECT_EQ (12000, state->getPitchWheelPosition (1)); + ASSERT_EQ (1, (int) listener->pitchWheelCalls.size()); + EXPECT_EQ (12000, listener->pitchWheelCalls[0].position); +} + +//============================================================================== +// Controller Tests +//============================================================================== +TEST_F (MidiKeyboardStateTests, ControllerValueInitiallyMinusOne) +{ + for (int ch = 1; ch <= 16; ++ch) + EXPECT_EQ (-1, state->getControllerValue (ch, 1)); +} + +TEST_F (MidiKeyboardStateTests, HandleControllerUpdatesValue) +{ + state->controlChange (1, 1, 64); + + EXPECT_EQ (64, state->getControllerValue (1, 1)); +} + +TEST_F (MidiKeyboardStateTests, HandleControllerNotifiesListener) +{ + state->addListener (listener.get()); + + state->controlChange (2, 1, 100); + + ASSERT_EQ (1, (int) listener->controllerCalls.size()); + EXPECT_EQ (2, listener->controllerCalls[0].channel); + EXPECT_EQ (1, listener->controllerCalls[0].number); + EXPECT_EQ (100, listener->controllerCalls[0].value); +} + +TEST_F (MidiKeyboardStateTests, HandleControllerIgnoresUnchangedValue) +{ + state->addListener (listener.get()); + + state->controlChange (1, 1, 64); + listener->reset(); + + state->controlChange (1, 1, 64); + + EXPECT_EQ (0, (int) listener->controllerCalls.size()); +} + +TEST_F (MidiKeyboardStateTests, HandleControllerTracksMultipleControllers) +{ + state->controlChange (1, 1, 10); + state->controlChange (1, 74, 20); + + EXPECT_EQ (10, state->getControllerValue (1, 1)); + EXPECT_EQ (20, state->getControllerValue (1, 74)); +} + +TEST_F (MidiKeyboardStateTests, HandleControllerIsPerChannel) +{ + state->controlChange (1, 1, 50); + state->controlChange (2, 1, 100); + + EXPECT_EQ (50, state->getControllerValue (1, 1)); + EXPECT_EQ (100, state->getControllerValue (2, 1)); +} + +TEST_F (MidiKeyboardStateTests, ProcessNextMidiEventController) +{ + state->addListener (listener.get()); + + state->processNextMidiEvent (MidiMessage::controllerEvent (1, 1, 127)); + + EXPECT_EQ (127, state->getControllerValue (1, 1)); + ASSERT_EQ (1, (int) listener->controllerCalls.size()); + EXPECT_EQ (127, listener->controllerCalls[0].value); +} + +TEST_F (MidiKeyboardStateTests, ResetClearsPitchWheelAndControllers) +{ + state->pitchWheel (1, 8192); + state->controlChange (1, 1, 64); + state->controlChange (1, 74, 32); + + state->reset(); + + EXPECT_EQ (0, state->getPitchWheelPosition (1)); + EXPECT_EQ (-1, state->getControllerValue (1, 1)); + EXPECT_EQ (-1, state->getControllerValue (1, 74)); +} + //============================================================================== // Process MIDI Event Tests //============================================================================== diff --git a/tests/yup_audio_devices.cpp b/tests/yup_audio_devices/module.cpp similarity index 85% rename from tests/yup_audio_devices.cpp rename to tests/yup_audio_devices/module.cpp index cdb0b5582..4b64758a3 100644 --- a/tests/yup_audio_devices.cpp +++ b/tests/yup_audio_devices/module.cpp @@ -19,7 +19,7 @@ ============================================================================== */ -#include "mocks/yup_audio_devices.h" +#include "../mocks/yup_audio_devices.h" -#include "yup_audio_devices/yup_AudioDeviceManager.cpp" -#include "yup_audio_devices/yup_UMPPacketCollector.cpp" +#include "yup_AudioDeviceManager.cpp" +#include "yup_UMPPacketCollector.cpp" diff --git a/tests/yup_audio_formats.cpp b/tests/yup_audio_formats/module.cpp similarity index 77% rename from tests/yup_audio_formats.cpp rename to tests/yup_audio_formats/module.cpp index 755cc817c..aa5830c93 100644 --- a/tests/yup_audio_formats.cpp +++ b/tests/yup_audio_formats/module.cpp @@ -31,26 +31,26 @@ #include #endif -#include "yup_audio_formats/yup_AudioFormatManager.cpp" -#include "yup_audio_formats/yup_AudioFormatReader.cpp" -#include "yup_audio_formats/yup_AudioFormatWriter.cpp" +#include "yup_AudioFormatManager.cpp" +#include "yup_AudioFormatReader.cpp" +#include "yup_AudioFormatWriter.cpp" #if YUP_MODULE_AVAILABLE_dr_libs && YUP_AUDIO_FORMAT_WAVE -#include "yup_audio_formats/yup_WaveAudioFormat.cpp" +#include "yup_WaveAudioFormat.cpp" #endif #if YUP_MODULE_AVAILABLE_dr_libs && YUP_AUDIO_FORMAT_MP3 -#include "yup_audio_formats/yup_Mp3AudioFormat.cpp" +#include "yup_Mp3AudioFormat.cpp" #endif #if YUP_MODULE_AVAILABLE_opus_library && YUP_AUDIO_FORMAT_OPUS -#include "yup_audio_formats/yup_OpusAudioFormat.cpp" +#include "yup_OpusAudioFormat.cpp" #endif #if YUP_MODULE_AVAILABLE_flac_library && YUP_AUDIO_FORMAT_FLAC -#include "yup_audio_formats/yup_FlacAudioFormat.cpp" +#include "yup_FlacAudioFormat.cpp" #endif #if YUP_AUDIO_FORMAT_COREAUDIO -#include "yup_audio_formats/yup_AppleCoreAudioFormat.cpp" +#include "yup_AppleCoreAudioFormat.cpp" #endif diff --git a/tests/yup_ai.cpp b/tests/yup_audio_graph/module.cpp similarity index 88% rename from tests/yup_ai.cpp rename to tests/yup_audio_graph/module.cpp index 8878d14d3..6187e581e 100644 --- a/tests/yup_ai.cpp +++ b/tests/yup_audio_graph/module.cpp @@ -19,6 +19,5 @@ ============================================================================== */ -#include "yup_ai/yup_LLMProviders.cpp" -#include "yup_ai/yup_LLMTypes.cpp" -#include "yup_ai/yup_MCPTypes.cpp" +//#include "yup_AudioGraphModel.cpp" +#include "yup_AudioGraphProcessor.cpp" diff --git a/tests/yup_audio_gui/module.cpp b/tests/yup_audio_gui/module.cpp new file mode 100644 index 000000000..4bf7cba33 --- /dev/null +++ b/tests/yup_audio_gui/module.cpp @@ -0,0 +1,33 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_AudioDeviceManagerWindow.cpp" +#include "yup_AudioPeakProfile.cpp" +#include "yup_AudioPeakProfileCache.cpp" +#include "yup_AudioThumbnail.cpp" +#include "yup_AudioGraphComponent.cpp" +#include "yup_AudioViewComponent.cpp" +#include "yup_CartesianPlane.cpp" +#include "yup_KMeterComponent.cpp" +#include "yup_MidiKeyboardComponent.cpp" +#include "yup_SpectrumAnalyzerComponent.cpp" +#include "yup_SpectrogramComponent.cpp" +#include "yup_AudioDeviceManagerPanel.cpp" diff --git a/tests/yup_audio_gui/yup_MidiKeyboardComponent.cpp b/tests/yup_audio_gui/yup_MidiKeyboardComponent.cpp new file mode 100644 index 000000000..372082d72 --- /dev/null +++ b/tests/yup_audio_gui/yup_MidiKeyboardComponent.cpp @@ -0,0 +1,483 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include +#include +#include + +using namespace yup; + +//============================================================================== +namespace +{ + +constexpr int kDefaultOctave = 3; +constexpr int kDefaultOctaveOffset = 12 * kDefaultOctave; + +} // namespace + +//============================================================================== +class MidiKeyboardComponentTests : public ::testing::Test +{ +protected: + struct NoteEvent + { + int channel; + int note; + float velocity; + }; + + class TestListener : public MidiKeyboardState::Listener + { + public: + void handleNoteOn (MidiKeyboardState*, int midiChannel, int midiNoteNumber, float velocity) override + { + noteOnCalls.push_back ({ midiChannel, midiNoteNumber, velocity }); + } + + void handleNoteOff (MidiKeyboardState*, int midiChannel, int midiNoteNumber, float velocity) override + { + noteOffCalls.push_back ({ midiChannel, midiNoteNumber, velocity }); + } + + std::vector noteOnCalls; + std::vector noteOffCalls; + }; + + void SetUp() override + { + state = std::make_unique(); + listener = std::make_unique(); + keyboard = std::make_unique (*state, MidiKeyboardComponent::horizontalKeyboard); + + state->addListener (listener.get()); + } + + void TearDown() override + { + state->removeListener (listener.get()); + keyboard.reset(); + listener.reset(); + state.reset(); + } + + MouseEvent makeWheelEvent (KeyModifiers modifiers = {}) const + { + return MouseEvent (MouseEvent::noButtons, modifiers, Point (0.0f, 0.0f)); + } + + void wheel (const MouseWheelData& data, KeyModifiers modifiers = {}) + { + keyboard->mouseWheel (makeWheelEvent (modifiers), data); + } + + std::unique_ptr state; + std::unique_ptr listener; + std::unique_ptr keyboard; +}; + +//============================================================================== +TEST_F (MidiKeyboardComponentTests, KeyDownSendsNoteOnForMappedKey) +{ + keyboard->keyDown (KeyPress ('z'), {}); + + ASSERT_EQ (1, (int) listener->noteOnCalls.size()); + EXPECT_EQ (1, listener->noteOnCalls[0].channel); + EXPECT_EQ (kDefaultOctaveOffset, listener->noteOnCalls[0].note); + EXPECT_FLOAT_EQ (1.0f, listener->noteOnCalls[0].velocity); + EXPECT_TRUE (keyboard->isNoteOn (kDefaultOctaveOffset)); +} + +TEST_F (MidiKeyboardComponentTests, KeyUpSendsNoteOffForTrackedNote) +{ + keyboard->keyDown (KeyPress ('z'), {}); + keyboard->keyUp (KeyPress ('z'), {}); + + ASSERT_EQ (1, (int) listener->noteOnCalls.size()); + ASSERT_EQ (1, (int) listener->noteOffCalls.size()); + EXPECT_EQ (kDefaultOctaveOffset, listener->noteOffCalls[0].note); + EXPECT_FALSE (keyboard->isNoteOn (kDefaultOctaveOffset)); +} + +TEST_F (MidiKeyboardComponentTests, KeyUpWithoutPriorKeyDownSendsNoNoteOff) +{ + keyboard->keyUp (KeyPress ('z'), {}); + + EXPECT_TRUE (listener->noteOnCalls.empty()); + EXPECT_TRUE (listener->noteOffCalls.empty()); +} + +TEST_F (MidiKeyboardComponentTests, RepeatedKeyDownDoesNotRetriggerNote) +{ + keyboard->keyDown (KeyPress ('z'), {}); + keyboard->keyDown (KeyPress ('z'), {}); + + ASSERT_EQ (1, (int) listener->noteOnCalls.size()); + EXPECT_EQ (kDefaultOctaveOffset, listener->noteOnCalls[0].note); +} + +TEST_F (MidiKeyboardComponentTests, FocusLostReleasesKeyboardNotes) +{ + keyboard->keyDown (KeyPress ('z'), {}); + keyboard->keyDown (KeyPress ('x'), {}); + + ASSERT_EQ (2, (int) listener->noteOnCalls.size()); + EXPECT_TRUE (keyboard->isNoteOn (kDefaultOctaveOffset)); + EXPECT_TRUE (keyboard->isNoteOn (kDefaultOctaveOffset + 2)); + + keyboard->focusLost(); + + ASSERT_EQ (2, (int) listener->noteOffCalls.size()); + EXPECT_FALSE (keyboard->isNoteOn (kDefaultOctaveOffset)); + EXPECT_FALSE (keyboard->isNoteOn (kDefaultOctaveOffset + 2)); +} + +TEST_F (MidiKeyboardComponentTests, OctaveChangeReleasesHeldKeyboardNotes) +{ + keyboard->keyDown (KeyPress ('z'), {}); + + EXPECT_TRUE (keyboard->isNoteOn (kDefaultOctaveOffset)); + + // A key held across an octave change can no longer be resolved by keyUp, + // so the held note must be released when the octave changes. + keyboard->setOctaveForMiddleC (4); + + ASSERT_EQ (1, (int) listener->noteOffCalls.size()); + EXPECT_EQ (kDefaultOctaveOffset, listener->noteOffCalls[0].note); + EXPECT_FALSE (keyboard->isNoteOn (kDefaultOctaveOffset)); +} + +TEST_F (MidiKeyboardComponentTests, KeysOutsideMidiRangeAreIgnored) +{ + keyboard->setOctaveForMiddleC (11); + + keyboard->keyDown (KeyPress ('z'), {}); + + EXPECT_TRUE (listener->noteOnCalls.empty()); + EXPECT_TRUE (listener->noteOffCalls.empty()); +} + +TEST_F (MidiKeyboardComponentTests, KeyMappingPlaysExpectedNotes) +{ + const std::pair mappings[] = { + { 'z', 0 }, { 's', 1 }, { 'x', 2 }, { 'd', 3 }, { 'c', 4 }, { 'v', 5 }, { 'g', 6 }, { 'b', 7 }, { 'h', 8 }, { 'n', 9 }, { 'j', 10 }, { 'm', 11 }, { 'q', 12 }, { '2', 13 }, { 'w', 14 }, { '3', 15 }, { 'e', 16 }, { 'r', 17 }, { '5', 18 }, { 't', 19 }, { '6', 20 }, { 'y', 21 }, { '7', 22 }, { 'u', 23 }, { 'i', 24 }, { '9', 25 }, { 'o', 26 }, { '0', 27 }, { 'p', 28 } + }; + + for (const auto& [key, relativeNote] : mappings) + { + listener->noteOnCalls.clear(); + listener->noteOffCalls.clear(); + + keyboard->keyDown (KeyPress (key), {}); + keyboard->keyUp (KeyPress (key), {}); + + ASSERT_EQ (1, (int) listener->noteOnCalls.size()) << "key: " << key; + ASSERT_EQ (1, (int) listener->noteOffCalls.size()) << "key: " << key; + EXPECT_EQ (kDefaultOctaveOffset + relativeNote, listener->noteOnCalls[0].note) << "key: " << key; + EXPECT_EQ (kDefaultOctaveOffset + relativeNote, listener->noteOffCalls[0].note) << "key: " << key; + EXPECT_FALSE (keyboard->isNoteOn (kDefaultOctaveOffset + relativeNote)); + } +} + +TEST_F (MidiKeyboardComponentTests, KeyMappingIsCaseInsensitive) +{ + keyboard->keyDown (KeyPress ('Z'), {}); + + ASSERT_EQ (1, (int) listener->noteOnCalls.size()); + EXPECT_EQ (kDefaultOctaveOffset, listener->noteOnCalls[0].note); +} + +TEST_F (MidiKeyboardComponentTests, UnmappedKeyIsIgnored) +{ + keyboard->keyDown (KeyPress ('k'), {}); + + EXPECT_TRUE (listener->noteOnCalls.empty()); +} + +TEST_F (MidiKeyboardComponentTests, DefaultKeyboardKeysMatchesDocumentedLayout) +{ + EXPECT_EQ (String ("zsxdcvgbhnjmq2w3er5t6y7ui9o0p"), keyboard->getKeyboardKeys()); +} + +TEST_F (MidiKeyboardComponentTests, SetKeyboardKeysChangesMapping) +{ + keyboard->setKeyboardKeys ("ab"); + + keyboard->keyDown (KeyPress ('a'), {}); + keyboard->keyDown (KeyPress ('b'), {}); + + ASSERT_EQ (2, (int) listener->noteOnCalls.size()); + EXPECT_EQ (kDefaultOctaveOffset, listener->noteOnCalls[0].note); + EXPECT_EQ (kDefaultOctaveOffset + 1, listener->noteOnCalls[1].note); + + // The old default mapping no longer plays anything. + keyboard->keyDown (KeyPress ('z'), {}); + EXPECT_EQ (2, (int) listener->noteOnCalls.size()); +} + +TEST_F (MidiKeyboardComponentTests, SetKeyboardKeysIsCaseInsensitive) +{ + keyboard->setKeyboardKeys ("AB"); + + EXPECT_EQ (String ("ab"), keyboard->getKeyboardKeys()); +} + +TEST_F (MidiKeyboardComponentTests, SetKeyboardKeysReleasesHeldKeyboardNotes) +{ + keyboard->keyDown (KeyPress ('z'), {}); + EXPECT_TRUE (keyboard->isNoteOn (kDefaultOctaveOffset)); + + // A key held across a mapping change can no longer be resolved by keyUp, + // so the held note must be released when the mapping changes. + keyboard->setKeyboardKeys ("ab"); + + ASSERT_EQ (1, (int) listener->noteOffCalls.size()); + EXPECT_EQ (kDefaultOctaveOffset, listener->noteOffCalls[0].note); + EXPECT_FALSE (keyboard->isNoteOn (kDefaultOctaveOffset)); +} + +//============================================================================== +TEST_F (MidiKeyboardComponentTests, WheelScrollsRightBySingleWhiteKeys) +{ + wheel (MouseWheelData (1.0f, 0.0f)); + + EXPECT_EQ (14, keyboard->getLowestVisibleKey()); + EXPECT_EQ (98, keyboard->getHighestVisibleKey()); +} + +TEST_F (MidiKeyboardComponentTests, WheelScrollsLeftBySingleWhiteKeys) +{ + wheel (MouseWheelData (-1.0f, 0.0f)); + + EXPECT_EQ (11, keyboard->getLowestVisibleKey()); + EXPECT_EQ (95, keyboard->getHighestVisibleKey()); +} + +TEST_F (MidiKeyboardComponentTests, WheelScrollSkipsBlackKeys) +{ + wheel (MouseWheelData (1.0f, 0.0f)); + + // Scrolling by one white key moves the range start from C (12) to D (14), + // skipping the C# black key in between. + EXPECT_EQ (14, keyboard->getLowestVisibleKey()); +} + +TEST_F (MidiKeyboardComponentTests, VerticalWheelScrollsHorizontalKeyboard) +{ + wheel (MouseWheelData (0.0f, 1.0f)); + + EXPECT_EQ (14, keyboard->getLowestVisibleKey()); + EXPECT_EQ (98, keyboard->getHighestVisibleKey()); +} + +TEST_F (MidiKeyboardComponentTests, WheelScrollsVerticalKeyboardAlongItsAxis) +{ + MidiKeyboardComponent vertical (*state, MidiKeyboardComponent::verticalKeyboardFacingLeft); + vertical.mouseWheel (makeWheelEvent(), MouseWheelData (0.0f, 1.0f)); + + EXPECT_EQ (14, vertical.getLowestVisibleKey()); + EXPECT_EQ (98, vertical.getHighestVisibleKey()); +} + +TEST_F (MidiKeyboardComponentTests, WheelScrollClampsAtRangeEdges) +{ + keyboard->setAvailableRange (0, 12); + wheel (MouseWheelData (-1.0f, 0.0f)); + + EXPECT_EQ (0, keyboard->getLowestVisibleKey()); + EXPECT_EQ (12, keyboard->getHighestVisibleKey()); + + keyboard->setAvailableRange (0, 127); + wheel (MouseWheelData (1.0f, 0.0f)); + + EXPECT_EQ (0, keyboard->getLowestVisibleKey()); + EXPECT_EQ (127, keyboard->getHighestVisibleKey()); +} + +TEST_F (MidiKeyboardComponentTests, WheelScrollKeepsRangeSpan) +{ + keyboard->setAvailableRange (12, 96); + wheel (MouseWheelData (1.0f, 0.0f)); + + EXPECT_EQ (84, keyboard->getHighestVisibleKey() - keyboard->getLowestVisibleKey()); +} + +TEST_F (MidiKeyboardComponentTests, WheelScrollAtTopEdgeKeepsSpanWithinRange) +{ + keyboard->setAvailableRange (115, 127); + wheel (MouseWheelData (1.0f, 0.0f)); + + EXPECT_EQ (115, keyboard->getLowestVisibleKey()); + EXPECT_EQ (127, keyboard->getHighestVisibleKey()); +} + +//============================================================================== +TEST_F (MidiKeyboardComponentTests, CtrlWheelZoomsInAroundAnchorNote) +{ + keyboard->setBounds (0.0f, 0.0f, 1000.0f, 200.0f); + keyboard->setAvailableRange (0, 127); + + // Anchor the zoom on the C4 key under the mouse position. + const auto anchorPoint = keyboard->getRectangleForKey (60).getCenter(); + + keyboard->mouseWheel ( + MouseEvent (MouseEvent::noButtons, KeyModifiers (KeyModifiers::controlMask), anchorPoint), + MouseWheelData (0.0f, 1.0f)); + + // 127 / 1.25 rounded to 102, anchored so that note 60 keeps its position. + EXPECT_EQ (12, keyboard->getLowestVisibleKey()); + EXPECT_EQ (114, keyboard->getHighestVisibleKey()); +} + +TEST_F (MidiKeyboardComponentTests, CtrlWheelZoomInClampedToOneOctave) +{ + keyboard->setAvailableRange (0, 127); + + const auto modifiers = KeyModifiers (KeyModifiers::controlMask); + + for (int i = 0; i < 30 && keyboard->getHighestVisibleKey() - keyboard->getLowestVisibleKey() > 12; ++i) + wheel (MouseWheelData (0.0f, 1.0f), modifiers); + + EXPECT_EQ (12, keyboard->getHighestVisibleKey() - keyboard->getLowestVisibleKey()); + + // Further zooming in is clamped and must not shrink the range further. + wheel (MouseWheelData (0.0f, 1.0f), modifiers); + EXPECT_EQ (12, keyboard->getHighestVisibleKey() - keyboard->getLowestVisibleKey()); + EXPECT_GE (keyboard->getLowestVisibleKey(), 0); + EXPECT_LE (keyboard->getHighestVisibleKey(), 127); +} + +TEST_F (MidiKeyboardComponentTests, CtrlWheelZoomOutExpandsToFullRange) +{ + keyboard->setAvailableRange (12, 96); + + const auto modifiers = KeyModifiers (KeyModifiers::controlMask); + + for (int i = 0; i < 30 && keyboard->getHighestVisibleKey() - keyboard->getLowestVisibleKey() < 127; ++i) + wheel (MouseWheelData (0.0f, -1.0f), modifiers); + + EXPECT_EQ (0, keyboard->getLowestVisibleKey()); + EXPECT_EQ (127, keyboard->getHighestVisibleKey()); + + // Further zooming out is a no-op once the full range is reached. + wheel (MouseWheelData (0.0f, -1.0f), modifiers); + EXPECT_EQ (0, keyboard->getLowestVisibleKey()); + EXPECT_EQ (127, keyboard->getHighestVisibleKey()); +} + +TEST_F (MidiKeyboardComponentTests, CtrlWheelZoomKeepsRangeValid) +{ + const auto modifiers = KeyModifiers (KeyModifiers::controlMask); + + for (int i = 0; i < 30; ++i) + { + wheel (MouseWheelData (0.0f, 1.0f), modifiers); + wheel (MouseWheelData (0.0f, -1.0f), modifiers); + + const auto start = keyboard->getLowestVisibleKey(); + const auto end = keyboard->getHighestVisibleKey(); + + EXPECT_GE (start, 0); + EXPECT_LE (end, 127); + EXPECT_GE (end - start, 12); + EXPECT_LE (end - start, 127); + } +} + +TEST_F (MidiKeyboardComponentTests, PlainWheelDoesNotZoom) +{ + keyboard->setAvailableRange (0, 127); + + wheel (MouseWheelData (0.0f, 1.0f)); + + EXPECT_EQ (0, keyboard->getLowestVisibleKey()); + EXPECT_EQ (127, keyboard->getHighestVisibleKey()); +} + +//============================================================================== +class MidiKeyboardComponentAsyncTests : public ::testing::Test +{ +protected: + class ObservableKeyboard : public MidiKeyboardComponent + { + public: + using MidiKeyboardComponent::MidiKeyboardComponent; + + int repaintCount = 0; + + protected: + void handleAsyncUpdate() override + { + ++repaintCount; + MidiKeyboardComponent::handleAsyncUpdate(); + } + }; + + void SetUp() override + { + messageManager = MessageManager::getInstance(); + state = std::make_unique(); + keyboard = std::make_unique (*state, MidiKeyboardComponent::horizontalKeyboard); + } + + void runDispatchLoopUntil (int millisecondsToRunFor = 100) + { + messageManager->runDispatchLoopUntil (millisecondsToRunFor); + } + + MessageManager* messageManager = nullptr; + std::unique_ptr state; + std::unique_ptr keyboard; +}; + +TEST_F (MidiKeyboardComponentAsyncTests, NotesFedFromAnotherThreadRepaintOnTheMessageThread) +{ + std::thread feeder ([this] + { + state->processNextMidiEvent (MidiMessage::noteOn (1, 60, 0.5f)); + }); + feeder.join(); + + EXPECT_TRUE (keyboard->isNoteOn (60)); + EXPECT_EQ (0, keyboard->repaintCount); // deferred - nothing repaints on the feeding thread + + runDispatchLoopUntil (100); + + EXPECT_GE (keyboard->repaintCount, 1); + + const auto repaintCountAfterNoteOn = keyboard->repaintCount; + + std::thread releaser ([this] + { + state->processNextMidiEvent (MidiMessage::noteOff (1, 60)); + }); + releaser.join(); + + EXPECT_FALSE (keyboard->isNoteOn (60)); + + runDispatchLoopUntil (100); + + EXPECT_GT (keyboard->repaintCount, repaintCountAfterNoteOn); +} diff --git a/tests/yup_audio_gui/yup_ModWheelComponent.cpp b/tests/yup_audio_gui/yup_ModWheelComponent.cpp new file mode 100644 index 000000000..5161144f2 --- /dev/null +++ b/tests/yup_audio_gui/yup_ModWheelComponent.cpp @@ -0,0 +1,329 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include + +using namespace yup; + +namespace yup +{ +extern std::unique_ptr yup_constructHeadlessGraphicsContext (yup::GpuDevice::Options, yup::GpuDevice::Ptr); +} // namespace yup + +class ModWheelComponentTest : public ::testing::Test +{ +protected: + void SetUp() override + { + state = std::make_unique(); + wheel = std::make_unique (*state, "testModWheel"); + wheel->setBounds (0, 0, 30, 120); + } + + std::unique_ptr state; + std::unique_ptr wheel; +}; + +//============================================================================== +TEST_F (ModWheelComponentTest, DefaultInitialization) +{ + EXPECT_DOUBLE_EQ (0.0, wheel->getValue()); + EXPECT_DOUBLE_EQ (0.0, wheel->getDefaultValue()); + EXPECT_DOUBLE_EQ (1.0, wheel->getMouseDragSensitivity()); + EXPECT_EQ (1, wheel->getMidiChannel()); + EXPECT_FALSE (wheel->isMouseOver()); + EXPECT_FALSE (wheel->isCurrentlyBeingDragged()); +} + +TEST_F (ModWheelComponentTest, ConstructWithComponentId) +{ + EXPECT_EQ (String ("testModWheel"), wheel->getComponentID()); +} + +//============================================================================== +TEST_F (ModWheelComponentTest, SetValueClampsToRange) +{ + wheel->setValue (0.5); + EXPECT_DOUBLE_EQ (0.5, wheel->getValue()); + + wheel->setValue (5.0); + EXPECT_DOUBLE_EQ (1.0, wheel->getValue()); + + wheel->setValue (-5.0); + EXPECT_DOUBLE_EQ (0.0, wheel->getValue()); +} + +TEST_F (ModWheelComponentTest, SetValueWithNotificationCallback) +{ + int callCount = 0; + double lastValue = 0.0; + wheel->onValueChanged = [&] (double v) + { + ++callCount; + lastValue = v; + }; + + wheel->setValue (0.5, sendNotification); + EXPECT_EQ (1, callCount); + EXPECT_DOUBLE_EQ (0.5, lastValue); +} + +TEST_F (ModWheelComponentTest, SetValueWithoutNotification) +{ + int callCount = 0; + wheel->onValueChanged = [&callCount] (double) + { + ++callCount; + }; + + wheel->setValue (0.5, dontSendNotification); + EXPECT_EQ (0, callCount); +} + +//============================================================================== +TEST_F (ModWheelComponentTest, DefaultValueOperations) +{ + wheel->setDefaultValue (0.75); + EXPECT_DOUBLE_EQ (0.75, wheel->getDefaultValue()); + + wheel->setDefaultValue (5.0); + EXPECT_DOUBLE_EQ (1.0, wheel->getDefaultValue()); +} + +//============================================================================== +TEST_F (ModWheelComponentTest, MouseDragSensitivity) +{ + wheel->setMouseDragSensitivity (2.5); + EXPECT_DOUBLE_EQ (2.5, wheel->getMouseDragSensitivity()); + + wheel->setMouseDragSensitivity (0.1); + EXPECT_DOUBLE_EQ (0.1, wheel->getMouseDragSensitivity()); +} + +//============================================================================== +TEST_F (ModWheelComponentTest, IsMouseOverDefaultFalse) +{ + EXPECT_FALSE (wheel->isMouseOver()); +} + +TEST_F (ModWheelComponentTest, IsCurrentlyBeingDraggedDefaultFalse) +{ + EXPECT_FALSE (wheel->isCurrentlyBeingDragged()); +} + +//============================================================================== +TEST_F (ModWheelComponentTest, DragStartCallback) +{ + bool called = false; + wheel->onDragStart = [&] (const MouseEvent&) + { + called = true; + }; + + EXPECT_FALSE (called); +} + +TEST_F (ModWheelComponentTest, DragEndCallback) +{ + bool called = false; + wheel->onDragEnd = [&] (const MouseEvent&) + { + called = true; + }; + + EXPECT_FALSE (called); +} + +//============================================================================== +TEST_F (ModWheelComponentTest, PaintWithThemeDoesNotCrash) +{ + auto context = yup_constructHeadlessGraphicsContext ({}, {}); + auto renderer = context->makeRenderer (30, 120); + Graphics g (*context, *renderer); + + EXPECT_NO_THROW ({ wheel->paint (g); }); +} + +TEST_F (ModWheelComponentTest, PaintAtTinyBoundsDoesNotCrash) +{ + // The grip-band and gradient math must degrade gracefully at a height too + // small for the band's travel margins to make sense. + wheel->setBounds (0, 0, 30, 10); + + auto context = yup_constructHeadlessGraphicsContext ({}, {}); + auto renderer = context->makeRenderer (30, 10); + Graphics g (*context, *renderer); + + EXPECT_NO_THROW ({ wheel->paint (g); }); +} + +//============================================================================== +class ModWheelComponentAsyncTests : public ::testing::Test +{ +protected: + class ObservableModWheel : public ModWheelComponent + { + public: + using ModWheelComponent::ModWheelComponent; + + int asyncUpdateCount = 0; + + protected: + void handleAsyncUpdate() override + { + ++asyncUpdateCount; + ModWheelComponent::handleAsyncUpdate(); + } + }; + + void SetUp() override + { + messageManager = MessageManager::getInstance(); + state = std::make_unique(); + wheel = std::make_unique (*state, "asyncModWheel"); + } + + void runDispatchLoopUntil (int millisecondsToRunFor = 100) + { + messageManager->runDispatchLoopUntil (millisecondsToRunFor); + } + + MouseEvent makeButtonEvent (float y) const + { + return MouseEvent (MouseEvent::Buttons::leftButton, {}, Point (15.0f, y)); + } + + MessageManager* messageManager = nullptr; + std::unique_ptr state; + std::unique_ptr wheel; +}; + +TEST_F (ModWheelComponentAsyncTests, ControllerFedFromAnotherThreadAppliesOnTheMessageThread) +{ + std::thread feeder ([this] + { + state->processNextMidiEvent (MidiMessage::controllerEvent (1, 1, 64)); + }); + feeder.join(); + + EXPECT_DOUBLE_EQ (0.0, wheel->getValue()); // deferred - nothing changes on the feeding thread + EXPECT_EQ (0, wheel->asyncUpdateCount); + + runDispatchLoopUntil (100); + + EXPECT_GE (wheel->asyncUpdateCount, 1); + EXPECT_NEAR (64.0 / 127.0, wheel->getValue(), 1e-9); +} + +TEST_F (ModWheelComponentAsyncTests, OnlyTheLatestControllerValueIsApplied) +{ + std::thread feeder ([this] + { + state->processNextMidiEvent (MidiMessage::controllerEvent (1, 1, 0)); + state->processNextMidiEvent (MidiMessage::controllerEvent (1, 1, 64)); + state->processNextMidiEvent (MidiMessage::controllerEvent (1, 1, 127)); + }); + feeder.join(); + + runDispatchLoopUntil (100); + + EXPECT_NEAR (1.0, wheel->getValue(), 1e-9); +} + +TEST_F (ModWheelComponentAsyncTests, OtherControllersAndChannelsAreIgnored) +{ + std::thread feeder ([this] + { + state->processNextMidiEvent (MidiMessage::controllerEvent (2, 1, 127)); + state->processNextMidiEvent (MidiMessage::controllerEvent (1, 74, 127)); + }); + feeder.join(); + + runDispatchLoopUntil (100); + + EXPECT_DOUBLE_EQ (0.0, wheel->getValue()); + EXPECT_EQ (0, wheel->asyncUpdateCount); +} + +TEST_F (ModWheelComponentAsyncTests, NoteEventsDoNotTriggerAsyncUpdate) +{ + std::thread feeder ([this] + { + state->processNextMidiEvent (MidiMessage::noteOn (1, 60, 0.5f)); + state->processNextMidiEvent (MidiMessage::noteOff (1, 60)); + }); + feeder.join(); + + runDispatchLoopUntil (100); + + EXPECT_DOUBLE_EQ (0.0, wheel->getValue()); + EXPECT_EQ (0, wheel->asyncUpdateCount); +} + +TEST_F (ModWheelComponentAsyncTests, ControllerIsNotAppliedWhileDragging) +{ + std::thread feeder ([this] + { + state->processNextMidiEvent (MidiMessage::controllerEvent (1, 1, 64)); + }); + feeder.join(); + + runDispatchLoopUntil (100); + + EXPECT_NEAR (64.0 / 127.0, wheel->getValue(), 1e-9); + + wheel->mouseDown (makeButtonEvent (60.0f)); + EXPECT_TRUE (wheel->isCurrentlyBeingDragged()); + + std::thread dragger ([this] + { + state->processNextMidiEvent (MidiMessage::controllerEvent (1, 1, 127)); + }); + dragger.join(); + + runDispatchLoopUntil (100); + + // The MIDI value must not move the wheel while the user is dragging it. + EXPECT_NEAR (64.0 / 127.0, wheel->getValue(), 1e-9); + + wheel->mouseUp (makeButtonEvent (60.0f)); + EXPECT_FALSE (wheel->isCurrentlyBeingDragged()); +} + +TEST_F (ModWheelComponentAsyncTests, MidiChannelSelection) +{ + wheel->setMidiChannel (2); + + std::thread feeder ([this] + { + state->processNextMidiEvent (MidiMessage::controllerEvent (2, 1, 127)); + }); + feeder.join(); + + runDispatchLoopUntil (100); + + EXPECT_NEAR (1.0, wheel->getValue(), 1e-9); + EXPECT_EQ (2, wheel->getMidiChannel()); +} diff --git a/tests/yup_audio_gui/yup_PitchWheelComponent.cpp b/tests/yup_audio_gui/yup_PitchWheelComponent.cpp new file mode 100644 index 000000000..80b4147ac --- /dev/null +++ b/tests/yup_audio_gui/yup_PitchWheelComponent.cpp @@ -0,0 +1,339 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include + +using namespace yup; + +namespace yup +{ +extern std::unique_ptr yup_constructHeadlessGraphicsContext (yup::GpuDevice::Options, yup::GpuDevice::Ptr); +} // namespace yup + +class PitchWheelComponentTest : public ::testing::Test +{ +protected: + void SetUp() override + { + state = std::make_unique(); + wheel = std::make_unique (*state, "testPitchWheel"); + wheel->setBounds (0, 0, 30, 120); + } + + std::unique_ptr state; + std::unique_ptr wheel; +}; + +//============================================================================== +TEST_F (PitchWheelComponentTest, DefaultInitialization) +{ + EXPECT_DOUBLE_EQ (0.0, wheel->getValue()); + EXPECT_DOUBLE_EQ (0.0, wheel->getDefaultValue()); + EXPECT_TRUE (wheel->getResetOnRelease()); + EXPECT_DOUBLE_EQ (1.0, wheel->getMouseDragSensitivity()); + EXPECT_EQ (1, wheel->getMidiChannel()); + EXPECT_FALSE (wheel->isMouseOver()); + EXPECT_FALSE (wheel->isCurrentlyBeingDragged()); +} + +TEST_F (PitchWheelComponentTest, ConstructWithComponentId) +{ + EXPECT_EQ (String ("testPitchWheel"), wheel->getComponentID()); +} + +//============================================================================== +TEST_F (PitchWheelComponentTest, SetValueClampsToRange) +{ + wheel->setValue (0.5); + EXPECT_DOUBLE_EQ (0.5, wheel->getValue()); + + wheel->setValue (5.0); + EXPECT_DOUBLE_EQ (1.0, wheel->getValue()); + + wheel->setValue (-5.0); + EXPECT_DOUBLE_EQ (-1.0, wheel->getValue()); +} + +TEST_F (PitchWheelComponentTest, SetValueWithNotificationCallback) +{ + int callCount = 0; + double lastValue = 0.0; + wheel->onValueChanged = [&] (double v) + { + ++callCount; + lastValue = v; + }; + + wheel->setValue (0.5, sendNotification); + EXPECT_EQ (1, callCount); + EXPECT_DOUBLE_EQ (0.5, lastValue); +} + +TEST_F (PitchWheelComponentTest, SetValueWithoutNotification) +{ + int callCount = 0; + wheel->onValueChanged = [&callCount] (double) + { + ++callCount; + }; + + wheel->setValue (0.5, dontSendNotification); + EXPECT_EQ (0, callCount); +} + +//============================================================================== +TEST_F (PitchWheelComponentTest, DefaultValueOperations) +{ + wheel->setDefaultValue (0.25); + EXPECT_DOUBLE_EQ (0.25, wheel->getDefaultValue()); + + wheel->setDefaultValue (5.0); + EXPECT_DOUBLE_EQ (1.0, wheel->getDefaultValue()); +} + +//============================================================================== +TEST_F (PitchWheelComponentTest, ResetOnReleaseRoundTrip) +{ + wheel->setResetOnRelease (false); + EXPECT_FALSE (wheel->getResetOnRelease()); + + wheel->setResetOnRelease (true); + EXPECT_TRUE (wheel->getResetOnRelease()); +} + +//============================================================================== +TEST_F (PitchWheelComponentTest, MouseDragSensitivity) +{ + wheel->setMouseDragSensitivity (2.5); + EXPECT_DOUBLE_EQ (2.5, wheel->getMouseDragSensitivity()); + + wheel->setMouseDragSensitivity (0.1); + EXPECT_DOUBLE_EQ (0.1, wheel->getMouseDragSensitivity()); +} + +//============================================================================== +TEST_F (PitchWheelComponentTest, IsMouseOverDefaultFalse) +{ + EXPECT_FALSE (wheel->isMouseOver()); +} + +TEST_F (PitchWheelComponentTest, IsCurrentlyBeingDraggedDefaultFalse) +{ + EXPECT_FALSE (wheel->isCurrentlyBeingDragged()); +} + +//============================================================================== +TEST_F (PitchWheelComponentTest, DragStartCallback) +{ + bool called = false; + wheel->onDragStart = [&] (const MouseEvent&) + { + called = true; + }; + + EXPECT_FALSE (called); +} + +TEST_F (PitchWheelComponentTest, DragEndCallback) +{ + bool called = false; + wheel->onDragEnd = [&] (const MouseEvent&) + { + called = true; + }; + + EXPECT_FALSE (called); +} + +//============================================================================== +TEST_F (PitchWheelComponentTest, PaintWithThemeDoesNotCrash) +{ + auto context = yup_constructHeadlessGraphicsContext ({}, {}); + auto renderer = context->makeRenderer (30, 120); + Graphics g (*context, *renderer); + + EXPECT_NO_THROW ({ wheel->paint (g); }); +} + +TEST_F (PitchWheelComponentTest, PaintAtTinyBoundsDoesNotCrash) +{ + // The grip-band and gradient math must degrade gracefully at a height too + // small for the band's travel margins to make sense. + wheel->setBounds (0, 0, 30, 10); + + auto context = yup_constructHeadlessGraphicsContext ({}, {}); + auto renderer = context->makeRenderer (30, 10); + Graphics g (*context, *renderer); + + EXPECT_NO_THROW ({ wheel->paint (g); }); +} + +//============================================================================== +class PitchWheelComponentAsyncTests : public ::testing::Test +{ +protected: + class ObservablePitchWheel : public PitchWheelComponent + { + public: + using PitchWheelComponent::PitchWheelComponent; + + int asyncUpdateCount = 0; + + protected: + void handleAsyncUpdate() override + { + ++asyncUpdateCount; + PitchWheelComponent::handleAsyncUpdate(); + } + }; + + void SetUp() override + { + messageManager = MessageManager::getInstance(); + state = std::make_unique(); + wheel = std::make_unique (*state, "asyncPitchWheel"); + } + + void runDispatchLoopUntil (int millisecondsToRunFor = 100) + { + messageManager->runDispatchLoopUntil (millisecondsToRunFor); + } + + MouseEvent makeButtonEvent (float y) const + { + return MouseEvent (MouseEvent::Buttons::leftButton, {}, Point (15.0f, y)); + } + + MessageManager* messageManager = nullptr; + std::unique_ptr state; + std::unique_ptr wheel; +}; + +TEST_F (PitchWheelComponentAsyncTests, PitchWheelFedFromAnotherThreadAppliesOnTheMessageThread) +{ + std::thread feeder ([this] + { + state->processNextMidiEvent (MidiMessage::pitchWheel (1, 4096)); + }); + feeder.join(); + + EXPECT_DOUBLE_EQ (0.0, wheel->getValue()); // deferred - nothing changes on the feeding thread + EXPECT_EQ (0, wheel->asyncUpdateCount); + + runDispatchLoopUntil (100); + + EXPECT_GE (wheel->asyncUpdateCount, 1); + EXPECT_NEAR ((4096.0 - 8192.0) / 8191.0, wheel->getValue(), 1e-9); +} + +TEST_F (PitchWheelComponentAsyncTests, OnlyTheLatestPitchWheelPositionIsApplied) +{ + std::thread feeder ([this] + { + state->processNextMidiEvent (MidiMessage::pitchWheel (1, 0)); + state->processNextMidiEvent (MidiMessage::pitchWheel (1, 4096)); + state->processNextMidiEvent (MidiMessage::pitchWheel (1, 12288)); + }); + feeder.join(); + + runDispatchLoopUntil (100); + + EXPECT_NEAR ((12288.0 - 8192.0) / 8191.0, wheel->getValue(), 1e-9); +} + +TEST_F (PitchWheelComponentAsyncTests, PitchWheelOnOtherChannelsIsIgnored) +{ + std::thread feeder ([this] + { + state->processNextMidiEvent (MidiMessage::pitchWheel (2, 12288)); + }); + feeder.join(); + + runDispatchLoopUntil (100); + + EXPECT_DOUBLE_EQ (0.0, wheel->getValue()); + EXPECT_EQ (0, wheel->asyncUpdateCount); +} + +TEST_F (PitchWheelComponentAsyncTests, NoteEventsDoNotTriggerAsyncUpdate) +{ + std::thread feeder ([this] + { + state->processNextMidiEvent (MidiMessage::noteOn (1, 60, 0.5f)); + state->processNextMidiEvent (MidiMessage::noteOff (1, 60)); + }); + feeder.join(); + + runDispatchLoopUntil (100); + + EXPECT_DOUBLE_EQ (0.0, wheel->getValue()); + EXPECT_EQ (0, wheel->asyncUpdateCount); +} + +TEST_F (PitchWheelComponentAsyncTests, PitchWheelIsNotAppliedWhileDragging) +{ + std::thread feeder ([this] + { + state->processNextMidiEvent (MidiMessage::pitchWheel (1, 8192)); + }); + feeder.join(); + + runDispatchLoopUntil (100); + + EXPECT_NEAR (0.0, wheel->getValue(), 1e-9); + + wheel->mouseDown (makeButtonEvent (60.0f)); + EXPECT_TRUE (wheel->isCurrentlyBeingDragged()); + + std::thread dragger ([this] + { + state->processNextMidiEvent (MidiMessage::pitchWheel (1, 12288)); + }); + dragger.join(); + + runDispatchLoopUntil (100); + + // The MIDI position must not move the wheel while the user is dragging it. + EXPECT_NEAR (0.0, wheel->getValue(), 1e-9); + + wheel->mouseUp (makeButtonEvent (60.0f)); + EXPECT_FALSE (wheel->isCurrentlyBeingDragged()); +} + +TEST_F (PitchWheelComponentAsyncTests, MidiChannelSelection) +{ + wheel->setMidiChannel (2); + + std::thread feeder ([this] + { + state->processNextMidiEvent (MidiMessage::pitchWheel (2, 12288)); + }); + feeder.join(); + + runDispatchLoopUntil (100); + + EXPECT_NEAR ((12288.0 - 8192.0) / 8191.0, wheel->getValue(), 1e-9); + EXPECT_EQ (2, wheel->getMidiChannel()); +} diff --git a/tests/yup_audio_plugin_client.cpp b/tests/yup_audio_plugin_client/module.cpp similarity index 93% rename from tests/yup_audio_plugin_client.cpp rename to tests/yup_audio_plugin_client/module.cpp index 402a651af..acdc7ef87 100644 --- a/tests/yup_audio_plugin_client.cpp +++ b/tests/yup_audio_plugin_client/module.cpp @@ -19,4 +19,4 @@ ============================================================================== */ -#include "yup_audio_plugin_client/yup_AudioPluginUtilities.cpp" +#include "yup_AudioPluginUtilities.cpp" diff --git a/tests/yup_audio_plugin_client_au.mm b/tests/yup_audio_plugin_client/module_au.mm similarity index 99% rename from tests/yup_audio_plugin_client_au.mm rename to tests/yup_audio_plugin_client/module_au.mm index c1c7634dc..6e659b254 100644 --- a/tests/yup_audio_plugin_client_au.mm +++ b/tests/yup_audio_plugin_client/module_au.mm @@ -31,13 +31,13 @@ #define YupPlugin_IsMono 0 // ============================================================================= -#include "yup_audio_plugin_client/yup_TestPluginProcessor.h" -#include "yup_audio_plugin_client/yup_TestAudioBufferList.h" +#include "yup_TestPluginProcessor.h" +#include "yup_TestAudioBufferList.h" #include #define YUP_AUDIO_PLUGIN_CREATE_FUNCTION createPluginProcessorAU -#include "yup_audio_plugin_client/au/yup_audio_plugin_client_AU.mm" +#include // ============================================================================= // Layout switching for testing different bus configurations, mirroring the AUv3 suite. diff --git a/tests/yup_audio_plugin_client_auv3.mm b/tests/yup_audio_plugin_client/module_auv3.mm similarity index 99% rename from tests/yup_audio_plugin_client_auv3.mm rename to tests/yup_audio_plugin_client/module_auv3.mm index 30d85b97b..c68edd918 100644 --- a/tests/yup_audio_plugin_client_auv3.mm +++ b/tests/yup_audio_plugin_client/module_auv3.mm @@ -34,11 +34,11 @@ #define YupPlugin_IsMono 0 // ============================================================================= -#include "yup_audio_plugin_client/yup_TestPluginProcessor.h" -#include "yup_audio_plugin_client/yup_TestAudioBufferList.h" +#include "yup_TestPluginProcessor.h" +#include "yup_TestAudioBufferList.h" #define YUP_AUDIO_PLUGIN_CREATE_FUNCTION createPluginProcessorAUv3 -#include "yup_audio_plugin_client/auv3/yup_audio_plugin_client_AUv3.mm" +#include // ============================================================================= #include diff --git a/tests/yup_audio_plugin_client_clap.cpp b/tests/yup_audio_plugin_client/module_clap.cpp similarity index 99% rename from tests/yup_audio_plugin_client_clap.cpp rename to tests/yup_audio_plugin_client/module_clap.cpp index 9b159b5d1..1c0ad25dc 100644 --- a/tests/yup_audio_plugin_client_clap.cpp +++ b/tests/yup_audio_plugin_client/module_clap.cpp @@ -37,10 +37,11 @@ // ============================================================================= #include -#include "yup_audio_plugin_client/yup_TestPluginProcessor.h" + +#include "yup_TestPluginProcessor.h" #define YUP_AUDIO_PLUGIN_CREATE_FUNCTION createPluginProcessorCLAP -#include "yup_audio_plugin_client/clap/yup_audio_plugin_client_CLAP.cpp" +#include extern "C" yup::AudioProcessor* createPluginProcessorCLAP() { diff --git a/tests/yup_audio_plugin_client_vst3.cpp b/tests/yup_audio_plugin_client/module_vst3.cpp similarity index 99% rename from tests/yup_audio_plugin_client_vst3.cpp rename to tests/yup_audio_plugin_client/module_vst3.cpp index d10acf96b..9cd589da7 100644 --- a/tests/yup_audio_plugin_client_vst3.cpp +++ b/tests/yup_audio_plugin_client/module_vst3.cpp @@ -33,10 +33,10 @@ // ============================================================================= #include -#include "yup_audio_plugin_client/yup_TestPluginProcessor.h" +#include "yup_TestPluginProcessor.h" #define YUP_AUDIO_PLUGIN_CREATE_FUNCTION createPluginProcessorVST3 -#include "yup_audio_plugin_client/vst3/yup_audio_plugin_client_VST3.cpp" +#include extern "C" yup::AudioProcessor* createPluginProcessorVST3() { diff --git a/tests/yup_audio_plugin_host/module.cpp b/tests/yup_audio_plugin_host/module.cpp new file mode 100644 index 000000000..f94b752f4 --- /dev/null +++ b/tests/yup_audio_plugin_host/module.cpp @@ -0,0 +1,26 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_AudioPluginDescription.cpp" +#include "yup_AudioPluginLV2Format.cpp" +#include "yup_AudioPluginScanner.cpp" +#include "yup_AudioPluginInstance.cpp" +#include "yup_AudioPluginState.cpp" diff --git a/tests/yup_audio_processors/module.cpp b/tests/yup_audio_processors/module.cpp new file mode 100644 index 000000000..becd24fcd --- /dev/null +++ b/tests/yup_audio_processors/module.cpp @@ -0,0 +1,30 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "../mocks/yup_audio_processors.h" + +#include "yup_AudioBus.cpp" +#include "yup_AudioBusBufferView.cpp" +#include "yup_AudioBusLayout.cpp" +#include "yup_AudioParameter.cpp" +#include "yup_AudioProcessContext.cpp" +#include "yup_ParameterChangeBuffer.cpp" +#include "yup_SpectralBridge.cpp" diff --git a/tests/yup_core.cpp b/tests/yup_core.cpp deleted file mode 100644 index 1b4aa9629..000000000 --- a/tests/yup_core.cpp +++ /dev/null @@ -1,119 +0,0 @@ -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2025 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ - -#include "mocks/yup_core.h" - -#include "yup_core/yup_AbstractFifo.cpp" -#include "yup_core/yup_ArrayBase.cpp" -#include "yup_core/yup_Atomic.cpp" -#include "yup_core/yup_Base64.cpp" -#include "yup_core/yup_BigInteger.cpp" -#include "yup_core/yup_BufferedInputStream.cpp" -#include "yup_core/yup_CancelToken.cpp" -#include "yup_core/yup_CancelTokenSource.cpp" -#include "yup_core/yup_CharacterFunctions.cpp" -#include "yup_core/yup_ChildProcess.cpp" -#include "yup_core/yup_ConsoleApplication.cpp" -#include "yup_core/yup_DynamicLibrary.cpp" -#include "yup_core/yup_DynamicObject.cpp" -#include "yup_core/yup_ElementComparator.cpp" -#include "yup_core/yup_Enumerate.cpp" -#include "yup_core/yup_EnumHelpers.cpp" -#include "yup_core/yup_Expression.cpp" -#include "yup_core/yup_File.cpp" -#include "yup_core/yup_FileInputStream.cpp" -#include "yup_core/yup_FileSearchPath.cpp" -#include "yup_core/yup_FixedSizeFunction.cpp" -#include "yup_core/yup_FlagSet.cpp" -#include "yup_core/yup_Functional.cpp" -#include "yup_core/yup_GZIPCompressorOutputStream.cpp" -#include "yup_core/yup_GZIPDecompressorInputStream.cpp" -#include "yup_core/yup_HashMap.cpp" -#include "yup_core/yup_HighResolutionTimer.cpp" -#include "yup_core/yup_Identifier.cpp" -#include "yup_core/yup_InterProcessLock.cpp" -#include "yup_core/yup_IPAddress.cpp" -#include "yup_core/yup_JavascriptEngine.cpp" -#include "yup_core/yup_JSON.cpp" -#include "yup_core/yup_JSONSerialisation.cpp" -#include "yup_core/yup_JSONUtils.cpp" -#include "yup_core/yup_LinkedListPointer.cpp" -#include "yup_core/yup_ListenerList.cpp" -#include "yup_core/yup_LocalisedStrings.cpp" -#include "yup_core/yup_Logger.cpp" -#include "yup_core/yup_MACAddress.cpp" -#include "yup_core/yup_MathFunctions.cpp" -#include "yup_core/yup_Memory.cpp" -#include "yup_core/yup_MemoryInputStream.cpp" -#include "yup_core/yup_MemoryMappedFile.cpp" -#include "yup_core/yup_MemoryOutputStream.cpp" -#include "yup_core/yup_MetaProgramming.cpp" -#include "yup_core/yup_MimeTypes.cpp" -#include "yup_core/yup_NamedPipe.cpp" -#include "yup_core/yup_NormalisableRange.cpp" -#include "yup_core/yup_OwnedArray.cpp" -#include "yup_core/yup_PerformanceCounter.cpp" -#include "yup_core/yup_PlatformDefs.cpp" -#include "yup_core/yup_Process.cpp" -#include "yup_core/yup_Profiler.cpp" -#include "yup_core/yup_PropertySet.cpp" -#include "yup_core/yup_Random.cpp" -#include "yup_core/yup_RecursiveSpinLock.cpp" -#include "yup_core/yup_ReferenceCountedArray.cpp" -#include "yup_core/yup_ReferenceCountedObject.cpp" -#include "yup_core/yup_Reservoir.cpp" -#include "yup_core/yup_Result.cpp" -#include "yup_core/yup_ResultValue.cpp" -#include "yup_core/yup_ScopedValueSetter.cpp" -#include "yup_core/yup_ScopeGuard.cpp" -#include "yup_core/yup_SHA1.cpp" -#include "yup_core/yup_SharedResourcePointer.cpp" -#include "yup_core/yup_Singleton.cpp" -#include "yup_core/yup_Socket.cpp" -#include "yup_core/yup_SortedSet.cpp" -#include "yup_core/yup_Span.cpp" -#include "yup_core/yup_SparseSet.cpp" -#include "yup_core/yup_SpinLock.cpp" -#include "yup_core/yup_SqliteDatabase.cpp" -#include "yup_core/yup_StatisticsAccumulator.cpp" -#include "yup_core/yup_String.cpp" -#include "yup_core/yup_StringArray.cpp" -#include "yup_core/yup_StringPairArray.cpp" -#include "yup_core/yup_StringPool.cpp" -#include "yup_core/yup_SubregionStream.cpp" -#include "yup_core/yup_SystemStats.cpp" -#include "yup_core/yup_TextDiff.cpp" -#include "yup_core/yup_Thread.cpp" -#include "yup_core/yup_ThreadLocalValue.cpp" -#include "yup_core/yup_ThreadPool.cpp" -#include "yup_core/yup_Time.cpp" -#include "yup_core/yup_TimeSliceThread.cpp" -#include "yup_core/yup_TypeErasedObject.cpp" -#include "yup_core/yup_URL.cpp" -#include "yup_core/yup_Uuid.cpp" -#include "yup_core/yup_Variant.cpp" -#include "yup_core/yup_WaitableTimer.cpp" -#include "yup_core/yup_Watchdog.cpp" -#include "yup_core/yup_WebInputStream.cpp" -#include "yup_core/yup_XmlDocument.cpp" -#include "yup_core/yup_XmlElement.cpp" -#include "yup_core/yup_YAML.cpp" -#include "yup_core/yup_ZipFile.cpp" diff --git a/tests/yup_core/module.cpp b/tests/yup_core/module.cpp new file mode 100644 index 000000000..647aa00d5 --- /dev/null +++ b/tests/yup_core/module.cpp @@ -0,0 +1,119 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "../mocks/yup_core.h" + +#include "yup_AbstractFifo.cpp" +#include "yup_ArrayBase.cpp" +#include "yup_Atomic.cpp" +#include "yup_Base64.cpp" +#include "yup_BigInteger.cpp" +#include "yup_BufferedInputStream.cpp" +#include "yup_CancelToken.cpp" +#include "yup_CancelTokenSource.cpp" +#include "yup_CharacterFunctions.cpp" +#include "yup_ChildProcess.cpp" +#include "yup_ConsoleApplication.cpp" +#include "yup_DynamicLibrary.cpp" +#include "yup_DynamicObject.cpp" +#include "yup_ElementComparator.cpp" +#include "yup_Enumerate.cpp" +#include "yup_EnumHelpers.cpp" +#include "yup_Expression.cpp" +#include "yup_File.cpp" +#include "yup_FileInputStream.cpp" +#include "yup_FileSearchPath.cpp" +#include "yup_FixedSizeFunction.cpp" +#include "yup_FlagSet.cpp" +#include "yup_Functional.cpp" +#include "yup_GZIPCompressorOutputStream.cpp" +#include "yup_GZIPDecompressorInputStream.cpp" +#include "yup_HashMap.cpp" +#include "yup_HighResolutionTimer.cpp" +#include "yup_Identifier.cpp" +#include "yup_InterProcessLock.cpp" +#include "yup_IPAddress.cpp" +#include "yup_JavascriptEngine.cpp" +#include "yup_JSON.cpp" +#include "yup_JSONSerialisation.cpp" +#include "yup_JSONUtils.cpp" +#include "yup_LinkedListPointer.cpp" +#include "yup_ListenerList.cpp" +#include "yup_LocalisedStrings.cpp" +#include "yup_Logger.cpp" +#include "yup_MACAddress.cpp" +#include "yup_MathFunctions.cpp" +#include "yup_Memory.cpp" +#include "yup_MemoryInputStream.cpp" +#include "yup_MemoryMappedFile.cpp" +#include "yup_MemoryOutputStream.cpp" +#include "yup_MetaProgramming.cpp" +#include "yup_MimeTypes.cpp" +#include "yup_NamedPipe.cpp" +#include "yup_NormalisableRange.cpp" +#include "yup_OwnedArray.cpp" +#include "yup_PerformanceCounter.cpp" +#include "yup_PlatformDefs.cpp" +#include "yup_Process.cpp" +#include "yup_Profiler.cpp" +#include "yup_PropertySet.cpp" +#include "yup_Random.cpp" +#include "yup_RecursiveSpinLock.cpp" +#include "yup_ReferenceCountedArray.cpp" +#include "yup_ReferenceCountedObject.cpp" +#include "yup_Reservoir.cpp" +#include "yup_Result.cpp" +#include "yup_ResultValue.cpp" +#include "yup_ScopedValueSetter.cpp" +#include "yup_ScopeGuard.cpp" +#include "yup_SHA1.cpp" +#include "yup_SharedResourcePointer.cpp" +#include "yup_Singleton.cpp" +#include "yup_Socket.cpp" +#include "yup_SortedSet.cpp" +#include "yup_Span.cpp" +#include "yup_SparseSet.cpp" +#include "yup_SpinLock.cpp" +#include "yup_SqliteDatabase.cpp" +#include "yup_StatisticsAccumulator.cpp" +#include "yup_String.cpp" +#include "yup_StringArray.cpp" +#include "yup_StringPairArray.cpp" +#include "yup_StringPool.cpp" +#include "yup_SubregionStream.cpp" +#include "yup_SystemStats.cpp" +#include "yup_TextDiff.cpp" +#include "yup_Thread.cpp" +#include "yup_ThreadLocalValue.cpp" +#include "yup_ThreadPool.cpp" +#include "yup_Time.cpp" +#include "yup_TimeSliceThread.cpp" +#include "yup_TypeErasedObject.cpp" +#include "yup_URL.cpp" +#include "yup_Uuid.cpp" +#include "yup_Variant.cpp" +#include "yup_WaitableTimer.cpp" +#include "yup_Watchdog.cpp" +#include "yup_WebInputStream.cpp" +#include "yup_XmlDocument.cpp" +#include "yup_XmlElement.cpp" +#include "yup_YAML.cpp" +#include "yup_ZipFile.cpp" diff --git a/tests/yup_core.mm b/tests/yup_core/module.mm similarity index 95% rename from tests/yup_core.mm rename to tests/yup_core/module.mm index b1efe17f7..5248f85c9 100644 --- a/tests/yup_core.mm +++ b/tests/yup_core/module.mm @@ -19,4 +19,4 @@ ============================================================================== */ -#include "yup_core/yup_ObjCHelpers.mm" +#include "yup_ObjCHelpers.mm" diff --git a/tests/yup_core/yup_CountDownLatch.cpp b/tests/yup_core/yup_CountDownLatch.cpp new file mode 100644 index 000000000..9746a6674 --- /dev/null +++ b/tests/yup_core/yup_CountDownLatch.cpp @@ -0,0 +1,149 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include +#include +#include + +using namespace yup; + +namespace +{ + +void runWorkers (CountDownLatch& latch, int numWorkers, std::atomic& done) +{ + std::vector workers; + + for (int i = 0; i < numWorkers; ++i) + workers.emplace_back ([&] + { + for (volatile int spin = 0; spin < 1000 * (i + 1); ++spin) + { + } + latch.countDown(); + ++done; + }); + + for (auto& worker : workers) + worker.join(); +} + +} // namespace + +TEST (CountDownLatchTests, WaitsUntilTheCountReachesZero) +{ + constexpr int numWorkers = 8; + + CountDownLatch latch (numWorkers); + std::atomic done { 0 }; + + std::thread waiter ([&] + { + latch.wait(); + }); + + runWorkers (latch, numWorkers, done); + + waiter.join(); + + EXPECT_EQ (numWorkers, done.load()); + EXPECT_EQ (0, latch.getCount()); +} + +TEST (CountDownLatchTests, CountsDownBelowZeroWithoutWakingAnyone) +{ + CountDownLatch latch (0); + + latch.countDown(); + latch.countDown(); + + EXPECT_EQ (0, latch.getCount()); +} + +TEST (CountDownLatchTests, AddCountBeforeDispatchingWorkKeepsTheWaiterBlocked) +{ + // The pattern the parallel import parser uses: a worker raises the count + // before its work is dispatched. The waiter starts blocked (count 1), so + // the only way it can wake before the worker finishes is if addCount() + // failed to keep the count above zero. + CountDownLatch latch (1); + + std::atomic workDone { false }; + + std::thread worker ([&] + { + latch.addCount (1); + + for (volatile int spin = 0; spin < 100000; ++spin) + { + } + + workDone.store (true); + latch.countDown(); // 2 -> 1: the waiter must still be blocked + latch.countDown(); // 1 -> 0: only now may it wake + }); + + std::thread waiter ([&] + { + latch.wait(); + EXPECT_TRUE (workDone.load()); + }); + + worker.join(); + waiter.join(); + + EXPECT_EQ (0, latch.getCount()); +} + +TEST (CountDownLatchTests, WaitsFromMultipleThreads) +{ + constexpr int numWorkers = 8; + + CountDownLatch latch (numWorkers); + std::atomic waiting { 0 }; + std::atomic released { 0 }; + + std::vector waiters; + + for (int i = 0; i < 4; ++i) + waiters.emplace_back ([&] + { + ++waiting; + latch.wait(); + ++released; + }); + + while (waiting.load() < 4) + std::this_thread::yield(); + + std::atomic done { 0 }; + runWorkers (latch, numWorkers, done); + + for (auto& waiter : waiters) + waiter.join(); + + EXPECT_EQ (4, released.load()); + EXPECT_EQ (0, latch.getCount()); +} diff --git a/tests/yup_core/yup_YAML.cpp b/tests/yup_core/yup_YAML.cpp index 2815dfbf9..14873c125 100644 --- a/tests/yup_core/yup_YAML.cpp +++ b/tests/yup_core/yup_YAML.cpp @@ -614,6 +614,7 @@ TEST_F (YAMLTests, Int64Min) EXPECT_EQ (std::numeric_limits::max(), static_cast (maxV)); } +#if ! YUP_WASM TEST_F (YAMLTests, DepthLimitExceeded) { var result; @@ -637,6 +638,7 @@ TEST_F (YAMLTests, DepthLimitExceeded) EXPECT_TRUE (YAML::parse (deepMap, result).failed()); } +#endif TEST_F (YAMLTests, DuplicateAnchor) { diff --git a/tests/yup_data_model/module.cpp b/tests/yup_data_model/module.cpp new file mode 100644 index 000000000..900aeea5d --- /dev/null +++ b/tests/yup_data_model/module.cpp @@ -0,0 +1,27 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_CachedValue.cpp" +#include "yup_DataTree.cpp" +#include "yup_DataTreeObjectList.cpp" +#include "yup_DataTreeQuery.cpp" +#include "yup_DataTreeSchema.cpp" +#include "yup_UndoManager.cpp" diff --git a/tests/yup_dsp.cpp b/tests/yup_dsp.cpp deleted file mode 100644 index 0ce49e06a..000000000 --- a/tests/yup_dsp.cpp +++ /dev/null @@ -1,50 +0,0 @@ -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2025 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ - -#include "yup_dsp/yup_AaIirAntialiaser.cpp" -#include "yup_dsp/yup_AnalogFilters.cpp" -#include "yup_dsp/yup_BiquadCascade.cpp" -#include "yup_dsp/yup_BiquadFilter.cpp" -#include "yup_dsp/yup_BlunterClipper.cpp" -#include "yup_dsp/yup_ButterworthFilter.cpp" -#include "yup_dsp/yup_CircularBuffer.cpp" -#include "yup_dsp/yup_CombFilter.cpp" -#include "yup_dsp/yup_DirectFIR.cpp" -#include "yup_dsp/yup_FFTProcessor.cpp" -#include "yup_dsp/yup_FilterDesigner.cpp" -#include "yup_dsp/yup_FirstOrderFilter.cpp" -#include "yup_dsp/yup_FractionallyAddressedDelay.cpp" -#include "yup_dsp/yup_KMeterState.cpp" -#include "yup_dsp/yup_LevelProcessor.cpp" -#include "yup_dsp/yup_LinkwitzRileyFilter.cpp" -#include "yup_dsp/yup_LoudnessFilter.cpp" -#include "yup_dsp/yup_NoiseGenerators.cpp" -#include "yup_dsp/yup_OnsetDetector.cpp" -#include "yup_dsp/yup_Oversampler.cpp" -#include "yup_dsp/yup_PartitionedConvolver.cpp" -#include "yup_dsp/yup_RbjFilter.cpp" -#include "yup_dsp/yup_Resampler.cpp" -#include "yup_dsp/yup_SincTable.cpp" -#include "yup_dsp/yup_SoftClipper.cpp" -#include "yup_dsp/yup_SpectrumAnalyzerState.cpp" -#include "yup_dsp/yup_StateVariableFilter.cpp" -#include "yup_dsp/yup_TimeStretchProcessor.cpp" -#include "yup_dsp/yup_WindowFunctions.cpp" diff --git a/tests/yup_dsp/module.cpp b/tests/yup_dsp/module.cpp new file mode 100644 index 000000000..febc9f119 --- /dev/null +++ b/tests/yup_dsp/module.cpp @@ -0,0 +1,50 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_AaIirAntialiaser.cpp" +#include "yup_AnalogFilters.cpp" +#include "yup_BiquadCascade.cpp" +#include "yup_BiquadFilter.cpp" +#include "yup_BlunterClipper.cpp" +#include "yup_ButterworthFilter.cpp" +#include "yup_CircularBuffer.cpp" +#include "yup_CombFilter.cpp" +#include "yup_DirectFIR.cpp" +#include "yup_FFTProcessor.cpp" +#include "yup_FilterDesigner.cpp" +#include "yup_FirstOrderFilter.cpp" +#include "yup_FractionallyAddressedDelay.cpp" +#include "yup_KMeterState.cpp" +#include "yup_LevelProcessor.cpp" +#include "yup_LinkwitzRileyFilter.cpp" +#include "yup_LoudnessFilter.cpp" +#include "yup_NoiseGenerators.cpp" +#include "yup_OnsetDetector.cpp" +#include "yup_Oversampler.cpp" +#include "yup_PartitionedConvolver.cpp" +#include "yup_RbjFilter.cpp" +#include "yup_Resampler.cpp" +#include "yup_SincTable.cpp" +#include "yup_SoftClipper.cpp" +#include "yup_SpectrumAnalyzerState.cpp" +#include "yup_StateVariableFilter.cpp" +#include "yup_TimeStretchProcessor.cpp" +#include "yup_WindowFunctions.cpp" diff --git a/tests/yup_dsp/yup_Oversampler.cpp b/tests/yup_dsp/yup_Oversampler.cpp index 591c849c6..84490ab35 100644 --- a/tests/yup_dsp/yup_Oversampler.cpp +++ b/tests/yup_dsp/yup_Oversampler.cpp @@ -23,6 +23,7 @@ #include +#include #include #include @@ -173,7 +174,7 @@ TEST_F (OversamplerTest, DownsampleConsumesPendingOversampledBlock) os2x.downsample (outputPtrs, 1, blockSize); EXPECT_EQ (os2x.getOversampledNumSamples(), 0); - EXPECT_EQ (os2x.getOversampledChannelData (0), nullptr); + EXPECT_NE (os2x.getOversampledChannelData (0), nullptr); } TEST_F (OversamplerTest, UpsampleThenDownsamplePreservesDCMagnitude) @@ -225,9 +226,6 @@ TEST_F (OversamplerTest, DecimationFiltersOversampledDomainHighFrequency) for (int b = 0; b < 5; ++b) os2x.upsample (silPtrs, 1, blockSize); - // Inject a tone ABOVE original Nyquist directly into the oversampled buffer. - // This simulates distortion harmonics created at the elevated sample rate. - // The decimation filter should attenuate this before downsampling. const double oversampledRate = sampleRate * 2.0; const double highFreq = sampleRate * 0.6; // above original Nyquist (22050 Hz) float* oversampledData = os2x.getOversampledChannelData (0); @@ -267,6 +265,148 @@ TEST_F (OversamplerTest, FourXOversamplerHasCorrectOutputSize) EXPECT_EQ (os4x.getOversampledNumSamples(), blockSize * 4); } +TEST_F (OversamplerTest, OversampledChannelDataIsAvailableAfterPrepare) +{ + EXPECT_NE (os2x.getOversampledChannelData (0), nullptr); + EXPECT_NE (os2x.getOversampledChannelData (1), nullptr); + + EXPECT_EQ (os2x.getOversampledChannelData (maxChannels), nullptr); + EXPECT_EQ (os2x.getOversampledChannelData (-1), nullptr); + + EXPECT_EQ (os2x.getOversampledNumSamples(), 0); +} + +TEST_F (OversamplerTest, DownsampleWithoutAPrecedingUpsampleFiltersHighFrequencies) +{ + Oversampler decimator; + decimator.prepare (sampleRate, 1, blockSize); + + const double highRate = sampleRate * 2.0; + const double aboveNyquist = sampleRate * 0.6; // above the decimated Nyquist + constexpr float amplitude = 0.5f; + + std::vector decimated (blockSize, 0.0f); + float* outPtrs[] = { decimated.data() }; + + // A few blocks so the filter history settles past its start-up transient. + for (int block = 0; block < 5; ++block) + { + auto* buffer = decimator.getOversampledChannelData (0); + ASSERT_NE (buffer, nullptr); + + for (int i = 0; i < blockSize * 2; ++i) + { + const auto n = static_cast (block * blockSize * 2 + i); + buffer[i] = amplitude * static_cast (std::sin (MathConstants::twoPi * aboveNyquist * n / highRate)); + } + + decimator.downsample (outPtrs, 1, blockSize); + } + + EXPECT_LT (calculateRMS (decimated.data(), blockSize), amplitude * 0.5f); +} + +TEST_F (OversamplerTest, DecimateThenInterpolateRoundTripPreservesLowFrequency) +{ + constexpr int factor = 2; + constexpr int baseBlock = blockSize / factor; + + Oversampler decimator; + Oversampler interpolator; + + decimator.prepare (sampleRate / factor, 1, baseBlock); + interpolator.prepare (sampleRate / factor, 1, baseBlock); + + constexpr int blockCount = 6; + constexpr float frequency = 300.0f; // well inside the decimated passband + + std::vector input (static_cast (blockSize * blockCount)); + fillSine (input, frequency); + + std::vector decimated (baseBlock, 0.0f); + float* decimatedPtrs[] = { decimated.data() }; + const float* interpolatedIn[] = { decimated.data() }; + + std::vector output (blockSize, 0.0f); + + for (int block = 0; block < blockCount; ++block) + { + auto* highRate = decimator.getOversampledChannelData (0); + ASSERT_NE (highRate, nullptr); + + std::copy (input.data() + block * blockSize, input.data() + (block + 1) * blockSize, highRate); + + decimator.downsample (decimatedPtrs, 1, baseBlock); + interpolator.upsample (interpolatedIn, 1, baseBlock); + + const auto* interpolated = interpolator.getOversampledChannelData (0); + ASSERT_NE (interpolated, nullptr); + + std::copy (interpolated, interpolated + blockSize, output.data()); + } + + const auto latency = decimator.getLatencyInSamples() * factor; + const auto* expected = input.data() + (blockCount - 1) * blockSize - latency; + + double error = 0.0; + double reference = 0.0; + + for (int i = 0; i < blockSize; ++i) + { + const auto diff = static_cast (output[static_cast (i)]) - static_cast (expected[i]); + error += diff * diff; + reference += static_cast (expected[i]) * static_cast (expected[i]); + } + + ASSERT_GT (reference, 1.0); // would pass vacuously on silence + EXPECT_LT (std::sqrt (error / reference), 0.1); +} + +TEST_F (OversamplerTest, DecimateFirstIsContinuousAcrossBlockBoundaries) +{ + constexpr int factor = 2; + constexpr int baseBlock = blockSize / factor; + constexpr int blockCount = 6; + constexpr float frequency = 500.0f; + + Oversampler decimator; + decimator.prepare (sampleRate / factor, 1, baseBlock); + + std::vector input (static_cast (blockSize * blockCount)); + fillSine (input, frequency); + + std::vector decimated (static_cast (baseBlock * blockCount), 0.0f); + + for (int block = 0; block < blockCount; ++block) + { + auto* highRate = decimator.getOversampledChannelData (0); + ASSERT_NE (highRate, nullptr); + + std::copy (input.data() + block * blockSize, input.data() + (block + 1) * blockSize, highRate); + + float* outPtrs[] = { decimated.data() + block * baseBlock }; + decimator.downsample (outPtrs, 1, baseBlock); + } + + // Skip the first two blocks: the filter is still ramping up from silence. + float largestInterior = 0.0f; + float largestAtBoundary = 0.0f; + + for (int i = 2 * baseBlock + 1; i < baseBlock * blockCount; ++i) + { + const auto step = std::fabs (decimated[static_cast (i)] - decimated[static_cast (i - 1)]); + + if (i % baseBlock == 0) + largestAtBoundary = std::max (largestAtBoundary, step); + else + largestInterior = std::max (largestInterior, step); + } + + ASSERT_GT (largestInterior, 0.0f); // would pass vacuously on silence + EXPECT_LT (largestAtBoundary, largestInterior * 1.5f) + << "boundary step " << largestAtBoundary << " against interior " << largestInterior; +} + //============================================================================== TEST (OversamplerTypeAliasTest, TypeAliasesCompile) { @@ -276,7 +416,6 @@ TEST (OversamplerTypeAliasTest, TypeAliasesCompile) Oversampler2xDouble d; Oversampler4xDouble e; - // Prepare briefly to confirm the types are usable a.prepare (44100.0, 1, 64); b.prepare (44100.0, 1, 64); c.prepare (44100.0, 1, 64); diff --git a/tests/yup_dsp_jit/module.cpp b/tests/yup_dsp_jit/module.cpp new file mode 100644 index 000000000..82db156ea --- /dev/null +++ b/tests/yup_dsp_jit/module.cpp @@ -0,0 +1,46 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_YdspDiagnosticsTests.cpp" +#include "yup_YdspBundleTests.cpp" +#include "yup_YdspCompilerOptionsTests.cpp" +#include "yup_YdspExamplePatchTests.cpp" +#include "yup_YdspFusionTests.cpp" +#include "yup_YdspFusedMultiplyAddTests.cpp" +#include "yup_YdspGraphTests.cpp" +#include "yup_YdspRuntimeOptimizationTests.cpp" +#include "yup_YdspLatencyTests.cpp" +#include "yup_YdspLexerParserTests.cpp" +#include "yup_YdspOptimizerTests.cpp" +#include "yup_YdspProjectTests.cpp" +#include "yup_YdspSemanticAnalyzerTests.cpp" +#include "yup_YdspSmokeTests.cpp" +#include "yup_YdspSubgraphTests.cpp" +#include "yup_YdspVectorizerTests.cpp" +#include "yup_YdspWasmBackendTests.cpp" +#include "yup_YdspWasmTests.cpp" + +#if ! YUP_WASM +#include "yup_YdspAsmJitCodegenTests.cpp" +#include "yup_SleefLibraryTests.cpp" +#endif + +#include "yup_YdspBenchmarkTests.cpp" diff --git a/tests/yup_dsp_jit/yup_SleefLibraryTests.cpp b/tests/yup_dsp_jit/yup_SleefLibraryTests.cpp new file mode 100644 index 000000000..10e7acb27 --- /dev/null +++ b/tests/yup_dsp_jit/yup_SleefLibraryTests.cpp @@ -0,0 +1,327 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include +#include +#include +#include +#include +#include + +using namespace yup; + +namespace yup::test +{ + +namespace +{ + +//============================================================================== +// SLEEF ships no public headers in the source tree; the module exports the +// subset the JIT needs (see sleef_library.h). These tests prove that subset +// compiles and links, and that scalar and 4-lane results agree with libm to a +// few ULP - the plain-C ABI check that Phase 2's InvokeNode lowering builds on. +// +// Tolerances are given in float steps (ULP distance), which stays meaningful +// where the function crosses zero, unlike a relative compare. u10 tiers sit +// within ~1 ULP of the correctly-rounded libm result (so a few steps apart), +// u35 within ~3.5 ULP (so ~8 steps is a safe cap), and an ABI break shows up +// as garbage orders of magnitude larger. + +std::vector benchmarkSleefRange (float start, float end, int samples) +{ + std::vector values (static_cast (samples)); + + for (int i = 0; i < samples; ++i) + values[static_cast (i)] = start + (end - start) * static_cast (i) / static_cast (samples - 1); + + return values; +} + +} // namespace + +class SleefLibraryTests : public ::testing::Test +{ +protected: + // ULP distance between two finite floats, as a count of representable + // values between them (0 when equal). + static int floatUlpDistance (float a, float b) + { + if (a == b) + return 0; + + const auto order = [] (float value) + { + auto bits = std::bit_cast (value); + return bits < 0 ? static_cast (0x80000000ll) - bits : static_cast (bits); + }; + + const auto da = order (a); + const auto db = order (b); + + return static_cast (da > db ? da - db : db - da); + } + + static void expectScalarClose (float sleefValue, float libmValue, int maxUlps) + { + const auto sleefNan = std::isnan (sleefValue); + const auto libmNan = std::isnan (libmValue); + const auto sleefInf = std::isinf (sleefValue); + const auto libmInf = std::isinf (libmValue); + + EXPECT_EQ (libmNan, sleefNan) << "sleef " << sleefValue << " vs libm " << libmValue; + EXPECT_EQ (libmInf, sleefInf) << "sleef " << sleefValue << " vs libm " << libmValue; + + if (libmNan || libmInf || sleefNan || sleefInf) + return; + + EXPECT_LE (floatUlpDistance (sleefValue, libmValue), maxUlps) + << "sleef " << sleefValue << " vs libm " << libmValue; + } + + using SleefScalarFloatFn = float (*) (float); + using SleefVectorFloatFn = Sleef_float32x4 (*) (Sleef_float32x4); + + // The libm reference is a concrete `float (*) (float)` rather than a + // template parameter so an overloaded name such as `std::sin` resolves to + // its float overload at the call site. + void expectUnaryScalar (SleefScalarFloatFn sleefFn, SleefScalarFloatFn libmFn, const std::vector& inputs, int maxUlps) + { + for (const auto x : inputs) + expectScalarClose (sleefFn (x), libmFn (x), maxUlps); + } + + static std::array sleefLanes (Sleef_float32x4 value) + { + std::array lanes {}; +#if defined(__aarch64__) || defined(_M_ARM64) + vst1q_f32 (lanes.data(), value); +#else + _mm_storeu_ps (lanes.data(), value); +#endif + return lanes; + } + + static Sleef_float32x4 sleefLoad (const std::array& lanes) + { +#if defined(__aarch64__) || defined(_M_ARM64) + return vld1q_f32 (lanes.data()); +#else + return _mm_loadu_ps (lanes.data()); +#endif + } + + void expectUnaryVector (SleefVectorFloatFn sleefFn, SleefScalarFloatFn libmFn, const std::vector& inputs, int maxUlps) + { + for (size_t i = 0; i + 3 < inputs.size(); i += 4) + { + const std::array group { inputs[i], inputs[i + 1], inputs[i + 2], inputs[i + 3] }; + const auto result = sleefLanes (sleefFn (sleefLoad (group))); + + for (int lane = 0; lane < 4; ++lane) + expectScalarClose (result[static_cast (lane)], + libmFn (group[static_cast (lane)]), + maxUlps); + } + } +}; + +TEST_F (SleefLibraryTests, ScalarTranscendentalsMatchLibm) +{ + const auto aroundPi = benchmarkSleefRange (-3.13f, 3.13f, 2001); + const auto aroundUnit = benchmarkSleefRange (-0.99f, 0.99f, 2001); + const auto positive = benchmarkSleefRange (0.01f, 7.9f, 2001); + const auto moderate = benchmarkSleefRange (-7.9f, 7.9f, 2001); + const auto acoshDomain = benchmarkSleefRange (1.0f, 8.0f, 2001); + + expectUnaryScalar (Sleef_sinf_u10, std::sin, aroundPi, 4); + expectUnaryScalar (Sleef_sinf_u35, std::sin, aroundPi, 8); + expectUnaryScalar (Sleef_cosf_u10, std::cos, aroundPi, 4); + expectUnaryScalar (Sleef_cosf_u35, std::cos, aroundPi, 8); + expectUnaryScalar (Sleef_tanf_u10, std::tan, moderate, 4); + expectUnaryScalar (Sleef_tanf_u35, std::tan, moderate, 8); + + expectUnaryScalar (Sleef_asinf_u10, std::asin, aroundUnit, 4); + expectUnaryScalar (Sleef_acosf_u10, std::acos, aroundUnit, 4); + expectUnaryScalar (Sleef_atanf_u10, std::atan, moderate, 4); + expectUnaryScalar (Sleef_atanf_u35, std::atan, moderate, 8); + + expectUnaryScalar (Sleef_sinhf_u10, std::sinh, moderate, 4); + expectUnaryScalar (Sleef_coshf_u10, std::cosh, moderate, 4); + expectUnaryScalar (Sleef_tanhf_u10, std::tanh, moderate, 4); + expectUnaryScalar (Sleef_asinhf_u10, std::asinh, moderate, 4); + expectUnaryScalar (Sleef_acoshf_u10, std::acosh, acoshDomain, 4); + expectUnaryScalar (Sleef_atanhf_u10, std::atanh, aroundUnit, 4); + + expectUnaryScalar (Sleef_expf_u10, std::exp, moderate, 4); + expectUnaryScalar (Sleef_logf_u10, std::log, positive, 4); + expectUnaryScalar (Sleef_logf_u35, std::log, positive, 8); + expectUnaryScalar (Sleef_log10f_u10, std::log10, positive, 4); +} + +TEST_F (SleefLibraryTests, ScalarBinaryTranscendentalsMatchLibm) +{ + const auto a = benchmarkSleefRange (-3.0f, 3.0f, 101); + const auto b = benchmarkSleefRange (0.5f, 4.0f, 101); + + for (const auto x : a) + for (const auto y : b) + { + expectScalarClose (Sleef_powf_u10 (x < 0.0f ? -x : x, y), std::pow (x < 0.0f ? -x : x, y), 8); + expectScalarClose (Sleef_atan2f_u10 (x, y), std::atan2 (x, y), 4); + expectScalarClose (Sleef_fmodf (x, y + 0.25f), std::fmod (x, y + 0.25f), 4); + } +} + +TEST_F (SleefLibraryTests, Vector4TranscendentalsMatchLibm) +{ + const auto aroundPi = benchmarkSleefRange (-3.13f, 3.13f, 2000); + const auto aroundUnit = benchmarkSleefRange (-0.99f, 0.99f, 2000); + const auto positive = benchmarkSleefRange (0.01f, 7.9f, 2000); + const auto moderate = benchmarkSleefRange (-7.9f, 7.9f, 2000); + + expectUnaryVector (Sleef_sinf4_u10, std::sin, aroundPi, 4); + expectUnaryVector (Sleef_sinf4_u35, std::sin, aroundPi, 8); + expectUnaryVector (Sleef_cosf4_u10, std::cos, aroundPi, 4); + expectUnaryVector (Sleef_tanhf4_u10, std::tanh, moderate, 4); + expectUnaryVector (Sleef_expf4_u10, std::exp, moderate, 4); + expectUnaryVector (Sleef_logf4_u10, std::log, positive, 4); + expectUnaryVector (Sleef_log10f4_u10, std::log10, positive, 4); + expectUnaryVector (Sleef_asinf4_u10, std::asin, aroundUnit, 4); + expectUnaryVector (Sleef_acosf4_u10, std::acos, aroundUnit, 4); + expectUnaryVector (Sleef_sinhf4_u10, std::sinh, moderate, 4); + expectUnaryVector (Sleef_coshf4_u10, std::cosh, moderate, 4); + expectUnaryVector (Sleef_asinhf4_u10, std::asinh, moderate, 4); + expectUnaryVector (Sleef_atanhf4_u10, std::atanh, aroundUnit, 4); + expectUnaryVector (Sleef_atanf4_u10, std::atan, moderate, 4); + expectUnaryVector (Sleef_atanf4_u35, std::atan, moderate, 8); +} + +TEST_F (SleefLibraryTests, EveryDeclaredSymbolLinks) +{ + // Touch every prototype declared by sleef_library.h so a missing or + // misspelled export fails at link time, not in Phase 2. + + const auto v1 = sleefLoad ({ 0.5f, 1.0f, 1.5f, 2.0f }); + const auto v2 = sleefLoad ({ 2.0f, 1.5f, 1.0f, 0.5f }); + const auto vUnit = sleefLoad ({ -0.5f, 0.25f, 0.5f, 0.75f }); // in-domain for asin/acos/atanh + + EXPECT_TRUE (std::isfinite (Sleef_sinf_u10 (1.0f))); + EXPECT_TRUE (std::isfinite (Sleef_sinf_u35 (1.0f))); + EXPECT_TRUE (std::isfinite (Sleef_cosf_u10 (1.0f))); + EXPECT_TRUE (std::isfinite (Sleef_cosf_u35 (1.0f))); + EXPECT_TRUE (std::isfinite (Sleef_tanf_u10 (1.0f))); + EXPECT_TRUE (std::isfinite (Sleef_tanf_u35 (1.0f))); + + EXPECT_TRUE (std::isfinite (Sleef_asinf_u10 (0.5f))); + EXPECT_TRUE (std::isfinite (Sleef_asinf_u35 (0.5f))); + EXPECT_TRUE (std::isfinite (Sleef_acosf_u10 (0.5f))); + EXPECT_TRUE (std::isfinite (Sleef_acosf_u35 (0.5f))); + EXPECT_TRUE (std::isfinite (Sleef_atanf_u10 (0.5f))); + EXPECT_TRUE (std::isfinite (Sleef_atanf_u35 (0.5f))); + + EXPECT_TRUE (std::isfinite (Sleef_sinhf_u10 (0.5f))); + EXPECT_TRUE (std::isfinite (Sleef_sinhf_u35 (0.5f))); + EXPECT_TRUE (std::isfinite (Sleef_coshf_u10 (0.5f))); + EXPECT_TRUE (std::isfinite (Sleef_coshf_u35 (0.5f))); + EXPECT_TRUE (std::isfinite (Sleef_tanhf_u10 (0.5f))); + EXPECT_TRUE (std::isfinite (Sleef_tanhf_u35 (0.5f))); + EXPECT_TRUE (std::isfinite (Sleef_asinhf_u10 (0.5f))); + EXPECT_TRUE (std::isfinite (Sleef_acoshf_u10 (1.5f))); + EXPECT_TRUE (std::isfinite (Sleef_atanhf_u10 (0.5f))); + + EXPECT_TRUE (std::isfinite (Sleef_expf_u10 (0.5f))); + EXPECT_TRUE (std::isfinite (Sleef_logf_u10 (1.5f))); + EXPECT_TRUE (std::isfinite (Sleef_logf_u35 (1.5f))); + EXPECT_TRUE (std::isfinite (Sleef_log10f_u10 (1.5f))); + EXPECT_TRUE (std::isfinite (Sleef_powf_u10 (1.5f, 2.0f))); + EXPECT_TRUE (std::isfinite (Sleef_atan2f_u10 (0.5f, 1.0f))); + EXPECT_TRUE (std::isfinite (Sleef_atan2f_u35 (0.5f, 1.0f))); + EXPECT_TRUE (std::isfinite (Sleef_fmodf (5.5f, 2.0f))); + + for (const auto& value : sleefLanes (Sleef_sinf4_u10 (v1))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_sinf4_u35 (v1))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_cosf4_u10 (v1))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_cosf4_u35 (v1))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_tanf4_u10 (v1))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_tanf4_u35 (v1))) + EXPECT_TRUE (std::isfinite (value)); + + for (const auto& value : sleefLanes (Sleef_asinf4_u10 (vUnit))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_asinf4_u35 (vUnit))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_acosf4_u10 (vUnit))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_acosf4_u35 (vUnit))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_atanf4_u10 (v2))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_atanf4_u35 (v2))) + EXPECT_TRUE (std::isfinite (value)); + + for (const auto& value : sleefLanes (Sleef_sinhf4_u10 (v2))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_sinhf4_u35 (v2))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_coshf4_u10 (v2))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_coshf4_u35 (v2))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_tanhf4_u10 (v2))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_tanhf4_u35 (v2))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_asinhf4_u10 (v2))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_acoshf4_u10 (sleefLoad ({ 1.5f, 2.0f, 2.5f, 3.0f })))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_atanhf4_u10 (vUnit))) + EXPECT_TRUE (std::isfinite (value)); + + for (const auto& value : sleefLanes (Sleef_expf4_u10 (v2))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_logf4_u10 (v2))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_logf4_u35 (v2))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_log10f4_u10 (v2))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_powf4_u10 (v1, v2))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_atan2f4_u10 (v1, v2))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_atan2f4_u35 (v1, v2))) + EXPECT_TRUE (std::isfinite (value)); + for (const auto& value : sleefLanes (Sleef_fmodf4 (sleefLoad ({ 5.5f, 4.5f, 3.5f, 2.5f }), v2))) + EXPECT_TRUE (std::isfinite (value)); +} + +} // namespace yup::test diff --git a/tests/yup_dsp_jit/yup_YdspAllocationCounter.h b/tests/yup_dsp_jit/yup_YdspAllocationCounter.h new file mode 100644 index 000000000..602f21a77 --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspAllocationCounter.h @@ -0,0 +1,71 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#if YUP_ENABLE_ALLOCATION_HOOKS + +namespace yup::test +{ + +/** Counts ordinary new/delete and HeapBlock growth on the calling thread. + Construct/register outside measurement. This does not intercept arbitrary + malloc calls or over-aligned C++ allocations. */ +class YdspAllocationCounter : private AllocationHooks::Listener +{ +public: + YdspAllocationCounter() + { + AllocationHooks::getForCurrentThread().addListener (this); + } + + ~YdspAllocationCounter() override + { + active = false; + AllocationHooks::getForCurrentThread().removeListener (this); + } + + void start() noexcept + { + count = 0; + active = true; + } + + size_t stop() noexcept + { + active = false; + return count; + } + +private: + void newOrDeleteCalled() noexcept override + { + if (active) + ++count; + } + + size_t count = 0; + bool active = false; +}; + +} // namespace yup::test + +#endif diff --git a/tests/yup_dsp_jit/yup_YdspAsmJitCodegenTests.cpp b/tests/yup_dsp_jit/yup_YdspAsmJitCodegenTests.cpp new file mode 100644 index 000000000..0597622a5 --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspAsmJitCodegenTests.cpp @@ -0,0 +1,2808 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace yup::test +{ + +namespace +{ + +//============================================================================== +/** Helper: runs the full pipeline and returns the compiled kernel function. */ +struct CompiledKernel +{ + YdspKernelFn fn = nullptr; + size_t stateBytes = 0; + size_t stateScalarBytes = 0; + int numInputs = 0; + int numOutputs = 0; + int numParams = 0; + int numParamsOut = 0; + String asmText; + String asmName; +}; + +CompiledKernel compileKernel (StringRef source, const char* kernelName, YdspDiagnostics& diagnostics, bool fastMath = false) +{ + // Lexer + YdspLexer lexer (source, diagnostics); + auto tokens = lexer.tokenize(); + + // Parser + YdspParser parser (std::move (tokens), diagnostics); + auto program = parser.parseProgram(); + if (program == nullptr || diagnostics.hasErrors()) + return {}; + + // Semantic analysis + YdspSemanticAnalyzer analyzer (diagnostics); + auto analyzed = analyzer.analyze (std::move (program)); + if (analyzed == nullptr || diagnostics.hasErrors()) + return {}; + + // Optimizer (build IR) + YdspOptimizer optimizer (diagnostics); + optimizer.setFastMath (fastMath); + auto ir = optimizer.build (*analyzed); + if (ir == nullptr || diagnostics.hasErrors()) + return {}; + + // Find the kernel by name + const YdspIrFunction* targetFn = nullptr; + for (const auto& fn : ir->kernels) + { + if (fn->name == kernelName) + { + targetFn = fn.get(); + break; + } + } + + if (targetFn == nullptr) + return {}; + + // Codegen + static asmjit::JitRuntime jitRuntime; + auto kernelFn = YdspAsmJitCodegen::compile (jitRuntime, *targetFn, diagnostics); + + if (kernelFn == nullptr || diagnostics.hasErrors()) + return {}; + + // Capture generated assembly for on-failure dump only + String asmText; + if (diagnostics.getCount() > 0) + asmText = diagnostics.toString(); + + return { + kernelFn, + YdspAsmJitCodegen::stateSize (*targetFn), + YdspAsmJitCodegen::stateScalarSize (*targetFn), + targetFn->numInputs, + targetFn->numOutputs, + targetFn->numParams, + targetFn->numParamsOut, + std::move (asmText), + String (kernelName) + }; +} + +/** Helper: allocates a context and runs the kernel for one block. + Returns the output buffer (size = numOutputs * numSamples). +*/ +std::vector runKernel (const CompiledKernel& kernel, const std::vector& input, int numSamples, const std::vector& initialParams = {}) +{ + EXPECT_NE (nullptr, kernel.fn); + + // Allocate state + std::vector state (kernel.stateBytes, 0); + + // Allocate params - use initial values when provided, else zero + std::vector params; + if (initialParams.empty()) + params.assign (static_cast (kernel.numParams), 0.0f); + else + params = initialParams; + + std::vector paramsOut (static_cast (kernel.numParamsOut), 0.0f); + + // Set up input pointers + std::vector inPtrs (static_cast (kernel.numInputs)); + size_t offset = 0; + for (int i = 0; i < kernel.numInputs; ++i) + { + inPtrs[static_cast (i)] = const_cast (input.data() + offset); + offset += static_cast (numSamples); + } + + // Allocate output buffers + std::vector output (static_cast (kernel.numOutputs * numSamples), 0.0f); + std::vector outPtrs (static_cast (kernel.numOutputs)); + offset = 0; + for (int i = 0; i < kernel.numOutputs; ++i) + { + outPtrs[static_cast (i)] = output.data() + offset; + offset += static_cast (numSamples); + } + + YdspKernelContext ctx; + ctx.inputs = reinterpret_cast (inPtrs.data()); + ctx.outputs = reinterpret_cast (outPtrs.data()); + ctx.params = params.data(); + ctx.paramOut = paramsOut.data(); + ctx.state = reinterpret_cast (state.data()); + ctx.stateArrays = state.empty() ? nullptr : state.data() + kernel.stateScalarBytes; + ctx.sampleRate = 44100.0f; + ctx.numSamples = numSamples; + + kernel.fn (&ctx); + + return output; +} + +/** Helper: runs a kernel whose streams/params are double-precision (f64). */ +std::vector runKernel64 (const CompiledKernel& kernel, const std::vector& input, int numSamples, const std::vector& initialParams = {}) +{ + EXPECT_NE (nullptr, kernel.fn); + + std::vector state (kernel.stateBytes, 0); + + std::vector params; + if (initialParams.empty()) + params.assign (static_cast (kernel.numParams), 0.0); + else + params = initialParams; + + std::vector paramsOut (static_cast (kernel.numParamsOut), 0.0); + + std::vector inPtrs (static_cast (kernel.numInputs)); + size_t offset = 0; + for (int i = 0; i < kernel.numInputs; ++i) + { + inPtrs[static_cast (i)] = const_cast (input.data() + offset); + offset += static_cast (numSamples); + } + + std::vector output (static_cast (kernel.numOutputs * numSamples), 0.0); + std::vector outPtrs (static_cast (kernel.numOutputs)); + offset = 0; + for (int i = 0; i < kernel.numOutputs; ++i) + { + outPtrs[static_cast (i)] = output.data() + offset; + offset += static_cast (numSamples); + } + + YdspKernelContext ctx; + ctx.inputs = reinterpret_cast (inPtrs.data()); + ctx.outputs = reinterpret_cast (outPtrs.data()); + ctx.params = params.data(); + ctx.paramOut = paramsOut.data(); + ctx.state = reinterpret_cast (state.data()); + ctx.stateArrays = state.empty() ? nullptr : state.data() + kernel.stateScalarBytes; + ctx.sampleRate = 44100.0f; + ctx.numSamples = numSamples; + + kernel.fn (&ctx); + + return output; +} + +std::vector makeRampCodegen (int size, float start = 0.0f) +{ + std::vector data (static_cast (size)); + + for (int i = 0; i < size; ++i) + data[static_cast (i)] = start + static_cast (i) * 0.01f; + + return data; +} + +//============================================================================== +/** Prints the generated assembly for `kernel` if the current test has failed. */ +void dumpAsmOnFailureCodegen (const CompiledKernel& kernel) +{ + if (::testing::Test::HasFailure() && ! kernel.asmText.isEmpty()) + std::cout << "\n[AsmJit] " << kernel.asmName << ":\n" + << kernel.asmText << std::endl; +} + +} // namespace + +//============================================================================== +// YdspAsmJitCodegenTests: direct codegen compilation and audio validation +//============================================================================== + +TEST (YdspAsmJitCodegenTests, CompilesPassThroughAndProducesCorrectOutput) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor P { input stream in; output stream out; process { out = in; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + "P", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + EXPECT_EQ (1, kernel.numInputs); + EXPECT_EQ (1, kernel.numOutputs); + EXPECT_GE (kernel.stateBytes, 0u); + + auto input = makeRampCodegen (64); + auto output = runKernel (kernel, input, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_NEAR (input[static_cast (i)], output[static_cast (i)], 1e-6f); + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, CompilesGainKernel) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor Gain { input stream in; output stream out; input parameter float g = 2; process { out = in * g; } } + graph G { input stream x; output stream y; node p = Gain; connection { x -> p.in; p.out -> y; } } + )YDSP", + "Gain", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + auto input = makeRampCodegen (64); + auto output = runKernel (kernel, input, 64, { 2.0f }); + + for (int i = 0; i < 64; ++i) + EXPECT_NEAR (input[static_cast (i)] * 2.0f, output[static_cast (i)], 1e-5f); + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, CompilesTanhClipper) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor Clip { input stream in; output stream out; process { out = tanh (in); } } + graph G { input stream x; output stream y; node c = Clip; connection { x -> c.in; c.out -> y; } } + )YDSP", + "Clip", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + auto input = makeRampCodegen (64, -2.0f); + auto output = runKernel (kernel, input, 64); + + for (int i = 0; i < 64; ++i) + { + const auto x = input[static_cast (i)]; + const auto expected = tanhf (x); + EXPECT_NEAR (expected, output[static_cast (i)], 1e-4f); + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, ScalarTanhPreservesAccuracyAndExceptionalValues) +{ + const auto infinity = std::numeric_limits::infinity(); + std::vector input { 0.0f, -0.0f, infinity, -infinity, + std::numeric_limits::quiet_NaN() }; + + for (int i = -4096; i <= 4096; ++i) + input.push_back (static_cast (i) / 256.0f); + + for (int i = -32768; i <= 32768; ++i) + input.push_back (static_cast (i) / 16384.0f); + + for (int exponent = -149; exponent <= 1; ++exponent) + { + const auto value = std::ldexp (1.0f, exponent); + input.push_back (value); + input.push_back (-value); + } + + for (const float boundary : { std::numeric_limits::denorm_min(), + std::numeric_limits::min(), + 0x1p-13f, 0.5f, 1.0f, 2.0f, 4.0f, 8.0f, 9.0f, 10.0f, + std::numeric_limits::max() }) + { + for (const float value : { std::nextafter (boundary, 0.0f), boundary, + std::nextafter (boundary, infinity) }) + { + input.push_back (value); + input.push_back (-value); + } + } + + for (const bool fastMath : { false, true }) + { + SCOPED_TRACE (fastMath); + YdspDiagnostics diagnostics; + const auto kernel = compileKernel (R"YDSP( + processor Clip { input stream in; output stream out; process { out = tanh (in); } } + graph G { input stream x; output stream y; node c = Clip; connection { x -> c.in; c.out -> y; } } + )YDSP", "Clip", diagnostics, fastMath); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + const auto output = runKernel (kernel, input, static_cast (input.size())); + + for (size_t i = 0; i < input.size(); ++i) + { + SCOPED_TRACE (input[i]); + if (std::isnan (input[i])) + { + EXPECT_TRUE (std::isnan (output[i])); + continue; + } + + if (input[i] == 0.0f || std::isinf (input[i])) + { + EXPECT_EQ (std::tanh (input[i]), output[i]); + EXPECT_EQ (std::signbit (input[i]), std::signbit (output[i])); + continue; + } + + const double expected = std::tanh (static_cast (input[i])); + // Use the binade of the double reference, not a rounded float that + // could cross a power-of-two boundary and double the tolerance. + int exponent = 0; + std::frexp (std::abs (expected), &exponent); + const double ulp = std::max (std::ldexp (1.0, exponent - 24), + static_cast (std::numeric_limits::denorm_min())); + EXPECT_TRUE (std::isfinite (output[i])); + EXPECT_LE (std::abs (static_cast (output[i]) - expected), + (fastMath ? 3.5 : 1.0) * ulp); + } + } +} + +TEST (YdspAsmJitCodegenTests, FastTanhFeedbackPreservesStateAcrossBlockPartitions) +{ + YdspDiagnostics diagnostics; + const auto kernel = compileKernel (R"YDSP( + processor Feedback { + input stream in; output stream out; state float z; + process { z = tanh (in + z * 0.5); out = z; } + } + graph G { input stream x; output stream y; node p = Feedback; connection { x -> p.in; p.out -> y; } } + )YDSP", "Feedback", diagnostics, true); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + std::vector input (16387), expected (input.size()), actual (input.size()); + for (size_t i = 0; i < input.size(); ++i) + input[i] = static_cast (static_cast (i % 257) - 128) / 32.0f; + std::vector referenceState (kernel.stateBytes, 0), partitionedState (kernel.stateBytes, 0); + void* inputs[] { input.data() }; + void* outputs[] { expected.data() }; + YdspKernelContext ctx; + ctx.inputs = inputs; + ctx.outputs = outputs; + ctx.state = referenceState.data(); + ctx.stateArrays = referenceState.data() + kernel.stateScalarBytes; + ctx.sampleRate = 48000.0f; + ctx.numSamples = static_cast (input.size()); + kernel.fn (&ctx); + + double reference = 0.0; + for (size_t i = 0; i < input.size(); ++i) + { + reference = std::tanh (static_cast (input[i]) + 0.5 * reference); + EXPECT_NEAR (reference, expected[i], 2.0e-6) << i; + } + + for (const bool inPlace : { false, true }) + { + std::fill (partitionedState.begin(), partitionedState.end(), 0); + actual = input; + ctx.state = partitionedState.data(); + ctx.stateArrays = partitionedState.data() + kernel.stateScalarBytes; + const int sizes[] { 1, 3, 4, 5, 31, 128, 512 }; + size_t offset = 0; + size_t block = 0; + while (offset < input.size()) + { + inputs[0] = (inPlace ? actual.data() : input.data()) + offset; + outputs[0] = actual.data() + offset; + const auto savedState = partitionedState; + const auto savedSample = actual[offset]; + ctx.numSamples = 0; + kernel.fn (&ctx); + EXPECT_EQ (savedState, partitionedState); + EXPECT_EQ (savedSample, actual[offset]); + ctx.numSamples = static_cast (std::min (input.size() - offset, + static_cast (sizes[block++ % 7]))); + kernel.fn (&ctx); + offset += static_cast (ctx.numSamples); + } + EXPECT_EQ (0, std::memcmp (expected.data(), actual.data(), actual.size() * sizeof (float))); + EXPECT_EQ (referenceState, partitionedState); + } +} + +TEST (YdspAsmJitCodegenTests, CompilesTwoInputTanhClipper) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor SidechainClip { + input stream in; + input stream side; + output stream out; + process { out = tanh (in * (1 + 0.5 * side)); } + } + graph G { + input stream dry; + input stream sc; + output stream wet; + node c = SidechainClip; + connection { dry -> c.in; sc -> c.side; c.out -> wet; } + } + )YDSP", + "SidechainClip", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + EXPECT_EQ (2, kernel.numInputs); + EXPECT_EQ (1, kernel.numOutputs); + + constexpr int numSamples = 64; + std::vector input (static_cast (numSamples * 2)); + + for (int i = 0; i < numSamples; ++i) + { + input[static_cast (i)] = static_cast (i) * 0.01f - 0.25f; + input[static_cast (numSamples + i)] = 0.1f + static_cast (i) * 0.005f; + } + + auto output = runKernel (kernel, input, numSamples); + + for (int i = 0; i < numSamples; ++i) + { + const auto dry = input[static_cast (i)]; + const auto side = input[static_cast (numSamples + i)]; + EXPECT_NEAR (tanhf (dry * (1.0f + 0.5f * side)), output[static_cast (i)], 1e-5f); + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, CompilesSampleModeOnePoleWithPrev) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor OnePole { + input stream in; + output stream out; + state float z; + process { out = 0.5 * in + 0.5 * out'; z = out; } + } + graph G { input stream x; output stream y; node p = OnePole; connection { x -> p.in; p.out -> y; } } + )YDSP", + "OnePole", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + auto input = makeRampCodegen (32); + auto output = runKernel (kernel, input, 32); + + float previous = 0.0f; + for (int i = 0; i < 32; ++i) + { + const auto expected = 0.5f * input[static_cast (i)] + 0.5f * previous; + EXPECT_NEAR (expected, output[static_cast (i)], 1e-5f); + previous = output[static_cast (i)]; + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, SampleLoopTailPreservesEmptyBlocksAndExactTripCounts) +{ + YdspDiagnostics diagnostics; + const auto kernel = compileKernel (R"YDSP( + processor Counter { + input stream in; output stream out; state float sum; + process { sum = sum + in; out = sum; } + } + graph G { input stream x; output stream y; node p = Counter; connection { x -> p.in; p.out -> y; } } + )YDSP", "Counter", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + std::vector state (kernel.stateBytes, 0); + std::vector input (18, 1.0f), output (18, -999.0f); + void* inputs[] { input.data() }; + void* outputs[] { output.data() }; + YdspKernelContext context; + context.inputs = inputs; + context.outputs = outputs; + context.state = state.data(); + context.stateArrays = state.data() + kernel.stateScalarBytes; + context.sampleRate = 48000.0f; + + float sum = 0.0f; + for (const int size : { 0, 1, 2, 7, 0, 17 }) + { + std::fill (output.begin(), output.end(), -999.0f); + context.numSamples = size; + kernel.fn (&context); + + for (int i = 0; i < size; ++i) + EXPECT_EQ (++sum, output[static_cast (i)]); + for (size_t i = static_cast (size); i < output.size(); ++i) + EXPECT_EQ (-999.0f, output[i]); + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, TwoIterationVectorMathUnrollPreservesOutputAndState) +{ + YdspDiagnostics diagnostics; + const auto source = R"YDSP( + processor P { + input stream in; output stream out; + state float phase[8]; + process { + float sum = 0.0; + for i in 0..8 { + phase[i] = phase[i] + in; + sum = sum + sin (phase[i]); + } + out = sum; + } + } + graph G { input stream x; output stream y; node p = P; + connection { x -> p.in; p.out -> y; } } + )YDSP"; + YdspLexer lexer (source, diagnostics); + YdspParser parser (lexer.tokenize(), diagnostics); + auto parsed = parser.parseProgram(); + ASSERT_NE (nullptr, parsed) << diagnostics.toString(); + YdspSemanticAnalyzer analyzer (diagnostics); + auto analyzed = analyzer.analyze (std::move (parsed)); + ASSERT_NE (nullptr, analyzed) << diagnostics.toString(); + YdspOptimizer optimizer (diagnostics); + optimizer.setFastMath (true); + optimizer.setVectorizationEnabled (true); + optimizer.setVectorWidth (4); + optimizer.setTargetHasVectorMath (true); + auto ir = optimizer.build (*analyzed); + ASSERT_NE (nullptr, ir) << diagnostics.toString(); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_FALSE (ir->kernels.empty()); + const auto& rolled = *ir->kernels.front(); + ASSERT_TRUE (rolled.vectorized); + auto unrolled = rolled; + optimizer.fullyUnrollBoundedLoops (unrolled); + ASSERT_TRUE (std::any_of (unrolled.loops.begin(), unrolled.loops.end(), [] (const auto& loop) + { + return loop.unrolled; + })); + EXPECT_EQ (YdspAsmJitCodegen::stateSize (rolled), YdspAsmJitCodegen::stateSize (unrolled)); + + asmjit::JitRuntime runtime; + const auto rolledKernel = YdspAsmJitCodegen::compile (runtime, rolled, diagnostics); + const auto unrolledKernel = YdspAsmJitCodegen::compile (runtime, unrolled, diagnostics); + ASSERT_NE (nullptr, rolledKernel) << diagnostics.toString(); + ASSERT_NE (nullptr, unrolledKernel) << diagnostics.toString(); + const auto bytes = YdspAsmJitCodegen::stateSize (rolled); + const auto scalarBytes = YdspAsmJitCodegen::stateScalarSize (rolled); + std::vector rolledState (bytes, 0), unrolledState (bytes, 0); + std::vector input (512), rolledOutput (512), unrolledOutput (512); + void* inputs[] { input.data() }; + void* rolledOutputs[] { rolledOutput.data() }; + void* unrolledOutputs[] { unrolledOutput.data() }; + YdspKernelContext rolledContext; + rolledContext.inputs = inputs; + rolledContext.outputs = rolledOutputs; + rolledContext.state = rolledState.data(); + rolledContext.stateArrays = rolledState.data() + scalarBytes; + rolledContext.sampleRate = 48000.0f; + auto unrolledContext = rolledContext; + unrolledContext.outputs = unrolledOutputs; + unrolledContext.state = unrolledState.data(); + unrolledContext.stateArrays = unrolledState.data() + scalarBytes; + + for (const float phase : { 0.0f, -0.0f, 1000000.0f, std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() }) + { + for (int mode = 0; mode < 8; ++mode) + std::memcpy (rolledState.data() + scalarBytes + static_cast (mode) * sizeof (float), &phase, sizeof (float)); + std::copy (rolledState.begin(), rolledState.end(), unrolledState.begin()); + for (size_t sample = 0; sample < input.size(); ++sample) + input[sample] = static_cast (static_cast (sample % 17) - 8) * 0.0125f; + for (int repeat = 0; repeat < 8; ++repeat) + for (const int size : { 0, 1, 3, 4, 5, 31, 512 }) + { + SCOPED_TRACE (size); + const bool inPlace = repeat % 2 != 0; + std::fill (rolledOutput.begin(), rolledOutput.end(), -999.0f); + std::fill (unrolledOutput.begin(), unrolledOutput.end(), -999.0f); + if (inPlace) + { + std::copy (input.begin(), input.end(), rolledOutput.begin()); + std::copy (input.begin(), input.end(), unrolledOutput.begin()); + } + rolledContext.inputs = inPlace ? rolledOutputs : inputs; + unrolledContext.inputs = inPlace ? unrolledOutputs : inputs; + const auto stateBefore = rolledState; + rolledContext.numSamples = unrolledContext.numSamples = size; + rolledKernel (&rolledContext); + unrolledKernel (&unrolledContext); + EXPECT_EQ (0, std::memcmp (rolledOutput.data(), unrolledOutput.data(), rolledOutput.size() * sizeof (float))); + EXPECT_EQ (rolledState, unrolledState); + if (size == 0) + EXPECT_EQ (stateBefore, rolledState); + for (size_t sample = static_cast (size); sample < rolledOutput.size(); ++sample) + EXPECT_EQ (inPlace ? input[sample] : -999.0f, unrolledOutput[sample]); + } + } +} + +TEST (YdspAsmJitCodegenTests, StatePersistsAcrossBlocks) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor OnePole { + input stream in; + output stream out; + process { out = 0.5 * in + 0.5 * out'; } + } + graph G { input stream x; output stream y; node p = OnePole; connection { x -> p.in; p.out -> y; } } + )YDSP", + "OnePole", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + // Two consecutive blocks share state + std::vector fullInput (32, 0.1f); + std::vector fullOutput (32); + + // Allocate shared state + std::vector state (kernel.stateBytes, 0); + std::vector params (static_cast (kernel.numParams), 0.0f); + std::vector paramsOut (static_cast (kernel.numParamsOut), 0.0f); + std::vector blockOut (32, 0.0f); + + auto runBlock = [&] (float* inPtr, float* outPtr, int n, bool first) + { + void* inPtrs[1] = { inPtr }; + void* outPtrs[1] = { outPtr }; + + YdspKernelContext ctx; + ctx.inputs = inPtrs; + ctx.outputs = outPtrs; + ctx.params = params.data(); + ctx.paramOut = paramsOut.data(); + ctx.state = reinterpret_cast (state.data()); + ctx.stateArrays = state.empty() ? nullptr : state.data() + kernel.stateScalarBytes; + ctx.sampleRate = 44100.0f; + ctx.numSamples = n; + + kernel.fn (&ctx); + + if (first) + std::memcpy (fullOutput.data(), outPtr, static_cast (n) * sizeof (float)); + else + std::memcpy (fullOutput.data() + n, outPtr, static_cast (n) * sizeof (float)); + }; + + runBlock (fullInput.data(), blockOut.data(), 16, true); + runBlock (fullInput.data() + 16, blockOut.data(), 16, false); + + float previous = 0.0f; + for (int i = 0; i < 32; ++i) + { + const auto expected = 0.5f * 0.1f + 0.5f * previous; + EXPECT_NEAR (expected, fullOutput[static_cast (i)], 1e-5f); + previous = fullOutput[static_cast (i)]; + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, CompilesBlockModeWithLoop) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor BlockGain { + input stream in; + output stream out; + input parameter float drive = 3; + process block { + for i in 0..blockSize { out[i] = in[i] * drive; } + } + } + graph G { input stream x; output stream y; node b = BlockGain; connection { x -> b.in; b.out -> y; } } + )YDSP", + "BlockGain", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + auto input = makeRampCodegen (32); + auto output = runKernel (kernel, input, 32, { 3.0f }); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (input[static_cast (i)] * 3.0f, output[static_cast (i)], 1e-5f); + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, CompilesFixedDelay) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor Delay3 { input stream in; output stream out; process { out = in @ 3; } } + graph G { input stream x; output stream y; node d = Delay3; connection { x -> d.in; d.out -> y; } } + )YDSP", + "Delay3", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + auto input = makeRampCodegen (32); + auto output = runKernel (kernel, input, 32); + + for (int i = 0; i < 32; ++i) + { + const auto expected = i >= 3 ? input[static_cast (i - 3)] : 0.0f; + EXPECT_NEAR (expected, output[static_cast (i)], 1e-6f); + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, IntegerImmediateOperandsPreserveSignedArithmetic) +{ + YdspDiagnostics diagnostics; + const auto kernel = compileKernel (R"YDSP( + processor Immediate { + input stream in; output stream out; + process { let x = int (in); out = float (((x + 7) - 3) & 511); } + } + graph G { input stream x; output stream y; node p = Immediate; connection { x -> p.in; p.out -> y; } } + )YDSP", "Immediate", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + const std::vector input { -1024.0f, -513.0f, -7.0f, -1.0f, 0.0f, 508.0f, 511.0f, 1024.0f }; + const auto output = runKernel (kernel, input, static_cast (input.size())); + ASSERT_EQ (input.size(), output.size()); + for (size_t i = 0; i < input.size(); ++i) + EXPECT_EQ (static_cast (((static_cast (input[i]) + 7) - 3) & 511), output[i]); + +#if ASMJIT_ARCH_ARM == 64 + bool sawImmediateMask = false; + for (const auto& line : StringArray::fromLines (kernel.asmText)) + sawImmediateMask |= line.trimStart().startsWith ("and ") && line.contains (", 511"); + EXPECT_TRUE (sawImmediateMask) << kernel.asmText; +#endif + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, IntegerImmediateSelectionKeepsDynamicAndUnencodableOperands) +{ + YdspDiagnostics diagnostics; + const auto kernel = compileKernel (R"YDSP( + processor Masks { + input stream in; output stream out; + process { + let x = int (in); + int mask = 511; + if (x < 0) { mask = 255; } + out = float (((x + 5000) & mask) + (x & 341)); + } + } + graph G { input stream x; output stream y; node p = Masks; connection { x -> p.in; p.out -> y; } } + )YDSP", "Masks", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + const std::vector input { -513.0f, -1.0f, 0.0f, 511.0f, 1024.0f }; + const auto output = runKernel (kernel, input, static_cast (input.size())); + ASSERT_EQ (input.size(), output.size()); + for (size_t i = 0; i < input.size(); ++i) + { + const auto x = static_cast (input[i]); + EXPECT_EQ (static_cast (((x + 5000) & (x < 0 ? 255 : 511)) + (x & 341)), output[i]); + } + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, VectorMathTargetsAreLoadedOnceBeforeCalls) +{ + for (const auto architecture : { YdspTargetArchitecture::arm64, YdspTargetArchitecture::x64 }) + for (const bool fastMath : { false, true }) + { + YdspIrFunction fn; + fn.name = "VectorMathTargets"; + fn.fastMath = fastMath; + fn.float32ArrayElements = 16; + fn.valueTypes = { YdspValueType::int32Type, YdspValueType::float32Type, + YdspValueType::float32Type, YdspValueType::float32Type, + YdspValueType::float32Type }; + fn.valueLanes = { 1, 4, 4, 4, 4 }; + fn.vectorized = true; + fn.vectorWidth = 4; + fn.blocks.resize (1); + fn.blocks[0].insts = { + { YdspIrOp::constI, 0, -1, -1, -1, -1, 0.0, 0 }, + { YdspIrOp::loadStateArrayF, 1, 0, -1, -1, 0 }, + { YdspIrOp::sinF, 2, 1 }, + { YdspIrOp::sinF, 3, 2 }, + { YdspIrOp::powF, 4, 3, 2 }, + { YdspIrOp::storeStateArrayF, -1, 0, 4, -1, 4 } + }; + YdspDiagnostics diagnostics; + const auto artifact = YdspAsmJitCodegen::emit ({ YdspTargetOperatingSystem::linuxTarget, architecture }, fn, diagnostics); + ASSERT_TRUE (artifact.wasOk()) << diagnostics.toString(); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto& symbols = artifact.getReference().symbols; + ASSERT_EQ (2u, symbols.size()); + EXPECT_EQ (String ("pow.v4.u10"), symbols[0].name); + EXPECT_EQ (String (fastMath ? "sin.v4.u35" : "sin.v4.u10"), symbols[1].name); + + if (architecture == YdspTargetArchitecture::arm64) + { + int targetLoads = 0; + int calls = 0; + bool sawVectorLoad = false; + for (const auto& line : StringArray::fromLines (diagnostics.toString())) + { + const auto instruction = line.trimStart(); + sawVectorLoad |= instruction.startsWith ("ldr q"); + if (instruction.startsWith ("ldr x") && instruction.contains ("[L")) + { + EXPECT_FALSE (sawVectorLoad) << diagnostics.toString(); + ++targetLoads; + } + if (instruction.startsWith ("blr ")) + ++calls; + } + EXPECT_EQ (2, targetLoads) << diagnostics.toString(); + EXPECT_EQ (3, calls) << diagnostics.toString(); + } + } +} + +TEST (YdspAsmJitCodegenTests, ReservesArrayBasesOnlyForIndexedAccesses) +{ + for (const bool indexed : { false, true }) + { + YdspIrFunction fn; + fn.name = "ArrayBaseReservation"; + fn.float32ArrayElements = 128; + fn.valueTypes = { YdspValueType::int32Type, YdspValueType::float32Type, YdspValueType::int32Type }; + fn.valueLanes = { 1, 4, 1 }; + fn.vectorized = true; + fn.vectorWidth = 4; + fn.blocks.resize (1); + fn.blocks[0].insts = { + { YdspIrOp::constI, 0, -1, -1, -1, -1, 0.0, 0 }, + { YdspIrOp::loadStateArrayF, 1, 0, -1, -1, 64 }, + { YdspIrOp::storeStateArrayF, -1, 0, 1, -1, 64 } + }; + if (indexed) + { + fn.blocks[0].insts.push_back ({ YdspIrOp::loadBlockSize, 2 }); + fn.blocks[0].insts.push_back ({ YdspIrOp::storeStateArrayF, -1, 2, 1, -1, 64 }); + } + YdspDiagnostics diagnostics; + const auto artifact = YdspAsmJitCodegen::emit ({ YdspTargetOperatingSystem::linuxTarget, YdspTargetArchitecture::arm64 }, fn, diagnostics); + ASSERT_TRUE (artifact.wasOk()) << diagnostics.toString(); + int addressAdds = 0; + for (const auto& line : StringArray::fromLines (diagnostics.toString())) + if (line.trimStart().startsWith ("add ")) + ++addressAdds; + EXPECT_EQ (indexed ? 1 : 0, addressAdds) << diagnostics.toString(); + } +} + +TEST (YdspAsmJitCodegenTests, VectorFusedOperationsReuseOnlyDeadLocalAddends) +{ + for (const auto architecture : { YdspTargetArchitecture::arm64, YdspTargetArchitecture::x64 }) + for (const auto op : { YdspIrOp::fmaF, YdspIrOp::fmsubF }) + for (const bool shared : { false, true }) + { + YdspIrFunction fn; + fn.name = "VectorAccumulatorReuse"; + fn.float32ArrayElements = 32; + fn.valueTypes = { YdspValueType::int32Type, YdspValueType::float32Type, + YdspValueType::float32Type, YdspValueType::float32Type, + YdspValueType::float32Type, YdspValueType::float32Type }; + fn.valueLanes = { 1, 4, 4, 4, 4, 4 }; + fn.vectorized = true; + fn.vectorWidth = 4; + fn.packedFmaAvailable = true; + fn.blocks.resize (1); + fn.blocks[0].insts = { + { YdspIrOp::constI, 0, -1, -1, -1, -1, 0.0, 0 }, + { YdspIrOp::loadStateArrayF, 1, 0, -1, -1, 0 }, + { YdspIrOp::loadStateArrayF, 2, 0, -1, -1, 4 }, + { YdspIrOp::loadStateArrayF, 3, 0, -1, -1, 8 }, + { YdspIrOp::mulF, 4, 1, 2 }, + { op, 5, 1, 3, 4 }, + { YdspIrOp::storeStateArrayF, -1, 0, 5, -1, 12 } + }; + if (shared) + fn.blocks[0].insts.push_back ({ YdspIrOp::storeStateArrayF, -1, 0, 4, -1, 16 }); + YdspDiagnostics diagnostics; + const auto artifact = YdspAsmJitCodegen::emit ({ YdspTargetOperatingSystem::linuxTarget, architecture }, fn, diagnostics); + ASSERT_TRUE (artifact.wasOk()) << diagnostics.toString(); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto listing = diagnostics.toString(); + if (architecture == YdspTargetArchitecture::arm64) + { + int vectorCopies = 0; + for (const auto& line : StringArray::fromLines (listing)) + if (line.trimStart().startsWith ("mov ") && line.contains (".16b")) + ++vectorCopies; + EXPECT_EQ (shared ? 1 : 0, vectorCopies) << listing; + } + else if (! shared) + EXPECT_TRUE (listing.contains (op == YdspIrOp::fmaF ? "vfmadd231ps" : "vfnmadd231ps")) << listing; + } +} + +TEST (YdspAsmJitCodegenTests, ArrayLoadEliminationPreservesOverlappingVectorWrites) +{ + for (const int testCase : { 0, 1, 2, 3, 4, 5 }) + { + SCOPED_TRACE (testCase); + const int offsets[] { 0, 1, 4 }; + const int offset = offsets[testCase % 3]; + const bool cse = testCase >= 3; + YdspIrFunction original; + original.name = "ArrayForwarding"; + original.isSampleMode = false; + original.float32ArrayElements = 32; + original.valueTypes = { YdspValueType::int32Type, YdspValueType::int32Type, + YdspValueType::float32Type, YdspValueType::float32Type, YdspValueType::float32Type }; + original.valueLanes = { 1, 1, 4, 4, 4 }; + original.vectorized = true; + original.vectorWidth = 4; + original.blocks.resize (1); + original.blocks[0].insts = { + { YdspIrOp::constI, 0, -1, -1, -1, -1, 0.0, 0 }, + { YdspIrOp::constI, 1, -1, -1, -1, -1, 0.0, offset }, + { YdspIrOp::loadStateArrayF, 2, 0, -1, -1, 16 }, + { YdspIrOp::loadStateArrayF, 3, 0, -1, -1, 20 }, + { YdspIrOp::storeStateArrayF, -1, 0, 2, -1, 0 }, + { YdspIrOp::storeStateArrayF, -1, 1, 3, -1, 0 }, + { YdspIrOp::loadStateArrayF, 4, 0, -1, -1, 0 }, + { YdspIrOp::storeStateArrayF, -1, 0, 4, -1, 8 } + }; + if (cse) + { + original.blocks[0].insts[5].a = 0; + original.blocks[0].insts[5].memIndex = 16 + offset; + original.blocks[0].insts[6].memIndex = 16; + } + auto forwarded = original; + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + if (cse) + { + optimizer.commonSubexpressionElimination (forwarded); + EXPECT_EQ (offset == 4 ? YdspIrOp::movF : YdspIrOp::loadStateArrayF, + forwarded.blocks[0].insts[6].op); + } + else + { + EXPECT_EQ (offset != 1, optimizer.storeToLoadForwarding (forwarded)); + } + for (const auto architecture : { YdspTargetArchitecture::arm64, YdspTargetArchitecture::x64 }) + { + auto artifact = YdspAsmJitCodegen::emit ({ YdspTargetOperatingSystem::linuxTarget, architecture }, forwarded, diagnostics); + ASSERT_TRUE (artifact.wasOk()) << diagnostics.toString(); + } + asmjit::JitRuntime runtime; + const auto before = YdspAsmJitCodegen::compile (runtime, original, diagnostics); + const auto after = YdspAsmJitCodegen::compile (runtime, forwarded, diagnostics); + ASSERT_NE (nullptr, before) << diagnostics.toString(); + ASSERT_NE (nullptr, after) << diagnostics.toString(); + std::vector reference (32, 0.0f); + reference[16] = -0.0f; + reference[17] = std::numeric_limits::quiet_NaN(); + reference[18] = std::numeric_limits::infinity(); + reference[19] = -1.0f; + for (int i = 20; i < 24; ++i) + reference[static_cast (i)] = static_cast (i); + auto actual = reference; + YdspKernelContext context; + context.numSamples = 4; + context.state = reference.data(); + context.stateArrays = reference.data(); + before (&context); + context.state = actual.data(); + context.stateArrays = actual.data(); + after (&context); + EXPECT_EQ (0, std::memcmp (reference.data(), actual.data(), reference.size() * sizeof (float))); + } +} + +TEST (YdspAsmJitCodegenTests, ConstantVectorArrayOffsetsRespectEncodingLimits) +{ + for (const int index : { 4, 5, 16380, 16384 }) + for (const auto architecture : { YdspTargetArchitecture::arm64, YdspTargetArchitecture::x64 }) + { + YdspIrFunction fn; + fn.name = "VectorArrayDisplacement"; + fn.float32ArrayElements = 16400; + fn.valueTypes = { YdspValueType::int32Type, YdspValueType::float32Type }; + fn.valueLanes = { 1, 4 }; + fn.vectorized = true; + fn.vectorWidth = 4; + fn.blocks.resize (1); + fn.blocks[0].insts = { + { YdspIrOp::constI, 0, -1, -1, -1, -1, 0.0, index }, + { YdspIrOp::loadStateArrayF, 1, 0, -1, -1, 0 }, + { YdspIrOp::storeStateArrayF, -1, 0, 1, -1, 0 } + }; + YdspDiagnostics diagnostics; + const auto artifact = YdspAsmJitCodegen::emit ({ YdspTargetOperatingSystem::linuxTarget, architecture }, fn, diagnostics); + ASSERT_TRUE (artifact.wasOk()) << diagnostics.toString(); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto listing = diagnostics.toString(); + if (architecture == YdspTargetArchitecture::arm64) + EXPECT_EQ (index == 5 || index == 16384, listing.contains ("lsl ")) << listing; + else + EXPECT_FALSE (listing.contains ("*4")) << listing; + } +} + +TEST (YdspAsmJitCodegenTests, ConstantArrayIndicesUseByteDisplacementsOnBothTargets) +{ + for (const auto type : { YdspValueType::float32Type, YdspValueType::float64Type, + YdspValueType::int32Type, YdspValueType::int64Type }) + for (const int index : { 5, 8192 }) + { + YdspIrFunction fn; + fn.name = "ArrayDisplacement"; + fn.float32ArrayElements = fn.float64ArrayElements = fn.int32ArrayElements = fn.int64ArrayElements = 8200; + // Test each type at the beginning of its own array segment. + if (type != YdspValueType::float32Type) + fn.float32ArrayElements = 0; + if (type != YdspValueType::float64Type) + fn.float64ArrayElements = 0; + if (type != YdspValueType::int32Type) + fn.int32ArrayElements = 0; + const bool floating = type == YdspValueType::float32Type || type == YdspValueType::float64Type; + fn.valueTypes = { YdspValueType::int32Type, type }; + fn.blocks.resize (1); + fn.blocks[0].insts = { + { YdspIrOp::constI, 0, -1, -1, -1, -1, 0.0, index }, + { floating ? YdspIrOp::loadStateArrayF : YdspIrOp::loadStateArrayI, 1, 0, -1, -1, 0 }, + { floating ? YdspIrOp::storeStateArrayF : YdspIrOp::storeStateArrayI, -1, 0, 1, -1, 0 } + }; + for (const auto architecture : { YdspTargetArchitecture::arm64, YdspTargetArchitecture::x64 }) + { + YdspDiagnostics diagnostics; + const auto artifact = YdspAsmJitCodegen::emit ({ YdspTargetOperatingSystem::linuxTarget, architecture }, fn, diagnostics); + ASSERT_TRUE (artifact.wasOk()) << diagnostics.toString(); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + if (architecture == YdspTargetArchitecture::arm64) + EXPECT_EQ (index == 8192, diagnostics.toString().contains ("lsl 2") || diagnostics.toString().contains ("lsl 3")) + << diagnostics.toString(); + else + { + EXPECT_FALSE (diagnostics.toString().contains ("*4")) << diagnostics.toString(); + EXPECT_FALSE (diagnostics.toString().contains ("*8")) << diagnostics.toString(); + } + if (index == 5) + { + const int bytes = type == YdspValueType::float64Type || type == YdspValueType::int64Type ? 8 : 4; + const auto offset = String (bytes * index); + EXPECT_TRUE (diagnostics.toString().contains (String (", ") + offset + "]") + || diagnostics.toString().contains (String ("+") + offset + "]")) << diagnostics.toString(); + } + } + } +} + +TEST (YdspAsmJitCodegenTests, OperandLoweringEmitsFusedSubtractAndImmediatesOnBothTargets) +{ + for (const auto type : { YdspValueType::float32Type, YdspValueType::float64Type }) + { + YdspIrFunction fn; + fn.name = "OperandLowering"; + fn.numInputs = fn.numOutputs = 1; + fn.inputTypes = fn.outputTypes = { type }; + fn.valueTypes = { YdspValueType::int32Type, YdspValueType::int32Type, + YdspValueType::int32Type, YdspValueType::int32Type, + YdspValueType::int32Type, type, type, type, type }; + fn.blocks.resize (1); + fn.blocks[0].insts = { + { YdspIrOp::constI, 0, -1, -1, -1, -1, 0.0, 0 }, + { YdspIrOp::constI, 1, -1, -1, -1, -1, 0.0, 7 }, + { YdspIrOp::addI, 2, 0, 1 }, + { YdspIrOp::constI, 3, -1, -1, -1, -1, 0.0, 511 }, + { YdspIrOp::andI, 4, 2, 3 }, + { YdspIrOp::loadInput, 5, 4, -1, -1, 0 }, + { YdspIrOp::constF, 6, -1, -1, -1, -1, 2.0 }, + { YdspIrOp::negF, 7, 5 }, + { YdspIrOp::fmaF, 8, 5, 6, 7 }, + { YdspIrOp::storeOutput, -1, 0, 8, -1, 0 } + }; + + for (const auto architecture : { YdspTargetArchitecture::arm64, YdspTargetArchitecture::x64 }) + { + YdspDiagnostics diagnostics; + const auto artifact = YdspAsmJitCodegen::emit ({ YdspTargetOperatingSystem::linuxTarget, architecture }, fn, diagnostics); + ASSERT_TRUE (artifact.wasOk()) << diagnostics.toString(); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto listing = diagnostics.toString(); + const auto mnemonic = architecture == YdspTargetArchitecture::arm64 ? "fnmsub " + : type == YdspValueType::float64Type ? "vfmsub213sd " : "vfmsub213ss "; + EXPECT_TRUE (listing.contains (mnemonic)) << listing; + + bool sawImmediateMask = false; + for (const auto& line : StringArray::fromLines (listing)) + sawImmediateMask |= line.trimStart().startsWith ("and ") && line.contains (", 511"); + EXPECT_TRUE (sawImmediateMask) << listing; + } + } +} + +TEST (YdspAsmJitCodegenTests, FusedIntegerComparisonsUseWidthCorrectImmediatesOnBothTargets) +{ + for (const auto architecture : { YdspTargetArchitecture::arm64, YdspTargetArchitecture::x64 }) + { + for (const int64_t bound : { int64_t (1116), int64_t (4096), int64_t (4294967295) }) + { + YdspIrFunction fn; + fn.name = "CompareImmediate"; + fn.numInputs = fn.numOutputs = 1; + fn.inputTypes = fn.outputTypes = { YdspValueType::float32Type }; + fn.valueTypes = { YdspValueType::int32Type, YdspValueType::int64Type, + YdspValueType::int64Type, YdspValueType::boolType, + YdspValueType::float32Type, YdspValueType::float32Type, + YdspValueType::float32Type }; + fn.blocks.resize (1); + fn.blocks[0].insts = { + { YdspIrOp::constI, 0, -1, -1, -1, -1, 0.0, 0 }, + { YdspIrOp::constI, 1, -1, -1, -1, -1, 0.0, 7 }, + { YdspIrOp::constI, 2, -1, -1, -1, -1, 0.0, bound }, + { YdspIrOp::ltI, 3, 1, 2 }, + { YdspIrOp::loadInput, 4, 0, -1, -1, 0 }, + { YdspIrOp::constF, 5, -1, -1, -1, -1, 1.0 }, + { YdspIrOp::selectB, 6, 3, 4, 5 }, + { YdspIrOp::storeOutput, -1, 0, 6, -1, 0 } + }; + + YdspDiagnostics diagnostics; + const auto artifact = YdspAsmJitCodegen::emit ({ YdspTargetOperatingSystem::linuxTarget, architecture }, fn, diagnostics); + ASSERT_TRUE (artifact.wasOk()) << diagnostics.toString(); + const auto listing = diagnostics.toString(); + bool sawImmediate = false; + for (const auto& line : StringArray::fromLines (listing)) + sawImmediate |= line.trimStart().startsWith ("cmp ") && line.contains (String (", ") + String (bound)); + const bool encodable = architecture == YdspTargetArchitecture::arm64 ? bound <= 4095 + : bound <= std::numeric_limits::max(); + EXPECT_EQ (encodable, sawImmediate) << listing; + } + } +} + +TEST (YdspAsmJitCodegenTests, IntegerSaturationOnlyEmitsOverflowHandlingWhenRequired) +{ + for (const auto architecture : { YdspTargetArchitecture::arm64, YdspTargetArchitecture::x64 }) + for (const bool wide : { false, true }) + for (const bool saturating : { false, true }) + for (const auto op : { YdspIrOp::addI, YdspIrOp::subI, YdspIrOp::mulI }) + { + SCOPED_TRACE (::testing::Message() << "arch=" << static_cast (architecture) << ", wide=" << wide + << ", saturating=" << saturating << ", op=" << static_cast (op)); + YdspIrFunction fn; + fn.name = "IntegerArithmetic"; + fn.numInputs = fn.numOutputs = 1; + fn.inputTypes = fn.outputTypes = { YdspValueType::float32Type }; + const auto type = wide ? YdspValueType::int64Type : YdspValueType::int32Type; + fn.valueTypes = { YdspValueType::int32Type, YdspValueType::float32Type, type, type, type, YdspValueType::float32Type }; + fn.blocks.resize (1); + auto& insts = fn.blocks[0].insts; + insts = { + { YdspIrOp::constI, 0, -1, -1, -1, -1, 0.0, 0 }, + { YdspIrOp::loadInput, 1, 0, -1, -1, 0 }, + { YdspIrOp::ftoi, 2, 1 }, + { YdspIrOp::constI, 3, -1, -1, -1, -1, 0.0, 3 }, + { op, 4, 2, 3 }, + { YdspIrOp::itof, 5, 4 }, + { YdspIrOp::storeOutput, -1, 0, 5, -1, 0 } + }; + insts[4].saturatingIntegerArithmetic = saturating; + YdspDiagnostics diagnostics; + const auto artifact = YdspAsmJitCodegen::emit ({ YdspTargetOperatingSystem::linuxTarget, architecture }, fn, diagnostics); + ASSERT_TRUE (artifact.wasOk()) << diagnostics.toString(); + const auto listing = diagnostics.toString(); + if (architecture == YdspTargetArchitecture::x64) + EXPECT_EQ (saturating, listing.contains ("cmovo")) << listing; + else if (op == YdspIrOp::mulI) + EXPECT_EQ (saturating, listing.contains (wide ? "smulh" : "smull")) << listing; + else + EXPECT_EQ (saturating, listing.contains (op == YdspIrOp::addI ? "adds " : "subs ")) << listing; + } +} + +TEST (YdspAsmJitCodegenTests, ProvenFloatConversionsKeepTheDirectInstructionPath) +{ + for (const auto architecture : { YdspTargetArchitecture::arm64, YdspTargetArchitecture::x64 }) + for (const bool bounded : { false, true }) + { + YdspIrFunction fn; + fn.name = "Conversion"; + fn.numInputs = fn.numOutputs = 1; + fn.inputTypes = fn.outputTypes = { YdspValueType::float32Type }; + fn.valueTypes = { YdspValueType::int32Type, YdspValueType::float32Type, + YdspValueType::int32Type, YdspValueType::float32Type }; + fn.blocks.resize (1); + auto& insts = fn.blocks[0].insts; + insts = { + { YdspIrOp::constI, 0, -1, -1, -1, -1, 0.0, 0 }, + { YdspIrOp::loadInput, 1, 0, -1, -1, 0 }, + { YdspIrOp::ftoi, 2, 1 }, + { YdspIrOp::itof, 3, 2 }, + { YdspIrOp::storeOutput, -1, 0, 3, -1, 0 }, + }; + insts[2].boundedFloatToInt = bounded; + YdspDiagnostics diagnostics; + const auto artifact = YdspAsmJitCodegen::emit ({ YdspTargetOperatingSystem::linuxTarget, architecture }, fn, diagnostics); + ASSERT_TRUE (artifact.wasOk()) << diagnostics.toString(); + const auto listing = diagnostics.toString(); + if (architecture == YdspTargetArchitecture::x64) + { + EXPECT_TRUE (listing.contains ("cvttss2si")) << listing; + EXPECT_EQ (! bounded, listing.contains ("cmovp")) << listing; + EXPECT_EQ (! bounded, listing.contains ("cmovae") || listing.contains ("cmovnb")) << listing; + } + else + { + EXPECT_TRUE (listing.contains ("fcvtzs")) << listing; + EXPECT_FALSE (listing.contains ("fcmp")) << listing; + } + } +} + +TEST (YdspAsmJitCodegenTests, SharedSelectComparisonsFuseOnlyWhileEveryUseIsStable) +{ + for (const auto architecture : { YdspTargetArchitecture::arm64, YdspTargetArchitecture::x64 }) + for (const bool overwriteOperand : { false, true }) + { + YdspIrFunction fn; + fn.name = "SharedCompare"; + fn.numInputs = fn.numOutputs = 1; + fn.inputTypes = fn.outputTypes = { YdspValueType::float32Type }; + fn.valueTypes = { YdspValueType::int32Type, YdspValueType::int32Type, + YdspValueType::int32Type, YdspValueType::boolType, + YdspValueType::float32Type, YdspValueType::float32Type, + YdspValueType::float32Type, YdspValueType::float32Type, + YdspValueType::float32Type }; + fn.blocks.resize (1); + auto& insts = fn.blocks[0].insts; + insts = { + { YdspIrOp::constI, 0, -1, -1, -1, -1, 0.0, 0 }, + { YdspIrOp::constI, 1, -1, -1, -1, -1, 0.0, 7 }, + { YdspIrOp::constI, 2, -1, -1, -1, -1, 0.0, 8 }, + { YdspIrOp::ltUI, 3, 1, 2 }, + { YdspIrOp::loadInput, 4, 0, -1, -1, 0 }, + { YdspIrOp::constF, 5, -1, -1, -1, -1, 1.0 }, + { YdspIrOp::selectB, 6, 3, 4, 5 } + }; + if (overwriteOperand) + insts.push_back ({ YdspIrOp::constI, 1, -1, -1, -1, -1, 0.0, 99 }); + insts.push_back ({ YdspIrOp::selectB, 7, 3, 5, 4 }); + insts.push_back ({ YdspIrOp::addF, 8, 6, 7 }); + insts.push_back ({ YdspIrOp::storeOutput, -1, 0, 8, -1, 0 }); + + YdspDiagnostics diagnostics; + const auto artifact = YdspAsmJitCodegen::emit ({ YdspTargetOperatingSystem::linuxTarget, architecture }, fn, diagnostics); + ASSERT_TRUE (artifact.wasOk()) << diagnostics.toString(); + bool materializedBoolean = false; + for (const auto& line : StringArray::fromLines (diagnostics.toString())) + { + const auto instruction = line.trimStart(); + materializedBoolean |= architecture == YdspTargetArchitecture::arm64 + ? instruction.startsWith ("cset ") + : instruction.startsWith ("setb ") || instruction.startsWith ("rex setb "); + } + EXPECT_EQ (overwriteOperand, materializedBoolean) << diagnostics.toString(); + } +} + +TEST (YdspAsmJitCodegenTests, FusedIntegerComparisonPreservesThresholdBoundaries) +{ + YdspDiagnostics diagnostics; + const auto kernel = compileKernel (R"YDSP( + processor Threshold { + input stream in; output stream out; + process { out = int (in) >= 1116 ? in + 1.0 : in - 1.0; } + } + graph G { input stream x; output stream y; node p = Threshold; connection { x -> p.in; p.out -> y; } } + )YDSP", "Threshold", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + const std::vector input { -1116.0f, -1.0f, 0.0f, 1115.0f, 1116.0f, 1117.0f }; + const auto output = runKernel (kernel, input, static_cast (input.size())); + ASSERT_EQ (input.size(), output.size()); + for (size_t i = 0; i < input.size(); ++i) + EXPECT_EQ (input[i] >= 1116.0f ? input[i] + 1.0f : input[i] - 1.0f, output[i]); +} + +TEST (YdspAsmJitCodegenTests, WideIntegerOperandsDoNotSignExtendUnsignedMasks) +{ + YdspDiagnostics diagnostics; + const auto kernel = compileKernel (R"YDSP( + processor Wide { + input stream in; output stream out; + process { + let x = int64 (in); + let mask = (int64 (1) << 32) - int64 (1); + out = float ((x + mask) >> 32) + float ((x & mask) >> 32); + } + } + graph G { input stream x; output stream y; node p = Wide; connection { x -> p.in; p.out -> y; } } + )YDSP", "Wide", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + const std::vector input { -1024.0f, -1.0f, 0.0f, 1.0f, 1024.0f }; + const auto output = runKernel (kernel, input, static_cast (input.size())); + ASSERT_EQ (input.size(), output.size()); + for (size_t i = 0; i < input.size(); ++i) + EXPECT_EQ (input[i] >= 1.0f ? 1.0f : 0.0f, output[i]); + dumpAsmOnFailureCodegen (kernel); +} + +#if ASMJIT_ARCH_ARM == 64 +TEST (YdspAsmJitCodegenTests, FusedNegatedAddendPreservesSignedZero) +{ + YdspDiagnostics diagnostics; + const auto kernel = compileKernel (R"YDSP( + processor Difference { + input stream in; output stream out; + process { out = fma (in, 2.0, -in); } + } + graph G { input stream x; output stream y; node p = Difference; connection { x -> p.in; p.out -> y; } } + )YDSP", "Difference", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + const std::vector input { -0.0f, 0.0f, -0.3f, 0.3f }; + const auto output = runKernel (kernel, input, static_cast (input.size())); + ASSERT_EQ (input.size(), output.size()); + for (size_t i = 0; i < input.size(); ++i) + { + const auto expected = std::fma (input[i], 2.0f, -input[i]); + EXPECT_EQ (expected, output[i]); + EXPECT_EQ (std::signbit (expected), std::signbit (output[i])); + } + EXPECT_TRUE (kernel.asmText.contains ("fnmsub ")) << kernel.asmText; + dumpAsmOnFailureCodegen (kernel); +} +#endif + +TEST (YdspAsmJitCodegenTests, CompilesMathIntrinsics) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor Maths { + input stream in; + output stream out; + process { out = sqrt (abs (in) + 1) * sin (in); } + } + graph G { input stream x; output stream y; node m = Maths; connection { x -> m.in; m.out -> y; } } + )YDSP", + "Maths", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + auto input = makeRampCodegen (64, -1.0f); + auto output = runKernel (kernel, input, 64); + + for (int i = 0; i < 64; ++i) + { + const auto x = input[static_cast (i)]; + const auto expected = sqrtf (fabsf (x) + 1.0f) * sinf (x); + EXPECT_NEAR (expected, output[static_cast (i)], 1e-4f); + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, CompilesSignIntrinsic) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor Sign { input stream in; output stream out; process { out = sign (in); } } + graph G { input stream x; output stream y; node s = Sign; connection { x -> s.in; s.out -> y; } } + )YDSP", + "Sign", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + // Ramp crosses zero at sample 35, covering the negative, zero and positive branches. + auto input = makeRampCodegen (64, -0.35f); + auto output = runKernel (kernel, input, 64); + + for (int i = 0; i < 64; ++i) + { + const auto x = input[static_cast (i)]; + const auto expected = x > 0.0f ? 1.0f : (x < 0.0f ? -1.0f : 0.0f); + EXPECT_NEAR (expected, output[static_cast (i)], 1e-6f); + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, CompilesIntegerMinMaxClampAbsSign) +{ + // min/max/clamp/abs/sign have an integer overload (minI/maxI/clampI/ + // absI/signI) alongside the existing float intrinsics: an int-typed + // argument must select it, not silently fall back to the float form. + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor IntMath { + input stream in; + output stream out; + process { + let n = int32 (in * 100.0) - 50; // -50 .. 49 over a 100-sample ramp + let mn = min (n, 3); + let mx = max (n, -3); + let cl = clamp (n, -10, 10); + let ab = abs (n); + let sg = sign (n); + out = float32 (mn + mx + cl + ab + sg); + } + } + graph G { input stream x; output stream y; node p = IntMath; connection { x -> p.in; p.out -> y; } } + )YDSP", + "IntMath", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + auto input = makeRampCodegen (100); + auto output = runKernel (kernel, input, 100); + + for (int i = 0; i < 100; ++i) + { + const auto n = static_cast (input[static_cast (i)] * 100.0f) - 50; + const auto mn = std::min (n, 3); + const auto mx = std::max (n, -3); + const auto cl = std::min (std::max (n, -10), 10); + const auto ab = n < 0 ? -n : n; + const auto sg = n > 0 ? 1 : (n < 0 ? -1 : 0); + const auto expected = static_cast (mn + mx + cl + ab + sg); + + EXPECT_NEAR (expected, output[static_cast (i)], 1e-3f) << "at " << i; + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, IntegerAbsOfIntMinSaturates) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor AbsIntMin { + input stream in; + output stream out; + state int base; + process { + let x = base - 2147483648; + let y = abs (x); + out = in + select (y == 2147483647, 1.0, 0.0); + } + } + graph G { input stream x; output stream y; node p = AbsIntMin; connection { x -> p.in; p.out -> y; } } + )YDSP", + "AbsIntMin", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + std::vector input (4, 0.0f); + auto output = runKernel (kernel, input, 4); + + for (const auto sample : output) + EXPECT_NEAR (1.0f, sample, 1e-6f); + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, CompilesTernaryExpression) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor Gate { + input stream in; + output stream out; + process { out = (in > 0) ? in : 0; } + } + graph G { input stream x; output stream y; node g = Gate; connection { x -> g.in; g.out -> y; } } + )YDSP", + "Gate", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + auto input = makeRampCodegen (64, -0.3f); + auto output = runKernel (kernel, input, 64); + + for (int i = 0; i < 64; ++i) + { + const auto x = input[static_cast (i)]; + const auto expected = x > 0.0f ? x : 0.0f; + EXPECT_NEAR (expected, output[static_cast (i)], 1e-6f); + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, CompilesBlockModeWithStateArray) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor DelayLine { + input stream in; + output stream out; + state float mem[256]; + state int wp; + process block { + for i in 0..blockSize { + mem[wp] = in[i]; + out[i] = mem[wp]; + wp = (wp + 1) % 256; + } + } + } + graph G { input stream x; output stream y; node d = DelayLine; connection { x -> d.in; d.out -> y; } } + )YDSP", + "DelayLine", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + auto input = makeRampCodegen (64); + auto output = runKernel (kernel, input, 64); + + // With write pointer at 0 and no prior reads, out[i] == in[i] (identity for first pass) + for (int i = 0; i < 64; ++i) + EXPECT_NEAR (input[static_cast (i)], output[static_cast (i)], 1e-6f); + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, StateSizeIsPositiveForKernelWithState) +{ + YdspDiagnostics diagnostics; + + auto kernelNoState = compileKernel (R"YDSP( + processor P { input stream in; output stream out; process { out = in; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + "P", + diagnostics); + + ASSERT_NE (nullptr, kernelNoState.fn); + // A pass-through with no state variables has zero state (sample-loop counter is virtual) + EXPECT_EQ (0u, kernelNoState.stateBytes); + + auto kernelWithState = compileKernel (R"YDSP( + processor Q { + input stream in; + output stream out; + state float buf[128]; + process block { + for i in 0..blockSize { buf[i] = in[i]; out[i] = buf[i]; } + } + } + graph G { input stream x; output stream y; node q = Q; connection { x -> q.in; q.out -> y; } } + )YDSP", + "Q", + diagnostics); + + ASSERT_NE (nullptr, kernelWithState.fn); + EXPECT_GT (kernelWithState.stateBytes, kernelNoState.stateBytes); + + dumpAsmOnFailureCodegen (kernelNoState); + dumpAsmOnFailureCodegen (kernelWithState); +} + +TEST (YdspAsmJitCodegenTests, CompilesStereoProcessor) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor StereoGain { + input stream leftIn; + input stream rightIn; + output stream leftOut; + output stream rightOut; + input parameter float gain = 1; + process { + leftOut = leftIn * gain; + rightOut = rightIn * gain; + } + } + graph G { + input stream L, R; + output stream outL, outR; + node sg = StereoGain (gain = 0.5); + connection { L -> sg.leftIn; R -> sg.rightIn; sg.leftOut -> outL; sg.rightOut -> outR; } + } + )YDSP", + "StereoGain", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + EXPECT_EQ (2, kernel.numInputs); + EXPECT_EQ (2, kernel.numOutputs); + + // Build interleaved stereo input + std::vector interleaved (128); + for (int i = 0; i < 64; ++i) + { + interleaved[static_cast (i * 2)] = static_cast (i) * 0.01f; + interleaved[static_cast (i * 2 + 1)] = static_cast (i) * 0.02f; + } + + auto output = runKernel (kernel, interleaved, 64, { 0.5f }); + + for (int i = 0; i < 64; ++i) + { + EXPECT_NEAR (interleaved[static_cast (i * 2)] * 0.5f, output[static_cast (i * 2)], 1e-5f); + EXPECT_NEAR (interleaved[static_cast (i * 2 + 1)] * 0.5f, output[static_cast (i * 2 + 1)], 1e-5f); + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, CompilesBlockModeWithConstantLoopBound) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor Taps { + input stream in; + output stream out; + state float mem[16]; + process block { + for i in 0..8 { out[i] = mem[i] + in[i]; } + } + } + graph G { input stream x; output stream y; node t = Taps; connection { x -> t.in; t.out -> y; } } + )YDSP", + "Taps", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + auto input = makeRampCodegen (32); + auto output = runKernel (kernel, input, 32); + + // First 8 samples: mem[i] is 0 + in[i]; next 24 samples: untouched (0) + for (int i = 0; i < 8; ++i) + EXPECT_NEAR (input[static_cast (i)], output[static_cast (i)], 1e-6f); + + for (int i = 8; i < 32; ++i) + EXPECT_NEAR (0.0f, output[static_cast (i)], 1e-6f); + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, CompilesLetBindings) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor WithLet { + input stream in; + output stream out; + process { + let a = in * 2; + let b = a + 1; + out = b * 0.5; + } + } + graph G { input stream x; output stream y; node w = WithLet; connection { x -> w.in; w.out -> y; } } + )YDSP", + "WithLet", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + auto input = makeRampCodegen (64); + auto output = runKernel (kernel, input, 64); + + for (int i = 0; i < 64; ++i) + { + const auto x = input[static_cast (i)]; + const auto expected = (x * 2.0f + 1.0f) * 0.5f; + EXPECT_NEAR (expected, output[static_cast (i)], 1e-5f); + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, CompilesComparisonOperators) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor Compare { + input stream in; + output stream out; + process { + let eq = (in == 0) ? 1 : 0; + let ne = (in != 0) ? 1 : 0; + let lt = (in < 0) ? 1 : 0; + let le = (in <= 0) ? 1 : 0; + let gt = (in > 0) ? 1 : 0; + let ge = (in >= 0) ? 1 : 0; + out = eq + ne + lt + le + gt + ge; + } + } + graph G { input stream x; output stream y; node c = Compare; connection { x -> c.in; c.out -> y; } } + )YDSP", + "Compare", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + std::vector input (8); + input[0] = -1.0f; + input[1] = -0.1f; + input[2] = 0.0f; + input[3] = 0.0f; + input[4] = 0.1f; + input[5] = 1.0f; + + auto output = runKernel (kernel, input, 8); + + for (int i = 0; i < 6; ++i) + { + const auto x = input[static_cast (i)]; + float expected = 0.0f; + if (x == 0.0f) + expected += 1.0f; // eq + if (x != 0.0f) + expected += 1.0f; // ne + if (x < 0.0f) + expected += 1.0f; // lt + if (x <= 0.0f) + expected += 1.0f; // le + if (x > 0.0f) + expected += 1.0f; // gt + if (x >= 0.0f) + expected += 1.0f; // ge + EXPECT_NEAR (expected, output[static_cast (i)], 1e-5f) << "at index " << i << " with x=" << x; + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, CompilesLogicalOperators) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor Logic { + input stream in; + output stream out; + process { + let pos = (in > 0); + let neg = (in < 0); + out = ((pos && neg) ? 1 : 0) + ((pos || neg) ? 2 : 0) + ((!pos) ? 4 : 0); + } + } + graph G { input stream x; output stream y; node l = Logic; connection { x -> l.in; l.out -> y; } } + )YDSP", + "Logic", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + std::vector input (8, 0.0f); + input[0] = 1.0f; // pos=true, neg=false -> out = 0 + 2 + 0 = 2 + input[1] = -1.0f; // pos=false, neg=true -> out = 0 + 2 + 4 = 6 + input[2] = 0.0f; // pos=false, neg=false -> out = 0 + 0 + 4 = 4 + + auto output = runKernel (kernel, input, 8); + + EXPECT_NEAR (2.0f, output[0], 1e-5f); // positive + EXPECT_NEAR (6.0f, output[1], 1e-5f); // negative + EXPECT_NEAR (4.0f, output[2], 1e-5f); // zero + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, CompilesFmodIntrinsic) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor FmodTest { input stream in; output stream out; process { out = fmod (in, 2.0); } } + graph G { input stream x; output stream y; node f = FmodTest; connection { x -> f.in; f.out -> y; } } + )YDSP", + "FmodTest", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + std::vector input (64); + for (int i = 0; i < 64; ++i) + input[static_cast (i)] = -6.0f + static_cast (i) * 0.2f; + + auto output = runKernel (kernel, input, 64); + + for (int i = 0; i < 64; ++i) + { + const auto x = input[static_cast (i)]; + const auto expected = fmodf (x, 2.0f); + EXPECT_NEAR (expected, output[static_cast (i)], 1e-4f); + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, IntDivModByRuntimeZeroDivisorReturnsZero) +{ + YdspDiagnostics diagnostics; + + // The divisor is derived from the input stream (int (in)) rather than a + // literal, so the optimizer cannot constant-fold the div/mod away - this + // exercises the actual codegen div/mod-by-zero guard at runtime. + auto kernel = compileKernel (R"YDSP( + processor IntDivMod { + input stream in; + output stream out; + process { + let n = int (in); + let q = 100 / n; + let r = 100 % n; + out = float (q) + float (r); + } + } + graph G { input stream x; output stream y; node d = IntDivMod; connection { x -> d.in; d.out -> y; } } + )YDSP", + "IntDivMod", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + std::vector input { -3.0f, 0.0f, 2.0f, 5.0f, -5.0f, 1.0f, -1.0f, 0.0f }; + auto output = runKernel (kernel, input, static_cast (input.size())); + + for (size_t i = 0; i < input.size(); ++i) + { + const auto n = static_cast (input[i]); + const auto q = n != 0 ? 100 / n : 0; + const auto r = n != 0 ? 100 % n : 0; + const auto expected = static_cast (q) + static_cast (r); + EXPECT_NEAR (expected, output[i], 1e-5f) << "at index " << i << " with n=" << n; + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, IntDivModGuardsTheOverflowingPair) +{ + YdspDiagnostics diagnostics; + + // Stream operands keep division and remainder live at runtime. + auto kernel = compileKernel (R"YDSP( + processor IntDivMod2 { + input stream num; + input stream den; + output stream quot; + output stream rem; + process { + let a = int (num); + let b = int (den); + quot = float (a / b); + rem = float (a % b); + } + } + graph G { + input stream x; input stream y; + output stream p; output stream q; + node d = IntDivMod2; + connection { x -> d.num; y -> d.den; d.quot -> p; d.rem -> q; } + } + )YDSP", + "IntDivMod2", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + ASSERT_EQ (2, kernel.numInputs); + ASSERT_EQ (2, kernel.numOutputs); + + constexpr auto intMin = std::numeric_limits::min(); + + const std::vector numerators { 100, -100, 100, -100, 100, intMin, intMin, 0 }; + const std::vector denominators { 7, 7, -7, -7, 0, -1, 1, 5 }; + + const auto numSamples = static_cast (numerators.size()); + + // runKernel lays the streams out back to back: all of `num`, then all of `den`. + std::vector input; + input.reserve (numerators.size() * 2); + + for (const auto value : numerators) + input.push_back (static_cast (value)); + + for (const auto value : denominators) + input.push_back (static_cast (value)); + + auto output = runKernel (kernel, input, numSamples); + + ASSERT_EQ (static_cast (numSamples * 2), output.size()); + + for (size_t i = 0; i < numerators.size(); ++i) + { + const auto a = numerators[i]; + const auto b = denominators[i]; + const bool defined = b != 0 && ! (a == intMin && b == -1); + + EXPECT_FLOAT_EQ (static_cast (defined ? a / b : (b == -1 ? std::numeric_limits::max() : 0)), output[i]) + << "quotient at index " << i << " with a=" << a << " b=" << b; + EXPECT_FLOAT_EQ (static_cast (defined ? a % b : 0), output[numerators.size() + i]) + << "remainder at index " << i << " with a=" << a << " b=" << b; + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, CompilesFloat64StreamsAndMath) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor F64Proc { + input stream float64 in; + output stream float64 out; + state float64 acc; + process { + acc = acc * 0.9999 + in; + out = acc; + } + } + graph G { input stream float64 x; output stream float64 y; node p = F64Proc; connection { x -> p.in; p.out -> y; } } + )YDSP", + "F64Proc", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + EXPECT_GT (kernel.stateBytes, 0u); + + // Build the full input sequence and run as a single block so state persists. + const int totalSamples = 16; + std::vector input (static_cast (totalSamples)); + for (int i = 0; i < totalSamples; ++i) + input[static_cast (i)] = 0.001 * static_cast (i) - 0.01; + + std::vector state (kernel.stateBytes, 0); + std::vector params (static_cast (kernel.numParams), 0.0); + std::vector paramsOut (static_cast (kernel.numParamsOut), 0.0); + std::vector output (static_cast (totalSamples), 0.0); + + double* inPtr = input.data(); + double* outPtr = output.data(); + + YdspKernelContext ctx; + ctx.inputs = reinterpret_cast (&inPtr); + ctx.outputs = reinterpret_cast (&outPtr); + ctx.params = params.data(); + ctx.paramOut = paramsOut.data(); + ctx.state = state.data(); + ctx.stateArrays = state.empty() ? nullptr : state.data() + kernel.stateScalarBytes; + ctx.sampleRate = 44100.0f; + ctx.numSamples = totalSamples; + + kernel.fn (&ctx); + + double acc = 0.0; + for (int i = 0; i < totalSamples; ++i) + { + acc = acc * 0.9999 + input[static_cast (i)]; + EXPECT_NEAR (acc, output[static_cast (i)], 1e-9) << "at sample " << i; + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, AdaptsFloat64LiteralWithoutRoundingThroughFloat32) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor F64LiteralProc { + input stream float64 in; + output stream float64 scaled; + output stream float64 flipped; + process { + scaled = in * 0.9999; + flipped = in * (-0.9999); + } + } + graph G { + input stream float64 x; + output stream float64 a; + output stream float64 b; + node p = F64LiteralProc; + connection { x -> p.in; p.scaled -> a; p.flipped -> b; } + } + )YDSP", + "F64LiteralProc", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + const std::vector input { 1.0, -0.5, 0.1, 3.14159265358979, -2.718281828459045 }; + const auto output = runKernel64 (kernel, input, static_cast (input.size())); + + ASSERT_EQ (input.size() * 2, output.size()); + + // The literals adapt to the float64 context, so the constants keep the double + // precision of the source values instead of being rounded through float32 first. + for (size_t i = 0; i < input.size(); ++i) + { + EXPECT_DOUBLE_EQ (input[i] * 0.9999, output[i]) << "at sample " << i; + EXPECT_DOUBLE_EQ (input[i] * -0.9999, output[input.size() + i]) << "at sample " << i; + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, KeepsFloat32ValueRoundingWhenWidenedToFloat64) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor F64WidenProc { + input stream float64 in; + output stream float64 out; + process { + float narrow = 0.1; + out = in + float64 (narrow); + } + } + graph G { input stream float64 x; output stream float64 y; node p = F64WidenProc; connection { x -> p.in; p.out -> y; } } + )YDSP", + "F64WidenProc", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + const std::vector input { 1.0, -0.5, 0.1 }; + const auto output = runKernel64 (kernel, input, static_cast (input.size())); + + ASSERT_EQ (input.size(), output.size()); + + // `narrow` is float32, so widening it must reproduce its float32 value - only a + // contextual float literal takes the precision of the surrounding expression. + const auto narrow = static_cast (static_cast (0.1)); + + for (size_t i = 0; i < input.size(); ++i) + EXPECT_DOUBLE_EQ (input[i] + narrow, output[i]) << "at sample " << i; + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, CompilesInt64ArithmeticAndConversion) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor I64Proc { + input stream in; + output stream out; + state int64 counter; + process { + counter = counter + 1000000000; + out = float32 (float64 (counter) * 0.000001); + } + } + graph G { input stream x; output stream y; node p = I64Proc; connection { x -> p.in; p.out -> y; } } + )YDSP", + "I64Proc", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + std::vector input (6, 0.0f); + auto output = runKernel (kernel, input, 6); + + // counter grows by 1e9 per sample: 1000, 2000, ... 6000 (exceeds 2^31 at + // sample 3, exercising the 64-bit addition). + for (int i = 0; i < 6; ++i) + EXPECT_NEAR (static_cast ((i + 1) * 1000.0), output[static_cast (i)], 1e-3f); + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, CompilesConversionRoundTripThroughFloat64) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor Conv { + input stream float64 in64; + output stream float64 out64; + process { + int64 j = int64 (in64); + out64 = float64 (j); + } + } + graph G { input stream float64 x; output stream float64 y; node c = Conv; connection { x -> c.in64; c.out64 -> y; } } + )YDSP", + "Conv", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + std::vector input { 3.5, -2.7, 0.0, 123456789012345.0, -42.0, 1e6 }; + auto output = runKernel64 (kernel, input, static_cast (input.size())); + + for (size_t i = 0; i < input.size(); ++i) + { + const auto j = static_cast (input[i]); + const auto expected = static_cast (j); + EXPECT_NEAR (expected, output[i], 1e-3f) << "at index " << i; + } + + dumpAsmOnFailureCodegen (kernel); +} + +TEST (YdspAsmJitCodegenTests, EmitEventFromProcessCommitsOneEntryPerSample) +{ + YdspDiagnostics diagnostics; + + auto kernel = compileKernel (R"YDSP( + processor P { + input stream in; + output stream out; + output event noteOn; + process { + emit noteOn (pitch: in) -> noteOn; + out = in; + } + } + graph G { input stream x; output stream y; output event noteOn; node p = P; connection { x -> p.in; p.out -> y; p.noteOn -> noteOn; } } + )YDSP", + "P", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, kernel.fn); + + const int numSamples = 4; + std::vector input { 10.0f, 20.0f, 30.0f, 40.0f }; + std::vector output (static_cast (numSamples), 0.0f); + std::vector state (kernel.stateBytes, 0); + std::vector params (static_cast (kernel.numParams), 0.0f); + std::vector paramsOut (static_cast (kernel.numParamsOut), 0.0f); + + float* inPtr = input.data(); + float* outPtr = output.data(); + + YdspOutputEventQueue queue; + queue.entries.reserve (static_cast (numSamples)); + + YdspKernelContext ctx; + ctx.inputs = reinterpret_cast (&inPtr); + ctx.outputs = reinterpret_cast (&outPtr); + ctx.params = params.data(); + ctx.paramOut = paramsOut.data(); + ctx.state = reinterpret_cast (state.data()); + ctx.stateArrays = state.empty() ? nullptr : state.data() + kernel.stateScalarBytes; + ctx.sampleRate = 44100.0f; + ctx.numSamples = numSamples; + ctx.outputEvents = &queue; + + kernel.fn (&ctx); + + ASSERT_EQ (static_cast (numSamples), queue.entries.size()); + + for (int i = 0; i < numSamples; ++i) + { + const auto& entry = queue.entries[static_cast (i)]; + EXPECT_EQ (i, entry.sampleOffset); + EXPECT_EQ (0, entry.endpointIndex); + EXPECT_EQ (static_cast (YdspEventShape::noteOn), entry.shapeTag); + EXPECT_FLOAT_EQ (input[static_cast (i)], entry.fields.pitch); + } + + EXPECT_EQ (0u, queue.droppedCount.load()); + + dumpAsmOnFailureCodegen (kernel); +} + +//============================================================================== +// Event-handler codegen + +namespace +{ + +/** Helper: runs the full pipeline and returns the compiled event handler. */ +struct CompiledEventHandler +{ + YdspEventHandlerFn fn = nullptr; + size_t stateBytes = 0; + size_t stateScalarBytes = 0; + int numParams = 0; +}; + +CompiledEventHandler compileEventHandlerFn (StringRef source, const char* handlerName, YdspDiagnostics& diagnostics) +{ + YdspLexer lexer (source, diagnostics); + auto tokens = lexer.tokenize(); + + YdspParser parser (std::move (tokens), diagnostics); + auto program = parser.parseProgram(); + if (program == nullptr || diagnostics.hasErrors()) + return {}; + + YdspSemanticAnalyzer analyzer (diagnostics); + auto analyzed = analyzer.analyze (std::move (program)); + if (analyzed == nullptr || diagnostics.hasErrors()) + return {}; + + YdspOptimizer optimizer (diagnostics); + auto ir = optimizer.build (*analyzed); + if (ir == nullptr || diagnostics.hasErrors()) + return {}; + + const YdspIrFunction* targetFn = nullptr; + + for (const auto& fn : ir->eventHandlers) + if (fn->name == handlerName) + { + targetFn = fn.get(); + break; + } + + if (targetFn == nullptr) + return {}; + + static asmjit::JitRuntime jitRuntime; + auto handlerFn = YdspAsmJitCodegen::compileEventHandler (jitRuntime, *targetFn, diagnostics); + + if (handlerFn == nullptr || diagnostics.hasErrors()) + return {}; + + return { handlerFn, YdspAsmJitCodegen::stateSize (*targetFn), YdspAsmJitCodegen::stateScalarSize (*targetFn), targetFn->numParams }; +} + +/** A zero-initialised event context wired to the handler's state and params, so + a payload field the test does not set reads as zero rather than as garbage. */ +YdspEventContext makeEventContext (const CompiledEventHandler& handler, + std::vector& state, + std::vector& params, + float sampleRate = 44100.0f) +{ + YdspEventContext ctx {}; + ctx.state = state.data(); + ctx.stateArrays = state.data() + handler.stateScalarBytes / sizeof (float); + ctx.params = params.data(); + ctx.sampleRate = sampleRate; + + return ctx; +} + +} // namespace + +TEST (YdspAsmJitCodegenTests, CompilesEventHandlerWritingState) +{ + YdspDiagnostics diagnostics; + + auto handler = compileEventHandlerFn (R"YDSP( + processor Voice { + output stream out; + input event midi; + state float freq; + state float env; + event midi (e: noteOn) { + freq = e.pitch * 2.0; + env = e.velocity; + } + process { out = freq; } + } + graph G { input event midi; output stream y; node v = Voice; connection { midi -> v.midi; v.out -> y; } } + )YDSP", + "Voice.noteOn", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, handler.fn); + ASSERT_GE (handler.stateBytes, 8u); // two float32 scalars + + std::vector state (handler.stateBytes / sizeof (float), 0.0f); + std::vector params (static_cast (handler.numParams), 0.0f); + + auto ctx = makeEventContext (handler, state, params, 48000.0f); + ctx.pitch = 69.0f; + ctx.velocity = 0.5f; + + handler.fn (&ctx); + + // Slot indices follow the shared (kernel) layout: freq = 0, env = 1. + EXPECT_FLOAT_EQ (69.0f * 2.0f, state[0]); + EXPECT_FLOAT_EQ (0.5f, state[1]); +} + +TEST (YdspAsmJitCodegenTests, CompilesEventHandlerReadingParam) +{ + YdspDiagnostics diagnostics; + + auto handler = compileEventHandlerFn (R"YDSP( + processor Voice { + output stream out; + input event midi; + input parameter float gain = 0.25; + state float amp; + event midi (e: noteOn) { + amp = gain * e.velocity; + } + process { out = amp; } + } + graph G { input event midi; output stream y; node v = Voice; connection { midi -> v.midi; v.out -> y; } } + )YDSP", + "Voice.noteOn", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, handler.fn); + ASSERT_EQ (1, handler.numParams); + + std::vector state (handler.stateBytes / sizeof (float), 0.0f); + std::vector params { 0.25f }; + + auto ctx = makeEventContext (handler, state, params); + ctx.pitch = 60.0f; + ctx.velocity = 0.8f; + + handler.fn (&ctx); + + EXPECT_FLOAT_EQ (0.25f * 0.8f, state[0]); +} + +TEST (YdspAsmJitCodegenTests, CompilesEventHandlerCallingFunc) +{ + YdspDiagnostics diagnostics; + + auto handler = compileEventHandlerFn (R"YDSP( + processor Voice { + output stream out; + input event midi; + state float freq; + func noteToFreq (pitch: float) : float { + return 440.0 * pow (2.0, (pitch - 69.0) / 12.0); + } + event midi (e: noteOn) { + freq = noteToFreq (e.pitch); + } + process { out = freq; } + } + graph G { input event midi; output stream y; node v = Voice; connection { midi -> v.midi; v.out -> y; } } + )YDSP", + "Voice.noteOn", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, handler.fn); + + std::vector state (handler.stateBytes / sizeof (float), 0.0f); + std::vector params (static_cast (handler.numParams), 0.0f); + + auto ctx = makeEventContext (handler, state, params); + ctx.pitch = 69.0f; + ctx.velocity = 1.0f; + + handler.fn (&ctx); + + // A4 (MIDI 69) maps to 440 Hz via the inlined func. + EXPECT_NEAR (440.0f, state[0], 1e-3f); +} + +TEST (YdspAsmJitCodegenTests, CompilesNoteOffHandler) +{ + YdspDiagnostics diagnostics; + + auto handler = compileEventHandlerFn (R"YDSP( + processor Voice { + output stream out; + input event midi; + state float env; + event midi (e: noteOn) { + env = 1.0; + } + event midi (e: noteOff) { + env = 0.0; + } + process { out = env; } + } + graph G { input event midi; output stream y; node v = Voice; connection { midi -> v.midi; v.out -> y; } } + )YDSP", + "Voice.noteOff", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, handler.fn); + + std::vector state (handler.stateBytes / sizeof (float), 0.0f); + std::vector params (static_cast (handler.numParams), 0.0f); + + auto ctx = makeEventContext (handler, state, params); + ctx.pitch = 69.0f; + ctx.velocity = 0.0f; + + handler.fn (&ctx); + + EXPECT_FLOAT_EQ (0.0f, state[0]); +} + +namespace +{ + +/** One processor declaring a handler per shape, each storing its payload into + the state slot of the same index - so the compiled handler for any shape can + be run against a hand-built context and its field read back. */ +constexpr const char* everyEventShapeSource = R"YDSP( + processor Voice { + output stream out; + + input event midi; + + state float a; + state float b; + state float c; + + event midi (e: noteOn) { + a = e.pitch; + b = e.velocity; + if (e.isLegato) { c = 1.0; } else { c = -1.0; } + } + event midi (e: noteOff) { a = e.pitch; b = e.velocity; } + event midi (e: pitchBend) { a = e.bendSemitones; } + event midi (e: pressure) { a = e.pressure; } + event midi (e: slide) { a = e.slide; } + event midi (e: controlChange) { a = float (e.control); b = e.value; } + event midi (e: programChange) { a = float (e.program); } + + process { out = a + b + c; } + } + graph G { input event midi; output stream y; node v = Voice; connection { midi -> v.midi; v.out -> y; } } +)YDSP"; + +} // namespace + +TEST (YdspAsmJitCodegenTests, EachEventShapeReadsItsOwnPayloadFields) +{ + struct Case + { + const char* handlerName; + void (*fill) (YdspEventContext&); + float expectedA; + float expectedB; + }; + + const Case cases[] = { + { "Voice.noteOff", [] (YdspEventContext& ctx) + { + ctx.pitch = 48.0f; + ctx.velocity = 0.75f; + }, + 48.0f, + 0.75f }, + { "Voice.pitchBend", [] (YdspEventContext& ctx) + { + ctx.bend = -3.5f; + }, + -3.5f, + 0.0f }, + { "Voice.pressure", [] (YdspEventContext& ctx) + { + ctx.pressure = 0.625f; + }, + 0.625f, + 0.0f }, + { "Voice.slide", [] (YdspEventContext& ctx) + { + ctx.slide = 0.125f; + }, + 0.125f, + 0.0f }, + { "Voice.controlChange", [] (YdspEventContext& ctx) + { + ctx.index = 74; + ctx.value = 0.5f; + }, + 74.0f, + 0.5f }, + { "Voice.programChange", [] (YdspEventContext& ctx) + { + ctx.index = 12; + }, + 12.0f, + 0.0f }, + }; + + for (const auto& testCase : cases) + { + YdspDiagnostics diagnostics; + + auto handler = compileEventHandlerFn (everyEventShapeSource, testCase.handlerName, diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << testCase.handlerName << ": " << diagnostics.toString(); + ASSERT_NE (nullptr, handler.fn) << testCase.handlerName; + + std::vector state (handler.stateBytes / sizeof (float), 0.0f); + std::vector params (static_cast (handler.numParams), 0.0f); + + auto ctx = makeEventContext (handler, state, params); + testCase.fill (ctx); + + handler.fn (&ctx); + + EXPECT_FLOAT_EQ (testCase.expectedA, state[0]) << testCase.handlerName; + EXPECT_FLOAT_EQ (testCase.expectedB, state[1]) << testCase.handlerName; + } +} + +TEST (YdspAsmJitCodegenTests, IsLegatoReadsBitZeroOfTheEventFlags) +{ + for (const bool legato : { false, true }) + { + YdspDiagnostics diagnostics; + + auto handler = compileEventHandlerFn (everyEventShapeSource, "Voice.noteOn", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, handler.fn); + + std::vector state (handler.stateBytes / sizeof (float), 0.0f); + std::vector params (static_cast (handler.numParams), 0.0f); + + auto ctx = makeEventContext (handler, state, params); + ctx.pitch = 60.0f; + ctx.velocity = 1.0f; + ctx.flags = legato ? ydspEventFlagLegato : 0; + + handler.fn (&ctx); + + EXPECT_FLOAT_EQ (60.0f, state[0]); + EXPECT_FLOAT_EQ (1.0f, state[1]); + EXPECT_FLOAT_EQ (legato ? 1.0f : -1.0f, state[2]) << "legato " << legato; + } +} + +// A constant-bound loop inside an event handler, filling a state array from an +// event payload field and a transcendental. This is the shape an additive +// voice's noteOn uses to seed its oscillator bank. +TEST (YdspAsmJitCodegenTests, CompilesConstantBoundLoopInsideAnEventHandler) +{ + YdspDiagnostics diagnostics; + + auto handler = compileEventHandlerFn (R"YDSP( + let partials = 4; + + processor Voice { + output stream out; + input event midi; + + state float amp[partials]; + state float mul[partials]; + + event midi (e: noteOn) { + for i in 0..partials { + let partial = float (i + 1); + amp[i] = e.velocity / partial; + mul[i] = cos (partial * 0.25); + } + } + + process { out = amp[0] * mul[0]; } + } + graph G { input event midi; output stream y; node v = Voice; connection { midi -> v.midi; v.out -> y; } } + )YDSP", + "Voice.noteOn", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, handler.fn); + + std::vector state (handler.stateBytes / sizeof (float), 0.0f); + std::vector params (1, 0.0f); // the processor declares no parameters + + auto ctx = makeEventContext (handler, state, params); + ctx.velocity = 1.0f; + + handler.fn (&ctx); + + // No scalar state is declared, so the array segment starts at the base and + // the two float arrays follow in declaration order. + const auto* amp = state.data() + handler.stateScalarBytes / sizeof (float); + const auto* mul = amp + 4; + + for (int i = 0; i < 4; ++i) + { + const auto partial = static_cast (i + 1); + + EXPECT_FLOAT_EQ (1.0f / partial, amp[i]) << "amp[" << i << "]"; + EXPECT_FLOAT_EQ (std::cos (partial * 0.25f), mul[i]) << "mul[" << i << "]"; + } +} + +//============================================================================== +// storeEventFieldF/I + emitEvent codegen + +TEST (YdspAsmJitCodegenTests, EmitEventCommitsAnOutputEventEntry) +{ + YdspDiagnostics diagnostics; + + auto handler = compileEventHandlerFn (R"YDSP( + processor Voice { + output stream out; + input event midi; + output event noteOn; + state float freq; + + event midi (e: noteOn) { + freq = e.pitch; + emit noteOn (pitch: e.pitch, velocity: e.velocity) -> noteOn; + } + + process { out = freq; } + } + graph G { input event midi; output stream y; output event noteOn; node v = Voice; connection { midi -> v.midi; v.out -> y; v.noteOn -> noteOn; } } + )YDSP", + "Voice.noteOn", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, handler.fn); + + std::vector state (handler.stateBytes / sizeof (float), 0.0f); + std::vector params (static_cast (handler.numParams), 0.0f); + + auto ctx = makeEventContext (handler, state, params); + ctx.pitch = 60.0f; + ctx.velocity = 0.8f; + ctx.sampleOffset = 12; + + YdspOutputEventQueue queue; + queue.entries.reserve (4); + ctx.outputEvents = &queue; + + handler.fn (&ctx); + + EXPECT_FLOAT_EQ (60.0f, state[0]); + + ASSERT_EQ (1u, queue.entries.size()); + const auto& entry = queue.entries[0]; + EXPECT_EQ (12, entry.sampleOffset); + EXPECT_EQ (0, entry.endpointIndex); + EXPECT_EQ (static_cast (YdspEventShape::noteOn), entry.shapeTag); + EXPECT_FLOAT_EQ (60.0f, entry.fields.pitch); + EXPECT_FLOAT_EQ (0.8f, entry.fields.velocity); + EXPECT_EQ (0u, queue.droppedCount.load()); +} + +TEST (YdspAsmJitCodegenTests, EmitEventDropsWhenTheQueueIsAtCapacity) +{ + YdspDiagnostics diagnostics; + + auto handler = compileEventHandlerFn (R"YDSP( + processor Voice { + output stream out; + input event midi; + output event noteOn; + state float freq; + + event midi (e: noteOn) { + freq = e.pitch; + emit noteOn (pitch: e.pitch, velocity: e.velocity) -> noteOn; + } + + process { out = freq; } + } + graph G { input event midi; output stream y; output event noteOn; node v = Voice; connection { midi -> v.midi; v.out -> y; v.noteOn -> noteOn; } } + )YDSP", + "Voice.noteOn", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, handler.fn); + + std::vector state (handler.stateBytes / sizeof (float), 0.0f); + std::vector params (static_cast (handler.numParams), 0.0f); + + auto ctx = makeEventContext (handler, state, params); + ctx.pitch = 60.0f; + ctx.velocity = 0.8f; + + YdspOutputEventQueue queue; // capacity 0: never reserved + ctx.outputEvents = &queue; + + handler.fn (&ctx); + + EXPECT_EQ (0u, queue.entries.size()); + EXPECT_EQ (1u, queue.droppedCount.load()); +} + +} // namespace yup::test diff --git a/tests/yup_dsp_jit/yup_YdspBenchmarkTests.cpp b/tests/yup_dsp_jit/yup_YdspBenchmarkTests.cpp new file mode 100644 index 000000000..cb869c480 --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspBenchmarkTests.cpp @@ -0,0 +1,4222 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include "yup_YdspTestPatches.h" +#include "yup_YdspAllocationCounter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if YUP_EMSCRIPTEN +#include +#endif + +using namespace yup; + +namespace yup::test +{ + +namespace +{ + +//============================================================================== +// The shapes below each isolate one codegen or optimiser property of the JIT, +// measured against a hand-written C++ routine computing the same thing: +// +// BenchmarkDelayTaps - the `@` ring-buffer wrap, lowered to the dedicated +// advanceWrapI opcode (a compare and a conditional +// subtract - no modulo and no helper call on either +// native target) +// BenchmarkLadderFilter - per-sample scalar `state` round-trips +// BenchmarkHarmonicBank - constant-heavy inner loop (float-constant +// materialisation, array addressing, register +// pressure) +// BenchmarkWaveShaper - compares and `select` inside the sample loop +// BenchmarkModalBank - work that is invariant across an inner loop but +// varies per sample, so it can only be hoisted into a +// *per-loop* preheader (per-loop code motion) +// BenchmarkWaveFolder - data-dependent if/else diamonds in the sample loop +// (if-conversion) +// +// The ratios are printed rather than asserted so the test stays informative on +// any machine and never flaky in CI; only a very loose regression guard fires. +// +// The benchmark shapes and their constants below are deliberately file-local +// (and named apart from the other suites' helpers): the tests directory is +// compiled as a unity build, so a shared name would collide or silently bind +// to another file's definition. compilePatch is the exception - it lives in +// namespace yup::test::patches (see yup_YdspTestPatches.h) for exactly that +// reason, and is pulled in below rather than duplicated here. + +constexpr double benchmarkSampleRate = 48000.0; +constexpr int benchmarkBlockSize = 512; +constexpr int benchmarkBlockCount = 200; // ~2.1 s of audio per timed pass +constexpr int benchmarkRepeats = 5; +constexpr double benchmarkRatioLimit = 10.0; + +// A shape that sits well under the general limit carries its own ceiling, so the +// guard catches it getting *worse* rather than only catching a collapse. +// +// The mode bank came down in four steps: 11.7x before +// per-loop invariant code motion, 7.5x before the vectoriser widened its 16 +// modes the way the reference is compiled, 2.2x before the unroller wrote the +// four widened iterations out straight, 1.24x before the accumulator was +// halved. Shape 7 sizes what is left of the reduction at ~1.0-1.5 ns/sample of +// the 7.4-8.3 total - a range rather than a figure, because that is what two +// runs of it gave. +// +// 2.0 is deliberately close now that unrolling also took the run-to-run spread +// from ~30% to under 8%. It fails if the widening or the unroll stops firing - +// rolled alone was 2.2x. Losing only the accumulator split would read ~1.24x +// and slip past this; the ratio printed by shape 7 is what watches that. +constexpr double benchmarkModalBankLimit = 2.0; + +// The transcendental shapes below measure a scalar-libm baseline that the SLEEF +// migration (Phase 2) is expected to pull far down, so their guard is looser +// than the modal bank's until that lands. The printed ratio is the record. +constexpr double benchmarkTranscendentalLimit = 25.0; + +// Voice skipping is the one ratio here that is not a constant of the codegen: +// it measures how much of a block is per-voice work at all, and that share +// moves with the target. Only a voice's own sample loop can be widened, so on +// x86-64 release - where those loops are - the fixed per-block graph cost is +// worth about twelve voices and the ratio lands at ~0.50. The guard therefore +// has to sit well above that: 0.75 still catches the skipping collapsing (no +// skipping at all reads ~1.0) without asserting a speedup the target is free to +// spend on the fixed half of the block instead. +constexpr double benchmarkVoiceSkippingLimit = 0.75; + +constexpr int benchmarkTotalSamples = benchmarkBlockSize * benchmarkBlockCount; + +//============================================================================== + +using patches::compilePatch; + +std::vector benchmarkNoise (int size) +{ + std::vector data (static_cast (size)); + + uint32_t seed = 0x13579bdfu; + + for (auto& sample : data) + { + seed = seed * 1664525u + 1013904223u; + sample = static_cast (static_cast (seed >> 8) & 0xffff) / 32768.0f - 1.0f; + } + + return data; +} + +struct BenchmarkTiming +{ + double best = std::numeric_limits::max(); + double worst = std::numeric_limits::lowest(); + double average = 0.0; +}; + +template +BenchmarkTiming benchmarkTimeRepeats (Fn&& fn) +{ + fn(); + + BenchmarkTiming timing; + int completed = 0; + + for (int r = 0; r < benchmarkRepeats; ++r) + { + const auto start = std::chrono::steady_clock::now(); + fn(); + const auto seconds = std::chrono::duration (std::chrono::steady_clock::now() - start).count(); + + timing.best = std::min (timing.best, seconds); + timing.worst = std::max (timing.worst, seconds); + + ++completed; + timing.average += (seconds - timing.average) / static_cast (completed); + } + + return timing; +} + +constexpr int benchmarkColumnWidth = 24; + +String benchmarkLine (StringRef label, StringRef best, StringRef worst, StringRef average) +{ + const auto cell = [] (StringRef text) + { + return " | " + String (text).paddedLeft (' ', benchmarkColumnWidth); + }; + + String output; + output << " | "; + output << String (label).paddedRight (' ', benchmarkColumnWidth); + output << cell (best) << cell (worst) << cell (average); + output << " |"; + return output; +} + +void benchmarkReport (const char* name, const BenchmarkTiming& jit, const BenchmarkTiming& native) +{ + const auto perSample = [] (double seconds) + { + return String (seconds * 1.0e9 / static_cast (benchmarkTotalSamples), 3); + }; + + const auto ratio = [] (double jitSeconds, double nativeSeconds) + { + return String (jitSeconds / nativeSeconds, 3); + }; + + const auto rule = String::repeatedString ("-", benchmarkColumnWidth); + const auto value = [&] (double seconds, double nativeSeconds) + { + return perSample (seconds) + " (" + ratio (seconds, nativeSeconds) + "x)"; + }; + std::cout << "\n |==== BENCH ==== (" << name << ")\n" + << benchmarkLine ("ns/sample", "best", "worst", "avg") << "\n" + << benchmarkLine (rule, rule, rule, rule) << "\n" + << benchmarkLine ("jit", value (jit.best, native.best), value (jit.worst, native.worst), value (jit.average, native.average)) << "\n" + << benchmarkLine ("c++", perSample (native.best), perSample (native.worst), perSample (native.average)) << "\n"; +} + +void benchmarkRunGraph (YdspAudioGraph& graph, const std::vector& input, std::vector& output, int samplesPerBlock = benchmarkBlockSize) +{ + const auto hasInput = graph.getInputStreamCount() > 0; + const auto blockSize = static_cast (samplesPerBlock); + + std::vector inputs; + std::vector outputs; + + if (hasInput) + inputs.emplace_back (Span (input.data(), blockSize)); + + outputs.emplace_back (Span (output.data(), blockSize)); + + for (int block = 0; block < benchmarkTotalSamples / samplesPerBlock; ++block) + { + const auto offset = static_cast (block) * blockSize; + + if (hasInput) + inputs[0] = Span (input.data() + offset, blockSize); + + outputs[0] = Span (output.data() + offset, blockSize); + + graph.process (yup::YdspProcessRequest { inputs, outputs, samplesPerBlock }); + } +} + +/** Runs a 1-in/2-out graph for the standard benchmark length. */ +void benchmarkRunSplitGraph (YdspAudioGraph& graph, + const std::vector& input, + std::vector& outputA, + std::vector& outputB) +{ + const auto blockSize = static_cast (benchmarkBlockSize); + + std::vector inputs { YdspInputBuffer (Span (input.data(), blockSize)) }; + std::vector outputs { + YdspOutputBuffer (Span (outputA.data(), blockSize)), + YdspOutputBuffer (Span (outputB.data(), blockSize)) + }; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block) * blockSize; + + inputs[0] = Span (input.data() + offset, blockSize); + outputs[0] = Span (outputA.data() + offset, blockSize); + outputs[1] = Span (outputB.data() + offset, blockSize); + + graph.process (yup::YdspProcessRequest { inputs, outputs, benchmarkBlockSize }); + } +} + +double benchmarkMagnitude (const std::vector& data) +{ + double sum = 0.0; + + for (const auto sample : data) + sum += std::fabs (static_cast (sample)); + + return sum; +} + +double benchmarkChecksum (const std::vector& data) +{ + double sum = 0.0; + + for (const auto sample : data) + sum += static_cast (sample); + + return sum; +} + +//============================================================================== +/** What the generated listing says about stack traffic and calls. + + Added to test whether spilling around the per-sample `exp` call explained + shape 7's readings. It did not - 0 vector spills either way - which is what + sent that investigation to the loop instead. Kept because it is the only + view here of what an instruction count leaves out. +*/ +struct BenchmarkListingStats +{ + int lines = 0; // emitted lines, a rough stand-in for code size + int stackAccesses = 0; // anything addressing the stack pointer + int vectorSpills = 0; // ... of which move a vector register + int calls = 0; // call/blr sites of any kind + int mathCalls = 0; // register-indirect call/blr sites + int otherCalls = 0; // direct call sites (immediate/label target) +}; + +/** Counts the above over a compiled graph's AsmJit listing. + + Only differences between two kernels mean anything here: the prologue's own + stack traffic is counted too, and the line count includes labels the + assembler never emits. + + The `mathCalls` bucket is what makes the transcendental work observable: a + libm transcendental is lowered as a call through a register whose address + was just materialised (`mov , Imm` + `call `, or `blr` on AArch64), + so the register-indirect sites are exactly the per-sample math calls in the + shapes below. A register-indirect call to a non-math helper (the `@` + modulo helper on x86-64, event emission) lands here too, which is why the + shapes that want a clean math count keep their loops free of those. +*/ +BenchmarkListingStats benchmarkAnalyzeListing (const YdspAudioGraph& graph) +{ + const auto isGpRegister = [] (const String& token) + { + static constexpr const char* registers[] = { "rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp", + "r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15" }; + + for (const auto* reg : registers) + if (token == String (reg)) + return true; + + return false; + }; + + BenchmarkListingStats stats; + + for (const auto& line : StringArray::fromLines (graph.getDiagnostics().toString())) + { + const auto text = line.trim(); + + if (text.isEmpty()) + continue; + + ++stats.lines; + + if (text.contains ("[sp") || text.contains ("[rsp") || text.contains ("[esp")) + { + ++stats.stackAccesses; + + if (text.contains (" q") || text.contains ("xmm")) + ++stats.vectorSpills; + } + + const auto isBlr = text.contains ("blr"); + const auto isCall = text.contains ("call"); + + if (isBlr || isCall) + { + ++stats.calls; + + // Operand token following the mnemonic (machine-code bytes can + // precede the instruction on the same line, so skip them). + const auto mnemonicLength = isBlr ? 3 : 4; + const auto mnemonicIndex = isBlr ? text.indexOf ("blr") : text.indexOf ("call"); + auto target = text.substring (mnemonicIndex + mnemonicLength).trim().upToFirstOccurrenceOf (" ", false, false); + + if (isBlr || isGpRegister (target.trim())) + ++stats.mathCalls; + else + ++stats.otherCalls; + } + } + + return stats; +} + +/** Prints one listing's counts as a table row. */ +void benchmarkReportListing (const String& label, const BenchmarkListingStats& stats) +{ + std::cout << " " << label.paddedRight (' ', 9) << ": " + << stats.lines << " lines, " + << stats.stackAccesses << " stack, " + << stats.vectorSpills << " vector spills, " + << stats.mathCalls << " math calls, " + << stats.otherCalls << " other calls\n"; +} + +//============================================================================== +// Shape 1: three `@` delay taps. Isolates the per-sample ring wrap. + +constexpr auto benchmarkDelaySource = R"YDSP( + processor DelayTaps { + input stream in; + output stream out; + + process { + let d1 = in @ 127; + let d2 = in @ 251; + let d3 = in @ 509; + + out = in * 0.5 + d1 * 0.25 + d2 * 0.15 + d3 * 0.1; + } + } + + graph G { + input stream x; + output stream y; + + node d = DelayTaps; + + connection { x -> d.in; d.out -> y; } + } +)YDSP"; + +class BenchmarkNativeDelayTaps +{ +public: + BenchmarkNativeDelayTaps() + { + ring1.assign (n1 + 1, 0.0f); + ring2.assign (n2 + 1, 0.0f); + ring3.assign (n3 + 1, 0.0f); + } + + void process (const float* in, float* out, int numSamples) + { + for (int i = 0; i < numSamples; ++i) + { + const auto x = in[i]; + + ring1[static_cast (w1)] = x; + if (++w1 > n1) + w1 = 0; + const auto d1 = ring1[static_cast (w1)]; + + ring2[static_cast (w2)] = x; + if (++w2 > n2) + w2 = 0; + const auto d2 = ring2[static_cast (w2)]; + + ring3[static_cast (w3)] = x; + if (++w3 > n3) + w3 = 0; + const auto d3 = ring3[static_cast (w3)]; + + out[i] = x * 0.5f + d1 * 0.25f + d2 * 0.15f + d3 * 0.1f; + } + } + +private: + static constexpr int n1 = 127; + static constexpr int n2 = 251; + static constexpr int n3 = 509; + + std::vector ring1, ring2, ring3; + int w1 = 0, w2 = 0, w3 = 0; +}; + +constexpr auto benchmarkBiquadSource = R"YDSP( + processor Biquad { + input stream in; + output stream out; + state float x1; + state float x2; + state float y1; + state float y2; + process { + let y = 0.206572 * in + 0.413144 * x1 + 0.206572 * x2 + + 0.369527 * y1 - 0.195816 * y2; + x2 = x1; + x1 = in; + y2 = y1; + y1 = y; + out = y; + } + } + graph G { + input stream x; + output stream y; + node b = Biquad; + connection { x -> b.in; b.out -> y; } + } +)YDSP"; + +class BenchmarkNativeBiquad +{ +public: + void process (const float* in, float* out, int count) + { + for (int i = 0; i < count; ++i) + { + const auto x = in[i]; + const auto y = 0.206572f * x + 0.413144f * x1 + 0.206572f * x2 + + 0.369527f * y1 - 0.195816f * y2; + x2 = x1; + x1 = x; + y2 = y1; + y1 = y; + out[i] = y; + } + } + +private: + float x1 = 0.0f, x2 = 0.0f, y1 = 0.0f, y2 = 0.0f; +}; + +//============================================================================== +// Shape 2: a four-pole ladder. Isolates the per-sample scalar state traffic. + +constexpr auto benchmarkLadderSource = R"YDSP( + processor Ladder { + input stream in; + output stream out; + + input parameter float cutoff = 0.25; + input parameter float resonance = 0.5; + + state float z1; + state float z2; + state float z3; + state float z4; + + process { + let g = clamp (cutoff, 0.01, 0.99); + let fb = resonance * 3.8; + + let x = in - fb * z4; + + z1 = z1 + g * (x - z1); + z2 = z2 + g * (z1 - z2); + z3 = z3 + g * (z2 - z3); + z4 = z4 + g * (z3 - z4); + + out = z4; + } + } + + graph G { + input stream x; + output stream y; + + node f = Ladder; + + connection { x -> f.in; f.out -> y; } + } +)YDSP"; + +constexpr auto benchmarkLadderFusedSource = R"YDSP( + processor Ladder { + input stream in; + output stream out; + + input parameter float cutoff = 0.25; + input parameter float resonance = 0.5; + + state float z1; + state float z2; + state float z3; + state float z4; + + process { + let g = clamp (cutoff, 0.01, 0.99); + let fb = resonance * 3.8; + + let x = in - fb * z4; + + z1 = fma (g, x - z1, z1); + z2 = fma (g, z1 - z2, z2); + z3 = fma (g, z2 - z3, z3); + z4 = fma (g, z3 - z4, z4); + + out = z4; + } + } + + graph G { + input stream x; + output stream y; + + node f = Ladder; + + connection { x -> f.in; f.out -> y; } + } +)YDSP"; + +class BenchmarkNativeLadder +{ +public: + void process (const float* in, float* out, int numSamples) + { + const auto g = std::min (std::max (cutoff, 0.01f), 0.99f); + const auto fb = resonance * 3.8f; + + for (int i = 0; i < numSamples; ++i) + { + const auto x = in[i] - fb * z4; + + z1 = z1 + g * (x - z1); + z2 = z2 + g * (z1 - z2); + z3 = z3 + g * (z2 - z3); + z4 = z4 + g * (z3 - z4); + + out[i] = z4; + } + } + +private: + float cutoff = 0.25f; + float resonance = 0.5f; + + float z1 = 0.0f, z2 = 0.0f, z3 = 0.0f, z4 = 0.0f; +}; + +class BenchmarkNativeLadderUncontracted +{ +public: + void process (const float* in, float* out, int numSamples) + { +#if defined(__clang__) +#pragma clang fp contract(off) +#endif + + const auto g = std::min (std::max (cutoff, 0.01f), 0.99f); + const auto fb = resonance * 3.8f; + + for (int i = 0; i < numSamples; ++i) + { + const auto x = in[i] - fb * z4; + + z1 = z1 + g * (x - z1); + z2 = z2 + g * (z1 - z2); + z3 = z3 + g * (z2 - z3); + z4 = z4 + g * (z3 - z4); + + out[i] = z4; + } + } + +private: + float cutoff = 0.25f; + float resonance = 0.5f; + + float z1 = 0.0f, z2 = 0.0f, z3 = 0.0f, z4 = 0.0f; +}; + +//============================================================================== +// Shape 3: a 32-partial rotating-phasor bank - the shape of the shipped +// ElectricPiano voice's inner loop, without the MIDI/voice machinery, so a +// like-for-like C++ reference stays readable. + +constexpr auto benchmarkBankSource = R"YDSP( + let partials = 32; + + processor Bank { + output stream out; + + state float oscR[partials]; + state float oscI[partials]; + state float mulR[partials]; + state float mulI[partials]; + state float amp[partials]; + + init { + for i in 0..partials { + let w = 2.0 * pi * 110.0 * float (i + 1) * samplePeriod; + + mulR[i] = cos (w); + mulI[i] = sin (w); + oscR[i] = 1.0; + oscI[i] = 0.0; + amp[i] = 0.5 / float (i + 1); + } + } + + process { + float sum = 0.0; + + for i in 0..partials { + let rotated = oscR[i] * mulR[i] - oscI[i] * mulI[i]; + + oscI[i] = oscR[i] * mulI[i] + oscI[i] * mulR[i]; + oscR[i] = rotated; + + sum = sum + oscI[i] * amp[i]; + } + + out = sum; + } + } + + graph G { + output stream y; + + node b = Bank; + + connection { b.out -> y; } + } +)YDSP"; + +class BenchmarkNativeBank +{ +public: + explicit BenchmarkNativeBank (double sampleRate) + { + const auto samplePeriod = static_cast (1.0 / sampleRate); + + for (int i = 0; i < partials; ++i) + { + const auto w = 2.0f * 3.14159265358979323846f * 110.0f * static_cast (i + 1) * samplePeriod; + + mulR[i] = std::cos (w); + mulI[i] = std::sin (w); + oscR[i] = 1.0f; + oscI[i] = 0.0f; + amp[i] = 0.5f / static_cast (i + 1); + } + } + + void process (float* out, int numSamples) + { + for (int s = 0; s < numSamples; ++s) + { + auto sum = 0.0f; + + for (int i = 0; i < partials; ++i) + { + const auto rotated = oscR[i] * mulR[i] - oscI[i] * mulI[i]; + + oscI[i] = oscR[i] * mulI[i] + oscI[i] * mulR[i]; + oscR[i] = rotated; + + sum += oscI[i] * amp[i]; + } + + out[s] = sum; + } + } + +private: + static constexpr int partials = 32; + + float oscR[partials] {}, oscI[partials] {}, mulR[partials] {}, mulI[partials] {}, amp[partials] {}; +}; + +//============================================================================== +// Shape 3b: an oscillator bank whose phase advance is a per-sample `sin()` +// inside the loop - the transcendental the vectorizer refuses to widen, so the +// whole loop stays scalar today. The phase is left unwrapped on purpose: the +// reference and the JIT feed the same float phase to the same libm `sinf`, so +// an exact mirror needs no wrap logic. + +constexpr auto benchmarkSineBankSource = R"YDSP( + let sines = 8; + + processor SineBank { + output stream out; + + state float phase[sines]; + state float freq[sines]; + state float amp[sines]; + + init { + for i in 0..sines { + let w = 2.0 * pi * 220.0 * float (i + 1) * samplePeriod; + + phase[i] = 0.0; + freq[i] = w; + amp[i] = 0.5 / float (i + 1); + } + } + + process { + float sum = 0.0; + + for i in 0..sines { + phase[i] = phase[i] + freq[i]; + sum = sum + sin (phase[i]) * amp[i]; + } + + out = sum; + } + } + + graph G { + output stream y; + + node b = SineBank; + + connection { b.out -> y; } + } +)YDSP"; + +class BenchmarkNativeSineBank +{ +public: + explicit BenchmarkNativeSineBank (double sampleRate) + { + const auto samplePeriod = static_cast (1.0 / sampleRate); + + for (int i = 0; i < sines; ++i) + { + const auto w = 2.0f * 3.14159265358979323846f * 220.0f * static_cast (i + 1) * samplePeriod; + + phase[i] = 0.0f; + freq[i] = w; + amp[i] = 0.5f / static_cast (i + 1); + } + } + + void process (float* out, int numSamples) + { + for (int s = 0; s < numSamples; ++s) + { + auto sum = 0.0f; + + for (int i = 0; i < sines; ++i) + { + phase[i] += freq[i]; + sum += std::sin (phase[i]) * amp[i]; + } + + out[s] = sum; + } + } + +private: + static constexpr int sines = 8; + + float phase[sines] {}, freq[sines] {}, amp[sines] {}; +}; + +//============================================================================== +// Shape 3c: a scalar per-sample `tanh()` waveshaper with a stateful drive +// envelope. Measures the per-sample scalar transcendental path (libm) under +// fastMath contraction. + +constexpr auto benchmarkTanhShaperSource = R"YDSP( + processor TanhShaper { + input stream in; + output stream out; + + state float env; + + process { + env = env * 0.999 + abs (in) * 0.001; + + out = tanh (in * (1.0 + env * 4.0)); + } + } + + graph G { + input stream x; + output stream y; + + node t = TanhShaper; + + connection { x -> t.in; t.out -> y; } + } +)YDSP"; + +class BenchmarkNativeTanhShaper +{ +public: + void process (const float* in, float* out, int numSamples) + { + for (int i = 0; i < numSamples; ++i) + { + const auto x = in[i]; + + env = env * 0.999f + std::fabs (x) * 0.001f; + + out[i] = std::tanh (x * (1.0f + env * 4.0f)); + } + } + +private: + float env = 0.0f; +}; + +//============================================================================== +// Shape 3d: a scalar one-pole envelope whose coefficient is an `exp()` of the +// (per-sample) input, so the call cannot be hoisted. This is the `exp` behind +// `smooth` named in the ARM64 x30/LR comment. + +constexpr auto benchmarkExpEnvelopeSource = R"YDSP( + processor ExpEnvelope { + input stream in; + output stream out; + + state float env; + + process { + env = env * exp (-0.001 * (1.0 + abs (in))) + in * 0.001; + + out = env; + } + } + + graph G { + input stream x; + output stream y; + + node e = ExpEnvelope; + + connection { x -> e.in; e.out -> y; } + } +)YDSP"; + +class BenchmarkNativeExpEnvelope +{ +public: + void process (const float* in, float* out, int numSamples) + { + for (int i = 0; i < numSamples; ++i) + { + const auto x = in[i]; + + env = env * std::exp (-0.001f * (1.0f + std::fabs (x))) + x * 0.001f; + + out[i] = env; + } + } + +private: + float env = 0.0f; +}; + +//============================================================================== +// Shape 4: compares plus `select` in the sample loop. A compare that only feeds +// a branch is folded into it; anything else lowers to `fcmp` + `cset` and the +// select to `fcsel`, so the shape measures branchless comparison throughput. + +constexpr auto benchmarkShaperSource = R"YDSP( + processor Shaper { + input stream in; + output stream out; + + state float env; + + process { + let a = abs (in); + + env = select (a > env, a, env * 0.9995); + + let hi = select (in > 0.7, 0.7, in); + let lo = select (hi < -0.7, -0.7, hi); + + out = lo * (1.0 - env * 0.5); + } + } + + graph G { + input stream x; + output stream y; + + node s = Shaper; + + connection { x -> s.in; s.out -> y; } + } +)YDSP"; + +class BenchmarkNativeShaper +{ +public: + void process (const float* in, float* out, int numSamples) + { + for (int i = 0; i < numSamples; ++i) + { + const auto x = in[i]; + const auto a = std::fabs (x); + + env = a > env ? a : env * 0.9995f; + + const auto hi = x > 0.7f ? 0.7f : x; + const auto lo = hi < -0.7f ? -0.7f : hi; + + out[i] = lo * (1.0f - env * 0.5f); + } + } + +private: + float env = 0.0f; +}; + +class BenchmarkNativeShaperUncontracted +{ +public: + void process (const float* in, float* out, int numSamples) + { +#if defined(__clang__) +#pragma clang fp contract(off) +#endif + + for (int i = 0; i < numSamples; ++i) + { + const auto x = in[i]; + const auto a = std::fabs (x); + + env = a > env ? a : env * 0.9995f; + + const auto hi = x > 0.7f ? 0.7f : x; + const auto lo = hi < -0.7f ? -0.7f : hi; + + out[i] = lo * (1.0f - env * 0.5f); + } + } + +private: + float env = 0.0f; +}; + +//============================================================================== +// Shape 5: a modal bank whose per-sample drive term is invariant across the +// inner loop, and whose 16 modes are independent. + +constexpr auto benchmarkModalSource = R"YDSP( + let modes = 16; + + processor Modal { + input stream in; + output stream out; + + input parameter float damping = 0.5; + + state float z[modes]; + state float env; + + process { + env = env * 0.999 + abs (in) * 0.001; + + float sum = 0.0; + + for i in 0..modes { + let drive = exp (-env * damping) * (1.0 - damping); + + z[i] = z[i] * 0.9 + in * drive; + sum = sum + z[i]; + } + + out = sum; + } + } + + graph G { + input stream x; + output stream y; + + node m = Modal; + + connection { x -> m.in; m.out -> y; } + } +)YDSP"; + +class BenchmarkNativeModal +{ +public: + void process (const float* in, float* out, int numSamples) + { + for (int s = 0; s < numSamples; ++s) + { + const auto x = in[s]; + + env = env * 0.999f + std::fabs (x) * 0.001f; + + const auto drive = std::exp (-env * damping) * (1.0f - damping); + + auto sum = 0.0f; + + for (int i = 0; i < modes; ++i) + { + z[i] = z[i] * 0.9f + x * drive; + sum += z[i]; + } + + out[s] = sum; + } + } + +private: + static constexpr int modes = 16; + + float damping = 0.5f; + float env = 0.0f; + float z[modes] {}; +}; + +//============================================================================== +// Shape 6: a wavefolder with two data-dependent if/else diamonds per sample. +// Both sides turn into conditional moves - the compiler by itself, the JIT via +// ifConversion() plus the backend's `fcsel` lowering of `select`. + +constexpr auto benchmarkFolderSource = R"YDSP( + processor Folder { + input stream in; + output stream out; + + state float last; + + process { + float y = in * 3.0; + + if (y > 1.0) { y = 2.0 - y; } + if (y < -1.0) { y = -2.0 - y; } + + last = last * 0.5 + y * 0.5; + + out = last; + } + } + + graph G { + input stream x; + output stream y; + + node f = Folder; + + connection { x -> f.in; f.out -> y; } + } +)YDSP"; + +constexpr auto benchmarkFolderFusedSource = R"YDSP( + processor Folder { + input stream in; + output stream out; + + state float last; + + process { + float y = in * 3.0; + + if (y > 1.0) { y = 2.0 - y; } + if (y < -1.0) { y = -2.0 - y; } + + last = fma (last, 0.5, y * 0.5); + + out = last; + } + } + + graph G { + input stream x; + output stream y; + + node f = Folder; + + connection { x -> f.in; f.out -> y; } + } +)YDSP"; + +class BenchmarkNativeFolder +{ +public: + void process (const float* in, float* out, int numSamples) + { + for (int i = 0; i < numSamples; ++i) + { + auto y = in[i] * 3.0f; + + if (y > 1.0f) + y = 2.0f - y; + + if (y < -1.0f) + y = -2.0f - y; + + last = last * 0.5f + y * 0.5f; + + out[i] = last; + } + } + +private: + float last = 0.0f; +}; + +class BenchmarkNativeFolderUncontracted +{ +public: + void process (const float* in, float* out, int numSamples) + { +#if defined(__clang__) +#pragma clang fp contract(off) +#endif + + for (int i = 0; i < numSamples; ++i) + { + auto y = in[i] * 3.0f; + + if (y > 1.0f) + y = 2.0f - y; + + if (y < -1.0f) + y = -2.0f - y; + + last = last * 0.5f + y * 0.5f; + + out[i] = last; + } + } + +private: + float last = 0.0f; +}; + +constexpr bool benchmarkCanDisableFpContraction = +#if defined(__clang__) + true; +#else + false; +#endif + +//============================================================================== +// Shape 7: the modal bank with its accumulation removed. Not a JIT-vs-native +// comparison - it is shape 5 measured against itself, so that the *delta* +// isolates one thing. + +constexpr auto benchmarkModalNoSumSource = R"YDSP( + let modes = 16; + + processor Modal { + input stream in; + output stream out; + + input parameter float damping = 0.5; + + state float z[modes]; + state float env; + + process { + env = env * 0.999 + abs (in) * 0.001; + + for i in 0..modes { + let drive = exp (-env * damping) * (1.0 - damping); + + z[i] = z[i] * 0.9 + in * drive; + } + + out = env; + } + } + + graph G { + input stream x; + output stream y; + + node m = Modal; + + connection { x -> m.in; m.out -> y; } + } +)YDSP"; + +//============================================================================== +// Shape 8: three chained nodes against one processor computing the same thing. +// A JIT-vs-JIT comparison, and originally the shape the fusion item was missing: +// its win had been asserted from first principles, never measured. + +constexpr auto benchmarkChainedSource = R"YDSP( + processor Shape { + input stream in; + output stream out; + + state float phase; + + process { + phase = phase + 0.01; + + if (phase >= 1.0) { phase = phase - 1.0; } + + out = in * (phase * 2.0 - 1.0); + } + } + + processor Filter { + input stream in; + output stream out; + + state float z; + + process { + z = z * 0.8 + in * 0.2; + out = z; + } + } + + processor Trim { + input stream in; + output stream out; + + process { out = in * 0.5; } + } + + graph G { + input stream x; + output stream y; + + process = x : Shape : Filter : Trim : y; + } +)YDSP"; + +constexpr auto benchmarkFusedSource = R"YDSP( + processor Fused { + input stream in; + output stream out; + + state float phase; + state float z; + + process { + phase = phase + 0.01; + + if (phase >= 1.0) { phase = phase - 1.0; } + + let shaped = in * (phase * 2.0 - 1.0); + + z = z * 0.8 + shaped * 0.2; + + out = z * 0.5; + } + } + + graph G { + input stream x; + output stream y; + + node f = Fused; + + connection { x -> f.in; f.out -> y; } + } +)YDSP"; + +//============================================================================== +// Graph-level dry/wet vs. dry/wet hand-rolled inside one processor. + +constexpr auto benchmarkInlineDryWetSource = R"YDSP( + processor InlineDryWet { + input stream in; + output stream out; + + input parameter float mix = 0.35; + + process { + let shaped = tanh (in * 3.0); + out = (1.0 - mix) * in + mix * shaped; + } + } + + graph G { + input stream x; + output stream y; + + node d = InlineDryWet; + + connection { x -> d.in; d.out -> y; } + } +)YDSP"; + +constexpr auto benchmarkFannedDryWetSource = R"YDSP( + processor DryTrim { + input stream in; + output stream out; + + input parameter float mix = 0.35; + + process { out = (1.0 - mix) * in; } + } + + processor WetShaper { + input stream in; + output stream out; + + input parameter float mix = 0.35; + + process { out = mix * tanh (in * 3.0); } + } + + graph G { + input stream x; + output stream y; + + node dry = DryTrim; + node wet = WetShaper; + + connection { + x -> dry.in; + x -> wet.in; + dry.out -> y; + wet.out -> y; + } + } +)YDSP"; + +constexpr auto benchmarkSplitDryWetSource = R"YDSP( + processor DryTrim { + input stream in; + output stream out; + + input parameter float mix = 0.35; + + process { out = (1.0 - mix) * in; } + } + + processor WetShaper { + input stream in; + output stream out; + + input parameter float mix = 0.35; + + process { out = mix * tanh (in * 3.0); } + } + + graph G { + input stream x; + output stream dryOut; + output stream wetOut; + + node dry = DryTrim; + node wet = WetShaper; + + connection { + x -> dry.in; + x -> wet.in; + dry.out -> dryOut; + wet.out -> wetOut; + } + } +)YDSP"; + +//============================================================================== +// Idle-voice skipping. + +/** Prints a two-variant table (the jit/c++ one above does not fit here). */ +void benchmarkReportVariants (const char* name, + const char* labelA, + const BenchmarkTiming& a, + const char* labelB, + const BenchmarkTiming& b) +{ + const auto perSample = [] (double seconds) + { + return String (seconds * 1.0e9 / static_cast (benchmarkTotalSamples), 3); + }; + + const auto ratio = [] (double lhs, double rhs) + { + return String (lhs / rhs, 3); + }; + + const auto rule = String::repeatedString ("-", benchmarkColumnWidth); + + const auto value = [] (double seconds) + { + return String (seconds * 1.0e9 / static_cast (benchmarkTotalSamples), 3); + }; + + std::cout << "\n |==== BENCH ==== (" << name << ")\n" + << benchmarkLine ("ns/sample", "best", "worst", "avg") << "\n" + << benchmarkLine (rule, rule, rule, rule) << "\n" + << benchmarkLine (labelA, value (a.best), value (a.worst), value (a.average)) << "\n" + << benchmarkLine (labelB, value (b.best), value (b.worst), value (b.average)) << "\n" + << benchmarkLine ("ratio", ratio (a.best, b.best), ratio (a.worst, b.worst), ratio (a.average, b.average)) << "\n"; +} + +void benchmarkReportPolicies (const char* name, + const std::array& labels, + const std::array& timings, + const BenchmarkTiming& native, + double limit = benchmarkRatioLimit) +{ + const auto perSample = [] (double seconds) + { + return String (seconds * 1.0e9 / static_cast (benchmarkTotalSamples), 3); + }; + + const auto ratio = [] (double lhs, double rhs) + { + return String (lhs / rhs, 3); + }; + + const auto value = [&] (double seconds, double nativeSeconds) + { + return perSample (seconds) + " (" + ratio (seconds, nativeSeconds) + "x)"; + }; + + const auto rule = String::repeatedString ("-", benchmarkColumnWidth); + std::cout << "\n |==== BENCH ==== (" << name << ")\n" + << benchmarkLine ("ns/sample", "best", "worst", "avg") << "\n" + << benchmarkLine (rule, rule, rule, rule) << "\n"; + + for (size_t i = 0; i < labels.size(); ++i) + std::cout << benchmarkLine (labels[i], + value (timings[i].best, native.best), + value (timings[i].worst, native.worst), + value (timings[i].average, native.average)) << "\n"; + + std::cout << benchmarkLine ("c++", perSample (native.best), perSample (native.worst), perSample (native.average)) << "\n"; + + if (timings.back().best / native.best > limit + && SystemStats::getEnvironmentVariable ("ACTION_RUNNER", {}) == "github-actions") + { + std::cerr << " WARNING - " << name << ": JIT kernel is more than " + << limit << "x slower than the equivalent compiled routine\n"; + } + else + { + EXPECT_LT (timings.back().best / native.best, limit); + } +} + +/** Says whether the contraction pragma changed a reference's output. + + Read this carefully, because "did NOT change" does *not* mean "contraction + did not happen". Contraction removes a rounding, so it can only change the + result when that rounding was doing something - and multiplying a float by + an exact power of two rounds to itself. The wave folder's + `last * 0.5f + y * 0.5f` is therefore bit-identical fused or not, and so is + the shaper's `1.0f - env * 0.5f`, while the ladder's `z + g * (x - z)` (with + `g = 0.25`... times a non-exact `x - z`) is not. +*/ +void benchmarkReportContraction (const char* name, + const std::vector& contracted, + const std::vector& uncontracted) +{ + const auto changed = benchmarkChecksum (contracted) != benchmarkChecksum (uncontracted); + + std::cout << " contraction pragma (" << name << "): " + << (benchmarkCanDisableFpContraction ? "supported" : "UNSUPPORTED on this compiler") + << ", and it " << (changed ? "changed" : "did NOT change") << " the reference output\n"; +} + +/** Runs a stereo, MIDI-driven graph for the standard benchmark length. + + The MIDI only lands in the first block: the notes are then held for the rest + of the run, so the steady state being measured is "two voices sounding". +*/ +void benchmarkRunVoiceGraph (YdspAudioGraph& graph, + std::vector& left, + std::vector& right, + const MidiBuffer& firstBlockMidi) +{ + const auto blockSize = static_cast (benchmarkBlockSize); + + std::vector inputs; + std::vector outputs; + + outputs.emplace_back (Span (left.data(), blockSize)); + outputs.emplace_back (Span (right.data(), blockSize)); + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block) * blockSize; + + outputs[0] = Span (left.data() + offset, blockSize); + outputs[1] = Span (right.data() + offset, blockSize); + + const yup::MidiBuffer* processEventInputs[] { block == 0 ? &firstBlockMidi : nullptr }; + + graph.process (yup::YdspProcessRequest { + inputs, + outputs, + benchmarkBlockSize, + yup::Span (processEventInputs, graph.getEventInputCount() > 0 ? 1 : 0), + {} + }); + } +} + +} // namespace + +//============================================================================== + +class YdspBenchmarkTests : public ::testing::Test +{ +protected: + static String listingMnemonic (const String& line) + { + // AsmJit prints the instruction first and machine-code bytes after ';'. + const auto text = line.upToFirstOccurrenceOf (";", false, false).trim(); + const auto tokens = StringArray::fromTokens (text, " \t", {}); + + if (tokens.isEmpty()) + return {}; + + const auto& mnemonic = tokens[0]; + + if (mnemonic.endsWith (":") || mnemonic.startsWith (".") || mnemonic == "align") + return {}; + + return mnemonic; + } + + /** Dumps a compiled kernel's full listing plus an opcode histogram. + + Gated on the YUP_DUMP_KERNEL environment variable so it costs nothing in + normal runs: set it to `1`, `*`, or a comma-separated list of labels to print + the AsmJit listing of those kernels to stdout, one mnemonic-frequency + table per kernel. This is the diagnostic for deciding where a shape still + loses to the native C++ reference - spills around a call, address + arithmetic, register moves - instead of guessing from the summary counts. + */ + static void benchmarkDumpListingIfRequested (const String& label, const YdspAudioGraph& graph) + { + const auto filter = measurementEnvironment ("YUP_DUMP_KERNEL", {}).trim(); + if (filter.isEmpty() || filter == "0") + return; + + auto labels = StringArray::fromTokens (filter, ",", {}); + labels.trim(); + if (filter != "1" && filter != "*" && ! labels.contains (label)) + return; + + std::cout << "\n--- kernel listing: " << label << " ---\n"; + + std::map histogram; + + for (const auto& line : StringArray::fromLines (graph.getDiagnostics().toString())) + { + const auto text = line.trim(); + + if (text.isEmpty()) + continue; + + std::cout << text << "\n"; + + const auto mnemonic = listingMnemonic (text); + + if (! mnemonic.isEmpty()) + ++histogram[mnemonic]; + } + + std::cout << " histogram:\n"; + + for (const auto& [mnemonic, count] : histogram) + std::cout << " " << mnemonic.paddedRight (' ', 12) << count << "\n"; + } + + using MeasurementClock = std::chrono::steady_clock; + static constexpr size_t measurementRepeats = 7; + using Measurements = std::array; + + struct MeasurementSummary + { + double best; + double worst; + double median; + double interquartileRange; + }; + + static MeasurementSummary summarize (Measurements values) + { + std::sort (values.begin(), values.end()); + return { values.front(), values.back(), values[3], values[5] - values[1] }; + } + + static double elapsed (MeasurementClock::time_point start) + { + return std::chrono::duration (MeasurementClock::now() - start).count(); + } + + static String measurementEnvironment (const String& name, const String& fallback) + { +#if YUP_EMSCRIPTEN + auto* value = reinterpret_cast (EM_ASM_PTR ({ + var key = UTF8ToString ($0); + var value = typeof process !== 'undefined' && process.env ? process.env[key] : undefined; + if (typeof value !== 'string') + return 0; + var length = lengthBytesUTF8 (value) + 1; + var pointer = _malloc (length); + stringToUTF8 (value, pointer, length); + return pointer; + }, name.toRawUTF8())); + if (value != nullptr) + { + const String result (value); + std::free (value); + return result; + } +#endif + return SystemStats::getEnvironmentVariable (name, fallback); + } + + struct PreparedRun + { + std::vector inputs; + std::vector outputs; + std::vector requests; + + void prepare (bool hasInput, const std::vector& input, std::vector& output, int blockSize) + { + const auto blocks = static_cast (benchmarkTotalSamples / blockSize); + inputs.resize (hasInput ? blocks : 0); + outputs.resize (blocks); + requests.resize (blocks); + + for (size_t block = 0; block < blocks; ++block) + { + const auto offset = block * static_cast (blockSize); + const auto length = static_cast (blockSize); + if (hasInput) + inputs[block] = Span (input.data() + offset, length); + + outputs[block] = Span (output.data() + offset, length); + requests[block] = { hasInput ? Span (&inputs[block], 1) : Span {}, + Span (&outputs[block], 1), blockSize }; + } + } + + YdspProcessResult run (YdspAudioGraph& graph) const + { + for (const auto& request : requests) + if (const auto result = graph.process (request); result != YdspProcessResult::ok) + return result; + + return YdspProcessResult::ok; + } + }; + + static void reportMeasurement (const char* patch, int blockSize, const char* phase, const Measurements& values) + { + const auto summary = summarize (values); + std::cout << "YDSP_MEASUREMENT\t" << patch << '\t' << blockSize << '\t' << phase + << '\t' << String (summary.median * 1.0e9 / benchmarkTotalSamples, 6) + << '\t' << String (summary.best * 1.0e9 / benchmarkTotalSamples, 6) + << '\t' << String (summary.worst * 1.0e9 / benchmarkTotalSamples, 6) + << '\t' << String (summary.interquartileRange * 1.0e9 / benchmarkTotalSamples, 6) << '\n'; + } + + static YdspCompileOptions allOptimizations() + { + YdspCompileOptions options; + options.fastMath = true; + return options; + } + + static YdspCompileOptions noVectorisation() + { + YdspCompileOptions options; + options.optimizationTier = YdspOptimizationTier::baseline; + options.fastMath = false; // keep the scalar reference row strict + return options; + } + + std::array benchmarkPolicies (StringRef source, const char* listingLabel = nullptr) + { + const auto baselineOptions = noVectorisation(); + auto baselineFastOptions = baselineOptions; + baselineFastOptions.fastMath = true; + + // fastMath is now the native default, so the strict host row has to + // opt out to stay a distinct policy; the default is row 4. + auto hostStrictOptions = YdspCompileOptions {}; + hostStrictOptions.fastMath = false; + + const auto hostFastOptions = YdspCompileOptions {}; + + const std::array options { baselineOptions, baselineFastOptions, hostStrictOptions, hostFastOptions }; + + std::array timings; + + for (size_t i = 0; i < options.size(); ++i) + { + auto graph = compilePatch (source, compiler, options[i]); + EXPECT_TRUE (graph.isValid()); + if (! graph.isValid()) + continue; + + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + if (listingLabel != nullptr && i + 1 == options.size()) + benchmarkDumpListingIfRequested (listingLabel, graph); + + timings[i] = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + } + + return timings; + } + + void SetUp() override + { + input = benchmarkNoise (benchmarkTotalSamples); + + jitOutput.assign (static_cast (benchmarkTotalSamples), 0.0f); + nativeOutput.assign (static_cast (benchmarkTotalSamples), 0.0f); + } + + void report (const char* name, const BenchmarkTiming& jit, const BenchmarkTiming& native, double limit = benchmarkRatioLimit) + { + benchmarkReport (name, jit, native); + + if (jit.best / native.best > limit) + { + if (SystemStats::getEnvironmentVariable ("ACTION_RUNNER", {}) == "github-actions") + { + std::cerr << " WARNING - " << name << ": JIT kernel is more than " + << limit << "x slower than the equivalent compiled routine\n"; + } + else + { + EXPECT_LT (jit.best / native.best, limit) + << " WARNING - " << name << ": JIT kernel is more than " + << limit << "x slower than the equivalent compiled routine"; + } + } + } + + YdspCompiler compiler; + + std::vector input; + std::vector jitOutput; + std::vector nativeOutput; +}; + +//============================================================================== + +// Native-reference benchmarks. Keep these together so each algorithm has a +// direct C++ baseline before the policy and graph-shape comparisons below. + +TEST_F (YdspBenchmarkTests, DelayTapsAgainstNative) +{ + auto graph = compilePatch (benchmarkDelaySource, compiler, allOptimizations()); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeDelayTaps reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + EXPECT_NEAR (benchmarkChecksum (nativeOutput), benchmarkChecksum (jitOutput), 1.0); + + benchmarkReportPolicies ("delay taps (@)", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkDelaySource), nativeTiming); + benchmarkDumpListingIfRequested ("delay", graph); + benchmarkReportListing ("delay", benchmarkAnalyzeListing (graph)); + + auto baseline = compilePatch (benchmarkDelaySource, compiler, noVectorisation()); + ASSERT_TRUE (baseline.isValid()); + baseline.prepare (benchmarkSampleRate, benchmarkBlockSize); + + std::vector baselineOutput (static_cast (benchmarkTotalSamples), 0.0f); + baseline.reset(); + benchmarkRunGraph (baseline, input, baselineOutput); + EXPECT_NEAR (benchmarkChecksum (nativeOutput), benchmarkChecksum (baselineOutput), 1.0); +} + +TEST_F (YdspBenchmarkTests, LadderFilterAgainstNative) +{ + auto graph = compilePatch (benchmarkLadderSource, compiler, allOptimizations()); + benchmarkDumpListingIfRequested ("ladder", graph); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeLadder reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + EXPECT_NEAR (benchmarkChecksum (nativeOutput), benchmarkChecksum (jitOutput), 1.0); + + benchmarkReportPolicies ("ladder filter (state)", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkLadderSource), nativeTiming); + + std::vector uncontractedOutput (static_cast (benchmarkTotalSamples), 0.0f); + + const auto uncontractedTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeLadderUncontracted reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, uncontractedOutput.data() + offset, benchmarkBlockSize); + } + }); + + EXPECT_NEAR (benchmarkChecksum (uncontractedOutput), benchmarkChecksum (jitOutput), 1.0); + + benchmarkReportContraction ("ladder filter", nativeOutput, uncontractedOutput); + + auto fused = compilePatch (benchmarkLadderFusedSource, compiler, allOptimizations()); + ASSERT_TRUE (fused.isValid()); + fused.prepare (benchmarkSampleRate, benchmarkBlockSize); + + std::vector fusedOutput (static_cast (benchmarkTotalSamples), 0.0f); + + const auto fusedTiming = benchmarkTimeRepeats ([&] + { + fused.reset(); + benchmarkRunGraph (fused, input, fusedOutput); + }); + + const auto ladderMagnitude = benchmarkMagnitude (nativeOutput); + ASSERT_GT (ladderMagnitude, 0.0); + EXPECT_NEAR (ladderMagnitude, benchmarkMagnitude (fusedOutput), 1.0e-3 * ladderMagnitude); + + benchmarkReportVariants ("ladder filter: fma() against the same patch written as mul + add", + "jit fma", + fusedTiming, + "jit", + jitTiming); +} + +TEST_F (YdspBenchmarkTests, RepresentativePatchesAcrossOptimizationPolicies) +{ + const std::array sources { benchmarkDelaySource, + benchmarkLadderSource, + benchmarkShaperSource, + benchmarkFolderSource }; + + for (const auto source : sources) + { + auto optimized = compilePatch (source, compiler, allOptimizations()); + auto baseline = compilePatch (source, compiler, noVectorisation()); + ASSERT_TRUE (optimized.isValid()); + ASSERT_TRUE (baseline.isValid()); + + optimized.prepare (benchmarkSampleRate, benchmarkBlockSize); + baseline.prepare (benchmarkSampleRate, benchmarkBlockSize); + + std::vector optimizedOutput (static_cast (benchmarkTotalSamples), 0.0f); + std::vector baselineOutput (static_cast (benchmarkTotalSamples), 0.0f); + optimized.reset(); + baseline.reset(); + + benchmarkRunGraph (optimized, input, optimizedOutput); + benchmarkRunGraph (baseline, input, baselineOutput); + + EXPECT_TRUE (std::isfinite (benchmarkChecksum (optimizedOutput))); + EXPECT_TRUE (std::isfinite (benchmarkChecksum (baselineOutput))); + } +} + +TEST_F (YdspBenchmarkTests, BiquadAgainstNativeAcrossOptimizationPolicies) +{ + const std::array labels { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }; + const auto timings = benchmarkPolicies (benchmarkBiquadSource, "biquad"); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeBiquad reference; + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + benchmarkReportPolicies ("biquad low-pass", labels, timings, nativeTiming); + EXPECT_TRUE (std::isfinite (benchmarkChecksum (nativeOutput))); +} + +TEST_F (YdspBenchmarkTests, HarmonicBankAgainstNative) +{ + auto graph = compilePatch (benchmarkBankSource, compiler, allOptimizations()); + benchmarkDumpListingIfRequested ("harmonic", graph); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeBank reference (benchmarkSampleRate); + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + EXPECT_GT (benchmarkChecksum (nativeOutput) * benchmarkChecksum (nativeOutput), 0.0); + + benchmarkReportPolicies ("harmonic bank (32 partials)", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkBankSource), nativeTiming); + + for (const auto& kernel : graph.getExecutionReport().getKernels()) + { + if (kernel.name != "Bank") + continue; + + std::cout << "\n kernel: " << kernel.instructionCount << " insts, vectorized " + << (kernel.vectorized ? "yes" : "no") << ", x" << kernel.vectorWidth + << ", unrolled " << (kernel.unrolled ? "yes" : "no") << "\n"; + break; + } +} + +TEST_F (YdspBenchmarkTests, SineBankAgainstNative) +{ + auto graph = compilePatch (benchmarkSineBankSource, compiler, allOptimizations()); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeSineBank reference (benchmarkSampleRate); + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + // Relative, not exact: the reference's compiled `sinf` and the JIT's can + // differ by a ULP or two under fastMath, and this shape is exactly where + // the SLEEF migration is allowed to move the result. + const auto native = benchmarkMagnitude (nativeOutput); + ASSERT_GT (native, 0.0); + EXPECT_NEAR (native, benchmarkMagnitude (jitOutput), 1.0e-3 * native); + + benchmarkReportPolicies ("sine bank (8 sin()/sample)", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkSineBankSource), nativeTiming, benchmarkTranscendentalLimit); + benchmarkDumpListingIfRequested ("sine bank", graph); + benchmarkReportListing ("sine bank", benchmarkAnalyzeListing (graph)); + + for (const auto& kernel : graph.getExecutionReport().getKernels()) + { + if (kernel.name != "SineBank") + continue; + + std::cout << "\n kernel: " << kernel.instructionCount << " insts, vectorized " + << (kernel.vectorized ? "yes" : "no") << ", x" << kernel.vectorWidth + << ", unrolled " << (kernel.unrolled ? "yes" : "no") << "\n"; + break; + } +} + +TEST_F (YdspBenchmarkTests, TanhShaperAgainstNative) +{ + auto graph = compilePatch (benchmarkTanhShaperSource, compiler, allOptimizations()); + benchmarkDumpListingIfRequested ("tanh shaper", graph); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeTanhShaper reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + const auto native = benchmarkMagnitude (nativeOutput); + ASSERT_GT (native, 0.0); + EXPECT_NEAR (native, benchmarkMagnitude (jitOutput), 1.0e-3 * native); + + benchmarkReportPolicies ("tanh shaper (scalar tanh())", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkTanhShaperSource), nativeTiming, benchmarkTranscendentalLimit); + benchmarkReportListing ("tanh shaper", benchmarkAnalyzeListing (graph)); +} + +TEST_F (YdspBenchmarkTests, ExpEnvelopeAgainstNative) +{ + auto graph = compilePatch (benchmarkExpEnvelopeSource, compiler, allOptimizations()); + benchmarkDumpListingIfRequested ("exp envelope", graph); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeExpEnvelope reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + const auto native = benchmarkMagnitude (nativeOutput); + ASSERT_GT (native, 0.0); + EXPECT_NEAR (native, benchmarkMagnitude (jitOutput), 1.0e-3 * native); + + benchmarkReportPolicies ("exp envelope (scalar exp())", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkExpEnvelopeSource), nativeTiming, benchmarkTranscendentalLimit); + benchmarkReportListing ("exp envelope", benchmarkAnalyzeListing (graph)); +} + +TEST_F (YdspBenchmarkTests, WaveShaperAgainstNative) +{ + auto graph = compilePatch (benchmarkShaperSource, compiler, allOptimizations()); + benchmarkDumpListingIfRequested ("wave shaper", graph); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeShaper reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + EXPECT_NEAR (benchmarkChecksum (nativeOutput), benchmarkChecksum (jitOutput), 1.0); + + benchmarkReportPolicies ("wave shaper (compare + select)", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkShaperSource), nativeTiming); +} + +TEST_F (YdspBenchmarkTests, ModalBankAgainstNative) +{ + auto graph = compilePatch (benchmarkModalSource, compiler, allOptimizations()); + benchmarkDumpListingIfRequested ("modal", graph); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeModal reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + const auto native = benchmarkMagnitude (nativeOutput); + ASSERT_GT (native, 0.0); + EXPECT_NEAR (native, benchmarkMagnitude (jitOutput), 1.0e-3 * native); + + benchmarkReportPolicies ("modal bank (loop-invariant inner work)", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkModalSource), nativeTiming, benchmarkModalBankLimit); +} + +TEST_F (YdspBenchmarkTests, WaveFolderAgainstNative) +{ + auto graph = compilePatch (benchmarkFolderSource, compiler, allOptimizations()); + benchmarkDumpListingIfRequested ("wave folder", graph); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeFolder reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + EXPECT_NEAR (benchmarkChecksum (nativeOutput), benchmarkChecksum (jitOutput), 1.0); + + benchmarkReportPolicies ("wave folder (branchy if/else)", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkFolderSource), nativeTiming); + + std::vector uncontractedOutput (static_cast (benchmarkTotalSamples), 0.0f); + + const auto uncontractedTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeFolderUncontracted reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, uncontractedOutput.data() + offset, benchmarkBlockSize); + } + }); + + EXPECT_NEAR (benchmarkChecksum (uncontractedOutput), benchmarkChecksum (jitOutput), 1.0); + + benchmarkReportContraction ("wave folder", nativeOutput, uncontractedOutput); +} + +//============================================================================== +// Real-life effect kernels (shapes 9-17). These are the DSP blocks a real +// effect chain is made of - feedback delays, modulated delays, reverb, +// dynamics, drive, filters and lo-fi processors - each measured against a +// hand-written C++ routine computing the same thing. The first five mirror the +// shipped fx/ example processors (UI annotations stripped, parameters left at +// their declared defaults); the last four are classic published algorithms. +// +// BenchmarkEcho - fractionally addressed feedback delay with a +// one-pole damping lowpass in the feedback path +// (fx/Delay.ydsp): an interpolated ring read, an int +// write-position state, and a mul/add feedback loop +// BenchmarkChorus - modulated delay tap (fx/Chorus.ydsp): a sine LFO +// moves the read position of a fixed ring, so one +// libm sin rides on top of the ring traffic +// BenchmarkReverb - Freeverb topology (fx/Reverb.ydsp): eight parallel +// combs with per-comb one-pole damping feeding four +// series allpass diffusers; the most delay memory +// and state-pointer traffic of the set +// BenchmarkCompressor - dB-domain feedforward bus compressor +// (fx/Compressor.ydsp): an envelope follower with +// separate attack/release coefficients, a log10 gain +// computer and a pow gain stage +// BenchmarkDistortion - tanh drive with a one-pole tone control and a +// parallel dry/wet mix (fx/Distortion.ydsp) +// BenchmarkSvf - TPT (zero-delay-feedback) state-variable low-pass: +// a tan-derived coefficient paid once per block and a +// tight two-state recurrence per sample +// BenchmarkPhaser - six series first-order allpasses swept by one LFO: +// a libm sin per sample over twelve scalar state +// round-trips +// BenchmarkKarplus - Karplus-Strong plucked string: a ring written +// through a two-tap averaged damping loop +// BenchmarkLoFi - 12:1 sample-rate reduction plus a 6-bit +// quantization stage: integer counter state feeding a +// floor-based bitcrusher +// +// Like the shapes above, the JIT-vs-native ratios are printed rather than +// asserted. The parity guard is the same loose one the other native-reference +// shapes use: a checksum agreement where the per-sample loop carries no libm +// call, and a relative-magnitude agreement where it does (scalar JIT +// transcendentals and the reference's can differ by an ulp or two). + +namespace +{ + +//============================================================================== +// Shape 9: a fractionally read feedback delay with damped feedback (the +// fx/Delay.ydsp algorithm). Each sample does an int cast, a conditional wrap +// and two data-dependent ring reads in addition to the feedback one-pole, so +// the ring addressing - not just the taps - is what gets measured. + +constexpr auto benchmarkEchoSource = R"YDSP( + processor Echo { + input stream in; + output stream out; + + input parameter float time = 0.375; + input parameter float feedback = 0.4; + input parameter float damping = 0.5; + input parameter float mix = 0.35; + + state float buf[96000]; + state int wp; + state float damped; + + process { + float fdelay = time * sampleRate; + if (fdelay > 96000.0) { fdelay = 96000.0; } + if (fdelay < 1.0) { fdelay = 1.0; } + + int d0 = int (fdelay); + float frac = fdelay - float (d0); + + int readA = wp - d0; + if (readA < 0) { readA = readA + 96000; } + + int readB = readA - 1; + if (readB < 0) { readB = readB + 96000; } + + float delayed = buf[readA] * (1.0 - frac) + buf[readB] * frac; + + damped = damped * damping + delayed * (1.0 - damping); + + buf[wp] = in + feedback * damped; + wp = wp + 1; + if (wp >= 96000) { wp = 0; } + + out = (1.0 - mix) * in + mix * delayed; + } + } + + graph G { + input stream x; + output stream y; + + node e = Echo; + + connection { x -> e.in; e.out -> y; } + } +)YDSP"; + +class BenchmarkNativeEcho +{ +public: + BenchmarkNativeEcho() + { + ring.assign (static_cast (ringSize), 0.0f); + } + + void process (const float* in, float* out, int numSamples) + { + for (int i = 0; i < numSamples; ++i) + { + const auto x = in[i]; + + auto fdelay = delaySeconds * sampleRate; + if (fdelay > static_cast (ringSize)) fdelay = static_cast (ringSize); + if (fdelay < 1.0f) fdelay = 1.0f; + + const auto d0 = static_cast (fdelay); + const auto frac = fdelay - static_cast (d0); + + auto readA = wp - d0; + if (readA < 0) readA += ringSize; + + auto readB = readA - 1; + if (readB < 0) readB += ringSize; + + const auto delayed = ring[static_cast (readA)] * (1.0f - frac) + ring[static_cast (readB)] * frac; + + damped = damped * damping + delayed * (1.0f - damping); + + ring[static_cast (wp)] = x + feedback * damped; + + if (++wp == ringSize) + wp = 0; + + out[i] = (1.0f - mix) * x + mix * delayed; + } + } + +private: + static constexpr int ringSize = 96000; + static constexpr float sampleRate = 48000.0f; + static constexpr float delaySeconds = 0.375f; + static constexpr float feedback = 0.4f; + static constexpr float damping = 0.5f; + static constexpr float mix = 0.35f; + + std::vector ring; + int wp = 0; + float damped = 0.0f; +}; + +//============================================================================== +// Shape 10: a chorus (fx/Chorus.ydsp). The ring write and read share one +// buffer while a sine LFO sweeps the read position around a 20 ms base delay, +// so the per-sample `sin` is what keeps the whole loop scalar. + +constexpr auto benchmarkChorusSource = R"YDSP( + processor Chorus { + input stream in; + output stream out; + + input parameter float rate = 1.5; + input parameter float depth = 0.004; + input parameter float mix = 0.5; + + state float buf[4096]; + state int wp; + state float lfoPhase; + + process { + buf[wp] = in; + wp = wp + 1; + if (wp >= 4096) { wp = 0; } + + lfoPhase = lfoPhase + rate / sampleRate; + if (lfoPhase >= 1.0) { lfoPhase = lfoPhase - 1.0; } + + float lfo = sin (lfoPhase * 6.283185307); + float delaySamples = clamp (0.02 * sampleRate + depth * sampleRate * lfo, 0.0, 4000.0); + + int readIdx = wp - int (delaySamples); + if (readIdx < 0) { readIdx = readIdx + 4096; } + + out = (1.0 - mix) * in + mix * buf[readIdx]; + } + } + + graph G { + input stream x; + output stream y; + + node c = Chorus; + + connection { x -> c.in; c.out -> y; } + } +)YDSP"; + +class BenchmarkNativeChorus +{ +public: + BenchmarkNativeChorus() + { + ring.assign (static_cast (ringSize), 0.0f); + } + + void process (const float* in, float* out, int numSamples) + { + for (int i = 0; i < numSamples; ++i) + { + const auto x = in[i]; + + ring[static_cast (wp)] = x; + + if (++wp == ringSize) + wp = 0; + + lfoPhase = lfoPhase + rate / sampleRate; + if (lfoPhase >= 1.0f) lfoPhase = lfoPhase - 1.0f; + + const auto lfo = std::sin (lfoPhase * twoPi); + const auto delaySamples = std::min (std::max (0.02f * sampleRate + depth * sampleRate * lfo, 0.0f), 4000.0f); + + auto readIdx = wp - static_cast (delaySamples); + if (readIdx < 0) readIdx += ringSize; + + out[i] = (1.0f - mix) * x + mix * ring[static_cast (readIdx)]; + } + } + +private: + static constexpr int ringSize = 4096; + static constexpr float sampleRate = 48000.0f; + static constexpr float rate = 1.5f; + static constexpr float depth = 0.004f; + static constexpr float mix = 0.5f; + static constexpr float twoPi = 6.283185307f; + + std::vector ring; + int wp = 0; + float lfoPhase = 0.0f; +}; + +//============================================================================== +// Shape 11: a Freeverb reverb (fx/Reverb.ydsp): eight parallel comb filters +// with per-comb one-pole damping, whose summed outputs run through four series +// allpass diffusers. Written with explicit ring buffers and write positions +// (the shipped file uses '@' delay slots for the same signal flow), because +// the point here is the cost of many independent delay lines and their pointer +// state in one per-sample loop. + +constexpr auto benchmarkReverbSource = R"YDSP( + processor Reverb { + input stream in; + output stream out; + + input parameter float mix = 0.33; + input parameter float damping = 0.5; + input parameter float roomSize = 0.8; + + state float buf1[1116]; + state int w1; + state float damp1; + + state float buf2[1188]; + state int w2; + state float damp2; + + state float buf3[1277]; + state int w3; + state float damp3; + + state float buf4[1356]; + state int w4; + state float damp4; + + state float buf5[1422]; + state int w5; + state float damp5; + + state float buf6[1491]; + state int w6; + state float damp6; + + state float buf7[1557]; + state int w7; + state float damp7; + + state float buf8[1617]; + state int w8; + state float damp8; + + state float ap1[225]; + state int wa1; + + state float ap2[341]; + state int wa2; + + state float ap3[441]; + state int wa3; + + state float ap4[556]; + state int wa4; + + process { + float feedback = roomSize * 0.28 + 0.7; + + let d1 = buf1[w1]; + damp1 = damp1 * damping + d1 * (1.0 - damping); + buf1[w1] = feedback * damp1 + 0.015 * in; + w1 = w1 + 1; + if (w1 >= 1116) { w1 = 0; } + + let d2 = buf2[w2]; + damp2 = damp2 * damping + d2 * (1.0 - damping); + buf2[w2] = feedback * damp2 + 0.015 * in; + w2 = w2 + 1; + if (w2 >= 1188) { w2 = 0; } + + let d3 = buf3[w3]; + damp3 = damp3 * damping + d3 * (1.0 - damping); + buf3[w3] = feedback * damp3 + 0.015 * in; + w3 = w3 + 1; + if (w3 >= 1277) { w3 = 0; } + + let d4 = buf4[w4]; + damp4 = damp4 * damping + d4 * (1.0 - damping); + buf4[w4] = feedback * damp4 + 0.015 * in; + w4 = w4 + 1; + if (w4 >= 1356) { w4 = 0; } + + let d5 = buf5[w5]; + damp5 = damp5 * damping + d5 * (1.0 - damping); + buf5[w5] = feedback * damp5 + 0.015 * in; + w5 = w5 + 1; + if (w5 >= 1422) { w5 = 0; } + + let d6 = buf6[w6]; + damp6 = damp6 * damping + d6 * (1.0 - damping); + buf6[w6] = feedback * damp6 + 0.015 * in; + w6 = w6 + 1; + if (w6 >= 1491) { w6 = 0; } + + let d7 = buf7[w7]; + damp7 = damp7 * damping + d7 * (1.0 - damping); + buf7[w7] = feedback * damp7 + 0.015 * in; + w7 = w7 + 1; + if (w7 >= 1557) { w7 = 0; } + + let d8 = buf8[w8]; + damp8 = damp8 * damping + d8 * (1.0 - damping); + buf8[w8] = feedback * damp8 + 0.015 * in; + w8 = w8 + 1; + if (w8 >= 1617) { w8 = 0; } + + let wet = d1 + d2 + d3 + d4 + d5 + d6 + d7 + d8; + + let a1d = ap1[wa1]; + ap1[wa1] = wet + a1d * 0.5; + wa1 = wa1 + 1; + if (wa1 >= 225) { wa1 = 0; } + let x1 = a1d - wet; + + let a2d = ap2[wa2]; + ap2[wa2] = x1 + a2d * 0.5; + wa2 = wa2 + 1; + if (wa2 >= 341) { wa2 = 0; } + let x2 = a2d - x1; + + let a3d = ap3[wa3]; + ap3[wa3] = x2 + a3d * 0.5; + wa3 = wa3 + 1; + if (wa3 >= 441) { wa3 = 0; } + let x3 = a3d - x2; + + let a4d = ap4[wa4]; + ap4[wa4] = x3 + a4d * 0.5; + wa4 = wa4 + 1; + if (wa4 >= 556) { wa4 = 0; } + let x4 = a4d - x3; + + out = (1.0 - mix) * in + mix * x4; + } + } + + graph G { + input stream x; + output stream y; + + node r = Reverb; + + connection { x -> r.in; r.out -> y; } + } +)YDSP"; + +class BenchmarkNativeReverb +{ +public: + BenchmarkNativeReverb() + { + for (int c = 0; c < 8; ++c) + combs[static_cast (c)].assign (static_cast (combLengths[static_cast (c)]), 0.0f); + + for (int c = 0; c < 4; ++c) + allpasses[static_cast (c)].assign (static_cast (allpassLengths[static_cast (c)]), 0.0f); + } + + void process (const float* in, float* out, int numSamples) + { + for (int i = 0; i < numSamples; ++i) + { + const auto x = in[i]; + const auto feedback = roomSize * 0.28f + 0.7f; + + auto wet = 0.0f; + + for (int c = 0; c < 8; ++c) + { + const auto idx = combWrite[static_cast (c)]; + const auto d = combs[static_cast (c)][static_cast (idx)]; + + damp[static_cast (c)] = damp[static_cast (c)] * damping + d * (1.0f - damping); + + combs[static_cast (c)][static_cast (idx)] = feedback * damp[static_cast (c)] + 0.015f * x; + + auto& wp = combWrite[static_cast (c)]; + if (++wp == combLengths[static_cast (c)]) + wp = 0; + + wet += d; + } + + auto stage = wet; + + for (int c = 0; c < 4; ++c) + { + const auto idx = allpassWrite[static_cast (c)]; + const auto d = allpasses[static_cast (c)][static_cast (idx)]; + + allpasses[static_cast (c)][static_cast (idx)] = stage + d * 0.5f; + + auto& wp = allpassWrite[static_cast (c)]; + if (++wp == allpassLengths[static_cast (c)]) + wp = 0; + + stage = d - stage; + } + + out[i] = (1.0f - mix) * x + mix * stage; + } + } + +private: + static constexpr int combLengths[8] = { 1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617 }; + static constexpr int allpassLengths[4] = { 225, 341, 441, 556 }; + static constexpr float damping = 0.5f; + static constexpr float roomSize = 0.8f; + static constexpr float mix = 0.33f; + + std::vector combs[8]; + std::vector allpasses[4]; + int combWrite[8] = {}; + int allpassWrite[4] = {}; + float damp[8] = {}; +}; + +//============================================================================== +// Shape 12: a dB-domain feedforward bus compressor (fx/Compressor.ydsp): the +// envelope follower picks attack or release per sample, the gain computer +// works in dB with log10 and the gain stage is a pow - two libm calls per +// sample whenever the level sits above the threshold. + +constexpr auto benchmarkCompressorSource = R"YDSP( + processor Compressor { + input stream in; + output stream out; + + input parameter float threshold = -18.0; + input parameter float ratio = 4.0; + input parameter float attack = 0.005; + input parameter float release = 0.15; + input parameter float makeup = 1.0; + + state float env; + + process { + float attackCoeff = 1.0 - exp (-1.0 / (attack * sampleRate)); + float releaseCoeff = 1.0 - exp (-1.0 / (release * sampleRate)); + + float level = abs (in); + float coeff = level > env ? attackCoeff : releaseCoeff; + env = env + coeff * (level - env); + + float envDb = 20.0 * log10 (env + 1e-6); + float gainDb = 0.0; + if (envDb > threshold) { gainDb = (threshold - envDb) * (1.0 - 1.0 / ratio); } + + float gain = pow (10.0, gainDb / 20.0); + out = in * gain * makeup; + } + } + + graph G { + input stream x; + output stream y; + + node c = Compressor; + + connection { x -> c.in; c.out -> y; } + } +)YDSP"; + +class BenchmarkNativeCompressor +{ +public: + void process (const float* in, float* out, int numSamples) + { + for (int i = 0; i < numSamples; ++i) + { + const auto x = in[i]; + + const auto attackCoeff = 1.0f - std::exp (-1.0f / (attack * sampleRate)); + const auto releaseCoeff = 1.0f - std::exp (-1.0f / (release * sampleRate)); + + const auto level = std::fabs (x); + const auto coeff = level > env ? attackCoeff : releaseCoeff; + env = env + coeff * (level - env); + + const auto envDb = 20.0f * std::log10 (env + 1.0e-6f); + + auto gainDb = 0.0f; + if (envDb > threshold) gainDb = (threshold - envDb) * (1.0f - 1.0f / ratio); + + const auto gain = std::pow (10.0f, gainDb / 20.0f); + out[i] = x * gain * makeup; + } + } + +private: + static constexpr float sampleRate = 48000.0f; + static constexpr float threshold = -18.0f; + static constexpr float ratio = 4.0f; + static constexpr float attack = 0.005f; + static constexpr float release = 0.15f; + static constexpr float makeup = 1.0f; + + float env = 0.0f; +}; + +//============================================================================== +// Shape 13: a tanh drive distortion with a one-pole tone control and a +// parallel dry/wet mix (fx/Distortion.ydsp). A scalar libm tanh per sample, +// with the tone one-pole and the mix as pure multiply/add after it. + +constexpr auto benchmarkDistortionSource = R"YDSP( + processor Distortion { + input stream in; + output stream out; + + input parameter float drive = 1.0; + input parameter float tone = 0.5; + input parameter float mix = 0.7; + + state float z1; + + process { + float shaped = tanh (in * drive); + + float k = 0.05 + 0.95 * tone; + z1 = z1 + k * (shaped - z1); + + out = in + mix * (z1 - in); + } + } + + graph G { + input stream x; + output stream y; + + node d = Distortion; + + connection { x -> d.in; d.out -> y; } + } +)YDSP"; + +class BenchmarkNativeDistortion +{ +public: + void process (const float* in, float* out, int numSamples) + { + for (int i = 0; i < numSamples; ++i) + { + const auto x = in[i]; + + const auto shaped = std::tanh (x * drive); + + const auto k = 0.05f + 0.95f * tone; + z1 = z1 + k * (shaped - z1); + + out[i] = x + mix * (z1 - x); + } + } + +private: + static constexpr float drive = 1.0f; + static constexpr float tone = 0.5f; + static constexpr float mix = 0.7f; + + float z1 = 0.0f; +}; + +//============================================================================== +// Shape 14: a TPT (zero-delay-feedback) state-variable low-pass. The +// tan-derived coefficients only depend on parameters, so they are paid once +// per block; the per-sample loop is a tight two-state recurrence of pure +// multiply/adds - the shape a real parametric filter bank spends most of its +// time in. The `tan` sits in the per-sample source but is hoisted (its +// operands are parameters); even if hoisting ever stopped firing, the parity +// guard below would still hold, because the native reference recomputes the +// same invariant libm call per sample. + +constexpr auto benchmarkSvfSource = R"YDSP( + processor Svf { + input stream in; + output stream out; + + input parameter float cutoff = 1000.0; + input parameter float resonance = 0.7; + + state float ic1eq; + state float ic2eq; + + process { + float g = tan (pi * cutoff * samplePeriod); + float k = 1.0 / clamp (resonance, 0.01, 1.0); + + float a1 = 1.0 / (1.0 + g * (g + k)); + float a2 = g * a1; + float a3 = g * a2; + + float v3 = in - ic2eq; + float v1 = a1 * ic1eq + a2 * v3; + float v2 = ic2eq + a2 * ic1eq + a3 * v3; + + ic1eq = 2.0 * v1 - ic1eq; + ic2eq = 2.0 * v2 - ic2eq; + + out = v2; + } + } + + graph G { + input stream x; + output stream y; + + node f = Svf; + + connection { x -> f.in; f.out -> y; } + } +)YDSP"; + +class BenchmarkNativeSvf +{ +public: + void process (const float* in, float* out, int numSamples) + { + for (int i = 0; i < numSamples; ++i) + { + const auto x = in[i]; + + const auto g = std::tan (pi * cutoff * samplePeriod); + const auto k = 1.0f / std::min (std::max (resonance, 0.01f), 1.0f); + + const auto a1 = 1.0f / (1.0f + g * (g + k)); + const auto a2 = g * a1; + const auto a3 = g * a2; + + const auto v3 = x - ic2eq; + const auto v1 = a1 * ic1eq + a2 * v3; + const auto v2 = ic2eq + a2 * ic1eq + a3 * v3; + + ic1eq = 2.0f * v1 - ic1eq; + ic2eq = 2.0f * v2 - ic2eq; + + out[i] = v2; + } + } + +private: + static constexpr float samplePeriod = 1.0f / 48000.0f; + static constexpr float pi = 3.14159265358979323846f; + static constexpr float cutoff = 1000.0f; + static constexpr float resonance = 0.7f; + + float ic1eq = 0.0f; + float ic2eq = 0.0f; +}; + +//============================================================================== +// Shape 15: a six-stage phaser. Six series first-order allpasses share one +// LFO-driven coefficient, so every sample pays a libm sin plus twelve scalar +// state round-trips - the feedback-free counterpart to the ladder's serial +// state chain, and a genuinely scalar loop. + +constexpr auto benchmarkPhaserSource = R"YDSP( + processor Phaser { + input stream in; + output stream out; + + input parameter float rate = 0.4; + input parameter float depth = 0.7; + + state float phase; + state float xm[6]; + state float ym[6]; + + process { + phase = phase + rate / sampleRate; + if (phase >= 1.0) { phase = phase - 1.0; } + + let g = depth * sin (phase * 2.0 * pi); + + let s1 = in; + let o1 = g * s1 + xm[0] - g * ym[0]; + xm[0] = s1; + ym[0] = o1; + + let s2 = o1; + let o2 = g * s2 + xm[1] - g * ym[1]; + xm[1] = s2; + ym[1] = o2; + + let s3 = o2; + let o3 = g * s3 + xm[2] - g * ym[2]; + xm[2] = s3; + ym[2] = o3; + + let s4 = o3; + let o4 = g * s4 + xm[3] - g * ym[3]; + xm[3] = s4; + ym[3] = o4; + + let s5 = o4; + let o5 = g * s5 + xm[4] - g * ym[4]; + xm[4] = s5; + ym[4] = o5; + + let s6 = o5; + let o6 = g * s6 + xm[5] - g * ym[5]; + xm[5] = s6; + ym[5] = o6; + + out = (in + o6) * 0.5; + } + } + + graph G { + input stream x; + output stream y; + + node p = Phaser; + + connection { x -> p.in; p.out -> y; } + } +)YDSP"; + +class BenchmarkNativePhaser +{ +public: + void process (const float* in, float* out, int numSamples) + { + for (int i = 0; i < numSamples; ++i) + { + const auto x = in[i]; + + phase = phase + rate / sampleRate; + if (phase >= 1.0f) phase = phase - 1.0f; + + const auto g = depth * std::sin (phase * twoPi); + + auto s = x; + + for (int stage = 0; stage < 6; ++stage) + { + const auto o = g * s + xm[stage] - g * ym[stage]; + xm[stage] = s; + ym[stage] = o; + s = o; + } + + out[i] = (x + s) * 0.5f; + } + } + +private: + static constexpr float sampleRate = 48000.0f; + static constexpr float rate = 0.4f; + static constexpr float depth = 0.7f; + static constexpr float twoPi = 6.283185307f; + + float phase = 0.0f; + float xm[6] = {}; + float ym[6] = {}; +}; + +//============================================================================== +// Shape 16: a Karplus-Strong plucked string. A two-tap averaged damping loop +// writes a ring through its own write position - the integer wrap and the +// second (one-older) tap are the interesting per-sample work. + +constexpr auto benchmarkKarplusSource = R"YDSP( + let stringLen = 550; + + processor Karplus { + input stream in; + output stream out; + + state float buf[stringLen]; + state int wp; + + process { + int read = wp - 1; + if (read < 0) { read = stringLen - 1; } + + let d0 = buf[wp]; + let d1 = buf[read]; + + let damped = in + (d0 + d1) * 0.498; + + buf[wp] = damped; + wp = wp + 1; + if (wp >= stringLen) { wp = 0; } + + out = damped; + } + } + + graph G { + input stream x; + output stream y; + + node k = Karplus; + + connection { x -> k.in; k.out -> y; } + } +)YDSP"; + +class BenchmarkNativeKarplus +{ +public: + BenchmarkNativeKarplus() + { + ring.assign (static_cast (stringLen), 0.0f); + } + + void process (const float* in, float* out, int numSamples) + { + for (int i = 0; i < numSamples; ++i) + { + const auto x = in[i]; + + auto read = wp - 1; + if (read < 0) read = stringLen - 1; + + const auto d0 = ring[static_cast (wp)]; + const auto d1 = ring[static_cast (read)]; + + const auto damped = x + (d0 + d1) * 0.498f; + + ring[static_cast (wp)] = damped; + + if (++wp == stringLen) + wp = 0; + + out[i] = damped; + } + } + +private: + static constexpr int stringLen = 550; + + std::vector ring; + int wp = 0; +}; + +//============================================================================== +// Shape 17: a lo-fi processor - 12:1 sample-and-hold rate reduction feeding a +// 6-bit quantization stage. The per-sample loop is mostly integer state (the +// downsampling counter) plus one floor-based bitcrush, so it isolates the +// int/float crossover the transcendental shapes do not touch. + +constexpr auto benchmarkLoFiSource = R"YDSP( + processor LoFi { + input stream in; + output stream out; + + input parameter float drive = 2.0; + + state float held; + state int counter; + + process { + if (counter == 0) { held = in; } + counter = counter + 1; + if (counter >= 12) { counter = 0; } + + let quantized = floor (held * 64.0) * 0.015625; + out = clamp (quantized * drive, -1.0, 1.0); + } + } + + graph G { + input stream x; + output stream y; + + node l = LoFi; + + connection { x -> l.in; l.out -> y; } + } +)YDSP"; + +class BenchmarkNativeLoFi +{ +public: + void process (const float* in, float* out, int numSamples) + { + for (int i = 0; i < numSamples; ++i) + { + const auto x = in[i]; + + if (counter == 0) + held = x; + + if (++counter >= hold) + counter = 0; + + const auto quantized = std::floor (held * 64.0f) * 0.015625f; + out[i] = std::min (std::max (quantized * drive, -1.0f), 1.0f); + } + } + +private: + static constexpr int hold = 12; + static constexpr float drive = 2.0f; + + float held = 0.0f; + int counter = 0; +}; + +} // namespace + +//============================================================================== +// Real-life native-reference benchmarks (shapes 9-17). + +TEST_F (YdspBenchmarkTests, EchoFeedbackDelayAgainstNative) +{ + auto graph = compilePatch (benchmarkEchoSource, compiler, allOptimizations()); + benchmarkDumpListingIfRequested ("echo", graph); + benchmarkReportListing ("echo", benchmarkAnalyzeListing (graph)); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeEcho reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + EXPECT_NEAR (benchmarkChecksum (nativeOutput), benchmarkChecksum (jitOutput), 1.0); + + benchmarkReportPolicies ("feedback echo (fractional delay + damping)", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkEchoSource), nativeTiming); +} + +TEST_F (YdspBenchmarkTests, ChorusAgainstNative) +{ + auto graph = compilePatch (benchmarkChorusSource, compiler, allOptimizations()); + benchmarkDumpListingIfRequested ("chorus", graph); + benchmarkReportListing ("chorus", benchmarkAnalyzeListing (graph)); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeChorus reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + const auto native = benchmarkMagnitude (nativeOutput); + ASSERT_GT (native, 0.0); + EXPECT_NEAR (native, benchmarkMagnitude (jitOutput), 1.0e-3 * native); + + benchmarkReportPolicies ("chorus (modulated delay tap)", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkChorusSource), nativeTiming, benchmarkTranscendentalLimit); +} + +TEST_F (YdspBenchmarkTests, ReverbAgainstNative) +{ + auto graph = compilePatch (benchmarkReverbSource, compiler, allOptimizations()); + benchmarkDumpListingIfRequested ("reverb", graph); + benchmarkReportListing ("reverb", benchmarkAnalyzeListing (graph)); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeReverb reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + EXPECT_NEAR (benchmarkChecksum (nativeOutput), benchmarkChecksum (jitOutput), 1.0); + + benchmarkReportPolicies ("reverb (8 combs + 4 allpasses)", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkReverbSource), nativeTiming); +} + +TEST_F (YdspBenchmarkTests, CompressorAgainstNative) +{ + auto graph = compilePatch (benchmarkCompressorSource, compiler, allOptimizations()); + benchmarkDumpListingIfRequested ("compressor", graph); + benchmarkReportListing ("compressor", benchmarkAnalyzeListing (graph)); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeCompressor reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + const auto native = benchmarkMagnitude (nativeOutput); + ASSERT_GT (native, 0.0); + EXPECT_NEAR (native, benchmarkMagnitude (jitOutput), 1.0e-3 * native); + + benchmarkReportPolicies ("bus compressor (dB gain computer)", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkCompressorSource), nativeTiming, benchmarkTranscendentalLimit); +} + +TEST_F (YdspBenchmarkTests, DistortionAgainstNative) +{ + auto graph = compilePatch (benchmarkDistortionSource, compiler, allOptimizations()); + benchmarkDumpListingIfRequested ("distortion", graph); + benchmarkReportListing ("distortion", benchmarkAnalyzeListing (graph)); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeDistortion reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + const auto native = benchmarkMagnitude (nativeOutput); + ASSERT_GT (native, 0.0); + EXPECT_NEAR (native, benchmarkMagnitude (jitOutput), 1.0e-3 * native); + + benchmarkReportPolicies ("distortion (tanh drive + tone)", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkDistortionSource), nativeTiming, benchmarkTranscendentalLimit); +} + +TEST_F (YdspBenchmarkTests, SvfAgainstNative) +{ + auto graph = compilePatch (benchmarkSvfSource, compiler, allOptimizations()); + benchmarkDumpListingIfRequested ("svf", graph); + benchmarkReportListing ("svf", benchmarkAnalyzeListing (graph)); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeSvf reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + EXPECT_NEAR (benchmarkChecksum (nativeOutput), benchmarkChecksum (jitOutput), 1.0); + + benchmarkReportPolicies ("state-variable low-pass (TPT)", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkSvfSource), nativeTiming); +} + +TEST_F (YdspBenchmarkTests, PhaserAgainstNative) +{ + auto graph = compilePatch (benchmarkPhaserSource, compiler, allOptimizations()); + benchmarkDumpListingIfRequested ("phaser", graph); + benchmarkReportListing ("phaser", benchmarkAnalyzeListing (graph)); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativePhaser reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + const auto native = benchmarkMagnitude (nativeOutput); + ASSERT_GT (native, 0.0); + EXPECT_NEAR (native, benchmarkMagnitude (jitOutput), 1.0e-3 * native); + + benchmarkReportPolicies ("phaser (6-stage allpass)", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkPhaserSource), nativeTiming, benchmarkTranscendentalLimit); +} + +TEST_F (YdspBenchmarkTests, KarplusStrongAgainstNative) +{ + auto graph = compilePatch (benchmarkKarplusSource, compiler, allOptimizations()); + benchmarkDumpListingIfRequested ("karplus", graph); + benchmarkReportListing ("karplus", benchmarkAnalyzeListing (graph)); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeKarplus reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + EXPECT_NEAR (benchmarkChecksum (nativeOutput), benchmarkChecksum (jitOutput), 1.0); + + benchmarkReportPolicies ("karplus-strong (plucked string)", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkKarplusSource), nativeTiming); +} + +TEST_F (YdspBenchmarkTests, LoFiAgainstNative) +{ + auto graph = compilePatch (benchmarkLoFiSource, compiler, allOptimizations()); + benchmarkDumpListingIfRequested ("lo-fi", graph); + benchmarkReportListing ("lo-fi", benchmarkAnalyzeListing (graph)); + ASSERT_TRUE (graph.isValid()); + graph.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto jitTiming = benchmarkTimeRepeats ([&] + { + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput); + }); + + const auto nativeTiming = benchmarkTimeRepeats ([&] + { + BenchmarkNativeLoFi reference; + + for (int block = 0; block < benchmarkBlockCount; ++block) + { + const auto offset = static_cast (block * benchmarkBlockSize); + reference.process (input.data() + offset, nativeOutput.data() + offset, benchmarkBlockSize); + } + }); + + EXPECT_NEAR (benchmarkChecksum (nativeOutput), benchmarkChecksum (jitOutput), 1.0); + + benchmarkReportPolicies ("lo-fi (12:1 sample-hold + bitcrush)", { "baseline (strict)", "baseline + fastMath", "host (strict)", "host + fastMath (default)" }, benchmarkPolicies (benchmarkLoFiSource), nativeTiming); +} + +//============================================================================== +// Optimisation-policy and graph-shape comparisons (no independent C++ row). + +TEST_F (YdspBenchmarkTests, ListingMnemonicPreservesHexLikeOpcodes) +{ + EXPECT_EQ (listingMnemonic ("add w19, w19, 1 ; 73060011"), String ("add")); + EXPECT_EQ (listingMnemonic ("fadd s9, s9, s0 ; 2929201E"), String ("fadd")); + EXPECT_EQ (listingMnemonic ("b L12 ; 00000014"), String ("b")); + EXPECT_EQ (listingMnemonic (" adc eax, ebx ; 11D8"), String ("adc")); + EXPECT_EQ (listingMnemonic ("\tfaddp v0.4s, v5.4s, v5.4s"), String ("faddp")); + EXPECT_EQ (listingMnemonic ("b.lt L4 ; 8BFCFF54"), String ("b.lt")); + EXPECT_EQ (listingMnemonic ("ret"), String ("ret")); + + for (const auto* line : { "", " \t", "; comment", "L12:", "L12: ; label", + "info: .section .text {#0}", ".section .text {#0}", + ".word 0x3F800000", ".xword 0x0000000000000000", "align 8" }) + { + EXPECT_TRUE (listingMnemonic (line).isEmpty()) << line; + } +} + +TEST_F (YdspBenchmarkTests, MeasurementSummaryUsesMedianAndInterquartileRange) +{ + const auto summary = summarize ({ 9.0, 1.0, 4.0, 2.0, 8.0, 3.0, 5.0 }); + EXPECT_DOUBLE_EQ (1.0, summary.best); + EXPECT_DOUBLE_EQ (9.0, summary.worst); + EXPECT_DOUBLE_EQ (4.0, summary.median); + EXPECT_DOUBLE_EQ (6.0, summary.interquartileRange); + + const auto constant = summarize ({ 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0 }); + EXPECT_DOUBLE_EQ (2.0, constant.median); + EXPECT_DOUBLE_EQ (0.0, constant.interquartileRange); +} + +TEST_F (YdspBenchmarkTests, PreparedRequestsMatchExistingRunnerWithAndWithoutInput) +{ + for (const auto* source : { benchmarkDelaySource, benchmarkSineBankSource }) + { + auto graph = compilePatch (source, compiler); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (benchmarkSampleRate, 2048).wasOk()); + graph.prewarmKernels(); + graph.reset(); + benchmarkRunGraph (graph, input, nativeOutput); + + PreparedRun prepared; + for (const int blockSize : { 32, 128, 512, 2048 }) + { + prepared.prepare (graph.getInputStreamCount() > 0, input, jitOutput, blockSize); + graph.reset(); + ASSERT_EQ (YdspProcessResult::ok, prepared.run (graph)); + EXPECT_EQ (nativeOutput, jitOutput) << "block size " << blockSize; + } + } +} + +TEST_F (YdspBenchmarkTests, DISABLED_AllPatchesAcrossBlockSizes) +{ + const std::array sources { + benchmarkDelaySource, benchmarkLadderSource, benchmarkBiquadSource, + benchmarkBankSource, benchmarkSineBankSource, benchmarkTanhShaperSource, + benchmarkExpEnvelopeSource, benchmarkShaperSource, benchmarkModalSource, + benchmarkFolderSource, benchmarkEchoSource, benchmarkChorusSource, + benchmarkReverbSource, benchmarkCompressorSource, benchmarkDistortionSource, + benchmarkSvfSource, benchmarkPhaserSource, benchmarkKarplusSource, benchmarkLoFiSource + }; + const std::array labels { + "delay", "ladder", "biquad", "harmonic", "sine", "tanh", "exp", + "shaper", "modal", "folder", "echo", "chorus", "reverb", "compressor", + "distortion", "svf", "phaser", "karplus", "lo-fi" + }; + constexpr std::array blockSizes { 32, 128, 512, 2048 }; + +#if YUP_WASM + constexpr auto target = "wasm"; +#elif YUP_ARM && YUP_64BIT + constexpr auto target = "arm64"; +#elif YUP_INTEL && YUP_64BIT + constexpr auto target = "x64"; +#else + constexpr auto target = "other"; +#endif + + const auto metadata = [] (const char* key, const String& value) + { + std::cout << "YDSP_METADATA\t" << key << '\t' << value.replaceCharacters ("\t\r\n", " ") << '\n'; + }; + metadata ("schema", "1"); + metadata ("target", target); + metadata ("cpu", measurementEnvironment ("YUP_BENCHMARK_CPU", SystemStats::getCpuModel())); + metadata ("os", measurementEnvironment ("YUP_BENCHMARK_OS", SystemStats::getOperatingSystemName())); + metadata ("policy", "platform-default"); + metadata ("sample-rate", String (benchmarkSampleRate, 0)); + metadata ("samples-per-pass", String (benchmarkTotalSamples)); + metadata ("batches", String (static_cast (measurementRepeats))); + metadata ("minimum-batch-ms", "20"); +#if defined (__clang__) + metadata ("compiler", "clang " __clang_version__); +#elif defined (__GNUC__) + metadata ("compiler", "gcc " __VERSION__); +#elif defined (_MSC_VER) + metadata ("compiler", "msvc " + String (_MSC_VER)); +#else + metadata ("compiler", "unknown"); +#endif + for (const auto* key : { "REVISION", "FLAGS", "ENGINE" }) + metadata (key, measurementEnvironment (String ("YUP_BENCHMARK_") + key, "unspecified")); + + std::cout << "YDSP_COLUMNS\tpatch\tblock\tphase\tmedian-ns/sample\tbest-ns/sample\tworst-ns/sample\tiqr-ns/sample\n"; + std::cout << "YDSP_SETUP_COLUMNS\tpatch\tcompile-ms\tprepare-ms\tprewarm-ms\tcode-bytes\n"; + + for (size_t shape = 0; shape < sources.size(); ++shape) + { + SCOPED_TRACE (labels[shape]); + YdspCompiler measurementCompiler; + YdspCompileOptions options; + options.emitOptimizationReport = true; + auto start = MeasurementClock::now(); + auto result = measurementCompiler.compile (sources[shape], options); + const auto compileSeconds = elapsed (start); + ASSERT_TRUE (result.wasOk()) << measurementCompiler.getDiagnostics().toString(); + auto graph = std::move (result).getValue(); + ASSERT_LE (graph.getInputStreamCount(), 1); + ASSERT_EQ (graph.getOutputStreamCount(), 1); + + start = MeasurementClock::now(); + const auto prepared = graph.prepare (benchmarkSampleRate, 2048); + const auto prepareSeconds = elapsed (start); + ASSERT_TRUE (prepared.wasOk()); + start = MeasurementClock::now(); + graph.prewarmKernels(); + const auto prewarmSeconds = elapsed (start); + std::cout << "YDSP_SETUP\t" << labels[shape] + << '\t' << String (compileSeconds * 1000.0, 6) + << '\t' << String (prepareSeconds * 1000.0, 6) + << '\t' << String (prewarmSeconds * 1000.0, 6) + << '\t' << measurementCompiler.getOptimizationReport().generatedCodeSize << '\n'; + + graph.reset(); + benchmarkRunGraph (graph, input, nativeOutput); + std::array runs; + std::array, blockSizes.size()> times {}; + std::array resetTimes {}; + std::array viewTimes {}; + + for (size_t index = 0; index < blockSizes.size(); ++index) + { + runs[index].prepare (graph.getInputStreamCount() > 0, input, jitOutput, blockSizes[index]); + graph.reset(); + ASSERT_EQ (YdspProcessResult::ok, runs[index].run (graph)); + ASSERT_EQ (nativeOutput, jitOutput) << "block size " << blockSizes[index]; + graph.reset(); + benchmarkRunGraph (graph, input, jitOutput, blockSizes[index]); + ASSERT_EQ (nativeOutput, jitOutput); + } + + for (size_t repeat = 0; repeat < measurementRepeats; ++repeat) + { + for (size_t position = 0; position < blockSizes.size() * 2; ++position) + { + const auto configuration = repeat % 2 == 0 ? position : blockSizes.size() * 2 - 1 - position; + const auto index = configuration / 2; + const auto endToEnd = configuration % 2 != 0; + double seconds = 0.0; + double resetSeconds = 0.0; + double viewSeconds = 0.0; + int passes = 0; + do + { + start = MeasurementClock::now(); + graph.reset(); + const auto resetDuration = elapsed (start); + resetSeconds += resetDuration; + if (endToEnd) + { + benchmarkRunGraph (graph, input, jitOutput, blockSizes[index]); + seconds += elapsed (start); + } + else + { + // Rebuild existing views outside the processing interval, without allocating. + start = MeasurementClock::now(); + runs[index].prepare (graph.getInputStreamCount() > 0, input, jitOutput, blockSizes[index]); + viewSeconds += elapsed (start); + start = MeasurementClock::now(); + const auto processed = runs[index].run (graph); + seconds += elapsed (start); + ASSERT_EQ (YdspProcessResult::ok, processed); + } + ++passes; + } + while (seconds < 0.020); + + times[index][endToEnd ? 1 : 0][repeat] = seconds / passes; + if (! endToEnd) + { + resetTimes[index][repeat] = resetSeconds / passes; + viewTimes[index][repeat] = viewSeconds / passes; + } + ASSERT_EQ (nativeOutput, jitOutput); + } + } + + for (size_t index = 0; index < blockSizes.size(); ++index) + { + reportMeasurement (labels[shape], blockSizes[index], "process", times[index][0]); + reportMeasurement (labels[shape], blockSizes[index], "end-to-end", times[index][1]); + reportMeasurement (labels[shape], blockSizes[index], "reset-init", resetTimes[index]); + reportMeasurement (labels[shape], blockSizes[index], "views", viewTimes[index]); + } + } +} + +TEST_F (YdspBenchmarkTests, AutomaticTierAgainstBaseline) +{ + YdspCompileOptions automaticOptions; + automaticOptions.emitOptimizationReport = true; + + YdspCompiler automaticCompiler; + auto automaticResult = automaticCompiler.compile (benchmarkModalNoSumSource, automaticOptions); + ASSERT_TRUE (automaticResult.wasOk()) << automaticCompiler.getDiagnostics().toString(); + + const auto automaticReport = automaticCompiler.getOptimizationReport(); + auto automatic = std::move (automaticResult).getValue(); + + YdspCompileOptions baselineOptions; + baselineOptions.optimizationTier = YdspOptimizationTier::baseline; + baselineOptions.targetPolicy = YdspTargetPolicy::baseline; + baselineOptions.baselineTarget = YdspNativeTarget::scalar; + baselineOptions.emitOptimizationReport = true; + + YdspCompiler baselineCompiler; + auto baselineResult = baselineCompiler.compile (benchmarkModalNoSumSource, baselineOptions); + ASSERT_TRUE (baselineResult.wasOk()) << baselineCompiler.getDiagnostics().toString(); + + const auto baselineReport = baselineCompiler.getOptimizationReport(); + auto baseline = std::move (baselineResult).getValue(); + + automatic.prepare (benchmarkSampleRate, benchmarkBlockSize); + baseline.prepare (benchmarkSampleRate, benchmarkBlockSize); + + std::vector automaticOutput (static_cast (benchmarkTotalSamples), 0.0f); + std::vector baselineOutput (static_cast (benchmarkTotalSamples), 0.0f); + + const auto automaticTiming = benchmarkTimeRepeats ([&] + { + automatic.reset(); + benchmarkRunGraph (automatic, input, automaticOutput); + }); + + const auto baselineTiming = benchmarkTimeRepeats ([&] + { + baseline.reset(); + benchmarkRunGraph (baseline, input, baselineOutput); + }); + + EXPECT_EQ (automaticOutput, baselineOutput); + EXPECT_TRUE (automaticReport.vectorizationEnabled); +#if ! YUP_WASM || defined (__wasm_simd128__) + // Loop unrolling is part of the automatic tier on native and on a wasm + // build compiled with -msimd128; only a scalar wasm build keeps it off + // (see the compiler's hasLoopTransforms gate). + EXPECT_TRUE (automaticReport.unrollingEnabled); +#endif + EXPECT_EQ (YdspNativeTarget::scalar, baselineReport.selectedIsa); + EXPECT_EQ (1, baselineReport.vectorWidth); + EXPECT_FALSE (baselineReport.vectorizationEnabled); + EXPECT_FALSE (baselineReport.unrollingEnabled); + + const auto modalKernel = [] (const YdspAudioGraph& graph) + { + for (const auto& kernel : graph.getExecutionReport().getKernels()) + if (kernel.name == "Modal") + return kernel; + + return YdspKernelReport {}; + }; + + const auto automaticKernel = modalKernel (automatic); + const auto baselineKernel = modalKernel (baseline); + + EXPECT_TRUE (automaticKernel.vectorized); + EXPECT_EQ (automaticReport.vectorWidth, automaticKernel.vectorWidth); + EXPECT_FALSE (baselineKernel.vectorized); + EXPECT_FALSE (baselineKernel.unrolled); + + std::cout << " | automatic | " << automaticReport.vectorWidth << " lanes" + << ", " << automaticReport.generatedCodeSize << " bytes\n" + << " | baseline | " << baselineReport.vectorWidth << " lane" + << ", " << baselineReport.generatedCodeSize << " bytes\n"; + + benchmarkReportVariants ("modal bank: automatic tier against scalar baseline", + "automatic", + automaticTiming, + "baseline", + baselineTiming); +} + +TEST_F (YdspBenchmarkTests, ModalBankReductionCost) +{ + auto withSum = compilePatch (benchmarkModalSource, compiler); + auto noSum = compilePatch (benchmarkModalNoSumSource, compiler); + + ASSERT_TRUE (withSum.isValid()); + ASSERT_TRUE (noSum.isValid()); + + withSum.prepare (benchmarkSampleRate, benchmarkBlockSize); + noSum.prepare (benchmarkSampleRate, benchmarkBlockSize); + + // Both variants are JIT graphs, so neither writes the fixture's `c++` buffer. + std::vector noSumOutput (static_cast (benchmarkTotalSamples), 0.0f); + + const auto withSumTiming = benchmarkTimeRepeats ([&] + { + withSum.reset(); + benchmarkRunGraph (withSum, input, jitOutput); + }); + + const auto noSumTiming = benchmarkTimeRepeats ([&] + { + noSum.reset(); + benchmarkRunGraph (noSum, input, noSumOutput); + }); + + EXPECT_GT (benchmarkMagnitude (jitOutput), 0.0); + EXPECT_GT (benchmarkMagnitude (noSumOutput), 0.0); + + const auto modalKernel = [] (const YdspAudioGraph& graph) + { + for (const auto& kernel : graph.getExecutionReport().getKernels()) + if (kernel.name == "Modal") + return kernel; + + return YdspKernelReport {}; + }; + + const auto withSumKernel = modalKernel (withSum); + const auto noSumKernel = modalKernel (noSum); + + const auto describe = [] (const YdspKernelReport& kernel) + { + return String (kernel.instructionCount) + " insts, vectorized " + + (kernel.vectorized ? "yes" : "no") + " x" + String (kernel.vectorWidth) + + ", unrolled " + (kernel.unrolled ? "yes" : "no") + + ", split " + (kernel.reductionSplit ? "yes" : "no"); + }; + + std::cout << "\n | with sum: " << describe (withSumKernel) + << "\n | no sum: " << describe (noSumKernel) << "\n"; + + EXPECT_TRUE (withSumKernel.vectorized) << "the accumulating mode loop stopped being widened"; + EXPECT_TRUE (noSumKernel.vectorized) << "the element-wise mode loop is not being widened"; + EXPECT_LT (noSumKernel.instructionCount, withSumKernel.instructionCount) + << "the no-sum variant should be the smaller kernel"; + + EXPECT_EQ (4 <= 16 / withSumKernel.vectorWidth, withSumKernel.reductionSplit) + << "the split must fire exactly when the unrolled reduction chain has at least four links"; + EXPECT_FALSE (noSumKernel.reductionSplit) << "there is no accumulator here to split"; + + benchmarkDumpListingIfRequested ("with sum", withSum); + benchmarkReportListing ("with sum", benchmarkAnalyzeListing (withSum)); + benchmarkDumpListingIfRequested ("no sum", noSum); + benchmarkReportListing ("no sum", benchmarkAnalyzeListing (noSum)); + + benchmarkReportVariants ("modal bank: what the reduction costs", + "with sum", + withSumTiming, + "no sum", + noSumTiming); +} + +TEST_F (YdspBenchmarkTests, ChainedNodesAgainstAFusedProcessor) +{ + auto chained = compilePatch (benchmarkChainedSource, compiler); + auto fused = compilePatch (benchmarkFusedSource, compiler); + + ASSERT_TRUE (chained.isValid()); + ASSERT_TRUE (fused.isValid()); + + chained.prepare (benchmarkSampleRate, benchmarkBlockSize); + fused.prepare (benchmarkSampleRate, benchmarkBlockSize); + + std::vector fusedOutput (static_cast (benchmarkTotalSamples), 0.0f); + + const auto chainedTiming = benchmarkTimeRepeats ([&] + { + chained.reset(); + benchmarkRunGraph (chained, input, jitOutput); + }); + + const auto fusedTiming = benchmarkTimeRepeats ([&] + { + fused.reset(); + benchmarkRunGraph (fused, input, fusedOutput); + }); + + EXPECT_NEAR (benchmarkChecksum (fusedOutput), benchmarkChecksum (jitOutput), 1.0); + + benchmarkReportVariants ("kernel fusion: 3 chained nodes vs 1 fused processor", + "chained", + chainedTiming, + "fused", + fusedTiming); + + EXPECT_LT (chainedTiming.best / fusedTiming.best, 1.5) + << "the chained form is no longer being fused"; +} + +TEST_F (YdspBenchmarkTests, IdleVoiceSkippingAgainstEveryVoiceRunning) +{ + const String annotated (patches::electricPiano); + const auto unannotated = annotated.replace ("[[ role: voiceActivity ]]", ""); + + ASSERT_NE (annotated, unannotated); + + auto skipping = compilePatch (annotated, compiler); + auto everyVoice = compilePatch (unannotated, compiler); + + ASSERT_TRUE (skipping.isValid()); + ASSERT_TRUE (everyVoice.isValid()); + + skipping.prepare (benchmarkSampleRate, benchmarkBlockSize); + everyVoice.prepare (benchmarkSampleRate, benchmarkBlockSize); + + MidiBuffer midi; + midi.addEvent (MidiMessage::noteOn (1, 60, static_cast (100)), 0); + midi.addEvent (MidiMessage::noteOn (1, 64, static_cast (100)), 0); + + std::vector left (static_cast (benchmarkTotalSamples), 0.0f); + std::vector right (static_cast (benchmarkTotalSamples), 0.0f); + + const auto skippingTiming = benchmarkTimeRepeats ([&] + { + skipping.reset(); + benchmarkRunVoiceGraph (skipping, left, right, midi); + }); + + EXPECT_EQ (2, skipping.getActiveVoiceCount ("voices")); + + const auto everyVoiceTiming = benchmarkTimeRepeats ([&] + { + everyVoice.reset(); + benchmarkRunVoiceGraph (everyVoice, left, right, midi); + }); + + EXPECT_EQ (16, everyVoice.getActiveVoiceCount ("voices")); + + benchmarkReportVariants ("idle-voice skipping, EPVoice[16] with 2 notes held", + "skipping", + skippingTiming, + "all 16", + everyVoiceTiming); + + EXPECT_LT (skippingTiming.best / everyVoiceTiming.best, benchmarkVoiceSkippingLimit) + << "voice skipping collapsed: the two held voices cost close to what all sixteen do"; +} + +TEST_F (YdspBenchmarkTests, GraphLevelDryWetAgainstAnInlinedDryWet) +{ + YdspCompiler fannedCompiler; + auto fannedResult = fannedCompiler.compile (benchmarkFannedDryWetSource); + + auto inlined = compilePatch (benchmarkInlineDryWetSource, compiler); + ASSERT_TRUE (inlined.isValid()); + + inlined.prepare (benchmarkSampleRate, benchmarkBlockSize); + + const auto inlinedTiming = benchmarkTimeRepeats ([&] + { + inlined.reset(); + benchmarkRunGraph (inlined, input, jitOutput); + }); + + if (! fannedResult.wasOk()) + { + std::cout << "\n |==== BENCH ==== (graph-level dry/wet - fan-out + fan-in)\n" + << " inline baseline: " + << String (inlinedTiming.best * 1.0e9 / static_cast (benchmarkTotalSamples), 3) + << " ns/sample; the fanned patch does not analyze yet, so the comparison is skipped\n"; + + GTEST_SKIP() << "graph fan-out / summing fan-in is not implemented yet: " + << fannedCompiler.getDiagnostics().toString(); + } + + auto fanned = std::move (fannedResult).getValue(); + ASSERT_TRUE (fanned.isValid()); + + fanned.prepare (benchmarkSampleRate, benchmarkBlockSize); + + std::vector fannedOutput (static_cast (benchmarkTotalSamples), 0.0f); + + const auto fannedTiming = benchmarkTimeRepeats ([&] + { + fanned.reset(); + benchmarkRunGraph (fanned, input, fannedOutput); + }); + + EXPECT_NEAR (benchmarkChecksum (jitOutput), benchmarkChecksum (fannedOutput), 1.0); + + benchmarkReportVariants ("graph-level dry/wet (fan-out + fan-in) vs. dry/wet inside one processor", + "fanned", + fannedTiming, + "inline", + inlinedTiming); + + const auto perSample = [] (double seconds) + { + return seconds * 1.0e9 / static_cast (benchmarkTotalSamples); + }; + + auto split = compilePatch (benchmarkSplitDryWetSource, compiler); + ASSERT_TRUE (split.isValid()); + + split.prepare (benchmarkSampleRate, benchmarkBlockSize); + + std::vector splitDry (static_cast (benchmarkTotalSamples), 0.0f); + std::vector splitWet (static_cast (benchmarkTotalSamples), 0.0f); + + const auto splitTiming = benchmarkTimeRepeats ([&] + { + split.reset(); + benchmarkRunSplitGraph (split, input, splitDry, splitWet); + }); + + benchmarkReportVariants ("graph-level dry/wet: summed into one output vs. split across two", + "summed", + fannedTiming, + "split", + splitTiming); + + std::cout << " second kernel call + mix path: " + << String (perSample (fannedTiming.best) - perSample (inlinedTiming.best), 3) + << " ns/sample\n" + << " mix path alone: " + << String (perSample (fannedTiming.best) - perSample (splitTiming.best), 3) + << " ns/sample (best-of-" << benchmarkRepeats << ")\n"; +} + +//============================================================================== + +TEST_F (YdspBenchmarkTests, ScalarLibmCallCostProbe) +{ + // Print-only: the host libm scalar cost per call for the intrinsics the + // per-sample shapes call, so the compressor ratio can be read as + // "N libm calls + JIT overhead around them" instead of an opaque 1.4x. + // The scalar transcendental path in the JIT is always libm (fastMath only + // adds contraction and the AArch64 inline exp), so the callee cost below + // is the floor both sides pay; anything above it per sample is call+parking + // and loop overhead on the JIT side. + constexpr int numSamples = 4096; + constexpr int numIterations = 2000; + + std::vector input (static_cast (numSamples)); + std::vector exponent (static_cast (numSamples)); + + for (int i = 0; i < numSamples; ++i) + { + input[static_cast (i)] = 0.25f + 3.75f * static_cast (i % 997) / 996.0f; + exponent[static_cast (i)] = 1.25f + 0.75f * static_cast (i % 97) / 96.0f; + } + + float sink = 0.0f; + + const auto measureUnary = [&] (const char* name, float (*fn) (float)) + { + const auto timing = benchmarkTimeRepeats ([&] + { + for (int it = 0; it < numIterations; ++it) + for (int i = 0; i < numSamples; ++i) + sink += fn (input[static_cast (i)]); + }); + + std::cout << " " << name << ": " << String (timing.best * 1e9 / (numSamples * numIterations), 3) + << " ns/call (host libm)\n"; + }; + + const auto measureBinary = [&] (const char* name, float (*fn) (float, float)) + { + // The exponent varies per element so the host compiler cannot fold the + // call to a cheaper closed form (powf(x, 1.5) would become x*sqrtf(x)). + const auto timing = benchmarkTimeRepeats ([&] + { + for (int it = 0; it < numIterations; ++it) + for (int i = 0; i < numSamples; ++i) + sink += fn (input[static_cast (i)], exponent[static_cast (i)]); + }); + + std::cout << " " << name << ": " << String (timing.best * 1e9 / (numSamples * numIterations), 3) + << " ns/call (host libm)\n"; + }; + + std::cout << " scalar libm cost per call (floor both JIT and C++ pay):\n"; + + measureUnary ("expf", &::expf); + measureUnary ("log10f", &::log10f); + measureUnary ("logf", &::logf); + measureUnary ("tanhf", &::tanhf); + measureUnary ("sinf", &::sinf); + measureBinary ("powf", &::powf); + + std::cout << " shapes for reference (JIT total ns/sample, avg, fastMath): " + << "exp envelope 1x expf, tanh shaper 1x tanhf, compressor 1x log10f + 1x powf\n" + << " (sink " << String (sink, 2) << ")\n"; +} + +TEST_F (YdspBenchmarkTests, DenseEventsAcrossSmallBlockSizes) +{ + for (const int blockSize : { 16, 32, 64, 512 }) + { + YdspCompiler compiler; + auto graph = compilePatch (R"YDSP( + processor Voice { + input event midi; + input parameter float gain = 1.0; + output stream out; + state float velocity, modulation; + + event midi (e: noteOn) { velocity = e.velocity; } + event midi (e: controlChange) { modulation = e.value; } + process { out = gain * velocity * (0.5 + modulation); } + } + + graph G { + input event midi; + output stream y; + node v = Voice[16]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", compiler); + + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, blockSize, 1024, 1024).wasOk()); + + MidiBuffer notes, controllers; + for (int voice = 0; voice < 16; ++voice) + notes.addEvent (MidiMessage::noteOn (1, 48 + voice, 1.0f), 0); + + std::vector automation; + const auto slot = graph.getParameterSlot ("v.gain"); + ASSERT_GE (slot, 0); + + for (int offset = blockSize - 1; offset >= 0; --offset) + { + automation.push_back ({ slot, offset, 0.75f }); + automation.push_back ({ slot, offset, 1.0f }); + controllers.addEvent (MidiMessage::controllerEvent (1, 1, 64), offset); + } + + std::vector output (static_cast (blockSize)); + YdspOutputBuffer outputs[] { Span (output) }; + const MidiBuffer* noteInputs[] { ¬es }; + const MidiBuffer* controllerInputs[] { &controllers }; + const YdspProcessRequest request { {}, outputs, blockSize, controllerInputs, automation }; + + graph.prewarmKernels(); + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, blockSize, noteInputs })); + + bool succeeded = true; + const auto timing = benchmarkTimeRepeats ([&] + { + for (int block = 0; block < benchmarkBlockCount; ++block) + succeeded &= graph.process (request) == YdspProcessResult::ok; + }); + + EXPECT_TRUE (succeeded); + EXPECT_EQ (0u, graph.getDroppedEventCount()); + EXPECT_GT (output.back(), 0.0f); + + std::cout << " dense events, 16 voices, " << blockSize << " samples: " + << timing.best * 1.0e9 / benchmarkBlockCount << " ns/block; " + << graph.getScratchMemorySizeBytes() << " scratch bytes\n"; + } +} + +TEST_F (YdspBenchmarkTests, SmallAndLargePatchCompileCost) +{ + for (const int stages : { 8, 128 }) + { + String source = "processor P { input stream in; output stream out; process { let v0 = in; "; + for (int stage = 1; stage <= stages; ++stage) + source += "let v" + String (stage) + " = sin (v" + String (stage - 1) + ") + 0.001; "; + + source += "out = v" + String (stages) + + "; } } graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } }"; + + for (int repeat = 0; repeat < 3; ++repeat) + { + YdspCompiler compiler; +#if YUP_ENABLE_ALLOCATION_HOOKS + YdspAllocationCounter allocations; + allocations.start(); +#endif + const auto start = std::chrono::steady_clock::now(); + auto result = compiler.compile (source); + const auto milliseconds = std::chrono::duration (std::chrono::steady_clock::now() - start).count(); +#if YUP_ENABLE_ALLOCATION_HOOKS + const auto heapOperations = allocations.stop(); +#endif + + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + std::cout << " compile " << stages << " stages, run " << repeat + 1 << ": " << milliseconds << " ms"; +#if YUP_ENABLE_ALLOCATION_HOOKS + std::cout << ", " << heapOperations << " tracked heap operations"; +#endif + std::cout << '\n'; + } + } +} + +TEST_F (YdspBenchmarkTests, MatchAgainstEquivalentIfAcrossOptimizationPolicies) +{ + for (const bool bank : { false, true }) + for (const bool constant : { false, true }) + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic }) + { + const String selector = constant ? "true" : "v > 0.0"; + const String ifStatement = "if (" + selector + ") { y = v * 0.5 + dry; } else { y = v * 0.25 - dry; }"; + const String matchStatement = "match (" + selector + ") { true => { y = v * 0.5 + dry; }, _ => { y = v * 0.25 - dry; } }"; + + const auto source = [&] (const String& selection) + { + return String ("processor P { input stream in; output stream out; state float z[32]; process { let dry = in; ") + + (bank ? "for i in 0..32 { let v = z[i]; float y = 0.0; " : "let v = in; float y = 0.0; ") + + selection + (bank ? " z[i] = y; } out = z[0]; " : " out = y; ") + + "} } graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } }"; + }; + + YdspCompileOptions options; + options.optimizationTier = tier; + + auto ifGraph = compilePatch (source (ifStatement), compiler, options); + auto matchGraph = compilePatch (source (matchStatement), compiler, options); + ASSERT_TRUE (ifGraph.isValid()); + ASSERT_TRUE (matchGraph.isValid()); + ASSERT_TRUE (ifGraph.prepare (benchmarkSampleRate, benchmarkBlockSize).wasOk()); + ASSERT_TRUE (matchGraph.prepare (benchmarkSampleRate, benchmarkBlockSize).wasOk()); + + std::vector matchOutput (static_cast (benchmarkTotalSamples)); + + const auto ifTiming = benchmarkTimeRepeats ([&] + { + ifGraph.reset(); + benchmarkRunGraph (ifGraph, input, jitOutput); + }); + + const auto matchTiming = benchmarkTimeRepeats ([&] + { + matchGraph.reset(); + benchmarkRunGraph (matchGraph, input, matchOutput); + }); + + EXPECT_EQ (jitOutput, matchOutput); + + const auto label = String (bank ? "match vs if: bank" : "match vs if: scalar") + + (constant ? ", constant" : ", dynamic") + ", tier=" + String (static_cast (tier)); + benchmarkReportVariants (label.toRawUTF8(), "match", matchTiming, "if", ifTiming); + } +} + +} // namespace yup::test diff --git a/tests/yup_dsp_jit/yup_YdspBundleTests.cpp b/tests/yup_dsp_jit/yup_YdspBundleTests.cpp new file mode 100644 index 000000000..28960cad5 --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspBundleTests.cpp @@ -0,0 +1,630 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +using namespace yup; + +namespace +{ + +const char* simplePatch = R"YDSP( + processor Passthrough + { + input stream in; + output stream out; + process { out = in; } + } + + graph Main + { + input stream in; + output stream out; + node p = Passthrough; + connection + { + in -> p.in; + p.out -> out; + } + } +)YDSP"; + +} // namespace + +class YdspBundleArtifactTests : public ::testing::Test +{ +public: + static YdspBundleCompileOptions allTargets() + { + YdspBundleCompileOptions options; + + for (const auto os : { YdspTargetOperatingSystem::macosTarget, YdspTargetOperatingSystem::linuxTarget, YdspTargetOperatingSystem::windowsTarget }) + for (const auto arch : { YdspTargetArchitecture::arm64, YdspTargetArchitecture::x64 }) + options.nativeTargets.push_back ({ os, arch }); + + return options; + } + + static void expectGain (YdspAudioGraph& graph, float gain) + { + graph.prepare (48000.0, 8); + + const std::array input { 0.25f, -0.25f, 0.5f, -0.5f, 1.0f, -1.0f, 0.0f, 0.125f }; + std::array output {}; + const YdspInputBuffer inputs[] { Span (input.data(), input.size()) }; + YdspOutputBuffer outputs[] { Span (output.data(), output.size()) }; + + ASSERT_EQ (YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { inputs, outputs, 8 })); + + for (size_t i = 0; i < output.size(); ++i) + EXPECT_FLOAT_EQ (input[i] * gain, output[i]); + } +}; + +TEST (YdspBundleTests, DiagnosticRangesAndExcerptsSurviveRoundTrip) +{ + YdspCompiler compiler; + auto compiled = compiler.compileBundle (String ("declare unknown_key \"value\";\n") + simplePatch, + YdspBundleArtifactTests::allTargets()); + ASSERT_TRUE (compiled.wasOk()) << compiler.getDiagnostics().toString(); + ASSERT_GT (compiled.getReference().getDiagnostics().getCount(), 0); + + MemoryBlock bytes; + ASSERT_TRUE (compiled.getReference().saveToMemoryBlock (bytes).wasOk()); + auto loaded = YdspBundle::loadFromMemoryBlock (bytes); + ASSERT_TRUE (loaded.wasOk()) << loaded.getErrorMessage(); + const auto& diagnostics = loaded.getReference().getDiagnostics(); + EXPECT_EQ (compiled.getReference().getDiagnostics().toString(), diagnostics.toString()); + EXPECT_EQ ("source-0", diagnostics.getItem (0).range.sourceId); + EXPECT_TRUE (diagnostics.toString().contains ("source-0:1:9: warning:")); + EXPECT_TRUE (diagnostics.toString().contains ("^~~~~~~~~~~")); +} + +TEST (YdspBundleTests, CompileAndLoadMemoryRoundTrip) +{ + YdspCompiler compiler; + const auto options = YdspBundleArtifactTests::allTargets(); + + auto compiled = compiler.compileBundle (simplePatch, options); + ASSERT_TRUE (compiled.wasOk()) << compiler.getDiagnostics().toString(); + + MemoryBlock bytes; + ASSERT_TRUE (compiled.getReference().saveToMemoryBlock (bytes).wasOk()); + + auto loaded = YdspBundle::loadFromMemoryBlock (bytes); + ASSERT_TRUE (loaded.wasOk()) << loaded.getErrorMessage(); + EXPECT_EQ (1u, loaded.getReference().getSources().size()); + EXPECT_EQ (options.nativeTargets.size(), static_cast (loaded.getReference().getNativeTargets().size())); + + auto graph = loaded.getReference().instantiate(); + ASSERT_TRUE (graph.wasOk()) << graph.getErrorMessage(); + EXPECT_TRUE (graph.getReference().isValid()); + YdspBundleArtifactTests::expectGain (graph.getReference(), 1.0f); +} + +TEST (YdspBundleTests, FileRoundTripPreservesBundle) +{ + YdspCompiler compiler; + auto compiled = compiler.compileBundle (simplePatch, {}); + ASSERT_TRUE (compiled.wasOk()) << compiler.getDiagnostics().toString(); + + const auto file = File::createTempFile ("ydsp-bundle.ydsb"); + ASSERT_TRUE (compiled.getReference().saveToFile (file).wasOk()); + + auto loaded = YdspBundle::loadFromFile (file); + EXPECT_TRUE (loaded.wasOk()) << loaded.getErrorMessage(); + if (loaded.wasOk()) + EXPECT_EQ (compiled.getReference().getSources()[0].source, + loaded.getReference().getSources()[0].source); + + file.deleteFile(); +} + +TEST (YdspBundleTests, RejectsCorruptHeader) +{ + YdspCompiler compiler; + auto compiled = compiler.compileBundle (simplePatch, {}); + ASSERT_TRUE (compiled.wasOk()) << compiler.getDiagnostics().toString(); + + MemoryBlock bytes; + ASSERT_TRUE (compiled.getReference().saveToMemoryBlock (bytes).wasOk()); + static_cast (bytes.getData())[0] ^= 0xff; + + EXPECT_TRUE (YdspBundle::loadFromMemoryBlock (bytes).failed()); +} + +TEST (YdspBundleTests, RejectsOldLanguageVersionWithParameterMigrationDiagnostic) +{ + YdspCompiler compiler; + auto compiled = compiler.compileBundle (simplePatch, {}); + ASSERT_TRUE (compiled.wasOk()) << compiler.getDiagnostics().toString(); + + MemoryBlock bytes; + ASSERT_TRUE (compiled.getReference().saveToMemoryBlock (bytes).wasOk()); + + // RIFF header, VERS chunk, then the META header and language version. + ASSERT_GE (bytes.getSize(), 36u); + auto* data = static_cast (bytes.getData()); + ASSERT_EQ ('M', data[24]); + ASSERT_EQ ('E', data[25]); + ASSERT_EQ ('T', data[26]); + ASSERT_EQ ('A', data[27]); + ASSERT_EQ (4, data[32]); + data[32] = 2; + + const auto loaded = YdspBundle::loadFromMemoryBlock (bytes); + ASSERT_TRUE (loaded.failed()); + EXPECT_TRUE (loaded.getErrorMessage().contains ("'input value' has been replaced by 'input parameter'")); +} + +TEST (YdspBundleTests, StoresImportedSourceClosure) +{ + const auto directory = File::getSpecialLocation (File::tempDirectory).getChildFile ("yup_ydsp_bundle_import_test"); + directory.deleteRecursively(); + ASSERT_TRUE (directory.getChildFile ("fx").createDirectory()); + + directory.getChildFile ("fx/Gain.ydsp").replaceWithText ("processor Gain { input stream in; output stream out; process { out = in; } }\n"); + + const auto source = R"YDSP( + import fx.Gain as fx; + graph Main { + input stream in; + output stream out; + node gain = fx.Gain; + connection { in -> gain.in; gain.out -> out; } + } + )YDSP"; + + YdspCompiler compiler; + auto compiled = compiler.compileBundle (source, YdspBundleArtifactTests::allTargets(), directory.getChildFile ("Main.ydsp").getFullPathName()); + ASSERT_TRUE (compiled.wasOk()) << compiler.getDiagnostics().toString(); + ASSERT_EQ (2u, compiled.getReference().getSources().size()); + EXPECT_EQ ("source-0", compiled.getReference().getSources()[0].id); + EXPECT_TRUE (compiled.getReference().getSources()[0].isRoot); + EXPECT_EQ ("source-1", compiled.getReference().getSources()[1].id); + EXPECT_FALSE (compiled.getReference().getSources()[1].isRoot); + + MemoryBlock bytes; + ASSERT_TRUE (compiled.getReference().saveToMemoryBlock (bytes).wasOk()); + auto loaded = YdspBundle::loadFromMemoryBlock (bytes); + ASSERT_TRUE (loaded.wasOk()) << loaded.getErrorMessage(); + EXPECT_EQ (2u, loaded.getReference().getSources().size()); + directory.deleteRecursively(); + + // The packaged import must remain usable after its source file is gone. + auto graph = loaded.getReference().instantiate(); + ASSERT_TRUE (graph.wasOk()) << graph.getErrorMessage(); + YdspBundleArtifactTests::expectGain (graph.getReference(), 1.0f); +} + +TEST_F (YdspBundleArtifactTests, EmitsBothArchitecturesAndPortableWasm) +{ + YdspCompiler compiler; + + auto compiled = compiler.compileBundle (simplePatch, allTargets()); + ASSERT_TRUE (compiled.wasOk()) << compiled.getErrorMessage(); + + const auto& bundle = compiled.getReference(); + ASSERT_EQ (6u, bundle.getNativeArtifacts().size()); + + for (const auto& target : bundle.getNativeArtifacts()) + { + ASSERT_FALSE (target.kernels.empty()); + EXPECT_FALSE (target.kernels.front().code.empty()); + } + + EXPECT_NE (bundle.getNativeArtifacts()[0].kernels.front().code, + bundle.getNativeArtifacts()[1].kernels.front().code); + + ASSERT_FALSE (bundle.getWasmModules().empty()); + const auto& wasm = bundle.getWasmModules().front(); + ASSERT_GE (wasm.size(), 8u); + EXPECT_EQ (0, wasm[0]); + EXPECT_EQ ('a', wasm[1]); + EXPECT_EQ ('s', wasm[2]); + EXPECT_EQ ('m', wasm[3]); +} + +TEST_F (YdspBundleArtifactTests, EmitsShortCircuitBranchesForEveryTarget) +{ + const auto source = R"YDSP( + processor P { + input stream in; output stream out; + state float gain; + func setGain (value: float): float { gain = value; return value; } + init { let value = true ? setGain (1.0) : setGain (100.0); } + process { + let positive = in > 0.0; + let a = positive && (setGain (2.0) > 0.0); + let b = positive || (setGain (3.0) > 0.0); + out = (a ? in : (b ? in : 0.0)); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + YdspCompiler compiler; + auto compiled = compiler.compileBundle (source, allTargets()); + ASSERT_TRUE (compiled.wasOk()) << compiler.getDiagnostics().toString(); + EXPECT_EQ (6u, compiled.getReference().getNativeArtifacts().size()); + EXPECT_EQ (2u, compiled.getReference().getWasmModules().size()); +} + +TEST_F (YdspBundleArtifactTests, PreservesExactIntegerLiteralsThroughSerialization) +{ + const auto source = R"YDSP( + processor P { + input parameter int64 initial = 9223372036854775807; + output parameter int64 maximum, minimum, exact; + output stream out; + state int64 saved = 9007199254740993; + func lowest(): int64 { return -9223372036854775808; } + process { maximum = initial; minimum = lowest(); exact = saved; out = 0.0; } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP"; + YdspCompiler compiler; + auto compiled = compiler.compileBundle (source, allTargets()); + ASSERT_TRUE (compiled.wasOk()) << compiler.getDiagnostics().toString(); + EXPECT_EQ (6u, compiled.getReference().getNativeArtifacts().size()); + EXPECT_EQ (2u, compiled.getReference().getWasmModules().size()); + + MemoryBlock bytes; + ASSERT_TRUE (compiled.getReference().saveToMemoryBlock (bytes).wasOk()); + auto loaded = YdspBundle::loadFromMemoryBlock (bytes); + ASSERT_TRUE (loaded.wasOk()) << loaded.getErrorMessage(); + auto instance = loaded.getReference().instantiate(); + ASSERT_TRUE (instance.wasOk()) << instance.getErrorMessage(); + auto& graph = instance.getReference(); + ASSERT_TRUE (graph.prepare (48000.0, 1).wasOk()); + float output = 0.0f; + YdspOutputBuffer outputs[] { Span (&output, 1) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 1 })); + EXPECT_EQ (std::numeric_limits::max(), graph.getIntOutputValue ("p.maximum")); + EXPECT_EQ (std::numeric_limits::min(), graph.getIntOutputValue ("p.minimum")); + EXPECT_EQ (9007199254740993LL, graph.getIntOutputValue ("p.exact")); +} + +TEST_F (YdspBundleArtifactTests, EmitsSaturatingFloatConversionsForEveryTarget) +{ + const auto source = R"YDSP( + processor P { + input parameter float32 a = 1.0e20; + input parameter float64 b = 1.0e20; + output parameter int64 a32, a64, b32, b64; + output stream out; + process { + a32 = int64 (int32 (a)); a64 = int64 (a); + b32 = int64 (int32 (b)); b64 = int64 (b); + out = 0.0; + } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP"; + YdspCompiler compiler; + auto compiled = compiler.compileBundle (source, allTargets()); + ASSERT_TRUE (compiled.wasOk()) << compiler.getDiagnostics().toString(); + EXPECT_EQ (6u, compiled.getReference().getNativeArtifacts().size()); + EXPECT_EQ (1u, compiled.getReference().getWasmModules().size()); + MemoryBlock bytes; + ASSERT_TRUE (compiled.getReference().saveToMemoryBlock (bytes).wasOk()); + auto loaded = YdspBundle::loadFromMemoryBlock (bytes); + ASSERT_TRUE (loaded.wasOk()) << loaded.getErrorMessage(); + auto instance = loaded.getReference().instantiate(); + ASSERT_TRUE (instance.wasOk()) << instance.getErrorMessage(); + auto& graph = instance.getReference(); + ASSERT_TRUE (graph.prepare (48000.0, 1).wasOk()); + float output = 0.0f; + YdspOutputBuffer outputs[] { Span (&output, 1) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 1 })); + for (const auto* meter : { "p.a32", "p.b32" }) + EXPECT_EQ (std::numeric_limits::max(), graph.getIntOutputValue (meter)); + for (const auto* meter : { "p.a64", "p.b64" }) + EXPECT_EQ (std::numeric_limits::max(), graph.getIntOutputValue (meter)); +} + +TEST_F (YdspBundleArtifactTests, EmitsSaturatingIntegerOperationsForEveryTarget) +{ + const auto source = R"YDSP( + processor P { + input parameter int32 a = -2147483648, b = -1; + input parameter int64 c = -9223372036854775808, d = -1; + output parameter int64 neg32, abs32, div32, rem32; + output parameter int64 neg64, abs64, div64, rem64; + output parameter int64 sum32, sub32, mul32, sum64, sub64, mul64; + output stream out; + process { + neg32 = int64 (-a); abs32 = int64 (abs (a)); div32 = int64 (a / b); rem32 = int64 (a % b); + neg64 = -c; abs64 = abs (c); div64 = c / d; rem64 = c % d; + sum32 = int64 (a + b); sub32 = int64 (b - a); mul32 = int64 (a * b); + sum64 = c + d; sub64 = d - c; mul64 = c * d; + out = 0.0; + } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP"; + YdspCompiler compiler; + auto compiled = compiler.compileBundle (source, allTargets()); + ASSERT_TRUE (compiled.wasOk()) << compiler.getDiagnostics().toString(); + EXPECT_EQ (6u, compiled.getReference().getNativeArtifacts().size()); + EXPECT_EQ (1u, compiled.getReference().getWasmModules().size()); + MemoryBlock bytes; + ASSERT_TRUE (compiled.getReference().saveToMemoryBlock (bytes).wasOk()); + auto loaded = YdspBundle::loadFromMemoryBlock (bytes); + ASSERT_TRUE (loaded.wasOk()) << loaded.getErrorMessage(); + auto instance = loaded.getReference().instantiate(); + ASSERT_TRUE (instance.wasOk()) << instance.getErrorMessage(); + auto& graph = instance.getReference(); + ASSERT_TRUE (graph.prepare (48000.0, 1).wasOk()); + float output = 0.0f; + YdspOutputBuffer outputs[] { Span (&output, 1) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 1 })); + for (const auto* meter : { "p.neg32", "p.abs32", "p.div32", "p.sub32", "p.mul32" }) + EXPECT_EQ (std::numeric_limits::max(), graph.getIntOutputValue (meter)); + for (const auto* meter : { "p.neg64", "p.abs64", "p.div64", "p.sub64", "p.mul64" }) + EXPECT_EQ (std::numeric_limits::max(), graph.getIntOutputValue (meter)); + EXPECT_EQ (0, graph.getIntOutputValue ("p.rem32")); + EXPECT_EQ (0, graph.getIntOutputValue ("p.rem64")); + EXPECT_EQ (std::numeric_limits::min(), graph.getIntOutputValue ("p.sum32")); + EXPECT_EQ (std::numeric_limits::min(), graph.getIntOutputValue ("p.sum64")); +} + +TEST_F (YdspBundleArtifactTests, ResolvesLibmAndInitKernelsAfterSerialization) +{ + const auto source = R"YDSP( + processor Gain { + input stream in; + output stream out; + state float gain; + init { gain = cos (0.0) + 1.0; } + process { out = in * gain + sin (in); } + } + graph Main { + input stream in; + output stream out; + node gain = Gain; + connection { in -> gain.in; gain.out -> out; } + } + )YDSP"; + + YdspCompiler compiler; + + auto compiled = compiler.compileBundle (source, allTargets()); + ASSERT_TRUE (compiled.wasOk()) << compiled.getErrorMessage(); + + for (const auto& target : compiled.getReference().getNativeArtifacts()) + { + ASSERT_EQ (2u, target.kernels.size()); + ASSERT_FALSE (target.kernels[0].symbols.empty()); + for (const auto& symbol : target.kernels[0].symbols) + { + ASSERT_LE (symbol.offset + 8, target.kernels[0].code.size()); + for (size_t i = 0; i < 8; ++i) + EXPECT_EQ (0, target.kernels[0].code[static_cast (symbol.offset) + i]); + } + } + + MemoryBlock bytes; + ASSERT_TRUE (compiled.getReference().saveToMemoryBlock (bytes).wasOk()); + auto loaded = YdspBundle::loadFromMemoryBlock (bytes); + ASSERT_TRUE (loaded.wasOk()) << loaded.getErrorMessage(); + + auto graph = loaded.getReference().instantiate(); + ASSERT_TRUE (graph.wasOk()) << graph.getErrorMessage(); + graph.getReference().prepare (48000.0, 1); + + const float input = 0.25f; + float output = 0.0f; + const YdspInputBuffer inputs[] { Span (&input, 1) }; + YdspOutputBuffer outputs[] { Span (&output, 1) }; + + ASSERT_EQ (YdspProcessResult::ok, graph.getReference().process (yup::YdspProcessRequest { inputs, outputs, 1 })); + EXPECT_NEAR (0.5f + std::sin (input), output, 1.0e-6f); +} + +#if ! YUP_WASM +TEST_F (YdspBundleArtifactTests, ForeignOnlyBundleCannotExecute) +{ + auto options = allTargets(); + options.includeWasm = false; + for (const auto target : options.nativeTargets) + { + YdspBundleCompileOptions single; + single.includeWasm = false; + single.nativeTargets = { target }; + + YdspCompiler compiler; + auto compiled = compiler.compileBundle (simplePatch, single); + ASSERT_TRUE (compiled.wasOk()) << compiled.getErrorMessage(); + +#if YUP_WINDOWS + const auto expectedOs = YdspTargetOperatingSystem::windowsTarget; +#elif YUP_MAC + const auto expectedOs = YdspTargetOperatingSystem::macosTarget; +#else + const auto expectedOs = YdspTargetOperatingSystem::linuxTarget; +#endif + + const auto expectedArch = ASMJIT_ARCH_ARM == 64 ? YdspTargetArchitecture::arm64 : YdspTargetArchitecture::x64; + + auto graph = compiled.getReference().instantiate(); + EXPECT_EQ (target.operatingSystem == expectedOs && target.architecture == expectedArch, graph.wasOk()); + } +} +#endif + +TEST_F (YdspBundleArtifactTests, RejectsEmptyTargetSelection) +{ + YdspBundleCompileOptions options; + options.includeWasm = false; + + YdspCompiler compiler; + EXPECT_TRUE (compiler.compileBundle (simplePatch, options).failed()); +} + +TEST_F (YdspBundleArtifactTests, LoadsEventHandlersAndRebindsEmissionHelper) +{ + const auto source = R"YDSP( + processor Source { + input stream trig; + output event notes; + process { if (trig > 0.5) { emit noteOn (pitch: 72, velocity: 0.5) -> notes; } } + } + processor Voice { + input event midi; + output stream out; + state float sounding; + event midi (e: noteOn) { sounding = e.pitch + e.velocity; } + process { out = sounding; } + } + graph Main { + input stream trig; + output stream y; + node source = Source; + node voice = Voice; + connection { trig -> source.trig; source.notes -> voice.midi; voice.out -> y; } + } + )YDSP"; + + YdspCompiler compiler; + auto compiled = compiler.compileBundle (source, allTargets()); + ASSERT_TRUE (compiled.wasOk()) << compiled.getErrorMessage(); + + MemoryBlock bytes; + ASSERT_TRUE (compiled.getReference().saveToMemoryBlock (bytes).wasOk()); + auto loaded = YdspBundle::loadFromMemoryBlock (bytes); + ASSERT_TRUE (loaded.wasOk()) << loaded.getErrorMessage(); + + auto graph = loaded.getReference().instantiate(); + ASSERT_TRUE (graph.wasOk()) << graph.getErrorMessage(); + graph.getReference().prepare (48000.0, 4); + + const std::array input { 0.0f, 0.0f, 1.0f, 0.0f }; + std::array output {}; + const YdspInputBuffer inputs[] { Span (input.data(), input.size()) }; + YdspOutputBuffer outputs[] { Span (output.data(), output.size()) }; + + ASSERT_EQ (YdspProcessResult::ok, graph.getReference().process (yup::YdspProcessRequest { inputs, outputs, 4 })); + EXPECT_FLOAT_EQ (0.0f, output[0]); + EXPECT_FLOAT_EQ (0.0f, output[1]); + EXPECT_FLOAT_EQ (72.5f, output[2]); + EXPECT_FLOAT_EQ (72.5f, output[3]); +} + +TEST_F (YdspBundleArtifactTests, EmitsDuplicateTargetsOnce) +{ + auto options = allTargets(); + options.nativeTargets.push_back (options.nativeTargets.front()); + + YdspCompiler compiler; + + auto compiled = compiler.compileBundle (simplePatch, options); + ASSERT_TRUE (compiled.wasOk()) << compiled.getErrorMessage(); + EXPECT_EQ (6u, compiled.getReference().getNativeArtifacts().size()); +} + +TEST_F (YdspBundleArtifactTests, RejectsInvalidTargetArchitecture) +{ + auto options = allTargets(); + options.nativeTargets.front().architecture = static_cast (99); + + YdspCompiler compiler; + EXPECT_TRUE (compiler.compileBundle (simplePatch, options).failed()); +} + +TEST_F (YdspBundleArtifactTests, RejectsIncompatibleCodegenRevision) +{ + YdspCompiler compiler; + auto compiled = compiler.compileBundle (simplePatch, allTargets()); + ASSERT_TRUE (compiled.wasOk()) << compiled.getErrorMessage(); + + MemoryBlock bytes; + ASSERT_TRUE (compiled.getReference().saveToMemoryBlock (bytes).wasOk()); + ASSERT_GT (bytes.getSize(), 44u); + static_cast (bytes.getData())[44] = 0xff; + EXPECT_TRUE (YdspBundle::loadFromMemoryBlock (bytes).failed()); +} + +TEST_F (YdspBundleArtifactTests, TraceRoundTripPreservesSitesAndAllTargetHelpers) +{ + YdspCompiler compiler; + auto options = allTargets(); + options.enableTracing = true; + auto compiled = compiler.compileBundle (R"YDSP( + processor P { + output stream out; + init { trace("init"); } + process { + int64 value = -9007199254740993; + float f = 0.5; bool b = true; + trace("value={value}, f={f}, b={b}"); + out = 0.0; + } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP", options); + ASSERT_TRUE (compiled.wasOk()) << compiler.getDiagnostics().toString(); + MemoryBlock bytes; + ASSERT_TRUE (compiled.getReference().saveToMemoryBlock (bytes).wasOk()); + auto loaded = YdspBundle::loadFromMemoryBlock (bytes); + ASSERT_TRUE (loaded.wasOk()) << loaded.getErrorMessage(); + auto instance = loaded.getReference().instantiate(); + ASSERT_TRUE (instance.wasOk()) << instance.getErrorMessage(); + auto& graph = instance.getReference(); + ASSERT_TRUE (graph.prepare (48000.0, 1).wasOk()); + float sample = 0.0f; + YdspOutputBuffer outputs[] { Span (&sample, 1) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 1 })); + const auto messages = graph.drainTraceMessages(); + ASSERT_EQ (2, messages.size()); + EXPECT_EQ (String ("init"), messages[0]); + EXPECT_EQ (String ("value=-9007199254740993, f=") + String (0.5) + ", b=1", messages[1]); +} + +TEST_F (YdspBundleArtifactTests, MatchRoundTripPreservesArmSelection) +{ + YdspCompiler compiler; + auto compiled = compiler.compileBundle (R"YDSP( + processor P { + output stream out; state int counter; + process { + match (counter) { 0 => { out = 0.25; }, _ => { out = 0.75; } } + counter = counter + 1; + } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP", allTargets()); + ASSERT_TRUE (compiled.wasOk()) << compiler.getDiagnostics().toString(); + MemoryBlock bytes; + ASSERT_TRUE (compiled.getReference().saveToMemoryBlock (bytes).wasOk()); + auto loaded = YdspBundle::loadFromMemoryBlock (bytes); + ASSERT_TRUE (loaded.wasOk()) << loaded.getErrorMessage(); + auto instance = loaded.getReference().instantiate(); + ASSERT_TRUE (instance.wasOk()) << instance.getErrorMessage(); + auto& graph = instance.getReference(); + ASSERT_TRUE (graph.prepare (48000.0, 2).wasOk()); + float samples[2] {}; + YdspOutputBuffer outputs[] { Span (samples, 2) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 2 })); + EXPECT_EQ (0.25f, samples[0]); + EXPECT_EQ (0.75f, samples[1]); +} diff --git a/tests/yup_dsp_jit/yup_YdspCompilerOptionsTests.cpp b/tests/yup_dsp_jit/yup_YdspCompilerOptionsTests.cpp new file mode 100644 index 000000000..2e182c274 --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspCompilerOptionsTests.cpp @@ -0,0 +1,216 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +using namespace yup; + +namespace +{ + +constexpr auto compilerOptionsSource = R"YDSP( + processor P { + input stream in; + output stream out; + state float bank[8]; + + process { + float sum = 0.0; + + for i in 0..8 { + bank[i] = bank[i] * 0.5 + in; + sum = sum + bank[i]; + } + + out = sum; + } + } + + graph G { + input stream x; + output stream y; + node p = P; + connection { x -> p.in; p.out -> y; } + } +)YDSP"; + +} // namespace + +//============================================================================== + +TEST (YdspCompilerOptionsTests, BaselineScalarTierLeavesTheKernelScalar) +{ + YdspCompileOptions options; + options.optimizationTier = YdspOptimizationTier::baseline; + options.targetPolicy = YdspTargetPolicy::baseline; + options.baselineTarget = YdspNativeTarget::scalar; + options.fastMath = false; // fastMath now defaults on natively; keep this tier strict + options.emitOptimizationReport = true; + + YdspCompiler compiler; + auto result = compiler.compile (compilerOptionsSource, options); + + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + const auto& optimizationReport = compiler.getOptimizationReport(); + EXPECT_EQ (YdspOptimizationTier::baseline, optimizationReport.optimizationTier); + EXPECT_EQ (YdspNativeTarget::scalar, optimizationReport.selectedIsa); + EXPECT_EQ (1, optimizationReport.vectorWidth); + EXPECT_FALSE (optimizationReport.vectorizationEnabled); + EXPECT_FALSE (optimizationReport.unrollingEnabled); + EXPECT_FALSE (optimizationReport.reductionSplittingEnabled); + EXPECT_FALSE (optimizationReport.contractionEnabled); + EXPECT_GT (optimizationReport.generatedCodeSize, 0u); + EXPECT_GE (optimizationReport.compileTimeMilliseconds, 0.0); + EXPECT_FALSE (optimizationReport.cacheHit); + EXPECT_TRUE (optimizationReport.cacheDecision.contains ("No persistent")); + + const auto graph = std::move (result).getValue(); + ASSERT_FALSE (graph.getExecutionReport().getKernels().empty()); + EXPECT_FALSE (graph.getExecutionReport().getKernels().front().vectorized); + EXPECT_FALSE (graph.getExecutionReport().getKernels().front().unrolled); +} + +TEST (YdspCompilerOptionsTests, FastMathIsExplicitAndReported) +{ + YdspCompileOptions options; + options.fastMath = true; + options.emitOptimizationReport = true; + + YdspCompiler compiler; + const auto result = compiler.compile (compilerOptionsSource, options); + + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + const auto& optimizationReport = compiler.getOptimizationReport(); + EXPECT_TRUE (optimizationReport.fastMath); + EXPECT_TRUE (optimizationReport.contractionEnabled); + EXPECT_GT (optimizationReport.generatedCodeSize, 0u); +} + +TEST (YdspCompilerOptionsTests, DisablingTheReportAvoidsReportCollection) +{ + YdspCompileOptions options; + + YdspCompiler compiler; + const auto result = compiler.compile (compilerOptionsSource, options); + + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + const auto& optimizationReport = compiler.getOptimizationReport(); + EXPECT_EQ (0u, optimizationReport.generatedCodeSize); + EXPECT_EQ (0.0, optimizationReport.compileTimeMilliseconds); + EXPECT_TRUE (optimizationReport.cacheDecision.isEmpty()); +} + +TEST (YdspCompilerOptionsTests, ScalarFloat32ExpInlinePathIsUsedUnderFastMathOnAArch64) +{ +#if ASMJIT_ARCH_ARM + const auto source = R"YDSP( + processor P { input stream in; output stream out; process { out = exp (in); } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + + // A scalar baseline keeps the per-sample loop scalar, so the float32 exp + // lowering is the thing under test. Under fastMath the inline degree-8 + // Estrin polynomial runs on the hot path (it keeps a rare libm fallback + // for |x| > 1, so the kernel still contains a blr); strict mode calls + // libm expf every sample and contains no polynomial at all. + YdspCompileOptions options; + options.optimizationTier = YdspOptimizationTier::baseline; + options.targetPolicy = YdspTargetPolicy::baseline; + options.baselineTarget = YdspNativeTarget::scalar; + + options.fastMath = false; + YdspCompiler strictCompiler; + auto strictResult = strictCompiler.compile (source, options); + ASSERT_TRUE (strictResult.wasOk()) << strictCompiler.getDiagnostics().toString(); + + const auto strictGraph = std::move (strictResult).getValue(); + ASSERT_TRUE (strictGraph.isValid()); + const auto strictListing = strictGraph.getDiagnostics().toString(); + EXPECT_TRUE (strictListing.contains ("blr")); + EXPECT_FALSE (strictListing.contains ("fmadd")); + + options.fastMath = true; + YdspCompiler fastCompiler; + auto fastResult = fastCompiler.compile (source, options); + ASSERT_TRUE (fastResult.wasOk()) << fastCompiler.getDiagnostics().toString(); + + const auto fastGraph = std::move (fastResult).getValue(); + ASSERT_TRUE (fastGraph.isValid()); + const auto fastListing = fastGraph.getDiagnostics().toString(); + EXPECT_TRUE (fastListing.contains ("fmadd")) << "the inline Estrin exp must be present under fastMath"; +#endif +} + +TEST (YdspCompilerOptionsTests, ValidationAcceptsLibrariesWithoutAnExecutableGraph) +{ + for (const auto* source : { + "func twice (x: float) : float { return x * 2.0; }", + "processor Gain { input stream in; output stream out; process { out = in * 2.0; } }", + "let scale = 2; func twice (x: float) : float { return x * float (scale); }" }) + { + SCOPED_TRACE (source); + YdspCompiler compiler; + EXPECT_TRUE (compiler.validate (source).wasOk()) << compiler.getDiagnostics().toString(); + EXPECT_FALSE (compiler.getDiagnostics().hasErrors()); + EXPECT_FALSE (compiler.compile (source).wasOk()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ("at least one graph")); + } +} + +TEST (YdspCompilerOptionsTests, ValidationStillChecksProcessorAndLibraryBodies) +{ + for (const auto* source : { + "func twice (x: float) : float { return missing; }", + "func twice (x: float) : float { if (x > 0.0) { return missing; } return x; }", + "func twice (x: float) : float { let value = missing; return value; }", + "processor Gain { output stream out; func unused (x: float) : float { return missing; } process { out = 0.0; } }", + "processor Gain { output stream out; process { out = missing; } }" }) + { + SCOPED_TRACE (source); + YdspCompiler compiler; + ASSERT_FALSE (compiler.validate (source).wasOk()); + ASSERT_TRUE (compiler.getDiagnostics().hasErrors()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ("missing")); + EXPECT_FALSE (compiler.getDiagnostics().toString().contains ("at least one graph")); + EXPECT_GT (compiler.getDiagnostics().getItem (0).range.startColumn, 0); + } +} + +TEST (YdspCompilerOptionsTests, ValidationScopesFunctionParametersAndLocals) +{ + const auto* source = R"( + func twice (x: float) : float { let value = x * 2.0; return value; } + func half (x: float) : float { let value = x * 0.5; return twice (value); } + processor Gain { + output stream out; + state float x = 1.0; + func local (x: float) : float { let value = half (x); return value; } + process { let value = x; out = local (value); } + } + )"; + YdspCompiler compiler; + EXPECT_TRUE (compiler.validate (source).wasOk()) << compiler.getDiagnostics().toString(); +} diff --git a/tests/yup_dsp_jit/yup_YdspDiagnosticsTests.cpp b/tests/yup_dsp_jit/yup_YdspDiagnosticsTests.cpp new file mode 100644 index 000000000..d178a1d54 --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspDiagnosticsTests.cpp @@ -0,0 +1,350 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include + +namespace yup::test +{ + +//============================================================================== +// YdspDiagnosticsTests +//============================================================================== + +TEST (YdspJitDiagnosticsTests, ShowsFiveLinesWithIssueThirdAndFullPath) +{ + YdspDiagnostics diagnostics; + diagnostics.setSource ("outside before\none\ntwo\n bad token\nfour\nfive\noutside after", "/patches/main.ydsp"); + diagnostics.addError ({ 4, 3, 4, 6 }, "Unknown symbol"); + + EXPECT_EQ (String ("/patches/main.ydsp:4:3: error: Unknown symbol\n" + " 2 | one\n" + " 3 | two\n" + " 4 | bad token\n" + " ^~~\n" + " 5 | four\n" + " 6 | five"), + diagnostics.toString()); +} + +TEST (YdspJitDiagnosticsTests, ClampsContextAtBothFileBoundaries) +{ + YdspDiagnostics diagnostics; + diagnostics.setSource ("first\nsecond\nthird\nfourth", "short.ydsp"); + diagnostics.addError ({ 1, 1, 1, 2 }, "first issue"); + diagnostics.addWarning ({ 4, 7, 4, 7 }, "last issue"); + + EXPECT_EQ (String ("short.ydsp:1:1: error: first issue\n" + " 1 | first\n ^\n 2 | second\n 3 | third\n" + "short.ydsp:4:7: warning: last issue\n" + " 2 | second\n 3 | third\n 4 | fourth\n ^"), + diagnostics.toString()); +} + +TEST (YdspJitDiagnosticsTests, RangeSelectsImportedSourceAndPreservesTabs) +{ + YdspDiagnostics diagnostics; + diagnostics.setSource ("root source", "main.ydsp"); + diagnostics.registerSource ("lib/filter.ydsp", "\tbad\nnext"); + diagnostics.addError ({ 1, 2, 2, 3, "lib/filter.ydsp" }, "Invalid expression"); + + EXPECT_EQ (String ("lib/filter.ydsp:1:2: error: Invalid expression\n" + " 1 | \tbad\n \t^~~\n 2 | next"), + diagnostics.toString()); + EXPECT_EQ ("lib/filter.ydsp", diagnostics.getItem (0).range.sourceId); +} + +TEST (YdspJitDiagnosticsTests, LegacyOverloadPopulatesRange) +{ + YdspDiagnostics diagnostics; + diagnostics.addError (3, 4, "legacy"); + EXPECT_EQ (3, diagnostics.getItem (0).range.startLine); + EXPECT_EQ (4, diagnostics.getItem (0).range.startColumn); + EXPECT_EQ (diagnostics.getItem (0).range.startLine, diagnostics.getItem (0).line); + EXPECT_EQ (diagnostics.getItem (0).range.startColumn, diagnostics.getItem (0).column); +} + +TEST (YdspJitDiagnosticsTests, StartsEmptyWithNoErrors) +{ + YdspDiagnostics diagnostics; + + EXPECT_FALSE (diagnostics.hasErrors()); + EXPECT_EQ (0, diagnostics.getCount()); +} + +TEST (YdspJitDiagnosticsTests, AddErrorAndQuery) +{ + YdspDiagnostics diagnostics; + + diagnostics.addError ({ 1, 5, 1, 5 }, "syntax error: unexpected token"); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_EQ (1, diagnostics.getCount()); + + const auto& item = diagnostics.getItem (0); + EXPECT_EQ (YdspSeverity::error, item.severity); + EXPECT_EQ (1, item.range.startLine); + EXPECT_EQ (5, item.range.startColumn); + EXPECT_EQ ("syntax error: unexpected token", item.message); +} + +TEST (YdspJitDiagnosticsTests, AddWarningDoesNotSetHasErrors) +{ + YdspDiagnostics diagnostics; + + diagnostics.addWarning ({ 3, 10, 3, 10 }, "unused variable 'x'"); + + EXPECT_FALSE (diagnostics.hasErrors()); + EXPECT_EQ (1, diagnostics.getCount()); + + const auto& item = diagnostics.getItem (0); + EXPECT_EQ (YdspSeverity::warning, item.severity); + EXPECT_EQ (3, item.range.startLine); + EXPECT_EQ (10, item.range.startColumn); + EXPECT_EQ ("unused variable 'x'", item.message); +} + +TEST (YdspJitDiagnosticsTests, AddInfoDoesNotSetHasErrors) +{ + YdspDiagnostics diagnostics; + + diagnostics.addInfo ({ 2, 1, 2, 1 }, "loop bound inferred as blockSize"); + + EXPECT_FALSE (diagnostics.hasErrors()); + EXPECT_EQ (1, diagnostics.getCount()); + + const auto& item = diagnostics.getItem (0); + EXPECT_EQ (YdspSeverity::info, item.severity); +} + +TEST (YdspJitDiagnosticsTests, ErrorsDetectedAmongMixedSeverities) +{ + YdspDiagnostics diagnostics; + + diagnostics.addInfo ({ 1, 1, 1, 1 }, "parsing program"); + diagnostics.addWarning ({ 2, 1, 2, 1 }, "implicit float conversion"); + diagnostics.addError ({ 3, 1, 3, 1 }, "unknown symbol 'foo'"); + diagnostics.addInfo ({ 4, 1, 4, 1 }, "compilation finished"); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_EQ (4, diagnostics.getCount()); + + EXPECT_EQ (YdspSeverity::info, diagnostics.getItem (0).severity); + EXPECT_EQ (YdspSeverity::warning, diagnostics.getItem (1).severity); + EXPECT_EQ (YdspSeverity::error, diagnostics.getItem (2).severity); + EXPECT_EQ (YdspSeverity::info, diagnostics.getItem (3).severity); +} + +TEST (YdspJitDiagnosticsTests, ReportsRegisteredSourceIds) +{ + YdspDiagnostics diagnostics; + + EXPECT_TRUE (diagnostics.getSourceIds().isEmpty()); + + diagnostics.setSource ("root source", "main.ydsp"); + diagnostics.registerSource ("lib/filter.ydsp", "filter source"); + diagnostics.registerSource ("lib/filter.ydsp", "filter source again"); + diagnostics.setSource ("unnamed source"); + + const auto ids = diagnostics.getSourceIds(); + ASSERT_EQ (2, ids.size()); + EXPECT_TRUE (ids.contains ("main.ydsp")); + EXPECT_TRUE (ids.contains ("lib/filter.ydsp")); +} + +TEST (YdspJitDiagnosticsTests, CompileRegistersEntryFileAndImports) +{ + const auto directory = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup-ydsp-sources-" + Uuid().toString()); + ASSERT_TRUE (directory.createDirectory().wasOk()); + ASSERT_TRUE (directory.getChildFile ("lib").createDirectory().wasOk()); + + const auto entry = directory.getChildFile ("Main.ydsp"); + ASSERT_TRUE (entry.replaceWithText ( + "import lib.Gain as lib; graph Main { input stream in; output stream out; node p = lib.Gain; connection { in -> p.in; p.out -> out; } }\n")); + + const auto imported = directory.getChildFile ("lib/Gain.ydsp"); + ASSERT_TRUE (imported.replaceWithText ("processor Gain { input stream in; output stream out; process { out = in * 2.0; } }\n")); + + YdspCompiler compiler; + // The import base path is the input contract this test rests on: it must be + // the entry file's own path on disk, otherwise the compiler cannot resolve + // the entry's imports and the failure says nothing about import resolution. + const auto entryPath = entry.getFullPathName(); + ASSERT_TRUE (File (entryPath).existsAsFile()) << entryPath; + const auto result = compiler.compile (entry.loadFileAsString(), entryPath); + ASSERT_TRUE (result.wasOk()) << entryPath << "\n" + << compiler.getDiagnostics().toString(); + + // getSourceIds() returns the closure sorted case-insensitively, so the + // imported "lib/..." file sorts before "Main.ydsp". Ids are native paths, + // so compare them as full paths instead of by separator-sensitive suffixes. + const auto ids = compiler.getDiagnostics().getSourceIds(); + std::string found; + + for (const auto& id : ids) + { + if (! found.empty()) + found += " | "; + + found += id.toRawUTF8(); + } + + // ASSERT is fatal, so the indexed reads below only run for the expected count. + ASSERT_EQ (2, ids.size()) << found; + EXPECT_EQ (imported.getFullPathName(), ids[0]) << found; + EXPECT_EQ (entry.getFullPathName(), ids[1]) << found; + + directory.deleteRecursively(); +} + +TEST (YdspJitDiagnosticsTests, ToStringWithoutSourceRendersMessages) +{ + YdspDiagnostics diagnostics; + + diagnostics.addError ({ 1, 5, 1, 5 }, "syntax error"); + diagnostics.addWarning ({ 2, 8, 2, 8 }, "unused variable"); + + const auto str = diagnostics.toString(); + + EXPECT_TRUE (str.contains ("error")); + EXPECT_TRUE (str.contains ("syntax error")); + EXPECT_TRUE (str.contains ("warning")); + EXPECT_TRUE (str.contains ("unused variable")); + // Line numbers should appear + EXPECT_TRUE (str.contains ("1")); + EXPECT_TRUE (str.contains ("2")); +} + +TEST (YdspJitDiagnosticsTests, ToStringWithSourceRendersCaret) +{ + YdspDiagnostics diagnostics; + + diagnostics.setSource ("processor P {\n out = in;\n}"); + diagnostics.addError ({ 2, 3, 2, 3 }, "expected ';'"); + + const auto str = diagnostics.toString(); + + // Should contain a caret (^) marker + EXPECT_TRUE (str.contains ("^")); + // Should contain the source line + EXPECT_TRUE (str.contains ("out = in;")); + // Should contain the error message + EXPECT_TRUE (str.contains ("expected ';'")); + + // The caret lands exactly under column 3 of line 2 (the 'o' of "out"): + // the source line renders as " 2 | out = in;" and the caret line has + // eight leading spaces before the '^'. + EXPECT_TRUE (str.contains (" 2 | out = in;\n ^")); +} + +TEST (YdspJitDiagnosticsTests, MultipleItemsAtSameLocation) +{ + YdspDiagnostics diagnostics; + + diagnostics.addError ({ 1, 10, 1, 10 }, "type mismatch"); + diagnostics.addError ({ 1, 10, 1, 10 }, "cannot assign to let"); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_EQ (2, diagnostics.getCount()); + + EXPECT_EQ (1, diagnostics.getItem (0).range.startLine); + EXPECT_EQ (10, diagnostics.getItem (0).range.startColumn); + EXPECT_EQ (1, diagnostics.getItem (1).range.startLine); + EXPECT_EQ (10, diagnostics.getItem (1).range.startColumn); +} + +TEST (YdspJitDiagnosticsTests, ClearingSourceThenToString) +{ + YdspDiagnostics diagnostics; + + diagnostics.setSource ("processor P { }"); + diagnostics.addError ({ 1, 1, 1, 1 }, "error"); + + auto s1 = diagnostics.toString(); + EXPECT_TRUE (s1.contains ("^")); + + // Setting empty source should still render (just without carets) + diagnostics.setSource (""); + auto s2 = diagnostics.toString(); + EXPECT_FALSE (s2.contains ("^")); + EXPECT_TRUE (s2.contains ("error")); +} + +//============================================================================== +// YdspExecutionReportTests +//============================================================================== + +TEST (YdspJitExecutionReportTests, EmptyReportIsSafe) +{ + YdspExecutionReport report; + + EXPECT_TRUE (report.getKernels().empty()); + EXPECT_EQ (0, report.getTotalBoundedIterations()); + EXPECT_TRUE (report.isProvenRealtimeSafe()); +} + +TEST (YdspJitExecutionReportTests, SingleKernelReportFields) +{ + YdspExecutionReport report; + + auto& kernels = report.getKernels(); + kernels.push_back ({ "MyKernel", 42, 128, true, { "blockSize" } }); + + ASSERT_EQ (1u, report.getKernels().size()); + EXPECT_EQ ("MyKernel", report.getKernels()[0].name); + EXPECT_EQ (42, report.getKernels()[0].instructionCount); + EXPECT_EQ (128, report.getKernels()[0].boundedIterationCount); + EXPECT_TRUE (report.getKernels()[0].provenRealtimeSafe); + ASSERT_EQ (1u, report.getKernels()[0].loopBounds.size()); + EXPECT_EQ ("blockSize", report.getKernels()[0].loopBounds[0]); + EXPECT_EQ (128, report.getTotalBoundedIterations()); + EXPECT_TRUE (report.isProvenRealtimeSafe()); +} + +TEST (YdspJitExecutionReportTests, MultipleKernelsAggregateCorrectly) +{ + YdspExecutionReport report; + + auto& kernels = report.getKernels(); + kernels.push_back ({ "A", 10, 32, true, {} }); + kernels.push_back ({ "B", 20, 64, true, {} }); + + EXPECT_EQ (32 + 64, report.getTotalBoundedIterations()); + EXPECT_TRUE (report.isProvenRealtimeSafe()); +} + +TEST (YdspJitExecutionReportTests, OneUnsafeKernelMakesReportUnsafe) +{ + YdspExecutionReport report; + + auto& kernels = report.getKernels(); + kernels.push_back ({ "Safe", 10, 64, true, {} }); + kernels.push_back ({ "Unsafe", 0, 0, false, {} }); + + EXPECT_FALSE (report.isProvenRealtimeSafe()); + EXPECT_EQ (64, report.getTotalBoundedIterations()); +} + +} // namespace yup::test diff --git a/tests/yup_dsp_jit/yup_YdspExamplePatchTests.cpp b/tests/yup_dsp_jit/yup_YdspExamplePatchTests.cpp new file mode 100644 index 000000000..18c9be743 --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspExamplePatchTests.cpp @@ -0,0 +1,1283 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include +#include +#include +#include + +namespace yup::test +{ + +namespace +{ + +/* + The demo patches under examples/graphics/data/synths/ are the corpus that + exercises the language's surface - one patch per feature cluster - and until + now nothing but a human clicking through the graphics demo's combo box ever + compiled them. Every other reference to them in this suite is a hand-copied + excerpt, which cannot go stale loudly. + + The tests below reach outside tests/ on purpose: the point is to compile the + files that ship, not a copy of them. +*/ +File exampleSynthsFolder() +{ + return File (__FILE__) + .getParentDirectory() // tests/yup_dsp_jit + .getParentDirectory() // tests + .getParentDirectory() // repository root + .getChildFile ("examples") + .getChildFile ("graphics") + .getChildFile ("data") + .getChildFile ("synths"); +} + +struct ElectricPianoDefaultParam +{ + const char* name; + double value; +}; + +} // namespace + +//============================================================================== + +class YdspExamplePatchTests : public ::testing::Test +{ +protected: + void SetUp() override + { + // On wasm the tests run against a preloaded virtual filesystem that + // carries tests/data only, so the example folder is not reachable. + if (! exampleSynthsFolder().isDirectory()) + GTEST_SKIP() << "example synth folder not available on this platform"; + } + + // Compiles one patch with its own path as the import base, which is what the + // demo app does, so a patch's relative `import fx.Delay` resolves. + YdspAudioGraph compilePatch (const File& patchFile, YdspCompiler& compiler) + { + auto result = compiler.compile (patchFile.loadFileAsString(), patchFile.getFullPathName()); + + EXPECT_TRUE (result.wasOk()) + << patchFile.getFileName() << ":\n" + << compiler.getDiagnostics().toString(); + + if (! result.wasOk()) + return YdspAudioGraph {}; + + return std::move (result).getValue(); + } + + void testPatch (const char* patchName) + { + const auto patchFile = exampleSynthsFolder().getChildFile (patchName); + ASSERT_TRUE (patchFile.existsAsFile()) << patchName; + + YdspCompiler compiler; + auto graph = compilePatch (patchFile, compiler); + + EXPECT_TRUE (graph.isValid()) << patchName; + + if (! graph.isValid()) + return; + + const auto patchFileName = patchFile.getFileName(); + const auto hasNoParameters = patchFileName == "HelloWorld.ydsp"; + + if (! hasNoParameters) + EXPECT_GT (graph.getParameterCount(), 0) << patchName; + + EXPECT_LE (graph.getInputStreamCount(), 2) << patchName; + + const auto isMidiOnlyPatch = patchFileName == "ArpTranspose.ydsp"; + + if (! isMidiOnlyPatch) + EXPECT_GE (graph.getOutputStreamCount(), 1) << patchName; + + EXPECT_LE (graph.getOutputStreamCount(), 2) << patchName; + + constexpr int blockSize = 128; + constexpr double sampleRate = 48000.0; + graph.prepare (sampleRate, blockSize); + + const auto numOutputs = graph.getOutputStreamCount(); + + std::vector left (blockSize, 0.0f); + std::vector right (blockSize, 0.0f); + + YdspOutputBuffer outputs[] = { + Span (left.data(), left.size()), + Span (right.data(), right.size()) + }; + + const auto numInputs = graph.getInputStreamCount(); + + std::vector inputA (static_cast (blockSize), 0.0f); + std::vector inputB (static_cast (blockSize), 0.0f); + + if (numInputs > 0) + { + constexpr double twoPi = 6.283185307179586476925286766559005768; + constexpr double freqA = 220.0; + constexpr double freqB = 329.6276; + + double phaseA = 0.0; + double phaseB = 0.0; + + for (int i = 0; i < blockSize; ++i) + { + const auto sample = static_cast (0.4 * std::sin (phaseA) + 0.22 * std::sin (phaseB)); + inputA[static_cast (i)] = sample; + inputB[static_cast (i)] = sample; + + phaseA += twoPi * freqA / sampleRate; + phaseB += twoPi * freqB / sampleRate; + } + } + + YdspInputBuffer inputs[] = { + Span (inputA.data(), inputA.size()), + Span (inputB.data(), inputB.size()) + }; + + MidiBuffer midi; + midi.addEvent (MidiMessage::noteOn (1, 60, 0.8f), 0); + + int nonFinite = 0; + + for (int block = 0; block < 8; ++block) + { + const yup::MidiBuffer* processEventInputs[] { &midi }; + + graph.process (yup::YdspProcessRequest { + Span (inputs, static_cast (numInputs)), + Span (outputs, static_cast (numOutputs)), + blockSize, + yup::Span (processEventInputs, graph.getEventInputCount() > 0 ? 1 : 0), + {} + }); + + midi.clear(); + + for (int channel = 0; channel < numOutputs; ++channel) + { + const auto& buffer = channel == 0 ? left : right; + + for (int i = 0; i < blockSize; ++i) + if (! std::isfinite (buffer[static_cast (i)])) + ++nonFinite; + } + } + + EXPECT_EQ (nonFinite, 0) << patchName << " produced non-finite samples"; + } + + static float measureHeldNote (YdspAudioGraph& graph, std::vector& energy) + { + constexpr double sampleRate = 44100.0; + constexpr int blockSize = 256; + constexpr int numBlocks = 96; // 0.56 s of held note at 44100 / 256 + + graph.prepare (sampleRate, blockSize); + + std::vector left (blockSize, 0.0f); + std::vector right (blockSize, 0.0f); + + YdspOutputBuffer outputs[] = { + Span (left.data(), left.size()), + Span (right.data(), right.size()) + }; + + MidiBuffer midi; + midi.addEvent (MidiMessage::noteOn (1, 60, 0.8f), 0); + + energy.assign (static_cast (numBlocks), 0.0f); + + for (int block = 0; block < numBlocks; ++block) + { + const yup::MidiBuffer* processEventInputs[] { &midi }; + + graph.process (yup::YdspProcessRequest { + {}, + Span (outputs, 2), + blockSize, + yup::Span (processEventInputs, graph.getEventInputCount() > 0 ? 1 : 0), + {} + }); + + midi.clear(); + + for (int i = 0; i < blockSize; ++i) + { + const auto l = left[static_cast (i)]; + const auto r = right[static_cast (i)]; + energy[static_cast (block)] += l * l + r * r; + } + } + + return energy[0]; + } +}; + +//============================================================================== + +TEST_F (YdspExamplePatchTests, ZitaReverbProducesStereoTailAcrossBlockBoundaries) +{ + YdspCompiler compiler; + auto graph = compilePatch (exampleSynthsFolder().getChildFile ("ZitaReverb.ydsp"), compiler); + ASSERT_TRUE (graph.isValid()); + ASSERT_EQ (2, graph.getInputStreamCount()); + ASSERT_EQ (2, graph.getOutputStreamCount()); + ASSERT_TRUE (graph.prepare (48000.0, 512).wasOk()); + + constexpr int frames = 48000; + std::vector inputL (frames, 0.0f), inputR (frames, 0.0f); + inputL[0] = 1.0f; + std::vector left (frames), right (frames), splitLeft (frames), splitRight (frames); + const auto render = [&] (int blockSize, float lowDecay, std::vector& outL, std::vector& outR) + { + graph.reset(); + graph.setParameter ("fx.mix", 100.0f); + graph.setParameter ("fx.rtLow", lowDecay); + graph.setParameter ("fx.eq1Gain", 6.0f); + graph.setParameter ("fx.eq2Gain", -6.0f); + for (int offset = 0; offset < frames; offset += blockSize) + { + const auto count = std::min (blockSize, frames - offset); + YdspInputBuffer inputs[] { Span (inputL.data() + offset, count), + Span (inputR.data() + offset, count) }; + YdspOutputBuffer outputs[] { Span (outL.data() + offset, count), + Span (outR.data() + offset, count) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, count })); + } + }; + render (512, 3.0f, left, right); + ASSERT_FALSE (HasFatalFailure()); + render (127, 3.0f, splitLeft, splitRight); + ASSERT_FALSE (HasFatalFailure()); + + double lateLeft = 0.0, lateRight = 0.0, stereoDifference = 0.0; + for (int i = 0; i < frames; ++i) + { + const auto index = static_cast (i); + ASSERT_TRUE (std::isfinite (left[index])); + ASSERT_TRUE (std::isfinite (right[index])); + EXPECT_NEAR (left[index], splitLeft[index], 1.0e-5f) << i; + EXPECT_NEAR (right[index], splitRight[index], 1.0e-5f) << i; + if (i < 960) + { + EXPECT_NEAR (0.0f, left[index], 1.0e-8f) << i; + EXPECT_NEAR (0.0f, right[index], 1.0e-8f) << i; + } + if (i >= 8192) + { + lateLeft += static_cast (left[index]) * left[index]; + lateRight += static_cast (right[index]) * right[index]; + const double difference = left[index] - right[index]; + stereoDifference += difference * difference; + } + } + EXPECT_GT (lateLeft, 1.0e-8); + EXPECT_GT (lateRight, 1.0e-8); + EXPECT_GT (stereoDifference, 1.0e-8); + + render (512, 1.0f, splitLeft, splitRight); + ASSERT_FALSE (HasFatalFailure()); + double decayDifference = 0.0; + for (int i = 8192; i < frames; ++i) + { + const auto index = static_cast (i); + const double difference = left[index] - splitLeft[index]; + decayDifference += difference * difference; + } + EXPECT_GT (decayDifference, 1.0e-8); +} + +TEST_F (YdspExamplePatchTests, AnalogSawCompilesAndRenders) +{ + testPatch ("AnalogSaw.ydsp"); +} + +TEST_F (YdspExamplePatchTests, ControlRateWahCompilesAndRenders) +{ + testPatch ("ControlRateWah.ydsp"); +} + +TEST_F (YdspExamplePatchTests, DigitalDrumsCompilesAndRenders) +{ + testPatch ("DigitalDrums.ydsp"); +} + +TEST_F (YdspExamplePatchTests, NoiseGeneratorsPreserveModulo32BitSequence) +{ + for (const auto* patchName : { "DigitalDrums.ydsp", "WaveLab.ydsp", "ControlRateWah.ydsp" }) + { + SCOPED_TRACE (patchName); + String seedStatement, updateStatement; + for (const auto& line : StringArray::fromLines (exampleSynthsFolder().getChildFile (patchName).loadFileAsString())) + { + if (line.contains ("1103515245")) + seedStatement = line.trim(); + if (line.contains ("1664525")) + updateStatement = line.trim(); + } + ASSERT_FALSE (seedStatement.isEmpty()); + ASSERT_FALSE (updateStatement.isEmpty()); + + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic, YdspOptimizationTier::aggressive }) + for (const bool fastMath : { false, true }) + for (const int pitch : { 36, 42, 127 }) + { + SCOPED_TRACE (::testing::Message() << "tier=" << static_cast (tier) << ", fastMath=" << fastMath << ", pitch=" << pitch); + const auto source = String (R"YDSP( + processor Noise { + state int rng; + output stream out; + init { SEED } + process { + UPDATE + out = float (rng >> 8) * 1.1920929e-7; + } + } + graph G { output stream out; node n = Noise; connection { n.out -> out; } } + )YDSP").replace ("SEED", seedStatement.replace ("e.pitch", String (pitch))) + .replace ("UPDATE", updateStatement); + YdspCompileOptions options; + options.optimizationTier = tier; + options.fastMath = fastMath; + YdspCompiler compiler; + auto result = compiler.compile (source, options); + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + auto graph = std::move (result).getValue(); + constexpr int blockSize = 64; + ASSERT_TRUE (graph.prepare (48000.0, blockSize).wasOk()); + float samples[blockSize] {}; + YdspOutputBuffer outputs[] { Span (samples, blockSize) }; + uint32_t reference = static_cast (pitch) * 1103515245u; + for (int block = 0; block < 8; ++block) + { + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, blockSize })); + for (const float sample : samples) + { + reference = reference * 1664525u + 1013904223u; + const int64_t signedHighBits = static_cast (reference >> 8) + - ((reference & 0x80000000u) != 0 ? 16777216 : 0); + EXPECT_FLOAT_EQ (static_cast (signedHighBits) * 1.1920929e-7f, sample); + } + } + } + } +} + +TEST_F (YdspExamplePatchTests, ElectricPianoCompilesAndRenders) +{ + testPatch ("ElectricPiano.ydsp"); +} + +TEST_F (YdspExamplePatchTests, ElectricPianoSustainsAtDeclaredDefaults) +{ + // Regression guard for the graphics demo: when every EPVoice envelope + // parameter (decayRate, brightness, ...) read 0 at noteOn time, each note + // collapsed to a ~0.15 s click, and the suite's short sweeps (~46 ms) could + // not tell that from the ~7 s decay the declared defaults should produce. + // This renders the shipped patch for over half a second at untouched + // defaults and requires the note body to still be sounding. + + const auto patchFile = exampleSynthsFolder().getChildFile ("ElectricPiano.ydsp"); + ASSERT_TRUE (patchFile.existsAsFile()); + + YdspCompiler compiler; + auto graph = compilePatch (patchFile, compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + + // The graph must already hold its declared defaults - no setParameter call. + const ElectricPianoDefaultParam declaredDefaults[] = { + { "brightness", 30.0 }, + { "velocitySensitivity", 60.0 }, + { "decayRate", 50.0 }, + { "harmonicDecayRate", 50.0 }, + { "keyScaling", 50.0 }, + { "releaseRate", 40.0 }, + { "vibratoRate", 4.0 }, + { "vibratoDepth", 0.5 } + }; + + for (const auto& param : declaredDefaults) + EXPECT_DOUBLE_EQ (param.value, graph.getParameter (param.name)) << param.name; + + std::vector energy; + const auto attackEnergy = measureHeldNote (graph, energy); + + EXPECT_GT (attackEnergy, 0.0f) << "The note's attack is silent"; + + // Block 24 sits at t ~0.14 s, far past where a 0.15 s click has died. + EXPECT_GT (energy[24], attackEnergy * 0.1f) << "The note died within its attack"; + + // The tail window (0.28-0.56 s) must still carry a clearly sounding body. + // A correct patch is quieter there by design: the per-partial envelope + // (harmonicDecayRate = 50) sends each partial to -60 dB within ~0.1-0.7 s, + // so by half a second only the fundamental region is left ringing, at + // roughly a tenth of the full-attack energy. A zeroed decayRate instead + // collapses everything to ~1e-12 (and puts the voice to sleep) well before + // 0.2 s. + float sustainedEnergy = 0.0f; + for (int block = 48; block < static_cast (energy.size()); ++block) + sustainedEnergy += energy[static_cast (block)]; + + sustainedEnergy /= static_cast (energy.size() - 48); + + EXPECT_GT (sustainedEnergy, attackEnergy * 0.05f) << "The held note died instead of sustaining"; +} + +TEST_F (YdspExamplePatchTests, ElectricPianoSustainsWhenDefaultsAreExplicitlySet) +{ + // Diagnostic twin of the test above: pushes the declared EPVoice defaults + // through the host setParameter/ring path before the note. If this one + // sustains while the untouched-defaults one dies, the compile-time seed + // never reaches the slot the noteOn kernel reads; if both die, the kernel + // binds its parameters to a slot that is never written at all. + + const auto patchFile = exampleSynthsFolder().getChildFile ("ElectricPiano.ydsp"); + + YdspCompiler compiler; + auto graph = compilePatch (patchFile, compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + + // Only the EPVoice envelope params shape the note body. + const ElectricPianoDefaultParam voiceDefaults[] = { + { "brightness", 30.0 }, + { "velocitySensitivity", 60.0 }, + { "decayRate", 50.0 }, + { "harmonicDecayRate", 50.0 }, + { "keyScaling", 50.0 }, + { "releaseRate", 40.0 } + }; + + for (const auto& param : voiceDefaults) + graph.setParameter (param.name, static_cast (param.value)); + + std::vector energy; + const auto attackEnergy = measureHeldNote (graph, energy); + + EXPECT_GT (attackEnergy, 0.0f) << "The note's attack is silent"; + EXPECT_GT (energy[24], attackEnergy * 0.1f) << "The note died within its attack"; + + // Same tail-window rationale as ElectricPianoSustainsAtDeclaredDefaults: + // the per-partial decay leaves the fundamental-only body at roughly a + // tenth of the full-attack energy by 0.3-0.56 s, far above the ~1e-12 a + // collapsed click leaves behind. + float sustainedEnergy = 0.0f; + for (int block = 48; block < static_cast (energy.size()); ++block) + sustainedEnergy += energy[static_cast (block)]; + + sustainedEnergy /= static_cast (energy.size() - 48); + + EXPECT_GT (sustainedEnergy, attackEnergy * 0.05f) << "The held note died instead of sustaining"; +} + +TEST_F (YdspExamplePatchTests, FMBellCompilesAndRenders) +{ + testPatch ("FMBell.ydsp"); +} + +TEST_F (YdspExamplePatchTests, FormantsCompilesAndRenders) +{ + testPatch ("Formants.ydsp"); +} + +TEST_F (YdspExamplePatchTests, HaasWidenerCompilesAndRenders) +{ + testPatch ("HaasWidener.ydsp"); +} + +TEST_F (YdspExamplePatchTests, HelloWorldCompilesAndRenders) +{ + testPatch ("HelloWorld.ydsp"); +} + +TEST_F (YdspExamplePatchTests, PolySineCompilesAndRenders) +{ + testPatch ("PolySine.ydsp"); +} + +TEST_F (YdspExamplePatchTests, PolySineArpCompilesAndRenders) +{ + testPatch ("PolySineArp.ydsp"); +} + +TEST_F (YdspExamplePatchTests, PulseBassCompilesAndRenders) +{ + testPatch ("PulseBass.ydsp"); +} + +TEST_F (YdspExamplePatchTests, StereoDelayCompilesAndRenders) +{ + testPatch ("StereoDelay.ydsp"); +} + +TEST_F (YdspExamplePatchTests, SubtractOneCompilesAndRenders) +{ + testPatch ("SubtractOne.ydsp"); +} + +TEST_F (YdspExamplePatchTests, TX81ZCompilesAndRenders) +{ + testPatch ("TX81Z.ydsp"); +} + +TEST_F (YdspExamplePatchTests, TremoloCompilesAndRenders) +{ + testPatch ("Tremolo.ydsp"); +} + +TEST_F (YdspExamplePatchTests, FreeverbCompilesAndRenders) +{ + testPatch ("Freeverb.ydsp"); +} + +TEST_F (YdspExamplePatchTests, WaveLabCompilesAndRenders) +{ + testPatch ("WaveLab.ydsp"); +} + +TEST_F (YdspExamplePatchTests, WobbleLeadCompilesAndRenders) +{ + testPatch ("WobbleLead.ydsp"); +} + +TEST_F (YdspExamplePatchTests, PerVoiceEchoCompilesAndRenders) +{ + testPatch ("PerVoiceEcho.ydsp"); +} + +//============================================================================== +// Minimal probe for the ElectricPiano regression: does a voice-bank noteOn +// handler actually read its processor's `input parameter` parameter? EPVoice reads +// all six envelope params (decayRate etc.) only inside noteOn, so a broken +// event-handler param load collapses every note to the same silent click and +// no host setParameter can fix it (both scenarios were bit-identical above). + +TEST (YdspParamProbeTests, VoiceBankNoteOnReadsItsValueParam) +{ + YdspCompiler compiler; + + auto result = compiler.compile (R"YDSP( + processor ParamVoice + { + output stream out; + input parameter float level = 1.0; + input event midi; + state float heldLevel; + event midi (e: noteOn) { heldLevel = level; } + process { out = heldLevel; } + } + graph Probe + { + input event midi; + output stream y; + node v = ParamVoice[4] [[ mode: poly, stealing: oldest ]]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP"); + + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + auto graph = std::move (result).getValue(); + + constexpr int blockSize = 128; + graph.prepare (44100.0, blockSize); + + std::vector output (blockSize, 0.0f); + + YdspOutputBuffer outputs[] = { + Span (output.data(), output.size()) + }; + + MidiBuffer midi; + midi.addEvent (MidiMessage::noteOn (1, 60, 0.8f), 0); + + for (int block = 0; block < 4; ++block) + { + const yup::MidiBuffer* processEventInputs[] { &midi }; + + graph.process (yup::YdspProcessRequest { + {}, + Span (outputs, 1), + blockSize, + yup::Span (processEventInputs, graph.getEventInputCount() > 0 ? 1 : 0), + {} + }); + + midi.clear(); + + float energy = 0.0f; + for (int i = 0; i < blockSize; ++i) + energy += output[static_cast (i)] * output[static_cast (i)]; + + // heldLevel = level = 1.0 makes out a constant 1.0 -> energy ~= 128. + EXPECT_GT (energy, 10.0f) << "noteOn handler did not read its level param (block " << block << ")"; + } +} + +TEST (YdspParamProbeTests, VoiceBankNoteOnReadsAliasedValueParam) +{ + // Same probe, but with the param driven through a graph `input parameter` + // endpoint and an explicit `level -> v.level` wire - the shape ElectricPiano + // uses for all six EPVoice params (brightness -> voices.brightness, etc.). + + YdspCompiler compiler; + + auto result = compiler.compile (R"YDSP( + processor ParamVoice + { + output stream out; + input parameter float level = 1.0; + input event midi; + state float heldLevel; + event midi (e: noteOn) { heldLevel = level; } + process { out = heldLevel; } + } + graph ProbeAliased + { + input event midi; + input parameter float level = 1.0; + output stream y; + node v = ParamVoice[4] [[ mode: poly, stealing: oldest ]]; + connection { midi -> v.midi; level -> v.level; v.out -> y; } + } + )YDSP"); + + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + auto graph = std::move (result).getValue(); + + constexpr int blockSize = 128; + graph.prepare (44100.0, blockSize); + + std::vector output (blockSize, 0.0f); + + YdspOutputBuffer outputs[] = { + Span (output.data(), output.size()) + }; + + MidiBuffer midi; + midi.addEvent (MidiMessage::noteOn (1, 60, 0.8f), 0); + + for (int block = 0; block < 4; ++block) + { + const yup::MidiBuffer* processEventInputs[] { &midi }; + + graph.process (yup::YdspProcessRequest { + {}, + Span (outputs, 1), + blockSize, + yup::Span (processEventInputs, graph.getEventInputCount() > 0 ? 1 : 0), + {} + }); + + midi.clear(); + + float energy = 0.0f; + for (int i = 0; i < blockSize; ++i) + energy += output[static_cast (i)] * output[static_cast (i)]; + + // heldLevel = level = 1.0 makes out a constant 1.0 -> energy ~= 128. + EXPECT_GT (energy, 10.0f) << "noteOn handler did not read its aliased level param (block " << block << ")"; + } +} + +TEST (YdspParamProbeTests, VoiceBankNoteOnHonorsUpdatedValueParam) +{ + // Control for the two probes above: they cannot tell a *dynamic* param + // read from a compile-time fold of the declared default (both yield 1.0). + // Here the aliased param is rewritten to 0.25 via setParameter before the + // note: a dynamic read outputs 0.25 (energy ~= 8), while a fold of the + // declared default keeps outputting 1.0 (energy ~= 128) regardless. + + YdspCompiler compiler; + + auto result = compiler.compile (R"YDSP( + processor ParamVoice + { + output stream out; + input parameter float level = 1.0; + input event midi; + state float heldLevel; + event midi (e: noteOn) { heldLevel = level; } + process { out = heldLevel; } + } + graph ProbeUpdated + { + input event midi; + input parameter float level = 1.0; + output stream y; + node v = ParamVoice[4] [[ mode: poly, stealing: oldest ]]; + connection { midi -> v.midi; level -> v.level; v.out -> y; } + } + )YDSP"); + + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + auto graph = std::move (result).getValue(); + + constexpr int blockSize = 128; + graph.prepare (44100.0, blockSize); + + graph.setParameter ("level", 0.25f); + + std::vector output (blockSize, 0.0f); + + YdspOutputBuffer outputs[] = { + Span (output.data(), output.size()) + }; + + MidiBuffer midi; + midi.addEvent (MidiMessage::noteOn (1, 60, 0.8f), 0); + + for (int block = 0; block < 2; ++block) + { + const yup::MidiBuffer* processEventInputs[] { &midi }; + + graph.process (yup::YdspProcessRequest { + {}, + Span (outputs, 1), + blockSize, + yup::Span (processEventInputs, graph.getEventInputCount() > 0 ? 1 : 0), + {} + }); + + midi.clear(); + + float energy = 0.0f; + for (int i = 0; i < blockSize; ++i) + energy += output[static_cast (i)] * output[static_cast (i)]; + + // out = heldLevel = 0.25 constantly -> energy = 128 * 0.0625 = 8. + EXPECT_NEAR (8.0f, energy, 1.0f) + << "noteOn ignored the setParameter update; param read is not dynamic (block " << block << ")"; + } +} + +TEST (YdspParamProbeTests, VoiceBankNoteOnReadsEventFields) +{ + // The ElectricPiano noteOn reads e.velocity / e.pitch; the probes above + // never touched an `e.*` field. A noteOn that stores the incoming velocity + // into the held output pins down whether event-field delivery is intact: + // velocity 0.8 -> out 0.8 constantly -> energy = 128 * 0.64 ~= 82. + + YdspCompiler compiler; + + auto result = compiler.compile (R"YDSP( + processor ParamVoice + { + output stream out; + input event midi; + state float heldValue; + event midi (e: noteOn) { heldValue = e.velocity; } + process { out = heldValue; } + } + graph ProbeFields + { + input event midi; + output stream y; + node v = ParamVoice[4] [[ mode: poly, stealing: oldest ]]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP"); + + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + auto graph = std::move (result).getValue(); + + constexpr int blockSize = 128; + graph.prepare (44100.0, blockSize); + + std::vector output (blockSize, 0.0f); + + YdspOutputBuffer outputs[] = { + Span (output.data(), output.size()) + }; + + MidiBuffer midi; + midi.addEvent (MidiMessage::noteOn (1, 60, 0.8f), 0); + + for (int block = 0; block < 2; ++block) + { + const yup::MidiBuffer* processEventInputs[] { &midi }; + + graph.process (yup::YdspProcessRequest { + {}, + Span (outputs, 1), + blockSize, + yup::Span (processEventInputs, graph.getEventInputCount() > 0 ? 1 : 0), + {} + }); + + midi.clear(); + + float energy = 0.0f; + for (int i = 0; i < blockSize; ++i) + energy += output[static_cast (i)] * output[static_cast (i)]; + + // out = e.velocity = 0.8 constantly -> energy = 128 * 0.64 = 81.92. + EXPECT_NEAR (81.92f, energy, 2.0f) + << "noteOn misread e.velocity (block " << block << ")"; + } +} + +TEST (YdspParamProbeTests, VoiceBankNoteOnReadsEventPitch) +{ + // Same idea for e.pitch: note 60 -> out 60.0 constantly -> energy = 128 * 3600. + + YdspCompiler compiler; + + auto result = compiler.compile (R"YDSP( + processor ParamVoice + { + output stream out; + input event midi; + state float heldValue; + event midi (e: noteOn) { heldValue = e.pitch; } + process { out = heldValue; } + } + graph ProbePitch + { + input event midi; + output stream y; + node v = ParamVoice[4] [[ mode: poly, stealing: oldest ]]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP"); + + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + auto graph = std::move (result).getValue(); + + constexpr int blockSize = 128; + graph.prepare (44100.0, blockSize); + + std::vector output (blockSize, 0.0f); + + YdspOutputBuffer outputs[] = { + Span (output.data(), output.size()) + }; + + MidiBuffer midi; + midi.addEvent (MidiMessage::noteOn (1, 60, 0.8f), 0); + + for (int block = 0; block < 2; ++block) + { + const yup::MidiBuffer* processEventInputs[] { &midi }; + + graph.process (yup::YdspProcessRequest { + {}, + Span (outputs, 1), + blockSize, + yup::Span (processEventInputs, graph.getEventInputCount() > 0 ? 1 : 0), + {} + }); + + midi.clear(); + + float energy = 0.0f; + for (int i = 0; i < blockSize; ++i) + energy += output[static_cast (i)] * output[static_cast (i)]; + + // out = e.pitch = 60.0 constantly -> energy = 128 * 3600 = 460800. + EXPECT_NEAR (460800.0f, energy, 1000.0f) + << "noteOn misread e.pitch (block " << block << ")"; + } +} + +//============================================================================== +// Regression: a `return` inside an inlined function used to be lowered as a +// plain value record, so the linear fall-through of later statements/returns +// overwrote the result - every branch returned the last value. This exercises +// a chained `if { return A; } ... return B;` function against four parameter +// values and asserts the chosen branch actually reaches the output. +TEST (YdspFunctionReturnTests, BranchReturnsFollowTheParameter) +{ + const char* patch = R"YDSP( +processor BranchProbe { + output stream out; + + input parameter float shape = 0.0; + + func branchOut (shapeValue: float) : float { + if (shapeValue < 0.5) { return 0.2; } + if (shapeValue < 1.5) { return 0.6; } + if (shapeValue < 2.5) { return 1.0; } + return 0.4; + } + + process { + out = branchOut (shape); + } +} + +graph BranchProbe { + output stream out; + + node probe = BranchProbe; + + connection { + probe.out -> out; + } +} +)YDSP"; + + YdspCompiler compiler; + auto result = compiler.compile (patch); + ASSERT_TRUE (result.wasOk()) + << compiler.getDiagnostics().toString(); + + auto graph = std::move (result).getValue(); + + constexpr int blockSize = 8; + constexpr double sampleRate = 48000.0; + graph.prepare (sampleRate, blockSize); + + ASSERT_GE (graph.getParameterCount(), 1); + + const auto& paramInfo = graph.getParameterInfo (0); + + const float shapeValues[] = { 0.0f, 1.0f, 2.0f, 3.0f }; + const float expected[] = { 0.2f, 0.6f, 1.0f, 0.4f }; + + for (int i = 0; i < 4; ++i) + { + std::vector out (blockSize, 0.0f); + YdspOutputBuffer outputs[] = { Span (out.data(), out.size()) }; + + graph.setParameter (paramInfo.name, shapeValues[i]); + + graph.process (yup::YdspProcessRequest { Span (), Span (outputs, 1), blockSize }); + + EXPECT_NEAR (expected[i], out[static_cast (blockSize - 1)], 1.0e-5f) + << "branchOut did not return the branch for shape " << shapeValues[i]; + } +} + +// The same chain driven by literal arguments: if literals select correctly but +// the parameter-driven case above does not, the fault is in how a parameter +// value reaches the function argument rather than in the chain lowering. +TEST (YdspFunctionReturnTests, LiteralArgumentsSelectTheirBranch) +{ + auto runWithLiteral = [] (double literal) + { + const std::string source = R"YDSP( +processor BranchProbe { + output stream out; + + func branchOut (shapeValue: float) : float { + if (shapeValue < 0.5) { return 0.2; } + if (shapeValue < 1.5) { return 0.6; } + if (shapeValue < 2.5) { return 1.0; } + return 0.4; + } + + process { + out = branchOut ()YDSP" + std::to_string (literal) + R"YDSP(); + } +} + +graph BranchProbe { + output stream out; + + node probe = BranchProbe; + + connection { + probe.out -> out; + } +} +)YDSP"; + + YdspCompiler compiler; + auto result = compiler.compile (source); + EXPECT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + if (! result.wasOk()) + return 0.0; + + auto graph = std::move (result).getValue(); + + constexpr int blockSize = 8; + constexpr double sampleRate = 48000.0; + graph.prepare (sampleRate, blockSize); + + std::vector out (blockSize, 0.0f); + YdspOutputBuffer outputs[] = { Span (out.data(), out.size()) }; + + graph.process (yup::YdspProcessRequest { Span (), Span (outputs, 1), blockSize }); + + return static_cast (out[static_cast (blockSize - 1)]); + }; + + EXPECT_NEAR (0.2, runWithLiteral (0.0), 1.0e-5); + EXPECT_NEAR (0.6, runWithLiteral (1.0), 1.0e-5); + EXPECT_NEAR (1.0, runWithLiteral (2.0), 1.0e-5); + EXPECT_NEAR (0.4, runWithLiteral (3.0), 1.0e-5); +} + +//============================================================================== +// Nested if/else cascades. An `else if` chain and a two-level if/else go +// through the branch-terminator path when their arms hold early returns, and +// through if-conversion/selectB when they hold local writes in the process +// body. Every branch is driven explicitly so a single mis-lowered arm fails. +//============================================================================== + +namespace +{ + +double compileAndRender (const char* patch, const std::vector& params) +{ + YdspCompiler compiler; + auto result = compiler.compile (patch); + + if (! result.wasOk()) + return std::numeric_limits::quiet_NaN(); + + auto graph = std::move (result).getValue(); + + constexpr int blockSize = 8; + constexpr double sampleRate = 48000.0; + graph.prepare (sampleRate, blockSize); + + for (size_t i = 0; i < params.size(); ++i) + { + if (i >= static_cast (graph.getParameterCount())) + break; + + graph.setParameter (graph.getParameterInfo (static_cast (i)).name, static_cast (params[i])); + } + + std::vector out (static_cast (blockSize), 0.0f); + YdspOutputBuffer outputs[] = { Span (out.data(), out.size()) }; + + graph.process (yup::YdspProcessRequest { Span (), Span (outputs, 1), blockSize }); + + return static_cast (out[static_cast (blockSize - 1)]); +} + +} // namespace + +TEST (YdspNestedIfElseTests, ElseIfCascadeInFunctionReturnsCorrectBranch) +{ + const char* patch = R"YDSP( +processor Cascade { + output stream out; + + input parameter float x = 0.0; + + func cascade (v: float) : float { + if (v < 0.5) { return 0.2; } + else if (v < 1.5) { return 0.6; } + else if (v < 2.5) { return 1.0; } + else { return 0.4; } + } + + process { + out = cascade (x); + } +} + +graph Cascade { + output stream out; + + node probe = Cascade; + + connection { + probe.out -> out; + } +} +)YDSP"; + + EXPECT_NEAR (0.2, compileAndRender (patch, { 0.0 }), 1.0e-5); + EXPECT_NEAR (0.6, compileAndRender (patch, { 1.0 }), 1.0e-5); + EXPECT_NEAR (1.0, compileAndRender (patch, { 2.0 }), 1.0e-5); + EXPECT_NEAR (0.4, compileAndRender (patch, { 3.0 }), 1.0e-5); +} + +TEST (YdspNestedIfElseTests, ElseIfCascadeWithTrailingReturn) +{ + const char* patch = R"YDSP( +processor Cascade { + output stream out; + + input parameter float x = 0.0; + + func cascade (v: float) : float { + if (v < 0.5) { return 0.2; } + else if (v < 1.5) { return 0.6; } + return 0.4; + } + + process { + out = cascade (x); + } +} + +graph Cascade { + output stream out; + + node probe = Cascade; + + connection { + probe.out -> out; + } +} +)YDSP"; + + EXPECT_NEAR (0.2, compileAndRender (patch, { 0.0 }), 1.0e-5); + EXPECT_NEAR (0.6, compileAndRender (patch, { 1.0 }), 1.0e-5); + EXPECT_NEAR (0.4, compileAndRender (patch, { 2.0 }), 1.0e-5); + EXPECT_NEAR (0.4, compileAndRender (patch, { 3.0 }), 1.0e-5); +} + +TEST (YdspNestedIfElseTests, TwoLevelNestedIfElseInFunctionReturnsCorrectQuadrant) +{ + const char* patch = R"YDSP( +processor Nest { + output stream out; + + input parameter float a = 0.0; + input parameter float b = 0.0; + + func nest (a: float, b: float) : float { + if (a < 0.5) { + if (b < 0.5) { return 0.1; } + else { return 0.2; } + } else { + if (b < 0.5) { return 0.3; } + else { return 0.4; } + } + } + + process { + out = nest (a, b); + } +} + +graph Nest { + output stream out; + + node probe = Nest; + + connection { + probe.out -> out; + } +} +)YDSP"; + + EXPECT_NEAR (0.1, compileAndRender (patch, { 0.0, 0.0 }), 1.0e-5); + EXPECT_NEAR (0.2, compileAndRender (patch, { 0.0, 1.0 }), 1.0e-5); + EXPECT_NEAR (0.3, compileAndRender (patch, { 1.0, 0.0 }), 1.0e-5); + EXPECT_NEAR (0.4, compileAndRender (patch, { 1.0, 1.0 }), 1.0e-5); +} + +TEST (YdspNestedIfElseTests, TwoLevelNestedIfElseInProcessBodySelectsCorrectly) +{ + const char* patch = R"YDSP( +processor Nest { + output stream out; + + input parameter float a = 0.0; + input parameter float b = 0.0; + + process { + float v = 0.0; + if (a < 0.5) { + if (b < 0.5) { v = 0.1; } else { v = 0.2; } + } else { + if (b < 0.5) { v = 0.3; } else { v = 0.4; } + } + out = v; + } +} + +graph Nest { + output stream out; + + node probe = Nest; + + connection { + probe.out -> out; + } +} +)YDSP"; + + EXPECT_NEAR (0.1, compileAndRender (patch, { 0.0, 0.0 }), 1.0e-5); + EXPECT_NEAR (0.2, compileAndRender (patch, { 0.0, 1.0 }), 1.0e-5); + EXPECT_NEAR (0.3, compileAndRender (patch, { 1.0, 0.0 }), 1.0e-5); + EXPECT_NEAR (0.4, compileAndRender (patch, { 1.0, 1.0 }), 1.0e-5); +} + +TEST (YdspNestedIfElseTests, DeepElseIfCascadeInFunctionReturnsCorrectBranch) +{ + const char* patch = R"YDSP( +processor Cascade { + output stream out; + + input parameter float x = 0.0; + + func cascade (v: float) : float { + if (v < 0.5) { return 0.1; } + else if (v < 1.5) { return 0.2; } + else if (v < 2.5) { return 0.3; } + else if (v < 3.5) { return 0.4; } + else if (v < 4.5) { return 0.5; } + else { return 0.9; } + } + + process { + out = cascade (x); + } +} + +graph Cascade { + output stream out; + + node probe = Cascade; + + connection { + probe.out -> out; + } +} +)YDSP"; + + EXPECT_NEAR (0.1, compileAndRender (patch, { 0.0 }), 1.0e-5); + EXPECT_NEAR (0.2, compileAndRender (patch, { 1.0 }), 1.0e-5); + EXPECT_NEAR (0.3, compileAndRender (patch, { 2.0 }), 1.0e-5); + EXPECT_NEAR (0.4, compileAndRender (patch, { 3.0 }), 1.0e-5); + EXPECT_NEAR (0.5, compileAndRender (patch, { 4.0 }), 1.0e-5); + EXPECT_NEAR (0.9, compileAndRender (patch, { 5.0 }), 1.0e-5); +} + +} // namespace yup::test diff --git a/tests/yup_dsp_jit/yup_YdspFusedMultiplyAddTests.cpp b/tests/yup_dsp_jit/yup_YdspFusedMultiplyAddTests.cpp new file mode 100644 index 000000000..c0b899667 --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspFusedMultiplyAddTests.cpp @@ -0,0 +1,727 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include +#include +#include + +namespace yup::test +{ + +namespace +{ + +//============================================================================== +// The fused multiply-add: `fma (a, b, c)` in a patch, and the contraction pass +// that produces the same operation from `a * b + c`. +// +// The property under test throughout is that `fmaF` means *one* value, not "one +// value per target". A backend with the instruction emits it; a backend without +// one computes the same value in float64 and rounds once. So the tests below +// check the arithmetic against std::fma rather than against whichever lowering +// this build happens to use, and the expansion is checked to produce the same +// answer as the instruction rather than merely to be well-formed. + +/** Builds IR the way the native compile path does, with the passes a test asks + for. Deliberately drives YdspOptimizer to exercise individual lowering + capabilities; YdspCompileOptions supplies those capabilities in normal + compilation. */ +std::unique_ptr fmaBuildIr (StringRef source, + YdspDiagnostics& diagnostics, + bool contract, + bool targetHasFma = true, + bool targetHasPackedFma = false, + bool vectorize = false) +{ + YdspLexer lexer (source, diagnostics); + auto tokens = lexer.tokenize(); + + YdspParser parser (std::move (tokens), diagnostics); + auto program = parser.parseProgram(); + if (program == nullptr) + return nullptr; + + YdspSemanticAnalyzer analyzer (diagnostics); + auto analyzed = analyzer.analyze (std::move (program)); + if (analyzed == nullptr) + return nullptr; + + YdspOptimizer optimizer (diagnostics); + optimizer.setContractionEnabled (contract); + optimizer.setTargetHasFusedMultiplyAdd (targetHasFma); + optimizer.setTargetHasPackedFusedMultiplyAdd (targetHasPackedFma); + optimizer.setVectorizationEnabled (vectorize); + + return optimizer.build (*analyzed); +} + +/** Wraps a processor in the smallest graph that drives it. */ +String fmaPatch (StringRef processorBody) +{ + return String (processorBody) + + "\ngraph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } }"; +} + +int fmaCountInst (const YdspIrFunction& fn, YdspIrOp op) +{ + int count = 0; + + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + if (inst.op == op) + ++count; + + return count; +} + +/** The kernel of the single processor in a built program. */ +const YdspIrFunction* fmaKernel (const YdspIrProgram& ir) +{ + return ir.kernels.empty() ? nullptr : ir.kernels.front().get(); +} + +/** Compiles and runs a patch over one block, returning the output. `listing`, + when given, receives the listing of the kernel that actually ran, taken from + the same graph the output came from - a second compile of the same source is + not guaranteed to take the same path. */ +std::vector fmaRun (StringRef source, const std::vector& input, String* listing = nullptr) +{ + YdspCompiler compiler; + + auto result = compiler.compile (source); + EXPECT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + if (! result.wasOk()) + return {}; + + auto graph = std::move (result).getValue(); + graph.prepare (48000.0, static_cast (input.size())); + + if (listing != nullptr) + *listing = graph.getDiagnostics().toString(); + + std::vector output (input.size(), 0.0f); + + std::vector inputs { YdspInputBuffer (Span (input.data(), input.size())) }; + std::vector outputs { YdspOutputBuffer (Span (output.data(), output.size())) }; + + graph.process (yup::YdspProcessRequest { inputs, outputs, static_cast (input.size()) }); + + return output; +} + +/** Operands for which fusing is observable, which is a stronger requirement + than it sounds and is asserted below rather than assumed. + + Fusing only removes a rounding, so it can only change the answer where that + rounding was doing something. Multiplying by an exact power of two rounds to + itself, so `x * 0.5 + y * 0.5` is bit-identical either way; and awkward- + looking operands are no guarantee either - a first attempt at this test used + 1.0000001 / 0.30000001 / 0.70000005 and those agree exactly too. + + `0.1 * 0.6 + 1.0` does not: two roundings give 1.05999994, one gives + 1.06000006. Spelled the same way in the YDSP source below, as literals in + both places, so that what the lexer parses is what the reference computes + with. */ +constexpr float fmaAwkwardA = 0.1f; +constexpr float fmaAwkwardB = 0.6f; +constexpr float fmaAwkwardC = 1.0f; + +constexpr auto fmaIntrinsicSource = R"( + processor P { + input stream in; + output stream out; + process { out = fma (in, 0.6, 1.0); } + } +)"; + +/** The same requirement for the fused multiply-*subtract* contraction forms + from `c - a * b`, and a separate triple because the fma one does not meet + it: `1.0 - 0.1 * 0.6` is 0.939999998 whichever way it is computed. + + `1.0 - 0.3 * 0.7` does differ - two roundings give 0.789999962, one gives + 0.790000021 - so it is the triple the fmsub tests below use. */ +constexpr float fmsubAwkwardA = 0.3f; +constexpr float fmsubAwkwardB = 0.7f; +constexpr float fmsubAwkwardC = 1.0f; + +constexpr auto fmsubContractedSource = R"( + processor P { + input stream in; + output stream out; + process { out = 1.0 - in * 0.7; } + } +)"; + +constexpr auto fmaVectorBankSource = R"( + processor P { + input stream in; + output stream out; + state float z[16]; + process { + float sum = 0.0; + for i in 0..16 { + z[i] = z[i] * 0.9 + in; + sum = sum + z[i]; + } + out = sum; + } + } +)"; + +} // namespace + +//============================================================================== + +TEST (YdspFusedMultiplyAddTests, LowersTheIntrinsicToASingleOp) +{ + YdspDiagnostics diagnostics; + + const auto ir = fmaBuildIr (fmaPatch (R"( + processor P { + input stream in; + output stream out; + process { out = fma (in, 0.3, 0.7); } + } + )"), + diagnostics, + false); + + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + + const auto* kernel = fmaKernel (*ir); + ASSERT_NE (nullptr, kernel); + + EXPECT_EQ (1, fmaCountInst (*kernel, YdspIrOp::fmaF)); +} + +TEST (YdspFusedMultiplyAddTests, RejectsFloat64Operands) +{ + YdspDiagnostics diagnostics; + + fmaBuildIr (fmaPatch (R"( + processor P { + input stream in; + output stream out; + process { + float64 wide = float64 (in); + out = float32 (fma (wide, wide, wide)); + } + } + )"), + diagnostics, + false); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (diagnostics.toString().contains ("float32 only")); +} + +TEST (YdspFusedMultiplyAddTests, ExpandsToFloat64WhenTheTargetHasNoInstruction) +{ + YdspDiagnostics diagnostics; + + const auto ir = fmaBuildIr (fmaPatch (R"( + processor P { + input stream in; + output stream out; + process { out = fma (in, 0.3, 0.7); } + } + )"), + diagnostics, + false, + false); + + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + + const auto* kernel = fmaKernel (*ir); + ASSERT_NE (nullptr, kernel); + + // Nothing is left for a backend that cannot lower it, and what replaced it + // is the widen / multiply / add / narrow sequence. + EXPECT_EQ (0, fmaCountInst (*kernel, YdspIrOp::fmaF)); + EXPECT_EQ (3, fmaCountInst (*kernel, YdspIrOp::extF)); + EXPECT_EQ (1, fmaCountInst (*kernel, YdspIrOp::truncF)); +} + +TEST (YdspFusedMultiplyAddTests, DoesNotExpandWhenTheTargetHasTheInstruction) +{ + YdspDiagnostics diagnostics; + + const auto ir = fmaBuildIr (fmaPatch (R"( + processor P { + input stream in; + output stream out; + process { out = fma (in, 0.3, 0.7); } + } + )"), + diagnostics, + false, + true); + + ASSERT_NE (nullptr, ir); + + const auto* kernel = fmaKernel (*ir); + ASSERT_NE (nullptr, kernel); + + EXPECT_EQ (1, fmaCountInst (*kernel, YdspIrOp::fmaF)); + EXPECT_EQ (0, fmaCountInst (*kernel, YdspIrOp::extF)); +} + +//============================================================================== + +TEST (YdspFusedMultiplyAddTests, ContractionDoesNothingUnlessEnabled) +{ + YdspDiagnostics diagnostics; + + const auto ir = fmaBuildIr (fmaPatch (R"( + processor P { + input stream in; + output stream out; + process { out = in * 0.3 + 0.7; } + } + )"), + diagnostics, + false); + + ASSERT_NE (nullptr, ir); + + const auto* kernel = fmaKernel (*ir); + ASSERT_NE (nullptr, kernel); + + EXPECT_EQ (0, fmaCountInst (*kernel, YdspIrOp::fmaF)); +} + +TEST (YdspFusedMultiplyAddTests, ContractionFusesAMultiplyFeedingAnAdd) +{ + YdspDiagnostics diagnostics; + + const auto ir = fmaBuildIr (fmaPatch (R"( + processor P { + input stream in; + output stream out; + state float last; + process { + last = last * 0.3 + in; + out = last; + } + } + )"), + diagnostics, + true); + + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + + const auto* kernel = fmaKernel (*ir); + ASSERT_NE (nullptr, kernel); + + EXPECT_EQ (1, fmaCountInst (*kernel, YdspIrOp::fmaF)); + + // The multiply it consumed is gone rather than merely unread - the pass + // only pays for itself if the dead-code pass behind it collects the + // orphan. + EXPECT_EQ (0, fmaCountInst (*kernel, YdspIrOp::mulF)); +} + +TEST (YdspFusedMultiplyAddTests, ContractionFusesTheMultiplyOnTheRecurrence) +{ + YdspDiagnostics diagnostics; + + // The wave folder's one-pole, and the case the choice exists for: *both* + // operands of the add are fusable multiplies, only one can be fused, and + // they are not equivalent. Fusing the `last` multiply leaves + // `last -> fma -> last`; fusing the `y` one leaves + // `last -> mul -> fma -> last`, a link longer round the chain that is the + // whole critical path of this shape. + const auto ir = fmaBuildIr (fmaPatch (R"( + processor P { + input stream in; + output stream out; + state float last; + process { + let y = in * 3.0; + last = last * 0.5 + y * 0.5; + out = last; + } + } + )"), + diagnostics, + true); + + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + + const auto* kernel = fmaKernel (*ir); + ASSERT_NE (nullptr, kernel); + ASSERT_EQ (1, fmaCountInst (*kernel, YdspIrOp::fmaF)); + + // One multiply survives - the one that was not fused - so counting alone + // cannot say which was chosen. What identifies it is that the fused + // operands are the ones feeding the surviving multiply's *consumer*: the + // fused op must read the state register, not the multiply's result. + const YdspIrInst* fused = nullptr; + const YdspIrInst* survivor = nullptr; + + for (const auto& block : kernel->blocks) + { + for (const auto& inst : block.insts) + { + if (inst.op == YdspIrOp::fmaF) + fused = &inst; + else if (inst.op == YdspIrOp::mulF) + survivor = &inst; + } + } + + ASSERT_NE (nullptr, fused); + ASSERT_NE (nullptr, survivor); + + // The surviving multiply is `y * 0.5`, so it feeds the fused op's *addend*. + // Had the other one been chosen, it would feed a factor instead. + EXPECT_EQ (survivor->result, fused->c); + EXPECT_NE (survivor->result, fused->a); + EXPECT_NE (survivor->result, fused->b); +} + +TEST (YdspFusedMultiplyAddTests, ContractionFollowsIndirectFeedbackDependencies) +{ + const auto source = fmaPatch (R"( + processor P { + input stream in; output stream out; state float z; + process { z = 0.37 * z + 0.63 * (in - z); out = z; } + } + )"); + YdspDiagnostics diagnostics; + const auto ir = fmaBuildIr (source, diagnostics, true); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + const auto* kernel = fmaKernel (*ir); + ASSERT_NE (nullptr, kernel); + + const YdspIrInst* difference = nullptr; + const YdspIrInst* fused = nullptr; + for (const auto& block : kernel->blocks) + for (const auto& inst : block.insts) + { + if (inst.op == YdspIrOp::subF) + difference = &inst; + if (inst.op == YdspIrOp::fmaF) + fused = &inst; + } + + ASSERT_NE (nullptr, difference); + ASSERT_NE (nullptr, fused); + EXPECT_TRUE (fused->a == difference->result || fused->b == difference->result); + + std::vector input (2049); + for (size_t i = 0; i < input.size(); ++i) + input[i] = static_cast (static_cast (i % 31) - 15) / 16.0f; + const auto output = fmaRun (source, input); + ASSERT_EQ (input.size(), output.size()); + float state = 0.0f; + for (size_t i = 0; i < input.size(); ++i) + { + const float differenceValue = input[i] - state; + const float direct = 0.37f * state; + state = std::fma (0.63f, differenceValue, direct); + EXPECT_EQ (state, output[i]) << i; + } +} + +TEST (YdspFusedMultiplyAddTests, ContractionFusesTheRightProductOfADifference) +{ + const auto source = fmaPatch (R"( + processor P { + input stream in; output stream out; + process { out = in * 0.3 - (in + 0.1) * 0.7; } + } + )"); + YdspDiagnostics diagnostics; + const auto ir = fmaBuildIr (source, diagnostics, true); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + const auto* kernel = fmaKernel (*ir); + ASSERT_NE (nullptr, kernel); + EXPECT_EQ (1, fmaCountInst (*kernel, YdspIrOp::fmsubF)); + EXPECT_EQ (1, fmaCountInst (*kernel, YdspIrOp::mulF)); + + const std::vector input { -0.9f, -0.1f, 0.0f, 0.3f, 0.9f }; + const auto output = fmaRun (source, input); + ASSERT_EQ (input.size(), output.size()); + for (size_t i = 0; i < input.size(); ++i) + { + const float left = input[i] * 0.3f; + const float right = input[i] + 0.1f; + EXPECT_EQ (std::fma (-right, 0.7f, left), output[i]); + } +} + +TEST (YdspFusedMultiplyAddTests, ContractionLeavesAMultiplyWithASecondReader) +{ + YdspDiagnostics diagnostics; + + // `scaled` is read by the add *and* by the output, so folding the multiply + // into the add would delete a value something else still needs. + const auto ir = fmaBuildIr (fmaPatch (R"( + processor P { + input stream in; + output stream out; + state float last; + process { + let scaled = in * 0.3; + last = scaled + last; + out = last + scaled; + } + } + )"), + diagnostics, + true); + + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + + const auto* kernel = fmaKernel (*ir); + ASSERT_NE (nullptr, kernel); + + EXPECT_EQ (0, fmaCountInst (*kernel, YdspIrOp::fmaF)); + EXPECT_EQ (1, fmaCountInst (*kernel, YdspIrOp::mulF)); +} + +TEST (YdspFusedMultiplyAddTests, ContractionLeavesABankLoopUnfusedWithoutPackedFma) +{ + YdspDiagnostics diagnostics; + + // A non-FMA vector target retains packed multiply then add. It must not + // form fmaF and rely on the scalar exact fallback, which cannot lower a + // whole vector without changing the loop shape. + const auto ir = fmaBuildIr (fmaPatch (fmaVectorBankSource), diagnostics, true, true, false, true); + ASSERT_NE (nullptr, ir); + + const auto* kernel = fmaKernel (*ir); + ASSERT_NE (nullptr, kernel); + ASSERT_TRUE (kernel->vectorized); + + EXPECT_EQ (0, fmaCountInst (*kernel, YdspIrOp::fmaF)); +} + +TEST (YdspFusedMultiplyAddTests, ContractionFormsPackedFmaWhenTargetSupportsIt) +{ + YdspDiagnostics diagnostics; + + const auto ir = fmaBuildIr (fmaPatch (fmaVectorBankSource), diagnostics, true, true, true, true); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + + const auto* kernel = fmaKernel (*ir); + ASSERT_NE (nullptr, kernel); + ASSERT_TRUE (kernel->vectorized); + + EXPECT_EQ (1, fmaCountInst (*kernel, YdspIrOp::fmaF)); +} + +//============================================================================== +// The arithmetic. These run the compiled kernel, so they check whichever +// lowering this build selected - the instruction on a target that has one, the +// float64 expansion otherwise - against the same reference either way. + +TEST (YdspFusedMultiplyAddTests, TheOperandsMakeFusingObservable) +{ + // The guard on every test below. Fusing only removes a rounding, so with + // operands where that rounding did nothing the arithmetic tests would pass + // against either lowering and prove nothing at all. + EXPECT_NE (std::fma (fmaAwkwardA, fmaAwkwardB, fmaAwkwardC), + fmaAwkwardA * fmaAwkwardB + fmaAwkwardC); +} + +TEST (YdspFusedMultiplyAddTests, TheIntrinsicRoundsOnce) +{ + const std::vector input (8, fmaAwkwardA); + const auto output = fmaRun (fmaPatch (fmaIntrinsicSource), input); + + ASSERT_EQ (input.size(), output.size()); + + // Exact equality, not EXPECT_FLOAT_EQ: that admits 4 ULP, and the whole + // difference this test exists to see is one. + const auto expected = std::fma (fmaAwkwardA, fmaAwkwardB, fmaAwkwardC); + + for (const auto sample : output) + EXPECT_EQ (expected, sample); +} + +TEST (YdspFusedMultiplyAddTests, TheExpansionMatchesTheInstruction) +{ + // Both lowerings of one source. The portability property the whole design + // rests on is that these are the same value, so the comparison is exact. + YdspDiagnostics withInstruction; + YdspDiagnostics withExpansion; + + const auto source = fmaPatch (fmaIntrinsicSource); + + const auto native = fmaBuildIr (source, withInstruction, false, true); + const auto expanded = fmaBuildIr (source, withExpansion, false, false); + + ASSERT_NE (nullptr, native); + ASSERT_NE (nullptr, expanded); + + // The two IR shapes differ, which is the point of the pass. + EXPECT_EQ (1, fmaCountInst (*fmaKernel (*native), YdspIrOp::fmaF)); + EXPECT_EQ (0, fmaCountInst (*fmaKernel (*expanded), YdspIrOp::fmaF)); + + // What must not differ is the value. This build compiles through whichever + // path its host supports; the reference is the same either way. + const std::vector input (8, fmaAwkwardA); + const auto output = fmaRun (source, input); + + ASSERT_EQ (input.size(), output.size()); + + for (const auto sample : output) + EXPECT_EQ (std::fma (fmaAwkwardA, fmaAwkwardB, fmaAwkwardC), sample); +} + +//============================================================================== +// The fused multiply-subtract. There is no `fmsub()` to spell in a patch, so +// the only way to reach `fmsubF` is contraction of `c - a * b` - which is +// exactly why its rounding needs pinning: on AArch64 the operation has a +// hardware instruction (`fmsub d, n, m, a` = a - n * m, one rounding), while a +// target without one reaches it through lowerFusedMultiplyAdd()'s float64 +// expansion, also one rounding. The requirement is that those agree. + +TEST (YdspFusedMultiplyAddTests, TheFmsubOperandsMakeFusingObservable) +{ + // The guard on the two tests below, for the same reason the fma one exists: + // with operands where the removed rounding did nothing, they would pass + // against either lowering. + EXPECT_NE (std::fma (-fmsubAwkwardA, fmsubAwkwardB, fmsubAwkwardC), + fmsubAwkwardC - fmsubAwkwardA * fmsubAwkwardB); +} + +TEST (YdspFusedMultiplyAddTests, ContractionFusesAMultiplyFeedingASubtract) +{ + YdspDiagnostics diagnostics; + + const auto ir = fmaBuildIr (fmaPatch (fmsubContractedSource), diagnostics, true); + + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + + const auto* kernel = fmaKernel (*ir); + ASSERT_NE (nullptr, kernel); + + EXPECT_EQ (1, fmaCountInst (*kernel, YdspIrOp::fmsubF)); + EXPECT_EQ (0, fmaCountInst (*kernel, YdspIrOp::mulF)); +} + +TEST (YdspFusedMultiplyAddTests, ContractionFusesAProductMinusAnAddend) +{ + const auto source = fmaPatch (R"( + processor P { + input stream in; output stream out; + process { out = in * 0.7 - 0.21; } + } + )"); + for (const bool contract : { false, true }) + { + YdspDiagnostics diagnostics; + const auto ir = fmaBuildIr (source, diagnostics, contract); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + const auto* kernel = fmaKernel (*ir); + ASSERT_NE (nullptr, kernel); + EXPECT_EQ (contract ? 1 : 0, fmaCountInst (*kernel, YdspIrOp::fmaF)); + EXPECT_EQ (contract ? 0 : 1, fmaCountInst (*kernel, YdspIrOp::mulF)); + } + + const std::vector input { -0.3f, 0.0f, 0.3f, 1.0f }; + String listing; + const auto output = fmaRun (source, input, &listing); + ASSERT_EQ (input.size(), output.size()); + + for (size_t i = 0; i < input.size(); ++i) + EXPECT_EQ (std::fma (input[i], 0.7f, -0.21f), output[i]) << std::endl + << listing.toRawUTF8(); +} + +TEST (YdspFusedMultiplyAddTests, FusedProductSubtractPreservesTheOldStateAddend) +{ + const auto source = fmaPatch (R"( + processor P { + input stream in; output stream out; state float z; + process { z = fma (0.7, in, -z); out = z; } + } + )"); + const std::vector input { 0.3f, -0.1f, 0.0f, 0.9f, -0.7f, 0.2f }; + const auto output = fmaRun (source, input); + ASSERT_EQ (input.size(), output.size()); + float state = 0.0f; + for (size_t i = 0; i < input.size(); ++i) + { + state = std::fma (0.7f, input[i], -state); + EXPECT_EQ (state, output[i]); + } +} + +TEST (YdspFusedMultiplyAddTests, FusedSubtractWriteBackPreservesEitherOldStateFactor) +{ + for (const auto* expression : { "in - z * 0.7", "in - 0.7 * z" }) + { + const auto source = fmaPatch (String ("processor P { input stream in; output stream out; state float z; process { z = ") + + expression + "; out = z; } }"); + const std::vector input { 0.3f, -0.1f, 0.0f, 0.9f, -0.7f, 0.2f }; + const auto output = fmaRun (source, input); + ASSERT_EQ (input.size(), output.size()); + float state = 0.0f; + for (size_t i = 0; i < input.size(); ++i) + { + state = std::fma (-state, 0.7f, input[i]); + EXPECT_EQ (state, output[i]); + } + } +} + +TEST (YdspFusedMultiplyAddTests, TheContractedSubtractRoundsOnce) +{ + // `c - a * b` fused is `fma (-a, b, c)`: the product is formed exactly and + // the subtraction rounds once. Exact equality, not EXPECT_FLOAT_EQ - the + // whole difference under test is a single ULP. + // + // This is what the AArch64 lowering of fmsubF has to deliver. Emitting + // `fmul` + `fsub` there would round twice and fail here, while every + // target *without* the instruction already rounds once through the float64 + // expansion - so this pins the two together rather than letting the + // FMA-capable target be the less accurate one. + const std::vector input (8, fmsubAwkwardA); + const auto output = fmaRun (fmaPatch (fmsubContractedSource), input); + + ASSERT_EQ (input.size(), output.size()); + + const auto expected = std::fma (-fmsubAwkwardA, fmsubAwkwardB, fmsubAwkwardC); + + for (const auto sample : output) + EXPECT_EQ (expected, sample); +} + +} // namespace yup::test diff --git a/tests/yup_dsp_jit/yup_YdspFusionTests.cpp b/tests/yup_dsp_jit/yup_YdspFusionTests.cpp new file mode 100644 index 000000000..41b3b6188 --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspFusionTests.cpp @@ -0,0 +1,1189 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include +#include +#include + +namespace yup::test +{ + +namespace +{ + +YdspAudioGraph fusionCompile (StringRef source, YdspCompiler& compiler) +{ + auto result = compiler.compile (source); + EXPECT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + if (! result.wasOk()) + return {}; + + return std::move (result).getValue(); +} + +/** True when the compiled graph contains a synthesized fused kernel. */ +bool fusionHappened (const YdspAudioGraph& graph) +{ + for (const auto& kernel : graph.getExecutionReport().getKernels()) + if (kernel.name.startsWith ("fused(")) + return true; + + return false; +} + +/** Dumps every kernel's generated code when a test fails. + + The fused kernel is synthesized, so when its output is wrong there is no + source to read - the listing is the only way to see what was actually + built. */ +void fusionDumpOnFailure (const YdspAudioGraph& graph) +{ + if (! ::testing::Test::HasFailure()) + return; + + const auto text = graph.getDiagnostics().toString(); + + if (! text.isEmpty()) + std::cout << "\n[fusion] generated kernels:\n" + << text << std::endl; +} + +void fusionRun (YdspAudioGraph& graph, const float* input, float* output, int numSamples) +{ + std::vector inputs; + inputs.emplace_back (Span (input, static_cast (numSamples))); + + std::vector outputs; + outputs.emplace_back (Span (output, static_cast (numSamples))); + + graph.process (yup::YdspProcessRequest { inputs, outputs, numSamples }); +} + +constexpr int fusionBlockSize = 16; + +std::vector fusionRamp() +{ + std::vector data (static_cast (fusionBlockSize)); + + for (int i = 0; i < fusionBlockSize; ++i) + data[static_cast (i)] = 0.1f + static_cast (i) * 0.05f; + + return data; +} + +// Three stages, each with state, deliberately reusing the same state name `z` +// and the same local name `t` so the rename has to keep them apart. +constexpr auto fusionChainSource = R"YDSP( + processor A { + input stream in; + output stream out; + state float z; + process { let t = in * 0.5; z = z * 0.5 + t; out = z; } + } + + processor B { + input stream in; + output stream out; + state float z; + process { let t = in + 0.25; z = z * 0.25 + t; out = z; } + } + + processor C { + input stream in; + output stream out; + state float z; + process { let t = in * 2.0; z = t - z * 0.125; out = z; } + } + + graph G { + input stream x; + output stream y; + + node a = A; + node b = B; + node c = C; + + connection { x -> a.in; a.out -> b.in; b.out -> c.in; c.out -> y; } + } +)YDSP"; + +// The same computation written as one processor: the reference the fused kernel +// has to agree with, bit for bit. +constexpr auto fusionEquivalentSource = R"YDSP( + processor Whole { + input stream in; + output stream out; + + state float za; + state float zb; + state float zc; + + process { + let ta = in * 0.5; + za = za * 0.5 + ta; + + let tb = za + 0.25; + zb = zb * 0.25 + tb; + + let tc = zb * 2.0; + zc = tc - zc * 0.125; + + out = zc; + } + } + + graph G { + input stream x; + output stream y; + node w = Whole; + connection { x -> w.in; w.out -> y; } + } +)YDSP"; + +} // namespace + +//============================================================================== +// What fusion does +//============================================================================== + +TEST (YdspFusionTests, FusesAChainOfThreeNodes) +{ + YdspCompiler compiler; + auto graph = fusionCompile (fusionChainSource, compiler); + + ASSERT_TRUE (graph.isValid()); + EXPECT_TRUE (fusionHappened (graph)); +} + +TEST (YdspFusionTests, FusedChainMatchesTheEquivalentSingleProcessor) +{ + YdspCompiler chainCompiler, wholeCompiler; + + auto chain = fusionCompile (fusionChainSource, chainCompiler); + auto whole = fusionCompile (fusionEquivalentSource, wholeCompiler); + + ASSERT_TRUE (chain.isValid()); + ASSERT_TRUE (whole.isValid()); + ASSERT_TRUE (fusionHappened (chain)); + ASSERT_FALSE (fusionHappened (whole)); // a single node is not a chain + + chain.prepare (44100.0, fusionBlockSize); + whole.prepare (44100.0, fusionBlockSize); + + const auto input = fusionRamp(); + std::vector chainOutput (static_cast (fusionBlockSize), 0.0f); + std::vector wholeOutput (static_cast (fusionBlockSize), 0.0f); + + // Several blocks, so the state carried across calls is exercised too. + for (int block = 0; block < 4; ++block) + { + fusionRun (chain, input.data(), chainOutput.data(), fusionBlockSize); + fusionRun (whole, input.data(), wholeOutput.data(), fusionBlockSize); + + for (int i = 0; i < fusionBlockSize; ++i) + EXPECT_FLOAT_EQ (wholeOutput[static_cast (i)], chainOutput[static_cast (i)]) + << "block " << block << " sample " << i; + } + + fusionDumpOnFailure (chain); +} + +TEST (YdspFusionTests, FusesTheAlgebraFormIdenticallyToTheConnectionForm) +{ + // Both syntaxes converge on the same analyzed edges, so both fuse. + YdspCompiler algebraCompiler, connectionCompiler; + + auto algebra = fusionCompile (R"YDSP( + processor Half { input stream in; output stream out; state float z; process { z = z * 0.5 + in; out = z; } } + processor Twice { input stream in; output stream out; process { out = in * 2.0; } } + graph G { + input stream x; + output stream y; + process = x : Half : Twice : y; + } + )YDSP", + algebraCompiler); + + auto connections = fusionCompile (R"YDSP( + processor Half { input stream in; output stream out; state float z; process { z = z * 0.5 + in; out = z; } } + processor Twice { input stream in; output stream out; process { out = in * 2.0; } } + graph G { + input stream x; + output stream y; + node h = Half; + node t = Twice; + connection { x -> h.in; h.out -> t.in; t.out -> y; } + } + )YDSP", + connectionCompiler); + + ASSERT_TRUE (algebra.isValid()); + ASSERT_TRUE (connections.isValid()); + + EXPECT_TRUE (fusionHappened (algebra)); + EXPECT_TRUE (fusionHappened (connections)); + + algebra.prepare (44100.0, fusionBlockSize); + connections.prepare (44100.0, fusionBlockSize); + + const auto input = fusionRamp(); + std::vector algebraOutput (static_cast (fusionBlockSize), 0.0f); + std::vector connectionOutput (static_cast (fusionBlockSize), 0.0f); + + fusionRun (algebra, input.data(), algebraOutput.data(), fusionBlockSize); + fusionRun (connections, input.data(), connectionOutput.data(), fusionBlockSize); + + for (int i = 0; i < fusionBlockSize; ++i) + EXPECT_FLOAT_EQ (connectionOutput[static_cast (i)], algebraOutput[static_cast (i)]) << "sample " << i; +} + +TEST (YdspFusionTests, DropsTheKernelsOfTheMembersItAbsorbed) +{ + // A member the fused body absorbed is no longer instantiated anywhere, so + // compiling it would be dead machine code - and it would show up in the + // report as a kernel the patch appears to run. + YdspCompiler compiler; + auto graph = fusionCompile (fusionChainSource, compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + ASSERT_TRUE (fusionHappened (graph)); + + StringArray names; + + for (const auto& kernel : graph.getExecutionReport().getKernels()) + names.add (kernel.name); + + EXPECT_EQ (1, names.size()) << "compiled " << names.joinIntoString (", "); + EXPECT_FALSE (names.contains ("A")); + EXPECT_FALSE (names.contains ("B")); + EXPECT_FALSE (names.contains ("C")); +} + +TEST (YdspFusionTests, KeepsAMemberKernelStillInstantiatedElsewhere) +{ + // `Half` is used twice: once inside the chain that fuses and once on its own + // branch. Absorbing the first instance must not take the processor away from + // the second, so exactly the fused kernel and `Half` survive. + YdspCompiler compiler; + + auto graph = fusionCompile (R"YDSP( + processor Half { input stream in; output stream out; process { out = in * 0.5; } } + processor Twice { input stream in; output stream out; process { out = in * 2.0; } } + + graph G { + input stream x; + input stream w; + output stream y; + output stream z; + + node chained = Half; + node tail = Twice; + node alone = Half; + + connection { x -> chained.in; chained.out -> tail.in; tail.out -> y; w -> alone.in; alone.out -> z; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + ASSERT_TRUE (fusionHappened (graph)); + + StringArray names; + + for (const auto& kernel : graph.getExecutionReport().getKernels()) + names.add (kernel.name); + + EXPECT_TRUE (names.contains ("Half")) << "compiled " << names.joinIntoString (", "); + EXPECT_FALSE (names.contains ("Twice")); + EXPECT_EQ (2, names.size()); +} + +//============================================================================== +// The host-visible surface has to survive +//============================================================================== + +TEST (YdspFusionTests, ParameterNamesSurviveFusion) +{ + YdspCompiler compiler; + + auto graph = fusionCompile (R"YDSP( + processor Gain { + input stream in; + output stream out; + input parameter float g = 1.0 [[ name: "Gain" ]]; + process { out = in * g; } + } + + processor Trim { + input stream in; + output stream out; + input parameter float t = 1.0; + process { out = in * t; } + } + + graph G { + input stream x; + output stream y; + node first = Gain; + node second = Trim; + connection { x -> first.in; first.out -> second.in; second.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (fusionHappened (graph)); + + // The patch named these `first.g` and `second.t`; fusing the two nodes into + // one must not rename them out from under the host. + ASSERT_TRUE (graph.hasParameter ("first.g")) << "fusion renamed a parameter"; + ASSERT_TRUE (graph.hasParameter ("second.t")); + + EXPECT_FLOAT_EQ (1.0f, graph.getParameter ("first.g")); + EXPECT_FLOAT_EQ (1.0f, graph.getParameter ("second.t")); + + graph.prepare (44100.0, fusionBlockSize); + graph.setParameter ("first.g", 2.0f); + graph.setParameter ("second.t", 3.0f); + + const auto input = fusionRamp(); + std::vector output (static_cast (fusionBlockSize), 0.0f); + + fusionRun (graph, input.data(), output.data(), fusionBlockSize); + + // Both parameters reach the fused kernel: 2 * 3 = 6. + for (int i = 0; i < fusionBlockSize; ++i) + EXPECT_FLOAT_EQ (input[static_cast (i)] * 6.0f, output[static_cast (i)]) << "sample " << i; + + fusionDumpOnFailure (graph); +} + +TEST (YdspFusionTests, GraphParameterAliasStillDrivesAFusedMember) +{ + YdspCompiler compiler; + + auto graph = fusionCompile (R"YDSP( + processor Gain { + input stream in; + output stream out; + input parameter float g = 1.0; + process { out = in * g; } + } + + processor Twice { input stream in; output stream out; process { out = in * 2.0; } } + + graph G { + input stream x; + output stream y; + input parameter float master = 0.5; + + node first = Gain; + node second = Twice; + + connection { x -> first.in; first.out -> second.in; second.out -> y; master -> first.g; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (fusionHappened (graph)); + + graph.prepare (44100.0, fusionBlockSize); + + ASSERT_TRUE (graph.hasParameter ("master")); + graph.setParameter ("master", 0.25f); + + const auto input = fusionRamp(); + std::vector output (static_cast (fusionBlockSize), 0.0f); + + fusionRun (graph, input.data(), output.data(), fusionBlockSize); + + // The graph parameter still reaches the (now fused) member: 0.25 * 2. + for (int i = 0; i < fusionBlockSize; ++i) + EXPECT_FLOAT_EQ (input[static_cast (i)] * 0.5f, output[static_cast (i)]) << "sample " << i; +} + +TEST (YdspFusionTests, FusesMembersCarryingStateInitialisersAndFunctions) +{ + YdspCompiler compiler; + + auto graph = fusionCompile (R"YDSP( + processor Scaled { + input stream in; + output stream out; + + state float coeff = 0.25; + + func scale (v: float) : float { return v * 2.0; } + + process { out = scale (in) * coeff; } + } + + processor Offset { + input stream in; + output stream out; + + state float bias = 1.0; + + func scale (v: float) : float { return v + v; } + + process { out = scale (in) + bias; } + } + + graph G { + input stream x; + output stream y; + node s = Scaled; + node o = Offset; + connection { x -> s.in; s.out -> o.in; o.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + ASSERT_TRUE (fusionHappened (graph)); + + graph.prepare (44100.0, fusionBlockSize); + + const auto input = fusionRamp(); + std::vector output (static_cast (fusionBlockSize), 0.0f); + + fusionRun (graph, input.data(), output.data(), fusionBlockSize); + + // Both members declare a function called `scale` with different bodies, so + // the renaming has to keep the two of them distinct. + for (int i = 0; i < fusionBlockSize; ++i) + { + const auto x = input[static_cast (i)]; + const auto expected = (x * 2.0f * 0.25f) * 2.0f + 1.0f; + + EXPECT_FLOAT_EQ (expected, output[static_cast (i)]) << "sample " << i; + } +} + +TEST (YdspFusionTests, MeterNamesSurviveFusion) +{ + // Three meters over two members, and three values that are pairwise + // distinct on the last sample - a name that survives but resolves to the + // wrong slot is a different failure from one that stops resolving, and only + // a patch with more than one meter can tell them apart. + YdspCompiler compiler; + + auto graph = fusionCompile (R"YDSP( + processor Metered { + input stream in; + output stream out; + output parameter float level; + output parameter float raw; + process { out = in * 0.5; level = abs (in); raw = in; } + } + + processor Twice { + input stream in; + output stream out; + output parameter float seen; + process { out = in * 2.0; seen = in; } + } + + graph G { + input stream x; + output stream y; + node m = Metered; + node t = Twice; + connection { x -> m.in; m.out -> t.in; t.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + ASSERT_TRUE (fusionHappened (graph)); + + graph.prepare (44100.0, fusionBlockSize); + + // Descending through zero, so `abs (in)` and `in` differ on the last sample + // and the two meters of the same member cannot be confused for each other. + std::vector input (static_cast (fusionBlockSize)); + + for (int i = 0; i < fusionBlockSize; ++i) + input[static_cast (i)] = 0.75f - static_cast (i) * 0.1f; + + std::vector output (static_cast (fusionBlockSize), 0.0f); + + fusionRun (graph, input.data(), output.data(), fusionBlockSize); + + for (int i = 0; i < fusionBlockSize; ++i) + EXPECT_FLOAT_EQ (input[static_cast (i)], output[static_cast (i)]) << "sample " << i; + + // A meter is written every sample, so what the host reads back is the last + // one of the block - and each is still addressable under the name the patch + // gave it, not one derived from the synthesized fused node. + const auto last = input.back(); + + ASSERT_LT (last, 0.0f); // the premise of the three expectations below + + EXPECT_FLOAT_EQ (-last, graph.getOutputValue ("m.level")) << "fusion renamed or reordered a meter"; + EXPECT_FLOAT_EQ (last, graph.getOutputValue ("m.raw")); + EXPECT_FLOAT_EQ (last * 0.5f, graph.getOutputValue ("t.seen")); + + fusionDumpOnFailure (graph); +} + +TEST (YdspFusionTests, MeterEdgeToAGraphMeterSurvivesFusion) +{ + // The edge itself has to be rerouted onto the fused node, not just the name: + // the compaction that follows maps a still-dead source node to -1 without a + // word in release builds, so a missed edge stops reporting silently. + YdspCompiler compiler; + + auto graph = fusionCompile (R"YDSP( + processor Twice { input stream in; output stream out; process { out = in * 2.0; } } + + processor Metered { + input stream in; + output stream out; + output parameter float level; + process { out = in; level = in * 0.25; } + } + + graph G { + input stream x; + output stream y; + output parameter float peak; + + node t = Twice; + node m = Metered; + + connection { x -> t.in; t.out -> m.in; m.out -> y; m.level -> peak; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + ASSERT_TRUE (fusionHappened (graph)); + + graph.prepare (44100.0, fusionBlockSize); + + const auto input = fusionRamp(); + std::vector output (static_cast (fusionBlockSize), 0.0f); + + fusionRun (graph, input.data(), output.data(), fusionBlockSize); + + // The meter reads the second stage's input, which is twice the graph input. + EXPECT_FLOAT_EQ (input.back() * 2.0f * 0.25f, graph.getOutputValue ("peak")); + EXPECT_FLOAT_EQ (input.back() * 2.0f * 0.25f, graph.getOutputValue ("m.level")); + + fusionDumpOnFailure (graph); +} + +TEST (YdspFusionTests, EventEdgeSurvivesCompactionAfterAnUnrelatedFusion) +{ + // `a`/`b` are an unrelated, otherwise-fusable pair declared first, so they + // are the ones that fuse away and free up the low node indices; `arp`/ + // `voice` are declared after them and hold the graph's only event edge, so + // it is their indices the fusion pass's own compaction has to shift. A + // missing (or wrong) `liveIndex` remap on `graph.eventEdges` would resolve + // the routed event against stale, pre-compaction node indices. + YdspCompiler compiler; + + auto graph = fusionCompile (R"YDSP( + processor Half { input stream in; output stream out; process { out = in * 0.5; } } + processor Twice { input stream in; output stream out; process { out = in * 2.0; } } + + processor Arp { + input stream trig; + output event noteOn; + process { if (trig > 0.5) { emit noteOn (pitch: 72, velocity: 0.8) -> noteOn; } } + } + + processor Voice { + input event midi; + output stream out; + state float sounding; + event midi (e: noteOn) { sounding = e.pitch; } + event midi (e: noteOff) { sounding = 0.0; } + process { out = sounding; } + } + + graph G { + input stream x; + input stream trig; + output stream y; + output stream v; + + node a = Half; + node b = Twice; + node arp = Arp; + node voice = Voice; + + connection { x -> a.in; a.out -> b.in; b.out -> y; trig -> arp.trig; arp.noteOn -> voice.midi; voice.out -> v; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + ASSERT_TRUE (fusionHappened (graph)); + + graph.prepare (44100.0, fusionBlockSize); + + const auto input = fusionRamp(); + std::vector trig (static_cast (fusionBlockSize), 0.0f); + trig[0] = 1.0f; + + std::vector output (static_cast (fusionBlockSize), 0.0f); + std::vector voiceOut (static_cast (fusionBlockSize), 0.0f); + + std::vector inputs { + YdspInputBuffer (Span (input.data(), input.size())), + YdspInputBuffer (Span (trig.data(), trig.size())) + }; + std::vector outputs { + YdspOutputBuffer (Span (output.data(), output.size())), + YdspOutputBuffer (Span (voiceOut.data(), voiceOut.size())) + }; + + graph.process (yup::YdspProcessRequest { inputs, outputs, fusionBlockSize }); + + for (int i = 0; i < fusionBlockSize; ++i) + EXPECT_FLOAT_EQ (72.0f, voiceOut[static_cast (i)]) << "sample " << i; + + fusionDumpOnFailure (graph); +} + +//============================================================================== +// What fusion must leave alone +//============================================================================== + +// The `fanOut != 1` half of the fusion rule became reachable when node outputs +// gained the ability to fan out: a tapped intermediate is observable, so the +// link it sits on must not fuse. See DoesNotFuseATappedIntermediate below. + +TEST (YdspFusionTests, DoesNotFuseIntoAMultipleInputNode) +{ + // Only a single-in/single-out kernel can become a stage of one fused loop, + // so a chain running into a mixer stops there. The second graph input keeps + // the two mixer inputs on distinct producers, which is what this test is + // about - one producer feeding both would now be legal but would be testing + // fan-out instead. + YdspCompiler compiler; + + auto graph = fusionCompile (R"YDSP( + processor Half { input stream in; output stream out; process { out = in * 0.5; } } + processor Mix { input stream a; input stream b; output stream out; process { out = a + b; } } + + graph G { + input stream x; + input stream w; + output stream y; + + node h = Half; + node m = Mix; + + connection { x -> h.in; h.out -> m.a; w -> m.b; m.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + EXPECT_FALSE (fusionHappened (graph)); +} + +//============================================================================== +// Fusing a member that owns hidden state +// +// `@` allocates a ring in the *array* segment plus an int32 write pointer in the +// scalar segment, and `smooth` allocates another scalar - none of them declared, +// all of them assigned by the IR builder as it lowers the body. Fusing two such +// members concatenates their bodies into one, so those hidden allocations have to +// accumulate across both. Getting it wrong writes outside the node's state slice. +// +// The two shapes below are the same patch; the second taps the intermediate to a +// second graph output, which makes the producer fan out and so blocks the fusion. +// Comparing them is what turns a layout error into a number rather than a crash. + +namespace +{ + +constexpr auto fusionHiddenStateBody = + "processor Rings {\n" + " input stream in;\n" + " output stream out;\n" + " state float c1;\n" + " state float c2;\n" + " process {\n" + " let d1 = c1 @ 7;\n" + " c1 = d1 * 0.5 + in;\n" + " let d2 = c2 @ 5;\n" + " c2 = d2 * 0.25 + in;\n" + " out = d1 + d2 + (in @ 3);\n" + " }\n" + "}\n" + "processor Trim {\n" + " input stream in;\n" + " output stream out;\n" + " input parameter float g = 0.5 [[ smoothing: 0.02 ]];\n" + " process { out = in * g; }\n" + "}\n"; + +} // namespace + +TEST (YdspFusionTests, FusingAMemberWithHiddenDelayStateMatchesTheUnfusedChain) +{ + YdspCompiler fusedCompiler; + YdspCompiler splitCompiler; + + auto fused = fusionCompile (String (fusionHiddenStateBody) + R"YDSP( + graph G { + input stream x; + output stream y; + node r = Rings; + node t = Trim; + connection { x -> r.in; r.out -> t.in; t.out -> y; } + } + )YDSP", + fusedCompiler); + + // The tap gives `r.out` a second destination, so this one cannot fuse - it + // is the reference the fused version has to agree with. + auto split = fusionCompile (String (fusionHiddenStateBody) + R"YDSP( + graph G { + input stream x; + output stream y; + output stream tap; + node r = Rings; + node t = Trim; + connection { x -> r.in; r.out -> t.in; r.out -> tap; t.out -> y; } + } + )YDSP", + splitCompiler); + + ASSERT_TRUE (fused.isValid()) << fusedCompiler.getDiagnostics().toString(); + ASSERT_TRUE (split.isValid()) << splitCompiler.getDiagnostics().toString(); + + EXPECT_TRUE (fusionHappened (fused)); + EXPECT_FALSE (fusionHappened (split)); + + constexpr int blockSize = 64; + constexpr int blockCount = 4; + + fused.prepare (48000.0, blockSize); + split.prepare (48000.0, blockSize); + + std::vector input (static_cast (blockSize)); + for (int i = 0; i < blockSize; ++i) + input[static_cast (i)] = 0.1f + static_cast (i % 9) * 0.07f; + + // Several blocks, so the rings wrap and the smoother settles: a layout error + // that only shows up after the write pointer has come round would be missed + // by a single block. + for (int block = 0; block < blockCount; ++block) + { + std::vector fusedOut (static_cast (blockSize), 0.0f); + std::vector splitOut (static_cast (blockSize), 0.0f); + std::vector splitTap (static_cast (blockSize), 0.0f); + + fusionRun (fused, input.data(), fusedOut.data(), blockSize); + + { + std::vector inputs { + YdspInputBuffer (Span (input.data(), input.size())) + }; + std::vector outputs { + YdspOutputBuffer (Span (splitOut.data(), splitOut.size())), + YdspOutputBuffer (Span (splitTap.data(), splitTap.size())) + }; + split.process (yup::YdspProcessRequest { inputs, outputs, blockSize }); + } + + for (int i = 0; i < blockSize; ++i) + EXPECT_NEAR (splitOut[static_cast (i)], fusedOut[static_cast (i)], 1e-5f) + << "block " << block << " sample " << i; + } + + fusionDumpOnFailure (fused); +} + +TEST (YdspFusionTests, ReverbSizedDelayRingsWorkWithoutFusion) +{ + // The control for FusesAMemberWithReverbSizedDelayRings below: the *same* + // processor, alone in the graph so there is no chain to fuse. If this + // crashes too then fusion is irrelevant and the bug is in large multi-ring + // state on its own; if it passes, fusion is genuinely implicated. + YdspCompiler compiler; + + auto graph = fusionCompile (R"YDSP( + processor Combs { + input stream in; + output stream out; + input parameter float feedback = 0.7 [[ smoothing: 0.02 ]]; + state float c1; + state float c2; + state float c3; + state float c4; + process { + let d1 = c1 @ 1116; + c1 = d1 * feedback + in * 0.015; + let d2 = c2 @ 1188; + c2 = d2 * feedback + in * 0.015; + let d3 = c3 @ 1277; + c3 = d3 * feedback + in * 0.015; + let d4 = c4 @ 1356; + c4 = d4 * feedback + in * 0.015; + out = d1 + d2 + d3 + d4; + } + } + graph G { + input stream x; + output stream y; + node c = Combs; + connection { x -> c.in; c.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + ASSERT_FALSE (fusionHappened (graph)); // one node is not a chain + + constexpr int blockSize = 64; + graph.prepare (48000.0, blockSize); + + std::vector input (static_cast (blockSize), 0.25f); + + for (int block = 0; block < 32; ++block) + { + std::vector output (static_cast (blockSize), 0.0f); + fusionRun (graph, input.data(), output.data(), blockSize); + + for (int i = 0; i < blockSize; ++i) + { + const auto sample = output[static_cast (i)]; + ASSERT_TRUE (std::isfinite (sample)) << "block " << block << " sample " << i; + ASSERT_LT (std::fabs (sample), 100.0f) << "block " << block << " sample " << i; + } + } +} + +TEST (YdspFusionTests, FusesTwoMembersEachOwningLargeDelayRings) +{ + // Narrows the ingredient list further: rings in *both* members, and no + // `smooth` anywhere. If this crashes but the smoothed version does not (or + // vice versa) that isolates whether the hidden int32 write pointers and the + // hidden `smooth` slots are interfering with each other's allocation. + YdspCompiler compiler; + + auto graph = fusionCompile (R"YDSP( + processor CombA { + input stream in; + output stream out; + state float c1; + state float c2; + process { + let d1 = c1 @ 1116; + c1 = d1 * 0.7 + in * 0.015; + let d2 = c2 @ 1188; + c2 = d2 * 0.7 + in * 0.015; + out = d1 + d2; + } + } + processor CombB { + input stream in; + output stream out; + state float c3; + state float c4; + process { + let d3 = c3 @ 1277; + c3 = d3 * 0.7 + in * 0.015; + let d4 = c4 @ 1356; + c4 = d4 * 0.7 + in * 0.015; + out = d3 + d4; + } + } + graph G { + input stream x; + output stream y; + node a = CombA; + node b = CombB; + connection { x -> a.in; a.out -> b.in; b.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + EXPECT_TRUE (fusionHappened (graph)); + + constexpr int blockSize = 64; + graph.prepare (48000.0, blockSize); + + std::vector input (static_cast (blockSize), 0.25f); + + for (int block = 0; block < 32; ++block) + { + std::vector output (static_cast (blockSize), 0.0f); + fusionRun (graph, input.data(), output.data(), blockSize); + + for (int i = 0; i < blockSize; ++i) + { + const auto sample = output[static_cast (i)]; + ASSERT_TRUE (std::isfinite (sample)) << "block " << block << " sample " << i; + ASSERT_LT (std::fabs (sample), 100.0f) << "block " << block << " sample " << i; + } + } +} + +TEST (YdspFusionTests, FusesAMemberWithReverbSizedDelayRings) +{ + // The shape that crashed: rings of the size a Schroeder reverb uses, so an + // out-of-bounds state-array store lands well outside the node's slice rather + // than merely corrupting a neighbouring scalar. + YdspCompiler compiler; + + auto graph = fusionCompile (R"YDSP( + processor Combs { + input stream in; + output stream out; + input parameter float feedback = 0.7 [[ smoothing: 0.02 ]]; + state float c1; + state float c2; + state float c3; + state float c4; + process { + let d1 = c1 @ 1116; + c1 = d1 * feedback + in * 0.015; + let d2 = c2 @ 1188; + c2 = d2 * feedback + in * 0.015; + let d3 = c3 @ 1277; + c3 = d3 * feedback + in * 0.015; + let d4 = c4 @ 1356; + c4 = d4 * feedback + in * 0.015; + out = d1 + d2 + d3 + d4; + } + } + processor Level { + input stream in; + output stream out; + input parameter float g = 1.0 [[ smoothing: 0.02 ]]; + process { out = in * g; } + } + graph G { + input stream x; + output stream y; + node c = Combs; + node l = Level; + connection { x -> c.in; c.out -> l.in; l.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + EXPECT_TRUE (fusionHappened (graph)); + + constexpr int blockSize = 64; + graph.prepare (48000.0, blockSize); + + std::vector input (static_cast (blockSize), 0.25f); + + // Well past the longest ring (1356), so every write pointer wraps. + for (int block = 0; block < 32; ++block) + { + std::vector output (static_cast (blockSize), 0.0f); + fusionRun (graph, input.data(), output.data(), blockSize); + + for (int i = 0; i < blockSize; ++i) + { + const auto sample = output[static_cast (i)]; + ASSERT_TRUE (std::isfinite (sample)) << "block " << block << " sample " << i; + ASSERT_LT (std::fabs (sample), 100.0f) << "block " << block << " sample " << i; + } + } + + fusionDumpOnFailure (graph); +} + +TEST (YdspFusionTests, DoesNotFuseATappedIntermediate) +{ + // `h.out` feeds both the next stage and a graph output, so the intermediate + // is observable and the h -> t link cannot become a register. This is the + // `fanOut != 1` half of the fusion rule, unreachable before node outputs + // could fan out. + YdspCompiler compiler; + + auto graph = fusionCompile (R"YDSP( + processor Half { input stream in; output stream out; process { out = in * 0.5; } } + processor Twice { input stream in; output stream out; process { out = in * 2.0; } } + + graph G { + input stream x; + output stream y; + output stream tap; + + node h = Half; + node t = Twice; + + connection { x -> h.in; h.out -> t.in; h.out -> tap; t.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + EXPECT_FALSE (fusionHappened (graph)); + + graph.prepare (44100.0, 8); + + std::vector input { 1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f }; + std::vector out (8, 0.0f); + std::vector tap (8, 0.0f); + + std::vector inputs { YdspInputBuffer (Span (input.data(), input.size())) }; + std::vector outputs { + YdspOutputBuffer (Span (out.data(), out.size())), + YdspOutputBuffer (Span (tap.data(), tap.size())) + }; + + graph.process (yup::YdspProcessRequest { inputs, outputs, 8 }); + + for (size_t i = 0; i < input.size(); ++i) + { + EXPECT_NEAR (input[i], out[i], 1e-6f) << "sample " << i; + EXPECT_NEAR (input[i] * 0.5f, tap[i], 1e-6f) << "sample " << i; + } +} + +TEST (YdspFusionTests, DoesNotFuseAcrossAnInlineDelay) +{ + // Only the runtime's delay buffer can hold samples between the two stages; + // a register cannot. + YdspCompiler compiler; + + auto graph = fusionCompile (R"YDSP( + processor Half { input stream in; output stream out; process { out = in * 0.5; } } + processor Twice { input stream in; output stream out; process { out = in * 2.0; } } + + graph G { + input stream x; + output stream y; + node a = Half; + node b = Twice; + connection { x -> a.in; a.out -> [4] -> b.in; b.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + EXPECT_FALSE (fusionHappened (graph)); +} + +TEST (YdspFusionTests, DoesNotFuseABlockModeNode) +{ + YdspCompiler compiler; + + auto graph = fusionCompile (R"YDSP( + processor Half { input stream in; output stream out; process { out = in * 0.5; } } + processor Blocky { + input stream in; + output stream out; + process block { for i in 0..blockSize { out[i] = in[i] * 2.0; } } + } + + graph G { + input stream x; + output stream y; + node a = Half; + node b = Blocky; + connection { x -> a.in; a.out -> b.in; b.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + EXPECT_FALSE (fusionHappened (graph)); +} + +TEST (YdspFusionTests, DoesNotFuseAnOversampledNode) +{ + // The runtime resamples around the kernel call, which one fused loop cannot + // reproduce. + YdspCompiler compiler; + + auto graph = fusionCompile (R"YDSP( + processor Half { input stream in; output stream out; process { out = in * 0.5; } } + processor Twice { input stream in; output stream out; process { out = in * 2.0; } } + + graph G { + input stream x; + output stream y; + node a = Half; + node b = Twice * 2; + connection { x -> a.in; a.out -> b.in; b.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + EXPECT_FALSE (fusionHappened (graph)); +} + +TEST (YdspFusionTests, DoesNotFuseANodeThatOnlyEmitsAnOutputEvent) +{ + // `Gate` has no input handler and no `input event` at all - just an + // `output event` it emits from `process` - so absent the exclusion it + // would otherwise qualify for fusion (1 audio in, 1 audio out, sample + // mode, voice count 1, no oversampling) and this whole three-node chain + // would collapse into one fused kernel. + YdspCompiler compiler; + + auto graph = fusionCompile (R"YDSP( + processor Half { input stream in; output stream out; process { out = in * 0.5; } } + + processor Gate { + input stream in; + output stream out; + output event noteOn; + process { + out = in; + emit noteOn (pitch: in) -> noteOn; + } + } + + processor Twice { input stream in; output stream out; process { out = in * 2.0; } } + + graph G { + input stream x; + output stream y; + output event noteOn; + + node a = Half; + node g = Gate; + node b = Twice; + + connection { x -> a.in; a.out -> g.in; g.out -> b.in; b.out -> y; g.noteOn -> noteOn; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + EXPECT_FALSE (fusionHappened (graph)); +} + +} // namespace yup::test diff --git a/tests/yup_dsp_jit/yup_YdspGraphTests.cpp b/tests/yup_dsp_jit/yup_YdspGraphTests.cpp new file mode 100644 index 000000000..58d198a7a --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspGraphTests.cpp @@ -0,0 +1,9165 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include "yup_YdspTestPatches.h" +#include "yup_YdspAllocationCounter.h" + +#include +#include +#include +#include +#include +#include + +namespace yup::test +{ + +namespace +{ + +using patches::compilePatch; +using patches::makeRamp; + +void runProcess32 (yup::YdspAudioGraph& graph, + const float* const* inputs, + float* const* outputs, + int numSamples) +{ + std::vector inputBuffers; + inputBuffers.reserve (static_cast (graph.getInputStreamCount())); + + for (int i = 0; i < graph.getInputStreamCount(); ++i) + inputBuffers.emplace_back (yup::Span (inputs[i], static_cast (numSamples))); + + std::vector outputBuffers; + outputBuffers.reserve (static_cast (graph.getOutputStreamCount())); + + for (int i = 0; i < graph.getOutputStreamCount(); ++i) + outputBuffers.emplace_back (yup::Span (outputs[i], static_cast (numSamples))); + + graph.process (yup::YdspProcessRequest { inputBuffers, outputBuffers, numSamples }); +} + +void runProcess (yup::YdspAudioGraph& graph, + const float* const* inputs, + int numInputs, + float* const* outputs, + int numOutputs, + int numSamples, + const yup::MidiBuffer* midi = nullptr, + const yup::YdspAutomationEvent* automation = nullptr, + int numAutomationEvents = 0) +{ + std::vector inputBuffers; + inputBuffers.reserve (static_cast (numInputs)); + + for (int i = 0; i < numInputs; ++i) + inputBuffers.emplace_back (yup::Span (inputs[i], static_cast (numSamples))); + + std::vector outputBuffers; + outputBuffers.reserve (static_cast (numOutputs)); + + for (int i = 0; i < numOutputs; ++i) + outputBuffers.emplace_back (yup::Span (outputs[i], static_cast (numSamples))); + + const yup::MidiBuffer* processEventInputs[] { midi }; + + graph.process (yup::YdspProcessRequest { + inputBuffers, + outputBuffers, + numSamples, + yup::Span (processEventInputs, graph.getEventInputCount() > 0 ? 1 : 0), + yup::Span (automation, static_cast (numAutomationEvents)) + }); +} + +void dumpAsmOnFailure (const YdspAudioGraph& graph) +{ + if (::testing::Test::HasFailure()) + { + const auto asmText = graph.getDiagnostics().toString(); + + if (! asmText.isEmpty()) + std::cout << "\n[AsmJit] graph kernels:\n" + << asmText << std::endl; + } +} + +} // namespace + +//============================================================================== + +TEST (YdspJitGraphTests, ArrayReadBackAfterWriteMatchesMemory) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + let size = 4; + + processor P { + input stream in; + output stream out; + + state float bank[size]; + state float other[size]; + + process { + float sum = 0.0; + + for i in 0..size { + let j = size - 1 - i; + + bank[i] = in + float (i); + sum = sum + bank[i]; + + other[i] = in * 2.0; + sum = sum + bank[i] + other[i]; + + bank[j] = in * 3.0; + sum = sum + bank[i]; + } + + out = sum; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 4); + + std::vector input { 1.0f, 2.0f, -0.5f, 0.25f }; + std::vector output (4, 0.0f); + + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 4); + + const auto expected = [] (float v) + { + float bank[4] {}, other[4] {}; + auto sum = 0.0f; + + for (int i = 0; i < 4; ++i) + { + const auto j = 3 - i; + + bank[i] = v + static_cast (i); + sum += bank[i]; + + other[i] = v * 2.0f; + sum += bank[i] + other[i]; + + bank[j] = v * 3.0f; + sum += bank[i]; // j == i never happens for size 4, but the compiler cannot know + } + + return sum; + }; + + for (int i = 0; i < 4; ++i) + EXPECT_NEAR (expected (input[static_cast (i)]), output[static_cast (i)], 1e-4f) + << "sample " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, SelectsSharingAndReusingComparisons) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Selects { + input stream in; + output stream out; + + process { + let wide = in > 0.25; + let a = select (wide, 1.0, 2.0); + let b = select (wide, 10.0, 20.0); + + float x = in; + let isBig = x > 0.5; + x = x * 100.0; + let moved = select (isBig, x, -x); + + let plain = select (in < 0.0, -1.0, 1.0); + + out = a + b + moved + plain; + } + } + graph G { input stream x; output stream y; node s = Selects; connection { x -> s.in; s.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 4); + + std::vector input { -1.0f, 0.3f, 0.75f, 0.0f }; + std::vector output (4, 0.0f); + + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 4); + + const auto expected = [] (float v) + { + const auto a = v > 0.25f ? 1.0f : 2.0f; + const auto b = v > 0.25f ? 10.0f : 20.0f; + const auto isBig = v > 0.5f; + const auto x = v * 100.0f; + const auto moved = isBig ? x : -x; + const auto plain = v < 0.0f ? -1.0f : 1.0f; + + return a + b + moved + plain; + }; + + for (int i = 0; i < 4; ++i) + EXPECT_NEAR (expected (input[static_cast (i)]), output[static_cast (i)], 1e-4f) + << "sample " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, NegatingZeroProducesNegativeZero) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Neg { + input stream in; + output stream out; + process { out = -in; } + } + graph G { input stream x; output stream y; node n = Neg; connection { x -> n.in; n.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + std::vector input { 0.0f, -0.0f, 1.0f, -1.0f, 0.0f, 0.0f, 0.0f, 0.0f }; + std::vector output (8, 1.0f); + + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 8); + + EXPECT_TRUE (std::signbit (output[0])) << "-(+0.0) must be -0.0"; + EXPECT_FALSE (std::signbit (output[1])) << "-(-0.0) must be +0.0"; + EXPECT_EQ (-1.0f, output[2]); + EXPECT_EQ (1.0f, output[3]); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsGainKernel) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Gain { + input stream in; + output stream out; + process { out = in * 2; } + } + graph G { input stream x; output stream y; node g = Gain; connection { x -> g.in; g.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + auto input = makeRamp (64); + std::vector output (64, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_NEAR (input[static_cast (i)] * 2.0f, output[static_cast (i)], 1e-5f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsOversampledNodeThroughResampler) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Pass { + input stream in; + output stream out; + process { out = in; } + } + graph G { input stream x; output stream y; node p = Pass * 2; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector input (64, 1.0f); + std::vector output (64, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 64); + + EXPECT_NEAR (0.0f, output[0], 0.1f); + EXPECT_NEAR (1.0f, output[63], 1e-3f); + + std::vector steadyOutput (64, 0.0f); + float* steadyPtrs[] = { steadyOutput.data() }; + + runProcess32 (graph, inPtrs, steadyPtrs, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_NEAR (1.0f, steadyOutput[static_cast (i)], 1e-3f); + + graph.reset(); + + std::vector resetOutput (64, 0.0f); + float* resetPtrs[] = { resetOutput.data() }; + + runProcess32 (graph, inPtrs, resetPtrs, 64); + + EXPECT_NEAR (0.0f, resetOutput[0], 0.1f); + EXPECT_NEAR (1.0f, resetOutput[63], 1e-3f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsOnePoleWithPrevState) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor OnePole { + input stream in; + output stream out; + input parameter float a = 0.5; + process { out = (1 - a) * in + a * out'; } + } + graph G { input stream x; output stream y; node p = OnePole; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + float previous = 0.0f; + + for (int i = 0; i < 32; ++i) + { + const auto expected = 0.5f * input[static_cast (i)] + 0.5f * previous; + EXPECT_NEAR (expected, output[static_cast (i)], 1e-5f); + previous = output[static_cast (i)]; + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, StatePersistsAcrossBlocks) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor OnePole { + input stream in; + output stream out; + process { out = 0.5 * in + 0.5 * out'; } + } + graph G { input stream x; output stream y; node p = OnePole; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 16); + + std::vector fullInput (32); + std::vector fullOutput (32); + + for (int i = 0; i < 32; ++i) + fullInput[static_cast (i)] = 0.1f; + + const float* inA[] = { fullInput.data() }; + float* outA[] = { fullOutput.data() }; + const float* inB[] = { fullInput.data() + 16 }; + float* outB[] = { fullOutput.data() + 16 }; + + runProcess32 (graph, inA, outA, 16); + runProcess32 (graph, inB, outB, 16); + + float previous = 0.0f; + + for (int i = 0; i < 32; ++i) + { + const auto expected = 0.5f * 0.1f + 0.5f * previous; + EXPECT_NEAR (expected, fullOutput[static_cast (i)], 1e-5f); + previous = fullOutput[static_cast (i)]; + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, CommaSeparatedStateDeclarationsStayIndependentAcrossBlocks) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Counter { + output stream out; + state float x, y, z; + process { + x = x + 1.0; + y = y + 2.0; + z = z + 3.0; + out = x + y + z; + } + } + graph G { output stream y; node c = Counter; connection { c.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 4); + + std::vector output (12, 0.0f); + float* outPtrs[] = { output.data() }; + + for (int block = 0; block < 3; ++block) + { + runProcess32 (graph, nullptr, outPtrs, 4); + outPtrs[0] += 4; + } + + // Each sample advances x by 1, y by 2 and z by 3, so the sum grows by 6 + // per sample and keeps counting across block boundaries. + for (int i = 0; i < 12; ++i) + EXPECT_NEAR (6.0f * static_cast (i + 1), output[static_cast (i)], 1e-4f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsTanhIntrinsic) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Tanh { + input stream in; + output stream out; + process { out = tanh (in); } + } + graph G { input stream x; output stream y; node t = Tanh; connection { x -> t.in; t.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (48000.0, 64); + + auto input = makeRamp (64, -0.5f); + std::vector output (64, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_NEAR (tanhf (input[static_cast (i)]), output[static_cast (i)], 1e-5f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsTanhAndMathIntrinsics) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor SoftClip { + input stream in; + output stream out; + process { out = tanh (in) * sqrt (abs (in) + 1); } + } + graph G { input stream x; output stream y; node s = SoftClip; connection { x -> s.in; s.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (48000.0, 64); + + auto input = makeRamp (64, -0.5f); + std::vector output (64, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 64); + + for (int i = 0; i < 64; ++i) + { + const auto x = input[static_cast (i)]; + const auto expected = tanhf (x) * sqrtf (fabsf (x) + 1.0f); + EXPECT_NEAR (expected, output[static_cast (i)], 1e-4f); + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsFixedDelayPrimitive) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Delay3 { + input stream in; + output stream out; + process { out = in @ 3; } + } + graph G { input stream x; output stream y; node d = Delay3; connection { x -> d.in; d.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + { + const auto expected = i >= 3 ? input[static_cast (i - 3)] : 0.0f; + EXPECT_NEAR (expected, output[static_cast (i)], 1e-6f); + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, GraphParameterDrivesSeveralNodeParameters) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Gain { input stream in; output stream out; input parameter float g = 1.0; process { out = in * g; } } + graph G { + input stream x; + output stream y; + input parameter float master = 2.0 [[ name: "Master", min: 0.0, max: 8.0 ]]; + node a = Gain; + node b = Gain; + connection { x -> a.in; a.out -> b.in; b.out -> y; master -> a.g; master -> b.g; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 16); + + EXPECT_EQ (graph.getParameterSlot ("master"), graph.getParameterSlot ("a.g")); + EXPECT_EQ (graph.getParameterSlot ("master"), graph.getParameterSlot ("b.g")); + + graph.setParameter ("master", 3.0f); + + auto input = makeRamp (16, 1.0f); + std::vector output (16, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 16); + + for (int i = 0; i < 16; ++i) + EXPECT_NEAR (input[static_cast (i)] * 9.0f, output[static_cast (i)], 1e-4f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, AutomationReachesEveryNodeAGraphParameterDrives) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Gain { input stream in; output stream out; input parameter float g = 1.0; process { out = in * g; } } + graph G { + input stream x; + output stream y; + input parameter float master = 1.0; + node a = Gain; + node b = Gain; + connection { x -> a.in; a.out -> b.in; b.out -> y; master -> a.g; master -> b.g; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 16); + + const auto slot = graph.getParameterSlot ("master"); + ASSERT_GE (slot, 0); + + const YdspAutomationEvent events[] = { { slot, 8, 2.0f } }; + + std::vector input (16, 1.0f); + std::vector output (16, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess (graph, inPtrs, 1, outPtrs, 1, 16, nullptr, events, 1); + + for (int i = 0; i < 16; ++i) + EXPECT_NEAR (i < 8 ? 1.0f : 4.0f, output[static_cast (i)], 1e-5f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsInlineConnectionDelay) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Pass { input stream in; output stream out; process { out = in; } } + graph G { input stream x; output stream y; node p = Pass; connection { x -> [3] -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32, 1.0f); + const auto original = input; + + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + { + const auto expected = i >= 3 ? original[static_cast (i - 3)] : 0.0f; + EXPECT_NEAR (expected, output[static_cast (i)], 1e-6f); + } + + EXPECT_EQ (original, input); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RejectsAnInlineDelayOnANonFloat32Stream) +{ + YdspCompiler compiler; + + auto result = compiler.compile (R"YDSP( + processor Pass { input stream float64 in; output stream float64 out; process { out = in; } } + graph G { input stream float64 x; output stream float64 y; node p = Pass; connection { x -> [3] -> p.in; p.out -> y; } } + )YDSP"); + + EXPECT_FALSE (result.wasOk()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ("inline delay is only supported on a float32 stream")); +} + +TEST (YdspJitGraphTests, PreparesTwiceAtDifferentBlockSizesWithAnInlineDelay) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Pass { input stream in; output stream out; process { out = in; } } + graph G { input stream x; output stream y; node p = Pass; connection { x -> [3] -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + + for (const int blockSize : { 16, 64, 32 }) + { + graph.prepare (44100.0, blockSize); + + auto input = makeRamp (blockSize, 1.0f); + const auto original = input; + + std::vector output (static_cast (blockSize), 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, blockSize); + + for (int i = 0; i < blockSize; ++i) + { + const auto expected = i >= 3 ? original[static_cast (i - 3)] : 0.0f; + EXPECT_NEAR (expected, output[static_cast (i)], 1e-6f) << "block " << blockSize << " sample " << i; + } + } +} + +TEST (YdspJitGraphTests, ResetClearsTheInlineDelayRing) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Pass { input stream in; output stream out; process { out = in; } } + graph G { input stream x; output stream y; node p = Pass; connection { x -> [3] -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + std::vector loud (8, 1.0f); + std::vector output (8, 0.0f); + + { + const float* inPtrs[] = { loud.data() }; + float* outPtrs[] = { output.data() }; + runProcess32 (graph, inPtrs, outPtrs, 8); + } + + // The ring now holds the last three 1.0 samples of that block. + graph.reset(); + + std::vector silence (8, 0.0f); + std::fill (output.begin(), output.end(), -1.0f); + + { + const float* inPtrs[] = { silence.data() }; + float* outPtrs[] = { output.data() }; + runProcess32 (graph, inPtrs, outPtrs, 8); + } + + for (int i = 0; i < 8; ++i) + EXPECT_NEAR (0.0f, output[static_cast (i)], 1e-6f) << "sample " << i; +} + +TEST (YdspJitGraphTests, ResolvesImportsRelativeToBasePath) +{ + const auto tempDir = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_ydsp_import_test"); + + tempDir.deleteRecursively(); + auto effectsDir = tempDir.getChildFile ("fx"); + effectsDir.createDirectory(); + + effectsDir.getChildFile ("Trim.ydsp") + .replaceWithText (R"YDSP( + processor Trim { input stream in; output stream out; process { out = in * 0.5; } } + )YDSP"); + + effectsDir.getChildFile ("Gain.ydsp") + .replaceWithText (R"YDSP( + import Trim as trim; + processor Gain { input stream in; output stream out; input parameter float gain = 2.0; process { out = in * gain; } } + )YDSP"); + + const auto patch = R"YDSP( + import fx.Gain as fx; + graph G { input stream x; output stream y; + node g = fx.Gain (gain = 3.0); + node t = fx.trim.Trim; + connection { x -> t.in; t.out -> g.in; g.out -> y; } } + )YDSP"; + + // Without a base path the import resolves against the CWD and must fail. + { + YdspCompiler compiler; + auto result = compiler.compile (patch); + EXPECT_FALSE (result.wasOk()); + } + + // With the base path (given as a file path inside the folder) the import + // and its nested import resolve and the graph runs. + { + YdspCompiler compiler; + auto result = compiler.compile (patch, tempDir.getChildFile ("Patch.ydsp").getFullPathName()); + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + auto graph = std::move (result).getValue(); + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + // The imported node's parameter is exposed on the graph. + EXPECT_TRUE (graph.hasParameter ("g.gain")); + EXPECT_NEAR (3.0, graph.getParameter ("g.gain"), 1e-6f); + + // x -> Trim (0.5x) -> Gain (3x) -> y, so y = 1.5x. + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (input[static_cast (i)] * 1.5f, output[static_cast (i)], 1e-4f); + + dumpAsmOnFailure (graph); + } + + tempDir.deleteRecursively(); +} + +TEST (YdspJitGraphTests, CallsTopLevelFunctionFromProcessorBody) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + func scale (x: float) : float { return x * 2.0; } + func addOne (x: float) : float { return scale (x) + 1.0; } + processor P { input stream in; output stream out; process { out = addOne (in); } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (input[static_cast (i)] * 2.0f + 1.0f, output[static_cast (i)], 1e-4f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, InlinedFunctionParameterReassignedInAnIfStaysDefined) +{ + YdspCompiler compiler; + + // The same shape the fold-state-write-back pass used to corrupt: a + // function parameter reassigned inside an `if`, whose value the join block + // after the inlined call reads. Compiles and runs only when every such + // read still resolves to a definition (the ?255 failure). + auto graph = compilePatch (R"YDSP( + processor P { + input stream in; + output stream out; + + func f (t: float) : float { + float r = 0.0; + if (t < 0.5) { + t = t * 2.0; + r = t; + } + return r; + } + + process { + float x = in * 0.5; + out = x - f (x); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + { + const float x = input[static_cast (i)] * 0.5f; + const float r = x < 0.5f ? x * 2.0f : 0.0f; + + EXPECT_NEAR (x - r, output[static_cast (i)], 1e-4f); + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, FusedAdjacentLoopsComputeTheUnfusedResult) +{ + // Two adjacent same-bound loops writing disjoint arrays. The host tier + // fuses (and vectorizes/unrolls) them; a scalar baseline tier leaves them + // apart. The fused kernel must compute the same samples - the exit block + // bug used to aim the fused loop at the block before its own header. + const auto source = R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + state float w[8]; + process block { + for i in 0..8 { z[i] = z[i] * 0.5 + in[i]; } + for j in 0..8 { w[j] = w[j] * 0.5 + 0.02; } + out[0] = z[0] + w[0]; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + + YdspCompileOptions strict; + strict.fastMath = false; // keep contraction off so both builds round identically + + YdspCompiler fusedCompiler; + auto fused = compilePatch (source, fusedCompiler, strict); + ASSERT_TRUE (fused.isValid()) << fusedCompiler.getDiagnostics().toString(); + + YdspCompileOptions scalar; + scalar.optimizationTier = YdspOptimizationTier::baseline; + scalar.targetPolicy = YdspTargetPolicy::baseline; + scalar.baselineTarget = YdspNativeTarget::scalar; + scalar.fastMath = false; + + YdspCompiler scalarCompiler; + auto unfused = compilePatch (source, scalarCompiler, scalar); + ASSERT_TRUE (unfused.isValid()) << scalarCompiler.getDiagnostics().toString(); + + fused.prepare (44100.0, 32); + unfused.prepare (44100.0, 32); + + const auto input = makeRamp (32); + + std::vector fusedOutput (32, 0.0f); + std::vector unfusedOutput (32, 0.0f); + + const float* inPtrs[] = { input.data() }; + float* fusedPtrs[] = { fusedOutput.data() }; + float* unfusedPtrs[] = { unfusedOutput.data() }; + + runProcess32 (fused, inPtrs, fusedPtrs, 32); + runProcess32 (unfused, inPtrs, unfusedPtrs, 32); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (unfusedOutput[static_cast (i)], fusedOutput[static_cast (i)], 1e-4f) + << "sample " << i; + + dumpAsmOnFailure (fused); + dumpAsmOnFailure (unfused); +} + +TEST (YdspJitGraphTests, StrictMulByZeroPreservesTheProductsSignedZero) +{ + // x * 0.0 is -0.0 for a negative x and NaN for inf/NaN inputs. Under strict + // math the multiply must survive, not be folded to a +0.0 constant. + YdspCompileOptions options; + options.fastMath = false; + + YdspCompiler compiler; + auto graph = compilePatch (R"YDSP( + processor P { input stream in; output stream out; process { out = in * 0.0; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler, options); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + graph.prepare (44100.0, 32); + + std::vector input (32, -1.0f); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + EXPECT_TRUE (std::signbit (output[0])) << "-1.0 * 0.0 must be -0.0, not the folded +0.0"; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, StrictAddZeroDoesNotEraseASignedZeroOperand) +{ + // x + 0.0 is +0.0 for x = -0.0, so folding the add away under strict math + // would change the sign of the result; the add must survive and round. + YdspCompileOptions options; + options.fastMath = false; + + YdspCompiler compiler; + auto graph = compilePatch (R"YDSP( + processor P { input stream in; output stream out; process { out = in + 0.0; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler, options); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + graph.prepare (44100.0, 32); + + std::vector input (32, -0.0f); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + EXPECT_FALSE (std::signbit (output[0])) << "-0.0 + 0.0 must round to +0.0, not fold to -0.0"; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, CallsImportedLibraryFunctions) +{ + const auto tempDir = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_ydsp_libfunc_test"); + + tempDir.deleteRecursively(); + tempDir.getChildFile ("lib").createDirectory(); + + tempDir.getChildFile ("lib/Util.ydsp").replaceWithText (R"YDSP( + func scale (v: float) : float { return v * 2.0; } + func wrap (v: float) : float { return scale (v) + 1.0; } + )YDSP"); + + const auto patch = R"YDSP( + import lib.Util as u; + processor P { input stream in; output stream out; process { out = u.wrap (in); } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + + YdspCompiler compiler; + auto result = compiler.compile (patch, tempDir.getFullPathName()); + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + auto graph = std::move (result).getValue(); + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (input[static_cast (i)] * 2.0f + 1.0f, output[static_cast (i)], 1e-4f); + + dumpAsmOnFailure (graph); + + tempDir.deleteRecursively(); +} + +TEST (YdspJitGraphTests, ImportedProcessorCallsItsLibraryFunctions) +{ + const auto tempDir = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_ydsp_libproc_test"); + + tempDir.deleteRecursively(); + tempDir.getChildFile ("lib").createDirectory(); + + tempDir.getChildFile ("lib/Osc.ydsp") + .replaceWithText (R"YDSP( + func scale (v: float) : float { return v * 2.0; } + processor P { input stream in; output stream out; process { out = scale (in); } } + )YDSP"); + + const auto patch = R"YDSP( + import lib.Osc as u; + graph G { input stream x; output stream y; node p = u.P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + + YdspCompiler compiler; + auto result = compiler.compile (patch, tempDir.getFullPathName()); + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + auto graph = std::move (result).getValue(); + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (input[static_cast (i)] * 2.0f, output[static_cast (i)], 1e-4f); + + tempDir.deleteRecursively(); +} + +TEST (YdspJitGraphTests, ImportedProcessorLocalFunctionCallsItsLibraryFunctions) +{ + const auto tempDir = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_ydsp_libproclocal_test"); + + tempDir.deleteRecursively(); + tempDir.getChildFile ("lib").createDirectory(); + + tempDir.getChildFile ("lib/Osc.ydsp") + .replaceWithText (R"YDSP( + func scale (v: float) : float { return v * 2.0; } + processor P { + input stream in; + output stream out; + func local (v: float) : float { return scale (v) + 1.0; } + process { out = local (in); } + } + )YDSP"); + + const auto patch = R"YDSP( + import lib.Osc as u; + graph G { input stream x; output stream y; node p = u.P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + + YdspCompiler compiler; + auto result = compiler.compile (patch, tempDir.getFullPathName()); + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + auto graph = std::move (result).getValue(); + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (input[static_cast (i)] * 2.0f + 1.0f, output[static_cast (i)], 1e-4f); + + tempDir.deleteRecursively(); +} + +TEST (YdspJitGraphTests, ProcessorFunctionShadowsTopLevelFunction) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + func scale (x: float) : float { return x * 2.0; } + processor P { + input stream in; + output stream out; + func scale (x: float) : float { return x * 3.0; } + process { out = scale (in); } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (input[static_cast (i)] * 3.0f, output[static_cast (i)], 1e-4f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, ReportsUnknownNamespacedFunctionCall) +{ + YdspCompiler compiler; + + auto result = compiler.compile (R"YDSP( + processor P { input stream in; output stream out; process { out = fx.missing (in); } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"); + + EXPECT_FALSE (result.wasOk()); + + bool found = false; + for (int i = 0; i < compiler.getDiagnostics().getCount(); ++i) + if (compiler.getDiagnostics().getItem (i).message.contains ("Unknown function 'fx.missing'")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspJitGraphTests, ReportsRecursiveTopLevelFunction) +{ + YdspCompiler compiler; + + auto result = compiler.compile (R"YDSP( + func loop (x: float) : float { return loop (x); } + processor P { input stream in; output stream out; process { out = loop (in); } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"); + + EXPECT_FALSE (result.wasOk()); + + bool found = false; + for (int i = 0; i < compiler.getDiagnostics().getCount(); ++i) + if (compiler.getDiagnostics().getItem (i).message.contains ("Recursive call detected in function 'loop'")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspJitGraphTests, ReportsRecursiveFunctionHiddenInAConditionalBody) +{ + YdspCompiler compiler; + + auto result = compiler.compile (R"YDSP( + func loop (x: float) : float { + if (x > 0.0) { return loop (x - 1.0); } + return x; + } + processor P { input stream in; output stream out; process { out = loop (in); } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"); + + EXPECT_FALSE (result.wasOk()); + + bool found = false; + for (int i = 0; i < compiler.getDiagnostics().getCount(); ++i) + if (compiler.getDiagnostics().getItem (i).message.contains ("Recursive call detected in function 'loop'")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspJitGraphTests, ReportsRecursiveFunctionHiddenInANestedExpression) +{ + YdspCompiler compiler; + + auto result = compiler.compile (R"YDSP( + func loop (x: float) : float { return loop (x) + 1.0; } + processor P { input stream in; output stream out; process { out = loop (in); } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"); + + EXPECT_FALSE (result.wasOk()); + + bool found = false; + for (int i = 0; i < compiler.getDiagnostics().getCount(); ++i) + if (compiler.getDiagnostics().getItem (i).message.contains ("Recursive call detected in function 'loop'")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspJitGraphTests, ImportDottedPathMapsToFileAndLastSegmentNamespace) +{ + const auto tempDir = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_ydsp_dotted_import"); + + tempDir.deleteRecursively(); + tempDir.getChildFile ("fx").createDirectory(); + + tempDir.getChildFile ("fx/Delay.ydsp") + .replaceWithText ("processor Delay { input stream in; output stream out; process { out = in * 0.5; } }"); + + const auto patch = R"YDSP( + import fx.Delay; + graph G { input stream x; output stream y; node d = Delay.Delay; connection { x -> d.in; d.out -> y; } } + )YDSP"; + + YdspCompiler compiler; + auto result = compiler.compile (patch, tempDir.getFullPathName()); + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + auto graph = std::move (result).getValue(); + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (input[static_cast (i)] * 0.5f, output[static_cast (i)], 1e-4f); + + tempDir.deleteRecursively(); +} + +TEST (YdspJitGraphTests, ImportAliasOverridesDefaultNamespace) +{ + const auto tempDir = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_ydsp_alias_import"); + + tempDir.deleteRecursively(); + tempDir.getChildFile ("fx").createDirectory(); + + tempDir.getChildFile ("fx/Delay.ydsp") + .replaceWithText ("processor Delay { input stream in; output stream out; process { out = in * 0.25; } }"); + + const auto patch = R"YDSP( + import fx.Delay as w; + graph G { input stream x; output stream y; node d = w.Delay; connection { x -> d.in; d.out -> y; } } + )YDSP"; + + YdspCompiler compiler; + auto result = compiler.compile (patch, tempDir.getFullPathName()); + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + auto graph = std::move (result).getValue(); + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (input[static_cast (i)] * 0.25f, output[static_cast (i)], 1e-4f); + + tempDir.deleteRecursively(); +} + +TEST (YdspJitGraphTests, ReportsImportNamespaceCollision) +{ + const auto tempDir = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_ydsp_collision_import"); + + tempDir.deleteRecursively(); + tempDir.getChildFile ("a").createDirectory(); + tempDir.getChildFile ("b").createDirectory(); + + tempDir.getChildFile ("a/A.ydsp") + .replaceWithText ("processor A { input stream in; output stream out; process { out = in; } }\n"); + + tempDir.getChildFile ("b/A.ydsp") + .replaceWithText ("processor B { input stream in; output stream out; process { out = in; } }\n"); + + const auto patch = R"YDSP( + import a.A; + import b.A; + graph G { input stream x; output stream y; node a = A.A; connection { x -> a.in; a.out -> y; } } + )YDSP"; + + YdspCompiler compiler; + auto result = compiler.compile (patch, tempDir.getFullPathName()); + EXPECT_FALSE (result.wasOk()); + + bool found = false; + for (int i = 0; i < compiler.getDiagnostics().getCount(); ++i) + if (compiler.getDiagnostics().getItem (i).message.contains ("would share the namespace 'A'") + && compiler.getDiagnostics().getItem (i).message.contains ("use 'as' to disambiguate")) + found = true; + + EXPECT_TRUE (found); + + tempDir.deleteRecursively(); +} + +TEST (YdspJitGraphTests, SameFileImportedTwiceIsNotAnError) +{ + const auto tempDir = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_ydsp_dup_import"); + + tempDir.deleteRecursively(); + tempDir.getChildFile ("fx").createDirectory(); + + tempDir.getChildFile ("fx/Gain.ydsp") + .replaceWithText ("processor Gain { input stream in; output stream out; process { out = in * 2.0; } }"); + + const auto patch = R"YDSP( + import fx.Gain; + import fx.Gain; + graph G { input stream x; output stream y; node g = Gain.Gain; connection { x -> g.in; g.out -> y; } } + )YDSP"; + + YdspCompiler compiler; + auto result = compiler.compile (patch, tempDir.getFullPathName()); + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + tempDir.deleteRecursively(); +} + +TEST (YdspJitGraphTests, ReportsCircularImport) +{ + const auto tempDir = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_ydsp_circular_import"); + + tempDir.deleteRecursively(); + tempDir.createDirectory(); + + tempDir.getChildFile ("a.ydsp") + .replaceWithText ("import b; processor A { input stream in; output stream out; process { out = in; } }"); + + tempDir.getChildFile ("b.ydsp") + .replaceWithText ("import a; processor B { input stream in; output stream out; process { out = in; } }"); + + const auto patch = R"YDSP( + import a; + graph G { input stream x; output stream y; node a = A.A; connection { x -> a.in; a.out -> y; } } + )YDSP"; + + YdspCompiler compiler; + auto result = compiler.compile (patch, tempDir.getFullPathName()); + EXPECT_FALSE (result.wasOk()); + + bool found = false; + for (int i = 0; i < compiler.getDiagnostics().getCount(); ++i) + if (compiler.getDiagnostics().getItem (i).message.contains ("Circular import detected for 'a'")) + found = true; + + EXPECT_TRUE (found); + + tempDir.deleteRecursively(); +} + +TEST (YdspJitGraphTests, RootDiagnosticsUseSuppliedFilePath) +{ + const auto path = File::getSpecialLocation (File::tempDirectory).getChildFile ("diagnostic-root.ydsp").getFullPathName(); + YdspCompiler compiler; + auto result = compiler.compile ("processor P { output stream out; process { out = missing; } }\n" + "graph G { output stream y; node p = P; connection { p.out -> y; } }", + path); + ASSERT_FALSE (result.wasOk()); + ASSERT_GT (compiler.getDiagnostics().getCount(), 0); + EXPECT_EQ (path, compiler.getDiagnostics().getItem (0).range.sourceId); + EXPECT_TRUE (compiler.getDiagnostics().toString().startsWith (path + ":1:")); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ("^~~~~~~")); +} + +TEST (YdspJitGraphTests, ReportsSyntaxErrorsInImportedFiles) +{ + const auto tempDir = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_ydsp_badimport_test"); + + tempDir.deleteRecursively(); + tempDir.getChildFile ("lib").createDirectory(); + + tempDir.getChildFile ("lib/Broken.ydsp") + .replaceWithText ("processor Broken { input stream in; output stream out; process { out = ; } }\n"); + + const auto patch = R"YDSP( + import lib.Broken as b; + graph G { input stream x; output stream y; node p = b.Broken; connection { x -> p.in; p.out -> y; } } + )YDSP"; + + YdspCompiler compiler; + auto result = compiler.compile (patch, tempDir.getFullPathName()); + EXPECT_FALSE (result.wasOk()); + + bool found = false; + for (int i = 0; i < compiler.getDiagnostics().getCount(); ++i) + if (compiler.getDiagnostics().getItem (i).message.contains ("Expected an expression")) + { + found = true; + const auto& diagnostic = compiler.getDiagnostics().getItem (i); + EXPECT_EQ (tempDir.getChildFile ("lib/Broken.ydsp").getFullPathName(), diagnostic.range.sourceId); + EXPECT_GT (diagnostic.range.startColumn, 1); + EXPECT_GT (diagnostic.range.endColumn, diagnostic.range.startColumn); + } + + EXPECT_TRUE (found); + const auto rendered = compiler.getDiagnostics().toString(); + EXPECT_TRUE (rendered.contains (tempDir.getChildFile ("lib/Broken.ydsp").getFullPathName() + ":1:")); + EXPECT_TRUE (rendered.contains ("out = ;")); + EXPECT_TRUE (rendered.contains ("^")); + + tempDir.getChildFile ("lib/Broken.ydsp") + .replaceWithText ("processor Broken { input stream in; output stream out; process { out = missing; } }\n"); + result = compiler.compile (patch, tempDir.getFullPathName()); + EXPECT_FALSE (result.wasOk()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ( + tempDir.getChildFile ("lib/Broken.ydsp").getFullPathName() + ":1:")); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ("out = missing;")); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ("^~~~~~~")); + + tempDir.deleteRecursively(); +} + +TEST (YdspJitGraphTests, ParallelImportCompilationMatchesSequential) +{ + const auto tempDir = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_ydsp_parallel_import"); + + tempDir.deleteRecursively(); + tempDir.getChildFile ("fx").createDirectory(); + tempDir.getChildFile ("lib").createDirectory(); + + tempDir.getChildFile ("fx/Trim.ydsp") + .replaceWithText ("processor Trim { input stream in; output stream out; process { out = in * 0.5; } }\n"); + + tempDir.getChildFile ("fx/Gain.ydsp") + .replaceWithText ("import Trim as trim;\nprocessor Gain { input stream in; output stream out; input parameter float g = 2.0; process { out = in * g; } }\n"); + + tempDir.getChildFile ("lib/Math.ydsp") + .replaceWithText ("func scale (v: float) : float { return v * 2.0; }\n"); + + const auto patch = R"YDSP( + import fx.Gain as fx; + import lib.Math as m; + processor P { input stream in; output stream out; process { out = m.scale (in); } } + graph G { + input stream x; output stream y; + node p = P; + node t = fx.trim.Trim; + node g = fx.Gain (g = 3.0); + connection { x -> p.in; p.out -> t.in; t.out -> g.in; g.out -> y; } + } + )YDSP"; + + const auto input = makeRamp (32); + + const auto runPatch = [&] (ThreadPool* pool) -> std::vector + { + YdspCompiler compiler; + auto result = compiler.compile (patch, tempDir.getFullPathName(), pool); + EXPECT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + if (! result.wasOk()) + return {}; + + auto graph = std::move (result).getValue(); + graph.prepare (44100.0, 32); + + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + return output; + }; + + const auto sequential = runPatch (nullptr); + ThreadPool pool; + const auto parallel = runPatch (&pool); + + ASSERT_EQ (32u, sequential.size()); + ASSERT_EQ (32u, parallel.size()); + + for (int i = 0; i < 32; ++i) + { + EXPECT_NEAR (input[static_cast (i)] * 3.0f, sequential[static_cast (i)], 1e-4f); + EXPECT_EQ (sequential[static_cast (i)], parallel[static_cast (i)]); + } + + tempDir.deleteRecursively(); +} + +TEST (YdspJitGraphTests, CallerOwnedPoolStillRunsUnrelatedJobs) +{ + struct MarkerJob : public ThreadPoolJob + { + MarkerJob() + : ThreadPoolJob ("parallel-import-marker") + { + } + + JobStatus runJob() override + { + ran = true; + return jobHasFinished; + } + + std::atomic ran { false }; + }; + + const auto tempDir = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_ydsp_pool_marker"); + + tempDir.deleteRecursively(); + tempDir.createDirectory(); + + tempDir.getChildFile ("Gain.ydsp") + .replaceWithText ("processor Gain { input stream in; output stream out; process { out = in * 2.0; } }\n"); + + const auto patch = R"YDSP( + import Gain; + graph G { input stream x; output stream y; node g = Gain.Gain; connection { x -> g.in; g.out -> y; } } + )YDSP"; + + ThreadPool pool; + MarkerJob marker; + pool.addJob (&marker, false); + + YdspCompiler compiler; + auto result = compiler.compile (patch, tempDir.getFullPathName(), &pool); + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + EXPECT_TRUE (pool.waitForJobToFinish (&marker, 1000)); + EXPECT_TRUE (marker.ran.load()); + + tempDir.deleteRecursively(); +} + +TEST (YdspJitGraphTests, ImportsTheSameLibraryFromTwoFilesUnderDifferentNamespaces) +{ + const auto tempDir = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_ydsp_diamond_import"); + + tempDir.deleteRecursively(); + tempDir.createDirectory(); + + tempDir.getChildFile ("z.ydsp") + .replaceWithText ("processor Z { input stream in; output stream out; process { out = in * 2.0; } }\n"); + + tempDir.getChildFile ("x.ydsp") + .replaceWithText ("import z;\ngraph XGraph { input stream in; output stream out; node zz = z.Z; connection { in -> zz.in; zz.out -> out; } }\n"); + + tempDir.getChildFile ("y.ydsp") + .replaceWithText ("import z as zz;\ngraph YGraph { input stream in; output stream out; node zz = zz.Z; connection { in -> zz.in; zz.out -> out; } }\n"); + + const auto patch = R"YDSP( + import x; + import y; + graph G { + input stream x; + output stream y; + node a = x.XGraph; + node b = y.YGraph; + connection { x -> a.in; a.out -> b.in; b.out -> y; } + } + )YDSP"; + + YdspCompiler compiler; + auto result = compiler.compile (patch, tempDir.getFullPathName()); + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + auto graph = std::move (result).getValue(); + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (input[static_cast (i)] * 4.0f, output[static_cast (i)], 1e-4f); + + tempDir.deleteRecursively(); +} + +TEST (YdspJitGraphTests, ParallelImportsMatchSequentialForDiamondsAndFailures) +{ + const auto tempDir = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_ydsp_parallel_import_edges"); + + tempDir.deleteRecursively(); + tempDir.createDirectory(); + + tempDir.getChildFile ("z.ydsp") + .replaceWithText ("processor Z { input stream in; output stream out; process { out = in * 2.0; } }\n"); + + tempDir.getChildFile ("x.ydsp") + .replaceWithText ("import z;\ngraph XGraph { input stream in; output stream out; node zz = z.Z; connection { in -> zz.in; zz.out -> out; } }\n"); + + tempDir.getChildFile ("y.ydsp") + .replaceWithText ("import z as zz;\ngraph YGraph { input stream in; output stream out; node zz = zz.Z; connection { in -> zz.in; zz.out -> out; } }\n"); + + tempDir.getChildFile ("broken.ydsp") + .replaceWithText ("processor Broken { input stream in; output stream out; process { out = ; } }\n"); + + const auto diamondPatch = R"YDSP( + import x; + import y; + graph G { input stream x; output stream y; node a = x.XGraph; node b = y.YGraph; + connection { x -> a.in; a.out -> b.in; b.out -> y; } } + )YDSP"; + + const auto missingPatch = R"YDSP( + import Missing; + graph G { input stream x; output stream y; node m = Missing.Missing; connection { x -> m.in; m.out -> y; } } + )YDSP"; + + const auto brokenPatch = R"YDSP( + import broken as b; + graph G { input stream x; output stream y; node p = b.Broken; connection { x -> p.in; p.out -> y; } } + )YDSP"; + + for (const auto& patch : { diamondPatch, missingPatch, brokenPatch }) + { + String sequentialMessages; + bool sequentialOk = false; + + { + YdspCompiler compiler; + auto sequential = compiler.compile (patch, tempDir.getFullPathName()); + sequentialOk = sequential.wasOk(); + + for (int i = 0; i < compiler.getDiagnostics().getCount(); ++i) + sequentialMessages += compiler.getDiagnostics().getItem (i).message + "\n"; + + if (patch == diamondPatch) + ASSERT_TRUE (sequentialOk) << sequentialMessages; + else + ASSERT_FALSE (sequentialOk); + } + + ThreadPool pool; + YdspCompiler compiler; + auto parallel = compiler.compile (patch, tempDir.getFullPathName(), &pool); + + String parallelMessages; + + for (int i = 0; i < compiler.getDiagnostics().getCount(); ++i) + parallelMessages += compiler.getDiagnostics().getItem (i).message + "\n"; + + EXPECT_EQ (sequentialOk, parallel.wasOk()); + EXPECT_EQ (sequentialMessages, parallelMessages); + + if (patch == diamondPatch && parallel.wasOk()) + { + auto graph = std::move (parallel).getValue(); + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (input[static_cast (i)] * 4.0f, output[static_cast (i)], 1e-4f); + } + } + + tempDir.deleteRecursively(); +} + +TEST (YdspJitGraphTests, RoutesSeparateEventInputsByName) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor A { + input event midi1; + output stream out; + state float f; + event midi1 (e: noteOn) { f = e.pitch; } + process { out = f; } + } + processor B { + input event midi2; + output stream out; + state float g; + event midi2 (e: noteOn) { g = e.pitch * 2.0; } + process { out = g; } + } + graph G { + input event midi1; + input event midi2; + output stream y1; + output stream y2; + node a = A; + node b = B; + connection { midi1 -> a.midi1; midi2 -> b.midi2; a.out -> y1; b.out -> y2; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + EXPECT_EQ (2, graph.getEventInputCount()); + EXPECT_EQ ("midi1", graph.getEventInputName (0)); + EXPECT_EQ ("midi2", graph.getEventInputName (1)); + + yup::MidiBuffer midi1; + midi1.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 0); + + yup::MidiBuffer midi2; + midi2.addEvent (yup::MidiMessage::noteOn (1, 72, static_cast (100)), 0); + + const auto run = [&] (const yup::MidiBuffer* in1, const yup::MidiBuffer* in2, std::vector& out1, std::vector& out2) + { + std::vector outputBuffers; + outputBuffers.emplace_back (yup::Span (out1.data(), static_cast (32))); + outputBuffers.emplace_back (yup::Span (out2.data(), static_cast (32))); + + const yup::MidiBuffer* buffers[] = { in1, in2 }; + + const auto result = graph.process (yup::YdspProcessRequest { + yup::Span(), + yup::Span (outputBuffers.data(), outputBuffers.size()), + 32, + yup::Span (buffers, 2), + {} + }); + + EXPECT_EQ (yup::YdspProcessResult::ok, result); + }; + + { + std::vector y1 (32, 0.0f), y2 (32, 0.0f); + run (&midi1, &midi2, y1, y2); + + for (int i = 0; i < 32; ++i) + { + EXPECT_NEAR (60.0f, y1[static_cast (i)], 1e-4f); + EXPECT_NEAR (144.0f, y2[static_cast (i)], 1e-4f); + } + } + + graph.reset(); + + { + std::vector y1 (32, 0.0f), y2 (32, 0.0f); + run (nullptr, &midi2, y1, y2); + + for (int i = 0; i < 32; ++i) + { + EXPECT_NEAR (0.0f, y1[static_cast (i)], 1e-4f); + EXPECT_NEAR (144.0f, y2[static_cast (i)], 1e-4f); + } + } +} + +TEST (YdspJitGraphTests, NoteOffOnOneEventInputDoesNotReleaseTheOtherInputsVoice) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Voice { + output stream out; + input event inA; + input event inB; + state float pitch; + event inA (e: noteOn) { pitch = e.pitch; } + event inA (e: noteOff) { pitch = 0.0; } + event inB (e: noteOn) { pitch = e.pitch + 100.0; } + event inB (e: noteOff) { pitch = 0.0; } + process { out = pitch; } + } + graph G { + input event inA; + input event inB; + output stream y; + node v = Voice[4]; + connection { inA -> v.inA; inB -> v.inB; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + std::vector output (512, 0.0f); + + yup::MidiBuffer inA; + inA.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 0); + inA.addEvent (yup::MidiMessage::noteOff (1, 60), 10); + + yup::MidiBuffer inB; + inB.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 20); + + inA.addEvent (yup::MidiMessage::noteOff (1, 60), 30); + + const yup::MidiBuffer* buffers[] = { &inA, &inB }; + + std::vector outputBuffers; + outputBuffers.emplace_back (yup::Span (output.data(), static_cast (512))); + + EXPECT_EQ (yup::YdspProcessResult::ok, + graph.process (yup::YdspProcessRequest { + yup::Span(), + yup::Span (outputBuffers.data(), outputBuffers.size()), + 512, + yup::Span (buffers, 2), + {} + })); + + for (int i = 0; i < 10; ++i) + EXPECT_FLOAT_EQ (60.0f, output[static_cast (i)]) << "before the inA note-off at " << i; + + for (int i = 20; i < 512; ++i) + EXPECT_FLOAT_EQ (160.0f, output[static_cast (i)]) << "after the inB note-on at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, MonoNoteOffOnOneEventInputDoesNotRemoveTheOtherInputsHeldNote) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Voice { + output stream out; + input event inA; + input event inB; + state float pitch; + event inA (e: noteOn) { pitch = e.pitch; } + event inA (e: noteOff) { pitch = 0.0; } + event inB (e: noteOn) { pitch = e.pitch + 100.0; } + event inB (e: noteOff) { pitch = 0.0; } + process { out = pitch; } + } + graph G { + input event inA; + input event inB; + output stream y; + node v = Voice [[ mode: mono ]]; + connection { inA -> v.inA; inB -> v.inB; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + std::vector output (512, 0.0f); + + const auto runBlock = [&] (const yup::MidiBuffer* a, const yup::MidiBuffer* b) + { + output.assign (512, 0.0f); + + const yup::MidiBuffer* buffers[] = { a, b }; + + std::vector outputBuffers; + outputBuffers.emplace_back (yup::Span (output.data(), static_cast (512))); + + return graph.process (yup::YdspProcessRequest { + yup::Span(), + yup::Span (outputBuffers.data(), outputBuffers.size()), + 512, + yup::Span (buffers, 2), + {} + }); + }; + + { + yup::MidiBuffer inB; + inB.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 0); + + EXPECT_EQ (yup::YdspProcessResult::ok, runBlock (nullptr, &inB)); + + for (int i = 0; i < 512; ++i) + EXPECT_FLOAT_EQ (160.0f, output[static_cast (i)]) << "B's note sounding at " << i; + } + + { + yup::MidiBuffer inA; + inA.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 0); + inA.addEvent (yup::MidiMessage::noteOff (1, 60), 10); + + EXPECT_EQ (yup::YdspProcessResult::ok, runBlock (&inA, nullptr)); + + for (int i = 10; i < 512; ++i) + EXPECT_FLOAT_EQ (160.0f, output[static_cast (i)]) << "B's note survives A's release at " << i; + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, AllSoundOffOnEachInputSilencesAtItsOwnOffset) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Voice { + output stream out; + input event inA; + input event inB; + state float active; + event inA (e: noteOn) { active = 1.0; } + process { out = active; } + } + graph G { + input event inA; + input event inB; + output stream y; + node v = Voice; + connection { inA -> v.inA; inB -> v.inB; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + std::vector output (512, 0.0f); + + yup::MidiBuffer inA; + inA.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 0); + inA.addEvent (yup::MidiMessage::controllerEvent (1, 120, 0), 100); + + yup::MidiBuffer inB; + inB.addEvent (yup::MidiMessage::controllerEvent (1, 120, 0), 200); + + const yup::MidiBuffer* buffers[] = { &inA, &inB }; + + std::vector outputBuffers; + outputBuffers.emplace_back (yup::Span (output.data(), static_cast (512))); + + EXPECT_EQ (yup::YdspProcessResult::ok, + graph.process (yup::YdspProcessRequest { + yup::Span(), + yup::Span (outputBuffers.data(), outputBuffers.size()), + 512, + yup::Span (buffers, 2), + {} + })); + + for (int i = 0; i < 100; ++i) + EXPECT_FLOAT_EQ (1.0f, output[static_cast (i)]) << "before the first all-sound-off at " << i; + + for (int i = 100; i < 512; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "after the first all-sound-off at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, AllSoundOffSilencesANoteRetriggeredAfterIt) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Voice { + output stream out; + input event inA; + input event inB; + state float active; + event inA (e: noteOn) { active = 1.0; } + process { out = active; } + } + graph G { + input event inA; + input event inB; + output stream y; + node v = Voice; + connection { inA -> v.inA; inB -> v.inB; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + std::vector output (512, 0.0f); + + yup::MidiBuffer inA; + inA.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 0); + inA.addEvent (yup::MidiMessage::controllerEvent (1, 120, 0), 100); + inA.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 150); + inA.addEvent (yup::MidiMessage::controllerEvent (1, 120, 0), 200); + + yup::MidiBuffer inB; + + const yup::MidiBuffer* buffers[] = { &inA, &inB }; + + std::vector outputBuffers; + outputBuffers.emplace_back (yup::Span (output.data(), static_cast (512))); + + EXPECT_EQ (yup::YdspProcessResult::ok, + graph.process (yup::YdspProcessRequest { + yup::Span(), + yup::Span (outputBuffers.data(), outputBuffers.size()), + 512, + yup::Span (buffers, 2), + {} + })); + + for (int i = 0; i < 100; ++i) + EXPECT_FLOAT_EQ (1.0f, output[static_cast (i)]) << "first sounding segment at " << i; + + for (int i = 100; i < 150; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "silenced at " << i; + + for (int i = 150; i < 200; ++i) + EXPECT_FLOAT_EQ (1.0f, output[static_cast (i)]) << "re-triggered at " << i; + + for (int i = 200; i < 512; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "silenced again at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, AssemblesKernelsWithLargeStateLayouts) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor DelayLine { + input stream in; + output stream out; + state float buf[5000]; + state int wp; + process { + buf[wp] = in; + out = in + 0.5 * buf[wp]; + wp = wp + 1; + if (wp >= 5000) { wp = 0; } + } + } + processor Rings { + input stream in; + output stream out; + process { + let a = in @ 2000; + let b = in @ 2000; + let c = in @ 2000; + out = a + b + c; + } + } + graph G { input stream x; output stream y; node d = DelayLine; node r = Rings; connection { x -> d.in; d.out -> r.in; r.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (0.0f, output[static_cast (i)], 1e-4f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsReverbStyleMultipleDelays) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor ReverbLike { + input stream in; + output stream out; + + input parameter float mix = 0.3; + input parameter float feedback = 0.7; + input parameter float damping = 0.5; + + state float c1; state float c2; state float c3; state float c4; + state float c5; state float c6; state float c7; state float c8; + state float l1; state float l2; state float l3; state float l4; + state float l5; state float l6; state float l7; state float l8; + + process { + let d1 = c1 @ 10; l1 = l1 * damping + d1 * (1.0 - damping); c1 = l1 * feedback + in; + let d2 = c2 @ 11; l2 = l2 * damping + d2 * (1.0 - damping); c2 = l2 * feedback + in; + let d3 = c3 @ 12; l3 = l3 * damping + d3 * (1.0 - damping); c3 = l3 * feedback + in; + let d4 = c4 @ 13; l4 = l4 * damping + d4 * (1.0 - damping); c4 = l4 * feedback + in; + let d5 = c5 @ 14; l5 = l5 * damping + d5 * (1.0 - damping); c5 = l5 * feedback + in; + let d6 = c6 @ 15; l6 = l6 * damping + d6 * (1.0 - damping); c6 = l6 * feedback + in; + let d7 = c7 @ 16; l7 = l7 * damping + d7 * (1.0 - damping); c7 = l7 * feedback + in; + let d8 = c8 @ 17; l8 = l8 * damping + d8 * (1.0 - damping); c8 = l8 * feedback + in; + let d9 = c1 @ 18; c1 = l1 * feedback + in; + let d10 = c2 @ 19; c2 = l2 * feedback + in; + let d11 = c3 @ 20; c3 = l3 * feedback + in; + let d12 = c4 @ 21; c4 = l4 * feedback + in; + out = (d1 + d2 + d3 + d4 + d5 + d6 + d7 + d8 + d9 + d10 + d11 + d12) * mix; + } + } + graph G { input stream x; output stream y; node p = ReverbLike; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + for (int block = 0; block < 16; ++block) + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + { + EXPECT_TRUE (std::isfinite (output[static_cast (i)])); + EXPECT_LT (std::fabs (output[static_cast (i)]), 100.0f); + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsBlockModeWithLoop) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor BlockGain { + input stream in; + output stream out; + input parameter float drive = 1; + process block { + for i in 0..blockSize { out[i] = in[i] * drive; } + } + } + graph G { input stream x; output stream y; node b = BlockGain (drive = 3); connection { x -> b.in; b.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (input[static_cast (i)] * 3.0f, output[static_cast (i)], 1e-5f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, SetsAndReadsParameters) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Gain { + input stream in; + output stream out; + input parameter float drive = 1; + output parameter float level; + process { out = in * drive; level = abs (out); } + } + graph G { + input stream x; + output stream y; + input parameter float master = 0.5; + node g = Gain; + connection { x -> g.in; g.out -> y; master -> g.drive; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + EXPECT_TRUE (graph.hasParameter ("master")); + EXPECT_TRUE (graph.hasParameter ("g.drive")); + EXPECT_EQ (0.5f, graph.getParameter ("master")); + + graph.prepare (44100.0, 16); + + auto input = makeRamp (16); + std::vector output (16, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + graph.setParameter ("master", 0.25f); + runProcess32 (graph, inPtrs, outPtrs, 16); + + for (int i = 0; i < 16; ++i) + EXPECT_NEAR (input[static_cast (i)] * 0.25f, output[static_cast (i)], 1e-5f); + + EXPECT_NEAR (fabsf (output[15]), graph.getOutputValue ("g.level"), 1e-6f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsMultiNodeGraphWithSidechain) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Saturator { + input stream in; + input stream side; + output stream out; + process { out = tanh (in * (1 + 0.5 * side)); } + } + processor Gain { + input stream in; + output stream out; + process { out = in * 0.5; } + } + graph G { + input stream dry; + input stream sc; + output stream wet; + node sat = Saturator; + node gain = Gain; + connection { + dry -> sat.in; + sc -> sat.side; + sat.out -> gain.in; + gain.out -> wet; + } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto dry = makeRamp (32); + auto sc = makeRamp (32, 0.1f); + std::vector wet (32, 0.0f); + const float* inPtrs[] = { dry.data(), sc.data() }; + float* outPtrs[] = { wet.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + { + const auto x = dry[static_cast (i)]; + const auto side = sc[static_cast (i)]; + const auto expected = 0.5f * tanhf (x * (1.0f + 0.5f * side)); + EXPECT_NEAR (expected, wet[static_cast (i)], 1e-4f); + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, AlgebraAndConnectionsProduceSameResult) +{ + YdspCompiler compilerA; + YdspCompiler compilerB; + + auto graphA = compilePatch (R"YDSP( + processor Gain { input stream in; output stream out; input parameter float g = 1; process { out = in * g; } } + graph G { + input stream dry; + output stream wet; + node g1 = Gain (g = 2); + node g2 = Gain (g = 3); + connection { dry -> g1.in; g1.out -> g2.in; g2.out -> wet; } + } + )YDSP", + compilerA); + + auto graphB = compilePatch (R"YDSP( + processor Gain { input stream in; output stream out; input parameter float g = 1; process { out = in * g; } } + graph G { + input stream dry; + output stream wet; + process = dry : Gain (g = 2) : Gain (g = 3) : wet; + } + )YDSP", + compilerB); + + ASSERT_TRUE (graphA.isValid()); + ASSERT_TRUE (graphB.isValid()); + + graphA.prepare (44100.0, 32); + graphB.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector outA (32), outB (32); + const float* inPtrs[] = { input.data() }; + float* outAPtrs[] = { outA.data() }; + float* outBPtrs[] = { outB.data() }; + + runProcess32 (graphA, inPtrs, outAPtrs, 32); + runProcess32 (graphB, inPtrs, outBPtrs, 32); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (outA[static_cast (i)], outB[static_cast (i)], 1e-6f); + + dumpAsmOnFailure (graphA); + dumpAsmOnFailure (graphB); +} + +TEST (YdspJitGraphTests, ReportsCompileErrors) +{ + YdspCompiler compiler; + + auto result = compiler.compile (R"YDSP(processor P { input stream in; output stream out; process { out = missing; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"); + + EXPECT_TRUE (result.failed()); + EXPECT_TRUE (compiler.getDiagnostics().hasErrors()); + EXPECT_EQ (1, compiler.getDiagnostics().getItem (0).range.startLine); +} + +TEST (YdspJitGraphTests, ExposesExecutionReport) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Taps { + input stream in; + output stream out; + process block { + for i in 0..8 { out[i] = in[i]; } + } + } + graph G { input stream x; output stream y; node t = Taps; connection { x -> t.in; t.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + + const auto& report = graph.getExecutionReport(); + ASSERT_EQ (1u, report.getKernels().size()); + EXPECT_EQ ("Taps", report.getKernels()[0].name); + EXPECT_GT (report.getKernels()[0].instructionCount, 0); + EXPECT_EQ (8, report.getKernels()[0].boundedIterationCount); + EXPECT_TRUE (report.isProvenRealtimeSafe()); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsBiquadLowpass) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor BiquadLP { + input stream in; + output stream out; + state float z1; + state float z2; + process { + let b0 = 0.0002414; + let b1 = 0.0004827; + let b2 = 0.0002414; + let a1 = -1.9556; + let a2 = 0.9565; + let y = b0 * in + z1; + z1 = b1 * in - a1 * y + z2; + z2 = b2 * in - a2 * y; + out = y; + } + } + graph G { input stream x; output stream y; node f = BiquadLP; connection { x -> f.in; f.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + + constexpr int kBlockSize = 512; + constexpr float dcGain = (0.0002414f + 0.0004827f + 0.0002414f) / (1.0f + (-1.9556f) + 0.9565f); + + graph.prepare (44100.0, kBlockSize); + + std::vector input (kBlockSize, 1.0f); + std::vector output (kBlockSize, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, kBlockSize); + + const auto steady = output[kBlockSize - 1]; + EXPECT_NEAR (dcGain, steady, 0.01f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsBiquadHighpass) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor BiquadHP { + input stream in; + output stream out; + state float z1; + state float z2; + process { + let b0 = 0.9780; + let b1 = -1.9561; + let b2 = 0.9780; + let a1 = -1.9556; + let a2 = 0.9565; + let y = b0 * in + z1; + z1 = b1 * in - a1 * y + z2; + z2 = b2 * in - a2 * y; + out = y; + } + } + graph G { input stream x; output stream y; node f = BiquadHP; connection { x -> f.in; f.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + + constexpr int kBlockSize = 512; + constexpr float dcGain = (0.9780f + (-1.9561f) + 0.9780f) / (1.0f + (-1.9556f) + 0.9565f); + + graph.prepare (44100.0, kBlockSize); + + std::vector input (kBlockSize, 1.0f); + std::vector output (kBlockSize, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, kBlockSize); + + const auto steady = output[kBlockSize - 1]; + EXPECT_NEAR (dcGain, steady, 0.01f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsEnvelopeFollower) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor EnvFollower { + input stream in; + output stream out; + state float env; + process { + let attack = 0.01; + let release = 0.0001; + let rect = abs (in); + let coeff = (rect > env) ? attack : release; + env = env + coeff * (rect - env); + out = env; + } + } + graph G { input stream x; output stream y; node e = EnvFollower; connection { x -> e.in; e.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + auto input = makeRamp (64); + std::vector output (64, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_GE (output[static_cast (i)], 0.0f); + + float previous = -1.0f; + for (int i = 0; i < 64; ++i) + { + EXPECT_GE (output[static_cast (i)], previous); + previous = output[static_cast (i)]; + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsPeakDetector) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor PeakDet { + input stream in; + output stream out; + state float peak; + process { + let rect = abs (in); + peak = max (peak * 0.9995, rect); + out = peak; + } + } + graph G { input stream x; output stream y; node p = PeakDet; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + std::vector input (32, 1.0f); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + EXPECT_NEAR (1.0f, output[31], 0.01f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsRingModulator) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor RingMod { + input stream in; + output stream out; + state float phase; + process { + let carrier = sin (phase); + out = in * carrier; + phase = phase + 0.1; + } + } + graph G { input stream x; output stream y; node r = RingMod; connection { x -> r.in; r.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector input (64, 1.0f); + std::vector output (64, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 64); + + float phase = 0.0f; + for (int i = 0; i < 64; ++i) + { + const auto expected = sinf (phase); + EXPECT_NEAR (expected, output[static_cast (i)], 1e-4f); + phase += 0.1f; + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, ParameterModulationFromHost) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor ModGain { + input stream in; + output stream out; + input parameter float g = 1; + process { out = in * g; } + } + graph G { + input stream x; + output stream y; + input parameter float master = 1; + node m = ModGain; + connection { x -> m.in; m.out -> y; master -> m.g; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 16); + + auto input = makeRamp (16); + std::vector output (16, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + for (int block = 0; block < 4; ++block) + { + const float gain = 0.5f + static_cast (block) * 0.25f; // 0.5, 0.75, 1.0, 1.25 + graph.setParameter ("master", gain); + runProcess32 (graph, inPtrs, outPtrs, 16); + + for (int i = 0; i < 16; ++i) + EXPECT_NEAR (input[static_cast (i)] * gain, output[static_cast (i)], 1e-5f) + << "block " << block << " sample " << i; + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, MeterOutputsReflectLastBlock) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor RMS { + input stream in; + output stream out; + output parameter float accum; + process { out = in; accum = accum + in * in; } + } + graph G { + input stream x; + output stream y; + output parameter float meter; + node r = RMS; + connection { x -> r.in; r.out -> y; r.accum -> meter; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 16); + + auto input = makeRamp (16, 0.5f); + std::vector output (16, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 16); + + const auto meterVal = graph.getOutputValue ("meter"); + EXPECT_GT (meterVal, 0.0f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, MultiBlockAccumulation) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Acc { + input stream in; + output stream out; + state float sum; + process { sum = sum + in; out = sum; } + } + graph G { input stream x; output stream y; node a = Acc; connection { x -> a.in; a.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 16); + + std::vector input (16, 2.0f); + std::vector output (16, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 16); + + float runningSum = 32.0f; // block 1: 16 * 2 = 32 + + runProcess32 (graph, inPtrs, outPtrs, 16); + runningSum += 32.0f; // block 2 -> running sum = 64 + runProcess32 (graph, inPtrs, outPtrs, 16); + runningSum += 32.0f; // block 3 -> running sum = 96 + + EXPECT_NEAR (runningSum, output[15], 1e-4f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, GraphWithMultipleInputsAndOutputs) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Mixer { + input stream a; + input stream b; + output stream sum; + output stream diff; + process { sum = a + b; diff = a - b; } + } + graph Mix { + input stream left, right; + output stream mix, delta; + node m = Mixer; + connection { + left -> m.a; + right -> m.b; + m.sum -> mix; + m.diff -> delta; + } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto left = makeRamp (32, 0.1f); + auto right = makeRamp (32, 0.2f); + + std::vector mixOut (32, 0.0f); + std::vector deltaOut (32, 0.0f); + const float* inPtrs[] = { left.data(), right.data() }; + float* outPtrs[] = { mixOut.data(), deltaOut.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + { + const auto a = left[static_cast (i)]; + const auto b = right[static_cast (i)]; + EXPECT_NEAR (a + b, mixOut[static_cast (i)], 1e-5f); + EXPECT_NEAR (a - b, deltaOut[static_cast (i)], 1e-5f); + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, BlockModeCircularBufferDelay) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor CircDelay { + input stream in; + output stream out; + state float buf[128]; + state int wp; + process block { + for i in 0..blockSize { + buf[wp] = in[i]; + let rp = (wp >= 8) ? (wp - 8) : (wp - 8 + 128); + out[i] = buf[rp]; + wp = (wp + 1) % 128; + } + } + } + graph G { input stream x; output stream y; node d = CircDelay; connection { x -> d.in; d.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 8; ++i) + EXPECT_NEAR (0.0f, output[static_cast (i)], 1e-6f) << "sample " << i; + + for (int i = 8; i < 32; ++i) + EXPECT_NEAR (input[static_cast (i - 8)], output[static_cast (i)], 1e-6f) << "sample " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, IdenticalGraphsProduceIdenticalOutput) +{ + YdspCompiler compiler; + + auto source = R"YDSP( + processor P { input stream in; output stream out; input parameter float g = 1; process { out = tanh (in * g); } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + + auto graphA = compilePatch (source, compiler); + YdspCompiler compilerB; + auto graphB = compilePatch (source, compilerB); + + ASSERT_TRUE (graphA.isValid()); + ASSERT_TRUE (graphB.isValid()); + graphA.prepare (44100.0, 128); + graphB.prepare (44100.0, 128); + + auto input = makeRamp (128, -1.0f); + std::vector outA (128), outB (128); + const float* inPtrs[] = { input.data() }; + float* outAPtrs[] = { outA.data() }; + float* outBPtrs[] = { outB.data() }; + + runProcess32 (graphA, inPtrs, outAPtrs, 128); + runProcess32 (graphB, inPtrs, outBPtrs, 128); + + for (int i = 0; i < 128; ++i) + EXPECT_NEAR (outA[static_cast (i)], outB[static_cast (i)], 1e-6f); + + dumpAsmOnFailure (graphA); + dumpAsmOnFailure (graphB); +} + +TEST (YdspJitGraphTests, HandlesSmallBlockSize) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor P { input stream in; output stream out; process { out = in * 2; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 1); + + std::vector input = { 0.5f }; + std::vector output (1, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 1); + + EXPECT_NEAR (1.0f, output[0], 1e-5f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, HandlesLargeBlockSize) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor P { input stream in; output stream out; process { out = in; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + auto input = makeRamp (512); + std::vector output (512, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 512); + + for (int i = 0; i < 512; ++i) + EXPECT_NEAR (input[static_cast (i)], output[static_cast (i)], 1e-6f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, InvalidGraphReturnsFalse) +{ + YdspAudioGraph graph; + EXPECT_FALSE (graph.isValid()); +} + +TEST (YdspJitGraphTests, GetParamOnNonexistentReturnsZero) +{ + YdspAudioGraph graph; + EXPECT_EQ (0.0f, graph.getParameter ("nonexistent")); + EXPECT_FALSE (graph.hasParameter ("nonexistent")); +} + +TEST (YdspJitGraphTests, GetOutputValueOnNonexistentReturnsZero) +{ + YdspAudioGraph graph; + EXPECT_EQ (0.0f, graph.getOutputValue ("nonexistent")); +} + +TEST (YdspJitGraphTests, HandlesPatchWithoutGraph) +{ + YdspCompiler compiler; + + auto result = compiler.compile (R"YDSP( + processor P { input stream in; output stream out; process { out = in; } } + )YDSP"); + + EXPECT_TRUE (result.failed()); +} + +TEST (YdspJitGraphTests, RunsProcessorWithFunctionCall) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor P { + input stream in; + output stream out; + func doubleIt(x: float) : float { + return x * 2.0; + } + process { out = doubleIt(in); } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (input[static_cast (i)] * 2.0f, output[static_cast (i)], 1e-5f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsProcessorWithMultiParamFunction) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor P { + input stream in; + output stream out; + func lerp(a: float, b: float, t: float) : float { + return a + (b - a) * t; + } + process { out = lerp(in, out', 0.5); } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + float previous = 0.0f; + for (int i = 0; i < 32; ++i) + { + const auto expected = 0.5f * input[static_cast (i)] + 0.5f * previous; + EXPECT_NEAR (expected, output[static_cast (i)], 1e-5f); + previous = output[static_cast (i)]; + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, FunctionParameterMutationDoesNotClobberCallerLocal) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor P { + input stream in; + output stream out; + func scaleThenZero(t: float) : float { + t = t * 2.0; + return t; + } + process { + float x = in; + out = scaleThenZero(x) - x; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + auto input = makeRamp (32); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (input[static_cast (i)], output[static_cast (i)], 1e-5f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, FunctionParameterMutationPreservesStateStore) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor P { + output stream out; + state float phase; + func halve(t: float) : float { + t = t * 0.5; + return t; + } + process { + phase = phase + 0.25; + if (phase >= 1.0) { phase = phase - 1.0; } + out = halve(phase) * 2.0 + phase; + } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + std::vector output (32, 0.0f); + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, nullptr, outPtrs, 32); + + float phase = 0.0f; + for (int i = 0; i < 32; ++i) + { + phase = phase + 0.25f; + if (phase >= 1.0f) + phase = phase - 1.0f; + + EXPECT_NEAR (2.0f * phase, output[static_cast (i)], 1e-5f); + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, CompoundAssignmentOnAnIndexedTargetComputesCorrectly) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor P { + input stream in; + output stream out; + state float buf[4]; + process { + let idx = 2; + buf[idx] += in; + out = buf[idx]; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + auto input = makeRamp (8, 1.0f); + std::vector output (8, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 8); + + float expected = 0.0f; + for (int i = 0; i < 8; ++i) + { + expected += input[static_cast (i)]; + EXPECT_NEAR (expected, output[static_cast (i)], 1e-5f); + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RejectsRecursionTheAnalyzerMissesInsteadOfCrashing) +{ + YdspCompiler compiler; + + auto result = compiler.compile (R"YDSP( + processor P { + input stream in; + output stream out; + func fact(n: float) : float { + float r = 1.0; + if (n > 0.0) { r = n * fact(n - 1.0); } + return r; + } + process { out = fact(in); } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"); + + EXPECT_FALSE (result.wasOk()); + EXPECT_TRUE (compiler.getDiagnostics().hasErrors()); +} + +TEST (YdspJitGraphTests, RunsFloat64StreamGraph) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor F64Gain { + input stream float64 in; + output stream float64 out; + input parameter float64 gain = 0.5; + process { out = in * gain; } + } + graph G { + input stream float64 x; + output stream float64 y; + input parameter float64 master = 2.0; + node g = F64Gain; + connection { x -> g.in; g.out -> y; master -> g.gain; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + + EXPECT_EQ (YdspElementType::float64, graph.getInputStreamType (0)); + EXPECT_EQ (YdspElementType::float64, graph.getOutputStreamType (0)); + EXPECT_EQ (YdspElementType::float64, graph.getParameterType ("master")); + EXPECT_EQ (YdspElementType::float64, graph.getParameterType ("g.gain")); + + graph.prepare (44100.0, 32); + + // The graph-level default (2.0) is copied to the node parameter. + EXPECT_NEAR (2.0, graph.getDoubleParameter ("master"), 1e-12); + EXPECT_NEAR (2.0, graph.getDoubleParameter ("g.gain"), 1e-12); + + std::vector input (32); + for (int i = 0; i < 32; ++i) + input[static_cast (i)] = 0.01 * static_cast (i) - 0.15; + + std::vector output (32, 0.0); + + std::vector inputBuffers { yup::Span (input.data(), 32) }; + std::vector outputBuffers { yup::Span (output.data(), 32) }; + + graph.process (yup::YdspProcessRequest { inputBuffers, outputBuffers, 32 }); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (input[static_cast (i)] * 2.0, output[static_cast (i)], 1e-9); + + graph.setDoubleParameter ("master", 0.25); + graph.process (yup::YdspProcessRequest { inputBuffers, outputBuffers, 32 }); + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (input[static_cast (i)] * 0.25, output[static_cast (i)], 1e-9); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsInt64ParamAndState) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Counter { + input stream in; + output stream out; + input parameter int64 start = 0; + state int64 counter; + process { + counter = start + 1; + out = float32 (float64 (counter)); + } + } + graph G { + input stream x; + output stream y; + input parameter int64 seed = 100; + node c = Counter; + connection { x -> c.in; c.out -> y; seed -> c.start; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + + EXPECT_EQ (YdspElementType::int64, graph.getParameterType ("seed")); + EXPECT_EQ (100, graph.getIntParameter ("seed")); + + graph.prepare (44100.0, 16); + + std::vector input (16, 0.0f); + std::vector output (16, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 16); + + for (int i = 0; i < 16; ++i) + EXPECT_NEAR (101.0f, output[static_cast (i)], 1e-4f); + + graph.setIntParameter ("seed", 7); + runProcess32 (graph, inPtrs, outPtrs, 16); + + for (int i = 0; i < 16; ++i) + EXPECT_NEAR (8.0f, output[static_cast (i)], 1e-4f); + + dumpAsmOnFailure (graph); +} + +//============================================================================== + +TEST (YdspJitGraphTests, RunsBitwiseInt32Ops) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Bit { + output stream float out; + process { + let a = 60; // 0b111100 + let b = 10; // 0b001010 + let s = 3; + let and_ = a & b; + let or_ = a | b; + let xor_ = a ^ b; + let shl_ = a << s; + let shr_ = a >> s; + let not_ = ~a & 255; // ~60 = -61 (0xFFFFFFC3), & 0xFF = 195 + out = float (and_ + or_ + xor_ + shl_ + shr_ + not_); + } + } + graph G { output stream y; node b = Bit; connection { b.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector output (64, 0.0f); + const float* inPtrs[] = { nullptr }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 64); + + constexpr float expected = 8.0f + 62.0f + 54.0f + 480.0f + 7.0f + 195.0f; + + for (int i = 0; i < 64; ++i) + EXPECT_NEAR (expected, output[static_cast (i)], 1e-5f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsHexAndBinaryIntegerLiterals) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Bit { + output stream float out; + process { + let masked = 0x1FF & 0xFF; // 511 & 255 = 255 + let bits = 0b1010 << 2; // 10 << 2 = 40 + let big = 1_000 + 0x10; // 1000 + 16 = 1016 + out = float (masked + bits + big); + } + } + graph G { output stream y; node b = Bit; connection { b.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + std::vector output (32, 0.0f); + const float* inPtrs[] = { nullptr }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 32); + + constexpr float expected = 255.0f + 40.0f + 1016.0f; + + for (int i = 0; i < 32; ++i) + EXPECT_NEAR (expected, output[static_cast (i)], 1e-5f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsBitwiseInt64Ops) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Bit64 { + output stream float out; + process { + let x = int64(-1); + let y = int64(255); + let masked = x & y; // 255 + let hi = (x >> 63) & int64(1); // arithmetic shift: -1 >> 63 = -1, & 1 = 1 + let low = (~int64(0)) & y; // ~0 = -1, & 255 = 255 + // Regression: `int64(1) << 32` must stay 64-bit; a 32-bit + // shift wraps to 1 and the mask collapses to 0. + let mask32 = (int64(1) << 32) - int64(1); // 0xFFFFFFFF + let maskLsb = mask32 & int64(1); // 1 (0 if the mask broke) + let shl32 = (int64(1) << 32) >> 32; // 1 (0 if the shift wrapped) + out = float (masked + hi + low + maskLsb + shl32); + } + } + graph G { output stream y; node b = Bit64; connection { b.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector output (64, 0.0f); + const float* inPtrs[] = { nullptr }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 64); + + constexpr float expected = 255.0f + 1.0f + 255.0f + 1.0f + 1.0f; + + for (int i = 0; i < 64; ++i) + EXPECT_NEAR (expected, output[static_cast (i)], 1e-5f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, BitwisePrecedenceMatchesC) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Pre { + output stream float out; + process { + let a = 1 << 2 + 3; // shift binds looser than additive: 1 << (2 + 3) = 32 + let b = 2 + 3 << 1; // (2 + 3) << 1 = 10 + let c = 1 | 2 & 4; // 1 | (2 & 4) = 1 + let d = 1 & 2 | 4; // (1 & 2) | 4 = 4 + let ex = 1 ^ 2 & 3; // 1 ^ (2 & 3) = 3 + let f = 4 | 2 ^ 1; // 4 | (2 ^ 1) = 7 + out = float (a + b + c + d + ex + f); + } + } + graph G { output stream y; node p = Pre; connection { p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector output (64, 0.0f); + const float* inPtrs[] = { nullptr }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 64); + + constexpr float expected = 32.0f + 10.0f + 1.0f + 4.0f + 3.0f + 7.0f; + + for (int i = 0; i < 64; ++i) + EXPECT_NEAR (expected, output[static_cast (i)], 1e-5f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsCompoundBitwiseAssignment) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Acc { + output stream float out; + state int acc; + process { + acc &= 15; // 0 -> 0 + acc |= 240; // -> 0xF0 + acc ^= 85; // ^ 0x55 -> 0xA5 = 165 + out = float (acc); + } + } + graph G { output stream y; node a = Acc; connection { a.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector output (64, 0.0f); + const float* inPtrs[] = { nullptr }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 64); + + int acc = 0; + + for (int i = 0; i < 64; ++i) + { + acc &= 15; + acc |= 240; + acc ^= 85; + EXPECT_NEAR (static_cast (acc), output[static_cast (i)], 1e-5f); + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsLfsrNoiseGenerator) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Lfsr { + output stream float out; + state int64 mem; + process { + if (mem == 0) + mem = int64(1); + + let w = mem; + let feedback = (w ^ (w >> 5) ^ (w >> 13)) << 31; + let masked = feedback & ((int64(1) << 32) - int64(1)); // keep low 32 bits + let next = (w >> 1) | masked; + out = float ((next & 1) * 2 - 1); + mem = next; + } + } + graph G { output stream y; node l = Lfsr; connection { l.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector output (64, 0.0f); + const float* inPtrs[] = { nullptr }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 64); + + int64_t w = 1; + + for (int i = 0; i < 64; ++i) + { + int64_t feedback = static_cast (static_cast (w ^ (w >> 5) ^ (w >> 13)) << 31); + feedback &= 0xFFFFFFFFLL; + w = (w >> 1) | feedback; + + const float expected = (w & 1) != 0 ? 1.0f : -1.0f; + EXPECT_NEAR (expected, output[static_cast (i)], 0.0f); + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsMaskedRingBuffer) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Ring { + input stream in; + output stream out; + state int wp; + state float buf[8]; + process { + wp = (wp + 1) & 7; + buf[wp] = in; + out = buf[(wp - 3) & 7]; // mask handles negative indices + } + } + graph G { input stream x; output stream y; node r = Ring; connection { x -> r.in; r.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + auto input = makeRamp (64); + std::vector output (64, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 64); + + std::vector buf (8, 0.0f); + int wp = 0; + + for (int i = 0; i < 64; ++i) + { + wp = (wp + 1) & 7; + buf[static_cast (wp)] = input[static_cast (i)]; + const float expected = buf[static_cast ((wp - 3) & 7)]; + EXPECT_NEAR (expected, output[static_cast (i)], 1e-5f); + } + + dumpAsmOnFailure (graph); +} + +//============================================================================== + +TEST (YdspJitGraphTests, RunsStructState) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor S { + input stream in; + output stream out; + struct Voice { float phase; float buf[8]; int idx; } + Voice mono; + process { + mono.idx = (mono.idx + 1) & 7; + mono.buf[mono.idx] = in; + out = mono.phase + mono.buf[(mono.idx - 3) & 7]; + mono.phase = mono.phase + 0.25; + } + } + graph G { input stream x; output stream y; node s = S; connection { x -> s.in; s.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + auto input = makeRamp (64); + std::vector output (64, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 64); + + float phase = 0.0f; + std::vector refBuf (8, 0.0f); + int idx = 0; + + for (int i = 0; i < 64; ++i) + { + idx = (idx + 1) & 7; + refBuf[static_cast (idx)] = input[static_cast (i)]; + const float expected = phase + refBuf[static_cast ((idx - 3) & 7)]; + EXPECT_NEAR (expected, output[static_cast (i)], 1e-5f); + phase += 0.25f; + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsStructArray) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor SA { + output stream float out; + struct Voice { float freq; float phase; } + Voice voices[8]; + process { + voices[3].freq = 440; + out = voices[3].freq + voices[7].phase; + voices[7].phase = voices[7].phase + 1; + } + } + graph G { output stream y; node s = SA; connection { s.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector output (64, 0.0f); + const float* inPtrs[] = { nullptr }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_NEAR (440.0f + static_cast (i), output[static_cast (i)], 1e-5f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsInitBlock) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor I { + input stream in; + output stream out; + state float gain; + init { gain = 2.5; } + process { out = in * gain; } + } + graph G { input stream x; output stream y; node i = I; connection { x -> i.in; i.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + auto input = makeRamp (64); + std::vector output (64, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_NEAR (input[static_cast (i)] * 2.5f, output[static_cast (i)], 1e-5f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RunsInitWithStructFieldsAndReset) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor R { + output stream float out; + struct C { float acc; } + C c; + init { c.acc = 10; } + process { out = c.acc; c.acc = c.acc + 1; } + } + graph G { output stream y; node r = R; connection { r.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector output (64, 0.0f); + const float* inPtrs[] = { nullptr }; + float* outPtrs[] = { output.data() }; + + runProcess32 (graph, inPtrs, outPtrs, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_NEAR (10.0f + static_cast (i), output[static_cast (i)], 1e-5f); + + graph.reset(); + + std::vector output2 (64, 0.0f); + float* outPtrs2[] = { output2.data() }; + + runProcess32 (graph, inPtrs, outPtrs2, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_NEAR (10.0f + static_cast (i), output2[static_cast (i)], 1e-5f); + + dumpAsmOnFailure (graph); +} + +//============================================================================== +// MIDI-driven events, voice banks and sample-accurate automation + +namespace +{ + +constexpr const char* activeVoiceSource = R"YDSP( + processor ActiveVoice { + output stream out; + input event midi; + state float active; + event midi (e: noteOn) { + active = active + 1.0; + } + event midi (e: noteOff) { + active = active - 1.0; + } + process { out = active; } + } +)YDSP"; + +constexpr const char* activeVoiceGraph = R"YDSP( + graph G { + input event midi; + output stream y; + node v = ActiveVoice[4]; + connection { midi -> v.midi; v.out -> y; } + } +)YDSP"; + +yup::MidiBuffer makeNoteBuffer (const std::vector>& events) +{ + yup::MidiBuffer buffer; + + for (const auto& [offset, pitch, velocity] : events) + { + const auto velocityByte = static_cast (std::clamp (velocity, 0.0f, 1.0f) * 127.0f); + + if (velocity > 0.0f) + buffer.addEvent (yup::MidiMessage::noteOn (1, pitch, velocityByte), offset); + else + buffer.addEvent (yup::MidiMessage::noteOff (1, pitch), offset); + } + + return buffer; +} + +} // namespace + +TEST (YdspJitGraphTests, MonosynthReceivesMidiNoteOn) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (activeVoiceSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node v = ActiveVoice[1]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector output (64, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi = makeNoteBuffer ({ { 0, 60, 1.0f } }); + + runProcess (graph, nullptr, 0, outPtrs, 1, 64, &midi); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (1.0f, output[static_cast (i)]); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, FourNoteOnsLandInFourDistinctVoices) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (activeVoiceSource) + activeVoiceGraph, compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector output (64, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi = makeNoteBuffer ({ { 0, 60, 1.0f }, { 0, 64, 1.0f }, { 0, 67, 1.0f }, { 0, 71, 1.0f } }); + + runProcess (graph, nullptr, 0, outPtrs, 1, 64, &midi); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (4.0f, output[static_cast (i)]); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, HeldNotesSurviveEventFreeBlocks) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (activeVoiceSource) + activeVoiceGraph, compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector output (64, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi = makeNoteBuffer ({ { 0, 60, 1.0f }, { 0, 64, 1.0f } }); + + runProcess (graph, nullptr, 0, outPtrs, 1, 64, &midi); + + for (int b = 0; b < 4; ++b) + { + runProcess (graph, nullptr, 0, outPtrs, 1, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (2.0f, output[static_cast (i)]) << "block " << b << " sample " << i; + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, FifthNoteOnStealsOldestTriggeredVoice) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (activeVoiceSource) + activeVoiceGraph, compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector output (64, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi = makeNoteBuffer ({ { 0, 60, 1.0f }, { 0, 64, 1.0f }, { 0, 67, 1.0f }, { 0, 71, 1.0f }, { 0, 74, 1.0f } }); + + runProcess (graph, nullptr, 0, outPtrs, 1, 64, &midi); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (4.0f, output[static_cast (i)]); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, SecondNoteOnAtSamePitchAndChannelRetriggers) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (activeVoiceSource) + activeVoiceGraph, compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector output (64, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi = makeNoteBuffer ({ { 0, 60, 1.0f }, { 0, 60, 1.0f }, { 10, 60, 0.0f } }); + + runProcess (graph, nullptr, 0, outPtrs, 1, 64, &midi); + + for (int i = 0; i < 10; ++i) + EXPECT_FLOAT_EQ (1.0f, output[static_cast (i)]) << "held at " << i; + + for (int i = 10; i < 64; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "released at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, SamePitchOnTwoMpeMemberChannelsStaysIndependent) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (activeVoiceSource) + activeVoiceGraph, compiler); + + ASSERT_TRUE (graph.isValid()); + + yup::MPEZoneLayout layout; + layout.setLowerZone (15); + graph.setMpeZoneLayout (layout); + + graph.prepare (44100.0, 64); + + std::vector output (64, 0.0f); + float* outPtrs[] = { output.data() }; + + yup::MidiBuffer midi; + midi.addEvent (yup::MidiMessage::noteOn (2, 60, static_cast (100)), 0); + midi.addEvent (yup::MidiMessage::noteOn (3, 60, static_cast (100)), 0); + midi.addEvent (yup::MidiMessage::noteOff (3, 60), 10); + + runProcess (graph, nullptr, 0, outPtrs, 1, 64, &midi); + + for (int i = 0; i < 10; ++i) + EXPECT_FLOAT_EQ (2.0f, output[static_cast (i)]) << "both held at " << i; + + for (int i = 10; i < 64; ++i) + EXPECT_FLOAT_EQ (1.0f, output[static_cast (i)]) << "one held at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, NoteOnAndNoteOffInSameBlockFireAtExactSampleOffsets) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (activeVoiceSource) + activeVoiceGraph, compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + std::vector output (512, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi = makeNoteBuffer ({ { 100, 60, 1.0f }, { 300, 60, 0.0f } }); + + runProcess (graph, nullptr, 0, outPtrs, 1, 512, &midi); + + for (int i = 0; i < 100; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "before note-on at " << i; + + for (int i = 100; i < 300; ++i) + EXPECT_FLOAT_EQ (1.0f, output[static_cast (i)]) << "between note-on and note-off at " << i; + + for (int i = 300; i < 512; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "after note-off at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, AutomationEventChangesParamAtExactSampleOffset) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor GainVoice { + output stream out; + input event midi; + input parameter float gain = 0.5; + state float env; + event midi (e: noteOn) { + env = e.velocity; + } + process { out = env * gain; } + } + graph G { + input event midi; + output stream y; + node v = GainVoice; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + std::vector output (512, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi = makeNoteBuffer ({ { 0, 60, 1.0f } }); + + const auto gainSlot = graph.getParameterSlot ("v.gain"); + ASSERT_GE (gainSlot, 0); + + YdspAutomationEvent automation { gainSlot, 100, 1.0f }; + + runProcess (graph, nullptr, 0, outPtrs, 1, 512, &midi, &automation, 1); + + for (int i = 0; i < 100; ++i) + EXPECT_FLOAT_EQ (0.5f, output[static_cast (i)]) << "before automation at " << i; + + for (int i = 100; i < 512; ++i) + EXPECT_FLOAT_EQ (1.0f, output[static_cast (i)]) << "after automation at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, PolyphonicAutomationAppliesTheSameTimelineToEveryVoice) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor GainVoice { + output stream out; + input event midi; + input parameter float gain = 0.5; + state float env; + event midi (e: noteOn) { env = e.velocity; } + process { out = env * gain; } + } + graph G { + input event midi; + output stream y; + node v = GainVoice[4]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + std::vector output (512, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi = makeNoteBuffer ({ { 0, 60, 1.0f }, { 0, 64, 1.0f } }); + + const auto gainSlot = graph.getParameterSlot ("v.gain"); + ASSERT_GE (gainSlot, 0); + + const YdspAutomationEvent automation { gainSlot, 100, 1.0f }; + + runProcess (graph, nullptr, 0, outPtrs, 1, 512, &midi, &automation, 1); + + for (int i = 0; i < 100; ++i) + EXPECT_FLOAT_EQ (1.0f, output[static_cast (i)]) << "before automation at " << i; + + for (int i = 100; i < 512; ++i) + EXPECT_FLOAT_EQ (2.0f, output[static_cast (i)]) << "after automation at " << i; + + EXPECT_FLOAT_EQ (1.0f, graph.getParameter ("v.gain")); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, SmoothedAutomationRampsInsteadOfStepping) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Gain { + input stream in; + output stream out; + input parameter float gain = 0.5 [[ name: "Gain", min: 0.0, max: 1.0, smoothing: 0.0005 ]]; + process { out = in * gain; } + } + graph G { input stream x; output stream y; node p = Gain; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + const std::vector input (512, 1.0f); + const float* inPtrs[] = { input.data() }; + + std::vector output (512, 0.0f); + float* outPtrs[] = { output.data() }; + + runProcess (graph, inPtrs, 1, outPtrs, 1, 512); + + for (int i = 0; i < 512; ++i) + EXPECT_FLOAT_EQ (0.5f, output[static_cast (i)]) << "primed block at " << i; + + const auto gainSlot = graph.getParameterSlot ("p.gain"); + ASSERT_GE (gainSlot, 0); + + YdspAutomationEvent automation { gainSlot, 100, 1.0f }; + + std::fill (output.begin(), output.end(), 0.0f); + runProcess (graph, inPtrs, 1, outPtrs, 1, 512, nullptr, &automation, 1); + + for (int i = 0; i < 100; ++i) + EXPECT_FLOAT_EQ (0.5f, output[static_cast (i)]) << "before automation at " << i; + + EXPECT_GT (output[100], 0.5f); + EXPECT_LT (output[100], 0.6f); + + for (int i = 101; i < 512; ++i) + { + const auto previous = output[static_cast (i - 1)]; + const auto current = output[static_cast (i)]; + + EXPECT_GE (current, previous) << "not monotone at " << i; + + if (previous < 1.0f) + EXPECT_GT (current, previous) << "stalled short of the target at " << i; + } + + EXPECT_FLOAT_EQ (1.0f, output[500]); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, ResetRePrimesSmoothedParameter) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Gain { + input stream in; + output stream out; + input parameter float gain = 0.5 [[ smoothing: 0.0005 ]]; + process { out = in * gain; } + } + graph G { input stream x; output stream y; node p = Gain; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + const std::vector input (512, 1.0f); + const float* inPtrs[] = { input.data() }; + + std::vector output (512, 0.0f); + float* outPtrs[] = { output.data() }; + + graph.setParameter ("p.gain", 0.25f); + + graph.reset(); + + runProcess (graph, inPtrs, 1, outPtrs, 1, 512); + + EXPECT_FLOAT_EQ (0.25f, output[0]); + EXPECT_FLOAT_EQ (0.25f, output[511]); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, SmoothsAProcessorParameterDrivenByAGraphEndpoint) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Gain { + input stream in; + output stream out; + input parameter float gain = 0.5 [[ smoothing: 0.0005 ]]; + process { out = in * gain; } + } + graph G { + input stream x; + output stream y; + input parameter float level = 0.5 [[ name: "Level", min: 0.0, max: 1.0 ]]; + node p = Gain; + connection { x -> p.in; p.out -> y; level -> p.gain; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + const std::vector input (512, 1.0f); + const float* inPtrs[] = { input.data() }; + + std::vector output (512, 0.0f); + float* outPtrs[] = { output.data() }; + + runProcess (graph, inPtrs, 1, outPtrs, 1, 512); + + for (int i = 0; i < 512; ++i) + EXPECT_FLOAT_EQ (0.5f, output[static_cast (i)]) << "primed block at " << i; + + const auto levelSlot = graph.getParameterSlot ("level"); + ASSERT_GE (levelSlot, 0); + + YdspAutomationEvent automation { levelSlot, 100, 1.0f }; + + std::fill (output.begin(), output.end(), 0.0f); + runProcess (graph, inPtrs, 1, outPtrs, 1, 512, nullptr, &automation, 1); + + for (int i = 0; i < 100; ++i) + EXPECT_FLOAT_EQ (0.5f, output[static_cast (i)]) << "before automation at " << i; + + EXPECT_GT (output[100], 0.5f); + EXPECT_LT (output[100], 0.6f); + EXPECT_FLOAT_EQ (1.0f, output[500]); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, ConstantSmoothedParameterMatchesUnsmoothedOutput) +{ + YdspCompiler smoothedCompiler; + + auto smoothed = compilePatch (R"YDSP( + processor Gain { + input stream in; + output stream out; + input parameter float gain = 0.375 [[ smoothing: 0.02 ]]; + process { out = in * gain; } + } + graph G { input stream x; output stream y; node p = Gain; connection { x -> p.in; p.out -> y; } } + )YDSP", + smoothedCompiler); + + YdspCompiler steppedCompiler; + + auto stepped = compilePatch (R"YDSP( + processor Gain { + input stream in; + output stream out; + input parameter float gain = 0.375; + process { out = in * gain; } + } + graph G { input stream x; output stream y; node p = Gain; connection { x -> p.in; p.out -> y; } } + )YDSP", + steppedCompiler); + + ASSERT_TRUE (smoothed.isValid()); + ASSERT_TRUE (stepped.isValid()); + + smoothed.prepare (44100.0, 256); + stepped.prepare (44100.0, 256); + + const auto input = makeRamp (256, -1.0f); + const float* inPtrs[] = { input.data() }; + + std::vector smoothedOut (256, 0.0f); + std::vector steppedOut (256, 0.0f); + float* smoothedPtrs[] = { smoothedOut.data() }; + float* steppedPtrs[] = { steppedOut.data() }; + + runProcess (smoothed, inPtrs, 1, smoothedPtrs, 1, 256); + runProcess (stepped, inPtrs, 1, steppedPtrs, 1, 256); + + for (int i = 0; i < 256; ++i) + EXPECT_EQ (steppedOut[static_cast (i)], smoothedOut[static_cast (i)]) << "at " << i; + + dumpAsmOnFailure (smoothed); +} + +TEST (YdspJitGraphTests, SmoothIntrinsicMatchesSmoothingAnnotation) +{ + YdspCompiler sugarCompiler; + + auto sugar = compilePatch (R"YDSP( + processor Gain { + input stream in; + output stream out; + input parameter float gain = 0.5 [[ smoothing: 0.001 ]]; + process { out = in * gain; } + } + graph G { input stream x; output stream y; node p = Gain; connection { x -> p.in; p.out -> y; } } + )YDSP", + sugarCompiler); + + YdspCompiler intrinsicCompiler; + + auto intrinsic = compilePatch (R"YDSP( + processor Gain { + input stream in; + output stream out; + input parameter float gain = 0.5; + process { + float gainSmoothed = smooth (gain, 0.001); + out = in * gainSmoothed; + } + } + graph G { input stream x; output stream y; node p = Gain; connection { x -> p.in; p.out -> y; } } + )YDSP", + intrinsicCompiler); + + ASSERT_TRUE (sugar.isValid()); + ASSERT_TRUE (intrinsic.isValid()); + + sugar.prepare (44100.0, 512); + intrinsic.prepare (44100.0, 512); + + const std::vector input (512, 1.0f); + const float* inPtrs[] = { input.data() }; + + std::vector sugarOut (512, 0.0f); + std::vector intrinsicOut (512, 0.0f); + float* sugarPtrs[] = { sugarOut.data() }; + float* intrinsicPtrs[] = { intrinsicOut.data() }; + + const auto sugarSlot = sugar.getParameterSlot ("p.gain"); + const auto intrinsicSlot = intrinsic.getParameterSlot ("p.gain"); + ASSERT_GE (sugarSlot, 0); + ASSERT_GE (intrinsicSlot, 0); + + const YdspAutomationEvent sugarAutomation { sugarSlot, 64, 1.0f }; + const YdspAutomationEvent intrinsicAutomation { intrinsicSlot, 64, 1.0f }; + + runProcess (sugar, inPtrs, 1, sugarPtrs, 1, 512, nullptr, &sugarAutomation, 1); + runProcess (intrinsic, inPtrs, 1, intrinsicPtrs, 1, 512, nullptr, &intrinsicAutomation, 1); + + for (int i = 0; i < 512; ++i) + EXPECT_EQ (intrinsicOut[static_cast (i)], sugarOut[static_cast (i)]) << "at " << i; + + dumpAsmOnFailure (sugar); +} + +TEST (YdspJitGraphTests, BlockWithNoSplitPointsTakesSingleKernelCall) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Counter { + output stream out; + input event midi; + state float calls; + event midi (e: noteOn) { } + process block { + calls = calls + 1.0; + out[0] = calls; + } + } + graph G { + input event midi; + output stream y; + node c = Counter; + connection { midi -> c.midi; c.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + { + std::vector output (64, 0.0f); + float* outPtrs[] = { output.data() }; + + runProcess (graph, nullptr, 0, outPtrs, 1, 64); + + EXPECT_FLOAT_EQ (1.0f, output[0]); + for (int i = 1; i < 64; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]); + } + + graph.reset(); + + { + std::vector output (64, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi = makeNoteBuffer ({ { 20, 60, 1.0f } }); + + runProcess (graph, nullptr, 0, outPtrs, 1, 64, &midi); + + EXPECT_FLOAT_EQ (1.0f, output[0]); + EXPECT_FLOAT_EQ (2.0f, output[20]); + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, DefaultRequestMatchesExplicitEmptyEvents) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor OnePole { + input stream in; + output stream out; + input parameter float a = 0.5; + process { out = (1 - a) * in + a * out'; } + } + graph G { input stream x; output stream y; node p = OnePole; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + auto input = makeRamp (64); + + std::vector outA (64, 0.0f); + std::vector outB (64, 0.0f); + + std::vector inputBuffers { yup::Span (input.data(), 64) }; + std::vector outBuffersA { yup::Span (outA.data(), 64) }; + std::vector outBuffersB { yup::Span (outB.data(), 64) }; + + for (int b = 0; b < 4; ++b) + { + graph.process (yup::YdspProcessRequest { inputBuffers, outBuffersA, 64 }); + graph.reset(); + + graph.process (yup::YdspProcessRequest { inputBuffers, outBuffersB, 64, {}, {} }); + graph.reset(); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (outA[static_cast (i)], outB[static_cast (i)]); + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RequestMidiOutputRemainsEmptyWithoutOutputEvents) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor OnePole { + input stream in; + output stream out; + input parameter float a = 0.5; + process { out = (1 - a) * in + a * out'; } + } + graph G { input stream x; output stream y; node p = OnePole; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + auto input = makeRamp (64); + + std::vector outA (64, 0.0f); + std::vector outB (64, 0.0f); + + std::vector inputBuffers { yup::Span (input.data(), 64) }; + std::vector outBuffersA { yup::Span (outA.data(), 64) }; + std::vector outBuffersB { yup::Span (outB.data(), 64) }; + + yup::MidiBuffer midiOut; + + EXPECT_EQ (yup::YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { inputBuffers, outBuffersA, 64 })); + graph.reset(); + + EXPECT_EQ (yup::YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { inputBuffers, outBuffersB, 64, {}, {}, &midiOut })); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (outA[static_cast (i)], outB[static_cast (i)]); + + EXPECT_TRUE (midiOut.isEmpty()); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, IgnoresMismatchedStreamBuffers) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Gain { input stream in; output stream out; process { out = in * 2; } } + graph G { input stream x; output stream y; node g = Gain; connection { x -> g.in; g.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector input (64, 0.5f); + std::vector output (64, 0.0f); + + std::vector outBuf { yup::Span (output.data(), 64) }; + + std::vector wrongInput (64, 0.5); + std::vector wrongIn { yup::Span (wrongInput.data(), 64) }; + + EXPECT_EQ (yup::YdspProcessResult::bufferTypeMismatch, graph.process (yup::YdspProcessRequest { wrongIn, outBuf, 64 })); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]); + + std::vector shortInput (32, 0.5f); + std::vector shortIn { yup::Span (shortInput.data(), 32) }; + + EXPECT_EQ (yup::YdspProcessResult::bufferTooShort, graph.process (yup::YdspProcessRequest { shortIn, outBuf, 64 })); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]); + + std::vector extraIn { + yup::Span (input.data(), 64), + yup::Span (input.data(), 64) + }; + + EXPECT_EQ (yup::YdspProcessResult::invalidBufferCount, graph.process (yup::YdspProcessRequest { extraIn, outBuf, 64 })); + + std::vector okIn { yup::Span (input.data(), 64) }; + + EXPECT_EQ (yup::YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { okIn, outBuf, 64 })); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (1.0f, output[static_cast (i)]); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, ExposesParamMetadataForUi) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Voice { + output stream out; + input parameter float cutoff = 1500.0 [[ name: "Cutoff", min: 60.0, max: 12000.0 ]]; + input parameter float decay = 0.25; + process { out = cutoff * decay; } + } + graph G { + output stream y; + input parameter float master = 0.8 [[ name: "Master Volume", min: 0.0, max: 1.0 ]]; + node v = Voice; + connection { v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + ASSERT_EQ (0, graph.getInputStreamCount()); + ASSERT_EQ (1, graph.getOutputStreamCount()); + ASSERT_EQ (3, graph.getParameterCount()); + + const auto master = graph.getParameterInfo (0); + EXPECT_EQ ("master", master.name); + EXPECT_EQ ("Master Volume", master.displayName); + EXPECT_EQ (YdspElementType::float32, master.type); + EXPECT_NEAR (0.8, master.defaultValue, 1e-6); + EXPECT_NEAR (0.0, master.minValue, 1e-6); + EXPECT_NEAR (1.0, master.maxValue, 1e-6); + + EXPECT_EQ (1, graph.getParameterSlot ("v.cutoff")); + + const auto cutoff = graph.getParameterInfo (1); + EXPECT_EQ ("v.cutoff", cutoff.name); + EXPECT_EQ ("Cutoff", cutoff.displayName); + EXPECT_NEAR (1500.0, cutoff.defaultValue, 1e-6); + EXPECT_NEAR (60.0, cutoff.minValue, 1e-6); + EXPECT_NEAR (12000.0, cutoff.maxValue, 1e-6); + + const auto decay = graph.getParameterInfo (2); + EXPECT_EQ ("v.decay", decay.name); + EXPECT_EQ ("decay", decay.displayName); + EXPECT_NEAR (0.25, decay.defaultValue, 1e-6); + EXPECT_NEAR (0.0, decay.minValue, 1e-6); + EXPECT_NEAR (1.0, decay.maxValue, 1e-6); + + EXPECT_TRUE (graph.getParameterInfo (99).name.isEmpty()); + + auto aliased = compilePatch (R"YDSP( + processor Voice { + output stream out; + input parameter float drive = 1.0; + process { out = drive; } + } + graph G { + output stream y; + input parameter float master = 0.5; + node v = Voice; + connection { v.out -> y; master -> v.drive; } + } + )YDSP", + compiler); + + ASSERT_TRUE (aliased.isValid()); + ASSERT_EQ (1, aliased.getParameterCount()); + EXPECT_EQ ("master", aliased.getParameterInfo (0).name); + EXPECT_EQ (aliased.getParameterSlot ("master"), aliased.getParameterSlot ("v.drive")); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, ExposesUnitStepAndStyleForUi) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Voice { + output stream out; + input parameter float cutoff = 1500.0 [[ name: "Cutoff", min: 60.0, max: 12000.0, unit: "Hz", step: 10.0, style: "knob" ]]; + input parameter float decay = 0.25; + process { out = cutoff * decay; } + } + graph G { output stream y; node v = Voice; connection { v.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + + const auto cutoff = graph.getParameterInfo (graph.getParameterSlot ("v.cutoff")); + EXPECT_EQ ("Hz", cutoff.unit); + EXPECT_NEAR (10.0, cutoff.stepSize, 1e-6); + EXPECT_EQ ("knob", cutoff.style); + + const auto decay = graph.getParameterInfo (graph.getParameterSlot ("v.decay")); + EXPECT_TRUE (decay.unit.isEmpty()); + EXPECT_NEAR (0.0, decay.stepSize, 1e-6); + EXPECT_TRUE (decay.style.isEmpty()); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, ExposesMidAndBipolarForUi) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Voice { + output stream out; + input parameter float cutoff = 1500.0 [[ name: "Cutoff", min: 60.0, max: 12000.0, mid: 632.46, bipolar: true ]]; + input parameter float decay = 0.25; + process { out = cutoff * decay; } + } + graph G { + output stream y; + input parameter float master = 0.5 [[ mid: 0.25, bipolar: true ]]; + node v = Voice; + connection { v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + ASSERT_EQ (3, graph.getParameterCount()); + + const auto master = graph.getParameterInfo (0); + ASSERT_TRUE (master.midValue.has_value()); + EXPECT_NEAR (0.25, *master.midValue, 1e-6); + EXPECT_TRUE (master.bipolar); + + const auto cutoff = graph.getParameterInfo (graph.getParameterSlot ("v.cutoff")); + ASSERT_TRUE (cutoff.midValue.has_value()); + EXPECT_NEAR (632.46, *cutoff.midValue, 1e-6); + EXPECT_TRUE (cutoff.bipolar); + + const auto decay = graph.getParameterInfo (graph.getParameterSlot ("v.decay")); + EXPECT_FALSE (decay.midValue.has_value()); + EXPECT_FALSE (decay.bipolar); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, ExposesDiscreteValuesForUi) + +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Voice { + output stream out; + input parameter float wave = 0.0 [[ name: "Waveform", min: 0.0, max: 3.0, values: { "Saw", "Square", "Triangle", "Pulse" } ]]; + process { out = wave; } + } + graph G { + output stream y; + node v = Voice; + connection { v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + ASSERT_EQ (1, graph.getParameterCount()); + + const auto info = graph.getParameterInfo (0); + ASSERT_EQ (4, info.discreteValues.size()); + EXPECT_EQ ("Saw", info.discreteValues[0]); + EXPECT_EQ ("Square", info.discreteValues[1]); + EXPECT_EQ ("Triangle", info.discreteValues[2]); + EXPECT_EQ ("Pulse", info.discreteValues[3]); + EXPECT_TRUE (info.isDiscrete()); + + EXPECT_NEAR (0.75, (info.maxValue - info.minValue) / 4.0, 1e-9); + EXPECT_EQ ("Saw", info.labelForValue (0.0)); + EXPECT_EQ ("Triangle", info.labelForValue (2.1)); + EXPECT_EQ ("Pulse", info.labelForValue (3.0)); + EXPECT_EQ ("Saw", info.labelForValue (-5.0)); // clamps below the range + EXPECT_EQ ("Pulse", info.labelForValue (99.0)); // clamps above the range + + auto continuous = compilePatch (R"YDSP( + processor Voice { + output stream out; + input parameter float cutoff = 1500.0 [[ name: "Cutoff", min: 60.0, max: 12000.0 ]]; + process { out = cutoff; } + } + graph G { + output stream y; + node v = Voice; + connection { v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (continuous.isValid()); + + const auto plain = continuous.getParameterInfo (0); + EXPECT_TRUE (plain.discreteValues.isEmpty()); + EXPECT_FALSE (plain.isDiscrete()); + EXPECT_TRUE (plain.labelForValue (100.0).isEmpty()); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RejectsMalformedDiscreteValuesAnnotation) +{ + YdspCompiler compiler; + + auto tooFew = compiler.compile (R"YDSP( + processor Voice { + output stream out; + input parameter float wave = 0.0 [[ values: { "Only" } ]]; + process { out = wave; } + } + graph G { + output stream y; + node v = Voice; + connection { v.out -> y; } + } + )YDSP"); + + EXPECT_TRUE (tooFew.failed()); + EXPECT_TRUE (compiler.getDiagnostics().hasErrors()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ("[[ values ]]")); + + auto tooFewOnGraph = compiler.compile (R"YDSP( + processor Voice { + output stream out; + input parameter float wave = 0.0; + process { out = wave; } + } + graph G { + output stream y; + input parameter float w = 0.0 [[ values: { "Only" } ]]; + node v = Voice; + connection { v.out -> y; w -> v.wave; } + } + )YDSP"); + + EXPECT_TRUE (tooFewOnGraph.failed()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ("[[ values ]]")); +} + +TEST (YdspJitGraphTests, ExposesDiscreteValuesOnAGraphParameterAliasedOntoNodes) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Band { + input stream in; + output stream out; + input parameter float vowel = 0.0 [[ name: "Vowel", min: 0.0, max: 2.0, + values: { "A", "E", "I" } ]]; + process { out = in * vowel; } + } + graph G { + input stream x; + output stream y; + + input parameter float vowel = 0.0 [[ name: "Vowel", min: 0.0, max: 2.0, + values: { "A", "E", "I" } ]]; + + node a = Band; + node b = Band; + + connection { + x -> a.in; + a.out -> b.in; + b.out -> y; + + vowel -> a.vowel; + vowel -> b.vowel; + } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + + ASSERT_EQ (1, graph.getParameterCount()); + + const auto& info = graph.getParameterInfo (0); + + EXPECT_EQ ("vowel", info.name); + EXPECT_TRUE (info.isDiscrete()); + ASSERT_EQ (3, info.discreteValues.size()); + EXPECT_EQ ("A", info.discreteValues[0]); + EXPECT_EQ ("E", info.discreteValues[1]); + EXPECT_EQ ("I", info.discreteValues[2]); + + EXPECT_EQ ("A", info.labelForValue (0.0)); + EXPECT_EQ ("I", info.labelForValue (2.0)); + + graph.setParameter ("vowel", 2.0f); + EXPECT_FLOAT_EQ (0.0f, graph.getParameter ("a.vowel")); + graph.prepare (44100.0, 1); + float input = 0.0f, output = 0.0f; + const float* inputPointers[] = { &input }; + float* outputPointers[] = { &output }; + runProcess32 (graph, inputPointers, outputPointers, 1); + EXPECT_FLOAT_EQ (2.0f, graph.getParameter ("a.vowel")); + EXPECT_FLOAT_EQ (2.0f, graph.getParameter ("b.vowel")); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, DroppedEventsAreCountedNotAllocated) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (activeVoiceSource) + activeVoiceGraph, compiler); + + ASSERT_TRUE (graph.isValid()); + + graph.prepare (44100.0, 64, 1, 1); + + std::vector output (64, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi = makeNoteBuffer ({ { 0, 60, 1.0f }, { 0, 64, 1.0f }, { 0, 67, 1.0f }, { 0, 71, 1.0f }, { 0, 74, 1.0f }, { 0, 76, 1.0f }, { 0, 79, 1.0f }, { 0, 81, 1.0f } }); + + const auto droppedBefore = graph.getDroppedEventCount(); + + runProcess (graph, nullptr, 0, outPtrs, 1, 64, &midi); + + EXPECT_GT (graph.getDroppedEventCount(), droppedBefore); +} + +TEST (YdspJitGraphTests, AutomatingANonFloat32ParameterRejectsTheRequest) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter int mode = 0; + process { out = in; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + const auto slot = graph.getParameterSlot ("p.mode"); + ASSERT_GE (slot, 0); + + std::vector input (32, 0.0f); + std::vector output (32, 0.0f); + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + const YdspAutomationEvent automation[] = { { slot, 0, 1.0f } }; + + const auto droppedBefore = graph.getDroppedEventCount(); + + const YdspInputBuffer inputBuffers[] { Span (inPtrs[0], 32) }; + YdspOutputBuffer outputBuffers[] { Span (outPtrs[0], 32) }; + EXPECT_EQ (YdspProcessResult::invalidAutomation, + graph.process (yup::YdspProcessRequest { inputBuffers, outputBuffers, 32, {}, yup::Span (automation, static_cast (1)) })); + + EXPECT_EQ (graph.getDroppedEventCount(), droppedBefore); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, OutputEventQueueAcceptsUpToReservedCapacityThenDrops) +{ + yup::YdspOutputEventQueue queue; + queue.entries.reserve (4); + + const auto capacity = queue.entries.capacity(); + + for (size_t i = 0; i < capacity; ++i) + { + queue.staging.pitch = 60.0f + static_cast (i); + ydspCommitOutputEvent (&queue, 1, static_cast (i) * 8, 0); + } + + ASSERT_EQ (capacity, queue.entries.size()); + EXPECT_EQ (capacity, queue.entries.capacity()); + EXPECT_EQ (0u, queue.droppedCount.load (std::memory_order_relaxed)); + + EXPECT_EQ (0, queue.entries[0].sampleOffset); + EXPECT_EQ (int64_t (1), queue.entries[0].shapeTag); + EXPECT_FLOAT_EQ (60.0f, queue.entries[0].fields.pitch); + + const auto lastIndex = capacity - 1; + EXPECT_EQ (static_cast (lastIndex) * 8, queue.entries[lastIndex].sampleOffset); + EXPECT_FLOAT_EQ (60.0f + static_cast (lastIndex), queue.entries[lastIndex].fields.pitch); + + queue.staging.pitch = 99.0f; + ydspCommitOutputEvent (&queue, 1, static_cast (capacity) * 8, 0); + + EXPECT_EQ (capacity, queue.entries.size()); + EXPECT_EQ (capacity, queue.entries.capacity()); + EXPECT_EQ (1u, queue.droppedCount.load (std::memory_order_relaxed)); + EXPECT_FLOAT_EQ (60.0f + static_cast (lastIndex), queue.entries[lastIndex].fields.pitch); +} + +//============================================================================== +// MIDI expression, MPE, voice modes and channel-mode messages + +namespace +{ + +constexpr const char* expressiveVoiceSource = R"YDSP( + processor ExpressiveVoice { + output stream out; + input event midi; + state float active; + state float bend; + state float press; + state float timbre; + event midi (e: noteOn) { active = 1.0; } + event midi (e: noteOff) { active = 0.0; } + event midi (e: pitchBend) { bend = e.bendSemitones; } + event midi (e: pressure) { press = e.pressure; } + event midi (e: slide) { timbre = e.slide; } + process { out = active * (bend + press + timbre); } + } +)YDSP"; + +constexpr const char* controlVoiceSource = R"YDSP( + processor ControlVoice { + output stream out; + input event midi; + state float active; + state float cc; + state float ccValue; + state float program; + event midi (e: noteOn) { active = 1.0; } + event midi (e: noteOff) { active = 0.0; } + event midi (e: controlChange) { cc = float (e.control); ccValue = e.value; } + event midi (e: programChange) { program = float (e.program); } + process { out = active * (cc + ccValue + program); } + } +)YDSP"; + +constexpr const char* monoVoiceSource = R"YDSP( + processor MonoVoice { + output stream out; + input event midi; + state float sounding; + event midi (e: noteOn) { + sounding = e.pitch; + if (e.isLegato) { sounding = sounding + 1000.0; } + } + event midi (e: noteOff) { sounding = 0.0; } + process { out = sounding; } + } +)YDSP"; + +} // namespace + +TEST (YdspJitGraphTests, ControlChangeReachesEveryVoiceAtItsSampleOffset) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (controlVoiceSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node v = ControlVoice; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 256); + + std::vector output (256, 0.0f); + float* outPtrs[] = { output.data() }; + + yup::MidiBuffer midi; + midi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 0); + midi.addEvent (yup::MidiMessage::controllerEvent (1, 1, 127), 100); + + runProcess (graph, nullptr, 0, outPtrs, 1, 256, &midi); + + for (int i = 0; i < 100; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "before CC at " << i; + + for (int i = 100; i < 256; ++i) + EXPECT_FLOAT_EQ (2.0f, output[static_cast (i)]) << "after CC at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, ProgramChangeReachesTheHandler) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (controlVoiceSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node v = ControlVoice; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector output (64, 0.0f); + float* outPtrs[] = { output.data() }; + + yup::MidiBuffer midi; + midi.addEvent (yup::MidiMessage::programChange (1, 7), 0); + midi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 0); + + runProcess (graph, nullptr, 0, outPtrs, 1, 64, &midi); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (7.0f, output[static_cast (i)]) << "at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, ChannelPitchBendAndPressureBroadcastInLegacyMode) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (expressiveVoiceSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node v = ExpressiveVoice[2]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.setLegacyMidiMode (12); // a full-octave bend range makes the maths exact + graph.prepare (44100.0, 256); + + std::vector output (256, 0.0f); + float* outPtrs[] = { output.data() }; + + yup::MidiBuffer midi; + midi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 0); + midi.addEvent (yup::MidiMessage::noteOn (1, 64, static_cast (100)), 0); + midi.addEvent (yup::MidiMessage::pitchWheel (1, 16383), 100); + midi.addEvent (yup::MidiMessage::channelPressureChange (1, 127), 150); + + runProcess (graph, nullptr, 0, outPtrs, 1, 256, &midi); + + for (int i = 0; i < 100; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "before bend at " << i; + + for (int i = 100; i < 150; ++i) + EXPECT_NEAR (24.0f, output[static_cast (i)], 1e-3f) << "bend at " << i; + + for (int i = 150; i < 256; ++i) + EXPECT_NEAR (26.0f, output[static_cast (i)], 1e-3f) << "pressure at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, PolyAftertouchFoldsIntoTheAffectedNotesPressure) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (expressiveVoiceSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node v = ExpressiveVoice[2]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 256); + + std::vector output (256, 0.0f); + float* outPtrs[] = { output.data() }; + + yup::MidiBuffer midi; + midi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 0); + midi.addEvent (yup::MidiMessage::noteOn (1, 64, static_cast (100)), 0); + midi.addEvent (yup::MidiMessage::aftertouchChange (1, 60, 127), 100); + + runProcess (graph, nullptr, 0, outPtrs, 1, 256, &midi); + + for (int i = 0; i < 100; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "before aftertouch at " << i; + + for (int i = 100; i < 256; ++i) + EXPECT_NEAR (1.0f, output[static_cast (i)], 1e-3f) << "after aftertouch at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, PerNoteMpeExpressionReachesOnlyTheOwningVoice) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (expressiveVoiceSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node v = ExpressiveVoice[4]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + + yup::MPEZoneLayout layout; + layout.setLowerZone (15, 12, 2); // 12-semitone per-note bend range + graph.setMpeZoneLayout (layout); + + graph.prepare (44100.0, 256); + + std::vector output (256, 0.0f); + float* outPtrs[] = { output.data() }; + + yup::MidiBuffer midi; + midi.addEvent (yup::MidiMessage::noteOn (2, 60, static_cast (100)), 0); + midi.addEvent (yup::MidiMessage::noteOn (3, 64, static_cast (100)), 0); + midi.addEvent (yup::MidiMessage::pitchWheel (2, 16383), 100); // member channel 2 only + midi.addEvent (yup::MidiMessage::controllerEvent (3, 74, 127), 150); // slide on channel 3 only + + runProcess (graph, nullptr, 0, outPtrs, 1, 256, &midi); + + for (int i = 0; i < 100; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "before expression at " << i; + + for (int i = 100; i < 150; ++i) + EXPECT_NEAR (12.0f, output[static_cast (i)], 1e-3f) << "per-note bend at " << i; + + for (int i = 150; i < 256; ++i) + EXPECT_NEAR (13.0f, output[static_cast (i)], 1e-3f) << "per-note slide at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, ExpressionForAnUnownedNoteIsDiscardedAndCounted) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (expressiveVoiceSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node v = ExpressiveVoice[1] [[ stealing: none ]]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + + yup::MPEZoneLayout layout; + layout.setLowerZone (15, 12, 2); + graph.setMpeZoneLayout (layout); + + graph.prepare (44100.0, 256); + + std::vector output (256, 0.0f); + float* outPtrs[] = { output.data() }; + + yup::MidiBuffer midi; + midi.addEvent (yup::MidiMessage::noteOn (2, 60, static_cast (100)), 0); + midi.addEvent (yup::MidiMessage::noteOn (3, 64, static_cast (100)), 0); + midi.addEvent (yup::MidiMessage::pitchWheel (3, 16383), 100); + + const auto droppedBefore = graph.getDroppedEventCount(); + + runProcess (graph, nullptr, 0, outPtrs, 1, 256, &midi); + + for (int i = 0; i < 256; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "at " << i; + + EXPECT_GT (graph.getDroppedEventCount(), droppedBefore); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, StealingPolicySelectsWhichVoiceIsReused) +{ + struct Policy + { + const char* annotation; + float afterFirstNoteOff; + float afterSecondNoteOff; + }; + + for (const auto& policy : { Policy { "stealing: oldest", 2.0f, 1.0f }, + Policy { "stealing: newest", 1.0f, 1.0f }, + Policy { "stealing: none", 1.0f, 0.0f } }) + { + YdspCompiler compiler; + + auto graph = compilePatch (std::string (activeVoiceSource) + "graph G { input event midi; output stream y; node v = ActiveVoice[2] [[ " + policy.annotation + " ]]; connection { midi -> v.midi; v.out -> y; } }", compiler); + + ASSERT_TRUE (graph.isValid()) << policy.annotation; + graph.prepare (44100.0, 64); + + std::vector output (64, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi = makeNoteBuffer ({ { 0, 60, 1.0f }, { 0, 64, 1.0f }, { 10, 67, 1.0f }, { 20, 60, 0.0f }, { 40, 64, 0.0f } }); + + runProcess (graph, nullptr, 0, outPtrs, 1, 64, &midi); + + for (int i = 10; i < 20; ++i) + EXPECT_FLOAT_EQ (2.0f, output[static_cast (i)]) << policy.annotation << " after the third note-on at " << i; + + for (int i = 20; i < 40; ++i) + EXPECT_FLOAT_EQ (policy.afterFirstNoteOff, output[static_cast (i)]) << policy.annotation << " after note-off 60 at " << i; + + for (int i = 40; i < 64; ++i) + EXPECT_FLOAT_EQ (policy.afterSecondNoteOff, output[static_cast (i)]) << policy.annotation << " after note-off 64 at " << i; + } +} + +//============================================================================== + +namespace +{ + +// A graph whose two members (a per-voice pitch source and a gain) must stay +// together under a voice bank: instantiating it with [N] makes the runtime +// replicate the *chain* per voice, not just the source. +constexpr const char* pitchChainGraph = R"YDSP( + processor PitchVoice { + output stream out; + input event midi; + state float s; + event midi (e: noteOn) { s = e.pitch; } + process { out = s; } + } + processor Amp { + input stream in; + output stream out; + input parameter float g = 1.0; + process { out = in * g; } + } + graph PitchChain { + input event midi; + output stream out; + node osc = PitchVoice; + node amp = Amp (g = 2.0); + connection { midi -> osc.midi; osc.out -> amp.in; amp.out -> out; } + } +)YDSP"; + +constexpr const char* delayedPitchChainGraph = R"YDSP( + processor PitchVoice { + output stream out; + input event midi; + state float s; + event midi (e: noteOn) { s = e.pitch; } + process { out = s; } + } + processor Amp { + input stream in; + output stream out; + input parameter float g = 1.0; + process { out = in * g; } + } + graph DelayedPitchChain { + input event midi; + output stream out; + node osc = PitchVoice; + node amp = Amp (g = 2.0); + connection { midi -> osc.midi; osc.out -> [10] -> amp.in; amp.out -> out; } + } +)YDSP"; + +std::vector renderOneBlock (YdspAudioGraph& graph, const yup::MidiBuffer* midi, int blockSize) +{ + std::vector output (static_cast (blockSize), 0.0f); + float* outPtrs[] = { output.data() }; + + runProcess (graph, nullptr, 0, outPtrs, 1, blockSize, midi); + + return output; +} + +} // namespace + +TEST (YdspJitGraphTests, VoiceBankedSubgraphRunsEachVoiceThroughTheWholeChain) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (pitchChainGraph) + R"YDSP( + graph G [[ main ]] { + input event midi; + output stream y; + node v = PitchChain[2]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + // Two notes held across the whole block: each voice carries its own pitch + // through the chain, so the output is 2 * (60 + 64) at every sample, and + // it is bit-identical to the sum of two single-note renders. Each render + // must start from a clean voice state (notes never get released here), so + // the graph is reset between blocks. + const auto bothMidi = makeNoteBuffer ({ { 0, 60, 1.0f }, { 0, 64, 1.0f } }); + const auto both = renderOneBlock (graph, &bothMidi, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (248.0f, both[static_cast (i)]) << "at " << i; + + graph.reset(); + + const auto firstMidi = makeNoteBuffer ({ { 0, 60, 1.0f } }); + const auto firstOnly = renderOneBlock (graph, &firstMidi, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (120.0f, firstOnly[static_cast (i)]) << "single note 60 at " << i; + + graph.reset(); + + const auto secondMidi = makeNoteBuffer ({ { 0, 64, 1.0f } }); + const auto secondOnly = renderOneBlock (graph, &secondMidi, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (128.0f, secondOnly[static_cast (i)]) << "single note 64 at " << i; + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (firstOnly[static_cast (i)] + secondOnly[static_cast (i)], both[static_cast (i)]) << "at " << i; +} + +TEST (YdspJitGraphTests, VoiceBankedSubgraphKeepsEachVoiceDelayRingIndependent) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (delayedPitchChainGraph) + R"YDSP( + graph G [[ main ]] { + input event midi; + output stream y; + node v = DelayedPitchChain[2]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + // The intra-chain `-> [10] ->` delay is per voice: the first voice starts + // its pitch at sample 0 (delayed output from sample 10) and the second + // voice starts its own pitch at sample 10 (delayed output from sample 20). + // A shared ring would leak the first voice's 60 into the second, so the + // exact 64, not 60, from sample 20 onward is the proof. + const auto staggeredMidi = makeNoteBuffer ({ { 0, 60, 1.0f }, { 10, 64, 1.0f } }); + const auto output = renderOneBlock (graph, &staggeredMidi, 64); + + for (int i = 0; i < 10; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "before the first delayed voice at " << i; + + for (int i = 10; i < 20; ++i) + EXPECT_FLOAT_EQ (120.0f, output[static_cast (i)]) << "first delayed voice only at " << i; + + for (int i = 20; i < 64; ++i) + EXPECT_FLOAT_EQ (248.0f, output[static_cast (i)]) << "both delayed voices at " << i; +} + +TEST (YdspJitGraphTests, VoiceBankedSubgraphAtOneVoiceMatchesTheInlineChain) +{ + YdspCompiler compiler; + + auto banked = compilePatch (R"YDSP( + processor Amp { + input stream in; + output stream out; + input parameter float g = 1.0; + process { out = in * g; } + } + graph Chain { + input stream x; + output stream y; + node a = Amp (g = 2.0); + node b = Amp (g = 3.0); + connection { x -> a.in; a.out -> b.in; b.out -> y; } + } + graph G [[ main ]] { + input stream x; + output stream y; + node c = Chain[1]; + connection { x -> c.x; c.y -> y; } + } + )YDSP", + compiler); + + auto flat = compilePatch (R"YDSP( + processor Amp { + input stream in; + output stream out; + input parameter float g = 1.0; + process { out = in * g; } + } + graph G { + input stream x; + output stream y; + node a = Amp (g = 2.0); + node b = Amp (g = 3.0); + connection { x -> a.in; a.out -> b.in; b.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (banked.isValid()); + ASSERT_TRUE (flat.isValid()); + + banked.prepare (44100.0, 8); + flat.prepare (44100.0, 8); + + const float inputSamples[] = { 1.0f, -0.5f, 0.25f, 0.0f, 2.0f, -3.0f, 4.0f, 1.5f }; + const float* inPtrs[] = { inputSamples }; + + // The banked-at-one-voice graph is the same pipeline as the inline chain + // and must produce bit-identical output. + std::vector bankedWithInput (8), flatWithInput (8); + float* bankedPtrs[] = { bankedWithInput.data() }; + float* flatPtrs[] = { flatWithInput.data() }; + + runProcess (banked, inPtrs, 1, bankedPtrs, 1, 8, nullptr); + runProcess (flat, inPtrs, 1, flatPtrs, 1, 8, nullptr); + + for (int i = 0; i < 8; ++i) + EXPECT_FLOAT_EQ (flatWithInput[static_cast (i)], bankedWithInput[static_cast (i)]) << "at " << i; +} + +//============================================================================== + +namespace +{ + +// Two pitch followers (same processor type, independent state) plus a product +// node. When the graph is banked, each host note must drive BOTH followers on +// the SAME voice: the per-voice contribution is pitch * pitch, so any voice +// mis-pairing between the two subscribers shows up as a different total. +constexpr const char* voicePairFixture = R"YDSP( + processor PitchVoice { + output stream out; + input event midi; + state float s; + event midi (e: noteOn) { s = e.pitch; } + process { out = s; } + } + processor Mul { + input stream a; + input stream b; + output stream out; + process { out = a * b; } + } + graph VoicePair { + input event midi; + output stream out; + node p = PitchVoice; + node l = PitchVoice; + node m = Mul; + connection { midi -> p.midi; midi -> l.midi; p.out -> m.a; l.out -> m.b; m.out -> out; } + } +)YDSP"; + +} // namespace + +TEST (YdspJitGraphTests, VoiceBankedSubgraphDeliversEachNoteToEveryMemberOnTheSameVoice) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (voicePairFixture) + R"YDSP( + graph G [[ main ]] { + input event midi; + output stream y; + node v = VoicePair[4] [[ stealing: oldest ]]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + // Four notes fill the bank, the fifth steals voice 0. With one group-level + // allocation per note, voice 0 ends on pitch 64 and voices 1-3 keep 61-63, + // so every sample equals 64^2 + 61^2 + 62^2 + 63^2 = 15630. + const auto midi = makeNoteBuffer ({ { 0, 60, 1.0f }, { 0, 61, 1.0f }, { 0, 62, 1.0f }, { 0, 63, 1.0f }, { 0, 64, 1.0f } }); + const auto output = renderOneBlock (graph, &midi, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (15630.0f, output[static_cast (i)]) << "at " << i; + + // Both members of the bank agree on how many voices are held. + EXPECT_EQ (4, graph.getActiveVoiceCount ("v")); +} + +TEST (YdspJitGraphTests, VoiceBankedSubgraphAllSoundOffSilencesEveryMember) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (voicePairFixture) + R"YDSP( + graph G [[ main ]] { + input event midi; + output stream y; + node v = VoicePair[2]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + EXPECT_EQ (0, graph.getActiveVoiceCount ("v")); + + yup::MidiBuffer midi; + midi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (127)), 0); + midi.addEvent (yup::MidiMessage::controllerEvent (1, 120, 0), 10); + + const auto output = renderOneBlock (graph, &midi, 64); + + for (int i = 0; i < 10; ++i) + EXPECT_FLOAT_EQ (3600.0f, output[static_cast (i)]) << "before the all-sound-off at " << i; + + for (int i = 10; i < 64; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "after the all-sound-off at " << i; + + // The shared slot table was cleared, so no voice reports active. + EXPECT_EQ (0, graph.getActiveVoiceCount ("v")); +} + +TEST (YdspJitGraphTests, PerVoiceChainEffectDiffersFromThePostMixEffect) +{ + YdspCompiler compiler; + + // Two simultaneous notes through a chain whose effect is per voice: each + // note goes through its own SoftClip, so the output is clip(60)+clip(64) + // and equals the sum of two single-note renders. The same notes through + // the post-mix layout (one clip after the summed bank) produce + // clip(60+64), which is numerically different - that difference is the + // whole point of per-voice effect chains. + auto perVoice = compilePatch (std::string (patches::voiceChainPV) + R"YDSP( + graph G [[ main ]] { + input event midi; + output stream y; + node v = VoiceChain[2]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + auto postMix = compilePatch (std::string (patches::voiceChainPV) + R"YDSP( + graph G [[ main ]] { + input event midi; + output stream y; + node voices = PitchVoice[2]; + node clip = SoftClip; + connection { midi -> voices.midi; voices.out -> clip.in; clip.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (perVoice.isValid()); + ASSERT_TRUE (postMix.isValid()); + + perVoice.prepare (44100.0, 64); + postMix.prepare (44100.0, 64); + + const auto chordMidi = makeNoteBuffer ({ { 0, 60, 1.0f }, { 0, 64, 1.0f } }); + const auto chordMidiA = makeNoteBuffer ({ { 0, 60, 1.0f } }); + const auto chordMidiB = makeNoteBuffer ({ { 0, 64, 1.0f } }); + + const auto perVoiceOut = renderOneBlock (perVoice, &chordMidi, 64); + const auto postMixOut = renderOneBlock (postMix, &chordMidi, 64); + + perVoice.reset(); + const auto perVoiceA = renderOneBlock (perVoice, &chordMidiA, 64); + perVoice.reset(); + const auto perVoiceB = renderOneBlock (perVoice, &chordMidiB, 64); + + // clip(x) = x + 0.01 * x^2: clip(60) + clip(64) = 96 + 104.96 = 200.96. + for (int i = 0; i < 64; ++i) + { + EXPECT_NEAR (200.96f, perVoiceOut[static_cast (i)], 1e-3f) << "per-voice chain at " << i; + EXPECT_FLOAT_EQ (perVoiceA[static_cast (i)] + perVoiceB[static_cast (i)], perVoiceOut[static_cast (i)]) << "independence at " << i; + EXPECT_NEAR (277.76f, postMixOut[static_cast (i)], 1e-3f) << "post-mix effect at " << i; + EXPECT_GT (postMixOut[static_cast (i)], perVoiceOut[static_cast (i)]) << "numerically different at " << i; + } +} + +TEST (YdspJitGraphTests, VoiceBankedSubgraphFusesAdjacentEffectMembers) +{ + YdspCompiler compiler; + + // Two adjacent plain members of one banked chain (gain 2 -> gain 3) are + // eligible for same-group fusion: the analyzer merges them into a single + // kernel that still runs once per voice. Output must be identical to the + // unfused chain - per voice the pitch is scaled by 2 then 3. + auto graph = compilePatch (R"YDSP( + processor PitchVoice { + output stream out; + input event midi; + state float s; + event midi (e: noteOn) { s = e.pitch; } + process { out = s; } + } + processor Amp { + input stream in; + output stream out; + input parameter float g = 1.0; + process { out = in * g; } + } + graph AmpChain { + input event midi; + output stream out; + node osc = PitchVoice; + node a = Amp (g = 2.0); + node b = Amp (g = 3.0); + connection { midi -> osc.midi; osc.out -> a.in; a.out -> b.in; b.out -> out; } + } + graph G [[ main ]] { + input event midi; + output stream y; + node v = AmpChain[2]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + const auto bothMidi = makeNoteBuffer ({ { 0, 60, 1.0f }, { 0, 64, 1.0f } }); + const auto both = renderOneBlock (graph, &bothMidi, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (744.0f, both[static_cast (i)]) << "60*6 + 64*6 at " << i; + + graph.reset(); + + const auto firstMidi = makeNoteBuffer ({ { 0, 60, 1.0f } }); + const auto firstOnly = renderOneBlock (graph, &firstMidi, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (360.0f, firstOnly[static_cast (i)]) << "single note 60 at " << i; + + graph.reset(); + + const auto secondMidi = makeNoteBuffer ({ { 0, 64, 1.0f } }); + const auto secondOnly = renderOneBlock (graph, &secondMidi, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (384.0f, secondOnly[static_cast (i)]) << "single note 64 at " << i; + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (firstOnly[static_cast (i)] + secondOnly[static_cast (i)], both[static_cast (i)]) << "at " << i; +} + +TEST (YdspJitGraphTests, VoiceBankedSubgraphSkipsVoicesWhoseWholeChainIsAsleep) +{ + YdspCompiler compiler; + + // Both members of the chain carry a voiceActivity flag - the oscillator + // (event-driven, as always) and a pure pass-through effect, which is now + // allowed to declare the flag too. A voice runs only while any member is + // awake or held, so after release and tail silence the whole voice is + // skipped instead of the effect running every block for every voice. + auto graph = compilePatch (R"YDSP( + processor FlagVoice { + output stream out; + input event midi; + state float s; + state int active [[ role: voiceActivity ]]; + event midi (e: noteOn) { s = e.pitch; active = 1; } + event midi (e: noteOff) { s = 0.0; active = 0; } + process { out = s; } + } + processor PassFlag { + input stream in; + output stream out; + state int active [[ role: voiceActivity ]]; + process { + out = in; + active = select (in * in < 0.000001, 0, 1); + } + } + graph FlagChain { + input event midi; + output stream out; + node osc = FlagVoice; + node pass = PassFlag; + connection { midi -> osc.midi; osc.out -> pass.in; pass.out -> out; } + } + graph G [[ main ]] { + input event midi; + output stream y; + node v = FlagChain[2]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + EXPECT_EQ (0, graph.getActiveVoiceCount ("v")); + + const auto noteOnMidi = makeNoteBuffer ({ { 0, 60, 1.0f } }); + const auto held = renderOneBlock (graph, ¬eOnMidi, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (60.0f, held[static_cast (i)]) << "held note at " << i; + + EXPECT_EQ (1, graph.getActiveVoiceCount ("v")); + + // A held voice must keep running even though no event arrives this block. + const auto stillHeld = renderOneBlock (graph, nullptr, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (60.0f, stillHeld[static_cast (i)]) << "still held at " << i; + + yup::MidiBuffer noteOff; + noteOff.addEvent (yup::MidiMessage::noteOff (1, 60), 0); + + const auto released = renderOneBlock (graph, ¬eOff, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (0.0f, released[static_cast (i)]) << "after release at " << i; + + EXPECT_EQ (0, graph.getActiveVoiceCount ("v")); + + // The whole chain is now asleep, so the voice is skipped entirely; output + // stays silent. + const auto asleep = renderOneBlock (graph, nullptr, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (0.0f, asleep[static_cast (i)]) << "asleep at " << i; +} + +TEST (YdspJitGraphTests, MonoNodeFollowsTheHeldNoteStackWithLegato) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (monoVoiceSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node bass = MonoVoice [[ mode: mono, priority: last ]]; + connection { midi -> bass.midi; bass.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + std::vector output (512, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi = makeNoteBuffer ({ { 0, 40, 1.0f }, { 100, 47, 1.0f }, { 200, 52, 1.0f }, { 300, 52, 0.0f }, { 400, 47, 0.0f } }); + + runProcess (graph, nullptr, 0, outPtrs, 1, 512, &midi); + + for (int i = 0; i < 100; ++i) + EXPECT_FLOAT_EQ (40.0f, output[static_cast (i)]) << "first note at " << i; + + for (int i = 100; i < 200; ++i) + EXPECT_FLOAT_EQ (1047.0f, output[static_cast (i)]) << "legato to 47 at " << i; + + for (int i = 200; i < 300; ++i) + EXPECT_FLOAT_EQ (1052.0f, output[static_cast (i)]) << "legato to 52 at " << i; + + for (int i = 300; i < 400; ++i) + EXPECT_FLOAT_EQ (1047.0f, output[static_cast (i)]) << "fall back to 47 at " << i; + + for (int i = 400; i < 512; ++i) + EXPECT_FLOAT_EQ (1040.0f, output[static_cast (i)]) << "fall back to 40 at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, MonoNotePriorityChoosesTheSoundingNote) +{ + for (const auto& [priority, expected] : { std::pair { "low", 40.0f }, + std::pair { "high", 1052.0f } }) + { + YdspCompiler compiler; + + auto graph = compilePatch (std::string (monoVoiceSource) + "graph G { input event midi; output stream y; node bass = MonoVoice [[ mode: mono, priority: " + priority + " ]]; connection { midi -> bass.midi; bass.out -> y; } }", compiler); + + ASSERT_TRUE (graph.isValid()) << priority; + graph.prepare (44100.0, 256); + + std::vector output (256, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi = makeNoteBuffer ({ { 0, 40, 1.0f }, { 100, 52, 1.0f } }); + + runProcess (graph, nullptr, 0, outPtrs, 1, 256, &midi); + + for (int i = 0; i < 100; ++i) + EXPECT_FLOAT_EQ (40.0f, output[static_cast (i)]) << priority << " at " << i; + + for (int i = 100; i < 256; ++i) + EXPECT_FLOAT_EQ (expected, output[static_cast (i)]) << priority << " at " << i; + } +} + +TEST (YdspJitGraphTests, SustainPedalHoldsAReleasedNoteWithoutYdspSustainState) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (activeVoiceSource) + activeVoiceGraph, compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + std::vector output (512, 0.0f); + float* outPtrs[] = { output.data() }; + + yup::MidiBuffer midi; + midi.addEvent (yup::MidiMessage::controllerEvent (1, 64, 127), 0); // sustain down + midi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 10); + midi.addEvent (yup::MidiMessage::noteOff (1, 60), 100); // withheld by the pedal + midi.addEvent (yup::MidiMessage::controllerEvent (1, 64, 0), 300); // sustain up + + runProcess (graph, nullptr, 0, outPtrs, 1, 512, &midi); + + for (int i = 10; i < 300; ++i) + EXPECT_FLOAT_EQ (1.0f, output[static_cast (i)]) << "sustained at " << i; + + for (int i = 300; i < 512; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "released at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, SostenutoPedalHoldsOnlyTheNotesHeldWhenItWentDown) +{ + YdspCompiler compiler; + + auto graph = compilePatch (std::string (activeVoiceSource) + activeVoiceGraph, compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + std::vector output (512, 0.0f); + float* outPtrs[] = { output.data() }; + + yup::MidiBuffer midi; + midi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 0); + midi.addEvent (yup::MidiMessage::controllerEvent (1, 66, 127), 50); // sostenuto captures note 60 + midi.addEvent (yup::MidiMessage::noteOn (1, 64, static_cast (100)), 100); + midi.addEvent (yup::MidiMessage::noteOff (1, 64), 200); // not captured: released now + midi.addEvent (yup::MidiMessage::noteOff (1, 60), 250); // captured: held + midi.addEvent (yup::MidiMessage::controllerEvent (1, 66, 0), 400); // sostenuto up + + runProcess (graph, nullptr, 0, outPtrs, 1, 512, &midi); + + for (int i = 100; i < 200; ++i) + EXPECT_FLOAT_EQ (2.0f, output[static_cast (i)]) << "both held at " << i; + + for (int i = 250; i < 400; ++i) + EXPECT_FLOAT_EQ (1.0f, output[static_cast (i)]) << "only the captured note at " << i; + + for (int i = 400; i < 512; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "released at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, AllNotesOffAndResetAllControllersReleaseWithoutZeroingState) +{ + for (const int controller : { 123, 121 }) + { + YdspCompiler compiler; + + auto graph = compilePatch (std::string (activeVoiceSource) + activeVoiceGraph, compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + std::vector output (512, 0.0f); + float* outPtrs[] = { output.data() }; + + yup::MidiBuffer midi; + midi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 0); + midi.addEvent (yup::MidiMessage::noteOn (1, 64, static_cast (100)), 0); + midi.addEvent (yup::MidiMessage::controllerEvent (1, controller, 0), 100); + + runProcess (graph, nullptr, 0, outPtrs, 1, 512, &midi); + + for (int i = 0; i < 100; ++i) + EXPECT_FLOAT_EQ (2.0f, output[static_cast (i)]) << "CC" << controller << " before at " << i; + + for (int i = 100; i < 512; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "CC" << controller << " after at " << i; + } +} + +TEST (YdspJitGraphTests, AllSoundOffSilencesAndReRunsInit) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor InitVoice { + output stream out; + input event midi; + state float active; + state float seed; + init { seed = 0.25; } + event midi (e: noteOn) { active = 1.0; } + event midi (e: noteOff) { active = 2.0; } + process { out = active + seed; } + } + graph G { + input event midi; + output stream y; + node v = InitVoice; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + std::vector output (512, 0.0f); + float* outPtrs[] = { output.data() }; + + yup::MidiBuffer midi; + midi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 0); + midi.addEvent (yup::MidiMessage::controllerEvent (1, 120, 0), 200); + + runProcess (graph, nullptr, 0, outPtrs, 1, 512, &midi); + + for (int i = 0; i < 200; ++i) + EXPECT_FLOAT_EQ (1.25f, output[static_cast (i)]) << "before all-sound-off at " << i; + + for (int i = 200; i < 512; ++i) + EXPECT_FLOAT_EQ (0.25f, output[static_cast (i)]) << "after all-sound-off at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, DenseMpeTrafficDoesNotAllocateDuringProcess) +{ +#if ! YUP_ENABLE_ALLOCATION_HOOKS + GTEST_SKIP() << "Enable YUP_TEST_ALLOCATION_HOOKS to measure allocations"; +#else + YdspCompiler compiler; + + auto graph = compilePatch (std::string (expressiveVoiceSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node v = ExpressiveVoice[8]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + + yup::MPEZoneLayout layout; + layout.setLowerZone (15, 12, 2); + graph.setMpeZoneLayout (layout); + + graph.prepare (44100.0, 512, 128); + + std::vector output (512, 0.0f); + yup::MidiBuffer midi; + + for (int channel = 2; channel <= 9; ++channel) + midi.addEvent (yup::MidiMessage::noteOn (channel, 48 + channel, static_cast (100)), 0); + + for (int step = 0; step < 32; ++step) + { + const auto offset = 16 + step * 8; + + for (int channel = 2; channel <= 9; ++channel) + { + midi.addEvent (yup::MidiMessage::pitchWheel (channel, 8192 + step * 100), offset); + midi.addEvent (yup::MidiMessage::channelPressureChange (channel, step * 4), offset); + midi.addEvent (yup::MidiMessage::controllerEvent (channel, 74, step * 4), offset); + } + } + + YdspOutputBuffer outputs[] { Span (output.data(), output.size()) }; + const MidiBuffer* events[] { &midi }; + const YdspProcessRequest request { {}, outputs, 512, events }; + + graph.prewarmKernels(); + ASSERT_EQ (YdspProcessResult::ok, graph.process (request)); + + YdspAllocationCounter allocations; + bool succeeded = true; + allocations.start(); + + for (int block = 0; block < 8; ++block) + succeeded &= graph.process (request) == YdspProcessResult::ok; + + const auto count = allocations.stop(); + + EXPECT_TRUE (succeeded); + EXPECT_EQ (0u, count); + EXPECT_NE (0.0f, output[511]); + + dumpAsmOnFailure (graph); +#endif +} + +//============================================================================== +// Acceptance: a 4-voice polyphonic sine synth + +TEST (YdspJitGraphTests, PolySineCompilesAndRunsSampleAccurately) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + declare name "PolySine"; + + processor Voice { + output stream out; + + input parameter float decay = 0.25 [[ name: "Decay", min: 0.02, max: 4.0 ]]; + + input event midi; + + state float phase; + state float freq; + state float env; + state float envCoeff; + + func noteToFreq (pitch: float) : float { + return 440.0 * pow (2.0, (pitch - 69.0) / 12.0); + } + + event midi (e: noteOn) { + freq = noteToFreq (e.pitch); + env = e.velocity; + envCoeff = pow (0.001, 1.0 / (decay * sampleRate)); + } + + event midi (e: noteOff) { + envCoeff = pow (0.001, 1.0 / (0.05 * sampleRate)); + } + + process { + phase = phase + freq / sampleRate; + if (phase >= 1.0) { phase = phase - 1.0; } + env = env * envCoeff; + out = sin (phase * 6.283185307) * env; + } + } + + graph PolySine { + input event midi; + output stream out; + + node voices = Voice[4]; + + connection { + midi -> voices.midi; + voices.out -> out; + } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + ASSERT_FALSE (compiler.getDiagnostics().hasErrors()); + + EXPECT_TRUE (graph.getExecutionReport().isProvenRealtimeSafe()); + + constexpr int blockSize = 512; + graph.prepare (44100.0, blockSize); + + std::vector output (blockSize, 0.0f); + float* outPtrs[] = { output.data() }; + + yup::MidiBuffer midi; + midi.addEvent (yup::MidiMessage::noteOn (1, 69, static_cast (127)), 0); + + runProcess (graph, nullptr, 0, outPtrs, 1, blockSize, &midi); + + std::vector sustained; + + for (int b = 0; b < 20; ++b) + { + sustained.insert (sustained.end(), output.begin(), output.end()); + + runProcess (graph, nullptr, 0, outPtrs, 1, blockSize); + } + + int crossings = 0; + + for (size_t i = 1; i < sustained.size(); ++i) + if (sustained[i - 1] <= 0.0f && sustained[i] > 0.0f) + ++crossings; + + const auto seconds = static_cast (sustained.size()) / 44100.0; + + const auto measuredHz = static_cast (crossings) / seconds; + + EXPECT_NEAR (440.0, measuredHz, 15.0) << "measured " << measuredHz << " Hz"; + + float earlyEnergy = 0.0f; + float lateEnergy = 0.0f; + + for (int i = 0; i < 256; ++i) + { + earlyEnergy += output[static_cast (i)] * output[static_cast (i)]; + lateEnergy += output[static_cast (i + 256)] * output[static_cast (i + 256)]; + } + + EXPECT_LT (lateEnergy, earlyEnergy); + + graph.reset(); + + std::vector oneOutput (blockSize, 0.0f); + float* onePtrs[] = { oneOutput.data() }; + + yup::MidiBuffer oneMidi; + oneMidi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (127)), 0); + + runProcess (graph, nullptr, 0, onePtrs, 1, blockSize, &oneMidi); + + graph.reset(); + + std::vector fourOutput (blockSize, 0.0f); + float* fourPtrs[] = { fourOutput.data() }; + + yup::MidiBuffer fourMidi; + fourMidi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (127)), 0); + fourMidi.addEvent (yup::MidiMessage::noteOn (1, 64, static_cast (127)), 0); + fourMidi.addEvent (yup::MidiMessage::noteOn (1, 67, static_cast (127)), 0); + fourMidi.addEvent (yup::MidiMessage::noteOn (1, 71, static_cast (127)), 0); + + runProcess (graph, nullptr, 0, fourPtrs, 1, blockSize, &fourMidi); + + float fourEnergy = 0.0f; + float oneEnergy = 0.0f; + + for (int i = 0; i < blockSize; ++i) + { + oneEnergy += oneOutput[static_cast (i)] * oneOutput[static_cast (i)]; + fourEnergy += fourOutput[static_cast (i)] * fourOutput[static_cast (i)]; + } + + EXPECT_GT (fourEnergy, oneEnergy); + + graph.reset(); + + std::vector timed (blockSize, 0.0f); + float* timedPtrs[] = { timed.data() }; + + yup::MidiBuffer timedMidi; + timedMidi.addEvent (yup::MidiMessage::noteOn (1, 81, static_cast (127)), 100); + timedMidi.addEvent (yup::MidiMessage::noteOff (1, 81), 300); + + runProcess (graph, nullptr, 0, timedPtrs, 1, blockSize, &timedMidi); + + for (int i = 0; i < 100; ++i) + EXPECT_NEAR (0.0f, timed[static_cast (i)], 1e-6f) << "pre-attack at " << i; + + float attackPeak = 0.0f; + for (int i = 100; i < 300; ++i) + attackPeak = std::max (attackPeak, std::fabs (timed[static_cast (i)])); + + EXPECT_GT (attackPeak, 1e-3f); + + float attackEnergy = 0.0f; + float releaseEnergy = 0.0f; + + for (int i = 0; i < 200; ++i) + attackEnergy += timed[100 + static_cast (i)] * timed[100 + static_cast (i)]; + + for (int i = 0; i < 200; ++i) + releaseEnergy += timed[300 + static_cast (i)] * timed[300 + static_cast (i)]; + + EXPECT_LT (releaseEnergy, attackEnergy * 0.75f); + + const auto decaySlot = graph.getParameterSlot ("voices.decay"); + ASSERT_GE (decaySlot, 0); + + graph.reset(); + + std::vector fastOut (blockSize, 0.0f); + float* fastPtrs[] = { fastOut.data() }; + + yup::MidiBuffer lateMidi; + lateMidi.addEvent (yup::MidiMessage::noteOn (1, 69, static_cast (127)), 200); + + runProcess (graph, nullptr, 0, fastPtrs, 1, blockSize, &lateMidi); + + graph.reset(); + + std::vector slowOut (blockSize, 0.0f); + float* slowPtrs[] = { slowOut.data() }; + + YdspAutomationEvent decayAutomation { decaySlot, 100, 4.0f }; + + runProcess (graph, nullptr, 0, slowPtrs, 1, blockSize, &lateMidi, &decayAutomation, 1); + + float fastLate = 0.0f; + float slowLate = 0.0f; + + for (int i = 400; i < blockSize; ++i) + { + fastLate += fastOut[static_cast (i)] * fastOut[static_cast (i)]; + slowLate += slowOut[static_cast (i)] * slowOut[static_cast (i)]; + } + + EXPECT_GT (slowLate, fastLate * 1.2f); + + dumpAsmOnFailure (graph); +} + +//============================================================================== + +namespace +{ + +constexpr const char* expressiveSynthSource = R"YDSP( + declare name "ExpressiveSynth"; + + processor Lead { + output stream out; + + input event midi; + + state float phase; + state float pitch; + state float bend; + state float press; + state float timbre; + state float modWheel; + state float env; + state float envCoeff; + state float lp; + + func noteToFreq (p: float) : float { + return 440.0 * pow (2.0, (p - 69.0) / 12.0); + } + + init { + envCoeff = 0.9999; + } + + event midi (e: noteOn) { + pitch = e.pitch; + env = e.velocity; + envCoeff = pow (0.001, 1.0 / (0.6 * sampleRate)); + } + + event midi (e: noteOff) { + envCoeff = pow (0.001, 1.0 / (0.05 * sampleRate)); + } + + event midi (e: pitchBend) { bend = e.bendSemitones; } + event midi (e: pressure) { press = e.pressure; } + event midi (e: slide) { timbre = e.slide; } + event midi (e: controlChange) { if (e.control == 1) { modWheel = e.value; } } + + process { + phase = phase + noteToFreq (pitch + bend) / sampleRate; + if (phase >= 1.0) { phase = phase - 1.0; } + + env = env * envCoeff; + + lp = lp + (0.02 + 0.9 * (modWheel + timbre)) * (sin (phase * 6.283185307) - lp); + + out = lp * env * (0.25 + 0.75 * press); + } + } + + processor Bass { + output stream out; + + input event midi; + + state float phase; + state float pitch; + state float env; + state float envCoeff; + + func noteToFreq (p: float) : float { + return 440.0 * pow (2.0, (p - 69.0) / 12.0); + } + + init { + envCoeff = 0.9999; + } + + event midi (e: noteOn) { + pitch = e.pitch; + + if (! e.isLegato) { env = e.velocity; } + + envCoeff = pow (0.001, 1.0 / (1.5 * sampleRate)); + } + + event midi (e: noteOff) { + envCoeff = pow (0.001, 1.0 / (0.05 * sampleRate)); + } + + process { + phase = phase + noteToFreq (pitch) / sampleRate; + if (phase >= 1.0) { phase = phase - 1.0; } + + env = env * envCoeff; + + out = sin (phase * 6.283185307) * env * 0.5; + } + } + + graph ExpressiveSynth { + input event midi; + + output stream leadOut; + output stream bassOut; + + node lead = Lead[8] [[ mode: poly, stealing: oldest ]]; + node bass = Bass [[ mode: mono, priority: last ]]; + + connection { + midi -> lead.midi; + midi -> bass.midi; + lead.out -> leadOut; + bass.out -> bassOut; + } + } +)YDSP"; + +float blockEnergy (const std::vector& block, int from, int to) +{ + float energy = 0.0f; + + for (int i = from; i < to; ++i) + energy += block[static_cast (i)] * block[static_cast (i)]; + + return energy; +} + +} // namespace + +TEST (YdspJitGraphTests, ExpressiveMpeSynthRespondsToEveryEventClass) +{ + YdspCompiler compiler; + + auto graph = compilePatch (expressiveSynthSource, compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + ASSERT_FALSE (compiler.getDiagnostics().hasErrors()); + + yup::MPEZoneLayout layout; + layout.setLowerZone (15, 12, 2); + graph.setMpeZoneLayout (layout); + + constexpr int blockSize = 512; + graph.prepare (44100.0, blockSize); + + std::vector lead (blockSize, 0.0f); + std::vector bass (blockSize, 0.0f); + float* outPtrs[] = { lead.data(), bass.data() }; + + const auto runBlock = [&] (const yup::MidiBuffer& midi) + { + runProcess (graph, nullptr, 0, outPtrs, 2, blockSize, &midi); + }; + + yup::MidiBuffer noteMidi; + noteMidi.addEvent (yup::MidiMessage::noteOn (2, 69, static_cast (100)), 0); + + runBlock (noteMidi); + + const auto plainEnergy = blockEnergy (lead, 256, blockSize); + EXPECT_GT (plainEnergy, 1e-6f); + + graph.reset(); + + yup::MidiBuffer pressureMidi; + pressureMidi.addEvent (yup::MidiMessage::noteOn (2, 69, static_cast (100)), 0); + pressureMidi.addEvent (yup::MidiMessage::channelPressureChange (2, 127), 1); + + runBlock (pressureMidi); + + EXPECT_GT (blockEnergy (lead, 256, blockSize), plainEnergy); + + graph.reset(); + + yup::MidiBuffer slideMidi; + slideMidi.addEvent (yup::MidiMessage::noteOn (2, 69, static_cast (100)), 0); + slideMidi.addEvent (yup::MidiMessage::controllerEvent (2, 74, 127), 1); + + runBlock (slideMidi); + + EXPECT_GT (blockEnergy (lead, 256, blockSize), plainEnergy); + + graph.reset(); + + yup::MidiBuffer modMidi; + modMidi.addEvent (yup::MidiMessage::noteOn (2, 69, static_cast (100)), 0); + modMidi.addEvent (yup::MidiMessage::controllerEvent (1, 1, 127), 1); + + runBlock (modMidi); + + EXPECT_GT (blockEnergy (lead, 256, blockSize), plainEnergy); + + const auto countRisingCrossings = [] (const std::vector& block) + { + int crossings = 0; + + for (size_t i = 1; i < block.size(); ++i) + if (block[i - 1] <= 0.0f && block[i] > 0.0f) + ++crossings; + + return crossings; + }; + + graph.reset(); + runBlock (noteMidi); + const auto plainCrossings = countRisingCrossings (lead); + + graph.reset(); + + yup::MidiBuffer bendMidi; + bendMidi.addEvent (yup::MidiMessage::noteOn (2, 69, static_cast (100)), 0); + bendMidi.addEvent (yup::MidiMessage::pitchWheel (2, 16383), 1); // +12 semitones + + runBlock (bendMidi); + + EXPECT_GT (countRisingCrossings (lead), plainCrossings); + + graph.reset(); + + yup::MidiBuffer quietMidi; + quietMidi.addEvent (yup::MidiMessage::noteOn (2, 43, static_cast (20)), 200); + + runBlock (quietMidi); + const auto freshEnergy = blockEnergy (bass, 400, blockSize); + + graph.reset(); + + yup::MidiBuffer legatoMidi; + legatoMidi.addEvent (yup::MidiMessage::noteOn (2, 36, static_cast (127)), 0); + legatoMidi.addEvent (yup::MidiMessage::noteOn (3, 43, static_cast (20)), 200); + + runBlock (legatoMidi); + + EXPECT_GT (blockEnergy (bass, 400, blockSize), freshEnergy * 2.0f); + + yup::MidiBuffer panicMidi; + panicMidi.addEvent (yup::MidiMessage::controllerEvent (1, 120, 0), 0); + + runBlock (panicMidi); + + for (int i = 0; i < blockSize; ++i) + { + EXPECT_NEAR (0.0f, lead[static_cast (i)], 1e-6f) << "lead after all-sound-off at " << i; + EXPECT_NEAR (0.0f, bass[static_cast (i)], 1e-6f) << "bass after all-sound-off at " << i; + } + + dumpAsmOnFailure (graph); +} + +//============================================================================== + +TEST (YdspJitGraphTests, NoteOnCarriesCurrentPitchBend) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor BendVoice { + output stream out; + input event midi; + state float bend; + event midi (e: noteOn) { bend = e.bendSemitones; } + process { out = bend; } + } + graph G { + input event midi; + output stream y; + node v = BendVoice[4] [[ mode: poly, stealing: oldest ]]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + + constexpr int blockSize = 128; + graph.prepare (44100.0, blockSize); + + std::vector output (blockSize, 0.0f); + float* outPtrs[] = { output.data() }; + + // A note pressed while the wheel is already bent must start at the current bend + // (legacy mode: 16383 = +2 semitones with the default 2-semitone range). + yup::MidiBuffer bentMidi; + bentMidi.addEvent (yup::MidiMessage::pitchWheel (1, 16383), 0); + bentMidi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 1); + + runProcess (graph, nullptr, 0, outPtrs, 1, blockSize, &bentMidi); + + for (int i = 1; i < blockSize; ++i) + EXPECT_FLOAT_EQ (2.0f, output[static_cast (i)]) << "bent noteOn at " << i; + + // A note pressed with the wheel at center starts un-bent. + graph.reset(); + std::fill (output.begin(), output.end(), 0.0f); + + yup::MidiBuffer centerMidi; + centerMidi.addEvent (yup::MidiMessage::pitchWheel (1, 8192), 0); // center + centerMidi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 1); + + runProcess (graph, nullptr, 0, outPtrs, 1, blockSize, ¢erMidi); + + for (int i = 1; i < blockSize; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "center noteOn at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, MonoNoteOnCarriesCurrentPitchBend) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor BendVoice { + output stream out; + input event midi; + state float bend; + event midi (e: noteOn) { bend = e.bendSemitones; } + process { out = bend; } + } + graph G { + input event midi; + output stream y; + node v = BendVoice [[ mode: mono, priority: last ]]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + + constexpr int blockSize = 128; + graph.prepare (44100.0, blockSize); + + std::vector output (blockSize, 0.0f); + float* outPtrs[] = { output.data() }; + + yup::MidiBuffer bentMidi; + bentMidi.addEvent (yup::MidiMessage::pitchWheel (1, 16383), 0); + bentMidi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 1); + bentMidi.addEvent (yup::MidiMessage::noteOn (1, 62, static_cast (100)), 50); // legato retrigger + bentMidi.addEvent (yup::MidiMessage::noteOff (1, 62, static_cast (64)), 80); // falls back to the held 60 + + runProcess (graph, nullptr, 0, outPtrs, 1, blockSize, &bentMidi); + + for (int i = 1; i < blockSize; ++i) + EXPECT_FLOAT_EQ (2.0f, output[static_cast (i)]) << "mono bent noteOn at " << i; + + dumpAsmOnFailure (graph); +} + +//============================================================================== + +TEST (YdspJitGraphTests, LoopNestedInsideAnIfRunsExactlyOnce) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor P { + input stream in; + output stream out; + + state float acc[4]; + + process { + if (in > 0.5) { + for i in 0..4 { acc[i] = acc[i] + 1.0; } + } else { + for i in 0..4 { acc[i] = acc[i] - 2.0; } + } + + float sum = 0.0; + + for i in 0..4 { sum = sum + acc[i]; } + + out = sum; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + + constexpr int blockSize = 8; + graph.prepare (44100.0, blockSize); + + std::vector output (blockSize, 0.0f); + float* outPtrs[] = { output.data() }; + + const std::vector ones (blockSize, 1.0f); + const float* onePtrs[] = { ones.data() }; + + runProcess (graph, onePtrs, 1, outPtrs, 1, blockSize); + + for (int i = 0; i < blockSize; ++i) + EXPECT_FLOAT_EQ (4.0f * static_cast (i + 1), output[static_cast (i)]) << "then-branch at " << i; + + graph.reset(); + + const std::vector zeros (blockSize, 0.0f); + const float* zeroPtrs[] = { zeros.data() }; + + runProcess (graph, zeroPtrs, 1, outPtrs, 1, blockSize); + + for (int i = 0; i < blockSize; ++i) + EXPECT_FLOAT_EQ (-8.0f * static_cast (i + 1), output[static_cast (i)]) << "else-branch at " << i; + + dumpAsmOnFailure (graph); +} + +//============================================================================== +// Acceptance: the 16-voice additive electric piano + +class YdspElectricPianoTests : public ::testing::Test +{ +protected: + static constexpr int blockSize = 256; + static constexpr double sampleRate = 44100.0; + + YdspElectricPianoTests() + : graph (patches::cachedPatch (patches::electricPiano)) + { + } + + void SetUp() override + { + ASSERT_TRUE (graph.isValid()) << patches::cachedPatchDiagnostics (patches::electricPiano); + + patches::restoreFreshState (graph, sampleRate, blockSize); + } + + void TearDown() override + { + dumpAsmOnFailure (graph); + } + + float runBlock (const yup::MidiBuffer* midi = nullptr) + { + float* outPtrs[] = { left.data(), right.data() }; + runProcess (graph, nullptr, 0, outPtrs, 2, static_cast (left.size()), midi); + + float energy = 0.0f; + + for (const auto sample : left) + energy += sample * sample; + + return energy; + } + + YdspAudioGraph& graph; + + std::vector left = std::vector (blockSize, 0.0f); + std::vector right = std::vector (blockSize, 0.0f); +}; + +TEST_F (YdspElectricPianoTests, ExposesTheGraphEndpointsAndAliasedParameters) +{ + EXPECT_EQ (0, graph.getInputStreamCount()); + EXPECT_EQ (2, graph.getOutputStreamCount()); + + EXPECT_EQ (8, graph.getParameterCount()); + + ASSERT_TRUE (graph.hasParameter ("vibratoRate")); + EXPECT_FLOAT_EQ (4.0f, graph.getParameter ("vibratoRate")); + + EXPECT_EQ (graph.getParameterSlot ("vibratoRate"), graph.getParameterSlot ("trem.vibratoRate")); +} + +TEST_F (YdspElectricPianoTests, PlaysDecaysAndReleasesANote) +{ + yup::MidiBuffer midi; + midi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (110)), 0); + + const auto attackEnergy = runBlock (&midi); + EXPECT_GT (attackEnergy, 0.0f); + + float heldEnergy = 0.0f; + for (int block = 0; block < 8; ++block) + heldEnergy = runBlock(); + + EXPECT_GT (heldEnergy, 0.0f); + EXPECT_LT (heldEnergy, attackEnergy); + + yup::MidiBuffer offMidi; + offMidi.addEvent (yup::MidiMessage::noteOff (1, 60), 0); + + runBlock (&offMidi); + + float releasedEnergy = 0.0f; + + for (int block = 0; block < 16; ++block) + releasedEnergy = runBlock(); + + EXPECT_LT (releasedEnergy, heldEnergy); +} + +TEST_F (YdspElectricPianoTests, TremoloPansTheTwoOutputChannels) +{ + yup::MidiBuffer midi; + midi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (110)), 0); + + EXPECT_GT (runBlock (&midi), 0.0f); + + bool channelsDiffer = false; + + for (size_t i = 0; i < left.size(); ++i) + if (left[i] != right[i]) + channelsDiffer = true; + + EXPECT_TRUE (channelsDiffer); +} + +TEST_F (YdspElectricPianoTests, SustainsPastTheAttackClick) +{ + // Long-horizon twin of the shipped-file sustain test in + // yup_YdspExamplePatchTests.cpp, run against the EMBEDDED patch copy, whose + // only content difference from the shipped file is the missing + // `[[ smoothing: 0.02 ]]` on trem.vibratoDepth. If the embedded copy + // sustains while the shipped file dies, smoothing is the trigger; if both + // die, the defect is in shared voice-parameter handling. + + yup::MidiBuffer midi; + midi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (110)), 0); + + constexpr int numBlocks = 96; // 0.56 s of held note at 44100 / 256 + std::vector energy (static_cast (numBlocks), 0.0f); + + energy[0] = runBlock (&midi); + for (int block = 1; block < numBlocks; ++block) + energy[static_cast (block)] = runBlock(); + + const auto attackEnergy = energy[0]; + EXPECT_GT (attackEnergy, 0.0f) << "The note's attack is silent"; + + // Block 24 sits at t ~0.14 s, far past where a 0.15 s click has died. + EXPECT_GT (energy[24], attackEnergy * 0.1f) << "The note died within its attack"; + + float sustainedEnergy = 0.0f; + for (int block = 48; block < numBlocks; ++block) + sustainedEnergy += energy[static_cast (block)]; + + sustainedEnergy /= static_cast (numBlocks - 48); + + // Same tail-window rationale as the shipped-file twin: the per-partial + // envelope leaves the fundamental-only body at roughly a tenth of the + // full-attack energy by 0.3-0.56 s, far above the ~1e-12 a collapsed + // click leaves behind. + EXPECT_GT (sustainedEnergy, attackEnergy * 0.05f) << "The held note died instead of sustaining"; +} + +TEST_F (YdspElectricPianoTests, DoesNotAllocateDuringProcess) +{ +#if ! YUP_ENABLE_ALLOCATION_HOOKS + GTEST_SKIP() << "Enable YUP_TEST_ALLOCATION_HOOKS to measure allocations"; +#else + graph.prepare (44100.0, blockSize, 128); + + yup::MidiBuffer midi; + + for (int note = 0; note < 16; ++note) + { + midi.addEvent (yup::MidiMessage::noteOn (1, 48 + note, static_cast (100)), note * 4); + midi.addEvent (yup::MidiMessage::noteOff (1, 48 + note), 128 + note * 4); + } + + YdspOutputBuffer outputs[] { Span (left.data(), left.size()), Span (right.data(), right.size()) }; + const MidiBuffer* events[] { &midi }; + const YdspProcessRequest request { {}, outputs, blockSize, events }; + + graph.prewarmKernels(); + ASSERT_EQ (YdspProcessResult::ok, graph.process (request)); + + YdspAllocationCounter allocations; + bool succeeded = true; + allocations.start(); + + for (int block = 0; block < 8; ++block) + succeeded &= graph.process (request) == YdspProcessResult::ok; + + const auto count = allocations.stop(); + + EXPECT_TRUE (succeeded); + EXPECT_EQ (0u, count); + EXPECT_TRUE (std::any_of (left.begin(), left.end(), [] (float sample) { return sample != 0.0f; })); +#endif +} + +//============================================================================== +// Idle-voice skipping: `state int x [[ role: voiceActivity ]]` + +namespace +{ + +constexpr const char* sleepProbeSource = R"YDSP( + processor SleepProbe { + output stream out; + input event midi; + state int active [[ role: voiceActivity ]]; + state int ccCount; + event midi (e: noteOn) { active = 1; } + event midi (e: noteOff) { active = 0; } + event midi (e: controlChange) { ccCount = ccCount + 1; } + process { out = select (active > 0, 0.5, 0.25); } + } +)YDSP"; + +float sumOf (const std::vector& block, int from, int to) +{ + float total = 0.0f; + + for (int i = from; i < to; ++i) + total += block[static_cast (i)]; + + return total; +} + +} // namespace + +TEST (YdspJitGraphTests, IdleVoiceBankProducesExactSilence) +{ + YdspCompiler compiler; + + auto graph = compilePatch (String (sleepProbeSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node voices = SleepProbe[16]; + connection { midi -> voices.midi; voices.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + std::vector output (512, 0.0f); + float* outPtrs[] = { output.data() }; + + runProcess (graph, nullptr, 0, outPtrs, 1, 512, nullptr); + + for (int i = 0; i < 512; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "at " << i; + + EXPECT_EQ (0, graph.getActiveVoiceCount ("voices")); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, GetActiveVoiceCountIsSafeBeforePrepare) +{ + YdspCompiler compiler; + + auto graph = compilePatch (String (sleepProbeSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node voices = SleepProbe[16]; + connection { midi -> voices.midi; voices.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + + EXPECT_EQ (16, graph.getActiveVoiceCount ("voices")); + + graph.prepare (44100.0, 512); +} + +TEST (YdspJitGraphTests, OnlySoundingVoicesContributeToTheMix) +{ + YdspCompiler compiler; + + auto graph = compilePatch (String (sleepProbeSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node voices = SleepProbe[16]; + connection { midi -> voices.midi; voices.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + std::vector output (512, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi = makeNoteBuffer ({ { 0, 60, 1.0f }, { 0, 64, 1.0f } }); + + runProcess (graph, nullptr, 0, outPtrs, 1, 512, &midi); + + for (int i = 0; i < 512; ++i) + EXPECT_FLOAT_EQ (1.0f, output[static_cast (i)]) << "at " << i; + + EXPECT_EQ (2, graph.getActiveVoiceCount ("voices")); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, VoiceRunsToTheBlockBoundaryThenSleeps) +{ + YdspCompiler compiler; + + auto graph = compilePatch (String (sleepProbeSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node voices = SleepProbe[16]; + connection { midi -> voices.midi; voices.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + std::vector output (512, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi1 = makeNoteBuffer ({ { 0, 60, 1.0f } }); + runProcess (graph, nullptr, 0, outPtrs, 1, 512, &midi1); + + for (int i = 0; i < 512; ++i) + ASSERT_FLOAT_EQ (0.5f, output[static_cast (i)]) << "held at " << i; + + const auto noteOff = makeNoteBuffer ({ { 60, 60, 0.0f } }); + runProcess (graph, nullptr, 0, outPtrs, 1, 512, ¬eOff); + + for (int i = 0; i < 60; ++i) + EXPECT_FLOAT_EQ (0.5f, output[static_cast (i)]) << "before note-off at " << i; + + for (int i = 60; i < 512; ++i) + EXPECT_FLOAT_EQ (0.25f, output[static_cast (i)]) << "after note-off at " << i; + + EXPECT_EQ (0, graph.getActiveVoiceCount ("voices")); + + runProcess (graph, nullptr, 0, outPtrs, 1, 512, nullptr); + + for (int i = 0; i < 512; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "asleep at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, NoteOnMidBlockRendersTheWholeBlock) +{ + YdspCompiler compiler; + + auto graph = compilePatch (String (sleepProbeSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node voices = SleepProbe[16]; + connection { midi -> voices.midi; voices.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 256); + + std::vector output (256, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi = makeNoteBuffer ({ { 100, 60, 1.0f } }); + + runProcess (graph, nullptr, 0, outPtrs, 1, 256, &midi); + + for (int i = 0; i < 100; ++i) + EXPECT_FLOAT_EQ (0.25f, output[static_cast (i)]) << "prefix at " << i; + + for (int i = 100; i < 256; ++i) + EXPECT_FLOAT_EQ (0.5f, output[static_cast (i)]) << "after note-on at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, WokenIdleVoiceLeavesNoStaleScratchBehind) +{ + YdspCompiler compiler; + + auto graph = compilePatch (String (sleepProbeSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node voices = SleepProbe[16]; + connection { midi -> voices.midi; voices.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 256); + + std::vector output (256, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi2 = makeNoteBuffer ({ { 0, 60, 1.0f }, { 0, 64, 1.0f } }); + runProcess (graph, nullptr, 0, outPtrs, 1, 256, &midi2); + const auto midi3 = makeNoteBuffer ({ { 0, 64, 0.0f } }); + runProcess (graph, nullptr, 0, outPtrs, 1, 256, &midi3); + runProcess (graph, nullptr, 0, outPtrs, 1, 256, nullptr); + + for (int i = 0; i < 256; ++i) + ASSERT_FLOAT_EQ (0.5f, output[static_cast (i)]) << "one voice sounding at " << i; + + ASSERT_EQ (1, graph.getActiveVoiceCount ("voices")); + + yup::MidiBuffer cc; + cc.addEvent (yup::MidiMessage::controllerEvent (1, 1, 127), 128); + + runProcess (graph, nullptr, 0, outPtrs, 1, 256, &cc); + + for (int i = 0; i < 256; ++i) + EXPECT_FLOAT_EQ (0.5f, output[static_cast (i)]) << "after CC at " << i; + + EXPECT_FLOAT_EQ (128.0f, sumOf (output, 0, 256)); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, AutomationStillAppliesToAFullyIdleVoiceBank) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor LevelProbe { + output stream out; + input parameter float level = 0.5; + input event midi; + state int active [[ role: voiceActivity ]]; + event midi (e: noteOn) { active = 1; } + event midi (e: noteOff) { active = 0; } + process { out = select (active > 0, level, 0.0); } + } + graph G { + input event midi; + output stream y; + node voices = LevelProbe[16]; + connection { midi -> voices.midi; voices.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 256); + + std::vector output (256, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto levelSlot = graph.getParameterSlot ("voices.level"); + ASSERT_GE (levelSlot, 0); + + const YdspAutomationEvent automation { levelSlot, 100, 2.0f }; + + ASSERT_EQ (0, graph.getActiveVoiceCount ("voices")); + + runProcess (graph, nullptr, 0, outPtrs, 1, 256, nullptr, &automation, 1); + + for (int i = 0; i < 256; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "idle at " << i; + + EXPECT_FLOAT_EQ (2.0f, graph.getParameter ("voices.level")); + + const auto midi4 = makeNoteBuffer ({ { 0, 60, 1.0f } }); + runProcess (graph, nullptr, 0, outPtrs, 1, 256, &midi4); + + for (int i = 0; i < 256; ++i) + EXPECT_FLOAT_EQ (2.0f, output[static_cast (i)]) << "after wake at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, MonoVoiceSleepsWhenIdle) +{ + YdspCompiler compiler; + + auto graph = compilePatch (String (sleepProbeSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node voices = SleepProbe [[ mode: mono ]]; + connection { midi -> voices.midi; voices.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 256); + + std::vector output (256, 0.0f); + float* outPtrs[] = { output.data() }; + + runProcess (graph, nullptr, 0, outPtrs, 1, 256, nullptr); + + for (int i = 0; i < 256; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "idle at " << i; + + EXPECT_EQ (0, graph.getActiveVoiceCount ("voices")); + + const auto midi5 = makeNoteBuffer ({ { 0, 60, 1.0f } }); + runProcess (graph, nullptr, 0, outPtrs, 1, 256, &midi5); + + for (int i = 0; i < 256; ++i) + EXPECT_FLOAT_EQ (0.5f, output[static_cast (i)]) << "held at " << i; + + EXPECT_EQ (1, graph.getActiveVoiceCount ("voices")); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, HeldVoiceRunsEvenWithItsFlagCleared) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor LyingProbe { + output stream out; + input event midi; + state int active [[ role: voiceActivity ]]; + event midi (e: noteOn) { active = 0; } + event midi (e: noteOff) { active = 0; } + process { out = 0.25; } + } + graph G { + input event midi; + output stream y; + node voices = LyingProbe[8]; + connection { midi -> voices.midi; voices.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 256); + + std::vector output (256, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi6 = makeNoteBuffer ({ { 0, 60, 1.0f } }); + runProcess (graph, nullptr, 0, outPtrs, 1, 256, &midi6); + + for (int i = 0; i < 256; ++i) + EXPECT_FLOAT_EQ (0.25f, output[static_cast (i)]) << "held at " << i; + + EXPECT_EQ (1, graph.getActiveVoiceCount ("voices")); + + const auto midi7 = makeNoteBuffer ({ { 0, 60, 0.0f } }); + runProcess (graph, nullptr, 0, outPtrs, 1, 256, &midi7); + runProcess (graph, nullptr, 0, outPtrs, 1, 256, nullptr); + + for (int i = 0; i < 256; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "released at " << i; + + EXPECT_EQ (0, graph.getActiveVoiceCount ("voices")); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, WriteOnlyActivityFlagStillPutsTheVoiceToSleep) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor DecayProbe { + output stream out; + input event midi; + state float env; + state int active [[ role: voiceActivity ]]; + event midi (e: noteOn) { env = 1.0; active = 1; } + event midi (e: noteOff) { env = 0.0; } + process { + env = env * 0.5; + active = select (env < 0.01, 0, 1); + out = env; + } + } + graph G { + input event midi; + output stream y; + node voices = DecayProbe[8]; + connection { midi -> voices.midi; voices.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector output (64, 0.0f); + float* outPtrs[] = { output.data() }; + + const auto midi8 = makeNoteBuffer ({ { 0, 60, 1.0f }, { 1, 60, 0.0f } }); + runProcess (graph, nullptr, 0, outPtrs, 1, 64, &midi8); + + EXPECT_FLOAT_EQ (0.5f, output[0]); + EXPECT_EQ (0, graph.getActiveVoiceCount ("voices")); + + runProcess (graph, nullptr, 0, outPtrs, 1, 64, nullptr); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "asleep at " << i; + + const auto midi9 = makeNoteBuffer ({ { 0, 60, 1.0f } }); + runProcess (graph, nullptr, 0, outPtrs, 1, 64, &midi9); + + EXPECT_FLOAT_EQ (0.5f, output[0]); + EXPECT_EQ (1, graph.getActiveVoiceCount ("voices")); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, VoiceBankWithoutTheAnnotationRunsEveryVoice) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor PlainProbe { + output stream out; + input event midi; + state int touched; + event midi (e: noteOn) { touched = 1; } + event midi (e: noteOff) { touched = 0; } + process { out = 0.25; } + } + graph G { + input event midi; + output stream y; + node voices = PlainProbe[16]; + connection { midi -> voices.midi; voices.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 256); + + std::vector output (256, 0.0f); + float* outPtrs[] = { output.data() }; + + runProcess (graph, nullptr, 0, outPtrs, 1, 256, nullptr); + + for (int i = 0; i < 256; ++i) + EXPECT_FLOAT_EQ (4.0f, output[static_cast (i)]) << "at " << i; + + EXPECT_EQ (16, graph.getActiveVoiceCount ("voices")); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, AllSoundOffSilencesAndSleepsTheVoices) +{ + YdspCompiler compiler; + + auto graph = compilePatch (String (sleepProbeSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node voices = SleepProbe[16]; + connection { midi -> voices.midi; voices.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 512); + + std::vector output (512, 0.0f); + float* outPtrs[] = { output.data() }; + + yup::MidiBuffer midi; + midi.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 0); + midi.addEvent (yup::MidiMessage::controllerEvent (1, 120, 0), 200); + + runProcess (graph, nullptr, 0, outPtrs, 1, 512, &midi); + + for (int i = 0; i < 200; ++i) + EXPECT_FLOAT_EQ (0.5f, output[static_cast (i)]) << "before all-sound-off at " << i; + + for (int i = 200; i < 512; ++i) + EXPECT_FLOAT_EQ (0.25f, output[static_cast (i)]) << "after all-sound-off at " << i; + + EXPECT_EQ (0, graph.getActiveVoiceCount ("voices")); + + runProcess (graph, nullptr, 0, outPtrs, 1, 512, nullptr); + + for (int i = 0; i < 512; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "asleep at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, ActiveVoiceCountIsZeroForAnUnknownNode) +{ + YdspCompiler compiler; + + auto graph = compilePatch (String (sleepProbeSource) + R"YDSP( + graph G { + input event midi; + output stream y; + node voices = SleepProbe[4]; + connection { midi -> voices.midi; voices.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + EXPECT_EQ (0, graph.getActiveVoiceCount ("nope")); +} + +//============================================================================== +// Fan-out and summing fan-in +//============================================================================== + +namespace +{ + +constexpr const char* fanProcessors = + "processor Gain { input stream in; output stream out; input parameter float g = 1.0; process { out = in * g; } }\n" + "processor Sub { input stream a; input stream b; output stream out; process { out = a - b; } }\n"; + +YdspAudioGraph fanCompile (StringRef body, YdspCompiler& compiler) +{ + return compilePatch (String (fanProcessors) + body, compiler); +} + +const std::vector fanInput { 1.0f, 2.0f, -0.5f, 0.25f, 4.0f, 0.0f, -3.0f, 1.5f }; + +std::vector> fanRun (YdspAudioGraph& graph, + const std::vector>& inputs, + int numOutputs) +{ + const auto blockSize = static_cast (inputs.empty() ? fanInput.size() : inputs[0].size()); + + std::vector inPtrs; + for (const auto& channel : inputs) + inPtrs.push_back (channel.data()); + + std::vector> outputs (static_cast (numOutputs), + std::vector (static_cast (blockSize), 0.0f)); + + std::vector outPtrs; + for (auto& channel : outputs) + outPtrs.push_back (channel.data()); + + runProcess (graph, + inPtrs.empty() ? nullptr : inPtrs.data(), + static_cast (inPtrs.size()), + outPtrs.data(), + numOutputs, + blockSize); + + return outputs; +} + +} // namespace + +TEST (YdspJitGraphTests, FansOutAGraphInputToTwoNodes) +{ + YdspCompiler compiler; + + auto graph = fanCompile (R"YDSP( + graph G { + input stream x; + output stream doubled; + output stream tripled; + node a = Gain (g = 2.0); + node b = Gain (g = 3.0); + connection { x -> a.in; x -> b.in; a.out -> doubled; b.out -> tripled; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto out = fanRun (graph, { fanInput }, 2); + + for (size_t i = 0; i < fanInput.size(); ++i) + { + EXPECT_NEAR (fanInput[i] * 2.0f, out[0][i], 1e-6f) << "sample " << i; + EXPECT_NEAR (fanInput[i] * 3.0f, out[1][i], 1e-6f) << "sample " << i; + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, SumsTwoSourcesIntoANodeInput) +{ + YdspCompiler compiler; + + auto graph = fanCompile (R"YDSP( + graph G { + input stream x; + output stream y; + node a = Gain (g = 2.0); + node b = Gain (g = 3.0); + node s = Gain (g = 1.0); + connection { x -> a.in; x -> b.in; a.out -> s.in; b.out -> s.in; s.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto out = fanRun (graph, { fanInput }, 1); + + for (size_t i = 0; i < fanInput.size(); ++i) + EXPECT_NEAR (fanInput[i] * 5.0f, out[0][i], 1e-6f) << "sample " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, SumsTwoSourcesIntoAGraphOutput) +{ + YdspCompiler compiler; + + auto graph = fanCompile (R"YDSP( + graph G { + input stream x; + output stream y; + node a = Gain (g = 2.0); + node b = Gain (g = 3.0); + connection { x -> a.in; x -> b.in; a.out -> y; b.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto out = fanRun (graph, { fanInput }, 1); + + for (size_t i = 0; i < fanInput.size(); ++i) + EXPECT_NEAR (fanInput[i] * 5.0f, out[0][i], 1e-6f) << "sample " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, SumsThreeSourcesIntoOneSlot) +{ + YdspCompiler compiler; + + auto graph = fanCompile (R"YDSP( + graph G { + input stream x; + output stream y; + node a = Gain (g = 1.0); + node b = Gain (g = 16777216.0); + node c = Gain (g = -16777216.0); + connection { x -> a.in; x -> b.in; x -> c.in; a.out -> y; b.out -> y; c.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const std::vector ones (8, 1.0f); + const auto out = fanRun (graph, { ones }, 1); + + for (size_t i = 0; i < ones.size(); ++i) + EXPECT_FLOAT_EQ (0.0f, out[0][i]) << "sample " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, NodeOutputFeedsAGraphOutputAndANode) +{ + YdspCompiler compiler; + + auto graph = fanCompile (R"YDSP( + graph G { + input stream x; + output stream tap; + output stream y; + node a = Gain (g = 2.0); + node b = Gain (g = 5.0); + connection { x -> a.in; a.out -> tap; a.out -> b.in; b.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto out = fanRun (graph, { fanInput }, 2); + + for (size_t i = 0; i < fanInput.size(); ++i) + { + EXPECT_NEAR (fanInput[i] * 2.0f, out[0][i], 1e-6f) << "tap sample " << i; + EXPECT_NEAR (fanInput[i] * 10.0f, out[1][i], 1e-5f) << "y sample " << i; + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, NodeOutputFeedsANodeAndAGraphOutputInTheOtherOrder) +{ + YdspCompiler compiler; + + auto graph = fanCompile (R"YDSP( + graph G { + input stream x; + output stream tap; + output stream y; + node a = Gain (g = 2.0); + node b = Gain (g = 5.0); + connection { x -> a.in; a.out -> b.in; a.out -> tap; b.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto out = fanRun (graph, { fanInput }, 2); + + for (size_t i = 0; i < fanInput.size(); ++i) + { + EXPECT_NEAR (fanInput[i] * 2.0f, out[0][i], 1e-6f) << "tap sample " << i; + EXPECT_NEAR (fanInput[i] * 10.0f, out[1][i], 1e-5f) << "y sample " << i; + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, NodeOutputDrivesTwoGraphOutputs) +{ + YdspCompiler compiler; + + auto graph = fanCompile (R"YDSP( + graph G { + input stream x; + output stream left; + output stream right; + node a = Gain (g = 2.0); + connection { x -> a.in; a.out -> left; a.out -> right; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto out = fanRun (graph, { fanInput }, 2); + + for (size_t i = 0; i < fanInput.size(); ++i) + { + EXPECT_NEAR (fanInput[i] * 2.0f, out[0][i], 1e-6f) << "left sample " << i; + EXPECT_NEAR (fanInput[i] * 2.0f, out[1][i], 1e-6f) << "right sample " << i; + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, AppliesPerEdgeDelayBeforeSumming) +{ + YdspCompiler compiler; + + auto graph = fanCompile (R"YDSP( + graph G { + input stream x; + output stream y; + node a = Gain (g = 1.0); + connection { x -> a.in; x -> [4] -> a.in; a.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto out = fanRun (graph, { fanInput }, 1); + + for (size_t i = 0; i < fanInput.size(); ++i) + { + const auto delayed = i >= 4 ? fanInput[i - 4] : 0.0f; + EXPECT_NEAR (fanInput[i] + delayed, out[0][i], 1e-6f) << "sample " << i; + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, SumsIntoOneSlotWithoutDisturbingTheOther) +{ + YdspCompiler compiler; + + auto graph = fanCompile (R"YDSP( + graph G { + input stream x; + input stream w; + output stream y; + node s = Sub; + connection { x -> s.a; w -> s.a; x -> s.b; s.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const std::vector w { 10.0f, 20.0f, 30.0f, 40.0f, 50.0f, 60.0f, 70.0f, 80.0f }; + const auto out = fanRun (graph, { fanInput, w }, 1); + + for (size_t i = 0; i < w.size(); ++i) + EXPECT_NEAR (w[i], out[0][i], 1e-5f) << "sample " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, AppliesADelayOnAnEdgeIntoAGraphOutput) +{ + YdspCompiler compiler; + + auto graph = fanCompile (R"YDSP( + graph G { + input stream x; + output stream y; + node a = Gain (g = 1.0); + connection { x -> a.in; a.out -> [3] -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto out = fanRun (graph, { fanInput }, 1); + + for (size_t i = 0; i < fanInput.size(); ++i) + EXPECT_NEAR (i >= 3 ? fanInput[i - 3] : 0.0f, out[0][i], 1e-6f) << "sample " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, WiresAGraphInputStraightToAGraphOutput) +{ + YdspCompiler compiler; + + auto graph = fanCompile (R"YDSP( + graph G { + input stream x; + output stream y; + output stream z; + node a = Gain (g = 4.0); + connection { x -> y; x -> a.in; a.out -> z; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto out = fanRun (graph, { fanInput }, 2); + + for (size_t i = 0; i < fanInput.size(); ++i) + { + EXPECT_NEAR (fanInput[i], out[0][i], 1e-6f) << "passthrough sample " << i; + EXPECT_NEAR (fanInput[i] * 4.0f, out[1][i], 1e-6f) << "gained sample " << i; + } + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, SumsAGraphInputAndANodeOutputIntoOneGraphOutput) +{ + YdspCompiler compiler; + + auto graph = fanCompile (R"YDSP( + graph G { + input stream x; + output stream y; + node a = Gain (g = 2.0); + connection { x -> a.in; x -> y; a.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto out = fanRun (graph, { fanInput }, 1); + + for (size_t i = 0; i < fanInput.size(); ++i) + EXPECT_NEAR (fanInput[i] * 3.0f, out[0][i], 1e-6f) << "sample " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, SumsFanInOnAFloat64Stream) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor WideGain { input stream float64 in; output stream float64 out; input parameter float64 g = 1.0; process { out = in * g; } } + graph G { + input stream float64 x; + output stream float64 y; + node a = WideGain (g = 2.0); + node b = WideGain (g = 3.0); + connection { x -> a.in; x -> b.in; a.out -> y; b.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + std::vector input (fanInput.begin(), fanInput.end()); + std::vector output (input.size(), 0.0); + + std::vector inputs { YdspInputBuffer (Span (input.data(), input.size())) }; + std::vector outputs { YdspOutputBuffer (Span (output.data(), output.size())) }; + + ASSERT_EQ (YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { inputs, outputs, static_cast (input.size()) })); + + for (size_t i = 0; i < input.size(); ++i) + EXPECT_NEAR (input[i] * 5.0, output[i], 1e-12) << "sample " << i; +} + +TEST (YdspJitGraphTests, FanInSurvivesASecondPrepareAtADifferentBlockSize) +{ + YdspCompiler compiler; + + auto graph = fanCompile (R"YDSP( + graph G { + input stream x; + output stream y; + node a = Gain (g = 2.0); + node b = Gain (g = 3.0); + node s = Gain (g = 1.0); + connection { x -> a.in; x -> b.in; a.out -> s.in; b.out -> s.in; s.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + + for (const int blockSize : { 8, 64, 16 }) + { + graph.prepare (44100.0, blockSize); + + const std::vector input (static_cast (blockSize), 2.0f); + const auto out = fanRun (graph, { input }, 1); + + for (int i = 0; i < blockSize; ++i) + EXPECT_NEAR (10.0f, out[0][static_cast (i)], 1e-6f) << "block " << blockSize << " sample " << i; + } +} + +TEST (YdspJitGraphTests, SplitMergeAlgebraMatchesTheHandWrittenConnectionBlock) +{ + YdspCompiler algebraCompiler; + YdspCompiler wiredCompiler; + + auto algebra = fanCompile (R"YDSP( + graph G { + input stream dry; + output stream wet; + process = dry <: (Gain (g = 2.0) , Gain (g = 3.0)) :> wet; + } + )YDSP", + algebraCompiler); + + auto wired = fanCompile (R"YDSP( + graph G { + input stream dry; + output stream wet; + node a = Gain (g = 2.0); + node b = Gain (g = 3.0); + connection { dry -> a.in; dry -> b.in; a.out -> wet; b.out -> wet; } + } + )YDSP", + wiredCompiler); + + ASSERT_TRUE (algebra.isValid()); + ASSERT_TRUE (wired.isValid()); + + algebra.prepare (44100.0, 8); + wired.prepare (44100.0, 8); + + const auto fromAlgebra = fanRun (algebra, { fanInput }, 1); + const auto fromWiring = fanRun (wired, { fanInput }, 1); + + for (size_t i = 0; i < fanInput.size(); ++i) + { + EXPECT_FLOAT_EQ (fromWiring[0][i], fromAlgebra[0][i]) << "sample " << i; + EXPECT_NEAR (fanInput[i] * 5.0f, fromAlgebra[0][i], 1e-6f) << "sample " << i; + } +} + +TEST (YdspJitGraphTests, SplitMergeCarriesTheIdentityFanThroughToRealEdges) +{ + YdspCompiler compiler; + + auto graph = fanCompile (R"YDSP( + graph G { + input stream dry; + output stream wet; + process = dry : _ <: (Gain (g = 2.0) , Gain (g = 3.0)) :> _ : wet; + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto out = fanRun (graph, { fanInput }, 1); + + for (size_t i = 0; i < fanInput.size(); ++i) + EXPECT_NEAR (fanInput[i] * 5.0f, out[0][i], 1e-6f) << "sample " << i; + + dumpAsmOnFailure (graph); +} + +//============================================================================== +// Node-to-node event routing: mid-loop drain, carry queue, midiOut delivery + +TEST (YdspJitGraphTests, RoutedNoteOnSoundsTheDestinationVoiceAtTheRoutedPitch) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Arp { + input stream trig; + output event noteOn; + process { if (trig > 0.5) { emit noteOn (pitch: 72, velocity: 0.8) -> noteOn; } } + } + processor Voice { + input event midi; + output stream out; + state float sounding; + event midi (e: noteOn) { sounding = e.pitch; } + event midi (e: noteOff) { sounding = 0.0; } + process { out = sounding; } + } + graph G { + input stream trig; + output stream y; + node arp = Arp; + node voice = Voice; + connection { trig -> arp.trig; arp.noteOn -> voice.midi; voice.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector trig (64, 0.0f); + trig[0] = 1.0f; + + std::vector output (64, 0.0f); + + std::vector inputBuffers { yup::Span (trig.data(), 64) }; + std::vector outputBuffers { yup::Span (output.data(), 64) }; + + EXPECT_EQ (yup::YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { inputBuffers, outputBuffers, 64 })); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (72.0f, output[static_cast (i)]) << "at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, AnImportedProcessorsEmitStatementSurvivesTheImportClone) +{ + const auto tempDir = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_ydsp_import_emit_test"); + + tempDir.deleteRecursively(); + tempDir.createDirectory(); + + tempDir.getChildFile ("Arp.ydsp") + .replaceWithText (R"YDSP( + processor Arp { + input stream trig; + output event noteOn; + process { if (trig > 0.5) { emit noteOn (pitch: 72, velocity: 0.8) -> noteOn; } } + } + )YDSP"); + + const auto patch = R"YDSP( + import Arp as arpLib; + processor Voice { + input event midi; + output stream out; + state float sounding; + event midi (e: noteOn) { sounding = e.pitch; } + process { out = sounding; } + } + graph G { + input stream trig; + output stream y; + node arp = arpLib.Arp; + node voice = Voice; + connection { trig -> arp.trig; arp.noteOn -> voice.midi; voice.out -> y; } + } + )YDSP"; + + YdspCompiler compiler; + auto result = compiler.compile (patch, tempDir.getChildFile ("Patch.ydsp").getFullPathName()); + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + auto graph = std::move (result).getValue(); + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector trig (64, 0.0f); + trig[0] = 1.0f; + + std::vector output (64, 0.0f); + + std::vector inputBuffers { yup::Span (trig.data(), 64) }; + std::vector outputBuffers { yup::Span (output.data(), 64) }; + + EXPECT_EQ (yup::YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { inputBuffers, outputBuffers, 64 })); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (72.0f, output[static_cast (i)]) << "at " << i; + + dumpAsmOnFailure (graph); + + tempDir.deleteRecursively(); +} + +TEST (YdspJitGraphTests, MidiOnlyNodeReceivesEveryHeldNoteWithoutSyntheticStealingReleases) +{ + // Before dispatchEventToNode()'s isMidiOnly bypass, a zero-stream + // event-driven node still went through the ordinary voiceCount=1 slot + // allocator: holding a second note while a first was still down stole + // slot 0 and synthesized a noteOff for the still-held first note. A node + // that manages its own polyphony (an arpeggiator's held-note table) must + // see every noteOn/noteOff exactly once, verbatim, with no synthetic + // releases injected by voice stealing. + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Collector { + input event midi; + output parameter float noteOffs; + state float count; + event midi (e: noteOn) { } + event midi (e: noteOff) { count = count + 1.0; } + process { noteOffs = count; } + } + graph G { + input event midi; + node c = Collector; + connection { midi -> c.midi; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + yup::MidiBuffer midiA; + midiA.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 0); + runProcess (graph, nullptr, 0, nullptr, 0, 64, &midiA); + + EXPECT_NEAR (0.0f, graph.getOutputValue ("c.noteOffs"), 1e-6f); + + // Note 60 is still held: a second note-on must not evict it. + yup::MidiBuffer midiB; + midiB.addEvent (yup::MidiMessage::noteOn (1, 64, static_cast (100)), 0); + runProcess (graph, nullptr, 0, nullptr, 0, 64, &midiB); + + EXPECT_NEAR (0.0f, graph.getOutputValue ("c.noteOffs"), 1e-6f); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, TwoSourceNodesRoutingTheSamePitchDoNotCollideOnOneVoiceBank) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor ArpA { + input stream trig; + output event noteOn; + output event noteOff; + process { + if (trig > 0.5) { emit noteOn (pitch: 60, velocity: 0.1) -> noteOn; } + if (trig < -0.5) { emit noteOff (pitch: 60, velocity: 0.1) -> noteOff; } + } + } + processor ArpB { + input stream trig; + output event noteOn; + output event noteOff; + process { + if (trig > 0.5) { emit noteOn (pitch: 60, velocity: 0.2) -> noteOn; } + if (trig < -0.5) { emit noteOff (pitch: 60, velocity: 0.2) -> noteOff; } + } + } + processor Voice { + input event midi; + output stream out; + state float sounding; + event midi (e: noteOn) { sounding = e.pitch + e.velocity; } + event midi (e: noteOff) { sounding = 0.0; } + process { out = sounding; } + } + graph G { + input stream trigA; + input stream trigB; + output stream y; + node arpA = ArpA; + node arpB = ArpB; + node voice = Voice[4] [[ mode: poly, stealing: oldest ]]; + connection { + trigA -> arpA.trig; + trigB -> arpB.trig; + arpA.noteOn -> voice.midi; + arpA.noteOff -> voice.midi; + arpB.noteOn -> voice.midi; + arpB.noteOff -> voice.midi; + voice.out -> y; + } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector trigAOn (64, 0.0f); + trigAOn[0] = 1.0f; + std::vector trigBOn (64, 0.0f); + trigBOn[0] = 1.0f; + + std::vector outputBlock1 (64, 0.0f); + + std::vector inBuf1 { + yup::Span (trigAOn.data(), 64), + yup::Span (trigBOn.data(), 64) + }; + std::vector outBuf1 { yup::Span (outputBlock1.data(), 64) }; + + EXPECT_EQ (yup::YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { inBuf1, outBuf1, 64 })); + + for (int i = 0; i < 64; ++i) + EXPECT_NEAR (120.3f, outputBlock1[static_cast (i)], 1e-3f) << "block 1 at " << i; + + std::vector trigAOff (64, 0.0f); + std::vector trigBOff (64, 0.0f); + trigBOff[0] = -1.0f; + + std::vector outputBlock2 (64, 0.0f); + + std::vector inBuf2 { + yup::Span (trigAOff.data(), 64), + yup::Span (trigBOff.data(), 64) + }; + std::vector outBuf2 { yup::Span (outputBlock2.data(), 64) }; + + EXPECT_EQ (yup::YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { inBuf2, outBuf2, 64 })); + + for (int i = 0; i < 64; ++i) + EXPECT_NEAR (60.1f, outputBlock2[static_cast (i)], 1e-3f) << "block 2 at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, LatencyAnnotatedEmitterDelaysTheRoutedEventByItsDeclaredLatency) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Source [[ latency: 8 ]] { + input stream trig; + output event noteOn; + process { if (trig > 0.5) { emit noteOn (pitch: 72, velocity: 0.8) -> noteOn; } } + } + processor Voice { + input event midi; + output stream out; + state float sounding; + event midi (e: noteOn) { sounding = e.pitch; } + process { out = sounding; } + } + graph G { + input stream trig; + output stream y; + node src = Source; + node voice = Voice; + connection { trig -> src.trig; src.noteOn -> voice.midi; voice.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector trig (64, 0.0f); + trig[0] = 1.0f; + + std::vector output (64, 0.0f); + + std::vector inputBuffers { yup::Span (trig.data(), 64) }; + std::vector outputBuffers { yup::Span (output.data(), 64) }; + + EXPECT_EQ (yup::YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { inputBuffers, outputBuffers, 64 })); + + for (int i = 0; i < 8; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "before compensated onset at " << i; + + for (int i = 8; i < 64; ++i) + EXPECT_FLOAT_EQ (72.0f, output[static_cast (i)]) << "after compensated onset at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, ACompensatedEventStraddlingTheBlockBoundaryCarriesToTheNextBlock) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Source [[ latency: 100 ]] { + input stream trig; + output event noteOn; + process { if (trig > 0.5) { emit noteOn (pitch: 72, velocity: 0.8) -> noteOn; } } + } + processor Voice { + input event midi; + output stream out; + state float sounding; + event midi (e: noteOn) { sounding = e.pitch; } + process { out = sounding; } + } + graph G { + input stream trig; + output stream y; + node src = Source; + node voice = Voice; + connection { trig -> src.trig; src.noteOn -> voice.midi; voice.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector trigOn (64, 0.0f); + trigOn[0] = 1.0f; + std::vector trigOff (64, 0.0f); + + std::vector outputBlock1 (64, 0.0f); + std::vector outputBlock2 (64, 0.0f); + + std::vector inBuf1 { yup::Span (trigOn.data(), 64) }; + std::vector outBuf1 { yup::Span (outputBlock1.data(), 64) }; + + EXPECT_EQ (yup::YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { inBuf1, outBuf1, 64 })); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (0.0f, outputBlock1[static_cast (i)]) << "block 1 at " << i; + + std::vector inBuf2 { yup::Span (trigOff.data(), 64) }; + std::vector outBuf2 { yup::Span (outputBlock2.data(), 64) }; + + EXPECT_EQ (yup::YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { inBuf2, outBuf2, 64 })); + + for (int i = 0; i < 36; ++i) + EXPECT_FLOAT_EQ (0.0f, outputBlock2[static_cast (i)]) << "block 2 before carried onset at " << i; + + for (int i = 36; i < 64; ++i) + EXPECT_FLOAT_EQ (72.0f, outputBlock2[static_cast (i)]) << "block 2 after carried onset at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, ACompensatedCarriedEventSurvivesASmallerNextBlock) +{ + // The carried record is unwound against the *next* block's start when it + // is stored, not against the current block's size when it is delivered - + // so a 64-sample block followed by a 32-sample block still lands the event + // at its exact sample. A latency-80 emission at sample 0 is due at global + // sample 80 = 64 (end of block 1) + 16 into block 2. + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Source [[ latency: 80 ]] { + input stream trig; + output event noteOn; + process { if (trig > 0.5) { emit noteOn (pitch: 72, velocity: 0.8) -> noteOn; } } + } + processor Voice { + input event midi; + output stream out; + state float sounding; + event midi (e: noteOn) { sounding = e.pitch; } + process { out = sounding; } + } + graph G { + input stream trig; + output stream y; + node src = Source; + node voice = Voice; + connection { trig -> src.trig; src.noteOn -> voice.midi; voice.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector trigOn (64, 0.0f); + trigOn[0] = 1.0f; + std::vector trigOff (32, 0.0f); + + std::vector outputBlock1 (64, 0.0f); + std::vector outputBlock2 (32, 0.0f); + + std::vector inBuf1 { yup::Span (trigOn.data(), 64) }; + std::vector outBuf1 { yup::Span (outputBlock1.data(), 64) }; + + EXPECT_EQ (yup::YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { inBuf1, outBuf1, 64 })); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (0.0f, outputBlock1[static_cast (i)]) << "block 1 at " << i; + + std::vector inBuf2 { yup::Span (trigOff.data(), 32) }; + std::vector outBuf2 { yup::Span (outputBlock2.data(), 32) }; + + EXPECT_EQ (yup::YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { inBuf2, outBuf2, 32 })); + + for (int i = 0; i < 16; ++i) + EXPECT_FLOAT_EQ (0.0f, outputBlock2[static_cast (i)]) << "block 2 before carried onset at " << i; + + for (int i = 16; i < 32; ++i) + EXPECT_FLOAT_EQ (72.0f, outputBlock2[static_cast (i)]) << "block 2 after carried onset at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, FeedbackVoicesEachStartFromTheirOwnScratch) +{ + // Each awake voice renders into the shared scratch and sums into the mix; + // a voice whose process reads its own output (`out = out + 1.0`) must start + // from its own zeroed scratch, not from the previous voice's samples. With + // two held notes the mix is exactly 2.0 per sample - the previous voice's + // leftover would make the second voice count 2.0 and the mix 3.0. + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Tick { + output stream out; + input event midi; + state int active [[ role: voiceActivity ]]; + event midi (e: noteOn) { active = 1; } + event midi (e: noteOff) { active = 0; } + process { out = out + 1.0; } + } + graph G { + input event midi; + output stream y; + node voices = Tick[2]; + connection { midi -> voices.midi; voices.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector output (64, 0.0f); + float* outPtrs[] = { output.data() }; + + // Two held notes: two awake voices, each contributing 1.0 per sample. + const auto midi = makeNoteBuffer ({ { 0, 60, 1.0f }, { 0, 64, 1.0f } }); + runProcess (graph, nullptr, 0, outPtrs, 1, 64, &midi); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (2.0f, output[static_cast (i)]) << "two held notes at " << i; + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, CarryQueueOverflowIncrementsTheDroppedOutputEventCounter) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Source [[ latency: 2000 ]] { + input stream trig; + output event noteOn; + process { if (trig > 0.5) { emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; } } + } + processor Sink { input event midi; output stream out; process { out = 0.0; } } + graph G { + input stream trig; + output stream y; + node src = Source; + node s1 = Sink; + node s2 = Sink; + connection { trig -> src.trig; src.noteOn -> s1.midi; src.noteOn -> s2.midi; s1.out -> y; s2.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64, 64, 32, 1); + + const auto before = graph.getDroppedOutputEventCount(); + + std::vector trig (64, 0.0f); + trig[0] = 1.0f; + + std::vector output (64, 0.0f); + + std::vector inputBuffers { yup::Span (trig.data(), 64) }; + std::vector outputBuffers { yup::Span (output.data(), 64) }; + + EXPECT_EQ (yup::YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { inputBuffers, outputBuffers, 64 })); + + EXPECT_EQ (before + 1, graph.getDroppedOutputEventCount()); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, RoutedNoteOnReachesTheHostMidiOutBuffer) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Arp { + input stream trig; + output event noteOn; + process { if (trig > 0.5) { emit noteOn (pitch: 64, velocity: 0.5, channel: 3) -> noteOn; } } + } + processor Silence { + output stream out; + process { out = 0.0; } + } + graph G { + input stream trig; + output stream y; + output event noteOn; + node arp = Arp; + node sil = Silence; + connection { trig -> arp.trig; arp.noteOn -> noteOn; sil.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + std::vector trig (64, 0.0f); + trig[0] = 1.0f; + + std::vector output (64, 0.0f); + + std::vector inputBuffers { yup::Span (trig.data(), 64) }; + std::vector outputBuffers { yup::Span (output.data(), 64) }; + + yup::MidiBuffer midiOut; + + EXPECT_EQ (yup::YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { inputBuffers, outputBuffers, 64, {}, {}, &midiOut })); + + ASSERT_FALSE (midiOut.isEmpty()); + + int numEvents = 0; + + for (const yup::MidiMessageMetadata metadata : midiOut) + { + const auto message = metadata.getMessage(); + + EXPECT_EQ (0, metadata.samplePosition); + EXPECT_TRUE (message.isNoteOn()); + EXPECT_EQ (64, message.getNoteNumber()); + EXPECT_EQ (4, message.getChannel()); + EXPECT_NEAR (0.5f, message.getFloatVelocity(), 1.0f / 127.0f); + + ++numEvents; + } + + EXPECT_EQ (1, numEvents); + + dumpAsmOnFailure (graph); +} + + +TEST (YdspJitGraphTests, ProcessOverMaxBlockSizeReportsBlockTooLarge) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor P { input stream in; output stream out; process { out = in * 0.5; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + // A 65-sample request must be reported, not silently truncated to 64. + std::vector input (65, 0.5f); + std::vector output (65, 0.0f); + + std::vector inBuf { yup::Span (input.data(), 65) }; + std::vector outBuf { yup::Span (output.data(), 65) }; + + EXPECT_EQ (yup::YdspProcessResult::blockTooLarge, graph.process (yup::YdspProcessRequest { inBuf, outBuf, 65 })); + + // The graph is untouched: a legal block still processes. + std::vector smallOut (64, 0.0f); + std::vector smallBuf { yup::Span (smallOut.data(), 64) }; + + EXPECT_EQ (yup::YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { inBuf, outBuf, 64 })); + + dumpAsmOnFailure (graph); +} + +TEST (YdspJitGraphTests, ResetClearsTheDroppedEventCounters) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor Source [[ latency: 2000 ]] { + input stream trig; + output event noteOn; + process { if (trig > 0.5) { emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; } } + } + processor Sink { input event midi; output stream out; process { out = 0.0; } } + graph G { + input stream trig; + output stream y; + node src = Source; + node s1 = Sink; + node s2 = Sink; + connection { trig -> src.trig; src.noteOn -> s1.midi; src.noteOn -> s2.midi; s1.out -> y; s2.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64, 64, 32, 1); + + std::vector trig (64, 0.0f); + trig[0] = 1.0f; + + std::vector output (64, 0.0f); + + std::vector inputBuffers { yup::Span (trig.data(), 64) }; + std::vector outputBuffers { yup::Span (output.data(), 64) }; + + EXPECT_EQ (yup::YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { inputBuffers, outputBuffers, 64 })); + EXPECT_GT (graph.getDroppedOutputEventCount(), 0u); + + // reset() must clear the counters, not leave them monotonically growing. + graph.reset(); + EXPECT_EQ (0u, graph.getDroppedEventCount()); + EXPECT_EQ (0u, graph.getDroppedOutputEventCount()); + + dumpAsmOnFailure (graph); +} + +class YdspRuntimeSchedulingTests : public ::testing::Test +{ +protected: + YdspCompiler compiler; + std::array input {}; + std::array output {}; + + YdspAudioGraph makeSnapshotGraph() + { + return compilePatch (R"YDSP( + processor Values { + input parameter float gain = 1.0; + input parameter float64 precise = 2.0; + input parameter int64 counter = 3; + output parameter float meter; + output parameter float64 preciseMeter; + output parameter int64 counterMeter; + output stream out; + process { + out = gain; + meter = gain; + preciseMeter = precise; + counterMeter = counter; + } + } + graph G { + output stream y; + node v = Values; + connection { v.out -> y; } + } + )YDSP", compiler); + } + + YdspAudioGraph makeDelayedGraph (int latency, bool toHost = false) + { + auto source = String ("processor Source [[ latency: ") + String (latency) + R"YDSP( ]] { + input stream trig; + output event notes; + )YDSP"; + if (toHost) + source += "output stream silent;"; + source += "process {"; + if (toHost) + source += "silent = 0.0;"; + source += R"YDSP( + if (trig > 0.5) { emit noteOn (pitch: 72, velocity: 0.8) -> notes; } + if (trig > 1.5) { emit noteOn (pitch: 73, velocity: 0.8) -> notes; } + } } + )YDSP"; + if (toHost) + { + source += R"YDSP( + graph G { + input stream trig; output stream y; output event notes; + node src = Source; + connection { trig -> src.trig; src.notes -> notes; src.silent -> y; } + } + )YDSP"; + } + else + { + source += R"YDSP( + processor Voice { + input event midi; output stream out; state float sounding; + event midi (e: noteOn) { sounding = sounding + 1.0; } + process { out = sounding; } + } + graph G { + input stream trig; output stream y; + node src = Source; node voice = Voice; + connection { trig -> src.trig; src.notes -> voice.midi; voice.out -> y; } + } + )YDSP"; + } + return compilePatch (source, compiler); + } + + YdspProcessResult process (YdspAudioGraph& graph, int size = 64, MidiBuffer* midiOut = nullptr) + { + const YdspInputBuffer in[] { Span (input.data(), static_cast (size)) }; + YdspOutputBuffer out[] { Span (output.data(), static_cast (size)) }; + return graph.process (yup::YdspProcessRequest { + Span (in, static_cast (graph.getInputStreamCount())), + Span (out, static_cast (graph.getOutputStreamCount())), + size, + {}, + {}, + midiOut + }); + } +}; + +TEST_F (YdspRuntimeSchedulingTests, GettersDoNotConsumeUpdatesAndSnapshotsPreserveAllTypes) +{ + auto graph = makeSnapshotGraph(); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (48000.0, 64); + + const auto gain = graph.getParameterSlot ("v.gain"); + const auto precise = graph.getParameterSlot ("v.precise"); + const auto counter = graph.getParameterSlot ("v.counter"); + const auto meter = graph.getOutputValueSlot ("v.meter"); + EXPECT_FLOAT_EQ (1.0f, graph.getParameterBySlot (gain)); + EXPECT_FLOAT_EQ (0.0f, graph.getOutputValueBySlot (meter)); + graph.setParameterBySlot (gain, 4.0f); + graph.setDoubleParameterBySlot (precise, 1.00000000000001); + graph.setIntParameterBySlot (counter, 9007199254740993LL); + EXPECT_FLOAT_EQ (1.0f, graph.getParameter ("v.gain")); + EXPECT_DOUBLE_EQ (2.0, graph.getDoubleParameterBySlot (precise)); + EXPECT_EQ (3, graph.getIntParameterBySlot (counter)); + ASSERT_EQ (YdspProcessResult::ok, process (graph, 0)); + EXPECT_FLOAT_EQ (1.0f, graph.getParameterBySlot (gain)); + ASSERT_EQ (YdspProcessResult::ok, process (graph)); + EXPECT_FLOAT_EQ (4.0f, graph.getParameterBySlot (gain)); + EXPECT_FLOAT_EQ (4.0f, graph.getOutputValueBySlot (meter)); + EXPECT_DOUBLE_EQ (1.00000000000001, graph.getDoubleParameter ("v.precise")); + EXPECT_DOUBLE_EQ (1.00000000000001, graph.getDoubleOutputValueBySlot (graph.getOutputValueSlot ("v.preciseMeter"))); + EXPECT_EQ (9007199254740993LL, graph.getIntOutputValueBySlot (graph.getOutputValueSlot ("v.counterMeter"))); + EXPECT_EQ (-1, graph.getOutputValueSlot ("missing")); + EXPECT_FLOAT_EQ (0.0f, graph.getParameterBySlot (-1)); + EXPECT_FLOAT_EQ (0.0f, graph.getParameterBySlot (precise)); + EXPECT_DOUBLE_EQ (0.0, graph.getDoubleOutputValueBySlot (meter)); + EXPECT_EQ (0, graph.getIntOutputValueBySlot (999)); +} + +TEST_F (YdspRuntimeSchedulingTests, ReadingSnapshotsDoesNotCreateASecondQueueConsumer) +{ + auto graph = makeSnapshotGraph(); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (48000.0, 64); + + const auto gain = graph.getParameterSlot ("v.gain"); + for (int i = 0; i < 256; ++i) + { + graph.setParameterBySlot (gain, static_cast (i)); + EXPECT_FLOAT_EQ (1.0f, graph.getParameterBySlot (gain)); + } + EXPECT_EQ (128u, graph.getDroppedEventCount()); + ASSERT_EQ (YdspProcessResult::ok, process (graph)); + EXPECT_FLOAT_EQ (127.0f, graph.getParameterBySlot (gain)); + graph.setParameterBySlot (gain, 512.0f); + ASSERT_EQ (YdspProcessResult::ok, process (graph)); + EXPECT_FLOAT_EQ (512.0f, graph.getOutputValue ("v.meter")); +} + +#if ! YUP_WASM +TEST_F (YdspRuntimeSchedulingTests, ConcurrentControlReadsAndWritesWithAudioProcessing) +{ + auto graph = makeSnapshotGraph(); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (48000.0, 64); + + const auto gain = graph.getParameterSlot ("v.gain"); + const auto meter = graph.getOutputValueSlot ("v.meter"); + std::atomic start { false }; + std::thread control ([&] + { + while (! start.load (std::memory_order_acquire)) + std::this_thread::yield(); + for (int i = 0; i < 4096; ++i) + { + graph.setParameterBySlot (gain, static_cast (i)); + const auto parameter = graph.getParameterBySlot (gain); + const auto level = graph.getOutputValueBySlot (meter); + EXPECT_GE (parameter, 0.0f); + EXPECT_LE (parameter, 4095.0f); + EXPECT_GE (level, 0.0f); + EXPECT_LE (level, 4095.0f); + } + }); + start.store (true, std::memory_order_release); + for (int i = 0; i < 1024; ++i) + EXPECT_EQ (YdspProcessResult::ok, process (graph)); + control.join(); + EXPECT_EQ (YdspProcessResult::ok, process (graph)); +} +#endif + +TEST_F (YdspRuntimeSchedulingTests, DelayedEventsSurviveSeveralVariableSizedBlocks) +{ + auto graph = makeDelayedGraph (200); + auto hostGraph = makeDelayedGraph (200, true); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (hostGraph.isValid()); + graph.prepare (48000.0, 64); + hostGraph.prepare (48000.0, 64); + input[0] = 2.0f; + input[1] = 1.0f; + int start = 0; + for (const int size : { 64, 32, 64, 32, 16, 64 }) + { + MidiBuffer midi; + ASSERT_EQ (YdspProcessResult::ok, process (graph, size)); + for (int i = 0; i < size; ++i) + EXPECT_FLOAT_EQ (start + i < 200 ? 0.0f : (start + i == 200 ? 2.0f : 3.0f), output[static_cast (i)]); + ASSERT_EQ (YdspProcessResult::ok, process (hostGraph, size, &midi)); + EXPECT_EQ (start == 192 ? 3 : 0, midi.getNumEvents()); + int eventIndex = 0; + for (const auto metadata : midi) + { + EXPECT_EQ ((eventIndex < 2 ? 200 : 201) - start, metadata.samplePosition); + EXPECT_EQ (eventIndex == 1 ? 73 : 72, metadata.getMessage().getNoteNumber()); + ++eventIndex; + } + input.fill (0.0f); + start += size; + } +} + +TEST_F (YdspRuntimeSchedulingTests, ExactBoundaryEventsUseOffsetZeroOfTheFollowingBlock) +{ + auto graph = makeDelayedGraph (128); + auto hostGraph = makeDelayedGraph (128, true); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (hostGraph.isValid()); + graph.prepare (48000.0, 64); + hostGraph.prepare (48000.0, 64); + input[0] = 1.0f; + for (int block = 0; block < 3; ++block) + { + MidiBuffer midi; + ASSERT_EQ (YdspProcessResult::ok, process (graph, 64)); + for (const float sample : output) + EXPECT_FLOAT_EQ (block == 2 ? 1.0f : 0.0f, sample); + ASSERT_EQ (YdspProcessResult::ok, process (hostGraph, 64, &midi)); + EXPECT_EQ (block == 2 ? 1 : 0, midi.getNumEvents()); + for (const auto metadata : midi) + EXPECT_EQ (0, metadata.samplePosition); + input.fill (0.0f); + } +} + +TEST_F (YdspRuntimeSchedulingTests, ResetDiscardsLongDelayedEvents) +{ + auto graph = makeDelayedGraph (200); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (48000.0, 64); + input[0] = 1.0f; + ASSERT_EQ (YdspProcessResult::ok, process (graph)); + graph.reset(); + input.fill (0.0f); + for (int block = 0; block < 5; ++block) + { + MidiBuffer midi; + ASSERT_EQ (YdspProcessResult::ok, process (graph, 64, &midi)); + EXPECT_TRUE (midi.isEmpty()); + for (const float sample : output) + EXPECT_FLOAT_EQ (0.0f, sample); + } +} + +TEST_F (YdspRuntimeSchedulingTests, RetainedEventsConsumeCarryCapacityUntilDelivery) +{ + auto graph = makeDelayedGraph (200); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (48000.0, 64, 8, 8, 1); + input[0] = 1.0f; + ASSERT_EQ (YdspProcessResult::ok, process (graph)); + EXPECT_EQ (0u, graph.getDroppedOutputEventCount()); + ASSERT_EQ (YdspProcessResult::ok, process (graph)); + EXPECT_EQ (1u, graph.getDroppedOutputEventCount()); + input.fill (0.0f); + + ASSERT_EQ (YdspProcessResult::ok, process (graph)); + for (const float sample : output) + EXPECT_FLOAT_EQ (0.0f, sample); + ASSERT_EQ (YdspProcessResult::ok, process (graph)); + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (i < 8 ? 0.0f : 1.0f, output[static_cast (i)]); + EXPECT_EQ (1u, graph.getDroppedOutputEventCount()); +} + +TEST_F (YdspRuntimeSchedulingTests, DenseUnsortedAutomationHasTheSameStableTimelineForEveryVoice) +{ + auto graph = compilePatch (R"YDSP( + processor Voice { + input event midi; + input parameter float gain = 0.0; + output stream out; + state float velocity; + event midi (e: noteOn) { velocity = e.velocity; } + process { out = gain * velocity; } + } + graph G { + input event midi; + output stream y; + node v = Voice[2]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (48000.0, 64, 8, 128); + const auto slot = graph.getParameterSlot ("v.gain"); + std::vector automation; + for (int i = 63; i >= 0; --i) + { + automation.push_back ({ slot, i, static_cast (i) }); + automation.push_back ({ slot, i, static_cast (i) + 0.5f }); + } + MidiBuffer midi; + midi.addEvent (MidiMessage::noteOn (1, 60, 1.0f), 0); + midi.addEvent (MidiMessage::noteOn (1, 64, 1.0f), 0); + + YdspOutputBuffer out[] { Span (output.data(), output.size()) }; + const yup::MidiBuffer* processEventInputs[] { &midi }; + + ASSERT_EQ (YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { + {}, + out, + 64, + yup::Span (processEventInputs, graph.getEventInputCount() > 0 ? 1 : 0), + yup::Span (automation.data(), static_cast (static_cast (automation.size()))) + })); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (2.0f * (static_cast (i) + 0.5f), output[static_cast (i)]); + EXPECT_FLOAT_EQ (63.5f, graph.getParameterBySlot (slot)); +} + +TEST_F (YdspRuntimeSchedulingTests, EqualOffsetsApplyAutomationThenSilenceThenHandlersInArrivalOrder) +{ + auto graph = compilePatch (R"YDSP( + processor Voice { + input event midi; + input parameter float gain = 1.0; + output stream out; + state float sounding; + init { sounding = gain; } + event midi (e: noteOn) { sounding = sounding * 10.0 + e.pitch; } + process { out = sounding; } + } + graph G { + input event midi; + output stream y; + node v = Voice; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (48000.0, 64); + + const auto slot = graph.getParameterSlot ("v.gain"); + const YdspAutomationEvent automation[] { { slot, 4, 2.0f }, { slot, 4, 3.0f } }; + + MidiBuffer midi; + midi.addEvent (MidiMessage::noteOn (1, 5, 1.0f), 0); + midi.addEvent (MidiMessage::allSoundOff (1), 4); + midi.addEvent (MidiMessage::noteOn (1, 1, 1.0f), 4); + midi.addEvent (MidiMessage::noteOn (1, 2, 1.0f), 4); + + YdspOutputBuffer out[] { Span (output.data(), output.size()) }; + const yup::MidiBuffer* processEventInputs[] { &midi }; + + ASSERT_EQ (YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { + {}, + out, + 64, + yup::Span (processEventInputs, graph.getEventInputCount() > 0 ? 1 : 0), + yup::Span (automation, static_cast (2)) + })); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (i < 4 ? 15.0f : 312.0f, output[static_cast (i)]); +} + +TEST_F (YdspRuntimeSchedulingTests, NamedParameterCallsAcceptYupStringsWithoutAmbiguity) +{ + auto graph = makeSnapshotGraph(); + ASSERT_TRUE (graph.isValid()); + + const String gain ("v.gain"), precise ("v.precise"), counter ("v.counter"); + graph.setParameter (gain, 4.0f); + graph.setDoubleParameter (precise, 5.0); + graph.setIntParameter (counter, 6); + + EXPECT_FLOAT_EQ (1.0f, graph.getParameter (gain)); + EXPECT_DOUBLE_EQ (2.0, graph.getDoubleParameter (precise)); + EXPECT_EQ (3, graph.getIntParameter (counter)); + + ASSERT_TRUE (graph.prepare (48000.0, 64).wasOk()); + ASSERT_EQ (YdspProcessResult::ok, process (graph)); + + EXPECT_FLOAT_EQ (4.0f, graph.getParameter (gain)); + EXPECT_DOUBLE_EQ (5.0, graph.getDoubleParameter (precise)); + EXPECT_EQ (6, graph.getIntParameter (counter)); +} + +TEST_F (YdspRuntimeSchedulingTests, PreparationRejectsInvalidSettingsWithoutReplacingThePreviousConfiguration) +{ + auto graph = makeSnapshotGraph(); + ASSERT_TRUE (graph.isValid()); + + EXPECT_EQ (YdspProcessResult::notPrepared, process (graph)); + + for (const double rate : { 0.0, -1.0, std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN(), std::numeric_limits::max() }) + EXPECT_TRUE (graph.prepare (rate, 64).failed()); + + EXPECT_TRUE (graph.prepare (48000.0, 0).failed()); + EXPECT_TRUE (graph.prepare (48000.0, -1).failed()); + EXPECT_TRUE (graph.prepare (48000.0, std::numeric_limits::max()).failed()); + EXPECT_TRUE (graph.prepare (48000.0, 64, -1).failed()); + EXPECT_TRUE (graph.prepare (48000.0, 64, 64, 0).failed()); + EXPECT_TRUE (graph.prepare (48000.0, 64, 64, 32, -1).failed()); + + EXPECT_EQ (YdspProcessResult::notPrepared, process (graph)); + ASSERT_TRUE (graph.prepare (48000.0, 64).wasOk()); + + graph.setParameter ("v.gain", 4.0f); + + EXPECT_TRUE (graph.prepare (0.0, 16).failed()); + ASSERT_EQ (YdspProcessResult::ok, process (graph)); + EXPECT_FLOAT_EQ (4.0f, output[63]); +} + +TEST_F (YdspRuntimeSchedulingTests, InvalidRequestsDoNotConsumeParametersOrChangeOutputsAndCounters) +{ + auto graph = makeSnapshotGraph(); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 64).wasOk()); + + const int slot = graph.getParameterSlot ("v.gain"); + graph.setParameterBySlot (slot, 9.0f); + output.fill (-99.0f); + + YdspOutputBuffer buffers[] { Span (output.data(), output.size()) }; + YdspProcessRequest request { {}, buffers, 64 }; + request.numSamples = -1; + EXPECT_EQ (YdspProcessResult::invalidArgument, graph.process (request)); + + request.numSamples = 65; + EXPECT_EQ (YdspProcessResult::blockTooLarge, graph.process (request)); + + request.numSamples = 64; + request.outputs = {}; + EXPECT_EQ (YdspProcessResult::invalidBufferCount, graph.process (request)); + + request.outputs = buffers; + YdspOutputBuffer shortBuffer[] { Span (output.data(), 1) }; + request.outputs = shortBuffer; + EXPECT_EQ (YdspProcessResult::bufferTooShort, graph.process (request)); + + std::array wrongType {}; + YdspOutputBuffer wrongBuffer[] { Span (wrongType.data(), wrongType.size()) }; + request.outputs = wrongBuffer; + EXPECT_EQ (YdspProcessResult::bufferTypeMismatch, graph.process (request)); + + YdspOutputBuffer nullBuffer[] { Span (static_cast (nullptr), 64) }; + request.outputs = nullBuffer; + EXPECT_EQ (YdspProcessResult::invalidArgument, graph.process (request)); + + request.outputs = buffers; + const MidiBuffer* events[] { nullptr }; + request.eventInputs = events; + EXPECT_EQ (YdspProcessResult::invalidBufferCount, graph.process (request)); + + request.eventInputs = {}; + YdspAutomationEvent automation { slot, 64, 2.0f }; + request.automation = Span (&automation, 1); + EXPECT_EQ (YdspProcessResult::invalidAutomation, graph.process (request)); + + automation.sampleOffset = -1; + EXPECT_EQ (YdspProcessResult::invalidAutomation, graph.process (request)); + + automation.sampleOffset = 0; + automation.parameterSlot = -1; + EXPECT_EQ (YdspProcessResult::invalidAutomation, graph.process (request)); + + request.automation = Span (static_cast (nullptr), 1); + EXPECT_EQ (YdspProcessResult::invalidArgument, graph.process (request)); + + EXPECT_EQ (0u, graph.getDroppedEventCount()); + + EXPECT_FLOAT_EQ (1.0f, graph.getParameterBySlot (slot)); + for (const float sample : output) + EXPECT_FLOAT_EQ (-99.0f, sample); + + for (int i = 0; i < 128; ++i) + graph.setParameterBySlot (slot, 2.0f); + + EXPECT_EQ (1u, graph.getDroppedEventCount()) << "Rejected requests must not free queue slots"; + + request.automation = {}; + ASSERT_EQ (YdspProcessResult::ok, graph.process (request)); + EXPECT_FLOAT_EQ (2.0f, graph.getParameterBySlot (slot)); +} + +TEST_F (YdspRuntimeSchedulingTests, RateConvertedAutomationIsRejectedBeforeAnyProcessing) +{ + for (const String rate : { String ("* 2"), String ("* 4"), String ("* 8"), + String ("/ 2"), String ("/ 4"), String ("/ 8") }) + { + const auto source = String (R"YDSP( + processor Gain { + input stream in; output stream out; input parameter float gain = 1.0; + process { out = in * gain; } + } + graph G { input stream x; output stream y; node p = Gain + )YDSP") + rate + "; connection { x -> p.in; p.out -> y; } }"; + + auto graph = compilePatch (source, compiler); + + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 64).wasOk()); + + const auto slot = graph.getParameterSlot ("p.gain"); + EXPECT_FALSE (graph.supportsSampleAccurateAutomation (slot)); + + const YdspInputBuffer in[] { Span (input.data(), input.size()) }; + YdspOutputBuffer out[] { Span (output.data(), output.size()) }; + + graph.setParameterBySlot (slot, 2.0f); + + for (const int offset : { 0, 16 }) + { + const YdspAutomationEvent event { slot, offset, 3.0f }; + output.fill (-99.0f); + + EXPECT_EQ (YdspProcessResult::unsupportedAutomation, + graph.process (YdspProcessRequest { in, out, 64, {}, Span (&event, 1) })); + + EXPECT_FLOAT_EQ (1.0f, graph.getParameterBySlot (slot)); + for (const float sample : output) + EXPECT_FLOAT_EQ (-99.0f, sample); + } + + EXPECT_EQ (YdspProcessResult::ok, graph.process (yup::YdspProcessRequest { in, out, 64 })); + EXPECT_FLOAT_EQ (2.0f, graph.getParameterBySlot (slot)); + } +} + +TEST_F (YdspRuntimeSchedulingTests, InvalidMidiDoesNotChangeVoiceStateOrOutputMidi) +{ + auto graph = compilePatch (R"YDSP( + processor Voice { + input event midi; output stream out; state float sounding; + event midi (e: noteOn) { sounding = e.pitch; } + process { out = sounding; } + } + graph G { input event midi; output stream y; node v = Voice; + connection { midi -> v.midi; v.out -> y; } } + )YDSP", compiler); + + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 64).wasOk()); + + MidiBuffer midi, midiOut; + midi.addEvent (MidiMessage::noteOn (1, 60, 1.0f), 0); + + const MidiBuffer* events[] { &midi }; + YdspOutputBuffer buffers[] { Span (output.data(), output.size()) }; + YdspProcessRequest request { {}, buffers, 64, events, {}, &midiOut }; + + ASSERT_EQ (YdspProcessResult::ok, graph.process (request)); + EXPECT_FLOAT_EQ (60.0f, output[63]); + + midiOut.addEvent (MidiMessage::noteOn (1, 42, 1.0f), 3); + for (const int offset : { -1, 64 }) + { + midi.clear(); + midi.addEvent (MidiMessage::allSoundOff (1), 0); + midi.addEvent (MidiMessage::noteOn (1, 72, 1.0f), offset); + output.fill (-99.0f); + + EXPECT_EQ (YdspProcessResult::invalidArgument, graph.process (request)); + EXPECT_EQ (1, midiOut.getNumEvents()); + for (const float sample : output) + EXPECT_FLOAT_EQ (-99.0f, sample); + + midi.clear(); + ASSERT_EQ (YdspProcessResult::ok, graph.process (request)); + EXPECT_FLOAT_EQ (60.0f, output[63]); + } + + request.midiOut = &midi; + EXPECT_EQ (YdspProcessResult::invalidArgument, graph.process (request)); +} + +TEST_F (YdspRuntimeSchedulingTests, RejectedBlockDoesNotAdvanceCarriedEvents) +{ + auto graph = makeDelayedGraph (128); + + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 64).wasOk()); + + input[0] = 1.0f; + ASSERT_EQ (YdspProcessResult::ok, process (graph)); + + input.fill (0.0f); + EXPECT_EQ (YdspProcessResult::invalidBufferCount, graph.process (yup::YdspProcessRequest { {}, {}, 64 })); + + ASSERT_EQ (YdspProcessResult::ok, process (graph)); + for (const float sample : output) + EXPECT_FLOAT_EQ (0.0f, sample); + + ASSERT_EQ (YdspProcessResult::ok, process (graph)); + for (const float sample : output) + EXPECT_FLOAT_EQ (1.0f, sample); +} + +TEST (YdspJitGraphTests, ReservedHostMidiOutputCapacityCoversDenseOutputWithoutAllocation) +{ + EXPECT_EQ (0u, YdspAudioGraph().getMidiOutputBufferSizeBytes()); + YdspCompiler compiler; + auto graph = compilePatch (R"YDSP( + processor P { + output stream out; + output event noteOn; + process { out = 0.0; emit noteOn (pitch: 60, velocity: 0.5) -> noteOn; } + } + graph G { + output stream out; + output event noteOn; + node p = P; + connection { p.out -> out; p.noteOn -> noteOn; } + } + )YDSP", compiler); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 64, 64, 32, 64).wasOk()); + graph.prewarmKernels(); + MidiBuffer midi; + midi.ensureSize (graph.getMidiOutputBufferSizeBytes()); + std::array audio {}; + YdspOutputBuffer outputs[] { Span (audio) }; + const YdspProcessRequest request { {}, outputs, 64, {}, {}, &midi }; +#if YUP_ENABLE_ALLOCATION_HOOKS + YdspAllocationCounter allocations; + allocations.start(); +#endif + bool succeeded = true; + for (int i = 0; i < 4; ++i) + { + midi.clear(); + succeeded &= graph.process (request) == YdspProcessResult::ok; + } +#if YUP_ENABLE_ALLOCATION_HOOKS + const auto count = allocations.stop(); + EXPECT_EQ (0u, count); +#endif + EXPECT_TRUE (succeeded); + EXPECT_EQ (64, midi.getNumEvents()); +} + +} // namespace yup::test diff --git a/tests/yup_dsp_jit/yup_YdspLatencyTests.cpp b/tests/yup_dsp_jit/yup_YdspLatencyTests.cpp new file mode 100644 index 000000000..0fc543f5c --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspLatencyTests.cpp @@ -0,0 +1,686 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include +#include + +namespace yup::test +{ + +namespace +{ + +constexpr int latencyOversamplerDelay = 16; + +constexpr const char* latencyProcessors = + "processor Ident { input stream in; output stream out; process { out = in; } }\n" + "processor Diff { input stream a; input stream b; output stream out; process { out = a - b; } }\n"; + +YdspAudioGraph latencyCompile (StringRef body, YdspCompiler& compiler) +{ + auto result = compiler.compile (String (latencyProcessors) + body); + EXPECT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + if (! result.wasOk()) + return {}; + + return std::move (result).getValue(); +} + +String latencyCompileError (StringRef body) +{ + YdspCompiler compiler; + auto result = compiler.compile (String (latencyProcessors) + body); + + EXPECT_FALSE (result.wasOk()); + + return compiler.getDiagnostics().toString(); +} + +std::vector latencySine (int size, double frequency, double sampleRate) +{ + std::vector data (static_cast (size)); + + for (int i = 0; i < size; ++i) + data[static_cast (i)] = static_cast (std::sin (2.0 * 3.14159265358979323846 * frequency * static_cast (i) / sampleRate)); + + return data; +} + +double latencyRms (const float* data, int count) +{ + double sum = 0.0; + + for (int i = 0; i < count; ++i) + sum += static_cast (data[i]) * static_cast (data[i]); + + return std::sqrt (sum / static_cast (count)); +} + +std::vector> latencyRunBlocks (YdspAudioGraph& graph, + const std::vector& input, + int blockSize, + int blockCount, + int numOutputs) +{ + std::vector> outputs (static_cast (numOutputs), + std::vector (static_cast (blockSize), 0.0f)); + + for (int block = 0; block < blockCount; ++block) + { + const auto offset = static_cast (block * blockSize); + + std::vector inputs { + YdspInputBuffer (Span (input.data() + offset, static_cast (blockSize))) + }; + + std::vector outputBuffers; + for (auto& channel : outputs) + outputBuffers.emplace_back (Span (channel.data(), channel.size())); + + graph.process (yup::YdspProcessRequest { inputs, outputBuffers, blockSize }); + } + + return outputs; +} + +std::vector> latencyRunOnce (YdspAudioGraph& graph, const std::vector& input, int numOutputs) +{ + return latencyRunBlocks (graph, input, static_cast (input.size()), 1, numOutputs); +} + +bool latencyGraphHasFusedKernel (const YdspAudioGraph& graph) +{ + for (const auto& kernel : graph.getExecutionReport().getKernels()) + if (kernel.name.startsWith ("fused(")) + return true; + + return false; +} + +} // namespace + +//============================================================================== + +TEST (YdspLatencyTests, CancelsAnOversampledBranchAgainstADryOne) +{ + YdspCompiler compiler; + + auto graph = latencyCompile (R"YDSP( + graph G { + input stream x; + output stream y; + node os = Ident * 4; + node mix = Diff; + connection { x -> mix.a; x -> os.in; os.out -> mix.b; mix.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + EXPECT_EQ (latencyOversamplerDelay, graph.getLatencySamples()); + + constexpr int blockSize = 256; + constexpr int blockCount = 8; + constexpr double sampleRate = 48000.0; + + graph.prepare (sampleRate, blockSize); + + const auto input = latencySine (blockSize * blockCount, 200.0, sampleRate); + const auto out = latencyRunBlocks (graph, input, blockSize, blockCount, 1); + + const auto residual = latencyRms (out[0].data(), blockSize); + const auto reference = latencyRms (input.data() + blockSize * (blockCount - 1), blockSize); + + ASSERT_GT (reference, 0.5); // the test would pass vacuously on silence + EXPECT_LT (residual, reference * 0.05) << "residual RMS " << residual << " against input RMS " << reference; +} + +TEST (YdspLatencyTests, AlignsAnImpulseAcrossTwoGraphOutputs) +{ + YdspCompiler compiler; + + auto graph = latencyCompile (R"YDSP( + graph G { + input stream x; + output stream left; + output stream right; + node os = Ident * 4; + connection { x -> left; x -> os.in; os.out -> right; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + EXPECT_EQ (latencyOversamplerDelay, graph.getLatencySamples()); + + constexpr int blockSize = 64; + graph.prepare (48000.0, blockSize); + + std::vector impulse (static_cast (blockSize), 0.0f); + impulse[0] = 1.0f; + + const auto out = latencyRunOnce (graph, impulse, 2); + + const auto peakIndex = [] (const std::vector& data) + { + int best = 0; + + for (size_t i = 1; i < data.size(); ++i) + if (std::fabs (data[i]) > std::fabs (data[static_cast (best)])) + best = static_cast (i); + + return best; + }; + + EXPECT_EQ (latencyOversamplerDelay, peakIndex (out[0])); + EXPECT_EQ (latencyOversamplerDelay, peakIndex (out[1])); +} + +//============================================================================== + +TEST (YdspLatencyTests, PreservesAHaasSkewAndReportsNoLatency) +{ + YdspCompiler compiler; + + auto graph = latencyCompile (R"YDSP( + graph G { + input stream x; + output stream left; + output stream right; + node g = Ident; + connection { x -> g.in; g.out -> left; g.out -> [400] -> right; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + EXPECT_EQ (0, graph.getLatencySamples()); + + constexpr int blockSize = 512; + graph.prepare (48000.0, blockSize); + + std::vector ramp (static_cast (blockSize)); + for (int i = 0; i < blockSize; ++i) + ramp[static_cast (i)] = static_cast (i + 1) * 0.001f; + + const auto out = latencyRunOnce (graph, ramp, 2); + + for (int i = 0; i < blockSize; ++i) + { + EXPECT_NEAR (ramp[static_cast (i)], out[0][static_cast (i)], 1e-6f) << "left sample " << i; + + const auto expected = i >= 400 ? ramp[static_cast (i - 400)] : 0.0f; + EXPECT_NEAR (expected, out[1][static_cast (i)], 1e-6f) << "right sample " << i; + } +} + +TEST (YdspLatencyTests, ADelayEffectReportsNoLatency) +{ + YdspCompiler compiler; + + auto graph = latencyCompile (R"YDSP( + processor Echo { + input stream in; + output stream out; + process { out = in + 0.5 * (in @ 400); } + } + graph G { + input stream x; + output stream y; + node e = Echo; + connection { x -> e.in; e.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + EXPECT_EQ (0, graph.getLatencySamples()); +} + +TEST (YdspLatencyTests, APlainChainReportsNoLatency) +{ + YdspCompiler compiler; + + auto graph = latencyCompile (R"YDSP( + graph G { + input stream x; + output stream y; + node a = Ident; + node b = Ident; + connection { x -> a.in; a.out -> b.in; b.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + EXPECT_EQ (0, graph.getLatencySamples()); +} + +//============================================================================== + +TEST (YdspLatencyTests, ReportsADeclaredProcessorLatency) +{ + YdspCompiler compiler; + + auto graph = latencyCompile (R"YDSP( + processor Slow [[ latency: 64 ]] { + input stream in; + output stream out; + process { out = in; } + } + graph G { + input stream x; + output stream y; + node s = Slow; + connection { x -> s.in; s.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + EXPECT_EQ (64, graph.getLatencySamples()); +} + +TEST (YdspLatencyTests, DividesADeclaredLatencyByTheOversamplingFactor) +{ + YdspCompiler compiler; + + auto graph = latencyCompile (R"YDSP( + processor Slow [[ latency: 64 ]] { + input stream in; + output stream out; + process { out = in; } + } + graph G { + input stream x; + output stream y; + node s = Slow * 4; + connection { x -> s.in; s.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + EXPECT_EQ (latencyOversamplerDelay + 64 / 4, graph.getLatencySamples()); +} + +TEST (YdspLatencyTests, RejectsADeclaredLatencyThatTheFactorDoesNotDivide) +{ + const auto errors = latencyCompileError (R"YDSP( + processor Slow [[ latency: 65 ]] { + input stream in; + output stream out; + process { out = in; } + } + graph G { + input stream x; + output stream y; + node s = Slow * 4; + connection { x -> s.in; s.out -> y; } + } + )YDSP"); + + EXPECT_TRUE (errors.contains ("[[ latency: 65 ]]")); + EXPECT_TRUE (errors.contains ("not divisible by this instance's oversampling factor of 4")); +} + +TEST (YdspLatencyTests, RejectsAnUnknownProcessorAnnotation) +{ + const auto errors = latencyCompileError (R"YDSP( + processor Slow [[ lateness: 64 ]] { + input stream in; + output stream out; + process { out = in; } + } + graph G { + input stream x; + output stream y; + node s = Slow; + connection { x -> s.in; s.out -> y; } + } + )YDSP"); + + EXPECT_TRUE (errors.contains ("Unknown processor annotation 'lateness'")); +} + +TEST (YdspLatencyTests, RejectsANonIntegerDeclaredLatency) +{ + const auto errors = latencyCompileError (R"YDSP( + processor Slow [[ latency: "soon" ]] { + input stream in; + output stream out; + process { out = in; } + } + graph G { + input stream x; + output stream y; + node s = Slow; + connection { x -> s.in; s.out -> y; } + } + )YDSP"); + + EXPECT_TRUE (errors.contains ("must be a non-negative integer number of samples")); +} + +//============================================================================== + +TEST (YdspLatencyTests, SumsDeclaredLatencyAcrossAFusedChainAndStillFuses) +{ + YdspCompiler compiler; + + auto graph = latencyCompile (R"YDSP( + processor Stage [[ latency: 8 ]] { + input stream in; + output stream out; + input parameter float g = 1.0; + process { out = in * g; } + } + graph G { + input stream x; + output stream y; + node a = Stage (g = 1.0); + node b = Stage (g = 1.0); + node c = Stage (g = 1.0); + connection { x -> a.in; a.out -> b.in; b.out -> c.in; c.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + EXPECT_EQ (24, graph.getLatencySamples()); + EXPECT_TRUE (latencyGraphHasFusedKernel (graph)) << "the chain stopped fusing"; +} + +//============================================================================== + +TEST (YdspLatencyTests, CompensatesEachBranchOfAThreeWaySplitToTheLongest) +{ + YdspCompiler compiler; + + auto graph = latencyCompile (R"YDSP( + graph G { + input stream x; + output stream y; + node one = Ident * 4; + node two = Ident * 4; + connection { x -> y; x -> one.in; one.out -> y; one.out -> two.in; two.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + EXPECT_EQ (2 * latencyOversamplerDelay, graph.getLatencySamples()); + + constexpr int blockSize = 256; + graph.prepare (48000.0, blockSize); + + std::vector impulse (static_cast (blockSize), 0.0f); + impulse[0] = 1.0f; + + const auto out = latencyRunOnce (graph, impulse, 1); + + int peak = 0; + + for (int i = 1; i < blockSize; ++i) + if (std::fabs (out[0][static_cast (i)]) > std::fabs (out[0][static_cast (peak)])) + peak = i; + + EXPECT_EQ (2 * latencyOversamplerDelay, peak); + EXPECT_GT (out[0][static_cast (peak)], 2.0f); +} + +TEST (YdspLatencyTests, AccumulatesOversamplingAndDeclaredLatencyInSeries) +{ + YdspCompiler compiler; + + auto graph = latencyCompile (R"YDSP( + processor Look [[ latency: 32 ]] { + input stream in; + output stream out; + process { out = in @ 32; } + } + graph G { + input stream x; + output stream y; + node os = Ident * 4; + node look = Look; + connection { x -> y; x -> os.in; os.out -> look.in; look.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + EXPECT_EQ (latencyOversamplerDelay + 32, graph.getLatencySamples()); + + constexpr int blockSize = 256; + graph.prepare (48000.0, blockSize); + + std::vector impulse (static_cast (blockSize), 0.0f); + impulse[0] = 1.0f; + + const auto out = latencyRunOnce (graph, impulse, 1); + + int peak = 0; + + for (int i = 1; i < blockSize; ++i) + if (std::fabs (out[0][static_cast (i)]) > std::fabs (out[0][static_cast (peak)])) + peak = i; + + EXPECT_EQ (latencyOversamplerDelay + 32, peak); + EXPECT_GT (out[0][static_cast (peak)], 1.5f); +} + +//============================================================================== + +TEST (YdspLatencyTests, UndersampledNodePassesLowFrequencyContentThrough) +{ + YdspCompiler compiler; + + auto graph = latencyCompile (R"YDSP( + graph G { + input stream x; + output stream y; + node slow = Ident / 4; + connection { x -> slow.in; slow.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + + const auto expectedLatency = latencyOversamplerDelay * 4 + 3; + EXPECT_EQ (expectedLatency, graph.getLatencySamples()); + + constexpr int blockSize = 256; + constexpr int blockCount = 8; + constexpr double sampleRate = 48000.0; + + graph.prepare (sampleRate, blockSize); + + const auto input = latencySine (blockSize * blockCount, 300.0, sampleRate); + const auto out = latencyRunBlocks (graph, input, blockSize, blockCount, 1); + + const auto* expected = input.data() + blockSize * (blockCount - 1) - expectedLatency; + + double error = 0.0; + double reference = 0.0; + + for (int i = 0; i < blockSize; ++i) + { + const auto diff = static_cast (out[0][static_cast (i)]) - static_cast (expected[i]); + error += diff * diff; + reference += static_cast (expected[i]) * static_cast (expected[i]); + } + + ASSERT_GT (reference, 1.0); // not vacuously passing on silence + EXPECT_LT (std::sqrt (error / reference), 0.1) << "decimated round trip did not reproduce the input"; +} + +TEST (YdspLatencyTests, UndersampledNodeSurvivesABlockSizeThatIsNotAMultipleOfTheFactor) +{ + YdspCompiler compiler; + + auto graph = latencyCompile (R"YDSP( + graph G { + input stream x; + output stream y; + node slow = Ident / 4; + connection { x -> slow.in; slow.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + + constexpr int blockSize = 100; // deliberately not a multiple of 4 + constexpr int blockCount = 16; + constexpr double sampleRate = 48000.0; + + graph.prepare (sampleRate, blockSize); + + const auto input = latencySine (blockSize * blockCount, 200.0, sampleRate); + const auto out = latencyRunBlocks (graph, input, blockSize, blockCount, 1); + + for (int i = 1; i < blockSize; ++i) + { + const auto sample = out[0][static_cast (i)]; + ASSERT_TRUE (std::isfinite (sample)) << "sample " << i; + EXPECT_LT (std::fabs (sample - out[0][static_cast (i - 1)]), 0.2f) << "discontinuity at sample " << i; + } +} + +TEST (YdspLatencyTests, CompensatesAnUndersampledBranchAgainstADryOne) +{ + YdspCompiler compiler; + + auto graph = latencyCompile (R"YDSP( + graph G { + input stream x; + output stream y; + node slow = Ident / 2; + node mix = Diff; + connection { x -> mix.a; x -> slow.in; slow.out -> mix.b; mix.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + EXPECT_EQ (latencyOversamplerDelay * 2 + 1, graph.getLatencySamples()); + + constexpr int blockSize = 256; + constexpr int blockCount = 8; + constexpr double sampleRate = 48000.0; + + graph.prepare (sampleRate, blockSize); + + const auto input = latencySine (blockSize * blockCount, 200.0, sampleRate); + const auto out = latencyRunBlocks (graph, input, blockSize, blockCount, 1); + + const auto residual = latencyRms (out[0].data(), blockSize); + const auto reference = latencyRms (input.data() + blockSize * (blockCount - 1), blockSize); + + ASSERT_GT (reference, 0.5); + EXPECT_LT (residual, reference * 0.15) << "residual RMS " << residual << " against input RMS " << reference; +} + +TEST (YdspLatencyTests, ARateChangedKernelReportsItsOwnSampleRate) +{ + YdspCompiler compiler; + + auto graph = latencyCompile (R"YDSP( + processor Report { input stream in; output stream out; process { out = sampleRate; } } + graph G { + input stream x; + output stream fast; + output stream slow; + node up = Report * 4; + node down = Report / 4; + connection { x -> up.in; x -> down.in; up.out -> fast; down.out -> slow; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + + constexpr int blockSize = 128; + constexpr double sampleRate = 48000.0; + + graph.prepare (sampleRate, blockSize); + + const std::vector input (static_cast (blockSize), 0.0f); + + const auto out = latencyRunBlocks (graph, std::vector (static_cast (blockSize * 6), 0.0f), blockSize, 6, 2); + + EXPECT_NEAR (sampleRate * 4.0, out[0][static_cast (blockSize - 1)], sampleRate * 0.02) + << "an oversampled kernel should see 4x the graph rate"; + + EXPECT_NEAR (sampleRate / 4.0, out[1][static_cast (blockSize - 1)], sampleRate * 0.02) + << "an undersampled kernel should see a quarter of the graph rate"; +} + +TEST (YdspLatencyTests, RejectsAnUnsupportedUndersamplingFactor) +{ + const auto errors = latencyCompileError (R"YDSP( + graph G { + input stream x; + output stream y; + node slow = Ident / 3; + connection { x -> slow.in; slow.out -> y; } + } + )YDSP"); + + EXPECT_TRUE (errors.contains ("undersampling (/N) supports a factor of 2, 4 or 8")); +} + +TEST (YdspLatencyTests, LeavesAPlainFanInUncompensated) +{ + YdspCompiler compiler; + + auto graph = latencyCompile (R"YDSP( + graph G { + input stream x; + output stream y; + node a = Ident; + node b = Ident; + connection { x -> a.in; x -> b.in; a.out -> y; b.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + EXPECT_EQ (0, graph.getLatencySamples()); + + constexpr int blockSize = 8; + graph.prepare (48000.0, blockSize); + + std::vector input { 1.0f, 2.0f, -0.5f, 0.25f, 4.0f, 0.0f, -3.0f, 1.5f }; + const auto out = latencyRunOnce (graph, input, 1); + + for (size_t i = 0; i < input.size(); ++i) + EXPECT_NEAR (input[i] * 2.0f, out[0][i], 1e-6f) << "sample " << i; +} + +} // namespace yup::test diff --git a/tests/yup_dsp_jit/yup_YdspLexerParserTests.cpp b/tests/yup_dsp_jit/yup_YdspLexerParserTests.cpp new file mode 100644 index 000000000..a766c408e --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspLexerParserTests.cpp @@ -0,0 +1,1985 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +namespace yup::test +{ + +namespace +{ + +std::vector tokenize (StringRef source, YdspDiagnostics& diagnostics) +{ + YdspLexer lexer (source, diagnostics); + return lexer.tokenize(); +} + +std::unique_ptr parse (StringRef source, YdspDiagnostics& diagnostics) +{ + YdspLexer lexer (source, diagnostics); + auto tokens = lexer.tokenize(); + + YdspParser parser (std::move (tokens), diagnostics); + return parser.parseProgram(); +} + +std::vector typesOf (const std::vector& tokens) +{ + std::vector types; + + for (const auto& token : tokens) + types.push_back (token.type); + + return types; +} + +} // namespace + +//============================================================================== + +TEST (YdspLexerTests, TokensCarryExclusiveRangesAndSourceIdentity) +{ + YdspDiagnostics diagnostics; + diagnostics.setSourceId ("range.ydsp"); + const auto tokens = tokenize ("\n hello + 12", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_EQ (4u, tokens.size()); + EXPECT_EQ ("range.ydsp", tokens[0].range.sourceId); + EXPECT_EQ (2, tokens[0].range.startLine); + EXPECT_EQ (3, tokens[0].range.startColumn); + EXPECT_EQ (2, tokens[0].range.endLine); + EXPECT_EQ (8, tokens[0].range.endColumn); + EXPECT_EQ (13, tokens.back().range.startColumn); + EXPECT_EQ (13, tokens.back().range.endColumn); +} + +TEST (YdspLexerTests, RejectsMalformedExponentsAndBaseDigitsAtTheirRange) +{ + for (const auto* source : { "1e+", "2E-", "3e", "0b102", "0x12g" }) + { + SCOPED_TRACE (source); + YdspDiagnostics diagnostics; + tokenize (source, diagnostics); + ASSERT_EQ (1, diagnostics.getCount()); + EXPECT_EQ (1, diagnostics.getItem (0).range.startColumn); + EXPECT_EQ (String (source).length() + 1, diagnostics.getItem (0).range.endColumn); + EXPECT_TRUE (diagnostics.hasErrors()); + } +} + +TEST (YdspParserTests, MissingSemicolonPreservesClosingBracesAndReportsFoundToken) +{ + YdspDiagnostics diagnostics; + const auto program = parse ("processor P { output stream out; process { out = 1.0 } }", diagnostics); + ASSERT_EQ (1, diagnostics.getCount()) << diagnostics.toString(); + EXPECT_TRUE (diagnostics.getItem (0).message.contains ("Expected")); + EXPECT_TRUE (diagnostics.getItem (0).message.contains ("found '}'")); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->processors.size()); +} + +TEST (YdspLexerTests, TokenizesKeywordsAndIdentifiers) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize ("processor Saturator { input stream in; }", diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + EXPECT_EQ ((std::vector { + YdspTokenType::kwProcessor, + YdspTokenType::identifier, + YdspTokenType::lBrace, + YdspTokenType::kwInput, + YdspTokenType::kwStream, + YdspTokenType::identifier, + YdspTokenType::semi, + YdspTokenType::rBrace, + YdspTokenType::endOfFile }), + typesOf (tokens)); +} + +TEST (YdspLexerTests, TokenizesNumbersAndRange) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize ("0..blockSize 1.5 2e3 42", diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + EXPECT_EQ ((std::vector { + YdspTokenType::intLiteral, + YdspTokenType::range, + YdspTokenType::identifier, + YdspTokenType::floatLiteral, + YdspTokenType::floatLiteral, + YdspTokenType::intLiteral, + YdspTokenType::endOfFile }), + typesOf (tokens)); +} + +TEST (YdspLexerTests, TokenizesLeadingAndTrailingDotFloats) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize (".5 1. 0..blockSize 1.sin", diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + EXPECT_EQ ((std::vector { + YdspTokenType::floatLiteral, // .5 + YdspTokenType::floatLiteral, // 1. + YdspTokenType::intLiteral, // 0 + YdspTokenType::range, // .. + YdspTokenType::identifier, // blockSize + YdspTokenType::intLiteral, // 1 + YdspTokenType::dot, // . (member access, not swallowed) + YdspTokenType::identifier, // sin + YdspTokenType::endOfFile }), + typesOf (tokens)); + + EXPECT_EQ (String (".5"), tokens[0].text); + EXPECT_EQ (String ("1."), tokens[1].text); +} + +TEST (YdspLexerTests, TokenizesHexBinaryAndDigitSeparatorIntegers) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize ("0x1F 0b1010 1_000 _", diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + EXPECT_EQ ((std::vector { + YdspTokenType::intLiteral, // 0x1F + YdspTokenType::intLiteral, // 0b1010 + YdspTokenType::intLiteral, // 1_000 + YdspTokenType::underscore, // standalone '_' is still the graph-algebra wildcard + YdspTokenType::endOfFile }), + typesOf (tokens)); + + EXPECT_EQ (String ("0x1F"), tokens[0].text); + EXPECT_EQ (String ("0b1010"), tokens[1].text); + EXPECT_EQ (String ("1_000"), tokens[2].text); +} + +TEST (YdspLexerTests, ReportsErrorOnHexOrBinaryLiteralWithNoDigits) +{ + YdspDiagnostics hexDiagnostics; + tokenize ("0x", hexDiagnostics); + EXPECT_TRUE (hexDiagnostics.hasErrors()); + + YdspDiagnostics binDiagnostics; + tokenize ("0b", binDiagnostics); + EXPECT_TRUE (binDiagnostics.hasErrors()); +} + +TEST (YdspLexerTests, ParsesStringEscapes) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize (R"( "a\nb\t\"c\"\\d" )", diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + ASSERT_EQ (2u, tokens.size()); + EXPECT_EQ (YdspTokenType::stringLiteral, tokens[0].type); + EXPECT_EQ (String ("a\nb\t\"c\"\\d"), tokens[0].text); +} + +TEST (YdspLexerTests, ReportsErrorOnUnknownStringEscape) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize (R"( "a\qb" )", diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspLexerTests, TokenizesSpecialOperators) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize ("<: :> ~ @ ' -> [[ ]] .. _ ? && || ! ==", diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + EXPECT_EQ ((std::vector { + YdspTokenType::lessColon, + YdspTokenType::colonGreater, + YdspTokenType::tilde, + YdspTokenType::at, + YdspTokenType::apostrophe, + YdspTokenType::arrow, + YdspTokenType::lAnnotation, + YdspTokenType::rAnnotation, + YdspTokenType::range, + YdspTokenType::underscore, + YdspTokenType::question, + YdspTokenType::andAnd, + YdspTokenType::orOr, + YdspTokenType::not_, + YdspTokenType::equal, + YdspTokenType::endOfFile }), + typesOf (tokens)); +} + +TEST (YdspLexerTests, TokenizesBitwiseOperators) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize ("a & b | c ^ d << e >> f ~g &= |= ^= <<= >>= 1 < 2 > 3", diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + EXPECT_EQ ((std::vector { + YdspTokenType::identifier, + YdspTokenType::ampersand, + YdspTokenType::identifier, + YdspTokenType::pipe, + YdspTokenType::identifier, + YdspTokenType::caret, + YdspTokenType::identifier, + YdspTokenType::shl, + YdspTokenType::identifier, + YdspTokenType::shr, + YdspTokenType::identifier, + YdspTokenType::tilde, + YdspTokenType::identifier, + YdspTokenType::ampersandEq, + YdspTokenType::pipeEq, + YdspTokenType::caretEq, + YdspTokenType::shlEq, + YdspTokenType::shrEq, + YdspTokenType::intLiteral, + YdspTokenType::less, + YdspTokenType::intLiteral, + YdspTokenType::greater, + YdspTokenType::intLiteral, + YdspTokenType::endOfFile }), + typesOf (tokens)); +} + +TEST (YdspLexerTests, TokenizesDivideAndModuloCompoundAssignment) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize ("a /= b % c %= d", diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + EXPECT_EQ ((std::vector { + YdspTokenType::identifier, + YdspTokenType::slashEq, + YdspTokenType::identifier, + YdspTokenType::percent, + YdspTokenType::identifier, + YdspTokenType::percentEq, + YdspTokenType::identifier, + YdspTokenType::endOfFile }), + typesOf (tokens)); +} + +TEST (YdspLexerTests, SkipsCommentsAndTracksLines) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize ("// line comment\nprocessor /* block\ncomment */ a", diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + EXPECT_EQ (YdspTokenType::kwProcessor, tokens[0].type); + EXPECT_EQ (2, tokens[0].range.startLine); + EXPECT_EQ (YdspTokenType::identifier, tokens[1].type); + EXPECT_EQ (3, tokens[1].range.startLine); +} + +TEST (YdspLexerTests, DoesNotEndACommentEarlyOnANonAsciiCharacterThatLooksLikeNewlineWhenTruncated) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize (String (CharPointer_UTF8 ("// abc\xC4\x8A" + "def\nprocessor")), + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + ASSERT_EQ (2u, tokens.size()); + EXPECT_EQ (YdspTokenType::kwProcessor, tokens[0].type); + EXPECT_EQ (2, tokens[0].range.startLine); +} + +TEST (YdspLexerTests, PreservesNonAsciiCharactersInStringLiterals) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize (String (CharPointer_UTF8 ("\"a\xC4\x8A" + "b\"")), + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + ASSERT_EQ (YdspTokenType::stringLiteral, tokens[0].type); + EXPECT_EQ (String (CharPointer_UTF8 ("a\xC4\x8A" + "b")), + tokens[0].text); +} + +TEST (YdspLexerTests, ReportsErrorOnUnknownCharacter) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize ("a $ b", diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_EQ (YdspTokenType::identifier, tokens[0].type); + EXPECT_EQ (1, diagnostics.getItem (0).range.startLine); + EXPECT_EQ (3, diagnostics.getItem (0).range.startColumn); +} + +TEST (YdspLexerTests, ReportsErrorOnUnterminatedString) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize ("\"hello", diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_EQ (YdspTokenType::stringLiteral, tokens[0].type); + EXPECT_EQ ("hello", tokens[0].text); +} + +TEST (YdspLexerTests, TokenizesEmptySource) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize ("", diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + ASSERT_EQ (1u, tokens.size()); + EXPECT_EQ (YdspTokenType::endOfFile, tokens[0].type); + EXPECT_EQ (1, tokens[0].range.startLine); + EXPECT_EQ (1, tokens[0].range.startColumn); +} + +TEST (YdspLexerTests, StopsAtEndOfSourceWhenPeekingPastAnIncompleteExponent) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize ("1e+", diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + ASSERT_EQ (1, diagnostics.getCount()); + EXPECT_EQ (String ("Exponent requires at least one digit"), diagnostics.getItem (0).message); + EXPECT_EQ (1, diagnostics.getItem (0).range.startColumn); + EXPECT_EQ (4, diagnostics.getItem (0).range.endColumn); + ASSERT_EQ (2u, tokens.size()); + EXPECT_EQ ((std::vector { + YdspTokenType::floatLiteral, + YdspTokenType::endOfFile }), + typesOf (tokens)); + + EXPECT_EQ (String ("1e+"), tokens[0].text); + EXPECT_EQ (1, tokens.back().range.startLine); + EXPECT_EQ (4, tokens.back().range.startColumn); + EXPECT_EQ (4, tokens.back().range.endColumn); +} + +TEST (YdspLexerTests, StopsAtEndOfSourceWhenPeekingPastAnIncompleteOperator) +{ + YdspDiagnostics diagnostics; + + auto lessTokens = tokenize ("a <", diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + EXPECT_EQ ((std::vector { + YdspTokenType::identifier, + YdspTokenType::less, + YdspTokenType::endOfFile }), + typesOf (lessTokens)); + + auto slashTokens = tokenize ("/", diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + EXPECT_EQ ((std::vector { + YdspTokenType::slash, + YdspTokenType::endOfFile }), + typesOf (slashTokens)); +} + +TEST (YdspLexerTests, CountsColumnsInCharactersNotBytesAfterAMultiByteCharacter) +{ + YdspDiagnostics diagnostics; + + // The '$' sits at character column 5; counting bytes would report 6. + tokenize (String (CharPointer_UTF8 ("\"\xC4\x8A\" $")), diagnostics); + + ASSERT_TRUE (diagnostics.hasErrors()); + EXPECT_EQ (1, diagnostics.getItem (0).range.startLine); + EXPECT_EQ (5, diagnostics.getItem (0).range.startColumn); +} + +//============================================================================== + +TEST (YdspLexerTests, TokenizesFuncAndReturnKeywords) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize ("func return", diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + EXPECT_EQ ((std::vector { + YdspTokenType::kwFunc, + YdspTokenType::kwReturn, + YdspTokenType::endOfFile }), + typesOf (tokens)); +} + +TEST (YdspLexerTests, TokenizesImportKeyword) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize ("import \"filters.ydsp\" as flt;", diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + EXPECT_EQ ((std::vector { + YdspTokenType::kwImport, + YdspTokenType::stringLiteral, + YdspTokenType::identifier, + YdspTokenType::identifier, + YdspTokenType::semi, + YdspTokenType::endOfFile }), + typesOf (tokens)); +} + +//============================================================================== + +TEST (YdspLexerTests, TokenizesParameterKeyword) +{ + YdspDiagnostics diagnostics; + const auto tokens = tokenize ("input parameter float gain = 1.0;", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_GE (tokens.size(), 2u); + EXPECT_EQ (YdspTokenType::kwInput, tokens[0].type); + EXPECT_EQ (YdspTokenType::kwParameter, tokens[1].type); +} + +TEST (YdspParserTests, PreservesExactIntegerLiteralPayloads) +{ + const std::pair cases[] { + { "9007199254740993", 9007199254740993LL }, + { "9_007_199_254_740_993", 9007199254740993LL }, + { "0x20000000000001", 9007199254740993LL }, + { "0b100000000000000000000000000000000000000000000000000001", 9007199254740993LL }, + { "9223372036854775807", std::numeric_limits::max() }, + { "0x7fffffffffffffff", std::numeric_limits::max() }, + { "-0x8000000000000000", std::numeric_limits::min() }, + { "-9223372036854775808", std::numeric_limits::min() } + }; + for (const auto& [literal, expected] : cases) + { + YdspDiagnostics diagnostics; + auto program = parse (String ("let value = ") + literal + ";", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->constants.size()); + EXPECT_EQ (YdspExprKind::intLiteral, program->constants[0].value->kind); + EXPECT_EQ (expected, program->constants[0].value->integer) << literal; + } +} + +TEST (YdspParserTests, RejectsOutOfRangeIntegerLiteralsAndStructuralSizes) +{ + for (const auto* source : { + "let value = 9223372036854775808;", + "let value = 18446744073709551616;", + "let value = -9223372036854775809;", + "let value = 0x8000000000000000;", + "let value = 0x10000000000000000;", + "let value = 0b10000000000000000000000000000000000000000000000000000000000000000;", + "processor P { state float data[4294967297]; }", + "processor P { struct S { float data[4294967297]; } }", + "processor P { input stream float data[4294967297]; }", + "graph G { node p = P[4294967297]; }", + "graph G { node p = P * 4294967297; }", + "graph G { node p = P / 4294967297; }", + "graph G { connection { x -> [4294967297] -> y; } }" }) + { + YdspDiagnostics diagnostics; + parse (source, diagnostics); + ASSERT_TRUE (diagnostics.hasErrors()) << source; + EXPECT_TRUE (diagnostics.toString().contains ("Integer literal exceeds the supported range")) << diagnostics.toString(); + } +} + +TEST (YdspParserTests, RejectsExplicitNonfiniteConstants) +{ + for (const auto* literal : { "1e9999", "-1e9999", "inf", "-inf", "nan" }) + { + YdspDiagnostics diagnostics; + parse (String ("let value = ") + literal + ";", diagnostics); + ASSERT_TRUE (diagnostics.hasErrors()) << literal; + EXPECT_TRUE (diagnostics.toString().contains ("Nonfinite source constants are not allowed")); + } +} + +TEST (YdspLexerTests, ValueIsAnIdentifier) +{ + YdspDiagnostics diagnostics; + const auto tokens = tokenize ("value", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_EQ (2u, tokens.size()); + EXPECT_EQ (YdspTokenType::identifier, tokens[0].type); +} + +TEST (YdspParserTests, RejectsObsoleteInputValuesInProcessorsAndGraphs) +{ + for (const auto* declaration : { "processor", "graph" }) + { + YdspDiagnostics diagnostics; + parse (String (declaration) + " Main { input value float gain = 1.0; }", diagnostics); + + ASSERT_TRUE (diagnostics.hasErrors()) << declaration; + EXPECT_TRUE (diagnostics.toString().contains ("'input value' has been replaced by 'input parameter'")); + } +} + +TEST (YdspParserTests, RejectsObsoleteOutputValuesInProcessorsAndGraphs) +{ + for (const auto* declaration : { "processor", "graph" }) + { + YdspDiagnostics diagnostics; + parse (String (declaration) + " Main { output value float level; }", diagnostics); + + ASSERT_TRUE (diagnostics.hasErrors()) << declaration; + EXPECT_TRUE (diagnostics.toString().contains ("'output value' has been replaced by 'output parameter'")); + } +} + +TEST (YdspParserTests, ParsesGraphParametersWithDefaultsAndAnnotations) +{ + YdspDiagnostics diagnostics; + auto program = parse (R"YDSP( + graph Main { + input parameter float gain = 0.5 [[ min: 0, max: 1 ]]; + output parameter float level; + } + )YDSP", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->graphs.size()); + const auto& endpoints = program->graphs[0].endpoints; + ASSERT_EQ (2u, endpoints.size()); + EXPECT_EQ (YdspEndpointKind::inputParameter, endpoints[0].kind); + ASSERT_NE (nullptr, endpoints[0].defaultValue); + EXPECT_DOUBLE_EQ (0.5, endpoints[0].defaultValue->number); + EXPECT_EQ (2u, endpoints[0].annotations.size()); + EXPECT_EQ (YdspEndpointKind::outputParameter, endpoints[1].kind); +} + +TEST (YdspParserTests, ParsesProcessorWithEndpointsStateAndProcess) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor Saturator { + input stream in; + input stream side; + output stream out; + input parameter float drive = 0.5 [[ name: "Drive", min: 0, max: 2 ]]; + output parameter float level; + state float z; + state float buf[256]; + process { + out = tanh (in * drive) * (1 + 0.5 * side); + z = 0.999 * z + in; + level = abs (z); + } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->processors.size()); + + const auto& processor = program->processors[0]; + EXPECT_EQ ("Saturator", processor.name); + + ASSERT_EQ (5u, processor.endpoints.size()); + EXPECT_EQ (YdspEndpointKind::inputStream, processor.endpoints[0].kind); + EXPECT_EQ ("in", processor.endpoints[0].name); + EXPECT_EQ (YdspEndpointKind::outputStream, processor.endpoints[2].kind); + EXPECT_EQ (YdspEndpointKind::inputParameter, processor.endpoints[3].kind); + EXPECT_NE (nullptr, processor.endpoints[3].defaultValue); + EXPECT_EQ (YdspExprKind::floatLiteral, processor.endpoints[3].defaultValue->kind); + ASSERT_EQ (3u, processor.endpoints[3].annotations.size()); + EXPECT_EQ ("name", processor.endpoints[3].annotations[0].first); + EXPECT_EQ ("Drive", processor.endpoints[3].annotations[0].second); + + ASSERT_EQ (2u, processor.states.size()); + EXPECT_EQ ("z", processor.states[0].name); + EXPECT_EQ (0, processor.states[0].arraySize); + EXPECT_EQ ("buf", processor.states[1].name); + EXPECT_EQ (256, processor.states[1].arraySize); + + ASSERT_NE (nullptr, processor.process); + EXPECT_EQ (YdspProcessMode::sample, processor.process->mode); + EXPECT_EQ (3u, processor.process->body.size()); +} + +TEST (YdspParserTests, ParsesStateArrayWithoutExplicitSizeInferringFromTheList) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor Wavetable { + output stream out; + state float melodyPitch[] = { 440.0, 554.37, 659.25, 880.0 }; + process { out = melodyPitch[0]; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->processors.size()); + + const auto& processor = program->processors[0]; + ASSERT_EQ (1u, processor.states.size()); + EXPECT_EQ ("melodyPitch", processor.states[0].name); + EXPECT_EQ (4, processor.states[0].arraySize); + EXPECT_EQ (4u, processor.states[0].initialisers.size()); +} + +TEST (YdspParserTests, ParsesMultipleStateDeclarationsSharingOneType) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + output stream out; + state float x, y, z; + process { out = x + y + z; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->processors.size()); + + const auto& processor = program->processors[0]; + ASSERT_EQ (3u, processor.states.size()); + EXPECT_EQ ("x", processor.states[0].name); + EXPECT_EQ (YdspPrimitiveType::float32Type, processor.states[0].type); + EXPECT_EQ (0, processor.states[0].arraySize); + EXPECT_TRUE (processor.states[0].structName.isEmpty()); + EXPECT_EQ ("y", processor.states[1].name); + EXPECT_EQ (YdspPrimitiveType::float32Type, processor.states[1].type); + EXPECT_EQ ("z", processor.states[2].name); + EXPECT_EQ (YdspPrimitiveType::float32Type, processor.states[2].type); + + // The declarators keep the locations of their own names (later columns on the same line). + EXPECT_EQ (processor.states[0].location.startLine, processor.states[1].location.startLine); + EXPECT_LT (processor.states[0].location.startColumn, processor.states[1].location.startColumn); + EXPECT_LT (processor.states[1].location.startColumn, processor.states[2].location.startColumn); +} + +TEST (YdspParserTests, ParsesMultipleStateDeclarationsWithArrayAndInitialiserMix) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + output stream out; + state float amp[2] = { 0.5, 1.0 }, freq = 440.0, table[] = { 2.0, 4.0, 8.0 }, inc; + process { out = amp[0] * freq + table[0] + inc; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + + const auto& processor = program->processors[0]; + ASSERT_EQ (4u, processor.states.size()); + + EXPECT_EQ ("amp", processor.states[0].name); + EXPECT_EQ (2, processor.states[0].arraySize); + ASSERT_EQ (2u, processor.states[0].initialisers.size()); + + EXPECT_EQ ("freq", processor.states[1].name); + EXPECT_EQ (0, processor.states[1].arraySize); + ASSERT_EQ (1u, processor.states[1].initialisers.size()); + + EXPECT_EQ ("table", processor.states[2].name); + EXPECT_EQ (3, processor.states[2].arraySize); + ASSERT_EQ (3u, processor.states[2].initialisers.size()); + + EXPECT_EQ ("inc", processor.states[3].name); + EXPECT_EQ (0, processor.states[3].arraySize); + EXPECT_TRUE (processor.states[3].initialisers.empty()); +} + +TEST (YdspParserTests, ParsesMultipleStructAndAnnotatedStateDeclarations) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + struct Voice { float phase; } + Voice mono, voices[2]; + state int active [[ role: voiceActivity ]], released; + init { mono.phase = 0.5; } + process { out = mono.phase + voices[1].phase; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + + const auto& processor = program->processors[0]; + + ASSERT_EQ (4u, processor.states.size()); + + EXPECT_EQ ("Voice", processor.states[0].structName); + EXPECT_EQ ("mono", processor.states[0].name); + EXPECT_EQ (0, processor.states[0].arraySize); + EXPECT_EQ ("Voice", processor.states[1].structName); + EXPECT_EQ ("voices", processor.states[1].name); + EXPECT_EQ (2, processor.states[1].arraySize); + + EXPECT_EQ (YdspPrimitiveType::int32Type, processor.states[2].type); + EXPECT_EQ ("active", processor.states[2].name); + ASSERT_EQ (1u, processor.states[2].annotations.size()); + EXPECT_EQ ("role", processor.states[2].annotations[0].first); + EXPECT_EQ ("voiceActivity", processor.states[2].annotations[0].second); + + EXPECT_EQ (YdspPrimitiveType::int32Type, processor.states[3].type); + EXPECT_EQ ("released", processor.states[3].name); + EXPECT_TRUE (processor.states[3].annotations.empty()); +} + +TEST (YdspParserTests, ReportsMissingStateDeclaratorNameOnceAndStopsTheList) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + output stream out; + state float x, 1.5, y; + process { out = x; } + } + )YDSP", + diagnostics); + + ASSERT_TRUE (diagnostics.hasErrors()); + ASSERT_EQ (1, diagnostics.getCount()); + EXPECT_TRUE (diagnostics.getItem (0).message.contains ("Expected a state name")); + + ASSERT_NE (nullptr, program); + const auto& processor = program->processors[0]; + + // The list stops at the bad declarator; the rest of the patch still parses. + ASSERT_EQ (1u, processor.states.size()); + EXPECT_EQ ("x", processor.states[0].name); + ASSERT_NE (nullptr, processor.process); +} + +TEST (YdspParserTests, ParsesBracedListAnnotationValue) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor Selector { + output stream out; + input parameter float wave = 0.0 [[ name: "Waveform", min: 0.0, max: 3.0, values: { "Saw", "Square", "Triangle", "Pulse" } ]]; + process { out = wave; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->processors.size()); + + const auto& annotations = program->processors[0].endpoints[1].annotations; + ASSERT_EQ (4u, annotations.size()); + EXPECT_EQ ("values", annotations[3].first); + + // The braced entries arrive joined by commas, ready to be split apart. + const auto entries = StringArray::fromTokens (annotations[3].second, ",", ""); + ASSERT_EQ (4, entries.size()); + EXPECT_EQ ("Saw", entries[0]); + EXPECT_EQ ("Square", entries[1]); + EXPECT_EQ ("Triangle", entries[2]); + EXPECT_EQ ("Pulse", entries[3]); +} + +TEST (YdspParserTests, ParsesBooleanAnnotationValue) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + output stream out; + input parameter float pan = 0.0 [[ name: "Pan", min: -1.0, max: 1.0, bipolar: true ]]; + input parameter float depth = 0.0 [[ bipolar: false ]]; + process { out = pan + depth; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->processors.size()); + + const auto& panAnnotations = program->processors[0].endpoints[1].annotations; + ASSERT_EQ (4u, panAnnotations.size()); + EXPECT_EQ ("bipolar", panAnnotations[3].first); + EXPECT_EQ ("true", panAnnotations[3].second); + + const auto& depthAnnotations = program->processors[0].endpoints[2].annotations; + ASSERT_EQ (1u, depthAnnotations.size()); + EXPECT_EQ ("bipolar", depthAnnotations[0].first); + EXPECT_EQ ("false", depthAnnotations[0].second); +} + +TEST (YdspParserTests, DoesNotPushANullStatementIntoTheProcessBody) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + output stream out; + process { ; out = 1.0; } + } + )YDSP", + diagnostics); + + ASSERT_TRUE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->processors.size()); + ASSERT_NE (nullptr, program->processors[0].process); + + for (const auto& stmt : program->processors[0].process->body) + EXPECT_NE (nullptr, stmt); +} + +TEST (YdspParserTests, ReportsDeeplyNestedExpressionsInsteadOfOverflowingTheStack) +{ + YdspDiagnostics diagnostics; + + String source = "processor P { output stream out; process { out = "; + + for (int i = 0; i < 10000; ++i) + source += "("; + + source += "1.0"; + + for (int i = 0; i < 10000; ++i) + source += ")"; + + source += "; } }"; + + auto program = parse (source, diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspParserTests, ParsesBlockProcessWithForLoop) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor Delay { + input stream in; + output stream out; + state float mem[8192]; + process block { + for i in 0..blockSize { + out[i] = mem[i]; + } + } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + + const auto& processor = program->processors[0]; + ASSERT_NE (nullptr, processor.process); + EXPECT_EQ (YdspProcessMode::block, processor.process->mode); + ASSERT_EQ (1u, processor.process->body.size()); + EXPECT_EQ (YdspStmtKind::forStmt, processor.process->body[0]->kind); + EXPECT_EQ ("i", processor.process->body[0]->name); + EXPECT_EQ (YdspExprKind::intLiteral, processor.process->body[0]->startExpr->kind); + EXPECT_EQ (YdspExprKind::identifier, processor.process->body[0]->endExpr->kind); + EXPECT_EQ ("blockSize", processor.process->body[0]->endExpr->text); +} + +TEST (YdspParserTests, DesugarsCompoundAssignmentOnIndexedAndMemberTargets) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + output stream out; + state float buf[8]; + process block { + for i in 0..8 { + buf[i] += 1.0; + } + } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + + const auto& forBody = program->processors[0].process->body[0]->body->children; + ASSERT_EQ (1u, forBody.size()); + + const auto& assign = *forBody[0]; + ASSERT_EQ (YdspStmtKind::assign, assign.kind); + ASSERT_EQ (YdspExprKind::index, assign.target->kind); + + ASSERT_EQ (YdspExprKind::binary, assign.value->kind); + EXPECT_EQ (YdspOperator::add, assign.value->op); + ASSERT_EQ (YdspExprKind::index, assign.value->children[0]->kind); + EXPECT_EQ ("buf", assign.value->children[0]->children[0]->text); + EXPECT_EQ ("i", assign.value->children[0]->children[1]->text); + + YdspDiagnostics memberDiagnostics; + + auto memberProgram = parse (R"YDSP( + processor P { + output stream out; + struct Voice { float phase; } + Voice v; + process { v.phase += 0.25; } + } + )YDSP", + memberDiagnostics); + + ASSERT_FALSE (memberDiagnostics.hasErrors()); + ASSERT_NE (nullptr, memberProgram); + + const auto& memberAssign = *memberProgram->processors[0].process->body[0]; + ASSERT_EQ (YdspStmtKind::assign, memberAssign.kind); + ASSERT_EQ (YdspExprKind::member, memberAssign.target->kind); + + ASSERT_EQ (YdspExprKind::binary, memberAssign.value->kind); + EXPECT_EQ (YdspOperator::add, memberAssign.value->op); + ASSERT_EQ (YdspExprKind::member, memberAssign.value->children[0]->kind); + EXPECT_EQ ("phase", memberAssign.value->children[0]->text); + EXPECT_EQ ("v", memberAssign.value->children[0]->children[0]->text); +} + +TEST (YdspParserTests, ParsesDivideAndModuloCompoundAssignment) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + output stream out; + state float x = 8.0; + process { + x /= 2.0; + x %= 3.0; + } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + + const auto& body = program->processors[0].process->body; + ASSERT_EQ (2u, body.size()); + + ASSERT_EQ (YdspStmtKind::assign, body[0]->kind); + EXPECT_EQ (YdspOperator::div, body[0]->value->op); + + ASSERT_EQ (YdspStmtKind::assign, body[1]->kind); + EXPECT_EQ (YdspOperator::mod, body[1]->value->op); +} + +TEST (YdspParserTests, ParsesExpressions) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + input stream in; + output stream out; + state float z; + process { + let a = -in + z * 2; + out = (a > 0.5) ? sin (a) : a'; + z = a @ 10; + } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + + const auto& body = program->processors[0].process->body; + + // let a = -in + z * 2; + ASSERT_EQ (YdspStmtKind::localDecl, body[0]->kind); + EXPECT_TRUE (body[0]->isLet); + EXPECT_EQ (YdspExprKind::binary, body[0]->value->kind); + EXPECT_EQ (YdspOperator::add, body[0]->value->op); + EXPECT_EQ (YdspExprKind::unary, body[0]->value->children[0]->kind); + EXPECT_EQ (YdspOperator::neg, body[0]->value->children[0]->op); + + // out = (a > 0.5) ? sin (a) : a'; + ASSERT_EQ (YdspStmtKind::assign, body[1]->kind); + EXPECT_EQ (YdspExprKind::ternary, body[1]->value->kind); + EXPECT_EQ (YdspExprKind::call, body[1]->value->children[1]->kind); + EXPECT_EQ ("sin", body[1]->value->children[1]->text); + EXPECT_EQ (YdspExprKind::prev, body[1]->value->children[2]->kind); + + // z = a @ 10; + ASSERT_EQ (YdspStmtKind::assign, body[2]->kind); + EXPECT_EQ (YdspExprKind::delay, body[2]->value->kind); + EXPECT_EQ (YdspExprKind::intLiteral, body[2]->value->children[1]->kind); +} + +TEST (YdspParserTests, ParsesGraphWithConnections) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + graph MyPatch { + input stream dry; + input stream side; + output stream wet; + input parameter float master = 0.8; + node sat = Saturator (drive = 1.5); + node dly = Delay (time = 0.25); + connection { + dry -> sat.in; + side -> sat.side; + sat.out -> dly.in; + dly.out -> wet; + master -> sat.drive; + } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->graphs.size()); + + const auto& graph = program->graphs[0]; + EXPECT_EQ ("MyPatch", graph.name); + ASSERT_EQ (4u, graph.endpoints.size()); + ASSERT_EQ (2u, graph.nodes.size()); + EXPECT_EQ ("sat", graph.nodes[0].instanceName); + EXPECT_EQ ("Saturator", graph.nodes[0].processorName); + ASSERT_EQ (1u, graph.nodes[0].overrides.size()); + EXPECT_EQ ("drive", graph.nodes[0].overrides[0].first); + + EXPECT_EQ (YdspGraphBodyKind::connections, graph.bodyKind); + ASSERT_EQ (5u, graph.connections.size()); + EXPECT_EQ ("dry", graph.connections[0].sourcePath); + EXPECT_EQ ("sat.in", graph.connections[0].destPath); + EXPECT_EQ ("sat.out", graph.connections[2].sourcePath); + EXPECT_EQ ("master", graph.connections[4].sourcePath); + EXPECT_EQ ("sat.drive", graph.connections[4].destPath); +} + +TEST (YdspParserTests, ParsesGraphWithAlgebra) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + graph Chain { + input stream dry; + output stream wet; + node sat = Saturator (drive = 1.5); + process = dry : sat : wet; + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_EQ (1u, program->graphs.size()); + + const auto& graph = program->graphs[0]; + EXPECT_EQ (YdspGraphBodyKind::algebra, graph.bodyKind); + ASSERT_NE (nullptr, graph.algebraRoot); + EXPECT_EQ (YdspExprKind::graphOp, graph.algebraRoot->kind); + EXPECT_EQ (YdspOperator::seq, graph.algebraRoot->op); + + // Left-associative: ((dry : sat) : wet) + ASSERT_EQ (2u, graph.algebraRoot->children.size()); + EXPECT_EQ (YdspExprKind::graphLeaf, graph.algebraRoot->children[1]->kind); + EXPECT_EQ ("wet", graph.algebraRoot->children[1]->text); + + const auto& inner = *graph.algebraRoot->children[0]; + EXPECT_EQ (YdspExprKind::graphOp, inner.kind); + EXPECT_EQ (YdspOperator::seq, inner.op); + EXPECT_EQ ("dry", inner.children[0]->text); + EXPECT_EQ ("sat", inner.children[1]->text); +} + +TEST (YdspParserTests, ParsesAlgebraWithParallelSplitAndRecursion) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + graph G { + input stream x, y; + output stream a, b; + process = x , y : ( _ , _ ) : (a , b); + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_EQ (1u, program->graphs.size()); + EXPECT_EQ (YdspExprKind::graphOp, program->graphs[0].algebraRoot->kind); +} + +TEST (YdspParserTests, ParsesDeclareMetadata) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + declare name "MyPatch"; + declare author "Jane"; + processor P { input stream in; output stream out; process { out = in; } } + graph G { input stream in; output stream out; node p = P; process = in : p : out; } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (2u, program->declares.size()); + EXPECT_EQ ("name", program->declares[0].key); + EXPECT_EQ ("MyPatch", program->declares[0].value); + EXPECT_EQ ("author", program->declares[1].key); +} + +TEST (YdspParserTests, ReportsMissingClosingBrace) +{ + YdspDiagnostics diagnostics; + + auto program = parse ("processor P { input stream in;", diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_EQ (1, diagnostics.getItem (0).range.startLine); + EXPECT_NE (nullptr, program); +} + +TEST (YdspParserTests, ParsesMultipleGraphsAndTheMainAnnotation) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + graph A { input stream i; output stream o; process = i : o; } + graph B [[ main ]] { input stream i; output stream o; process = i : o; } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, program); + ASSERT_EQ (2u, program->graphs.size()); + EXPECT_EQ ("A", program->graphs[0].name); + EXPECT_TRUE (program->graphs[0].annotations.empty()); + EXPECT_EQ ("B", program->graphs[1].name); + ASSERT_EQ (1u, program->graphs[1].annotations.size()); + EXPECT_EQ ("main", program->graphs[1].annotations[0].first); +} + +TEST (YdspParserTests, RejectsStatementOutsideProcess) +{ + YdspDiagnostics diagnostics; + + auto program = parse ("processor P { out = in; }", diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspParserTests, ReportsExpressionErrorWithLocation) +{ + YdspDiagnostics diagnostics; + + auto program = parse ("processor P { input stream in; output stream out; process { out = + ; } }", diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_EQ (1, diagnostics.getItem (0).range.startLine); +} + +TEST (YdspParserTests, ParsesFunctionDeclaration) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor WithFunc { + input stream in; + output stream out; + func add(a: float, b: float) : float { + return a + b; + } + process { out = in; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->processors.size()); + + const auto& processor = program->processors[0]; + ASSERT_EQ (1u, processor.functions.size()); + EXPECT_EQ ("add", processor.functions[0].name); + EXPECT_EQ (2u, processor.functions[0].params.size()); + EXPECT_EQ ("a", processor.functions[0].params[0].first); + EXPECT_EQ (YdspPrimitiveType::float32Type, processor.functions[0].params[0].second); + EXPECT_EQ ("b", processor.functions[0].params[1].first); + EXPECT_TRUE (processor.functions[0].hasReturnType); + EXPECT_EQ (YdspPrimitiveType::float32Type, processor.functions[0].returnType); + ASSERT_EQ (1u, processor.functions[0].body.size()); + EXPECT_EQ (YdspStmtKind::returnStmt, processor.functions[0].body[0]->kind); +} + +TEST (YdspParserTests, RejectsLegacyTypeFirstFunctionParameters) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor WithFunc { + input stream in; + output stream out; + func add(float a, float b) : float { + return a + b; + } + process { out = in; } + } + )YDSP", + diagnostics); + + ASSERT_TRUE (diagnostics.hasErrors()); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + { + if (diagnostics.getItem (i).message.contains ("':' after the parameter name")) + found = true; + } + + EXPECT_TRUE (found); +} + +TEST (YdspParserTests, ParsesFunctionWithoutReturnType) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + input stream in; + output stream out; + func doit(n: int) { + out = in * float(n); + } + process { out = in; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + const auto& processor = program->processors[0]; + EXPECT_FALSE (processor.functions[0].hasReturnType); +} + +TEST (YdspParserTests, ParsesFunctionCallInProcess) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + input stream in; + output stream out; + func scale(x: float, s: float) : float { + return x * s; + } + process { out = scale(in, 2.0); } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + const auto& body = program->processors[0].process->body; + ASSERT_EQ (1u, body.size()); + ASSERT_EQ (YdspStmtKind::assign, body[0]->kind); + EXPECT_EQ (YdspExprKind::call, body[0]->value->kind); + EXPECT_EQ ("scale", body[0]->value->text); + EXPECT_EQ (2u, body[0]->value->children.size()); +} + +TEST (YdspParserTests, ParsesImportDirective) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + import filters as flt; + processor P { input stream in; output stream out; process { out = in; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->imports.size()); + EXPECT_EQ ("filters", program->imports[0].path); + EXPECT_EQ ("flt", program->imports[0].alias); +} + +TEST (YdspParserTests, ParsesImportWithoutAlias) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + import lib.utils; + processor P { input stream in; output stream out; process { out = in; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->imports.size()); + EXPECT_EQ ("lib.utils", program->imports[0].path); + EXPECT_TRUE (program->imports[0].alias.isEmpty()); +} + +TEST (YdspParserTests, ParsesNamespacedNodeProcessorName) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + graph G { input stream x; output stream y; node d = fx.Delay (time = 0.5); connection { x -> d.in; d.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->graphs.size()); + ASSERT_EQ (1u, program->graphs[0].nodes.size()); + EXPECT_EQ ("fx.Delay", program->graphs[0].nodes[0].processorName); + EXPECT_EQ (1u, program->graphs[0].nodes[0].overrides.size()); +} + +TEST (YdspParserTests, ParsesNamespacedAlgebraProcessor) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + graph G { input stream x; output stream y; process = x : fx.Delay : y; } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->graphs.size()); + ASSERT_NE (nullptr, program->graphs[0].algebraRoot); + + // x : fx.Delay : y parses as (x : fx.Delay) : y; find the fx.Delay leaf. + const auto* root = program->graphs[0].algebraRoot.get(); + ASSERT_EQ (YdspExprKind::graphOp, root->kind); + ASSERT_EQ (2u, root->children.size()); + + const auto* lhs = root->children[0].get(); + ASSERT_EQ (YdspExprKind::graphOp, lhs->kind); + ASSERT_EQ (2u, lhs->children.size()); + + const auto* leaf = lhs->children[1].get(); + EXPECT_EQ (YdspExprKind::graphLeaf, leaf->kind); + EXPECT_EQ ("fx.Delay", leaf->text); +} + +TEST (YdspParserTests, ParsesNodeWithOversampling) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor Sat { input stream in; output stream out; process { out = tanh(in); } } + graph G { + input stream x; + output stream y; + node sat = Sat * 4; + connection { x -> sat.in; sat.out -> y; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->graphs.size()); + ASSERT_EQ (1u, program->graphs[0].nodes.size()); + EXPECT_EQ (4, program->graphs[0].nodes[0].rateMultiplier); + EXPECT_EQ (1, program->graphs[0].nodes[0].rateDivider); +} + +TEST (YdspParserTests, ParsesNodeWithUndersampling) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor Sat { input stream in; output stream out; process { out = tanh(in); } } + graph G { + input stream x; + output stream y; + node sat = Sat (drive = 1.5) / 2; + connection { x -> sat.in; sat.out -> y; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->graphs.size()); + ASSERT_EQ (1u, program->graphs[0].nodes.size()); + EXPECT_EQ (1, program->graphs[0].nodes[0].rateMultiplier); + EXPECT_EQ (2, program->graphs[0].nodes[0].rateDivider); +} + +TEST (YdspParserTests, ParsesStructAndInitDeclarations) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + struct Voice { float phase; float buf[8]; int idx; } + Voice mono; + Voice voices[4]; + init { mono.phase = 0.5; voices[1].idx = 3; } + process { out = mono.phase + voices[1].idx; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + + const auto& processor = program->processors[0]; + + ASSERT_EQ (1u, processor.structs.size()); + EXPECT_EQ ("Voice", processor.structs[0].name); + ASSERT_EQ (3u, processor.structs[0].fields.size()); + EXPECT_EQ ("phase", processor.structs[0].fields[0].name); + EXPECT_EQ ("buf", processor.structs[0].fields[1].name); + EXPECT_EQ (8, processor.structs[0].fields[1].arraySize); + EXPECT_EQ ("idx", processor.structs[0].fields[2].name); + + ASSERT_EQ (2u, processor.states.size()); + EXPECT_EQ ("Voice", processor.states[0].structName); + EXPECT_EQ (0, processor.states[0].arraySize); + EXPECT_EQ ("Voice", processor.states[1].structName); + EXPECT_EQ (4, processor.states[1].arraySize); + + ASSERT_NE (nullptr, processor.init); + EXPECT_FALSE (processor.init->body.empty()); + EXPECT_NE (nullptr, processor.process); +} + +TEST (YdspParserTests, ParsesFloatIntAliasesAs32BitTypes) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + input parameter float p = 0.5; + input parameter int q = 1; + state float z; + state int wp; + func mix(a: float, b: int) : float { return a + float(b); } + process { + float f = 0.5; + int i = 1; + out = f * float(i); + } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + const auto& processor = program->processors[0]; + + EXPECT_EQ (YdspPrimitiveType::float32Type, processor.endpoints[0].type); + EXPECT_EQ (YdspPrimitiveType::int32Type, processor.endpoints[1].type); + EXPECT_EQ (YdspPrimitiveType::float32Type, processor.states[0].type); + EXPECT_EQ (YdspPrimitiveType::int32Type, processor.states[1].type); + EXPECT_EQ (YdspPrimitiveType::float32Type, processor.functions[0].params[0].second); + EXPECT_EQ (YdspPrimitiveType::int32Type, processor.functions[0].params[1].second); + EXPECT_EQ (YdspPrimitiveType::float32Type, processor.functions[0].returnType); + + ASSERT_NE (nullptr, processor.process); + const auto& f = processor.process->body[0]; + const auto& i = processor.process->body[1]; + ASSERT_EQ (YdspStmtKind::localDecl, f->kind); + ASSERT_EQ (YdspStmtKind::localDecl, i->kind); + EXPECT_EQ (YdspPrimitiveType::float32Type, f->declType); + EXPECT_EQ (YdspPrimitiveType::int32Type, i->declType); +} + +TEST (YdspParserTests, ParsesExplicit32And64BitTypes) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + input parameter float64 acc = 0.0; + input parameter int64 counter = 0; + input stream float64 in64; + output stream float64 out64; + state float64 z; + state int64 wp; + func add(a: float64, b: int64) : float64 { return a + float64(b); } + process { + float32 f = 0.5; + float64 d = 0.25; + int32 i = 1; + int64 j = 2; + out64 = d * f; + acc = acc + d; + counter = counter + j; + } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + const auto& processor = program->processors[0]; + + ASSERT_EQ (4u, processor.endpoints.size()); + EXPECT_EQ (YdspPrimitiveType::float64Type, processor.endpoints[0].type); + EXPECT_EQ (YdspEndpointKind::inputParameter, processor.endpoints[0].kind); + EXPECT_EQ (YdspPrimitiveType::int64Type, processor.endpoints[1].type); + EXPECT_EQ (YdspPrimitiveType::float64Type, processor.endpoints[2].type); + EXPECT_EQ (YdspEndpointKind::inputStream, processor.endpoints[2].kind); + EXPECT_EQ (YdspPrimitiveType::float64Type, processor.endpoints[3].type); + EXPECT_EQ (YdspEndpointKind::outputStream, processor.endpoints[3].kind); + EXPECT_EQ (YdspPrimitiveType::float64Type, processor.states[0].type); + EXPECT_EQ (YdspPrimitiveType::int64Type, processor.states[1].type); + EXPECT_EQ (YdspPrimitiveType::float64Type, processor.functions[0].params[0].second); + EXPECT_EQ (YdspPrimitiveType::int64Type, processor.functions[0].params[1].second); + EXPECT_EQ (YdspPrimitiveType::float64Type, processor.functions[0].returnType); + + ASSERT_NE (nullptr, processor.process); + ASSERT_EQ (7u, processor.process->body.size()); + EXPECT_EQ (YdspPrimitiveType::float32Type, processor.process->body[0]->declType); + EXPECT_EQ (YdspPrimitiveType::float64Type, processor.process->body[1]->declType); + EXPECT_EQ (YdspPrimitiveType::int32Type, processor.process->body[2]->declType); + EXPECT_EQ (YdspPrimitiveType::int64Type, processor.process->body[3]->declType); +} + +TEST (YdspParserTests, ReportsUnknownTypeName) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + input parameter double x = 0.0; + process { x = 1; } + } + )YDSP", + diagnostics); + + ASSERT_TRUE (diagnostics.hasErrors()); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + { + if (diagnostics.getItem (i).message.contains ("Unknown type 'double'")) + found = true; + } + + EXPECT_TRUE (found); + ASSERT_NE (nullptr, program); +} + +TEST (YdspParserTests, ToStringRoundTripsTypeNames) +{ + EXPECT_EQ (StringRef ("float32"), yup::toString (YdspPrimitiveType::float32Type)); + EXPECT_EQ (StringRef ("float64"), yup::toString (YdspPrimitiveType::float64Type)); + EXPECT_EQ (StringRef ("int32"), yup::toString (YdspPrimitiveType::int32Type)); + EXPECT_EQ (StringRef ("int64"), yup::toString (YdspPrimitiveType::int64Type)); + EXPECT_EQ (StringRef ("bool"), yup::toString (YdspPrimitiveType::boolType)); +} + +//============================================================================== + +TEST (YdspLexerTests, TokenizesEventKeyword) +{ + YdspDiagnostics diagnostics; + + auto tokens = tokenize ("event noteOn", diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + ASSERT_EQ (3u, tokens.size()); + EXPECT_EQ (YdspTokenType::kwEvent, tokens[0].type); + EXPECT_EQ (YdspTokenType::identifier, tokens[1].type); + EXPECT_EQ (YdspTokenType::endOfFile, tokens[2].type); +} + +TEST (YdspParserTests, ParsesInputEventEndpoints) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor Voice { + input event midi; + output stream out; + process { out = 0; } + } + graph G { + input event midi; + output stream y; + node v = Voice; + connection { v.out -> y; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->processors.size()); + + const auto& processor = program->processors[0]; + ASSERT_EQ (2u, processor.endpoints.size()); + EXPECT_EQ (YdspEndpointKind::inputEvent, processor.endpoints[0].kind); + EXPECT_EQ ("midi", processor.endpoints[0].name); + EXPECT_EQ (YdspEndpointKind::outputStream, processor.endpoints[1].kind); + EXPECT_EQ ("out", processor.endpoints[1].name); + + ASSERT_EQ (1u, program->graphs.size()); + ASSERT_EQ (2u, program->graphs[0].endpoints.size()); + EXPECT_EQ (YdspEndpointKind::inputEvent, program->graphs[0].endpoints[0].kind); + EXPECT_EQ ("midi", program->graphs[0].endpoints[0].name); + EXPECT_EQ (YdspEndpointKind::outputStream, program->graphs[0].endpoints[1].kind); + EXPECT_EQ ("y", program->graphs[0].endpoints[1].name); +} + +TEST (YdspParserTests, ParsesOutputEventEndpointAtProcessorScope) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + output event noteOn; + process { } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->processors.size()); + + const auto& processor = program->processors[0]; + ASSERT_EQ (1u, processor.endpoints.size()); + EXPECT_EQ (YdspEndpointKind::outputEvent, processor.endpoints[0].kind); + EXPECT_EQ ("noteOn", processor.endpoints[0].name); +} + +TEST (YdspParserTests, ParsesOutputEventEndpointAtGraphScope) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + graph G { + output event noteOn; + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->graphs.size()); + + const auto& graph = program->graphs[0]; + ASSERT_EQ (1u, graph.endpoints.size()); + EXPECT_EQ (YdspEndpointKind::outputEvent, graph.endpoints[0].kind); + EXPECT_EQ ("noteOn", graph.endpoints[0].name); +} + +TEST (YdspParserTests, ParsesEmitStatementInProcessBody) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + output event noteOn; + process { + emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; + } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->processors.size()); + + const auto& processor = program->processors[0]; + ASSERT_NE (nullptr, processor.process); + ASSERT_EQ (1u, processor.process->body.size()); + + const auto& stmt = *processor.process->body[0]; + EXPECT_EQ (YdspStmtKind::emitStmt, stmt.kind); + EXPECT_EQ ("noteOn", stmt.shapeName); + EXPECT_EQ ("noteOn", stmt.endpointName); + ASSERT_EQ (2u, stmt.emitFields.size()); + EXPECT_EQ ("pitch", stmt.emitFields[0].first); + EXPECT_EQ ("velocity", stmt.emitFields[1].first); +} + +TEST (YdspParserTests, ParsesEmitStatementInEventHandlerBody) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + input event midi; + output event noteOn; + event midi (e: noteOn) { + emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; + } + process { } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->processors.size()); + + const auto& processor = program->processors[0]; + ASSERT_EQ (1u, processor.eventHandlers.size()); + ASSERT_EQ (1u, processor.eventHandlers[0].body.size()); + + const auto& stmt = *processor.eventHandlers[0].body[0]; + EXPECT_EQ (YdspStmtKind::emitStmt, stmt.kind); + EXPECT_EQ ("noteOn", stmt.shapeName); + EXPECT_EQ ("noteOn", stmt.endpointName); + ASSERT_EQ (2u, stmt.emitFields.size()); +} + +TEST (YdspParserTests, ParsesEmitStatementWithEmptyFieldList) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + output event noteOn; + process { + emit noteOn () -> noteOn; + } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->processors.size()); + + const auto& processor = program->processors[0]; + ASSERT_NE (nullptr, processor.process); + ASSERT_EQ (1u, processor.process->body.size()); + + const auto& stmt = *processor.process->body[0]; + EXPECT_EQ (YdspStmtKind::emitStmt, stmt.kind); + EXPECT_EQ ("noteOn", stmt.shapeName); + EXPECT_EQ ("noteOn", stmt.endpointName); + EXPECT_TRUE (stmt.emitFields.empty()); +} + +TEST (YdspParserTests, ParsesEventHandlerDeclarations) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor Voice { + input event midi; + state float env; + event midi (e: noteOn) { + env = e.velocity; + } + event midi (e: noteOff) { + env = 0.0; + } + process { } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->processors.size()); + + const auto& processor = program->processors[0]; + ASSERT_EQ (2u, processor.eventHandlers.size()); + EXPECT_EQ ("midi", processor.eventHandlers[0].endpointName); + EXPECT_EQ ("noteOn", processor.eventHandlers[0].shapeName); + EXPECT_EQ ("e", processor.eventHandlers[0].paramName); + EXPECT_FALSE (processor.eventHandlers[0].body.empty()); + EXPECT_EQ ("midi", processor.eventHandlers[1].endpointName); + EXPECT_EQ ("noteOff", processor.eventHandlers[1].shapeName); +} + +TEST (YdspParserTests, ParsesVoiceBankNodeGrammar) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor Voice { + output stream out; + process { out = 0; } + } + graph G { + output stream y; + node a = Voice[8]; + node b = Voice; + node c = Voice[8] * 4; + connection { a.out -> y; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->graphs.size()); + ASSERT_EQ (3u, program->graphs[0].nodes.size()); + + EXPECT_EQ (8, program->graphs[0].nodes[0].voiceCount); + EXPECT_EQ (1, program->graphs[0].nodes[0].rateMultiplier); + EXPECT_EQ (1, program->graphs[0].nodes[1].voiceCount); + EXPECT_EQ (8, program->graphs[0].nodes[2].voiceCount); + EXPECT_EQ (4, program->graphs[0].nodes[2].rateMultiplier); +} + +TEST (YdspParserTests, ParsesNodeVoiceAnnotations) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor Voice { + output stream out; + process { out = 0; } + } + graph G { + output stream y; + node lead = Voice[8] [[ mode: poly, stealing: oldest ]]; + node bass = Voice (gain = 2) / 2 [[ mode: mono, priority: last ]]; + node plain = Voice; + connection { lead.out -> y; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->graphs.size()); + ASSERT_EQ (3u, program->graphs[0].nodes.size()); + + const auto& lead = program->graphs[0].nodes[0].annotations; + ASSERT_EQ (2u, lead.size()); + EXPECT_EQ ("mode", lead[0].first); + EXPECT_EQ ("poly", lead[0].second); + EXPECT_EQ ("stealing", lead[1].first); + EXPECT_EQ ("oldest", lead[1].second); + + const auto& bass = program->graphs[0].nodes[1].annotations; + ASSERT_EQ (2u, bass.size()); + EXPECT_EQ ("mono", bass[0].second); + EXPECT_EQ ("last", bass[1].second); + EXPECT_EQ (2, program->graphs[0].nodes[1].rateDivider); + + EXPECT_TRUE (program->graphs[0].nodes[2].annotations.empty()); +} + +TEST (YdspParserTests, ParsesReservedWordsAsMemberNames) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + output stream out; + input event midi; + state float f; + event midi (e: controlChange) { + f = e.value + float (e.control); + } + process { out = f; } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, program); +} + +TEST (YdspParserTests, ParsesStateAnnotations) +{ + YdspDiagnostics diagnostics; + + auto program = parse (R"YDSP( + processor P { + output stream out; + input event midi; + state int active [[ role: voiceActivity ]]; + state float plain; + state float seeded[4] = { 1, 2 } [[ role: whatever ]]; + event midi (e: noteOn) { active = 1; } + process { out = float (active) + plain + seeded[0]; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, program); + ASSERT_EQ (1u, program->processors.size()); + + const auto& states = program->processors[0].states; + ASSERT_EQ (3u, states.size()); + + ASSERT_EQ (1u, states[0].annotations.size()); + EXPECT_EQ ("role", states[0].annotations[0].first); + EXPECT_EQ ("voiceActivity", states[0].annotations[0].second); + + EXPECT_TRUE (states[1].annotations.empty()); + + EXPECT_EQ (4, states[2].arraySize); + ASSERT_EQ (2u, states[2].initialisers.size()); + ASSERT_EQ (1u, states[2].annotations.size()); + EXPECT_EQ ("whatever", states[2].annotations[0].second); +} + +} // namespace yup::test diff --git a/tests/yup_dsp_jit/yup_YdspOptimizerTests.cpp b/tests/yup_dsp_jit/yup_YdspOptimizerTests.cpp new file mode 100644 index 000000000..957ab4130 --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspOptimizerTests.cpp @@ -0,0 +1,4534 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include +#include +#include + +using namespace yup; + +namespace +{ + +std::unique_ptr buildIr (StringRef source, YdspDiagnostics& diagnostics, bool fastMath) +{ + YdspLexer lexer (source, diagnostics); + auto tokens = lexer.tokenize(); + + YdspParser parser (std::move (tokens), diagnostics); + auto program = parser.parseProgram(); + if (program == nullptr) + return nullptr; + + YdspSemanticAnalyzer analyzer (diagnostics); + auto analyzed = analyzer.analyze (std::move (program)); + if (analyzed == nullptr) + return nullptr; + + YdspOptimizer optimizer (diagnostics); + optimizer.setFastMath (fastMath); + + return optimizer.build (*analyzed); +} + +std::unique_ptr buildIr (StringRef source, YdspDiagnostics& diagnostics) +{ + return buildIr (source, diagnostics, false); +} + +std::unique_ptr buildIrWithLoopTransforms (StringRef source, YdspDiagnostics& diagnostics) +{ + YdspLexer lexer (source, diagnostics); + auto tokens = lexer.tokenize(); + + YdspParser parser (std::move (tokens), diagnostics); + auto program = parser.parseProgram(); + if (program == nullptr) + return nullptr; + + YdspSemanticAnalyzer analyzer (diagnostics); + auto analyzed = analyzer.analyze (std::move (program)); + if (analyzed == nullptr) + return nullptr; + + // Host-like tier: vectorisation at four lanes plus unrolling, which is the + // configuration under which loop fusion is enabled. + YdspOptimizer optimizer (diagnostics); + optimizer.setVectorizationEnabled (true); + optimizer.setVectorWidth (4); + optimizer.setUnrollingEnabled (true); + + return optimizer.build (*analyzed); +} + +int countInst (const YdspIrFunction& fn, YdspIrOp op) +{ + int count = 0; + + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + if (inst.op == op) + ++count; + + return count; +} + +bool hasConstF (const YdspIrFunction& fn, double value) +{ + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + if (inst.op == YdspIrOp::constF && inst.fvalue == value) + return true; + + return false; +} + +bool hasConstI (const YdspIrFunction& fn, int64_t value) +{ + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + if (inst.op == YdspIrOp::constI && inst.ivalue == value) + return true; + + return false; +} + +} // namespace + +//============================================================================== +// IR building (the optimizer's default pipeline) +//============================================================================== + +TEST (YdspOptimizerTests, BuildsSampleLoopIR) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { input stream in; output stream out; process { out = in; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, ir); + ASSERT_EQ (1u, ir->kernels.size()); + + const auto& fn = *ir->kernels[0]; + EXPECT_TRUE (fn.isSampleMode); + EXPECT_EQ (1, fn.numInputs); + EXPECT_EQ (1, fn.numOutputs); + ASSERT_EQ (1u, fn.loops.size()); + EXPECT_EQ (YdspLoopBoundKind::blockSize, fn.loops[0].bound.kind); + EXPECT_EQ (1, countInst (fn, YdspIrOp::loadInput)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::storeOutput)); +} + +TEST (YdspOptimizerTests, BuildsBlockModeLoop) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..blockSize { out[i] = in[i] * 2; } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + EXPECT_FALSE (fn.isSampleMode); + ASSERT_EQ (1u, fn.loops.size()); + EXPECT_EQ (YdspLoopBoundKind::blockSize, fn.loops[0].bound.kind); +} + +//============================================================================== +// Constant folding +//============================================================================== + +TEST (YdspOptimizerTests, ConstantFoldsArithmetic) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { input stream in; output stream out; process { out = in + (2.0 + 3.0); } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + EXPECT_EQ (1, countInst (fn, YdspIrOp::addF)); + EXPECT_EQ (0, countInst (fn, YdspIrOp::mulF)); + EXPECT_TRUE (hasConstF (fn, 5.0)); +} + +TEST (YdspOptimizerTests, ConstantFoldsArithmeticInsideSampleLoop) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { input stream in; output stream out; process { out = in + (2.0 * 3.0); } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + EXPECT_EQ (0, countInst (fn, YdspIrOp::mulF)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::addF)); + EXPECT_TRUE (hasConstF (fn, 6.0)); +} + +TEST (YdspOptimizerTests, ConstantFoldingSaturatesInt64MinDividedByMinusOne) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + process { + int64 a = -9223372036854775808; + int64 b = -1; + out = in + float (a / b); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + EXPECT_EQ (0, countInst (fn, YdspIrOp::divI)); + EXPECT_TRUE (hasConstF (fn, static_cast (std::numeric_limits::max()))); +} + +TEST (YdspOptimizerTests, ConstantFoldingDoesNotShiftInt64ByAnOutOfRangeAmount) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + process { + int64 a = 1; + int64 b = 100; + out = in + float (a << b); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); +} + +TEST (YdspOptimizerTests, ConstantFoldingSaturatesSourceInt64Addition) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + process { + int64 half = 0x4000000000000000; + out = in + float (half + half); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + bool foundSaturated = hasConstF (fn, static_cast (std::numeric_limits::max())); + + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + if (inst.op == YdspIrOp::constI && inst.ivalue == std::numeric_limits::max()) + foundSaturated = true; + + EXPECT_TRUE (foundSaturated) + << "constI=" << countInst (fn, YdspIrOp::constI) + << " constF=" << countInst (fn, YdspIrOp::constF) + << " addI=" << countInst (fn, YdspIrOp::addI) + << " itof=" << countInst (fn, YdspIrOp::itof); +} + +TEST (YdspOptimizerTests, ConstantFoldingKeepsSampleLoopInductionLive) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { input stream in; output stream out; process { out = in; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + // The induction add stays (constant folding must not turn the loop counter + // into a constant), and its write-back move has folded into the add, which + // now updates the loop-carried register in place. + EXPECT_EQ (1, countInst (fn, YdspIrOp::ltI)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::addI)); + EXPECT_EQ (0, countInst (fn, YdspIrOp::movI)); +} + +TEST (YdspOptimizerTests, ConstantFoldingKeepsBoundedLoopInductionLive) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..16 { out[i] = in[i]; } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + // The loop condition and two stream bounds guards must remain. + EXPECT_EQ (1, countInst (fn, YdspIrOp::ltI)); + EXPECT_EQ (2, countInst (fn, YdspIrOp::ltUI)); + EXPECT_TRUE (fn.hasGuardedControlFlow); + EXPECT_EQ (1, countInst (fn, YdspIrOp::addI)); + // The constant-bound loop keeps its canonical seed and write-back moves + // (fusion, unrolling and the vectoriser pattern-match on them), so both + // movIs survive the write-back fold. + EXPECT_EQ (2, countInst (fn, YdspIrOp::movI)); +} + +//============================================================================== +// if-conversion +//============================================================================== + +TEST (YdspOptimizerTests, IfConversionSpeculatesASampleHoldInputLoad) +{ + YdspDiagnostics diagnostics; + + // The 12:1 sample-hold shape: `if (counter == 0) { held = in; }` reads the + // input stream inside the branch. The load reads the sample loop's own + // induction, so it is provably in-bounds and read-only - if-conversion may + // load it unconditionally and select, turning the periodic branch (which + // mispredicts every 12th sample) into straight-line code. The counter wrap + // was already a select; after the change the hold must be one too. + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + state float held; + state int counter; + process { + if (counter == 0) { held = in; } + counter = counter + 1; + if (counter >= 12) { counter = 0; } + out = held; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_EQ (1u, ir->kernels.size()); + + const auto& fn = *ir->kernels[0]; + + // Only the sample loop header still branches: the hold and the wrap are + // both straight-line selects now. + int branchIfCount = 0; + + for (const auto& block : fn.blocks) + if (block.term == YdspIrTerm::branchIf) + ++branchIfCount; + + EXPECT_EQ (1, branchIfCount); + EXPECT_GE (countInst (fn, YdspIrOp::selectB), 2); +} + +TEST (YdspOptimizerTests, RematerializesConstantsBetweenCallsAndAfterTheLastCall) +{ + YdspDiagnostics diagnostics; + auto ir = buildIr (R"YDSP( + processor P { + input stream in; output stream out; + process { out = pow (10.0, log10 (in) * 0.05) + 0.25; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_EQ (1u, ir->kernels.size()); + + bool sawLog = false; + bool sawPow = false; + int middleConstants = 0; + int tailConstants = 0; + for (const auto& block : ir->kernels[0]->blocks) + for (const auto& inst : block.insts) + { + if (inst.op == YdspIrOp::log10F) + sawLog = true; + if (inst.op == YdspIrOp::powF) + sawPow = true; + if (inst.op == YdspIrOp::constF && (inst.fvalue == 10.0 || inst.fvalue == 0.05)) + { + EXPECT_TRUE (sawLog); + EXPECT_FALSE (sawPow); + ++middleConstants; + } + if (inst.op == YdspIrOp::constF && inst.fvalue == 0.25) + { + EXPECT_TRUE (sawPow); + ++tailConstants; + } + } + EXPECT_EQ (2, middleConstants); + EXPECT_EQ (1, tailConstants); +} + +TEST (YdspOptimizerTests, KeepsConstantsWhoseUsesSpanMultipleCallRegions) +{ + YdspDiagnostics diagnostics; + auto ir = buildIr (R"YDSP( + processor P { + input stream in; output stream out; + process { out = pow (10.0, log10 (in + 0.25) * 0.25) + 0.25; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_EQ (1u, ir->kernels.size()); + int constants = 0; + for (size_t b = 0; b < ir->kernels[0]->blocks.size(); ++b) + for (const auto& inst : ir->kernels[0]->blocks[b].insts) + if (inst.op == YdspIrOp::constF && inst.fvalue == 0.25) + { + EXPECT_EQ (0u, b); + ++constants; + } + EXPECT_EQ (1, constants); +} + +TEST (YdspOptimizerTests, RematerializesDryCoefficientAfterCall) +{ + YdspDiagnostics diagnostics; + auto ir = buildIr (R"YDSP( + processor P { + input stream in; output stream out; + input parameter float wet = 0.25; + process { let shaped = sin (in); out = (1.0 - wet) * in + wet * shaped; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + bool sawCall = false; + int differences = 0; + for (const auto& block : ir->kernels[0]->blocks) + for (const auto& inst : block.insts) + { + sawCall |= inst.op == YdspIrOp::sinF; + if (inst.op == YdspIrOp::subF) + { + EXPECT_TRUE (sawCall); + ++differences; + } + } + EXPECT_EQ (1, differences); +} + +TEST (YdspOptimizerTests, RematerializesPostCallParameterUse) +{ + YdspDiagnostics diagnostics; + + // The `g` parameter is loaded before the sample loop but only multiplied + // after the tanh call, so it crosses the per-sample libm call and gets + // parked around it. The remat pass re-defines it right after the call: + // one per-sample reload, no parking, and the pre-loop load disappears. + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float g = 2.0; + process { out = tanh (in) * g; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_EQ (1u, ir->kernels.size()); + + const auto& fn = *ir->kernels[0]; + + // Exactly one parameter load remains, and it sits in the sample loop body + // after the tanh call instead of before the loop. + int paramLoads = 0; + bool loadAfterCall = false; + + for (const auto& block : fn.blocks) + { + int callIndex = -1; + int loadIndex = -1; + + for (size_t i = 0; i < block.insts.size(); ++i) + { + if (block.insts[i].op == YdspIrOp::tanhF) + callIndex = static_cast (i); + + if (block.insts[i].op == YdspIrOp::loadParam) + { + ++paramLoads; + loadIndex = static_cast (i); + } + } + + if (callIndex >= 0 && loadIndex > callIndex) + loadAfterCall = true; + } + + EXPECT_EQ (1, paramLoads); + EXPECT_TRUE (loadAfterCall); +} + +//============================================================================== +// Loop fusion +//============================================================================== + +TEST (YdspOptimizerTests, LoopFusionMergesAdjacentDisjointLoops) +{ + // Two consecutive 8-iteration loops over disjoint arrays fuse into one + // loop (the host tier enables fusion via vectorization/unrolling). + YdspDiagnostics diagnostics; + + auto ir = buildIrWithLoopTransforms (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + state float w[8]; + process block { + let sample = in[0]; + for i in 0..8 { z[i] = z[i] * 0.5 + sample; } + for j in 0..8 { w[j] = w[j] * 0.5 + 0.02; } + out[0] = z[0] + w[0]; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_EQ (1u, ir->kernels.size()); + + const auto& fn = *ir->kernels[0]; + EXPECT_EQ (1u, fn.loops.size()) << "the two loops should have fused into one"; +} + +TEST (YdspOptimizerTests, LoopFusionSkipsLoopsSharingMemory) +{ + // Loop 2 reads the array loop 1 writes, so fusing would change which + // values it sees; the pass must leave them apart. + YdspDiagnostics diagnostics; + + auto ir = buildIrWithLoopTransforms (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + state float w[8]; + process block { + let sample = in[0]; + for i in 0..8 { z[i] = z[i] * 0.5 + sample; } + for j in 0..8 { w[j] = w[j] * 0.5 + z[j]; } + out[0] = z[0] + w[0]; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_EQ (1u, ir->kernels.size()); + + const auto& fn = *ir->kernels[0]; + EXPECT_EQ (2u, fn.loops.size()) << "a read-after-write dependency must block fusion"; +} + +TEST (YdspOptimizerTests, LoopFusionExitsIntoItsRecordedExitBlock) +{ + // The fused loop's terminator must jump to the block the loop record names + // as its exit. The old code retargeted the header to e2's *post-remap* + // index and then remapped it a second time, aiming the fused loop at the + // block before its own header. + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + state float w[8]; + process block { + let sample = in[0]; + for i in 0..8 { z[i] = z[i] * 0.5 + sample; } + for j in 0..8 { w[j] = w[j] * 0.5 + 0.02; } + out[0] = z[0] + w[0]; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_EQ (1u, ir->kernels.size()); + + // Run only the fusion pass (the default pipeline leaves loop fusion off), + // exactly as the loop-transform tier would before vectorizing/unrolling. + YdspIrFunction fn = *ir->kernels[0]; + YdspOptimizer optimizer (diagnostics); + optimizer.loopFusion (fn); + + ASSERT_EQ (1u, fn.loops.size()) << "the two loops should have fused into one"; + + const auto& fusedLoop = fn.loops[0]; + ASSERT_GE (fusedLoop.headerBlock, 0); + ASSERT_LT (static_cast (fusedLoop.headerBlock), fn.blocks.size()); + ASSERT_LT (static_cast (fusedLoop.exitBlock), fn.blocks.size()); + + const auto& header = fn.blocks[static_cast (fusedLoop.headerBlock)]; + + EXPECT_EQ (YdspIrTerm::branchIf, header.term); + EXPECT_EQ (fusedLoop.exitBlock, header.termTarget2) + << "the fused loop's terminator must target the block its loop record names as the exit"; + + // Every remaining terminator target must name a block that still exists. + for (int blockIndex = 0; blockIndex < static_cast (fn.blocks.size()); ++blockIndex) + { + const auto& block = fn.blocks[static_cast (blockIndex)]; + + if (block.termTarget >= static_cast (fn.blocks.size())) + FAIL() << "dangling termTarget " << block.termTarget << " in block " << blockIndex; + + if (block.termTarget2 >= static_cast (fn.blocks.size())) + FAIL() << "dangling termTarget2 " << block.termTarget2 << " in block " << blockIndex; + } +} + +TEST (YdspOptimizerTests, LoopFusionSkipsALoopWhoseInductionDoesNotStartAtZero) +{ + // The second loop runs j over 3..8: its `j = 3` init must survive, so the + // loops cannot fuse. The old code treated any movI into the second + // induction as a droppable "init from literal 0" and silently restarted it + // at the first loop's value. + YdspDiagnostics diagnostics; + + auto ir = buildIrWithLoopTransforms (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + state float w[8]; + process block { + for i in 0..8 { z[i] = z[i] * 0.5 + in[i]; } + for j in 3..8 { w[j] = w[j] * 0.5 + 0.02; } + out[0] = z[0] + w[0]; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_EQ (1u, ir->kernels.size()); + + const auto& fn = *ir->kernels[0]; + EXPECT_EQ (2u, fn.loops.size()) << "a non-zero second start must block fusion"; +} + +//============================================================================== +// Algebraic simplification +//============================================================================== + +TEST (YdspOptimizerTests, AlgebraicSimplifiesMulByOne) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { input stream in; output stream out; process { out = in * 1.0; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + EXPECT_EQ (0, countInst (fn, YdspIrOp::mulF)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::loadInput)); +} + +TEST (YdspOptimizerTests, AlgebraicSimplifiesMulByZero) +{ + YdspDiagnostics diagnostics; + + // The x * 0.0 -> 0.0 identity is fast-math only (it is wrong for signed + // zero, NaN and infinity); strict-mode coverage lives in the graph tests. + auto ir = buildIr (R"YDSP( + processor P { input stream in; output stream out; process { out = in * 0; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics, true); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + EXPECT_EQ (0, countInst (fn, YdspIrOp::mulF)); + EXPECT_EQ (0, countInst (fn, YdspIrOp::loadInput)); +} + +TEST (YdspOptimizerTests, ReciprocalMultiplyReplacesDivisionByConstantOnlyUnderFastMath) +{ + // x / c -> x * (1 / c) removes the division but changes rounding, so it is + // gated on fastMath and must leave the strict build untouched. + constexpr auto source = R"YDSP( + processor P { input stream in; output stream out; process { out = in / 0.25; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + + YdspDiagnostics strictDiagnostics; + auto strict = buildIr (source, strictDiagnostics, false); + ASSERT_FALSE (strictDiagnostics.hasErrors()) << strictDiagnostics.toString(); + ASSERT_NE (nullptr, strict); + EXPECT_EQ (1, countInst (*strict->kernels[0], YdspIrOp::divF)); + + YdspDiagnostics fastDiagnostics; + auto fast = buildIr (source, fastDiagnostics, true); + ASSERT_FALSE (fastDiagnostics.hasErrors()) << fastDiagnostics.toString(); + ASSERT_NE (nullptr, fast); + + const auto& fn = *fast->kernels[0]; + EXPECT_EQ (0, countInst (fn, YdspIrOp::divF)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::mulF)); + EXPECT_TRUE (hasConstF (fn, 4.0)); +} + +TEST (YdspOptimizerTests, PowSquaredBecomesAMultiplyOnlyUnderFastMath) +{ + // pow (x, 2.0) -> x * x is a rounding change (libm pow rounds once with + // its own algorithm), so it is fast-math only; strict keeps the intrinsic. + YdspDiagnostics diagnostics; + + auto strict = buildIr (R"YDSP( + processor P { input stream in; output stream out; process { out = pow (in, 2.0); } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics, false); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, strict); + EXPECT_EQ (1, countInst (*strict->kernels[0], YdspIrOp::powF)); + EXPECT_EQ (0, countInst (*strict->kernels[0], YdspIrOp::mulF)); + + YdspDiagnostics fastDiagnostics; + + auto fast = buildIr (R"YDSP( + processor P { input stream in; output stream out; process { out = pow (in, 2.0); } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + fastDiagnostics, true); + + ASSERT_FALSE (fastDiagnostics.hasErrors()) << fastDiagnostics.toString(); + ASSERT_NE (nullptr, fast); + EXPECT_EQ (0, countInst (*fast->kernels[0], YdspIrOp::powF)); + EXPECT_EQ (1, countInst (*fast->kernels[0], YdspIrOp::mulF)); +} + +TEST (YdspOptimizerTests, Pow2ModuloOfMaskedValueBecomesAMask) +{ + // i is masked to 0..15 before the modulo, so `i % 8` is provably + // non-negative and rewrites to `i & 7` without changing the result - in + // every tier, not just fastMath. + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + process { + let i = int32 (in * 127.0) & 15; + out = float (i % 8) * 0.001; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + EXPECT_EQ (0, countInst (fn, YdspIrOp::modI)); + EXPECT_EQ (2, countInst (fn, YdspIrOp::andI)); // the source `& 15` plus the rewritten `& 7` + EXPECT_TRUE (hasConstI (fn, 7)); +} + +TEST (YdspOptimizerTests, DoesNotSimplifyAgainstAnOverwrittenLiteral) +{ + // `t` is bound to the literal 0 by its declaration and then reassigned, all + // in one block. Treating the literal as `t`'s value would fold `t + 1.0` to + // a constant and drop the input entirely - the IR is not SSA, so a constant + // writing a register does not mean the register holds it at a later use. + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + process { float t = 0.0; t = in * 2.0; out = t + 1.0; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + // The multiply and the add both have to survive, and the input still has to + // be read: the output depends on it. + EXPECT_EQ (1, countInst (fn, YdspIrOp::loadInput)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::mulF)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::addF)); +} + +//============================================================================== +// Copy propagation +//============================================================================== + +TEST (YdspOptimizerTests, ReusesIdenticalPureExpressions) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + process { let x = in; let k = 2.0; out = x * k + x * k; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + EXPECT_EQ (1, countInst (fn, YdspIrOp::mulF)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::addF)); +} + +TEST (YdspOptimizerTests, CopyPropagatesFunctionParameterCopies) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + func doubleIt(x: float) : float { return x * 2.0; } + process { out = doubleIt(in); } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + EXPECT_EQ (0, countInst (fn, YdspIrOp::movF)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::mulF)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::loadInput)); + + int loadResult = -1; + + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + if (inst.op == YdspIrOp::loadInput) + loadResult = inst.result; + + bool propagatesIntoMul = false; + + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + if (inst.op == YdspIrOp::mulF) + propagatesIntoMul = (inst.a == loadResult); + + EXPECT_TRUE (propagatesIntoMul); +} + +//============================================================================== +// Dead code elimination +//============================================================================== + +TEST (YdspOptimizerTests, DeadCodeEliminationRemovesUnusedComputation) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + process { + float32 dead = in * 2.0; + out = in; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + EXPECT_EQ (0, countInst (fn, YdspIrOp::mulF)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::loadInput)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::storeOutput)); +} + +//============================================================================== +// Loop-invariant code motion +//============================================================================== + +TEST (YdspOptimizerTests, HoistsInvariantParamComputation) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float drive = 0.5; + process block { + for i in 0..blockSize { out[i] = in[i] * (1 - drive); } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + auto& fn = *ir->kernels[0]; + + YdspOptimizer optimizer (diagnostics); + optimizer.loopInvariantCodeMotion (fn); + + // (1 - drive) is loop-invariant and must be hoisted into the entry block. + bool sawHoistedSubF = false; + + for (const auto& inst : fn.blocks[0].insts) + if (inst.op == YdspIrOp::subF) + sawHoistedSubF = true; + + EXPECT_TRUE (sawHoistedSubF); +} + +TEST (YdspOptimizerTests, GuardsPreserveConditionalWorkWithoutBlockingUnconditionalHoisting) +{ + YdspDiagnostics diagnostics; + auto ir = buildIr (R"YDSP( + processor P { + input stream in; output stream out; + input parameter float attack = 0.005, release = 0.15; + input parameter int index = 0; + state float data[8]; + process { + let attackCoeff = exp (-1.0 / (attack * sampleRate)); + let releaseCoeff = exp (-1.0 / (release * sampleRate)); + let selected = in > 0.0 ? exp (attack) : releaseCoeff; + float polarity = 1.0; + if (in < 0.0) { polarity = -1.0; } + data[index] = in; + out = polarity * selected * attackCoeff; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + const auto& fn = *ir->kernels[0]; + ASSERT_TRUE (fn.hasGuardedControlFlow); + EXPECT_EQ (3, countInst (fn, YdspIrOp::expF)); + EXPECT_EQ (2, std::count_if (fn.blocks[0].insts.begin(), fn.blocks[0].insts.end(), [] (const YdspIrInst& inst) + { + return inst.op == YdspIrOp::expF; + })); + EXPECT_EQ (1, countInst (fn, YdspIrOp::selectB)); + EXPECT_EQ (0, std::count_if (fn.blocks[0].insts.begin(), fn.blocks[0].insts.end(), [] (const YdspIrInst& inst) + { + return inst.op == YdspIrOp::loadStateArrayF; + })); + EXPECT_EQ (2, std::count_if (fn.blocks.begin(), fn.blocks.end(), [] (const YdspIrBlock& block) + { + return block.preservesConditionalEvaluation && block.term == YdspIrTerm::branchIf; + })); +} + +TEST (YdspOptimizerTests, HoistsIterationLocalClampAndConversionChain) +{ + YdspDiagnostics diagnostics; + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float delay = 3.5; + process { + float d = delay; + if (d > 8.0) d = 8.0; + if (d < 1.0) d = 1.0; + let whole = int32 (d); + out = in * (d - float (whole)); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + const auto& fn = *ir->kernels[0]; + int entrySelects = 0; + int entryConversions = 0; + for (const auto& inst : fn.blocks[0].insts) + { + entrySelects += inst.op == YdspIrOp::selectB; + entryConversions += inst.op == YdspIrOp::ftoi; + } + EXPECT_EQ (2, entrySelects); + EXPECT_EQ (1, entryConversions); + EXPECT_EQ (1, countInst (fn, YdspIrOp::ftoi)); +} + +TEST (YdspOptimizerTests, ProvesPersistentRingCounterAndWrappedClampedTaps) +{ + YdspDiagnostics diagnostics; + auto ir = buildIr (R"YDSP( + processor P { + input stream in; output stream out; + input parameter float delay = 3.5; + state float data[8]; state int wp; + process { + float d = delay; + if (d > 8.0) { d = 8.0; } + if (d < 1.0) { d = 1.0; } + int count = int (d); + int a = wp - count; + if (a < 0) { a = a + 8; } + int b = a - 1; + if (b < 0) { b = b + 8; } + out = data[a] + data[b]; + data[wp] = in; + wp = wp + 1; + if (wp >= 8) { wp = 0; } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + EXPECT_EQ (0, countInst (*ir->kernels[0], YdspIrOp::ltUI)); + EXPECT_EQ (2, countInst (*ir->kernels[0], YdspIrOp::loadStateArrayF)); + EXPECT_EQ (1, countInst (*ir->kernels[0], YdspIrOp::storeStateArrayF)); + for (const auto& block : ir->kernels[0]->blocks) + for (const auto& inst : block.insts) + if (inst.op == YdspIrOp::addI || inst.op == YdspIrOp::subI || inst.op == YdspIrOp::mulI) + EXPECT_FALSE (inst.saturatingIntegerArithmetic); +} + +TEST (YdspOptimizerTests, RingProofAuditsInitializationEventsAndEveryUpdate) +{ + for (const auto* change : { "init { wp = 3; }", "init { wp = 8; }", + "event midi (e: noteOn) { wp = 0; }", "event midi (e: noteOn) { wp = int (e.pitch); }" }) + { + const bool event = String (change).startsWith ("event"); + YdspDiagnostics diagnostics; + const auto source = String (R"YDSP( + processor P { + input stream in; output stream out; EVENT_INPUT + state float data[8]; state int wp; + CHANGE + process { + out = data[wp]; data[wp] = in; + wp = wp + 1; if (wp >= 8) { wp = 0; } + } + } + graph G { input stream x; output stream y; EVENT_INPUT + node p = P; connection { x -> p.in; p.out -> y; EVENT_EDGE } } + )YDSP").replace ("CHANGE", change) + .replace ("EVENT_INPUT", event ? "input event midi;" : "") + .replace ("EVENT_EDGE", event ? "midi -> p.midi;" : ""); + auto ir = buildIr (source, diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + const bool safe = String (change).contains ("wp = 3") || String (change).contains ("wp = 0"); + EXPECT_EQ (safe, countInst (*ir->kernels[0], YdspIrOp::ltUI) == 0) << change; + } + for (const auto* extra : { "wp = wp + 1;", "wp = -1;", "wp = int (in);" }) + { + YdspDiagnostics diagnostics; + auto ir = buildIr (String (R"YDSP( + processor P { + input stream in; output stream out; + state float data[8]; state int wp; + process { + EXTRA + out = data[wp]; data[wp] = in; + wp = wp + 1; if (wp >= 8) { wp = 0; } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP").replace ("EXTRA", extra), diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + const auto& fn = *ir->kernels[0]; + if (String (extra) == "wp = -1;") + { + // Constant folding replaces the comparison, but does not require + // removal of the safe fallback load or the guarded store block. + bool hasRejectedAccess = false; + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + hasRejectedAccess |= inst.op == YdspIrOp::constB && ! inst.bvalue; + EXPECT_TRUE (hasRejectedAccess); + EXPECT_EQ (1, countInst (fn, YdspIrOp::storeOutput)); + EXPECT_TRUE (hasConstF (fn, 0.0)); + } + else + EXPECT_GT (countInst (fn, YdspIrOp::ltUI), 0) << extra; + } +} + +TEST (YdspOptimizerTests, RingProofRetainsChecksForIncompleteTapBounds) +{ + for (const auto* tap : { + "int a = wp - int (delay); if (a < 0) { a = a + 8; }", + "float d = delay; if (d > 9.0) { d = 9.0; } if (d < 1.0) { d = 1.0; } int a = wp - int (d); if (a < 0) { a = a + 8; }", + "int a = wp - 1;", + "int a = wp + 1;" }) + { + YdspDiagnostics diagnostics; + auto ir = buildIr (String (R"YDSP( + processor P { + input stream in; output stream out; + input parameter float delay = 3.5; + state float data[8]; state int wp; + process { + TAP + out = data[a]; data[wp] = in; + wp = wp + 1; if (wp >= 8) { wp = 0; } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP").replace ("TAP", tap), diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + EXPECT_GT (countInst (*ir->kernels[0], YdspIrOp::ltUI), 0) << tap; + } +} + +TEST (YdspOptimizerTests, RemovesBoundsChecksForProvenIntegerRanges) +{ + for (const auto* expression : { "raw & 7", "clamp (raw, 0, 7)", "min (max (raw, 0), 7)", + "(raw & 3) + 4", "(raw & 3) - 0", "select (raw < 0, int32 (0), int32 (7))" }) + { + YdspDiagnostics diagnostics; + auto ir = buildIr (String (R"YDSP( + processor P { + input stream in; output stream out; + state float data[8]; + process { + let raw = int32 (in); + let index = EXPRESSION; + out = data[index]; + data[index] = in; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP").replace ("EXPRESSION", expression), diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << expression << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + EXPECT_EQ (0, countInst (*ir->kernels[0], YdspIrOp::ltUI)) << expression; + } +} + +TEST (YdspOptimizerTests, RetainsBoundsChecksForNegativeAndOversizedRanges) +{ + for (const auto* expression : { "raw & 15", "clamp (raw, -1, 7)", "(raw & 7) - 1", "raw + 1" }) + { + YdspDiagnostics diagnostics; + auto ir = buildIr (String (R"YDSP( + processor P { + input stream in; output stream out; + state float data[8]; + process { let raw = int32 (in); out = data[EXPRESSION]; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP").replace ("EXPRESSION", expression), diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << expression << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + EXPECT_GT (countInst (*ir->kernels[0], YdspIrOp::ltUI), 0) << expression; + } +} + +TEST (YdspOptimizerTests, CheckedArrayReadsSelectASafeIndexBeforeLoading) +{ + YdspDiagnostics diagnostics; + auto ir = buildIr (R"YDSP( + processor P { + input stream in; output stream out; + state float data[8]; + process { out = data[int32 (in)]; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + const auto& fn = *ir->kernels[0]; + EXPECT_EQ (1, countInst (fn, YdspIrOp::ltUI)); + EXPECT_EQ (2, countInst (fn, YdspIrOp::selectB)); + EXPECT_EQ (1, std::count_if (fn.blocks.begin(), fn.blocks.end(), [] (const YdspIrBlock& block) + { + return block.term == YdspIrTerm::branchIf; + })); + for (const auto& block : fn.blocks) + for (size_t i = 0; i < block.insts.size(); ++i) + if (block.insts[i].op == YdspIrOp::loadStateArrayF) + { + const auto safeIndex = block.insts[i].a; + EXPECT_TRUE (std::any_of (block.insts.begin(), block.insts.begin() + static_cast (i), [safeIndex] (const YdspIrInst& inst) + { + return inst.op == YdspIrOp::selectB && inst.result == safeIndex; + })); + } +} + +TEST (YdspOptimizerTests, HoistsClampAndConversionBeforeAnArrayGuard) +{ + YdspDiagnostics diagnostics; + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float delay = 3.5; + state float data[8]; + process { + float d = delay; + if (d > 8.0) d = 8.0; + if (d < 1.0) d = 1.0; + let whole = int32 (d); + data[int32 (in)] = in; + out = in * (d - float (whole)); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + const auto& fn = *ir->kernels[0]; + EXPECT_EQ (1, countInst (fn, YdspIrOp::ltUI)); + EXPECT_EQ (0, countInst (fn, YdspIrOp::andB)); + int entrySelects = 0; + int entryConversions = 0; + for (const auto& inst : fn.blocks[0].insts) + { + entrySelects += inst.op == YdspIrOp::selectB; + entryConversions += inst.op == YdspIrOp::ftoi; + } + EXPECT_EQ (2, entrySelects); + EXPECT_EQ (1, entryConversions); + EXPECT_EQ (2, countInst (fn, YdspIrOp::ftoi)); +} + +TEST (YdspOptimizerTests, LoopInvariantCodeMotionKeepsInputLoadsInLoop) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float drive = 0.5; + process block { + for i in 0..blockSize { out[i] = in[i] * (1 - drive); } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + auto& fn = *ir->kernels[0]; + + YdspOptimizer optimizer (diagnostics); + optimizer.loopInvariantCodeMotion (fn); + + // The input load depends on the sample index and must not leave the loop. + bool sawLoadInEntry = false; + + for (const auto& inst : fn.blocks[0].insts) + if (inst.op == YdspIrOp::loadInput) + sawLoadInEntry = true; + + EXPECT_FALSE (sawLoadInEntry); + EXPECT_EQ (1, countInst (fn, YdspIrOp::loadInput)); +} + +TEST (YdspOptimizerTests, LoopInvariantCodeMotionKeepsInductionUpdateInLoop) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..16 { out[i] = in[i]; } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + auto& fn = *ir->kernels[0]; + + YdspOptimizer optimizer (diagnostics); + optimizer.loopInvariantCodeMotion (fn); + + bool sawIncrementInEntry = false; + + for (const auto& inst : fn.blocks[0].insts) + if (inst.op == YdspIrOp::addI) + sawIncrementInEntry = true; + + EXPECT_FALSE (sawIncrementInEntry); + EXPECT_EQ (1, countInst (fn, YdspIrOp::addI)); +} + +TEST (YdspOptimizerTests, LoopInvariantCodeMotionKeepsSampleInductionInLoop) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { input stream in; output stream out; process { out = in; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + auto& fn = *ir->kernels[0]; + + YdspOptimizer optimizer (diagnostics); + optimizer.loopInvariantCodeMotion (fn); + + bool sawIncrementInEntry = false; + + for (const auto& inst : fn.blocks[0].insts) + if (inst.op == YdspIrOp::addI) + sawIncrementInEntry = true; + + EXPECT_FALSE (sawIncrementInEntry); + EXPECT_EQ (1, countInst (fn, YdspIrOp::addI)); + // The sample-loop induction write-back folded into the increment during + // the build; LICM must leave that in-place increment in the loop. + EXPECT_EQ (0, countInst (fn, YdspIrOp::movI)); +} + +TEST (YdspOptimizerTests, LoopInvariantCodeMotionKeepsPathDependentValuesInLoop) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[64]; + process block { + for i in 0..blockSize { + float32 v; + // A state-array load inside the arm keeps this diamond + // branchy (if-conversion only speculates pure values and + // in-bounds input-stream reads, never state-array loads); + // v's definitions stay path-dependent in the loop. + if (in[i] > 0.5) v = z[i]; else v = 3.0; + out[i] = v; + } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, ir); + + auto& fn = *ir->kernels[0]; + + YdspOptimizer optimizer (diagnostics); + optimizer.loopInvariantCodeMotion (fn); + + bool sawCopyInEntry = false; + + for (const auto& inst : fn.blocks[0].insts) + if (inst.op == YdspIrOp::movF) + sawCopyInEntry = true; + + EXPECT_FALSE (sawCopyInEntry); + // The array arm retains its copy; the other arm becomes a constant. + EXPECT_EQ (1, countInst (fn, YdspIrOp::movF)); + EXPECT_EQ (0, std::count_if (fn.blocks[0].insts.begin(), fn.blocks[0].insts.end(), [] (const YdspIrInst& inst) + { + return inst.op == YdspIrOp::loadStateArrayF; + })); +} + +TEST (YdspOptimizerTests, LoopInvariantCodeMotionKeepsIfConvertedSelectInLoop) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..blockSize { + float32 v; + // Both arms move pure constants, so if-conversion fuses + // the diamond into one selectB; that select depends on the + // per-sample input test and must stay in the loop. + if (in[i] > 0.5) v = 2.0; else v = 3.0; + out[i] = v; + } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, ir); + + auto& fn = *ir->kernels[0]; + + YdspOptimizer optimizer (diagnostics); + optimizer.loopInvariantCodeMotion (fn); + + bool sawSelectInEntry = false; + + for (const auto& inst : fn.blocks[0].insts) + if (inst.op == YdspIrOp::selectB) + sawSelectInEntry = true; + + EXPECT_FALSE (sawSelectInEntry); + EXPECT_EQ (0, countInst (fn, YdspIrOp::movF)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::selectB)); +} + +//============================================================================== +// State lowering and execution reports +//============================================================================== + +TEST (YdspOptimizerTests, LowersPrevIntoHiddenState) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + state float z; + process { out = z'; z = in; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + EXPECT_EQ (2, fn.float32Scalars); + EXPECT_EQ (0, fn.float64Scalars); +} + +TEST (YdspOptimizerTests, LowersDelayIntoRingBuffer) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { input stream in; output stream out; process { out = in @ 3; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + EXPECT_EQ (4, fn.float32ArrayElements); + EXPECT_EQ (1, fn.int32Scalars); +} + +TEST (YdspOptimizerTests, SharesUnconditionalInputDelayHistories) +{ + YdspDiagnostics diagnostics; + const auto ir = buildIr (R"YDSP( + processor P { + input stream in; output stream out; + process { out = (in @ 127) + (in @ 509) + (in @ 251); } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + const auto& fn = *ir->kernels[0]; + EXPECT_EQ (512, fn.float32ArrayElements); + EXPECT_EQ (1, fn.int32Scalars); + EXPECT_EQ (1, countInst (fn, YdspIrOp::storeStateArrayF)); + EXPECT_EQ (3, countInst (fn, YdspIrOp::loadStateArrayF)); + EXPECT_EQ (0, countInst (fn, YdspIrOp::advanceWrapI)); +} + +TEST (YdspOptimizerTests, SharedInputDelaysKeepStreamsSeparateAndAcceptTheMaximumDelay) +{ + YdspDiagnostics diagnostics; + const auto ir = buildIr (R"YDSP( + processor P { + input stream left; input stream right; output stream out; + process { + out = (left @ 1) + (left @ 65536) + (right @ 1) + (right @ 3); + } + } + graph G { + input stream x; input stream y; output stream z; node p = P; + connection { x -> p.left; y -> p.right; p.out -> z; } + } + )YDSP", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + const auto& fn = *ir->kernels[0]; + EXPECT_EQ (131072 + 4, fn.float32ArrayElements); + EXPECT_EQ (2, fn.int32Scalars); + EXPECT_EQ (2, countInst (fn, YdspIrOp::storeStateArrayF)); + EXPECT_EQ (4, countInst (fn, YdspIrOp::loadStateArrayF)); +} + +TEST (YdspOptimizerTests, KeepsConditionalAndMutableDelayHistoriesSeparate) +{ + YdspDiagnostics diagnostics; + const auto ir = buildIr (R"YDSP( + processor P { + input stream in; output stream out; state float z; + process { + let a = z @ 3; + z = in; + let b = z @ 7; + float c = 0.0; + if (in > 0.0) { c = in @ 3; } + out = a + b + c + (in @ 7); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + const auto& fn = *ir->kernels[0]; + EXPECT_EQ (24, fn.float32ArrayElements); + EXPECT_EQ (4, fn.int32Scalars); + EXPECT_EQ (4, countInst (fn, YdspIrOp::storeStateArrayF)); +} + +TEST (YdspOptimizerTests, BuildsExecutionReport) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor Taps { + input stream in; + output stream out; + process block { + for i in 0..16 { out[i] = in[i]; } + } + } + graph G { input stream x; output stream y; node t = Taps; connection { x -> t.in; t.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + YdspExecutionReport report; + YdspOptimizer::buildReport (*ir, report); + + ASSERT_EQ (1u, report.getKernels().size()); + const auto& kernel = report.getKernels()[0]; + EXPECT_EQ ("Taps", kernel.name); + EXPECT_GT (kernel.instructionCount, 0); + EXPECT_EQ (16, kernel.boundedIterationCount); + EXPECT_TRUE (kernel.provenRealtimeSafe); + ASSERT_EQ (1u, kernel.loopBounds.size()); + EXPECT_EQ ("16", kernel.loopBounds[0]); + EXPECT_TRUE (report.isProvenRealtimeSafe()); +} + +TEST (YdspOptimizerTests, RecordsEachNestedLoopsOwnBound) +{ + YdspDiagnostics diagnostics; + + // The outer loop's bound is resolved before its body is lowered, but only + // recorded afterwards - so lowering the inner loop in between must not + // clobber it. With the bound held in a builder member both loops reported + // the inner bound, and the worst-case iteration count came out as 5 x 5. + auto ir = buildIr (R"YDSP( + processor Nested { + input stream in; + output stream out; + state float acc; + process block { + for i in 0..7 { + for j in 0..5 { acc = acc + 1.0; } + out[i] = in[i] + acc; + } + } + } + graph G { input stream x; output stream y; node n = Nested; connection { x -> n.in; n.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + YdspExecutionReport report; + YdspOptimizer::buildReport (*ir, report); + + ASSERT_EQ (1u, report.getKernels().size()); + const auto& kernel = report.getKernels()[0]; + + ASSERT_EQ (2u, kernel.loopBounds.size()); + + StringArray bounds (kernel.loopBounds); + bounds.sort (false); + + EXPECT_EQ ("5", bounds[0]); + EXPECT_EQ ("7", bounds[1]); + EXPECT_EQ (35, kernel.boundedIterationCount); +} + +TEST (YdspOptimizerTests, LowersDelayWrapWithoutAnIntegerDivision) +{ + YdspDiagnostics diagnostics; + + // The `@` ring wrap used to emit modI, which on x86-64 is a call into a + // helper - once per delay tap per sample. The write pointer is provably + // within [0, n], so the dedicated increment-and-wrap operation is exact. + auto ir = buildIr (R"YDSP( + processor P { input stream in; output stream out; process { out = in @ 8; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + EXPECT_EQ (1, countInst (fn, YdspIrOp::advanceWrapI)); + EXPECT_EQ (0, countInst (fn, YdspIrOp::wrapI)); + EXPECT_EQ (0, countInst (fn, YdspIrOp::modI)); + EXPECT_EQ (0, countInst (fn, YdspIrOp::divI)); +} + +TEST (YdspOptimizerTests, ConvertsAShortElselessIfIntoSelects) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + + process { + float y = in * 3.0; + + if (y > 1.0) { y = 2.0 - y; } + if (y < -1.0) { y = -2.0 - y; } + + out = y; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + // Both folds become selects ... + EXPECT_EQ (2, countInst (fn, YdspIrOp::selectB)); + + const auto branches = std::count_if (fn.blocks.begin(), fn.blocks.end(), [] (const YdspIrBlock& block) + { + return block.term == YdspIrTerm::branchIf; + }); + + // ... leaving only the sample loop's own termination test, which is a real + // loop back-edge rather than a diamond and is never a conversion candidate. + EXPECT_EQ (1, branches); +} + +TEST (YdspOptimizerTests, FusesATwoSidedIfOfMovesIntoASelect) +{ + YdspDiagnostics diagnostics; + + // if (cond) { y = a; } else { y = b; } - both arms a single move into the + // same value - becomes one select, killing the branch (the wavefolder and + // clipper shapes). + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + process { + float y = 0.0; + if (in > 0.5) { y = -1.0; } + else { y = 1.0; } + out = y; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_EQ (1u, ir->kernels.size()); + + const auto& fn = *ir->kernels[0]; + + EXPECT_EQ (1, countInst (fn, YdspIrOp::selectB)) << "both arms should fuse into a select"; + + const auto branches = std::count_if (fn.blocks.begin(), fn.blocks.end(), [] (const YdspIrBlock& block) + { + return block.term == YdspIrTerm::branchIf; + }); + + // Only the sample loop's own termination test remains. + EXPECT_EQ (1, branches); +} + +TEST (YdspOptimizerTests, DoesNotIfConvertABodyThatTouchesMemory) +{ + YdspDiagnostics diagnostics; + + // The array read is guarded: running it unconditionally would index the + // array with whatever `idx` holds when the guard is false. + auto ir = buildIr (R"YDSP( + let size = 4; + + processor P { + input stream in; + output stream out; + + input parameter float pick = 0.0; + + state float bank[size]; + + process { + let idx = int (pick); + + float y = in; + + if (idx < size) { y = bank[idx]; } + + out = y; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + const auto branches = std::count_if (fn.blocks.begin(), fn.blocks.end(), [] (const YdspIrBlock& block) + { + return block.term == YdspIrTerm::branchIf; + }); + + // The sample-loop header and source condition remain branchy. + EXPECT_EQ (2, branches); +} + +TEST (YdspOptimizerTests, HoistsInnerLoopInvariantWorkIntoItsOwnPreheader) +{ + YdspDiagnostics diagnostics; + + // `drive` does not depend on `i`, so it belongs in the inner loop's + // preheader - but it *does* depend on `env`, which changes every sample, so + // it must not climb any further than that. Hoisting it to the entry block + // would freeze it at the value `env` had before the first sample. + auto ir = buildIr (R"YDSP( + let modes = 8; + + processor P { + input stream in; + output stream out; + + input parameter float damping = 0.5; + + state float z[modes]; + state float env; + + process { + env = env * 0.999 + abs (in) * 0.001; + + float sum = 0.0; + + for i in 0..modes { + let drive = exp (-env * damping) * (1.0 - damping); + + z[i] = z[i] * 0.9 + in * drive; + sum = sum + z[i]; + } + + out = sum; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + // The inner `for` is the constant-bound loop; the sample loop is bounded by + // blockSize. + const auto inner = std::find_if (fn.loops.begin(), fn.loops.end(), [] (const YdspIrLoop& loop) + { + return loop.bound.kind == YdspLoopBoundKind::constant; + }); + + ASSERT_NE (fn.loops.end(), inner); + ASSERT_GT (inner->headerBlock, 0); + + const auto countIn = [&fn] (int block, YdspIrOp op) + { + const auto& insts = fn.blocks[static_cast (block)].insts; + + return static_cast (std::count_if (insts.begin(), insts.end(), [op] (const YdspIrInst& inst) + { + return inst.op == op; + })); + }; + + // Evaluated once per sample in the preheader ... + EXPECT_EQ (1, countIn (inner->headerBlock - 1, YdspIrOp::expF)); + + // ... not once per mode inside the loop ... + for (int block = inner->headerBlock; block < inner->exitBlock; ++block) + EXPECT_EQ (0, countIn (block, YdspIrOp::expF)) << "block " << block; + + // ... and not lifted clear of the sample loop, where `env` would be stale. + EXPECT_EQ (0, countIn (0, YdspIrOp::expF)); +} + +TEST (YdspOptimizerTests, ForwardsAStoredArrayElementToALaterLoad) +{ + YdspDiagnostics diagnostics; + + // `bank[i]` is written and read back in the same iteration, with a write to + // a *different* array through the same index in between - which cannot + // alias, so the read still forwards. + auto ir = buildIr (R"YDSP( + let size = 8; + + processor P { + input stream in; + output stream out; + + state float bank[size]; + state float other[size]; + + process { + float sum = 0.0; + + for i in 0..size { + bank[i] = bank[i] + in; + other[i] = in; + sum = sum + bank[i]; + } + + out = sum; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + // Two reads of bank[i] in the source, but the second is satisfied from the + // value just stored, leaving one real load. + EXPECT_EQ (1, countInst (fn, YdspIrOp::loadStateArrayF)); + EXPECT_EQ (2, countInst (fn, YdspIrOp::storeStateArrayF)); +} + +TEST (YdspOptimizerTests, DoesNotForwardAcrossAPossiblyAliasingStore) +{ + YdspDiagnostics diagnostics; + + // The intervening write uses a *different* index into the same array, so + // `bank[j]` may be `bank[i]` at runtime and the read has to stay a load. + auto ir = buildIr (R"YDSP( + let size = 8; + + processor P { + input stream in; + output stream out; + + state float bank[size]; + + process { + float sum = 0.0; + + for i in 0..size { + let j = size - 1 - i; + + bank[i] = in; + bank[j] = in * 2.0; + sum = sum + bank[i]; + } + + out = sum; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + EXPECT_EQ (1, countInst (*ir->kernels[0], YdspIrOp::loadStateArrayF)); +} + +TEST (YdspOptimizerTests, ArrayCsePreservesLoadsAcrossDisjointStores) +{ + for (const auto type : { YdspValueType::float32Type, YdspValueType::float64Type, + YdspValueType::int32Type, YdspValueType::int64Type }) + for (const int lanes : { 1, 4, 8 }) + for (const int offset : { -8, -1, 0, 1, 4, 8 }) + for (const int hazard : { 0, 1, 2, 3 }) + { + SCOPED_TRACE (::testing::Message() << "type=" << static_cast (type) + << " lanes=" << lanes << " offset=" << offset << " hazard=" << hazard); + const bool integer = type == YdspValueType::int32Type || type == YdspValueType::int64Type; + const auto load = integer ? YdspIrOp::loadStateArrayI : YdspIrOp::loadStateArrayF; + const auto store = integer ? YdspIrOp::storeStateArrayI : YdspIrOp::storeStateArrayF; + YdspIrFunction fn; + fn.valueTypes = { YdspValueType::int32Type, type, type, type, YdspValueType::int32Type }; + fn.valueLanes = { 1, lanes, lanes, lanes, 1 }; + fn.blocks.resize (1); + auto& insts = fn.blocks[0].insts; + insts = { + { load, 1, 0, -1, -1, 16 }, + { store, -1, hazard == 1 ? 4 : 0, 2, -1, 16 + offset } + }; + if (hazard == 2) + insts.push_back ({ YdspIrOp::constI, 0, -1, -1, -1, -1, 0.0, 1 }); + if (hazard == 3) + fn.valueTypes[2] = integer ? YdspValueType::int64Type : YdspValueType::float64Type; + insts.push_back ({ load, 3, 0, -1, -1, 16 }); + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + const bool sameType = fn.valueTypes[1] == fn.valueTypes[2]; + const bool expected = hazard != 1 && hazard != 2 && sameType + && (offset >= lanes || offset + lanes <= 0); + EXPECT_EQ (expected, optimizer.commonSubexpressionElimination (fn)); + EXPECT_EQ (expected ? (integer ? YdspIrOp::movI : YdspIrOp::movF) : load, insts.back().op); + if (expected) + EXPECT_EQ (1, insts.back().a); + } +} + +TEST (YdspOptimizerTests, ArrayForwardingUsesConstantRangesAndExactLaneShapes) +{ + for (const auto type : { YdspValueType::float32Type, YdspValueType::float64Type, + YdspValueType::int32Type, YdspValueType::int64Type }) + for (const int lanes : { 1, 4, 8 }) + for (const int offset : { 0, 1, 4, 8 }) + { + SCOPED_TRACE (::testing::Message() << "type=" << static_cast (type) + << ", lanes=" << lanes << ", offset=" << offset); + const bool integer = type == YdspValueType::int32Type || type == YdspValueType::int64Type; + const auto load = integer ? YdspIrOp::loadStateArrayI : YdspIrOp::loadStateArrayF; + const auto store = integer ? YdspIrOp::storeStateArrayI : YdspIrOp::storeStateArrayF; + YdspIrFunction fn; + fn.valueTypes = { YdspValueType::int32Type, YdspValueType::int32Type, + type, type, type, YdspValueType::int32Type }; + fn.valueLanes = { 1, 1, lanes, lanes, lanes, 1 }; + fn.blocks.resize (1); + fn.blocks[0].insts = { + { YdspIrOp::constI, 0, -1, -1, -1, -1, 0.0, 0 }, + { YdspIrOp::constI, 1, -1, -1, -1, -1, 0.0, offset }, + { YdspIrOp::constI, 5, -1, -1, -1, -1, 0.0, 0 }, + { load, 2, 0, -1, -1, 32 }, + { load, 3, 0, -1, -1, 64 }, + { store, -1, 0, 2, -1, 0 }, + { store, -1, 1, 3, -1, 0 }, + { load, 4, 5, -1, -1, 0 } + }; + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + const bool expected = offset == 0 || offset >= lanes; + EXPECT_EQ (expected, optimizer.storeToLoadForwarding (fn)); + const auto& result = fn.blocks[0].insts.back(); + EXPECT_EQ (expected ? (integer ? YdspIrOp::movI : YdspIrOp::movF) : load, result.op); + if (expected) + EXPECT_EQ (offset == 0 ? 3 : 2, result.a); + EXPECT_EQ (lanes, fn.laneCountOf (result.result)); + } +} + +TEST (YdspOptimizerTests, ArrayForwardingRejectsWidthTypeAndRedefinitionHazards) +{ + for (const int hazard : { 0, 1, 2, 3, 4, 5 }) + { + SCOPED_TRACE (hazard); + YdspIrFunction fn; + fn.valueTypes = { YdspValueType::int32Type, YdspValueType::float32Type, YdspValueType::float32Type }; + fn.valueLanes = { 1, 4, 4 }; + fn.blocks.resize (1); + auto& insts = fn.blocks[0].insts; + insts = { + { YdspIrOp::constI, 0, -1, -1, -1, -1, 0.0, 0 }, + { YdspIrOp::loadStateArrayF, 1, 0, -1, -1, 16 }, + { YdspIrOp::storeStateArrayF, -1, 0, 1, -1, 0 } + }; + if (hazard == 0) + fn.valueLanes[2] = 1; + else if (hazard == 1) + fn.valueTypes[2] = YdspValueType::float64Type; + else if (hazard == 2) + insts.push_back ({ YdspIrOp::constI, 0, -1, -1, -1, -1, 0.0, 1 }); + else if (hazard == 3) + insts.push_back ({ YdspIrOp::loadStateArrayF, 1, 0, -1, -1, 32 }); + else if (hazard == 4) + insts.push_back ({ YdspIrOp::storeStateArrayF, -1, 0, 1, -1, 1 }); + else + insts.push_back ({ YdspIrOp::emitEvent }); + insts.push_back ({ YdspIrOp::loadStateArrayF, 2, 0, -1, -1, 0 }); + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + EXPECT_FALSE (optimizer.storeToLoadForwarding (fn)); + EXPECT_EQ (YdspIrOp::loadStateArrayF, insts.back().op); + } +} + +TEST (YdspOptimizerTests, DoesNotForwardTheDelayRingRead) +{ + YdspDiagnostics diagnostics; + + // `@` writes the ring at the write pointer and reads it one slot later, so + // the two indices differ and the read must not be forwarded - that would + // turn the delay into a pass-through. + auto ir = buildIr (R"YDSP( + processor P { input stream in; output stream out; process { out = in @ 6; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + EXPECT_EQ (1, countInst (fn, YdspIrOp::loadStateArrayF)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::storeStateArrayF)); +} + +TEST (YdspOptimizerTests, PromotesScalarStateOutOfTheSampleLoop) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor OnePole { + input stream in; + output stream out; + state float z; + process { z = z * 0.5 + in * 0.5; out = z; } + } + graph G { input stream x; output stream y; node p = OnePole; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + ASSERT_FALSE (fn.blocks.empty()); + + const auto countIn = [] (const YdspIrBlock& block, YdspIrOp op) + { + return static_cast (std::count_if (block.insts.begin(), block.insts.end(), [op] (const YdspIrInst& inst) + { + return inst.op == op; + })); + }; + + // The load belongs to the prologue and the store to the epilogue (the last + // block); the per-sample body in between must touch neither. + EXPECT_EQ (1, countIn (fn.blocks.front(), YdspIrOp::loadStateF)); + EXPECT_EQ (1, countIn (fn.blocks.back(), YdspIrOp::storeStateF)); + + for (size_t i = 1; i + 1 < fn.blocks.size(); ++i) + { + EXPECT_EQ (0, countIn (fn.blocks[i], YdspIrOp::loadStateF)) << "block " << i; + EXPECT_EQ (0, countIn (fn.blocks[i], YdspIrOp::storeStateF)) << "block " << i; + } +} + +TEST (YdspOptimizerTests, LowersFloat64AndInt64Values) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + state float64 z; + state int64 wp; + process { + float64 d = 0.5; + int64 j = 2; + float32 f = 0.25; + z = z + d; + wp = wp + j; + out = in + f; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + // Per-width state layout: one f64 scalar and one i64 scalar. + EXPECT_EQ (1, fn.float64Scalars); + EXPECT_EQ (1, fn.int64Scalars); + EXPECT_EQ (0, fn.float32Scalars); + EXPECT_EQ (0, fn.int32Scalars); + + bool sawF32 = false, sawF64 = false, sawI32 = false, sawI64 = false; + + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + { + if (inst.op == YdspIrOp::constF) + { + if (fn.valueTypes[static_cast (inst.result)] == YdspValueType::float64Type) + sawF64 = true; + if (fn.valueTypes[static_cast (inst.result)] == YdspValueType::float32Type) + sawF32 = true; + } + + if (inst.op == YdspIrOp::constI) + { + if (fn.valueTypes[static_cast (inst.result)] == YdspValueType::int64Type) + sawI64 = true; + if (fn.valueTypes[static_cast (inst.result)] == YdspValueType::int32Type) + sawI32 = true; + } + } + + EXPECT_TRUE (sawF64); + EXPECT_TRUE (sawI64); + EXPECT_TRUE (sawF32); + EXPECT_TRUE (sawI32); + + // The f64 scalar state is loaded as a float64-typed value. + bool sawF64Load = false; + + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + if (inst.op == YdspIrOp::loadStateF && inst.result >= 0 + && fn.valueTypes[static_cast (inst.result)] == YdspValueType::float64Type) + sawF64Load = true; + + EXPECT_TRUE (sawF64Load); +} + +TEST (YdspOptimizerTests, KeepsFloat64StateArraysSeparate) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + state float64 mem[8]; + process block { + for i in 0..blockSize { out[i] = float32(mem[i]); } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + EXPECT_EQ (8, fn.float64ArrayElements); + EXPECT_EQ (0, fn.float32ArrayElements); + EXPECT_EQ (0, fn.int64ArrayElements); +} + +TEST (YdspOptimizerTests, CopyPropagationDoesNotClobberDelayWritePointerSlots) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor ReverbLike { + input stream in; + output stream out; + + input parameter float mix = 0.3; + input parameter float feedback = 0.7; + input parameter float damping = 0.5; + + state float c1; state float c2; state float c3; state float c4; + state float c5; state float c6; state float c7; state float c8; + state float l1; state float l2; state float l3; state float l4; + state float l5; state float l6; state float l7; state float l8; + + process { + let d1 = c1 @ 10; l1 = l1 * damping + d1 * (1.0 - damping); c1 = l1 * feedback + in; + let d2 = c2 @ 11; l2 = l2 * damping + d2 * (1.0 - damping); c2 = l2 * feedback + in; + let d3 = c3 @ 12; l3 = l3 * damping + d3 * (1.0 - damping); c3 = l3 * feedback + in; + let d4 = c4 @ 13; l4 = l4 * damping + d4 * (1.0 - damping); c4 = l4 * feedback + in; + let d5 = c5 @ 14; l5 = l5 * damping + d5 * (1.0 - damping); c5 = l5 * feedback + in; + let d6 = c6 @ 15; l6 = l6 * damping + d6 * (1.0 - damping); c6 = l6 * feedback + in; + let d7 = c7 @ 16; l7 = l7 * damping + d7 * (1.0 - damping); c7 = l7 * feedback + in; + let d8 = c8 @ 17; l8 = l8 * damping + d8 * (1.0 - damping); c8 = l8 * feedback + in; + let d9 = c1 @ 18; c1 = l1 * feedback + in; + let d10 = c2 @ 19; c2 = l2 * feedback + in; + let d11 = c3 @ 20; c3 = l3 * feedback + in; + let d12 = c4 @ 21; c4 = l4 * feedback + in; + out = (d1 + d2 + d3 + d4 + d5 + d6 + d7 + d8 + d9 + d10 + d11 + d12) * mix; + } + } + graph G { input stream x; output stream y; node p = ReverbLike; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + std::vector wpLoadSlots; + std::vector wpStoreSlots; + + for (const auto& block : fn.blocks) + { + for (const auto& inst : block.insts) + { + if (inst.op == YdspIrOp::loadStateI) + wpLoadSlots.push_back (inst.a); + + if (inst.op == YdspIrOp::storeStateI) + wpStoreSlots.push_back (inst.memIndex); + } + } + + EXPECT_EQ (12u, wpLoadSlots.size()); + EXPECT_EQ (12u, wpStoreSlots.size()); + + // Every write-pointer load must stay within the int32 scalar segment... + for (const auto slot : wpLoadSlots) + EXPECT_LT (slot, fn.int32Scalars) << "write-pointer load addressed slot " << slot; + + // ... and must pair up with the matching store (same slot). + for (const auto slot : wpLoadSlots) + EXPECT_NE (wpStoreSlots.end(), std::find (wpStoreSlots.begin(), wpStoreSlots.end(), slot)) + << "no write-pointer store for load slot " << slot; +} + +//============================================================================== +// Event-handler lowering +//============================================================================== + +namespace +{ + +const YdspIrFunction* findEventHandler (const YdspIrProgram& ir, const char* name) +{ + for (const auto& fn : ir.eventHandlers) + if (fn->name == name) + return fn.get(); + + return nullptr; +} + +/** Collects the byte offsets carried by every event-field load of one opcode. */ +std::vector eventFieldOffsets (const YdspIrFunction& fn, YdspIrOp op) +{ + std::vector offsets; + + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + if (inst.op == op) + offsets.push_back (inst.memIndex); + + return offsets; +} + +constexpr const char* eventShapeIrSource = R"YDSP( + processor Voice { + output stream out; + input event midi; + state float f; + state int i; + event midi (e: noteOn) { f = e.pitch + e.velocity; if (e.isLegato) { f = 0.0; } } + event midi (e: pitchBend) { f = e.bendSemitones; } + event midi (e: controlChange) { i = e.control; f = e.value; } + process { out = f; } + } + graph G { input event midi; output stream y; node v = Voice; connection { midi -> v.midi; v.out -> y; } } +)YDSP"; + +} // namespace + +TEST (YdspOptimizerTests, LowersEventFieldsToOffsetCarryingLoads) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (eventShapeIrSource, diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto* noteOn = findEventHandler (*ir, "Voice.noteOn"); + ASSERT_NE (nullptr, noteOn); + + // `.pitch` and `.velocity` are float loads at their ABI offsets, and + // `.isLegato` is an int load of the flags word (no dedicated opcode). + const auto noteOnFloats = eventFieldOffsets (*noteOn, YdspIrOp::loadEventFieldF); + ASSERT_EQ (2u, noteOnFloats.size()); + EXPECT_EQ (static_cast (offsetof (YdspEventContext, pitch)), noteOnFloats[0]); + EXPECT_EQ (static_cast (offsetof (YdspEventContext, velocity)), noteOnFloats[1]); + + const auto noteOnInts = eventFieldOffsets (*noteOn, YdspIrOp::loadEventFieldI); + ASSERT_EQ (1u, noteOnInts.size()); + EXPECT_EQ (static_cast (offsetof (YdspEventContext, flags)), noteOnInts[0]); + + // `.isLegato` masks bit 0 and compares against zero. + EXPECT_EQ (1, countInst (*noteOn, YdspIrOp::andI)); + EXPECT_EQ (1, countInst (*noteOn, YdspIrOp::neI)); + + const auto* pitchBend = findEventHandler (*ir, "Voice.pitchBend"); + ASSERT_NE (nullptr, pitchBend); + + const auto bendFloats = eventFieldOffsets (*pitchBend, YdspIrOp::loadEventFieldF); + ASSERT_EQ (1u, bendFloats.size()); + EXPECT_EQ (static_cast (offsetof (YdspEventContext, bend)), bendFloats[0]); + + const auto* controlChange = findEventHandler (*ir, "Voice.controlChange"); + ASSERT_NE (nullptr, controlChange); + + const auto controlInts = eventFieldOffsets (*controlChange, YdspIrOp::loadEventFieldI); + ASSERT_EQ (1u, controlInts.size()); + EXPECT_EQ (static_cast (offsetof (YdspEventContext, index)), controlInts[0]); + + const auto controlFloats = eventFieldOffsets (*controlChange, YdspIrOp::loadEventFieldF); + ASSERT_EQ (1u, controlFloats.size()); + EXPECT_EQ (static_cast (offsetof (YdspEventContext, value)), controlFloats[0]); +} + +//============================================================================== +// Output-event ('emit') lowering +//============================================================================== + +namespace +{ + +int storeEventFieldValueFor (const YdspIrFunction& fn, YdspIrOp storeOp, int byteOffset) +{ + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + if (inst.op == storeOp && inst.memIndex == byteOffset) + return inst.a; + + return -1; +} + +bool isZeroConst (const YdspIrFunction& fn, int valueId) +{ + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + if (inst.result == valueId) + return (inst.op == YdspIrOp::constF && inst.fvalue == 0.0) + || (inst.op == YdspIrOp::constI && inst.ivalue == 0) + || (inst.op == YdspIrOp::constB && ! inst.bvalue); + + return false; +} + +bool isDefinedByOp (const YdspIrFunction& fn, int valueId, YdspIrOp op) +{ + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + if (inst.result == valueId) + return inst.op == op; + + return false; +} + +} // namespace + +TEST (YdspOptimizerTests, EmitZeroFillsFieldsOmittedFromTheEmitCall) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + output event noteOn; + process { + emit noteOn (pitch: in) -> noteOn; + out = in; + } + } + graph G { input stream x; output stream y; output event noteOn; node p = P; connection { x -> p.in; p.out -> y; p.noteOn -> noteOn; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_EQ (1u, ir->kernels.size()); + + const auto& fn = *ir->kernels[0]; + + // noteOn declares 5 fields (pitch, velocity, bendSemitones, isLegato, + // channel); only `pitch` is given, so all 5 must still be staged - the + // other 4 zero-filled. + EXPECT_EQ (3, countInst (fn, YdspIrOp::storeEventFieldF)); + EXPECT_EQ (2, countInst (fn, YdspIrOp::storeEventFieldI)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::emitEvent)); + + const auto pitchOffset = static_cast (offsetof (YdspEventContext, pitch)); + const auto velocityOffset = static_cast (offsetof (YdspEventContext, velocity)); + const auto bendOffset = static_cast (offsetof (YdspEventContext, bend)); + const auto flagsOffset = static_cast (offsetof (YdspEventContext, flags)); + const auto channelOffset = static_cast (offsetof (YdspEventContext, channel)); + + const auto pitchValue = storeEventFieldValueFor (fn, YdspIrOp::storeEventFieldF, pitchOffset); + ASSERT_GE (pitchValue, 0); + EXPECT_TRUE (isDefinedByOp (fn, pitchValue, YdspIrOp::loadInput)) << "pitch was given and must come from the real expression, not a zero-fill"; + + const auto velocityValue = storeEventFieldValueFor (fn, YdspIrOp::storeEventFieldF, velocityOffset); + ASSERT_GE (velocityValue, 0); + EXPECT_TRUE (isZeroConst (fn, velocityValue)) << "velocity was omitted and must be zero-filled"; + + const auto bendValue = storeEventFieldValueFor (fn, YdspIrOp::storeEventFieldF, bendOffset); + ASSERT_GE (bendValue, 0); + EXPECT_TRUE (isZeroConst (fn, bendValue)) << "bendSemitones was omitted and must be zero-filled"; + + const auto flagsValue = storeEventFieldValueFor (fn, YdspIrOp::storeEventFieldI, flagsOffset); + ASSERT_GE (flagsValue, 0); + EXPECT_TRUE (isZeroConst (fn, flagsValue)) << "isLegato was omitted and must be zero-filled"; + + const auto channelValue = storeEventFieldValueFor (fn, YdspIrOp::storeEventFieldI, channelOffset); + ASSERT_GE (channelValue, 0); + EXPECT_TRUE (isZeroConst (fn, channelValue)) << "channel was omitted and must be zero-filled"; +} + +//============================================================================== +// Parameter smoothing +//============================================================================== + +TEST (YdspOptimizerTests, HoistsSmoothCoefficientOutOfSampleLoop) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float gain = 0.5; + process { out = in * smooth (gain, 0.02); } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_EQ (1u, ir->kernels.size()); + + const auto& fn = *ir->kernels[0]; + + // The whole coefficient chain (1 - exp (-samplePeriod / tau)) depends only + // on sampleRate and a literal, so it must be hoisted into the entry block + // and paid once per kernel invocation rather than once per sample. This is + // the performance claim behind smooth() being one lerp per sample. + int entryExpF = 0; + + for (const auto& inst : fn.blocks[0].insts) + if (inst.op == YdspIrOp::expF) + ++entryExpF; + + EXPECT_EQ (1, entryExpF); + EXPECT_EQ (1, countInst (fn, YdspIrOp::expF)); + + // The per-sample work is the lerp plus the snap compare/select. + EXPECT_EQ (1, countInst (fn, YdspIrOp::lerpF)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::selectB)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::eqF)); +} + +TEST (YdspOptimizerTests, SmoothingAnnotationLowersIdenticallyToExplicitSmoothLocal) +{ + YdspDiagnostics sugarDiagnostics; + + auto sugar = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float gain = 0.5 [[ smoothing: 0.02 ]]; + process { out = in * gain; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + sugarDiagnostics); + + YdspDiagnostics explicitDiagnostics; + + auto explicitForm = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float gain = 0.5; + process { + float gainSmoothed = smooth (gain, 0.02); + out = in * gainSmoothed; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + explicitDiagnostics); + + ASSERT_FALSE (sugarDiagnostics.hasErrors()) << sugarDiagnostics.toString(); + ASSERT_FALSE (explicitDiagnostics.hasErrors()) << explicitDiagnostics.toString(); + ASSERT_NE (nullptr, sugar); + ASSERT_NE (nullptr, explicitForm); + + // `[[ smoothing: t ]]` emits literally `smooth (param, t)`, so the two + // forms must produce the same opcodes against the same state slots. Value + // ids are deliberately not compared: they are an emission-order artifact, + // not part of what the sugar promises. YdspJitGraphTests' + // SmoothIntrinsicMatchesSmoothingAnnotation pins the observable property + // (identical output samples). + ASSERT_EQ (sugar->kernels.size(), explicitForm->kernels.size()); + + const auto& a = *sugar->kernels[0]; + const auto& b = *explicitForm->kernels[0]; + + EXPECT_EQ (a.float32Scalars, b.float32Scalars); + EXPECT_EQ (a.int32Scalars, b.int32Scalars); + + ASSERT_EQ (a.blocks.size(), b.blocks.size()); + + for (size_t block = 0; block < a.blocks.size(); ++block) + { + const auto& lhs = a.blocks[block].insts; + const auto& rhs = b.blocks[block].insts; + + ASSERT_EQ (lhs.size(), rhs.size()) << "block " << block; + + for (size_t i = 0; i < lhs.size(); ++i) + { + EXPECT_EQ (lhs[i].op, rhs[i].op) << "block " << block << " inst " << i; + EXPECT_EQ (lhs[i].memIndex, rhs[i].memIndex) << "block " << block << " inst " << i; + } + } +} + +TEST (YdspOptimizerTests, SmoothAllocatesOneHiddenSlotPairPerCallSite) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float gain = 0.5; + input parameter float pan = 0.5; + process { out = in * smooth (gain, 0.02) * smooth (pan, 0.05); } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + // The scalar layout counts only declared `state`, and this processor has + // none, so every slot here is a smoother's: two float slots (the running + // values) and two int slots (the primed flags). + EXPECT_EQ (2, fn.float32Scalars); + EXPECT_EQ (2, fn.int32Scalars); + EXPECT_EQ (2, countInst (fn, YdspIrOp::lerpF)); + EXPECT_EQ (2, countInst (fn, YdspIrOp::loadStateF)); + EXPECT_EQ (2, countInst (fn, YdspIrOp::storeStateF)); + EXPECT_EQ (2, countInst (fn, YdspIrOp::loadStateI)); + EXPECT_EQ (2, countInst (fn, YdspIrOp::storeStateI)); +} + +//============================================================================== +// Parameter hoisting +//============================================================================== + +TEST (YdspOptimizerTests, ParameterDerivedCoefficientIsHoistedOutOfSampleLoop) +{ + YdspDiagnostics diagnostics; + + // Parameters are sampled once per kernel invocation - the builder loads + // them in the prologue, and automation is applied *between* invocations + // because the runtime splits the block at each event offset. So a + // coefficient built only from parameters is loop-invariant and its exp is + // paid once per (sub-)block, which is what lets a patch compute a filter + // coefficient straight from a parameter without per-sample cost. Both the + // smoothing docs and the Analog Saw / Pulse Bass patches rely on this. + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float cutoff = 400.0; + state float z1; + process { + float k = 1.0 - exp (-6.283185307 * cutoff / sampleRate); + z1 = z1 + k * (in - z1); + out = z1; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + + const auto& fn = *ir->kernels[0]; + + bool paramLoadInEntry = false; + bool coefficientInEntry = false; + + for (const auto& inst : fn.blocks[0].insts) + { + if (inst.op == YdspIrOp::loadParam) + paramLoadInEntry = true; + + if (inst.op == YdspIrOp::expF) + coefficientInEntry = true; + } + + EXPECT_TRUE (paramLoadInEntry); + EXPECT_TRUE (coefficientInEntry); + EXPECT_EQ (1, countInst (fn, YdspIrOp::loadParam)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::expF)); + + // The recurrence itself must stay in the loop: z1 is updated per sample. + int loopStores = 0; + + for (size_t block = 1; block < fn.blocks.size(); ++block) + for (const auto& inst : fn.blocks[block].insts) + if (inst.op == YdspIrOp::storeStateF) + ++loopStores; + + EXPECT_GT (loopStores, 0); +} + +//============================================================================== +// The [[ role: voiceActivity ]] flag's resolved byte offset. + +TEST (YdspOptimizerTests, ResolvesActivityByteOffsetPastHiddenSlots) +{ + YdspDiagnostics diagnostics; + + // Two declared f32 scalars, then a ' and a smooth() - both of which + // allocate hidden f32 slots *past* the declared layout and so push the i32 + // region base out. A naive `declaredFloat32Scalars * 4` would land on the + // wrong slot; the offset must be computed off the kernel's final counts. + auto ir = buildIr (R"YDSP( + processor V { + input stream in; + output stream out; + input event midi; + state float env; + state float z; + state int other; + state int active [[ role: voiceActivity ]]; + event midi (e: noteOn) { env = e.velocity; active = 1; } + process { + other = other + 1; + env = env * 0.999; + active = select (env < 0.000001, 0, 1); + z = in; + out = smooth (env, 0.003) * z'; + } + } + graph G { input stream x; input event midi; output stream y; node v = V[4]; connection { x -> v.in; midi -> v.midi; v.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + const auto& fn = *ir->kernels[0]; + + // The hidden slots really are there: more f32 scalars than the two declared. + EXPECT_GT (fn.float32Scalars, 2); + + // Hidden i32 slots are appended past the declared ones too, so this is a + // lower bound rather than an equality. + EXPECT_GE (fn.int32Scalars, 2); + + // `active` is the second *declared* i32 scalar - declared slots come first + // within the region - and the i32 region starts after *every* f32 scalar, + // hidden ones included. + EXPECT_EQ (fn.float32Scalars * 4 + 4, fn.activityByteOffset); + + // ... and it lands inside the scalar segment. + EXPECT_LT (static_cast (fn.activityByteOffset), fn.stateScalarSize()); +} + +TEST (YdspOptimizerTests, LeavesActivityByteOffsetUnsetWithoutTheAnnotation) +{ + YdspDiagnostics diagnostics; + + auto ir = buildIr (R"YDSP( + processor V { + output stream out; + input event midi; + state float env; + state int active; + event midi (e: noteOn) { env = e.velocity; active = 1; } + process { out = env * float (active); } + } + graph G { input event midi; output stream y; node v = V[4]; connection { midi -> v.midi; v.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + EXPECT_EQ (-1, ir->kernels[0]->activityByteOffset); +} + +//============================================================================== +// Full unroll of constant-trip-count loops +//============================================================================== + +namespace +{ + +/** buildIr with the unroller switched on, as the compiler runs it natively. */ +std::unique_ptr buildUnrolledIr (StringRef source, YdspDiagnostics& diagnostics) +{ + YdspLexer lexer (source, diagnostics); + auto tokens = lexer.tokenize(); + + YdspParser parser (std::move (tokens), diagnostics); + auto program = parser.parseProgram(); + if (program == nullptr) + return nullptr; + + YdspSemanticAnalyzer analyzer (diagnostics); + auto analyzed = analyzer.analyze (std::move (program)); + if (analyzed == nullptr) + return nullptr; + + YdspOptimizer optimizer (diagnostics); + optimizer.setUnrollingEnabled (true); + + return optimizer.build (*analyzed); +} + +/** buildIr with the native transforms on, optionally including the split. */ +std::unique_ptr buildNativeIr (StringRef source, YdspDiagnostics& diagnostics, bool splitReductions) +{ + YdspLexer lexer (source, diagnostics); + auto tokens = lexer.tokenize(); + + YdspParser parser (std::move (tokens), diagnostics); + auto program = parser.parseProgram(); + if (program == nullptr) + return nullptr; + + YdspSemanticAnalyzer analyzer (diagnostics); + auto analyzed = analyzer.analyze (std::move (program)); + if (analyzed == nullptr) + return nullptr; + + YdspOptimizer optimizer (diagnostics); + optimizer.setVectorizationEnabled (true); + optimizer.setUnrollingEnabled (true); + optimizer.setReductionSplittingEnabled (splitReductions); + + return optimizer.build (*analyzed); +} + +/** A bank whose 8 modes widen to two lanes' worth of copies and accumulate. */ +constexpr auto accumulatingBankSource = R"YDSP( + processor P { + input stream in; + output stream out; + state float z[16]; + process { + float sum = 0.0; + for i in 0..16 { + z[i] = z[i] * 0.9 + in; + sum = sum + z[i]; + } + out = sum; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } +)YDSP"; + +/** The `for` loop of a single-processor patch, i.e. not the sample loop. */ +const YdspIrLoop* innerLoop (const YdspIrFunction& fn) +{ + for (const auto& loop : fn.loops) + if (loop.bound.kind == YdspLoopBoundKind::constant) + return &loop; + + return nullptr; +} + +} // namespace + +TEST (YdspOptimizerTests, FullyUnrollsAConstantTripCountLoop) +{ + YdspDiagnostics diagnostics; + + auto ir = buildUnrolledIr (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[4]; + process { + for i in 0..4 { z[i] = z[i] * 0.5 + in; } + out = z[0]; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + const auto& fn = *ir->kernels[0]; + const auto* loop = innerLoop (fn); + + ASSERT_NE (nullptr, loop); + EXPECT_TRUE (loop->unrolled); + + // The header and the body are what the loop was; both are emptied and left + // falling through, so no block index moved. + EXPECT_TRUE (fn.blocks[static_cast (loop->headerBlock)].insts.empty()); + EXPECT_TRUE (fn.blocks[static_cast (loop->headerBlock + 1)].insts.empty()); + EXPECT_EQ (YdspIrTerm::fallthrough, fn.blocks[static_cast (loop->headerBlock)].term); + EXPECT_EQ (YdspIrTerm::fallthrough, fn.blocks[static_cast (loop->headerBlock + 1)].term); + + // Four copies of the body: four element loads rather than one. + EXPECT_EQ (4, countInst (fn, YdspIrOp::storeStateArrayF)); + + // ... and the worst-case iteration count the report reads is untouched, so + // "how many iterations could this run" still answers 4 rather than 1. + EXPECT_EQ (4, loop->bound.constant); +} + +TEST (YdspOptimizerTests, HalvesAWidenedAccumulator) +{ + YdspDiagnostics rolledUpDiagnostics, splitDiagnostics; + + auto single = buildNativeIr (accumulatingBankSource, rolledUpDiagnostics, false); + auto split = buildNativeIr (accumulatingBankSource, splitDiagnostics, true); + + ASSERT_FALSE (rolledUpDiagnostics.hasErrors()) << rolledUpDiagnostics.toString(); + ASSERT_FALSE (splitDiagnostics.hasErrors()) << splitDiagnostics.toString(); + ASSERT_NE (nullptr, single); + ASSERT_NE (nullptr, split); + + const auto& singleFn = *single->kernels[0]; + const auto& splitFn = *split->kernels[0]; + + ASSERT_TRUE (singleFn.vectorized) << "the split only ever applies to a widened accumulator"; + ASSERT_TRUE (splitFn.vectorized); + + // The balanced tree over a 4-link chain adds nine vector values: the + // pre-chain accumulator snapshot, four per-link addend snapshots (loop + // splitting conservatively snapshots each addend at its original link) + // and four internal pair sums (leaves = 5, depth 2). + ASSERT_EQ (singleFn.valueTypes.size() + 9, splitFn.valueTypes.size()); + EXPECT_EQ (YdspVectorizer::vectorWidth, splitFn.laneCountOf (static_cast (splitFn.valueTypes.size()) - 1)); + + // The add count is unchanged: the four serial chain adds become the four + // tree adds. The serial form's per-link accumulator moves are dropped, but + // the per-link addend snapshots the tree needs replace them, so the move + // count is no longer a meaningful comparison between the two forms. + EXPECT_EQ (countInst (singleFn, YdspIrOp::addF), countInst (splitFn, YdspIrOp::addF)); + + // Still one horizontal fold: the tree is built before it, not beside it. + EXPECT_EQ (countInst (singleFn, YdspIrOp::vreduceAddF), countInst (splitFn, YdspIrOp::vreduceAddF)); + EXPECT_EQ (1, countInst (splitFn, YdspIrOp::vreduceAddF)); +} + +TEST (YdspOptimizerTests, HalvesALongAccumulatorRepeatedly) +{ + YdspDiagnostics singleDiagnostics, splitDiagnostics; + + // 32 modes over four lanes is eight links. The balanced tree adds + // seventeen vector values for that chain: the pre-chain accumulator + // snapshot, eight per-link addend snapshots (one per unrolled copy, see + // HalvesAWidenedAccumulator) and eight internal pair sums (leaves = 9). + constexpr auto source = R"YDSP( + processor P { + input stream in; + output stream out; + state float z[32]; + process { + float sum = 0.0; + for i in 0..32 { + z[i] = z[i] * 0.5 + in; + sum = sum + z[i]; + } + out = sum; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + + auto single = buildNativeIr (source, singleDiagnostics, false); + auto split = buildNativeIr (source, splitDiagnostics, true); + + ASSERT_FALSE (singleDiagnostics.hasErrors()) << singleDiagnostics.toString(); + ASSERT_FALSE (splitDiagnostics.hasErrors()) << splitDiagnostics.toString(); + ASSERT_NE (nullptr, single); + ASSERT_NE (nullptr, split); + + ASSERT_TRUE (single->kernels[0]->vectorized); + + EXPECT_EQ (single->kernels[0]->valueTypes.size() + 17, split->kernels[0]->valueTypes.size()); + + EXPECT_EQ (1, countInst (*split->kernels[0], YdspIrOp::vreduceAddF)); +} + +TEST (YdspOptimizerTests, LeavesAScalarAccumulatorAlone) +{ + YdspDiagnostics plainDiagnostics, splitDiagnostics; + + // No vectoriser, so the accumulator never becomes a widened one - and a + // scalar sum carries no licence to be re-associated. + const auto build = [] (YdspDiagnostics& diagnostics, bool splitReductions) + { + YdspLexer lexer (accumulatingBankSource, diagnostics); + auto tokens = lexer.tokenize(); + + YdspParser parser (std::move (tokens), diagnostics); + auto program = parser.parseProgram(); + + YdspSemanticAnalyzer analyzer (diagnostics); + auto analyzed = analyzer.analyze (std::move (program)); + + YdspOptimizer optimizer (diagnostics); + optimizer.setUnrollingEnabled (true); + optimizer.setReductionSplittingEnabled (splitReductions); + + return optimizer.build (*analyzed); + }; + + auto plain = build (plainDiagnostics, false); + auto split = build (splitDiagnostics, true); + + ASSERT_FALSE (plainDiagnostics.hasErrors()) << plainDiagnostics.toString(); + ASSERT_FALSE (splitDiagnostics.hasErrors()) << splitDiagnostics.toString(); + ASSERT_NE (nullptr, plain); + ASSERT_NE (nullptr, split); + + ASSERT_FALSE (plain->kernels[0]->vectorized); + + EXPECT_EQ (plain->kernels[0]->valueTypes.size(), split->kernels[0]->valueTypes.size()) + << "a scalar accumulator was re-associated"; + EXPECT_EQ (countInst (*plain->kernels[0], YdspIrOp::addF), + countInst (*split->kernels[0], YdspIrOp::addF)); +} + +TEST (YdspOptimizerTests, ReportsWhetherAKernelWasUnrolled) +{ + YdspDiagnostics rolledDiagnostics, unrolledDiagnostics; + + constexpr auto source = R"YDSP( + processor P { + input stream in; + output stream out; + state float z[4]; + process { + for i in 0..4 { z[i] = z[i] * 0.5 + in; } + out = z[0]; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + + auto rolled = buildIr (source, rolledDiagnostics); + auto unrolled = buildUnrolledIr (source, unrolledDiagnostics); + + ASSERT_FALSE (rolledDiagnostics.hasErrors()) << rolledDiagnostics.toString(); + ASSERT_FALSE (unrolledDiagnostics.hasErrors()) << unrolledDiagnostics.toString(); + ASSERT_NE (nullptr, rolled); + ASSERT_NE (nullptr, unrolled); + + YdspExecutionReport rolledReport, unrolledReport; + YdspOptimizer::buildReport (*rolled, rolledReport); + YdspOptimizer::buildReport (*unrolled, unrolledReport); + + ASSERT_FALSE (rolledReport.getKernels().empty()); + ASSERT_FALSE (unrolledReport.getKernels().empty()); + + EXPECT_FALSE (rolledReport.getKernels()[0].unrolled); + EXPECT_TRUE (unrolledReport.getKernels()[0].unrolled); + + EXPECT_EQ (rolledReport.getKernels()[0].boundedIterationCount, + unrolledReport.getKernels()[0].boundedIterationCount); +} + +TEST (YdspOptimizerTests, LeavesARuntimeBoundLoopRolled) +{ + YdspDiagnostics diagnostics; + + auto ir = buildUnrolledIr (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..blockSize { out[i] = in[i] * 0.5; } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + const auto& fn = *ir->kernels[0]; + + ASSERT_FALSE (fn.loops.empty()); + + for (const auto& loop : fn.loops) + EXPECT_FALSE (loop.unrolled) << "a loop with no constant trip count was unrolled"; +} + +TEST (YdspOptimizerTests, LeavesALoopTooLargeToUnrollRolled) +{ + YdspDiagnostics diagnostics; + + auto ir = buildUnrolledIr (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[32]; + process { + float sum = 0.0; + for i in 0..32 { + z[i] = z[i] * 0.9 + in * 0.1 + z[i] * z[i] * 0.01 - in * in * 0.02; + sum = sum + z[i] * 0.5 + z[i] * 0.25; + } + out = sum; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + const auto& fn = *ir->kernels[0]; + const auto* loop = innerLoop (fn); + + ASSERT_NE (nullptr, loop); + EXPECT_FALSE (loop->unrolled); + EXPECT_EQ (YdspIrTerm::branchIf, fn.blocks[static_cast (loop->headerBlock)].term); +} + +TEST (YdspOptimizerTests, LeavesAHighPressureScalarLoopRolled) +{ + YdspDiagnostics diagnostics; + + auto ir = buildUnrolledIr (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[16]; + process { + float sum = 0.0; + for i in 0..16 { + z[i] = z[i] * 0.9 + in * 0.1 + z[i] * z[i] * 0.01 - in * in * 0.02; + sum = sum + z[i] * 0.5 + z[i] * 0.25; + } + out = sum; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + const auto& fn = *ir->kernels[0]; + const auto* loop = innerLoop (fn); + + ASSERT_NE (nullptr, loop); + EXPECT_FALSE (loop->unrolled); + EXPECT_EQ (YdspIrTerm::branchIf, fn.blocks[static_cast (loop->headerBlock)].term); +} + +TEST (YdspOptimizerTests, LeavesAScalarLoopWithNativeCallsRolled) +{ + YdspDiagnostics diagnostics; + + auto ir = buildUnrolledIr (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[4]; + process { + for i in 0..4 { z[i] = pow (abs (z[i]) + 1.0, 0.5) + in; } + out = z[0]; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + const auto& fn = *ir->kernels[0]; + const auto* loop = innerLoop (fn); + + ASSERT_NE (nullptr, loop); + EXPECT_FALSE (loop->unrolled); + EXPECT_EQ (YdspIrTerm::branchIf, fn.blocks[static_cast (loop->headerBlock)].term); +} + +TEST (YdspOptimizerTests, LeavesANestedLoopRolled) +{ + YdspDiagnostics diagnostics; + + auto ir = buildUnrolledIr (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[4]; + process { + for i in 0..4 { + for j in 0..2 { z[i] = z[i] * 0.5 + in; } + } + out = z[0]; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + const auto& fn = *ir->kernels[0]; + + int unrolledCount = 0; + + for (const auto& loop : fn.loops) + if (loop.unrolled) + ++unrolledCount; + + EXPECT_LE (unrolledCount, 1); + + for (const auto& loop : fn.loops) + if (loop.unrolled) + EXPECT_EQ (2, loop.bound.constant) << "the outer loop was unrolled"; +} + +//============================================================================== + +TEST (YdspOptimizerTests, FoldsStateWriteBackMovesIntoTheirProducers) +{ + YdspDiagnostics diagnostics; + + // Three chained per-sample scalar state updates. Each becomes a fresh + // arithmetic result plus a `movF` write-back into the loop-carried state + // register; the fold removes the moves so the state chain stays in + // registers without a per-sample hop through a second register. + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + state float y0; + state float y1; + state float y2; + process { + y0 = y0 + in; + y1 = y1 * 0.5 + y0; + y2 = y2 * 0.25 + y1; + out = y2; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_EQ (1u, ir->kernels.size()); + + const auto& fn = *ir->kernels[0]; + + // The integer induction update folds exactly like the float chains now: no + // write-back move survives - each increment writes the loop-carried + // register directly. + EXPECT_EQ (0, countInst (fn, YdspIrOp::movF)); + EXPECT_EQ (0, countInst (fn, YdspIrOp::movI)); +} + +//============================================================================== +// foldStateWriteBacks validity: every IR read must resolve to a definition. +//============================================================================== + +namespace +{ + +// Mirrors the module's isValueIdOperand: which operands hold a value id at +// all. A scalar load's first operand is a state/param slot, not a value. +bool ydspIsValueIdOperandForTest (YdspIrOp op, int operand) +{ + switch (op) + { + case YdspIrOp::loadParam: + case YdspIrOp::loadParamOut: + case YdspIrOp::loadStateF: + case YdspIrOp::loadStateI: + return operand != 0; + + default: + return true; + } +} + +// True when every value-id operand and every branchIf terminator condition has +// at least one instruction anywhere in the function writing that value. A read +// with no definition is the ?255 asmjit renders on AArch64. +bool ydspEveryUseIsDefined (const YdspIrFunction& fn) +{ + std::vector defined (fn.valueTypes.size(), 0); + + for (const auto& block : fn.blocks) + { + for (const auto& inst : block.insts) + { + if (inst.result >= 0 && static_cast (inst.result) < defined.size()) + defined[static_cast (inst.result)] = 1; + } + } + + const auto isDefined = [&defined, &fn] (int value) + { + if (value < 0) + return true; + + const auto index = static_cast (value); + + return index < fn.valueTypes.size() && index < defined.size() && defined[index] != 0; + }; + + for (const auto& block : fn.blocks) + { + for (const auto& inst : block.insts) + { + if (ydspIsValueIdOperandForTest (inst.op, 0) && ! isDefined (inst.a)) + return false; + + if (ydspIsValueIdOperandForTest (inst.op, 1) && ! isDefined (inst.b)) + return false; + + if (ydspIsValueIdOperandForTest (inst.op, 2) && ! isDefined (inst.c)) + return false; + } + + if (block.term == YdspIrTerm::branchIf && ! isDefined (block.termCond)) + return false; + } + + return true; +} + +YdspIrInst ydspInst (YdspIrOp op, int result, int a = -1, int b = -1, int c = -1) +{ + YdspIrInst inst; + inst.op = op; + inst.result = result; + inst.a = a; + inst.b = b; + inst.c = c; + return inst; +} + +} // namespace + +TEST (YdspOptimizerTests, ConstantFoldingUnsignedBoundsRejectsNegativeIndices) +{ + for (const auto type : { YdspValueType::int32Type, YdspValueType::int64Type }) + for (const int64_t index : { int64_t { -2147483648 }, int64_t { -1 }, int64_t { 0 }, int64_t { 7 }, int64_t { 8 }, int64_t { 2147483647 } }) + { + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes = { type, type, YdspValueType::boolType }; + YdspIrBlock block; + block.insts = { ydspInst (YdspIrOp::constI, 0), ydspInst (YdspIrOp::constI, 1), ydspInst (YdspIrOp::ltUI, 2, 0, 1) }; + block.insts[0].ivalue = index; + block.insts[1].ivalue = 8; + fn.blocks.push_back (std::move (block)); + EXPECT_TRUE (optimizer.constantFolding (fn)); + EXPECT_EQ (YdspIrOp::constB, fn.blocks[0].insts[2].op); + EXPECT_EQ (index >= 0 && index < 8, fn.blocks[0].insts[2].bvalue); + } +} + +TEST (YdspOptimizerTests, BoundsProofRejectsRedefinedValuesAndOverflow) +{ + for (const bool redefine : { false, true }) + { + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes = { YdspValueType::int32Type, YdspValueType::int32Type, + YdspValueType::int32Type, YdspValueType::int32Type, + YdspValueType::boolType }; + fn.blocks.resize (1); + auto& insts = fn.blocks[0].insts; + insts = { ydspInst (YdspIrOp::constI, 0), ydspInst (YdspIrOp::constI, 1), + ydspInst (YdspIrOp::addI, 2, 0, 1), ydspInst (YdspIrOp::constI, 3), + ydspInst (YdspIrOp::ltUI, 4, 2, 3) }; + insts[0].ivalue = redefine ? 0 : std::numeric_limits::max(); + insts[1].ivalue = 1; + insts[3].ivalue = 8; + if (redefine) + { + auto overwrite = ydspInst (YdspIrOp::constI, 2); + overwrite.ivalue = -1; + insts.insert (insts.begin() + 3, overwrite); + } + EXPECT_FALSE (optimizer.eliminateProvenBoundsChecks (fn)); + EXPECT_EQ (YdspIrOp::ltUI, insts.back().op); + } +} + +TEST (YdspOptimizerTests, BoundsProofHandlesSignedProductsAndRetainsOverflowChecks) +{ + struct Case + { + int64_t lowerA, upperA, lowerB, upperB, offset, bound; + bool proven; + }; + const Case cases[] { + { 0, 3, 4, 4, 3, 16, true }, + { -3, 0, -4, -2, 0, 13, true }, + { -3, 2, -4, 5, 15, 31, true }, + { -3, 2, -4, 5, 15, 25, false }, + { -3, 2, -4, 5, 14, 31, false }, + { 0, 4, 4, 4, 0, 16, false }, + { -2147483648, 2147483647, 0, 0, 0, 1, true }, + { 0, 65536, 0, 65536, 0, 2147483647, false }, + { -2147483648, -2147483648, -1, -1, 0, 2147483647, false }, + { -2147483648, -2147483648, -2147483648, -2147483648, 0, 1, false }, + }; + for (const auto& item : cases) + for (const bool redefine : { false, true }) + { + SCOPED_TRACE (::testing::Message() << "a=[" << item.lowerA << "," << item.upperA + << "], b=[" << item.lowerB << "," << item.upperB + << "], offset=" << item.offset << ", bound=" << item.bound + << ", redefine=" << redefine); + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes.assign (13, YdspValueType::int32Type); + fn.valueTypes[12] = YdspValueType::boolType; + fn.blocks.resize (1); + auto& insts = fn.blocks[0].insts; + insts = { + ydspInst (YdspIrOp::loadParam, 0), ydspInst (YdspIrOp::loadParam, 1), + ydspInst (YdspIrOp::constI, 2), ydspInst (YdspIrOp::constI, 3), + ydspInst (YdspIrOp::constI, 4), ydspInst (YdspIrOp::constI, 5), + ydspInst (YdspIrOp::clampI, 6, 0, 2, 3), + ydspInst (YdspIrOp::clampI, 7, 1, 4, 5), + ydspInst (YdspIrOp::mulI, 8, 6, 7), + ydspInst (YdspIrOp::constI, 9), + ydspInst (YdspIrOp::addI, 10, 8, 9), + ydspInst (YdspIrOp::constI, 11), + ydspInst (YdspIrOp::ltUI, 12, 10, 11), + }; + insts[2].ivalue = item.lowerA; + insts[3].ivalue = item.upperA; + insts[4].ivalue = item.lowerB; + insts[5].ivalue = item.upperB; + insts[9].ivalue = item.offset; + insts[11].ivalue = item.bound; + if (redefine) + insts.insert (insts.begin() + 9, ydspInst (YdspIrOp::loadParam, 8)); + const bool expected = item.proven && ! redefine; + EXPECT_EQ (expected, optimizer.eliminateProvenBoundsChecks (fn)); + EXPECT_EQ (expected ? YdspIrOp::constB : YdspIrOp::ltUI, insts.back().op); + if (expected) + EXPECT_TRUE (insts.back().bvalue); + } +} + +TEST (YdspOptimizerTests, RemovesBoundsChecksForMaskedStridedArrayAccess) +{ + YdspDiagnostics diagnostics; + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + state float table[16]; + process { + int32 index = (int32 (in) & 3) * 4 + 3; + out = table[index]; + table[index] = in; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + EXPECT_EQ (0, countInst (*ir->kernels[0], YdspIrOp::ltUI)); + EXPECT_EQ (1, countInst (*ir->kernels[0], YdspIrOp::loadStateArrayF)); + EXPECT_EQ (1, countInst (*ir->kernels[0], YdspIrOp::storeStateArrayF)); +} + +TEST (YdspOptimizerTests, ConstantFoldingSignExtendsInt32Constants) +{ + for (const int64_t bits : { int64_t { 0 }, int64_t { 7 }, int64_t { -1 }, int64_t { 0x7fffffff }, int64_t { 0x80000000 }, int64_t { 0xffffffff } }) + { + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes = { YdspValueType::int32Type, YdspValueType::int64Type }; + YdspIrBlock block; + block.insts = { ydspInst (YdspIrOp::constI, 0), ydspInst (YdspIrOp::extI, 1, 0) }; + block.insts[0].ivalue = bits; + fn.blocks.push_back (std::move (block)); + + EXPECT_TRUE (optimizer.constantFolding (fn)); + const auto& result = fn.blocks[0].insts[1]; + EXPECT_EQ (YdspIrOp::constI, result.op); + EXPECT_EQ (static_cast (static_cast (bits)), result.ivalue) << bits; + EXPECT_EQ (YdspValueType::int64Type, fn.valueTypes[1]); + EXPECT_FALSE (optimizer.constantFolding (fn)); + } +} + +TEST (YdspOptimizerTests, BoundsUnrollingAroundFourLaneMathCalls) +{ + for (const int modes : { 8, 16 }) + for (const int calls : { 1, 2 }) + { + SCOPED_TRACE (::testing::Message() << "modes=" << modes << ", calls=" << calls); + YdspDiagnostics diagnostics; + const auto source = String (R"YDSP( + processor P { + input stream in; + output stream out; + state float phase[MODES]; + process { + float sum = 0.0; + for i in 0..MODES { + phase[i] = phase[i] + in; + sum = sum + (SHAPE); + } + out = sum; + } + } + graph G { input stream x; output stream y; node p = P; + connection { x -> p.in; p.out -> y; } } + )YDSP") + .replace ("MODES", String (modes)) + .replace ("SHAPE", calls == 1 ? "sin (phase[i])" : "sin (phase[i]) + cos (phase[i])"); + YdspLexer lexer (source, diagnostics); + YdspParser parser (lexer.tokenize(), diagnostics); + auto parsed = parser.parseProgram(); + ASSERT_NE (nullptr, parsed) << diagnostics.toString(); + YdspSemanticAnalyzer analyzer (diagnostics); + auto analyzed = analyzer.analyze (std::move (parsed)); + ASSERT_NE (nullptr, analyzed) << diagnostics.toString(); + YdspOptimizer optimizer (diagnostics); + optimizer.setFastMath (true); + optimizer.setVectorizationEnabled (true); + optimizer.setVectorWidth (4); + optimizer.setTargetHasVectorMath (true); + auto ir = optimizer.build (*analyzed); + ASSERT_NE (nullptr, ir) << diagnostics.toString(); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_FALSE (ir->kernels.empty()); + auto& fn = *ir->kernels[0]; + StringArray vectorizationDecisions; + for (const auto& result : fn.vectorizationResults) + vectorizationDecisions.add (result.describe()); + ASSERT_TRUE (fn.vectorized) << vectorizationDecisions.joinIntoString ("\n"); + if (modes == 8 && calls == 1) + { + auto unsupported = fn; + unsupported.vectorMathEnabled = false; + optimizer.fullyUnrollBoundedLoops (unsupported); + ASSERT_NE (nullptr, innerLoop (unsupported)); + EXPECT_FALSE (innerLoop (unsupported)->unrolled); + + auto pressured = fn; + ASSERT_NE (nullptr, innerLoop (pressured)); + const auto& loop = *innerLoop (pressured); + auto& preheader = pressured.blocks[static_cast (loop.headerBlock - 1)].insts; + auto& body = pressured.blocks[static_cast (loop.headerBlock + 1)].insts; + const auto scalar = static_cast (pressured.valueTypes.size()); + pressured.valueTypes.push_back (YdspValueType::float32Type); + pressured.valueLanes.push_back (1); + preheader.push_back ({ YdspIrOp::constF, scalar, -1, -1, -1, -1, 0.25 }); + for (int index = 0; index < 3; ++index) + { + const auto value = static_cast (pressured.valueTypes.size()); + pressured.valueTypes.push_back (YdspValueType::float32Type); + pressured.valueLanes.push_back (4); + preheader.push_back ({ YdspIrOp::vsplat, value, scalar }); + body.insert (body.end() - 2, { YdspIrOp::storeStateArrayF, -1, loop.induction, value, -1, pressured.float32ArrayElements }); + pressured.float32ArrayElements += modes; + } + ASSERT_LE (body.size() * 2, 64u); + optimizer.fullyUnrollBoundedLoops (pressured); + EXPECT_FALSE (innerLoop (pressured)->unrolled); + } + optimizer.fullyUnrollBoundedLoops (fn); + ASSERT_NE (nullptr, innerLoop (fn)); + const bool expected = modes == 8 && calls == 1; + EXPECT_EQ (expected, innerLoop (fn)->unrolled); + EXPECT_EQ (expected ? 2 : 1, countInst (fn, YdspIrOp::sinF)); + EXPECT_TRUE (ydspEveryUseIsDefined (fn)); + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + if (inst.op == YdspIrOp::sinF) + EXPECT_EQ (4, fn.laneCountOf (inst.result)); + } +} + +TEST (YdspOptimizerTests, UnrolledBanksExposeUniqueProductsAndConstantIndices) +{ + for (const bool vector : { false, true }) + { + YdspDiagnostics diagnostics; + const auto source = vector ? String (accumulatingBankSource) : String (accumulatingBankSource).replace ("16", "4"); + auto ir = vector ? buildNativeIr (source, diagnostics, false) + : buildUnrolledIr (source, diagnostics); + ASSERT_NE (nullptr, ir) << diagnostics.toString(); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + auto& fn = *ir->kernels[0]; + ASSERT_NE (nullptr, innerLoop (fn)); + ASSERT_TRUE (innerLoop (fn)->unrolled); + const int lanes = vector ? 4 : 1; + const int copies = 4; + EXPECT_EQ (copies, countInst (fn, YdspIrOp::mulF)); + std::vector definitions (fn.valueTypes.size(), 0); + std::vector literals (fn.valueTypes.size(), -1); + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + { + if (inst.result >= 0) + ++definitions[static_cast (inst.result)]; + if (inst.op == YdspIrOp::constI) + literals[static_cast (inst.result)] = inst.ivalue; + } + int stores = 0; + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + { + if (inst.op == YdspIrOp::mulF) + { + EXPECT_EQ (1, definitions[static_cast (inst.result)]); + EXPECT_EQ (lanes, fn.laneCountOf (inst.result)); + } + if (inst.op == YdspIrOp::storeStateArrayF) + { + EXPECT_EQ (1, definitions[static_cast (inst.a)]); + EXPECT_EQ (stores++ * lanes, literals[static_cast (inst.a)]); + } + } + EXPECT_EQ (copies, stores); + YdspOptimizer optimizer (diagnostics); + optimizer.setTargetHasPackedFusedMultiplyAdd (true); + optimizer.contractMultiplyAdd (fn); + optimizer.deadCodeElimination (fn); + EXPECT_EQ (copies, countInst (fn, YdspIrOp::fmaF)); + EXPECT_EQ (0, countInst (fn, YdspIrOp::mulF)); + EXPECT_TRUE (ydspEveryUseIsDefined (fn)); + } +} + +TEST (YdspOptimizerTests, ArrayLoadSharingStopsAtStoresAndIndexChanges) +{ + for (const bool integer : { false, true }) + { + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + const auto type = integer ? YdspValueType::int32Type : YdspValueType::float32Type; + const auto load = integer ? YdspIrOp::loadStateArrayI : YdspIrOp::loadStateArrayF; + const auto store = integer ? YdspIrOp::storeStateArrayI : YdspIrOp::storeStateArrayF; + fn.valueTypes = { YdspValueType::int32Type, type, type, type, type }; + fn.blocks.resize (1); + fn.blocks[0].insts = { + { YdspIrOp::constI, 0, -1, -1, -1, -1, 0.0, 0 }, + { load, 1, 0, -1, -1, 0 }, + { load, 2, 0, -1, -1, 0 }, + { store, -1, 0, 2, -1, 0 }, + { load, 3, 0, -1, -1, 0 }, + { YdspIrOp::constI, 0, -1, -1, -1, -1, 0.0, 1 }, + { load, 4, 0, -1, -1, 0 } + }; + EXPECT_TRUE (optimizer.commonSubexpressionElimination (fn)); + EXPECT_EQ (integer ? YdspIrOp::movI : YdspIrOp::movF, fn.blocks[0].insts[2].op); + EXPECT_EQ (load, fn.blocks[0].insts[4].op); + EXPECT_EQ (load, fn.blocks[0].insts[6].op); + } +} + +TEST (YdspOptimizerTests, CombinesConstantProductsOnlyUnderFastMath) +{ + for (const auto type : { YdspValueType::float32Type, YdspValueType::float64Type }) + for (const bool fast : { false, true }) + for (const bool modified : { false, true }) + { + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.fastMath = fast; + fn.valueTypes.assign (5, type); + fn.blocks.resize (1); + fn.blocks[0].insts = { + { YdspIrOp::loadParam, 0, 0 }, + { YdspIrOp::constF, 1, -1, -1, -1, -1, 0.05 }, + { YdspIrOp::constF, 2, -1, -1, -1, -1, 2.302585092994046 }, + { YdspIrOp::mulF, 3, 0, 1 } + }; + if (modified) + fn.blocks[0].insts.push_back ({ YdspIrOp::loadParam, 0, 1 }); + fn.blocks[0].insts.push_back ({ YdspIrOp::mulF, 4, 3, 2 }); + fn.blocks[0].insts.push_back ({ YdspIrOp::storeParamOut, -1, 4, -1, -1, 0 }); + optimizer.algebraicSimplification (fn); + optimizer.deadCodeElimination (fn); + EXPECT_EQ (fast && ! modified ? 1 : 2, countInst (fn, YdspIrOp::mulF)); + EXPECT_TRUE (ydspEveryUseIsDefined (fn)); + } +} + +TEST (YdspOptimizerTests, SharedConstantDivisorsKeepTheirOtherUses) +{ + for (const auto type : { YdspValueType::float32Type, YdspValueType::float64Type }) + for (const bool fastMath : { false, true }) + { + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.fastMath = fastMath; + fn.valueTypes.assign (5, type); + fn.blocks.resize (1); + fn.blocks[0].insts = { + ydspInst (YdspIrOp::loadParam, 0, 0), + ydspInst (YdspIrOp::constF, 1), + ydspInst (YdspIrOp::divF, 2, 0, 1), + ydspInst (YdspIrOp::divF, 3, 0, 1), + ydspInst (YdspIrOp::addF, 4, 0, 1) + }; + fn.blocks[0].insts[1].fvalue = 4.0; + EXPECT_EQ (fastMath, optimizer.algebraicSimplification (fn)); + EXPECT_EQ (fastMath ? 0 : 2, countInst (fn, YdspIrOp::divF)); + EXPECT_EQ (fastMath ? 2 : 0, countInst (fn, YdspIrOp::mulF)); + EXPECT_EQ (4.0, fn.blocks[0].insts[1].fvalue); + EXPECT_EQ (1, fn.blocks[0].insts.back().b); + } +} + +TEST (YdspOptimizerTests, ConstantBasePowerSpecializationRespectsMathPolicyAndDomain) +{ + for (const bool fastMath : { false, true }) + for (const double base : { -2.0, -0.0, 0.0, 0.5, 1.0, 10.0, std::numeric_limits::infinity() }) + { + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.fastMath = fastMath; + fn.valueTypes.assign (3, YdspValueType::float32Type); + fn.blocks.resize (1); + fn.blocks[0].insts = { + ydspInst (YdspIrOp::loadParam, 0, 0), + ydspInst (YdspIrOp::constF, 1), + ydspInst (YdspIrOp::powF, 2, 1, 0) + }; + fn.blocks[0].insts[1].fvalue = base; + const bool specialize = fastMath && base > 0.0 && base != 1.0 && std::isfinite (base); + EXPECT_EQ (specialize, optimizer.algebraicSimplification (fn)); + EXPECT_EQ (specialize ? 0 : 1, countInst (fn, YdspIrOp::powF)); + EXPECT_EQ (specialize ? 1 : 0, countInst (fn, YdspIrOp::expF)); + EXPECT_EQ (specialize ? 1 : 0, countInst (fn, YdspIrOp::mulF)); + } +} + +TEST (YdspOptimizerTests, FoldStateWriteBacksKeepsAMoveWhoseSourceIsReadInAnotherBlock) +{ + YdspDiagnostics diagnostics; + + // `f` reassigns its parameter inside an `if`, so the read of `t` in the + // continuation after the (inlined) call lives in a different block than + // the parameter-copy `movF` the builder emitted. Folding that copy into + // its producer would rename the producer's result and leave the later read + // with no definition at all - the ?255 ten YdspExamplePatchTests + // fail with on AArch64. + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + + func f (t: float) : float { + float r = 0.0; + if (t < 0.5) { + t = t * 2.0; + r = t; + } + return r; + } + + process { + float x = in * 0.5; + out = x - f (x); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_EQ (1u, ir->kernels.size()); + + const auto& fn = *ir->kernels[0]; + + EXPECT_TRUE (ydspEveryUseIsDefined (fn)); +} + +TEST (YdspOptimizerTests, FoldsIntegerStateWriteBackMovesIntoTheirProducers) +{ + YdspDiagnostics diagnostics; + + // A sample-mode integer state chain, the ring-buffer shape: two carried + // counters updated every sample. Each update is a fresh add plus a `movI` + // write-back; folding removes the moves so the counters stay in registers + // without a per-sample hop through a second register. + auto ir = buildIr (R"YDSP( + processor P { + input stream in; + output stream out; + state int reads; + state int writes; + process { + reads = reads + 1; + writes = writes + 2; + out = in + float (reads + writes); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_EQ (1u, ir->kernels.size()); + + const auto& fn = *ir->kernels[0]; + + EXPECT_EQ (0, countInst (fn, YdspIrOp::movI)); + + // The two per-sample increments and the `reads + writes` sum still exist + // (plus the sample-loop induction increment), each state update now + // writing its carried register in place. + EXPECT_EQ (4, countInst (fn, YdspIrOp::addI)); + EXPECT_TRUE (ydspEveryUseIsDefined (fn)); +} + +TEST (YdspOptimizerTests, FoldStateWriteBacksCollapsesCopiesWithoutRereadingTheirSource) +{ + for (const auto type : { YdspValueType::float32Type, YdspValueType::float64Type, + YdspValueType::int32Type, YdspValueType::int64Type }) + { + const bool floating = type == YdspValueType::float32Type || type == YdspValueType::float64Type; + const auto move = floating ? YdspIrOp::movF : YdspIrOp::movI; + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes.assign (4, type); + fn.blocks.resize (1); + auto& insts = fn.blocks.front().insts; + insts = { + ydspInst (floating ? YdspIrOp::loadStateF : YdspIrOp::loadStateI, 0, 0), + ydspInst (YdspIrOp::loadParam, 1, 0), + ydspInst (move, 2, 1), + ydspInst (floating ? YdspIrOp::constF : YdspIrOp::constI, 1), + ydspInst (move, 0, 2), + ydspInst (floating ? YdspIrOp::addF : YdspIrOp::addI, 3, 2, 0) + }; + + optimizer.foldStateWriteBacks (fn); + + ASSERT_EQ (5u, insts.size()); + EXPECT_EQ (move, insts[2].op); + EXPECT_EQ (0, insts[2].result); + EXPECT_EQ (1, insts[2].a); + EXPECT_EQ (1, insts[3].result); // The source changes after the snapshot. + EXPECT_EQ (0, insts.back().a); + EXPECT_EQ (0, insts.back().b); + EXPECT_TRUE (ydspEveryUseIsDefined (fn)); + } +} + +TEST (YdspOptimizerTests, FoldStateWriteBackCopiesPreserveOldStateAndLaterSnapshotReads) +{ + for (const bool floating : { false, true }) + { + for (const bool readOldState : { false, true }) + { + const auto move = floating ? YdspIrOp::movF : YdspIrOp::movI; + const auto add = floating ? YdspIrOp::addF : YdspIrOp::addI; + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes.assign (5, floating ? YdspValueType::float32Type : YdspValueType::int32Type); + fn.blocks.resize (1); + auto& insts = fn.blocks.front().insts; + insts = { + ydspInst (floating ? YdspIrOp::loadStateF : YdspIrOp::loadStateI, 0, 0), + ydspInst (YdspIrOp::loadParam, 1, 0), + ydspInst (move, 2, 1) + }; + if (readOldState) + insts.push_back (ydspInst (add, 3, 0, 1)); + insts.push_back (ydspInst (move, 0, 2)); + if (! readOldState) + insts.push_back (ydspInst (floating ? YdspIrOp::constF : YdspIrOp::constI, 0)); + insts.push_back (ydspInst (add, 4, 2, 0)); + + optimizer.foldStateWriteBacks (fn); + + ASSERT_EQ (6u, insts.size()); + EXPECT_EQ (2, insts[2].result); + EXPECT_EQ (1, insts[2].a); + EXPECT_EQ (2, insts.back().a); + EXPECT_TRUE (ydspEveryUseIsDefined (fn)); + } + } +} + +TEST (YdspOptimizerTests, FoldFusedSubtractWriteBacksWithEachCarriedOperand) +{ + for (const auto type : { YdspValueType::float32Type, YdspValueType::float64Type }) + { + for (int carriedOperand = 0; carriedOperand < 3; ++carriedOperand) + { + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes.assign (5, type); + fn.blocks.resize (1); + int operands[] { 1, 2, 0 }; + std::swap (operands[carriedOperand], operands[2]); + auto& insts = fn.blocks.front().insts; + insts = { + ydspInst (YdspIrOp::loadStateF, 0, 0), + ydspInst (YdspIrOp::loadParam, 1, 0), + ydspInst (YdspIrOp::loadParam, 2, 1), + ydspInst (YdspIrOp::fmsubF, 3, operands[0], operands[1], operands[2]), + ydspInst (YdspIrOp::movF, 0, 3), + ydspInst (YdspIrOp::addF, 4, 3, 1) + }; + + optimizer.foldStateWriteBacks (fn); + + ASSERT_EQ (5u, insts.size()); + EXPECT_EQ (YdspIrOp::fmsubF, insts[3].op); + EXPECT_EQ (0, insts[3].result); + EXPECT_EQ (operands[0], insts[3].a); + EXPECT_EQ (operands[1], insts[3].b); + EXPECT_EQ (operands[2], insts[3].c); + EXPECT_EQ (0, insts.back().a); + } + } +} + +TEST (YdspOptimizerTests, FoldStateWriteBacksDoesNotTouchSlotIndexOperands) +{ + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + + // Hand-built block where the `produced` value id (2) numerically equals a + // state slot carried in loadStateF's first operand. The fold rewrites uses + // of `produced` - the move and the add below - never the slot field. + constexpr int constZero = 0; + constexpr int constHalf = 1; + constexpr int produced = 2; + constexpr int carried = 3; + constexpr int loaded = 4; + constexpr int used = 5; + + YdspIrFunction fn; + fn.valueTypes.assign (6, YdspValueType::float32Type); + + YdspIrBlock block; + block.insts = + { + ydspInst (YdspIrOp::constF, constZero, -1, -1, -1), + ydspInst (YdspIrOp::constF, constHalf, -1, -1, -1), + ydspInst (YdspIrOp::mulF, produced, constZero, constHalf), + ydspInst (YdspIrOp::movF, carried, produced), + ydspInst (YdspIrOp::loadStateF, loaded, produced), // slot index == produced! + ydspInst (YdspIrOp::addF, used, produced, carried), + }; + fn.blocks.push_back (std::move (block)); + + optimizer.foldStateWriteBacks (fn); + + const auto& insts = fn.blocks[0].insts; + + // The fold applied (no movF left) and redirected the real uses of the + // produced value, but the loadStateF slot index must be untouched. + bool sawLoad = false; + bool sawAdd = false; + int moveCount = 0; + + for (const auto& inst : insts) + { + if (inst.op == YdspIrOp::movF) + ++moveCount; + + if (inst.op == YdspIrOp::loadStateF) + { + sawLoad = true; + EXPECT_EQ (produced, inst.a); + } + + if (inst.op == YdspIrOp::addF) + { + sawAdd = true; + EXPECT_EQ (carried, inst.a); + } + } + + EXPECT_EQ (0, moveCount); + EXPECT_TRUE (sawLoad); + EXPECT_TRUE (sawAdd); + EXPECT_TRUE (ydspEveryUseIsDefined (fn)); +} + +TEST (YdspOptimizerTests, FoldStateWriteBacksAppliesOverlappingFoldsOneAtATime) +{ + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + + // Two candidate write-backs into the same carried register, with a read of + // the first producer's value between the second producer and its move: + // + // v1 = a * b; carried = v1; (candidate fold 1) + // v2 = c + d; (candidate fold 2's producer) + // use = v1 + e; (reads v1, after v2's producer) + // carried = v2; (candidate fold 2's move) + // + // Folding fold 1 first redirects `use` to `carried`, which makes fold 2 + // unsafe: its producer would redefine `carried` before that redirected + // read. A batch rewrite validated against the pre-fold snapshot cannot see + // that - both folds look valid - so folds must be applied one at a time. + constexpr int constA = 0; + constexpr int constB = 1; + constexpr int constC = 2; + constexpr int produced1 = 3; + constexpr int produced2 = 4; + constexpr int carried = 5; + constexpr int used = 6; + + YdspIrFunction fn; + fn.valueTypes.assign (7, YdspValueType::float32Type); + + YdspIrBlock block; + block.insts = + { + ydspInst (YdspIrOp::constF, constA, -1, -1, -1), + ydspInst (YdspIrOp::constF, constB, -1, -1, -1), + ydspInst (YdspIrOp::constF, constC, -1, -1, -1), + ydspInst (YdspIrOp::mulF, produced1, constA, constB), + ydspInst (YdspIrOp::movF, carried, produced1), + ydspInst (YdspIrOp::addF, produced2, constA, constB), + ydspInst (YdspIrOp::addF, used, produced1, constC), + ydspInst (YdspIrOp::movF, carried, produced2), + }; + fn.blocks.push_back (std::move (block)); + + optimizer.foldStateWriteBacks (fn); + + const auto& insts = fn.blocks[0].insts; + + // Fold 1 applies (its producer now writes the carried register and its + // move is gone); fold 2 is refused, so its move and its producer's result + // id survive. + bool sawFirstProducer = false; + bool sawSecondProducer = false; + bool sawUse = false; + int moveCount = 0; + + for (const auto& inst : insts) + { + if (inst.op == YdspIrOp::movF) + ++moveCount; + + if (inst.op == YdspIrOp::mulF) + { + sawFirstProducer = true; + EXPECT_EQ (carried, inst.result); + } + + if (inst.op == YdspIrOp::addF && inst.result == produced2) + sawSecondProducer = true; + + if (inst.op == YdspIrOp::addF && inst.result == used) + { + sawUse = true; + EXPECT_EQ (carried, inst.a); + } + } + + EXPECT_EQ (1, moveCount); + EXPECT_TRUE (sawFirstProducer); + EXPECT_TRUE (sawSecondProducer); + EXPECT_TRUE (sawUse); + EXPECT_TRUE (ydspEveryUseIsDefined (fn)); +} + +TEST (YdspOptimizerTests, ConstantFoldingShiftsRespectWidthAndMaskedCounts) +{ + struct Case + { + int64_t value, count, left32, right32, left64, right64; + }; + const Case cases[] { + { 1, 32, 1, 1, 4294967296, 0 }, + { 3, 33, 6, 1, 25769803776, 0 }, + { 3, 64, 3, 3, 3, 3 }, + { -8, 1, -16, -4, -16, -4 }, + { -1, -1, std::numeric_limits::min(), -1, std::numeric_limits::min(), -1 }, + { 2147483648, 0, std::numeric_limits::min(), std::numeric_limits::min(), 2147483648, 2147483648 }, + { 4294967295, 1, -2, -1, 8589934590, 2147483647 }, + }; + for (const auto& item : cases) + for (const bool wide : { false, true }) + for (const bool left : { false, true }) + { + SCOPED_TRACE (::testing::Message() << "value=" << item.value << ", count=" << item.count << ", wide=" << wide << ", left=" << left); + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes.assign (3, wide ? YdspValueType::int64Type : YdspValueType::int32Type); + YdspIrBlock block; + block.insts = { + ydspInst (YdspIrOp::constI, 0), + ydspInst (YdspIrOp::constI, 1), + ydspInst (left ? YdspIrOp::shlI : YdspIrOp::shrI, 2, 0, 1), + }; + block.insts[0].ivalue = item.value; + block.insts[1].ivalue = item.count; + fn.blocks.push_back (std::move (block)); + ASSERT_TRUE (optimizer.constantFolding (fn)); + const auto& result = fn.blocks[0].insts[2]; + EXPECT_EQ (YdspIrOp::constI, result.op); + EXPECT_EQ (wide ? (left ? item.left64 : item.right64) : (left ? item.left32 : item.right32), result.ivalue); + } +} + +TEST (YdspOptimizerTests, ConstantIntegerArithmeticPreservesWidthBeforeConsumers) +{ + struct Case + { + YdspIrOp op; + int64_t a, b, narrow, wide; + }; + const Case cases[] { + { YdspIrOp::addI, 2147483647, 1, -2147483648, 2147483648 }, + { YdspIrOp::subI, -2147483648, 1, 2147483647, -2147483649 }, + { YdspIrOp::mulI, 65536, 65536, 0, 4294967296 }, + { YdspIrOp::mulI, 2147483647, 2, -2, 4294967294 }, + { YdspIrOp::minI, 4294967295, 1, -1, 1 }, + { YdspIrOp::maxI, 4294967295, 1, 1, 4294967295 }, + { YdspIrOp::andI, 4294967295, 2147483648, -2147483648, 2147483648 }, + { YdspIrOp::orI, 2147483648, 1, -2147483647, 2147483649 }, + { YdspIrOp::xorI, 4294967295, 1, -2, 4294967294 }, + }; + for (const auto& item : cases) + for (const bool wide : { false, true }) + { + SCOPED_TRACE (::testing::Message() << "op=" << static_cast (item.op) << ", wide=" << wide); + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes.assign (6, wide ? YdspValueType::int64Type : YdspValueType::int32Type); + fn.valueTypes[4] = YdspValueType::boolType; + fn.valueTypes[5] = YdspValueType::float64Type; + YdspIrBlock block; + block.insts = { + ydspInst (YdspIrOp::constI, 0), + ydspInst (YdspIrOp::constI, 1), + ydspInst (item.op, 2, 0, 1), + ydspInst (YdspIrOp::constI, 3), + ydspInst (YdspIrOp::ltI, 4, 2, 3), + ydspInst (YdspIrOp::itof, 5, 2), + }; + block.insts[0].ivalue = item.a; + block.insts[1].ivalue = item.b; + fn.blocks.push_back (std::move (block)); + ASSERT_TRUE (optimizer.constantFolding (fn)); + const auto expected = wide ? item.wide : item.narrow; + const auto& insts = fn.blocks[0].insts; + EXPECT_EQ (YdspIrOp::constI, insts[2].op); + EXPECT_EQ (expected, insts[2].ivalue); + EXPECT_EQ (YdspIrOp::constB, insts[4].op); + EXPECT_EQ (expected < 0, insts[4].bvalue); + EXPECT_EQ (YdspIrOp::constF, insts[5].op); + EXPECT_EQ (static_cast (expected), insts[5].fvalue); + } +} + +TEST (YdspOptimizerTests, ConstantFloatToIntFoldingRespectsBothWidths) +{ + struct Case + { + double value; + int64_t narrow, wide; + }; + const Case cases[] { + { 7.9, 7, 7 }, { -7.9, -7, -7 }, + { 2147483647.75, 2147483647, 2147483647 }, + { 2147483648.0, 2147483647, 2147483648 }, + { -2147483648.75, -2147483648, -2147483648 }, + { -2147483649.0, -2147483648, -2147483649 }, + { 9223372036854775808.0, 2147483647, std::numeric_limits::max() }, + { -9223372036854775808.0, -2147483648, std::numeric_limits::min() }, + { std::numeric_limits::infinity(), 2147483647, std::numeric_limits::max() }, + { -std::numeric_limits::infinity(), -2147483648, std::numeric_limits::min() }, + { std::numeric_limits::quiet_NaN(), 0, 0 }, + }; + for (const auto& item : cases) + for (const bool wide : { false, true }) + { + SCOPED_TRACE (::testing::Message() << "value=" << item.value << ", wide=" << wide); + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes = { YdspValueType::float64Type, wide ? YdspValueType::int64Type : YdspValueType::int32Type }; + YdspIrBlock block; + block.insts = { ydspInst (YdspIrOp::constF, 0), ydspInst (YdspIrOp::ftoi, 1, 0) }; + block.insts[0].fvalue = item.value; + fn.blocks.push_back (std::move (block)); + EXPECT_TRUE (optimizer.constantFolding (fn)); + EXPECT_EQ (YdspIrOp::constI, fn.blocks[0].insts[1].op); + EXPECT_EQ (wide ? item.wide : item.narrow, fn.blocks[0].insts[1].ivalue); + } +} + +TEST (YdspOptimizerTests, ConstantConversionsRespectSourceStorageWidth) +{ + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes = { YdspValueType::int32Type, YdspValueType::float64Type, + YdspValueType::float32Type, YdspValueType::int32Type, YdspValueType::int64Type }; + YdspIrBlock block; + block.insts = { + ydspInst (YdspIrOp::constI, 0), ydspInst (YdspIrOp::itof, 1, 0), + ydspInst (YdspIrOp::constF, 2), ydspInst (YdspIrOp::ftoi, 3, 2), ydspInst (YdspIrOp::ftoi, 4, 2), + }; + block.insts[0].ivalue = 4294967295; + block.insts[2].fvalue = 2147483647.0; // Rounds to 2^31 in float32 storage. + fn.blocks.push_back (std::move (block)); + ASSERT_TRUE (optimizer.constantFolding (fn)); + EXPECT_EQ (YdspIrOp::constF, fn.blocks[0].insts[1].op); + EXPECT_EQ (-1.0, fn.blocks[0].insts[1].fvalue); + EXPECT_EQ (YdspIrOp::constI, fn.blocks[0].insts[3].op); + EXPECT_EQ (2147483647, fn.blocks[0].insts[3].ivalue); + EXPECT_EQ (YdspIrOp::constI, fn.blocks[0].insts[4].op); + EXPECT_EQ (2147483648, fn.blocks[0].insts[4].ivalue); +} + +TEST (YdspOptimizerTests, ConstantFoldingDoesNotMaterialiseAnUnknownConversion) +{ + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes = { YdspValueType::float64Type, YdspValueType::int64Type }; + YdspIrBlock block; + block.insts = { ydspInst (YdspIrOp::loadInput, 0), ydspInst (YdspIrOp::ftoi, 1, 0) }; + fn.blocks.push_back (std::move (block)); + EXPECT_FALSE (optimizer.constantFolding (fn)); + EXPECT_EQ (YdspIrOp::ftoi, fn.blocks[0].insts[1].op); + EXPECT_EQ (0, fn.blocks[0].insts[1].a); +} + +TEST (YdspOptimizerTests, CopyPropagationDoesNotRetargetATerminatorPastARedefinition) +{ + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + + // c = a; c = p && q; branchIf c: the terminator reads the *second* c (a + // non-move, so nothing later can legitimately retarget it). Copy + // propagation over the first move must leave the terminator alone; the + // old code stopped at the redefinition but still retargeted it to `a`. + YdspIrFunction fn; + fn.valueTypes.assign (4, YdspValueType::boolType); + + YdspIrBlock block; + block.insts = + { + ydspInst (YdspIrOp::constB, 0, -1, -1, -1), // a = true + ydspInst (YdspIrOp::movB, 1, 0), // c = a + ydspInst (YdspIrOp::constB, 2, -1, -1, -1), // p = false + ydspInst (YdspIrOp::constB, 3, -1, -1, -1), // q = false + ydspInst (YdspIrOp::andB, 1, 2, 3), // c = p && q (redefinition) + }; + + block.insts[0].bvalue = true; + block.insts[2].bvalue = false; + block.insts[3].bvalue = true; + + block.term = YdspIrTerm::branchIf; + block.termCond = 1; + block.termTarget = 0; + block.termTarget2 = 0; + fn.blocks.push_back (std::move (block)); + + optimizer.copyPropagation (fn); + + EXPECT_EQ (1, fn.blocks[0].termCond) << "the terminator must keep reading the redefined c"; +} + +TEST (YdspOptimizerTests, DeadCodeWorklistRemovesLongDependencyChains) +{ + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes.assign (4096, YdspValueType::float32Type); + fn.blocks.resize (1); + auto& instructions = fn.blocks.front().insts; + instructions.push_back (ydspInst (YdspIrOp::constF, 0)); + + for (int id = 1; id < 4096; ++id) + instructions.push_back (ydspInst (YdspIrOp::movF, id, id - 1)); + + EXPECT_TRUE (optimizer.deadCodeElimination (fn)); + EXPECT_TRUE (instructions.empty()); + EXPECT_FALSE (optimizer.deadCodeElimination (fn)); +} + +TEST (YdspOptimizerTests, DeadCodeWorklistPreservesAllDefinitionsOfATerminatorCondition) +{ + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes.assign (3, YdspValueType::boolType); + fn.blocks.resize (1); + auto& block = fn.blocks.front(); + block.insts = { + ydspInst (YdspIrOp::constB, 0), + ydspInst (YdspIrOp::movB, 1, 0), + ydspInst (YdspIrOp::constB, 1), + ydspInst (YdspIrOp::movB, 2, 0), + ydspInst (YdspIrOp::constB, 2) + }; + block.term = YdspIrTerm::branchIf; + block.termCond = 1; + block.termTarget = block.termTarget2 = 0; + + EXPECT_TRUE (optimizer.deadCodeElimination (fn)); + ASSERT_EQ (3u, block.insts.size()); + EXPECT_EQ (0, block.insts[0].result); + EXPECT_EQ (1, block.insts[1].result); + EXPECT_EQ (1, block.insts[2].result); + EXPECT_FALSE (optimizer.deadCodeElimination (fn)); +} + +TEST (YdspOptimizerTests, SubtractZeroIdentityRespectsItsSignInStrictMode) +{ + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + + for (const bool fastMath : { false, true }) + { + for (const double zero : { 0.0, -0.0 }) + { + YdspIrFunction fn; + fn.fastMath = fastMath; + fn.valueTypes.assign (3, YdspValueType::float64Type); + fn.blocks.resize (1); + auto& instructions = fn.blocks.front().insts; + instructions = { + ydspInst (YdspIrOp::loadParam, 0, 0), + ydspInst (YdspIrOp::constF, 1), + ydspInst (YdspIrOp::subF, 2, 0, 1) + }; + instructions[1].fvalue = zero; + const bool shouldSimplify = fastMath || ! std::signbit (zero); + + EXPECT_EQ (shouldSimplify, optimizer.algebraicSimplification (fn)); + EXPECT_EQ (shouldSimplify ? YdspIrOp::movF : YdspIrOp::subF, instructions.back().op); + EXPECT_FALSE (optimizer.algebraicSimplification (fn)); + } + } +} + +TEST (YdspOptimizerTests, SharesImmutableEntryCoefficientsAcrossBlocks) +{ + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes.assign (6, YdspValueType::float32Type); + fn.valueTypes[5] = YdspValueType::int32Type; + fn.blocks.resize (3); + fn.blocks[0].insts = { + ydspInst (YdspIrOp::loadParam, 0, 0), + ydspInst (YdspIrOp::constF, 1), + ydspInst (YdspIrOp::subF, 2, 1, 0), + ydspInst (YdspIrOp::constF, 3), + ydspInst (YdspIrOp::subF, 4, 3, 0), + ydspInst (YdspIrOp::constI, 5) + }; + fn.blocks[0].insts[1].fvalue = fn.blocks[0].insts[3].fvalue = 1.0; + fn.blocks[1].insts = { ydspInst (YdspIrOp::storeOutput, -1, 5, 2) }; + fn.blocks[2].insts = { ydspInst (YdspIrOp::storeOutput, -1, 5, 4) }; + + EXPECT_TRUE (optimizer.commonSubexpressionElimination (fn)); + EXPECT_TRUE (optimizer.deadCodeElimination (fn)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::constF)); + EXPECT_EQ (1, countInst (fn, YdspIrOp::subF)); + EXPECT_EQ (0, countInst (fn, YdspIrOp::movF)); + EXPECT_EQ (2, fn.blocks[2].insts[0].b); + EXPECT_FALSE (optimizer.commonSubexpressionElimination (fn)); +} + +TEST (YdspOptimizerTests, EntrySharingPreservesMutableValuesAndReenteredBlocks) +{ + for (const bool reentered : { false, true }) + { + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes.assign (4, YdspValueType::float32Type); + fn.blocks.resize (2); + fn.blocks[0].insts = { + ydspInst (YdspIrOp::constF, 0), + ydspInst (YdspIrOp::constF, 1) + }; + fn.blocks[1].insts = { ydspInst (YdspIrOp::addF, 2, 0, 1) }; + if (reentered) + { + fn.blocks[1].term = YdspIrTerm::branch; + fn.blocks[1].termTarget = 0; + } + else + { + fn.blocks[1].insts.push_back (ydspInst (YdspIrOp::constF, 1)); + fn.blocks[1].insts.back().fvalue = 2.0; + fn.blocks[1].insts.push_back (ydspInst (YdspIrOp::addF, 3, 0, 1)); + } + + optimizer.commonSubexpressionElimination (fn); + EXPECT_EQ (1, fn.blocks[1].insts[0].b); + if (! reentered) + EXPECT_EQ (1, fn.blocks[1].insts.back().b); + } +} + +TEST (YdspOptimizerTests, EntryConstantSharingPreservesSignedZeroAndTypes) +{ + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes = { YdspValueType::float32Type, YdspValueType::float32Type, + YdspValueType::float64Type, YdspValueType::int32Type }; + fn.blocks.resize (2); + fn.blocks[0].insts = { + ydspInst (YdspIrOp::constF, 0), + ydspInst (YdspIrOp::constF, 1), + ydspInst (YdspIrOp::constF, 2), + ydspInst (YdspIrOp::constI, 3) + }; + fn.blocks[0].insts[1].fvalue = -0.0; + for (int value = 0; value < 3; ++value) + fn.blocks[1].insts.push_back (ydspInst (YdspIrOp::storeOutput, -1, 3, value)); + + EXPECT_FALSE (optimizer.commonSubexpressionElimination (fn)); + for (int value = 0; value < 3; ++value) + EXPECT_EQ (value, fn.blocks[1].insts[static_cast (value)].b); +} + +TEST (YdspOptimizerTests, CleanupPassesReportAnAlreadyStableFunction) +{ + YdspDiagnostics diagnostics; + YdspOptimizer optimizer (diagnostics); + YdspIrFunction fn; + fn.valueTypes.assign (1, YdspValueType::float32Type); + fn.blocks.resize (1); + fn.blocks.front().insts = { + ydspInst (YdspIrOp::constF, 0), + ydspInst (YdspIrOp::storeStateF, -1, 0) + }; + + EXPECT_FALSE (optimizer.constantFolding (fn)); + EXPECT_FALSE (optimizer.algebraicSimplification (fn)); + EXPECT_FALSE (optimizer.ifConversion (fn)); + EXPECT_FALSE (optimizer.storeToLoadForwarding (fn)); + EXPECT_FALSE (optimizer.copyPropagation (fn)); + EXPECT_FALSE (optimizer.commonSubexpressionElimination (fn)); + EXPECT_FALSE (optimizer.deadCodeElimination (fn)); +} diff --git a/tests/yup_dsp_jit/yup_YdspProjectTests.cpp b/tests/yup_dsp_jit/yup_YdspProjectTests.cpp new file mode 100644 index 000000000..fb116f9c9 --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspProjectTests.cpp @@ -0,0 +1,371 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include +#include + +using namespace yup; + +class YdspProjectTests : public ::testing::Test +{ +protected: + File directory; + File manifest; + + void SetUp() override + { + directory = File::getSpecialLocation (File::tempDirectory).getChildFile ("yup-project-" + Uuid().toString()); + ASSERT_TRUE (directory.createDirectory().wasOk()); + manifest = directory.getChildFile ("test.ydsp-project"); + } + + void TearDown() override + { + directory.deleteRecursively(); + } + + void write (StringRef path, StringRef source) + { + const auto file = directory.getChildFile (String (path)); + ASSERT_TRUE (file.getParentDirectory().createDirectory().wasOk()); + ASSERT_TRUE (file.replaceWithText (String (source))); + } + + void project (StringRef main, StringRef sources) + { + write ("test.ydsp-project", "formatVersion: 1\nname: Test patch\nversion: \"1.0\"\nmain: " + + String (main) + "\nsources: " + String (sources) + "\n"); + } + + static void expectOutput (YdspAudioGraph& graph, float expected) + { + graph.prepare (48000.0, 8); + std::array input; + input.fill (0.25f); + std::array output {}; + const YdspInputBuffer inputs[] { Span (input.data(), input.size()) }; + YdspOutputBuffer outputs[] { Span (output.data(), output.size()) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process (YdspProcessRequest { inputs, outputs, 8 })); + for (const auto value : output) + EXPECT_FLOAT_EQ (expected, value); + } +}; + +TEST_F (YdspProjectTests, ParsesYamlMetadataAndSourceList) +{ + YdspDiagnostics diagnostics; + auto parsed = YdspProject::parse ( + "formatVersion: 1\nmain: Main\nsources:\n - Main.ydsp\n - lib/Math.ydsp\n" + "id: org.yup.test\nname: 'Patch: test'\nversion: \"1.0\"\nisInstrument: false\n" + "description: |\n A reusable patch.\n With explicit imports.\n", + manifest, diagnostics); + ASSERT_TRUE (parsed.wasOk()) << diagnostics.toString(); + EXPECT_EQ ("Main", parsed.getReference().getMain()); + EXPECT_EQ (2, parsed.getReference().getSources().size()); + EXPECT_EQ ("lib/Math.ydsp", parsed.getReference().getSources()[1]); + EXPECT_EQ ("Patch: test", parsed.getReference().getMetadata()["name"].toString()); + EXPECT_TRUE (parsed.getReference().getMetadata()["description"].toString().contains ("explicit imports")); + EXPECT_EQ (manifest, parsed.getReference().getFile()); +} + +TEST_F (YdspProjectTests, RejectsInvalidManifestFields) +{ + for (const auto* text : { + "formatVersion: 2\nmain: P\nsources: [P.ydsp]", + "formatVersion: true\nmain: P\nsources: [P.ydsp]", + "formatVersion: 1\nsources: [P.ydsp]", + "formatVersion: 1\nmain: P\nsources: []", + "formatVersion: 1\nmain: P\nsources: P.ydsp", + "formatVersion: 1\nmain: P\nsources: [3]", + "formatVersion: 1\nmain: P\nsources: [P.txt]", + "formatVersion: 1\nmain: P\nsources: [P.ydsp, ./P.ydsp]", + "formatVersion: 1\nmain: P\nsources: [P.ydsp]\nversion: 1.0", + "formatVersion: 1\nmain: P\nsources: [P.ydsp]\nisInstrument: \"yes\"", + "formatVersion: 1\nmain: P\nsources: [P.ydsp]\nmisspelled: value", + "- P.ydsp" }) + { + SCOPED_TRACE (text); + YdspDiagnostics diagnostics; + EXPECT_FALSE (YdspProject::parse (text, manifest, diagnostics).wasOk()); + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (diagnostics.toString().contains (manifest.getFullPathName() + ":")); + EXPECT_TRUE (diagnostics.toString().contains ("^")); + } +} + +TEST_F (YdspProjectTests, AcceptsYamlBooleanSpellings) +{ + for (const auto* value : { "true", "yes", "false", "no" }) + { + SCOPED_TRACE (value); + YdspDiagnostics diagnostics; + auto parsed = YdspProject::parse ( + String ("formatVersion: 1\nmain: P\nsources: [P.ydsp]\nisInstrument: ") + value, + manifest, diagnostics); + ASSERT_TRUE (parsed.wasOk()) << diagnostics.toString(); + EXPECT_EQ (String (value) == "true" || String (value) == "yes", + static_cast (parsed.getReference().getMetadata()["isInstrument"])); + } +} + +TEST_F (YdspProjectTests, ReportsYamlSyntaxErrorWithSourceExcerpt) +{ + YdspDiagnostics diagnostics; + EXPECT_FALSE (YdspProject::parse ("formatVersion: 1\nmain: P\nsources: [P.ydsp", manifest, diagnostics).wasOk()); + EXPECT_TRUE (diagnostics.toString().contains ("sources: [P.ydsp")); + EXPECT_TRUE (diagnostics.toString().contains ("^")); +} + +TEST_F (YdspProjectTests, CompilesProcessorEntryWithExplicitFunctionImport) +{ + project ("Main", "[lib/Math.ydsp, Main.ydsp]"); + write ("lib/Math.ydsp", "func twice (x: float) : float { return x * 2.0; }"); + write ("Main.ydsp", "import lib.Math as math; processor Main { input stream in; output stream out; process { out = math.twice (in); } }"); + + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic }) + { + YdspCompileOptions options; + options.optimizationTier = tier; + YdspCompiler compiler; + auto compiled = compiler.compileProject (manifest, options); + ASSERT_TRUE (compiled.wasOk()) << compiler.getDiagnostics().toString(); + expectOutput (compiled.getReference(), 0.5f); + } +} + +TEST_F (YdspProjectTests, ListedFilesDoNotShareDefinitionsWithoutImports) +{ + project ("Main", "[Main.ydsp, Library.ydsp]"); + write ("Library.ydsp", "func twice (x: float) : float { return x * 2.0; }"); + write ("Main.ydsp", "processor Main { input stream in; output stream out; process { out = twice (in); } }"); + YdspCompiler compiler; + EXPECT_FALSE (compiler.compileProject (manifest).wasOk()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ("Unknown function 'twice'")); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains (directory.getChildFile ("Main.ydsp").getFullPathName())); +} + +TEST_F (YdspProjectTests, HostOverrideSelectsAnotherProcessorWithoutChangingManifest) +{ + project ("First", "[First.ydsp, Second.ydsp]"); + write ("First.ydsp", "processor First { input stream in; output stream out; process { out = in; } }"); + write ("Second.ydsp", "processor Second { input stream in; output stream out; process { out = in * 3.0; } }"); + YdspCompiler compiler; + auto first = compiler.compileProject (manifest); + ASSERT_TRUE (first.wasOk()) << compiler.getDiagnostics().toString(); + expectOutput (first.getReference(), 0.25f); + auto second = compiler.compileProject (manifest, {}, "Second"); + ASSERT_TRUE (second.wasOk()) << compiler.getDiagnostics().toString(); + expectOutput (second.getReference(), 0.75f); + EXPECT_TRUE (manifest.loadFileAsString().contains ("main: First")); + EXPECT_FALSE (compiler.compileProject (manifest, {}, "Missing").wasOk()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ("Unknown project main")); +} + +TEST_F (YdspProjectTests, GraphSelectionOverridesMainAnnotationAndResolvesNestedImports) +{ + project ("Chosen", "[src/Main.ydsp, src/lib/Gain.ydsp, src/lib/Math.ydsp]"); + write ("src/lib/Math.ydsp", "func twice (x: float) : float { return x * 2.0; }"); + write ("src/lib/Gain.ydsp", "import Math as math; processor Gain { input stream in; output stream out; process { out = math.twice (in); } }"); + write ("src/Main.ydsp", + "import lib.Gain as lib; " + "graph Other [[ main ]] { input stream in; output stream out; node p = lib.Gain; connection { in -> p.in; p.out -> out; } } " + "graph Chosen { input stream in; output stream out; node p = lib.Gain; connection { in -> p.in; p.out -> out; } }"); + ThreadPool pool (2); + YdspCompiler compiler; + auto compiled = compiler.compileProject (manifest, {}, {}, &pool); + ASSERT_TRUE (compiled.wasOk()) << compiler.getDiagnostics().toString(); + expectOutput (compiled.getReference(), 0.5f); +} + +TEST_F (YdspProjectTests, RejectsAmbiguousEntryNamesAcrossIndependentFiles) +{ + project ("Main", "[A.ydsp, B.ydsp]"); + for (const auto* path : { "A.ydsp", "B.ydsp" }) + write (path, "processor Main { input stream in; output stream out; process { out = in; } }"); + YdspCompiler compiler; + EXPECT_FALSE (compiler.compileProject (manifest).wasOk()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ("Ambiguous project main")); +} + +TEST_F (YdspProjectTests, ReportsMissingManifestSourceAndUnlistedImport) +{ + YdspCompiler compiler; + EXPECT_FALSE (compiler.compileProject (manifest).wasOk()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ("Cannot read project manifest")); + project ("Main", "[Main.ydsp]"); + EXPECT_FALSE (compiler.compileProject (manifest).wasOk()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ("Cannot read project source")); + write ("Main.ydsp", "import Library; processor Main { input stream in; output stream out; process { out = in; } }"); + write ("Library.ydsp", "func twice (x: float) : float { return x * 2.0; }"); + EXPECT_FALSE (compiler.compileProject (manifest).wasOk()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ("not listed in project 'sources'")); +} + +TEST_F (YdspProjectTests, ImportedSemanticErrorsRetainFileAndRange) +{ + project ("Main", "[Main.ydsp, Library.ydsp]"); + write ("Main.ydsp", "import Library; graph Main { input stream in; output stream out; node p = Library.P; connection { in -> p.in; p.out -> out; } }"); + write ("Library.ydsp", "processor P { input stream in; output stream out; process { out = missing; } }"); + YdspCompiler compiler; + EXPECT_FALSE (compiler.compileProject (manifest).wasOk()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains (directory.getChildFile ("Library.ydsp").getFullPathName() + ":1:")); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ("^~~~~~~")); +} + +TEST_F (YdspProjectTests, ProcessorEntryExposesParameterDefaults) +{ + project ("Main", "[Main.ydsp]"); + write ("Main.ydsp", "processor Main { input stream in; output stream out; input parameter float gain = 2.0; process { out = in * gain; } }"); + YdspCompiler compiler; + auto compiled = compiler.compileProject (manifest); + ASSERT_TRUE (compiled.wasOk()) << compiler.getDiagnostics().toString(); + expectOutput (compiled.getReference(), 0.5f); +} + + +TEST_F (YdspProjectTests, ProcessorEntryExposesMetersAndEventInputs) +{ + project ("Main", "[Main.ydsp]"); + write ("Main.ydsp", "processor Main { input stream in; output stream out; input event midi; " + "output parameter float level; process { out = in; level = in; } }"); + YdspCompiler compiler; + auto compiled = compiler.compileProject (manifest); + ASSERT_TRUE (compiled.wasOk()) << compiler.getDiagnostics().toString(); + auto& graph = compiled.getReference(); + EXPECT_EQ (1, graph.getEventInputCount()); + EXPECT_EQ ("midi", graph.getEventInputName (0)); + expectOutput (graph, 0.25f); + EXPECT_FLOAT_EQ (0.25f, graph.getOutputValue ("level")); +} + + +TEST_F (YdspProjectTests, NestedLibraryFunctionsGainOuterPrefixWithoutCapturingLocalFunctions) +{ + project ("Main", "[Main.ydsp, lib/Outer.ydsp, lib/Inner.ydsp]"); + write ("lib/Inner.ydsp", "func twice (x: float) : float { return x * 2.0; } " + "func wrap (x: float) : float { return twice (x); }"); + write ("lib/Outer.ydsp", "import Inner as inner; " + "func wrap (x: float) : float { return inner.wrap (x); } " + "processor Gain { input stream in; output stream out; " + "func wrap (x: float) : float { return x * 3.0; } " + "process { out = inner.wrap (in) + wrap (in); } }"); + const auto source = String ("import lib.Outer as outer; " + "processor Main { input stream in; output stream out; " + "process { out = outer.wrap (in); } } " + "graph Alternative { input stream in; output stream out; node p = outer.Gain; " + "connection { in -> p.in; p.out -> out; } }"); + write ("Main.ydsp", source); + YdspCompiler compiler; + auto compiled = compiler.compileProject (manifest); + ASSERT_TRUE (compiled.wasOk()) << compiler.getDiagnostics().toString(); + expectOutput (compiled.getReference(), 0.5f); + auto alternate = compiler.compileProject (manifest, {}, "Alternative"); + ASSERT_TRUE (alternate.wasOk()) << compiler.getDiagnostics().toString(); + expectOutput (alternate.getReference(), 1.25f); + auto direct = compiler.compile (source, directory.getChildFile ("Main.ydsp").getFullPathName()); + ASSERT_TRUE (direct.wasOk()) << compiler.getDiagnostics().toString(); + expectOutput (direct.getReference(), 1.25f); +} + +TEST_F (YdspProjectTests, BundlesPreserveMainOverrideAndNestedImportsWithoutSourceFiles) +{ + project ("Main", "[Main.ydsp, lib/Gain.ydsp, lib/Math.ydsp]"); + write ("lib/Math.ydsp", "func twice (x: float) : float { return x * 2.0; }"); + write ("lib/Gain.ydsp", "import Math as math; processor Gain { input stream in; output stream out; process { out = math.twice (in); } }"); + write ("Main.ydsp", "import lib.Gain as lib; processor Main { input stream in; output stream out; process { out = in; } } " + "graph Alternative [[ main ]] { input stream in; output stream out; node p = lib.Gain; connection { in -> p.in; p.out -> out; } }"); + YdspBundleCompileOptions options; + for (const auto os : { YdspTargetOperatingSystem::macosTarget, YdspTargetOperatingSystem::linuxTarget, YdspTargetOperatingSystem::windowsTarget }) + for (const auto arch : { YdspTargetArchitecture::arm64, YdspTargetArchitecture::x64 }) + options.nativeTargets.push_back ({ os, arch }); + std::array bytes; + for (int i = 0; i < 2; ++i) + { + YdspCompiler compiler; + auto bundle = compiler.compileProjectBundle (manifest, options, i == 0 ? "" : "Alternative"); + ASSERT_TRUE (bundle.wasOk()) << compiler.getDiagnostics().toString(); + ASSERT_EQ (3u, bundle.getReference().getSources().size()); + ASSERT_TRUE (bundle.getReference().saveToMemoryBlock (bytes[static_cast (i)]).wasOk()); + } + ASSERT_TRUE (directory.deleteRecursively()); + for (int i = 0; i < 2; ++i) + { + auto loaded = YdspBundle::loadFromMemoryBlock (bytes[static_cast (i)]); + ASSERT_TRUE (loaded.wasOk()) << loaded.getErrorMessage(); + auto graph = loaded.getReference().instantiate(); + ASSERT_TRUE (graph.wasOk()) << graph.getErrorMessage(); + expectOutput (graph.getReference(), i == 0 ? 0.25f : 0.5f); + } +} + +TEST_F (YdspProjectTests, EditorOverridesIncludeManifestAndImportedSources) +{ + project ("WrongMain", "[Main.ydsp, lib/Gain.ydsp]"); + write ("Main.ydsp", "import lib.Gain as lib; graph Main { input stream in; output stream out; node p = lib.Gain; connection { in -> p.in; p.out -> out; } }"); + write ("lib/Gain.ydsp", "invalid on disk"); + YdspCompileOptions options; + options.sourceOverrides[manifest.getFullPathName()] = "formatVersion: 1\nmain: Main\nsources: [Main.ydsp, lib/Gain.ydsp]\n"; + const auto importedPath = directory.getChildFile ("lib/Gain.ydsp").getFullPathName(); + options.sourceOverrides[importedPath] = "processor Gain { input stream in; output stream out; process { out = in * 2.0; } }"; + YdspCompiler compiler; + auto graph = compiler.compileProject (manifest, options); + ASSERT_TRUE (graph.wasOk()) << compiler.getDiagnostics().toString(); + expectOutput (graph.getReference(), 0.5f); + options.sourceOverrides[importedPath] = "processor Gain { input stream in; output stream out; process { out = missing; } }"; + EXPECT_FALSE (compiler.compileProject (manifest, options).wasOk()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains (importedPath + ":")); +} + +TEST_F (YdspProjectTests, ProjectBundlesRequireTheirEntryPointChunkAndVersion) +{ + project ("Main", "[Main.ydsp]"); + write ("Main.ydsp", "processor Main { output stream out; process { out = 0.0; } }"); + YdspCompiler compiler; + auto bundle = compiler.compileProjectBundle (manifest, {}); + ASSERT_TRUE (bundle.wasOk()) << compiler.getDiagnostics().toString(); + MemoryBlock bytes; + ASSERT_TRUE (bundle.getReference().saveToMemoryBlock (bytes).wasOk()); + // RIFF header (12 bytes), VERS header (8 bytes), then the version word. + ASSERT_GT (bytes.getSize(), 28u); + auto* data = static_cast (bytes.getData()); + ASSERT_EQ (3, data[20]); + data[20] = 2; + EXPECT_FALSE (YdspBundle::loadFromMemoryBlock (bytes).wasOk()); + data[20] = 3; + // Hide PROJ as an unknown chunk: a version 3 reader must still reject it. + ASSERT_EQ ('P', data[24]); + data[24] = 'X'; + EXPECT_FALSE (YdspBundle::loadFromMemoryBlock (bytes).wasOk()); +} + +TEST_F (YdspProjectTests, LibraryValidationResolvesImportsWithoutChoosingAnImportedGraph) +{ + write ("lib/Math.ydsp", "func twice (x: float) : float { return x * 2.0; }"); + write ("lib/Graph.ydsp", "processor P { output stream out; process { out = 0.0; } } graph G { output stream out; node p = P; connection { p.out -> out; } }"); + const auto source = String ("import lib.Math as math; import lib.Graph as graphs; processor Gain { input stream in; output stream out; process { out = math.twice (in); } }"); + YdspCompiler compiler; + const auto path = directory.getChildFile ("Library.ydsp").getFullPathName(); + EXPECT_TRUE (compiler.validate (source, {}, path).wasOk()) << compiler.getDiagnostics().toString(); + YdspCompileOptions options; + options.sourceOverrides[directory.getChildFile ("lib/Math.ydsp").getFullPathName()] = "func twice (x: float) : float { return missing; }"; + EXPECT_FALSE (compiler.validate (source, options, path).wasOk()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ("Math.ydsp:")); +} diff --git a/tests/yup_dsp_jit/yup_YdspRuntimeOptimizationTests.cpp b/tests/yup_dsp_jit/yup_YdspRuntimeOptimizationTests.cpp new file mode 100644 index 000000000..f1b705f5f --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspRuntimeOptimizationTests.cpp @@ -0,0 +1,1930 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include +#include +#include + +#include "yup_YdspTestPatches.h" +#include "yup_YdspAllocationCounter.h" + +using namespace yup; + +class YdspRuntimeOptimizationTests : public ::testing::Test +{ +protected: + YdspCompiler compiler; + + YdspCompileOptions strictOptions() + { + YdspCompileOptions options; + options.optimizationTier = YdspOptimizationTier::baseline; + options.targetPolicy = YdspTargetPolicy::baseline; + options.fastMath = false; + return options; + } + + YdspAudioGraph chain (bool doublePrecision, bool readsOutput = false) + { + const String type = doublePrecision ? "float64" : "float"; + const auto expression = readsOutput ? "in + out" : "in + 1.0"; + + // A second input prevents chain fusion from removing the scratch under test. + const auto source = String ("processor Step { input stream ") + type + + " in; input stream " + type + " bias; output stream " + type + " out; process { out = " + expression + "; } } " + + "graph G { input stream " + type + " x; output stream " + type + " y; " + "node a = Step; node b = Step; node c = Step; node d = Step; " + "connection { x -> a.in; x -> a.bias; x -> b.bias; x -> c.bias; x -> d.bias; " + "a.out -> b.in; b.out -> c.in; c.out -> d.in; d.out -> y; } }"; + + return yup::test::patches::compilePatch (source, compiler, strictOptions()); + } +}; + +TEST_F (YdspRuntimeOptimizationTests, IntegerLiteralsRemainExactThroughDefaultsStateAndConstantSubstitution) +{ + const auto source = R"YDSP( + let exact = 9007199254740993; + processor P { + input parameter int64 defaultValue = 9223372036854775807; + input parameter int64 forwarded = 0; + output stream out; + output parameter int64 literal, constant, storedValue, minimum, defaultMeter, forwardedMeter; + state int64 stored = 9007199254740993; + process { + int64 value = 9007199254740993; + literal = value; + constant = exact; + storedValue = stored; + minimum = -9223372036854775808; + defaultMeter = defaultValue; + forwardedMeter = forwarded; + out = 0.0; + } + } + graph G { + input parameter int64 forwarded = 9007199254740993; + output stream y; + node p = P; + connection { p.out -> y; forwarded -> p.forwarded; } + } + )YDSP"; + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic, YdspOptimizationTier::aggressive }) + { + YdspCompileOptions options; + options.optimizationTier = tier; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 1).wasOk()); + std::array output {}; + YdspOutputBuffer outputs[] { Span (output) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 1 })); + for (const auto* meter : { "p.literal", "p.constant", "p.storedValue", "p.forwardedMeter" }) + EXPECT_EQ (9007199254740993LL, graph.getIntOutputValue (meter)) << meter; + EXPECT_EQ (std::numeric_limits::min(), graph.getIntOutputValue ("p.minimum")); + EXPECT_EQ (std::numeric_limits::max(), graph.getIntOutputValue ("p.defaultMeter")); + } +} + +TEST_F (YdspRuntimeOptimizationTests, IntegerNegationAbsoluteValueAndDivisionSaturateAtBothWidths) +{ + for (const bool wide : { false, true }) + { + const int64_t minimum = wide ? std::numeric_limits::min() : std::numeric_limits::min(); + const int64_t maximum = wide ? std::numeric_limits::max() : std::numeric_limits::max(); + const auto source = String (R"YDSP( + processor P { + input parameter int64 inputA = 0, inputB = 0; + input parameter int64 negativeMinimum = -(-9223372036854775808); + output parameter int64 defaultNeg; + output parameter int64 negative, magnitude, quotient, remainder, aliased; + output parameter int64 foldedNeg, foldedAbs, foldedDiv, foldedRem, foldedZero; + output stream out; + process { + defaultNeg = negativeMinimum; + TYPE a = TYPE (inputA); + TYPE b = TYPE (inputB); + negative = int64 (-a); + magnitude = int64 (abs (a)); + quotient = int64 (a / b); + remainder = int64 (a % b); + TYPE copy = a; + copy = -copy; + aliased = int64 (copy); + TYPE low = MINIMUM; + TYPE minusOne = -1; + TYPE zero = 0; + foldedNeg = int64 (-low); + foldedAbs = int64 (abs (low)); + foldedDiv = int64 (low / minusOne); + foldedRem = int64 (low % minusOne); + foldedZero = int64 (low / zero); + out = 0.0; + } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP").replace ("TYPE", wide ? "int64" : "int32") + .replace ("MINIMUM", wide ? "-9223372036854775808" : "-2147483648"); + + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic, YdspOptimizationTier::aggressive }) + { + for (const bool fastMath : { false, true }) + { + auto options = strictOptions(); + SCOPED_TRACE (::testing::Message() << "wide=" << wide << ", tier=" << static_cast (tier) << ", fastMath=" << fastMath); + options.optimizationTier = tier; + options.fastMath = fastMath; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 1).wasOk()); + float output = 0.0f; + YdspOutputBuffer outputs[] { Span (&output, 1) }; + for (const int64_t a : { minimum, minimum + 1, int64_t { -7 }, int64_t { 0 }, int64_t { 7 }, maximum }) + { + for (const int64_t b : { int64_t { -1 }, int64_t { 0 }, int64_t { 1 }, int64_t { 3 } }) + { + SCOPED_TRACE (::testing::Message() << "a=" << a << ", b=" << b); + graph.setIntParameter ("p.inputA", a); + graph.setIntParameter ("p.inputB", b); + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 1 })); + EXPECT_EQ (std::numeric_limits::max(), graph.getIntOutputValue ("p.defaultNeg")); + const auto negated = a == minimum ? maximum : -a; + const auto quotient = b == 0 ? 0 : (a == minimum && b == -1 ? maximum : a / b); + const auto remainder = b == 0 || (a == minimum && b == -1) ? 0 : a % b; + EXPECT_EQ (negated, graph.getIntOutputValue ("p.negative")); + EXPECT_EQ (negated, graph.getIntOutputValue ("p.aliased")); + EXPECT_EQ (a < 0 ? negated : a, graph.getIntOutputValue ("p.magnitude")); + EXPECT_EQ (quotient, graph.getIntOutputValue ("p.quotient")); + EXPECT_EQ (remainder, graph.getIntOutputValue ("p.remainder")); + for (const auto* meter : { "p.foldedNeg", "p.foldedAbs", "p.foldedDiv" }) + EXPECT_EQ (maximum, graph.getIntOutputValue (meter)); + EXPECT_EQ (0, graph.getIntOutputValue ("p.foldedRem")); + EXPECT_EQ (0, graph.getIntOutputValue ("p.foldedZero")); + } + } + } + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, IntegerBinaryArithmeticSaturatesAtBothWidths) +{ + for (const bool wide : { false, true }) + { + const int64_t lo = wide ? std::numeric_limits::min() : std::numeric_limits::min(); + const int64_t hi = wide ? std::numeric_limits::max() : std::numeric_limits::max(); + struct Case { int64_t a, b, sum, difference, product; }; + const Case cases[] { + { hi, 1, hi, hi - 1, hi }, { lo, -1, lo, lo + 1, hi }, + { lo, 1, lo + 1, lo, lo }, { hi, -1, hi - 1, hi, -hi }, + { hi, hi, hi, 0, hi }, { lo, lo, lo, 0, hi }, + { hi, lo, -1, hi, lo }, { lo, hi, -1, lo, lo }, + { lo, 0, lo, lo, 0 }, { 0, lo, lo, hi, 0 }, + { 7, -3, 4, 10, -21 }, { -7, -3, -10, -4, 21 }, + { hi / 2, 2, hi / 2 + 2, hi / 2 - 2, hi - 1 }, + { lo / 2, 2, lo / 2 + 2, lo / 2 - 2, lo }, + }; + const auto source = String (R"YDSP( + processor P { + input parameter int64 inputA = 0, inputB = 0; + output parameter int64 sum, difference, product, aliased; + output parameter int64 foldedSum, foldedDifference, foldedProduct; + output stream out; + process { + TYPE a = TYPE (inputA); + TYPE b = TYPE (inputB); + sum = int64 (a + b); difference = int64 (a - b); product = int64 (a * b); + TYPE copy = a; copy = copy * b; aliased = int64 (copy); + TYPE high = HIGH; TYPE low = LOW; TYPE one = 1; TYPE minusOne = -1; + foldedSum = int64 (high + one); + foldedDifference = int64 (low - one); + foldedProduct = int64 (low * minusOne); + out = 0.0; + } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP").replace ("TYPE", wide ? "int64" : "int32") + .replace ("HIGH", String (hi)).replace ("LOW", String (lo)); + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic, YdspOptimizationTier::aggressive }) + for (const bool fastMath : { false, true }) + { + SCOPED_TRACE (::testing::Message() << "wide=" << wide << ", tier=" << static_cast (tier) << ", fastMath=" << fastMath); + auto options = strictOptions(); + options.optimizationTier = tier; + options.fastMath = fastMath; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 1).wasOk()); + float output = 0.0f; + YdspOutputBuffer outputs[] { Span (&output, 1) }; + for (const auto& item : cases) + { + SCOPED_TRACE (::testing::Message() << "a=" << item.a << ", b=" << item.b); + graph.setIntParameter ("p.inputA", item.a); + graph.setIntParameter ("p.inputB", item.b); + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 1 })); + EXPECT_EQ (item.sum, graph.getIntOutputValue ("p.sum")); + EXPECT_EQ (item.difference, graph.getIntOutputValue ("p.difference")); + EXPECT_EQ (item.product, graph.getIntOutputValue ("p.product")); + EXPECT_EQ (item.product, graph.getIntOutputValue ("p.aliased")); + EXPECT_EQ (hi, graph.getIntOutputValue ("p.foldedSum")); + EXPECT_EQ (lo, graph.getIntOutputValue ("p.foldedDifference")); + EXPECT_EQ (hi, graph.getIntOutputValue ("p.foldedProduct")); + } + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, FloatToIntegerConversionsSaturateAndMapNaNToZero) +{ + struct Case { double value; int64_t narrow, wide; }; + const Case cases[] { + { 0.0, 0, 0 }, { -0.0, 0, 0 }, { 7.75, 7, 7 }, { -7.75, -7, -7 }, + { 2147483648.0, 2147483647, 2147483648 }, + { -2147483648.0, -2147483648, -2147483648 }, + { 9223372036854775808.0, 2147483647, std::numeric_limits::max() }, + { -9223372036854775808.0, -2147483648, std::numeric_limits::min() }, + { std::numeric_limits::infinity(), 2147483647, std::numeric_limits::max() }, + { -std::numeric_limits::infinity(), -2147483648, std::numeric_limits::min() }, + { std::numeric_limits::quiet_NaN(), 0, 0 }, + }; + for (const bool doublePrecision : { false, true }) + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic, YdspOptimizationTier::aggressive }) + for (const bool fastMath : { false, true }) + { + const auto source = String (R"YDSP( + processor P { + input parameter TYPE value = 0.0; + output parameter int64 narrow, wide; + output stream out; + process { narrow = int64 (int32 (value)); wide = int64 (value); out = 0.0; } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP").replace ("TYPE", doublePrecision ? "float64" : "float32"); + auto options = strictOptions(); + options.optimizationTier = tier; + options.fastMath = fastMath; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 1).wasOk()); + float output = 0.0f; + YdspOutputBuffer outputs[] { Span (&output, 1) }; + for (const auto& item : cases) + { + SCOPED_TRACE (::testing::Message() << "value=" << item.value << ", double=" << doublePrecision << ", tier=" << static_cast (tier) << ", fastMath=" << fastMath); + if (doublePrecision) graph.setDoubleParameter ("p.value", item.value); + else graph.setParameter ("p.value", static_cast (item.value)); + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 1 })); + EXPECT_EQ (item.narrow, graph.getIntOutputValue ("p.narrow")); + EXPECT_EQ (item.wide, graph.getIntOutputValue ("p.wide")); + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, FoldedAndRuntimeIntegerOverflowComparisonsAgree) +{ + const auto source = R"YDSP( + processor P { + input parameter int32 largest = 2147483647; + input parameter int32 smallest = -2147483648; + input parameter int32 factor = 65536; + output parameter int64 live, folded; + output stream out; + process { + int32 a = 2147483647; + int32 b = -2147483648; + int32 c = 65536; + live = ((largest + 1) < 0 ? 1 : 0) + + ((smallest - 1) > 0 ? 2 : 0) + + ((factor * factor) == 0 ? 4 : 0); + folded = ((a + 1) < 0 ? 1 : 0) + + ((b - 1) > 0 ? 2 : 0) + + ((c * c) == 0 ? 4 : 0); + out = 0.0; + } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP"; + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic, YdspOptimizationTier::aggressive }) + for (const bool fastMath : { false, true }) + { + SCOPED_TRACE (::testing::Message() << "tier=" << static_cast (tier) << ", fastMath=" << fastMath); + auto options = strictOptions(); + options.optimizationTier = tier; + options.fastMath = fastMath; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 1).wasOk()); + float output = 0.0f; + YdspOutputBuffer outputs[] { Span (&output, 1) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 1 })); + EXPECT_EQ (0, graph.getIntOutputValue ("p.live")); + EXPECT_EQ (0, graph.getIntOutputValue ("p.folded")); + } +} + +TEST_F (YdspRuntimeOptimizationTests, FoldedAndRuntimeShiftsAgreeAtBothWidths) +{ + for (const bool wide : { false, true }) + for (const int count : { -1, 0, 1, 31, 32, 33, 63, 64, 65 }) + { + const auto source = String (R"YDSP( + processor P { + input parameter TYPE inputA = -7; + input parameter TYPE inputB = COUNT; + output parameter int64 liveLeft, liveRight, foldedLeft, foldedRight; + output stream out; + process { + TYPE a = -7; + TYPE b = COUNT; + liveLeft = int64 (inputA << inputB); + liveRight = int64 (inputA >> inputB); + foldedLeft = int64 (a << b); + foldedRight = int64 (a >> b); + out = 0.0; + } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP").replace ("TYPE", wide ? "int64" : "int32").replace ("COUNT", String (count)); + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic, YdspOptimizationTier::aggressive }) + for (const bool fastMath : { false, true }) + { + SCOPED_TRACE (::testing::Message() << "wide=" << wide << ", count=" << count << ", tier=" << static_cast (tier) << ", fastMath=" << fastMath); + auto options = strictOptions(); + options.optimizationTier = tier; + options.fastMath = fastMath; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 1).wasOk()); + float output = 0.0f; + YdspOutputBuffer outputs[] { Span (&output, 1) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 1 })); + EXPECT_EQ (graph.getIntOutputValue ("p.liveLeft"), graph.getIntOutputValue ("p.foldedLeft")); + EXPECT_EQ (graph.getIntOutputValue ("p.liveRight"), graph.getIntOutputValue ("p.foldedRight")); + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, ValueIdentifierAndOutputParameterPreserveMeterBehavior) +{ + const auto source = R"YDSP( + processor P { + input stream in; + input parameter float gain = 2.0; + output stream out; + output parameter float level; + process { + float value = in * gain; + out = value; + level = value; + } + } + graph G { + input stream x; output stream y; + input parameter float gain = 3.0; + output parameter float level; + node p = P; + connection { x -> p.in; p.out -> y; gain -> p.gain; p.level -> level; } + } + )YDSP"; + auto graph = yup::test::patches::compilePatch (source, compiler); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 1).wasOk()); + const std::array input { 0.25f }; + std::array output {}; + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, 1 })); + EXPECT_FLOAT_EQ (0.75f, output[0]); + EXPECT_FLOAT_EQ (0.75f, graph.getOutputValue ("level")); +} + +TEST_F (YdspRuntimeOptimizationTests, ConditionalExpressionsEvaluateOnlyTheSelectedBranch) +{ + const auto source = R"YDSP( + processor P { + input stream in; output stream out; + state int calls; + func touch (result: bool): bool { calls = calls + 1; return result; } + func number (value: float): float { calls = calls + 1; return value; } + process { + calls = 0; + let positive = in > 0.0; + let a = positive && touch (true); + let b = positive || touch (false); + let c = positive ? number (2.0) : number (3.0); + let d = positive ? (a ? 4.0 : number (100.0)) : (b ? number (100.0) : 5.0); + let eager = select (positive, number (6.0), number (7.0)); + out = float (calls) * 1000.0 + c * 100.0 + d * 10.0 + eager; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic, YdspOptimizationTier::aggressive }) + { + for (const bool fastMath : { false, true }) + { + YdspCompileOptions options; + options.optimizationTier = tier; + options.fastMath = fastMath; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 5).wasOk()); + const std::array input { 1.0f, -1.0f, 1.0f, 0.0f, -1.0f }; + std::array output {}; + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, 5 })); + for (size_t i = 0; i < input.size(); ++i) + EXPECT_FLOAT_EQ (input[i] > 0.0f ? 4246.0f : 4357.0f, output[i]); + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, InitializationPreservesShortCircuitSideEffects) +{ + const auto source = R"YDSP( + processor P { + output stream out; + state int calls; + state float result; + func touch (): bool { calls = calls + 1; return true; } + func number (value: float64): float64 { calls = calls + 1; return value; } + init { + let a = false && touch(); + let b = true || touch(); + let c = b ? number (2.0) : 0; + let d = a ? 0 : number (3.0); + result = float (calls) * 100.0 + float (c + d); + } + process { out = result; } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP"; + auto graph = yup::test::patches::compilePatch (source, compiler); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 1).wasOk()); + std::array output {}; + YdspOutputBuffer outputs[] { Span (output) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 1 })); + EXPECT_FLOAT_EQ (205.0f, output[0]); +} + +TEST_F (YdspRuntimeOptimizationTests, InvalidArrayAndStructIndicesReadZeroAndDiscardWrites) +{ + const auto source = R"YDSP( + processor P { + input stream in; output stream out; + output parameter float sentinel; + state float table[2] = { 10.0, 20.0 }; + struct Voice { float taps[2]; float level; } + state Voice voices[2]; + init { + voices[0].taps[0] = 1.0; voices[0].taps[1] = 2.0; + voices[1].taps[0] = 30.0; voices[1].taps[1] = 40.0; + voices[0].level = 100.0; voices[1].level = 200.0; + } + process { + let index = int (in); + out = table[index] + voices[0].taps[index] + voices[index].level; + if (index < 0 || index >= 2) { + table[index] = 999.0; + voices[0].taps[index] = 999.0; + voices[index].level = 999.0; + voices[index].taps[0] = 999.0; + } + sentinel = voices[1].taps[0]; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic, YdspOptimizationTier::aggressive }) + { + YdspCompileOptions options; + options.optimizationTier = tier; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 7).wasOk()); + const std::array input { -1.0f, 2.0f, -2147483648.0f, 2147483520.0f, 3.0f, 0.0f, 1.0f }; + std::array output {}; + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, 7 })); + for (size_t i = 0; i < 5; ++i) + EXPECT_FLOAT_EQ (0.0f, output[i]); + EXPECT_FLOAT_EQ (111.0f, output[5]); + EXPECT_FLOAT_EQ (222.0f, output[6]); + EXPECT_FLOAT_EQ (30.0f, graph.getOutputValue ("p.sentinel")); + } +} + +TEST_F (YdspRuntimeOptimizationTests, BranchlessArrayReadsHandleExtremeIndicesAtEveryNumericWidth) +{ + for (const auto* type : { "float32", "float64", "int32", "int64" }) + { + const auto source = String (R"YDSP( + processor P { + input parameter int64 index; + output stream out; + state TYPE data[2] = { 10, 20 }; + process { out = float (data[int32 (index)]); } + } + graph G { output stream out; node p = P; connection { p.out -> out; } } + )YDSP").replace ("TYPE", type); + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic, YdspOptimizationTier::aggressive }) + { + YdspCompileOptions options; + options.optimizationTier = tier; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 1).wasOk()); + std::array output {}; + YdspOutputBuffer outputs[] { Span (output) }; + for (const int index : { std::numeric_limits::min(), -1, 0, 1, 2, std::numeric_limits::max() }) + { + graph.setIntParameter ("p.index", index); + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 1 })); + EXPECT_FLOAT_EQ (index == 0 ? 10.0f : index == 1 ? 20.0f : 0.0f, output[0]) << type << " index=" << index; + } + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, ProvenMaskedAndClampedIndicesPreserveReadsAndWrites) +{ + for (const bool masked : { false, true }) + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic, YdspOptimizationTier::aggressive }) + { + const auto source = String (R"YDSP( + processor P { + input parameter int64 index; + output stream out; + state float data[8]; + process { + let raw = int32 (index); + let bounded = EXPRESSION; + out = data[bounded]; + data[bounded] = float (bounded + 1); + } + } + graph G { output stream out; node p = P; connection { p.out -> out; } } + )YDSP").replace ("EXPRESSION", masked ? "raw & 7" : "clamp (raw, 0, 7)"); + YdspCompileOptions options; + options.optimizationTier = tier; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 1).wasOk()); + std::array expectedState {}; + std::array output {}; + YdspOutputBuffer outputs[] { Span (output) }; + for (const int index : { std::numeric_limits::min(), -1, 0, 7, 8, std::numeric_limits::max() }) + { + graph.setIntParameter ("p.index", index); + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 1 })); + const auto bounded = static_cast (masked ? index & 7 : std::clamp (index, 0, 7)); + EXPECT_FLOAT_EQ (expectedState[bounded], output[0]); + expectedState[bounded] = static_cast (bounded + 1); + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, ProvenStridedArrayAccessMatchesReferenceAcrossReset) +{ + const auto source = R"YDSP( + processor P { + input stream in; + output stream out; + state float table[16]; + process { + int32 index = (int32 (in) & 3) * 4 + 3; + out = table[index]; + table[index] = in; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic, YdspOptimizationTier::aggressive }) + for (const bool fastMath : { false, true }) + { + SCOPED_TRACE (::testing::Message() << "tier=" << static_cast (tier) << ", fastMath=" << fastMath); + auto options = strictOptions(); + options.optimizationTier = tier; + options.fastMath = fastMath; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 8).wasOk()); + const float input[] { -1, 0, 1, 2, 3, -5, 1000000, -1000000 }; + float output[8] {}; + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + for (int reset = 0; reset < 2; ++reset) + { + graph.reset(); + float reference[16] {}; + for (int block = 0; block < 2; ++block) + { + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, 8 })); + for (size_t i = 0; i < 8; ++i) + { + const auto index = (static_cast (input[i]) & 3) * 4 + 3; + EXPECT_FLOAT_EQ (reference[index], output[i]); + reference[index] = input[i]; + } + } + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, InvalidConstantRingUpdateReadsZeroAndDiscardsWrites) +{ + const auto source = R"YDSP( + processor P { + input stream in; output stream out; + state float data[8]; state int wp; + init { data[0] = 37.0; } + process { + wp = -1; + float invalid = data[wp]; + data[wp] = in; + out = invalid + data[0]; + wp = wp + 1; if (wp >= 8) { wp = 0; } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic, YdspOptimizationTier::aggressive }) + for (const bool fastMath : { false, true }) + { + YdspCompileOptions options; + options.optimizationTier = tier; + options.fastMath = fastMath; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 4).wasOk()); + const std::array input { 11, 22, 33, 44 }; + std::array output {}; + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + for (int block = 0; block < 3; ++block) + { + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, 4 })); + for (const auto value : output) + EXPECT_FLOAT_EQ (37.0f, value); + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, ProvenRingTapsSurviveWrapsParameterChangesAndReset) +{ + const auto source = R"YDSP( + processor P { + input stream in; output stream out; + input parameter float delay = 1.0; + state float data[8]; state int wp; + process { + float d = delay; + if (d > 8.0) { d = 8.0; } + if (d < 1.0) { d = 1.0; } + int a = wp - int (d); + if (a < 0) { a = a + 8; } + int b = a - 1; + if (b < 0) { b = b + 8; } + out = data[a] + data[b]; + data[wp] = in; + wp = wp + 1; + if (wp >= 8) { wp = 0; } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic, YdspOptimizationTier::aggressive }) + for (const bool fastMath : { false, true }) + { + YdspCompileOptions options; + options.optimizationTier = tier; + options.fastMath = fastMath; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 17).wasOk()); + for (int reset = 0; reset < 2; ++reset) + { + graph.reset(); + std::array ring {}; + int wp = 0; + for (const float delay : { 1.0f, 8.0f, 3.5f, -100.0f, 100.0f, + std::numeric_limits::infinity(), -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN() }) + { + graph.setParameter ("p.delay", delay); + for (const int length : { 1, 3, 17 }) + { + std::vector input (static_cast (length)), output (input.size()); + for (int i = 0; i < length; ++i) input[static_cast (i)] = static_cast (i + 1); + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, length })); + for (int i = 0; i < length; ++i) + { + const int d = std::isnan (delay) ? 0 : static_cast (std::clamp (delay, 1.0f, 8.0f)); + const int a = (wp - d + 8) % 8; + const float expected = ring[static_cast (a)] + ring[static_cast ((a + 7) % 8)]; + if (std::isnan (delay)) + { + // Existing native float comparisons may clamp NaN to + // the lower endpoint before it reaches the conversion. + const float lowerClamped = ring[static_cast ((wp + 7) % 8)] + ring[static_cast ((wp + 6) % 8)]; + EXPECT_TRUE (output[static_cast (i)] == expected || output[static_cast (i)] == lowerClamped); + } + else + EXPECT_FLOAT_EQ (expected, output[static_cast (i)]); + ring[static_cast (wp)] = input[static_cast (i)]; + wp = (wp + 1) % 8; + } + } + } + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, IndexedStreamsCheckTheActualBlockLength) +{ + const auto source = R"YDSP( + processor P { + input stream in; output stream out; + process block { + for i in 0..blockSize { out[i] = in[i + 1]; } + out[blockSize] = 999.0; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + auto graph = yup::test::patches::compilePatch (source, compiler); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 7).wasOk()); + const std::array input { 1, 2, 3, 4, 5, 6, 7 }; + std::array output {}; + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + for (const int length : { 1, 3, 7 }) + { + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, length })); + for (int i = 0; i + 1 < length; ++i) + EXPECT_FLOAT_EQ (input[static_cast (i + 1)], output[static_cast (i)]); + EXPECT_FLOAT_EQ (0.0f, output[static_cast (length - 1)]); + } +} + +TEST_F (YdspRuntimeOptimizationTests, MismatchedLoopAndBufferLengthsRetainBoundsGuards) +{ + for (const bool fixedStreamLoop : { false, true }) + { + const auto source = String (R"YDSP( + processor P { + input stream in; output stream out; + state float z[8] = { 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0 }; + process block { + )YDSP") + (fixedStreamLoop + ? "for i in 0..blockSize { out[i] = 0.0; } for i in 0..10 { out[i] = in[i] * 2.0; }" + : "for i in 0..blockSize { out[i] = in[i] + z[i]; }") + + "} } graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } }"; + + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic, YdspOptimizationTier::aggressive }) + { + YdspCompileOptions options; + options.optimizationTier = tier; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 13).wasOk()); + std::array input; + std::array output; + for (size_t i = 0; i < input.size(); ++i) + input[i] = static_cast (i + 1); + + for (const int length : { 1, 3, 7, 8, 9, 10, 13 }) + { + output.fill (-999.0f); + const YdspInputBuffer inputs[] { Span (input.data(), static_cast (length)) }; + YdspOutputBuffer outputs[] { Span (output.data(), static_cast (length)) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, length })); + for (int i = 0; i < length; ++i) + { + const auto expected = fixedStreamLoop ? (i < 10 ? input[static_cast (i)] * 2.0f : 0.0f) + : input[static_cast (i)] + (i < 8 ? 2.0f : 0.0f); + EXPECT_FLOAT_EQ (expected, output[static_cast (i)]); + } + for (size_t i = static_cast (length); i < output.size(); ++i) + EXPECT_FLOAT_EQ (-999.0f, output[i]); + } + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, RejectsOverflowingStructStateLayouts) +{ + const auto source = R"YDSP( + processor P { + struct Voice { float samples[65536]; } + state Voice voices[65536]; + output stream out; + process { out = 0.0; } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP"; + auto result = compiler.compile (source); + ASSERT_TRUE (result.failed()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains ("State layout exceeds")); +} + +TEST_F (YdspRuntimeOptimizationTests, VectorFmaPreservesSharedAddendsAcrossBlocks) +{ + for (const bool shared : { false, true }) + { + const auto source = String (R"YDSP( + processor P { + input stream in; output stream out; + state float z[16]; + process { + float sum = 0.0; + for i in 0..16 { + let product = z[i] * 0.25; + z[i] = fma (in, 0.5, product); + sum = sum + z[i]; + )YDSP") + (shared ? "sum = sum + product;" : "") + R"YDSP( + } + out = sum; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + auto graph = yup::test::patches::compilePatch (source, compiler); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 17).wasOk()); + std::vector input (17), output (17); + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + float state = 0.0f; + for (const int size : { 1, 17, 3 }) + { + for (int i = 0; i < size; ++i) + input[static_cast (i)] = (i - 8) * 0.0625f; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, size })); + for (int i = 0; i < size; ++i) + { + const float product = state * 0.25f; + state = std::fma (input[static_cast (i)], 0.5f, product); + EXPECT_NEAR (16.0f * (state + (shared ? product : 0.0f)), output[static_cast (i)], 0.00001f); + } + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, UnrolledVectorBankPreservesEveryElementAcrossBlocks) +{ + const auto source = R"YDSP( + processor P { + input stream in; output stream out; + state float z[16]; + process { + float sum = 0.0; + for i in 0..16 { + z[i] = z[i] * 0.5 + in; + sum = sum + z[i]; + } + out = sum; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + for (const bool fast : { false, true }) + { + YdspCompileOptions options; + options.fastMath = fast; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 17).wasOk()); + std::vector input (17, 0.125f), output (17); + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + float state = 0.0f; + for (const int size : { 1, 17, 3, 16 }) + { + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, size })); + for (int i = 0; i < size; ++i) + { + state = state * 0.5f + 0.125f; + EXPECT_NEAR (16.0f * state, output[static_cast (i)], 0.00001f); + } + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, UnrolledLoopsPreserveCarriedValuesAndArrayStateAcrossBlocks) +{ + const auto source = R"YDSP( + processor P { + input stream in; output stream out; + state float z[8]; + state float carry = 0.0; + process { + float sum = 0.0; + for i in 1..8 { + let previous = carry; + carry = carry * 0.5 + in; + z[i] = z[i] * 0.25 + previous; + sum = sum + z[i]; + } + out = sum + carry; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + for (const bool fast : { false, true }) + { + YdspCompileOptions options; + options.fastMath = fast; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 17).wasOk()); + std::vector input (17), output (17); + float state[8] {}; + float carry = 0.0f; + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + for (const int size : { 1, 17, 3, 16 }) + { + for (int i = 0; i < size; ++i) + input[static_cast (i)] = (i - 8) * 0.0625f; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, size })); + for (int i = 0; i < size; ++i) + { + float sum = 0.0f; + for (int j = 1; j < 8; ++j) + { + const float previous = carry; + carry = carry * 0.5f + input[static_cast (i)]; + state[j] = state[j] * 0.25f + previous; + sum += state[j]; + } + EXPECT_NEAR (sum + carry, output[static_cast (i)], 0.00001f); + } + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, RematerializedDryWetAndConstantProductsMatchReference) +{ + const auto source = R"YDSP( + processor P { + input stream in; output stream out; + input parameter float wet = 0.25; + process { + let shaped = sin (in); + out = ((1.0 - wet) * in + wet * shaped) * 0.05 * 2.302585092994046; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + for (const bool fast : { false, true }) + { + auto options = strictOptions(); + options.fastMath = fast; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 65).wasOk()); + std::vector input (65), output (65); + for (size_t i = 0; i < input.size(); ++i) + input[i] = (static_cast (i) - 32.0f) * 0.125f; + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + for (int block = 0; block < 2; ++block) + { + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, 65 })); + for (size_t i = 0; i < input.size(); ++i) + EXPECT_NEAR ((0.75f * input[i] + 0.25f * std::sin (input[i])) * 0.05f * 2.302585092994046f, + output[i], 0.000001f); + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, HoistedLocalClampsPreserveOutputAcrossBlocks) +{ + for (const auto* delay : { "-2.0", "3.5", "20.0" }) + { + const auto source = String (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float delay = )YDSP") + delay + R"YDSP(; + process { + float d = delay; + if (d > 8.0) d = 8.0; + if (d < 1.0) d = 1.0; + out = in * (d - float (int32 (d))); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + auto graph = yup::test::patches::compilePatch (source, compiler, strictOptions()); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 17).wasOk()); + std::vector input (17, 3.0f), output (17); + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + for (int block = 0; block < 2; ++block) + { + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, 17 })); + for (const auto sample : output) + EXPECT_FLOAT_EQ (String (delay) == "3.5" ? 1.5f : 0.0f, sample); + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, GuardedClampHoistingTracksParameterChangesAcrossBlocks) +{ + const auto source = R"YDSP( + processor P { + input stream in; output stream out; + input parameter float delay = 3.5; + state float data[2] = { 10.0, 20.0 }; + process { + float d = delay; + if (d > 8.0) d = 8.0; + if (d < 1.0) d = 1.0; + let whole = int32 (d); + out = data[int32 (in)] + d - float (whole); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP"; + for (const bool fastMath : { false, true }) + { + auto options = strictOptions(); + options.fastMath = fastMath; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 4).wasOk()); + const std::array input { -1.0f, 0.0f, 1.0f, 2.0f }; + std::array output {}; + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + for (const float delay : { 3.5f, -2.0f, 20.0f, 2.25f }) + { + graph.setParameter ("p.delay", delay); + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, 4 })); + const float clamped = std::clamp (delay, 1.0f, 8.0f); + const float fraction = clamped - std::floor (clamped); + for (size_t i = 0; i < input.size(); ++i) + EXPECT_FLOAT_EQ ((i == 1 ? 10.0f : i == 2 ? 20.0f : 0.0f) + fraction, output[i]); + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, ConstantBasePowerMatchesReferenceAcrossGainRange) +{ + for (const auto* base : { "10.0", "0.5" }) + { + const auto source = String ("processor P { input stream in; output stream out; process { out = pow (") + + base + ", in); } } graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } }"; + for (const bool fastMath : { false, true }) + { + auto options = strictOptions(); + options.fastMath = fastMath; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + std::vector input (97), output (97); + for (size_t i = 0; i < input.size(); ++i) + input[i] = -12.0f + static_cast (i) * 0.25f; + ASSERT_TRUE (graph.prepare (48000.0, static_cast (input.size())).wasOk()); + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, static_cast (input.size()) })); + for (size_t i = 0; i < input.size(); ++i) + { + const float expected = std::pow (String (base).getFloatValue(), input[i]); + EXPECT_NEAR (expected, output[i], std::abs (expected) * 0.00002f) << "exponent " << input[i]; + } + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, SharedDelayTapsPreserveSamplesAcrossWrapsBlocksAndReset) +{ + const auto source = R"YDSP( + processor Taps { + input stream in; output stream a; output stream b; output stream c; + process { a = in @ 509; b = in @ 1; c = in @ 251; } + } + graph G { + input stream x; output stream a; output stream b; output stream c; + node p = Taps; + connection { x -> p.in; p.a -> a; p.b -> b; p.c -> c; } + } + )YDSP"; + + for (const bool fastMath : { false, true }) + { + auto options = strictOptions(); + options.fastMath = fastMath; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 257).wasOk()); + + for (int pass = 0; pass < 2; ++pass) + { + graph.reset(); + int position = 0; + + for (const int size : { 1, 17, 257, 3, 251, 257, 257, 17 }) + { + std::vector input (static_cast (size)); + std::vector a (input.size()), b (input.size()), c (input.size()); + for (int i = 0; i < size; ++i) + input[static_cast (i)] = static_cast (position + i + 1); + + const YdspInputBuffer inputs[] { Span (input.data(), input.size()) }; + YdspOutputBuffer outputs[] { + Span (a.data(), a.size()), + Span (b.data(), b.size()), + Span (c.data(), c.size()) + }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, size })); + + for (int i = 0; i < size; ++i) + { + const auto expected = [position, i] (int delay) + { + return position + i < delay ? 0.0f : static_cast (position + i - delay + 1); + }; + EXPECT_FLOAT_EQ (expected (509), a[static_cast (i)]); + EXPECT_FLOAT_EQ (expected (1), b[static_cast (i)]); + EXPECT_FLOAT_EQ (expected (251), c[static_cast (i)]); + } + position += size; + } + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, StateCopyChainsPreserveSampleHistoryAcrossBlocks) +{ + const auto source = R"YDSP( + processor History { + input stream in; output stream a; output stream b; + state float x1, x2; + state int i1, i2; + process { + let previous = x2; + let previousInt = i2; + x2 = x1; + x1 = in; + i2 = i1; + i1 = int (in); + a = previous; + b = float (previousInt); + } + } + graph G { + input stream x; output stream a; output stream b; + node p = History; + connection { x -> p.in; p.a -> a; p.b -> b; } + } + )YDSP"; + + for (const bool fastMath : { false, true }) + { + auto options = strictOptions(); + options.fastMath = fastMath; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 17).wasOk()); + int position = 0; + + for (const int size : { 1, 1, 17, 3, 9 }) + { + std::vector input (static_cast (size)); + std::vector a (input.size()), b (input.size()); + for (int i = 0; i < size; ++i) + input[static_cast (i)] = static_cast (position + i + 1); + + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (a), Span (b) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, size })); + + for (int i = 0; i < size; ++i) + { + const float expected = position + i < 2 ? 0.0f : static_cast (position + i - 1); + EXPECT_FLOAT_EQ (expected, a[static_cast (i)]); + EXPECT_FLOAT_EQ (expected, b[static_cast (i)]); + } + position += size; + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, LocalInitializersRemainIndependentOfLaterAssignments) +{ + const auto source = R"YDSP( + processor Copies { + input stream in; output stream a; output stream b; + process { + float original = in; + let saved = original; + float copy = original; + copy = copy + 1.0; + original = original + 2.0; + a = saved; + b = copy + original; + } + } + graph G { + input stream x; output stream a; output stream b; + node p = Copies; + connection { x -> p.in; p.a -> a; p.b -> b; } + } + )YDSP"; + + for (const bool fastMath : { false, true }) + { + auto options = strictOptions(); + options.fastMath = fastMath; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 4).wasOk()); + const std::vector input { -2.0f, 0.0f, 3.0f, 7.0f }; + std::vector a (input.size()), b (input.size()); + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (a), Span (b) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, 4 })); + + for (size_t i = 0; i < input.size(); ++i) + { + EXPECT_FLOAT_EQ (input[i], a[i]); + EXPECT_FLOAT_EQ (2.0f * input[i] + 3.0f, b[i]); + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, ReusesIntermediateBuffersAndSupportsRepeatedPreparation) +{ + auto graph = chain (false); + + ASSERT_TRUE (graph.isValid()); + EXPECT_EQ (0u, graph.getScratchMemorySizeBytes()); + + for (const int size : { 17, 64, 9 }) + { + ASSERT_TRUE (graph.prepare (48000.0, size).wasOk()); + + const auto regionBytes = (static_cast (size) * sizeof (float) + 7) & ~size_t (7); + EXPECT_EQ (2 * regionBytes, graph.getScratchMemorySizeBytes()); + + std::vector input (static_cast (size), 2.0f); + std::vector output (static_cast (size)); + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + + for (int block = 0; block < 3; ++block) + { + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, size })); + + for (const auto sample : output) + EXPECT_FLOAT_EQ (6.0f, sample); + } + } +} + +TEST_F (YdspRuntimeOptimizationTests, ReusesFloat64RegionsAtOddBlockSizes) +{ + auto graph = chain (true); + + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 17).wasOk()); + EXPECT_EQ (2u * 17u * sizeof (double), graph.getScratchMemorySizeBytes()); + + std::array input {}, output {}; + input.fill (0.25); + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, 17 })); + + for (const auto sample : output) + EXPECT_DOUBLE_EQ (4.25, sample); +} + +TEST_F (YdspRuntimeOptimizationTests, PinsOutputStorageWhenKernelsReadItsPreviousContents) +{ + auto graph = chain (false, true); + + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 16).wasOk()); + EXPECT_EQ (3u * 16u * sizeof (float), graph.getScratchMemorySizeBytes()); +} + +TEST_F (YdspRuntimeOptimizationTests, FanOutKeepsSourcesAliveUntilTheirFinalConsumer) +{ + auto graph = yup::test::patches::compilePatch (R"YDSP( + processor Step { + input stream in; output stream out; + process { out = in + 1.0; } + } + graph G { + input stream x; output stream y; + node a = Step; node b = Step; node c = Step; node d = Step; + connection { + x -> a.in; a.out -> b.in; a.out -> c.in; + b.out -> d.in; c.out -> d.in; d.out -> y; + } + } + )YDSP", compiler, strictOptions()); + + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 17).wasOk()); + + std::array input {}, output {}; + input.fill (2.0f); + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, 17 })); + + for (const auto sample : output) + EXPECT_FLOAT_EQ (9.0f, sample); +} + +TEST_F (YdspRuntimeOptimizationTests, DelayWrappingMatchesTheGlobalTimelineAcrossVariableBlocks) +{ + auto graph = yup::test::patches::compilePatch (R"YDSP( + processor Pass { input stream in; output stream out; process { out = in; } } + graph G { + input stream x; output stream y; node p = Pass; + connection { x -> [5] -> p.in; p.out -> y; } + } + )YDSP", compiler, strictOptions()); + + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 17).wasOk()); + + int position = 0; + + for (const int size : { 1, 17, 3, 9, 17, 2, 17 }) + { + std::vector input (static_cast (size)), output (static_cast (size)); + for (int i = 0; i < size; ++i) + input[static_cast (i)] = static_cast (position + i + 1); + + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, size })); + + for (int i = 0; i < size; ++i) + EXPECT_FLOAT_EQ (position + i < 5 ? 0.0f : static_cast (position + i - 4), output[static_cast (i)]); + + position += size; + } +} + +TEST_F (YdspRuntimeOptimizationTests, StrictSubtractionPreservesSignedZeroAndSpecialValues) +{ + for (const bool useDouble : { false, true }) + { + const String type = useDouble ? "float64" : "float"; + const auto source = String ("processor Sub { input stream ") + type + " in; output stream " + type + + " out; process { out = in - (-0.0); } } graph G { input stream " + type + + " x; output stream " + type + " y; node p = Sub; connection { x -> p.in; p.out -> y; } }"; + auto graph = yup::test::patches::compilePatch (source, compiler, strictOptions()); + + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 6).wasOk()); + + const auto verify = [&]() + { + const std::array input { Float (-0.0), Float (0.0), + std::numeric_limits::infinity(), -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN(), std::numeric_limits::denorm_min() }; + std::array output {}; + const YdspInputBuffer inputs[] { Span (input) }; + YdspOutputBuffer outputs[] { Span (output) }; + + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ inputs, outputs, 6 })); + + EXPECT_FALSE (std::signbit (output[0])); + EXPECT_FALSE (std::signbit (output[1])); + EXPECT_EQ (input[2], output[2]); + EXPECT_EQ (input[3], output[3]); + EXPECT_TRUE (std::isnan (output[4])); + + // Match the calling thread's current flush-to-zero policy. + volatile Float tiny = input[5]; + EXPECT_EQ (tiny - Float (-0.0), output[5]); + }; + + if (useDouble) + verify.template operator()(); + else + verify.template operator()(); + } +} + +TEST_F (YdspRuntimeOptimizationTests, AllocationInstrumentationDetectsCppAndHeapBlockGrowth) +{ +#if ! YUP_ENABLE_ALLOCATION_HOOKS + GTEST_SKIP() << "Enable YUP_TEST_ALLOCATION_HOOKS to measure allocations"; +#else + yup::test::YdspAllocationCounter allocations; + std::vector values; + + allocations.start(); + values.resize (256); + const auto cppCount = allocations.stop(); + + EXPECT_GT (cppCount, 0u); + + HeapBlock memory; + + allocations.start(); + memory.malloc (256); + const auto heapCount = allocations.stop(); + + EXPECT_GT (heapCount, 0u); +#endif +} + +TEST_F (YdspRuntimeOptimizationTests, TraceCapturesScalarsInOrderAndCanBeDisabled) +{ + const auto source = R"YDSP( + processor P { + output stream out; + state int count; + init { trace("ready {{noise}}"); } + process { + int x = count; + int64 exact = 9007199254740993; + float value = 0.5; + float64 precise = 0.25; + bool odd = (x & 1) == 1; + trace("x={x}, exact={exact}, value={value}, precise={precise}, odd={odd}, again={x}"); + count = count + 1; + if (odd) { trace("odd {x}"); } + out = float (count); + } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP"; + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic, YdspOptimizationTier::aggressive }) + for (const bool enabled : { false, true }) + { + SCOPED_TRACE (::testing::Message() << "tier=" << static_cast (tier) << ", enabled=" << enabled); + YdspCompileOptions options; + options.optimizationTier = tier; + options.enableTracing = enabled; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 2).wasOk()); + auto messages = graph.drainTraceMessages(); + ASSERT_EQ (enabled ? 1 : 0, messages.size()); + if (enabled) + EXPECT_EQ (String ("ready {noise}"), messages[0]); + float samples[2] {}; + YdspOutputBuffer outputs[] { Span (samples, 2) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 2 })); + EXPECT_EQ (1.0f, samples[0]); + EXPECT_EQ (2.0f, samples[1]); + messages = graph.drainTraceMessages(); + ASSERT_EQ (enabled ? 3 : 0, messages.size()); + if (enabled) + { + EXPECT_EQ (String ("x=0, exact=9007199254740993, value=") + String (0.5) + ", precise=" + String (0.25) + ", odd=0, again=0", messages[0]); + EXPECT_EQ (String ("x=1, exact=9007199254740993, value=") + String (0.5) + ", precise=" + String (0.25) + ", odd=1, again=1", messages[1]); + EXPECT_EQ (String ("odd 1"), messages[2]); + } + EXPECT_TRUE (graph.drainTraceMessages().isEmpty()); + graph.setTracingEnabled (false); + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 2 })); + EXPECT_TRUE (graph.drainTraceMessages().isEmpty()); + graph.setTracingEnabled (true); + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 2 })); + EXPECT_EQ (enabled ? 3 : 0, graph.drainTraceMessages().size()); + } +} + +TEST_F (YdspRuntimeOptimizationTests, TraceQueueDropsWholeMessagesAndReusesDrainedSlots) +{ + YdspCompileOptions options; + options.enableTracing = true; + auto graph = yup::test::patches::compilePatch (R"YDSP( + processor P { + output stream out; state int count; + process { trace("{count}"); count = count + 1; out = 0.0; } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP", compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 300).wasOk()); + float samples[300] {}; + YdspOutputBuffer outputs[] { Span (samples, 300) }; +#if YUP_ENABLE_ALLOCATION_HOOKS + yup::test::YdspAllocationCounter allocations; + allocations.start(); +#endif + const auto result = graph.process ({ {}, outputs, 300 }); +#if YUP_ENABLE_ALLOCATION_HOOKS + const auto count = allocations.stop(); + EXPECT_EQ (0u, count); +#endif + ASSERT_EQ (YdspProcessResult::ok, result); + EXPECT_EQ (44u, graph.getDroppedTraceCount()); + auto messages = graph.drainTraceMessages(); + ASSERT_EQ (256, messages.size()); + for (int i = 0; i < messages.size(); ++i) + EXPECT_EQ (String (i), messages[i]); + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 2 })); + messages = graph.drainTraceMessages(); + ASSERT_EQ (2, messages.size()); + EXPECT_EQ (String ("300"), messages[0]); + EXPECT_EQ (String ("301"), messages[1]); +} + +TEST_F (YdspRuntimeOptimizationTests, TraceRejectsMalformedAndOutOfScopePlaceholdersEvenWhenDisabled) +{ + struct Case { const char* statement; const char* diagnostic; }; + const Case cases[] { + { "trace(1);", "string literal" }, + { "trace(\"{x\");", "Invalid trace placeholder" }, + { "trace(\"}\");", "Unmatched '}'" }, + { "trace(\"{}\");", "Invalid trace placeholder" }, + { "trace(\"{x + 1}\");", "Invalid trace placeholder" }, + { "trace(\"{missing}\");", "Unknown symbol 'missing'" }, + { "trace(\"{x}{x}{x}{x}{x}{x}{x}{x}{x}\");", "at most 8" }, + { "{ int hidden = 1; } trace(\"{hidden}\");", "Unknown symbol 'hidden'" }, + { "trace(\"text\", x);", "')' after the trace string" }, + }; + for (const auto& item : cases) + for (const bool enabled : { false, true }) + { + SCOPED_TRACE (item.statement); + YdspCompileOptions options; + options.enableTracing = enabled; + const auto source = String ("processor P { output stream out; process { int x = 1; ") + item.statement + + " out = 0.0; } } graph G { output stream y; node p = P; connection { p.out -> y; } }"; + const auto result = compiler.compile (source, options); + EXPECT_FALSE (result.wasOk()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains (item.diagnostic)) << compiler.getDiagnostics().toString(); + } +} + +TEST_F (YdspRuntimeOptimizationTests, TraceCapturesEventAndFunctionLocals) +{ + YdspCompileOptions options; + options.enableTracing = true; + auto graph = yup::test::patches::compilePatch (R"YDSP( + processor P { + input event midi; output stream out; + func twice (x: float) : float { trace("function {x}"); return x * 2.0; } + event midi (e: noteOn) { int note = int (e.pitch); trace("note {note}"); } + process { out = twice (0.5); } + } + graph G { input event midi; output stream y; node p = P; + connection { midi -> p.midi; p.out -> y; } } + )YDSP", compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 1).wasOk()); + float sample = 0.0f; + YdspOutputBuffer outputs[] { Span (&sample, 1) }; + MidiBuffer midi; + midi.addEvent (MidiMessage::noteOn (1, 60, 1.0f), 0); + const MidiBuffer* events[] { &midi }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 1, Span (events, 1), {} })); + const auto messages = graph.drainTraceMessages(); + ASSERT_EQ (2, messages.size()); + EXPECT_EQ (String ("note 60"), messages[0]); + EXPECT_EQ (String ("function ") + String (0.5), messages[1]); + EXPECT_EQ (1.0f, sample); +} + +TEST_F (YdspRuntimeOptimizationTests, TraceOnlyCapturesTakenFunctionReturnBranches) +{ + YdspCompileOptions options; + options.enableTracing = true; + auto graph = yup::test::patches::compilePatch (R"YDSP( + processor P { + output stream out; + func record (x: int) : int { trace("chosen {x}"); return x; } + func choose (x: int) : int { + if (x > 0) { return record (1); } + return record (2); + } + func fallback (x: int) : int { + if (x > 0) { return record (3); } + trace("fallback"); + return record (4); + } + process { out = float (choose (1) + choose (0) + fallback (0)); } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP", compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 1).wasOk()); + float sample = 0.0f; + YdspOutputBuffer outputs[] { Span (&sample, 1) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 1 })); + const auto messages = graph.drainTraceMessages(); + ASSERT_EQ (4, messages.size()); + EXPECT_EQ (String ("chosen 1"), messages[0]); + EXPECT_EQ (String ("chosen 2"), messages[1]); + EXPECT_EQ (String ("fallback"), messages[2]); + EXPECT_EQ (String ("chosen 4"), messages[3]); + EXPECT_EQ (7.0f, sample); +} + +TEST_F (YdspRuntimeOptimizationTests, TraceFormatsBooleansAndIntegerCastsAsIntegers) +{ + YdspCompileOptions options; + options.enableTracing = true; + auto graph = yup::test::patches::compilePatch (R"YDSP( + processor P { + output stream out; state bool flag; + process { + let alias = flag; + int numeric = int (flag); + bool local = true; + trace("{flag} {alias} {numeric} {local}"); + int narrow = int (true); + int64 wide = int64 (true); + float single = float (true); + float64 precise = float64 (true); + trace("{narrow} {wide} {single} {precise}"); + out = 0.0; + } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP", compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 1).wasOk()); + float sample = 0.0f; + YdspOutputBuffer outputs[] { Span (&sample, 1) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 1 })); + const auto messages = graph.drainTraceMessages(); + ASSERT_EQ (2, messages.size()); + EXPECT_EQ (String ("0 0 0 1"), messages[0]); + EXPECT_EQ (String ("1 1 ") + String (1.0) + " " + String (1.0), messages[1]); +} + +#if ! YUP_WASM +TEST_F (YdspRuntimeOptimizationTests, TraceCanBeDrainedConcurrentlyWithProcessing) +{ + YdspCompileOptions options; + options.enableTracing = true; + auto graph = yup::test::patches::compilePatch (R"YDSP( + processor P { + output stream out; state int count; + process { trace("{count}"); count = count + 1; out = 0.0; } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP", compiler, options); + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.prepare (48000.0, 64).wasOk()); + std::atomic done { false }; + bool success = true; + std::thread producer ([&] + { + float samples[64] {}; + YdspOutputBuffer outputs[] { Span (samples, 64) }; + for (int block = 0; block < 64; ++block) + if (graph.process ({ {}, outputs, 64 }) != YdspProcessResult::ok) + success = false; + done.store (true, std::memory_order_release); + }); + StringArray captured; + while (! done.load (std::memory_order_acquire)) + { + captured.addArray (graph.drainTraceMessages()); + std::this_thread::yield(); + } + producer.join(); + captured.addArray (graph.drainTraceMessages()); + EXPECT_TRUE (success); + EXPECT_EQ (4096u, static_cast (captured.size()) + graph.getDroppedTraceCount()); + int previous = -1; + for (const auto& message : captured) + { + const int current = message.getIntValue(); + EXPECT_GT (current, previous); + EXPECT_LT (current, 4096); + EXPECT_EQ (String (current), message); + previous = current; + } +} +#endif + +TEST_F (YdspRuntimeOptimizationTests, MatchEvaluatesSelectorOnceAndScopesSelectedArms) +{ + const auto source = R"YDSP( + processor P { + output stream out; + state int count = -1; + func next () : int { int previous = count; count = count + 1; return previous; } + process { + int result = 7; + int outer = 5; + match (next()) { + -1 => { int local = 10; result = local; }, + 0 => { int local = 20; result = local; }, + 1 => { + match (true) { false => { result = 99; }, true => { result = 30; } } + }, + _ => { result = 40; }, + } + match (count) { -100 => { result = 99; } } + out = float (result * 10 + count + outer); + } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP"; + for (const auto tier : { YdspOptimizationTier::baseline, YdspOptimizationTier::automatic, YdspOptimizationTier::aggressive }) + for (const bool fastMath : { false, true }) + { + SCOPED_TRACE (::testing::Message() << "tier=" << static_cast (tier) << ", fastMath=" << fastMath); + YdspCompileOptions options; + options.optimizationTier = tier; + options.fastMath = fastMath; + auto graph = yup::test::patches::compilePatch (source, compiler, options); + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + ASSERT_TRUE (graph.prepare (48000.0, 5).wasOk()); + float samples[5] {}; + YdspOutputBuffer outputs[] { Span (samples, 5) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 5 })); + const float expected[] { 105.0f, 206.0f, 307.0f, 408.0f, 409.0f }; + for (int i = 0; i < 5; ++i) + EXPECT_EQ (expected[i], samples[i]); + } +} + +TEST_F (YdspRuntimeOptimizationTests, MatchPreservesWidePatternsAndFunctionReturns) +{ + auto graph = yup::test::patches::compilePatch (R"YDSP( + processor P { + output stream out; state int64 value = 9007199254740992; + func classify (x: int64) : float { + match (x) { + -9223372036854775808 => { return -1.0; }, + 9007199254740992 => { return 1.0; }, + 9007199254740993 => { return 2.0; }, + _ => { return 3.0; } + } + } + process { out = classify (value); value = value + 1; } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP", compiler); + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + ASSERT_TRUE (graph.prepare (48000.0, 3).wasOk()); + float samples[3] {}; + YdspOutputBuffer outputs[] { Span (samples, 3) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 3 })); + EXPECT_EQ (1.0f, samples[0]); + EXPECT_EQ (2.0f, samples[1]); + EXPECT_EQ (3.0f, samples[2]); +} + +TEST_F (YdspRuntimeOptimizationTests, MatchRejectsInvalidPatternsSelectorsAndArmSyntax) +{ + const struct { const char* statement; const char* diagnostic; } cases[] { + { "match (1) {}", "at least one arm" }, + { "match (1) { 1 => {}, 1 => {} }", "Duplicate match pattern" }, + { "match (1) { 0 => {}, -0 => {} }", "Duplicate match pattern" }, + { "match (1) { _ => {}, 1 => {} }", "must be last" }, + { "match (1) { _ => {}, _ => {} }", "must be last" }, + { "match (1) { 1.0 => {} }", "integer or boolean literal" }, + { "match (1) { x => {} }", "integer or boolean literal" }, + { "match (1) { -x => {} }", "integer literal after '-'" }, + { "match (1) { --9223372036854775808 => {} }", "integer literal after '-'" }, + { "match (true) { false => {}, false => {} }", "Duplicate match pattern" }, + { "match (1) { 1 -> {} }", "'=>'" }, + { "match (1) { 1 => out = 1.0; }", "match arm body" }, + { "match (1) { 1 => {} 2 => {} }", "separate arms" }, + { "match (0.5) { _ => {} }", "selector must be an integer or bool" }, + { "match (1) { 1 => { int hidden = 2; } } out = float (hidden);", "hidden" }, + }; + for (const auto& item : cases) + { + SCOPED_TRACE (item.statement); + const auto source = String ("processor P { output stream out; process { out = 0.0; ") + + item.statement + " } } graph G { output stream y; node p = P; connection { p.out -> y; } }"; + const auto result = compiler.compile (source); + EXPECT_FALSE (result.wasOk()); + EXPECT_TRUE (compiler.getDiagnostics().toString().contains (item.diagnostic)) << compiler.getDiagnostics().toString(); + } +} + +TEST_F (YdspRuntimeOptimizationTests, MatchWorksInInitAndEventsAndWithDynamicBooleans) +{ + auto graph = yup::test::patches::compilePatch (R"YDSP( + processor P { + input event midi; output stream out; state int note; state int count; + init { match (1) { _ => { note = 5; } } } + event midi (e: noteOn) { + match (int (e.pitch)) { 60 => { note = 60; }, _ => { note = 1; } } + } + process { + match ((count & 1) == 0) { + true => { out = float (note); }, false => { out = -float (note); } + } + count = count + 1; + } + } + graph G { input event midi; output stream y; node p = P; + connection { midi -> p.midi; p.out -> y; } } + )YDSP", compiler); + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + ASSERT_TRUE (graph.prepare (48000.0, 2).wasOk()); + float samples[2] {}; + YdspOutputBuffer outputs[] { Span (samples, 2) }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 2 })); + EXPECT_EQ (5.0f, samples[0]); + EXPECT_EQ (-5.0f, samples[1]); + MidiBuffer midi; + midi.addEvent (MidiMessage::noteOn (1, 60, 1.0f), 0); + const MidiBuffer* events[] { &midi }; + ASSERT_EQ (YdspProcessResult::ok, graph.process ({ {}, outputs, 2, Span (events, 1), {} })); + EXPECT_EQ (60.0f, samples[0]); + EXPECT_EQ (-60.0f, samples[1]); +} + +TEST_F (YdspRuntimeOptimizationTests, ConstantMatchRemovesUnselectedArmsAndTheirLoops) +{ + for (const auto* selector : { "2", "1 + 1", "9" }) + { + SCOPED_TRACE (selector); + const auto source = String (R"YDSP( + processor P { + output stream out; state float result; + init { + match ()YDSP") + selector + R"YDSP() { + 1 => { for i in 0..4 { result = float (i); trace("discarded {i}"); } }, + 2 => { result = 0.5; }, + _ => { result = 0.75; } + } + } + process { out = result; } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP"; + YdspDiagnostics diagnostics; + YdspLexer lexer (source, diagnostics); + YdspParser parser (lexer.tokenize(), diagnostics); + auto program = parser.parseProgram(); + ASSERT_NE (nullptr, program); + YdspSemanticAnalyzer analyzer (diagnostics); + auto analyzed = analyzer.analyze (std::move (program)); + ASSERT_NE (nullptr, analyzed); + YdspOptimizer optimizer (diagnostics); + optimizer.setTracingEnabled (true); + auto ir = optimizer.build (*analyzed); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + bool foundInit = false; + for (const auto& kernel : ir->kernels) + { + if (! kernel->isInit) + continue; + foundInit = true; + EXPECT_TRUE (kernel->loops.empty()); + int stores = 0; + for (size_t index = 0; index < kernel->blocks.size(); ++index) + { + const auto& block = kernel->blocks[index]; + EXPECT_NE (YdspIrTerm::branchIf, block.term); + if (block.term == YdspIrTerm::branch) + EXPECT_EQ (static_cast (index + 1), block.termTarget); + for (const auto& inst : block.insts) + { + EXPECT_NE (YdspIrOp::traceCommit, inst.op); + if (inst.op == YdspIrOp::storeStateF) + ++stores; + } + } + EXPECT_EQ (1, stores); + } + EXPECT_TRUE (foundInit); + } +} diff --git a/tests/yup_dsp_jit/yup_YdspSemanticAnalyzerTests.cpp b/tests/yup_dsp_jit/yup_YdspSemanticAnalyzerTests.cpp new file mode 100644 index 000000000..d25abf2dc --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspSemanticAnalyzerTests.cpp @@ -0,0 +1,4632 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +using namespace yup; + +namespace +{ + +std::unique_ptr analyze (StringRef source, YdspDiagnostics& diagnostics) +{ + YdspLexer lexer (source, diagnostics); + auto tokens = lexer.tokenize(); + + YdspParser parser (std::move (tokens), diagnostics); + auto program = parser.parseProgram(); + + if (program == nullptr) + return nullptr; + + YdspSemanticAnalyzer analyzer (diagnostics); + return analyzer.analyze (std::move (program)); +} + +} // namespace + +//============================================================================== + +TEST (YdspSemanticAnalyzerTests, RejectsConstantOutOfBoundsIndices) +{ + for (const auto* expression : { "table[-1]", "table[2]", "voices[2].level", "voices[0].taps[2]" }) + { + for (const bool write : { false, true }) + { + YdspDiagnostics diagnostics; + const auto source = String (R"YDSP( + processor P { + output stream out; + state float table[2]; + struct Voice { float taps[2]; float level; } + state Voice voices[2]; + process { + )YDSP") + (write ? String (expression) + " = 1.0; out = 0.0;" : "out = " + String (expression) + ";") + + "} } graph G { output stream y; node p = P; connection { p.out -> y; } }"; + analyze (source, diagnostics); + ASSERT_TRUE (diagnostics.hasErrors()) << expression; + EXPECT_TRUE (diagnostics.toString().contains ("out of bounds")) << diagnostics.toString(); + } + } +} + +TEST (YdspSemanticAnalyzerTests, AnalyzesPassThroughGraph) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { input stream in; output stream out; process { out = in; } } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, analyzed); + + EXPECT_EQ (1u, analyzed->processors.size()); + EXPECT_EQ (1u, analyzed->graph.inputStreams.size()); + EXPECT_EQ (1u, analyzed->graph.outputStreams.size()); + ASSERT_EQ (1u, analyzed->graph.nodes.size()); + EXPECT_EQ ("p", analyzed->graph.nodes[0].instanceName); + + ASSERT_EQ (2u, analyzed->graph.edges.size()); + EXPECT_EQ (-1, analyzed->graph.edges[0].srcNode); + EXPECT_EQ (0, analyzed->graph.edges[0].srcStream); + EXPECT_EQ (0, analyzed->graph.edges[0].dstNode); + EXPECT_EQ (-1, analyzed->graph.edges[1].dstNode); + + ASSERT_EQ (1u, analyzed->graph.topoOrder.size()); + EXPECT_EQ (0, analyzed->graph.topoOrder[0]); +} + +TEST (YdspSemanticAnalyzerTests, AnalyzesSampleProcessorWithPrevAndParams) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor OnePole { + input stream in; + output stream out; + input parameter float a = 0.5; + output parameter float level; + state float z; + process { + out = (1 - a) * in + a * out'; + z = 0.999 * z + in; + level = abs (z); + } + } + graph G { input stream x; output stream y; node f = OnePole; connection { x -> f.in; f.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, analyzed); + + const auto& processor = analyzed->processors[0]; + EXPECT_EQ (YdspProcessMode::sample, processor.mode); + EXPECT_EQ (1u, processor.inputValues.size()); + EXPECT_EQ (1u, processor.outputValues.size()); + EXPECT_EQ (1u, processor.states.size()); +} + +TEST (YdspSemanticAnalyzerTests, AnalyzesBlockProcessorWithLoop) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Delay { + input stream in; + output stream out; + state float mem[256]; + state int wp; + process block { + for i in 0..blockSize { + mem[wp] = in[i]; + out[i] = mem[wp]; + wp = wp + 1; + } + } + } + graph G { input stream x; output stream y; node d = Delay; connection { x -> d.in; d.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, analyzed); + + const auto& processor = analyzed->processors[0]; + EXPECT_EQ (YdspProcessMode::block, processor.mode); +} + +TEST (YdspSemanticAnalyzerTests, AnalyzesConstantLoopBound) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Taps { + input stream in; + output stream out; + state float mem[16]; + process block { + for i in 0..4 { out[i] = mem[i]; } + } + } + graph G { input stream x; output stream y; node t = Taps; connection { x -> t.in; t.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, AllowsSameLocalNameInProcessAndInit) +{ + YdspDiagnostics diagnostics; + + // Each top-level body is its own scope: a `float x` in `process` must not + // collide with (or shadow for) a same-named local in `init`. + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { float x = in; out = x; } + init { float x = 2.0; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, BoundsALiteralStateArraySize) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[2000000]; + process { out = in; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_TRUE (diagnostics.hasErrors()); + + bool sawBound = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("must not exceed 1000000")) + sawBound = true; + + EXPECT_TRUE (sawBound) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, BoundsAnExcessiveVoiceCount) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { out = in; } + } + graph G { input stream x; output stream y; node p = P [9999]; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_TRUE (diagnostics.hasErrors()); + + bool sawBound = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("must not exceed 256")) + sawBound = true; + + EXPECT_TRUE (sawBound) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, RejectsAZeroRateFactor) +{ + YdspDiagnostics diagnostics; + + // A *0 / /0 node used to reach `declaredLatencySamples % rateMultiplier` + // with a zero divisor (UB, and a crash in the analyzer); it must be a + // diagnostics error instead. + auto analyzed = analyze (R"YDSP( + processor P { input stream in; output stream out; process { out = in; } } + graph G { input stream x; output stream y; node p = P * 0; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_TRUE (diagnostics.hasErrors()); + + bool sawPositiveInteger = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("must be a positive integer")) + sawPositiveInteger = true; + + EXPECT_TRUE (sawPositiveInteger) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, AnalyzesGraphWithAlgebra) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Gain { input stream in; output stream out; input parameter float g = 1; process { out = in * g; } } + graph Chain { + input stream dry; + output stream wet; + process = dry : Gain (g = 0.5) : wet; + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, analyzed); + + const auto& graph = analyzed->graph; + ASSERT_EQ (1u, graph.nodes.size()); + ASSERT_EQ (1u, graph.nodes[0].paramDefaults.size()); + EXPECT_EQ (YdspValueType::float32Type, graph.nodes[0].paramDefaults[0].type); + EXPECT_EQ (0.5, graph.nodes[0].paramDefaults[0].asDouble); + + // dry -> gain.in, gain.out -> wet + ASSERT_EQ (2u, graph.edges.size()); + EXPECT_EQ (-1, graph.edges[0].srcNode); + EXPECT_EQ (0, graph.edges[0].srcStream); + EXPECT_EQ (0, graph.edges[0].dstNode); + EXPECT_EQ (0, graph.edges[0].dstStream); + EXPECT_EQ (0, graph.edges[1].srcNode); + EXPECT_EQ (-1, graph.edges[1].dstNode); +} + +TEST (YdspSemanticAnalyzerTests, AnalyzesValueAndMeterEdges) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Sat { + input stream in; + output stream out; + input parameter float drive = 1; + output parameter float level; + process { out = tanh (in * drive); level = abs (out); } + } + graph G { + input stream x; + output stream y; + output parameter float meter; + input parameter float master = 0.8; + node sat = Sat; + connection { + x -> sat.in; + sat.out -> y; + master -> sat.drive; + sat.level -> meter; + } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, analyzed); + + const auto& graph = analyzed->graph; + ASSERT_EQ (1u, graph.valueEdges.size()); + EXPECT_EQ (0, graph.valueEdges[0].srcParam); + EXPECT_EQ (0, graph.valueEdges[0].dstNode); + EXPECT_EQ (0, graph.valueEdges[0].dstParam); + + ASSERT_EQ (1u, graph.meterEdges.size()); + EXPECT_EQ (0, graph.meterEdges[0].srcNode); + EXPECT_EQ (0, graph.meterEdges[0].srcMeter); + EXPECT_EQ (0, graph.meterEdges[0].dstMeter); +} + +TEST (YdspSemanticAnalyzerTests, AnalyzesIdentityAlgebra) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + graph G { + input stream x; + output stream y; + process = _; + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, analyzed); + + ASSERT_EQ (1u, analyzed->graph.edges.size()); + EXPECT_EQ (-1, analyzed->graph.edges[0].srcNode); + EXPECT_EQ (0, analyzed->graph.edges[0].srcStream); + EXPECT_EQ (-1, analyzed->graph.edges[0].dstNode); + EXPECT_EQ (0, analyzed->graph.edges[0].dstStream); +} + +//============================================================================== + +TEST (YdspSemanticAnalyzerTests, RejectsUnknownSymbol) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { input stream in; output stream out; process { out = missing; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_EQ (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, RejectsTypeMismatch) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { input stream in; output stream out; process { int x = 1.5; out = x; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsUnboundedLoop) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + let n = 4; + for i in 0..(n * 2) { out[i] = in[i]; } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsParamWriteInSampleMode) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float drive = 1; + process { drive = drive + 1; out = in; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, AllowsParamWriteInBlockMode) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float drive = 1; + process block { drive = drive + 1; for i in 0..blockSize { out[i] = in[i] * drive; } } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsDelayPrimitiveInBlockMode) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..blockSize { out[i] = in[i]'; } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsStreamIndexInSampleMode) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { input stream in; output stream out; process { out[0] = in; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsArityMismatchInAlgebra) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Mix { input stream a; input stream b; output stream out; process { out = a + b; } } + graph G { + input stream dry; + output stream wet; + process = dry : Mix : wet; + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsUnconnectedGraphInput) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { input stream in; output stream out; process { out = in; } } + graph G { + input stream a; + input stream b; + output stream y; + node p = P; + connection { a -> p.in; p.out -> y; } + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsDuplicateSymbol) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + state float z; + state int z; + process { out = in; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsUnknownProcessorInGraph) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + graph G { + input stream x; + output stream y; + node p = Nope; + connection { x -> p.in; p.out -> y; } + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsMultiChannelStream) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in[2]; + output stream out; + process { out = in; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsSingleChannelStream) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in[1]; + output stream out; + process { out = in; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsIntegerArgumentsToMinMaxClampAbsSign) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output stream out; + state int n; + process { + let mn = min (n, 3); + let mx = max (n, -3); + let cl = clamp (n, -10, 10); + let ab = abs (n); + let sg = sign (n); + out = float32 (mn + mx + cl + ab + sg); + } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, RejectsMixedIntAndFloatArgumentsToMin) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output stream out; + state int n; + state float f; + process { out = min (n, f); } + } + graph G { input event midi; output stream y; node p = P; connection { p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsMixedIntAndFloatArgumentsToClamp) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output stream out; + state int n; + state float f; + process { out = clamp (n, 0, f); } + } + graph G { input event midi; output stream y; node p = P; connection { p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, IntegerLiteralArgumentAloneStaysFloat) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output stream out; + process { out = min (3, 5); } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, WarnsOnUnknownEndpointAnnotationKey) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output stream out; + input parameter float cutoff = 100.0 [[ mim: 0.5 ]]; + process { out = cutoff; } + } + graph G { output stream y; node v = P; connection { v.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_NE (nullptr, analyzed); + EXPECT_FALSE (diagnostics.hasErrors()); + + bool foundExpectedWarning = false; + + for (int i = 0; i < diagnostics.getCount(); ++i) + { + const auto& item = diagnostics.getItem (i); + + if (item.severity == YdspSeverity::warning + && item.message.contains ("mim") + && item.message.contains ("name") + && item.message.contains ("style")) + { + foundExpectedWarning = true; + } + } + + EXPECT_TRUE (foundExpectedWarning); +} + +TEST (YdspSemanticAnalyzerTests, DoesNotWarnOnKnownEndpointAnnotationKeys) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output stream out; + input parameter float cutoff = 100.0 [[ name: "Cutoff", min: 0.0, max: 1000.0, unit: "Hz", step: 1.0, style: "knob", mid: 100.0, bipolar: true ]]; + process { out = cutoff; } + } + graph G { output stream y; node v = P; connection { v.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_NE (nullptr, analyzed); + EXPECT_FALSE (diagnostics.hasErrors()); + + for (int i = 0; i < diagnostics.getCount(); ++i) + EXPECT_FALSE (diagnostics.getItem (i).message.contains ("Unknown endpoint annotation")) + << diagnostics.getItem (i).message; +} + +TEST (YdspSemanticAnalyzerTests, RejectsRecursionOperator) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor F { input stream a; input stream b; output stream out; process { out = a + b; } } + graph G { + input stream x; + output stream y; + process = x : F ~ F : y; + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsAssignmentToLet) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { + let t = in; + t = 0; + out = t; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, AnalyzesProcessorWithFunctionCall) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + func scale(x: float) : float { + return x * 2.0; + } + process { out = scale(in); } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, AnalyzesProcessorWithMultiParamFunction) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + func mix(a: float, b: float, t: float) : float { + return a + (b - a) * t; + } + process { out = mix(in, out', 0.5); } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, RejectsMutuallyRecursiveTopLevelFunctions) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + func even (n: int) : int { return odd (n - 1); } + func odd (n: int) : int { return even (n - 1); } + processor P { + input stream in; + output stream out; + process { out = even (1); } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_TRUE (diagnostics.hasErrors()); + + bool sawMutual = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Mutual recursion detected")) + sawMutual = true; + + EXPECT_TRUE (sawMutual) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, RejectsAFunctionArgumentWhoseTypeDoesNotFit) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + func id (x: int) : int { return x; } + process { + float w = in; + out = id (w); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_TRUE (diagnostics.hasErrors()); + + bool sawMismatch = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("parameter 1") && diagnostics.getItem (i).message.contains ("incompatible type")) + { + sawMismatch = true; + EXPECT_TRUE (diagnostics.getItem (i).message.contains ("expected 'int32', got 'float32'")); + EXPECT_EQ (8, diagnostics.getItem (i).range.startLine); + EXPECT_EQ (diagnostics.getItem (i).range.startColumn + 1, diagnostics.getItem (i).range.endColumn); + } + + EXPECT_TRUE (sawMismatch) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsAnImplicitlyConvertibleFunctionArgument) +{ + YdspDiagnostics diagnostics; + + // int32 -> float32 is an implicit widening, so passing an int local to a + // float parameter must not be an error. + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + func scale (x: float) : float { return x * 0.5; } + process { + int n = 1; + out = scale (n); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, AnalyzesProcessorWithMultipleFunctions) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output stream out; + input event midi; + state float freq; + state float phase; + func noteToFreq(pitch: float) : float { + return 440.0 * pow(2.0, (pitch - 69.0) / 12.0); + } + func polyBlep(t: float, dt: float) : float { + float r = 0.0; + if (t < dt) { + r = -1.0; + } else if (t > 1.0 - dt) { + r = 1.0; + } + return r; + } + event midi (e: noteOn) { + freq = noteToFreq(e.pitch); + } + process { + phase = phase + freq / sampleRate; + out = polyBlep(phase, freq / sampleRate); + } + } + graph G { input event midi; output stream y; node p = P[1]; connection { midi -> p.midi; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, RejectsRecursiveFunction) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + func recurse(x: float) : float { + return recurse(x); + } + process { out = in; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, AnalyzesFloat64AndInt64Program) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float64 acc = 0.0; + input parameter int64 counter = 0; + output parameter float64 level; + state float64 z; + process { + float64 d = 0.25; + int64 j = 2; + acc = acc + d; + counter = counter + j; + z = z * 0.999 + float64 (in); + level = abs (z); + out = in; + } + } + graph G { + input stream x; + output stream y; + input parameter float64 gacc = 1.0; + input parameter int64 gcnt = 7; + node p = P; + connection { x -> p.in; p.out -> y; gacc -> p.acc; gcnt -> p.counter; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, analyzed); + + const auto& processor = analyzed->processors[0]; + ASSERT_EQ (2u, processor.inputValues.size()); + EXPECT_EQ (YdspPrimitiveType::float64Type, processor.inputValues[0]->type); + EXPECT_EQ (YdspPrimitiveType::int64Type, processor.inputValues[1]->type); + EXPECT_EQ (YdspPrimitiveType::float64Type, processor.outputValues[0]->type); + EXPECT_EQ (YdspPrimitiveType::float64Type, processor.states[0]->type); + + const auto& graph = analyzed->graph; + ASSERT_EQ (2u, graph.inputValueDefaults.size()); + EXPECT_EQ (YdspValueType::float64Type, graph.inputValueDefaults[0].type); + EXPECT_EQ (1.0, graph.inputValueDefaults[0].asDouble); + EXPECT_EQ (YdspValueType::int64Type, graph.inputValueDefaults[1].type); + EXPECT_EQ (7, graph.inputValueDefaults[1].asInt); + + ASSERT_EQ (1u, graph.nodes.size()); + ASSERT_EQ (2u, graph.nodes[0].paramDefaults.size()); + EXPECT_EQ (YdspValueType::float64Type, graph.nodes[0].paramDefaults[0].type); + EXPECT_EQ (0.0, graph.nodes[0].paramDefaults[0].asDouble); + EXPECT_EQ (YdspValueType::int64Type, graph.nodes[0].paramDefaults[1].type); + EXPECT_EQ (0, graph.nodes[0].paramDefaults[1].asInt); +} + +TEST (YdspSemanticAnalyzerTests, AllowsLiteralAdaptationAcrossWidths) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { + float64 d = 0.9 * 2.0; // float literals adapt to float64 + int64 j = 2 * 3; // int literals adapt to int64 + out = in * (1 - 0.5); // int literal adapts to float32 + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, RejectsMixedWidthBinary) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { + float64 d = 0.5; + out = in * d; // float32 * float64 requires an explicit cast + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Cannot mix 'float32' and 'float64'")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsImplicitIntFloatMix) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { + int i = 2; + out = in * i; // int32 * float32 requires an explicit cast + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsIntCondition) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { + int i = 1; + if (i) { out = in; } else { out = 0; } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsInt64Index) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + state float buf[16]; + process block { + int64 j = 0; + for i in 0..blockSize { buf[int32(j)] = in[i]; out[i] = buf[int32(j)]; } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + + YdspDiagnostics diagnostics2; + auto analyzed2 = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + state float buf[16]; + process block { + int64 j = 0; + for i in 0..blockSize { buf[j] = in[i]; out[i] = buf[j]; } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics2); + + EXPECT_TRUE (diagnostics2.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsNarrowingAssignment) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { + float64 d = 0.5; + float32 f = d; // float64 -> float32 requires an explicit cast + out = f; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, CastsFixStrictViolations) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { + int i = 2; + float64 d = 0.5; + float32 f = float32 (d); // explicit narrowing + out = in * float32 (i); // explicit int -> float32 + float64 acc = float64 (out) + float64 (f); // both float64 + acc = acc + float64 (in); + out = float32 (acc); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, RejectsStreamTypeMismatchInGraph) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { input stream float64 in; output stream out; process { out = float32(in); } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, AnalyzesStructStateAndInit) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + struct Voice { float phase; float buf[8]; int idx; } + Voice mono; + Voice voices[4]; + init { + mono.phase = 0.5; + mono.buf[2] = 1.0; + voices[1].idx = 3; + voices[0].buf[0] = 0.25; + } + process { + mono.idx = (mono.idx + 1) & 7; + mono.buf[mono.idx] = in; + out = mono.phase + mono.buf[(mono.idx - 3) & 7] + float (voices[1].idx); + mono.phase = mono.phase + 0.25; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, RejectsUnknownStructType) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output stream out; + Voice mono; // no struct named Voice + process { out = mono.phase; } + } + graph G { input event midi; output stream y; node p = P; connection { p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Unknown struct type 'Voice'")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsUnknownStructField) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output stream out; + struct Voice { float phase; } + Voice mono; + process { out = mono.volume; } // no such field + } + graph G { input event midi; output stream y; node p = P; connection { p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("has no field 'volume'")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, ResetsStateIndexAcrossProcessors) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor A { + output stream out; + struct Voice { float phase; } + state Voice v; + process { out = v.phase; } + } + processor B { + output stream out; + state int x; + process { out = float (x.bogus); } // x is a plain int state, not struct-typed + } + graph G { output stream y; output stream z; node a = A; node b = B; connection { a.out -> y; b.out -> z; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("'x' is not a struct-typed state")) + found = true; + + EXPECT_TRUE (found) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, RejectsStreamAccessInInit) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + state float gain; + init { gain = in; } // streams are not accessible during init + process { out = in * gain; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Streams are not accessible during init")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsDelayPrimitiveInInit) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output stream out; + state float mem; + init { mem = mem'; } // ' is only allowed in the per-sample body + process { out = mem; } + } + graph G { input event midi; output stream y; node p = P; connection { p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsBareStructStateAccess) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output stream out; + struct Voice { float phase; } + Voice mono; + process { out = mono; } // must use state.field + } + graph G { input event midi; output stream y; node p = P; connection { p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsIndexingScalarStructField) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output stream out; + struct Voice { float phase; } + Voice mono; + process { out = mono.phase[1]; } // scalar field cannot be indexed + } + graph G { input event midi; output stream y; node p = P; connection { p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsIntrinsicOnNonFloat) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { + int i = 2; + out = sin (i); // sin requires float operands + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsBitwiseOpsOnInts) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { + int a = 60; + int64 b = int64(-1); + let c = a & 15; + let d = a | 240; + let ex = a ^ 85; + let f = a << 3; + let g = a >> 2; + let h = ~a; + let i = b & int64(a); + let j = b >> 63; + out = float (int64(c + d + ex + f + g + h) + i + j); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); + ASSERT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, RejectsBitwiseOpsOnFloats) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { + out = in & 3; // float32 & int32 is not allowed + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Bitwise operators require integer operands")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsBitwiseNotOnFloats) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { + out = ~in; // float32 has no bitwise complement + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("'~' requires an integer operand")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsMixedWidthBitwiseOps) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { + int64 x = int64(7); + out = float (x ^ in); // int64 ^ float32 is not allowed + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +//============================================================================== +// Event endpoints, handlers and voice banks + +TEST (YdspSemanticAnalyzerTests, AnalyzesStreamFreeParameterProcessor) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Voice { + output stream out; + input parameter float decay = 0.25; + process { out = decay; } + } + graph G { output stream y; node v = Voice; connection { v.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + ASSERT_EQ (1u, analyzed->graph.nodes.size()); + EXPECT_EQ (1u, analyzed->graph.nodes[0].paramDefaults.size()); +} + +TEST (YdspSemanticAnalyzerTests, AnalyzesEventDrivenProcessor) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Voice { + output stream out; + input parameter float decay = 0.25; + input event midi; + state float freq; + state float env; + event midi (e: noteOn) { + freq = e.pitch; + env = e.velocity * decay; + } + event midi (e: noteOff) { + env = 0.0; + } + process { out = env; } + } + graph G { + input event midi; + output stream y; + node v = Voice[4]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + ASSERT_EQ (1u, analyzed->processors.size()); + + const auto& processor = analyzed->processors[0]; + EXPECT_EQ (1u, processor.inputEvents.size()); + ASSERT_EQ (2u, processor.eventHandlers.size()); + EXPECT_EQ (YdspEventShape::noteOn, processor.eventHandlers[0].shape); + EXPECT_EQ (YdspEventShape::noteOff, processor.eventHandlers[1].shape); + + ASSERT_EQ (1u, analyzed->graph.nodes.size()); + EXPECT_EQ (4, analyzed->graph.nodes[0].voiceCount); + EXPECT_TRUE (analyzed->graph.nodes[0].isEventDriven); +} + +TEST (YdspSemanticAnalyzerTests, ResolvesEventFieldsToFloat32) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Voice { + output stream out; + input event midi; + state float f; + event midi (e: noteOn) { + f = e.pitch + e.velocity; + } + process { out = f; } + } + graph G { input event midi; output stream y; node v = Voice; connection { midi -> v.midi; v.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + EXPECT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsNamedEventInputsAtProcessorScope) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input event midi1; + input event midi2; + process { } + } + graph G { input stream x; output stream y; connection { x -> y; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsSingleMidiInputAtProcessorScope) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input event midi; + process { } + } + graph G { input stream x; output stream y; connection { x -> y; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsDuplicateEventEndpoint) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input event midi; + input event midi; + process { } + } + graph G { output stream y; connection { } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Duplicate event input 'midi'")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsAnUnconnectedNodeEventInput) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input event midi; + output stream out; + event midi (e: noteOn) { } + process { out = 0; } + } + graph G { + input event other; + output stream y; + node p = P; + connection { p.out -> y; } + } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Node 'p' input event 'midi' is not connected: it must be driven by at least one source")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsDuplicateMidiEndpoint) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { process { } } + graph G { + input event midi; + input event midi; + output stream y; + node p = P; + } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Duplicate event input 'midi'")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsSameShapeOnDifferentEventInputs) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input event midi1; + input event midi2; + output stream out; + state float f; + event midi1 (e: noteOn) { f = e.pitch; } + event midi2 (e: noteOn) { f = 0.0; } + process { out = f; } + } + graph G { + input event midi1; + input event midi2; + output stream y; + node p = P; + connection { midi1 -> p.midi1; midi2 -> p.midi2; p.out -> y; } + } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, RejectsHandlerForUnknownEndpoint) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input event midi; + event other (e: noteOn) { } + process { } + } + graph G { output stream y; connection { } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("unknown event endpoint 'other'")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsUnknownShapeInEventHandler) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input event midi; + event midi (e: foo) { } + process { } + } + graph G { output stream y; connection { } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Unknown event shape 'foo'")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsDuplicateShapeHandlerOnSameInput) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input event midi; + event midi (e: noteOn) { } + event midi (f: noteOn) { } + process { } + } + graph G { output stream y; connection { } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Duplicate event handler for shape 'noteOn' on input 'midi'")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsEventFieldAccess) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input event midi; + state float f; + event midi (e: noteOn) { + f = e.bogus; + } + process { } + } + graph G { output stream y; connection { } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Event 'noteOn' has no field 'bogus'") + && diagnostics.getItem (i).message.contains ("'pitch', 'velocity', 'bendSemitones'")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsEveryProcessorScopeEventShape) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Voice { + output stream out; + input event midi; + state float f; + state int i; + event midi (e: noteOn) { f = e.pitch + e.velocity + e.bendSemitones; if (e.isLegato) { f = 0.0; } } + event midi (e: noteOff) { f = e.pitch + e.velocity; } + event midi (e: pitchBend) { f = e.bendSemitones; } + event midi (e: pressure) { f = e.pressure; } + event midi (e: slide) { f = e.slide; } + event midi (e: controlChange) { i = e.control; f = e.value; } + event midi (e: programChange) { i = e.program; } + process { out = f; } + } + graph G { input event midi; output stream y; node v = Voice; connection { midi -> v.midi; v.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + ASSERT_EQ (1u, analyzed->processors.size()); + + const auto& processor = analyzed->processors[0]; + ASSERT_EQ (7u, processor.eventHandlers.size()); + + EXPECT_EQ (YdspEventShape::noteOn, processor.eventHandlers[0].shape); + EXPECT_EQ (YdspEventShape::noteOff, processor.eventHandlers[1].shape); + EXPECT_EQ (YdspEventShape::pitchBend, processor.eventHandlers[2].shape); + EXPECT_EQ (YdspEventShape::pressure, processor.eventHandlers[3].shape); + EXPECT_EQ (YdspEventShape::slide, processor.eventHandlers[4].shape); + EXPECT_EQ (YdspEventShape::controlChange, processor.eventHandlers[5].shape); + EXPECT_EQ (YdspEventShape::programChange, processor.eventHandlers[6].shape); +} + +TEST (YdspSemanticAnalyzerTests, RejectsFieldBelongingToAnotherShape) +{ + struct Case + { + const char* shape; + const char* field; + const char* expectedFields; + }; + + for (const auto& testCase : { Case { "noteOff", "isLegato", "'pitch', 'velocity'" }, + Case { "pitchBend", "pitch", "'bendSemitones'" }, + Case { "controlChange", "pressure", "'control', 'value'" } }) + { + YdspDiagnostics diagnostics; + + analyze ("processor P { output stream out; input event midi; state float f; event midi (e: " + String (testCase.shape) + ") { f = e." + String (testCase.field) + "; } process { out = f; } } graph G { input event midi; output stream y; node p = P; connection { p.out -> y; } }", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains (String ("Event '") + testCase.shape + "' has no field '" + testCase.field + "'") + && diagnostics.getItem (i).message.contains (testCase.expectedFields)) + found = true; + + EXPECT_TRUE (found) << testCase.shape << "." << testCase.field << ": " << diagnostics.toString(); + } +} + +TEST (YdspSemanticAnalyzerTests, ParsesVoiceModeAnnotationsOnNodes) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Voice { + output stream out; + input event midi; + state float f; + event midi (e: noteOn) { f = e.pitch; } + process { out = f; } + } + graph G { + input event midi; + output stream leadOut; + output stream bassOut; + node lead = Voice[4] [[ mode: poly, stealing: newest ]]; + node bass = Voice [[ mode: mono, priority: low ]]; + connection { midi -> lead.midi; midi -> bass.midi; lead.out -> leadOut; bass.out -> bassOut; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + ASSERT_EQ (2u, analyzed->graph.nodes.size()); + + EXPECT_EQ (YdspVoiceMode::poly, analyzed->graph.nodes[0].voiceMode); + EXPECT_EQ (YdspVoiceStealing::newest, analyzed->graph.nodes[0].stealing); + + EXPECT_EQ (YdspVoiceMode::mono, analyzed->graph.nodes[1].voiceMode); + EXPECT_EQ (YdspMonoPriority::low, analyzed->graph.nodes[1].monoPriority); + + // Unannotated defaults must reproduce the previous behaviour exactly. + EXPECT_EQ (YdspVoiceStealing::oldest, analyzed->graph.nodes[1].stealing); + EXPECT_EQ (YdspMonoPriority::last, analyzed->graph.nodes[0].monoPriority); +} + +TEST (YdspSemanticAnalyzerTests, RejectsMonoModeWithAVoiceBank) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor Voice { + output stream out; + input event midi; + state float f; + event midi (e: noteOn) { f = e.pitch; } + process { out = f; } + } + graph G { + input event midi; + output stream y; + node v = Voice[4] [[ mode: mono ]]; + connection { v.out -> y; } + } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("'mode: mono' and must declare exactly one voice")) + found = true; + + EXPECT_TRUE (found) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, RejectsUnknownNodeAnnotations) +{ + struct Case + { + const char* annotation; + const char* expected; + }; + + for (const auto& testCase : { Case { "mode: duo", "Unknown voice mode 'duo'" }, + Case { "stealing: quietest", "Unknown stealing policy 'quietest'" }, + Case { "priority: middle", "Unknown note priority 'middle'" }, + Case { "glide: 5", "Unknown node annotation 'glide'" } }) + { + YdspDiagnostics diagnostics; + + analyze ("processor Voice { output stream out; input event midi; state float f; event midi (e: noteOn) { f = e.pitch; } process { out = f; } } graph G { input event midi; output stream y; node v = Voice [[ " + String (testCase.annotation) + " ]]; connection { v.out -> y; } }", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains (testCase.expected)) + found = true; + + EXPECT_TRUE (found) << testCase.annotation << ": " << diagnostics.toString(); + } +} + +TEST (YdspSemanticAnalyzerTests, RejectsBareEventValue) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input event midi; + state float f; + event midi (e: noteOn) { + f = e; + } + process { } + } + graph G { output stream y; connection { } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("must be accessed via a member")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsAssignmentToEventValue) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input event midi; + event midi (e: noteOn) { + e.pitch = 3.0; + } + process { } + } + graph G { output stream y; connection { } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Cannot assign to the event value")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsDelayPrimitiveInEventHandler) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input event midi; + state float f; + event midi (e: noteOn) { + f = e.pitch' ; + } + process { } + } + graph G { output stream y; connection { } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Delay primitives are not available inside event handlers")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsSmoothInEventHandler) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input event midi; + input parameter float gain = 0.5; + state float f; + event midi (e: noteOn) { + f = smooth (gain, 0.02); + } + process { } + } + graph G { output stream y; connection { } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("smooth() is not available inside event handlers")) + found = true; + + EXPECT_TRUE (found) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, RejectsSmoothInsideLoop) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + output stream out; + input parameter float gain = 0.5; + process { + float acc = 0.0; + for i in 0..4 { acc = acc + smooth (gain, 0.02); } + out = acc; + } + } + graph G { input event midi; output stream y; node p = P; connection { p.out -> y; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("smooth() is only allowed in the per-sample body, outside loops")) + found = true; + + EXPECT_TRUE (found) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, RejectsSmoothInBlockModeBody) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + output stream out; + input parameter float gain = 0.5; + process block { + out[0] = smooth (gain, 0.02); + } + } + graph G { input event midi; output stream y; node p = P; connection { p.out -> y; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("smooth() is only allowed in the per-sample body, outside loops")) + found = true; + + EXPECT_TRUE (found) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, FixedArityDiagnosticNamesSingleArgumentOnce) +{ + YdspDiagnostics diagnostics; + analyze ("processor P { output stream out; process { out = sin (); } } " + "graph G { output stream y; node p = P; connection { p.out -> y; } }", + diagnostics); + ASSERT_TRUE (diagnostics.hasErrors()); + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message == "Function 'sin' expects 1 argument, got 0") + found = true; + EXPECT_TRUE (found) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, RejectsSmoothWithWrongArity) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + output stream out; + input parameter float gain = 0.5; + process { out = smooth (gain); } + } + graph G { input event midi; output stream y; node p = P; connection { p.out -> y; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Function 'smooth' expects") + && diagnostics.getItem (i).message.contains ("got 1")) + found = true; + + EXPECT_TRUE (found) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, RejectsSmoothWithNonFloatOperand) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + output stream out; + input parameter int steps = 4; + process { out = smooth (steps, 0.02); } + } + graph G { input event midi; output stream y; node p = P; connection { p.out -> y; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("smooth() requires float32 operands")) + found = true; + + EXPECT_TRUE (found) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsSmoothingAnnotationOnFloatParameter) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float gain = 0.5 [[ smoothing: 0.02 ]]; + process { out = in * gain; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + ASSERT_FALSE (analyzed->processors.empty()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsSmoothingAnnotationOnOutputValue) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input stream in; + output stream out; + output parameter float meter [[ smoothing: 0.02 ]]; + process { out = in; meter = in; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("[[ smoothing ]] is only valid on an 'input parameter' parameter")) + found = true; + + EXPECT_TRUE (found) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, RejectsSmoothingAnnotationOnInputStream) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input stream in [[ smoothing: 0.02 ]]; + output stream out; + process { out = in; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("[[ smoothing ]] is only valid on an 'input parameter' parameter")) + found = true; + + EXPECT_TRUE (found) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, RejectsSmoothingAnnotationOnNonFloat32Parameter) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter int steps = 4 [[ smoothing: 0.02 ]]; + process { out = in * float (steps); } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("[[ smoothing ]] requires a float32 parameter")) + found = true; + + EXPECT_TRUE (found) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, RejectsSmoothingAnnotationWithNonPositiveTimeConstant) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float gain = 0.5 [[ smoothing: 0.0 ]]; + process { out = in * gain; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("[[ smoothing ]] requires a positive time constant")) + found = true; + + EXPECT_TRUE (found) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, RejectsSmoothingAnnotationInBlockModeProcessor) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float gain = 0.5 [[ smoothing: 0.02 ]]; + process block { + for i in 0..blockSize { out[i] = in[i] * gain; } + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("[[ smoothing ]] requires a per-sample 'process { }' body")) + found = true; + + EXPECT_TRUE (found) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, RejectsSmoothingAnnotationOnGraphEndpoint) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float gain = 0.5; + process { out = in * gain; } + } + graph G { + input stream x; + output stream y; + input parameter float level = 0.5 [[ smoothing: 0.02 ]]; + node p = P; + connection { x -> p.in; p.out -> y; } + } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("[[ smoothing ]] is not available on a graph endpoint")) + found = true; + + EXPECT_TRUE (found) << diagnostics.toString(); +} + +TEST (YdspSemanticAnalyzerTests, SmoothingAnnotationLeavesEventHandlersReadingTheRawTarget) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output stream out; + input event midi; + input parameter float decay = 0.5 [[ smoothing: 0.02 ]]; + state float env; + event midi (e: noteOn) { env = e.velocity * decay; } + process { out = env * decay; } + } + graph G { input event midi; output stream y; node p = P; connection { midi -> p.midi; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + EXPECT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, RejectsStreamAccessInEventHandler) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input stream in; + output stream out; + input event midi; + event midi (e: noteOn) { + out = in; + } + process { out = in; } + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsBlockSizeInEventHandler) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input event midi; + state float f; + event midi (e: noteOn) { + f = blockSize; + } + process { } + } + graph G { output stream y; connection { } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsNonConstantLoopBoundInEventHandler) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input event midi; + state float f; + event midi (e: noteOn) { + for i in 0 .. blockSize { + f = f + 1; + } + } + process { } + } + graph G { output stream y; connection { } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Loop bounds in an event handler must be compile-time constants")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsConstantLoopInEventHandler) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output stream out; + input event midi; + state float f; + event midi (e: noteOn) { + for i in 0 .. 16 { + f = f + 1; + } + } + process { out = 0; } + } + graph G { + input event midi; + output stream y; + node p = P; + connection { midi -> p.midi; p.out -> y; } + } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + EXPECT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, RejectsVoiceBankOnNonEventProcessor) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + output stream out; + process { out = 0; } + } + graph G { + output stream y; + node v = P[8]; + connection { v.out -> y; } + } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("declares a voice bank ([N]) but processor 'P' has no event input")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsVoiceBankWithOversampling) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor Voice { + output stream out; + input event midi; + event midi (e: noteOn) { } + process { out = 0; } + } + graph G { + output stream y; + node v = Voice[4] * 2; + connection { v.out -> y; } + } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("An event-driven node cannot use oversampling/undersampling")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsZeroVoiceCount) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor Voice { + output stream out; + input event midi; + process { out = 0; } + } + graph G { + output stream y; + node v = Voice[0]; + connection { v.out -> y; } + } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("must be a positive integer")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsEventParamShadowing) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input event midi; + state float freq; + event midi (freq: noteOn) { + freq = 1.0; + } + process { } + } + graph G { output stream y; connection { } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("shadows an existing symbol")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsEventDrivenProcessorWithInputStreamButNoOutputStream) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input stream in; + input event midi; + event midi (e: noteOn) { } + process { } + } + graph G { + input stream x; + output stream y; + node p = P; + connection { x -> p.in; x -> y; } + } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("must declare exactly one output stream")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsEventDrivenProcessorWithNoStreamsAtAll) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input event midi; + output event noteOn; + event midi (e: noteOn) { + emit noteOn (pitch: e.pitch, velocity: e.velocity) -> noteOn; + } + process { } + } + graph G { + input event midi; + output event noteOn; + node p = P; + connection { midi -> p.midi; p.noteOn -> noteOn; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + EXPECT_TRUE (analyzed->graph.nodes[0].isEventDriven); +} + +//============================================================================== +// Program-scope `let` constants + +TEST (YdspSemanticAnalyzerTests, AcceptsTopLevelLetAsArraySizeLoopBoundAndValue) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + let taps = 8; + let gain = 0.5; + + processor P { + input stream in; + output stream out; + state float buf[taps]; + process { + float sum = 0.0; + for i in 0..taps { sum = sum + buf[i]; } + buf[0] = in; + out = sum * gain; + } + } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + ASSERT_EQ (1u, analyzed->processors.size()); + ASSERT_EQ (1u, analyzed->processors[0].states.size()); + EXPECT_EQ (8, analyzed->processors[0].states[0]->arraySize); +} + +TEST (YdspSemanticAnalyzerTests, FoldsLetDefinedFromEarlierConstants) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + let base = 4; + let taps = base * 2 + 1; + + processor P { + input stream in; + output stream out; + state float buf[taps]; + process { buf[0] = in; out = buf[0]; } + } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + EXPECT_EQ (9, analyzed->processors[0].states[0]->arraySize); +} + +TEST (YdspSemanticAnalyzerTests, RejectsNonConstantLet) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + let bad = sampleRate; + processor P { input stream in; output stream out; process { out = in; } } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("not a compile-time constant")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsDeclarationShadowingAProgramConstant) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + let taps = 8; + processor P { + input stream in; + output stream out; + state float taps; + process { out = in; } + } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("redeclares the program constant")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsUnknownConstantAsArraySize) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input stream in; + output stream out; + state float buf[nope]; + process { out = in; } + } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Unknown program constant")) + found = true; + + EXPECT_TRUE (found); +} + +//============================================================================== +// State initialisers + +TEST (YdspSemanticAnalyzerTests, LowersStateInitialisersIntoTheInitBlock) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + state float feedback = 0.5; + state float table[4] = { 1.0, 2.0 }; + process { out = in * feedback + table[0]; } + } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + const auto* decl = analyzed->processors[0].decl; + ASSERT_NE (nullptr, decl->init); + EXPECT_EQ (3u, decl->init->body.size()); +} + +TEST (YdspSemanticAnalyzerTests, StateInitialisersRunBeforeAnExplicitInitBlock) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + state float feedback = 0.5; + init { feedback = feedback * 2.0; } + process { out = in * feedback; } + } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + const auto* decl = analyzed->processors[0].decl; + ASSERT_NE (nullptr, decl->init); + ASSERT_EQ (2u, decl->init->body.size()); + + ASSERT_NE (nullptr, decl->init->body[0]->value); + EXPECT_EQ (YdspExprKind::floatLiteral, decl->init->body[0]->value->kind); +} + +TEST (YdspSemanticAnalyzerTests, RejectsTooManyStateInitialisers) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input stream in; + output stream out; + state float table[2] = { 1.0, 2.0, 3.0 }; + process { out = in + table[0]; } + } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("but holds only")) + found = true; + + EXPECT_TRUE (found); +} + +//============================================================================== +// State array size inference (`[]`) and the compile-time `size ()` intrinsic + +TEST (YdspSemanticAnalyzerTests, InfersStateArraySizeFromTheInitialiserListAndExposesItViaSize) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + state float melodyPitch[] = { 440.0, 554.37, 659.25, 880.0 }; + process { + float sum = 0.0; + for i in 0..size (melodyPitch) { sum = sum + melodyPitch[i]; } + out = in + sum; + } + } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + ASSERT_EQ (1u, analyzed->processors.size()); + ASSERT_EQ (1u, analyzed->processors[0].states.size()); + EXPECT_EQ (4, analyzed->processors[0].states[0]->arraySize); + + // The four values become four `melodyPitch[i] = ...` statements in the + // synthesized init kernel. + ASSERT_NE (nullptr, analyzed->processors[0].decl->init); + EXPECT_EQ (4u, analyzed->processors[0].decl->init->body.size()); +} + +TEST (YdspSemanticAnalyzerTests, SizeIntrinsicWorksOnExplicitlySizedAndConstantSizedArrays) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + let taps = 3; + processor P { + input stream in; + output stream out; + state float direct[8]; + state float named[taps]; + process { + float acc = 0.0; + for i in 0..size (direct) { acc = acc + direct[i]; } + for j in 0..size (named) { acc = acc + named[j]; } + out = in + acc; + } + } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + EXPECT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, SizeIntrinsicIsAConstantUsableInAnEventHandler) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output stream out; + input event midi; + state float table[] = { 1.0, 0.5, 0.25 }; + event midi (e: noteOn) { + for i in 0..size (table) { table[i] = table[i] * e.velocity; } + } + process { out = table[0]; } + } + graph G { input event midi; output stream b; node p = P; connection { midi -> p.midi; p.out -> b; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + EXPECT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, SizeIntrinsicWorksOnStructArrayFields) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + struct Comb { float buf[64]; int wp; } + input stream in; + output stream out; + state Comb comb; + state Comb combs[4]; + process { + float acc = 0.0; + for i in 0..size (comb.buf) { acc = acc + comb.buf[i]; } + for j in 0..size (combs[0].buf) { acc = acc + combs[0].buf[j]; } + comb.wp = size (comb.buf); + out = in + acc; + } + } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + EXPECT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, SizeIntrinsicOnABlockModeStreamResolvesToBlockSize) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..size (in) { out[i] = in[i]; } + } + } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + EXPECT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, RejectsUnsizedArrayStateWithNoInitialiserList) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input stream in; + output stream out; + state float a[]; + process { out = in; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("declares an array without a size")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsUnsizedArrayStateWithAScalarInitialiser) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input stream in; + output stream out; + state float a[] = 5.0; + process { out = in; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("declares an array without a size")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsEmptyInitialiserListForAnUnsizedArrayState) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input stream in; + output stream out; + state float a[] = {}; + process { out = in; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("declares an array without a size")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, RejectsUnsizedStructArrayState) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + struct Comb { float buf[2]; int wp; } + input stream in; + output stream out; + state Comb combs[]; + process { out = in; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("must state its size explicitly")) + found = true; + + EXPECT_TRUE (found); +} + +//============================================================================== +// samplePeriod and [[ init: ... ]] + +TEST (YdspSemanticAnalyzerTests, AcceptsSamplePeriodBuiltin) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + state float phase; + input event midi; + event midi (e: noteOn) { phase = samplePeriod; } + process { phase = phase + 100.0 * samplePeriod; out = in * phase; } + } + graph G { input event midi; input stream a; output stream b; node p = P; connection { midi -> p.midi; a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + EXPECT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, InitAnnotationSuppliesTheParameterDefault) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { input stream in; output stream out; process { out = in; } } + graph G { + input stream a; + output stream b; + input parameter float rate [[ name: "Rate", min: 0.5, max: 12.0, init: 4 ]]; + input parameter float depth = 0.25 [[ init: 9 ]]; + node p = P; + connection { a -> p.in; p.out -> b; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + ASSERT_EQ (2u, analyzed->graph.inputValueDefaults.size()); + + EXPECT_DOUBLE_EQ (4.0, analyzed->graph.inputValueDefaults[0].asDouble); + EXPECT_DOUBLE_EQ (0.25, analyzed->graph.inputValueDefaults[1].asDouble); +} + +//============================================================================== +// Loop-variable scoping + +TEST (YdspSemanticAnalyzerTests, SiblingLoopsMayReuseTheSameVariableName) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + state float a[4]; + state float b[4]; + process { + for i in 0..4 { a[i] = in; } + for i in 0..4 { b[i] = a[i]; } + out = b[0]; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + EXPECT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, RejectsLoopVariableUseAfterTheLoop) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input stream in; + output stream out; + state float a[4]; + process { + for i in 0..4 { a[i] = in; } + out = a[i]; + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Unknown symbol 'i'")) + found = true; + + EXPECT_TRUE (found); +} + +//============================================================================== + +TEST (YdspSemanticAnalyzerTests, RejectsANestedBlockRedeclaringAnOuterLocal) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { + let x = 1.0; + { let x = 2.0; } + out = in * x; + } + } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Duplicate symbol 'x'")) + found = true; + + EXPECT_TRUE (found); +} + +TEST (YdspSemanticAnalyzerTests, SiblingIfElseBranchesMayReuseTheSameLocalName) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { + if (in > 0.0) { let x = 1.0; out = in + x; } + else { let x = -1.0; out = in + x; } + } + } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + EXPECT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, SiblingIfElseBranchesMayDeclareDifferentTypesForTheSameName) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { + if (in > 0.0) { let x = 1.0; out = in + x; } + else { let x = 1; out = in + float (x); } + } + } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + EXPECT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, SiblingPlainBlocksMayReuseTheSameLocalName) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { + { let x = 1.0; out = in + x; } + { let x = 2.0; out = in - x; } + } + } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + EXPECT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, RejectsUseOfABlockScopedLocalAfterItsBlockEnds) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + input stream in; + output stream out; + process { + if (in > 0.0) { let x = 1.0; out = x; } + out = x; + } + } + graph G { input stream a; output stream b; node p = P; connection { a -> p.in; p.out -> b; } } + )YDSP", + diagnostics); + + bool found = false; + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains ("Unknown symbol 'x'")) + found = true; + + EXPECT_TRUE (found); +} + +//============================================================================== +// The [[ role: voiceActivity ]] state annotation. + +namespace +{ + +/** A voice processor with `activityDecl` spliced in as its only extra state. */ +String voiceActivitySource (StringRef activityDecl) +{ + return String (R"YDSP( + processor V { + output stream out; + input event midi; + state float env; + )YDSP") + + activityDecl + + R"YDSP( + event midi (e: noteOn) { env = e.velocity; } + event midi (e: noteOff) { env = 0.0; } + process { out = env; } + } + graph G { input event midi; output stream y; node v = V[4]; connection { midi -> v.midi; v.out -> y; } } + )YDSP"; +} + +/** Returns true if any diagnostic message contains `fragment`. */ +bool anyDiagnosticContains (const YdspDiagnostics& diagnostics, StringRef fragment) +{ + for (int i = 0; i < diagnostics.getCount(); ++i) + if (diagnostics.getItem (i).message.contains (fragment)) + return true; + + return false; +} + +} // namespace + +TEST (YdspSemanticAnalyzerTests, RecordsVoiceActivityState) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (voiceActivitySource ("state int active [[ role: voiceActivity ]];"), diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + ASSERT_EQ (1u, analyzed->processors.size()); + + const auto* activityState = analyzed->processors[0].activityState; + ASSERT_NE (nullptr, activityState); + EXPECT_EQ ("active", activityState->name); + EXPECT_EQ (YdspPrimitiveType::int32Type, activityState->type); +} + +TEST (YdspSemanticAnalyzerTests, LeavesVoiceActivityUnsetWithoutTheAnnotation) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (voiceActivitySource ("state int active;"), diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + ASSERT_EQ (1u, analyzed->processors.size()); + + EXPECT_EQ (nullptr, analyzed->processors[0].activityState); +} + +TEST (YdspSemanticAnalyzerTests, RejectsUnknownStateRole) +{ + YdspDiagnostics diagnostics; + + analyze (voiceActivitySource ("state int active [[ role: whatever ]];"), diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "Unknown state role")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsUnknownStateAnnotationKey) +{ + YdspDiagnostics diagnostics; + + analyze (voiceActivitySource ("state int active [[ rol: voiceActivity ]];"), diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "Unknown state annotation")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsTwoVoiceActivityStates) +{ + YdspDiagnostics diagnostics; + + analyze (voiceActivitySource ("state int active [[ role: voiceActivity ]];\n" + " state int alsoActive [[ role: voiceActivity ]];"), + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "more than one 'voiceActivity' state")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsNonIntVoiceActivityState) +{ + YdspDiagnostics diagnostics; + + analyze (voiceActivitySource ("state float active [[ role: voiceActivity ]];"), diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "must be a scalar 'int'")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsBoolVoiceActivityState) +{ + YdspDiagnostics diagnostics; + + analyze (voiceActivitySource ("state bool active [[ role: voiceActivity ]];"), diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "must be a scalar 'int'")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsArrayVoiceActivityState) +{ + YdspDiagnostics diagnostics; + + analyze (voiceActivitySource ("state int active[4] [[ role: voiceActivity ]];"), diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "must be a scalar 'int'")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsInitialisedVoiceActivityState) +{ + YdspDiagnostics diagnostics; + + analyze (voiceActivitySource ("state int active = 1 [[ role: voiceActivity ]];"), diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "must not have an initialiser")); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsVoiceActivityStateWithoutEventHandlers) +{ + YdspDiagnostics diagnostics; + + // The flag is not restricted to event-driven processors: a pure effect + // (filter, delay, reverb) may declare it too, so a banked voice chain can + // sleep a voice only once every member reports silence. + auto analyzed = analyze (R"YDSP( + processor P { + input stream in; + output stream out; + state int active [[ role: voiceActivity ]]; + process { out = in; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + ASSERT_EQ (1u, analyzed->processors.size()); + + const auto* activityState = analyzed->processors[0].activityState; + ASSERT_NE (nullptr, activityState); + EXPECT_EQ ("active", activityState->name); +} + +//============================================================================== +// Rate changes + +TEST (YdspSemanticAnalyzerTests, AcceptsUndersamplingOnANode) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Sat { input stream in; output stream out; process { out = in * 0.5; } } + graph G { input stream x; output stream y; node s = Sat / 2; connection { x -> s.in; s.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + ASSERT_EQ (1u, analyzed->graph.nodes.size()); + EXPECT_EQ (2, analyzed->graph.nodes[0].rateDivider); + EXPECT_EQ (ydspOversamplerLatencySamples * 2 + 1, analyzed->graph.latencySamples); +} + +TEST (YdspSemanticAnalyzerTests, RejectsUndersamplingOnANonFloat32Stream) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor Sat { input stream float64 in; output stream float64 out; process { out = in * 0.5; } } + graph G { input stream float64 x; output stream float64 y; node s = Sat / 4; connection { x -> s.in; s.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "a rate change (*N or /N) is only supported on float32 streams")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsOversamplingOnANonFloat32Stream) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor Sat { input stream float64 in; output stream float64 out; process { out = in * 0.5; } } + graph G { input stream float64 x; output stream float64 y; node s = Sat * 4; connection { x -> s.in; s.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "a rate change (*N or /N) is only supported on float32 streams")); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "declares stream 'in' as float64")); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsOversamplingOnFloat32Streams) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Sat { input stream in; output stream out; process { out = in * 0.5; } } + graph G { input stream x; output stream y; node s = Sat * 4; connection { x -> s.in; s.out -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + ASSERT_EQ (1u, analyzed->graph.nodes.size()); + EXPECT_EQ (4, analyzed->graph.nodes[0].rateMultiplier); +} + +//============================================================================== +// Feedback cycles + +TEST (YdspSemanticAnalyzerTests, RejectsAFeedbackCycleWithoutOfferingADelayAsTheFix) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor Fork { input stream a; input stream b; output stream c; output stream d; process { c = a + b; d = a - b; } } + processor Pass { input stream in; output stream out; process { out = in; } } + graph G { + input stream x; + output stream y; + node f = Fork; + node p = Pass; + connection { x -> f.a; p.out -> f.b; f.c -> p.in; f.d -> y; } + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "feedback cycle, which is not supported in this version")); + EXPECT_FALSE (anyDiagnosticContains (diagnostics, "cycle without a delay")); +} + +//============================================================================== +// Algebra-form shapes that used to read past the end of a port vector + +TEST (YdspSemanticAnalyzerTests, RejectsSequencingPastAGraphOutputStream) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor Gain { input stream in; output stream out; process { out = in * 2; } } + graph G { input stream dry; output stream wet; process = dry : wet : Gain; } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "left side of ':' has no output to connect from")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsSequencingIntoAGraphInputStream) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor Gain { input stream in; output stream out; process { out = in * 2; } } + graph G { input stream dry; output stream wet; process = Gain : dry; } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "right side of ':' has no input to connect to")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsIdentityMixedWithAProcessorInsideParallel) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor Gain { input stream in; output stream out; process { out = in * 2; } } + graph G { + input stream a; + input stream b; + output stream c; + output stream d; + process = (a , b) : (_ , Gain) : (c , d); + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "'_' cannot be combined with another operand inside ','")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsIdentityOnEitherSideOfParallel) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor Gain { input stream in; output stream out; process { out = in * 2; } } + graph G { + input stream a; + input stream b; + output stream c; + output stream d; + process = (a , b) : (Gain , _) : (c , d); + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "'_' cannot be combined with another operand inside ','")); +} + +TEST (YdspSemanticAnalyzerTests, StillAcceptsIdentityOnBothSidesOfParallel) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Gain { input stream in; output stream out; process { out = in * 2; } } + graph G { + input stream a; + input stream b; + output stream c; + output stream d; + process = (a , b) : (_ , _) : (Gain , Gain) : (c , d); + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + EXPECT_EQ (4u, analyzed->graph.edges.size()); +} + +//============================================================================== +// Fan-out, summing fan-in, and the zero-use rejections that replaced "connected exactly once" + +namespace +{ + +constexpr const char* fanGainProcessor = + "processor Gain { input stream in; output stream out; input parameter float g = 1.0; process { out = in * g; } }\n" + "processor Mix { input stream a; input stream b; output stream out; process { out = a + b; } }\n"; + +std::unique_ptr analyzeFan (StringRef source, YdspDiagnostics& diagnostics) +{ + return analyze (String (fanGainProcessor) + source, diagnostics); +} + +} // namespace + +TEST (YdspSemanticAnalyzerTests, AcceptsFanOutFromAGraphInput) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyzeFan (R"YDSP( + graph G { + input stream x; + output stream y; + node a = Gain (g = 2.0); + node b = Gain (g = 3.0); + node m = Mix; + connection { x -> a.in; x -> b.in; a.out -> m.a; b.out -> m.b; m.out -> y; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + int fromInput = 0; + + for (const auto& edge : analyzed->graph.edges) + if (edge.srcNode == -1 && edge.srcStream == 0) + ++fromInput; + + EXPECT_EQ (2, fromInput); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsFanOutFromANodeOutput) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyzeFan (R"YDSP( + graph G { + input stream x; + output stream y; + output stream tap; + node a = Gain (g = 2.0); + node b = Gain (g = 3.0); + connection { x -> a.in; a.out -> b.in; a.out -> tap; b.out -> y; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + int fromA = 0; + + for (const auto& edge : analyzed->graph.edges) + if (edge.srcNode == 0 && edge.srcStream == 0) + ++fromA; + + EXPECT_EQ (2, fromA); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsSummingFanInIntoANodeInput) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyzeFan (R"YDSP( + graph G { + input stream x; + input stream w; + output stream y; + node a = Gain (g = 1.0); + connection { x -> a.in; w -> a.in; a.out -> y; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + int intoA = 0; + + for (const auto& edge : analyzed->graph.edges) + if (edge.dstNode == 0 && edge.dstStream == 0) + ++intoA; + + EXPECT_EQ (2, intoA); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsSummingFanInIntoAGraphOutput) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyzeFan (R"YDSP( + graph G { + input stream x; + output stream y; + node a = Gain (g = 2.0); + node b = Gain (g = 3.0); + connection { x -> a.in; x -> b.in; a.out -> y; b.out -> y; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + int intoY = 0; + + for (const auto& edge : analyzed->graph.edges) + if (edge.dstNode == -1 && edge.dstStream == 0) + ++intoY; + + EXPECT_EQ (2, intoY); +} + +TEST (YdspSemanticAnalyzerTests, RejectsAnUnconnectedGraphInput) +{ + YdspDiagnostics diagnostics; + + analyzeFan (R"YDSP( + graph G { + input stream x; + input stream unused; + output stream y; + node a = Gain (g = 1.0); + connection { x -> a.in; a.out -> y; } + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "Graph input 'unused' is not connected: it must feed at least one destination")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsAnUnconnectedGraphOutput) +{ + YdspDiagnostics diagnostics; + + analyzeFan (R"YDSP( + graph G { + input stream x; + output stream y; + output stream silent; + node a = Gain (g = 1.0); + connection { x -> a.in; a.out -> y; } + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "Graph output 'silent' is not connected: it must be driven by at least one source")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsAnUnconnectedNodeInput) +{ + YdspDiagnostics diagnostics; + + analyzeFan (R"YDSP( + graph G { + input stream x; + output stream y; + node m = Mix; + connection { x -> m.a; m.out -> y; } + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "Node 'm' input 'b' is not connected: it must be driven by at least one source")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsAnUnconnectedNodeOutput) +{ + YdspDiagnostics diagnostics; + + analyzeFan (R"YDSP( + graph G { + input stream x; + output stream y; + node a = Gain (g = 1.0); + node dangling = Gain (g = 1.0); + connection { x -> a.in; a.out -> y; x -> dangling.in; } + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "Node 'dangling' output 'out' is not connected: it must feed at least one destination")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsSummingFanInOnANonFloatStream) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor Bump { input stream int32 in; output stream int32 out; process { out = in + 1; } } + graph G { + input stream int32 x; + input stream int32 w; + output stream int32 y; + node a = Bump; + connection { x -> a.in; w -> a.in; a.out -> y; } + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "is driven by 2 sources, but implicit summing is only supported on float32 and float64 streams (this one is int32)")); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsFanOutOnANonFloatStream) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Bump { input stream int32 in; output stream int32 out; process { out = in + 1; } } + graph G { + input stream int32 x; + output stream int32 y; + output stream int32 z; + node a = Bump; + node b = Bump; + connection { x -> a.in; x -> b.in; a.out -> y; b.out -> z; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + EXPECT_EQ (4u, analyzed->graph.edges.size()); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsSummingFanInOnAFloat64Stream) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Wide { input stream float64 in; output stream float64 out; process { out = in * 2.0; } } + graph G { + input stream float64 x; + output stream float64 y; + node a = Wide; + node b = Wide; + connection { x -> a.in; x -> b.in; a.out -> y; b.out -> y; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + EXPECT_EQ (4u, analyzed->graph.edges.size()); +} + +//============================================================================== +// The split and merge algebra operators + +namespace +{ + +constexpr const char* splitMergeProcessors = + "processor Gain { input stream in; output stream out; input parameter float g = 1.0; process { out = in * g; } }\n" + "processor Fork { input stream in; output stream a; output stream b; process { a = in; b = in * 0.5; } }\n" + "processor Pair { input stream a; input stream b; output stream out; process { out = a + b; } }\n"; + +std::unique_ptr analyzeSplitMerge (StringRef source, YdspDiagnostics& diagnostics) +{ + return analyze (String (splitMergeProcessors) + source, diagnostics); +} + +int countEdgesFromGraphInput (const YdspAnalyzedGraph& graph, int index) +{ + int count = 0; + + for (const auto& edge : graph.edges) + if (edge.srcNode == -1 && edge.srcStream == index) + ++count; + + return count; +} + +int countEdgesIntoGraphOutput (const YdspAnalyzedGraph& graph, int index) +{ + int count = 0; + + for (const auto& edge : graph.edges) + if (edge.dstNode == -1 && edge.dstStream == index) + ++count; + + return count; +} + +} // namespace + +TEST (YdspSemanticAnalyzerTests, SplitsAndMergesAroundAParallelPair) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyzeSplitMerge (R"YDSP( + graph G { + input stream dry; + output stream wet; + process = dry <: (Gain (g = 2.0) , Gain (g = 3.0)) :> wet; + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + EXPECT_EQ (4u, analyzed->graph.edges.size()); + EXPECT_EQ (2, countEdgesFromGraphInput (analyzed->graph, 0)); + EXPECT_EQ (2, countEdgesIntoGraphOutput (analyzed->graph, 0)); +} + +TEST (YdspSemanticAnalyzerTests, SplitRepeatsTheSourceChannelsCyclically) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyzeSplitMerge (R"YDSP( + graph G { + input stream x; + output stream y; + process = x : Fork <: (Pair , Pair) :> y; + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + EXPECT_EQ (7u, analyzed->graph.edges.size()); + EXPECT_EQ (2, countEdgesIntoGraphOutput (analyzed->graph, 0)); + + int fromForkA = 0; + int fromForkB = 0; + + for (const auto& edge : analyzed->graph.edges) + { + if (edge.srcNode < 0) + continue; + + if (analyzed->graph.nodes[static_cast (edge.srcNode)].targetName() != "Fork") + continue; + + if (edge.srcStream == 0) + ++fromForkA; + else + ++fromForkB; + } + + EXPECT_EQ (2, fromForkA); + EXPECT_EQ (2, fromForkB); +} + +TEST (YdspSemanticAnalyzerTests, RejectsANonDividingSplitArity) +{ + YdspDiagnostics diagnostics; + + analyzeSplitMerge (R"YDSP( + graph G { + input stream x; + output stream y; + process = x : Fork <: (Gain , Gain , Gain) :> y; + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "Arity mismatch in '<:' : the left side has 2 outputs, which does not divide the right side's 3 inputs")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsANonDividingMergeArity) +{ + YdspDiagnostics diagnostics; + + analyzeSplitMerge (R"YDSP( + graph G { + input stream x; + output stream y; + process = x <: (Gain , Gain , Gain) :> Pair : y; + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "Arity mismatch in ':>' : the right side has 2 inputs, which does not divide the left side's 3 outputs")); +} + +TEST (YdspSemanticAnalyzerTests, IdentityDefaultsToArityOneOnTheSplitSide) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyzeSplitMerge (R"YDSP( + graph G { + input stream dry; + output stream wet; + process = dry : _ <: (Gain (g = 2.0) , Gain (g = 3.0)) :> wet; + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + EXPECT_EQ (4u, analyzed->graph.edges.size()); + EXPECT_EQ (2, countEdgesFromGraphInput (analyzed->graph, 0)); + EXPECT_EQ (2, countEdgesIntoGraphOutput (analyzed->graph, 0)); +} + +TEST (YdspSemanticAnalyzerTests, IdentityDefaultsToArityOneOnTheMergeSide) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyzeSplitMerge (R"YDSP( + graph G { + input stream dry; + output stream wet; + process = dry <: (Gain (g = 2.0) , Gain (g = 3.0)) :> _ : wet; + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + EXPECT_EQ (4u, analyzed->graph.edges.size()); + EXPECT_EQ (2, countEdgesFromGraphInput (analyzed->graph, 0)); + EXPECT_EQ (2, countEdgesIntoGraphOutput (analyzed->graph, 0)); +} + +TEST (YdspSemanticAnalyzerTests, SequentialCompositionStillRequiresEqualArities) +{ + YdspDiagnostics diagnostics; + + analyzeSplitMerge (R"YDSP( + graph G { + input stream x; + output stream y; + process = x : Pair : y; + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "Arity mismatch in ':' : the left side has 1 outputs but the right side has 2 inputs")); +} + +//============================================================================== +// output event endpoints and the emit statement + +TEST (YdspSemanticAnalyzerTests, AcceptsOutputEventDeclarationMatchingShapeName) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output event noteOn; + process { } + } + graph G { input stream x; output stream y; connection { x -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + ASSERT_EQ (1u, analyzed->processors[0].outputEvents.size()); + EXPECT_EQ ("noteOn", analyzed->processors[0].outputEvents[0]->name); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsOutputEventDeclarationWithAnArbitraryChannelName) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output event notAShapeName; + process { emit noteOn (pitch: 60, velocity: 0.8) -> notAShapeName; } + } + graph G { input stream x; output stream y; connection { x -> y; } } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + ASSERT_EQ (1u, analyzed->processors[0].outputEvents.size()); + EXPECT_EQ ("notAShapeName", analyzed->processors[0].outputEvents[0]->name); +} + +TEST (YdspSemanticAnalyzerTests, RegistersOutputEventAtGraphScope) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { output event noteOn; process { emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; } } + graph G { + input stream x; + output stream y; + output event noteOn; + node p = P; + connection { x -> y; p.noteOn -> noteOn; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + ASSERT_EQ (1u, analyzed->graph.outputEvents.size()); + EXPECT_EQ ("noteOn", analyzed->graph.outputEvents[0]->name); +} + +TEST (YdspSemanticAnalyzerTests, RejectsDuplicateOutputEventAtGraphScope) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + graph G { + input stream x; + output stream y; + output event noteOn; + output event noteOn; + connection { x -> y; } + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "Duplicate event output 'noteOn'")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsEmitWithUnknownShape) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + output event noteOn; + process { emit bogus () -> noteOn; } + } + graph G { input stream x; output stream y; connection { x -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "Unknown event shape 'bogus'")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsEmitWithUnknownField) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + output event noteOn; + process { emit noteOn (bogus: 1.0) -> noteOn; } + } + graph G { input stream x; output stream y; connection { x -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "has no field 'bogus'")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsEmitToUnknownTarget) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + output event noteOn; + process { emit noteOn (pitch: 60, velocity: 0.8) -> nope; } + } + graph G { input stream x; output stream y; connection { x -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "Unknown emit target 'nope'")); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsEmitOfAnyShapeToAnEventChannelNamedAfterADifferentShape) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output event noteOn; + output event noteOff; + process { emit noteOn (pitch: 60, velocity: 0.8) -> noteOff; } + } + graph G { input stream x; output stream y; connection { x -> y; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, RejectsEmitInInit) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + output event noteOn; + init { emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; } + process { } + } + graph G { input stream x; output stream y; connection { x -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "emit is not allowed in init or block-mode process")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsEmitInBlockModeProcess) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor P { + output event noteOn; + process block { emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; } + } + graph G { input stream x; output stream y; connection { x -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "emit is not allowed in init or block-mode process")); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsEmitInsideForLoopInSampleModeProcess) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + output event noteOn; + process { + for i in 0..4 { + emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; + } + } + } + graph G { input stream x; output stream y; connection { x -> y; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsEmitInsideEventHandler) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input event midi; + output event noteOn; + event midi (e: noteOn) { + emit noteOn (pitch: e.pitch, velocity: e.velocity) -> noteOn; + } + process { } + } + graph G { input stream x; output stream y; connection { x -> y; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); +} + +TEST (YdspSemanticAnalyzerTests, AcceptsEmitInsideEventHandlerOnBlockModeProcessor) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor P { + input event midi; + output event noteOn; + event midi (e: noteOn) { + emit noteOn (pitch: e.pitch, velocity: e.velocity) -> noteOn; + } + process block { } + } + graph G { input stream x; output stream y; connection { x -> y; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); +} + +//============================================================================== +// event edges: node -> node, node -> graph boundary, and connectivity + +TEST (YdspSemanticAnalyzerTests, ResolvesNodeToNodeEventConnectionForASpecificShape) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Source { output event noteOn; process { emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; } } + processor Sink { input event noteOn; output stream out; process { out = 0.0; } } + graph G { + output stream y; + node src = Source; + node snk = Sink; + connection { src.noteOn -> snk.noteOn; snk.out -> y; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + ASSERT_EQ (1u, analyzed->graph.eventEdges.size()); + const auto& edge = analyzed->graph.eventEdges[0]; + EXPECT_EQ (0, edge.srcNode); + EXPECT_EQ (0, edge.srcEndpoint); + EXPECT_EQ (1, edge.dstNode); + EXPECT_EQ (0, edge.dstEndpoint); +} + +TEST (YdspSemanticAnalyzerTests, ResolvesNodeToNodeEventConnectionTargetingTheMidiPolymorphicEndpoint) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Source { output event pitchBend; process { emit pitchBend (bendSemitones: 2.0) -> pitchBend; } } + processor Sink { input event midi; output stream out; process { out = 0.0; } } + graph G { + output stream y; + node src = Source; + node snk = Sink; + connection { src.pitchBend -> snk.midi; snk.out -> y; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + ASSERT_EQ (1u, analyzed->graph.eventEdges.size()); +} + +TEST (YdspSemanticAnalyzerTests, ResolvesAnEventConnectionAgainstASpecificInputEventNamedAfterADifferentShape) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Source { output event noteOn; process { emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; } } + processor Sink { input event noteOff; output stream out; process { out = 0.0; } } + graph G { + output stream y; + node src = Source; + node snk = Sink; + connection { src.noteOn -> snk.noteOff; snk.out -> y; } + } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + ASSERT_EQ (1u, analyzed->graph.eventEdges.size()); +} + +TEST (YdspSemanticAnalyzerTests, RejectsAnInlineDelayOnAnEventConnection) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor Source { output event noteOn; process { emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; } } + processor Sink { input event midi; output stream out; process { out = 0.0; } } + graph G { + input event midi; + output stream y; + node src = Source; + node snk = Sink; + connection { src.noteOn -> [4] -> snk.midi; snk.out -> y; } + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "An inline delay is not supported on an event connection")); +} + +TEST (YdspSemanticAnalyzerTests, ResolvesNodeToGraphBoundaryOutputEventConnection) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Source { output event noteOn; process { emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; } } + graph G { + output event noteOn; + node src = Source; + connection { src.noteOn -> noteOn; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + ASSERT_EQ (1u, analyzed->graph.eventEdges.size()); + const auto& edge = analyzed->graph.eventEdges[0]; + EXPECT_EQ (0, edge.srcNode); + EXPECT_EQ (0, edge.srcEndpoint); + EXPECT_EQ (-1, edge.dstNode); + EXPECT_EQ (0, edge.dstEndpoint); +} + +TEST (YdspSemanticAnalyzerTests, RejectsAnUnconnectedNodeOutputEvent) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor Source { output event noteOn; process { emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; } } + graph G { input stream x; output stream y; node src = Source; connection { x -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "Node 'src' output event 'noteOn' is not connected: it must feed at least one destination")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsAnUnconnectedGraphOutputEvent) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + graph G { input stream x; output stream y; output event noteOn; connection { x -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "Graph output event 'noteOn' is not connected: it must be driven by at least one source")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsAnUnconnectedInputEventEndpoint) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor Sink { input event midi; output stream out; process { out = 0.0; } } + graph G { input event midi; output stream y; node snk = Sink; connection { snk.out -> y; } } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "Graph input event 'midi' is not connected: it must feed at least one destination")); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "Node 'snk' input event 'midi' is not connected: it must be driven by at least one source")); +} + +//============================================================================== +// event edges feed the same topological sort as audio edges + +TEST (YdspSemanticAnalyzerTests, RejectsAPureEventFeedbackCycle) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor A { input event midi; output event noteOn; state float f; event midi (e: noteOn) { f = e.pitch; } process { } } + processor B { input event midi; output event noteOn; state float f; event midi (e: noteOn) { f = e.pitch; } process { } } + graph G { + node a = A; + node b = B; + connection { a.noteOn -> b.midi; b.noteOn -> a.midi; } + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "feedback cycle, which is not supported in this version")); +} + +TEST (YdspSemanticAnalyzerTests, RejectsAMixedAudioAndEventFeedbackCycle) +{ + YdspDiagnostics diagnostics; + + analyze (R"YDSP( + processor Fork { input stream a; output stream c; output event noteOn; process { c = a; emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; } } + processor Pass { input event midi; output stream out; state float f; event midi (e: noteOn) { f = e.pitch; } process { out = f; } } + graph G { + output stream y; + node f = Fork; + node p = Pass; + connection { p.out -> f.a; f.noteOn -> p.midi; f.c -> y; } + } + )YDSP", + diagnostics); + + EXPECT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (anyDiagnosticContains (diagnostics, "feedback cycle, which is not supported in this version")); +} + +TEST (YdspSemanticAnalyzerTests, TopoSortsAnAcyclicEventOnlyGraphWithNoAudio) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Arp { output event noteOn; process { emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; } } + processor Voice { input event midi; process { } } + graph G { + node arp = Arp; + node voice = Voice; + connection { arp.noteOn -> voice.midi; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + ASSERT_EQ (2u, analyzed->graph.topoOrder.size()); + EXPECT_EQ (0, analyzed->graph.topoOrder[0]); + EXPECT_EQ (1, analyzed->graph.topoOrder[1]); +} + +//============================================================================== +// event edge latency compensation + +TEST (YdspSemanticAnalyzerTests, CompensatesANodeToNodeEventEdgeByTheSourcesDeclaredLatency) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Source [[ latency: 8 ]] { output event noteOn; process { emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; } } + processor Sink { input event noteOn; output stream out; process { out = 0.0; } } + graph G { + output stream y; + node src = Source; + node snk = Sink; + connection { src.noteOn -> snk.noteOn; snk.out -> y; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + ASSERT_EQ (1u, analyzed->graph.eventEdges.size()); + EXPECT_EQ (8, analyzed->graph.eventEdges[0].compensationSamples); +} + +TEST (YdspSemanticAnalyzerTests, CompensatesANodeToGraphBoundaryEventEdgeByTheGraphsOverallLatency) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Source [[ latency: 8 ]] { output event noteOn; process { emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; } } + processor Slow [[ latency: 64 ]] { input stream in; output stream out; process { out = in; } } + graph G { + input stream x; + output stream y; + output event noteOn; + node src = Source; + node slow = Slow; + connection { src.noteOn -> noteOn; x -> slow.in; slow.out -> y; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + ASSERT_EQ (64, analyzed->graph.latencySamples); + + ASSERT_EQ (1u, analyzed->graph.eventEdges.size()); + EXPECT_EQ (64, analyzed->graph.eventEdges[0].compensationSamples); +} + +TEST (YdspSemanticAnalyzerTests, LeavesAnEventEdgeWithNoLatencyAnywhereUncompensated) +{ + YdspDiagnostics diagnostics; + + auto analyzed = analyze (R"YDSP( + processor Source { output event noteOn; process { emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; } } + processor Sink { input event noteOn; output stream out; process { out = 0.0; } } + graph G { + output stream y; + node src = Source; + node snk = Sink; + connection { src.noteOn -> snk.noteOn; snk.out -> y; } + } + )YDSP", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, analyzed); + + ASSERT_EQ (1u, analyzed->graph.eventEdges.size()); + EXPECT_EQ (0, analyzed->graph.eventEdges[0].compensationSamples); +} diff --git a/tests/yup_dsp_jit/yup_YdspSmokeTests.cpp b/tests/yup_dsp_jit/yup_YdspSmokeTests.cpp new file mode 100644 index 000000000..9189a3955 --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspSmokeTests.cpp @@ -0,0 +1,51 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +using namespace yup; + +//============================================================================== + +TEST (YdspJitModuleTests, ModuleIsAvailable) +{ + EXPECT_TRUE (YUP_MODULE_AVAILABLE_yup_dsp_jit); + +#if ! YUP_WASM + EXPECT_TRUE (YUP_MODULE_AVAILABLE_asmjit_library); +#endif +} + +TEST (YdspJitModuleTests, HostArchitectureIsSupported) +{ +#if YUP_WASM + // The wasm backend emits portable wasm bytes that run on the browser's + // native WebAssembly engine, so there is no host-architecture requirement. + EXPECT_TRUE (true); +#else + // asmjit must be able to target the host so JIT code can be emitted; + // x86-64 and AArch64 are the two backends of the yup_dsp_jit module. + const auto arch = asmjit::Environment::host().arch(); + EXPECT_TRUE (arch == asmjit::Arch::kX64 || arch == asmjit::Arch::kAArch64); +#endif +} diff --git a/tests/yup_dsp_jit/yup_YdspSubgraphTests.cpp b/tests/yup_dsp_jit/yup_YdspSubgraphTests.cpp new file mode 100644 index 000000000..5baa885e4 --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspSubgraphTests.cpp @@ -0,0 +1,928 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include + +using namespace yup; + +namespace +{ + +//============================================================================== +constexpr const char* subgraphGainProcessor = + "processor Gain { input stream in; output stream out; input parameter float g = 2.0; process { out = in * g; } }\n"; + +YdspAudioGraph subgraphCompile (StringRef source, YdspCompiler& compiler) +{ + auto result = compiler.compile (String (subgraphGainProcessor) + source); + EXPECT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + if (! result.wasOk()) + return {}; + + return std::move (result).getValue(); +} + +String subgraphCompileError (StringRef source) +{ + YdspCompiler compiler; + auto result = compiler.compile (String (subgraphGainProcessor) + source); + + EXPECT_FALSE (result.wasOk()); + + return compiler.getDiagnostics().toString(); +} + +std::vector subgraphRun (YdspAudioGraph& graph, std::vector input) +{ + std::vector output (input.size(), 0.0f); + + std::vector inputBuffers { YdspInputBuffer (Span (input.data(), input.size())) }; + std::vector outputBuffers { YdspOutputBuffer (Span (output.data(), output.size())) }; + + graph.process (yup::YdspProcessRequest { inputBuffers, outputBuffers, static_cast (input.size()) }); + + return output; +} + +const std::vector subgraphInput { 1.0f, 2.0f, -0.5f, 0.25f, 4.0f, 0.0f, -3.0f, 1.5f }; + +} // namespace + +//============================================================================== + +TEST (YdspSubgraphTests, InlinesASubgraphUnderTheParentNodeNamePrefix) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + graph Sub { + input stream in; + output stream out; + node a = Gain (g = 3.0); + connection { in -> a.in; a.out -> out; } + } + graph Main [[ main ]] { + input stream x; + output stream y; + node s = Sub; + connection { x -> s.in; s.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + EXPECT_TRUE (graph.hasParameter ("s.a.g")); + EXPECT_FALSE (graph.hasParameter ("a.g")); + EXPECT_NEAR (3.0f, graph.getParameter ("s.a.g"), 1e-6f); + + const auto output = subgraphRun (graph, subgraphInput); + + for (size_t i = 0; i < subgraphInput.size(); ++i) + EXPECT_NEAR (subgraphInput[i] * 3.0f, output[i], 1e-5f); +} + +TEST (YdspSubgraphTests, SingleGraphNeedsNoMainAnnotation) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + graph Only { + input stream x; + output stream y; + node a = Gain; + connection { x -> a.in; a.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto output = subgraphRun (graph, subgraphInput); + + for (size_t i = 0; i < subgraphInput.size(); ++i) + EXPECT_NEAR (subgraphInput[i] * 2.0f, output[i], 1e-5f); +} + +TEST (YdspSubgraphTests, RejectsSeveralGraphsWithNoMainAnnotation) +{ + const auto message = subgraphCompileError (R"YDSP( + graph A { input stream x; output stream y; node a = Gain; connection { x -> a.in; a.out -> y; } } + graph B { input stream x; output stream y; node b = Gain; connection { x -> b.in; b.out -> y; } } + )YDSP"); + + EXPECT_TRUE (message.contains ("none is annotated")); + EXPECT_TRUE (message.contains ("'A'")); + EXPECT_TRUE (message.contains ("'B'")); +} + +TEST (YdspSubgraphTests, RejectsTwoMainAnnotations) +{ + const auto message = subgraphCompileError (R"YDSP( + graph A [[ main ]] { input stream x; output stream y; node a = Gain; connection { x -> a.in; a.out -> y; } } + graph B [[ main ]] { input stream x; output stream y; node b = Gain; connection { x -> b.in; b.out -> y; } } + )YDSP"); + + EXPECT_TRUE (message.contains ("Only one graph can be annotated")); +} + +TEST (YdspSubgraphTests, NodeOverrideSetsTheInnerNodeDefault) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + graph Sub { + input stream in; + output stream out; + input parameter float amount = 3.0; + node a = Gain (g = 99.0); + connection { in -> a.in; a.out -> out; amount -> a.g; } + } + graph Main [[ main ]] { + input stream x; + output stream y; + node s = Sub (amount = 5.0); + connection { x -> s.in; s.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + EXPECT_NEAR (5.0f, graph.getParameter ("s.a.g"), 1e-6f); + + const auto output = subgraphRun (graph, subgraphInput); + + for (size_t i = 0; i < subgraphInput.size(); ++i) + EXPECT_NEAR (subgraphInput[i] * 5.0f, output[i], 1e-5f); +} + +TEST (YdspSubgraphTests, InstantiatesTheSameSubgraphTwiceIndependently) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + graph Sub { + input stream in; + output stream out; + input parameter float amount = 3.0; + node a = Gain; + connection { in -> a.in; a.out -> out; amount -> a.g; } + } + graph Main [[ main ]] { + input stream x; + output stream y; + node s1 = Sub (amount = 2.0); + node s2 = Sub (amount = 5.0); + connection { x -> s1.in; s1.out -> s2.in; s2.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + EXPECT_NEAR (2.0f, graph.getParameter ("s1.a.g"), 1e-6f); + EXPECT_NEAR (5.0f, graph.getParameter ("s2.a.g"), 1e-6f); + + const auto output = subgraphRun (graph, subgraphInput); + + for (size_t i = 0; i < subgraphInput.size(); ++i) + EXPECT_NEAR (subgraphInput[i] * 10.0f, output[i], 1e-4f); +} + +TEST (YdspSubgraphTests, ParentGraphParameterAliasesThroughASubgraph) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + graph Sub { + input stream in; + output stream out; + input parameter float amount = 3.0; + node a = Gain; + connection { in -> a.in; a.out -> out; amount -> a.g; } + } + graph Main [[ main ]] { + input stream x; + output stream y; + input parameter float master = 4.0 [[ name: "Master", min: 0.0, max: 10.0 ]]; + node s = Sub; + connection { x -> s.in; s.out -> y; master -> s.amount; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + ASSERT_TRUE (graph.hasParameter ("master")); + ASSERT_TRUE (graph.hasParameter ("s.a.g")); + EXPECT_EQ (graph.getParameterSlot ("master"), graph.getParameterSlot ("s.a.g")); + EXPECT_NEAR (4.0f, graph.getParameter ("master"), 1e-6f); + + graph.setParameter ("master", 6.0f); + + const auto output = subgraphRun (graph, subgraphInput); + + for (size_t i = 0; i < subgraphInput.size(); ++i) + EXPECT_NEAR (subgraphInput[i] * 6.0f, output[i], 1e-5f); +} + +TEST (YdspSubgraphTests, SubgraphParameterDrivesSeveralInnerNodes) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + graph Sub { + input stream in; + output stream out; + input parameter float amount = 1.0; + node a = Gain; + node b = Gain; + connection { in -> a.in; a.out -> b.in; b.out -> out; amount -> a.g; amount -> b.g; } + } + graph Main [[ main ]] { + input stream x; + output stream y; + input parameter float master = 2.0; + node s = Sub; + connection { x -> s.in; s.out -> y; master -> s.amount; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + EXPECT_EQ (graph.getParameterSlot ("master"), graph.getParameterSlot ("s.a.g")); + EXPECT_EQ (graph.getParameterSlot ("master"), graph.getParameterSlot ("s.b.g")); + + graph.setParameter ("master", 3.0f); + + const auto output = subgraphRun (graph, subgraphInput); + + for (size_t i = 0; i < subgraphInput.size(); ++i) + EXPECT_NEAR (subgraphInput[i] * 9.0f, output[i], 1e-4f); +} + +TEST (YdspSubgraphTests, MeterAliasesOutThroughASubgraph) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + processor Peak { + input stream in; + output stream out; + output parameter float peak; + process { out = in; peak = max (peak, abs (in)); } + } + graph Sub { + input stream in; + output stream out; + output parameter float level; + node p = Peak; + connection { in -> p.in; p.out -> out; p.peak -> level; } + } + graph Main [[ main ]] { + input stream x; + output stream y; + output parameter float meter; + node s = Sub; + connection { x -> s.in; s.out -> y; s.level -> meter; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + subgraphRun (graph, subgraphInput); + + EXPECT_NEAR (4.0f, graph.getOutputValue ("meter"), 1e-5f); +} + +TEST (YdspSubgraphTests, NestsTwoLevelsDeep) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + graph Inner { + input stream in; + output stream out; + node a = Gain (g = 3.0); + connection { in -> a.in; a.out -> out; } + } + graph Middle { + input stream in; + output stream out; + node i = Inner; + node b = Gain (g = 5.0); + connection { in -> i.in; i.out -> b.in; b.out -> out; } + } + graph Main [[ main ]] { + input stream x; + output stream y; + node m = Middle; + connection { x -> m.in; m.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + EXPECT_TRUE (graph.hasParameter ("m.i.a.g")); + EXPECT_TRUE (graph.hasParameter ("m.b.g")); + + const auto output = subgraphRun (graph, subgraphInput); + + for (size_t i = 0; i < subgraphInput.size(); ++i) + EXPECT_NEAR (subgraphInput[i] * 15.0f, output[i], 1e-4f); +} + +TEST (YdspSubgraphTests, AccumulatesInlineDelaysAcrossTheBoundary) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + graph Sub { + input stream in; + output stream out; + node a = Gain (g = 1.0); + connection { in -> [3] -> a.in; a.out -> out; } + } + graph Main [[ main ]] { + input stream x; + output stream y; + node s = Sub; + connection { x -> [2] -> s.in; s.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto output = subgraphRun (graph, subgraphInput); + + for (size_t i = 0; i < subgraphInput.size(); ++i) + EXPECT_NEAR (i >= 5 ? subgraphInput[i - 5] : 0.0f, output[i], 1e-5f); +} + +TEST (YdspSubgraphTests, ResolvesAGraphLeafInTheAlgebraForm) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + graph Sub { + input stream in; + output stream out; + node a = Gain (g = 3.0); + connection { in -> a.in; a.out -> out; } + } + graph Main [[ main ]] { + input stream x; + output stream y; + node s = Sub; + node d = Gain (g = 5.0); + process = x : s : d : y; + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + EXPECT_TRUE (graph.hasParameter ("s.a.g")); + + const auto output = subgraphRun (graph, subgraphInput); + + for (size_t i = 0; i < subgraphInput.size(); ++i) + EXPECT_NEAR (subgraphInput[i] * 15.0f, output[i], 1e-4f); +} + +TEST (YdspSubgraphTests, ReportsAGraphInstantiationCycle) +{ + const auto message = subgraphCompileError (R"YDSP( + graph A [[ main ]] { input stream x; output stream y; node b = B; connection { x -> b.in; b.out -> y; } } + graph B { input stream in; output stream out; node a = A; connection { in -> a.x; a.y -> out; } } + )YDSP"); + + EXPECT_TRUE (message.contains ("instantiates itself")); + EXPECT_TRUE (message.contains ("A -> B -> A")); +} + +TEST (YdspSubgraphTests, AllowsAVoiceBankOfAGraphWithAnEventInput) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + processor PitchVoice { + output stream out; + input event midi; + state float s; + event midi (e: noteOn) { s = e.pitch; } + process { out = s; } + } + graph VoiceChain { + input event midi; + output stream out; + node osc = PitchVoice; + node gain = Gain (g = 2.0); + connection { midi -> osc.midi; osc.out -> gain.in; gain.out -> out; } + } + graph Main [[ main ]] { + input event midi; + output stream y; + node v = VoiceChain[4]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + EXPECT_TRUE (graph.hasParameter ("v.gain.g")); +} + +TEST (YdspSubgraphTests, RejectsAVoiceBankOfAGraphWithoutAnEventInput) +{ + const auto message = subgraphCompileError (R"YDSP( + graph Sub { input stream in; output stream out; node a = Gain; connection { in -> a.in; a.out -> out; } } + graph Main [[ main ]] { input stream x; output stream y; node s = Sub[4]; connection { x -> s.in; s.out -> y; } } + )YDSP"); + + EXPECT_TRUE (message.contains ("voice bank")); + EXPECT_TRUE (message.contains ("must declare an input event")); +} + +TEST (YdspSubgraphTests, RejectsMonoModeOnAVoiceBankOfAGraph) +{ + const auto message = subgraphCompileError (R"YDSP( + processor PitchVoice { + output stream out; + input event midi; + state float s; + event midi (e: noteOn) { s = e.pitch; } + process { out = s; } + } + graph VoiceChain { + input event midi; + output stream out; + node osc = PitchVoice; + node gain = Gain (g = 2.0); + connection { midi -> osc.midi; osc.out -> gain.in; gain.out -> out; } + } + graph Main [[ main ]] { + input event midi; + output stream y; + node v = VoiceChain[4] [[ mode: mono ]]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP"); + + EXPECT_TRUE (message.contains ("'mode: mono'")); +} + +TEST (YdspSubgraphTests, RejectsNestedVoiceBanks) +{ + const auto message = subgraphCompileError (R"YDSP( + processor PitchVoice { + output stream out; + input event midi; + state float s; + event midi (e: noteOn) { s = e.pitch; } + process { out = s; } + } + graph Inner { + input event midi; + output stream out; + node osc = PitchVoice; + node gain = Gain (g = 2.0); + connection { midi -> osc.midi; osc.out -> gain.in; gain.out -> out; } + } + graph Outer { + input event midi; + output stream out; + node osc = PitchVoice; + node inner = Inner[4]; + node tail = Gain; + connection { midi -> osc.midi; midi -> inner.midi; osc.out -> tail.in; inner.out -> tail.in; tail.out -> out; } + } + graph Main [[ main ]] { + input event midi; + output stream y; + node v = Outer[2]; + connection { midi -> v.midi; v.out -> y; } + } + )YDSP"); + + EXPECT_TRUE (message.contains ("nested voice banks")); +} + +TEST (YdspSubgraphTests, RejectsOversamplingAGraph) +{ + const auto message = subgraphCompileError (R"YDSP( + graph Sub { input stream in; output stream out; node a = Gain; connection { in -> a.in; a.out -> out; } } + graph Main [[ main ]] { input stream x; output stream y; node s = Sub * 4; connection { x -> s.in; s.out -> y; } } + )YDSP"); + + EXPECT_TRUE (message.contains ("oversampling")); +} + +TEST (YdspSubgraphTests, RejectsAGraphWithAnEventInputUsedAsANode) +{ + const auto message = subgraphCompileError (R"YDSP( + processor Tap { input event midi; process { } } + graph Sub { input event midi; input stream in; output stream out; node a = Gain; node t = Tap; connection { in -> a.in; a.out -> out; midi -> t.midi; } } + graph Main [[ main ]] { input stream x; output stream y; node s = Sub; connection { x -> s.in; s.out -> y; } } + )YDSP"); + + EXPECT_TRUE (message.contains ("event input")); + EXPECT_TRUE (message.contains ("main graph")); +} + +TEST (YdspSubgraphTests, ImportsAGraphUnderItsAlias) +{ + const auto tempDir = File::getSpecialLocation (File::tempDirectory) + .getChildFile ("yup_ydsp_subgraph_test"); + + tempDir.deleteRecursively(); + tempDir.getChildFile ("fx").createDirectory(); + + tempDir.getChildFile ("fx/Boost.ydsp") + .replaceWithText ("processor Boost { input stream in; output stream out; input parameter float g = 2.0; process { out = in * g; } }\n"); + + tempDir.getChildFile ("fx/Chain.ydsp") + .replaceWithText (R"YDSP( + import Boost as b; + graph Chain [[ main ]] { + input stream in; + output stream out; + input parameter float amount = 3.0; + node first = b.Boost; + node second = b.Boost (g = 2.0); + connection { in -> first.in; first.out -> second.in; second.out -> out; amount -> first.g; } + } + )YDSP"); + + const auto patch = R"YDSP( + import fx.Chain as fx; + + graph Patch { + input stream x; + output stream y; + node master = fx.Chain (amount = 4.0); + connection { x -> master.in; master.out -> y; } + } + )YDSP"; + + YdspCompiler compiler; + auto result = compiler.compile (patch, tempDir.getChildFile ("Patch.ydsp").getFullPathName()); + ASSERT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + auto graph = std::move (result).getValue(); + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + EXPECT_TRUE (graph.hasParameter ("master.first.g")); + EXPECT_TRUE (graph.hasParameter ("master.second.g")); + EXPECT_NEAR (4.0f, graph.getParameter ("master.first.g"), 1e-6f); + + const auto output = subgraphRun (graph, subgraphInput); + + for (size_t i = 0; i < subgraphInput.size(); ++i) + EXPECT_NEAR (subgraphInput[i] * 8.0f, output[i], 1e-4f); + + tempDir.deleteRecursively(); +} + +//============================================================================== + +TEST (YdspSubgraphTests, SplicesFanOutAndFanInInsideASubgraph) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + graph Sub { + input stream in; + output stream out; + node dry = Gain (g = 2.0); + node wet = Gain (g = 5.0); + connection { in -> dry.in; in -> wet.in; dry.out -> out; wet.out -> out; } + } + graph Main [[ main ]] { + input stream x; + output stream y; + node s = Sub; + connection { x -> s.in; s.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto output = subgraphRun (graph, subgraphInput); + + for (size_t i = 0; i < subgraphInput.size(); ++i) + EXPECT_NEAR (subgraphInput[i] * 7.0f, output[i], 1e-5f) << "sample " << i; +} + +TEST (YdspSubgraphTests, SplicesSeveralParentSourcesIntoASubgraphInput) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + graph Sub { + input stream in; + output stream out; + node a = Gain (g = 1.0); + connection { in -> a.in; a.out -> out; } + } + graph Main [[ main ]] { + input stream x; + output stream y; + node p = Gain (g = 2.0); + node q = Gain (g = 3.0); + node s = Sub; + connection { x -> p.in; x -> q.in; p.out -> s.in; q.out -> s.in; s.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto output = subgraphRun (graph, subgraphInput); + + for (size_t i = 0; i < subgraphInput.size(); ++i) + EXPECT_NEAR (subgraphInput[i] * 5.0f, output[i], 1e-5f) << "sample " << i; +} + +TEST (YdspSubgraphTests, SplicesASubgraphOutputReadFromSeveralPlaces) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + graph Sub { + input stream in; + output stream out; + node a = Gain (g = 2.0); + connection { in -> a.in; a.out -> out; } + } + graph Main [[ main ]] { + input stream x; + output stream y; + node p = Gain (g = 1.0); + node q = Gain (g = 4.0); + node s = Sub; + connection { x -> s.in; s.out -> p.in; s.out -> q.in; p.out -> y; q.out -> y; } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto output = subgraphRun (graph, subgraphInput); + + for (size_t i = 0; i < subgraphInput.size(); ++i) + EXPECT_NEAR (subgraphInput[i] * 10.0f, output[i], 1e-4f) << "sample " << i; +} + +TEST (YdspSubgraphTests, SplicesAPassThroughSubgraphFannedOnBothSides) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + graph Wire { + input stream in; + output stream out; + connection { in -> out; } + } + graph Main [[ main ]] { + input stream x; + output stream y; + node p = Gain (g = 1.0); + node q = Gain (g = 2.0); + node r = Gain (g = 4.0); + node t = Gain (g = 8.0); + node w = Wire; + connection { + x -> p.in; + x -> q.in; + p.out -> w.in; + q.out -> w.in; + w.out -> r.in; + w.out -> t.in; + r.out -> y; + t.out -> y; + } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto output = subgraphRun (graph, subgraphInput); + + for (size_t i = 0; i < subgraphInput.size(); ++i) + EXPECT_NEAR (subgraphInput[i] * 36.0f, output[i], 1e-3f) << "sample " << i; +} + +TEST (YdspSubgraphTests, AccumulatesInlineDelaysAcrossAFannedBoundary) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + graph Sub { + input stream in; + output stream out; + node a = Gain (g = 1.0); + connection { in -> [3] -> a.in; a.out -> out; } + } + graph Main [[ main ]] { + input stream x; + output stream y; + node p = Gain (g = 1.0); + node q = Gain (g = 10.0); + node s = Sub; + connection { + x -> p.in; + x -> q.in; + p.out -> [2] -> s.in; + q.out -> [5] -> s.in; + s.out -> y; + } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 16); + + const std::vector impulse { 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f }; + + const auto output = subgraphRun (graph, impulse); + + for (size_t i = 0; i < impulse.size(); ++i) + { + const auto expected = i == 5 ? 1.0f : (i == 8 ? 10.0f : 0.0f); + EXPECT_NEAR (expected, output[i], 1e-5f) << "sample " << i; + } +} + +//============================================================================== +// Event edges across a subgraph boundary +// +// There is no dedicated test here for an event wire with both ends fully +// inside a subgraph that is itself used as a node ("no boundary crossing"): +// it cannot be expressed in valid YDSP. Any processor declaring `input event +// X` unconditionally requires its *direct* containing graph to also declare +// a matching `input event X` (yup_YdspSemanticGraph.cpp, the node-event-input +// resolution loop guarded by `if (processor != nullptr)`), and separately, +// any graph that declares an `input event` is unconditionally rejected the +// moment it is used as a node (the same pre-existing check this task's brief +// says not to touch). A subgraph housing both ends of an internal event wire +// would need the first rule to force it into declaring an `input event`, and +// the second rule then forbids that same subgraph from ever being +// instantiated as a node - so the scenario is unreachable through the +// compiler's public surface. The splice code's `edge.dstNode >= 0` branch +// (copy + remap both ends by `+base`) is exercised structurally by the same +// mechanism the streams/meters splices already rely on, but has no reachable +// integration-test surface of its own given these two independent rules. + +TEST (YdspSubgraphTests, RoutesASubgraphsOutputEventOntoAParentNodeAfterCompaction) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + processor Arp { + input stream trig; + output event noteOn; + process { if (trig > 0.5) { emit noteOn (pitch: 72, velocity: 0.8) -> noteOn; } } + } + processor Voice { + input event midi; + output stream out; + state float sounding; + event midi (e: noteOn) { sounding = e.pitch; } + process { out = sounding; } + } + processor Silence { + output stream out; + process { out = 0.0; } + } + graph Sub { + input stream trig; + output event noteOn; + node arp = Arp; + connection { trig -> arp.trig; arp.noteOn -> noteOn; } + } + graph Main [[ main ]] { + input stream trig; + output stream y; + node pad1 = Silence; + node pad2 = Silence; + node s = Sub; + node pad3 = Silence; + node voice = Voice; + connection { + trig -> s.trig; + s.noteOn -> voice.midi; + pad1.out -> y; + pad2.out -> y; + pad3.out -> y; + voice.out -> y; + } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto output = subgraphRun (graph, subgraphInput); + + for (size_t i = 0; i < subgraphInput.size(); ++i) + EXPECT_NEAR (72.0f, output[i], 1e-5f) << "sample " << i; +} + +TEST (YdspSubgraphTests, FansASubgraphsOutputEventToTwoParentDestinations) +{ + YdspCompiler compiler; + + auto graph = subgraphCompile (R"YDSP( + processor Arp { + input stream trig; + output event noteOn; + process { if (trig > 0.5) { emit noteOn (pitch: 72, velocity: 0.8) -> noteOn; } } + } + processor Voice { + input event midi; + output stream out; + state float sounding; + event midi (e: noteOn) { sounding = e.pitch; } + process { out = sounding; } + } + graph Sub { + input stream trig; + output event noteOn; + node arp = Arp; + connection { trig -> arp.trig; arp.noteOn -> noteOn; } + } + graph Main [[ main ]] { + input stream trig; + output stream y; + node s = Sub; + node voiceA = Voice; + node voiceB = Voice; + connection { + trig -> s.trig; + s.noteOn -> voiceA.midi; + s.noteOn -> voiceB.midi; + voiceA.out -> y; + voiceB.out -> y; + } + } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto output = subgraphRun (graph, subgraphInput); + + for (size_t i = 0; i < subgraphInput.size(); ++i) + EXPECT_NEAR (144.0f, output[i], 1e-4f) << "sample " << i; +} diff --git a/tests/yup_dsp_jit/yup_YdspTestPatches.h b/tests/yup_dsp_jit/yup_YdspTestPatches.h new file mode 100644 index 000000000..f963beacb --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspTestPatches.h @@ -0,0 +1,443 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#include +#include + +/** Full YDSP patch sources shared by more than one test file. + + Anything long enough that inlining it twice would be a maintenance hazard + lives here, so the desktop (asmjit) and wasm test suites exercise the exact + same source text. Short single-purpose snippets stay inline in the test that + uses them. + + This header is unity-built alongside every other test file, so the helpers + below live in namespace yup::test::patches to avoid clashing with + identically-named locals in the files that include it. +*/ + +namespace yup::test::patches +{ + +/** Compiles `source` with a fresh compiler, failing the current test via + EXPECT_TRUE if it does not compile. Shared by the graph, wasm and + benchmark test suites, which were each carrying a byte-for-byte identical + copy of this function. +*/ +inline YdspAudioGraph compilePatch (StringRef source, YdspCompiler& compiler) +{ + auto result = compiler.compile (source); + EXPECT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + if (! result.wasOk()) + return {}; + + return std::move (result).getValue(); +} + +inline YdspAudioGraph compilePatch (StringRef source, YdspCompiler& compiler, const YdspCompileOptions& options) +{ + auto result = compiler.compile (source, options); + EXPECT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + if (! result.wasOk()) + return {}; + return std::move (result).getValue(); +} + +/** Returns a ramp of `size` samples starting at `start` and increasing by + 0.01 per sample. Shared by the graph and wasm test suites. +*/ +inline std::vector makeRamp (int size, float start = 0.0f) +{ + std::vector data (static_cast (size)); + + for (int i = 0; i < size; ++i) + data[static_cast (i)] = start + static_cast (i) * 0.01f; + + return data; +} + +namespace detail +{ + +struct CachedPatch +{ + YdspAudioGraph graph; + String diagnostics; +}; + +/** Compiles `source` at most once per process, keyed by its text. Deliberately + does not EXPECT/ASSERT here: this runs inside a function-local static + initializer, so a failure here would be silently attributed to whichever + test happens to trigger the first compile, while every later test using the + same source would receive an invalid graph with no complaint of its own. + Callers must check isValid() themselves (see cachedPatch/cachedPatchDiagnostics). +*/ +inline CachedPatch& cachedPatchEntry (StringRef source) +{ + static std::unordered_map cache; + + const String key (source); + auto it = cache.find (key); + + if (it != cache.end()) + return it->second; + + YdspCompiler compiler; + auto result = compiler.compile (source); + + CachedPatch entry; + entry.diagnostics = compiler.getDiagnostics().toString(); + + if (result.wasOk()) + entry.graph = std::move (result).getValue(); + + return cache.emplace (key, std::move (entry)).first->second; +} + +} // namespace detail + +/** Returns the graph compiled from `source`, compiling it on first request and + reusing the same graph (and its JIT-owned executable memory) for every + later call with the same source text. The graph outlives the compiler that + produced it, so this is safe to hold onto for the lifetime of the process. + + Callers must check isValid() (and report cachedPatchDiagnostics() on + failure) themselves - see restoreFreshState() for the accompanying + per-test state reset. +*/ +inline YdspAudioGraph& cachedPatch (StringRef source) +{ + return detail::cachedPatchEntry (source).graph; +} + +/** Returns the compiler diagnostics produced when `source` was first compiled + by cachedPatch(). Empty when compilation succeeded with nothing to report. +*/ +inline const String& cachedPatchDiagnostics (StringRef source) +{ + return detail::cachedPatchEntry (source).diagnostics; +} + +/** Restores a cached graph to a freshly-prepared state: prepare(), then + reset(), then every parameter is written back to its declared default. + + The explicit parameter write-back is required because reset() leaves + parameter values untouched by design (see YdspAudioGraph::reset()) - without + it, a test that changes a parameter would leak that change into the next + test sharing the same cached graph. +*/ +inline void restoreFreshState (YdspAudioGraph& graph, double sampleRate, int blockSize) +{ + graph.prepare (sampleRate, blockSize); + graph.reset(); + + for (int i = 0; i < graph.getParameterCount(); ++i) + { + const auto& info = graph.getParameterInfo (i); + + switch (info.type) + { + case YdspElementType::float32: + graph.setParameter (info.name, static_cast (info.defaultValue)); + break; + + case YdspElementType::float64: + graph.setDoubleParameter (info.name, info.defaultValue); + break; + + case YdspElementType::int32: + case YdspElementType::int64: + graph.setIntParameter (info.name, static_cast (info.defaultValue)); + break; + + case YdspElementType::boolean: + break; + } + } +} + +/** The shipped Electric Piano patch (examples/graphics/data/synths/ElectricPiano.ydsp). + + Kept in sync by hand: the test target has no path into the examples tree. The + shipped file's comments and `declare` metadata are dropped here and everything + is indented one level; the code is otherwise identical, so a diff against it + should show only those differences. +*/ +inline constexpr auto electricPiano = R"YDSP( + let harmonics = 32; + let rampSteps = 64; + + processor EPVoice { + output stream out; + + input parameter float brightness = 30.0; + input parameter float velocitySensitivity = 60.0; + input parameter float decayRate = 50.0; + input parameter float harmonicDecayRate = 50.0; + input parameter float keyScaling = 50.0; + input parameter float releaseRate = 40.0; + + input event midi; + + state float bendFactor = 1.0; + state float modWheel; + state float vibratoPhase; + + state float velAt100[harmonics] = { + 0.150869, 0.385766, 0.166484, 0.087412, 0.114967, 0.062138, 0.038751, 0.048902, + 0.031164, 0.019870, 0.024413, 0.015082, 0.010337, 0.012061, 0.007988, 0.005541, + 0.006272, 0.004319, 0.003043, 0.003371, 0.002397, 0.001723, 0.001858, 0.001361, + 0.000994, 0.001048, 0.000784, 0.000582, 0.000602, 0.000459, 0.000345, 0.000352 + }; + + state float velAt0[harmonics] = { + 0.020000, 0.050000, 0.012000, 0.004500, 0.003000, 0.001200, 0.000600, 0.000350, + 0.000200, 0.000120, 0.000075, 0.000048, 0.000030, 0.000020, 0.000013, 0.000009 + }; + + state float oscR[harmonics]; + state float oscI[harmonics]; + state float mulR[harmonics]; + state float mulI[harmonics]; + + state float amp[harmonics]; + state float inc[harmonics]; + state float dec[harmonics]; + state float rel[harmonics]; + + state int released; + state int rampCount; + + state float baseOmega; + state float lastPitchFactor; + + // The runtime skips a released voice whose activity flag reads 0, so this + // patch only has to answer "am I still audible?" - a held key keeps its + // voice running regardless. Starts at 0, so every voice starts asleep. + state int active [[ role: voiceActivity ]]; + + event midi (e: noteOn) { + let freq = 440.0 * pow (2.0, (e.pitch - 69.0) / 12.0); + let nyquist = sampleRate * 0.5; + let omega = 2.0 * pi * freq * samplePeriod; + + baseOmega = omega; + + let keyFactor = pow (0.5, ((e.pitch - 60.0) / 24.0) * (keyScaling * 0.01)); + let decaySeconds = (0.15 + decayRate * 0.14) * keyFactor; + let releaseSeconds = 0.02 + releaseRate * 0.008; + + let velocity = clamp (e.velocity, 0.0, 1.0); + let sensitivity = clamp (velocitySensitivity * 0.01, 0.0, 1.0); + let blend = clamp (1.0 - sensitivity + velocity * sensitivity, 0.0, 1.0); + let tilt = brightness * 0.01 - 0.5; + + released = 0; + rampCount = 0; + active = 1; + + bendFactor = pow (2.0, e.bendSemitones / 12.0); + vibratoPhase = 0.0; + lastPitchFactor = 1.0; + + for i in 0..harmonics { + let partial = float (i + 1); + let w = omega * partial; + let audible = select (freq * partial < nyquist, 1.0, 0.0); + + mulR[i] = cos (w); + mulI[i] = sin (w); + oscR[i] = 1.0; + oscI[i] = 0.0; + + amp[i] = lerp (velAt0[i], velAt100[i], blend) * pow (partial, tilt) * velocity * audible; + inc[i] = 0.0; + + let harmonicFactor = 1.0 + (partial - 1.0) * (harmonicDecayRate * 0.05); + dec[i] = pow (0.001, (rampSteps * samplePeriod) / (decaySeconds / harmonicFactor)); + rel[i] = pow (0.001, (rampSteps * samplePeriod) / releaseSeconds); + } + } + + event midi (e: noteOff) { + released = 1; + } + + event midi (e: pitchBend) { + bendFactor = pow (2.0, e.bendSemitones / 12.0); + } + + event midi (e: controlChange) { + if (e.control == 1) { modWheel = e.value; } + } + + process { + float peak = 0.0; + + if (rampCount <= 0) { + rampCount = rampSteps; + + vibratoPhase = vibratoPhase + 5.5 * float (rampSteps) * samplePeriod; + vibratoPhase = vibratoPhase - floor (vibratoPhase); + + float pitchFactor = bendFactor + * (1.0 + 0.02888113 * modWheel * sin (vibratoPhase * 2.0 * pi)); + + if (pitchFactor != lastPitchFactor) { + lastPitchFactor = pitchFactor; + + for i in 0..harmonics { + let w = baseOmega * float (i + 1) * pitchFactor; + mulR[i] = cos (w); + mulI[i] = sin (w); + } + } + + for i in 0..harmonics { + let target = amp[i] * select (released > 0, rel[i], dec[i]); + inc[i] = (target - amp[i]) / rampSteps; + peak = max (peak, abs (amp[i])); + } + + // The partial amplitudes *are* this voice's output envelope, so once + // the loudest of them is below -120 dBFS the voice is finished. Folded + // into the chunk-boundary loop, so this costs one compare per partial + // every `rampSteps` samples rather than per sample - and it never tests + // `sum`, which crosses zero. + active = select (peak < 0.000001, 0, 1); + } + + rampCount = rampCount - 1; + + float sum = 0.0; + + for i in 0..harmonics { + let rotated = oscR[i] * mulR[i] - oscI[i] * mulI[i]; + oscI[i] = oscR[i] * mulI[i] + oscI[i] * mulR[i]; + oscR[i] = rotated; + amp[i] = amp[i] + inc[i]; + sum = sum + oscI[i] * amp[i]; + } + + out = sum; + } + } + + processor Tremolo { + input stream in; + output stream outL, outR; + + input parameter float vibratoRate = 4.0; + input parameter float vibratoDepth = 0.5; + + state float phase; + state float direction = 1.0; + + process { + let step = 4.0 * vibratoRate * samplePeriod; + + phase = phase + step * direction; + + if (phase >= 1.0) { phase = 1.0; direction = -1.0; } + if (phase <= -1.0) { phase = -1.0; direction = 1.0; } + + let depth = clamp (vibratoDepth, 0.0, 1.0) * 0.5; + + outL = in * (1.0 - depth + depth * phase); + outR = in * (1.0 - depth - depth * phase); + } + } + + graph ElectricPiano { + input event midi; + + output stream outL, outR; + + input parameter float brightness = 30.0 [[ name: "Brightness", min: 0.0, max: 100.0 ]]; + input parameter float velocitySensitivity = 60.0 [[ name: "Velocity Sensitivity", min: 0.0, max: 100.0 ]]; + input parameter float decayRate = 50.0 [[ name: "Decay", min: 0.0, max: 100.0 ]]; + input parameter float harmonicDecayRate = 50.0 [[ name: "Harmonic Decay", min: 0.0, max: 100.0 ]]; + input parameter float keyScaling = 50.0 [[ name: "Key Scaling", min: 0.0, max: 100.0 ]]; + input parameter float releaseRate = 40.0 [[ name: "Release", min: 0.0, max: 100.0 ]]; + input parameter float vibratoRate [[ name: "Vibrato Rate", min: 0.5, max: 12.0, init: 4 ]]; + input parameter float vibratoDepth = 0.5 [[ name: "Vibrato Depth", min: 0.0, max: 1.0 ]]; + + node voices = EPVoice[16] [[ mode: poly, stealing: oldest ]]; + node trem = Tremolo; + + connection { + midi -> voices.midi; + + voices.out -> trem.in; + + trem.outL -> outL; + trem.outR -> outR; + + brightness -> voices.brightness; + velocitySensitivity -> voices.velocitySensitivity; + decayRate -> voices.decayRate; + harmonicDecayRate -> voices.harmonicDecayRate; + keyScaling -> voices.keyScaling; + releaseRate -> voices.releaseRate; + + vibratoRate -> trem.vibratoRate; + vibratoDepth -> trem.vibratoDepth; + } + } +)YDSP"; + +//============================================================================== + +/** A per-voice effect chain. The graph is instantiated with a voice bank + ([N]) on it, which replicates the whole chain - source AND effect - per + voice, so two notes never share effect state. The paired post-mix layout + (one effect after the summed bank) is what every pre-chain patch had to + use, and is the layout a crosstalk test must differ from. +*/ +inline constexpr auto voiceChainPV = R"YDSP( + processor PitchVoice { + output stream out; + input event midi; + state float s; + event midi (e: noteOn) { s = e.pitch; } + process { out = s; } + } + processor SoftClip { + input stream in; + output stream out; + process { + float x = in; + out = x + 0.01 * x * x; + } + } + graph VoiceChain { + input event midi; + output stream out; + node osc = PitchVoice; + node clip = SoftClip; + connection { midi -> osc.midi; osc.out -> clip.in; clip.out -> out; } + } +)YDSP"; + +} // namespace yup::test::patches diff --git a/tests/yup_dsp_jit/yup_YdspVectorizerTests.cpp b/tests/yup_dsp_jit/yup_YdspVectorizerTests.cpp new file mode 100644 index 000000000..b5e5d5e3a --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspVectorizerTests.cpp @@ -0,0 +1,1749 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include +#include +#include +#include +#include + +using namespace yup; + +namespace +{ + +//============================================================================== + +std::unique_ptr vectorizerBuildIr (StringRef source, YdspDiagnostics& diagnostics) +{ + YdspLexer lexer (source, diagnostics); + auto tokens = lexer.tokenize(); + + YdspParser parser (std::move (tokens), diagnostics); + auto program = parser.parseProgram(); + if (program == nullptr) + return nullptr; + + YdspSemanticAnalyzer analyzer (diagnostics); + auto analyzed = analyzer.analyze (std::move (program)); + if (analyzed == nullptr) + return nullptr; + + YdspOptimizer optimizer (diagnostics); + optimizer.setVectorizationEnabled (true); + + return optimizer.build (*analyzed); +} + +String vectorizerPatch (StringRef processorBody) +{ + return String (processorBody) + + "\ngraph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } }"; +} + +int vectorizerCountInst (const YdspIrFunction& fn, YdspIrOp op) +{ + int count = 0; + + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + if (inst.op == op) + ++count; + + return count; +} + +bool vectorizerWidens (StringRef processorBody) +{ + YdspDiagnostics diagnostics; + auto ir = vectorizerBuildIr (vectorizerPatch (processorBody), diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + EXPECT_NE (nullptr, ir); + + if (ir == nullptr || ir->kernels.empty()) + return false; + + return ir->kernels[0]->vectorized; +} + +YdspAudioGraph vectorizerCompileGraph (StringRef source, YdspCompiler& compiler) +{ + auto result = compiler.compile (source); + EXPECT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + if (! result.wasOk()) + return {}; + + return std::move (result).getValue(); +} + +YdspAudioGraph vectorizerCompileGraph (StringRef source, YdspCompiler& compiler, const YdspCompileOptions& options) +{ + auto result = compiler.compile (source, options); + EXPECT_TRUE (result.wasOk()) << compiler.getDiagnostics().toString(); + + if (! result.wasOk()) + return {}; + + return std::move (result).getValue(); +} + +void vectorizerRunBlock (YdspAudioGraph& graph, const float* input, float* output, int numSamples) +{ + std::vector inputs; + inputs.emplace_back (Span (input, static_cast (numSamples))); + + std::vector outputs; + outputs.emplace_back (Span (output, static_cast (numSamples))); + + graph.process (yup::YdspProcessRequest { inputs, outputs, numSamples }); +} + +constexpr auto vectorizerBankSource = R"YDSP( + let modes = 8; + + processor P { + input stream in; + output stream out; + + state float z[modes]; + + process { + float sum = 0.0; + + for i in 0..modes { + z[i] = z[i] * 0.5 + in; + sum = sum + z[i]; + } + + out = sum; + } + } +)YDSP"; + +constexpr auto vectorizerElementWiseSource = R"YDSP( + let taps = 4; + + processor P { + input stream in; + output stream out; + + state float a[taps]; + + process { + for i in 0..taps { + a[i] = a[i] * 0.5 + in * 0.25; + } + + out = a[0] + a[3]; + } + } +)YDSP"; + +// Three loops: loop 0 widens (constant span, bounded state-array accesses), +// loop 1 trips on a rounding op consuming a widened value (transcendentals are only admitted to +// the packed set when the target reports vector math, so this stays a +// rejection on both native and wasm), loop 2 is shorter than one vector. +// Loop 1 reads z[] which loop 0 writes, so loop fusion leaves them apart. +// (The state arrays avoid the built-in constant names `pi`, `e` and `inf`.) +constexpr auto vectorizerMixedReasonsSource = R"YDSP( + processor P { + input stream in; + output stream out; + + state float z[8]; + state float w[8]; + state float r[2]; + + process block { + let sample = in[0]; + for i in 0..8 { z[i] = z[i] * 0.5 + sample; } + for j in 0..8 { w[j] = round (z[j]); } + for k in 0..2 { r[k] = r[k] * 0.5; } + + out[0] = z[0] + w[0] + r[0]; + } + } +)YDSP"; + +} // namespace + +//============================================================================== + +TEST (YdspVectorizerTests, WidensAConstantBoundBankLoop) +{ + YdspDiagnostics diagnostics; + auto ir = vectorizerBuildIr (vectorizerPatch (vectorizerBankSource), diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + const auto& fn = *ir->kernels[0]; + + EXPECT_TRUE (fn.vectorized); + EXPECT_EQ (YdspVectorizer::vectorWidth, fn.vectorWidth); + + EXPECT_GT (vectorizerCountInst (fn, YdspIrOp::vsplat), 0); + EXPECT_EQ (1, vectorizerCountInst (fn, YdspIrOp::vreduceAddF)); + + bool foundWidenedLoad = false; + + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + if (inst.op == YdspIrOp::loadStateArrayF && fn.laneCountOf (inst.result) == fn.vectorWidth) + foundWidenedLoad = true; + + EXPECT_TRUE (foundWidenedLoad); +} + +TEST (YdspVectorizerTests, WidensAnElementWiseLoopWithNoReduction) +{ + YdspDiagnostics diagnostics; + auto ir = vectorizerBuildIr (vectorizerPatch (vectorizerElementWiseSource), diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + const auto& fn = *ir->kernels[0]; + + EXPECT_TRUE (fn.vectorized); + EXPECT_EQ (0, vectorizerCountInst (fn, YdspIrOp::vreduceAddF)); +} + +TEST (YdspVectorizerTests, IsOffUnlessEnabled) +{ + YdspDiagnostics diagnostics; + + YdspLexer lexer (vectorizerPatch (vectorizerBankSource), diagnostics); + auto tokens = lexer.tokenize(); + + YdspParser parser (std::move (tokens), diagnostics); + auto program = parser.parseProgram(); + ASSERT_NE (nullptr, program); + + YdspSemanticAnalyzer analyzer (diagnostics); + auto analyzed = analyzer.analyze (std::move (program)); + ASSERT_NE (nullptr, analyzed); + + YdspOptimizer optimizer (diagnostics); // no setVectorizationEnabled + auto ir = optimizer.build (*analyzed); + + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + EXPECT_FALSE (ir->kernels[0]->vectorized); + EXPECT_EQ (1, ir->kernels[0]->vectorWidth); + EXPECT_TRUE (ir->kernels[0]->valueLanes.empty()); +} + +//============================================================================== + +TEST (YdspVectorizerTests, WidensATripCountWithAScalarTail) +{ + // 6 modes with 4 lanes: two scalar iterations peeled, one vector trip. + YdspDiagnostics diagnostics; + auto ir = vectorizerBuildIr (vectorizerPatch (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[6]; + process { + for i in 0..6 { z[i] = z[i] * 0.5 + in; } + out = z[0]; + } + } + )YDSP"), diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + const auto& fn = *ir->kernels[0]; + + EXPECT_TRUE (fn.vectorized); + + // The peel is straight-line code in the preheader: one constI index per + // leading scalar iteration, plus the re-started induction at start + tail. + const auto& preheader = fn.blocks[static_cast (ir->kernels[0]->loops[0].headerBlock - 1)]; + bool foundPeelIndexZero = false; + bool foundPeelIndexOne = false; + bool foundVectorStart = false; + + for (const auto& inst : preheader.insts) + { + if (inst.op != YdspIrOp::constI) + continue; + + foundPeelIndexZero = foundPeelIndexZero || inst.ivalue == 0; + foundPeelIndexOne = foundPeelIndexOne || inst.ivalue == 1; + foundVectorStart = foundVectorStart || inst.ivalue == 2; + } + + EXPECT_TRUE (foundPeelIndexZero) << "the first scalar iteration must be peeled at index 0"; + EXPECT_TRUE (foundPeelIndexOne) << "the second scalar iteration must be peeled at index 1"; + EXPECT_TRUE (foundVectorStart) << "the vector loop must start at start + tail = 2"; +} + +TEST (YdspVectorizerTests, LeavesATripCountShorterThanOneVectorScalar) +{ + // 2 modes never fill a single 4-lane vector: no widening to gain from. + EXPECT_FALSE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[2]; + process { + for i in 0..2 { z[i] = z[i] * 0.5 + in; } + out = z[0]; + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, WidensAConstantBoundLoopWithANonZeroStart) +{ + // span = 12 - 2 = 10: two leading scalar iterations (i = 2, 3) and two + // vector trips over [4, 12). The old divisibility-only rule would have + // stepped from i = 2 by 4 and overrun the bound. + EXPECT_TRUE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[12]; + process { + for i in 2..12 { z[i] = z[i] * 0.5 + in; } + out = z[2] + z[11]; + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, RejectsARuntimeLoopBound) +{ + EXPECT_FALSE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[64]; + process block { + for i in 0..blockSize { z[i] = z[i] * 0.5; } + out[0] = z[0]; + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, RejectsAnIndirectIndex) +{ + EXPECT_FALSE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + process { + for i in 0..8 { + let j = 7 - i; + z[j] = z[j] * 0.5 + in; + } + out = z[0]; + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, RejectsTheInductionVariableUsedAsAValue) +{ + EXPECT_FALSE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + process { + for i in 0..8 { z[i] = z[i] * 0.5 + float (i); } + out = z[0]; + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, RejectsATranscendentalOnAWidenedValueWithoutVectorMath) +{ + // The IR-level helper builds with vector math unset, which is what a wasm + // or SLEEF-less target reports: the transcendental stays outside the + // packed set and the loop is refused. The compiler path on native admits + // it - see WidensATranscendentalLoopWithVectorMath below. + EXPECT_FALSE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + process { + for i in 0..8 { z[i] = sin (z[i]) * 0.5 + in; } + out = z[0]; + } + } + )YDSP")); +} + +#if ! YUP_WASM +TEST (YdspVectorizerTests, WidensATranscendentalLoopWithVectorMath) +{ + // The full compiler path reports vector math on native (sleef_library is + // linked), so the same shape now widens and is lowered to 4-lane SLEEF + // calls rather than being refused. The block-mode body keeps the loop + // count at exactly the one explicit `for`, like the mixed-reasons source. + YdspCompileOptions options; + options.emitOptimizationReport = true; + + YdspCompiler compiler; + auto graph = vectorizerCompileGraph (vectorizerPatch (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + process block { + let sample = in[0]; + for i in 0..8 { z[i] = sin (z[i]) * 0.5 + sample; } + out[0] = z[0]; + } + } + )YDSP"), compiler, options); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + + const auto& report = graph.getExecutionReport().getKernels()[0].loopVectorization; + + ASSERT_EQ (1u, report.loops.size()); + EXPECT_TRUE (report.loops[0].widened()); + EXPECT_EQ (1, report.countWidened()); +} +#endif + +TEST (YdspVectorizerTests, WidensAFloatSelectOnWidenedValues) +{ + EXPECT_TRUE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + state float w[8]; + process { + for i in 0..8 { z[i] = select (z[i] > w[i], z[i], w[i]); } + out = z[0]; + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, WidenedSelectUsesAVectorPredicateAndSelect) +{ + YdspDiagnostics diagnostics; + auto ir = vectorizerBuildIr (vectorizerPatch (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + process { + for i in 0..8 { z[i] = select (z[i] > 0.0, z[i], in); } + out = z[0]; + } + } + )YDSP"), diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + const auto& fn = *ir->kernels[0]; + EXPECT_TRUE (fn.vectorized); + + bool foundVectorPredicate = false; + bool foundVectorSelect = false; + + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + { + if (inst.op == YdspIrOp::gtF && fn.laneCountOf (inst.result) == fn.vectorWidth) + foundVectorPredicate = true; + + if (inst.op == YdspIrOp::selectB && fn.laneCountOf (inst.result) == fn.vectorWidth) + foundVectorSelect = true; + } + + EXPECT_TRUE (foundVectorPredicate); + EXPECT_TRUE (foundVectorSelect); +} + +TEST (YdspVectorizerTests, RejectsAScalarStateWriteInsideTheLoop) +{ + EXPECT_FALSE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + state float env; + process { + for i in 0..8 { + z[i] = z[i] * 0.5 + in; + env = env * 0.5 + z[i]; + } + out = env; + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, WidensASampleLoopOverStreams) +{ + // The canonical per-sample gain shape: stream accesses at the induction + // variable of the blockSize-bound sample loop. + YdspDiagnostics diagnostics; + auto ir = vectorizerBuildIr (vectorizerPatch (R"YDSP( + processor P { + input stream in; + output stream out; + process { out = in * 0.5 + 0.25; } + } + )YDSP"), diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + const auto& fn = *ir->kernels[0]; + + EXPECT_TRUE (fn.vectorized); + EXPECT_EQ (YdspVectorizer::vectorWidth, fn.vectorWidth); + + // One widened stream load and one widened stream store in the loop body. + bool foundWidenedLoad = false; + bool foundWidenedStore = false; + + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + { + if (inst.op == YdspIrOp::loadInput && fn.laneCountOf (inst.result) == fn.vectorWidth) + foundWidenedLoad = true; + + if (inst.op == YdspIrOp::storeOutput && fn.laneCountOf (inst.b) == fn.vectorWidth) + foundWidenedStore = true; + } + + EXPECT_TRUE (foundWidenedLoad); + EXPECT_TRUE (foundWidenedStore); + + // The runtime remainder is a second blockSize-bound loop appended after + // the widened sample loop. + ASSERT_EQ (2u, fn.loops.size()); + EXPECT_EQ (YdspLoopBoundKind::blockSize, fn.loops[1].bound.kind); +} + +TEST (YdspVectorizerTests, WidensABlockSizeBoundStreamLoop) +{ + EXPECT_TRUE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..blockSize { out[i] = in[i] * 0.5 + 0.25; } + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, WidensABlockSizeMinusOneBoundStreamLoop) +{ + // The builder records `0..blockSize - 1` as a blockSizeMinusConst bound. + // It is a runtime bound like plain blockSize, so it needs a stream access + // for the remainder loop, and the whole-vector trip count is computed from + // the runtime `blockSize - 1` value by the same epilogue machinery. + EXPECT_TRUE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..blockSize - 1 { out[i] = in[i] * 0.5 + 0.25; } + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, KeepsConstantBoundStreamLoopScalarWithoutABlockLengthProof) +{ + // A valid process call may supply fewer than ten samples. + EXPECT_FALSE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..10 { out[i] = in[i] * 0.5 + 0.25; } + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, WidensPositiveStartsWithRuntimeBounds) +{ + for (const int start : { 1, 3, 4, 7, 16 }) + EXPECT_TRUE (vectorizerWidens (String (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in )YDSP") + String (start) + R"YDSP(..blockSize - 1 { out[i] = in[i] * 0.5; } + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, WidensIfConvertedStreamLoops) +{ + EXPECT_TRUE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..blockSize { + float v; + if (in[i] > 0.0) v = in[i] * 0.5; else v = in[i] * 0.25; + out[i] = v; + } + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, RejectsFixedIndexReadsFromWrittenArrays) +{ + EXPECT_FALSE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + process { + for i in 0..8 { z[i] = z[0] + in; } + out = z[7]; + } + } + )YDSP")); + + EXPECT_FALSE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..blockSize { out[i] = out[0] + in[i]; } + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, WidensALoopContainingRoundingIntrinsics) +{ + // floor/ceil/rint lower to one native packed instruction on both SIMD + // targets (frintm/frintp/frintn, roundps), so a quantiser loop may widen. + EXPECT_TRUE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..blockSize { + out[i] = floor (in[i] * 4.0) * 0.25 + ceil (in[i]) * 0.5 + rint (in[i]) * 0.125; + } + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, KeepsStreamLoopScalarWhenItMayExceedTheStateArray) +{ + // blockSize may exceed the eight-element state allocation. + EXPECT_FALSE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + process block { + for i in 0..blockSize { out[i] = z[i] + in[i]; } + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, WidensAStreamLoopWithAReduction) +{ + YdspDiagnostics diagnostics; + auto ir = vectorizerBuildIr (vectorizerPatch (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + float s = 0.0; + for i in 0..blockSize { s = s + in[i]; } + out[0] = s; + } + } + )YDSP"), diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + const auto& fn = *ir->kernels[0]; + + EXPECT_TRUE (fn.vectorized); + + // The vector accumulator folds into the scalar one in the tail loop's + // header, and the scalar tail accumulates on top of it. + EXPECT_EQ (1, vectorizerCountInst (fn, YdspIrOp::vreduceAddF)); +} + +TEST (YdspVectorizerTests, RejectsAStreamStoreAtANonInductionIndex) +{ + EXPECT_FALSE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + process block { + for i in 0..8 { out[0] = z[i] + in[i]; } + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, WidensEveryStreamLoopInAKernel) +{ + // Two blockSize-bound stream loops: widening the first appends its scalar + // tail loop, which must not shift the second loop's index or be widened + // itself. + YdspDiagnostics diagnostics; + auto ir = vectorizerBuildIr (vectorizerPatch (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..blockSize { out[i] = in[i] * 0.5; } + for j in 0..blockSize { out[j] = out[j] + 1.0; } + } + } + )YDSP"), diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + const auto& fn = *ir->kernels[0]; + + EXPECT_TRUE (fn.vectorized); + + // Two widened stream loads (one per loop) and two widened stream stores. + int widenedLoads = 0; + int widenedStores = 0; + + for (const auto& block : fn.blocks) + for (const auto& inst : block.insts) + { + if ((inst.op == YdspIrOp::loadInput || inst.op == YdspIrOp::loadOutput) + && fn.laneCountOf (inst.result) == fn.vectorWidth) + ++widenedLoads; + + if (inst.op == YdspIrOp::storeOutput && fn.laneCountOf (inst.b) == fn.vectorWidth) + ++widenedStores; + } + + EXPECT_EQ (2, widenedLoads); + EXPECT_EQ (2, widenedStores); + + // Each widened loop appended exactly one scalar tail. + EXPECT_EQ (4u, fn.loops.size()); +} + +TEST (YdspVectorizerTests, RejectsControlFlowInsideTheLoop) +{ + EXPECT_FALSE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + process { + for i in 0..8 { + if (in > 0.5) { z[i] = z[i] * 0.5; } else { z[i] = z[i] * 0.25; } + } + out = z[0]; + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, RejectsANestedLoop) +{ + EXPECT_FALSE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + process { + for i in 0..8 { + for j in 0..4 { z[i] = z[i] * 0.5 + in; } + } + out = z[0]; + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, RejectsALoopWithNoArrayAccessToWiden) +{ + EXPECT_FALSE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + process { + float sum = 0.0; + for i in 0..8 { sum = sum + in; } + out = sum; + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, RejectsALoopUsingIntegerMin) +{ + EXPECT_FALSE (vectorizerWidens (R"YDSP( + processor P { + input stream in; + output stream out; + state int buf[16]; + process { + for i in 0..16 { buf[i] = min (buf[i], 10); } + out = in + float32 (buf[0]); + } + } + )YDSP")); +} + +TEST (YdspVectorizerTests, RejectsAnEmitInsideTheLoop) +{ + YdspDiagnostics diagnostics; + + auto ir = vectorizerBuildIr (R"YDSP( + processor P { + input stream in; + output stream out; + output event noteOn; + state float z[8]; + process { + for i in 0..8 { + z[i] = z[i] * 0.5 + in; + emit noteOn (pitch: 60, velocity: 0.8) -> noteOn; + } + out = z[0]; + } + } + graph G { input stream x; output stream y; output event noteOn; node p = P; connection { x -> p.in; p.out -> y; p.noteOn -> noteOn; } } + )YDSP", + diagnostics); + + EXPECT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + EXPECT_FALSE (ir->kernels[0]->vectorized); +} + +//============================================================================== + +TEST (YdspVectorizerTests, WidenedBankProducesTheExpectedOutput) +{ + YdspCompiler compiler; + auto graph = vectorizerCompileGraph (vectorizerPatch (vectorizerBankSource), compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const std::vector input (8, 1.0f); + std::vector output (8, 0.0f); + + vectorizerRunBlock (graph, input.data(), output.data(), 8); + + float expected = 0.0f; + + for (int sample = 0; sample < 8; ++sample) + { + expected = expected * 0.5f + 1.0f; + EXPECT_FLOAT_EQ (8.0f * expected, output[static_cast (sample)]) << "sample " << sample; + } + + if (::testing::Test::HasFailure()) + std::cout << "\n[AsmJit] " << graph.getDiagnostics().toString() << std::endl; +} + +TEST (YdspVectorizerTests, SplitAccumulatorBankProducesTheExpectedOutput) +{ + for (const int modes : { 16, 32 }) + { + YdspCompiler compiler; + + auto graph = vectorizerCompileGraph (vectorizerPatch (String ("let modes = ") + String (modes) + R"YDSP(; + + processor P { + input stream in; + output stream out; + + state float z[modes]; + + process { + float sum = 0.0; + + for i in 0..modes { + z[i] = z[i] * 0.5 + in; + sum = sum + z[i]; + } + + out = sum; + } + } + )YDSP"), + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + graph.prepare (44100.0, 8); + + const std::vector input (8, 1.0f); + std::vector output (8, 0.0f); + + vectorizerRunBlock (graph, input.data(), output.data(), 8); + + float expected = 0.0f; + + for (int sample = 0; sample < 8; ++sample) + { + expected = expected * 0.5f + 1.0f; + + EXPECT_FLOAT_EQ (static_cast (modes) * expected, output[static_cast (sample)]) + << modes << " modes, sample " << sample; + } + + if (::testing::Test::HasFailure()) + std::cout << "\n[AsmJit] " << graph.getDiagnostics().toString() << std::endl; + } +} + +TEST (YdspVectorizerTests, WidenedElementWiseLoopIsBitExact) +{ + YdspCompiler compiler; + auto graph = vectorizerCompileGraph (vectorizerPatch (vectorizerElementWiseSource), compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const std::vector input (8, 1.0f); + std::vector output (8, 0.0f); + + vectorizerRunBlock (graph, input.data(), output.data(), 8); + + float tap = 0.0f; + + for (int sample = 0; sample < 8; ++sample) + { + tap = tap * 0.5f + 0.25f; + EXPECT_FLOAT_EQ (2.0f * tap, output[static_cast (sample)]) << "sample " << sample; + } + + if (::testing::Test::HasFailure()) + std::cout << "\n[AsmJit] " << graph.getDiagnostics().toString() << std::endl; +} + +TEST (YdspVectorizerTests, ReportsTheLaneCountPerKernel) +{ + YdspCompiler compiler; + auto graph = vectorizerCompileGraph (vectorizerPatch (vectorizerBankSource), compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const auto& kernels = graph.getExecutionReport().getKernels(); + ASSERT_FALSE (kernels.empty()); + + bool foundVectorized = false; + + for (const auto& kernel : kernels) + { + if (! kernel.vectorized) + continue; + + foundVectorized = true; + EXPECT_TRUE (kernel.vectorWidth == 4 || kernel.vectorWidth == 8); + + EXPECT_EQ (8, kernel.boundedIterationCount); + } + +#if ! YUP_WASM || defined (__wasm_simd128__) + EXPECT_TRUE (foundVectorized); +#else + EXPECT_FALSE (foundVectorized); // a scalar wasm build (no -msimd128) stays scalar +#endif +} + +#if ! YUP_WASM + +TEST (YdspVectorizerTests, EmitsPackedInstructions) +{ + YdspCompiler compiler; + auto graph = vectorizerCompileGraph (vectorizerPatch (vectorizerBankSource), compiler); + + ASSERT_TRUE (graph.isValid()); + + const auto listing = graph.getDiagnostics().toString(); + ASSERT_FALSE (listing.isEmpty()); + +#if ASMJIT_ARCH_ARM + EXPECT_TRUE (listing.contains ("v0.4s") || listing.contains (".4s")) + << "expected an ASIMD vector form in the listing"; + EXPECT_TRUE (listing.contains ("faddp")); +#elif ASMJIT_ARCH_X86 + EXPECT_TRUE (listing.contains ("mulps") || listing.contains ("addps")); + EXPECT_TRUE (listing.contains ("shufps")); +#endif + + if (::testing::Test::HasFailure()) + std::cout << "\n[AsmJit] " << listing << std::endl; +} + +#endif + +//============================================================================== + +TEST (YdspVectorizerTests, PeeledBankProducesTheExpectedOutput) +{ + for (const int modes : { 6, 10, 14 }) + { + YdspCompiler compiler; + + auto graph = vectorizerCompileGraph (vectorizerPatch (String ("let modes = ") + String (modes) + R"YDSP(; + + processor P { + input stream in; + output stream out; + + state float z[modes]; + + process { + float sum = 0.0; + + for i in 0..modes { + z[i] = z[i] * 0.5 + in; + sum = sum + z[i]; + } + + out = sum; + } + } + )YDSP"), + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + graph.prepare (44100.0, 8); + + const std::vector input (8, 1.0f); + std::vector output (8, 0.0f); + + vectorizerRunBlock (graph, input.data(), output.data(), 8); + + float expected = 0.0f; + + for (int sample = 0; sample < 8; ++sample) + { + expected = expected * 0.5f + 1.0f; + + EXPECT_FLOAT_EQ (static_cast (modes) * expected, output[static_cast (sample)]) + << modes << " modes, sample " << sample; + } + + if (::testing::Test::HasFailure()) + std::cout << "\n[AsmJit] " << graph.getDiagnostics().toString() << std::endl; + } +} + +TEST (YdspVectorizerTests, PeeledNonZeroStartLoopProducesTheExpectedOutput) +{ + YdspCompiler compiler; + + auto graph = vectorizerCompileGraph (vectorizerPatch (R"YDSP( + processor P { + input stream in; + output stream out; + + state float z[12]; + + process { + for i in 2..12 { z[i] = z[i] * 0.5 + in; } + out = z[2] + z[11]; + } + } + )YDSP"), + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + graph.prepare (44100.0, 8); + + const std::vector input (8, 1.0f); + std::vector output (8, 0.0f); + + vectorizerRunBlock (graph, input.data(), output.data(), 8); + + // The state starts zeroed: z[2] and z[11] decay identically per sample. + float z = 0.0f; + + for (int sample = 0; sample < 8; ++sample) + { + z = z * 0.5f + 1.0f; + + EXPECT_FLOAT_EQ (2.0f * z, output[static_cast (sample)]) << "sample " << sample; + } + + if (::testing::Test::HasFailure()) + std::cout << "\n[AsmJit] " << graph.getDiagnostics().toString() << std::endl; +} + +TEST (YdspVectorizerTests, BlockSizeMinusOneStreamLoopProducesTheExpectedOutput) +{ + // The widened loop plus its scalar remainder must cover exactly the + // `blockSize - 1` samples - the last sample of the block is never touched, + // at block sizes that are and are not whole multiples of the vector width. + for (const int blockSize : { 10, 63 }) + { + YdspCompiler compiler; + + auto graph = vectorizerCompileGraph (vectorizerPatch (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..blockSize - 1 { out[i] = in[i] * 0.5 + in[i] * in[i]; } + } + } + )YDSP"), + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + graph.prepare (44100.0, blockSize); + + std::vector input (static_cast (blockSize)); + std::vector output (static_cast (blockSize), 0.0f); + + for (int s = 0; s < blockSize; ++s) + input[static_cast (s)] = 0.25f * static_cast (s); + + vectorizerRunBlock (graph, input.data(), output.data(), blockSize); + + for (int s = 0; s < blockSize - 1; ++s) + { + const auto in = input[static_cast (s)]; + + EXPECT_FLOAT_EQ (in * 0.5f + in * in, output[static_cast (s)]) + << blockSize << " samples, sample " << s; + } + + if (::testing::Test::HasFailure()) + std::cout << "\n[AsmJit] " << graph.getDiagnostics().toString() << std::endl; + } +} + +TEST (YdspVectorizerTests, PositiveStartStreamLoopsPreservePrefixesAndTails) +{ + for (const int start : { 1, 3, 4, 7, 16 }) + { + YdspCompiler compiler; + auto graph = vectorizerCompileGraph (vectorizerPatch (String (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for j in 0..blockSize { out[j] = -2.0; } + for i in )YDSP") + String (start) + R"YDSP(..blockSize - 1 { out[i] = in[i] * 0.5; } + } + } + )YDSP"), compiler); + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + graph.prepare (44100.0, 65); + + for (int size = 1; size <= 65; ++size) + { + std::vector input (static_cast (size), 3.0f); + std::vector output (static_cast (size), 99.0f); + vectorizerRunBlock (graph, input.data(), output.data(), size); + for (int i = 0; i < size; ++i) + EXPECT_FLOAT_EQ (i >= start && i < size - 1 ? 1.5f : -2.0f, output[static_cast (i)]) + << "start " << start << ", size " << size << ", sample " << i; + } + } +} + +TEST (YdspVectorizerTests, IfConvertedStreamLoopsMatchScalarAcrossBlockSizes) +{ + YdspCompiler compiler; + auto graph = vectorizerCompileGraph (vectorizerPatch (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..blockSize { + float v; + if (in[i] > 0.0) v = in[i] * 0.5; else v = in[i] * 0.25; + out[i] = v; + } + for j in 0..blockSize { out[j] = out[j] + 1.0; } + } + } + )YDSP"), compiler); + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + graph.prepare (44100.0, 65); + for (int size = 1; size <= 65; ++size) + { + std::vector input (static_cast (size)); + std::vector output (static_cast (size)); + for (int i = 0; i < size; ++i) + input[static_cast (i)] = static_cast (i % 7 - 3); + vectorizerRunBlock (graph, input.data(), output.data(), size); + for (int i = 0; i < size; ++i) + { + const float x = input[static_cast (i)]; + EXPECT_FLOAT_EQ (x * (x > 0.0f ? 0.5f : 0.25f) + 1.0f, output[static_cast (i)]) + << "size " << size << ", sample " << i; + } + } +} + +TEST (YdspVectorizerTests, FixedIndexArrayReadsObserveEarlierIterations) +{ + YdspCompiler compiler; + auto graph = vectorizerCompileGraph (vectorizerPatch (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + process { + for i in 0..8 { z[i] = z[0] + in; } + out = z[7]; + } + } + )YDSP"), compiler); + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + std::vector input (8, 1.0f), output (8); + for (int block = 0; block < 2; ++block) + { + vectorizerRunBlock (graph, input.data(), output.data(), 8); + for (int i = 0; i < 8; ++i) + EXPECT_FLOAT_EQ (static_cast (block * 8 + i + 2), output[static_cast (i)]); + } +} + +TEST (YdspVectorizerTests, WidenedRoundingLoopProducesTheExpectedOutput) +{ + // Rounding widens element-wise and each op rounds once, so the widened + // kernel must be bit-identical to the scalar formula - including negative + // inputs and the rint ties-to-even cases (-2.5 -> -2, -0.5 -> -0, 1.5 -> 2). + for (const int blockSize : { 10, 63 }) + { + YdspCompiler compiler; + + auto graph = vectorizerCompileGraph (vectorizerPatch (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..blockSize { + out[i] = floor (in[i] * 4.0) * 0.25 + ceil (in[i]) * 0.5 + rint (in[i]) * 0.125; + } + } + } + )YDSP"), + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + graph.prepare (44100.0, blockSize); + + std::vector input (static_cast (blockSize)); + std::vector output (static_cast (blockSize), 0.0f); + + for (int s = 0; s < blockSize; ++s) + { + const auto base = static_cast ((s % 7) - 3); + input[static_cast (s)] = base + ((s % 2) == 0 ? 0.0f : 0.5f); + } + + vectorizerRunBlock (graph, input.data(), output.data(), blockSize); + + for (int s = 0; s < blockSize; ++s) + { + const auto in = input[static_cast (s)]; + + const auto expected = std::floor (in * 4.0f) * 0.25f + + std::ceil (in) * 0.5f + + std::rint (in) * 0.125f; + + EXPECT_FLOAT_EQ (expected, output[static_cast (s)]) + << blockSize << " samples, sample " << s << " (in = " << in << ")"; + } + + if (::testing::Test::HasFailure()) + std::cout << "\n[AsmJit] " << graph.getDiagnostics().toString() << std::endl; + } +} + +TEST (YdspVectorizerTests, StreamLoopProducesTheExpectedOutput) +{ + for (const int blockSize : { 10, 63 }) + { + YdspCompiler compiler; + + auto graph = vectorizerCompileGraph (vectorizerPatch (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..blockSize { out[i] = in[i] * 0.5 + in[i] * in[i]; } + } + } + )YDSP"), + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + graph.prepare (44100.0, blockSize); + + std::vector input (static_cast (blockSize)); + std::vector output (static_cast (blockSize), 0.0f); + + for (int s = 0; s < blockSize; ++s) + input[static_cast (s)] = 0.25f * static_cast (s); + + vectorizerRunBlock (graph, input.data(), output.data(), blockSize); + + for (int s = 0; s < blockSize; ++s) + { + const auto in = input[static_cast (s)]; + + EXPECT_FLOAT_EQ (in * 0.5f + in * in, output[static_cast (s)]) + << blockSize << " samples, sample " << s; + } + + if (::testing::Test::HasFailure()) + std::cout << "\n[AsmJit] " << graph.getDiagnostics().toString() << std::endl; + } +} + +TEST (YdspVectorizerTests, SampleModeGainProducesTheExpectedOutput) +{ + YdspCompiler compiler; + + auto graph = vectorizerCompileGraph (vectorizerPatch (R"YDSP( + processor P { + input stream in; + output stream out; + process { out = in * 0.5 + 0.25; } + } + )YDSP"), + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + graph.prepare (44100.0, 10); + + const std::vector input (10, 1.0f); + std::vector output (10, 0.0f); + + vectorizerRunBlock (graph, input.data(), output.data(), 10); + + for (int s = 0; s < 10; ++s) + EXPECT_FLOAT_EQ (0.75f, output[static_cast (s)]) << "sample " << s; + + if (::testing::Test::HasFailure()) + std::cout << "\n[AsmJit] " << graph.getDiagnostics().toString() << std::endl; +} + +TEST (YdspVectorizerTests, StreamLoopReductionProducesTheExpectedOutput) +{ + for (const int blockSize : { 10, 63 }) + { + YdspCompiler compiler; + + auto graph = vectorizerCompileGraph (vectorizerPatch (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + float s = 0.0; + for i in 0..blockSize { s = s + in[i]; } + out[0] = s; + } + } + )YDSP"), + compiler); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + graph.prepare (44100.0, blockSize); + + // 0.25 is a power of two, so every reassociated partial sum is exact: + // the folded vector result must equal the scalar-ordered sum bit for bit. + std::vector input (static_cast (blockSize), 0.25f); + std::vector output (static_cast (blockSize), 0.0f); + + vectorizerRunBlock (graph, input.data(), output.data(), blockSize); + + EXPECT_FLOAT_EQ (0.25f * static_cast (blockSize), output[0]) + << blockSize << " samples"; + + if (::testing::Test::HasFailure()) + std::cout << "\n[AsmJit] " << graph.getDiagnostics().toString() << std::endl; + } +} + +#if ! YUP_WASM + +TEST (YdspVectorizerTests, EmitsPackedStreamInstructions) +{ + YdspCompiler compiler; + auto graph = vectorizerCompileGraph (vectorizerPatch (R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..blockSize { out[i] = in[i] * 0.5 + 0.25; } + } + } + )YDSP"), + compiler); + + ASSERT_TRUE (graph.isValid()); + + const auto listing = graph.getDiagnostics().toString(); + ASSERT_FALSE (listing.isEmpty()); + +#if ASMJIT_ARCH_ARM + EXPECT_TRUE (listing.contains (".4s")) + << "expected an ASIMD vector form for the stream loop in the listing"; +#elif ASMJIT_ARCH_X86 + EXPECT_TRUE (listing.contains ("movups")) + << "expected packed stream loads/stores in the listing"; + EXPECT_TRUE (listing.contains ("mulps") || listing.contains ("addps") + || listing.contains ("vfmadd") || listing.contains ("vfmsub")) + << "expected packed arithmetic (or a packed fused multiply-add) in the listing"; +#endif + + if (::testing::Test::HasFailure()) + std::cout << "\n[AsmJit] " << listing << std::endl; +} + +#endif + +//============================================================================== + +TEST (YdspVectorizerTests, ReportsWhyEachLoopWasNotVectorized) +{ + YdspDiagnostics diagnostics; + auto ir = vectorizerBuildIr (vectorizerPatch (vectorizerMixedReasonsSource), diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + const auto& fn = *ir->kernels[0]; + const auto& results = fn.vectorizationResults; + + ASSERT_EQ (3u, results.size()); + + EXPECT_TRUE (results[0].widened()); + EXPECT_EQ (0, results[0].loopId); + EXPECT_EQ (YdspVectorizer::vectorWidth, results[0].laneCount); + + EXPECT_FALSE (results[1].widened()); + EXPECT_EQ (YdspVectorizationReason::unsupportedWidenedOp, results[1].reason); + EXPECT_TRUE (results[1].describe().contains ("was not vectorized")); + EXPECT_TRUE (results[1].describe().contains ("unsupported operation")); + + EXPECT_FALSE (results[2].widened()); + EXPECT_EQ (YdspVectorizationReason::shortTripCount, results[2].reason); + + // The report aggregates the rejection reasons, deduplicated. + YdspVectorizationReport report; + report.loops = results; + + EXPECT_EQ (1, report.countWidened()); + EXPECT_EQ (2, report.rejectionReasons().size()); +} + +TEST (YdspVectorizerTests, ReportsARuntimeStartAsTheReason) +{ + YdspDiagnostics diagnostics; + auto ir = vectorizerBuildIr (vectorizerPatch (R"YDSP( + processor P { + input stream in; + output stream out; + state float z[8]; + state float offset; + process block { + let start = int32 (offset); + let sample = in[0]; + for i in start..8 { z[0] = z[0] * 0.5 + sample; } + out[0] = z[0]; + } + } + )YDSP"), diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + + const auto& results = ir->kernels[0]->vectorizationResults; + + ASSERT_EQ (1u, results.size()); + EXPECT_EQ (YdspVectorizationReason::nonConstantStart, results[0].reason); +} + +TEST (YdspVectorizerTests, ExecutionReportCarriesPerLoopReasons) +{ +#if ! YUP_WASM || defined (__wasm_simd128__) + YdspCompileOptions options; + options.emitOptimizationReport = true; + + YdspCompiler compiler; + auto graph = vectorizerCompileGraph (vectorizerPatch (vectorizerMixedReasonsSource), compiler, options); + + ASSERT_TRUE (graph.isValid()) << compiler.getDiagnostics().toString(); + + const auto& kernels = graph.getExecutionReport().getKernels(); + ASSERT_FALSE (kernels.empty()); + + const auto& report = kernels[0].loopVectorization; + + ASSERT_EQ (3u, report.loops.size()); + EXPECT_EQ (1, report.countWidened()); + EXPECT_TRUE (report.loops[0].widened()); + EXPECT_EQ (YdspVectorizationReason::unsupportedWidenedOp, report.loops[1].reason); + EXPECT_EQ (YdspVectorizationReason::shortTripCount, report.loops[2].reason); + + EXPECT_TRUE (report.rejectionReasons().contains ("an unsupported operation consumes a widened value")); + EXPECT_TRUE (report.rejectionReasons().contains ("the loop span is shorter than one vector")); +#endif +} + +TEST (YdspVectorizerTests, MissedVectorizationAppearsAsInfoDiagnostics) +{ +#if ! YUP_WASM || defined (__wasm_simd128__) + YdspCompileOptions options; + options.emitOptimizationReport = true; + + YdspCompiler compiler; + auto graph = vectorizerCompileGraph (vectorizerPatch (vectorizerMixedReasonsSource), compiler, options); + + ASSERT_TRUE (graph.isValid()); + + // A successful compile moves the diagnostics into the graph. + const auto text = graph.getDiagnostics().toString(); + + EXPECT_TRUE (text.contains ("kernel 'P': loop 1 was not vectorized")) + << "expected the select rejection as an info diagnostic"; + EXPECT_TRUE (text.contains ("kernel 'P': loop 2 was not vectorized")) + << "expected the short-trip-count rejection as an info diagnostic"; + EXPECT_FALSE (text.contains ("loop 0 was not vectorized")) + << "the widened loop must not be reported as a miss"; +#endif +} + +TEST (YdspVectorizerTests, MissedVectorizationIsSilentUnlessRequested) +{ + // Without emitOptimizationReport no info diagnostics are produced. + YdspCompiler compiler; + auto graph = vectorizerCompileGraph (vectorizerPatch (vectorizerMixedReasonsSource), compiler); + + ASSERT_TRUE (graph.isValid()); + + EXPECT_FALSE (graph.getDiagnostics().toString().contains ("was not vectorized")); +} + +//============================================================================== +// Widened comparison values. +// +// Each relational operator contributes a distinct power of two to the sum, so +// the per-sample result is a bitmask and a single wrong predicate names itself. +// The values are whole numbers, so the comparison below is exact. +// +// Worth having as a *value* test rather than a "did it assemble" one: the +// legacy SSE2 CMPPS is two-operand and only decodes a three-bit predicate, so +// the four-lane x86 path has to seed its destination and swap the operands of +// the greater-than forms, none of which an assembler would reject. + +namespace +{ + +constexpr auto vectorizerCompareSource = R"YDSP( + processor P { + input stream in; + output stream out; + process block { + for i in 0..blockSize { + let x = in[i]; + out[i] = select (x > 0.0, 1.0, 0.0) + + select (x >= 0.0, 2.0, 0.0) + + select (x < 0.0, 4.0, 0.0) + + select (x <= 0.0, 8.0, 0.0) + + select (x == 0.0, 16.0, 0.0) + + select (x != 0.0, 32.0, 0.0); + } + } + } +)YDSP"; + +/** The mask the six operators produce for `x`, computed the way the patch + spells them. */ +float vectorizerCompareExpected (float x) +{ + return (x > 0.0f ? 1.0f : 0.0f) + + (x >= 0.0f ? 2.0f : 0.0f) + + (x < 0.0f ? 4.0f : 0.0f) + + (x <= 0.0f ? 8.0f : 0.0f) + + (x == 0.0f ? 16.0f : 0.0f) + + (x != 0.0f ? 32.0f : 0.0f); +} + +void vectorizerCheckCompares (YdspAudioGraph& graph, const char* what) +{ + ASSERT_TRUE (graph.isValid()); + + // Not a multiple of four, so the widened body and the scalar remainder both + // run and both have to agree. + constexpr int numSamples = 11; + + graph.prepare (44100.0, numSamples); + + const std::vector input { 1.0f, -1.0f, 0.0f, 2.5f, -2.5f, 0.0f, 0.25f, -0.25f, 7.0f, -7.0f, 0.0f }; + std::vector output (numSamples, 0.0f); + + vectorizerRunBlock (graph, input.data(), output.data(), numSamples); + + for (int i = 0; i < numSamples; ++i) + { + const auto x = input[static_cast (i)]; + + EXPECT_EQ (vectorizerCompareExpected (x), output[static_cast (i)]) + << what << ": sample " << i << " (x = " << x << ")"; + } +} + +} // namespace + +TEST (YdspVectorizerTests, WidenedComparesProduceTheExpectedValues) +{ + YdspCompiler compiler; + auto graph = vectorizerCompileGraph (vectorizerPatch (vectorizerCompareSource), compiler); + + vectorizerCheckCompares (graph, "host target"); + + if (::testing::Test::HasFailure()) + std::cout << "\n[AsmJit] " << graph.getDiagnostics().toString() << std::endl; +} + +TEST (YdspVectorizerTests, WidenedComparesAgreeOnTheFourLaneBaselineTarget) +{ + // Requesting the SSE2 baseline is what reaches the legacy four-lane CMPPS + // path on x86-64; on a host without SSE2 the request is declined and the + // kernel compiles scalar, which still has to produce the same values. + YdspCompileOptions options; + options.targetPolicy = YdspTargetPolicy::baseline; + options.baselineTarget = YdspNativeTarget::sse2; + + YdspCompiler compiler; + auto graph = vectorizerCompileGraph (vectorizerPatch (vectorizerCompareSource), compiler, options); + + vectorizerCheckCompares (graph, "sse2 baseline target"); + + if (::testing::Test::HasFailure()) + std::cout << "\n[AsmJit] " << graph.getDiagnostics().toString() << std::endl; +} + +TEST (YdspVectorizerTests, MatchAndIfRetainEquivalentOptimizedLoops) +{ + const struct { const char* selector; const char* ifBody; const char* matchBody; bool mustVectorize; } cases[] { + { "v > 0.0", "if (choice) { y = v * 0.5; } else { y = v + dry; }", + "match (choice) { true => { y = v * 0.5; }, _ => { y = v + dry; } }", true }, + { "v > 0.0", "if (!choice) { y = v + dry; } else { y = v * 0.5; }", + "match (choice) { false => { y = v + dry; }, _ => { y = v * 0.5; } }", false }, + { "1 + 1", "if (choice == 1) { y = v - dry; } else if (choice == 2) { y = v * 0.5; } else { y = dry; }", + "match (choice) { 1 => { y = v - dry; }, 2 => { y = v * 0.5; }, _ => { y = dry; } }", true }, + { "int (dry)", "if (choice == 1) { y = v - dry; } else if (choice == 2) { y = v * 0.5; } else { y = dry; }", + "match (choice) { 1 => { y = v - dry; }, 2 => { y = v * 0.5; }, _ => { y = dry; } }", false }, + }; + for (const auto& item : cases) + { + SCOPED_TRACE (item.matchBody); + const auto source = [&] (const char* body) + { + const String statement (body); + const auto selection = statement.startsWith ("match") + ? statement.replace ("match (choice)", String ("match (") + item.selector + ")") + : String ("let choice = ") + item.selector + "; " + statement; + return vectorizerPatch (String ("processor P { input stream in; output stream out; state float z[32]; process { let dry = in; for i in 0..32 { let v = z[i]; float y = 0.0; ") + + selection + " z[i] = y; } out = z[0]; } }"); + }; + YdspDiagnostics ifDiagnostics, matchDiagnostics; + auto ifIr = vectorizerBuildIr (source (item.ifBody), ifDiagnostics); + auto matchIr = vectorizerBuildIr (source (item.matchBody), matchDiagnostics); + ASSERT_FALSE (ifDiagnostics.hasErrors()) << ifDiagnostics.toString(); + ASSERT_FALSE (matchDiagnostics.hasErrors()) << matchDiagnostics.toString(); + ASSERT_NE (nullptr, ifIr); + ASSERT_NE (nullptr, matchIr); + ASSERT_EQ (1u, ifIr->kernels.size()); + ASSERT_EQ (1u, matchIr->kernels.size()); + const auto& reference = *ifIr->kernels[0]; + const auto& actual = *matchIr->kernels[0]; + EXPECT_EQ (reference.vectorized, actual.vectorized); + EXPECT_EQ (reference.vectorWidth, actual.vectorWidth); + if (item.mustVectorize) + EXPECT_TRUE (actual.vectorized); + ASSERT_EQ (reference.vectorizationResults.size(), actual.vectorizationResults.size()); + for (size_t i = 0; i < reference.vectorizationResults.size(); ++i) + { + EXPECT_EQ (reference.vectorizationResults[i].reason, actual.vectorizationResults[i].reason); + EXPECT_EQ (reference.vectorizationResults[i].laneCount, actual.vectorizationResults[i].laneCount); + } + const auto shape = [] (const YdspIrFunction& fn) + { + std::map, int> counts; + for (const auto& block : fn.blocks) + { + if (block.term == YdspIrTerm::branchIf) + ++counts[{ -1, 0 }]; + for (const auto& inst : block.insts) + ++counts[{ static_cast (inst.op), inst.result >= 0 ? fn.laneCountOf (inst.result) : 0 }]; + } + return counts; + }; + EXPECT_EQ (shape (reference), shape (actual)); + } +} diff --git a/tests/yup_dsp_jit/yup_YdspWasmBackendTests.cpp b/tests/yup_dsp_jit/yup_YdspWasmBackendTests.cpp new file mode 100644 index 000000000..a62ec3f6a --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspWasmBackendTests.cpp @@ -0,0 +1,510 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#if YUP_WASM + +#include + +#include + +#include "yup_YdspTestPatches.h" + +#include + +using namespace yup; + +namespace +{ + +//============================================================================== + +using yup::test::patches::compilePatch; +using yup::test::patches::makeRamp; + +void runWasmProcess (YdspAudioGraph& graph, + const float* const* inputs, + int numInputs, + float* const* outputs, + int numOutputs, + int numSamples, + const yup::MidiBuffer* midi = nullptr) +{ + std::vector inputBuffers; + inputBuffers.reserve (static_cast (numInputs)); + + for (int i = 0; i < numInputs; ++i) + inputBuffers.emplace_back (yup::Span (inputs[i], static_cast (numSamples))); + + std::vector outputBuffers; + outputBuffers.reserve (static_cast (numOutputs)); + + for (int i = 0; i < numOutputs; ++i) + outputBuffers.emplace_back (yup::Span (outputs[i], static_cast (numSamples))); + + const yup::MidiBuffer* processEventInputs[] { midi }; + + const auto result = graph.process (yup::YdspProcessRequest { + inputBuffers, + outputBuffers, + numSamples, + yup::Span (processEventInputs, graph.getEventInputCount() > 0 ? 1 : 0), + {} + }); + EXPECT_EQ (YdspProcessResult::ok, result); +} + +constexpr const char* wasmPassThroughSource = R"YDSP( + processor P { input stream in; output stream out; process { out = in; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } +)YDSP"; + +constexpr const char* wasmAccumulateSource = R"YDSP( + processor P { input stream in; output stream out; state float s; process { s = s + in; out = s; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } +)YDSP"; + +constexpr const char* wasmParamSource = R"YDSP( + processor P { input stream in; output stream out; input parameter float gain = 0.5; process { out = in * gain; } } + graph G { input stream x; output stream y; input parameter float gain = 1.0; node p = P; connection { x -> p.in; p.out -> y; gain -> p.gain; } } +)YDSP"; + +constexpr const char* wasmSmoothSource = R"YDSP( + processor P { + input stream in; + output stream out; + input parameter float gain = 0.5 [[ smoothing: 0.0002 ]]; + process { out = in * gain; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } +)YDSP"; + +constexpr const char* wasmSinSource = R"YDSP( + processor P { input stream in; output stream out; process { out = sin (in); } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } +)YDSP"; + +constexpr const char* wasmDelaySource = R"YDSP( + processor P { input stream in; output stream out; process { out = in @ 4; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } +)YDSP"; + +constexpr const char* wasmSubgraphSource = R"YDSP( + processor Gain { input stream in; output stream out; input parameter float g = 2.0; process { out = in * g; } } + graph Sub { + input stream in; + output stream out; + input parameter float amount = 3.0; + node a = Gain; + connection { in -> a.in; a.out -> out; amount -> a.g; } + } + graph Main [[ main ]] { + input stream x; + output stream y; + node s = Sub (amount = 4.0); + connection { x -> s.in; s.out -> y; } + } +)YDSP"; + +} // namespace + +//============================================================================== + +TEST (YdspWasmBackendTests, CompilesAndRunsPassThrough) +{ + YdspCompiler compiler; + auto graph = compilePatch (wasmPassThroughSource, compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 128); + + graph.prewarmKernels(); + + const auto input = makeRamp (128); + std::vector output (128, 0.0f); + + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runWasmProcess (graph, inPtrs, 1, outPtrs, 1, 128); + + for (int i = 0; i < 128; ++i) + EXPECT_FLOAT_EQ (input[static_cast (i)], output[static_cast (i)]); +} + +TEST (YdspWasmBackendTests, StateAccumulatesAcrossBlocks) +{ + YdspCompiler compiler; + auto graph = compilePatch (wasmAccumulateSource, compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 8); + + const std::vector ones (8, 1.0f); + std::vector output (8, 0.0f); + + const float* inPtrs[] = { ones.data() }; + float* outPtrs[] = { output.data() }; + + runWasmProcess (graph, inPtrs, 1, outPtrs, 1, 8); + + for (int i = 0; i < 8; ++i) + EXPECT_FLOAT_EQ (static_cast (i + 1), output[static_cast (i)]); + + runWasmProcess (graph, inPtrs, 1, outPtrs, 1, 8); + + for (int i = 0; i < 8; ++i) + EXPECT_FLOAT_EQ (static_cast (9 + i), output[static_cast (i)]); +} + +TEST (YdspWasmBackendTests, VectorizedStateBankMatchesScalarReference) +{ + // The automatic tier vectorises this constant-bound bank loop to f32x4 + // (the emscripten build compiles with -msimd128), so the module runs the + // packed lowering: v128 load/store, f32x4 arithmetic, splats and the + // shuffle-based horizontal reduction. Compare its output against the + // scalar recurrence (EXPECT_FLOAT_EQ tolerates the reassociated sum). + YdspCompiler compiler; + auto graph = compilePatch (R"YDSP( + let modes = 8; + + processor P { + input stream in; + output stream out; + + state float z[modes]; + + process { + float sum = 0.0; + + for i in 0..modes { + z[i] = z[i] * 0.5 + in; + sum = sum + z[i]; + } + + out = sum; + } + } + + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + const std::vector ones (32, 1.0f); + std::vector output (32, 0.0f); + + const float* inPtrs[] = { ones.data() }; + float* outPtrs[] = { output.data() }; + + runWasmProcess (graph, inPtrs, 1, outPtrs, 1, 32); + + float z[8] = {}; + std::vector reference (32, 0.0f); + + for (int s = 0; s < 32; ++s) + { + float sum = 0.0f; + + for (int i = 0; i < 8; ++i) + { + z[i] = z[i] * 0.5f + 1.0f; + sum += z[i]; + } + + reference[static_cast (s)] = sum; + } + + for (int s = 0; s < 32; ++s) + EXPECT_FLOAT_EQ (reference[static_cast (s)], output[static_cast (s)]); +} + +TEST (YdspWasmBackendTests, ParamsDriveTheKernel) +{ + YdspCompiler compiler; + auto graph = compilePatch (wasmParamSource, compiler); + + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.hasParameter ("gain")); + graph.prepare (44100.0, 64); + + const auto input = makeRamp (64); + std::vector output (64, 0.0f); + + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + graph.setParameter ("gain", 0.25f); + runWasmProcess (graph, inPtrs, 1, outPtrs, 1, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_FLOAT_EQ (input[static_cast (i)] * 0.25f, output[static_cast (i)]); +} + +TEST (YdspWasmBackendTests, SinIntrinsicMatchesHostMath) +{ + YdspCompiler compiler; + auto graph = compilePatch (wasmSinSource, compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 128); + + const auto input = makeRamp (128, -0.3f); + std::vector output (128, 0.0f); + + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runWasmProcess (graph, inPtrs, 1, outPtrs, 1, 128); + + for (int i = 0; i < 128; ++i) + EXPECT_NEAR (std::sin (input[static_cast (i)]), output[static_cast (i)], 1e-6f); +} + +TEST (YdspWasmBackendTests, PolyphonicVoiceBankWithMidiEvents) +{ + YdspCompiler compiler; + auto graph = compilePatch (R"YDSP( + processor P { + output stream out; + input parameter float gain = 0.5; + input event midi; + state float phase; + state float freq; + state float env; + + func noteToFreq (pitch: float) : float { return 440.0 * pow (2.0, (pitch - 69.0) / 12.0); } + + event midi (e: noteOn) { freq = noteToFreq (e.pitch); env = e.velocity; } + event midi (e: noteOff) { env = 0.0; } + + process { + phase = phase + freq / sampleRate; + if (phase >= 1.0) { phase = phase - 1.0; } + out = env * phase; + } + } + graph G { input event midi; output stream y; node p = P[4]; connection { midi -> p.midi; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 128); + + std::vector output (128, 0.0f); + float* outPtrs[] = { output.data() }; + + yup::MidiBuffer noteOn; + noteOn.addEvent (yup::MidiMessage::noteOn (1, 69, static_cast (127)), 0); + runWasmProcess (graph, nullptr, 0, outPtrs, 1, 128, ¬eOn); + + float peak = 0.0f; + + for (const auto sample : output) + peak = std::max (peak, std::abs (sample)); + + EXPECT_GT (peak, 0.0f); // the voice produced audio + + yup::MidiBuffer noteOff; + noteOff.addEvent (yup::MidiMessage::noteOff (1, 69, static_cast (0)), 0); + runWasmProcess (graph, nullptr, 0, outPtrs, 1, 128, ¬eOff); + + std::fill (output.begin(), output.end(), 0.0f); + runWasmProcess (graph, nullptr, 0, outPtrs, 1, 128); + + for (const auto sample : output) + EXPECT_FLOAT_EQ (0.0f, sample); +} + +//============================================================================== + +TEST (YdspWasmBackendTests, ElectricPianoRunsInWasm) +{ + YdspCompiler compiler; + + auto graph = compilePatch (yup::test::patches::electricPiano, compiler); + + ASSERT_TRUE (graph.isValid()); + EXPECT_EQ (2, graph.getOutputStreamCount()); + EXPECT_FLOAT_EQ (4.0f, graph.getParameter ("vibratoRate")); + + graph.prepare (44100.0, 128); + + std::vector left (128, 0.0f); + std::vector right (128, 0.0f); + float* outPtrs[] = { left.data(), right.data() }; + + yup::MidiBuffer noteOn; + noteOn.addEvent (yup::MidiMessage::noteOn (1, 60, static_cast (100)), 0); + runWasmProcess (graph, nullptr, 0, outPtrs, 2, 128, ¬eOn); + + float peak = 0.0f; + bool channelsDiffer = false; + + for (size_t i = 0; i < left.size(); ++i) + { + peak = std::max (peak, std::abs (left[i])); + + if (left[i] != right[i]) + channelsDiffer = true; + } + + EXPECT_GT (peak, 0.0f); + EXPECT_TRUE (channelsDiffer); +} + +TEST (YdspWasmBackendTests, SmoothedParameterRampsAcrossBlocks) +{ + YdspCompiler compiler; + auto graph = compilePatch (wasmSmoothSource, compiler); + + ASSERT_TRUE (graph.isValid()); + ASSERT_TRUE (graph.hasParameter ("p.gain")); + graph.prepare (44100.0, 256); + + const std::vector ones (256, 1.0f); + std::vector output (256, 0.0f); + + const float* inPtrs[] = { ones.data() }; + float* outPtrs[] = { output.data() }; + + runWasmProcess (graph, inPtrs, 1, outPtrs, 1, 256); + + for (int i = 0; i < 256; ++i) + EXPECT_FLOAT_EQ (0.5f, output[static_cast (i)]) << "primed block at " << i; + + graph.setParameter ("p.gain", 1.0f); + runWasmProcess (graph, inPtrs, 1, outPtrs, 1, 256); + + EXPECT_GT (output[0], 0.5f); + EXPECT_LT (output[0], 0.6f); + + for (int i = 1; i < 256; ++i) + EXPECT_GE (output[static_cast (i)], output[static_cast (i - 1)]) << "not monotone at " << i; + + EXPECT_FLOAT_EQ (1.0f, output[255]); +} + +TEST (YdspWasmBackendTests, DelayRingWrapsWithoutIntegerDivision) +{ + YdspCompiler compiler; + auto graph = compilePatch (wasmDelaySource, compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 32); + + const auto input = makeRamp (32, 1.0f); + std::vector output (32, -1.0f); + + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runWasmProcess (graph, inPtrs, 1, outPtrs, 1, 32); + + for (int i = 0; i < 4; ++i) + EXPECT_FLOAT_EQ (0.0f, output[static_cast (i)]) << "pre-roll at " << i; + + for (int i = 4; i < 32; ++i) + EXPECT_FLOAT_EQ (input[static_cast (i - 4)], output[static_cast (i)]) << "delayed at " << i; + + const auto second = makeRamp (32, 100.0f); + const float* secondPtrs[] = { second.data() }; + + runWasmProcess (graph, secondPtrs, 1, outPtrs, 1, 32); + + for (int i = 0; i < 4; ++i) + EXPECT_FLOAT_EQ (input[static_cast (28 + i)], output[static_cast (i)]) << "carry-over at " << i; + + for (int i = 4; i < 32; ++i) + EXPECT_FLOAT_EQ (second[static_cast (i - 4)], output[static_cast (i)]) << "delayed at " << i; +} + +TEST (YdspWasmBackendTests, IntegerMinMaxClampAbsSignMatchTheComposedSelectLowering) +{ + YdspCompiler compiler; + + auto graph = compilePatch (R"YDSP( + processor P { + input stream in; + output stream out; + process { + let n = int32 (in * 100.0) - 50; + let mn = min (n, 3); + let mx = max (n, -3); + let cl = clamp (n, -10, 10); + let ab = abs (n); + let sg = sign (n); + out = float32 (mn + mx + cl + ab + sg); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 100); + + const auto input = makeRamp (100); + std::vector output (100, 0.0f); + + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runWasmProcess (graph, inPtrs, 1, outPtrs, 1, 100); + + for (int i = 0; i < 100; ++i) + { + const auto n = static_cast (input[static_cast (i)] * 100.0f) - 50; + const auto mn = n < 3 ? n : 3; + const auto mx = n > -3 ? n : -3; + const auto clamped = n < -10 ? -10 : (n > 10 ? 10 : n); + const auto ab = n < 0 ? -n : n; + const auto sg = n > 0 ? 1 : (n < 0 ? -1 : 0); + const auto expected = static_cast (mn + mx + clamped + ab + sg); + + EXPECT_NEAR (expected, output[static_cast (i)], 1e-3f) << "at " << i; + } +} + +TEST (YdspWasmBackendTests, SubgraphIsInlinedBeforeCodegen) +{ + YdspCompiler compiler; + auto graph = compilePatch (wasmSubgraphSource, compiler); + + ASSERT_TRUE (graph.isValid()); + graph.prepare (44100.0, 64); + + EXPECT_TRUE (graph.hasParameter ("s.a.g")); + EXPECT_NEAR (4.0f, graph.getParameter ("s.a.g"), 1e-6f); + + const auto input = makeRamp (64, 0.25f); + std::vector output (64, 0.0f); + + const float* inPtrs[] = { input.data() }; + float* outPtrs[] = { output.data() }; + + runWasmProcess (graph, inPtrs, 1, outPtrs, 1, 64); + + for (int i = 0; i < 64; ++i) + EXPECT_NEAR (input[static_cast (i)] * 4.0f, output[static_cast (i)], 1e-4f); +} + +#endif // YUP_WASM diff --git a/tests/yup_dsp_jit/yup_YdspWasmTests.cpp b/tests/yup_dsp_jit/yup_YdspWasmTests.cpp new file mode 100644 index 000000000..6cb0156d3 --- /dev/null +++ b/tests/yup_dsp_jit/yup_YdspWasmTests.cpp @@ -0,0 +1,1305 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include +#include +#include + +using namespace yup; + +namespace +{ + +//============================================================================== +// Minimal wasm binary parsing helpers (MVP subset). + +struct WasmSection +{ + uint8_t id = 0; + std::vector payload; +}; + +std::vector parseSections (const std::vector& bytes) +{ + std::vector sections; + size_t pos = 8; // skip magic + version + + while (pos < bytes.size()) + { + WasmSection section; + section.id = bytes[pos++]; + + uint32_t size = 0; + uint32_t shift = 0; + + while (pos < bytes.size()) + { + const auto byte = bytes[pos++]; + size |= static_cast (byte & 0x7F) << shift; + shift += 7; + + if ((byte & 0x80) == 0) + break; + } + + if (size > bytes.size() - pos) + break; + + section.payload.assign (bytes.begin() + static_cast (pos), + bytes.begin() + static_cast (pos + size)); + pos += size; + sections.push_back (std::move (section)); + } + + return sections; +} + +uint32_t readLebU (const std::vector& data, size_t& pos) +{ + uint32_t value = 0; + uint32_t shift = 0; + + while (pos < data.size()) + { + const auto byte = data[pos++]; + value |= static_cast (byte & 0x7F) << shift; + shift += 7; + + if ((byte & 0x80) == 0) + break; + } + + return value; +} + +std::string readName (const std::vector& data, size_t& pos) +{ + const auto length = readLebU (data, pos); + + std::string name; + name.reserve (length); + + for (uint32_t i = 0; i < length && pos < data.size(); ++i) + name.push_back (static_cast (data[pos++])); + + return name; +} + +struct WasmImport +{ + std::string moduleName; + std::string name; + uint8_t kind = 0; + uint32_t typeIndex = 0; + bool isMemory = false; + uint32_t memoryMin = 0; + uint32_t memoryMax = 0; + bool memoryShared = false; +}; + +std::vector parseImports (const WasmSection& section) +{ + std::vector imports; + size_t pos = 0; + + const auto count = readLebU (section.payload, pos); + + for (uint32_t i = 0; i < count; ++i) + { + WasmImport import; + import.moduleName = readName (section.payload, pos); + import.name = readName (section.payload, pos); + import.kind = section.payload[pos++]; + + if (import.kind == YdspWasmEmitter::importKindFunc) + { + import.typeIndex = readLebU (section.payload, pos); + } + else if (import.kind == YdspWasmEmitter::importKindMemory) + { + import.isMemory = true; + const auto limitsFlag = section.payload[pos++]; + import.memoryShared = (limitsFlag & 0x02) != 0; + import.memoryMin = readLebU (section.payload, pos); + + if ((limitsFlag & 0x01) != 0) + import.memoryMax = readLebU (section.payload, pos); + } + + imports.push_back (std::move (import)); + } + + return imports; +} + +struct WasmFuncType +{ + std::vector params; + std::vector results; +}; + +std::vector parseFuncTypes (const WasmSection& section) +{ + std::vector types; + size_t pos = 0; + + const auto count = readLebU (section.payload, pos); + + for (uint32_t i = 0; i < count; ++i) + { + WasmFuncType type; + const auto form = section.payload[pos++]; + (void) form; // 0x60 + + const auto numParams = readLebU (section.payload, pos); + type.params.assign (section.payload.begin() + static_cast (pos), + section.payload.begin() + static_cast (pos + numParams)); + pos += numParams; + + const auto numResults = readLebU (section.payload, pos); + type.results.assign (section.payload.begin() + static_cast (pos), + section.payload.begin() + static_cast (pos + numResults)); + pos += numResults; + + types.push_back (std::move (type)); + } + + return types; +} + +std::vector collectCodeInstructions (const WasmSection& section) +{ + std::vector instructions; + size_t pos = 0; + + const auto count = readLebU (section.payload, pos); + + for (uint32_t i = 0; i < count; ++i) + { + const auto bodySize = readLebU (section.payload, pos); + const auto bodyStart = pos; + + const auto numGroups = readLebU (section.payload, pos); + + for (uint32_t g = 0; g < numGroups; ++g) + { + (void) readLebU (section.payload, pos); // run length + ++pos; // valtype byte + } + + instructions.insert (instructions.end(), + section.payload.begin() + static_cast (pos), + section.payload.begin() + static_cast (bodyStart + bodySize - 1)); + + pos = bodyStart + bodySize; + } + + return instructions; +} + +bool containsSubsequence (const std::vector& haystack, const std::vector& needle) +{ + if (needle.empty() || needle.size() > haystack.size()) + return false; + + for (size_t i = 0; i + needle.size() <= haystack.size(); ++i) + { + bool match = true; + + for (size_t j = 0; j < needle.size(); ++j) + if (haystack[i + j] != needle[j]) + { + match = false; + break; + } + + if (match) + return true; + } + + return false; +} + +// A minimal stack-balance validator over the emitted code section. Walks the +// instruction bytes, consumes each opcode's immediates and tracks the value +// stack depth, asserting it never underflows and ends balanced. This is what +// catches a missing-operand lowering (e.g. a binary op fed one value) on the +// desktop, where no wasm engine is available to reject the module at +// instantiation. +void expectBalancedStack (const std::vector& code, const std::vector& sections) +{ + std::vector types; + + for (const auto& section : sections) + if (section.id == YdspWasmEmitter::sectionType) + types = parseFuncTypes (section); + + // Function index space: function imports first (in import order), then + // defined functions (function-section order). + std::vector arity; + + for (const auto& section : sections) + if (section.id == YdspWasmEmitter::sectionImport) + for (const auto& import : parseImports (section)) + if (import.kind == YdspWasmEmitter::importKindFunc && import.typeIndex < types.size()) + arity.push_back (static_cast (types[import.typeIndex].params.size())); + + for (const auto& section : sections) + if (section.id == YdspWasmEmitter::sectionFunction) + { + size_t pos = 0; + const auto count = readLebU (section.payload, pos); + + for (uint32_t i = 0; i < count && pos < section.payload.size(); ++i) + { + const auto typeIndex = readLebU (section.payload, pos); + arity.push_back (typeIndex < types.size() ? static_cast (types[typeIndex].params.size()) : 0); + } + } + + int depth = 0; + size_t pos = 0; + struct ControlFrame { int entryDepth, results; }; + std::vector control; + + const auto byte = [&] { return code[pos++]; }; + const auto lebU = [&] { return readLebU (code, pos); }; + + const auto delta = [&] (int d) + { + depth += d; + EXPECT_GE (depth, 0) << "value stack underflow at byte offset " << pos; + }; + + while (pos < code.size()) + { + const auto op = byte(); + + switch (op) + { + case 0x00: // unreachable + case 0x01: // nop + case 0x0F: // return (void kernel) + break; + + case 0x02: // block + case 0x03: // loop + { + const auto type = byte(); + control.push_back ({ depth, type == 0x40 ? 0 : 1 }); + break; + } + + case 0x04: // if + { + const auto type = byte(); + delta (-1); // condition + control.push_back ({ depth, type == 0x40 ? 0 : 1 }); + break; + } + + case 0x05: // else + ASSERT_FALSE (control.empty()); + EXPECT_EQ (control.back().entryDepth + control.back().results, depth); + depth = control.back().entryDepth; + break; + + case 0x0B: // end + if (! control.empty()) + { + EXPECT_EQ (control.back().entryDepth + control.back().results, depth); + control.pop_back(); + } + break; + + case 0x0C: // br + (void) lebU(); + break; + + case 0x0D: // br_if + (void) lebU(); + delta (-1); + break; + + case 0x10: // call + { + const auto index = lebU(); + delta (index < arity.size() ? -arity[index] : 0); + break; + } + + case 0x1A: // drop + delta (-1); + break; + + case 0x1B: // select (untyped) + delta (-2); + break; + + case 0x20: // local.get + case 0x21: // local.set + case 0x22: // local.tee + (void) lebU(); + delta (op == 0x20 ? 1 : (op == 0x21 ? -1 : 0)); + break; + + case 0x41: // i32.const + case 0x42: // i64.const + (void) lebU(); + delta (+1); + break; + + case 0x43: // f32.const + pos += 4; + delta (+1); + break; + + case 0x44: // f64.const + pos += 8; + delta (+1); + break; + + case 0x28: case 0x29: case 0x2A: case 0x2B: // loads + case 0x2C: case 0x2D: case 0x2E: case 0x2F: + case 0x30: case 0x31: case 0x32: case 0x33: case 0x34: case 0x35: + (void) lebU(); // align + (void) lebU(); // offset + break; // pop the address, push the value: net 0 + + case 0x36: case 0x37: case 0x38: case 0x39: // stores + case 0x3A: case 0x3B: case 0x3C: case 0x3D: case 0x3E: + (void) lebU(); + (void) lebU(); + delta (-2); // pop the address and the value + break; + + case 0xFC: // saturating conversion, pop 1 push 1 + EXPECT_LT (lebU(), 8u); + break; + + case 0x45: case 0x50: // eqz + case 0x67: case 0x68: case 0x69: // i32 clz/ctz/popcnt + case 0x79: case 0x7A: case 0x7B: // i64 clz/ctz/popcnt + case 0x8B: case 0x8C: case 0x8D: case 0x8E: case 0x8F: case 0x90: case 0x91: // f32 unary + case 0x99: case 0x9A: case 0x9B: case 0x9C: case 0x9D: case 0x9E: case 0x9F: // f64 unary + case 0xA7: case 0xAC: case 0xB6: case 0xBB: // wrap/extend/demote/promote + case 0xBC: case 0xBD: case 0xBE: case 0xBF: // reinterpret + case 0xC0: case 0xC1: case 0xC2: case 0xC3: case 0xC4: // sign-extension + case 0xA8: case 0xA9: case 0xAA: case 0xAB: // i32.trunc_f* + case 0xAE: case 0xAF: case 0xB0: case 0xB1: // i64.trunc_f* + case 0xB2: case 0xB3: case 0xB4: case 0xB5: // f32.convert_i* + case 0xB7: case 0xB8: case 0xB9: case 0xBA: // f64.convert_i* + break; // unary: pop 1 push 1 + + case 0x46: case 0x47: case 0x48: case 0x49: case 0x4A: case 0x4B: + case 0x4C: case 0x4D: case 0x4E: case 0x4F: // i32 comparisons + case 0x51: case 0x52: case 0x53: case 0x54: case 0x55: case 0x56: + case 0x57: case 0x58: case 0x59: case 0x5A: // i64 comparisons + case 0x5B: case 0x5C: case 0x5D: case 0x5E: case 0x5F: case 0x60: // f32 comparisons + case 0x61: case 0x62: case 0x63: case 0x64: case 0x65: case 0x66: // f64 comparisons + case 0x6A: case 0x6B: case 0x6C: case 0x6D: case 0x6E: case 0x6F: case 0x70: // i32 arith + case 0x71: case 0x72: case 0x73: case 0x74: case 0x75: case 0x76: + case 0x77: case 0x78: // i32 bitwise/shifts/rotates + case 0x7C: case 0x7D: case 0x7E: case 0x7F: case 0x80: case 0x81: case 0x82: // i64 arith + case 0x83: case 0x84: case 0x85: case 0x86: case 0x87: case 0x88: + case 0x89: case 0x8A: // i64 bitwise/shifts/rotates + case 0x92: case 0x93: case 0x94: case 0x95: case 0x96: case 0x97: case 0x98: // f32 binary + case 0xA0: case 0xA1: case 0xA2: case 0xA3: case 0xA4: case 0xA5: case 0xA6: // f64 binary + delta (-1); // binary: pop 2 push 1 + break; + +#if defined (__wasm_simd128__) + case YdspWasmEmitter::simdPrefix: + { + const auto sub = lebU(); + + switch (sub) + { + case YdspWasmEmitter::opV128Load: + (void) lebU(); + (void) lebU(); + break; // pop the address, push the value: net 0 + + case YdspWasmEmitter::opV128Store: + (void) lebU(); + (void) lebU(); + delta (-2); // pop the address and the value + break; + + case YdspWasmEmitter::opI8x16Shuffle: + pos += 16; // lane mask + delta (-1); // pop 2 push 1 + break; + + case YdspWasmEmitter::opF32x4ExtractLane: + (void) byte(); // lane index + break; // pop 1 push 1 + + case YdspWasmEmitter::opF32x4Splat: + case YdspWasmEmitter::opF32x4Ceil: + case YdspWasmEmitter::opF32x4Floor: + case YdspWasmEmitter::opF32x4Nearest: + case YdspWasmEmitter::opF32x4Abs: + case YdspWasmEmitter::opF32x4Neg: + case YdspWasmEmitter::opF32x4Sqrt: + break; // unary + + case YdspWasmEmitter::opF32x4Add: + case YdspWasmEmitter::opF32x4Sub: + case YdspWasmEmitter::opF32x4Mul: + case YdspWasmEmitter::opF32x4Div: + case YdspWasmEmitter::opF32x4Min: + case YdspWasmEmitter::opF32x4Max: + delta (-1); + break; + + default: + break; // subopcode the codegen never emits + } + + break; + } +#endif + + default: + break; // opcode the codegen never emits + } + } + + EXPECT_EQ (0, depth) << "value stack not balanced at end of code section"; + EXPECT_TRUE (control.empty()); +} + +//============================================================================== +// Runs the full YDSP pipeline (lexer -> parser -> analyzer -> optimizer) and +// emits the wasm module for the named kernel. + +std::vector compileWasm (StringRef source, const char* kernelName, YdspDiagnostics& diagnostics, bool enableVectorization = false, bool enableTracing = false) +{ + YdspLexer lexer (source, diagnostics); + auto tokens = lexer.tokenize(); + + YdspParser parser (std::move (tokens), diagnostics); + auto program = parser.parseProgram(); + + if (program == nullptr || diagnostics.hasErrors()) + return {}; + + YdspSemanticAnalyzer analyzer (diagnostics); + auto analyzed = analyzer.analyze (std::move (program)); + + if (analyzed == nullptr || diagnostics.hasErrors()) + return {}; + + YdspOptimizer optimizer (diagnostics); + optimizer.setTracingEnabled (enableTracing); + + if (enableVectorization) + { + optimizer.setVectorizationEnabled (true); + optimizer.setVectorWidth (4); + } + + auto ir = optimizer.build (*analyzed); + + if (ir == nullptr || diagnostics.hasErrors()) + return {}; + + const YdspIrFunction* targetFn = nullptr; + + for (const auto& fn : ir->kernels) + { + if (fn->name == kernelName) + { + targetFn = fn.get(); + break; + } + } + + if (targetFn == nullptr) + return {}; + + return YdspWasmCodegen::compile (*targetFn, diagnostics); +} + +//============================================================================== +// YDSP source snippets + +constexpr const char* passThroughSource = R"YDSP( + processor P { input stream in; output stream out; process { out = in; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } +)YDSP"; + +constexpr const char* sinF32Source = R"YDSP( + processor P { input stream in; output stream out; process { out = sin (in); } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } +)YDSP"; + +constexpr const char* sinF64Source = R"YDSP( + processor P { input stream float64 in; output stream float64 out; process { out = sin (in); } } + graph G { input stream float64 x; output stream float64 y; node p = P; connection { x -> p.in; p.out -> y; } } +)YDSP"; + +constexpr const char* powSource = R"YDSP( + processor P { input stream in; output stream out; process { out = pow (in, 2.0); } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } +)YDSP"; + +constexpr const char* emitEventSource = R"YDSP( + processor P { + input stream in; + output stream out; + output event noteOn; + process { + emit noteOn (pitch: in) -> noteOn; + out = in; + } + } + graph G { input stream x; output stream y; output event noteOn; node p = P; connection { x -> p.in; p.out -> y; p.noteOn -> noteOn; } } +)YDSP"; + +constexpr const char* intDivSource = R"YDSP( + processor P { output stream out; process { out = float32 (blockSize / 2); } } + graph G { output stream y; node p = P; connection { p.out -> y; } } +)YDSP"; + +constexpr const char* modSource = R"YDSP( + processor P { input stream in; output stream out; process { out = in % 2.0; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } +)YDSP"; + +constexpr const char* stateSource = R"YDSP( + processor P { input stream in; output stream out; state float s; process { s = in; out = s; } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } +)YDSP"; + +constexpr const char* ifElseSource = R"YDSP( + processor P { input stream in; output stream out; process { if (in > 0) { out = in; } else { out = 0; } } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } +)YDSP"; + +constexpr const char* forLoopSource = R"YDSP( + processor P { input stream in; output stream out; process block { for i in 0..blockSize { out[i] = in[i] * 2; } } } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } +)YDSP"; + +// The vectoriser widens this constant-bound bank loop to four f32 lanes; the +// wasm backend emits f32x4 only when compiled with -msimd128. +constexpr const char* wasmVectorBankSource = R"YDSP( + let modes = 8; + + processor P { + input stream in; + output stream out; + + state float z[modes]; + + process { + float sum = 0.0; + + for i in 0..modes { + z[i] = z[i] * 0.5 + in; + sum = sum + z[i]; + } + + out = sum; + } + } + + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } +)YDSP"; + +} // namespace + +//============================================================================== +// YdspWasmEmitter: raw encoding tests +//============================================================================== + +TEST (WasmEmitterTests, ModuleStartsWithMagicAndVersion) +{ + YdspWasmEmitter emitter; + emitter.beginModule(); + + const auto& bytes = emitter.getBytes(); + ASSERT_EQ (8u, bytes.size()); + + EXPECT_EQ (0x00, bytes[0]); + EXPECT_EQ (0x61, bytes[1]); + EXPECT_EQ (0x73, bytes[2]); + EXPECT_EQ (0x6D, bytes[3]); + EXPECT_EQ (1u, bytes[4]); // version 1 + EXPECT_EQ (0u, bytes[5]); + EXPECT_EQ (0u, bytes[6]); + EXPECT_EQ (0u, bytes[7]); +} + +TEST (WasmEmitterTests, Leb128EncodingsAreCorrect) +{ + YdspWasmEmitter emitter; + + emitter.u32 (624485); + EXPECT_EQ ((std::vector { 0xE5, 0x8E, 0x26 }), emitter.getBytes()); + + YdspWasmEmitter emitter2; + emitter2.u32 (127); + EXPECT_EQ ((std::vector { 0x7F }), emitter2.getBytes()); + + YdspWasmEmitter emitter3; + emitter3.u32 (128); + EXPECT_EQ ((std::vector { 0x80, 0x01 }), emitter3.getBytes()); + + YdspWasmEmitter emitter4; + emitter4.s32 (-123456); + EXPECT_EQ ((std::vector { 0xC0, 0xBB, 0x78 }), emitter4.getBytes()); + + YdspWasmEmitter emitter5; + emitter5.s64 (-2); + EXPECT_EQ ((std::vector { 0x7E }), emitter5.getBytes()); +} + +TEST (WasmEmitterTests, SectionPayloadIsSizePrefixed) +{ + YdspWasmEmitter emitter; + emitter.beginModule(); + + emitter.beginSection (YdspWasmEmitter::sectionType); + emitter.u32 (1); + emitter.funcType ({ YdspWasmEmitter::ValType::i32 }, {}); + emitter.endSection(); + + const auto& bytes = emitter.getBytes(); + ASSERT_GE (bytes.size(), 12u); + + EXPECT_EQ (YdspWasmEmitter::sectionType, bytes[8]); // section id + EXPECT_EQ (0x05, bytes[9]); // payload size (5 bytes) + EXPECT_EQ (0x01, bytes[10]); // one type entry + EXPECT_EQ (0x60, bytes[11]); // functype form +} + +TEST (WasmEmitterTests, MemoryImportEncodesSharedEnvMemory) +{ + YdspWasmEmitter emitter; + emitter.beginModule(); + + emitter.beginSection (YdspWasmEmitter::sectionImport); + emitter.u32 (1); + emitter.importMemory ("env", "memory", 1, 65536, true); + emitter.endSection(); + + const auto sections = parseSections (emitter.getBytes()); + ASSERT_EQ (1u, sections.size()); + ASSERT_EQ (YdspWasmEmitter::sectionImport, sections[0].id); + + const auto imports = parseImports (sections[0]); + ASSERT_EQ (1u, imports.size()); + EXPECT_EQ ("env", imports[0].moduleName); + EXPECT_EQ ("memory", imports[0].name); + EXPECT_TRUE (imports[0].isMemory); + EXPECT_TRUE (imports[0].memoryShared); + EXPECT_EQ (1u, imports[0].memoryMin); + EXPECT_EQ (65536u, imports[0].memoryMax); +} + +TEST (WasmEmitterTests, MemoryImportCanBeNonSharedMinOnly) +{ + YdspWasmEmitter emitter; + emitter.beginModule(); + + emitter.beginSection (YdspWasmEmitter::sectionImport); + emitter.u32 (1); + emitter.importMemory ("env", "memory", 1, 0, false); + emitter.endSection(); + + const auto sections = parseSections (emitter.getBytes()); + const auto imports = parseImports (sections[0]); + ASSERT_EQ (1u, imports.size()); + EXPECT_TRUE (imports[0].isMemory); + EXPECT_FALSE (imports[0].memoryShared); + EXPECT_EQ (1u, imports[0].memoryMin); + EXPECT_EQ (0u, imports[0].memoryMax); +} + +TEST (WasmEmitterTests, LocalRunsAreCompressed) +{ + YdspWasmEmitter emitter; + emitter.beginModule(); + + emitter.beginSection (YdspWasmEmitter::sectionCode); + emitter.u32 (1); + emitter.beginBody(); + emitter.declareLocals (3, YdspWasmEmitter::ValType::f32); + emitter.declareLocals (2, YdspWasmEmitter::ValType::i64); + emitter.nop(); + emitter.endBody(); + emitter.endSection(); + + const auto sections = parseSections (emitter.getBytes()); + ASSERT_EQ (1u, sections.size()); + + // payload = [body count=1][body size][vec(locals)=2 groups][3 x f32][2 x i64][nop][end] + // f32 = 0x7D, i64 = 0x7E. + EXPECT_EQ ((std::vector { 0x01, 0x07, 0x02, 0x03, 0x7D, 0x02, 0x7E, 0x01, 0x0B }), + sections[0].payload); +} + +//============================================================================== +// YdspWasmCodegen: module-level structural tests +//============================================================================== + +TEST (YdspWasmTests, CompilesPassThroughToAValidModule) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (passThroughSource, "P", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_FALSE (bytes.empty()); + + const auto sections = parseSections (bytes); + ASSERT_EQ (5u, sections.size()); + + EXPECT_EQ (YdspWasmEmitter::sectionType, sections[0].id); + EXPECT_EQ (YdspWasmEmitter::sectionImport, sections[1].id); + EXPECT_EQ (YdspWasmEmitter::sectionFunction, sections[2].id); + EXPECT_EQ (YdspWasmEmitter::sectionExport, sections[3].id); + EXPECT_EQ (YdspWasmEmitter::sectionCode, sections[4].id); +} + +TEST (YdspWasmTests, ImportsHostMemoryFirst) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (passThroughSource, "P", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (bytes); + + const auto imports = parseImports (sections[1]); + ASSERT_FALSE (imports.empty()); + EXPECT_EQ ("env", imports[0].moduleName); + EXPECT_EQ ("memory", imports[0].name); + EXPECT_TRUE (imports[0].isMemory); + + EXPECT_TRUE (imports[0].memoryShared); + EXPECT_EQ (1u, imports[0].memoryMin); + EXPECT_GE (imports[0].memoryMax, 1u); +} + +TEST (YdspWasmTests, ExportsKernelFunction) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (passThroughSource, "P", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (bytes); + + const auto& exportSection = sections[3]; + size_t pos = 0; + const auto count = readLebU (exportSection.payload, pos); + ASSERT_EQ (1u, count); + + EXPECT_EQ ("ydsp_kernel", readName (exportSection.payload, pos)); + EXPECT_EQ (YdspWasmEmitter::exportKindFunc, exportSection.payload[pos++]); + EXPECT_EQ (0u, readLebU (exportSection.payload, pos)); // no function imports -> defined function 0 +} + +TEST (YdspWasmTests, ToTextRendersReadableWasmListing) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (passThroughSource, "P", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_FALSE (bytes.empty()); + + const auto text = YdspWasmCodegen::toText (bytes); + + EXPECT_TRUE (text.contains ("(module")); + EXPECT_TRUE (text.contains ("(import \"env\" \"memory\"")); + EXPECT_TRUE (text.contains ("(export \"ydsp_kernel\"")); + + EXPECT_TRUE (text.contains ("local.get")); + EXPECT_TRUE (text.contains ("i32.load")); + EXPECT_TRUE (text.contains ("f32.store")); +} + +TEST (YdspWasmTests, ToTextIncludesLibmImportsAndCalls) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (sinF32Source, "P", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + + const auto text = YdspWasmCodegen::toText (bytes); + EXPECT_TRUE (text.contains ("(import \"env\" \"sinf\"")); + EXPECT_TRUE (text.contains ("call 0")); // sinf is function import 0 +} + +TEST (YdspWasmTests, CompilesIfElseIfChainInsideInlinedFunction) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (R"YDSP( + processor P { + input stream in; + output stream out; + state float phase; + + func pick (t: float, dt: float) : float { + float r = 0.0; + if (t < dt) { + r = t + t - 1.0; + } else if (t > 1.0 - dt) { + r = t * t + 1.0; + } + return r; + } + + process { + float dt = 0.01; + phase = phase + dt; + if (phase >= 1.0) { phase = phase - 1.0; } + out = in * 2.0 - pick (phase, dt); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + "P", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_FALSE (bytes.empty()); + + const auto sections = parseSections (bytes); + const auto code = collectCodeInstructions (sections[4]); + + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opIf, YdspWasmEmitter::emptyBlockType })); + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opElse })); +} + +// An `else if` cascade whose arms are early returns lowers through the branch +// terminator path (nested forward branches to a single join). The wasm backend +// must reloop those into nested block/if/else regions; a branch whose target +// escapes the structured region fails codegen with a diagnostic, so compiling +// without errors is the assertion that matters here. +TEST (YdspWasmTests, CompilesElseIfEarlyReturnCascadeInInlinedFunction) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (R"YDSP( + processor P { + input stream in; + output stream out; + + func pick (t: float) : float { + if (t < 0.5) { return 0.2; } + else if (t < 1.5) { return 0.6; } + else if (t < 2.5) { return 1.0; } + else { return 0.4; } + } + + process { + out = pick (in); + } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", + "P", + diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + ASSERT_FALSE (bytes.empty()); + + const auto sections = parseSections (bytes); + const auto code = collectCodeInstructions (sections[4]); + + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opIf, YdspWasmEmitter::emptyBlockType })); + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opElse })); + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opBr })); +} + +TEST (YdspWasmTests, SinF32IsImportedFromEnv) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (sinF32Source, "P", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (bytes); + + const auto imports = parseImports (sections[1]); + ASSERT_EQ (2u, imports.size()); // memory + sinf + + EXPECT_EQ ("env", imports[1].moduleName); + EXPECT_EQ ("sinf", imports[1].name); + EXPECT_EQ (YdspWasmEmitter::importKindFunc, imports[1].kind); + + const auto types = parseFuncTypes (sections[0]); + ASSERT_LT (imports[1].typeIndex, types.size()); + EXPECT_EQ ((std::vector { 0x7D }), types[imports[1].typeIndex].params); + EXPECT_EQ ((std::vector { 0x7D }), types[imports[1].typeIndex].results); +} + +TEST (YdspWasmTests, SinF64IsImportedAsSin) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (sinF64Source, "P", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (bytes); + + const auto imports = parseImports (sections[1]); + ASSERT_EQ (2u, imports.size()); + + EXPECT_EQ ("sin", imports[1].name); + + const auto types = parseFuncTypes (sections[0]); + EXPECT_EQ ((std::vector { 0x7C }), types[imports[1].typeIndex].params); + EXPECT_EQ ((std::vector { 0x7C }), types[imports[1].typeIndex].results); +} + +TEST (YdspWasmTests, PowIsABinaryFunctionImport) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (powSource, "P", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (bytes); + + const auto imports = parseImports (sections[1]); + ASSERT_EQ (2u, imports.size()); + + EXPECT_EQ ("powf", imports[1].name); + + const auto types = parseFuncTypes (sections[0]); + EXPECT_EQ ((std::vector { 0x7D, 0x7D }), types[imports[1].typeIndex].params); + EXPECT_EQ ((std::vector { 0x7D }), types[imports[1].typeIndex].results); +} + +TEST (YdspWasmTests, EmitEventImportsTheCommitFunctionFromEnv) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (emitEventSource, "P", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (bytes); + + const auto imports = parseImports (sections[1]); + ASSERT_EQ (2u, imports.size()); // memory + ydspCommitOutputEvent + + EXPECT_EQ ("env", imports[1].moduleName); + EXPECT_EQ ("ydspCommitOutputEvent", imports[1].name); + EXPECT_EQ (YdspWasmEmitter::importKindFunc, imports[1].kind); + + const auto types = parseFuncTypes (sections[0]); + ASSERT_LT (imports[1].typeIndex, types.size()); + EXPECT_EQ ((std::vector { 0x7F, 0x7F, 0x7F, 0x7F }), types[imports[1].typeIndex].params); + EXPECT_TRUE (types[imports[1].typeIndex].results.empty()); + + // No libm calls in this source, so ydspCommitOutputEvent is function index 0. + const auto code = collectCodeInstructions (sections[4]); + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opCall, 0x00 })); +} + +TEST (YdspWasmTests, FloatToIntegerConversionsUseNonTrappingOpcodesAtEveryWidth) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (R"YDSP( + processor P { + input parameter float32 a = 0.0; + input parameter float64 b = 0.0; + output parameter int32 a32, b32; + output parameter int64 a64, b64; + output stream out; + process { a32 = int32 (a); b32 = int32 (b); a64 = int64 (a); b64 = int64 (b); out = 0.0; } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP", "P", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (bytes); + const auto code = collectCodeInstructions (sections[4]); + for (const uint8_t sub : { 0, 2, 4, 6 }) + EXPECT_TRUE (containsSubsequence (code, { 0xFC, sub })); +} + +TEST (YdspWasmTests, ArrayBoundsUseUnsignedComparison) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (R"YDSP( + processor P { + input stream in; output stream out; + state float data[8]; + process { out = data[int32 (in)]; } + } + graph G { input stream x; output stream y; node p = P; connection { x -> p.in; p.out -> y; } } + )YDSP", "P", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (bytes); + const auto code = collectCodeInstructions (sections[4]); + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opI32LtU })); +} + +TEST (YdspWasmTests, StackValidatorHandlesValueReturningBranches) +{ + // A nested if/else produces one value, which the outer if/else also returns. + const std::vector code { + 0x41, 1, 0x04, 0x7F, + 0x41, 0, 0x04, 0x7F, 0x41, 2, 0x05, 0x41, 3, 0x0B, + 0x05, 0x41, 4, 0x0B, 0x1A, 0x0B + }; + expectBalancedStack (code, {}); +} + +TEST (YdspWasmTests, SaturatingIntegerArithmeticEmitsBalancedOverflowPaths) +{ + for (const bool wide : { false, true }) + { + YdspDiagnostics diagnostics; + const auto source = String (R"YDSP( + processor P { + input parameter TYPE a = 0, b = 0; + output parameter TYPE sum, difference, product; + output stream out; + process { sum = a + b; difference = a - b; product = a * b; out = 0.0; } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP").replace ("TYPE", wide ? "int64" : "int32"); + auto bytes = compileWasm (source, "P", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (bytes); + const auto code = collectCodeInstructions (sections[4]); + EXPECT_TRUE (containsSubsequence (code, { wide ? YdspWasmEmitter::opI64DivU : YdspWasmEmitter::opI32DivU })); + EXPECT_TRUE (containsSubsequence (code, { wide ? YdspWasmEmitter::opI64GtU : YdspWasmEmitter::opI32GtU })); + expectBalancedStack (code, sections); + } +} + +TEST (YdspWasmTests, IntegerDivisionGuardsZeroDivisor) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (intDivSource, "P", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (bytes); + const auto code = collectCodeInstructions (sections[4]); + + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opI32Eqz, YdspWasmEmitter::opIf, 0x7F })); + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opI32DivS })); +} + +TEST (YdspWasmTests, FloatModuloUsesTruncOpcode) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (modSource, "P", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (bytes); + const auto code = collectCodeInstructions (sections[4]); + + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opF32Trunc })); +} + +TEST (YdspWasmTests, StateAccessLoadsTheStatePointerFromCtx) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (stateSource, "P", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (bytes); + const auto code = collectCodeInstructions (sections[4]); + + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opI32Load, 0x02, 0x10 })); +} + +TEST (YdspWasmTests, IfElseEmitsBlockAndIfElse) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (ifElseSource, "P", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (bytes); + const auto code = collectCodeInstructions (sections[4]); + + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opBlock, YdspWasmEmitter::emptyBlockType })); + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opIf, YdspWasmEmitter::emptyBlockType })); + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opElse })); +} + +TEST (YdspWasmTests, LoopsEmitBlockLoopBrIfAndBr) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (forLoopSource, "P", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (bytes); + const auto code = collectCodeInstructions (sections[4]); + + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opBlock, YdspWasmEmitter::emptyBlockType })); + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opLoop, YdspWasmEmitter::emptyBlockType })); + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opI32Eqz, YdspWasmEmitter::opBrIf })); + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::opBr })); +} + +TEST (YdspWasmTests, DoubleKernelDeclaresF64Locals) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (sinF64Source, "P", diagnostics); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (bytes); + + const auto& payload = sections[4].payload; + EXPECT_NE (payload.end(), std::find (payload.begin(), payload.end(), 0x7C)); +} + +//============================================================================== +// SIMD lowering: the vectoriser widens the constant-bound bank loop to four +// f32 lanes. Without -msimd128 the wasm backend rejects a widened function; +// with it, the module carries f32x4 opcodes. + +#if ! defined (__wasm_simd128__) +TEST (YdspWasmTests, VectorizedKernelIsRejectedWithoutSimd) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (wasmVectorBankSource, "P", diagnostics, true); + + EXPECT_TRUE (bytes.empty()); + ASSERT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (diagnostics.getItem (0).message.contains ("msimd128")); +} +#endif + +#if defined (__wasm_simd128__) +TEST (YdspWasmTests, VectorizedKernelEmitsF32x4Opcodes) +{ + YdspDiagnostics diagnostics; + auto bytes = compileWasm (wasmVectorBankSource, "P", diagnostics, true); + + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (bytes); + const auto code = collectCodeInstructions (sections[4]); + + // The module must be stack-balanced: a missing operand would only be + // caught by the wasm engine at instantiation, so validate it here. + expectBalancedStack (code, sections); + + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::simdPrefix, YdspWasmEmitter::opV128Load })); + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::simdPrefix, YdspWasmEmitter::opV128Store })); + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::simdPrefix, YdspWasmEmitter::opF32x4Splat })); + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::simdPrefix, YdspWasmEmitter::opF32x4Mul })); + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::simdPrefix, YdspWasmEmitter::opF32x4Add })); + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::simdPrefix, YdspWasmEmitter::opI8x16Shuffle })); + EXPECT_TRUE (containsSubsequence (code, { YdspWasmEmitter::simdPrefix, YdspWasmEmitter::opF32x4ExtractLane })); + + const auto text = YdspWasmCodegen::toText (bytes); + + EXPECT_TRUE (text.contains ("v128.load")); + EXPECT_TRUE (text.contains ("v128.store")); + EXPECT_TRUE (text.contains ("f32x4.splat")); + EXPECT_TRUE (text.contains ("f32x4.mul")); + EXPECT_TRUE (text.contains ("f32x4.add")); + EXPECT_TRUE (text.contains ("i8x16.shuffle")); + EXPECT_TRUE (text.contains ("f32x4.extract_lane")); +} + +TEST (YdspWasmTests, RejectsVectorWidthsBeyondF32x4) +{ + YdspDiagnostics diagnostics; + YdspLexer lexer (wasmVectorBankSource, diagnostics); + auto tokens = lexer.tokenize(); + + YdspParser parser (std::move (tokens), diagnostics); + auto program = parser.parseProgram(); + ASSERT_NE (nullptr, program); + + YdspSemanticAnalyzer analyzer (diagnostics); + auto analyzed = analyzer.analyze (std::move (program)); + ASSERT_NE (nullptr, analyzed); + + YdspOptimizer optimizer (diagnostics); + optimizer.setVectorizationEnabled (true); + optimizer.setVectorWidth (8); // AVX2 width; wasm SIMD is 128-bit only + auto ir = optimizer.build (*analyzed); + + ASSERT_NE (nullptr, ir); + ASSERT_FALSE (ir->kernels.empty()); + ASSERT_TRUE (ir->kernels[0]->vectorized); + + auto bytes = YdspWasmCodegen::compile (*ir->kernels[0], diagnostics); + + EXPECT_TRUE (bytes.empty()); + ASSERT_TRUE (diagnostics.hasErrors()); + EXPECT_TRUE (diagnostics.getItem (0).message.contains ("width")); +} +#endif + +TEST (YdspWasmTests, TraceImportsHaveBalancedCallsAndDisappearWhenDisabled) +{ + const String source = R"YDSP( + processor P { + output stream out; + process { + int x = -123; int64 y = 9007199254740993; + float f = 0.5; float64 d = 0.25; bool b = true; + trace("{x} {y} {f} {d} {b}"); + out = 0.0; + } + } + graph G { output stream y; node p = P; connection { p.out -> y; } } + )YDSP"; + YdspDiagnostics diagnostics; + const auto enabled = compileWasm (source, "P", diagnostics, false, true); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (enabled); + ASSERT_EQ (5u, sections.size()); + const auto imports = parseImports (sections[1]); + for (const auto* name : { "ydspTraceInt", "ydspTraceFloat", "ydspTraceCommit" }) + EXPECT_TRUE (std::any_of (imports.begin(), imports.end(), [name] (const WasmImport& item) { return item.name == name; })); + expectBalancedStack (collectCodeInstructions (sections[4]), sections); + const auto disabled = compileWasm (source, "P", diagnostics); + const auto absent = compileWasm (source.replace ("trace(\"{x} {y} {f} {d} {b}\");", ""), "P", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + EXPECT_EQ (absent, disabled); +} + +TEST (YdspWasmTests, MatchBranchesHaveBalancedStacks) +{ + YdspDiagnostics diagnostics; + const auto bytes = compileWasm (R"YDSP( + processor P { + input stream in; output stream out; + func classify (x: int) : float { + match (x) { -1 => { return -0.5; }, _ => { return 0.25; } } + } + process { + match (int (in)) { + -1 => { out = classify (int (in)); }, + 2 => { match (in > 0.0) { true => { out = 0.5; }, _ => { out = 0.0; } } }, + _ => { out = 1.0; } + } + } + } + graph G { input stream x; output stream y; node p = P; + connection { x -> p.in; p.out -> y; } } + )YDSP", "P", diagnostics); + ASSERT_FALSE (diagnostics.hasErrors()) << diagnostics.toString(); + const auto sections = parseSections (bytes); + ASSERT_EQ (5u, sections.size()); + expectBalancedStack (collectCodeInstructions (sections[4]), sections); +} diff --git a/tests/yup_events.cpp b/tests/yup_events/module.cpp similarity index 80% rename from tests/yup_events.cpp rename to tests/yup_events/module.cpp index 461fbb205..fd2285f2d 100644 --- a/tests/yup_events.cpp +++ b/tests/yup_events/module.cpp @@ -19,9 +19,9 @@ ============================================================================== */ -#include "mocks/yup_events.h" +#include "../mocks/yup_events.h" -#include "yup_events/yup_Timer.cpp" -#include "yup_events/yup_MessageManager.cpp" -#include "yup_events/yup_InterprocessConnection.cpp" -#include "yup_events/yup_ToastNotification.cpp" +#include "yup_Timer.cpp" +#include "yup_MessageManager.cpp" +#include "yup_InterprocessConnection.cpp" +#include "yup_ToastNotification.cpp" diff --git a/tests/yup_graphics.cpp b/tests/yup_graphics.cpp deleted file mode 100644 index 6124167a2..000000000 --- a/tests/yup_graphics.cpp +++ /dev/null @@ -1,80 +0,0 @@ -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2025 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ - -#include "mocks/rive_gpu.h" -#include "mocks/rive_ore.h" -#include "mocks/yup_graphics.h" - -#include "yup_graphics/yup_AffineTransform.cpp" -#include "yup_graphics/yup_Color.cpp" -#include "yup_graphics/yup_ColorGradient.cpp" -#include "yup_graphics/yup_CubicBezier.cpp" -#include "yup_graphics/yup_Drawable.cpp" -#include "yup_graphics/yup_Font.cpp" -#include "yup_graphics/yup_Graphics.cpp" -#include "yup_graphics/yup_GraphicsContext.cpp" -#include "yup_graphics/yup_GraphicsOffscreen.cpp" -#include "yup_graphics/yup_GpuCanvas.cpp" -#include "yup_graphics/yup_Image.cpp" -#include "yup_graphics/yup_ImageFormatManager.cpp" -#include "yup_graphics/yup_ImageFormatMetadataExtended.cpp" -#include "yup_graphics/yup_ImageFormatReader.cpp" -#include "yup_graphics/yup_ImageFormatWriter.cpp" -#include "yup_graphics/yup_ImageFileIO.cpp" -#include "yup_graphics/yup_ImageDataFiles.cpp" -#include "yup_graphics/yup_ImageFormats.cpp" -#include "yup_graphics/yup_ImageMetadata.cpp" -#include "yup_graphics/yup_ImageFormatMetadata.cpp" -#include "yup_graphics/yup_Line.cpp" -#include "yup_graphics/yup_Path.cpp" -#include "yup_graphics/yup_Point.cpp" -#include "yup_graphics/yup_Rectangle.cpp" -#include "yup_graphics/yup_RectangleList.cpp" -#include "yup_graphics/yup_Size.cpp" -#include "yup_graphics/yup_StrokeType.cpp" -#include "yup_graphics/yup_StyledText.cpp" -#include "yup_graphics/yup_SVGDocument.cpp" -#include "yup_graphics/yup_SVGParser.cpp" - -#if YUP_IMAGE_FORMAT_BMP -#include "yup_graphics/yup_BmpImageFormat.cpp" -#endif -#if YUP_IMAGE_FORMAT_PPM -#include "yup_graphics/yup_PpmImageFormat.cpp" -#endif -#if YUP_IMAGE_FORMAT_TGA -#include "yup_graphics/yup_TgaImageFormat.cpp" -#endif -#if YUP_MODULE_AVAILABLE_libpng && YUP_IMAGE_FORMAT_PNG -#include "yup_graphics/yup_PngImageFormat.cpp" -#endif -#if YUP_MODULE_AVAILABLE_libjpeg && YUP_IMAGE_FORMAT_JPEG -#include "yup_graphics/yup_JpegImageFormat.cpp" -#endif -#if YUP_MODULE_AVAILABLE_libwebp && YUP_IMAGE_FORMAT_WEBP -#include "yup_graphics/yup_WebPImageFormat.cpp" -#endif -#if YUP_MODULE_AVAILABLE_libgif && YUP_IMAGE_FORMAT_GIF -#include "yup_graphics/yup_GifImageFormat.cpp" -#endif -#if YUP_MODULE_AVAILABLE_libtiff && YUP_IMAGE_FORMAT_TIFF -#include "yup_graphics/yup_TiffImageFormat.cpp" -#endif diff --git a/tests/yup_graphics/module.cpp b/tests/yup_graphics/module.cpp new file mode 100644 index 000000000..ed0f7397b --- /dev/null +++ b/tests/yup_graphics/module.cpp @@ -0,0 +1,80 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "../mocks/rive_gpu.h" +#include "../mocks/rive_ore.h" +#include "../mocks/yup_graphics.h" + +#include "yup_AffineTransform.cpp" +#include "yup_Color.cpp" +#include "yup_ColorGradient.cpp" +#include "yup_CubicBezier.cpp" +#include "yup_Drawable.cpp" +#include "yup_Font.cpp" +#include "yup_Graphics.cpp" +#include "yup_GraphicsContext.cpp" +#include "yup_GraphicsOffscreen.cpp" +#include "yup_GpuCanvas.cpp" +#include "yup_Image.cpp" +#include "yup_ImageFormatManager.cpp" +#include "yup_ImageFormatMetadataExtended.cpp" +#include "yup_ImageFormatReader.cpp" +#include "yup_ImageFormatWriter.cpp" +#include "yup_ImageFileIO.cpp" +#include "yup_ImageDataFiles.cpp" +#include "yup_ImageFormats.cpp" +#include "yup_ImageMetadata.cpp" +#include "yup_ImageFormatMetadata.cpp" +#include "yup_Line.cpp" +#include "yup_Path.cpp" +#include "yup_Point.cpp" +#include "yup_Rectangle.cpp" +#include "yup_RectangleList.cpp" +#include "yup_Size.cpp" +#include "yup_StrokeType.cpp" +#include "yup_StyledText.cpp" +#include "yup_SVGDocument.cpp" +#include "yup_SVGParser.cpp" + +#if YUP_IMAGE_FORMAT_BMP +#include "yup_BmpImageFormat.cpp" +#endif +#if YUP_IMAGE_FORMAT_PPM +#include "yup_PpmImageFormat.cpp" +#endif +#if YUP_IMAGE_FORMAT_TGA +#include "yup_TgaImageFormat.cpp" +#endif +#if YUP_MODULE_AVAILABLE_libpng && YUP_IMAGE_FORMAT_PNG +#include "yup_PngImageFormat.cpp" +#endif +#if YUP_MODULE_AVAILABLE_libjpeg && YUP_IMAGE_FORMAT_JPEG +#include "yup_JpegImageFormat.cpp" +#endif +#if YUP_MODULE_AVAILABLE_libwebp && YUP_IMAGE_FORMAT_WEBP +#include "yup_WebPImageFormat.cpp" +#endif +#if YUP_MODULE_AVAILABLE_libgif && YUP_IMAGE_FORMAT_GIF +#include "yup_GifImageFormat.cpp" +#endif +#if YUP_MODULE_AVAILABLE_libtiff && YUP_IMAGE_FORMAT_TIFF +#include "yup_TiffImageFormat.cpp" +#endif diff --git a/tests/yup_gui.cpp b/tests/yup_gui.cpp deleted file mode 100644 index 23aea3f7a..000000000 --- a/tests/yup_gui.cpp +++ /dev/null @@ -1,71 +0,0 @@ -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2025 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ - -#include "mocks/yup_gui.h" - -#include "yup_gui/yup_Application.cpp" -#include "yup_gui/yup_ApplicationTheme.cpp" -#include "yup_gui/yup_Artboard.cpp" -#include "yup_gui/yup_ArtboardFile.cpp" -#include "yup_gui/yup_ArtboardNode.cpp" -#include "yup_gui/yup_ArtboardViewModel.cpp" -#include "yup_gui/yup_ArtboardViewModelInstance.cpp" -#include "yup_gui/yup_CodeDocument.cpp" -#include "yup_gui/yup_CodeEditor.cpp" -#include "yup_gui/yup_CodeEditorScheme.cpp" -#include "yup_gui/yup_CodeTokeniser.cpp" -#include "yup_gui/yup_ComboBox.cpp" -#include "yup_gui/yup_Component.cpp" -#include "yup_gui/yup_ComponentNative.cpp" -#include "yup_gui/yup_ComponentEffect.cpp" -#include "yup_gui/yup_Desktop.cpp" -#include "yup_gui/yup_DragAndDropData.cpp" -#include "yup_gui/yup_DragAndDropManager.cpp" -#include "yup_gui/yup_DragAndDropSource.cpp" -#include "yup_gui/yup_DragAndDropTarget.cpp" -#include "yup_gui/yup_DragImageComponent.cpp" -#include "yup_gui/yup_FileChooser.cpp" -#include "yup_gui/yup_FlexBox.cpp" -#include "yup_gui/yup_FlexBoxParity.cpp" -#include "yup_gui/yup_Grid.cpp" -#include "yup_gui/yup_GridParity.cpp" -#include "yup_gui/yup_KeyModifiers.cpp" -#include "yup_gui/yup_KeyPress.cpp" -#include "yup_gui/yup_Label.cpp" -#include "yup_gui/yup_ListBox.cpp" -#include "yup_gui/yup_ListBoxItem.cpp" -#include "yup_gui/yup_MouseCursor.cpp" -#include "yup_gui/yup_MouseEvent.cpp" -#include "yup_gui/yup_MouseWheelData.cpp" -#include "yup_gui/yup_PaintProfileStats.cpp" -#include "yup_gui/yup_PaintProfiler.cpp" -#include "yup_gui/yup_PopupMenu.cpp" -#include "yup_gui/yup_ProgressBar.cpp" -#include "yup_gui/yup_ScrollBar.cpp" -#include "yup_gui/yup_Slider.cpp" -#include "yup_gui/yup_SwitchButton.cpp" -#include "yup_gui/yup_SystemClipboard.cpp" -#include "yup_gui/yup_SyntaxDefinition.cpp" -#include "yup_gui/yup_TextButton.cpp" -#include "yup_gui/yup_TextEditor.cpp" -#include "yup_gui/yup_TextInputTarget.cpp" -#include "yup_gui/yup_ThemeVersion1.cpp" -#include "yup_gui/yup_ToggleButton.cpp" diff --git a/tests/yup_gui/module.cpp b/tests/yup_gui/module.cpp new file mode 100644 index 000000000..d9e2ba2fb --- /dev/null +++ b/tests/yup_gui/module.cpp @@ -0,0 +1,71 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "../mocks/yup_gui.h" + +#include "yup_Application.cpp" +#include "yup_ApplicationTheme.cpp" +#include "yup_Artboard.cpp" +#include "yup_ArtboardFile.cpp" +#include "yup_ArtboardNode.cpp" +#include "yup_ArtboardViewModel.cpp" +#include "yup_ArtboardViewModelInstance.cpp" +#include "yup_CodeDocument.cpp" +#include "yup_CodeEditor.cpp" +#include "yup_CodeEditorScheme.cpp" +#include "yup_CodeTokeniser.cpp" +#include "yup_ComboBox.cpp" +#include "yup_Component.cpp" +#include "yup_ComponentNative.cpp" +#include "yup_ComponentEffect.cpp" +#include "yup_Desktop.cpp" +#include "yup_DragAndDropData.cpp" +#include "yup_DragAndDropManager.cpp" +#include "yup_DragAndDropSource.cpp" +#include "yup_DragAndDropTarget.cpp" +#include "yup_DragImageComponent.cpp" +#include "yup_FileChooser.cpp" +#include "yup_FlexBox.cpp" +#include "yup_FlexBoxParity.cpp" +#include "yup_Grid.cpp" +#include "yup_GridParity.cpp" +#include "yup_KeyModifiers.cpp" +#include "yup_KeyPress.cpp" +#include "yup_Label.cpp" +#include "yup_ListBox.cpp" +#include "yup_ListBoxItem.cpp" +#include "yup_MouseCursor.cpp" +#include "yup_MouseEvent.cpp" +#include "yup_MouseWheelData.cpp" +#include "yup_PaintProfileStats.cpp" +#include "yup_PaintProfiler.cpp" +#include "yup_PopupMenu.cpp" +#include "yup_ProgressBar.cpp" +#include "yup_ScrollBar.cpp" +#include "yup_Slider.cpp" +#include "yup_SwitchButton.cpp" +#include "yup_SystemClipboard.cpp" +#include "yup_SyntaxDefinition.cpp" +#include "yup_TextButton.cpp" +#include "yup_TextEditor.cpp" +#include "yup_TextInputTarget.cpp" +#include "yup_ThemeVersion1.cpp" +#include "yup_ToggleButton.cpp" diff --git a/tests/yup_gui/yup_Component.cpp b/tests/yup_gui/yup_Component.cpp index 3cdb12df7..2d1453994 100644 --- a/tests/yup_gui/yup_Component.cpp +++ b/tests/yup_gui/yup_Component.cpp @@ -3218,6 +3218,44 @@ TEST (ComponentHitTestTests, AHitTestOverrideCannotClaimPointsOutsideItsOwnBound EXPECT_EQ (&parent, parent.findComponentAt (Point (50.0f, 50.0f))); } +TEST (ComponentHitTestTests, FindComponentAtForMouseEventFallsThroughAComponentThatRejectsThePoint) +{ + Component parent; + parent.setBounds (0.0f, 0.0f, 100.0f, 100.0f); + parent.setVisible (true); + + RightHalfOnlyComponent child; + child.setBounds (0.0f, 0.0f, 100.0f, 100.0f); + parent.addAndMakeVisible (child); + + EXPECT_EQ (&child, parent.findComponentAtForMouseEvent (Point (75.0f, 50.0f))); + EXPECT_EQ (&parent, parent.findComponentAtForMouseEvent (Point (25.0f, 50.0f))); +} + +TEST (ComponentHitTestTests, FindComponentAtForMouseEventReturnsNothingWhenTheRootRejectsThePoint) +{ + RightHalfOnlyComponent root; + root.setBounds (0.0f, 0.0f, 100.0f, 100.0f); + root.setVisible (true); + + EXPECT_EQ (&root, root.findComponentAtForMouseEvent (Point (75.0f, 50.0f))); + EXPECT_EQ (nullptr, root.findComponentAtForMouseEvent (Point (25.0f, 50.0f))); +} + +TEST (ComponentHitTestTests, FindComponentAtForMouseEventKeepsAHitTestOverrideInsideItsOwnBounds) +{ + Component parent; + parent.setBounds (0.0f, 0.0f, 100.0f, 100.0f); + parent.setVisible (true); + + AlwaysHitComponent child; + child.setBounds (0.0f, 0.0f, 10.0f, 10.0f); + parent.addAndMakeVisible (child); + + EXPECT_EQ (&child, parent.findComponentAtForMouseEvent (Point (5.0f, 5.0f))); + EXPECT_EQ (&parent, parent.findComponentAtForMouseEvent (Point (50.0f, 50.0f))); +} + // ============================================================================= // childBoundsChanged // ============================================================================= diff --git a/tests/yup_gui/yup_SyntaxDefinition.cpp b/tests/yup_gui/yup_SyntaxDefinition.cpp index 5f1d1c1f3..dc6c4b5a4 100644 --- a/tests/yup_gui/yup_SyntaxDefinition.cpp +++ b/tests/yup_gui/yup_SyntaxDefinition.cpp @@ -110,6 +110,7 @@ TEST (SyntaxDefinitionTests, BuiltInForExtension) EXPECT_EQ (String ("Python"), SyntaxDefinition::getBuiltInForExtension ("py")->getName()); EXPECT_EQ (String ("XML"), SyntaxDefinition::getBuiltInForExtension ("xml")->getName()); EXPECT_EQ (String ("XML"), SyntaxDefinition::getBuiltInForExtension ("svg")->getName()); + EXPECT_EQ (String ("YDSP"), SyntaxDefinition::getBuiltInForExtension ("ydsp")->getName()); EXPECT_EQ (nullptr, SyntaxDefinition::getBuiltInForExtension ("xyz")); } @@ -146,6 +147,39 @@ TEST (SyntaxDefinitionTests, BuiltInXmlLoads) EXPECT_FALSE (definition.isOperator ("word")); } +// ============================================================================== +// Built-in YDSP +// ============================================================================== + +TEST (SyntaxDefinitionTests, BuiltInYdspLoads) +{ + const auto& definition = SyntaxDefinition::getBuiltIn ("ydsp"); + + EXPECT_EQ (String ("YDSP"), definition.getName()); + EXPECT_TRUE (definition.isKeyword ("processor")); + EXPECT_TRUE (definition.isKeyword ("graph")); + EXPECT_TRUE (definition.isKeyword ("import")); + EXPECT_TRUE (definition.isKeyword ("as")); + + EXPECT_TRUE (definition.isType ("float32")); + EXPECT_TRUE (definition.isType ("int64")); + EXPECT_FALSE (definition.isType ("processor")); + + EXPECT_EQ (String ("//"), definition.getLineCommentPrefix()); + ASSERT_TRUE (definition.getBlockComment().has_value()); + EXPECT_EQ (String ("/*"), definition.getBlockComment()->start); + EXPECT_EQ (String ("*/"), definition.getBlockComment()->end); + + // Faust-style composition algebra operators. + EXPECT_TRUE (definition.isOperator ("<:")); + EXPECT_TRUE (definition.isOperator (":>")); + EXPECT_TRUE (definition.isOperator ("~")); + EXPECT_TRUE (definition.isOperator ("->")); + EXPECT_TRUE (definition.isOperator ("[[")); + EXPECT_TRUE (definition.isOperator ("]]")); + EXPECT_FALSE (definition.isOperator ("word")); +} + TEST (SyntaxDefinitionTests, UnknownBuiltInIsInert) { const auto& definition = SyntaxDefinition::getBuiltIn ("unknown"); diff --git a/tests/yup_python.cpp b/tests/yup_python/module.cpp similarity index 90% rename from tests/yup_python.cpp rename to tests/yup_python/module.cpp index 72411abd6..a089eca32 100644 --- a/tests/yup_python.cpp +++ b/tests/yup_python/module.cpp @@ -19,5 +19,5 @@ ============================================================================== */ -#include "yup_python/yup_ScriptEngine.cpp" -#include "yup_python/yup_ScriptPython.cpp" +#include "yup_ScriptEngine.cpp" +#include "yup_ScriptPython.cpp" diff --git a/tests/yup_rhi.cpp b/tests/yup_rhi.cpp deleted file mode 100644 index 52b8b3a01..000000000 --- a/tests/yup_rhi.cpp +++ /dev/null @@ -1,36 +0,0 @@ -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2025 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ - -#include "mocks/rive_gpu.h" -#include "mocks/rive_ore.h" -#include "mocks/yup_rhi.h" -#include "mocks/yup_graphics.h" - -#include "yup_rhi/yup_GpuDevice.cpp" -#include "yup_rhi/yup_GpuTarget.cpp" -#include "yup_rhi/yup_GpuTexture.cpp" -#include "yup_rhi/yup_GpuPipeline.cpp" -#include "yup_rhi/yup_GpuPipelineMocked.cpp" -#include "yup_rhi/yup_GpuComputePass.cpp" - -#if YUP_LINUX -#include "yup_rhi/native/yup_GpuDevice_linux.cpp" -#endif diff --git a/tests/yup_data_model.cpp b/tests/yup_rhi/module.cpp similarity index 69% rename from tests/yup_data_model.cpp rename to tests/yup_rhi/module.cpp index 8393ce4c1..870f29896 100644 --- a/tests/yup_data_model.cpp +++ b/tests/yup_rhi/module.cpp @@ -19,9 +19,18 @@ ============================================================================== */ -#include "yup_data_model/yup_CachedValue.cpp" -#include "yup_data_model/yup_DataTree.cpp" -#include "yup_data_model/yup_DataTreeObjectList.cpp" -#include "yup_data_model/yup_DataTreeQuery.cpp" -#include "yup_data_model/yup_DataTreeSchema.cpp" -#include "yup_data_model/yup_UndoManager.cpp" +#include "../mocks/rive_gpu.h" +#include "../mocks/rive_ore.h" +#include "../mocks/yup_rhi.h" +#include "../mocks/yup_graphics.h" + +#include "yup_GpuDevice.cpp" +#include "yup_GpuTarget.cpp" +#include "yup_GpuTexture.cpp" +#include "yup_GpuPipeline.cpp" +#include "yup_GpuPipelineMocked.cpp" +#include "yup_GpuComputePass.cpp" + +#if YUP_LINUX +#include "native/yup_GpuDevice_linux.cpp" +#endif diff --git a/tests/yup_shading.cpp b/tests/yup_shading/module.cpp similarity index 82% rename from tests/yup_shading.cpp rename to tests/yup_shading/module.cpp index 2b7ad0d0f..f1a8a30af 100644 --- a/tests/yup_shading.cpp +++ b/tests/yup_shading/module.cpp @@ -19,7 +19,7 @@ ============================================================================== */ -#include "yup_shading/yup_ShaderCache.cpp" -#include "yup_shading/yup_ShaderTranspiler.cpp" -#include "yup_shading/yup_ShaderBundle.cpp" -#include "yup_shading/yup_WgslTranspiler.cpp" +#include "yup_ShaderCache.cpp" +#include "yup_ShaderTranspiler.cpp" +#include "yup_ShaderBundle.cpp" +#include "yup_WgslTranspiler.cpp" diff --git a/tests/yup_simd.cpp b/tests/yup_simd.cpp deleted file mode 100644 index aa74f453e..000000000 --- a/tests/yup_simd.cpp +++ /dev/null @@ -1,27 +0,0 @@ -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2025 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ - -#include "yup_simd/yup_FloatVectorOperations.cpp" -#include "yup_simd/yup_ComplexVectorOperations.cpp" -#include "yup_simd/yup_AffineTransformOperations.cpp" -#include "yup_simd/yup_ColorVectorOperations.cpp" -#include "yup_simd/yup_SIMDRegister.cpp" -#include "yup_simd/yup_Vec.cpp" diff --git a/tests/yup_simd/module.cpp b/tests/yup_simd/module.cpp new file mode 100644 index 000000000..b9f02a94e --- /dev/null +++ b/tests/yup_simd/module.cpp @@ -0,0 +1,27 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_FloatVectorOperations.cpp" +#include "yup_ComplexVectorOperations.cpp" +#include "yup_AffineTransformOperations.cpp" +#include "yup_ColorVectorOperations.cpp" +#include "yup_SIMDRegister.cpp" +#include "yup_Vec.cpp" diff --git a/thirdparty/asmjit_library/asmjit_library.cpp b/thirdparty/asmjit_library/asmjit_library.cpp new file mode 100644 index 000000000..6e90b5c4a --- /dev/null +++ b/thirdparty/asmjit_library/asmjit_library.cpp @@ -0,0 +1,61 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "asmjit_library.h" + +// ===== core ===== +#include "upstream/asmjit/core/arch_traits.cpp" +#include "upstream/asmjit/core/assembler.cpp" +#include "upstream/asmjit/core/builder.cpp" +#include "upstream/asmjit/core/code_holder.cpp" +#include "upstream/asmjit/core/code_writer.cpp" +#include "upstream/asmjit/core/compiler.cpp" +#include "upstream/asmjit/core/const_pool.cpp" +#include "upstream/asmjit/core/cpu_info.cpp" +#include "upstream/asmjit/core/debug_utils.cpp" +#include "upstream/asmjit/core/emit_helper.cpp" +#include "upstream/asmjit/core/emitter_utils.cpp" +#include "upstream/asmjit/core/emitter.cpp" +#include "upstream/asmjit/core/environment.cpp" +#include "upstream/asmjit/core/error_handler.cpp" +#include "upstream/asmjit/core/error.cpp" +#include "upstream/asmjit/core/formatter.cpp" +#include "upstream/asmjit/core/func_args_context.cpp" +#include "upstream/asmjit/core/func.cpp" +#include "upstream/asmjit/core/inst_db.cpp" +#include "upstream/asmjit/core/inst.cpp" +#include "upstream/asmjit/core/jit_allocator.cpp" +#include "upstream/asmjit/core/jit_runtime.cpp" +#include "upstream/asmjit/core/logger.cpp" +#include "upstream/asmjit/core/os_utils.cpp" +#include "upstream/asmjit/core/ra_local.cpp" +#include "upstream/asmjit/core/ra_pass.cpp" +#include "upstream/asmjit/core/ra_stack.cpp" +#include "upstream/asmjit/core/string.cpp" +#include "upstream/asmjit/core/target.cpp" +#include "upstream/asmjit/core/type.cpp" +#include "upstream/asmjit/core/virt_mem.cpp" + +// ===== axl ===== +#include "upstream/asmjit/axl/arena.cpp" +#include "upstream/asmjit/axl/arena_bit_set.cpp" +#include "upstream/asmjit/axl/arena_hash.cpp" +#include "upstream/asmjit/axl/arena_vector.cpp" diff --git a/thirdparty/asmjit_library/asmjit_library.h b/thirdparty/asmjit_library/asmjit_library.h new file mode 100644 index 000000000..c5c903db1 --- /dev/null +++ b/thirdparty/asmjit_library/asmjit_library.h @@ -0,0 +1,77 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +/* + ============================================================================== + + BEGIN_YUP_MODULE_DECLARATION + + ID: asmjit + vendor: asmjit + version: 1.22.0 + name: AsmJit + description: AsmJit is a lightweight library for generating machine code at runtime. + website: https://asmjit.com + license: Zlib + + defines: ASMJIT_STATIC=1 ASMJIT_NO_SHM_OPEN=1 + searchpaths: upstream + + END_YUP_MODULE_DECLARATION + + ============================================================================== +*/ + +#pragma once + +// WebAssembly hosts can emit native code but cannot install or execute it. +#if defined(__wasm__) && !defined(ASMJIT_NO_JIT) + #define ASMJIT_NO_JIT +#endif + +#if defined(_M_X64) || defined(__x86_64__) + #define ASMJIT_ARCH_X86 64 +#elif defined(_M_IX86) || defined(__X86__) || defined(__i386__) + #define ASMJIT_ARCH_X86 32 +#else + #define ASMJIT_ARCH_X86 0 +#endif + +#if defined(_M_ARM64) || defined(__arm64__) || defined(__aarch64__) + #define ASMJIT_ARCH_ARM 64 +#elif defined(_M_ARM) || defined(_M_ARMT) || defined(__arm__) || defined(__thumb__) || defined(__thumb2__) + #define ASMJIT_ARCH_ARM 32 +#else + #define ASMJIT_ARCH_ARM 0 +#endif + +#include + +#if !defined(ASMJIT_NO_X86) + #include +#endif + +#if !defined(ASMJIT_NO_AARCH64) + #include +#endif + +#include +#include diff --git a/thirdparty/asmjit_library/asmjit_library_arm.cpp b/thirdparty/asmjit_library/asmjit_library_arm.cpp new file mode 100644 index 000000000..9c39c2280 --- /dev/null +++ b/thirdparty/asmjit_library/asmjit_library_arm.cpp @@ -0,0 +1,33 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "asmjit_library.h" + +#include "upstream/asmjit/arm/arm_formatter.cpp" +#include "upstream/asmjit/arm/a64_assembler.cpp" +#include "upstream/asmjit/arm/a64_builder.cpp" +#include "upstream/asmjit/arm/a64_compiler.cpp" +#include "upstream/asmjit/arm/a64_emit_helper.cpp" +#include "upstream/asmjit/arm/a64_formatter.cpp" +#include "upstream/asmjit/arm/a64_func.cpp" +#include "upstream/asmjit/arm/a64_inst_api.cpp" +#include "upstream/asmjit/arm/a64_inst_db.cpp" +#include "upstream/asmjit/arm/a64_ra_pass.cpp" diff --git a/thirdparty/asmjit_library/asmjit_library_ujit.cpp b/thirdparty/asmjit_library/asmjit_library_ujit.cpp new file mode 100644 index 000000000..b31b822bd --- /dev/null +++ b/thirdparty/asmjit_library/asmjit_library_ujit.cpp @@ -0,0 +1,26 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "asmjit_library.h" + +#include "upstream/asmjit/ujit/uni_compiler_a64.cpp" +#include "upstream/asmjit/ujit/uni_compiler_x86.cpp" +#include "upstream/asmjit/ujit/vec_const_table.cpp" diff --git a/tests/yup_audio_plugin_host.cpp b/thirdparty/asmjit_library/asmjit_library_x86.cpp similarity index 65% rename from tests/yup_audio_plugin_host.cpp rename to thirdparty/asmjit_library/asmjit_library_x86.cpp index 269b83d00..32cebe351 100644 --- a/tests/yup_audio_plugin_host.cpp +++ b/thirdparty/asmjit_library/asmjit_library_x86.cpp @@ -19,8 +19,14 @@ ============================================================================== */ -#include "yup_audio_plugin_host/yup_AudioPluginDescription.cpp" -#include "yup_audio_plugin_host/yup_AudioPluginLV2Format.cpp" -#include "yup_audio_plugin_host/yup_AudioPluginScanner.cpp" -#include "yup_audio_plugin_host/yup_AudioPluginInstance.cpp" -#include "yup_audio_plugin_host/yup_AudioPluginState.cpp" +#include "asmjit_library.h" + +#include "upstream/asmjit/x86/x86_assembler.cpp" +#include "upstream/asmjit/x86/x86_builder.cpp" +#include "upstream/asmjit/x86/x86_compiler.cpp" +#include "upstream/asmjit/x86/x86_emit_helper.cpp" +#include "upstream/asmjit/x86/x86_formatter.cpp" +#include "upstream/asmjit/x86/x86_func.cpp" +#include "upstream/asmjit/x86/x86_inst_api.cpp" +#include "upstream/asmjit/x86/x86_inst_db.cpp" +#include "upstream/asmjit/x86/x86_ra_pass.cpp" diff --git a/thirdparty/asmjit_library/patches/ra_pass_01.patch b/thirdparty/asmjit_library/patches/ra_pass_01.patch new file mode 100644 index 000000000..91525c577 --- /dev/null +++ b/thirdparty/asmjit_library/patches/ra_pass_01.patch @@ -0,0 +1,145 @@ +diff --git a/thirdparty/asmjit_library/upstream/asmjit/core/ra_pass.cpp b/thirdparty/asmjit_library/upstream/asmjit/core/ra_pass.cpp +index f72e0577..0483bd81 100644 +--- a/thirdparty/asmjit_library/upstream/asmjit/core/ra_pass.cpp ++++ b/thirdparty/asmjit_library/upstream/asmjit/core/ra_pass.cpp +@@ -1470,8 +1470,75 @@ ASMJIT_FAVOR_SPEED Error BaseRAPass::bin_pack(RegGroup group) noexcept { + size_t num_work_regs = work_regs.size(); + RegMask available_regs = _available_regs[group]; + RegMask preserved_regs = func()->frame().preserved_regs(group); + ++ // A value that lives across a call must survive it in a preserved register. ++ // `clobber_survival_mask` is normally fed only by values tied to a call (its ++ // arguments and returns); a loop-carried value - scalar state, a stream ++ // pointer, a coefficient - that merely crosses the call is never tied to it, ++ // so it gets no mask and keeps a caller-saved home here. The local allocator ++ // then has to park such a value in a preserved register at every call and ++ // move it back home afterwards (see the ra_local.cpp STEP 7 change), a ++ // shuffle paid on every sample of a loop containing a libm call. Detect ++ // call-crossing values here, by live-span containment over the calls' node ++ // positions, so their homes are preserved from the start and the loop never ++ // moves them. Local change over upstream AsmJit. ++ struct InvokeSite { ++ NodePosition pos; ++ RegMask clobbered; ++ }; ++ ++ axl::ArenaVector invoke_sites; ++ ASMJIT_PROPAGATE(invoke_sites.reserve_fit(arena(), 4)); ++ ++ for (RABlock* block : _blocks) { ++ if (!block->is_reachable()) { ++ continue; ++ } ++ ++ BaseNode* node = block->first(); ++ const BaseNode* stop = block->last(); ++ for (;;) { ++ if (node->is_invoke()) { ++ auto* ra_inst = node->as()->pass_data(); ++ if (ra_inst && ra_inst->_clobbered_regs[group]) { ++ ASMJIT_PROPAGATE(invoke_sites.append(arena(), InvokeSite{node->position(), ra_inst->_clobbered_regs[group]})); ++ } ++ } ++ ++ if (node == stop) { ++ break; ++ } ++ ++ node = node->next(); ++ } ++ } ++ ++ if (!invoke_sites.is_empty()) { ++ for (RAWorkReg* work_reg : group_regs) { ++ const RALiveSpans& spans = work_reg->live_spans(); ++ RegMask clobbered = 0; ++ ++ for (const InvokeSite& site : invoke_sites) { ++ if ((clobbered & site.clobbered) == site.clobbered) { ++ continue; ++ } ++ ++ for (size_t i = 0; i < spans.size(); i++) { ++ const RALiveSpan& span = spans[i]; ++ if (span.a <= site.pos && (site.pos < span.b || span.b == RALiveSpan::kInf)) { ++ clobbered |= site.clobbered; ++ break; ++ } ++ } ++ } ++ ++ if (clobbered) { ++ work_reg->add_clobber_survival_mask(clobbered); ++ } ++ } ++ } ++ + // First try to pack everything that provides register-id hint as these are most likely function arguments and fixed + // (pre-colored) virtual registers. + if (!work_regs.is_empty()) { + uint32_t dst_index = 0; +@@ -1609,32 +1676,38 @@ ASMJIT_FAVOR_SPEED Error BaseRAPass::bin_pack(RegGroup group) noexcept { + if (remaining_phys_regs & work_reg->preferred_mask()) { + remaining_phys_regs &= work_reg->preferred_mask(); + } + +- RegMask phys_regs = remaining_phys_regs & ~preserved_regs; +- remaining_phys_regs &= preserved_regs; ++ // A value that lives across a call (its clobber_survival_mask was set ++ // above from live-span containment over the call sites) has to survive it ++ // in a preserved register, so take the preserved pool first for it and ++ // fall back to scratch registers only when the pool is exhausted. ++ // Everything else keeps the scratch-first order, which leaves call-free ++ // kernels and short-lived values allocating exactly as upstream does. ++ // Local change over upstream AsmJit. ++ RegMask phys_regs; ++ RegMask fallback_regs; ++ ++ if (work_reg->clobber_survival_mask()) { ++ phys_regs = remaining_phys_regs & preserved_regs; ++ fallback_regs = remaining_phys_regs & ~preserved_regs; ++ } ++ else { ++ phys_regs = remaining_phys_regs & ~preserved_regs; ++ fallback_regs = remaining_phys_regs & preserved_regs; ++ } + + for (;;) { + if (!phys_regs) { +- if (!remaining_phys_regs) { ++ if (!fallback_regs) { + break; + } +- phys_regs = remaining_phys_regs; +- remaining_phys_regs = 0; ++ phys_regs = fallback_regs; ++ fallback_regs = 0; + } + + uint32_t phys_id = axl::ctz(phys_regs); + +- if (work_reg->clobber_survival_mask()) { +- RegMask preferred_mask = (phys_regs | remaining_phys_regs) & work_reg->clobber_survival_mask(); +- if (preferred_mask) { +- if (preferred_mask & ~remaining_phys_regs) { +- preferred_mask &= ~remaining_phys_regs; +- } +- phys_id = axl::ctz(preferred_mask); +- } +- } +- + RALiveSpans& live = _global_live_spans[group][phys_id]; + Error err = tmp_spans.non_overlapping_union_of(arena(), live, work_reg->live_spans()); + + if (err == Error::kOk) { +@@ -1648,9 +1721,9 @@ ASMJIT_FAVOR_SPEED Error BaseRAPass::bin_pack(RegGroup group) noexcept { + return err; + } + + phys_regs &= ~axl::bit_mask(phys_id); +- remaining_phys_regs &= ~axl::bit_mask(phys_id); ++ fallback_regs &= ~axl::bit_mask(phys_id); + } + + // Keep it in `work_regs` if it was not allocated. + if (!phys_regs) { diff --git a/thirdparty/asmjit_library/patches/ra_pass_02.patch b/thirdparty/asmjit_library/patches/ra_pass_02.patch new file mode 100644 index 000000000..387dfcb16 --- /dev/null +++ b/thirdparty/asmjit_library/patches/ra_pass_02.patch @@ -0,0 +1,40 @@ +diff --git a/thirdparty/asmjit_library/upstream/asmjit/core/ra_pass.cpp b/thirdparty/asmjit_library/upstream/asmjit/core/ra_pass.cpp +index 0483bd81..f331bba5 100644 +--- a/thirdparty/asmjit_library/upstream/asmjit/core/ra_pass.cpp ++++ b/thirdparty/asmjit_library/upstream/asmjit/core/ra_pass.cpp +@@ -1661,8 +1661,35 @@ ASMJIT_FAVOR_SPEED Error BaseRAPass::bin_pack(RegGroup group) noexcept { + } + } + } + ++ // Pack the call-crossing values (their clobber_survival_mask was set from ++ // live-span containment over the call sites above) before any call-free ++ // value: they need a preserved home, and a long-lived call-free value can ++ // otherwise take the last preserved register as a fallback while a value ++ // that must survive every per-sample call is left in a caller-saved one - ++ // which then parks on the stack at each call. The order inside each group ++ // stays priority-sorted, so this only changes which values claim the ++ // preserved pool, not how the pool is shared. Local change over upstream ++ // AsmJit. ++ if (num_work_regs > 1) { ++ size_t first = 0; ++ ++ for (size_t index = 0; index < num_work_regs; index++) { ++ if (!work_regs[index]->clobber_survival_mask()) { ++ continue; ++ } ++ ++ if (first != index) { ++ RAWorkReg* crossing = work_regs[first]; ++ work_regs[first] = work_regs[index]; ++ work_regs[index] = crossing; ++ } ++ ++ first++; ++ } ++ } ++ + // Try to pack the rest. + if (!work_regs.is_empty()) { + size_t dst_index = 0; + diff --git a/thirdparty/asmjit_library/upstream/LICENSE.md b/thirdparty/asmjit_library/upstream/LICENSE.md new file mode 100644 index 000000000..7818b21e5 --- /dev/null +++ b/thirdparty/asmjit_library/upstream/LICENSE.md @@ -0,0 +1,17 @@ +Copyright (c) 2008-2025 Petr Kobalicek + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. diff --git a/thirdparty/asmjit_library/upstream/asmjit/a64.h b/thirdparty/asmjit_library/upstream/asmjit/a64.h new file mode 100644 index 000000000..b47698ef5 --- /dev/null +++ b/thirdparty/asmjit_library/upstream/asmjit/a64.h @@ -0,0 +1,55 @@ +// This file is part of AsmJit project +// +// See or LICENSE.md for license and copyright information +// SPDX-License-Identifier: Zlib + +#ifndef ASMJIT_A64_H_INCLUDED +#define ASMJIT_A64_H_INCLUDED + +//! \addtogroup asmjit_a64 +//! +//! ### Emitters +//! +//! - \ref a64::Assembler - AArch64 assembler (must read, provides examples). +//! - \ref a64::Builder - AArch64 builder. +//! - \ref a64::Compiler - AArch64 compiler. +//! - \ref a64::Emitter - AArch64 emitter (abstract). +//! +//! ### Supported Instructions +//! +//! - Emitters: +//! - \ref a64::EmitterExplicitT - Provides all instructions that use explicit operands, provides also utility +//! functions. The member functions provided are part of all AArch64 emitters. +//! +//! - Instruction representation: +//! - \ref a64::Inst::Id - instruction identifiers. +//! +//! ### Register Operands +//! +//! - \ref a64::Gp - General purpose register (abstracts 32-bit and 64-bit general purpose registers). +//! - \ref a64::Vec - Vector register (abstracts B, H, S, D, and Q NEON register with possible element type and index). +//! +//! ### Memory Operands +//! +//! - \ref a64::Mem - AArch64 memory operand that provides support for all ARM addressing features including base, +//! index, pre/post increment, and ARM-specific shift addressing + index extending. +//! +//! ### Other +//! +//! - \ref arm::Shift - Shift operation and value. +//! - \ref arm::Utils - Utilities that can help during code generation for AArch32 and AArch64. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#endif // ASMJIT_A64_H_INCLUDED + diff --git a/thirdparty/asmjit_library/upstream/asmjit/arm.h b/thirdparty/asmjit_library/upstream/asmjit/arm.h new file mode 100644 index 000000000..5c7ae828f --- /dev/null +++ b/thirdparty/asmjit_library/upstream/asmjit/arm.h @@ -0,0 +1,57 @@ +// This file is part of AsmJit project +// +// See or LICENSE.md for license and copyright information +// SPDX-License-Identifier: Zlib + +#ifndef ASMJIT_ARM_H_INCLUDED +#define ASMJIT_ARM_H_INCLUDED + +//! \addtogroup asmjit_arm +//! +//! ### Namespaces +//! +//! - \ref arm - arm namespace provides common functionality for both AArch32 and AArch64 backends. +//! \ref arm namespace, so you can only use a single namespace when targeting AArch32 architecture. +//! - \ref a64 - a64 namespace provides support for AArch64 architecture. In addition it includes +//! \ref arm namespace, so you can only use a single namespace when targeting AArch64 architecture. +//! +//! ### Emitters +//! +//! - AArch64: +//! - \ref a64::Assembler - AArch64 assembler (must read, provides examples). +//! - \ref a64::Builder - AArch64 builder. +//! - \ref a64::Compiler - AArch64 compiler. +//! - \ref a64::Emitter - AArch64 emitter (abstract). +//! +//! ### Supported Instructions +//! +//! - AArch64: +//! - Emitters: +//! - \ref a64::EmitterExplicitT - Provides all instructions that use explicit operands, provides also +//! utility functions. The member functions provided are part of all AArch64 emitters. +//! - Instruction representation: +//! - \ref a64::Inst::Id - instruction identifiers. +//! +//! ### ARM Operands +//! +//! - AArch64: +//! - \ref a64::Gp - 32-bit or 64-bit general purpose register used by AArch64: +//! - \ref a64::Vec - Vector (SIMD) register. +//! - \ref a64::Mem - AArch64 memory operand that provides support for all AArch64 addressing features +//! including base, index, pre/post increment, and AArch64 specific shift/extend of memory index. +//! +//! ### Memory Operands +//! +//! ### Other +//! +//! - \ref arm::Shift - Shift operation and value (both AArch32 and AArch64). +//! - \ref arm::Utils - Utilities that can help during code generation for AArch32 and AArch64. + +#include + +#include +#include +#include +#include + +#endif // ASMJIT_ARM_H_INCLUDED diff --git a/thirdparty/asmjit_library/upstream/asmjit/arm/a64_arch_traits_p.h b/thirdparty/asmjit_library/upstream/asmjit/arm/a64_arch_traits_p.h new file mode 100644 index 000000000..4fa90f4f2 --- /dev/null +++ b/thirdparty/asmjit_library/upstream/asmjit/arm/a64_arch_traits_p.h @@ -0,0 +1,72 @@ +// This file is part of AsmJit project +// +// See or LICENSE.md for license and copyright information +// SPDX-License-Identifier: Zlib + +#ifndef ASMJIT_ARM_A64_ARCH_TRAITS_P_H_INCLUDED +#define ASMJIT_ARM_A64_ARCH_TRAITS_P_H_INCLUDED + +#include +#include +#include +#include +#include + +ASMJIT_BEGIN_SUB_NAMESPACE(a64) + +//! \cond INTERNAL +//! \addtogroup asmjit_a64 +//! \{ + +static const constexpr ArchTraits a64_arch_traits = { + // SP/FP/LR/PC. + Gp::kIdSp, Gp::kIdFp, Gp::kIdLr, 0xFFu, + + // Reserved. + { 0u, 0u, 0u }, + + // HW stack alignment (AArch64 requires stack aligned to 16 bytes at HW level). + 16u, + + // Min/max stack offset - byte addressing is the worst, vec.q addressing the best. + 4095, 65520, + + // Supported register types. + 0u | (1u << uint32_t(RegType::kGp32 )) + | (1u << uint32_t(RegType::kGp64 )) + | (1u << uint32_t(RegType::kVec8 )) + | (1u << uint32_t(RegType::kVec16 )) + | (1u << uint32_t(RegType::kVec32 )) + | (1u << uint32_t(RegType::kVec64 )) + | (1u << uint32_t(RegType::kVec128)) + | (1u << uint32_t(RegType::kMask )), + + // Instruction hints [Gp, Vec, Mask, Extra]. + {{ InstHints::kPushPop, InstHints::kPushPop, InstHints::kNoHints, InstHints::kNoHints }}, + + // TypeIdToRegType. + #define V(index) (index + uint32_t(TypeId::_kBaseStart) == uint32_t(TypeId::kInt8) ? RegType::kGp32 : \ + index + uint32_t(TypeId::_kBaseStart) == uint32_t(TypeId::kUInt8) ? RegType::kGp32 : \ + index + uint32_t(TypeId::_kBaseStart) == uint32_t(TypeId::kInt16) ? RegType::kGp32 : \ + index + uint32_t(TypeId::_kBaseStart) == uint32_t(TypeId::kUInt16) ? RegType::kGp32 : \ + index + uint32_t(TypeId::_kBaseStart) == uint32_t(TypeId::kInt32) ? RegType::kGp32 : \ + index + uint32_t(TypeId::_kBaseStart) == uint32_t(TypeId::kUInt32) ? RegType::kGp32 : \ + index + uint32_t(TypeId::_kBaseStart) == uint32_t(TypeId::kInt64) ? RegType::kGp64 : \ + index + uint32_t(TypeId::_kBaseStart) == uint32_t(TypeId::kUInt64) ? RegType::kGp64 : \ + index + uint32_t(TypeId::_kBaseStart) == uint32_t(TypeId::kIntPtr) ? RegType::kGp64 : \ + index + uint32_t(TypeId::_kBaseStart) == uint32_t(TypeId::kUIntPtr) ? RegType::kGp64 : \ + index + uint32_t(TypeId::_kBaseStart) == uint32_t(TypeId::kFloat32) ? RegType::kVec32 : \ + index + uint32_t(TypeId::_kBaseStart) == uint32_t(TypeId::kFloat64) ? RegType::kVec64 : RegType::kNone) + {{ ASMJIT_LOOKUP_TABLE_32(V, 0) }}, + #undef V + + // Word names of 8-bit, 16-bit, 32-bit, and 64-bit quantities. + { ArchTypeNameId::kByte, ArchTypeNameId::kHWord, ArchTypeNameId::kWord, ArchTypeNameId::kXWord } +}; + +//! \} +//! \endcond + +ASMJIT_END_SUB_NAMESPACE + +#endif // ASMJIT_ARM_A64_ARCH_TRAITS_P_H_INCLUDED diff --git a/thirdparty/asmjit_library/upstream/asmjit/arm/a64_assembler.cpp b/thirdparty/asmjit_library/upstream/asmjit/arm/a64_assembler.cpp new file mode 100644 index 000000000..fc5da138e --- /dev/null +++ b/thirdparty/asmjit_library/upstream/asmjit/arm/a64_assembler.cpp @@ -0,0 +1,5339 @@ +// This file is part of AsmJit project +// +// See or LICENSE.md for license and copyright information +// SPDX-License-Identifier: Zlib + +#include +#if !defined(ASMJIT_NO_AARCH64) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +ASMJIT_BEGIN_SUB_NAMESPACE(a64) + +// a64::Assembler - Utils +// ====================== + +static ASMJIT_INLINE_CONSTEXPR uint32_t diff(RegType a, RegType b) noexcept { return uint32_t(a) - uint32_t(b); } +static ASMJIT_INLINE_CONSTEXPR uint32_t diff(VecElementType element_type, VecElementType base_type) noexcept { return uint32_t(element_type) - uint32_t(base_type); } + +// a64::Assembler - Cond +// ===================== + +static inline uint32_t cond_code_to_opcode_field(uint32_t cond) noexcept { return (uint32_t(cond) - 2u) & 0xFu; } + +// a64::Assembler - Bits +// ===================== + +template +static inline constexpr uint32_t B(const T& index) noexcept { return uint32_t(1u) << uint32_t(index); } + +static constexpr uint32_t kSP = Gp::kIdSp; +static constexpr uint32_t kZR = Gp::kIdZr; +static constexpr uint32_t kWX = InstDB::kWX; + +// a64::Assembler - ShiftOpToLdStOptMap +// ==================================== + +// Table that maps ShiftOp to OPT part in LD/ST (register) opcode. +#define VALUE(index) index == uint32_t(ShiftOp::kUXTW) ? 2u : \ + index == uint32_t(ShiftOp::kLSL) ? 3u : \ + index == uint32_t(ShiftOp::kSXTW) ? 6u : \ + index == uint32_t(ShiftOp::kSXTX) ? 7u : 0xFF +static const uint8_t shift_op_to_ld_st_opt_map[] = { ASMJIT_LOOKUP_TABLE_16(VALUE, 0) }; +#undef VALUE + +// a64::Assembler - ExtendOpToRegType +// ================================== + +static inline RegType extend_option_to_reg_type(uint32_t option) noexcept { + uint32_t pred = (uint32_t(RegType::kGp32) << (0x0 * 4)) | // 0b000 - UXTB. + (uint32_t(RegType::kGp32) << (0x1 * 4)) | // 0b001 - UXTH. + (uint32_t(RegType::kGp32) << (0x2 * 4)) | // 0b010 - UXTW. + (uint32_t(RegType::kGp64) << (0x3 * 4)) | // 0b011 - UXTX|LSL. + (uint32_t(RegType::kGp32) << (0x4 * 4)) | // 0b100 - SXTB. + (uint32_t(RegType::kGp32) << (0x5 * 4)) | // 0b101 - SXTH. + (uint32_t(RegType::kGp32) << (0x6 * 4)) | // 0b110 - SXTW. + (uint32_t(RegType::kGp64) << (0x7 * 4)) ; // 0b111 - SXTX. + return RegType((pred >> (option * 4u)) & 0xFu); +} + +// asmjit::a64::Assembler - SizeOp +// =============================== + +//! Struct that contains Size (2 bits), Q flag, and S (scalar) flag. These values +//! are used to encode Q, Size, and Scalar fields in an opcode. +struct SizeOp { + //! \name Constants + //! \{ + + static inline constexpr uint8_t k128BitShift = 0; + static inline constexpr uint8_t kScalarShift = 1; + static inline constexpr uint8_t kSizeShift = 2; + + static inline constexpr uint8_t kQ = uint8_t(1u << k128BitShift); + static inline constexpr uint8_t kS = uint8_t(1u << kScalarShift); + + static inline constexpr uint8_t k00 = uint8_t(0 << kSizeShift); + static inline constexpr uint8_t k01 = uint8_t(1 << kSizeShift); + static inline constexpr uint8_t k10 = uint8_t(2 << kSizeShift); + static inline constexpr uint8_t k11 = uint8_t(3 << kSizeShift); + + static inline constexpr uint8_t k00Q = k00 | kQ; + static inline constexpr uint8_t k01Q = k01 | kQ; + static inline constexpr uint8_t k10Q = k10 | kQ; + static inline constexpr uint8_t k11Q = k11 | kQ; + + static inline constexpr uint8_t k00S = k00 | kS; + static inline constexpr uint8_t k01S = k01 | kS; + static inline constexpr uint8_t k10S = k10 | kS; + static inline constexpr uint8_t k11S = k11 | kS; + + static inline constexpr uint8_t kInvalid = 0xFFu; + + // Masks used by SizeOpMap. + static inline constexpr uint8_t kSzQ = (0x3u << kSizeShift) | kQ; + static inline constexpr uint8_t kSzS = (0x3u << kSizeShift) | kS; + static inline constexpr uint8_t kSzQS = (0x3u << kSizeShift) | kQ | kS; + + //! \} + + //! \name Members + //! \{ + + uint8_t value; + + //! \} + + //! \name Accessors + //! \{ + + inline bool is_valid() const noexcept { return value != kInvalid; } + inline void make_invalid() noexcept { value = kInvalid; } + + inline uint32_t q() const noexcept { return (value >> k128BitShift) & 0x1u; } + inline uint32_t qs() const noexcept { return ((value >> k128BitShift) | (value >> kScalarShift)) & 0x1u; } + inline uint32_t scalar() const noexcept { return (value >> kScalarShift) & 0x1u; } + inline uint32_t size() const noexcept { return (value >> kSizeShift) & 0x3u; } + + inline void decrement_size() noexcept { + ASMJIT_ASSERT(size() > 0); + value = uint8_t(value - (1u << kSizeShift)); + } + + //! \} +}; + +struct SizeOpTable { + enum TableId : uint8_t { + kTableBin = 0, + kTableAny, + kCount + }; + + // 40 elements for each combination. + SizeOp array[(uint32_t(RegType::kVec128) - uint32_t(RegType::kVec8) + 1) * 8]; +}; + +#define VALUE_BIN(x) { \ + x == (((uint32_t(RegType::kVec64 ) - uint32_t(RegType::kVec8)) << 3) | uint32_t(VecElementType::kNone)) ? SizeOp::k00 : \ + x == (((uint32_t(RegType::kVec128) - uint32_t(RegType::kVec8)) << 3) | uint32_t(VecElementType::kNone)) ? SizeOp::k00Q : \ + x == (((uint32_t(RegType::kVec64 ) - uint32_t(RegType::kVec8)) << 3) | uint32_t(VecElementType::kB )) ? SizeOp::k00 : \ + x == (((uint32_t(RegType::kVec128) - uint32_t(RegType::kVec8)) << 3) | uint32_t(VecElementType::kB )) ? SizeOp::k00Q : SizeOp::kInvalid \ +} + +#define VALUE_ANY(x) { \ + x == (((uint32_t(RegType::kVec8) - uint32_t(RegType::kVec8)) << 3) | uint32_t(VecElementType::kNone)) ? SizeOp::k00S : \ + x == (((uint32_t(RegType::kVec16) - uint32_t(RegType::kVec8)) << 3) | uint32_t(VecElementType::kNone)) ? SizeOp::k01S : \ + x == (((uint32_t(RegType::kVec32) - uint32_t(RegType::kVec8)) << 3) | uint32_t(VecElementType::kNone)) ? SizeOp::k10S : \ + x == (((uint32_t(RegType::kVec64) - uint32_t(RegType::kVec8)) << 3) | uint32_t(VecElementType::kNone)) ? SizeOp::k11S : \ + x == (((uint32_t(RegType::kVec64) - uint32_t(RegType::kVec8)) << 3) | uint32_t(VecElementType::kB )) ? SizeOp::k00 : \ + x == (((uint32_t(RegType::kVec128) - uint32_t(RegType::kVec8)) << 3) | uint32_t(VecElementType::kB )) ? SizeOp::k00Q : \ + x == (((uint32_t(RegType::kVec64) - uint32_t(RegType::kVec8)) << 3) | uint32_t(VecElementType::kH )) ? SizeOp::k01 : \ + x == (((uint32_t(RegType::kVec128) - uint32_t(RegType::kVec8)) << 3) | uint32_t(VecElementType::kH )) ? SizeOp::k01Q : \ + x == (((uint32_t(RegType::kVec64) - uint32_t(RegType::kVec8)) << 3) | uint32_t(VecElementType::kS )) ? SizeOp::k10 : \ + x == (((uint32_t(RegType::kVec128) - uint32_t(RegType::kVec8)) << 3) | uint32_t(VecElementType::kS )) ? SizeOp::k10Q : \ + x == (((uint32_t(RegType::kVec64) - uint32_t(RegType::kVec8)) << 3) | uint32_t(VecElementType::kD )) ? SizeOp::k11S : \ + x == (((uint32_t(RegType::kVec128) - uint32_t(RegType::kVec8)) << 3) | uint32_t(VecElementType::kD )) ? SizeOp::k11Q : SizeOp::kInvalid \ +} + +static const SizeOpTable size_op_table[SizeOpTable::kCount] = { + {{ ASMJIT_LOOKUP_TABLE_40(VALUE_BIN, 0) }}, + {{ ASMJIT_LOOKUP_TABLE_40(VALUE_ANY, 0) }} +}; + +#undef VALUE_ANY +#undef VALUE_BIN + +struct SizeOpMap { + uint8_t table_id; + uint8_t size_op_mask; + uint16_t accept_mask; +}; + +static const constexpr SizeOpMap size_op_map[InstDB::kVO_Count] = { + { // kVO_V_B: + SizeOpTable::kTableBin, SizeOp::kQ , uint16_t(B(SizeOp::k00) | B(SizeOp::k00Q)) + }, + + { // kVO_V_BH: + SizeOpTable::kTableAny, SizeOp::kSzQS, uint16_t(B(SizeOp::k00) | B(SizeOp::k00Q) | B(SizeOp::k01) | B(SizeOp::k01Q)) + }, + + { // kVO_V_BH_4S: + SizeOpTable::kTableAny, SizeOp::kSzQS, uint16_t(B(SizeOp::k00) | B(SizeOp::k00Q) | B(SizeOp::k01) | B(SizeOp::k01Q) | B(SizeOp::k10Q)) + }, + + { // kVO_V_BHS: + SizeOpTable::kTableAny, SizeOp::kSzQS, uint16_t(B(SizeOp::k00) | B(SizeOp::k00Q) | B(SizeOp::k01) | B(SizeOp::k01Q) | B(SizeOp::k10) | B(SizeOp::k10Q)) + }, + + { // kVO_V_BHS_D2: + SizeOpTable::kTableAny, SizeOp::kSzQS, uint16_t(B(SizeOp::k00) | B(SizeOp::k00Q) | B(SizeOp::k01) | B(SizeOp::k01Q) | B(SizeOp::k10) | B(SizeOp::k10Q) | B(SizeOp::k11Q)) + }, + + { // kVO_V_HS: + SizeOpTable::kTableAny, SizeOp::kSzQS, uint16_t(B(SizeOp::k01) | B(SizeOp::k01Q) | B(SizeOp::k10) | B(SizeOp::k10Q)) + }, + + { // kVO_V_S: + SizeOpTable::kTableAny, SizeOp::kQ , uint16_t(B(SizeOp::k10) | B(SizeOp::k10Q)) + }, + + { // kVO_V_B8H4: + SizeOpTable::kTableAny, SizeOp::kSzQS, uint16_t(B(SizeOp::k00) | B(SizeOp::k01)) + }, + + { // kVO_V_B8H4S2: + SizeOpTable::kTableAny, SizeOp::kSzQS, uint16_t(B(SizeOp::k00) | B(SizeOp::k01) | B(SizeOp::k10)) + }, + + { // kVO_V_B8D1: + SizeOpTable::kTableAny, SizeOp::kSzQ , uint16_t(B(SizeOp::k00) | B(SizeOp::k11S)) + }, + + { // kVO_V_H4S2: + SizeOpTable::kTableAny, SizeOp::kSzQS, uint16_t(B(SizeOp::k01) | B(SizeOp::k10)) + }, + + { // kVO_V_B16: + SizeOpTable::kTableBin, SizeOp::kQ , uint16_t(B(SizeOp::k00Q)) + }, + + { // kVO_V_B16H8: + SizeOpTable::kTableAny, SizeOp::kSzQS, uint16_t(B(SizeOp::k00Q) | B(SizeOp::k01Q)) + }, + + { // kVO_V_B16H8S4: + SizeOpTable::kTableAny, SizeOp::kSzQS, uint16_t(B(SizeOp::k00Q) | B(SizeOp::k01Q) | B(SizeOp::k10Q)) + }, + + { // kVO_V_B16D2: + SizeOpTable::kTableAny, SizeOp::kSzQS, uint16_t(B(SizeOp::k00Q) | B(SizeOp::k11Q)) + }, + + { // kVO_V_H8S4: + SizeOpTable::kTableAny, SizeOp::kSzQS, uint16_t(B(SizeOp::k01Q) | B(SizeOp::k10Q)) + }, + + { // kVO_V_S4: + SizeOpTable::kTableAny, 0 , uint16_t(B(SizeOp::k10Q)) + }, + + { // kVO_V_D2: + SizeOpTable::kTableAny, 0 , uint16_t(B(SizeOp::k11Q)) + }, + + { // kVO_SV_BHS: + SizeOpTable::kTableAny, SizeOp::kSzQS, uint16_t(B(SizeOp::k00) | B(SizeOp::k00Q) | B(SizeOp::k00S) | B(SizeOp::k01) | B(SizeOp::k01Q) | B(SizeOp::k01S) | B(SizeOp::k10) | B(SizeOp::k10Q) | B(SizeOp::k10S)) + }, + + { // kVO_SV_B8H4S2: + SizeOpTable::kTableAny, SizeOp::kSzQS, uint16_t(B(SizeOp::k00) | B(SizeOp::k00S) | B(SizeOp::k01) | B(SizeOp::k01S) | B(SizeOp::k10) | B(SizeOp::k10S)) + }, + + { // kVO_SV_HS: + SizeOpTable::kTableAny, SizeOp::kSzQS, uint16_t(B(SizeOp::k01) | B(SizeOp::k01Q) | B(SizeOp::k01S) | B(SizeOp::k10) | B(SizeOp::k10Q) | B(SizeOp::k10S)) + }, + + { // kVO_V_Any: + SizeOpTable::kTableAny, SizeOp::kSzQS, uint16_t(B(SizeOp::k00) | B(SizeOp::k00Q) | B(SizeOp::k01) | B(SizeOp::k01Q) | B(SizeOp::k10) | B(SizeOp::k10Q) | B(SizeOp::k11S) | B(SizeOp::k11Q)) + }, + + { // kVO_SV_Any: + SizeOpTable::kTableAny, SizeOp::kSzQS, uint16_t(B(SizeOp::k00) | B(SizeOp::k00Q) | B(SizeOp::k00S) | + B(SizeOp::k01) | B(SizeOp::k01Q) | B(SizeOp::k01S) | + B(SizeOp::k10) | B(SizeOp::k10Q) | B(SizeOp::k10S) | + B(SizeOp::k11) | B(SizeOp::k11Q) | B(SizeOp::k11S)) + } +}; + +static const Operand_& significant_simd_op(const Operand_& o0, const Operand_& o1, uint32_t inst_flags) noexcept { + return !(inst_flags & InstDB::kInstFlagLong) ? o0 : o1; +} + +static inline SizeOp element_type_to_size_op(uint32_t vec_op_type, RegType reg_type, VecElementType element_type) noexcept { + // Instruction data or Assembler is wrong if this triggers an assertion failure. + ASMJIT_ASSERT(vec_op_type < InstDB::kVO_Count); + // ElementType uses 3 bits in the operand signature, it should never overflow. + ASMJIT_ASSERT(uint32_t(element_type) <= 0x7u); + + const SizeOpMap& map = size_op_map[vec_op_type]; + const SizeOpTable& table = size_op_table[map.table_id]; + + size_t index = (axl::min(diff(reg_type, RegType::kVec8), diff(RegType::kVec128, RegType::kVec8) + 1) << 3) | uint32_t(element_type); + SizeOp op = table.array[index]; + SizeOp modified_op { uint8_t(op.value & map.size_op_mask) }; + + if (!axl::bit_test(map.accept_mask, op.value)) { + modified_op.make_invalid(); + } + + return modified_op; +} + +// a64::Assembler - Immediate Encoding Utilities (Integral) +// ======================================================== + +using Utils::LogicalImm; + +struct HalfWordImm { + uint32_t hw; + uint32_t inv; + uint32_t imm; +}; + +struct LMHImm { + uint32_t lm; + uint32_t h; + uint32_t max_rm_id; +}; + +static inline uint32_t count_zero_half_words_64(uint64_t imm) noexcept { + return uint32_t((imm & 0x000000000000FFFFu) == 0) + + uint32_t((imm & 0x00000000FFFF0000u) == 0) + + uint32_t((imm & 0x0000FFFF00000000u) == 0) + + uint32_t((imm & 0xFFFF000000000000u) == 0) ; +} + +static uint32_t encode_mov_sequence_32(uint32_t out[2], uint32_t imm, uint32_t rd, uint32_t x) noexcept { + ASMJIT_ASSERT(rd <= 31); + + uint32_t kMovZ = 0b01010010100000000000000000000000 | (x << 31); + uint32_t kMovN = 0b00010010100000000000000000000000; + uint32_t kMovK = 0b01110010100000000000000000000000; + + if ((imm & 0xFFFF0000u) == 0x00000000u) { + out[0] = kMovZ | (0 << 21) | ((imm & 0xFFFFu) << 5) | rd; + return 1; + } + + if ((imm & 0xFFFF0000u) == 0xFFFF0000u) { + out[0] = kMovN | (0 << 21) | ((~imm & 0xFFFFu) << 5) | rd; + return 1; + } + + if ((imm & 0x0000FFFFu) == 0x00000000u) { + out[0] = kMovZ | (1 << 21) | ((imm >> 16) << 5) | rd; + return 1; + } + + if ((imm & 0x0000FFFFu) == 0x0000FFFFu) { + out[0] = kMovN | (1 << 21) | ((~imm >> 16) << 5) | rd; + return 1; + } + + out[0] = kMovZ | (0 << 21) | ((imm & 0xFFFFu) << 5) | rd; + out[1] = kMovK | (1 << 21) | ((imm >> 16) << 5) | rd; + return 2; +} + +static uint32_t encode_mov_sequence_64(uint32_t out[4], uint64_t imm, uint32_t rd, uint32_t x) noexcept { + ASMJIT_ASSERT(rd <= 31); + + uint32_t kMovZ = 0b11010010100000000000000000000000; + uint32_t kMovN = 0b10010010100000000000000000000000; + uint32_t kMovK = 0b11110010100000000000000000000000; + + if (imm <= 0xFFFFFFFFu) + return encode_mov_sequence_32(out, uint32_t(imm), rd, x); + + uint32_t zhw = count_zero_half_words_64( imm); + uint32_t ohw = count_zero_half_words_64(~imm); + + if (zhw >= ohw) { + uint32_t op = kMovZ; + uint32_t count = 0; + + for (uint32_t hw_index = 0; hw_index < 4; hw_index++, imm >>= 16) { + uint32_t hw_imm = uint32_t(imm & 0xFFFFu); + if (hw_imm == 0) { + continue; + } + + out[count++] = op | (hw_index << 21) | (hw_imm << 5) | rd; + op = kMovK; + } + + // This should not happen - zero should be handled by encode_mov_sequence_32(). + ASMJIT_ASSERT(count > 0); + + return count; + } + else { + uint32_t op = kMovN; + uint32_t count = 0; + uint32_t neg_mask = 0xFFFFu; + + for (uint32_t hw_index = 0; hw_index < 4; hw_index++, imm >>= 16) { + uint32_t hw_imm = uint32_t(imm & 0xFFFFu); + if (hw_imm == 0xFFFFu) { + continue; + } + + out[count++] = op | (hw_index << 21) | ((hw_imm ^ neg_mask) << 5) | rd; + op = kMovK; + neg_mask = 0; + } + + if (count == 0) { + out[count++] = kMovN | ((0xFFFF ^ neg_mask) << 5) | rd; + } + + return count; + } +} + +static inline bool encode_lmh(uint32_t size_field, uint32_t element_index, Out out) noexcept { + if (size_field != 1 && size_field != 2) + return false; + + uint32_t h_shift = 3u - size_field; + uint32_t lm_shift = size_field - 1u; + uint32_t max_element_index = 15u >> size_field; + + out->h = element_index >> h_shift; + out->lm = (element_index << lm_shift) & 0x3u; + out->max_rm_id = (8u << size_field) - 1; + + return element_index <= max_element_index; +} + +// a64::Assembler - Opcode +// ======================= + +//! Helper class to store and manipulate ARM opcode. +struct Opcode { + uint32_t v; + + enum Bits : uint32_t { + kN = (1u << 22), + kQ = (1u << 30), + kX = (1u << 31) + }; + + // -------------------------------------------------------------------------- + // [Opcode Builder] + // -------------------------------------------------------------------------- + + inline uint32_t get() const noexcept { return v; } + inline void reset(uint32_t value) noexcept { v = value; } + + inline bool has_q() const noexcept { return (v & kQ) != 0; } + inline bool has_x() const noexcept { return (v & kX) != 0; } + + template + inline Opcode& add_imm(T value, uint32_t bit_index) noexcept { return operator|=(uint32_t(value) << bit_index); } + + template + inline Opcode& xor_imm(T value, uint32_t bit_index) noexcept { return operator^=(uint32_t(value) << bit_index); } + + template + inline Opcode& add_if(T value, const Condition& condition) noexcept { return operator|=(condition ? uint32_t(value) : uint32_t(0)); } + + inline Opcode& add_logical_imm(const LogicalImm& logical_imm) noexcept { + add_imm(logical_imm.n, 22); + add_imm(logical_imm.r, 16); + add_imm(logical_imm.s, 10); + return *this; + } + + inline Opcode& add_reg(uint32_t id, uint32_t bit_index) noexcept { return operator|=((id & 31u) << bit_index); } + inline Opcode& add_reg(const Operand_& op, uint32_t bit_index) noexcept { return add_reg(op.id(), bit_index); } + + inline Opcode& operator=(uint32_t x) noexcept { v = x; return *this; } + inline Opcode& operator&=(uint32_t x) noexcept { v &= x; return *this; } + inline Opcode& operator|=(uint32_t x) noexcept { v |= x; return *this; } + inline Opcode& operator^=(uint32_t x) noexcept { v ^= x; return *this; } + + inline uint32_t operator&(uint32_t x) const noexcept { return v & x; } + inline uint32_t operator|(uint32_t x) const noexcept { return v | x; } + inline uint32_t operator^(uint32_t x) const noexcept { return v ^ x; } +}; + +// a64::Assembler - Signature Utilities +// ==================================== + +// TODO: [ARM] Deprecate match_signature. +static inline bool match_signature(const Operand_& o0, const Operand_& o1, uint32_t inst_flags) noexcept { + if (!(inst_flags & (InstDB::kInstFlagLong | InstDB::kInstFlagNarrow))) + return o0.signature() == o1.signature(); + + // TODO: [ARM] Something smart to validate this. + return true; +} + +static inline bool match_signature(const Operand_& o0, const Operand_& o1, const Operand_& o2, uint32_t inst_flags) noexcept { + return match_signature(o0, o1, inst_flags) && o1.signature() == o2.signature(); +} + +static inline bool match_signature(const Operand_& o0, const Operand_& o1, const Operand_& o2, const Operand_& o3, uint32_t inst_flags) noexcept { + return match_signature(o0, o1, inst_flags) && o1.signature() == o2.signature() && o2.signature() == o3.signature(); +} + +// Memory must be either: +// 1. Absolute address, which will be converted to relative. +// 2. Relative displacement (Label). +// 3. Base register + either offset or index. +static inline bool check_mem_base_index_rel(const Mem& mem) noexcept { + // Allowed base types (Nothing, Label, and Gp64). + constexpr uint32_t kBaseMask = B(0) | B(RegType::kLabelTag) | B(RegType::kGp64); + // Allowed index types (Nothing, Gp32, and Gp64). + constexpr uint32_t kIndexMask = B(0) | B(RegType::kGp32) | B(RegType::kGp64) ; + + RegType base_type = mem.base_type(); + RegType index_type = mem.index_type(); + + if (!axl::bit_test(kBaseMask, base_type)) { + return false; + } + + if (base_type > RegType::kLabelTag) { + // Index allows either Gp32 or Gp64. + if (!axl::bit_test(kIndexMask, index_type)) { + return false; + } + + if (index_type == RegType::kNone) { + return true; + } + else { + return !mem.has_offset(); + } + } + else { + // No index register allowed if this is a PC relative address (literal). + return index_type == RegType::kNone; + } +} + +struct EncodeFpOpcodeBits { + uint32_t size_mask; + uint32_t mask[3]; +}; + +static inline bool pick_fp_opcode(const Vec& reg, uint32_t s_op, uint32_t s_hf, uint32_t v_op, uint32_t v_hf, Opcode* opcode, uint32_t* sz_out) noexcept { + static constexpr uint32_t kQBitIndex = 30; + + static const EncodeFpOpcodeBits sz_bits_table[InstDB::kHF_Count] = { + { B(2) | B(1) , { 0u , 0u, B(22) } }, + { B(2) | B(1) | B(0), { 0u , 0u, 0u } }, + { B(2) | B(1) | B(0), { B(23) | B(22) , 0u, B(22) } }, + { B(2) | B(1) | B(0), { B(22) | B(20) | B(19) , 0u, B(22) } }, + { B(2) | B(1) | B(0), { B(22) | B(21) | B(15) | B(14), 0u, B(22) } }, + { B(2) | B(1) | B(0), { B(23) , 0u, B(22) } } + }; + + if (!reg.has_element_type()) { + // Scalar operation [HSD]. + uint32_t sz = diff(reg.reg_type(), RegType::kVec16); + if (sz > 2u || !axl::bit_test(sz_bits_table[s_hf].size_mask, sz)) { + return false; + } + + opcode->reset(sz_bits_table[s_hf].mask[sz] ^ s_op); + *sz_out = sz; + return s_op != 0; + } + else { + // Vector operation [HSD]. + uint32_t q = diff(reg.reg_type(), RegType::kVec64); + uint32_t sz = diff(reg.element_type(), VecElementType::kH); + + if (q > 1u || sz > 2u || !axl::bit_test(sz_bits_table[v_hf].size_mask, sz)) { + return false; + } + + opcode->reset(sz_bits_table[v_hf].mask[sz] ^ (v_op | (q << kQBitIndex))); + *sz_out = sz; + return v_op != 0; + } +} + +static inline bool pick_fp_opcode(const Vec& reg, uint32_t s_op, uint32_t s_hf, uint32_t v_op, uint32_t v_hf, Opcode* opcode) noexcept { + uint32_t sz; + return pick_fp_opcode(reg, s_op, s_hf, v_op, v_hf, opcode, &sz); +} + +// a64::Assembler - Operand Checks +// =============================== + +// Checks whether all operands have the same signature. +static inline bool check_signature(const Operand_& o0, const Operand_& o1) noexcept { + return o0.signature() == o1.signature(); +} + +static inline bool check_signature(const Operand_& o0, const Operand_& o1, const Operand_& o2) noexcept { + return o0.signature() == o1.signature() && + o1.signature() == o2.signature(); +} + +static inline bool check_signature(const Operand_& o0, const Operand_& o1, const Operand_& o2, const Operand_& o3) noexcept { + return o0.signature() == o1.signature() && + o1.signature() == o2.signature() && + o2.signature() == o3.signature(); +} + +// Checks whether the register is GP register of the allowed types. +// +// Allowed is a 2-bit mask, where the first bits allows Gp32 and the second bit allows Gp64. These bits are usually +// stored within the instruction, but could be also hardcoded in the assembler for instructions where GP types are +// not selectable. +static inline bool check_gp_type(const Operand_& op, uint32_t allowed) noexcept { + RegType type = op.as().reg_type(); + return axl::bit_test(allowed << uint32_t(RegType::kGp32), type); +} + +static inline bool check_gp_type(const Operand_& op, uint32_t allowed, uint32_t* x) noexcept { + // NOTE: We set 'x' to one only when Gp32 is allowed, otherwise the X is part + // of the opcode and we cannot set it. This is why this works without requiring + // additional logic. + RegType type = op.as().reg_type(); + *x = diff(type, RegType::kGp32) & allowed; + return axl::bit_test(allowed << uint32_t(RegType::kGp32), type); +} + +static inline bool check_gp_type(const Operand_& o0, const Operand_& o1, uint32_t allowed, uint32_t* x) noexcept { + return check_gp_type(o0, allowed, x) && check_signature(o0, o1); +} + +static inline bool check_gp_type(const Operand_& o0, const Operand_& o1, const Operand_& o2, uint32_t allowed, uint32_t* x) noexcept { + return check_gp_type(o0, allowed, x) && check_signature(o0, o1, o2); +} + +static inline bool check_gp_id(const Operand_& op, uint32_t hi_id = kZR) noexcept { + uint32_t id = op.as().id(); + return id < 31u || id == hi_id; +} + +static inline bool check_gp_id(const Operand_& o0, const Operand_& o1, uint32_t hi_id = kZR) noexcept { + uint32_t id0 = o0.as().id(); + uint32_t id1 = o1.as().id(); + + return (id0 < 31u || id0 == hi_id) && (id1 < 31u || id1 == hi_id); +} + +static inline bool check_gp_id(const Operand_& o0, const Operand_& o1, const Operand_& o2, uint32_t hi_id = kZR) noexcept { + uint32_t id0 = o0.as().id(); + uint32_t id1 = o1.as().id(); + uint32_t id2 = o2.as().id(); + + return (id0 < 31u || id0 == hi_id) && (id1 < 31u || id1 == hi_id) && (id2 < 31u || id2 == hi_id); +} + +static inline bool check_vec_id(const Operand_& op) noexcept { + uint32_t id = op.as().id(); + return id <= 31u; +} + +static inline bool check_vec_id(const Operand_& o0, const Operand_& o1) noexcept { + uint32_t id0 = o0.as().id(); + uint32_t id1 = o1.as().id(); + + return (id0 | id1) <= 31u; +} + +/* Unused at the moment. +static inline bool check_vec_id(const Operand_& o0, const Operand_& o1, const Operand_& o2) noexcept { + uint32_t id0 = o0.as().id(); + uint32_t id1 = o1.as().id(); + uint32_t id2 = o2.as().id(); + + return (id0 | id1 | id2) <= 31u; +} + +static inline bool check_vec_id(const Operand_& o0, const Operand_& o1, const Operand_& o2, const Operand_& o3) noexcept { + uint32_t id0 = o0.as().id(); + uint32_t id1 = o1.as().id(); + uint32_t id2 = o2.as().id(); + uint32_t id3 = o3.as().id(); + + return (id0 | id1 | id2 | id3) <= 31u; +} +*/ + +static inline bool check_mem_base(const Mem& mem) noexcept { + return mem.base_type() == RegType::kGp64 && mem.base_id() <= 31; +} + +static inline bool check_even(const Operand_& o0, const Operand_& o1) noexcept { + return ((o0.id() | o1.id()) & 1) == 0; +} + +static inline bool check_consecutive(const Operand_& o0, const Operand_& o1) noexcept { + return ((o0.id() + 1u) & 0x1Fu) == o1.id(); +} + +static inline bool check_consecutive(const Operand_& o0, const Operand_& o1, const Operand_& o2) noexcept { + return ((o0.id() + 1u) & 0x1Fu) == o1.id() && + ((o0.id() + 2u) & 0x1Fu) == o2.id(); +} + +static inline bool check_consecutive(const Operand_& o0, const Operand_& o1, const Operand_& o2, const Operand_& o3) noexcept { + return ((o0.id() + 1u) & 0x1Fu) == o1.id() && + ((o0.id() + 2u) & 0x1Fu) == o2.id() && + ((o0.id() + 3u) & 0x1Fu) == o3.id(); +} + +// a64::Assembler - CheckReg +// ========================= + +#define V(index) (index == uint32_t(RegType::kGp32) ? Gp::kIdZr : \ + index == uint32_t(RegType::kGp64) ? Gp::kIdZr : \ + index == uint32_t(RegType::kVec8) ? 31u : \ + index == uint32_t(RegType::kVec16) ? 31u : \ + index == uint32_t(RegType::kVec32) ? 31u : \ + index == uint32_t(RegType::kVec64) ? 31u : \ + index == uint32_t(RegType::kVec128) ? 31u : 0) +static const axl::InplaceArray common_hi_reg_id_of_type_table = {{ + ASMJIT_LOOKUP_TABLE_32(V, 0) +}}; +#undef V + +static inline bool check_valid_regs(const Operand_& o0) noexcept { + return bool(unsigned(o0.id() < 31) | unsigned(o0.id() == common_hi_reg_id_of_type_table[o0.as().reg_type()])); +} + +static inline bool check_valid_regs(const Operand_& o0, const Operand_& o1) noexcept { + return bool((unsigned(o0.id() < 31) | unsigned(o0.id() == common_hi_reg_id_of_type_table[o0.as().reg_type()])) & + (unsigned(o1.id() < 31) | unsigned(o1.id() == common_hi_reg_id_of_type_table[o1.as().reg_type()]))); +} + +static inline bool check_valid_regs(const Operand_& o0, const Operand_& o1, const Operand_& o2) noexcept { + return bool((unsigned(o0.id() < 31) | unsigned(o0.id() == common_hi_reg_id_of_type_table[o0.as().reg_type()])) & + (unsigned(o1.id() < 31) | unsigned(o1.id() == common_hi_reg_id_of_type_table[o1.as().reg_type()])) & + (unsigned(o2.id() < 31) | unsigned(o2.id() == common_hi_reg_id_of_type_table[o2.as().reg_type()]))); +} + +static inline bool check_valid_regs(const Operand_& o0, const Operand_& o1, const Operand_& o2, const Operand_& o3) noexcept { + return bool((unsigned(o0.id() < 31) | unsigned(o0.id() == common_hi_reg_id_of_type_table[o0.as().reg_type()])) & + (unsigned(o1.id() < 31) | unsigned(o1.id() == common_hi_reg_id_of_type_table[o1.as().reg_type()])) & + (unsigned(o2.id() < 31) | unsigned(o2.id() == common_hi_reg_id_of_type_table[o2.as().reg_type()])) & + (unsigned(o3.id() < 31) | unsigned(o3.id() == common_hi_reg_id_of_type_table[o3.as().reg_type()]))); +} + +// a64::Assembler - Construction & Destruction +// =========================================== + +Assembler::Assembler(CodeHolder* code) noexcept : BaseAssembler() { + _arch_mask = uint64_t(1) << uint32_t(Arch::kAArch64); + init_emitter_funcs(this); + + if (code) { + code->attach(this); + } +} + +Assembler::~Assembler() noexcept {} + +// a64::Assembler - Emit +// ===================== + +#define ENC_OPS1(OP0) \ + (uint32_t(OperandType::k##OP0)) + +#define ENC_OPS2(OP0, OP1) \ + (uint32_t(OperandType::k##OP0) + \ + (uint32_t(OperandType::k##OP1) << 3)) + +#define ENC_OPS3(OP0, OP1, OP2) \ + (uint32_t(OperandType::k##OP0) + \ + (uint32_t(OperandType::k##OP1) << 3) + \ + (uint32_t(OperandType::k##OP2) << 6)) + +#define ENC_OPS4(OP0, OP1, OP2, OP3) \ + (uint32_t(OperandType::k##OP0) + \ + (uint32_t(OperandType::k##OP1) << 3) + \ + (uint32_t(OperandType::k##OP2) << 6) + \ + (uint32_t(OperandType::k##OP3) << 9)) + +Error Assembler::_emit(InstId inst_id, const Operand_& o0, const Operand_& o1, const Operand_& o2, const Operand_* op_ext) { + // Logging/Validation/Error. + constexpr InstOptions kRequiresSpecialHandling = InstOptions::kReserved; + + Error err; + CodeWriter writer(this); + + // Combine all instruction options and also check whether the instruction + // is valid. All options that require special handling (including invalid + // instruction) are handled by the next branch. + InstOptions options = InstOptions(inst_id - 1 >= Inst::_kIdCount - 1) | InstOptions((size_t)(_buffer_end - writer.cursor()) < 4) | inst_options() | forced_inst_options(); + + CondCode inst_cc = BaseInst::extract_arm_cond_code(inst_id); + inst_id = inst_id & uint32_t(InstIdParts::kRealId); + + if (inst_id >= Inst::_kIdCount) { + inst_id = 0; + } + + const InstDB::InstInfo* inst_info = &InstDB::_inst_info_table[inst_id]; + uint32_t encoding_index = inst_info->_encoding_data_index; + + Opcode opcode; + uint32_t isign4; + uint32_t inst_flags; + + const Operand_& o3 = op_ext[EmitterUtils::kOp3]; + const Operand_* rm_rel = nullptr; + + uint32_t multiple_op_data[4]; + uint32_t multiple_op_count; + + // These are only used when instruction uses a relative displacement. + OffsetFormat offset_format; // Offset format. + uint64_t offset_value; // Offset value (if known). + + if (ASMJIT_UNLIKELY(axl::test(options, kRequiresSpecialHandling))) { + if (ASMJIT_UNLIKELY(!_code)) { + return report_error(make_error(Error::kNotInitialized)); + } + + // Unknown instruction. + if (ASMJIT_UNLIKELY(inst_id == 0)) { + goto InvalidInstruction; + } + + // Condition code can only be used with 'B' instruction. + if (ASMJIT_UNLIKELY(inst_cc != CondCode::kAL && inst_id != Inst::kIdB)) { + goto InvalidInstruction; + } + + // Grow request, happens rarely. + err = writer.ensure_space(this, 4); + if (ASMJIT_UNLIKELY(err != Error::kOk)) { + goto Failed; + } + +#ifndef ASMJIT_NO_INTROSPECTION + // Strict validation. + if (has_diagnostic_option(DiagnosticOptions::kValidateAssembler)) { + Operand_ op_array[Globals::kMaxOpCount]; + EmitterUtils::op_array_from_emit_args(op_array, o0, o1, o2, op_ext); + + err = _funcs.validate(BaseInst(inst_id, options, _extra_reg), op_array, Globals::kMaxOpCount, ValidationFlags::kNone); + if (ASMJIT_UNLIKELY(err != Error::kOk)) { + goto Failed; + } + } +#endif + } + + // Signature of the first 4 operands. + isign4 = (uint32_t(o0.op_type()) ) + + (uint32_t(o1.op_type()) << 3) + + (uint32_t(o2.op_type()) << 6) + + (uint32_t(o3.op_type()) << 9); + inst_flags = inst_info->flags(); + + switch (inst_info->_encoding) { + // ------------------------------------------------------------------------ + // [Base - Universal] + // ------------------------------------------------------------------------ + + case InstDB::kEncodingBaseOp: { + const InstDB::EncodingData::BaseOp& op_data = InstDB::EncodingData::baseOp[encoding_index]; + + if (isign4 == 0) { + opcode.reset(op_data.opcode); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseOpX16: { + const InstDB::EncodingData::BaseOpX16& op_data = InstDB::EncodingData::baseOpX16[encoding_index]; + + if (isign4 == ENC_OPS1(Reg) && o0.as().is_gp64(16)) { + opcode.reset(op_data.opcode); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseOpImm: { + const InstDB::EncodingData::BaseOpImm& op_data = InstDB::EncodingData::baseOpImm[encoding_index]; + + if (isign4 == ENC_OPS1(Imm)) { + uint64_t imm = o0.as().value_as(); + uint32_t imm_max = 1u << op_data.imm_bits; + + if (imm >= imm_max) + goto InvalidImmediate; + + opcode.reset(op_data.opcode); + opcode.add_imm(imm, op_data.imm_offset); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseR: { + const InstDB::EncodingData::BaseR& op_data = InstDB::EncodingData::baseR[encoding_index]; + + if (isign4 == ENC_OPS1(Reg)) { + if (!check_gp_type(o0, op_data.reg_type)) + goto InvalidInstruction; + + if (!check_gp_id(o0, op_data.reg_hi_id)) + goto InvalidPhysId; + + opcode.reset(op_data.opcode); + opcode.add_reg(o0, op_data.r_shift); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseRR: { + const InstDB::EncodingData::BaseRR& op_data = InstDB::EncodingData::baseRR[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Reg)) { + uint32_t x; + if (!check_gp_type(o0, op_data.a_type, &x)) + goto InvalidInstruction; + + if (!check_gp_type(o1, op_data.b_type)) + goto InvalidInstruction; + + if (op_data.uniform && !check_signature(o0, o1)) + goto InvalidInstruction; + + if (!check_gp_id(o0, op_data.a_hi_id)) + goto InvalidPhysId; + + if (!check_gp_id(o1, op_data.b_hi_id)) + goto InvalidPhysId; + + opcode.reset(op_data.opcode); + opcode.add_imm(x, 31); + opcode.add_reg(o1, op_data.b_shift); + opcode.add_reg(o0, op_data.a_shift); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseRRR: { + const InstDB::EncodingData::BaseRRR& op_data = InstDB::EncodingData::baseRRR[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Reg)) { + uint32_t x; + if (!check_gp_type(o0, op_data.a_type, &x)) + goto InvalidInstruction; + + if (!check_gp_type(o1, op_data.b_type)) + goto InvalidInstruction; + + if (!check_gp_type(o2, op_data.c_type)) + goto InvalidInstruction; + + if (op_data.uniform && !check_signature(o0, o1, o2)) + goto InvalidInstruction; + + if (!check_gp_id(o0, op_data.a_hi_id)) + goto InvalidPhysId; + + if (!check_gp_id(o1, op_data.b_hi_id)) + goto InvalidPhysId; + + if (!check_gp_id(o2, op_data.c_hi_id)) + goto InvalidPhysId; + + opcode.reset(op_data.opcode()); + opcode.add_imm(x, 31); + opcode.add_reg(o2, 16); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseRRRR: { + const InstDB::EncodingData::BaseRRRR& op_data = InstDB::EncodingData::baseRRRR[encoding_index]; + + if (isign4 == ENC_OPS4(Reg, Reg, Reg, Reg)) { + uint32_t x; + if (!check_gp_type(o0, op_data.a_type, &x)) + goto InvalidInstruction; + + if (!check_gp_type(o1, op_data.b_type)) + goto InvalidInstruction; + + if (!check_gp_type(o2, op_data.c_type)) + goto InvalidInstruction; + + if (!check_gp_type(o3, op_data.d_type)) + goto InvalidInstruction; + + if (op_data.uniform && !check_signature(o0, o1, o2, o3)) + goto InvalidInstruction; + + if (!check_gp_id(o0, op_data.a_hi_id)) + goto InvalidPhysId; + + if (!check_gp_id(o1, op_data.b_hi_id)) + goto InvalidPhysId; + + if (!check_gp_id(o2, op_data.c_hi_id)) + goto InvalidPhysId; + + if (!check_gp_id(o3, op_data.d_hi_id)) + goto InvalidPhysId; + + opcode.reset(op_data.opcode()); + opcode.add_imm(x, 31); + opcode.add_reg(o2, 16); + opcode.add_reg(o3, 10); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseRRII: { + const InstDB::EncodingData::BaseRRII& op_data = InstDB::EncodingData::baseRRII[encoding_index]; + + if (isign4 == ENC_OPS4(Reg, Reg, Imm, Imm)) { + if (!check_gp_type(o0, op_data.a_type)) + goto InvalidInstruction; + + if (!check_gp_type(o1, op_data.b_type)) + goto InvalidInstruction; + + if (!check_gp_id(o0, op_data.a_hi_id)) + goto InvalidPhysId; + + if (!check_gp_id(o1, op_data.b_hi_id)) + goto InvalidPhysId; + + if (o2.as().value_as() >= axl::bit_mask(op_data.a_imm_size + op_data.a_imm_discard_lsb) || + o3.as().value_as() >= axl::bit_mask(op_data.b_imm_size + op_data.b_imm_discard_lsb)) + goto InvalidImmediate; + + uint32_t a_imm = o2.as().value_as() >> op_data.a_imm_discard_lsb; + uint32_t b_imm = o3.as().value_as() >> op_data.b_imm_discard_lsb; + + if ((a_imm << op_data.a_imm_discard_lsb) != o2.as().value_as() || + (b_imm << op_data.b_imm_discard_lsb) != o3.as().value_as()) + goto InvalidImmediate; + + opcode.reset(op_data.opcode()); + opcode.add_imm(a_imm, op_data.a_imm_offset); + opcode.add_imm(b_imm, op_data.b_imm_offset); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + break; + } + + // ------------------------------------------------------------------------ + // [Base - Mov] + // ------------------------------------------------------------------------ + + case InstDB::kEncodingBaseMov: { + // MOV is a pseudo instruction that uses various instructions depending on its signature. + uint32_t x = diff(o0.as().reg_type(), RegType::kGp32); + if (x > 1) + goto InvalidInstruction; + + if (isign4 == ENC_OPS2(Reg, Reg)) { + if (!o0.as().is_gp()) + goto InvalidInstruction; + + if (!check_signature(o0, o1)) + goto InvalidInstruction; + + bool has_sp = o0.as().is_sp() || o1.as().is_sp(); + if (has_sp) { + // Cannot be combined with ZR. + if (!check_gp_id(o0, o1, kSP)) + goto InvalidPhysId; + + // MOV Rd, Rm -> ADD Rd, Rn, #0. + opcode.reset(0b00010001000000000000000000000000); + opcode.add_imm(x, 31); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + else { + if (!check_gp_id(o0, o1, kZR)) + goto InvalidPhysId; + + // MOV Rd, Rm -> ORR Rd, , Rm. + opcode.reset(0b00101010000000000000001111100000); + opcode.add_imm(x, 31); + opcode.add_reg(o1, 16); + opcode.add_reg(o0, 0); + goto EmitOp; + } + } + + if (isign4 == ENC_OPS2(Reg, Imm)) { + if (!o0.as().is_gp()) + goto InvalidInstruction; + + uint64_t imm_value = o1.as().value_as(); + if (!x) + imm_value &= 0xFFFFFFFFu; + + // Prefer a single MOVN/MOVZ instruction over a logical instruction. + multiple_op_count = encode_mov_sequence_64(multiple_op_data, imm_value, o0.id() & 31, x); + if (multiple_op_count == 1 && !o0.as().is_sp()) { + opcode.reset(multiple_op_data[0]); + goto EmitOp; + } + + // Logical instructions use 13-bit immediate pattern encoded as N:ImmR:ImmS. + LogicalImm logical_imm; + if (!o0.as().is_zr()) { + if (Utils::encode_logical_imm(imm_value, x ? 64 : 32, Out(logical_imm))) { + if (!check_gp_id(o0, kSP)) + goto InvalidPhysId; + + opcode.reset(0b00110010000000000000001111100000); + opcode.add_imm(x, 31); + opcode.add_logical_imm(logical_imm); + opcode.add_reg(o0, 0); + goto EmitOp; + } + } + + if (!check_gp_id(o0, kZR)) + goto InvalidPhysId; + + goto EmitOp_Multiple; + } + + break; + } + + case InstDB::kEncodingBaseMovKNZ: { + const InstDB::EncodingData::BaseMovKNZ& op_data = InstDB::EncodingData::baseMovKNZ[encoding_index]; + + uint32_t x = diff(o0.as().reg_type(), RegType::kGp32); + if (x > 1) + goto InvalidInstruction; + + if (!check_gp_id(o0, kZR)) + goto InvalidPhysId; + + opcode.reset(op_data.opcode); + opcode.add_imm(x, 31); + + if (isign4 == ENC_OPS2(Reg, Imm)) { + uint64_t imm16 = o1.as().value_as(); + if (imm16 > 0xFFFFu) + goto InvalidImmediate; + + opcode.add_imm(imm16, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + if (isign4 == ENC_OPS3(Reg, Imm, Imm)) { + uint64_t imm16 = o1.as().value_as(); + uint32_t shift_type = o2.as().predicate(); + uint64_t shift_value = o2.as().value_as(); + + if (imm16 > 0xFFFFu || shift_value > 48 || shift_type != uint32_t(ShiftOp::kLSL)) + goto InvalidImmediate; + + // Convert shift value to 'hw' field. + uint32_t hw = uint32_t(shift_value) >> 4; + if ((hw << 4) != uint32_t(shift_value)) + goto InvalidImmediate; + + opcode.add_imm(hw, 21); + opcode.add_imm(imm16, 5); + opcode.add_reg(o0, 0); + + if (!x && hw > 1u) + goto InvalidImmediate; + + goto EmitOp; + } + + break; + } + + // ------------------------------------------------------------------------ + // [Base - Adr] + // ------------------------------------------------------------------------ + + case InstDB::kEncodingBaseAdr: { + const InstDB::EncodingData::BaseAdr& op_data = InstDB::EncodingData::baseAdr[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Label) || isign4 == ENC_OPS2(Reg, Imm)) { + if (!o0.as().is_gp64()) + goto InvalidInstruction; + + if (!check_gp_id(o0, kZR)) + goto InvalidPhysId; + + opcode.reset(op_data.opcode()); + opcode.add_reg(o0, 0); + offset_format.reset_to_imm_value(op_data.offset_type, 4, 5, 21, 0); + + if (inst_id == Inst::kIdAdrp) + offset_format._imm_discard_lsb = 12; + + rm_rel = &o1; + goto EmitOp_Rel; + } + + break; + } + + // ------------------------------------------------------------------------ + // [Base - Arithmetic and Logical] + // ------------------------------------------------------------------------ + + case InstDB::kEncodingBaseAddSub: { + const InstDB::EncodingData::BaseAddSub& op_data = InstDB::EncodingData::baseAddSub[encoding_index]; + + uint32_t x; + if (!check_gp_type(o0, o1, kWX, &x)) + goto InvalidInstruction; + + if (isign4 == ENC_OPS3(Reg, Reg, Imm) || isign4 == ENC_OPS4(Reg, Reg, Imm, Imm)) { + opcode.reset(uint32_t(op_data.immediate_op) << 24); + + // ADD | SUB (immediate) - ZR is not allowed. + // ADDS|SUBS (immediate) - ZR allowed in Rd, SP allowed in Rn. + uint32_t a_hi_id = opcode.get() & B(29) ? kZR : kSP; + uint32_t b_hi_id = kSP; + + if (!check_gp_id(o0, a_hi_id) || !check_gp_id(o1, b_hi_id)) + goto InvalidPhysId; + + // ADD|SUB (immediate) use 12-bit immediate optionally shifted by 'LSL #12'. + uint64_t imm = o2.as().value_as(); + uint32_t shift = 0; + + if (isign4 == ENC_OPS4(Reg, Reg, Imm, Imm)) { + if (o3.as().predicate() != uint32_t(ShiftOp::kLSL)) + goto InvalidImmediate; + + if (o3.as().value() != 0 && o3.as().value() != 12) + goto InvalidImmediate; + + shift = uint32_t(o3.as().value() != 0); + } + + // Accept immediate value of '0x00XXX000' by setting 'shift' to 12. + if (imm > 0xFFFu) { + if (shift || (imm & ~uint64_t(0xFFFu << 12)) != 0) + goto InvalidImmediate; + shift = 1; + imm >>= 12; + } + + opcode.add_imm(x, 31); + opcode.add_imm(shift, 22); + opcode.add_imm(imm, 10); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + if (isign4 == ENC_OPS3(Reg, Reg, Reg) || isign4 == ENC_OPS4(Reg, Reg, Reg, Imm)) { + uint32_t op_size = x ? 64 : 32; + uint64_t shift = 0; + uint32_t shift_type = uint32_t(ShiftOp::kLSL); + + if (isign4 == ENC_OPS4(Reg, Reg, Reg, Imm)) { + shift_type = o3.as().predicate(); + shift = o3.as().value_as(); + } + + if (!check_gp_id(o2, kZR)) + goto InvalidPhysId; + + // Shift operation - LSL, LSR, ASR. + if (shift_type <= uint32_t(ShiftOp::kASR)) { + bool has_sp = o0.as().is_sp() || o1.as().is_sp(); + if (!has_sp) { + if (!check_signature(o1, o2)) { + goto InvalidInstruction; + } + + if (!check_gp_id(o0, o1, kZR)) { + goto InvalidPhysId; + } + + if (shift >= op_size) { + goto InvalidImmediate; + } + + opcode.reset(uint32_t(op_data.shifted_op) << 21); + opcode.add_imm(x, 31); + opcode.add_imm(shift_type, 22); + opcode.add_reg(o2, 16); + opcode.add_imm(shift, 10); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + // SP register can only be used with LSL or Extend. + if (shift_type != uint32_t(ShiftOp::kLSL)) { + goto InvalidImmediate; + } + + shift_type = x ? uint32_t(ShiftOp::kUXTX) : uint32_t(ShiftOp::kUXTW); + } + + // Extend operation - UXTB, UXTH, UXTW, UXTX, SXTB, SXTH, SXTW, SXTX. + opcode.reset(uint32_t(op_data.extended_op) << 21); + shift_type -= uint32_t(ShiftOp::kUXTB); + + if (shift_type > 7 || shift > 4) { + goto InvalidImmediate; + } + + if (!(opcode.get() & B(29))) { + // ADD|SUB (extend) - ZR is not allowed. + if (!check_gp_id(o0, o1, kSP)) + goto InvalidPhysId; + } + else { + // ADDS|SUBS (extend) - ZR allowed in Rd, SP allowed in Rn. + if (!check_gp_id(o0, kZR) || !check_gp_id(o1, kSP)) + goto InvalidPhysId; + } + + // Validate whether the register operands match extend option. + if (o2.as().reg_type() != extend_option_to_reg_type(shift_type) || o1.as().reg_type() < o2.as().reg_type()) { + goto InvalidInstruction; + } + + opcode.add_imm(x, 31); + opcode.add_reg(o2, 16); + opcode.add_imm(shift_type, 13); + opcode.add_imm(shift, 10); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseLogical: { + const InstDB::EncodingData::BaseLogical& op_data = InstDB::EncodingData::baseLogical[encoding_index]; + + uint32_t x; + if (!check_gp_type(o0, o1, kWX, &x)) + goto InvalidInstruction; + + if (!check_signature(o0, o1)) + goto InvalidInstruction; + + uint32_t op_size = x ? 64 : 32; + + if (isign4 == ENC_OPS3(Reg, Reg, Imm) && op_data.immediate_op != 0) { + opcode.reset(uint32_t(op_data.immediate_op) << 23); + + // AND|ANDS|BIC|BICS|ORR|EOR (immediate) uses a LogicalImm format described by N:R:S values. + uint64_t imm_mask = axl::trailing_bits_run(op_size); + uint64_t imm_value = o2.as().value_as(); + + if (op_data.negate_imm) + imm_value ^= imm_mask; + + // Logical instructions use 13-bit immediate pattern encoded as N:ImmS:ImmR. + LogicalImm logical_imm; + if (!Utils::encode_logical_imm(imm_value & imm_mask, op_size, Out(logical_imm))) + goto InvalidImmediate; + + // AND|BIC|ORR|EOR (immediate) can have SP on destination, but ANDS|BICS (immediate) cannot. + uint32_t kOpANDS = 0x3 << 29; + bool is_ands = (opcode.get() & kOpANDS) == kOpANDS; + + if (!check_gp_id(o0, is_ands ? kZR : kSP) || !check_gp_id(o1, kZR)) + goto InvalidPhysId; + + opcode.add_imm(x, 31); + opcode.add_logical_imm(logical_imm); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + if (!check_signature(o1, o2)) + goto InvalidInstruction; + + if (isign4 == ENC_OPS3(Reg, Reg, Reg)) { + if (!check_gp_id(o0, o1, o2, kZR)) + goto InvalidPhysId; + + opcode.reset(uint32_t(op_data.shifted_op) << 21); + opcode.add_imm(x, 31); + opcode.add_reg(o2, 16); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + if (isign4 == ENC_OPS4(Reg, Reg, Reg, Imm)) { + if (!check_gp_id(o0, o1, o2, kZR)) + goto InvalidPhysId; + + uint32_t shift_type = o3.as().predicate(); + uint64_t op_shift = o3.as().value_as(); + + if (shift_type > 0x3 || op_shift >= op_size) + goto InvalidImmediate; + + opcode.reset(uint32_t(op_data.shifted_op) << 21); + opcode.add_imm(x, 31); + opcode.add_imm(shift_type, 22); + opcode.add_reg(o2, 16); + opcode.add_imm(op_shift, 10); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseCmpCmn: { + const InstDB::EncodingData::BaseCmpCmn& op_data = InstDB::EncodingData::baseCmpCmn[encoding_index]; + + uint32_t x; + if (!check_gp_type(o0, kWX, &x)) + goto InvalidInstruction; + + if (isign4 == ENC_OPS2(Reg, Imm)) { + // CMN|CMP (immediate) - ZR is not allowed. + if (!check_gp_id(o0, kSP)) + goto InvalidPhysId; + + // CMN|CMP (immediate) use 12-bit immediate optionally shifted by 'LSL #12'. + const Imm& imm12 = o1.as(); + uint32_t imm_shift = 0; + uint64_t imm_value = imm12.value_as(); + + if (imm_value > 0xFFFu) { + if ((imm_value & ~uint64_t(0xFFFu << 12)) != 0) + goto InvalidImmediate; + imm_shift = 1; + imm_value >>= 12; + } + + opcode.reset(uint32_t(op_data.immediate_op) << 24); + opcode.add_imm(x, 31); + opcode.add_imm(imm_shift, 22); + opcode.add_imm(imm_value, 10); + opcode.add_reg(o0, 5); + opcode.add_reg(Gp::kIdZr, 0); + goto EmitOp; + } + + if (isign4 == ENC_OPS2(Reg, Reg) || isign4 == ENC_OPS3(Reg, Reg, Imm)) { + uint32_t op_size = x ? 64 : 32; + uint32_t shift_type = 0; + uint64_t shift_value = 0; + + if (isign4 == ENC_OPS3(Reg, Reg, Imm)) { + shift_type = o2.as().predicate(); + shift_value = o2.as().value_as(); + } + + bool has_sp = o0.as().is_sp() || o1.as().is_sp(); + + // Shift operation - LSL, LSR, ASR. + if (shift_type <= uint32_t(ShiftOp::kASR)) { + if (!has_sp) { + if (!check_signature(o0, o1)) { + goto InvalidInstruction; + } + + if (shift_value >= op_size) { + goto InvalidImmediate; + } + + opcode.reset(uint32_t(op_data.shifted_op) << 21); + opcode.add_imm(x, 31); + opcode.add_imm(shift_type, 22); + opcode.add_reg(o1, 16); + opcode.add_imm(shift_value, 10); + opcode.add_reg(o0, 5); + opcode.add_reg(Gp::kIdZr, 0); + goto EmitOp; + } + + // SP register can only be used with LSL or Extend. + if (shift_type != uint32_t(ShiftOp::kLSL)) + goto InvalidImmediate; + + shift_type = x ? uint32_t(ShiftOp::kUXTX) : uint32_t(ShiftOp::kUXTW); + } + + // Extend operation - UXTB, UXTH, UXTW, UXTX, SXTB, SXTH, SXTW, SXTX. + shift_type -= uint32_t(ShiftOp::kUXTB); + if (shift_type > 7 || shift_value > 4) { + goto InvalidImmediate; + } + + // Validate whether the register operands match extend option. + if (o1.as().reg_type() != extend_option_to_reg_type(shift_type) || o0.as().reg_type() < o1.as().reg_type()) { + goto InvalidInstruction; + } + + opcode.reset(uint32_t(op_data.extended_op) << 21); + opcode.add_imm(x, 31); + opcode.add_reg(o1, 16); + opcode.add_imm(shift_type, 13); + opcode.add_imm(shift_value, 10); + opcode.add_reg(o0, 5); + opcode.add_reg(Gp::kIdZr, 0); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseMvnNeg: { + const InstDB::EncodingData::BaseMvnNeg& op_data = InstDB::EncodingData::baseMvnNeg[encoding_index]; + + uint32_t x; + if (!check_gp_type(o0, o1, kWX, &x)) + goto InvalidInstruction; + + opcode.reset(op_data.opcode); + opcode.add_imm(x, 31); + opcode.add_reg(o1, 16); + opcode.add_reg(o0, 0); + + if (isign4 == ENC_OPS2(Reg, Reg)) { + if (!check_gp_id(o0, o1, kZR)) + goto InvalidPhysId; + + goto EmitOp; + } + + if (isign4 == ENC_OPS3(Reg, Reg, Imm)) { + if (!check_gp_id(o0, o1, kZR)) + goto InvalidPhysId; + + uint32_t op_size = x ? 64 : 32; + uint32_t shift_type = o2.as().predicate(); + uint64_t shift_value = o2.as().value_as(); + + if (shift_type > uint32_t(ShiftOp::kROR) || shift_value >= op_size) + goto InvalidImmediate; + + opcode.add_imm(shift_type, 22); + opcode.add_imm(shift_value, 10); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseTst: { + const InstDB::EncodingData::BaseTst& op_data = InstDB::EncodingData::baseTst[encoding_index]; + + uint32_t x; + if (!check_gp_type(o0, kWX, &x)) + goto InvalidInstruction; + + uint32_t op_size = x ? 64 : 32; + + if (isign4 == ENC_OPS2(Reg, Imm) && op_data.immediate_op != 0) { + if (!check_gp_id(o0, kZR)) + goto InvalidPhysId; + + // TST (immediate) uses a LogicalImm format described by N:R:S values. + uint64_t imm_mask = axl::trailing_bits_run(op_size); + uint64_t imm_value = o1.as().value_as(); + + // Logical instructions use 13-bit immediate pattern encoded as N:ImmS:ImmR. + LogicalImm logical_imm; + if (!Utils::encode_logical_imm(imm_value & imm_mask, op_size, Out(logical_imm))) + goto InvalidImmediate; + + opcode.reset(uint32_t(op_data.immediate_op) << 22); + opcode.add_logical_imm(logical_imm); + opcode.add_imm(x, 31); + opcode.add_reg(o0, 5); + opcode.add_reg(Gp::kIdZr, 0); + goto EmitOp; + } + + opcode.reset(uint32_t(op_data.shifted_op) << 21); + opcode.add_imm(x, 31); + opcode.add_reg(o1, 16); + opcode.add_reg(o0, 5); + opcode.add_reg(Gp::kIdZr, 0); + + if (isign4 == ENC_OPS2(Reg, Reg)) { + if (!check_gp_id(o0, o1, kZR)) + goto InvalidPhysId; + + goto EmitOp; + } + + if (isign4 == ENC_OPS3(Reg, Reg, Imm)) { + if (!check_gp_id(o0, o1, kZR)) + goto InvalidPhysId; + + uint32_t shift_type = o2.as().predicate(); + uint64_t op_shift = o2.as().value_as(); + + if (shift_type > 0x3 || op_shift >= op_size) + goto InvalidImmediate; + + opcode.add_imm(shift_type, 22); + opcode.add_imm(op_shift, 10); + goto EmitOp; + } + + break; + } + + // ------------------------------------------------------------------------ + // [Base - Bit Manipulation] + // ------------------------------------------------------------------------ + + case InstDB::kEncodingBaseBfc: { + const InstDB::EncodingData::BaseBfc& op_data = InstDB::EncodingData::baseBfc[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Imm, Imm)) { + uint32_t x; + if (!check_gp_type(o0, InstDB::kWX, &x)) + goto InvalidInstruction; + + if (!check_gp_id(o0)) + goto InvalidPhysId; + + uint64_t lsb = o1.as().value_as(); + uint64_t width = o2.as().value_as(); + uint32_t op_size = x ? 64 : 32; + + if (lsb >= op_size || width == 0 || width > op_size) + goto InvalidImmediate; + + uint32_t lsb32 = axl::neg(uint32_t(lsb)) & (op_size - 1); + uint32_t width32 = uint32_t(width) - 1; + + opcode.reset(op_data.opcode); + opcode.add_imm(x, 31); + opcode.add_imm(x, 22); + opcode.add_imm(lsb32, 16); + opcode.add_imm(width32, 10); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseBfi: { + const InstDB::EncodingData::BaseBfi& op_data = InstDB::EncodingData::baseBfi[encoding_index]; + + if (isign4 == ENC_OPS4(Reg, Reg, Imm, Imm)) { + uint32_t x; + if (!check_gp_type(o0, InstDB::kWX, &x)) + goto InvalidInstruction; + + if (!check_signature(o0, o1)) + goto InvalidInstruction; + + if (!check_gp_id(o0, o1)) + goto InvalidPhysId; + + uint64_t lsb = o2.as().value_as(); + uint64_t width = o3.as().value_as(); + uint32_t op_size = x ? 64 : 32; + + if (lsb >= op_size || width == 0 || width > op_size) + goto InvalidImmediate; + + uint32_t imm_l = axl::neg(uint32_t(lsb)) & (op_size - 1); + uint32_t imm_w = uint32_t(width) - 1; + + opcode.reset(op_data.opcode); + opcode.add_imm(x, 31); + opcode.add_imm(x, 22); + opcode.add_imm(imm_l, 16); + opcode.add_imm(imm_w, 10); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseBfm: { + const InstDB::EncodingData::BaseBfm& op_data = InstDB::EncodingData::baseBfm[encoding_index]; + + if (isign4 == ENC_OPS4(Reg, Reg, Imm, Imm)) { + uint32_t x; + if (!check_gp_type(o0, InstDB::kWX, &x)) + goto InvalidInstruction; + + if (!check_signature(o0, o1)) + goto InvalidInstruction; + + if (!check_gp_id(o0, o1)) + goto InvalidPhysId; + + uint64_t imm_r = o2.as().value_as(); + uint64_t imm_s = o3.as().value_as(); + uint32_t op_size = x ? 64 : 32; + + if ((imm_r | imm_s) >= op_size) + goto InvalidImmediate; + + opcode.reset(op_data.opcode); + opcode.add_imm(x, 31); + opcode.add_imm(x, 22); + opcode.add_imm(imm_r, 16); + opcode.add_imm(imm_s, 10); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseBfx: { + const InstDB::EncodingData::BaseBfx& op_data = InstDB::EncodingData::baseBfx[encoding_index]; + + if (isign4 == ENC_OPS4(Reg, Reg, Imm, Imm)) { + uint32_t x; + if (!check_gp_type(o0, InstDB::kWX, &x)) + goto InvalidInstruction; + + if (!check_signature(o0, o1)) + goto InvalidInstruction; + + if (!check_gp_id(o0, o1)) + goto InvalidPhysId; + + uint64_t lsb = o2.as().value_as(); + uint64_t width = o3.as().value_as(); + uint32_t op_size = x ? 64 : 32; + + if (lsb >= op_size || width == 0 || width > op_size) + goto InvalidImmediate; + + uint32_t lsb32 = uint32_t(lsb); + uint32_t width32 = lsb32 + uint32_t(width) - 1u; + + if (width32 >= op_size) + goto InvalidImmediate; + + opcode.reset(op_data.opcode); + opcode.add_imm(x, 31); + opcode.add_imm(x, 22); + opcode.add_imm(lsb32, 16); + opcode.add_imm(width32, 10); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseExtend: { + const InstDB::EncodingData::BaseExtend& op_data = InstDB::EncodingData::baseExtend[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Reg)) { + uint32_t x; + if (!check_gp_type(o0, op_data.reg_type, &x)) + goto InvalidInstruction; + + if (!o1.as().is_gp32()) + goto InvalidInstruction; + + if (!check_gp_id(o0, o1)) + goto InvalidPhysId; + + opcode.reset(op_data.opcode()); + opcode.add_imm(x, 31); + opcode.add_imm(x, 22); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseExtract: { + const InstDB::EncodingData::BaseExtract& op_data = InstDB::EncodingData::baseExtract[encoding_index]; + + if (isign4 == ENC_OPS4(Reg, Reg, Reg, Imm)) { + uint32_t x; + if (!check_gp_type(o0, kWX, &x)) + goto InvalidInstruction; + + if (!check_signature(o0, o1, o2)) + goto InvalidInstruction; + + if (!check_gp_id(o0, o1, o2)) + goto InvalidPhysId; + + uint64_t lsb = o3.as().value_as(); + uint32_t op_size = x ? 64 : 32; + + if (lsb >= op_size) + goto InvalidImmediate; + + opcode.reset(op_data.opcode); + opcode.add_imm(x, 31); + opcode.add_imm(x, 22); + opcode.add_reg(o2, 16); + opcode.add_imm(lsb, 10); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseRev: { + if (isign4 == ENC_OPS2(Reg, Reg)) { + uint32_t x; + if (!check_gp_type(o0, InstDB::kWX, &x)) + goto InvalidInstruction; + + if (!check_signature(o0, o1)) + goto InvalidInstruction; + + if (!check_gp_id(o0, o1)) + goto InvalidPhysId; + + opcode.reset(0b01011010110000000000100000000000); + opcode.add_imm(x, 31); + opcode.add_imm(x, 10); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseShift: { + const InstDB::EncodingData::BaseShift& op_data = InstDB::EncodingData::baseShift[encoding_index]; + + uint32_t x; + if (!check_gp_type(o0, kWX, &x)) + goto InvalidInstruction; + + if (isign4 == ENC_OPS3(Reg, Reg, Reg)) { + if (!check_signature(o0, o1, o2)) + goto InvalidInstruction; + + if (!check_gp_id(o0, o1, o2, kZR)) + goto InvalidPhysId; + + opcode.reset(op_data.register_op()); + opcode.add_imm(x, 31); + opcode.add_reg(o2, 16); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + if (isign4 == ENC_OPS3(Reg, Reg, Imm) && op_data.immediate_op()) { + if (!check_signature(o0, o1)) + goto InvalidInstruction; + + if (!check_gp_id(o0, o1, kZR)) + goto InvalidPhysId; + + uint64_t imm_r = o2.as().value_as(); + uint32_t op_size = x ? 64 : 32; + + if (imm_r >= op_size) + goto InvalidImmediate; + + opcode.reset(op_data.immediate_op()); + opcode.add_imm(x, 31); + opcode.add_imm(x, 22); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + + if (opcode.get() & B(10)) { + // ASR and LSR (immediate) has the same logic. + opcode.add_imm(x, 15); + opcode.add_imm(imm_r, 16); + goto EmitOp; + } + + if (op_data.ror == 0) { + // LSL (immediate) is an alias to UBFM + uint32_t ubfm_imm_r = axl::neg(uint32_t(imm_r)) & (op_size - 1); + uint32_t ubfm_imm_s = op_size - 1 - uint32_t(imm_r); + + opcode.add_imm(ubfm_imm_r, 16); + opcode.add_imm(ubfm_imm_s, 10); + goto EmitOp; + } + else { + // ROR (immediate) is an alias to EXTR. + opcode.add_imm(imm_r, 10); + opcode.add_reg(o1, 16); + goto EmitOp; + } + } + + break; + } + + // ------------------------------------------------------------------------ + // [Base - Conditionals] + // ------------------------------------------------------------------------ + + case InstDB::kEncodingBaseCCmp: { + const InstDB::EncodingData::BaseCCmp& op_data = InstDB::EncodingData::baseCCmp[encoding_index]; + + if (isign4 == ENC_OPS4(Reg, Reg, Imm, Imm) || isign4 == ENC_OPS4(Reg, Imm, Imm, Imm)) { + uint32_t x; + if (!check_gp_type(o0, InstDB::kWX, &x)) + goto InvalidInstruction; + + if (!check_gp_id(o0, kZR)) + goto InvalidPhysId; + + uint64_t nzcv = o2.as().value_as(); + uint64_t cond = o3.as().value_as(); + + if ((nzcv | cond) > 0xFu) + goto InvalidImmediate; + + opcode.reset(op_data.opcode); + opcode.add_imm(x, 31); + opcode.add_imm(cond_code_to_opcode_field(uint32_t(cond)), 12); + opcode.add_imm(nzcv, 0); + + if (isign4 == ENC_OPS4(Reg, Reg, Imm, Imm)) { + // CCMN|CCMP (register) form. + if (!check_signature(o0, o1)) + goto InvalidInstruction; + + if (!check_gp_id(o1, kZR)) + goto InvalidPhysId; + + opcode.add_reg(o1, 16); + opcode.add_reg(o0, 5); + goto EmitOp; + } + else { + // CCMN|CCMP (immediate) form. + uint64_t imm5 = o1.as().value_as(); + if (imm5 > 0x1F) + goto InvalidImmediate; + + opcode.add_imm(1, 11); + opcode.add_imm(imm5, 16); + opcode.add_reg(o0, 5); + goto EmitOp; + } + } + + break; + } + + case InstDB::kEncodingBaseCInc: { + const InstDB::EncodingData::BaseCInc& op_data = InstDB::EncodingData::baseCInc[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Imm)) { + uint32_t x; + if (!check_gp_type(o0, o1, InstDB::kWX, &x)) + goto InvalidInstruction; + + if (!check_gp_id(o0, o1, kZR)) + goto InvalidPhysId; + + uint64_t cond = o2.as().value_as(); + if (cond - 2u > 0xEu) + goto InvalidImmediate; + + opcode.reset(op_data.opcode); + opcode.add_imm(x, 31); + opcode.add_reg(o1, 16); + opcode.add_imm(cond_code_to_opcode_field(uint32_t(cond)) ^ 1u, 12); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseCSel: { + const InstDB::EncodingData::BaseCSel& op_data = InstDB::EncodingData::baseCSel[encoding_index]; + + if (isign4 == ENC_OPS4(Reg, Reg, Reg, Imm)) { + uint32_t x; + if (!check_gp_type(o0, o1, o2, InstDB::kWX, &x)) + goto InvalidInstruction; + + if (!check_gp_id(o0, o1, o2, kZR)) + goto InvalidPhysId; + + uint64_t cond = o3.as().value_as(); + if (cond > 0xFu) + goto InvalidImmediate; + + opcode.reset(op_data.opcode); + opcode.add_imm(x, 31); + opcode.add_reg(o2, 16); + opcode.add_imm(cond_code_to_opcode_field(uint32_t(cond)), 12); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseCSet: { + const InstDB::EncodingData::BaseCSet& op_data = InstDB::EncodingData::baseCSet[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Imm)) { + uint32_t x; + if (!check_gp_type(o0, InstDB::kWX, &x)) + goto InvalidInstruction; + + if (!check_gp_id(o0, kZR)) + goto InvalidPhysId; + + uint64_t cond = o1.as().value_as(); + if (cond - 2u >= 0xEu) + goto InvalidImmediate; + + opcode.reset(op_data.opcode); + opcode.add_imm(x, 31); + opcode.add_imm(cond_code_to_opcode_field(uint32_t(cond)) ^ 1u, 12); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + break; + } + + // ------------------------------------------------------------------------ + // [Base - Min/Max] + // ------------------------------------------------------------------------ + + case InstDB::kEncodingBaseMinMax: { + const InstDB::EncodingData::BaseMinMax& op_data = InstDB::EncodingData::baseMinMax[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Reg)) { + uint32_t x; + if (!check_gp_type(o0, InstDB::kWX, &x)) + goto InvalidInstruction; + + if (!check_signature(o0, o1, o2)) + goto InvalidInstruction; + + opcode.reset(op_data.register_op); + opcode.add_imm(x, 31); + opcode.add_reg(o2, 16); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + if (isign4 == ENC_OPS3(Reg, Reg, Imm)) { + uint32_t x; + if (!check_gp_type(o0, InstDB::kWX, &x)) + goto InvalidInstruction; + + if (!check_signature(o0, o1)) + goto InvalidInstruction; + + uint64_t imm = o2.as().value_as(); + + if (op_data.immediate_op & (1u << 18)) { + // Zero extend imm. + if (!axl::is_uint_n<8>(imm)) { + goto InvalidImmediate; + } + } + else { + // Sign extend imm. + if (!axl::is_int_n<8>(int64_t(imm))) { + goto InvalidImmediate; + } + } + + opcode.reset(op_data.immediate_op); + opcode.add_imm(x, 31); + opcode.add_imm(uint32_t(imm & 0xFFu), 10); + opcode.add_reg(o1, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + break; + } + + // ------------------------------------------------------------------------ + // [Base - Special] + // ------------------------------------------------------------------------ + + case InstDB::kEncodingBaseAtDcIcTlbi: { + const InstDB::EncodingData::BaseAtDcIcTlbi& op_data = InstDB::EncodingData::baseAtDcIcTlbi[encoding_index]; + + if (isign4 == ENC_OPS1(Imm) || isign4 == ENC_OPS2(Imm, Reg)) { + if (op_data.mandatory_reg && isign4 != ENC_OPS2(Imm, Reg)) + goto InvalidInstruction; + + if (o0.as().value_as() > 0x7FFFu) + goto InvalidImmediate; + + uint32_t imm = o0.as().value_as(); + if ((imm & op_data.imm_verify_mask) != op_data.imm_verify_data) + goto InvalidImmediate; + + uint32_t rt = 31; + if (o1.is_reg()) { + if (!o1.as().is_gp64()) + goto InvalidInstruction; + + if (!check_gp_id(o1, kZR)) + goto InvalidPhysId; + + rt = o1.id() & 31; + } + + opcode.reset(0b11010101000010000000000000000000); + opcode.add_imm(imm, 5); + opcode.add_reg(rt, 0); + goto EmitOp; + } + break; + } + + case InstDB::kEncodingBaseMrs: { + if (isign4 == ENC_OPS2(Reg, Imm)) { + if (!o0.as().is_gp64()) + goto InvalidInstruction; + + if (!check_gp_id(o0, kZR)) + goto InvalidPhysId; + + if (o1.as().value_as() > 0xFFFFu) + goto InvalidImmediate; + + uint32_t imm = o1.as().value_as(); + if (!(imm & B(15))) + goto InvalidImmediate; + + opcode.reset(0b11010101001100000000000000000000); + opcode.add_imm(imm, 5); + opcode.add_reg(o0, 0); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseMsr: { + if (isign4 == ENC_OPS2(Imm, Reg)) { + if (!o1.as().is_gp64()) + goto InvalidInstruction; + + if (o0.as().value_as() > 0xFFFFu) + goto InvalidImmediate; + + uint32_t imm = o0.as().value_as(); + if (!(imm & B(15))) + goto InvalidImmediate; + + if (!check_gp_id(o1, kZR)) + goto InvalidPhysId; + + opcode.reset(0b11010101000100000000000000000000); + opcode.add_imm(imm, 5); + opcode.add_reg(o1, 0); + goto EmitOp; + } + + if (isign4 == ENC_OPS2(Imm, Imm)) { + if (o0.as().value_as() > 0x1Fu) + goto InvalidImmediate; + + if (o1.as().value_as() > 0xFu) + goto InvalidImmediate; + + uint32_t op = o0.as().value_as(); + uint32_t crm = o1.as().value_as(); + + uint32_t op1 = uint32_t(op) >> 3; + uint32_t op2 = uint32_t(op) & 0x7u; + + opcode.reset(0b11010101000000000100000000011111); + opcode.add_imm(op1, 16); + opcode.add_imm(crm, 8); + opcode.add_imm(op2, 5); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseSys: { + if (isign4 == ENC_OPS4(Imm, Imm, Imm, Imm)) { + if (o0.as().value_as() > 0x7u || + o1.as().value_as() > 0xFu || + o2.as().value_as() > 0xFu || + o3.as().value_as() > 0x7u) + goto InvalidImmediate; + + uint32_t op1 = o0.as().value_as(); + uint32_t crn = o1.as().value_as(); + uint32_t crm = o2.as().value_as(); + uint32_t op2 = o3.as().value_as(); + uint32_t rt = 31; + + const Operand_& o4 = op_ext[EmitterUtils::kOp4]; + if (o4.is_reg()) { + if (!o4.as().is_gp64()) + goto InvalidInstruction; + + if (!check_gp_id(o4, kZR)) + goto InvalidPhysId; + + rt = o4.id() & 31; + } + else if (!o4.is_none()) { + goto InvalidInstruction; + } + + opcode.reset(0b11010101000010000000000000000000); + opcode.add_imm(op1, 16); + opcode.add_imm(crn, 12); + opcode.add_imm(crm, 8); + opcode.add_imm(op2, 5); + opcode.add_imm(rt, 0); + goto EmitOp; + } + + break; + } + + // ------------------------------------------------------------------------ + // [Base - Branch] + // ------------------------------------------------------------------------ + + case InstDB::kEncodingBaseBranchReg: { + const InstDB::EncodingData::BaseBranchReg& op_data = InstDB::EncodingData::baseBranchReg[encoding_index]; + + if (isign4 == ENC_OPS1(Reg)) { + if (!o0.as().is_gp64()) + goto InvalidInstruction; + + if (!check_gp_id(o0, kZR)) + goto InvalidPhysId; + + opcode.reset(op_data.opcode); + opcode.add_reg(o0, 5); + goto EmitOp; + } + + break; + } + + case InstDB::kEncodingBaseBranchRel: { + const InstDB::EncodingData::BaseBranchRel& op_data = InstDB::EncodingData::baseBranchRel[encoding_index]; + + if (isign4 == ENC_OPS1(Label) || isign4 == ENC_OPS1(Imm)) { + opcode.reset(op_data.opcode); + rm_rel = &o0; + + // A variation that uses Cond code (or where Cond code is forced like BC.). + if (inst_cc != CondCode::kAL || axl::bit_test(opcode.v, 30)) { + if (opcode.has_x()) { + // Condition code cannot be applied when the instruction has X bit set (this would be BL instruction). + goto InvalidInstruction; + } + + opcode |= B(30); + opcode.add_imm(cond_code_to_opcode_field(uint32_t(inst_cc)), 0); + offset_format.reset_to_imm_value(OffsetType::kSignedOffset, 4, 5, 19, 2); + goto EmitOp_Rel; + } + + offset_format.reset_to_imm_value(OffsetType::kSignedOffset, 4, 0, 26, 2); + goto EmitOp_Rel; + } + + break; + } + + case InstDB::kEncodingBaseBranchCmp: { + const InstDB::EncodingData::BaseBranchCmp& op_data = InstDB::EncodingData::baseBranchCmp[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Label) || isign4 == ENC_OPS2(Reg, Imm)) { + uint32_t x; + if (!check_gp_type(o0, kWX, &x)) + goto InvalidInstruction; + + if (!check_gp_id(o0, kZR)) + goto InvalidPhysId; + + opcode.reset(op_data.opcode); + opcode.add_imm(x, 31); + opcode.add_reg(o0, 0); + offset_format.reset_to_imm_value(OffsetType::kSignedOffset, 4, 5, 19, 2); + + rm_rel = &o1; + goto EmitOp_Rel; + } + + break; + } + + case InstDB::kEncodingBaseBranchTst: { + const InstDB::EncodingData::BaseBranchTst& op_data = InstDB::EncodingData::baseBranchTst[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Imm, Label) || isign4 == ENC_OPS3(Reg, Imm, Imm)) { + uint32_t x; + if (!check_gp_type(o0, kWX, &x)) + goto InvalidInstruction; + + if (!check_gp_id(o0, kZR)) + goto InvalidPhysId; + + uint64_t imm = o1.as().value_as(); + + opcode.reset(op_data.opcode); + if (imm >= 32) { + if (!x) + goto InvalidImmediate; + opcode.add_imm(x, 31); + imm &= 0x1F; + } + + opcode.add_reg(o0, 0); + opcode.add_imm(imm, 19); + offset_format.reset_to_imm_value(OffsetType::kSignedOffset, 4, 5, 14, 2); + + rm_rel = &o2; + goto EmitOp_Rel; + } + + break; + } + + // ------------------------------------------------------------------------ + // [Base - Prefetch] + // ------------------------------------------------------------------------ + + case InstDB::kEncodingBasePrfm: { + const InstDB::EncodingData::BasePrfm& op_data = InstDB::EncodingData::basePrfm[encoding_index]; + + if (isign4 == ENC_OPS2(Imm, Mem)) { + const Mem& m = o1.as(); + rm_rel = &m; + + uint32_t imm_shift = 3u; + + if (o0.as().value_as() > 0x1Fu) + goto InvalidImmediate; + + if (!check_mem_base_index_rel(m)) + goto InvalidAddress; + + int64_t offset = m.offset(); + uint32_t prfop = o0.as().value_as(); + + if (m.has_base_reg()) { + // [Base {Offset | Index}] + if (m.has_index()) { + uint32_t opt = shift_op_to_ld_st_opt_map[size_t(m.shift_op())]; + if (opt == 0xFF) + goto InvalidAddress; + + uint32_t shift = m.shift(); + uint32_t s = shift != 0; + + if (s && shift != imm_shift) + goto InvalidAddressScale; + + opcode.reset(uint32_t(op_data.register_op) << 21); + opcode.add_imm(opt, 13); + opcode.add_imm(s, 12); + opcode |= B(11); + opcode.add_imm(prfop, 0); + goto EmitOp_MemBaseIndex_Rn5_Rm16; + } + + if (!axl::is_int_n<32>(offset)) + goto InvalidDisplacement; + + int32_t offset32 = int32_t(offset); + + if (m.is_pre_or_post()) + goto InvalidAddress; + + uint32_t imm12 = uint32_t(offset32) >> imm_shift; + + if (axl::is_uint_n<12>(imm12) && (imm12 << imm_shift) == uint32_t(offset32)) { + opcode.reset(uint32_t(op_data.s_offset_op) << 22); + opcode.add_imm(imm12, 10); + opcode.add_imm(prfop, 0); + goto EmitOp_MemBase_Rn5; + } + + if (axl::is_int_n<9>(offset32)) { + opcode.reset(uint32_t(op_data.u_offset_op) << 21); + opcode.add_imm(uint32_t(offset32) & 0x1FFu, 12); + opcode.add_imm(prfop, 0); + goto EmitOp_MemBase_Rn5; + } + + goto InvalidAddress; + } + else { + opcode.reset(uint32_t(op_data.literal_op) << 24); + opcode.add_imm(prfop, 0); + offset_format.reset_to_imm_value(OffsetType::kSignedOffset, 4, 5, 19, 2); + goto EmitOp_Rel; + } + } + + break; + } + + // ------------------------------------------------------------------------ + // [Base - Load / Store] + // ------------------------------------------------------------------------ + + case InstDB::kEncodingBaseLdSt: { + const InstDB::EncodingData::BaseLdSt& op_data = InstDB::EncodingData::baseLdSt[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Mem)) { + const Mem& m = o1.as(); + rm_rel = &m; + + uint32_t x; + if (!check_gp_type(o0, op_data.reg_type, &x)) + goto InvalidInstruction; + + if (!check_gp_id(o0, kZR)) + goto InvalidPhysId; + + // Instructions that work with either word or dword have the unsigned + // offset shift set to 2 (word), so we set it to 3 (dword) if this is + // X version of the instruction. + uint32_t x_shift_mask = uint32_t(op_data.u_offset_shift == 2); + uint32_t imm_shift = uint32_t(op_data.u_offset_shift) + (x & x_shift_mask); + + if (!check_mem_base_index_rel(m)) + goto InvalidAddress; + + int64_t offset = m.offset(); + if (m.has_base_reg()) { + // [Base {Offset | Index}] + if (m.has_index()) { + uint32_t opt = shift_op_to_ld_st_opt_map[size_t(m.shift_op())]; + if (opt == 0xFF) + goto InvalidAddress; + + uint32_t shift = m.shift(); + uint32_t s = shift != 0; + + if (s && shift != imm_shift) + goto InvalidAddressScale; + + opcode.reset(uint32_t(op_data.register_op) << 21); + opcode.xor_imm(x, op_data.x_offset); + opcode.add_imm(opt, 13); + opcode.add_imm(s, 12); + opcode |= B(11); + opcode.add_reg(o0, 0); + goto EmitOp_MemBaseIndex_Rn5_Rm16; + } + + // Makes it easier to work with the offset especially on 32-bit arch. + if (!axl::is_int_n<32>(offset)) + goto InvalidDisplacement; + int32_t offset32 = int32_t(offset); + + if (m.is_pre_or_post()) { + if (!axl::is_int_n<9>(offset32)) + goto InvalidDisplacement; + + opcode.reset(uint32_t(op_data.pre_post_op) << 21); + opcode.xor_imm(x, op_data.x_offset); + opcode.add_imm(offset32 & 0x1FF, 12); + opcode.add_imm(m.is_pre_index(), 11); + opcode |= B(10); + opcode.add_reg(o0, 0); + goto EmitOp_MemBase_Rn5; + } + else { + uint32_t imm12 = uint32_t(offset32) >> imm_shift; + + // Alternative form of LDUR/STUR and related instructions as described by AArch64 reference manual: + // + // If this instruction is not encodable with scaled unsigned offset, try unscaled signed offset. + if (!axl::is_uint_n<12>(imm12) || (imm12 << imm_shift) != uint32_t(offset32)) { + inst_id = op_data.u_alt_inst_id; + inst_info = &InstDB::_inst_info_table[inst_id]; + encoding_index = inst_info->_encoding_data_index; + goto Case_BaseLdurStur; + } + + opcode.reset(uint32_t(op_data.u_offset_op) << 22); + opcode.xor_imm(x, op_data.x_offset); + opcode.add_imm(imm12, 10); + opcode.add_reg(o0, 0); + goto EmitOp_MemBase_Rn5; + } + } + else { + if (!op_data.literal_op) + goto InvalidAddress; + + opcode.reset(uint32_t(op_data.literal_op) << 24); + opcode.xor_imm(x, op_data.x_offset); + opcode.add_reg(o0, 0); + offset_format.reset_to_imm_value(OffsetType::kSignedOffset, 4, 5, 19, 2); + goto EmitOp_Rel; + } + } + + break; + } + + case InstDB::kEncodingBaseLdpStp: { + const InstDB::EncodingData::BaseLdpStp& op_data = InstDB::EncodingData::baseLdpStp[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Mem)) { + const Mem& m = o2.as(); + rm_rel = &m; + + uint32_t x; + if (!check_gp_type(o0, o1, op_data.reg_type, &x)) + goto InvalidInstruction; + + if (!check_gp_id(o0, o1, kZR)) + goto InvalidPhysId; + + if (m.base_type() != RegType::kGp64 || m.has_index()) + goto InvalidAddress; + + if (m.is_offset_64bit()) + goto InvalidDisplacement; + + uint32_t offset_shift = op_data.offset_shift + x; + int32_t offset32 = m.offset_lo32() >> offset_shift; + + // Make sure we didn't lose bits by applying the mandatory offset shift. + if (uint32_t(offset32) << offset_shift != uint32_t(m.offset_lo32())) + goto InvalidDisplacement; + + // Offset is encoded as 7-bit immediate. + if (!axl::is_int_n<7>(offset32)) + goto InvalidDisplacement; + + if (m.is_pre_or_post() && offset32 != 0) { + if (!op_data.pre_post_op) + goto InvalidAddress; + + opcode.reset(uint32_t(op_data.pre_post_op) << 22); + opcode.add_imm(m.is_pre_index(), 24); + } + else { + opcode.reset(uint32_t(op_data.offset_op) << 22); + } + + opcode.add_imm(x, op_data.x_offset); + opcode.add_imm(offset32 & 0x7F, 15); + opcode.add_reg(o1, 10); + opcode.add_reg(o0, 0); + goto EmitOp_MemBase_Rn5; + } + + break; + } + + case InstDB::kEncodingBaseStx: { + const InstDB::EncodingData::BaseStx& op_data = InstDB::EncodingData::baseStx[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Mem)) { + const Mem& m = o2.as(); + uint32_t x; + + if (!o0.as().is_gp32() || !check_gp_type(o1, op_data.reg_type, &x)) + goto InvalidInstruction; + + if (!check_gp_id(o0, o1, kZR)) + goto InvalidPhysId; + + opcode.reset(op_data.opcode()); + opcode.add_imm(x, op_data.x_offset); + opcode.add_reg(o0, 16); + opcode.add_reg(o1, 0); + + rm_rel = &m; + goto EmitOp_MemBaseNoImm_Rn5; + } + + break; + } + + case InstDB::kEncodingBaseLdxp: { + const InstDB::EncodingData::BaseLdxp& op_data = InstDB::EncodingData::baseLdxp[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Mem)) { + const Mem& m = o2.as(); + uint32_t x; + + if (!check_gp_type(o0, op_data.reg_type, &x) || !check_signature(o0, o1)) + goto InvalidInstruction; + + if (!check_gp_id(o0, o1, kZR)) + goto InvalidPhysId; + + opcode.reset(op_data.opcode()); + opcode.add_imm(x, op_data.x_offset); + opcode.add_reg(o1, 10); + opcode.add_reg(o0, 0); + + rm_rel = &m; + goto EmitOp_MemBaseNoImm_Rn5; + } + + break; + } + + case InstDB::kEncodingBaseStxp: { + const InstDB::EncodingData::BaseStxp& op_data = InstDB::EncodingData::baseStxp[encoding_index]; + + if (isign4 == ENC_OPS4(Reg, Reg, Reg, Mem)) { + const Mem& m = o3.as(); + uint32_t x; + + if (!o0.as().is_gp32() || !check_gp_type(o1, op_data.reg_type, &x) || !check_signature(o1, o2)) + goto InvalidInstruction; + + if (!check_gp_id(o0, o1, o2, kZR)) + goto InvalidPhysId; + + opcode.reset(op_data.opcode()); + opcode.add_imm(x, op_data.x_offset); + opcode.add_reg(o0, 16); + opcode.add_reg(o2, 10); + opcode.add_reg(o1, 0); + + rm_rel = &m; + goto EmitOp_MemBaseNoImm_Rn5; + } + + break; + } + + case InstDB::kEncodingBaseRM_NoImm: { + const InstDB::EncodingData::BaseRM_NoImm& op_data = InstDB::EncodingData::baseRM_NoImm[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Mem)) { + const Mem& m = o1.as(); + rm_rel = &m; + + uint32_t x; + if (!check_gp_type(o0, op_data.reg_type, &x)) + goto InvalidInstruction; + + if (!check_gp_id(o0, op_data.reg_hi_id)) + goto InvalidPhysId; + + opcode.reset(op_data.opcode()); + opcode.add_imm(x, op_data.x_offset); + opcode.add_reg(o0, 0); + goto EmitOp_MemBaseNoImm_Rn5; + } + + break; + } + + case InstDB::kEncodingBaseRM_SImm9: { +Case_BaseLdurStur: + const InstDB::EncodingData::BaseRM_SImm9& op_data = InstDB::EncodingData::baseRM_SImm9[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Mem)) { + const Mem& m = o1.as(); + rm_rel = &m; + + uint32_t x; + if (!check_gp_type(o0, op_data.reg_type, &x)) + goto InvalidInstruction; + + if (!check_gp_id(o0, op_data.reg_hi_id)) + goto InvalidPhysId; + + if (m.has_base_reg() && !m.has_index()) { + if (m.is_offset_64bit()) + goto InvalidDisplacement; + + int32_t offset32 = m.offset_lo32() >> op_data.imm_shift; + if (axl::shl(offset32, op_data.imm_shift) != m.offset_lo32()) + goto InvalidDisplacement; + + if (!axl::is_int_n<9>(offset32)) + goto InvalidDisplacement; + + if (m.is_fixed_offset()) { + opcode.reset(op_data.offset_op()); + } + else { + if (!op_data.pre_post_op()) + goto InvalidInstruction; + + opcode.reset(op_data.pre_post_op()); + opcode.xor_imm(m.is_pre_index(), 11); + } + + opcode.xor_imm(x, op_data.x_offset); + opcode.add_imm(offset32 & 0x1FF, 12); + opcode.add_reg(o0, 0); + goto EmitOp_MemBase_Rn5; + } + + goto InvalidAddress; + } + + break; + } + + case InstDB::kEncodingBaseRM_SImm10: { + const InstDB::EncodingData::BaseRM_SImm10& op_data = InstDB::EncodingData::baseRM_SImm10[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Mem)) { + const Mem& m = o1.as(); + rm_rel = &m; + + uint32_t x; + if (!check_gp_type(o0, op_data.reg_type, &x)) + goto InvalidInstruction; + + if (!check_gp_id(o0, op_data.reg_hi_id)) + goto InvalidPhysId; + + if (m.has_base_reg() && !m.has_index()) { + if (m.is_offset_64bit()) + goto InvalidDisplacement; + + int32_t offset32 = m.offset_lo32() >> op_data.imm_shift; + if (axl::shl(offset32, op_data.imm_shift) != m.offset_lo32()) + goto InvalidDisplacement; + + if (!axl::is_int_n<10>(offset32)) + goto InvalidDisplacement; + + if (m.is_post_index()) + goto InvalidAddress; + + // Offset has 10 bits, sign is stored in the 10th bit. + offset32 &= 0x3FF; + + opcode.reset(op_data.opcode()); + opcode.xor_imm(m.is_pre_index(), 11); + opcode.xor_imm(x, op_data.x_offset); + opcode.add_imm(offset32 >> 9, 22); + opcode.add_imm(offset32, 12); + opcode.add_reg(o0, 0); + goto EmitOp_MemBase_Rn5; + } + + goto InvalidAddress; + } + + break; + } + + case InstDB::kEncodingBaseAtomicOp: { + const InstDB::EncodingData::BaseAtomicOp& op_data = InstDB::EncodingData::baseAtomicOp[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Mem)) { + const Mem& m = o2.as(); + uint32_t x; + + if (!check_gp_type(o0, op_data.reg_type, &x) || !check_signature(o0, o1)) + goto InvalidInstruction; + + if (!check_gp_id(o0, o1, kZR)) + goto InvalidInstruction; + + opcode.reset(op_data.opcode()); + opcode.add_imm(x, op_data.x_offset); + opcode.add_reg(o0, 16); + opcode.add_reg(o1, 0); + + rm_rel = &m; + goto EmitOp_MemBaseNoImm_Rn5; + } + + break; + } + + case InstDB::kEncodingBaseAtomicSt: { + const InstDB::EncodingData::BaseAtomicSt& op_data = InstDB::EncodingData::baseAtomicSt[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Mem)) { + const Mem& m = o1.as(); + uint32_t x; + + if (!check_gp_type(o0, op_data.reg_type, &x)) + goto InvalidInstruction; + + if (!check_gp_id(o0, kZR)) + goto InvalidPhysId; + + opcode.reset(op_data.opcode()); + opcode.add_imm(x, op_data.x_offset); + opcode.add_reg(o0, 16); + opcode.add_reg(Gp::kIdZr, 0); + + rm_rel = &m; + goto EmitOp_MemBaseNoImm_Rn5; + } + + break; + } + + case InstDB::kEncodingBaseAtomicCasp: { + const InstDB::EncodingData::BaseAtomicCasp& op_data = InstDB::EncodingData::baseAtomicCasp[encoding_index]; + const Operand_& o4 = op_ext[EmitterUtils::kOp4]; + + if (isign4 == ENC_OPS4(Reg, Reg, Reg, Reg) && o4.is_mem()) { + const Mem& m = o4.as(); + uint32_t x; + + if (!check_gp_type(o0, op_data.reg_type, &x)) + goto InvalidInstruction; + + if (!check_signature(o0, o1, o2, o3)) + goto InvalidInstruction; + + if (!check_even(o0, o2) || !check_gp_id(o0, o2, kZR)) + goto InvalidPhysId; + + if (!check_consecutive(o0, o1) || !check_consecutive(o2, o3)) + goto InvalidPhysId; + + opcode.reset(op_data.opcode()); + opcode.add_imm(x, op_data.x_offset); + opcode.add_reg(o0, 16); + opcode.add_reg(o2, 0); + + rm_rel = &m; + goto EmitOp_MemBaseNoImm_Rn5; + } + + break; + } + + // ------------------------------------------------------------------------ + // [FSimd - Instructions] + // ------------------------------------------------------------------------ + + case InstDB::kEncodingFSimdSV: { + const InstDB::EncodingData::FSimdSV& op_data = InstDB::EncodingData::fSimdSV[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Reg)) { + uint32_t q = diff(o1.as().reg_type(), RegType::kVec64); + if (q > 1) + goto InvalidInstruction; + + if (o0.as().has_element_type()) + goto InvalidInstruction; + + // This operation is only defined for: + // hD, vS.{4|8}h (16-bit) + // sD, vS.4s (32-bit) + uint32_t sz = diff(o0.as().reg_type(), RegType::kVec16); + uint32_t element_sz = diff(o1.as().element_type(), VecElementType::kH); + + // Size greater than 1 means 64-bit elements, not supported. + if ((sz | element_sz) > 1 || sz != element_sz) + goto InvalidInstruction; + + // Size 1 (32-bit float) requires at least 4 elements. + if (sz && !q) + goto InvalidInstruction; + + // Bit flipping according to sz. + static const uint32_t sz_bits_table[] = { B(29), 0 }; + + opcode.reset(op_data.opcode << 10); + opcode ^= sz_bits_table[sz]; + opcode.add_imm(q, 30); + goto EmitOp_Rd0_Rn5; + } + + break; + } + + case InstDB::kEncodingFSimdVV: { + const InstDB::EncodingData::FSimdVV& op_data = InstDB::EncodingData::fSimdVV[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Reg)) { + if (!match_signature(o0, o1, inst_flags)) + goto InvalidInstruction; + + if (!pick_fp_opcode(o0.as(), op_data.scalar_op(), op_data.scalar_hf(), op_data.vector_op(), op_data.vector_hf(), &opcode)) + goto InvalidInstruction; + + goto EmitOp_Rd0_Rn5; + } + + break; + } + + case InstDB::kEncodingFSimdVVV: { + const InstDB::EncodingData::FSimdVVV& op_data = InstDB::EncodingData::fSimdVVV[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Reg)) { + if (!match_signature(o0, o1, o2, inst_flags)) + goto InvalidInstruction; + + if (!pick_fp_opcode(o0.as(), op_data.scalar_op(), op_data.scalar_hf(), op_data.vector_op(), op_data.vector_hf(), &opcode)) + goto InvalidInstruction; + + goto EmitOp_Rd0_Rn5_Rm16; + } + + break; + } + + case InstDB::kEncodingFSimdVVVe: { + const InstDB::EncodingData::FSimdVVVe& op_data = InstDB::EncodingData::fSimdVVVe[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Reg)) { + if (!o2.as().has_element_index()) { + if (!match_signature(o0, o1, o2, inst_flags)) + goto InvalidInstruction; + + if (!pick_fp_opcode(o0.as(), op_data.scalar_op(), op_data.scalar_hf(), op_data.vector_op(), op_data.vector_hf(), &opcode)) + goto InvalidInstruction; + + goto EmitOp_Rd0_Rn5_Rm16; + } + else { + if (!match_signature(o0, o1, inst_flags)) + goto InvalidInstruction; + + uint32_t q = o1.as().is_vec128(); + uint32_t sz; + + if (!pick_fp_opcode(o0.as(), op_data.element_scalar_op(), InstDB::kHF_D, op_data.element_vector_op(), InstDB::kHF_D, &opcode, &sz)) + goto InvalidInstruction; + + if (sz == 0 && o2.as().id() > 15) + goto InvalidPhysId; + + uint32_t element_index = o2.as().element_index(); + if (element_index > (7u >> sz)) + goto InvalidElementIndex; + + uint32_t hlm = element_index << sz; + opcode.add_imm(q, 30); + opcode.add_imm(hlm & 3u, 20); + opcode.add_imm(hlm >> 2, 11); + goto EmitOp_Rd0_Rn5_Rm16; + } + } + + break; + } + + case InstDB::kEncodingFSimdVVVV: { + const InstDB::EncodingData::FSimdVVVV& op_data = InstDB::EncodingData::fSimdVVVV[encoding_index]; + + if (isign4 == ENC_OPS4(Reg, Reg, Reg, Reg)) { + if (!match_signature(o0, o1, o2, o3, inst_flags)) + goto InvalidInstruction; + + if (!pick_fp_opcode(o0.as(), op_data.scalar_op(), op_data.scalar_hf(), op_data.vector_op(), op_data.vector_hf(), &opcode)) + goto InvalidInstruction; + + goto EmitOp_Rd0_Rn5_Rm16_Ra10; + } + + break; + } + + case InstDB::kEncodingSimdFcadd: { + const InstDB::EncodingData::SimdFcadd& op_data = InstDB::EncodingData::simdFcadd[encoding_index]; + + if (isign4 == ENC_OPS4(Reg, Reg, Reg, Imm)) { + if (!check_signature(o0, o1, o2) || o0.as().has_element_index()) + goto InvalidInstruction; + + uint32_t q = diff(o0.as().reg_type(), RegType::kVec64); + if (q > 1) + goto InvalidInstruction; + + uint32_t sz = diff(o0.as().element_type(), VecElementType::kB); + if (sz == 0 || sz > 3) + goto InvalidInstruction; + + // 0 <- 90deg. + // 1 <- 270deg. + uint32_t rot = 0; + if (o3.as().value() == 270) + rot = 1; + else if (o3.as().value() != 90) + goto InvalidImmediate; + + opcode.reset(op_data.opcode()); + opcode.add_imm(q, 30); + opcode.add_imm(sz, 22); + opcode.add_imm(rot, 12); + goto EmitOp_Rd0_Rn5_Rm16; + } + + break; + } + + case InstDB::kEncodingSimdFccmpFccmpe: { + const InstDB::EncodingData::SimdFccmpFccmpe& op_data = InstDB::EncodingData::simdFccmpFccmpe[encoding_index]; + + if (isign4 == ENC_OPS4(Reg, Reg, Imm, Imm)) { + uint32_t sz = diff(o0.as().reg_type(), RegType::kVec16); + if (sz > 2) + goto InvalidInstruction; + + if (!check_signature(o0, o1) || o0.as().has_element_type()) + goto InvalidInstruction; + + uint64_t nzcv = o2.as().value_as(); + uint64_t cond = o3.as().value_as(); + + if ((nzcv | cond) > 0xFu) + goto InvalidImmediate; + + uint32_t type = (sz - 1) & 0x3u; + + opcode.reset(op_data.opcode()); + opcode.add_imm(type, 22); + opcode.add_imm(cond_code_to_opcode_field(uint32_t(cond)), 12); + opcode.add_imm(nzcv, 0); + + goto EmitOp_Rn5_Rm16; + } + + break; + } + + case InstDB::kEncodingSimdFcm: { + const InstDB::EncodingData::SimdFcm& op_data = InstDB::EncodingData::simdFcm[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Reg) && op_data.has_register_op()) { + if (!match_signature(o0, o1, o2, inst_flags)) + goto InvalidInstruction; + + if (!pick_fp_opcode(o0.as(), op_data.register_scalar_op(), op_data.register_scalar_hf(), op_data.register_vector_op(), op_data.register_vector_hf(), &opcode)) + goto InvalidInstruction; + + goto EmitOp_Rd0_Rn5_Rm16; + } + + if (isign4 == ENC_OPS3(Reg, Reg, Imm) && op_data.has_zero_op()) { + if (!check_signature(o0, o1)) + goto InvalidInstruction; + + if (o2.as().value() != 0 || o2.as().predicate() != 0) + goto InvalidImmediate; + + if (!pick_fp_opcode(o0.as(), op_data.zero_scalar_op(), InstDB::kHF_B, op_data.zero_vector_op(), InstDB::kHF_B, &opcode)) + goto InvalidInstruction; + + goto EmitOp_Rd0_Rn5; + } + + break; + } + + case InstDB::kEncodingSimdFcmla: { + const InstDB::EncodingData::SimdFcmla& op_data = InstDB::EncodingData::simdFcmla[encoding_index]; + + if (isign4 == ENC_OPS4(Reg, Reg, Reg, Imm)) { + if (!check_signature(o0, o1)) + goto InvalidInstruction; + + uint32_t q = diff(o0.as().reg_type(), RegType::kVec64); + if (q > 1) + goto InvalidInstruction; + + uint32_t sz = diff(o0.as().element_type(), VecElementType::kB); + if (sz == 0 || sz > 3) + goto InvalidInstruction; + + uint32_t rot = 0; + switch (o3.as().value()) { + case 0 : rot = 0; break; + case 90 : rot = 1; break; + case 180: rot = 2; break; + case 270: rot = 3; break; + default: + goto InvalidImmediate; + } + + if (!o2.as().has_element_index()) { + if (!check_signature(o1, o2)) + goto InvalidInstruction; + + opcode.reset(op_data.regular_op()); + opcode.add_imm(q, 30); + opcode.add_imm(sz, 22); + opcode.add_imm(rot, 11); + goto EmitOp_Rd0_Rn5_Rm16; + } + else { + if (o0.as().element_type() != o2.as().element_type()) + goto InvalidInstruction; + + // Only allowed vectors are: 4H, 8H, and 4S. + if (!(sz == 1 || (q == 1 && sz == 2))) + goto InvalidInstruction; + + // Element index ranges: + // 4H - ElementIndex[0..1] (index 2..3 is UNDEFINED). + // 8H - ElementIndex[0..3]. + // 4S - ElementIndex[0..1]. + uint32_t element_index = o2.as().element_index(); + uint32_t hl_field_shift = sz == 1 ? 0u : 1u; + uint32_t max_element_index = q == 1 && sz == 1 ? 3u : 1u; + + if (element_index > max_element_index) + goto InvalidElementIndex; + + uint32_t hl = element_index << hl_field_shift; + + opcode.reset(op_data.element_op()); + opcode.add_imm(q, 30); + opcode.add_imm(sz, 22); + opcode.add_imm(hl & 1u, 21); // L field. + opcode.add_imm(hl >> 1, 11); // H field. + opcode.add_imm(rot, 13); + goto EmitOp_Rd0_Rn5_Rm16; + } + } + + break; + } + + case InstDB::kEncodingSimdFcmpFcmpe: { + const InstDB::EncodingData::SimdFcmpFcmpe& op_data = InstDB::EncodingData::simdFcmpFcmpe[encoding_index]; + + uint32_t sz = diff(o0.as().reg_type(), RegType::kVec16); + uint32_t type = (sz - 1) & 0x3u; + + if (sz > 2) + goto InvalidInstruction; + + if (o0.as().has_element_type()) + goto InvalidInstruction; + + opcode.reset(op_data.opcode()); + opcode.add_imm(type, 22); + + if (isign4 == ENC_OPS2(Reg, Reg)) { + if (!check_signature(o0, o1)) + goto InvalidInstruction; + + goto EmitOp_Rn5_Rm16; + } + + if (isign4 == ENC_OPS2(Reg, Imm)) { + if (o1.as().value() != 0 || o1.as().predicate() != 0) + goto InvalidInstruction; + + opcode |= B(3); + goto EmitOp_Rn5; + } + + break; + } + + case InstDB::kEncodingSimdFcsel: { + if (isign4 == ENC_OPS4(Reg, Reg, Reg, Imm)) { + if (!check_signature(o0, o1, o2)) + goto InvalidInstruction; + + uint32_t sz = diff(o0.as().reg_type(), RegType::kVec16); + uint32_t type = (sz - 1) & 0x3u; + + if (sz > 2 || o0.as().has_element_type()) + goto InvalidInstruction; + + uint64_t cond = o3.as().value_as(); + if (cond > 0xFu) + goto InvalidImmediate; + + opcode.reset(0b00011110001000000000110000000000); + opcode.add_imm(type, 22); + opcode.add_imm(cond_code_to_opcode_field(uint32_t(cond)), 12); + goto EmitOp_Rd0_Rn5_Rm16; + } + + break; + } + + case InstDB::kEncodingSimdFcvt: { + if (isign4 == ENC_OPS2(Reg, Reg)) { + uint32_t dst_sz = diff(o0.as().reg_type(), RegType::kVec16); + uint32_t src_sz = diff(o1.as().reg_type(), RegType::kVec16); + + if ((dst_sz | src_sz) > 3) + goto InvalidInstruction; + + if (o0.as().has_element_type() || o1.as().has_element_type()) + goto InvalidInstruction; + + // Table that provides 'type' and 'opc' according to the dst/src combination. + static const uint8_t table[] = { + 0xFFu, // H <- H (Invalid). + 0x03u, // H <- S (type=00 opc=11). + 0x13u, // H <- D (type=01 opc=11). + 0xFFu, // H <- Q (Invalid). + 0x30u, // S <- H (type=11 opc=00). + 0xFFu, // S <- S (Invalid). + 0x10u, // S <- D (type=01 opc=00). + 0xFFu, // S <- Q (Invalid). + 0x31u, // D <- H (type=11 opc=01). + 0x01u, // D <- S (type=00 opc=01). + 0xFFu, // D <- D (Invalid). + 0xFFu, // D <- Q (Invalid). + 0xFFu, // Q <- H (Invalid). + 0xFFu, // Q <- S (Invalid). + 0xFFu, // Q <- D (Invalid). + 0xFFu // Q <- Q (Invalid). + }; + + uint32_t type_opc = table[(dst_sz << 2) | src_sz]; + opcode.reset(0b0001111000100010010000 << 10); + opcode.add_imm(type_opc >> 4, 22); + opcode.add_imm(type_opc & 15, 15); + goto EmitOp_Rd0_Rn5; + } + + break; + } + + case InstDB::kEncodingSimdFcvtLN: { + const InstDB::EncodingData::SimdFcvtLN& op_data = InstDB::EncodingData::simdFcvtLN[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Reg)) { + // Scalar form - only FCVTXN. + if (o0.as().is_vec32() && o1.as().is_vec64()) { + if (!op_data.has_scalar()) + goto InvalidInstruction; + + if (o0.as().has_element_type() || o1.as().has_element_type()) + goto InvalidInstruction; + + opcode.reset(op_data.scalar_op()); + opcode |= B(22); // sz bit must be 1, the only supported combination of FCVTXN. + goto EmitOp_Rd0_Rn5; + } + + opcode.reset(op_data.vector_op()); + + const Vec& rl = (inst_flags & InstDB::kInstFlagLong) ? o0.as() : o1.as(); + const Vec& rn = (inst_flags & InstDB::kInstFlagLong) ? o1.as() : o0.as(); + + uint32_t q = diff(rn.reg_type(), RegType::kVec64); + if (uint32_t(opcode.has_q()) != q) + goto InvalidInstruction; + + if (rl.is_vec_s4() && rn.element_type() == VecElementType::kH && !op_data.is_cvtxn()) { + goto EmitOp_Rd0_Rn5; + } + + if (rl.is_vec_d2() && rn.element_type() == VecElementType::kS) { + opcode |= B(22); + goto EmitOp_Rd0_Rn5; + } + } + + break; + } + + case InstDB::kEncodingSimdFcvtSV: { + const InstDB::EncodingData::SimdFcvtSV& op_data = InstDB::EncodingData::simdFcvtSV[encoding_index]; + + // So we can support both IntToFloat and FloatToInt conversions. + const Operand_& op_gp = op_data.is_float_to_int() ? o0 : o1; + const Operand_& op_vec = op_data.is_float_to_int() ? o1 : o0; + + if (isign4 == ENC_OPS2(Reg, Reg)) { + if (op_gp.as().is_gp() && op_vec.as().is_vec()) { + uint32_t x = op_gp.as().is_gp64(); + uint32_t type = diff(op_vec.as().reg_type(), RegType::kVec16); + + if (type > 2u) + goto InvalidInstruction; + + type = (type - 1u) & 0x3; + opcode.reset(op_data.general_op()); + opcode.add_imm(type, 22); + opcode.add_imm(x, 31); + goto EmitOp_Rd0_Rn5; + } + + if (o0.as().is_vec() && o1.as().is_vec()) { + if (!check_signature(o0, o1)) + goto InvalidInstruction; + + if (!pick_fp_opcode(o0.as(), op_data.scalar_int_op(), InstDB::kHF_B, op_data.vector_int_op(), InstDB::kHF_B, &opcode)) + goto InvalidInstruction; + + goto EmitOp_Rd0_Rn5; + } + } + + if (isign4 == ENC_OPS3(Reg, Reg, Imm) && op_data.is_fixed_point()) { + if (o2.as().value_as() >= 64) + goto InvalidInstruction; + + uint32_t scale = o2.as().value_as(); + if (scale == 0) + goto InvalidInstruction; + + if (op_gp.as().is_gp() && op_vec.as().is_vec()) { + uint32_t x = op_gp.as().is_gp64(); + uint32_t type = diff(op_vec.as().reg_type(), RegType::kVec16); + + uint32_t scale_limit = 32u << x; + if (scale > scale_limit) + goto InvalidInstruction; + + type = (type - 1u) & 0x3; + opcode.reset(op_data.general_op() ^ B(21)); + opcode.add_imm(type, 22); + opcode.add_imm(x, 31); + opcode.add_imm(64u - scale, 10); + goto EmitOp_Rd0_Rn5; + } + + if (o0.as().is_vec() && o1.as().is_vec()) { + if (!check_signature(o0, o1)) + goto InvalidInstruction; + + uint32_t sz; + if (!pick_fp_opcode(o0.as(), op_data.scalar_fp_op(), InstDB::kHF_0, op_data.vector_fp_op(), InstDB::kHF_0, &opcode, &sz)) + goto InvalidInstruction; + + uint32_t scale_limit = 16u << sz; + if (scale > scale_limit) + goto InvalidInstruction; + + uint32_t imm = axl::neg(scale) & axl::trailing_bits_run(sz + 4 + 1); + opcode.add_imm(imm, 16); + goto EmitOp_Rd0_Rn5; + } + } + + break; + } + + case InstDB::kEncodingSimdFmlal: { + const InstDB::EncodingData::SimdFmlal& op_data = InstDB::EncodingData::simdFmlal[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Reg)) { + uint32_t q = diff(o0.as().reg_type(), RegType::kVec64); + uint32_t q_is_optional = op_data.optional_q(); + + if (q_is_optional) { + // This instruction works with either 64-bit or 128-bit registers, + // encoded by Q bit. + if (q > 1) + goto InvalidInstruction; + } + else { + // This instruction requires 128-bit vector registers. + if (q != 1) + goto InvalidInstruction; + + // The instruction is ehtier B (bottom) or T (top), which is part of + // the opcode, which uses Q bit, so we have to clear it explicitly. + q = 0; + } + + if (uint32_t(o0.as().reg_type()) != uint32_t(o1.as().reg_type()) + q_is_optional || + uint32_t(o0.as().element_type()) != op_data.ta || + uint32_t(o1.as().element_type()) != op_data.tb) + goto InvalidInstruction; + + if (!o2.as().has_element_index()) { + if (!check_signature(o1, o2)) + goto InvalidInstruction; + + opcode.reset(op_data.vector_op()); + opcode.add_imm(q, 30); + goto EmitOp_Rd0_Rn5_Rm16; + } + else { + if (uint32_t(o2.as().element_type()) != op_data.tElement) + goto InvalidInstruction; + + if (o2.as().id() > 15) + goto InvalidPhysId; + + uint32_t element_index = o2.as().element_index(); + if (element_index > 7u) + goto InvalidElementIndex; + + opcode.reset(op_data.element_op()); + opcode.add_imm(q, 30); + opcode.add_imm(element_index & 3u, 20); + opcode.add_imm(element_index >> 2, 11); + goto EmitOp_Rd0_Rn5_Rm16; + } + } + + break; + } + + case InstDB::kEncodingSimdFmov: { + if (isign4 == ENC_OPS2(Reg, Reg)) { + // FMOV Gp <-> Vec opcode: + opcode.reset(0b00011110001001100000000000000000); + + if (o0.as().is_gp() && o1.as().is_vec()) { + // FMOV Wd, Hn (sf=0 type=11 rmode=00 op=110) + // FMOV Xd, Hn (sf=1 type=11 rmode=00 op=110) + // FMOV Wd, Sn (sf=0 type=00 rmode=00 op=110) + // FMOV Xd, Dn (sf=1 type=11 rmode=00 op=110) + // FMOV Xd, Vn.d[1] (sf=1 type=10 rmode=01 op=110) + uint32_t x = o0.as().is_gp64(); + uint32_t sz = diff(o1.as().reg_type(), RegType::kVec16); + + uint32_t type = (sz - 1) & 0x3u; + uint32_t r_mode_op = 0b00110; + + if (o1.as().has_element_index()) { + // Special case. + if (!x || !o1.as().is_vec_d2() || o1.as().element_index() != 1) + goto InvalidInstruction; + type = 0b10; + r_mode_op = 0b01110; + } + else { + // Must be scalar. + if (sz > 2) + goto InvalidInstruction; + + if (o1.as().has_element_type()) + goto InvalidInstruction; + + if (o1.as().is_vec32() && x) + goto InvalidInstruction; + + if (o1.as().is_vec64() && !x) + goto InvalidInstruction; + } + + opcode.add_imm(x, 31); + opcode.add_imm(type, 22); + opcode.add_imm(r_mode_op, 16); + goto EmitOp_Rd0_Rn5; + } + + if (o0.as().is_vec() && o1.as().is_gp()) { + // FMOV Hd, Wn (sf=0 type=11 rmode=00 op=111) + // FMOV Hd, Xn (sf=1 type=11 rmode=00 op=111) + // FMOV Sd, Wn (sf=0 type=00 rmode=00 op=111) + // FMOV Dd, Xn (sf=1 type=11 rmode=00 op=111) + // FMOV Vd.d[1], Xn (sf=1 type=10 rmode=01 op=111) + uint32_t x = o1.as().is_gp64(); + uint32_t sz = diff(o0.as().reg_type(), RegType::kVec16); + + uint32_t type = (sz - 1) & 0x3u; + uint32_t r_mode_op = 0b00111; + + if (o0.as().has_element_index()) { + // Special case. + if (!x || !o0.as().is_vec_d2() || o0.as().element_index() != 1) + goto InvalidInstruction; + type = 0b10; + r_mode_op = 0b01111; + } + else { + // Must be scalar. + if (sz > 2) + goto InvalidInstruction; + + if (o0.as().has_element_type()) + goto InvalidInstruction; + + if (o0.as().is_vec32() && x) + goto InvalidInstruction; + + if (o0.as().is_vec64() && !x) + goto InvalidInstruction; + } + + opcode.add_imm(x, 31); + opcode.add_imm(type, 22); + opcode.add_imm(r_mode_op, 16); + goto EmitOp_Rd0_Rn5; + } + + if (check_signature(o0, o1)) { + uint32_t sz = diff(o0.as().reg_type(), RegType::kVec16); + if (sz > 2) + goto InvalidInstruction; + + if (o0.as().has_element_type()) + goto InvalidInstruction; + + uint32_t type = (sz - 1) & 0x3; + opcode.reset(0b00011110001000000100000000000000); + opcode.add_imm(type, 22); + goto EmitOp_Rd0_Rn5; + } + } + + if (isign4 == ENC_OPS2(Reg, Imm)) { + if (o0.as().is_vec()) { + double fp_value; + if (o1.as().is_double()) + fp_value = o1.as().value_as(); + else if (o1.as().is_int32()) + fp_value = o1.as().value_as(); + else + goto InvalidImmediate; + + if (!Utils::is_fp64_imm8(fp_value)) + goto InvalidImmediate; + + uint32_t imm8 = Utils::encode_fp64_to_imm8(fp_value); + if (!o0.as().has_element_type()) { + // FMOV (scalar, immediate). + uint32_t sz = diff(o0.as().reg_type(), RegType::kVec16); + uint32_t type = (sz - 1u) & 0x3u; + + if (sz > 2) + goto InvalidInstruction; + + opcode.reset(0b00011110001000000001000000000000); + opcode.add_imm(type, 22); + opcode.add_imm(imm8, 13); + goto EmitOp_Rd0; + } + else { + uint32_t q = diff(o0.as().reg_type(), RegType::kVec64); + uint32_t sz = diff(o0.as().element_type(), VecElementType::kH); + + if (q > 1 || sz > 2) + goto InvalidInstruction; + + static const uint32_t sz_bits_table[3] = { B(11), 0, B(29) }; + opcode.reset(0b00001111000000001111010000000000); + opcode ^= sz_bits_table[sz]; + opcode.add_imm(q, 30); + opcode.add_imm(imm8 >> 5, 16); + opcode.add_imm(imm8 & 31, 5); + goto EmitOp_Rd0; + } + } + } + + break; + } + + case InstDB::kEncodingFSimdPair: { + const InstDB::EncodingData::FSimdPair& op_data = InstDB::EncodingData::fSimdPair[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Reg)) { + // This operation is only defined for: + // hD, vS.2h (16-bit) + // sD, vS.2s (32-bit) + // dD, vS.2d (64-bit) + uint32_t sz = diff(o0.as().reg_type(), RegType::kVec16); + if (sz > 2) + goto InvalidInstruction; + + static const uint32_t sz_signatures[3] = { + RegTraits::kSignature | (Vec::kSignatureElementH), + RegTraits::kSignature | (Vec::kSignatureElementS), + RegTraits::kSignature | (Vec::kSignatureElementD) + }; + + if (o1.signature() != sz_signatures[sz]) + goto InvalidInstruction; + + static const uint32_t sz_bits_table[] = { B(29), 0, B(22) }; + opcode.reset(op_data.scalar_op()); + opcode ^= sz_bits_table[sz]; + goto EmitOp_Rd0_Rn5; + } + + if (isign4 == ENC_OPS3(Reg, Reg, Reg)) { + if (!check_signature(o0, o1, o2)) + goto InvalidInstruction; + + uint32_t q = diff(o0.as().reg_type(), RegType::kVec64); + if (q > 1) + goto InvalidInstruction; + + uint32_t sz = diff(o0.as().element_type(), VecElementType::kH); + if (sz > 2) + goto InvalidInstruction; + + static const uint32_t sz_bits_table[3] = { B(22) | B(21) | B(15) | B(14), 0, B(22) }; + opcode.reset(op_data.vector_op()); + opcode ^= sz_bits_table[sz]; + opcode.add_imm(q, 30); + goto EmitOp_Rd0_Rn5_Rm16; + } + + break; + } + + // ------------------------------------------------------------------------ + // [ISimd - Instructions] + // ------------------------------------------------------------------------ + + case InstDB::kEncodingISimdSV: { + const InstDB::EncodingData::ISimdSV& op_data = InstDB::EncodingData::iSimdSV[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Reg)) { + // The first destination operand is scalar, which matches element-type of source vectors. + uint32_t L = (inst_flags & InstDB::kInstFlagLong) != 0; + if (diff(o0.as().reg_type(), RegType::kVec8) != diff(o1.as().element_type(), VecElementType::kB) + L) + goto InvalidInstruction; + + SizeOp size_op = element_type_to_size_op(op_data.vec_op_type, o1.as().reg_type(), o1.as().element_type()); + if (!size_op.is_valid()) + goto InvalidInstruction; + + opcode.reset(op_data.opcode()); + opcode.add_imm(size_op.q(), 30); + opcode.add_imm(size_op.size(), 22); + goto EmitOp_Rd0_Rn5; + } + + break; + } + + case InstDB::kEncodingISimdVV: { + const InstDB::EncodingData::ISimdVV& op_data = InstDB::EncodingData::iSimdVV[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Reg)) { + const Operand_& sop = significant_simd_op(o0, o1, inst_flags); + if (!match_signature(o0, o1, inst_flags)) + goto InvalidInstruction; + + SizeOp size_op = element_type_to_size_op(op_data.vec_op_type, sop.as().reg_type(), sop.as().element_type()); + if (!size_op.is_valid()) + goto InvalidInstruction; + + opcode.reset(op_data.opcode()); + opcode.add_imm(size_op.qs(), 30); + opcode.add_imm(size_op.scalar(), 28); + opcode.add_imm(size_op.size(), 22); + goto EmitOp_Rd0_Rn5; + } + + break; + } + + case InstDB::kEncodingISimdVVx: { + const InstDB::EncodingData::ISimdVVx& op_data = InstDB::EncodingData::iSimdVVx[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Reg)) { + if (o0.signature() != op_data.op0_signature || + o1.signature() != op_data.op1_signature) + goto InvalidInstruction; + + opcode.reset(op_data.opcode()); + goto EmitOp_Rd0_Rn5; + } + + break; + } + + case InstDB::kEncodingISimdVVV: { + const InstDB::EncodingData::ISimdVVV& op_data = InstDB::EncodingData::iSimdVVV[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Reg)) { + const Operand_& sop = significant_simd_op(o0, o1, inst_flags); + if (!match_signature(o0, o1, o2, inst_flags)) + goto InvalidInstruction; + + SizeOp size_op = element_type_to_size_op(op_data.vec_op_type, sop.as().reg_type(), sop.as().element_type()); + if (!size_op.is_valid()) + goto InvalidInstruction; + + opcode.reset(op_data.opcode()); + opcode.add_imm(size_op.qs(), 30); + opcode.add_imm(size_op.scalar(), 28); + opcode.add_imm(size_op.size(), 22); + goto EmitOp_Rd0_Rn5_Rm16; + } + + break; + } + + case InstDB::kEncodingISimdVVVx: { + const InstDB::EncodingData::ISimdVVVx& op_data = InstDB::EncodingData::iSimdVVVx[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Reg)) { + if (o0.signature() != op_data.op0_signature || + o1.signature() != op_data.op1_signature || + o2.signature() != op_data.op2_signature) + goto InvalidInstruction; + + opcode.reset(op_data.opcode()); + goto EmitOp_Rd0_Rn5_Rm16; + } + + break; + } + + case InstDB::kEncodingISimdWWV: { + // Special case for wide add/sub [s|b][add|sub][w]{2}. + const InstDB::EncodingData::ISimdWWV& op_data = InstDB::EncodingData::iSimdWWV[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Reg)) { + SizeOp size_op = element_type_to_size_op(op_data.vec_op_type, o2.as().reg_type(), o2.as().element_type()); + if (!size_op.is_valid()) + goto InvalidInstruction; + + if (!check_signature(o0, o1) || !o0.as().is_vec128() || uint32_t(o0.as().element_type()) != uint32_t(o2.as().element_type()) + 1u) + goto InvalidInstruction; + + opcode.reset(op_data.opcode()); + opcode.add_imm(size_op.qs(), 30); + opcode.add_imm(size_op.scalar(), 28); + opcode.add_imm(size_op.size(), 22); + goto EmitOp_Rd0_Rn5_Rm16; + } + + break; + } + + case InstDB::kEncodingISimdVVVe: { + const InstDB::EncodingData::ISimdVVVe& op_data = InstDB::EncodingData::iSimdVVVe[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Reg)) { + const Operand_& sop = significant_simd_op(o0, o1, inst_flags); + if (!match_signature(o0, o1, inst_flags)) + goto InvalidInstruction; + + if (!o2.as().has_element_index()) { + SizeOp size_op = element_type_to_size_op(op_data.regular_vec_type, sop.as().reg_type(), sop.as().element_type()); + if (!size_op.is_valid()) + goto InvalidInstruction; + + if (!check_signature(o1, o2)) + goto InvalidInstruction; + + opcode.reset(uint32_t(op_data.regular_op) << 10); + opcode.add_imm(size_op.qs(), 30); + opcode.add_imm(size_op.scalar(), 28); + opcode.add_imm(size_op.size(), 22); + goto EmitOp_Rd0_Rn5_Rm16; + } + else { + SizeOp size_op = element_type_to_size_op(op_data.element_vec_type, sop.as().reg_type(), sop.as().element_type()); + if (!size_op.is_valid()) + goto InvalidInstruction; + + uint32_t element_index = o2.as().element_index(); + LMHImm lmh; + + if (!encode_lmh(size_op.size(), element_index, Out(lmh))) + goto InvalidElementIndex; + + if (o2.as().id() > lmh.max_rm_id) + goto InvalidPhysId; + + opcode.reset(uint32_t(op_data.element_op) << 10); + opcode.add_imm(size_op.q(), 30); + opcode.add_imm(size_op.size(), 22); + opcode.add_imm(lmh.lm, 20); + opcode.add_imm(lmh.h, 11); + goto EmitOp_Rd0_Rn5_Rm16; + } + } + + break; + } + + case InstDB::kEncodingISimdVVVI: { + const InstDB::EncodingData::ISimdVVVI& op_data = InstDB::EncodingData::iSimdVVVI[encoding_index]; + + if (isign4 == ENC_OPS4(Reg, Reg, Reg, Imm)) { + const Operand_& sop = significant_simd_op(o0, o1, inst_flags); + if (!match_signature(o0, o1, o2, inst_flags)) + goto InvalidInstruction; + + SizeOp size_op = element_type_to_size_op(op_data.vec_op_type, sop.as().reg_type(), sop.as().element_type()); + if (!size_op.is_valid()) + goto InvalidInstruction; + + uint64_t imm_value = o3.as().value_as(); + uint32_t imm_size = op_data.imm_size; + + if (op_data.imm64_has_one_bit_less && !size_op.q()) + imm_size--; + + uint32_t imm_max = 1u << imm_size; + if (imm_value >= imm_max) + goto InvalidImmediate; + + opcode.reset(op_data.opcode()); + opcode.add_imm(size_op.qs(), 30); + opcode.add_imm(size_op.scalar(), 28); + opcode.add_imm(size_op.size(), 22); + opcode.add_imm(imm_value, op_data.imm_shift); + goto EmitOp_Rd0_Rn5_Rm16; + } + + break; + } + + case InstDB::kEncodingISimdVVVV: { + const InstDB::EncodingData::ISimdVVVV& op_data = InstDB::EncodingData::iSimdVVVV[encoding_index]; + + if (isign4 == ENC_OPS4(Reg, Reg, Reg, Reg)) { + const Operand_& sop = significant_simd_op(o0, o1, inst_flags); + if (!match_signature(o0, o1, o2, o3, inst_flags)) + goto InvalidInstruction; + + SizeOp size_op = element_type_to_size_op(op_data.vec_op_type, sop.as().reg_type(), sop.as().element_type()); + if (!size_op.is_valid()) + goto InvalidInstruction; + + opcode.reset(uint32_t(op_data.opcode) << 10); + opcode.add_imm(size_op.qs(), 30); + opcode.add_imm(size_op.scalar(), 28); + opcode.add_imm(size_op.size(), 22); + goto EmitOp_Rd0_Rn5_Rm16_Ra10; + } + + break; + } + + case InstDB::kEncodingISimdVVVVx: { + const InstDB::EncodingData::ISimdVVVVx& op_data = InstDB::EncodingData::iSimdVVVVx[encoding_index]; + + if (isign4 == ENC_OPS4(Reg, Reg, Reg, Reg)) { + if (o0.signature() != op_data.op0_signature || + o1.signature() != op_data.op1_signature || + o2.signature() != op_data.op2_signature || + o3.signature() != op_data.op3_signature) + goto InvalidInstruction; + + opcode.reset(uint32_t(op_data.opcode) << 10); + goto EmitOp_Rd0_Rn5_Rm16_Ra10; + } + + break; + } + + + case InstDB::kEncodingISimdPair: { + const InstDB::EncodingData::ISimdPair& op_data = InstDB::EncodingData::iSimdPair[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Reg) && op_data.opcode2) { + if (o0.as().is_vec_d1() && o1.as().is_vec_d2()) { + opcode.reset(uint32_t(op_data.opcode2) << 10); + opcode.add_imm(0x3, 22); // size. + goto EmitOp_Rd0_Rn5; + } + } + + if (isign4 == ENC_OPS3(Reg, Reg, Reg)) { + if (!match_signature(o0, o1, o2, inst_flags)) + goto InvalidInstruction; + + SizeOp size_op = element_type_to_size_op(op_data.op_type3, o0.as().reg_type(), o0.as().element_type()); + if (!size_op.is_valid()) + goto InvalidInstruction; + + opcode.reset(uint32_t(op_data.opcode3) << 10); + opcode.add_imm(size_op.qs(), 30); + opcode.add_imm(size_op.scalar(), 28); + opcode.add_imm(size_op.size(), 22); + goto EmitOp_Rd0_Rn5_Rm16; + } + + break; + } + + case InstDB::kEncodingSimdBicOrr: { + const InstDB::EncodingData::SimdBicOrr& op_data = InstDB::EncodingData::simdBicOrr[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Reg)) { + if (!match_signature(o0, o1, o2, inst_flags)) + goto InvalidInstruction; + + SizeOp size_op = element_type_to_size_op(InstDB::kVO_V_B, o0.as().reg_type(), o0.as().element_type()); + if (!size_op.is_valid()) + goto InvalidInstruction; + + opcode.reset(uint32_t(op_data.register_op) << 10); + opcode.add_imm(size_op.q(), 30); + goto EmitOp_Rd0_Rn5_Rm16; + } + + if (isign4 == ENC_OPS2(Reg, Imm) || isign4 == ENC_OPS3(Reg, Imm, Imm)) { + SizeOp size_op = element_type_to_size_op(InstDB::kVO_V_HS, o0.as().reg_type(), o0.as().element_type()); + if (!size_op.is_valid()) + goto InvalidInstruction; + + if (o1.as().value_as() > 0xFFFFFFFFu) + goto InvalidImmediate; + + uint32_t imm = o1.as().value_as(); + uint32_t shift = 0; + uint32_t max_shift = (8u << size_op.size()) - 8u; + + if (o2.is_imm()) { + if (o2.as().predicate() != uint32_t(ShiftOp::kLSL)) + goto InvalidImmediate; + + if (imm > 0xFFu || o2.as().value_as() > max_shift) + goto InvalidImmediate; + + shift = o2.as().value_as(); + if ((shift & 0x7u) != 0u) + goto InvalidImmediate; + } + else if (imm) { + shift = axl::ctz(imm) & ~0x7u; + imm >>= shift; + + if (imm > 0xFFu || shift > max_shift) + goto InvalidImmediate; + } + + uint32_t cmode = 0x1u | ((shift / 8u) << 1); + if (size_op.size() == 1) + cmode |= B(3); + + // The immediate value is split into ABC and DEFGH parts. + uint32_t abc = (imm >> 5) & 0x7u; + uint32_t defgh = imm & 0x1Fu; + + opcode.reset(uint32_t(op_data.immediate_op) << 10); + opcode.add_imm(size_op.q(), 30); + opcode.add_imm(abc, 16); + opcode.add_imm(cmode, 12); + opcode.add_imm(defgh, 5); + goto EmitOp_Rd0; + } + + break; + } + + case InstDB::kEncodingSimdCmp: { + const InstDB::EncodingData::SimdCmp& op_data = InstDB::EncodingData::simdCmp[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Reg) && op_data.register_op) { + if (!match_signature(o0, o1, o2, inst_flags)) + goto InvalidInstruction; + + SizeOp size_op = element_type_to_size_op(op_data.vec_op_type, o0.as().reg_type(), o0.as().element_type()); + if (!size_op.is_valid()) + goto InvalidInstruction; + + opcode.reset(uint32_t(op_data.register_op) << 10); + opcode.add_imm(size_op.qs(), 30); + opcode.add_imm(size_op.scalar(), 28); + opcode.add_imm(size_op.size(), 22); + goto EmitOp_Rd0_Rn5_Rm16; + } + + if (isign4 == ENC_OPS3(Reg, Reg, Imm) && op_data.zero_op) { + if (!match_signature(o0, o1, inst_flags)) + goto InvalidInstruction; + + if (o2.as().value() != 0) + goto InvalidImmediate; + + SizeOp size_op = element_type_to_size_op(op_data.vec_op_type, o0.as().reg_type(), o0.as().element_type()); + if (!size_op.is_valid()) + goto InvalidInstruction; + + opcode.reset(uint32_t(op_data.zero_op) << 10); + opcode.add_imm(size_op.qs(), 30); + opcode.add_imm(size_op.scalar(), 28); + opcode.add_imm(size_op.size(), 22); + goto EmitOp_Rd0_Rn5; + } + + break; + } + + case InstDB::kEncodingSimdDot: { + const InstDB::EncodingData::SimdDot& op_data = InstDB::EncodingData::simdDot[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Reg)) { + uint32_t q = diff(o0.as().reg_type(), RegType::kVec64); + uint32_t size = 2; + + if (q > 1u) + goto InvalidInstruction; + + if (!o2.as().has_element_index()) { + if (!op_data.vector_op) + goto InvalidInstruction; + + if (o0.as().reg_type() != o1.as().reg_type() || o1.as().reg_type() != o2.as().reg_type()) + goto InvalidInstruction; + + if (uint32_t(o0.as().element_type()) != op_data.ta || + uint32_t(o1.as().element_type()) != op_data.tb || + uint32_t(o2.as().element_type()) != op_data.tb) + goto InvalidInstruction; + + opcode.reset(uint32_t(op_data.vector_op) << 10); + opcode.add_imm(q, 30); + goto EmitOp_Rd0_Rn5_Rm16; + } + else { + if (!op_data.element_op) + goto InvalidInstruction; + + if (o0.as().reg_type() != o1.as().reg_type() || !o2.as().is_vec128()) + goto InvalidInstruction; + + if (uint32_t(o0.as().element_type()) != op_data.ta || + uint32_t(o1.as().element_type()) != op_data.tb || + uint32_t(o2.as().element_type()) != op_data.tElement) + goto InvalidInstruction; + + uint32_t element_index = o2.as().element_index(); + LMHImm lmh; + + if (!encode_lmh(size, element_index, Out(lmh))) + goto InvalidElementIndex; + + if (o2.as().id() > lmh.max_rm_id) + goto InvalidPhysId; + + opcode.reset(uint32_t(op_data.element_op) << 10); + opcode.add_imm(q, 30); + opcode.add_imm(lmh.lm, 20); + opcode.add_imm(lmh.h, 11); + goto EmitOp_Rd0_Rn5_Rm16; + } + } + + break; + } + + case InstDB::kEncodingSimdDup: SimdDup: { + if (isign4 == ENC_OPS2(Reg, Reg)) { + // Truth table of valid encodings of `Q:1|ElementType:3` + uint32_t kValidEncodings = B(uint32_t(VecElementType::kB) + 0) | + B(uint32_t(VecElementType::kH) + 0) | + B(uint32_t(VecElementType::kS) + 0) | + B(uint32_t(VecElementType::kB) + 8) | + B(uint32_t(VecElementType::kH) + 8) | + B(uint32_t(VecElementType::kS) + 8) | + B(uint32_t(VecElementType::kD) + 8) ; + + uint32_t q = diff(o0.as().reg_type(), RegType::kVec64); + + if (o1.as().is_gp()) { + // DUP - Vec (scalar|vector) <- GP register. + // + // NOTE: This is only scalar for `dup d, x` case, otherwise the value + // would be duplicated across all vector elements (1, 2, 4, 8, or 16). + uint32_t element_type = uint32_t(o0.as().element_type()); + if (q > 1 || !axl::bit_test(kValidEncodings, (q << 3) | element_type)) + goto InvalidInstruction; + + uint32_t lsb_index = element_type - 1u; + uint32_t imm5 = 1u << lsb_index; + + opcode.reset(0b0000111000000000000011 << 10); + opcode.add_imm(q, 30); + opcode.add_imm(imm5, 16); + goto EmitOp_Rd0_Rn5; + } + + if (!o1.as().is_vec() || !o1.as().has_element_index()) + goto InvalidInstruction; + + uint32_t dst_index = o1.as().element_index(); + if (!o0.as().has_element_type()) { + // DUP - Vec (scalar) <- Vec[N]. + uint32_t lsb_index = diff(o0.as().reg_type(), RegType::kVec8); + + if (lsb_index != diff(o1.as().element_type(), VecElementType::kB) || lsb_index > 3) + goto InvalidInstruction; + + uint32_t imm5 = ((dst_index << 1) | 1u) << lsb_index; + if (imm5 > 31) + goto InvalidElementIndex; + + opcode.reset(0b0101111000000000000001 << 10); + opcode.add_imm(imm5, 16); + goto EmitOp_Rd0_Rn5; + } + else { + // DUP - Vec (all) <- Vec[N]. + uint32_t element_type = uint32_t(o0.as().element_type()); + if (q > 1 || !axl::bit_test(kValidEncodings, (q << 3) | element_type)) + goto InvalidInstruction; + + uint32_t lsb_index = element_type - 1u; + uint32_t imm5 = ((dst_index << 1) | 1u) << lsb_index; + + if (imm5 > 31) + goto InvalidElementIndex; + + opcode.reset(0b0000111000000000000001 << 10); + opcode.add_imm(q, 30); + opcode.add_imm(imm5, 16); + goto EmitOp_Rd0_Rn5; + } + } + + break; + } + + case InstDB::kEncodingSimdIns: SimdIns: { + if (isign4 == ENC_OPS2(Reg, Reg) && o0.as().is_vec128()) { + if (!o0.as().has_element_index()) + goto InvalidInstruction; + + uint32_t element_type = uint32_t(o0.as().element_type()); + uint32_t dst_index = o0.as().element_index(); + uint32_t lsb_index = element_type - 1u; + + uint32_t imm5 = ((dst_index << 1) | 1u) << lsb_index; + if (imm5 > 31) + goto InvalidElementIndex; + + if (o1.as().is_gp()) { + // INS - Vec[N] <- GP register. + opcode.reset(0b0100111000000000000111 << 10); + opcode.add_imm(imm5, 16); + goto EmitOp_Rd0_Rn5; + } + else if (o1.as().is_vec128() && o1.as().has_element_index()) { + // INS - Vec[N] <- Vec[M]. + if (o0.as().element_type() != o1.as().element_type()) + goto InvalidInstruction; + + uint32_t src_index = o1.as().element_index(); + if (o0.as().reg_type() != o1.as().reg_type()) + goto InvalidInstruction; + + uint32_t imm4 = src_index << lsb_index; + if (imm4 > 15) + goto InvalidElementIndex; + + opcode.reset(0b0110111000000000000001 << 10); + opcode.add_imm(imm5, 16); + opcode.add_imm(imm4, 11); + goto EmitOp_Rd0_Rn5; + } + } + + break; + } + + case InstDB::kEncodingSimdMov: { + if (isign4 == ENC_OPS2(Reg, Reg)) { + if (o0.as().is_vec() && o1.as().is_vec()) { + // INS v.x[index], v.x[index]. + if (o0.as().has_element_index() && o1.as().has_element_index()) + goto SimdIns; + + // DUP {b|h|s|d}, v.{b|h|s|d}[index]. + if (o1.as().has_element_index()) + goto SimdDup; + + if (!check_signature(o0, o1)) + goto InvalidInstruction; + + // ORR Vd, Vn, Vm + uint32_t q = diff(o0.as().reg_type(), RegType::kVec64); + if (q > 1) + goto InvalidInstruction; + + opcode.reset(0b0000111010100000000111 << 10); + opcode.add_imm(q, 30); + opcode.add_reg(o1, 16); // Vn == Vm. + goto EmitOp_Rd0_Rn5; + } + + if (o0.as().is_vec() && o1.as().is_gp()) { + // INS v.x[index], Rn. + if (o0.as().has_element_index()) + goto SimdIns; + + goto InvalidInstruction; + } + + if (o0.as().is_gp() && o1.as().is_vec()) { + // UMOV Rd, V.{s|d}[index]. + encoding_index = 1; + goto SimdUmov; + } + } + + break; + } + + case InstDB::kEncodingSimdMoviMvni: { + const InstDB::EncodingData::SimdMoviMvni& op_data = InstDB::EncodingData::simdMoviMvni[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Imm) || isign4 == ENC_OPS3(Reg, Imm, Imm)) { + SizeOp size_op = element_type_to_size_op(InstDB::kVO_V_Any, o0.as().reg_type(), o0.as().element_type()); + if (!size_op.is_valid()) + goto InvalidInstruction; + + uint64_t imm64 = o1.as().value_as(); + uint32_t imm8 = 0; + uint32_t cmode = 0; + uint32_t inverted = op_data.inverted; + uint32_t op = 0; + uint32_t shift = 0; + uint32_t shift_op = uint32_t(ShiftOp::kLSL); + + if (size_op.size() == 3u) { + // The second immediate should not be present, however, we accept + // an immediate value of zero as some user code may still pass it. + if (o2.is_imm() && o0.as().value() != 0) + goto InvalidImmediate; + + if (Utils::is_byte_mask_imm(imm64)) { + imm8 = Utils::encode_imm64_byte_mask_to_imm8(imm64); + } + else { + // Change from D to S and from 64-bit imm to 32-bit imm if this + // is not a byte-mask pattern. + if ((imm64 >> 32) == (imm64 & 0xFFFFFFFFu)) { + imm64 &= 0xFFFFFFFFu; + size_op.decrement_size(); + } + else { + goto InvalidImmediate; + } + } + } + + if (size_op.size() < 3u) { + if (imm64 > 0xFFFFFFFFu) + goto InvalidImmediate; + imm8 = uint32_t(imm64); + + if (size_op.size() == 2) { + if ((imm8 >> 16) == (imm8 & 0xFFFFu)) { + imm8 >>= 16; + size_op.decrement_size(); + } + } + + if (size_op.size() == 1) { + if (imm8 > 0xFFFFu) + goto InvalidImmediate; + + if ((imm8 >> 8) == (imm8 & 0xFFu)) { + imm8 >>= 8; + size_op.decrement_size(); + } + } + + uint32_t max_shift = (8u << size_op.size()) - 8u; + if (o2.is_imm()) { + if (imm8 > 0xFFu || o2.as().value_as() > max_shift) + goto InvalidImmediate; + + shift = o2.as().value_as(); + shift_op = o2.as().predicate(); + } + else if (imm8) { + shift = axl::ctz(imm8) & ~0x7u; + imm8 >>= shift; + + if (imm8 > 0xFFu || shift > max_shift) + goto InvalidImmediate; + } + + if ((shift & 0x7u) != 0u) + goto InvalidImmediate; + } + + shift /= 8u; + + switch (size_op.size()) { + case 0: + if (shift_op != uint32_t(ShiftOp::kLSL)) + goto InvalidImmediate; + + if (inverted) { + imm8 = ~imm8 & 0xFFu; + } + + cmode = B(3) | B(2) | B(1); + break; + + case 1: + if (shift_op != uint32_t(ShiftOp::kLSL)) + goto InvalidImmediate; + + cmode = B(3) | (shift << 1); + op = inverted; + break; + + case 2: + if (shift_op == uint32_t(ShiftOp::kLSL)) { + cmode = shift << 1; + } + else if (shift_op == uint32_t(ShiftOp::kMSL)) { + if (shift == 0 || shift > 2) + goto InvalidImmediate; + cmode = B(3) | B(2) | (shift - 1u); + } + else { + goto InvalidImmediate; + } + + op = inverted; + break; + + case 3: + if (inverted) { + imm8 = ~imm8 & 0xFFu; + } + + op = 1; + cmode = B(3) | B(2) | B(1); + break; + } + + // The immediate value is split into ABC and DEFGH parts. + uint32_t abc = (imm8 >> 5) & 0x7u; + uint32_t defgh = imm8 & 0x1Fu; + + opcode.reset(uint32_t(op_data.opcode) << 10); + opcode.add_imm(size_op.q(), 30); + opcode.add_imm(op, 29); + opcode.add_imm(abc, 16); + opcode.add_imm(cmode, 12); + opcode.add_imm(defgh, 5); + goto EmitOp_Rd0; + } + + break; + } + + case InstDB::kEncodingSimdShift: { + const InstDB::EncodingData::SimdShift& op_data = InstDB::EncodingData::simdShift[encoding_index]; + + const Operand_& sop = significant_simd_op(o0, o1, inst_flags); + SizeOp size_op = element_type_to_size_op(op_data.vec_op_type, sop.as().reg_type(), sop.as().element_type()); + + if (!size_op.is_valid()) + goto InvalidInstruction; + + if (isign4 == ENC_OPS3(Reg, Reg, Imm) && op_data.immediate_op) { + if (!match_signature(o0, o1, inst_flags)) + goto InvalidInstruction; + + if (o2.as().value_as() > 63) + goto InvalidImmediate; + + uint32_t lsb_shift = size_op.size() + 3u; + uint32_t lsb_mask = (1u << lsb_shift) - 1u; + uint32_t imm = o2.as().value_as(); + + // Some instructions use IMM and some X - IMM, so negate if required. + if (op_data.inverted_imm) { + if (imm == 0 || imm > (1u << lsb_shift)) + goto InvalidImmediate; + imm = axl::neg(imm) & lsb_mask; + } + + if (imm > lsb_mask) + goto InvalidImmediate; + imm |= (1u << lsb_shift); + + opcode.reset(uint32_t(op_data.immediate_op) << 10); + opcode.add_imm(size_op.qs(), 30); + opcode.add_imm(size_op.scalar(), 28); + opcode.add_imm(imm, 16); + goto EmitOp_Rd0_Rn5; + } + + if (isign4 == ENC_OPS3(Reg, Reg, Reg) && op_data.register_op) { + if (!match_signature(o0, o1, o2, inst_flags)) + goto InvalidInstruction; + + opcode.reset(uint32_t(op_data.register_op) << 10); + opcode.add_imm(size_op.qs(), 30); + opcode.add_imm(size_op.scalar(), 28); + opcode.add_imm(size_op.size(), 22); + goto EmitOp_Rd0_Rn5_Rm16; + } + + break; + } + + case InstDB::kEncodingSimdShiftES: { + const InstDB::EncodingData::SimdShiftES& op_data = InstDB::EncodingData::simdShiftES[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Imm)) { + SizeOp size_op = element_type_to_size_op(op_data.vec_op_type, o1.as().reg_type(), o1.as().element_type()); + if (!size_op.is_valid()) + goto InvalidInstruction; + + if (!match_signature(o0, o1, inst_flags)) + goto InvalidInstruction; + + // The immediate value must match the element size. + uint64_t shift = o2.as().value_as(); + uint32_t shift_op = o2.as().predicate(); + + if (shift != (8u << size_op.size()) || shift_op != uint32_t(ShiftOp::kLSL)) + goto InvalidImmediate; + + opcode.reset(uint32_t(op_data.opcode) << 10); + opcode.add_imm(size_op.q(), 30); + opcode.add_imm(size_op.size(), 22); + goto EmitOp_Rd0_Rn5; + } + + break; + } + + case InstDB::kEncodingSimdSm3tt: { + const InstDB::EncodingData::SimdSm3tt& op_data = InstDB::EncodingData::simdSm3tt[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Reg)) { + if (o0.as().is_vec_s4() && o1.as().is_vec_s4() && o2.as().is_vec_s4() && o2.as().has_element_index()) { + uint32_t imm2 = o2.as().element_index(); + if (imm2 > 3) + goto InvalidElementIndex; + + opcode.reset(uint32_t(op_data.opcode) << 10); + opcode.add_imm(imm2, 12); + goto EmitOp_Rd0_Rn5_Rm16; + } + } + + break; + } + + + case InstDB::kEncodingSimdSmovUmov: SimdUmov: { + const InstDB::EncodingData::SimdSmovUmov& op_data = InstDB::EncodingData::simdSmovUmov[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Reg) && o0.as().is_gp() && o1.as().is_vec()) { + SizeOp size_op = element_type_to_size_op(op_data.vec_op_type, o1.as().reg_type(), o1.as().element_type()); + if (!size_op.is_valid()) + goto InvalidInstruction; + + if (!o1.as().has_element_index()) + goto InvalidInstruction; + + uint32_t x = o0.as().is_gp64(); + uint32_t gp_must_be_x = uint32_t(size_op.size() >= 3u - op_data.is_signed); + + if (op_data.is_signed) { + if (gp_must_be_x && !x) + goto InvalidInstruction; + } + else { + if (x != gp_must_be_x) + goto InvalidInstruction; + } + + uint32_t element_index = o1.as().element_index(); + uint32_t max_element_index = 15u >> size_op.size(); + + if (element_index > max_element_index) + goto InvalidElementIndex; + + uint32_t imm5 = (1u | (element_index << 1)) << size_op.size(); + + opcode.reset(uint32_t(op_data.opcode) << 10); + opcode.add_imm(x, 30); + opcode.add_imm(imm5, 16); + goto EmitOp_Rd0_Rn5; + } + + break; + } + + case InstDB::kEncodingSimdSxtlUxtl: { + const InstDB::EncodingData::SimdSxtlUxtl& op_data = InstDB::EncodingData::simdSxtlUxtl[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Reg)) { + SizeOp size_op = element_type_to_size_op(op_data.vec_op_type, o1.as().reg_type(), o1.as().element_type()); + if (!size_op.is_valid()) + goto InvalidInstruction; + + if (!match_signature(o0, o1, inst_flags)) + goto InvalidInstruction; + + opcode.reset(uint32_t(op_data.opcode) << 10); + opcode.add_imm(size_op.q(), 30); + opcode.add_imm(1u, size_op.size() + 19); + goto EmitOp_Rd0_Rn5; + } + + break; + } + + case InstDB::kEncodingSimdTblTbx: { + const InstDB::EncodingData::SimdTblTbx& op_data = InstDB::EncodingData::simdTblTbx[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Reg) || isign4 == ENC_OPS4(Reg, Reg, Reg, Reg)) { + // TBL/TBX ., { .16B }, . + // TBL/TBX ., { .16B, .16B }, . + // TBL/TBX ., { .16B, .16B, .16B }, . + // TBL/TBX ., { .16B, .16B, .16B, .16B }, . + opcode.reset(uint32_t(op_data.opcode) << 10); + + const Operand_& o4 = op_ext[EmitterUtils::kOp4]; + const Operand_& o5 = op_ext[EmitterUtils::kOp5]; + + uint32_t q = diff(o0.as().reg_type(), RegType::kVec64); + if (q > 1 || o0.as().has_element_index()) + goto InvalidInstruction; + + if (!o1.as().is_vec_b16() || o1.as().has_element_index()) + goto InvalidInstruction; + + uint32_t len = uint32_t(!o3.is_none()) + uint32_t(!o4.is_none()) + uint32_t(!o5.is_none()); + opcode.add_imm(q, 30); + opcode.add_imm(len, 13); + + switch (len) { + case 0: + if (!check_signature(o0, o2)) + goto InvalidInstruction; + + if (o2.id() > 31) + goto InvalidPhysId; + + opcode.add_reg(o2, 16); + goto EmitOp_Rd0_Rn5; + + case 1: + if (!check_signature(o0, o3)) + goto InvalidInstruction; + + if (o3.id() > 31) + goto InvalidPhysId; + + opcode.add_reg(o3, 16); + goto EmitOp_Rd0_Rn5; + + case 2: + if (!check_signature(o0, o4)) + goto InvalidInstruction; + + if (o4.id() > 31) + goto InvalidPhysId; + + opcode.add_reg(o4, 16); + goto EmitOp_Rd0_Rn5; + + case 3: + if (!check_signature(o0, o5)) + goto InvalidInstruction; + + if (o5.id() > 31) + goto InvalidPhysId; + + opcode.add_reg(o5, 16); + goto EmitOp_Rd0_Rn5; + + default: + // Should never happen. + goto InvalidInstruction; + } + } + + break; + } + + // ------------------------------------------------------------------------ + // [Simd - Load / Store] + // ------------------------------------------------------------------------ + + case InstDB::kEncodingSimdLdSt: { + const InstDB::EncodingData::SimdLdSt& op_data = InstDB::EncodingData::simdLdSt[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Mem)) { + const Mem& m = o1.as(); + rm_rel = &m; + + // Width | SZ | XY | XSZ + // -------+----------+-----------+----- + // 8-bit | size==00 | opc == 01 | 000 + // 16-bit | size==01 | opc == 01 | 001 + // 32-bit | size==10 | opc == 01 | 010 + // 64-bit | size==11 | opc == 01 | 011 + // 128-bit| size==00 | opc == 11 | 100 + uint32_t xsz = diff(o0.as().reg_type(), RegType::kVec8); + if (xsz > 4u || o0.as().has_element_index()) + goto InvalidRegType; + + if (!check_vec_id(o0)) + goto InvalidPhysId; + + if (!check_mem_base_index_rel(m)) + goto InvalidAddress; + + int64_t offset = m.offset(); + if (m.has_base_reg()) { + // [Base {Offset | Index}] + if (m.has_index()) { + uint32_t opt = shift_op_to_ld_st_opt_map[size_t(m.shift_op())]; + if (opt == 0xFFu) + goto InvalidAddress; + + uint32_t shift = m.shift(); + uint32_t s = (shift != 0); + + if (s && shift != xsz) + goto InvalidAddressScale; + + opcode.reset(uint32_t(op_data.register_op) << 21); + opcode.add_imm(xsz & 3u, 30); + opcode.add_imm(xsz >> 2, 23); + opcode.add_imm(opt, 13); + opcode.add_imm(s, 12); + opcode |= B(11); + opcode.add_reg(o0, 0); + goto EmitOp_MemBaseIndex_Rn5_Rm16; + } + + // Makes it easier to work with the offset especially on 32-bit arch. + if (!axl::is_int_n<32>(offset)) + goto InvalidDisplacement; + int32_t offset32 = int32_t(offset); + + if (m.is_pre_or_post()) { + if (!axl::is_int_n<9>(offset32)) + goto InvalidDisplacement; + + opcode.reset(uint32_t(op_data.pre_post_op) << 21); + opcode.add_imm(xsz & 3u, 30); + opcode.add_imm(xsz >> 2, 23); + opcode.add_imm(offset32 & 0x1FF, 12); + opcode.add_imm(m.is_pre_index(), 11); + opcode |= B(10); + opcode.add_reg(o0, 0); + goto EmitOp_MemBase_Rn5; + } + else { + uint32_t imm12 = uint32_t(offset32) >> xsz; + + // If this instruction is not encodable with scaled unsigned offset, try unscaled signed offset. + if (!axl::is_uint_n<12>(imm12) || (imm12 << xsz) != uint32_t(offset32)) { + inst_id = op_data.u_alt_inst_id; + inst_info = &InstDB::_inst_info_table[inst_id]; + encoding_index = inst_info->_encoding_data_index; + goto Case_SimdLdurStur; + } + + opcode.reset(uint32_t(op_data.u_offset_op) << 22); + opcode.add_imm(xsz & 3u, 30); + opcode.add_imm(xsz >> 2, 23); + opcode.add_imm(imm12, 10); + opcode.add_reg(o0, 0); + goto EmitOp_MemBase_Rn5; + } + } + else { + if (!op_data.literal_op) + goto InvalidAddress; + + if (xsz < 2u) + goto InvalidRegType; + + uint32_t opc = xsz - 2u; + opcode.reset(uint32_t(op_data.literal_op) << 24); + opcode.add_imm(opc, 30); + opcode.add_reg(o0, 0); + offset_format.reset_to_imm_value(OffsetType::kSignedOffset, 4, 5, 19, 2); + goto EmitOp_Rel; + } + } + + break; + } + + case InstDB::kEncodingSimdLdpStp: { + const InstDB::EncodingData::SimdLdpStp& op_data = InstDB::EncodingData::simdLdpStp[encoding_index]; + + if (isign4 == ENC_OPS3(Reg, Reg, Mem)) { + const Mem& m = o2.as(); + rm_rel = &m; + + uint32_t opc = diff(o0.as().reg_type(), RegType::kVec32); + if (opc > 2u || o0.as().has_element_type_or_index()) + goto InvalidInstruction; + + if (!check_signature(o0, o1)) + goto InvalidInstruction; + + if (!check_vec_id(o0, o1)) + goto InvalidPhysId; + + if (m.base_type() != RegType::kGp64 || m.has_index()) + goto InvalidAddress; + + if (m.is_offset_64bit()) + goto InvalidDisplacement; + + uint32_t offset_shift = 2u + opc; + int32_t offset32 = m.offset_lo32() >> offset_shift; + + // Make sure we didn't lose bits by applying the mandatory offset shift. + if (axl::shl(offset32, offset_shift) != m.offset_lo32()) + goto InvalidDisplacement; + + // Offset is encoded as a 7-bit immediate. + if (!axl::is_int_n<7>(offset32)) + goto InvalidDisplacement; + + if (m.is_pre_or_post() && offset32 != 0) { + if (!op_data.pre_post_op) + goto InvalidAddress; + + opcode.reset(uint32_t(op_data.pre_post_op) << 22); + opcode.add_imm(m.is_pre_index(), 24); + } + else { + opcode.reset(uint32_t(op_data.offset_op) << 22); + } + + opcode.add_imm(opc, 30); + opcode.add_imm(offset32 & 0x7F, 15); + opcode.add_reg(o1, 10); + opcode.add_reg(o0, 0); + goto EmitOp_MemBase_Rn5; + } + + break; + } + + case InstDB::kEncodingSimdLdurStur: { +Case_SimdLdurStur: + const InstDB::EncodingData::SimdLdurStur& op_data = InstDB::EncodingData::simdLdurStur[encoding_index]; + + if (isign4 == ENC_OPS2(Reg, Mem)) { + const Mem& m = o1.as(); + rm_rel = &m; + + uint32_t sz = diff(o0.as().reg_type(), RegType::kVec8); + if (sz > 4 || o0.as().has_element_type_or_index()) + goto InvalidInstruction; + + if (!check_vec_id(o0)) + goto InvalidPhysId; + + if (!check_mem_base_index_rel(m)) + goto InvalidAddress; + + if (m.has_base_reg() && !m.has_index() && !m.is_pre_or_post()) { + if (m.is_offset_64bit()) + goto InvalidDisplacement; + + int32_t offset32 = m.offset_lo32(); + if (!axl::is_int_n<9>(offset32)) + goto InvalidDisplacement; + + opcode.reset(uint32_t(op_data.opcode) << 10); + opcode.add_imm(sz & 3u, 30); + opcode.add_imm(sz >> 2, 23); + opcode.add_imm(offset32 & 0x1FF, 12); + opcode.add_reg(o0, 0); + goto EmitOp_MemBase_Rn5; + } + + goto InvalidAddress; + } + + break; + } + + case InstDB::kEncodingSimdLdNStN: { + const InstDB::EncodingData::SimdLdNStN& op_data = InstDB::EncodingData::simdLdNStN[encoding_index]; + const Operand_& o4 = op_ext[EmitterUtils::kOp4]; + + uint32_t n = 1; + + if (isign4 == ENC_OPS2(Reg, Mem)) { + if (op_data.n != 1) + goto InvalidInstruction; + + rm_rel = &o1; + } + else if (isign4 == ENC_OPS3(Reg, Reg, Mem)) { + if (op_data.n != 1 && op_data.n != 2) + goto InvalidInstruction; + + if (!check_signature(o0, o1) || !check_consecutive(o0, o1)) + goto InvalidInstruction; + + n = 2; + rm_rel = &o2; + } + else if (isign4 == ENC_OPS4(Reg, Reg, Reg, Mem) && o4.is_none()) { + if (op_data.n != 1 && op_data.n != 3) + goto InvalidInstruction; + + if (!check_signature(o0, o1, o2) || !check_consecutive(o0, o1, o2)) + goto InvalidInstruction; + + n = 3; + rm_rel = &o3; + } + else if (isign4 == ENC_OPS4(Reg, Reg, Reg, Reg) && o4.is_mem()) { + if (op_data.n != 1 && op_data.n != 4) + goto InvalidInstruction; + + if (!check_signature(o0, o1, o2, o3) || !check_consecutive(o0, o1, o2, o3)) + goto InvalidInstruction; + + n = 4; + rm_rel = &o4; + } + else { + goto InvalidInstruction; + } + + // We will use `v` and `m` from now as those are relevant for encoding. + const Vec& v = o0.as(); + const Mem& m = rm_rel->as(); + + uint32_t q = 0; + uint32_t rm = 0; + uint32_t rn = m.base_id(); + uint32_t sz = diff(v.element_type(), VecElementType::kB); + uint32_t opc_s_size = sz; + uint32_t offset_possibility = 0; + + if (sz > 3) + goto InvalidInstruction; + + if (m.base_type() != RegType::kGp64) + goto InvalidAddress; + + // Rn cannot be ZR, but can be SP. + if (rn > 30 && rn != Gp::kIdSp) + goto InvalidAddress; + + rn &= 31; + + if (op_data.replicate) { + if (n != op_data.n) + goto InvalidInstruction; + + // Replicates to the whole register, element index cannot be used. + if (v.has_element_index()) + goto InvalidInstruction; + + q = diff(v.reg_type(), RegType::kVec64); + if (q > 1) + goto InvalidInstruction; + + opcode.reset(uint32_t(op_data.single_op) << 10); + offset_possibility = (1u << sz) * n; + } + else if (v.has_element_index()) { + if (n != op_data.n) + goto InvalidInstruction; + + // LDx/STx (single structure). + static const uint8_t opc_s_size_by_sz_table[] = { 0x0u << 3, 0x2u << 3, 0x4u << 3, (0x4u << 3) | 1u }; + + opcode.reset(uint32_t(op_data.single_op) << 10); + opc_s_size = opc_s_size_by_sz_table[sz]; + offset_possibility = (1u << sz) * op_data.n; + + uint32_t element_index = v.element_index(); + uint32_t max_element_index = 15 >> sz; + + if (element_index > max_element_index) + goto InvalidElementIndex; + + element_index <<= sz; + q = element_index >> 3; + opc_s_size |= element_index & 0x7u; + } + else { + // LDx/STx (multiple structures). + static const uint8_t opc_s_size_by_n_table[] = { 0u, 0x7u << 2, 0xAu << 2, 0x6u << 2, 0x2u << 2 }; + + q = diff(v.reg_type(), RegType::kVec64); + if (q > 1) + goto InvalidInstruction; + + if (op_data.n == 1) + opc_s_size |= opc_s_size_by_n_table[n]; + + opcode.reset(uint32_t(op_data.multiple_op) << 10); + offset_possibility = (8u << q) * n; + } + + if (m.has_index()) { + if (m.has_offset() || !m.is_post_index()) + goto InvalidAddress; + + rm = m.index_id(); + if (rm > 30) + goto InvalidAddress; + + // Bit 23 - PostIndex. + opcode |= B(23); + } + else { + if (m.has_offset()) { + if (m.offset() != int32_t(offset_possibility) || !m.is_post_index()) + goto InvalidAddress; + rm = 31; + + // Bit 23 - PostIndex. + opcode |= B(23); + } + } + + opcode.add_imm(q, 30); + opcode.add_imm(rm, 16); + opcode.add_imm(opc_s_size, 10); + opcode.add_imm(rn, 5); + goto EmitOp_Rd0; + } + + default: + break; + } + + goto InvalidInstruction; + + // -------------------------------------------------------------------------- + // [EmitGp - Single] + // -------------------------------------------------------------------------- + +EmitOp_Rd0: + if (!check_valid_regs(o0)) + goto InvalidPhysId; + + opcode.add_reg(o0, 0); + goto EmitOp; + +EmitOp_Rn5: + if (!check_valid_regs(o0)) + goto InvalidPhysId; + + opcode.add_reg(o0, 5); + goto EmitOp; + +EmitOp_Rn5_Rm16: + if (!check_valid_regs(o0, o1)) + goto InvalidPhysId; + + opcode.add_reg(o0, 5); + opcode.add_reg(o1, 16); + goto EmitOp; + +EmitOp_Rd0_Rn5: + if (!check_valid_regs(o0, o1)) + goto InvalidPhysId; + + opcode.add_reg(o0, 0); + opcode.add_reg(o1, 5); + goto EmitOp; + +EmitOp_Rd0_Rn5_Rm16_Ra10: + if (!check_valid_regs(o0, o1, o2, o3)) + goto InvalidPhysId; + + opcode.add_reg(o0, 0); + opcode.add_reg(o1, 5); + opcode.add_reg(o2, 16); + opcode.add_reg(o3, 10); + goto EmitOp; + +EmitOp_Rd0_Rn5_Rm16: + if (!check_valid_regs(o0, o1, o3)) + goto InvalidPhysId; + + opcode.add_reg(o0, 0); + opcode.add_reg(o1, 5); + opcode.add_reg(o2, 16); + goto EmitOp; + + // -------------------------------------------------------------------------- + // [EmitGp - Multiple] + // -------------------------------------------------------------------------- + +EmitOp_Multiple: + { + ASMJIT_ASSERT(multiple_op_count > 0); + err = writer.ensure_space(this, multiple_op_count * 4u); + if (ASMJIT_UNLIKELY(err != Error::kOk)) { + goto Failed; + } + + for (uint32_t i = 0; i < multiple_op_count; i++) { + writer.emit32u_le(multiple_op_data[i]); + } + + goto EmitDone; + } + + // -------------------------------------------------------------------------- + // [EmitGp - Memory] + // -------------------------------------------------------------------------- + +EmitOp_MemBase_Rn5: + if (!check_mem_base(rm_rel->as())) { + goto InvalidAddress; + } + + opcode.add_reg(rm_rel->as().base_id(), 5); + goto EmitOp; + +EmitOp_MemBaseNoImm_Rn5: + if (!check_mem_base(rm_rel->as()) || rm_rel->as().has_index()) { + goto InvalidAddress; + } + + if (rm_rel->as().has_offset()) { + goto InvalidDisplacement; + } + + opcode.add_reg(rm_rel->as().base_id(), 5); + goto EmitOp; + +EmitOp_MemBaseIndex_Rn5_Rm16: + if (!rm_rel->as().has_base_reg()) { + goto InvalidAddress; + } + + if (rm_rel->as().index_id() > 30 && rm_rel->as().index_id() != Gp::kIdZr) { + goto InvalidPhysId; + } + + opcode.add_reg(rm_rel->as().index_id(), 16); + opcode.add_reg(rm_rel->as().base_id(), 5); + goto EmitOp; + + // -------------------------------------------------------------------------- + // [EmitOp - PC Relative] + // -------------------------------------------------------------------------- + +EmitOp_Rel: + { + if (rm_rel->is_label() || rm_rel->is_mem()) { + uint32_t label_id; + int64_t label_offset = 0; + + if (rm_rel->is_label()) { + label_id = rm_rel->as