diff --git a/.github/workflows/cmake-multi-platform.yml b/.github/workflows/cmake-multi-platform.yml index 705779c..6885861 100644 --- a/.github/workflows/cmake-multi-platform.yml +++ b/.github/workflows/cmake-multi-platform.yml @@ -1,10 +1,6 @@ name: Cross-Platform Build on: - pull_request: - branches: [ "main" ] - push: - branches: [ "main" ] workflow_dispatch: permissions: diff --git a/.github/workflows/spatial9-core.yml b/.github/workflows/spatial9-core.yml new file mode 100644 index 0000000..57d1802 --- /dev/null +++ b/.github/workflows/spatial9-core.yml @@ -0,0 +1,21 @@ +name: Spatial9 Core and Bridge Tests +on: [push, pull_request, workflow_dispatch] +permissions: + contents: read +jobs: + core: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install ./bridge + - run: cmake -S tests/spatial9 -B build-core -DCMAKE_BUILD_TYPE=Release + - run: cmake --build build-core + - run: ctest --test-dir build-core --output-on-failure + - run: python -m unittest discover -s bridge/tests -v + - run: python bridge/tests/mcp_smoke.py + - run: python bridge/tests/http_smoke.py diff --git a/.github/workflows/spatial9-native.yml b/.github/workflows/spatial9-native.yml new file mode 100644 index 0000000..c94ef7e --- /dev/null +++ b/.github/workflows/spatial9-native.yml @@ -0,0 +1,388 @@ +name: Spatial9 Native Builds + +on: + pull_request: + branches: [ "main" ] + push: + branches: [ "main" ] + workflow_dispatch: + +permissions: + contents: read + +env: + BUILD_TYPE: Release + TEST_TYPE: INTERNAL_TEST + CLEAN_BUILD: 'true' + # macOS compatibility patch is applied by the pinned bootstrap script. + ENABLE_MACOS_COMPILE: 'true' + # Enable vcpkg binary caching for GitHub Actions + VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" + +jobs: + build: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + os: [ macos-15, windows-2022 ] + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + lfs: false + persist-credentials: false + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Bootstrap pinned dependencies and compatibility patch + run: python scripts/spatial9/bootstrap.py + + - name: Debug LFS (Windows) + if: runner.os == 'Windows' + shell: powershell + run: | + ls third_party/obr/lib/Windows/Release/obr.lib + Get-Item third_party/obr/lib/Windows/Release/obr.lib | Select-Object Length + + + # --- Caching Steps (Common) --- + - name: Cache Protobuf Build + if: (runner.os != 'macOS' || env.ENABLE_MACOS_COMPILE == 'true') && env.CLEAN_BUILD != 'true' + id: protobuf-cache + uses: actions/cache@v4 + with: + path: build/_deps/protobuf-build + key: ${{ runner.os }}-protobuf-build-${{ hashFiles('third_party/protobuf/version.json') }} + + - name: Check Protobuf Cache + if: (runner.os != 'macOS' || env.ENABLE_MACOS_COMPILE == 'true') && env.CLEAN_BUILD != 'true' + shell: bash + env: + CACHE_HIT: ${{ steps.protobuf-cache.outputs.cache-hit }} + run: | + echo "Protobuf cache hit: $CACHE_HIT" + if [ "$CACHE_HIT" != 'true' ]; then + echo "Protobuf cache missed or expired, setting CLEAN_BUILD to true." + echo "CLEAN_BUILD=true" >> $GITHUB_ENV + fi + + - name: Cache JUCE Build + if: (runner.os != 'macOS' || env.ENABLE_MACOS_COMPILE == 'true') && env.CLEAN_BUILD != 'true' + id: juce-cache + uses: actions/cache@v4 + with: + path: build/third_party/JUCE + key: ${{ runner.os }}-juce-build-${{ hashFiles('third_party/JUCE/CMakeLists.txt') }} + + - name: Check JUCE Cache + if: (runner.os != 'macOS' || env.ENABLE_MACOS_COMPILE == 'true') && env.CLEAN_BUILD != 'true' + shell: bash + env: + CACHE_HIT: ${{ steps.juce-cache.outputs.cache-hit }} + run: | + echo "JUCE cache hit: $CACHE_HIT" + if [ "$CACHE_HIT" != 'true' ]; then + echo "JUCE cache missed or expired, setting CLEAN_BUILD to true." + echo "CLEAN_BUILD=true" >> $GITHUB_ENV + fi + + - name: Cache IAMF Build + if: (runner.os != 'macOS' || env.ENABLE_MACOS_COMPILE == 'true') && env.CLEAN_BUILD != 'true' + id: iamf-cache + uses: actions/cache@v4 + with: + path: build/_deps/libiamf-build + key: ${{ runner.os }}-iamf-build-${{ hashFiles('third_party/libiamf/CMakeLists.txt') }} + + - name: Check IAMF Cache + if: (runner.os != 'macOS' || env.ENABLE_MACOS_COMPILE == 'true') && env.CLEAN_BUILD != 'true' + shell: bash + env: + CACHE_HIT: ${{ steps.iamf-cache.outputs.cache-hit }} + run: | + echo "IAMF cache hit: $CACHE_HIT" + if [ "$CACHE_HIT" != 'true' ]; then + echo "IAMF cache missed or expired, setting CLEAN_BUILD to true." + echo "CLEAN_BUILD=true" >> $GITHUB_ENV + fi + + - name: Cache Libear Build + if: (runner.os != 'macOS' || env.ENABLE_MACOS_COMPILE == 'true') && env.CLEAN_BUILD != 'true' + id: libear-cache + uses: actions/cache@v4 + with: + path: build/third_party/libear + key: ${{ runner.os }}-libear-build-${{ hashFiles('third_party/libear/CMakeLists.txt') }} + + - name: Check Libear Cache + if: (runner.os != 'macOS' || env.ENABLE_MACOS_COMPILE == 'true') && env.CLEAN_BUILD != 'true' + shell: bash + env: + CACHE_HIT: ${{ steps.libear-cache.outputs.cache-hit }} + run: | + echo "Libear cache hit: $CACHE_HIT" + if [ "$CACHE_HIT" != 'true' ]; then + echo "Libear cache missed or expired, setting CLEAN_BUILD to true." + echo "CLEAN_BUILD=true" >> $GITHUB_ENV + fi + + - name: Cache Main Build + if: (runner.os != 'macOS' || env.ENABLE_MACOS_COMPILE == 'true') && env.CLEAN_BUILD != 'true' + id: main-build-cache + uses: actions/cache@v4 + with: + path: build + key: ${{ runner.os }}-main-build-${{ hashFiles('CMakeLists.txt', 'cmake/**', 'components/**/*.cpp', 'components/**/*.h') }} + + - name: Check Main Build Cache + if: (runner.os != 'macOS' || env.ENABLE_MACOS_COMPILE == 'true') && env.CLEAN_BUILD != 'true' + shell: bash + env: + CACHE_HIT: ${{ steps.main-build-cache.outputs.cache-hit }} + run: | + echo "Main build cache hit: $CACHE_HIT" + if [ "$CACHE_HIT" != 'true' ]; then + echo "Main build cache missed or expired, setting CLEAN_BUILD to true." + echo "CLEAN_BUILD=true" >> $GITHUB_ENV + fi + + - name: Check Clean Build + if: (runner.os != 'macOS' || env.ENABLE_MACOS_COMPILE == 'true') && env.CLEAN_BUILD == 'true' + shell: bash + run: | + echo "CLEAN_BUILD is true, removing entire build directory." + if [ -d "build" ]; then rm -rf build; fi + + # --- FFmpeg (macOS) --- + - name: Install FFmpeg (macOS) + if: env.ENABLE_MACOS_COMPILE == 'true' && runner.os == 'macOS' + run: | + echo "Installing FFmpeg via Homebrew..." + brew update + brew install ffmpeg + + echo "Verifying installation:" + ffmpeg -version + ffprobe -version + + echo "FFmpeg location:" + which ffmpeg + which ffprobe + + # --- FFmpeg (Windows) --- + - name: Install FFmpeg (Windows) + if: runner.os == 'Windows' + shell: powershell + run: | + Write-Host "Installing FFmpeg via Chocolatey..." + choco install ffmpeg -y + + Write-Host "Verifying installation:" + ffmpeg -version + ffprobe -version + + Write-Host "FFmpeg location:" + where.exe ffmpeg + where.exe ffprobe + + # --- CMake Setup --- + - name: Setup CMake + if: (runner.os != 'macOS' || env.ENABLE_MACOS_COMPILE == 'true') + uses: jwlawson/actions-setup-cmake@d06b37b47cfd043ec794ffa3e40e0b6b5858a7ec # v1.14.2 + with: + cmake-version: "3.31.x" + + - name: Verify CMake + if: (runner.os != 'macOS' || env.ENABLE_MACOS_COMPILE == 'true') + shell: bash + run: | + cmake --version + which cmake + + # --- Install Dependencies (Windows) --- + - name: Cache Intel OneAPI + id: cache-oneapi + if: runner.os == 'Windows' + uses: actions/cache@v4 + with: + path: oneapi + key: ${{ runner.os }}-oneapi-mkl-2025.3.0 + + - name: Install Intel OneAPI MKL (Windows) + if: runner.os == 'Windows' && steps.cache-oneapi.outputs.cache-hit != 'true' + shell: powershell + run: | + # Download Intel OneAPI Base Toolkit (Offline Installer) + # We use the offline installer because the online installer URL is not stable/predictable. + # This is a large download (~2.5GB), but we cache the result so this only happens once. + $installerUrl = "https://registrationcenter-download.intel.com/akdlm/IRC_NAS/1f18901e-877d-469d-a41a-a10f11b39336/intel-oneapi-base-toolkit-2025.3.0.372_offline.exe" + $installerPath = "$env:TEMP\intel-oneapi-base-toolkit-offline.exe" + + Write-Host "Downloading Intel OneAPI Base Toolkit from $installerUrl..." + + # Use curl.exe for faster download (Invoke-WebRequest can be slow due to progress bar) + # -L: Follow redirects + # -o: Output file + # --retry: Retry on transient errors + & curl.exe -L -o "$installerPath" "$installerUrl" --retry 5 --retry-delay 5 + + if (-not (Test-Path $installerPath)) { + Write-Error "Download failed: File not found at $installerPath" + exit 1 + } + + Write-Host "Installing Intel OneAPI Base Toolkit (MKL only)..." + + # Install to a local directory within the workspace so it can be cached + $installDir = "$env:GITHUB_WORKSPACE\oneapi" + + # Run installer silently with custom install directory + $proc = Start-Process -FilePath $installerPath -ArgumentList "-s -a --silent --eula accept --components intel.oneapi.win.mkl.devel -p=NEED_VS2022_INTEGRATION=0 --install-dir `"$installDir`"" -Wait -NoNewWindow -PassThru + + if ($proc.ExitCode -ne 0) { + Write-Error "Installation failed with exit code $($proc.ExitCode)" + exit 1 + } + + Write-Host "Installation complete." + + - name: Set MKLROOT Environment Variable + if: runner.os == 'Windows' + shell: powershell + run: | + $mklRoot = "$env:GITHUB_WORKSPACE\oneapi\mkl\latest" + if (Test-Path $mklRoot) { + Write-Host "MKL found at: $mklRoot" + echo "MKLROOT=$mklRoot" | Out-File -FilePath $env:GITHUB_ENV -Append + } else { + Write-Error "MKL installation failed or not found at expected path: $mklRoot" + exit 1 + } + + - name: Verify MKL install (Windows) + if: runner.os == 'Windows' + shell: powershell + run: | + Write-Host "MKLROOT is: $env:MKLROOT" + + $libPath = "$env:MKLROOT\lib" + $lib64Path = "$env:MKLROOT\lib\intel64" + + if (Test-Path $lib64Path) { + Write-Host "Found lib/intel64" + Get-ChildItem $lib64Path | Select-Object Name | Select-Object -First 5 + } elseif (Test-Path $libPath) { + Write-Host "Found lib (root)" + Get-ChildItem $libPath | Select-Object Name | Select-Object -First 5 + } else { + Write-Warning "Could not find lib or lib/intel64 under MKLROOT" + Get-ChildItem "$env:MKLROOT" -Recurse -Depth 2 + } + # --- vcpkg Setup (Windows) --- + - name: Export GitHub Actions cache environment variables + if: runner.os == 'Windows' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + + - name: Setup vcpkg + if: runner.os == 'Windows' + shell: powershell + run: | + echo "VCPKG_ROOT=C:\vcpkg" | Out-File -FilePath $env:GITHUB_ENV -Append + cd C:\vcpkg + git fetch origin 0d9d4684352ba8de70bdf251c6fc9a3c464fa12b + git checkout --detach 0d9d4684352ba8de70bdf251c6fc9a3c464fa12b + .\bootstrap-vcpkg.bat + + # --- Install vcpkg dependencies (Windows) --- + - name: Install vcpkg dependencies + if: runner.os == 'Windows' + shell: powershell + run: | + vcpkg install --triplet x64-windows + + # --- Configure (Windows) --- + - name: Configure CMake (Windows) + if: runner.os == 'Windows' + shell: powershell + run: | + cmake -S . -B build -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} ` + -D${{ env.TEST_TYPE }}=ON -DBUILD_VST3=ON -DJUCE_COPY_PLUGIN_AFTER_BUILD=OFF -DECLIPSA_VERSION=0.1.0 ` + -DBUILD_TESTING=ON ` + -DZMQ_BUILD_TESTS=OFF ` + -DMKL_ROOT="$env:MKLROOT" ` + -DVCPKG_ROOT="$env:VCPKG_ROOT" ` + -DVCPKG_TARGET_TRIPLET=x64-windows ` + -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" ` + -DVCPKG_CHAINLOAD_TOOLCHAIN_FILE="${{ github.workspace }}/cmake/toolchains/windows.cmake" + + # --- Configure (macOS) --- + - name: Configure CMake (macOS) + if: env.ENABLE_MACOS_COMPILE == 'true' && runner.os == 'macOS' + run: | + cmake -S . -B build -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} -G "Unix Makefiles" \ + -D${{ env.TEST_TYPE }}=ON -DBUILD_VST3=ON -DJUCE_COPY_PLUGIN_AFTER_BUILD=OFF -DECLIPSA_VERSION=0.1.0 -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \ + -DCMAKE_CXX_COMPILER=clang++ \ + -DCMAKE_C_COMPILER=clang \ + -DBUILD_TESTING=ON \ + -DZMQ_BUILD_TESTS=OFF \ + -DCMAKE_TOOLCHAIN_FILE="cmake/toolchains/macos.cmake" + + # --- Build --- + - name: Build + if: (runner.os != 'macOS' || env.ENABLE_MACOS_COMPILE == 'true') + shell: bash + run: | + if [ "${{ runner.os }}" == "macOS" ]; then + # Limit parallelism on macOS to avoid "Resource temporarily unavailable" errors + cmake --build build --config ${{ env.BUILD_TYPE }} -j 4 + else + cmake --build build --config ${{ env.BUILD_TYPE }} --parallel + fi + + # --- Test --- + - name: Test + if: (runner.os != 'macOS' || env.ENABLE_MACOS_COMPILE == 'true') + working-directory: ${{ github.workspace }}/build + shell: bash + run: ctest -C ${{ env.BUILD_TYPE }} --output-on-failure + + # --- Logs --- + - name: Dump Test Logs + if: ${{ (runner.os != 'macOS' || env.ENABLE_MACOS_COMPILE == 'true') && failure() }} + shell: bash + run: | + echo "//// CTest Logs ////" + if [ -f "Testing/Temporary/LastTest.log" ]; then cat Testing/Temporary/LastTest.log; fi + echo "//// Eclipsa Logs ////" + # Try to find logs in common locations + if [ -f "/tmp/Eclipsa_Audio_Plugin/log.txt" ]; then + cat /tmp/Eclipsa_Audio_Plugin/log.txt + elif [ -f "$TEMP/Eclipsa_Audio_Plugin/log.txt" ]; then + cat "$TEMP/Eclipsa_Audio_Plugin/log.txt" + else + echo "Log file not found." + fi + + - name: Package development binaries + if: success() + run: python scripts/spatial9/package_binaries.py build dist + + - name: Upload development binaries + if: success() + uses: actions/upload-artifact@v4 + with: + name: spatial9-eclipsa-${{ runner.os }}-unsigned + path: dist/* + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index d7f6302..e5266be 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,10 @@ obj/ .idea/ CMakeSettings.json + +# Spatial9 development outputs +/build-spatial9*/ +/dist/ +/.venv/ +__pycache__/ +*.egg-info/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 446f7e2..ad4dd23 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,18 +89,18 @@ add_compile_definitions(ECLIPSA_VERSION="${ECLIPSA_VERSION}") #==================================================================== # Make company name configurable via command line with a default value if (NOT DEFINED ECLIPSA_COMPANY_NAME) - set(ECLIPSA_COMPANY_NAME "Eclipsa Project" CACHE STRING "Company name for Eclipsa project") + set(ECLIPSA_COMPANY_NAME "Spatial9" CACHE STRING "Company name for Eclipsa project") endif () # Make manufacturer code configurable via command line with a default value if (NOT DEFINED ECLIPSA_MANUFACTURER_CODE) - set(ECLIPSA_MANUFACTURER_CODE "Eclp" CACHE STRING "Manufacturer code for Eclipsa project") + set(ECLIPSA_MANUFACTURER_CODE "Spn9" CACHE STRING "Manufacturer code for Eclipsa project") endif () # Make bundle IDs configurable via command line with default values if (NOT DEFINED ECLIPSA_RENDERER_BUNDLE_ID) - set(ECLIPSA_RENDERER_BUNDLE_ID "com.eclipsaproject.renderer" CACHE STRING "Bundle ID for the renderer plugin") + set(ECLIPSA_RENDERER_BUNDLE_ID "ai.spatial9.eclipsa.renderer" CACHE STRING "Bundle ID for the renderer plugin") endif () if (NOT DEFINED ECLIPSA_PANNER_BUNDLE_ID) - set(ECLIPSA_PANNER_BUNDLE_ID "com.eclipsaproject.panner" CACHE STRING "Bundle ID for the panner plugin") + set(ECLIPSA_PANNER_BUNDLE_ID "ai.spatial9.eclipsa.element" CACHE STRING "Bundle ID for the panner plugin") endif () #==================================================================== diff --git a/README.md b/README.md index de23002..d07838a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +> **Spatial9 development fork:** See [README_SPATIAL9.md](README_SPATIAL9.md) for the new integration, build status and publishing guide. Native binaries and production readiness are not yet verified. + # Eclipsa Audio Plugins ## Building the Plugins diff --git a/README_SPATIAL9.md b/README_SPATIAL9.md new file mode 100644 index 0000000..7453aac --- /dev/null +++ b/README_SPATIAL9.md @@ -0,0 +1,107 @@ +# Spatial9 Eclipsa — development source + +**Status: implementation preview, not a production release. No Windows or macOS plugin binary has been compiled or DAW-tested for this patch set.** + +This is a separately identified fork of Google's Eclipsa Audio plugin at +`db210550eb273cb4ea08ae2e923ca46fc1b9c9d5`, with a Spatial9 scene-plan engine, +local companion, Data Feed adapter, and MCP adapter. The source is provided for +review, native builds and integration with Spatial9's real service. + +Start with [build and publishing instructions](docs/spatial9/BUILD_AND_PUBLISH.md), +[API contract](docs/spatial9/DATA_FEED_CONTRACT.md), and +[validation and limitations](docs/spatial9/VALIDATION.md). + +## What is implemented + +- Separate Spatial9 Element and Renderer product names, bundle IDs, plugin IDs, + and internal ports, so this fork does not replace the upstream plugin identity. +- Private per-instance loopback discovery/control; a local browser companion + for reading the scene, obtaining or pasting a proposal, reviewing, applying, + and undoing it. Plugin editors have a Spatial9 button to open that companion. +- Versioned, bounded plans for mono/stereo sources: normalized XYZ, gain, mute, + stereo angular width, seconds/beat timing, linear/smoothstep interpolation. +- Cached plan playback and project-state serialization. New plan evaluation has + no network calls, allocations or mutex operations in the audio callback. + This does not remove locks elsewhere in upstream audio processing. +- Revision checking, all-source preflight, acknowledged per-source apply, + best-effort rollback and undo that refuses to overwrite subsequent edits. +- Two-source stereo panning, full spherical radius conversion, and preallocated + panner input chunks. These DSP changes require native listening/host tests. +- HTTP Data Feed adapter with a proposed contract, explicit errors when the real + endpoint is missing, and a one-request-per-second local limiter. +- MCP stdio server exposing local scene/plan tools; optional client calls to + `https://spatial9.ai/mcp` using the official Python MCP SDK. +- Native GitHub Actions build definitions for Windows x64 VST3 and macOS 15+ + Apple Silicon VST3/AU. These workflows have not been executed on GitHub. + +## What is still needed + +| Input | Why it is needed | +| --- | --- | +| Real Data Feed URL, request/response examples or OpenAPI/JSON schema, authentication method, errors/rate-limit rules | Replace or confirm the proposed adapter contract; prove real inference end to end. | +| Spatial9 backend repository access, if backend work is part of this task | Implement the server endpoint, account quotas and model integration. None of that backend source is included here. | +| Target GitHub owner/repository and branch with Actions enabled | Run and fix native CI, obtain real compiled artifacts, then prepare a release. | +| Minimum Windows/macOS versions and DAW/version matrix | Select release targets and test buffer sizes, automation, recall, rendering and host compatibility. Current Mac target is macOS 15+, arm64 only. | +| Apple Silicon only versus Intel/universal Mac | Upstream vendored Mac libraries are ARM64; universal requires rebuilding/replacing every native dependency for x86_64 as well. | +| Production signing preferences | Unsigned development builds can be made first. Public Mac releases need Developer ID signing and notarization; Windows installer signing is separate. Supply credentials through CI secrets, never source code or chat. | +| AAX requirement | AAX is not enabled; SDK access, build setup, signing and Pro Tools validation are a separate track. | + +## Quick start for the companion + +Install Python 3.11+ (3.12 is used in CI), then from this source folder: + +```sh +python -m venv .venv +# macOS/Linux: +. .venv/bin/activate +# Windows PowerShell instead: .venv\Scripts\Activate.ps1 +python -m pip install ./bridge +python -m spatial9_bridge ui +``` + +This starts a loopback-only browser UI. It does not install a compiled plugin. +After a native build, open **one active Spatial9 Renderer/session per computer** +and its Spatial9 Elements in a DAW, enable panning, and choose unconstrained +(`None`) elevation. Click Spatial9 in the plugin or use the companion's startup +browser window. Proposals can also be pasted manually without a cloud service. + +Configure your actual HTTPS Data Feed URL in `SPATIAL9_FEED_URL` and optional +bearer key in `SPATIAL9_FEED_API_KEY` in the environment that starts the companion. +No endpoint or key is hardcoded. The included contract is a proposal, not a claim +about Spatial9's currently deployed Data Feed. + +## MCP + +Start the companion UI first. For an MCP client supporting local stdio servers, +configure the installed virtual environment's Python executable as `command`, +with `args: ["-m", "spatial9_bridge", "mcp"]`. Use absolute executable paths. + +Local tools: `eclipsa_get_sessions`, `eclipsa_get_scene`, `eclipsa_suggest_plan`, +`eclipsa_preview_plan`, `eclipsa_apply_plan`, `eclipsa_restore_snapshot`. + +Cloud tools: `spatial9_cloud_tools` discovers the remote tool schemas; +`spatial9_cloud_call` invokes a restricted allowlist. Set `SPATIAL9_MCP_TOKEN` +only in the MCP server process environment. The supplied adapter uses a bearer +access token; interactive OAuth login and refresh are not implemented. SDK +startup/tool discovery was tested locally; authenticated cloud calls were not. +Cloud renders can consume account credits. Cloud outputs are returned as tool +results/links; automatic DAW audio import, track creation, file upload and +YouTube publication are not implemented by this companion. + +## Source layout + +| Path | Purpose | +| --- | --- | +| `common/spatial9/` | Plan model, fixed-slot exchange, JSON validation, native loopback control, state integration and UI launcher. | +| `audioelementplugin/src/` | Audio callback and DAW state hooks. | +| `common/processors/panner/` and `common/substream_rdr/surround_panner/` | Floating-point plan positions, stereo pair panning, geometry/chunking changes. | +| `rendererplugin/src/RendererPluginSyncServer.h` | Session identity in renderer snapshots. | +| `bridge/spatial9_bridge/` | Companion HTTP/UI, Data Feed adapter and MCP tools. | +| `tests/spatial9/`, `bridge/tests/` | Core/concurrency, bridge, native interop, HTTP and MCP verification. | +| `scripts/spatial9/` | Pinned dependency bootstrap, binary packaging and integrity manifest. | +| `.github/workflows/spatial9-*.yml` | Portable tests and native development builds. | + +Original licenses and third-party notices remain in place. Spatial9 changes are +Apache-2.0 unless a dependency's own license states otherwise. This fork is not +an official Google, Samsung or AOMedia release. Review third-party redistribution +requirements before distributing binaries, especially bundled runtime libraries. diff --git a/SPATIAL9_CHANGES.md b/SPATIAL9_CHANGES.md new file mode 100644 index 0000000..57975ec --- /dev/null +++ b/SPATIAL9_CHANGES.md @@ -0,0 +1,13 @@ +# Spatial9 modifications + +The new files under `common/spatial9`, `bridge`, `tests/spatial9`, +`scripts/spatial9` and `docs/spatial9` are Spatial9 integration work (2026). +Existing Google/third-party notices remain unchanged. See README_SPATIAL9.md +for scope and VALIDATION.md for unverified behavior. + +Modified existing areas: product identifiers/build configuration; native editor +launch buttons; Audio Element scene/state/audio hooks; renderer session identity; +local IPC/telemetry port separation; panner float positions, stereo handling, +full 3D radius and preallocated chunk processing; Boost version consistency; +modern macOS linker; CI dispatch changes. The JUCE submodule compatibility patch +is applied explicitly by bootstrap, not a new upstream commit. diff --git a/audioelementplugin/CMakeLists.txt b/audioelementplugin/CMakeLists.txt index 74117ac..119877a 100644 --- a/audioelementplugin/CMakeLists.txt +++ b/audioelementplugin/CMakeLists.txt @@ -16,11 +16,11 @@ # Set plugin code and name based on build type #==================================================================== if (ECLIPSA_LOGIC_PRO_BUILD) - set(AUDIO_ELEMENT_PLUGIN_CODE "Eacl") - set(AUDIO_ELEMENT_PRODUCT_NAME "Eclipsa Audio Element Plugin for Logic Pro") + set(AUDIO_ELEMENT_PLUGIN_CODE "S9al") + set(AUDIO_ELEMENT_PRODUCT_NAME "Spatial9 Eclipsa Element for Logic Pro") else () - set(AUDIO_ELEMENT_PLUGIN_CODE "Ecae") - set(AUDIO_ELEMENT_PRODUCT_NAME "Eclipsa Audio Element Plugin") + set(AUDIO_ELEMENT_PLUGIN_CODE "S9ae") + set(AUDIO_ELEMENT_PRODUCT_NAME "Spatial9 Eclipsa Element") endif () juce_add_plugin(AudioElementPlugin @@ -88,7 +88,7 @@ endif () # Copy resources for each plugin format #==================================================================== set(_ARTEFACTS_BASE "${CMAKE_BINARY_DIR}/audioelementplugin/AudioElementPlugin_artefacts") -set(_PLUGIN_NAME "Eclipsa Audio Element Plugin") +set(_PLUGIN_NAME "Spatial9 Eclipsa Element") if (TARGET AudioElementPlugin_AAX) copy_resources(AudioElementPlugin_AAX "${_ARTEFACTS_BASE}/$/AAX/${_PLUGIN_NAME}.aaxplugin") diff --git a/audioelementplugin/src/AudioElementPluginEditor.cpp b/audioelementplugin/src/AudioElementPluginEditor.cpp index 4c4ef7a..0ea1b76 100644 --- a/audioelementplugin/src/AudioElementPluginEditor.cpp +++ b/audioelementplugin/src/AudioElementPluginEditor.cpp @@ -13,6 +13,7 @@ // limitations under the License. #include "AudioElementPluginEditor.h" +#include "spatial9/OpenBridge.h" #ifdef _WIN32 // Windows doesn't need unistd.h - functionality is in io.h if needed @@ -359,6 +360,9 @@ void AudioElementPluginEditor::paint(juce::Graphics& g) { versionBounds.removeFromBottom(5); auto bottomStrip = versionBounds.removeFromBottom(15); versionLabel_.setBounds(bottomStrip.removeFromLeft(150)); + addAndMakeVisible(spatial9Button_); + spatial9Button_.setBounds(bottomStrip.removeFromRight(90)); + spatial9Button_.onClick = [] { spatial9::openBridge(); }; } void AudioElementPluginEditor::resized() { diff --git a/audioelementplugin/src/AudioElementPluginEditor.h b/audioelementplugin/src/AudioElementPluginEditor.h index 89b1c7d..1fe8d6f 100644 --- a/audioelementplugin/src/AudioElementPluginEditor.h +++ b/audioelementplugin/src/AudioElementPluginEditor.h @@ -93,6 +93,7 @@ class AudioElementPluginEditor final : public juce::AudioProcessorEditor, juce::Label titleLabel_; juce::Label versionLabel_; + juce::TextButton spatial9Button_{"Spatial9"}; juce::Label panningControlsLabel_; SliderButton panningControls_; juce::Component* currentScreen_; diff --git a/audioelementplugin/src/AudioElementPluginProcessor.cpp b/audioelementplugin/src/AudioElementPluginProcessor.cpp index d12185d..9ea2f66 100644 --- a/audioelementplugin/src/AudioElementPluginProcessor.cpp +++ b/audioelementplugin/src/AudioElementPluginProcessor.cpp @@ -49,7 +49,7 @@ AudioElementPluginProcessor::AudioElementPluginProcessor() kMSPlaybackRepositoryStateKey, nullptr)), firstOutputChannel(-1), outputChannelCount(1), - syncClient_(&audioElementSpatialLayoutRepository_, 2134), + syncClient_(&audioElementSpatialLayoutRepository_, 32134), automationParametersTreeState(*this), trackName_("") { elevationListener_.setListeners(&automationParametersTreeState, @@ -58,7 +58,7 @@ AudioElementPluginProcessor::AudioElementPluginProcessor() audioProcessors_.push_back(std::make_unique(this, false)); audioProcessors_.push_back(std::make_unique( this, &audioElementSpatialLayoutRepository_, - &automationParametersTreeState)); + &automationParametersTreeState, &spatial9Runtime_)); audioProcessors_.push_back(std::make_unique(msRespository_)); audioProcessors_.push_back(std::make_unique( monitorData_, &audioElementSpatialLayoutRepository_)); @@ -100,6 +100,23 @@ AudioElementPluginProcessor::AudioElementPluginProcessor() // Register this instance of the Audio Element plugin with the renderer plugin audioElementSpatialLayoutRepository_.registerListener(this); syncClient_.connect(); + spatial9Controller_ = std::make_unique(spatial9Runtime_, automationParametersTreeState, + [this] { + const auto source = audioElementSpatialLayoutRepository_.get(); + return spatial9::object({{"source_id", source.getId().toString()}, {"session_id", syncClient_.getSpatial9SessionId()}, + {"name", source.getName()}, {"channels", getMainBusNumInputChannels()}, + {"panning_enabled", source.isPanningEnabled()}, {"elevation_mode", (int)source.getElevation()}}); + }, + [this] { + const auto source = audioElementSpatialLayoutRepository_.get(); + if (getMainBusNumInputChannels() < 1 || getMainBusNumInputChannels() > 2) + throw std::invalid_argument("Spatial9 plans support mono/stereo input only"); + if (!source.isPanningEnabled()) throw std::invalid_argument("Enable Eclipsa panning first"); + if (source.getChannelLayout() == Speakers::kMono || source.getChannelLayout() == Speakers::kExplLFE) + throw std::invalid_argument("Select a spatial output layout before applying a plan"); + if (source.getElevation() != AudioElementSpatialLayout::Elevation::kNone) + throw std::invalid_argument("Select unconstrained elevation (None) first"); + }); } void AudioElementPluginProcessor::releaseResources() {} @@ -183,6 +200,9 @@ bool AudioElementPluginProcessor::applyBusLayouts(const BusesLayout& layouts) { void AudioElementPluginProcessor::valueTreePropertyChanged( juce::ValueTree& treeWhosePropertyHasChanged, const juce::Identifier& property) { + if (property == AudioElementSpatialLayout::kLayout || property == AudioElementSpatialLayout::kPanningEnabled || property == AudioElementSpatialLayout::kElevation) + spatial9Runtime_.enabled.store(false); + ++spatial9Runtime_.revision; // An update to the audio element spatial layout repository has occurred // Fetch the first channel and output channels and update accordingly // Note that updates apply sequentially, so an update that updates first and @@ -218,8 +238,24 @@ void AudioElementPluginProcessor::setOutputChannels(int firstChannel, void AudioElementPluginProcessor::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) { + double seconds = std::numeric_limits::quiet_NaN(); + double beats = seconds; + if (auto* playhead = getPlayHead()) if (auto position = playhead->getPosition()) { + if (auto value = position->getTimeInSeconds()) seconds = *value; + if (auto value = position->getPpqPosition()) beats = *value; + } + bool valid = false; + spatial9Runtime_.frame = spatial9Runtime_.plans.sample(seconds, beats, valid); + spatial9Runtime_.active = valid && spatial9Runtime_.enabled.load(); + if (spatial9Runtime_.active) { + const auto& f = spatial9Runtime_.frame; + const float gain = f.muted ? 0.f : juce::Decibels::decibelsToGain(f.gainDb); + for (int ch = 0; ch < std::min(getMainBusNumInputChannels(), buffer.getNumChannels()); ++ch) + buffer.applyGainRamp(ch, 0, buffer.getNumSamples(), spatial9PreviousGain_, gain); + spatial9PreviousGain_ = gain; + } else spatial9PreviousGain_ = juce::Decibels::decibelsToGain(automationParametersTreeState.getVolume()); // if the first channel is set, and unmute is true, apply automation - if (firstOutputChannel >= 0 && automationParametersTreeState.getUnmute()) { + if (!spatial9Runtime_.active && firstOutputChannel >= 0 && automationParametersTreeState.getUnmute()) { // get gain in decibels float currentVolume = automationParametersTreeState.getVolume(); // convert to linear gain @@ -251,6 +287,8 @@ void AudioElementPluginProcessor::getStateInformation( LOG_ANALYTICS(instanceId_, "Audio Element Plugin Processor getStateInformation \n"); // This function is called when the plugin is saved, so save the current + if (spatial9Controller_) persistentState_.setProperty("spatial9_state", + juce::JSON::toString(spatial9Controller_->state()), nullptr); juce::ValueTree automationTree = automationParametersTreeState.copyState(); persistentState_.appendChild(automationTree, nullptr); @@ -337,6 +375,10 @@ void AudioElementPluginProcessor::setStateInformation(const void* data, persistentState_.getChildWithName(AutoParamMetaData::kTreeType); if (automationTree.isValid()) { automationParametersTreeState.replaceState(automationTree); + if (spatial9Controller_) { + try { spatial9Controller_->restore(juce::JSON::parse(persistentState_.getProperty("spatial9_state").toString())); } + catch (const std::exception&) { spatial9Runtime_.enabled.store(false); } + } } // Re-initialize components after state restoration diff --git a/audioelementplugin/src/AudioElementPluginProcessor.h b/audioelementplugin/src/AudioElementPluginProcessor.h index 51d9c9b..e406431 100644 --- a/audioelementplugin/src/AudioElementPluginProcessor.h +++ b/audioelementplugin/src/AudioElementPluginProcessor.h @@ -27,6 +27,7 @@ #include #include +#include "spatial9/SourceController.h" #include #include @@ -59,6 +60,7 @@ class AudioElementPluginProcessor final : public ProcessorBase, ~AudioElementPluginProcessor() override { // Ensure the processors are destroyed before the repositories or other // dependent pieces + spatial9Controller_.reset(); audioProcessors_.clear(); syncClient_.disconnectClient(); } @@ -80,7 +82,7 @@ class AudioElementPluginProcessor final : public ProcessorBase, bool hasEditor() const override { return true; } const juce::String getName() const override { - return "Eclipsa Audio Element Plugin"; + return "Spatial9 Eclipsa Element"; } void setOutputChannels(int firstChannel, int totalChannels); @@ -135,5 +137,8 @@ class AudioElementPluginProcessor final : public ProcessorBase, AmbisonicsData ambisonicsData_; // intiialized in SoundFieldProcessor juce::String trackName_; + spatial9::Runtime spatial9Runtime_; + std::unique_ptr spatial9Controller_; + float spatial9PreviousGain_ = 1.f; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AudioElementPluginProcessor) }; \ No newline at end of file diff --git a/bridge/pyproject.toml b/bridge/pyproject.toml new file mode 100644 index 0000000..8126502 --- /dev/null +++ b/bridge/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "spatial9-eclipsa-bridge" +version = "0.1.0" +description = "Local Data Feed and MCP bridge for the Spatial9 Eclipsa source fork" +requires-python = ">=3.11" +dependencies = ["mcp==1.29.0"] + +[project.scripts] +spatial9-eclipsa = "spatial9_bridge.__main__:main" + +[tool.setuptools.package-data] +spatial9_bridge = ["*.html"] diff --git a/bridge/spatial9_bridge/__init__.py b/bridge/spatial9_bridge/__init__.py new file mode 100644 index 0000000..ff7745b --- /dev/null +++ b/bridge/spatial9_bridge/__init__.py @@ -0,0 +1,2 @@ +"""Copyright 2026 Spatial9. Licensed under Apache-2.0.""" +__version__ = "0.1.0" diff --git a/bridge/spatial9_bridge/__main__.py b/bridge/spatial9_bridge/__main__.py new file mode 100644 index 0000000..0f690e2 --- /dev/null +++ b/bridge/spatial9_bridge/__main__.py @@ -0,0 +1,14 @@ +import argparse +def main(): + parser=argparse.ArgumentParser(description="Spatial9 Eclipsa local companion") + parser.add_argument("mode",choices=["ui","mcp"]) + parser.add_argument("--port",type=int,default=38490) + parser.add_argument("--no-browser",action="store_true") + args=parser.parse_args() + if args.mode=="mcp": + from .mcp_server import run + run() + else: + from .server import serve + serve(args.port,not args.no_browser) +if __name__=="__main__": main() diff --git a/bridge/spatial9_bridge/core.py b/bridge/spatial9_bridge/core.py new file mode 100644 index 0000000..7e7c6d9 --- /dev/null +++ b/bridge/spatial9_bridge/core.py @@ -0,0 +1,230 @@ +"""Local IPC, validated scene plans and explicit apply/undo transactions.""" +from __future__ import annotations +import hashlib +import json +import math +import os +from pathlib import Path +import secrets +import socket +import struct +import threading +import time +import urllib.error +import urllib.parse +import urllib.request + +MAGIC = 0xF2B49E2C +MAX_BYTES = 1024 * 1024 +DIRECTORY = Path(os.environ.get("SPATIAL9_BRIDGE_DIR", str(Path.home() / ".spatial9-eclipsa"))) + +def private_write(path: Path, value: dict): + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + tmp = path.with_name(path.name + "." + secrets.token_hex(6) + ".tmp") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(value, f, allow_nan=False) + tmp.replace(path) + +def exact_read(sock, size): + data = bytearray() + while len(data) < size: + chunk = sock.recv(size - len(data)) + if not chunk: raise ConnectionError("Plugin disconnected") + data.extend(chunk) + return bytes(data) + +def rpc(endpoint: dict, operation: str, **args): + port = endpoint.get("port") + if type(port) is not int or not 0 < port < 65536: raise ValueError("Invalid plugin port") + request = dict(args, token=endpoint["token"], instance_id=endpoint["instance_id"], operation=operation) + data = json.dumps(request, allow_nan=False).encode() + if len(data) > MAX_BYTES: raise ValueError("Request too large") + with socket.create_connection(("127.0.0.1", port), timeout=3) as sock: + sock.settimeout(5) + sock.sendall(struct.pack(" MAX_BYTES: raise ValueError("Invalid response header") + reply = json.loads(exact_read(sock, size)) + if reply.get("ok") is not True: raise ValueError(reply.get("error", "Plugin rejected request")) + return reply["result"] + +def finite(value, low, high, name): + if type(value) not in (int, float) or not math.isfinite(value) or not low <= value <= high: + raise ValueError(f"{name} must be finite and within [{low}, {high}]") + +def validate_plan(plan): + if not isinstance(plan, dict): raise ValueError("Plan must be an object") + if type(plan.get("schema_version")) is not int or plan["schema_version"] != 1: raise ValueError("Unsupported schema version") + if plan.get("coordinate_system") != "eclipsa_normalized_xyz": raise ValueError("Unsupported coordinates") + if plan.get("timebase") not in ("seconds", "beats"): raise ValueError("Unsupported timebase") + if plan.get("interpolation") not in ("linear", "smoothstep"): raise ValueError("Unsupported interpolation") + keys = plan.get("keyframes") + if not isinstance(keys, list) or not 1 <= len(keys) <= 2048: raise ValueError("Expected 1–2048 keyframes") + previous = -1 + for key in keys: + if not isinstance(key, dict): raise ValueError("Keyframe must be an object") + finite(key.get("time"), 0, 1e12, "time") + if key["time"] <= previous: raise ValueError("Keyframe times must increase strictly") + previous = key["time"] + for axis in ("x", "y", "z"): finite(key.get(axis), -1, 1, axis) + finite(key.get("gain_db"), -100, 12, "gain_db") + finite(key.get("stereo_width_deg"), 0, 180, "stereo_width_deg") + if type(key.get("muted")) is not bool: raise ValueError("muted must be boolean") + return plan + +def scene_revision(sources): + # Labels and routing changes also invalidate cached proposals. + body = json.dumps(sorted(sources, key=lambda s: s["instance_id"]), sort_keys=True, allow_nan=False) + return hashlib.sha256(body.encode()).hexdigest() + +def validate_proposal(proposal, scene): + if not isinstance(proposal, dict) or type(proposal.get("schema_version")) is not int or proposal.get("schema_version") != 1: raise ValueError("Unsupported proposal") + if proposal.get("session_id") != scene["session_id"] or proposal.get("scene_revision") != scene["scene_revision"]: + raise ValueError("Proposal belongs to a different or changed session") + items = proposal.get("sources") + if not isinstance(items, list) or not 1 <= len(items) <= 128: raise ValueError("Expected 1–128 source plans") + known = {s["source_id"]: s for s in scene["sources"]} + seen = set() + for item in items: + if not isinstance(item, dict): raise ValueError("Source plan must be an object") + sid = item.get("source_id") + if sid not in known or sid in seen: raise ValueError("Unknown or duplicate source ID") + seen.add(sid) + source = known[sid] + if source["channels"] not in (1, 2): raise ValueError("Only mono/stereo inputs supported") + if not source["panning_enabled"] or source["elevation_mode"] != -1: + raise ValueError(f"Enable panning and unconstrained elevation for {source['name']}") + validate_plan(item.get("plan")) + effects = proposal.get("effect_suggestions", []) + if not isinstance(effects, list) or len(effects) > 128 or any(not isinstance(s, str) or len(s)>2000 for s in effects): + raise ValueError("Effect suggestions must be a bounded list of text instructions") + return proposal + +class Bridge: + def __init__(self, directory=DIRECTORY, call=rpc): + self.directory = Path(directory) + self.call = call + self.lock = threading.RLock() + self.proposals = {} + self.undo = {} + self.endpoints = {} + self.next_feed = 0.0 + + def scenes(self): + with self.lock: + sessions = {}; endpoints = {}; unavailable = 0 + for path in sorted((self.directory / "instances").glob("*.json"))[:256]: + try: + if path.stat().st_size > 4096: continue + endpoint = json.loads(path.read_text()) + source = self.call(endpoint, "get_scene") + sid = source.get("session_id") + if not sid: continue + instance = endpoint["instance_id"] + source["instance_id"] = instance + endpoints[instance] = endpoint + sessions.setdefault(sid, []).append(source) + except (OSError, ValueError, KeyError, ConnectionError): unavailable += 1 + self.endpoints = endpoints + result = [] + for sid, sources in sessions.items(): + ids = [s["source_id"] for s in sources] + if len(set(ids)) != len(ids): + raise ValueError("Duplicate source IDs in session; recreate the duplicated Audio Element plugin") + result.append(dict(session_id=sid, scene_revision=scene_revision(sources), sources=sources)) + return {"sessions": result, "unavailable_instances": unavailable} + + def scene(self, session_id): + for scene in self.scenes()["sessions"]: + if scene["session_id"] == session_id: return scene + raise ValueError("Session is not available; open its Renderer and Audio Element plugins") + + def preview(self, proposal): + with self.lock: + scene = self.scene(proposal.get("session_id")) + validate_proposal(proposal, scene) + if len(self.proposals) >= 32: self.proposals.pop(next(iter(self.proposals))) + pid = secrets.token_hex(16) + self.proposals[pid] = json.loads(json.dumps(proposal, allow_nan=False)) + return {"proposal_id": pid, "proposal": proposal, "applied": False, + "note": "Effects are recommendations only. Review before applying."} + + def suggest(self, session_id, brief): + with self.lock: + if not isinstance(brief,str) or not 1 <= len(brief) <= 8000: raise ValueError("Provide a brief of 1–8000 characters") + url = os.environ.get("SPATIAL9_FEED_URL", "") + if urllib.parse.urlsplit(url).scheme != "https": + raise ValueError("Configure SPATIAL9_FEED_URL with your real HTTPS Data Feed endpoint; no inference service is bundled") + scene = self.scene(session_id) + remaining = self.next_feed - time.monotonic() + if remaining > 0: raise ValueError(f"Rate limited; retry in {remaining:.1f} seconds") + self.next_feed = time.monotonic() + 1.0 + payload = dict(schema_version=1, brief=brief, **scene, + capabilities={"coordinates":"eclipsa_normalized_xyz","max_keyframes":2048,"input_channels":[1,2],"effects":"advisory_only"}) + headers = {"Content-Type":"application/json", "Accept":"application/json"} + key = os.environ.get("SPATIAL9_FEED_API_KEY") + if key: headers["Authorization"] = "Bearer " + key + request = urllib.request.Request(url, json.dumps(payload, allow_nan=False).encode(), headers, method="POST") + # Never forward bearer credentials to a redirected host. + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): return None + try: + with urllib.request.build_opener(NoRedirect).open(request, timeout=30) as response: + raw = response.read(MAX_BYTES+1) + if len(raw)>MAX_BYTES: raise ValueError("Feed response exceeds 1 MiB") + proposal = json.loads(raw) + except urllib.error.HTTPError as e: + if e.code == 429: + try: retry = max(1, min(3600, float(e.headers.get("Retry-After", "1")))) + except ValueError: retry = 1 + self.next_feed = time.monotonic() + retry + raise ValueError(f"Data Feed rate limited; retry in {retry:g} seconds") from None + raise ValueError(f"Data Feed returned HTTP {e.code}") from None + return self.preview(proposal) + + def apply(self, proposal_id): + with self.lock: + if proposal_id not in self.proposals: raise ValueError("Preview the proposal before applying") + proposal = self.proposals[proposal_id] + scene = self.scene(proposal["session_id"]) + validate_proposal(proposal, scene) + known = {s["source_id"]:s for s in scene["sources"]} + before = []; changed = [] + for item in proposal["sources"]: + source = known[item["source_id"]]; endpoint = self.endpoints[source["instance_id"]] + self.call(endpoint,"validate_plan",expected_revision=source["revision"],plan=item["plan"]) + before.append((endpoint,source,item["plan"])) + try: + for endpoint, source, plan in before: + updated = self.call(endpoint,"apply_plan",expected_revision=source["revision"],plan=plan) + changed.append((endpoint,source,updated["revision"])) + except Exception as exc: + uncertain_source = source["name"] + rollback_errors = [] + for endpoint, source, revision in reversed(changed): + try: self.call(endpoint,"restore_snapshot",expected_revision=revision,state=source["state"]) + except Exception: rollback_errors.append(source["name"]) + raise ValueError(f"Apply interrupted: {exc}. Rollback failures: {rollback_errors}. " + f"The last attempted source ({uncertain_source}) may have applied without acknowledgement; inspect it and refresh before retrying.") from None + snapshot = secrets.token_hex(16) + if len(self.undo)>=32: self.undo.pop(next(iter(self.undo))) + self.undo[snapshot] = changed + del self.proposals[proposal_id] + return {"applied_sources":len(changed),"snapshot_id":snapshot, + "note":"Changes are acknowledged per source; multi-source apply is not sample-atomic."} + + def restore(self, snapshot_id): + with self.lock: + if snapshot_id not in self.undo: raise ValueError("Snapshot unavailable; undo history belongs to this companion session") + rows = self.undo[snapshot_id] + for endpoint, _, revision in rows: + if self.call(endpoint,"get_scene")["revision"] != revision: + raise ValueError("A source was edited after apply; undo refused to preserve the edit") + failures = [] + for endpoint, source, revision in rows: + try: self.call(endpoint,"restore_snapshot",expected_revision=revision,state=source["state"]) + except Exception: failures.append(source["name"]) + del self.undo[snapshot_id] + if failures: raise ValueError(f"Partial restore; inspect these tracks: {failures}") + return {"restored_sources":len(rows)} diff --git a/bridge/spatial9_bridge/index.html b/bridge/spatial9_bridge/index.html new file mode 100644 index 0000000..1ff862c --- /dev/null +++ b/bridge/spatial9_bridge/index.html @@ -0,0 +1,28 @@ + +Spatial9 · Eclipsa + +SPATIAL9 / ECLIPSA EDITION

Your session. A new perspective.

+

Generate an editable spatial plan, review it, then apply it to your tracks. Accepted movement stays in your project and runs locally.

+Development preview · native DAW validation pending +

1 · Connect your session

TrackInputReadyPlan

Use one active Spatial9 Renderer/session per computer. Add Spatial9 Elements, enable Panning, and choose unconstrained elevation. Existing DAW automation or a manual parameter edit takes control and bypasses the accepted plan.

+

2 · Describe the arrangement

Requires your configured Data Feed endpoint. Sends track metadata and your brief; this companion does not upload audio.

+

3 · Review and apply

Inspect the returned plan or paste a version-1 proposal. XYZ uses normalized Eclipsa coordinates: right, front, up. Effects remain written suggestions.

+

Based on Google's open-source Eclipsa Audio plugins. This Spatial9 edition is a separate fork. + diff --git a/bridge/spatial9_bridge/mcp_server.py b/bridge/spatial9_bridge/mcp_server.py new file mode 100644 index 0000000..1412f50 --- /dev/null +++ b/bridge/spatial9_bridge/mcp_server.py @@ -0,0 +1,72 @@ +"""Official MCP SDK adapter. Local tools share the companion's transactions.""" +import json +import os +import urllib.request +import urllib.error +from .core import DIRECTORY, MAX_BYTES + +def local(path, **data): + try: settings=json.loads((DIRECTORY/"ui.json").read_text()) + except (OSError,ValueError): raise ValueError("Start spatial9-eclipsa ui before connecting MCP") from None + port=settings["port"] + if type(port) is not int or not 0 dict: + """List locally running Spatial9 Eclipsa sessions and sources.""" + return local("scenes") + @mcp.tool() + def eclipsa_get_scene(session_id: str) -> dict: + """Read source IDs, revisions, positions and accepted plans.""" + return local("scene",session_id=session_id) + @mcp.tool() + def eclipsa_suggest_plan(session_id: str, brief: str) -> dict: + """Send session metadata and the creative brief to the configured Data Feed. Does not apply changes.""" + return local("suggest",session_id=session_id,brief=brief) + @mcp.tool() + def eclipsa_preview_plan(proposal: dict) -> dict: + """Validate a full version-1 proposal and obtain its apply token. No audio changes.""" + return local("preview",proposal=proposal) + @mcp.tool() + def eclipsa_apply_plan(proposal_id: str) -> dict: + """Apply a previously previewed plan after the user chooses it. Returns an undo snapshot.""" + return local("apply",proposal_id=proposal_id) + @mcp.tool() + def eclipsa_restore_snapshot(snapshot_id: str) -> dict: + """Undo a bridge apply, refusing if a source was edited subsequently.""" + return local("restore",snapshot_id=snapshot_id) + + async def cloud_session(action, name=None, arguments=None): + import httpx + from mcp import ClientSession + from mcp.client.streamable_http import streamable_http_client + token=os.environ.get("SPATIAL9_MCP_TOKEN") + if not token: raise ValueError("Set SPATIAL9_MCP_TOKEN to a supported Spatial9 access token; interactive OAuth is not bundled") + async with httpx.AsyncClient(headers={"Authorization":"Bearer "+token},timeout=60,follow_redirects=False) as http: + async with streamable_http_client("https://spatial9.ai/mcp",http_client=http) as (read,write,_): + async with ClientSession(read,write) as session: + await session.initialize() + result=await session.list_tools() if action=="list" else await session.call_tool(name,arguments or {}) + return result.model_dump(mode="json",exclude_none=True) + @mcp.tool() + async def spatial9_cloud_tools() -> dict: + """Discover the actual tools and schemas on Spatial9's remote MCP service.""" + return await cloud_session("list") + @mcp.tool() + async def spatial9_cloud_call(tool_name: str, arguments: dict) -> dict: + """Call a discovered render or read tool. Render/remix/stem jobs may use credits; obtain the user's choice. Publishing is excluded.""" + allowed={"get_usage","get_mix","get_effects","find_effects","get_job_status","get_downloads","list_renders", + "create_upload_link","create_spatial_audio","split_stems","remix_render","apply_effects"} + if tool_name not in allowed: raise ValueError("Tool is outside the bridge allowlist") + return await cloud_session("call",tool_name,arguments) + mcp.run(transport="stdio") diff --git a/bridge/spatial9_bridge/server.py b/bridge/spatial9_bridge/server.py new file mode 100644 index 0000000..c3d1fbb --- /dev/null +++ b/bridge/spatial9_bridge/server.py @@ -0,0 +1,64 @@ +"""A loopback-only control surface; tokens never enter DAW project state.""" +import hmac +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +import secrets +import urllib.parse +import webbrowser +from .core import Bridge, DIRECTORY, MAX_BYTES, private_write + +def serve(port=38490, open_browser=True): + bridge = Bridge() + token = secrets.token_hex(32) + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): pass # No token-bearing requests in logs. + def reply(self, status, body, content_type="application/json"): + if not isinstance(body,bytes): body=json.dumps(body,allow_nan=False).encode() + self.send_response(status) + self.send_header("Content-Type",content_type) + self.send_header("Content-Length",str(len(body))) + self.send_header("Cache-Control","no-store") + self.send_header("X-Content-Type-Options","nosniff") + self.send_header("Referrer-Policy","no-referrer") + self.send_header("Content-Security-Policy","default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'") + self.end_headers(); self.wfile.write(body) + def allowed_host(self): + return self.headers.get("Host")==f"127.0.0.1:{self.server.server_port}" + def do_GET(self): + if not self.allowed_host(): return self.reply(403,{"error":"Invalid host"}) + if self.path!="/": return self.reply(404,{"error":"Not found"}) + return self.reply(200,Path(__file__).with_name("index.html").read_bytes(),"text/html; charset=utf-8") + def do_POST(self): + if not self.allowed_host(): return self.reply(403,{"error":"Invalid host"}) + if not hmac.compare_digest(self.headers.get("Authorization",""),"Bearer "+token): + return self.reply(401,{"error":"Open Spatial9 from the plugin button or companion startup link"}) + origin=self.headers.get("Origin") + if origin and origin!=f"http://127.0.0.1:{self.server.server_port}": return self.reply(403,{"error":"Invalid origin"}) + try: + if self.headers.get_content_type()!="application/json": raise ValueError("JSON required") + size=int(self.headers.get("Content-Length","0")) + if not 0& elements) { juce::ScopedLock lock(rendererAudioElementsLock_); juce::OwnedArray elementsInternal; diff --git a/common/processors/panner/Panner3DProcessor.cpp b/common/processors/panner/Panner3DProcessor.cpp index 7c37bdb..e3976a3 100644 --- a/common/processors/panner/Panner3DProcessor.cpp +++ b/common/processors/panner/Panner3DProcessor.cpp @@ -13,6 +13,7 @@ // limitations under the License. #include "Panner3DProcessor.h" +#include "substream_rdr/surround_panner/StereoPairPanner.h" #include #include @@ -27,10 +28,10 @@ Panner3DProcessor::Panner3DProcessor( ProcessorBase* hostProcessor, AudioElementSpatialLayoutRepository* audioElementSpatialLayoutRepository, - AudioElementParameterTree* automationParameterTree) + AudioElementParameterTree* automationParameterTree, spatial9::Runtime* spatial9Runtime) : hostProcessor_(hostProcessor), audioElementSpatialLayoutData_(audioElementSpatialLayoutRepository), - automationParameterTree_(automationParameterTree) { + automationParameterTree_(automationParameterTree), spatial9Runtime_(spatial9Runtime) { outputLayout_ = audioElementSpatialLayoutRepository->get().getChannelLayout(); xPosition_ = automationParameterTree_->getXPosition(); @@ -54,10 +55,10 @@ Panner3DProcessor::~Panner3DProcessor() { void Panner3DProcessor::prepareToPlay(double sampleRate, int samplesPerBlock) { if (kIsAUBuild) { // AU Build: Use 32-sample chunks to handle variable buffer sizes - samplesPerBlock_ = std::min(samplesPerBlock, 32); + samplesPerBlock_ = std::clamp(samplesPerBlock, 1, 32); } else { // Non-AU builds (VST3, AAX, etc.): Use host buffer size directly - samplesPerBlock_ = samplesPerBlock; + samplesPerBlock_ = std::max(1, samplesPerBlock); } sampleRate_ = sampleRate; @@ -83,11 +84,13 @@ void Panner3DProcessor::initializePanning() { // And also updating the panner value tree / position renderLock.enter(); - // Note that for all speakers we are currently just using mono inputs + // Spatial9 preserves stereo as two independently panned sources. if (!isPanningEnabled || (outputLayout_ == Speakers::kMono) || (outputLayout_ == Speakers::kExplLFE)) { // If panning is disabled, we need to set the panner to NULL surroundPanner_.reset(); + } else if (inputLayout_ == Speakers::kStereo) { + surroundPanner_ = std::make_unique(outputLayout_, samplesPerBlock_, sampleRate_); } else if (outputLayout_ == Speakers::kBinaural) { // For binaural layouts, use the Binaural Panner based on the obr library surroundPanner_ = @@ -107,6 +110,8 @@ void Panner3DProcessor::initializePanning() { } outputBuffer_.setSize(outputLayout_.getNumChannels(), samplesPerBlock_); + inputChunk_.setSize(std::max(1, inputLayout_.getNumChannels()), samplesPerBlock_); + lastSetXPosition_ = lastSetYPosition_ = lastSetZPosition_ = -999.f; renderLock.exit(); hostProcessor_->suspendProcessing(false); } @@ -118,91 +123,30 @@ void Panner3DProcessor::processBlock(juce::AudioBuffer& buffer, renderLock.enter(); if (surroundPanner_ != NULL) { - // Only update position if it has changed - avoid redundant renderer updates - // This significantly reduces CPU load during automation with many callbacks - if (xPosition_ != lastSetXPosition_ || yPosition_ != lastSetYPosition_ || - zPosition_ != lastSetZPosition_) { - surroundPanner_->setPosition(xPosition_, yPosition_, zPosition_); - lastSetXPosition_ = xPosition_; - lastSetYPosition_ = yPosition_; - lastSetZPosition_ = zPosition_; + const bool feedActive = spatial9Runtime_ && spatial9Runtime_->active; + const float x = feedActive ? spatial9Runtime_->frame.x * 50.f : xPosition_.load(); + const float y = feedActive ? spatial9Runtime_->frame.y * 50.f : yPosition_.load(); + const float z = feedActive ? spatial9Runtime_->frame.z * 50.f : zPosition_.load(); + if (x != lastSetXPosition_ || y != lastSetYPosition_ || z != lastSetZPosition_) { + surroundPanner_->setPosition(x, y, z); + lastSetXPosition_ = x; lastSetYPosition_ = y; lastSetZPosition_ = z; } - - if (kIsAUBuild) { - // AU Build: Chunked processing to handle Logic Pro's variable buffer - // sizes This prevents artifacts caused by buffer size changes during - // playback - const int rendererChunkSize = samplesPerBlock_; // 32 samples for AU - - if (hostBufferSize <= rendererChunkSize) { - // Simple case: host buffer fits in one chunk - most efficient - surroundPanner_->process(buffer, outputBuffer_); - - const int copyChannels = - std::min(outputBuffer_.getNumChannels(), buffer.getNumChannels()); - for (int channel = 0; channel < copyChannels; ++channel) { - buffer.copyFrom(channel, 0, outputBuffer_, channel, 0, - hostBufferSize); - } - for (int channel = copyChannels; channel < buffer.getNumChannels(); - ++channel) { - buffer.clear(channel, 0, hostBufferSize); - } - } else { - // Chunked processing: split large buffers into 32-sample chunks - static thread_local juce::AudioBuffer chunkInput; - if (chunkInput.getNumChannels() != buffer.getNumChannels() || - chunkInput.getNumSamples() != rendererChunkSize) { - chunkInput.setSize(buffer.getNumChannels(), rendererChunkSize, false, - false, true); - } - - for (int processed = 0; processed < hostBufferSize; - processed += rendererChunkSize) { - const int chunkSize = - std::min(rendererChunkSize, hostBufferSize - processed); - - // Copy input and zero-pad if needed - for (int channel = 0; channel < buffer.getNumChannels(); ++channel) { - chunkInput.copyFrom(channel, 0, buffer, channel, processed, - chunkSize); - if (chunkSize < rendererChunkSize) { - chunkInput.clear(channel, chunkSize, - rendererChunkSize - chunkSize); - } - } - - // Process chunk through renderer - surroundPanner_->process(chunkInput, outputBuffer_); - - // Copy back processed audio - const int copyChannels = - std::min(outputBuffer_.getNumChannels(), buffer.getNumChannels()); - for (int channel = 0; channel < copyChannels; ++channel) { - buffer.copyFrom(channel, processed, outputBuffer_, channel, 0, - chunkSize); - } - // Clear any extra channels - for (int channel = copyChannels; channel < buffer.getNumChannels(); - ++channel) { - buffer.clear(channel, processed, chunkSize); - } - } - } - } else { - // Non-AU builds (VST3, AAX): Direct processing without chunking - // These formats provide constant buffer sizes, so chunking is unnecessary - surroundPanner_->process(buffer, outputBuffer_); - - const int copyChannels = - std::min(outputBuffer_.getNumChannels(), buffer.getNumChannels()); - for (int channel = 0; channel < copyChannels; ++channel) { - buffer.copyFrom(channel, 0, outputBuffer_, channel, 0, hostBufferSize); - } - for (int channel = copyChannels; channel < buffer.getNumChannels(); - ++channel) { - buffer.clear(channel, 0, hostBufferSize); - } + surroundPanner_->setStereoWidth(feedActive ? spatial9Runtime_->frame.widthDegrees : 30.f); + + // Fixed renderer chunks, including a zero-padded final partial chunk. + // Scratch is allocated in initializePanning, never in this callback. + // VST3 hosts can also deliver a block shorter than prepareToPlay's maximum. + for (int offset = 0; offset < hostBufferSize; offset += samplesPerBlock_) { + const int count = std::min(samplesPerBlock_, hostBufferSize - offset); + inputChunk_.clear(); + for (int ch = 0; ch < std::min(inputChunk_.getNumChannels(), buffer.getNumChannels()); ++ch) + inputChunk_.copyFrom(ch, 0, buffer, ch, offset, count); + surroundPanner_->process(inputChunk_, outputBuffer_); + const int channels = std::min(outputBuffer_.getNumChannels(), buffer.getNumChannels()); + for (int ch = 0; ch < channels; ++ch) + buffer.copyFrom(ch, offset, outputBuffer_, ch, 0, count); + for (int ch = channels; ch < buffer.getNumChannels(); ++ch) + buffer.clear(ch, offset, count); } } diff --git a/common/processors/panner/Panner3DProcessor.h b/common/processors/panner/Panner3DProcessor.h index b7cc8ce..d40818f 100644 --- a/common/processors/panner/Panner3DProcessor.h +++ b/common/processors/panner/Panner3DProcessor.h @@ -17,6 +17,7 @@ #pragma once #include +#include "spatial9/Plan.h" #include "../processor_base/ProcessorBase.h" #include "data_repository/implementation/AudioElementSpatialLayoutRepository.h" @@ -35,7 +36,7 @@ class Panner3DProcessor final Panner3DProcessor( ProcessorBase* hostProcessor, AudioElementSpatialLayoutRepository* audioElementSpatialLayoutRepository, - AudioElementParameterTree* automationParameterTree); + AudioElementParameterTree* automationParameterTree, spatial9::Runtime* spatial9Runtime = nullptr); ~Panner3DProcessor() override; //============================================================================== @@ -96,19 +97,18 @@ class Panner3DProcessor final AudioElementParameterTree* automationParameterTree_; juce::SpinLock renderLock; std::unique_ptr surroundPanner_; - int samplesPerBlock_; - int sampleRate_; + int samplesPerBlock_ = 512; + int sampleRate_ = 48000; Speakers::AudioElementSpeakerLayout inputLayout_; Speakers::AudioElementSpeakerLayout outputLayout_; - juce::AudioBuffer outputBuffer_; - int xPosition_ = 0; - int yPosition_ = 0; - int zPosition_ = 0; + juce::AudioBuffer outputBuffer_, inputChunk_; + spatial9::Runtime* spatial9Runtime_ = nullptr; + std::atomic xPosition_{0}, yPosition_{0}, zPosition_{0}; // Position caching for optimization - avoid redundant renderer updates - int lastSetXPosition_ = -999; - int lastSetYPosition_ = -999; - int lastSetZPosition_ = -999; + float lastSetXPosition_ = -999; + float lastSetYPosition_ = -999; + float lastSetZPosition_ = -999; //============================================================================== JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(Panner3DProcessor) diff --git a/common/spatial9/ControlServer.h b/common/spatial9/ControlServer.h new file mode 100644 index 0000000..100fad2 --- /dev/null +++ b/common/spatial9/ControlServer.h @@ -0,0 +1,75 @@ +// Copyright 2026 Spatial9. Licensed under Apache-2.0. +#pragma once +#include "JsonPlan.h" +#include +#include +#include +#ifndef _WIN32 +#include +#endif +namespace spatial9 { +// Private loopback IPC. JUCE framing, JSON payloads, per-instance capability. +// Requests run on the message thread, never the audio thread. +class ControlServer : private juce::InterprocessConnectionServer, private juce::Timer { + public: + using Handler = std::function; + explicit ControlServer(Handler handler) : handler_(std::move(handler)) { + instance_ = juce::Uuid().toString(); token_ = juce::Uuid().toString()+juce::Uuid().toString(); + auto directory = bridgeDirectory().getChildFile("instances"); + directory.createDirectory(); +#ifndef _WIN32 + ::chmod(bridgeDirectory().getFullPathName().toRawUTF8(),0700); + ::chmod(directory.getFullPathName().toRawUTF8(),0700); +#endif + if (!beginWaitingForSocket(0,"127.0.0.1")) return; + discovery_ = directory.getChildFile(instance_+".json"); + discovery_.replaceWithText(juce::JSON::toString(object({{"protocol",1},{"instance_id",instance_},{"port",getBoundPort()},{"token",token_}}))); +#ifndef _WIN32 + ::chmod(discovery_.getFullPathName().toRawUTF8(),0600); +#endif + startTimer(1000); + } + ~ControlServer() override { + stopTimer(); stop(); discovery_.deleteFile(); + const std::lock_guard guard(lock_); + for (auto& c : connections_) c->disconnect(1000); + connections_.clear(); + } + juce::String instanceId() const { return instance_; } + private: + class Connection : public juce::InterprocessConnection { + public: + explicit Connection(ControlServer& s) : juce::InterprocessConnection(true), server(s) {} + void connectionMade() override {} + void connectionLost() override { ended.store(true); } + void messageReceived(const juce::MemoryBlock& data) override { + juce::var reply; + try { + if (data.getSize()>1024*1024) throw std::invalid_argument("Request too large"); + const auto request=juce::JSON::parse(data.toString()); + if (request["token"].toString()!=server.token_ || request["instance_id"].toString()!=server.instance_) + throw std::invalid_argument("Unauthorized instance"); + reply=object({{"ok",true},{"result",server.handler_(request)}}); + } catch (const std::exception& e) { reply=object({{"ok",false},{"error",juce::String(e.what())}}); } + const auto text=juce::JSON::toString(reply,true); sendMessage(juce::MemoryBlock(text.toRawUTF8(),text.getNumBytesAsUTF8())); + } + std::atomic ended{false}; + private: ControlServer& server; + }; + juce::InterprocessConnection* createConnectionObject() override { + const std::lock_guard guard(lock_); + if (connections_.size()>=64) return nullptr; + auto c=std::make_unique(*this); auto* ptr=c.get(); connections_.push_back(std::move(c)); return ptr; + } + void timerCallback() override { + const std::lock_guard guard(lock_); + // Both this timer and connectionLost run on the message thread. + connections_.erase(std::remove_if(connections_.begin(),connections_.end(),[](const auto& c){return c->ended.load();}),connections_.end()); + } + Handler handler_; + juce::String instance_,token_; + juce::File discovery_; + std::mutex lock_; + std::vector> connections_; +}; +} // namespace spatial9 diff --git a/common/spatial9/JsonPlan.h b/common/spatial9/JsonPlan.h new file mode 100644 index 0000000..b7f8944 --- /dev/null +++ b/common/spatial9/JsonPlan.h @@ -0,0 +1,47 @@ +// Copyright 2026 Spatial9. Licensed under Apache-2.0. +#pragma once +#include "Plan.h" +#include +namespace spatial9 { +inline juce::var object(std::initializer_list> fields) { + auto* o = new juce::DynamicObject(); for (const auto& f : fields) o->setProperty(f.first,f.second); return o; +} +inline double number(const juce::var& o, const char* key) { + const auto v = o[juce::Identifier(key)]; + if (!(v.isDouble() || v.isInt() || v.isInt64())) throw std::invalid_argument(std::string("Missing numeric field: ")+key); + return static_cast(v); +} +inline Plan parsePlan(const juce::var& o) { + if (!(o["schema_version"].isInt() || o["schema_version"].isInt64()) || (int)o["schema_version"] != 1 || o["coordinate_system"].toString() != "eclipsa_normalized_xyz") + throw std::invalid_argument("Unsupported schema or coordinate system"); + Plan p; + const auto timebase = o["timebase"].toString(); + if (timebase != "seconds" && timebase != "beats") throw std::invalid_argument("Unknown timebase"); + p.beats = timebase == "beats"; + const auto interpolation = o["interpolation"].toString(); + if (interpolation != "linear" && interpolation != "smoothstep") throw std::invalid_argument("Unknown interpolation"); + p.smooth = interpolation == "smoothstep"; + const auto* keys = o["keyframes"].getArray(); + if (!keys || keys->isEmpty() || keys->size() > (int)maxKeys) throw std::invalid_argument("Invalid keyframes"); + for (const auto& k : *keys) { + if (!k["muted"].isBool()) throw std::invalid_argument("muted must be boolean"); + p.keys[p.count++] = {number(k,"time"), {(float)number(k,"x"),(float)number(k,"y"),(float)number(k,"z"), + (float)number(k,"gain_db"),(float)number(k,"stereo_width_deg"),(bool)k["muted"]}}; + } + validate(p); return p; +} +inline juce::var frameJson(const Frame& f) { + return object({{"x",f.x},{"y",f.y},{"z",f.z},{"gain_db",f.gainDb},{"stereo_width_deg",f.widthDegrees},{"muted",f.muted}}); +} +inline juce::var planJson(const Plan& p) { + juce::Array keys; + for (std::size_t i=0;isetProperty("time",p.keys[i].time); keys.add(k); } + return object({{"schema_version",1},{"coordinate_system","eclipsa_normalized_xyz"},{"timebase",p.beats?"beats":"seconds"}, + {"interpolation",p.smooth?"smoothstep":"linear"},{"keyframes",keys}}); +} +inline juce::File bridgeDirectory() { + const auto overridePath=juce::SystemStats::getEnvironmentVariable("SPATIAL9_BRIDGE_DIR",""); + if(overridePath.isNotEmpty()) return juce::File(overridePath); + return juce::File::getSpecialLocation(juce::File::userHomeDirectory).getChildFile(".spatial9-eclipsa"); +} +} // namespace spatial9 diff --git a/common/spatial9/OpenBridge.h b/common/spatial9/OpenBridge.h new file mode 100644 index 0000000..2de0b4d --- /dev/null +++ b/common/spatial9/OpenBridge.h @@ -0,0 +1,17 @@ +// Copyright 2026 Spatial9. Licensed under Apache-2.0. +#pragma once +#include "JsonPlan.h" +#include +namespace spatial9 { +inline void openBridge() { + const auto config=juce::JSON::parse(bridgeDirectory().getChildFile("ui.json")); + const int port=(int)config["port"]; + const auto token=config["token"].toString(); + if(port>0 && port<65536 && token.containsOnly("0123456789abcdef") && token.length()==64) { + juce::URL("http://127.0.0.1:"+juce::String(port)+"/#"+token).launchInDefaultBrowser(); + } else { + juce::AlertWindow::showMessageBoxAsync(juce::MessageBoxIconType::InfoIcon,"Spatial9 Bridge", + "Start the included Spatial9 companion first:\npython -m spatial9_bridge ui\n\nThen click Spatial9 again. Accepted plans keep playing without the companion."); + } +} +} diff --git a/common/spatial9/Plan.h b/common/spatial9/Plan.h new file mode 100644 index 0000000..a90840d --- /dev/null +++ b/common/spatial9/Plan.h @@ -0,0 +1,84 @@ +// Copyright 2026 Spatial9. Licensed under Apache-2.0. +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace spatial9 { +constexpr std::size_t maxKeys = 2048; +struct Frame { + float x = 0, y = 1, z = 0, gainDb = 0, widthDegrees = 30; + bool muted = false; +}; +struct Key { double time = 0; Frame value; }; +struct Plan { + std::array keys{}; + std::size_t count = 0; + bool beats = false; + bool smooth = true; +}; +inline void validate(const Plan& p) { + if (p.count == 0 || p.count > maxKeys) throw std::invalid_argument("Invalid keyframe count"); + for (std::size_t i = 0; i < p.count; ++i) { + const auto& k = p.keys[i]; const auto& f = k.value; + if (!std::isfinite(k.time) || k.time < 0 || k.time > 1e12 || (i && k.time <= p.keys[i-1].time)) + throw std::invalid_argument("Keyframe times must increase strictly and be nonnegative"); + for (float v : {f.x, f.y, f.z}) + if (!std::isfinite(v) || std::abs(v) > 1) throw std::invalid_argument("XYZ must be finite and within [-1,1]"); + if (!std::isfinite(f.gainDb) || f.gainDb < -100 || f.gainDb > 12) + throw std::invalid_argument("Gain must be between -100 and 12 dB"); + if (!std::isfinite(f.widthDegrees) || f.widthDegrees < 0 || f.widthDegrees > 180) + throw std::invalid_argument("Stereo width must be between 0 and 180 degrees"); + } +} +inline Frame evaluate(const Plan& p, double t) noexcept { + if (!p.count) return {}; + if (!std::isfinite(t) || t <= p.keys[0].time) return p.keys[0].value; + if (t >= p.keys[p.count-1].time) return p.keys[p.count-1].value; + std::size_t lo = 0, hi = p.count - 1; + while (hi - lo > 1) { const auto m = (hi + lo)/2; if (p.keys[m].time <= t) lo = m; else hi = m; } + const auto& a = p.keys[lo]; const auto& b = p.keys[hi]; + float u = static_cast((t-a.time)/(b.time-a.time)); + if (p.smooth) u = u*u*(3-2*u); + auto lerp = [u](float x, float y) { return x + (y-x)*u; }; + return {lerp(a.value.x,b.value.x), lerp(a.value.y,b.value.y), lerp(a.value.z,b.value.z), + lerp(a.value.gainDb,b.value.gainDb), lerp(a.value.widthDegrees,b.value.widthDegrees), a.value.muted}; +} +// One audio reader. Serialized non-realtime writers. No allocations, locks or +// reference-count destruction on the audio reader. Fixed-size triple buffer. +class PlanStore { + public: + void publish(const Plan& p) { + std::lock_guard guard(writer_); + const int current = published_.load(); const int reading = reading_.load(); + int next = 0; while (next == current || next == reading) ++next; + slots_[next] = p; published_.store(next); + } + Plan copy() const { std::lock_guard guard(writer_); return slots_[published_.load()]; } + Frame sample(double seconds, double beats, bool& valid) noexcept { + int slot; + do { slot = published_.load(); reading_.store(slot); } while (slot != published_.load()); + const auto& p = slots_[slot]; + const double time = p.beats ? beats : seconds; + valid = p.count != 0 && (p.count == 1 || std::isfinite(time)); + const Frame result = valid ? evaluate(p,time) : Frame{}; + reading_.store(-1); return result; + } + private: + std::array slots_{}; + std::atomic published_{0}, reading_{-1}; + mutable std::mutex writer_; +}; +struct Runtime { + PlanStore plans; + std::atomic enabled{false}; + std::atomic revision{0}; + // Only the audio callback writes/reads these fields. + Frame frame; + bool active = false; +}; +} // namespace spatial9 diff --git a/common/spatial9/SourceController.h b/common/spatial9/SourceController.h new file mode 100644 index 0000000..f93c071 --- /dev/null +++ b/common/spatial9/SourceController.h @@ -0,0 +1,53 @@ +// Copyright 2026 Spatial9. Licensed under Apache-2.0. +#pragma once +#include "ControlServer.h" +#include "data_structures/src/AudioElementParameterTree.h" +namespace spatial9 { +class SourceController : private juce::AudioProcessorValueTreeState::Listener { + public: + SourceController(Runtime& runtime, AudioElementParameterTree& params, + std::function describe, std::function validateTarget) + : runtime_(runtime),params_(params),describe_(std::move(describe)),validateTarget_(std::move(validateTarget)) { + for (const auto& id : ids()) params_.addParameterListener(id,this); + server_=std::make_unique([this](const juce::var& r){return handle(r);}); + } + ~SourceController() override { server_.reset(); for (const auto& id:ids()) params_.removeParameterListener(id,this); } + juce::var state() const { auto o=object({{"enabled",runtime_.enabled.load()}}); auto p=runtime_.plans.copy(); + if(p.count) o.getDynamicObject()->setProperty("plan",planJson(p)); return o; } + void restore(const juce::var& o) { + Plan p; if(o.hasProperty("plan")) p=parsePlan(o["plan"]); + runtime_.enabled.store(false); runtime_.plans.publish(p); runtime_.enabled.store(p.count && (bool)o["enabled"]); ++runtime_.revision; + } + private: + static std::array ids() { return {"X","Y","Z","PannerVolume","PannerMute"}; } + void parameterChanged(const juce::String&,float) override { + // Manual edits or host automation always win; explicitly re-apply to resume. + runtime_.enabled.store(false); ++runtime_.revision; + } + juce::var scene() const { + auto result=describe_(); auto* data=result.getDynamicObject(); + if(!data) throw std::invalid_argument("Source descriptor unavailable"); + data->setProperty("revision",(juce::int64)runtime_.revision.load()); + data->setProperty("position",frameJson({params_.getXPosition()/50.f,params_.getYPosition()/50.f,params_.getZPosition()/50.f, + params_.getVolume(),30,!params_.getUnmute()})); + data->setProperty("state",state());return result; + } + juce::var handle(const juce::var& r) { + const auto op=r["operation"].toString(); + if(op=="get_scene") return scene(); + if (number(r,"expected_revision")!=(double)runtime_.revision.load()) throw std::invalid_argument("Stale source revision; refresh the scene"); + if(op=="apply_plan" || op=="validate_plan") { + const auto p=parsePlan(r["plan"]); + validateTarget_(); + if(op=="apply_plan") { runtime_.enabled.store(false);runtime_.plans.publish(p);runtime_.enabled.store(true);++runtime_.revision; } + } else if(op=="restore_snapshot") restore(r["state"]); + else if(op=="bypass") {runtime_.enabled.store(false);++runtime_.revision;} + else throw std::invalid_argument("Unknown operation"); + return scene(); + } + Runtime& runtime_;AudioElementParameterTree& params_; + std::function describe_; + std::function validateTarget_; + std::unique_ptr server_; +}; +} // namespace spatial9 diff --git a/common/substream_rdr/surround_panner/AudioPanner.h b/common/substream_rdr/surround_panner/AudioPanner.h index 1f6bfe4..d5d4613 100644 --- a/common/substream_rdr/surround_panner/AudioPanner.h +++ b/common/substream_rdr/surround_panner/AudioPanner.h @@ -67,7 +67,7 @@ class AudioPanner { if (radius < epsilon && std::abs(normZ) < epsilon) { return {0, 0.f, 0.f}; } else if (radius < epsilon && std::abs(normZ) >= epsilon) { - return {0, normZ > 0.f ? 90.f : -90.f, 0.f}; + return {0, normZ > 0.f ? 90.f : -90.f, std::abs(normZ)}; } // calculate azimuth @@ -87,11 +87,13 @@ class AudioPanner { // convert to degrees float elevation = std::atan(normZ / radius) * 180.f / M_PI; - return {azimuth, elevation, radius}; + return {azimuth, elevation, std::sqrt(radius * radius + normZ * normZ)}; } ear::PolarPosition getPosition() const { return currPos_; } + virtual void setStereoWidth(float) {} + virtual void process(juce::AudioBuffer& inputBuffer, juce::AudioBuffer& outputBuffer) = 0; diff --git a/common/substream_rdr/surround_panner/StereoPairPanner.h b/common/substream_rdr/surround_panner/StereoPairPanner.h new file mode 100644 index 0000000..535f6aa --- /dev/null +++ b/common/substream_rdr/surround_panner/StereoPairPanner.h @@ -0,0 +1,44 @@ +// Copyright 2026 Spatial9. Licensed under Apache-2.0. +#pragma once +#include +#include +#include "AmbisonicPanner.h" +#include "BinauralPanner.h" +#include "MonoToSpeakerPanner.h" +// A stereo input remains two sources. Width is angular separation; each side +// receives -3.01 dB headroom. No right-channel truncation or implicit downmix. +class StereoPairPanner final : public AudioPanner { + public: + StereoPairPanner(Speakers::AudioElementSpeakerLayout layout,int block,int rate) + : AudioPanner(layout,block,rate),input_(1,block),leftOutput_(layout.getNumChannels(),block),rightOutput_(layout.getNumChannels(),block) { + auto make=[&]() -> std::unique_ptr { + if(layout==Speakers::kBinaural) return std::make_unique(block,rate); + if(layout.isAmbisonics()) return std::make_unique(layout,block,rate); + return std::make_unique(layout,block,rate); + }; + left_=make();right_=make(); + } + void setStereoWidth(float degrees) override { const auto w=std::clamp(degrees,0.f,180.f);if(w!=width_){width_=w;positionUpdated();} } + void positionUpdated() override { + auto position=[&](AudioPanner& p,float offset) { + const float a=(currPos_.azimuth+offset)*(float)M_PI/180.f; + const float e=currPos_.elevation*(float)M_PI/180.f; + const float d=static_cast(std::max(currPos_.distance,0.001))*50.f; + p.setPosition(-d*std::cos(e)*std::sin(a),d*std::cos(e)*std::cos(a),d*std::sin(e)); + }; + position(*left_,width_/2);position(*right_,-width_/2); + } + void process(juce::AudioBuffer& input,juce::AudioBuffer& output) override { + input_.copyFrom(0,0,input,0,0,kSamplesPerBlock_);left_->process(input_,leftOutput_); + input_.copyFrom(0,0,input,1,0,kSamplesPerBlock_);right_->process(input_,rightOutput_); + output.clear(); + for(int c=0;c left_,right_; + juce::AudioBuffer input_,leftOutput_,rightOutput_; + float width_=30; +}; diff --git a/docs/spatial9/BUILD_AND_PUBLISH.md b/docs/spatial9/BUILD_AND_PUBLISH.md new file mode 100644 index 0000000..571bd95 --- /dev/null +++ b/docs/spatial9/BUILD_AND_PUBLISH.md @@ -0,0 +1,121 @@ +# Build, publish and release + +## Contents of the source distribution + +The source archive contains the entire patched upstream plugin tree, the new +companion/tests/docs, and the JUCE/Spatial Audio Framework sources. It excludes +Git metadata, build outputs, caches and credentials. Large upstream Git LFS +objects are represented by their original pointers; the bootstrap downloads and +SHA-256 verifies their exact contents. This is a source distribution, not an +offline dependency bundle and not a compiled plugin installer. + +`scripts/spatial9/dependencies.json` pins the upstream commit, JUCE and SAF commits +and all 26 upstream LFS objects. Other CMake dependencies retain upstream's +fetch/build definitions. Internet access is needed; this is not a completely +hermetic or reproducibly identical binary build. + +## Preferred GitHub route: fork upstream and apply the patch + +1. Fork `https://github.com/google/eclipsa-audio-plugin` into your GitHub account. +2. Clone that fork, make a working branch from the pinned upstream commit, and + apply the supplied `spatial9-integration.patch` (included at the source archive + root). It is an alternative to copying the already patched source, not an + additional patch to apply to this archive. + +```sh +git clone YOUR_FORK_URL spatial9-eclipsa +cd spatial9-eclipsa +git checkout -b spatial9-integration db210550eb273cb4ea08ae2e923ca46fc1b9c9d5 +git apply --check /path/to/spatial9-integration.patch +git apply /path/to/spatial9-integration.patch +python scripts/spatial9/bootstrap.py +python -m pip install ./bridge +``` + +3. Review the diff, retain the upstream license and notices, commit the changes + and push your branch. The bootstrap applies a small JUCE compatibility patch + locally; do not accidentally commit the changed submodule as a new gitlink. + The bootstrap reapplies it in CI. `git add` the first-party changes, not the + JUCE submodule. Large binary objects come from the upstream LFS manifest. +4. Run **Spatial9 Core and Bridge Tests**. Run **Spatial9 Native Builds** using + its manual dispatch on your branch, or open a PR to `main`. Allow GitHub Actions + for the fork if GitHub has disabled workflows by default. +5. Read the native build logs and fix any compile/link/test failures before a + release. Successful jobs upload development ZIPs; a workflow file alone is + not evidence of successful compilation. + +Alternatively initialize a new repository from the extracted source tree. +Commit the included dependency source directories as ordinary folders (no nested +`.git` directories are included). Keep `.gitattributes` and LFS installed. The +workflow checkout uses `lfs: false`; the bootstrap obtains exact upstream LFS +objects even when they are not hosted by your new repository. + +Nothing has been pushed or published to your GitHub account by this work. + +## Native targets and dependencies + +| Target | CI | Current scope | +| --- | --- | --- | +| Windows | `windows-2022`, x64 | VST3; Visual Studio 2022, pinned vcpkg baseline, Intel MKL, CMake 3.31 and FFmpeg. | +| macOS | `macos-15`, arm64 | VST3 + AU, deployment target 15.0; Apple Accelerate, CMake 3.31 and FFmpeg. | +| Intel Mac / universal | Not configured | Rebuild all ARM64-only vendor dependencies for Intel first. | +| AAX | Not configured | Requires separate SDK/signing/host work. | + +The native workflow derives dependency installation and test steps from upstream +but enables Mac compilation, upgrades cache/checkout actions, pins vcpkg, and +packages actual Spatial9 bundles only. It currently requests a clean build. +Its MKL installer and third-party download availability still need a native run. + +Mac uses the current Apple linker instead of `ld_classic`. The pinned WithACX +JUCE fork contains host-specific changes; it is not silently replaced with a +new JUCE release. Bootstrap disables only its unused legacy NSWindow screenshot +helper when compiling with macOS 15+ SDKs. This compatibility change also needs +native verification. All these changes are inspectable in the supplied source. + +For local native builds use the configure arguments and dependency steps in +`.github/workflows/spatial9-native.yml`. Set `JUCE_COPY_PLUGIN_AFTER_BUILD=OFF` +while developing. Do not use the upstream package/install scripts without +adapting their hardcoded upstream product names to this fork. + +`python scripts/spatial9/package_binaries.py build dist` refuses nonnative +platforms or missing bundles. It archives only built Spatial9 `.vst3`/`.component` +bundles and labels them as unsigned development builds. It does not manufacture +binaries from dependency DLLs or claim signing/notarization. The Python companion +must currently be installed separately; a bundled desktop app/installer is +future packaging work. + +## Production release gate + +- Complete real Data Feed auth, contract and error-path integration; verify the + service's account-wide quota separately from the local one-QPS limiter. +- Obtain successful Windows and Mac build/test artifacts, inspect their actual + architecture/dependencies, and run plugin validation (including AU validation). +- DAW tests: REAPER on Windows/macOS first; Logic Pro for AU; other DAWs according + to the supported product matrix. Test mono and genuinely different L/R stereo, + automation ownership, loop/seek, tempo changes, save/reopen, offline render, + missing companion, expired credentials, session shutdown and instance deletion. +- Inspect output channel order, coordinate handedness, loudness, partial blocks, + different sample rates, interpolation, panner load and IAMF exports. +- Confirm supported OS versions with a clean machine, including runtime DLLs, + macOS dylib install names/rpaths, and FFmpeg dependency behavior. +- For public Mac downloads, sign nested code/bundles with Developer ID, notarize + and staple the deliverable. Provide Apple identity, Team ID and notarization + credentials through GitHub Secrets or the signing system. Windows code-signing + identity and installer policy are a separate choice. No signing secrets are + needed to inspect the source or run the portable tests. + +## Portable checks + +```sh +python -m pip install ./bridge +cmake -S tests/spatial9 -B build-spatial9-tests -DCMAKE_BUILD_TYPE=Release +cmake --build build-spatial9-tests +ctest --test-dir build-spatial9-tests --output-on-failure +python -m unittest discover -s bridge/tests -v +python bridge/tests/mcp_smoke.py +python bridge/tests/http_smoke.py +``` + +`bridge/tests/native_interop.py /path/to/native_smoke` additionally tests the real +JUCE loopback wire format. `tests/spatial9/native_smoke.cpp` is the small native +server used for that check; it is not a compiled audio plugin. diff --git a/docs/spatial9/DATA_FEED_CONTRACT.md b/docs/spatial9/DATA_FEED_CONTRACT.md new file mode 100644 index 0000000..61d2f6c --- /dev/null +++ b/docs/spatial9/DATA_FEED_CONTRACT.md @@ -0,0 +1,82 @@ +# Proposed Data Feed contract, version 1 + +**This contract is implemented by the companion as an integration proposal. It +has not been confirmed against Spatial9's newer production Data Feed.** Provide +the actual endpoint/schema/auth details to adapt it. + +The plugin does not call the internet. The companion posts JSON to the HTTPS URL +in `SPATIAL9_FEED_URL`, with optional `Authorization: Bearer` from +`SPATIAL9_FEED_API_KEY`. Redirects are rejected so credentials are not forwarded +to a different origin. Timeout is 30 seconds; response cap is 1 MiB. No raw audio, +waveform, embedding, instrument analysis or DAW file is uploaded by this adapter. + +## Request + +| Field | Meaning | +| --- | --- | +| `schema_version` | Integer `1`. | +| `brief` | User's creative instruction, 1–8000 characters. | +| `session_id` | Runtime renderer identity, not a persistent project key. | +| `scene_revision` | Opaque SHA-256 revision for the exact scene snapshot. Echo unchanged. | +| `sources` | Source ID, plugin runtime instance ID, track name, input channels, panning flag, elevation mode, source revision, manual position, accepted plan state. | +| `capabilities` | Coordinate system, max 2048 keys, mono/stereo support, effects advisory-only. | + +Source IDs are stable only for the loaded plugin/session. Upstream intentionally +creates new source identities on project reopening; accepted plans are saved in +plugin state, independently of those IDs. Never reuse old proposals after recall. +Names can imply an instrument but are not verified audio analysis. A Data Feed +requiring spectral/features/audio input needs additional capture and consent work. + +## Response + +Return a JSON proposal conforming to `proposal.schema.json`. The included +`example-proposal.json` uses placeholder IDs/revision and therefore cannot be +applied until it is populated from a live scene. It is an example of the format, +not an inference result. + +Each source plan specifies `coordinate_system: "eclipsa_normalized_xyz"`: +X right, Y front, Z up, each in [-1,1]. Native panner values are normalized values +multiplied by 50. This is the current Eclipsa cube convention, not metres. For +ADM-style azimuth, positive azimuth points left, so use +`x=-r*cos(elevation)*sin(azimuth)`, `y=r*cos(elevation)*cos(azimuth)`, +`z=r*sin(elevation)` and validate the resulting normalized coordinates. +The radius is the full 3D radius; do not normalize each vector automatically. + +A plan has 1–2048 increasing nonnegative keyframe times (up to 1e12), XYZ, gain in +[-100,12] dB, stereo width in [0,180] degrees, and a boolean mute. Timebase is +absolute DAW `seconds` or `beats` (host PPQ). The first key holds before its time; +the last key holds afterward. `linear` or `smoothstep` interpolation affects +position, gain and width. Mute holds until the next key. One key is static and +works without a playhead; dynamic plans bypass when their timebase is missing. + +Position/width are evaluated once per host audio block; gain ramps over that +block. This is not sample-accurate automation or DAW automation-lane writing. +Stereo is rendered as two mono sources at plus/minus half the angular width, +each with -3.01 dB headroom; this is not a claim of identical stereo loudness or +stereo image for every signal/host. Listening and export tests remain required. + +## Ownership and failure behavior + +Preview validates against a fresh scene. Apply rechecks its revision and all +targets. It changes no channel routing, plugin insertion or DAW track structure. +Only mono/stereo inputs with panning enabled, a spatial output layout (not mono/LFE), and elevation `None` are accepted. +Manual XYZ/gain/mute edits or DAW automation bypass the cached plan; layout, +panning or elevation changes also bypass it. Reapply explicitly to resume. + +All-source preflight is followed by per-source acknowledgement. This is not a +cross-plugin atomic transaction. An apply failure attempts to restore previously +acknowledged sources; an interrupted acknowledgement leaves the last attempted +source's outcome uncertain and the error instructs the user to inspect it. There +is no automatic retry of a mutation. Undo refuses to overwrite later revisions; +a partial restore reports failed tracks. Preview/undo history is in memory only. + +One QPS means at most one outgoing recommendation request per second from this +companion process, across its sessions. It does not represent model latency or +sample rate, and does not enforce account-wide paid/free quotas. Numeric +`Retry-After` on HTTP 429 is honored (1–3600 seconds); account entitlement and +paid tiers must be enforced by the real backend. HTTP errors surface to the user; +there is no fabricated fallback recommendation. + +`effect_suggestions` is bounded text for the user. No third-party effects are +inserted or controlled. Native effects processing and DAW-specific plugin control +would require a separate typed effect model, validated adapters and host support. diff --git a/docs/spatial9/VALIDATION.md b/docs/spatial9/VALIDATION.md new file mode 100644 index 0000000..5498219 --- /dev/null +++ b/docs/spatial9/VALIDATION.md @@ -0,0 +1,72 @@ +# Validation record and open work + +Baseline: upstream `db210550eb273cb4ea08ae2e923ca46fc1b9c9d5`. +Implementation work and portable verification: 2026-11-13, Linux x86_64. + +## Completed checks + +- Standard C++20 plan tests built with g++ 14.2, Release assertions enabled: + finite/bounded inputs, strict timing, interpolation, seeks, timebase handling, + and concurrent plan publication (20,000 writes). CTest passed. +- 13 Python bridge tests passed: preview/apply/undo, stale scene rejection, + rollback of acknowledged changes, preserving manual edits, duplicate/unknown + IDs, invalid floats/bools/timestamps/counts, capability gating, rename revision, + explicit missing-service error, defensive proposal copy and advisory effects. +- JUCE Core/Events native server compiled on Linux. Python-to-native IPC test + passed authentication, framing, JSON plan round trip, malformed value rejection, + native shutdown and discovery cleanup. This is not a plugin-host test. +- New SourceController/OpenBridge headers syntax-checked against the actual + pinned JUCE headers on Linux. Full plugin translation units were not compiled; + a direct panner syntax attempt requires build-generated protobuf headers. +- Official Python MCP SDK 1.29.0 smoke: stdio initialize, list eight tool schemas, + reject a tool outside the cloud allowlist. No authenticated cloud render ran. +- Actual local HTTP server smoke passed startup/UI, bearer auth, Host/Origin + rejection and authorized empty-session discovery. +- Dependency bootstrap completed: pinned JUCE/SAF sources, all 26 upstream LFS + hashes verified, and the macOS JUCE compatibility patch applied. +- New workflow YAML parsed; patch and source archive integrity checked before + delivery. Native workflow jobs have not run. + +## Not completed / release blockers + +- Windows/MSVC and macOS/Xcode complete compile, link and tests; compiled VST3/AU + deliverables; plugin validators; real DAW playback and offline-render testing. +- Data Feed production endpoint/schema/auth compatibility, real inference, + account quotas, latency/error behavior and cloud MCP token compatibility. +- Public installers, packaged companion executable, signing and notarization. +- Full stereo/DSP regression and audio-quality validation, including right-only + audio, correlated stereo, gain/mute changes, radius geometry, short/large host + blocks, partial chunk boundaries, sample rates and IAMF output. +- Intel Mac/universal builds and AAX/Pro Tools integration. + +## Intentional preview limitations + +- One active Spatial9 Renderer/session per computer; legacy renderer IPC uses + a fixed port (32134), although source control servers use ephemeral ports. + Running multiple DAWs with separate Spatial9 renderers is not supported yet. +- The existing Eclipsa stage visuals/APVTS telemetry reflect manual parameters, + not an animated mirror of cached-plan positions. The bridge exposes the saved + plan. Native visual synchronization is still needed for a polished release. +- Motion is block-rate, with gain ramping per block. Final partial renderer chunks + are zero-padded; timing/artifacts of stateful renderers need host validation. + The complete inherited audio path still contains locking/reconfiguration. +- Local apply/undo is acknowledgement-based, not atomic across sources. A + disconnect after a mutation may leave its outcome uncertain. Inspect the + reported source before retrying; automatic mutation retry is not implemented. +- Undo/preview cache is bounded and memory-only. Accepted plans persist in DAW + state; runtime identity and in-memory undo history do not survive reopening. +- Manual/automated position, gain and mute edits bypass a plan. No automation-lane + export, automation merge, write/latch mode or sample-accurate motion is included. +- UI is a separate local browser view with a button in the native editor. A fully + embedded native recommendation/motion editor and onboarding is not included. +- The service receives metadata and a brief only. There is no audio feature + extractor, stem capture, audio upload or automatic instrument recognition here. +- Effects remain advice. MCP cloud tools operate on cloud projects; downloaded + results are not automatically imported or connected to local DAW sources. +- The local companion uses Python and a pinned MCP SDK, not a bundled desktop + executable. Interactive OAuth login and refresh remain to be implemented. + +This code is suitable for engineering review and native integration testing. +It is not represented as production-ready, signed, or ready for public binary +release. Publishing the source as an explicitly marked development branch is a +separate choice from releasing installable plugins to customers. diff --git a/docs/spatial9/example-proposal.json b/docs/spatial9/example-proposal.json new file mode 100644 index 0000000..5e1ad5a --- /dev/null +++ b/docs/spatial9/example-proposal.json @@ -0,0 +1,39 @@ +{ + "schema_version": 1, + "session_id": "REPLACE_WITH_LIVE_SESSION_ID", + "scene_revision": "0000000000000000000000000000000000000000000000000000000000000000", + "sources": [ + { + "source_id": "REPLACE_WITH_LIVE_SOURCE_ID", + "plan": { + "schema_version": 1, + "coordinate_system": "eclipsa_normalized_xyz", + "timebase": "beats", + "interpolation": "smoothstep", + "keyframes": [ + { + "time": 0, + "x": 0, + "y": 1, + "z": 0, + "gain_db": -3, + "stereo_width_deg": 30, + "muted": false + }, + { + "time": 16, + "x": 0.5, + "y": 0.5, + "z": 0.7, + "gain_db": -3, + "stereo_width_deg": 60, + "muted": false + } + ] + } + } + ], + "effect_suggestions": [ + "Consider a subtle room reverb; no effect will be applied by this proposal." + ] +} diff --git a/docs/spatial9/proposal.schema.json b/docs/spatial9/proposal.schema.json new file mode 100644 index 0000000..db37a10 --- /dev/null +++ b/docs/spatial9/proposal.schema.json @@ -0,0 +1,140 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Proposed Spatial9 Eclipsa proposal v1", + "type": "object", + "required": [ + "schema_version", + "session_id", + "scene_revision", + "sources" + ], + "properties": { + "schema_version": { + "type": "integer", + "const": 1 + }, + "session_id": { + "type": "string", + "minLength": 1 + }, + "scene_revision": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "sources": { + "type": "array", + "minItems": 1, + "maxItems": 128, + "items": { + "type": "object", + "required": [ + "source_id", + "plan" + ], + "properties": { + "source_id": { + "type": "string", + "minLength": 1 + }, + "plan": { + "$ref": "#/$defs/plan" + } + } + } + }, + "effect_suggestions": { + "type": "array", + "maxItems": 128, + "items": { + "type": "string", + "maxLength": 2000 + } + } + }, + "$defs": { + "plan": { + "type": "object", + "required": [ + "schema_version", + "coordinate_system", + "timebase", + "interpolation", + "keyframes" + ], + "properties": { + "schema_version": { + "const": 1, + "type": "integer" + }, + "coordinate_system": { + "const": "eclipsa_normalized_xyz" + }, + "timebase": { + "enum": [ + "seconds", + "beats" + ] + }, + "interpolation": { + "enum": [ + "linear", + "smoothstep" + ] + }, + "keyframes": { + "type": "array", + "minItems": 1, + "maxItems": 2048, + "items": { + "type": "object", + "required": [ + "time", + "x", + "y", + "z", + "gain_db", + "stereo_width_deg", + "muted" + ], + "properties": { + "x": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "y": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "z": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "time": { + "type": "number", + "minimum": 0, + "maximum": 1000000000000.0 + }, + "gain_db": { + "type": "number", + "minimum": -100, + "maximum": 12 + }, + "stereo_width_deg": { + "type": "number", + "minimum": 0, + "maximum": 180 + }, + "muted": { + "type": "boolean" + } + } + } + } + } + } + }, + "$comment": "Runtime additionally enforces strictly increasing key times, finite numbers, known unique sources, capability limits and exact live scene revision." +} diff --git a/rendererplugin/CMakeLists.txt b/rendererplugin/CMakeLists.txt index bb3ae43..0709ff6 100644 --- a/rendererplugin/CMakeLists.txt +++ b/rendererplugin/CMakeLists.txt @@ -16,11 +16,11 @@ # Set plugin code and name based on build type #==================================================================== if (ECLIPSA_LOGIC_PRO_BUILD) - set(RENDERER_PLUGIN_CODE "Ercl") - set(RENDERER_PRODUCT_NAME "Eclipsa Audio Renderer for Logic Pro") + set(RENDERER_PLUGIN_CODE "S9rl") + set(RENDERER_PRODUCT_NAME "Spatial9 Eclipsa Renderer for Logic Pro") else () - set(RENDERER_PLUGIN_CODE "Ecrd") - set(RENDERER_PRODUCT_NAME "Eclipsa Audio Renderer") + set(RENDERER_PLUGIN_CODE "S9rd") + set(RENDERER_PRODUCT_NAME "Spatial9 Eclipsa Renderer") endif () juce_add_plugin(RendererPlugin @@ -88,7 +88,7 @@ endif () #==================================================================== set(_ARTEFACTS_BASE "${CMAKE_BINARY_DIR}/rendererplugin/RendererPlugin_artefacts") -set(_PLUGIN_NAME "Eclipsa Audio Renderer") +set(_PLUGIN_NAME "Spatial9 Eclipsa Renderer") if (TARGET RendererPlugin_AAX) copy_resources(RendererPlugin_AAX "${_ARTEFACTS_BASE}/$/AAX/${_PLUGIN_NAME}.aaxplugin") diff --git a/rendererplugin/src/RendererEditor.cpp b/rendererplugin/src/RendererEditor.cpp index 0d6d195..3235b0b 100644 --- a/rendererplugin/src/RendererEditor.cpp +++ b/rendererplugin/src/RendererEditor.cpp @@ -13,6 +13,7 @@ // limitations under the License. #include "RendererEditor.h" +#include "spatial9/OpenBridge.h" #include "components/src/DAWWarningBanner.h" #include "components/src/EclipsaColours.h" @@ -211,6 +212,9 @@ void RendererEditor::paint(juce::Graphics& g) { versionBounds.removeFromBottom(5); auto bottomStrip = versionBounds.removeFromBottom(15); versionLabel_.setBounds(bottomStrip.removeFromLeft(150)); + addAndMakeVisible(spatial9Button_); + spatial9Button_.setBounds(bottomStrip.removeFromRight(90)); + spatial9Button_.onClick = [] { spatial9::openBridge(); }; } void RendererEditor::resized() { diff --git a/rendererplugin/src/RendererEditor.h b/rendererplugin/src/RendererEditor.h index f2110ca..14f5403 100644 --- a/rendererplugin/src/RendererEditor.h +++ b/rendererplugin/src/RendererEditor.h @@ -57,6 +57,7 @@ class RendererEditor final : public MainEditor { juce::Label titleLabel_; juce::Label versionLabel_; + juce::TextButton spatial9Button_{"Spatial9"}; DAWWarningBanner dawWarningBanner_; ExportErrorBanner exportErrorBanner_; LoudnessWarningBanner loudnessWarningBanner_; diff --git a/rendererplugin/src/RendererPluginSyncServer.h b/rendererplugin/src/RendererPluginSyncServer.h index 24713bb..493e764 100644 --- a/rendererplugin/src/RendererPluginSyncServer.h +++ b/rendererplugin/src/RendererPluginSyncServer.h @@ -134,6 +134,7 @@ class RendererPluginSyncServer : public juce::InterprocessConnectionServer, repositoryLock_; // Use lock to prevent writing from multiple threads AudioElementPluginUpdateListener* listener_; int connectionPort; + juce::String spatial9SessionId_{juce::Uuid().toString()}; // Variables for retrying the connections and handling premature deletion bool closing; @@ -239,7 +240,9 @@ class RendererPluginSyncServer : public juce::InterprocessConnectionServer, juce::MemoryBlock block; juce::MemoryOutputStream stream(block, false); - outgoingRepository_->writeToStream(stream); + auto snapshot = outgoingRepository_->getValueTree().createCopy(); + snapshot.setProperty("spatial9_session_id", spatial9SessionId_, nullptr); + snapshot.writeToStream(stream); for (auto connection : connections_) { connection->sendRepository(block); } diff --git a/rendererplugin/src/RendererProcessor.cpp b/rendererplugin/src/RendererProcessor.cpp index a1d20e1..a437e30 100644 --- a/rendererplugin/src/RendererProcessor.cpp +++ b/rendererplugin/src/RendererProcessor.cpp @@ -46,7 +46,7 @@ RendererProcessor::RendererProcessor() multichannelgainRepository_(getTreeWithId(kMultiChannelGainsKey)), audioElementSpatialLayoutRepository_( juce::ValueTree("AudioElementSpatialLayoutRepository")), - syncServer_(&audioElementRepository_, 2134, this), + syncServer_(&audioElementRepository_, 32134, this), fileExportRepository_(getTreeWithId(kFileExportKey)), msPlaybackRepository_(getTreeWithId(kMSPlaybackKey)), activeMixPresentationRepository_(getTreeWithId(kActiveMixKey)), @@ -170,7 +170,7 @@ bool RendererProcessor::applyBusLayouts(const BusesLayout& layouts) { //============================================================================== const juce::String RendererProcessor::getName() const { - return {"Eclipsa Audio Renderer"}; + return {"Spatial9 Eclipsa Renderer"}; } //============================================================================== diff --git a/scripts/spatial9/bootstrap.py b/scripts/spatial9/bootstrap.py new file mode 100644 index 0000000..153e2bb --- /dev/null +++ b/scripts/spatial9/bootstrap.py @@ -0,0 +1,63 @@ +"""Fetch pinned dependencies for either a Git checkout or the source ZIP.""" +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import tempfile + +ROOT=Path(__file__).resolve().parents[2] +MANIFEST=json.loads(Path(__file__).with_name("dependencies.json").read_text()) +def run(*args,cwd=ROOT): subprocess.run(args,cwd=cwd,check=True) +def sha(path): + h=hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda:f.read(1024*1024),b""):h.update(chunk) + return h.hexdigest() +for dep in MANIFEST["submodules"]: + path=ROOT/dep["path"] + if not (path/"CMakeLists.txt").exists(): + path.mkdir(parents=True,exist_ok=True) + if any(path.iterdir()):raise RuntimeError(f"Refusing to overwrite nonempty {path}") + run("git","init",str(path)) + run("git","remote","add","origin",dep["url"],cwd=path) + run("git","fetch","--depth","1","origin",dep["commit"],cwd=path) + run("git","checkout","--detach","FETCH_HEAD",cwd=path) + if (path/".git").exists(): + head=subprocess.check_output(["git","rev-parse","HEAD"],cwd=path,text=True).strip() + if head!=dep["commit"]:raise RuntimeError(f"Unexpected dependency revision in {path}: {head}") + print("Dependency ready:",dep["path"],flush=True) + +missing=[] +for item in MANIFEST["lfs_files"]: + path=ROOT/item["path"] + if not path.exists() or path.stat().st_size!=item["size"] or sha(path)!=item["sha256"]:missing.append(item) +if missing: + # A source ZIP does not have upstream's Git object database. Fetch the exact + # upstream commit in an isolated checkout, never credentials from a fork. + with tempfile.TemporaryDirectory(prefix="spatial9-upstream-") as tmp: + cache=Path(tmp) + run("git","init",str(cache)) + run("git","remote","add","origin","https://github.com/google/eclipsa-audio-plugin.git",cwd=cache) + run("git","lfs","install","--local",cwd=cache) + run("git","-c","filter.lfs.smudge=","-c","filter.lfs.required=false","fetch","--depth","1","origin",MANIFEST["upstream_commit"],cwd=cache) + run("git","-c","filter.lfs.smudge=","-c","filter.lfs.required=false","checkout","--detach","FETCH_HEAD",cwd=cache) + run("git","lfs","pull","--include="+",".join(i["path"] for i in missing),cwd=cache) + for item in missing: + source=cache/item["path"] + if sha(source)!=item["sha256"]:raise RuntimeError("Dependency hash mismatch: "+item["path"]) + shutil.copy2(source,ROOT/item["path"]) + +# Keep the pinned WithACX JUCE fork (contains host changes). The old window +# screenshot helper uses an API removed from macOS 15 SDKs. This application +# does not need that helper; disable only it when built with those SDKs. +path=ROOT/"third_party/JUCE/modules/juce_gui_basics/native/juce_Windowing_mac.mm" +text=path.read_text() +marker="// SPATIAL9_MACOS15_WINDOW_SNAPSHOT" +if marker not in text: + start=text.index("static Image createNSWindowSnapshot (NSWindow* nsWindow)") + body=text.index("{",start) + next_function=text.index("\n}",body) + text=text[:body+1]+"\n"+marker+"\n#if defined(MAC_OS_VERSION_15_0) && MAC_OS_X_VERSION_MAX_ALLOWED >= MAC_OS_VERSION_15_0\n ignoreUnused(nsWindow);\n return {};\n#else\n"+text[body+1:next_function]+"\n#endif"+text[next_function:] + path.write_text(text) +print("Pinned dependencies and macOS compatibility patch ready.") diff --git a/scripts/spatial9/dependencies.json b/scripts/spatial9/dependencies.json new file mode 100644 index 0000000..5f4a472 --- /dev/null +++ b/scripts/spatial9/dependencies.json @@ -0,0 +1,147 @@ +{ + "upstream_commit": "db210550eb273cb4ea08ae2e923ca46fc1b9c9d5", + "submodules": [ + { + "path": "third_party/JUCE", + "url": "https://github.com/WithACX/JUCE.git", + "commit": "2c75900809c5f6224c323d6fa2325f4dcc43535d" + }, + { + "path": "third_party/Spatial_Audio_Framework", + "url": "https://github.com/leomccormack/Spatial_Audio_Framework.git", + "commit": "018e06e86ccdbb37cc527ca511a3a26576126b71" + } + ], + "lfs_files": [ + { + "path": "third_party/gpac/lib/Windows/Debug/libgpac.lib", + "size": 552418, + "sha256": "f76d85b5a8ead836c7bf79cd9347239d3ee475811742dbd355d68da12aeb2902" + }, + { + "path": "third_party/gpac/lib/Windows/Release/libgpac.lib", + "size": 552418, + "sha256": "f76d85b5a8ead836c7bf79cd9347239d3ee475811742dbd355d68da12aeb2902" + }, + { + "path": "third_party/iamftools/lib/Windows/Debug/iamf_tools.dll", + "size": 20161536, + "sha256": "b0714cf944710694e3f1219135a34e0cf0a70119fc8ffc56b324ac5ddba7f00c" + }, + { + "path": "third_party/iamftools/lib/Windows/Debug/iamf_tools.if.lib", + "size": 4142, + "sha256": "f47107fc45fa4455d9b3f9d0f9e3074f55bf9a2a05a47d378345c1bc9e25b54a" + }, + { + "path": "third_party/iamftools/lib/Windows/Release/iamf_tools.dll", + "size": 11939840, + "sha256": "57924d1d927fbde76a60579e2604e93c462640d64094b94e5ad671ed4e58c954" + }, + { + "path": "third_party/iamftools/lib/Windows/Release/iamf_tools.if.lib", + "size": 4142, + "sha256": "f47107fc45fa4455d9b3f9d0f9e3074f55bf9a2a05a47d378345c1bc9e25b54a" + }, + { + "path": "third_party/libear/lib/Windows/Debug/libear.lib", + "size": 54271514, + "sha256": "422c1ef5442fd8579a420fb6a9c0ea055b69b4c06f3205c42297f9eabfe59330" + }, + { + "path": "third_party/libear/lib/Windows/Release/libear.lib", + "size": 4603314, + "sha256": "e7f7c1b9dfc58b48e5082184fc6398dc59f4809cc928fe0fa5b259a75c085ece" + }, + { + "path": "third_party/libiamf/lib/Windows/Debug/FLAC++.lib", + "size": 213532, + "sha256": "4d697936c5919d0338be8a862891a10b595ee64ed292721c2229544afc89e070" + }, + { + "path": "third_party/libiamf/lib/Windows/Debug/FLAC.lib", + "size": 91418, + "sha256": "ceed1b10e1ca63aab93307d8cb85b6d02894739ac5d689fe73b27560e2097dff" + }, + { + "path": "third_party/libiamf/lib/Windows/Debug/fdk-aac.lib", + "size": 5370, + "sha256": "4af4dc28317b85f9dcb0999b0a7ebcdacd8d0d5fb432c67e411878cf1c3ba9d8" + }, + { + "path": "third_party/libiamf/lib/Windows/Debug/iamf_tools.if.lib", + "size": 6744, + "sha256": "a3899b801510fd24fc3285e6962c6a840facde71b6dbdb921ecf1cda57e9d0c4" + }, + { + "path": "third_party/libiamf/lib/Windows/Debug/libgpac.dll", + "size": 25774592, + "sha256": "6e40e93b97b35d4ed65ead7676fd3e1b5b4c5babb0093122e301922d08b894b7" + }, + { + "path": "third_party/libiamf/lib/Windows/Debug/libgpac.lib", + "size": 552694, + "sha256": "5da8442dff48b3b1691d3b964b3465bcec151e228955b82cee84a69ed875cf20" + }, + { + "path": "third_party/libiamf/lib/Windows/Debug/obr.lib", + "size": 83538308, + "sha256": "df7757e548a1b6b5f84ee838c78b158aace77186cdc42d9c5e28f739db14fc24" + }, + { + "path": "third_party/libiamf/lib/Windows/Debug/ogg.lib", + "size": 14758, + "sha256": "07c549d60dfca02dbe56320719846767ca1ff5709c1ff041daabd98099beabde" + }, + { + "path": "third_party/libiamf/lib/Windows/Debug/opus.lib", + "size": 21268, + "sha256": "bfa80f0c06101633996ef3859dc7b1eb17a62892c75f2bf40759b93c1282a205" + }, + { + "path": "third_party/libiamf/lib/Windows/Release/FLAC++.lib", + "size": 213532, + "sha256": "4d697936c5919d0338be8a862891a10b595ee64ed292721c2229544afc89e070" + }, + { + "path": "third_party/libiamf/lib/Windows/Release/FLAC.lib", + "size": 91418, + "sha256": "ceed1b10e1ca63aab93307d8cb85b6d02894739ac5d689fe73b27560e2097dff" + }, + { + "path": "third_party/libiamf/lib/Windows/Release/fdk-aac.lib", + "size": 5370, + "sha256": "4af4dc28317b85f9dcb0999b0a7ebcdacd8d0d5fb432c67e411878cf1c3ba9d8" + }, + { + "path": "third_party/libiamf/lib/Windows/Release/ogg.lib", + "size": 14758, + "sha256": "07c549d60dfca02dbe56320719846767ca1ff5709c1ff041daabd98099beabde" + }, + { + "path": "third_party/libiamf/lib/Windows/Release/opus.lib", + "size": 21268, + "sha256": "bfa80f0c06101633996ef3859dc7b1eb17a62892c75f2bf40759b93c1282a205" + }, + { + "path": "third_party/obr/lib/Windows/Debug/obr.lib", + "size": 32280508, + "sha256": "1ee54c5e23bd56414eb67d9881920f41ad6cad81739892e07cb8d7a2c3cc8e89" + }, + { + "path": "third_party/obr/lib/Windows/Debug/pffft.lib", + "size": 133090, + "sha256": "e2c963d25637928c26b93ac6937996ae9d2518221b364127db62da77cf15e2f4" + }, + { + "path": "third_party/obr/lib/Windows/Release/obr.lib", + "size": 5822268, + "sha256": "334535deb34cfb56856e32e7e246f17b03a0df02e9d36d73de68ab69dd39980b" + }, + { + "path": "third_party/obr/lib/Windows/Release/pffft.lib", + "size": 69320, + "sha256": "cbe3148db5d7a431906186f4398f2f0f1ef2ef4b96bdb1c8f1b1ad32977b9fe8" + } + ] +} diff --git a/scripts/spatial9/package_binaries.py b/scripts/spatial9/package_binaries.py new file mode 100644 index 0000000..342591c --- /dev/null +++ b/scripts/spatial9/package_binaries.py @@ -0,0 +1,27 @@ +"""Package only actual build outputs. Never substitutes dependency binaries.""" +from pathlib import Path +import json +import platform +import shutil +import sys +import zipfile +if platform.system() not in ("Darwin", "Windows"): raise SystemExit("Native Windows or macOS build required") +build=Path(sys.argv[1]).resolve();out=Path(sys.argv[2]).resolve();out.mkdir(parents=True,exist_ok=True) +bundles=[] +for kind in ("*.vst3","*.component"): + bundles += [p for p in build.rglob(kind) if "_artefacts" in str(p) and p.name.startswith("Spatial9")] +if not bundles:raise SystemExit("No compiled Spatial9 plugin bundles found; refusing to create an empty binary release") +staging=out/"Spatial9-Eclipsa-development";staging.mkdir(exist_ok=True) +for source in bundles: + target=staging/source.name + if source.is_dir():shutil.copytree(source,target,dirs_exist_ok=True,symlinks=True) + else:shutil.copy2(source,target) +(staging/"BUILD_STATUS.json").write_text(json.dumps({"platform":platform.system(),"machine":platform.machine(), + "signing":"unsigned development build; notarization and DAW validation required","bundles":[p.name for p in bundles]},indent=2)) +shutil.copy2(Path(__file__).resolve().parents[2]/"LICENSE",staging/"LICENSE") +# Keep symlinks and executable bits in macOS bundles by using the native archiver. +if platform.system()=="Darwin": + import subprocess + subprocess.run(["ditto","-c","-k","--sequesterRsrc","--keepParent",str(staging),str(out/"Spatial9-Eclipsa-macOS-arm64-development.zip")],check=True) +else:shutil.make_archive(str(out/"Spatial9-Eclipsa-Windows-x64-development"),"zip",out,staging.name) +shutil.rmtree(staging) diff --git a/tests/spatial9/CMakeLists.txt b/tests/spatial9/CMakeLists.txt new file mode 100644 index 0000000..644fdd9 --- /dev/null +++ b/tests/spatial9/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.20) +project(Spatial9CoreTests LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 20) +find_package(Threads REQUIRED) +add_executable(spatial9_plan_test plan_test.cpp) +target_include_directories(spatial9_plan_test PRIVATE ../../common) +target_link_libraries(spatial9_plan_test PRIVATE Threads::Threads) +# Assertions are the test checks and must also run in Release. +if(MSVC) + target_compile_options(spatial9_plan_test PRIVATE /UNDEBUG) +else() + target_compile_options(spatial9_plan_test PRIVATE -UNDEBUG -Wall -Wextra -Werror) +endif() +enable_testing() +add_test(NAME spatial9_plan_test COMMAND spatial9_plan_test) diff --git a/tests/spatial9/native_smoke.cpp b/tests/spatial9/native_smoke.cpp new file mode 100644 index 0000000..63eb213 --- /dev/null +++ b/tests/spatial9/native_smoke.cpp @@ -0,0 +1,14 @@ +#include "spatial9/ControlServer.h" +#include +int main() { + juce::ScopedJuceInitialiser_GUI initialise; + spatial9::ControlServer server([](const juce::var& request){ + if(request["operation"].toString()=="roundtrip") { + return spatial9::planJson(spatial9::parsePlan(request["plan"])); + } + if(request["operation"].toString()=="quit")juce::MessageManager::getInstance()->stopDispatchLoop(); + return spatial9::object({{"native",true}}); + }); + std::cout<<"ready"<runDispatchLoop(); +} diff --git a/tests/spatial9/plan_test.cpp b/tests/spatial9/plan_test.cpp new file mode 100644 index 0000000..241c433 --- /dev/null +++ b/tests/spatial9/plan_test.cpp @@ -0,0 +1,27 @@ +#include "spatial9/Plan.h" +#include +#include +#include +#include +using namespace spatial9; +int main() { + Plan p;p.count=2;p.smooth=false;p.keys[0]={0,{-1,1,0,-6,30,false}};p.keys[1]={4,{1,0,1,0,90,true}}; + validate(p);assert(evaluate(p,2).x==0);assert(evaluate(p,-1).x==-1);assert(evaluate(p,9).x==1); + assert(evaluate(p,2).gainDb==-3);assert(!evaluate(p,3).muted);assert(evaluate(p,4).muted); + p.smooth=true;assert(std::abs(evaluate(p,1).x+0.6875f)<1e-6f); + bool caught=false;p.keys[1].time=0;try{validate(p);}catch(const std::invalid_argument&){caught=true;}assert(caught); + p.keys[1].time=4;p.keys[1].value.x=std::numeric_limits::quiet_NaN();caught=false; + try{validate(p);}catch(const std::invalid_argument&){caught=true;}assert(caught);p.keys[1].value.x=1; + PlanStore store;store.publish(p);bool valid=false; + auto frame=store.sample(2,999,valid);assert(valid&&frame.x==0); + p.beats=true;store.publish(p);frame=store.sample(999,2,valid);assert(valid&&frame.x==0); + store.sample(0,std::numeric_limits::quiet_NaN(),valid);assert(!valid); + p.count=1;store.publish(p);store.sample(NAN,NAN,valid);assert(valid); + // Seeks and offline block order cannot change deterministic evaluation. + p.count=2;p.beats=false;store.publish(p); + for(double t:{3.,0.,2.,4.,1.,2.}) assert(store.sample(t,0,valid).x==evaluate(p,t).x); + std::atomic done{false}; + std::thread writer([&]{for(int i=0;i<20000;++i){Plan next;next.count=1;float x=(i%2)?1.f:-1.f;next.keys[0].value={x,x,x,0,30,false};store.publish(next);}done=true;}); + while(!done){auto f=store.sample(0,0,valid);if(valid&&f.y!=1)assert(f.x==f.y&&f.y==f.z);} + writer.join();std::cout<<"Plan validation, interpolation, seek, timebase and concurrent publication passed\n"; +}